src/server/ingress-router.ts
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bun
// Ingress router: tiny host-header TCP router fronting shraga instances.
// The deployment's ingress (CF Tunnel or Caddy) always points here; this process
// routes by Host header to local ports. Previews and blue-green flips are just edits
// to the routing file — this process never restarts during a flip (that's why it runs
// as its own process, NOT inside the server: `flip-restart` restarts the server while
// the router holds traffic).
//
// bun run src/server/ingress-router.ts # INGRESS_PORT (default 3100)
// shraga ingress # same, via the CLI
//
// Routing file (dataPath('ingress-router.json'), hot-reloaded on change):
// { "default": 3032, "routes": { "pr-13.preview.agent.example.com": 3850 } }
//
// Works at the TCP level: reads bytes until the first request's headers end, parses Host,
// connects upstream, replays the buffered bytes, then splices both directions blindly.
// WebSocket upgrades and keep-alive flow through untouched (same upstream per connection).
import net from 'node:net';
import { existsSync, readFileSync, watch, writeFileSync } from 'node:fs';
import { dataPath } from './paths.ts';
const PORT = Number(process.env.INGRESS_PORT) || 3100;
const CONFIG = dataPath('ingress-router.json');
const HEADER_LIMIT = 16 * 1024;
interface Routing { default: number; routes: Record<string, number>; }
let routing: Routing = { default: 3032, routes: {} };
function loadRouting() {
try {
routing = { routes: {}, ...JSON.parse(readFileSync(CONFIG, 'utf-8')) };
console.log(`[ingress] routing: default→:${routing.default}, ${Object.keys(routing.routes).length} route(s)`);
} catch (err) {
console.error(`[ingress] bad routing file, keeping previous:`, (err as Error).message);
}
}
if (!existsSync(CONFIG)) writeFileSync(CONFIG, JSON.stringify(routing, null, 2));
loadRouting();
let reloadTimer: ReturnType<typeof setTimeout> | null = null;
watch(CONFIG, () => { // debounce — editors fire multiple events per save
if (reloadTimer) clearTimeout(reloadTimer);
reloadTimer = setTimeout(loadRouting, 100);
});
function upstreamFor(host: string): number {
const bare = host.toLowerCase().split(':')[0];
return routing.routes[bare] ?? routing.default;
}
const server = net.createServer((client) => {
let buf = Buffer.alloc(0);
client.once('error', () => client.destroy());
const onData = (chunk: Buffer) => {
buf = Buffer.concat([buf, chunk]);
const headerEnd = buf.indexOf('\r\n\r\n');
if (headerEnd === -1) {
if (buf.length > HEADER_LIMIT) client.destroy();
return;
}
client.off('data', onData);
client.pause();
const head = buf.subarray(0, headerEnd).toString('latin1');
const host = /\r\nhost:\s*([^\r\n]+)/i.exec('\r\n' + head)?.[1]?.trim() ?? '';
const port = upstreamFor(host);
const upstream = net.connect(port, '127.0.0.1', () => {
client.setTimeout(0); // routed — long-lived (WS) connections idle freely
upstream.write(buf);
client.pipe(upstream);
upstream.pipe(client);
client.resume();
});
const drop = () => { client.destroy(); upstream.destroy(); };
upstream.on('error', (err) => {
console.warn(`[ingress] upstream :${port} (${host}):`, err.message);
if (!client.writableEnded) client.end('HTTP/1.1 502 Bad Gateway\r\ncontent-length: 0\r\nconnection: close\r\n\r\n');
upstream.destroy();
});
client.on('error', drop);
client.on('close', () => upstream.destroy());
upstream.on('close', () => client.destroy());
};
client.on('data', onData);
client.setTimeout(15_000, () => { if (!client.bytesWritten) client.destroy(); });
});
server.listen(PORT, () => console.log(`[ingress] listening on :${PORT}, routing file: ${CONFIG}`));