diff --git a/api/_json-response.js b/api/_json-response.js new file mode 100644 index 000000000..646db33c9 --- /dev/null +++ b/api/_json-response.js @@ -0,0 +1,9 @@ +export function jsonResponse(body, status, headers = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + }); +} diff --git a/api/_rate-limit.js b/api/_rate-limit.js index 77b0aed40..ec5be1354 100644 --- a/api/_rate-limit.js +++ b/api/_rate-limit.js @@ -1,5 +1,6 @@ import { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis'; +import { jsonResponse } from './_json-response.js'; let ratelimit = null; @@ -38,16 +39,12 @@ export async function checkRateLimit(request, corsHeaders) { const { success, limit, reset } = await rl.limit(ip); if (!success) { - return new Response(JSON.stringify({ error: 'Too many requests' }), { - status: 429, - headers: { - 'Content-Type': 'application/json', - 'X-RateLimit-Limit': String(limit), - 'X-RateLimit-Remaining': '0', - 'X-RateLimit-Reset': String(reset), - 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)), - ...corsHeaders, - }, + return jsonResponse({ error: 'Too many requests' }, 429, { + 'X-RateLimit-Limit': String(limit), + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': String(reset), + 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)), + ...corsHeaders, }); } diff --git a/api/_relay.js b/api/_relay.js index 30ae53984..1fb90d22e 100644 --- a/api/_relay.js +++ b/api/_relay.js @@ -1,6 +1,7 @@ import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; import { validateApiKey } from './_api-key.js'; import { checkRateLimit } from './_rate-limit.js'; +import { jsonResponse } from './_json-response.js'; export function getRelayBaseUrl() { const relayUrl = process.env.WS_RELAY_URL; @@ -34,29 +35,20 @@ export function createRelayHandler(cfg) { const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS'); if (isDisallowedOrigin(req)) { - return new Response(JSON.stringify({ error: 'Origin not allowed' }), { - status: 403, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + return jsonResponse({ error: 'Origin not allowed' }, 403, corsHeaders); } if (req.method === 'OPTIONS') { return new Response(null, { status: 204, headers: corsHeaders }); } if (req.method !== 'GET') { - return new Response(JSON.stringify({ error: 'Method not allowed' }), { - status: 405, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + return jsonResponse({ error: 'Method not allowed' }, 405, corsHeaders); } if (cfg.requireApiKey) { const keyCheck = validateApiKey(req); if (keyCheck.required && !keyCheck.valid) { - return new Response(JSON.stringify({ error: keyCheck.error }), { - status: 401, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + return jsonResponse({ error: keyCheck.error }, 401, corsHeaders); } } @@ -68,10 +60,7 @@ export function createRelayHandler(cfg) { const relayBaseUrl = getRelayBaseUrl(); if (!relayBaseUrl) { if (cfg.fallback) return cfg.fallback(req, corsHeaders); - return new Response(JSON.stringify({ error: 'WS_RELAY_URL is not configured' }), { - status: 503, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + return jsonResponse({ error: 'WS_RELAY_URL is not configured' }, 503, corsHeaders); } try { @@ -108,13 +97,10 @@ export function createRelayHandler(cfg) { } catch (error) { if (cfg.fallback) return cfg.fallback(req, corsHeaders); const isTimeout = error?.name === 'AbortError'; - return new Response(JSON.stringify({ + return jsonResponse({ error: isTimeout ? 'Relay timeout' : 'Relay request failed', details: error?.message || String(error), - }), { - status: isTimeout ? 504 : 502, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + }, isTimeout ? 504 : 502, corsHeaders); } }; } diff --git a/api/bootstrap.js b/api/bootstrap.js index 31c2c3be0..9d468c16f 100644 --- a/api/bootstrap.js +++ b/api/bootstrap.js @@ -1,5 +1,6 @@ import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; import { validateApiKey } from './_api-key.js'; +import { jsonResponse } from './_json-response.js'; export const config = { runtime: 'edge' }; @@ -121,9 +122,7 @@ export default async function handler(req) { const apiKeyResult = validateApiKey(req); if (apiKeyResult.required && !apiKeyResult.valid) - return new Response(JSON.stringify({ error: apiKeyResult.error }), { - status: 401, headers: { ...cors, 'Content-Type': 'application/json' }, - }); + return jsonResponse({ error: apiKeyResult.error }, 401, cors); const url = new URL(req.url); const tier = url.searchParams.get('tier'); @@ -145,10 +144,7 @@ export default async function handler(req) { try { cached = await getCachedJsonBatch(keys); } catch { - return new Response(JSON.stringify({ data: {}, missing: names }), { - status: 200, - headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' }, - }); + return jsonResponse({ data: {}, missing: names }, 200, { ...cors, 'Cache-Control': 'no-cache' }); } const data = {}; @@ -161,13 +157,9 @@ export default async function handler(req) { const cacheControl = (tier && TIER_CACHE[tier]) || 'public, s-maxage=600, stale-while-revalidate=120, stale-if-error=900'; - return new Response(JSON.stringify({ data, missing }), { - status: 200, - headers: { - ...cors, - 'Content-Type': 'application/json', - 'Cache-Control': cacheControl, - 'CDN-Cache-Control': (tier && TIER_CDN_CACHE[tier]) || TIER_CDN_CACHE.fast, - }, + return jsonResponse({ data, missing }, 200, { + ...cors, + 'Cache-Control': cacheControl, + 'CDN-Cache-Control': (tier && TIER_CDN_CACHE[tier]) || TIER_CDN_CACHE.fast, }); } diff --git a/api/cache-purge.js b/api/cache-purge.js index 6f6e8dc33..cbab20f33 100644 --- a/api/cache-purge.js +++ b/api/cache-purge.js @@ -1,4 +1,5 @@ import { getCorsHeaders } from './_cors.js'; +import { jsonResponse } from './_json-response.js'; export const config = { runtime: 'edge' }; @@ -25,13 +26,6 @@ function isDurableData(key) { return DURABLE_DATA_PREFIXES.some(p => key.startsWith(p)); } -function json(body, status, corsHeaders) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); -} - function getRedisCredentials() { const url = process.env.UPSTASH_REDIS_REST_URL; const token = process.env.UPSTASH_REDIS_REST_TOKEN; @@ -104,20 +98,20 @@ export default async function handler(req) { } if (req.method !== 'POST') { - return json({ error: 'Method not allowed' }, 405, corsHeaders); + return jsonResponse({ error: 'Method not allowed' }, 405, corsHeaders); } const auth = req.headers.get('authorization') || ''; const secret = process.env.RELAY_SHARED_SECRET; if (!secret || !(await timingSafeEqual(auth, `Bearer ${secret}`))) { - return json({ error: 'Unauthorized' }, 401, corsHeaders); + return jsonResponse({ error: 'Unauthorized' }, 401, corsHeaders); } let body; try { body = await req.json(); } catch { - return json({ error: 'Invalid JSON body' }, 422, corsHeaders); + return jsonResponse({ error: 'Invalid JSON body' }, 422, corsHeaders); } const { keys: explicitKeys, patterns, dryRun = false } = body || {}; @@ -125,21 +119,21 @@ export default async function handler(req) { const hasPatterns = Array.isArray(patterns) && patterns.length > 0; if (!hasKeys && !hasPatterns) { - return json({ error: 'At least one of "keys" or "patterns" required' }, 422, corsHeaders); + return jsonResponse({ error: 'At least one of "keys" or "patterns" required' }, 422, corsHeaders); } if (hasKeys && explicitKeys.length > MAX_EXPLICIT_KEYS) { - return json({ error: `"keys" exceeds max of ${MAX_EXPLICIT_KEYS}` }, 422, corsHeaders); + return jsonResponse({ error: `"keys" exceeds max of ${MAX_EXPLICIT_KEYS}` }, 422, corsHeaders); } if (hasPatterns && patterns.length > MAX_PATTERNS) { - return json({ error: `"patterns" exceeds max of ${MAX_PATTERNS}` }, 422, corsHeaders); + return jsonResponse({ error: `"patterns" exceeds max of ${MAX_PATTERNS}` }, 422, corsHeaders); } if (hasPatterns) { for (const p of patterns) { if (typeof p !== 'string' || !p.endsWith('*') || p === '*') { - return json({ error: `Invalid pattern "${p}": must end with "*" and cannot be bare "*"` }, 422, corsHeaders); + return jsonResponse({ error: `Invalid pattern "${p}": must end with "*" and cannot be bare "*"` }, 422, corsHeaders); } } } @@ -178,12 +172,12 @@ export default async function handler(req) { if (dryRun) { console.log('[cache-purge]', { mode: 'dry-run', matched: keyList.length, deleted: 0, truncated, dryRun: true, ip, ts }); - return json({ matched: keyList.length, deleted: 0, keys: keyList, dryRun: true, truncated }, 200, corsHeaders); + return jsonResponse({ matched: keyList.length, deleted: 0, keys: keyList, dryRun: true, truncated }, 200, corsHeaders); } if (keyList.length === 0) { console.log('[cache-purge]', { mode: 'purge', matched: 0, deleted: 0, truncated, dryRun: false, ip, ts }); - return json({ matched: 0, deleted: 0, keys: [], dryRun: false, truncated }, 200, corsHeaders); + return jsonResponse({ matched: 0, deleted: 0, keys: [], dryRun: false, truncated }, 200, corsHeaders); } let deleted = 0; @@ -193,9 +187,9 @@ export default async function handler(req) { deleted = results.reduce((sum, r) => sum + (r.result || 0), 0); } catch (err) { console.log('[cache-purge]', { mode: 'purge-error', matched: keyList.length, error: err.message, ip, ts }); - return json({ error: 'Redis pipeline failed' }, 502, corsHeaders); + return jsonResponse({ error: 'Redis pipeline failed' }, 502, corsHeaders); } console.log('[cache-purge]', { mode: 'purge', matched: keyList.length, deleted, truncated, dryRun: false, ip, ts }); - return json({ matched: keyList.length, deleted, keys: keyList, dryRun: false, truncated }, 200, corsHeaders); + return jsonResponse({ matched: keyList.length, deleted, keys: keyList, dryRun: false, truncated }, 200, corsHeaders); } diff --git a/api/contact.js b/api/contact.js index d219c9e26..78fd16d77 100644 --- a/api/contact.js +++ b/api/contact.js @@ -3,6 +3,7 @@ export const config = { runtime: 'edge' }; import { ConvexHttpClient } from 'convex/browser'; import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; import { getClientIp, verifyTurnstile } from './_turnstile.js'; +import { jsonResponse } from './_json-response.js'; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const PHONE_RE = /^[+(]?\d[\d\s()./-]{4,23}\d$/; @@ -95,15 +96,6 @@ function sanitizeForSubject(str, maxLen = 50) { return str.replace(/[\r\n\0]/g, '').slice(0, maxLen); } -function jsonResponse(body, status, cors) { - return new Response(JSON.stringify(body), { - status, - headers: cors - ? { 'Content-Type': 'application/json', ...cors } - : { 'Content-Type': 'application/json' }, - }); -} - export default async function handler(req) { if (isDisallowedOrigin(req)) { return jsonResponse({ error: 'Origin not allowed' }, 403); diff --git a/api/fwdstart.js b/api/fwdstart.js index 00e92985b..d5728670a 100644 --- a/api/fwdstart.js +++ b/api/fwdstart.js @@ -1,12 +1,13 @@ // Non-sebuf: returns XML/HTML, stays as standalone Vercel function import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; +import { jsonResponse } from './_json-response.js'; export const config = { runtime: 'edge' }; // Scrape FwdStart newsletter archive and return as RSS export default async function handler(req) { const cors = getCorsHeaders(req); if (isDisallowedOrigin(req)) { - return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors }); + return jsonResponse({ error: 'Origin not allowed' }, 403, cors); } try { const response = await fetch('https://www.fwdstart.me/archive', { @@ -96,15 +97,9 @@ export default async function handler(req) { }); } catch (error) { console.error('FwdStart scraper error:', error); - return new Response(JSON.stringify({ + return jsonResponse({ error: 'Failed to fetch FwdStart archive', details: error.message - }), { - status: 502, - headers: { - 'Content-Type': 'application/json', - ...cors, - }, - }); + }, 502, cors); } } diff --git a/api/geo.js b/api/geo.js index 05a994668..d2c3acbc4 100644 --- a/api/geo.js +++ b/api/geo.js @@ -1,14 +1,12 @@ +import { jsonResponse } from './_json-response.js'; + export const config = { runtime: 'edge' }; export default function handler(req) { const cfCountry = req.headers.get('cf-ipcountry'); const country = (cfCountry && cfCountry !== 'T1' ? cfCountry : null) || req.headers.get('x-vercel-ip-country') || 'XX'; - return new Response(JSON.stringify({ country }), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-if-error=3600', - 'Access-Control-Allow-Origin': '*', - }, + return jsonResponse({ country }, 200, { + 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-if-error=3600', + 'Access-Control-Allow-Origin': '*', }); } diff --git a/api/gpsjam.js b/api/gpsjam.js index 562999170..73d593e45 100644 --- a/api/gpsjam.js +++ b/api/gpsjam.js @@ -1,4 +1,5 @@ import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; +import { jsonResponse } from './_json-response.js'; import { readJsonFromUpstash } from './_upstash-json.js'; export const config = { runtime: 'edge' }; @@ -64,31 +65,25 @@ export default async function handler(req) { } if (isDisallowedOrigin(req)) { - return new Response(JSON.stringify({ error: 'Origin not allowed' }), { - status: 403, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + return jsonResponse({ error: 'Origin not allowed' }, 403, corsHeaders); } const data = await fetchGpsJamData(); if (!data) { - return new Response(JSON.stringify({ error: 'GPS interference data temporarily unavailable' }), { - status: 503, - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-cache, no-store', - ...corsHeaders, - }, - }); + return jsonResponse( + { error: 'GPS interference data temporarily unavailable' }, + 503, + { 'Cache-Control': 'no-cache, no-store', ...corsHeaders }, + ); } - return new Response(JSON.stringify(data), { - status: 200, - headers: { - 'Content-Type': 'application/json', + return jsonResponse( + data, + 200, + { 'Cache-Control': 's-maxage=3600, stale-while-revalidate=1800, stale-if-error=3600', ...corsHeaders, }, - }); + ); } diff --git a/api/health.js b/api/health.js index 824ca3aa7..dcbef98e2 100644 --- a/api/health.js +++ b/api/health.js @@ -1,3 +1,5 @@ +import { jsonResponse } from './_json-response.js'; + export const config = { runtime: 'edge' }; const BOOTSTRAP_KEYS = { @@ -221,11 +223,11 @@ export default async function handler(req) { const commands = allKeys.map(k => ['GET', k]); results = await redisPipeline(commands); } catch (err) { - return new Response(JSON.stringify({ + return jsonResponse({ status: 'REDIS_DOWN', error: err.message, checkedAt: new Date(now).toISOString(), - }), { status: 503, headers }); + }, 503, headers); } const keyValues = new Map(); diff --git a/api/military-flights.js b/api/military-flights.js index 5943c73e9..ad5e29e4c 100644 --- a/api/military-flights.js +++ b/api/military-flights.js @@ -1,4 +1,5 @@ import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; +import { jsonResponse } from './_json-response.js'; import { readJsonFromUpstash } from './_upstash-json.js'; export const config = { runtime: 'edge' }; @@ -43,31 +44,25 @@ export default async function handler(req) { } if (isDisallowedOrigin(req)) { - return new Response(JSON.stringify({ error: 'Origin not allowed' }), { - status: 403, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + return jsonResponse({ error: 'Origin not allowed' }, 403, corsHeaders); } const data = await fetchMilitaryFlightsData(); if (!data) { - return new Response(JSON.stringify({ error: 'Military flight data temporarily unavailable' }), { - status: 503, - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-cache, no-store', - ...corsHeaders, - }, - }); + return jsonResponse( + { error: 'Military flight data temporarily unavailable' }, + 503, + { 'Cache-Control': 'no-cache, no-store', ...corsHeaders }, + ); } - return new Response(JSON.stringify(data), { - status: 200, - headers: { - 'Content-Type': 'application/json', + return jsonResponse( + data, + 200, + { 'Cache-Control': 's-maxage=120, stale-while-revalidate=60, stale-if-error=300', ...corsHeaders, }, - }); + ); } diff --git a/api/oref-alerts.js b/api/oref-alerts.js index acffbdf98..5e37afc47 100644 --- a/api/oref-alerts.js +++ b/api/oref-alerts.js @@ -1,4 +1,5 @@ import { createRelayHandler } from './_relay.js'; +import { jsonResponse } from './_json-response.js'; export const config = { runtime: 'edge' }; @@ -13,14 +14,11 @@ export default createRelayHandler({ cacheHeaders: () => ({ 'Cache-Control': 'public, max-age=60, s-maxage=300, stale-while-revalidate=120, stale-if-error=900', }), - fallback: (_req, corsHeaders) => new Response(JSON.stringify({ + fallback: (_req, corsHeaders) => jsonResponse({ configured: false, alerts: [], historyCount24h: 0, timestamp: new Date().toISOString(), error: 'No data source available', - }), { - status: 503, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }), + }, 503, corsHeaders), }); diff --git a/api/register-interest.js b/api/register-interest.js index 0f7779a7b..01f953513 100644 --- a/api/register-interest.js +++ b/api/register-interest.js @@ -3,6 +3,7 @@ export const config = { runtime: 'edge' }; import { ConvexHttpClient } from 'convex/browser'; import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; import { getClientIp, verifyTurnstile } from './_turnstile.js'; +import { jsonResponse } from './_json-response.js'; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const MAX_EMAIL_LENGTH = 320; @@ -12,15 +13,6 @@ const rateLimitMap = new Map(); const RATE_LIMIT = 5; const RATE_WINDOW_MS = 60 * 60 * 1000; -function jsonResponse(body, status, cors) { - return new Response(JSON.stringify(body), { - status, - headers: cors - ? { 'Content-Type': 'application/json', ...cors } - : { 'Content-Type': 'application/json' }, - }); -} - function isRateLimited(ip) { const now = Date.now(); const entry = rateLimitMap.get(ip); diff --git a/api/reverse-geocode.js b/api/reverse-geocode.js index 1a8f31b6c..5f76fea11 100644 --- a/api/reverse-geocode.js +++ b/api/reverse-geocode.js @@ -1,4 +1,5 @@ import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; +import { jsonResponse } from './_json-response.js'; export const config = { runtime: 'edge' }; @@ -21,10 +22,7 @@ export default async function handler(req) { const lonN = Number(lon); if (!lat || !lon || Number.isNaN(latN) || Number.isNaN(lonN) || latN < -90 || latN > 90 || lonN < -180 || lonN > 180) { - return new Response(JSON.stringify({ error: 'valid lat (-90..90) and lon (-180..180) required' }), { - status: 400, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); + return jsonResponse({ error: 'valid lat (-90..90) and lon (-180..180) required' }, 400, cors); } const redisUrl = process.env.UPSTASH_REDIS_REST_URL; @@ -63,10 +61,7 @@ export default async function handler(req) { ); if (!resp.ok) { - return new Response(JSON.stringify({ error: `Nominatim ${resp.status}` }), { - status: 502, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); + return jsonResponse({ error: `Nominatim ${resp.status}` }, 502, cors); } const data = await resp.json(); @@ -93,9 +88,6 @@ export default async function handler(req) { }, }); } catch (err) { - return new Response(JSON.stringify({ error: 'Nominatim request failed' }), { - status: 502, - headers: { ...cors, 'Content-Type': 'application/json' }, - }); + return jsonResponse({ error: 'Nominatim request failed' }, 502, cors); } } diff --git a/api/rss-proxy.js b/api/rss-proxy.js index 3504c9087..e6be937de 100644 --- a/api/rss-proxy.js +++ b/api/rss-proxy.js @@ -3,6 +3,7 @@ import { validateApiKey } from './_api-key.js'; import { checkRateLimit } from './_rate-limit.js'; import { getRelayBaseUrl, getRelayHeaders, fetchWithTimeout } from './_relay.js'; import RSS_ALLOWED_DOMAINS from './_rss-allowed-domains.js'; +import { jsonResponse } from './_json-response.js'; export const config = { runtime: 'edge' }; @@ -60,10 +61,7 @@ export default async function handler(req) { const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS'); if (isDisallowedOrigin(req)) { - return new Response(JSON.stringify({ error: 'Origin not allowed' }), { - status: 403, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + return jsonResponse({ error: 'Origin not allowed' }, 403, corsHeaders); } // Handle CORS preflight @@ -71,18 +69,12 @@ export default async function handler(req) { return new Response(null, { status: 204, headers: corsHeaders }); } if (req.method !== 'GET') { - return new Response(JSON.stringify({ error: 'Method not allowed' }), { - status: 405, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + return jsonResponse({ error: 'Method not allowed' }, 405, corsHeaders); } const keyCheck = validateApiKey(req); if (keyCheck.required && !keyCheck.valid) { - return new Response(JSON.stringify({ error: keyCheck.error }), { - status: 401, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + return jsonResponse({ error: keyCheck.error }, 401, corsHeaders); } const rateLimitResponse = await checkRateLimit(req, corsHeaders); @@ -92,10 +84,7 @@ export default async function handler(req) { const feedUrl = requestUrl.searchParams.get('url'); if (!feedUrl) { - return new Response(JSON.stringify({ error: 'Missing url parameter' }), { - status: 400, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + return jsonResponse({ error: 'Missing url parameter' }, 400, corsHeaders); } try { @@ -104,10 +93,7 @@ export default async function handler(req) { // Security: Check if domain is allowed (normalize www prefix) const hostname = parsedUrl.hostname; if (!isAllowedDomain(hostname)) { - return new Response(JSON.stringify({ error: 'Domain not allowed' }), { - status: 403, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + return jsonResponse({ error: 'Domain not allowed' }, 403, corsHeaders); } const isRelayOnly = RELAY_ONLY_DOMAINS.has(hostname); @@ -188,13 +174,10 @@ export default async function handler(req) { } catch (error) { const isTimeout = error.name === 'AbortError'; console.error('RSS proxy error:', feedUrl, error.message); - return new Response(JSON.stringify({ + return jsonResponse({ error: isTimeout ? 'Feed timeout' : 'Failed to fetch feed', details: error.message, url: feedUrl - }), { - status: isTimeout ? 504 : 502, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + }, isTimeout ? 504 : 502, corsHeaders); } } diff --git a/api/satellites.js b/api/satellites.js index 95039fed4..23d967953 100644 --- a/api/satellites.js +++ b/api/satellites.js @@ -1,4 +1,5 @@ import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; +import { jsonResponse } from './_json-response.js'; import { readJsonFromUpstash } from './_upstash-json.js'; export const config = { runtime: 'edge' }; @@ -33,24 +34,16 @@ export default async function handler(req) { return new Response(null, { status: 204, headers: corsHeaders }); } if (isDisallowedOrigin(req)) { - return new Response(JSON.stringify({ error: 'Origin not allowed' }), { - status: 403, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + return jsonResponse({ error: 'Origin not allowed' }, 403, corsHeaders); } const data = await fetchSatelliteData(); if (!data) { - return new Response(JSON.stringify({ error: 'Satellite data temporarily unavailable' }), { - status: 503, - headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache, no-store', ...corsHeaders }, + return jsonResponse({ error: 'Satellite data temporarily unavailable' }, 503, { + 'Cache-Control': 'no-cache, no-store', ...corsHeaders, }); } - return new Response(JSON.stringify(data), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 's-maxage=3600, stale-while-revalidate=1800, stale-if-error=3600', - ...corsHeaders, - }, + return jsonResponse(data, 200, { + 'Cache-Control': 's-maxage=3600, stale-while-revalidate=1800, stale-if-error=3600', + ...corsHeaders, }); } diff --git a/api/seed-health.js b/api/seed-health.js index c021a77fb..54ddb3d73 100644 --- a/api/seed-health.js +++ b/api/seed-health.js @@ -1,5 +1,6 @@ import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; import { validateApiKey } from './_api-key.js'; +import { jsonResponse } from './_json-response.js'; export const config = { runtime: 'edge' }; @@ -87,9 +88,7 @@ export default async function handler(req) { const apiKeyResult = validateApiKey(req); if (apiKeyResult.required && !apiKeyResult.valid) - return new Response(JSON.stringify({ error: apiKeyResult.error }), { - status: 401, headers: { ...cors, 'Content-Type': 'application/json' }, - }); + return jsonResponse({ error: apiKeyResult.error }, 401, cors); const now = Date.now(); const entries = Object.entries(SEED_DOMAINS); @@ -99,9 +98,7 @@ export default async function handler(req) { try { metaMap = await getMetaBatch(metaKeys); } catch { - return new Response(JSON.stringify({ error: 'Redis unavailable' }), { - status: 503, headers: { ...cors, 'Content-Type': 'application/json' }, - }); + return jsonResponse({ error: 'Redis unavailable' }, 503, cors); } const seeds = {}; @@ -136,12 +133,8 @@ export default async function handler(req) { const httpStatus = overall === 'healthy' ? 200 : overall === 'warning' ? 200 : 503; - return new Response(JSON.stringify({ overall, seeds, checkedAt: now }), { - status: httpStatus, - headers: { - ...cors, - 'Content-Type': 'application/json', - 'Cache-Control': 'no-cache', - }, + return jsonResponse({ overall, seeds, checkedAt: now }, httpStatus, { + ...cors, + 'Cache-Control': 'no-cache', }); } diff --git a/api/telegram-feed.js b/api/telegram-feed.js index b16b3b6e8..6393a0972 100644 --- a/api/telegram-feed.js +++ b/api/telegram-feed.js @@ -1,5 +1,6 @@ import { getRelayBaseUrl, getRelayHeaders, fetchWithTimeout } from './_relay.js'; import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; +import { jsonResponse } from './_json-response.js'; export const config = { runtime: 'edge' }; @@ -7,27 +8,18 @@ export default async function handler(req) { const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS'); if (isDisallowedOrigin(req)) { - return new Response(JSON.stringify({ error: 'Origin not allowed' }), { - status: 403, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + return jsonResponse({ error: 'Origin not allowed' }, 403, corsHeaders); } if (req.method === 'OPTIONS') { return new Response(null, { status: 204, headers: corsHeaders }); } if (req.method !== 'GET') { - return new Response(JSON.stringify({ error: 'Method not allowed' }), { - status: 405, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + return jsonResponse({ error: 'Method not allowed' }, 405, corsHeaders); } const relayBaseUrl = getRelayBaseUrl(); if (!relayBaseUrl) { - return new Response(JSON.stringify({ error: 'WS_RELAY_URL is not configured' }), { - status: 503, - headers: { 'Content-Type': 'application/json', ...corsHeaders }, - }); + return jsonResponse({ error: 'WS_RELAY_URL is not configured' }, 503, corsHeaders); } try { @@ -65,12 +57,9 @@ export default async function handler(req) { }); } catch (error) { const isTimeout = error?.name === 'AbortError'; - return new Response(JSON.stringify({ + return jsonResponse({ error: isTimeout ? 'Relay timeout' : 'Relay request failed', details: error?.message || String(error), - }), { - status: isTimeout ? 504 : 502, - headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...corsHeaders }, - }); + }, isTimeout ? 504 : 502, { 'Cache-Control': 'no-store', ...corsHeaders }); } } diff --git a/api/version.js b/api/version.js index b16f40fa8..47db1b689 100644 --- a/api/version.js +++ b/api/version.js @@ -1,37 +1,27 @@ import { fetchLatestRelease } from './_github-release.js'; +import { jsonResponse } from './_json-response.js'; -// Non-sebuf: returns XML/HTML, stays as standalone Vercel function export const config = { runtime: 'edge' }; export default async function handler() { try { const release = await fetchLatestRelease('WorldMonitor-Version-Check'); if (!release) { - return new Response(JSON.stringify({ error: 'upstream' }), { - status: 502, - headers: { 'Content-Type': 'application/json' }, - }); + return jsonResponse({ error: 'upstream' }, 502); } const tag = release.tag_name ?? ''; const version = tag.replace(/^v/, ''); - return new Response(JSON.stringify({ + return jsonResponse({ version, tag, url: release.html_url, prerelease: release.prerelease ?? false, - }), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60, stale-if-error=3600', - 'Access-Control-Allow-Origin': '*', - }, + }, 200, { + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60, stale-if-error=3600', + 'Access-Control-Allow-Origin': '*', }); } catch { - return new Response(JSON.stringify({ error: 'fetch_failed' }), { - status: 502, - headers: { 'Content-Type': 'application/json' }, - }); + return jsonResponse({ error: 'fetch_failed' }, 502); } }