Skip to content

Hosting Adapter

The hosting adapter provides platform-agnostic utilities for environment variables, platform detection, and error logging. Write once, deploy anywhere (Cloudflare, Netlify, Vercel).

import {
detectPlatform,
getEnv,
getRequiredEnv,
getClientIP,
getPlatformInfo,
logError
} from '@rejected-media/podcast-framework-core';

Different platforms expose environment variables differently:

// ❌ Platform-specific (breaks on other platforms)
const apiKey = process.env.API_KEY; // Netlify, Vercel
const apiKey = context.locals.runtime.env.API_KEY; // Cloudflare
// ✅ Works everywhere
import { getEnv } from '@rejected-media/podcast-framework-core';
const apiKey = getEnv('API_KEY', context);

Benefits:

  • ✅ Same code works on all platforms
  • ✅ Easy migration between providers
  • ✅ No vendor lock-in
  • ✅ Testable code

Detect which hosting platform code is running on.

Signature:

function detectPlatform(context?: APIContext): HostingPlatform
type HostingPlatform = 'cloudflare' | 'netlify' | 'vercel' | 'local' | 'unknown'

Parameters:

  • context - Astro API context (optional)

Returns: Platform identifier

Examples:

import { detectPlatform } from '@rejected-media/podcast-framework-core';
const platform = detectPlatform();
// → "cloudflare" | "netlify" | "vercel" | "local" | "unknown"
if (platform === 'cloudflare') {
// Cloudflare-specific optimization
}

Usage in API Routes:

src/pages/api/example.ts
import type { APIRoute } from 'astro';
import { detectPlatform } from '@rejected-media/podcast-framework-core';
export const GET: APIRoute = async (context) => {
const platform = detectPlatform(context);
return new Response(JSON.stringify({
platform,
message: `Running on ${platform}`
}));
};

Get environment variable with optional fallback.

Signature:

function getEnv(
key: string,
context?: APIContext,
fallback?: string
): string

Parameters:

  • key - Environment variable name
  • context - Astro API context (required for Cloudflare)
  • fallback - Default value if not found

Returns: Environment variable value or fallback

Examples:

import { getEnv } from '@rejected-media/podcast-framework-core';
// With fallback
const apiKey = getEnv('API_KEY', context, 'default-key');
// Without fallback (returns empty string if not found)
const optional = getEnv('OPTIONAL_VAR', context);
// Required variable (will be empty if missing)
const required = getEnv('REQUIRED_VAR', context);

Usage in API Routes:

src/pages/api/newsletter-subscribe.ts
import type { APIRoute } from 'astro';
import { getEnv } from '@rejected-media/podcast-framework-core';
export const POST: APIRoute = async (context) => {
const apiKey = getEnv('CONVERTKIT_API_KEY', context);
const formId = getEnv('CONVERTKIT_FORM_ID', context);
if (!apiKey || !formId) {
return new Response('Newsletter not configured', { status: 500 });
}
// Use apiKey and formId...
};

Get multiple required environment variables with validation.

Signature:

function getRequiredEnv(
keys: string[],
context?: APIContext
): Record<string, string>

Parameters:

  • keys - Array of required variable names
  • context - Astro API context (required for Cloudflare)

Returns: Object with all variables

Throws: Error if any variables are missing

Examples:

import { getRequiredEnv } from '@rejected-media/podcast-framework-core';
// Get multiple variables (throws if missing)
const { API_KEY, API_SECRET, PROJECT_ID } = getRequiredEnv(
['API_KEY', 'API_SECRET', 'PROJECT_ID'],
context
);
// Use variables
const config = {
apiKey: API_KEY,
apiSecret: API_SECRET,
projectId: PROJECT_ID
};

Error Handling:

try {
const env = getRequiredEnv(['API_KEY', 'API_SECRET'], context);
} catch (error) {
console.error(error.message);
// → "Missing required environment variables: API_KEY, API_SECRET"
}

Usage in Services:

src/pages/api/contribute.ts
import type { APIRoute } from 'astro';
import { getRequiredEnv, ContributionService } from '@rejected-media/podcast-framework-core';
export const POST: APIRoute = async (context) => {
try {
const env = getRequiredEnv([
'PUBLIC_SANITY_PROJECT_ID',
'PUBLIC_SANITY_DATASET',
'SANITY_API_TOKEN',
'RESEND_API_KEY',
'NOTIFICATION_EMAIL'
], context);
const service = new ContributionService({
sanityProjectId: env.PUBLIC_SANITY_PROJECT_ID,
sanityDataset: env.PUBLIC_SANITY_DATASET,
sanityApiToken: env.SANITY_API_TOKEN,
resendApiKey: env.RESEND_API_KEY,
notificationEmail: env.NOTIFICATION_EMAIL,
resendFromEmail: '[email protected]'
});
// ... handle contribution
} catch (error) {
return new Response('Configuration error', { status: 500 });
}
};

Get client IP address in a platform-agnostic way.

Signature:

function getClientIP(context: APIContext): string

Parameters:

  • context - Astro API context (required)

Returns: Client IP address

Examples:

import { getClientIP } from '@rejected-media/podcast-framework-core';
export const POST: APIRoute = async (context) => {
const clientIP = getClientIP(context);
// → "192.168.1.1" or "2001:0db8:..."
console.log(`Request from ${clientIP}`);
};

Platform Detection:

// Tries in order:
// 1. cf-connecting-ip header (Cloudflare)
// 2. x-forwarded-for header (Netlify, Vercel, proxies)
// 3. clientAddress property (Astro built-in)
// 4. 'unknown' fallback

Usage - Rate Limiting:

import { getClientIP } from '@rejected-media/podcast-framework-core';
const rateLimits = new Map<string, number>();
export const POST: APIRoute = async (context) => {
const ip = getClientIP(context);
const requests = rateLimits.get(ip) || 0;
if (requests > 10) {
return new Response('Rate limit exceeded', { status: 429 });
}
rateLimits.set(ip, requests + 1);
// ... handle request
};

Get comprehensive platform context information.

Signature:

function getPlatformInfo(context?: APIContext): {
platform: HostingPlatform;
isDevelopment: boolean;
isProduction: boolean;
region: string;
deploymentId: string;
}

Parameters:

  • context - Astro API context (optional)

Returns: Platform metadata object

Examples:

import { getPlatformInfo } from '@rejected-media/podcast-framework-core';
const info = getPlatformInfo(context);
console.log(info);
// {
// platform: 'cloudflare',
// isDevelopment: false,
// isProduction: true,
// region: 'us-east-1',
// deploymentId: 'abc123def456'
// }

Usage - Conditional Features:

export const POST: APIRoute = async (context) => {
const { platform, isDevelopment } = getPlatformInfo(context);
if (isDevelopment) {
// Skip email in development
console.log('Email would be sent:', emailData);
return new Response('OK (dev mode)');
}
if (platform === 'cloudflare') {
// Use Cloudflare-specific features
}
// ... normal processing
};

Platform-agnostic error logging with optional Sentry integration.

Signature:

function logError(
error: unknown,
context?: Record<string, any>,
apiContext?: APIContext
): void

Parameters:

  • error - Error to log
  • context - Additional context (tags, extra data)
  • apiContext - Astro API context (optional)

Returns: void (logs to console and Sentry if configured)

Examples:

import { logError } from '@rejected-media/podcast-framework-core';
try {
await riskyOperation();
} catch (error) {
logError(error, {
tags: { function: 'newsletter-subscribe', operation: 'submit' },
extra: { email: userEmail }
}, context);
return new Response('Internal error', { status: 500 });
}

Logging Behavior:

Console (Always):

// Cloudflare/Netlify: JSON structured logging
console.error('[Error]', {
platform: 'cloudflare',
error: 'API call failed',
stack: '...',
tags: { function: 'newsletter-subscribe' },
extra: { email: '[email protected]' }
});
// Local/Other: Object logging
console.error('[Error]', { ... });

Sentry (If Configured):

// Automatically sends to Sentry if initialized
captureException(error, {
tags: context?.tags,
extra: context?.extra,
level: 'error'
});
// 1. Check for Cloudflare Cache API
if (typeof globalThis.caches !== 'undefined') {
return 'cloudflare';
}
// 2. Check environment variables
if (env.CF_PAGES === '1' || env.CF_PAGES_BRANCH) {
return 'cloudflare';
}
if (env.NETLIFY === 'true' || env.NETLIFY_DEV === 'true') {
return 'netlify';
}
if (env.VERCEL === '1' || env.VERCEL_ENV) {
return 'vercel';
}
if (env.NODE_ENV === 'development') {
return 'local';
}
return 'unknown';

Each platform exposes unique env vars:

Cloudflare:

  • CF_PAGES = “1”
  • CF_PAGES_BRANCH = “main” | “preview-branch”
  • CF_PAGES_COMMIT_SHA = “abc123…”

Netlify:

  • NETLIFY = “true”
  • NETLIFY_DEV = “true” (local dev)
  • COMMIT_REF = “abc123…”

Vercel:

  • VERCEL = “1”
  • VERCEL_ENV = “production” | “preview” | “development”
  • VERCEL_GIT_COMMIT_SHA = “abc123…”
src/pages/api/submit.ts
import type { APIRoute } from 'astro';
import {
getRequiredEnv,
getClientIP,
getPlatformInfo,
logError
} from '@rejected-media/podcast-framework-core';
export const POST: APIRoute = async (context) => {
try {
// 1. Get platform info
const { platform, isDevelopment } = getPlatformInfo(context);
console.log(`Processing request on ${platform}`);
// 2. Get required env vars
const env = getRequiredEnv([
'API_KEY',
'API_SECRET'
], context);
// 3. Get client IP for rate limiting
const clientIP = getClientIP(context);
// 4. Process request
const result = await processRequest(env.API_KEY, env.API_SECRET);
return new Response(JSON.stringify(result), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
// 5. Log error with context
logError(error, {
tags: { endpoint: 'submit', platform: 'api' },
extra: { timestamp: new Date().toISOString() }
}, context);
return new Response('Internal server error', {
status: 500
});
}
};

The hosting adapter drastically reduces migration effort:

Without Adapter:

Migrate from Netlify → Cloudflare:
- Rewrite all env var access (50+ files)
- Update API route patterns
- Change logging calls
- Test everything
Estimated: 30+ hours

With Adapter:

Migrate from Netlify → Cloudflare:
- Update deployment config only
- No code changes needed
Estimated: 2-4 hours

Actual Case Study (Strange Water):

  • Old approach: 31 hours estimated
  • With adapter: 4 hours actual
  • Savings: 93% reduction
import { detectPlatform } from '@rejected-media/podcast-framework-core';
export const POST: APIRoute = async (context) => {
const platform = detectPlatform(context);
if (platform === 'cloudflare') {
// Use Cloudflare Workers KV
const cache = context.locals.runtime.env.KV;
const cached = await cache.get('key');
} else if (platform === 'vercel') {
// Use Vercel Edge Config
} else {
// Default implementation
}
};
import { getPlatformInfo } from '@rejected-media/podcast-framework-core';
export const POST: APIRoute = async (context) => {
const { isDevelopment, isProduction } = getPlatformInfo(context);
if (isDevelopment) {
// Skip email sending in dev
console.log('Would send email:', emailData);
return new Response('OK (dev)');
}
if (isProduction) {
// Send actual email
await sendEmail(emailData);
}
};
import { logError } from '@rejected-media/podcast-framework-core';
try {
await riskyOperation();
} catch (error) {
logError(error);
// Logs to console
}
logError(error, {
tags: {
function: 'newsletter-subscribe',
operation: 'convertkit-api'
},
extra: {
email: userEmail,
timestamp: Date.now()
}
}, context);

Logged Output (Cloudflare):

{
"platform": "cloudflare",
"error": "API call failed",
"stack": "Error: API call failed\n at ...",
"tags": {
"function": "newsletter-subscribe",
"operation": "convertkit-api"
},
"extra": {
"email": "[email protected]",
"timestamp": 1234567890
}
}

If Sentry is initialized, errors automatically go to Sentry:

import { initSentry, logError } from '@rejected-media/podcast-framework-core';
// Initialize Sentry once
initSentry({
dsn: import.meta.env.SENTRY_DSN,
environment: import.meta.env.SENTRY_ENVIRONMENT || 'production'
});
// Log errors (goes to console + Sentry)
try {
await operation();
} catch (error) {
logError(error, {
tags: { feature: 'newsletter' },
level: 'warning' // 'error' | 'warning' | 'info'
}, context);
}
// ❌ Platform-specific
export const POST: APIRoute = async ({ request }) => {
const apiKey = process.env.API_KEY; // Breaks on Cloudflare
};
// ✅ Platform-agnostic
export const POST: APIRoute = async (context) => {
const apiKey = getEnv('API_KEY', context); // Works everywhere
};
// ❌ Missing context
const apiKey = getEnv('API_KEY'); // Won't work on Cloudflare
// ✅ With context
const apiKey = getEnv('API_KEY', context); // Works everywhere
// ❌ Manual validation
const apiKey = getEnv('API_KEY', context);
if (!apiKey) {
throw new Error('Missing API_KEY');
}
// ✅ Automatic validation
const { API_KEY } = getRequiredEnv(['API_KEY'], context);
// Throws automatically if missing
try {
await operation();
} catch (error) {
logError(error, { tags: { feature: 'x' } }, context); // Always log
return new Response('Error', { status: 500 });
}

Secure:

  • Only server-side access (API routes)
  • No exposure to client
  • Validates required variables

Usage:

// ✅ Safe (server-side only)
export const POST: APIRoute = async (context) => {
const apiKey = getEnv('API_KEY', context); // Server-only
};
// ❌ Don't use in client-side code
<script>
import { getEnv } from '@rejected-media/podcast-framework-core';
const apiKey = getEnv('API_KEY'); // Won't work, no context
</script>

”Missing required environment variables”

Section titled “”Missing required environment variables””

Add variables to .env:

Terminal window
API_KEY="your-key-here"
API_SECRET="your-secret-here"

For Cloudflare Pages, add in dashboard: Settings → Environment variables

Check variable name matches .env:

.env
PUBLIC_SANITY_PROJECT_ID="abc123"
# Code (must match exactly)
const id = getEnv('PUBLIC_SANITY_PROJECT_ID', context);

Ensure you’re calling from API route with valid context:

// ✅ In API route
export const POST: APIRoute = async (context) => {
const ip = getClientIP(context); // Works
};
// ❌ In page component
const ip = getClientIP(); // Won't work, no context