// shared fetch-proxy + app-api handlers for the sootsim dev/daemon HTTP // surface. both the vite dev middleware (packages/sootsim-shell/src/ // dev-middleware.ts) and the daemon bridge (packages/sootsim/src/host/ // bridge-host.ts) mount these on their respective servers so guest bundle // fetches resolve the same way regardless of which surface the sim is // loaded through. // // the symptom that drove this extraction: when the shell dev server was // not running, sims fell back to the daemon's loopback HTTP server, // which only knew about `/__bundle-proxy` and let every `/__fetch-proxy` // or `/__app-api` request fall through to the SPA index.html. tenant // code (e.g. Expensify's NetInfo reachability poll) saw a 200 response // whose body was the shell HTML, parsed it as JSON, failed, and reported // `isInternetReachable: false` → "you appear to be offline". // // routes implemented here: // /__fetch-proxy?url=… — generic CORS-bypassing proxy // /__proxy?url=… — alias of /__fetch-proxy (demo-gateway path) // /__app-api?origin=…&path=… — tenant API reverse proxy, stateless // (bundle origin carried in the query string) // /__app-api/ — legacy form for callers that registered an // origin separately. requires an out-of-band // tenant-origin registry to resolve; left as // the caller's responsibility. import http, { type IncomingMessage, type ServerResponse } from 'http' import https from 'https' import { finished, pipeline } from 'stream/promises' import { isLoopbackHost, normalizeHostname } from '../backend-origin.ts' import { FETCH_PROXY_BROWSER_USER_AGENT, getFetchProxyTargetHeaders, resolveFetchProxyTargetUrl, } from './fetch-proxy-overrides.ts' const STRIP_FETCH_PROXY_HEADERS = new Set([ 'host', 'origin', 'referer', 'user-agent', 'accept-encoding', 'cookie', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'content-length', 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', ]) const FETCH_PROXY_CORS_HEADERS: Record = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET,POST,PUT,DELETE,PATCH,OPTIONS', 'access-control-allow-headers': '*', 'access-control-expose-headers': '*', 'access-control-max-age': '3600', } const APP_API_HEADER_REWRITES = new Set([ 'host', 'origin', 'referer', 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', ]) // a developer's local https stack signs with a locally-trusted CA (mkcert et // al) that node's bundled root store cannot see, so proxied requests to it die // with SELF_SIGNED_CERT_IN_CHAIN. traffic to a loopback target never leaves // the machine, so certificate validation adds nothing there — accept it rather // than requiring every host to plumb a CA bundle into a long-lived daemon. function loopbackTlsOptions(targetUrl: URL): { rejectUnauthorized?: boolean } { if (targetUrl.protocol !== 'https:') return {} return isLoopbackHost(targetUrl.hostname) ? { rejectUnauthorized: false } : {} } function applyFetchProxyCors(res: ServerResponse) { for (const [key, value] of Object.entries(FETCH_PROXY_CORS_HEADERS)) { res.setHeader(key, value) } } function formatFetchProxyError(targetUrl: string, err: unknown): string { const details: string[] = [] const error = err as | { message?: string code?: string cause?: { message?: string; code?: string } } | undefined if (error?.code) details.push(error.code) if (error?.message) details.push(error.message) if (error?.cause?.code) details.push(error.cause.code) if (error?.cause?.message) details.push(error.cause.message) const uniqueDetails = [...new Set(details.filter(Boolean))] const message = uniqueDetails.join(' | ') || String(err) if (targetUrl.includes('stored-in-.env.local')) { return `${message} | upstream url still contains placeholder env values` } return message } export function buildFetchProxyHeaders( reqHeaders: Record, targetUrl?: URL, ): Record { const headers: Record = {} for (const [key, value] of Object.entries(reqHeaders)) { if (!value) continue if (STRIP_FETCH_PROXY_HEADERS.has(key.toLowerCase())) continue headers[key] = Array.isArray(value) ? value.join(', ') : value } Object.assign( headers, targetUrl ? getFetchProxyTargetHeaders(targetUrl) : { 'user-agent': FETCH_PROXY_BROWSER_USER_AGENT }, ) return headers } export function buildAppApiProxyHeaders( reqHeaders: Record, targetUrl: URL, ): Record { const headers: Record = {} for (const [key, value] of Object.entries(reqHeaders)) { if (!value) continue if (APP_API_HEADER_REWRITES.has(key.toLowerCase())) continue headers[key] = value } headers.host = targetUrl.host if (!Object.keys(headers).some((name) => name.toLowerCase() === 'expo-origin')) { headers.origin = targetUrl.origin headers.referer = `${targetUrl.origin}/` } return headers } export function isFetchProxyRequestUrl(rawUrl: string | undefined): boolean { return rawUrl?.startsWith('/__fetch-proxy?') || rawUrl?.startsWith('/__proxy?') || false } export function isBundleProxyRequestUrl(rawUrl: string | undefined): boolean { return rawUrl?.startsWith('/__bundle-proxy?') ?? false } export function isAppApiRequestUrl(rawUrl: string | undefined): boolean { if (!rawUrl) return false if (rawUrl.startsWith('/__app-api?')) return true if (rawUrl.startsWith('/__app-api/')) return true return false } export async function handleFetchProxyRequest( req: IncomingMessage, res: ServerResponse, ): Promise { if (req.method === 'OPTIONS') { applyFetchProxyCors(res) res.writeHead(204) res.end() return } const params = new URLSearchParams((req.url || '').split('?')[1] || '') const targetUrl = params.get('url') if (!targetUrl) { applyFetchProxyCors(res) res.writeHead(400, { 'Content-Type': 'text/plain' }) res.end('missing url param') return } let upstreamUrl: URL try { upstreamUrl = resolveFetchProxyTargetUrl(new URL(targetUrl)) } catch { applyFetchProxyCors(res) res.writeHead(400, { 'Content-Type': 'text/plain' }) res.end('invalid url param') return } let body: Buffer | undefined if (req.method !== 'GET' && req.method !== 'HEAD') { const chunks: Buffer[] = [] for await (const chunk of req) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) } if (chunks.length > 0) { body = Buffer.concat(chunks) } } const originalMethod = req.method || 'GET' const proxy = async ( targetUrl: URL, method: string, nextBody: Buffer | undefined, redirects: number, ): Promise => { const transport = targetUrl.protocol === 'https:' ? https : http const targetHeaders = buildFetchProxyHeaders(req.headers, targetUrl) if (targetUrl.origin !== upstreamUrl.origin) { delete targetHeaders.authorization delete targetHeaders['proxy-authorization'] } if (method === 'GET' || method === 'HEAD') { delete targetHeaders['content-length'] delete targetHeaders['content-type'] } else if (nextBody) { targetHeaders['content-length'] = String(nextBody.byteLength) } const proxyRes = await new Promise((resolve, reject) => { const proxyReq = transport.request( { hostname: normalizeHostname(targetUrl.hostname), port: targetUrl.port || (targetUrl.protocol === 'https:' ? 443 : 80), path: targetUrl.pathname + targetUrl.search, method, headers: targetHeaders, ...loopbackTlsOptions(targetUrl), }, resolve, ) const abort = () => proxyReq.destroy(new Error('fetch proxy client disconnected')) req.once('aborted', abort) proxyReq.once('close', () => req.off('aborted', abort)) proxyReq.once('error', reject) if (nextBody === undefined) proxyReq.end() else proxyReq.end(nextBody) }) const status = proxyRes.statusCode ?? 502 const location = proxyRes.headers.location if ( location && (status === 301 || status === 302 || status === 303 || status === 307 || status === 308) ) { if (redirects >= 10) { proxyRes.destroy() throw new Error('too many redirects') } const switchToGet = ((status === 301 || status === 302) && method === 'POST') || (status === 303 && method !== 'GET' && method !== 'HEAD') const redirectUrl = new URL(location, targetUrl) proxyRes.resume() await finished(proxyRes) await proxy( redirectUrl, switchToGet ? 'GET' : method, switchToGet ? undefined : nextBody, redirects + 1, ) return } for (const [key, value] of Object.entries(proxyRes.headers)) { const lowerKey = key.toLowerCase() if ( value === undefined || lowerKey === 'set-cookie' || lowerKey === 'connection' || lowerKey === 'keep-alive' || lowerKey === 'proxy-authenticate' || lowerKey === 'proxy-authorization' || lowerKey === 'te' || lowerKey === 'trailer' || lowerKey === 'transfer-encoding' || lowerKey === 'upgrade' || lowerKey.startsWith('access-control-') ) { continue } res.setHeader(key, value) } applyFetchProxyCors(res) const setCookie = proxyRes.headers['set-cookie'] ?? [] if (setCookie.length > 0) { res.setHeader( 'x-sootsim-set-cookie', (Array.isArray(setCookie) ? setCookie : [setCookie]).join(', '), ) } res.statusCode = status await pipeline(proxyRes, res) } try { await proxy(upstreamUrl, originalMethod, body, 0) } catch (err) { if (res.headersSent) { res.destroy(err instanceof Error ? err : new Error(String(err))) return } const norm = normalizeHostname(upstreamUrl.hostname) if (isLoopbackHost(norm) && (err as { code?: string })?.code === 'ECONNREFUSED') { const altHost = norm === '::1' ? '127.0.0.1' : norm === '127.0.0.1' ? '::1' : '127.0.0.1' if (altHost !== norm) { try { const altUrl = new URL(upstreamUrl.href) altUrl.hostname = altHost await proxy(altUrl, originalMethod, body, 0) return } catch {} } } applyFetchProxyCors(res) res.writeHead(502, { 'Content-Type': 'text/plain' }) res.end(`fetch proxy error: ${formatFetchProxyError(upstreamUrl.href, err)}`) } } export async function handleBundleProxyRequest( req: IncomingMessage, res: ServerResponse, ): Promise { const params = new URLSearchParams((req.url || '').split('?')[1] || '') const target = params.get('url') if (!target) { applyFetchProxyCors(res) res.writeHead(400, { 'Content-Type': 'text/plain' }) res.end('bundle-proxy: missing url query param') return } let targetUrl: URL try { targetUrl = new URL(target) } catch { applyFetchProxyCors(res) res.writeHead(400, { 'Content-Type': 'text/plain' }) res.end('bundle-proxy: invalid url') return } if (!isLoopbackHost(targetUrl.hostname)) { applyFetchProxyCors(res) res.writeHead(403, { 'Content-Type': 'text/plain' }) res.end('bundle-proxy: only loopback targets allowed') return } await handleFetchProxyRequest(req, res) } export function handleAppApiRequest(req: IncomingMessage, res: ServerResponse): boolean { // returns true if it handled the request, false if the caller should fall // through. /__app-api requires `origin` query param for stateless mode; // without it we return false so the caller decides what to do (the vite // middleware uses its own out-of-band origin registry; the daemon has // no such registry so it returns 400). const reqUrl = req.url || '' let targetPath = '' let targetOrigin = '' if (reqUrl.startsWith('/__app-api?')) { const parsed = new URL(reqUrl, 'http://sootsim.local') targetPath = parsed.searchParams.get('path') || '' targetOrigin = parsed.searchParams.get('origin')?.trim() || '' } else if (reqUrl.startsWith('/__app-api/')) { targetPath = reqUrl.slice('/__app-api'.length) } else { return false } if (!targetOrigin) { res.writeHead(400, { 'Content-Type': 'text/plain' }) res.end('app-api: missing origin query param') return true } if (req.method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Origin': (req.headers.origin as string) || '*', 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS', 'Access-Control-Allow-Headers': (req.headers['access-control-request-headers'] as string) || '*', 'Access-Control-Allow-Credentials': 'true', 'Access-Control-Max-Age': '86400', }) res.end() return true } let targetUrl: URL try { targetUrl = new URL(targetPath, targetOrigin) } catch { res.writeHead(400, { 'Content-Type': 'text/plain' }) res.end('app-api: invalid origin or path') return true } const transport = targetUrl.protocol === 'https:' ? https : http const fwdHeaders = buildAppApiProxyHeaders(req.headers, targetUrl) const proxyReq = transport.request( { hostname: normalizeHostname(targetUrl.hostname), port: targetUrl.port || (targetUrl.protocol === 'https:' ? 443 : 80), path: targetUrl.pathname + targetUrl.search, method: req.method, headers: fwdHeaders, ...loopbackTlsOptions(targetUrl), }, (proxyRes) => { const exposedHeaders = Object.keys(proxyRes.headers) .filter((name) => { const lower = name.toLowerCase() return !lower.startsWith('access-control-') && lower !== 'set-cookie' }) .join(', ') res.writeHead(proxyRes.statusCode ?? 502, { ...proxyRes.headers, 'access-control-allow-origin': (req.headers.origin as string) || '*', 'access-control-allow-credentials': 'true', 'access-control-expose-headers': exposedHeaders, }) proxyRes.pipe(res) }, ) proxyReq.on('error', (err) => { res.statusCode = 502 res.end(`app proxy error: ${err.message}`) }) req.pipe(proxyReq) return true }