import { createReadStream } from 'node:fs'; import { realpath, stat } from 'node:fs/promises'; import { createServer, request as httpRequest, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse, } from 'node:http'; import { extname, isAbsolute, relative, resolve, sep } from 'node:path'; import { pipeline } from 'node:stream/promises'; import { GATEWAY_AUTH_HEADER } from './runtime-config.js'; export const CANARY_DEFAULT_HOST = '127.0.0.1'; export const CANARY_DEFAULT_PORT = 8789; export const CANARY_DEFAULT_UPSTREAM_HOST = '127.0.0.1'; export const CANARY_DEFAULT_UPSTREAM_PORT = 8790; const RESERVED_PORTS = new Set([8788, 8790]); export const PRODUCTION_GATEWAY_PORT = 8788; const BASE_HOP_BY_HOP_HEADERS = new Set([ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', ]); const MIME_TYPES: Readonly> = { '.css': 'text/css; charset=utf-8', '.gif': 'image/gif', '.html': 'text/html; charset=utf-8', '.ico': 'image/x-icon', '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.map': 'application/json; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.txt': 'text/plain; charset=utf-8', '.webp': 'image/webp', '.woff': 'font/woff', '.woff2': 'font/woff2', }; function isNumericLoopback(host: string): boolean { if (host === '::1') return true; const octets = host.split('.'); if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) return false; const numbers = octets.map(Number); return numbers[0] === 127 && numbers.every((octet) => octet >= 0 && octet <= 255); } export interface CanaryGatewayOptions { readonly gatewayToken?: string; readonly distDir: string; readonly host?: string; readonly allowedHosts?: readonly string[]; readonly port?: number; readonly upstreamHost?: string; readonly upstreamPort?: number; /** Set only after the production runtime validates its explicit acknowledgement. */ readonly ownsProductionPort?: boolean; } export interface CanaryGateway { readonly running: boolean; readonly origin: string; start(): Promise; stop(): Promise; } function connectionTokens(headers: IncomingHttpHeaders): Set { const tokens = new Set(BASE_HOP_BY_HOP_HEADERS); const connection = headers.connection; if (connection) { for (const token of connection.split(',')) tokens.add(token.trim().toLowerCase()); } return tokens; } function withoutHopByHop(headers: IncomingHttpHeaders): IncomingHttpHeaders { const blocked = connectionTokens(headers); return Object.fromEntries( Object.entries(headers).filter( ([name, value]) => value !== undefined && !blocked.has(name.toLowerCase()), ), ); } function trustedUpstreamHeaders( headers: IncomingHttpHeaders, gatewayToken: string | undefined, upstreamHost: string, upstreamPort: number, ): IncomingHttpHeaders { const sanitized = withoutHopByHop(headers); sanitized.host = `${upstreamHost}:${upstreamPort}`; if (sanitized.origin !== undefined) sanitized.origin = `http://${upstreamHost}:${upstreamPort}`; delete sanitized[GATEWAY_AUTH_HEADER]; if (gatewayToken !== undefined) sanitized[GATEWAY_AUTH_HEADER] = gatewayToken; return sanitized; } function isAllowedBrowserOrigin(origin: string, allowedAuthorities: ReadonlySet): boolean { try { const parsed = new URL(origin); return ( parsed.protocol === 'http:' && parsed.username === '' && parsed.password === '' && parsed.pathname === '/' && parsed.search === '' && parsed.hash === '' && allowedAuthorities.has(parsed.host.toLowerCase()) ); } catch { return false; } } function isProxyPath(pathname: string): boolean { return pathname === '/healthz' || pathname === '/readyz' || pathname.startsWith('/api/v1/'); } function isHashedAsset(pathname: string): boolean { return /(?:^|[.-])[a-f0-9]{8,}(?:[.-]|$)/i.test(pathname); } function isWithin(root: string, candidate: string): boolean { const child = relative(root, candidate); return child === '' || (!child.startsWith(`..${sep}`) && child !== '..' && !isAbsolute(child)); } function sendText(response: ServerResponse, status: number, text: string): void { response.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'content-length': Buffer.byteLength(text), 'cache-control': 'no-store', }); response.end(text); } async function proxyRequest( incoming: IncomingMessage, response: ServerResponse, upstreamHost: string, upstreamPort: number, gatewayToken: string | undefined, ): Promise { await new Promise((resolvePromise) => { const upstream = httpRequest( { host: upstreamHost, port: upstreamPort, method: incoming.method, path: incoming.url, headers: trustedUpstreamHeaders(incoming.headers, gatewayToken, upstreamHost, upstreamPort), }, (upstreamResponse) => { response.writeHead( upstreamResponse.statusCode ?? 502, upstreamResponse.statusMessage, withoutHopByHop(upstreamResponse.headers), ); pipeline(upstreamResponse, response) .then(resolvePromise) .catch(() => { response.destroy(); resolvePromise(); }); }, ); upstream.once('error', () => { if (!response.headersSent) sendText(response, 502, 'Bad Gateway'); else response.destroy(); resolvePromise(); }); incoming.once('aborted', () => upstream.destroy()); incoming.pipe(upstream); }); } async function serveFile( request: IncomingMessage, response: ServerResponse, distRoot: string, pathname: string, ): Promise { const candidate = resolve(distRoot, `.${pathname}`); if (!isWithin(distRoot, candidate)) return false; let canonical: string; try { canonical = await realpath(candidate); if (!isWithin(distRoot, canonical) || !(await stat(canonical)).isFile()) return false; } catch { return false; } const headers = { 'content-type': MIME_TYPES[extname(canonical).toLowerCase()] ?? 'application/octet-stream', 'cache-control': isHashedAsset(pathname) ? 'public, max-age=31536000, immutable' : 'no-cache', 'x-content-type-options': 'nosniff', }; response.writeHead(200, headers); if (request.method === 'HEAD') response.end(); else await pipeline(createReadStream(canonical), response); return true; } export function createCanaryGateway(options: CanaryGatewayOptions): CanaryGateway { const host = options.host ?? CANARY_DEFAULT_HOST; const port = options.port ?? CANARY_DEFAULT_PORT; const upstreamHost = options.upstreamHost ?? CANARY_DEFAULT_UPSTREAM_HOST; const upstreamPort = options.upstreamPort ?? CANARY_DEFAULT_UPSTREAM_PORT; if (!isNumericLoopback(host) && !(options.ownsProductionPort && host === '0.0.0.0')) { throw new Error('Canary gateway must bind to loopback; production may explicitly bind 0.0.0.0'); } if (!isNumericLoopback(upstreamHost)) { throw new Error('Canary upstream must use a loopback host'); } if (!Number.isInteger(port) || port < 0 || port > 65_535) throw new Error('Invalid canary port'); if (RESERVED_PORTS.has(port) && !(port === PRODUCTION_GATEWAY_PORT && options.ownsProductionPort)) throw new Error(`Port ${port} is reserved and cannot run the canary`); if (!options.distDir) throw new Error('distDir is required'); const allowedAuthorities = new Set( options.allowedHosts?.map((allowedHost) => { const ipv4Octets = /^\d+(?:\.\d+){3}$/u.test(allowedHost) ? allowedHost.split('.').map(Number) : undefined; if ( allowedHost === '' || allowedHost.includes(':') || (ipv4Octets !== undefined && (ipv4Octets.some((octet) => octet > 255) || ipv4Octets.every((octet) => octet === 0))) || !/^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\d{1,3}(?:\.\d{1,3}){3})$/iu.test(allowedHost) ) throw new Error('Invalid allowed gateway host'); return `${allowedHost.toLowerCase()}:${port}`; }), ); let server: Server | undefined; let activeOrigin = ''; async function handle(request: IncomingMessage, response: ServerResponse): Promise { if (!request.url || !request.method) return sendText(response, 400, 'Bad Request'); if (!request.url.startsWith('/') || request.url.startsWith('//')) { return sendText(response, 400, 'Bad Request'); } const expectedAuthority = activeOrigin ? new URL(activeOrigin).host : ''; const acceptedAuthorities = host === '0.0.0.0' ? allowedAuthorities : new Set([expectedAuthority, ...allowedAuthorities]); const hostHeaders = request.headersDistinct.host; const requestAuthority = hostHeaders?.[0]?.toLowerCase(); if ( hostHeaders?.length !== 1 || requestAuthority === undefined || !acceptedAuthorities.has(requestAuthority) ) { return sendText(response, 400, 'Bad Request'); } const incomingOrigin = request.headers.origin; if ( incomingOrigin !== undefined && !isAllowedBrowserOrigin(incomingOrigin, acceptedAuthorities) ) { return sendText(response, 403, 'Forbidden'); } const rawPathname = request.url.split('?', 1)[0] ?? ''; let unnormalizedPathname: string; try { unnormalizedPathname = decodeURIComponent(rawPathname); } catch { return sendText(response, 400, 'Bad Request'); } if ( unnormalizedPathname.includes('\0') || unnormalizedPathname.split('/').includes('..') || unnormalizedPathname.includes('\\') ) { return sendText(response, 400, 'Bad Request'); } let parsed: URL; try { parsed = new URL(request.url, 'http://canary.invalid'); } catch { return sendText(response, 400, 'Bad Request'); } let pathname: string; try { pathname = decodeURIComponent(parsed.pathname); } catch { return sendText(response, 400, 'Bad Request'); } if (isProxyPath(pathname)) { await proxyRequest(request, response, upstreamHost, upstreamPort, options.gatewayToken); return; } if (pathname.startsWith('/api')) return sendText(response, 404, 'Not Found'); if (request.method !== 'GET' && request.method !== 'HEAD') { return sendText(response, 405, 'Method Not Allowed'); } // Browsers request this implicitly. Avoid a noisy console 404 without routing // this file-like path through the SPA fallback. if (pathname === '/favicon.ico') { response.writeHead(204, { 'cache-control': 'no-cache' }); response.end(); return; } const distRoot = await realpath(resolve(options.distDir)).catch(() => resolve(options.distDir)); if (pathname !== '/' && (await serveFile(request, response, distRoot, pathname))) return; if ( pathname.startsWith('/assets/') || extname(pathname) !== '' || !(await serveFile(request, response, distRoot, '/index.html')) ) { sendText(response, 404, 'Not Found'); } } return { get running() { return server?.listening ?? false; }, get origin() { if (!activeOrigin) throw new Error('Canary gateway is not running'); return activeOrigin; }, async start() { if (server?.listening) return; const nextServer = createServer((request, response) => { void handle(request, response).catch(() => { if (!response.headersSent) sendText(response, 500, 'Internal Server Error'); else response.destroy(); }); }); server = nextServer; await new Promise((resolvePromise, reject) => { nextServer.once('error', reject); nextServer.listen(port, host, resolvePromise); }).catch((error: unknown) => { server = undefined; throw error; }); const address = nextServer.address(); if (!address || typeof address === 'string') throw new Error('Expected TCP server address'); const originHost = address.family === 'IPv6' ? `[${address.address}]` : address.address; activeOrigin = `http://${originHost}:${address.port}`; }, async stop() { const activeServer = server; server = undefined; activeOrigin = ''; if (!activeServer?.listening) return; await new Promise((resolvePromise, reject) => { activeServer.close((error) => (error ? reject(error) : resolvePromise())); activeServer.closeAllConnections(); }); }, }; }