serve.ts
Rama master · réplica de lectura del repositorio original.
#!/usr/bin/env node
// Static + API-proxy server for wish-web (hardened per decisions 23 / task 7).
// · compiled static files from ./dist (build with ./pnpmw build)
// · /api/* → wish (WISH_UPSTREAM; default http://127.0.0.1:9780)
// · server-side bearer injection: WISH_HTTP_TOKEN
// Hardening:
// · explicit Host allowlist — DNS rebinding / foreign-Host requests
// are rejected BEFORE any proxying or token injection;
// · cross-origin mutations (Origin / Sec-Fetch-Site) rejected 403;
// · upstream base path preserved; http/https only (others rejected);
// · client disconnect destroys the upstream request (SSE release);
// · errors never leak stack traces to clients.
// Usage: node serve.ts (PORT=8790 by default)
import { createServer, request as httpRequest } from 'node:http';
import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const PORT = Number(process.env.PORT || 8790);
const LISTEN_HOST = process.env.LISTEN_HOST || '127.0.0.1';
const ROOT = fileURLToPath(new URL('./dist', import.meta.url));
// Missing builds are a startup error, not an apparently healthy empty server.
await stat(join(ROOT, 'index.html'));
const BACKENDS = [
{ prefix: '/api', base: (process.env.WISH_UPSTREAM || 'http://127.0.0.1:9780').replace(/\/+$/, '') + '/api', token: process.env.WISH_HTTP_TOKEN || '' },
].map(backend => {
const url = new URL(backend.base);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`serve.ts: unsupported ${backend.prefix} protocol ${url.protocol} (http/https only)`);
}
return { ...backend, url, path: url.pathname.replace(/\/+$/, '') };
});
type Backend = (typeof BACKENDS)[number];
// Loopback by default; LAN access explicitly adds trusted HTTP authorities.
const HOSTS = new Set([
`127.0.0.1:${PORT}`, `localhost:${PORT}`, `[::1]:${PORT}`,
...(PORT === 80 ? ['127.0.0.1', 'localhost', '[::1]'] : []),
...(process.env.ALLOWED_HOSTS || '').split(',').map(h => h.trim().toLowerCase()).filter(Boolean),
]);
const MUTATION = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.wasm': 'application/wasm',
'.json': 'application/json', '.webmanifest': 'application/manifest+json',
'.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon',
'.woff2': 'font/woff2', '.map': 'application/json',
};
function reject(res: ServerResponse, status: number, why: string): void {
if (res.headersSent) { res.destroy(); return; }
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify({ error: 'rejected', detail: why }));
}
const server = createServer(async (req, res) => {
try {
// ── boundary checks (before any proxying / token injection) ──────────
const host = String(req.headers.host || '').toLowerCase();
if (!HOSTS.has(host)) return reject(res, 421, 'unexpected host');
const origin = req.headers.origin ? String(req.headers.origin) : null;
const fetchSite = req.headers['sec-fetch-site'] ? String(req.headers['sec-fetch-site']) : null;
if (MUTATION.has(req.method ?? '')) {
if (origin && origin !== `http://${host}` && origin !== `https://${host}`) return reject(res, 403, 'cross-origin mutation');
if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') {
return reject(res, 403, `cross-site mutation (${fetchSite})`);
}
}
const url = new URL(req.url ?? '/', `http://${host}`);
if (url.pathname === '/healthz') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ ok: true }));
}
const backend = BACKENDS.find(b => url.pathname === b.prefix || url.pathname.startsWith(b.prefix + '/'));
if (backend) return proxyApi(req, res, url, backend);
// ── static ────────────────────────────────────────────────────────────
let path = normalize(decodeURIComponent(url.pathname)).replace(/^(\.\.[/\\])+/, '');
if (path === '/' || path === '') path = '/index.html';
const file = join(ROOT, path);
if (!file.startsWith(ROOT)) { res.writeHead(403); return res.end('forbidden'); }
try {
const st = await stat(file);
if (st.isDirectory()) throw new Error('dir');
const data = await readFile(file);
res.writeHead(200, {
'content-type': MIME[extname(file)] || 'application/octet-stream',
'cache-control': url.pathname.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache',
'x-content-type-options': 'nosniff',
});
return res.end(req.method === 'HEAD' ? undefined : data);
} catch {
// SPA-ish fallback for unknown non-asset paths → index
if (!extname(path)) {
const index = await readFile(join(ROOT, 'index.html'));
res.writeHead(200, { 'content-type': MIME['.html'], 'cache-control': 'no-cache' });
return res.end(index);
}
res.writeHead(404, { 'content-type': 'text/plain' });
return res.end('not found');
}
} catch (err) {
console.error('[serve]', (err as Error | undefined)?.message || err); // server-side log only
if (!res.headersSent) {
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'internal' }));
} else {
res.destroy();
}
}
});
function proxyApi(req: IncomingMessage, res: ServerResponse, url: URL, backend: Backend): void {
const up = backend.url;
const target = backend.path + url.pathname.slice(backend.prefix.length) + url.search;
const headers: OutgoingHttpHeaders = { ...req.headers, host: up.host };
delete headers['accept-encoding']; // avoid compressed SSE buffering
delete headers.referer; delete headers.origin;
delete headers['sec-fetch-site']; delete headers['sec-fetch-mode'];
delete headers['sec-fetch-dest'];
if (backend.token) headers.authorization = `Bearer ${backend.token}`;
const doRequest = up.protocol === 'https:' ? httpsRequest : httpRequest;
const prox = doRequest(
{ hostname: up.hostname, port: up.port || (up.protocol === 'https:' ? 443 : 80),
path: target, method: req.method, headers },
(pr) => {
const h = { ...pr.headers };
res.writeHead(pr.statusCode || 502, h);
pr.pipe(res); // streams SSE frames unbuffered
});
// Browser disconnected (SSE closed, tab killed) → release the upstream
// connection immediately instead of pinning daemon streams.
const killUpstream = () => prox.destroy();
res.on('close', killUpstream);
req.on('aborted', killUpstream);
prox.on('error', (err) => {
console.error('[serve:proxy]', err?.message || err);
if (!res.headersSent) {
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'upstream_unreachable', detail: String(err.message || 'unreachable') }));
} else {
res.destroy();
}
});
req.pipe(prox);
}
server.listen(PORT, LISTEN_HOST, () => {
console.log(`wish-web listening on ${LISTEN_HOST}:${PORT}; wish API routes enabled`);
});