@specific.dev/spectest 0.101.0 → 0.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js CHANGED
@@ -57,9 +57,10 @@ import { bindInstrumentationScope, createInstrumentationScope, runInstrumented,
57
57
  import { encodeRegistry } from "./harness/names-registry.js";
58
58
  import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
59
59
  import { HOP_BY_HOP_HEADERS, withIngressCors, upstreamRequestHeaders, } from "./harness/http-proxy.js";
60
- import { WS_BRIDGE_HANDLERS, openUpstreamWebSocket, parseSubprotocols, upstreamHandshakeHeaders, } from "./harness/ws-bridge.js";
60
+ import { openUpstreamWebSocket, parseSubprotocols, upstreamHandshakeHeaders, } from "./harness/ws-bridge.js";
61
61
  import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectCertName, wildcardSuffix, } from "./harness/hostmatch.js";
62
62
  import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
63
+ import { startIngressServer } from "./harness/ingress-server.js";
63
64
  import { startHttp2Proxy } from "./harness/http2-proxy.js";
64
65
  import { startTlsTerminator } from "./harness/tls-terminator.js";
65
66
  import { runContainerArgs } from "./harness/container-run.js";
@@ -1495,11 +1496,10 @@ let LOWERED = {
1495
1496
  aliasesByService: {},
1496
1497
  wildcards: [],
1497
1498
  };
1498
- /** Running HTTP servers per port (Bun.Server). Rebuilt on /load. */
1499
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1499
+ /** Running HTTP servers per port. Rebuilt on /load. */
1500
1500
  const INGRESS_HTTP_SERVERS = new Map();
1501
+ const INGRESS_HTTP_STARTING = new Map();
1501
1502
  /** Running HTTPS servers per port (currently always {INGRESS_HTTPS_PORT}). */
1502
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1503
1503
  const INGRESS_HTTPS_SERVERS = new Map();
1504
1504
  /**
1505
1505
  * The :443 TLS terminator, once per harness process.
@@ -1564,6 +1564,7 @@ function stopIngressServers() {
1564
1564
  }
1565
1565
  }
1566
1566
  INGRESS_HTTP_SERVERS.clear();
1567
+ INGRESS_HTTP_STARTING.clear();
1567
1568
  for (const [port, srv] of INGRESS_HTTPS_SERVERS) {
1568
1569
  try {
1569
1570
  srv.stop?.(true);
@@ -1691,7 +1692,7 @@ async function startIngress() {
1691
1692
  // eslint-disable-next-line no-console
1692
1693
  console.warn(`[ingress] root CA missing at ${CA_PATH}; skipping HTTPS bind (fakes/proxies will be HTTP-only)`);
1693
1694
  }
1694
- const Bun = requireBun();
1695
+ requireBun();
1695
1696
  // Resolve every ingress hostname to its handler into the live per-port
1696
1697
  // route tables (module scope, so runtime `tls` can extend them later).
1697
1698
  // Fakes run an in-daemon handler on their declared port; proxies
@@ -1732,13 +1733,13 @@ async function startIngress() {
1732
1733
  for (const [port, byHost] of INGRESS.routesByPort) {
1733
1734
  if (port === INGRESS_HTTPS_PORT)
1734
1735
  continue;
1735
- INGRESS_HTTP_SERVERS.set(port, bindIngressServer(Bun, port, byHost, `port ${port}`));
1736
+ await ensureHttpIngress(port, byHost);
1736
1737
  // eslint-disable-next-line no-console
1737
1738
  console.log(`[ingress] http :${port} for ${[...byHost.keys()].join(", ")}`);
1738
1739
  }
1739
1740
  // ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
1740
1741
  if (INGRESS.certByHost.size > 0)
1741
- await ensureHttpsIngress(Bun);
1742
+ await ensureHttpsIngress();
1742
1743
  // Seed the resolver's names registry: ingress hostnames (fakes, TLS
1743
1744
  // proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
1744
1745
  // wildcards. Service-targeted wildcards wait for the post-container pass
@@ -1750,17 +1751,17 @@ function requireBun() {
1750
1751
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1751
1752
  const Bun = globalThis.Bun;
1752
1753
  if (!Bun?.serve) {
1753
- throw new Error("ingress requires Bun.serve; the daemon must run under Bun (it does in-VM)");
1754
+ throw new Error("ingress requires Bun; the daemon must run under Bun (it does in-VM)");
1754
1755
  }
1755
1756
  return Bun;
1756
1757
  }
1757
1758
  /**
1758
1759
  * Bring :443 up, once.
1759
1760
  *
1760
- * Two pieces: a plaintext `Bun.serve` on an ephemeral loopback port that
1761
+ * Two pieces: a plaintext HTTP server on an ephemeral loopback port that
1761
1762
  * carries the HTTPS route table (and therefore all the routing, fake
1762
- * dispatch, interception, reverse-proxying and `server.upgrade()`
1763
- * WebSocket bridging that :80 already gets, unchanged), and the TLS
1763
+ * dispatch, interception, reverse-proxying and WebSocket
1764
+ * bridging that :80 already gets, unchanged), and the TLS
1764
1765
  * terminator in front of it on :443.
1765
1766
  *
1766
1767
  * **This is idempotent, and that is the feature.** Adding a certificate
@@ -1781,27 +1782,25 @@ function requireBun() {
1781
1782
  * container can reach (containers arrive at the bridge gateway), so the
1782
1783
  * only way in is through the terminator.
1783
1784
  */
1784
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1785
- async function ensureHttpsIngress(Bun) {
1785
+ async function ensureHttpsIngress() {
1786
1786
  if (INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT))
1787
1787
  return;
1788
1788
  if (!HTTPS_INGRESS_STARTING) {
1789
1789
  // Cleared on both outcomes: once bound, the check above short-circuits
1790
1790
  // every later caller, and a failed bind must be retryable rather than
1791
1791
  // remembered as a rejection for the life of the project.
1792
- HTTPS_INGRESS_STARTING = startHttpsIngress(Bun).finally(() => {
1792
+ HTTPS_INGRESS_STARTING = startHttpsIngress().finally(() => {
1793
1793
  HTTPS_INGRESS_STARTING = undefined;
1794
1794
  });
1795
1795
  }
1796
1796
  return HTTPS_INGRESS_STARTING;
1797
1797
  }
1798
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1799
- async function startHttpsIngress(Bun) {
1798
+ async function startHttpsIngress() {
1800
1799
  const routes = routesFor(INGRESS, INGRESS_HTTPS_PORT);
1801
1800
  // `proto` is passed rather than inferred: this listener speaks plain
1802
1801
  // HTTP, but everything reaching it arrived over TLS, so X-Forwarded-Proto
1803
1802
  // must say https.
1804
- const plain = bindIngressServer(Bun, 0, routes, `https :${INGRESS_HTTPS_PORT}`, {
1803
+ const plain = await bindIngressServer(0, routes, `https :${INGRESS_HTTPS_PORT}`, {
1805
1804
  proto: "https",
1806
1805
  hostname: "127.0.0.1",
1807
1806
  });
@@ -1884,7 +1883,7 @@ async function bindRuntimeTls(hostname, service, port, http2, cors) {
1884
1883
  if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
1885
1884
  throw new Error(`runtime tls for ${JSON.stringify(host)} requires the in-VM root CA at ${CA_PATH}`);
1886
1885
  }
1887
- const Bun = requireBun();
1886
+ requireBun();
1888
1887
  const route = { kind: "proxy", service, port, http2, cors };
1889
1888
  // Decide the cost before mutating anything: an already-covered hostname
1890
1889
  // (an exact duplicate, or one under a boot wildcard) needs only a route.
@@ -1899,9 +1898,7 @@ async function bindRuntimeTls(hostname, service, port, http2, cors) {
1899
1898
  // the route is only reachable once the hostname resolves, which is the
1900
1899
  // registry write at the end.
1901
1900
  bindRoute(INGRESS, host, route);
1902
- if (!INGRESS_HTTP_SERVERS.has(INGRESS_HTTP_PORT)) {
1903
- INGRESS_HTTP_SERVERS.set(INGRESS_HTTP_PORT, bindIngressServer(Bun, INGRESS_HTTP_PORT, routesFor(INGRESS, INGRESS_HTTP_PORT), `port ${INGRESS_HTTP_PORT}`));
1904
- }
1901
+ await ensureHttpIngress(INGRESS_HTTP_PORT, routesFor(INGRESS, INGRESS_HTTP_PORT));
1905
1902
  if (plan.needsCert) {
1906
1903
  // The terminator reads this table on every handshake, so the leaf is
1907
1904
  // live the moment it lands — no listener is touched, and no connection
@@ -1909,7 +1906,7 @@ async function bindRuntimeTls(hostname, service, port, http2, cors) {
1909
1906
  INGRESS.certByHost.set(host, await generateHostCert(host, [host]));
1910
1907
  }
1911
1908
  if (plan.needsHttpsListener)
1912
- await ensureHttpsIngress(Bun);
1909
+ await ensureHttpsIngress();
1913
1910
  // Resolve the hostname to the daemon gateway (where :443/:80 listen).
1914
1911
  // A wildcard can only live in the resolver's suffix table.
1915
1912
  const gw = await bridgeGatewayIp();
@@ -1946,57 +1943,27 @@ async function unbindRuntimeTls(hostname) {
1946
1943
  await writeRegistry();
1947
1944
  }
1948
1945
  }
1949
- /**
1950
- * Spin up one Bun.serve listener bound to (port, optional TLS) that
1951
- * dispatches every request to the matching Route by Host header.
1952
- *
1953
- * Shared by the HTTP and HTTPS branches. Also carries the `websocket`
1954
- * handler set so reverse-proxy targets can transparently bridge WS
1955
- * upgrades through to their upstream service.
1956
- */
1957
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1958
- function bindIngressServer(
1959
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1960
- Bun, port, byHost, listenerLabel,
1961
- // Every ingress listener speaks plain HTTP now — the one behind :443
1962
- // sits under the TLS terminator. So the scheme a client actually used
1963
- // can no longer be inferred from the socket and is declared instead; it
1964
- // stamps X-Forwarded-Proto, which upstreams that build absolute URLs or
1965
- // redirect depend on.
1966
- serve = { proto: "http" }) {
1967
- const proto = serve.proto;
1968
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1969
- const opts = {
1946
+ /** Concurrent runtime routes must share the asynchronous first bind. */
1947
+ async function ensureHttpIngress(port, byHost) {
1948
+ if (INGRESS_HTTP_SERVERS.has(port))
1949
+ return;
1950
+ let starting = INGRESS_HTTP_STARTING.get(port);
1951
+ if (!starting) {
1952
+ starting = bindIngressServer(port, byHost, `port ${port}`).then(server => {
1953
+ INGRESS_HTTP_SERVERS.set(port, server);
1954
+ }).finally(() => INGRESS_HTTP_STARTING.delete(port));
1955
+ INGRESS_HTTP_STARTING.set(port, starting);
1956
+ }
1957
+ await starting;
1958
+ }
1959
+ /** Bind the shared HTTP/1 ingress handler, including the listener behind TLS. */
1960
+ async function bindIngressServer(port, byHost, listenerLabel, serve = { proto: "http" }) {
1961
+ return startIngressServer({
1970
1962
  port,
1971
1963
  hostname: serve.hostname ?? "0.0.0.0",
1972
- // SO_REUSEPORT on every ingress listener. It was introduced so a
1973
- // replacement :443 could overlap the original during a certificate
1974
- // swap; the terminator removed that swap, and this stays for the
1975
- // remaining case — the `/load` teardown force-closes and rebinds, and
1976
- // a socket the kernel has not finished releasing would otherwise fail
1977
- // the next bind with "Is port 443 in use?". Note the flip side, which
1978
- // {@link stopIngressServers} depends on: a half-alive listener shares
1979
- // the port silently instead of colliding loudly.
1980
1964
  reusePort: true,
1981
- // OCI layer uploads can exceed Bun's default 128 MiB request limit.
1982
- // Large proxy bodies stream through; this limit does not allocate a buffer.
1983
- maxRequestBodySize: 1024 ** 4,
1984
- // Bun.serve defaults to a 10s idleTimeout, which kills any proxied
1985
- // request whose upstream takes >10s to produce bytes — under parallel
1986
- // test load that surfaced as "fetch failed"/"other side closed" on
1987
- // deploy-archive uploads and ERR_EMPTY_RESPONSE in browser tests
1988
- // ([Bun.serve]: request timed out after 10 seconds). Ingress fronts
1989
- // arbitrarily slow app endpoints (deploys can legitimately take
1990
- // minutes), so disable the idle timeout entirely; forked test VMs are
1991
- // short-lived, leaked-connection risk is bounded by the fork.
1992
- idleTimeout: 0,
1993
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1994
- fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, proto),
1995
- // The frame relay for bridged WebSocket upgrades; `proxyToService`
1996
- // opens the upstream before it upgrades the client (harness/ws-bridge.ts).
1997
- websocket: WS_BRIDGE_HANDLERS,
1998
- };
1999
- return Bun.serve(opts);
1965
+ fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, serve.proto),
1966
+ });
2000
1967
  }
2001
1968
  /**
2002
1969
  * Resolve a Host header to a route: an exact entry first, then the longest
@@ -2128,7 +2095,7 @@ server, service, port, listenerLabel, proto) {
2128
2095
  headers: selected ? { "sec-websocket-protocol": selected } : undefined,
2129
2096
  });
2130
2097
  if (ok) {
2131
- // Bun has already taken over the response — return a stub.
2098
+ // The WebSocket has already taken over the response — return a stub.
2132
2099
  return new Response(null, { status: 101 });
2133
2100
  }
2134
2101
  try {
@@ -2189,6 +2156,7 @@ server, service, port, listenerLabel, proto) {
2189
2156
  headers: fwdHeaders,
2190
2157
  body: hasBody ? (bufferedBody ?? req.body) : undefined,
2191
2158
  redirect: "manual",
2159
+ signal: req.signal,
2192
2160
  });
2193
2161
  // decompress:false → forward the encoded body untouched (see fn doc).
2194
2162
  // keepalive:false → fresh connection per request (see fwdHeaders above).
@@ -2198,7 +2166,7 @@ server, service, port, listenerLabel, proto) {
2198
2166
  keepalive: false,
2199
2167
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2200
2168
  });
2201
- // Strip hop-by-hop response headers; let Bun set content-length / TE.
2169
+ // Strip connection framing while preserving the unchanged body's length.
2202
2170
  const respHeaders = new Headers();
2203
2171
  for (const [k, v] of upstreamRes.headers) {
2204
2172
  if (HOP_BY_HOP_HEADERS.has(k.toLowerCase()))
@@ -0,0 +1,26 @@
1
+ import { type WsBridgeData } from "./ws-bridge.js";
2
+ export interface IngressServer {
3
+ port: number;
4
+ stop(force?: boolean): void;
5
+ requestIP(req: Request): {
6
+ address: string;
7
+ port: number;
8
+ } | undefined;
9
+ upgrade(req: Request, options: {
10
+ data: WsBridgeData;
11
+ headers?: Record<string, string>;
12
+ }): boolean;
13
+ }
14
+ /**
15
+ * Serve the ingress's Fetch handlers through node:http. Bun.serve discards
16
+ * Content-Length on a still-streaming Response, even when its bytes are
17
+ * unchanged. ServerResponse preserves the declared length while pipeline
18
+ * propagates backpressure and cancellation without buffering the whole body.
19
+ * This is also the HTTP/1 listener behind the TLS terminator.
20
+ */
21
+ export declare function startIngressServer(options: {
22
+ port: number;
23
+ hostname?: string;
24
+ reusePort?: boolean;
25
+ fetch(req: Request, server: IngressServer): Response | undefined | Promise<Response | undefined>;
26
+ }): Promise<IngressServer>;
@@ -0,0 +1,166 @@
1
+ import http from "node:http";
2
+ import { Readable } from "node:stream";
3
+ import { pipeline } from "node:stream/promises";
4
+ import { createRequire } from "node:module";
5
+ import { dirname, join } from "node:path";
6
+ import { WS_BRIDGE_HANDLERS } from "./ws-bridge.js";
7
+ // Load the installed implementation, not Bun's built-in `ws` replacement.
8
+ // On Bun 1.4.0 that replacement cannot upgrade node:http sockets after the
9
+ // asynchronous upstream handshake. The package uses the ordinary socket API.
10
+ const require = createRequire(import.meta.url);
11
+ const { WebSocketServer } = require(join(dirname(require.resolve("ws/package.json")), "index.js"));
12
+ /**
13
+ * Serve the ingress's Fetch handlers through node:http. Bun.serve discards
14
+ * Content-Length on a still-streaming Response, even when its bytes are
15
+ * unchanged. ServerResponse preserves the declared length while pipeline
16
+ * propagates backpressure and cancellation without buffering the whole body.
17
+ * This is also the HTTP/1 listener behind the TLS terminator.
18
+ */
19
+ export async function startIngressServer(options) {
20
+ const peers = new WeakMap();
21
+ const upgrades = new WeakMap();
22
+ const protocols = new WeakMap();
23
+ const sockets = new Set();
24
+ const websockets = new WebSocketServer({
25
+ noServer: true,
26
+ // The upstream handshake has already selected a protocol. Never select
27
+ // the client's first offer on its behalf.
28
+ handleProtocols: (_offered, req) => protocols.get(req) || false,
29
+ });
30
+ const server = http.createServer({ requestTimeout: 0 }, (req, res) => {
31
+ void dispatch(req, res);
32
+ });
33
+ server.on("connection", socket => {
34
+ sockets.add(socket);
35
+ socket.once("close", () => sockets.delete(socket));
36
+ });
37
+ server.on("upgrade", (req, socket, head) => {
38
+ void dispatch(req, undefined, { req, socket: socket, head, upgraded: false });
39
+ });
40
+ const facade = {
41
+ port: 0,
42
+ stop(force = false) {
43
+ if (force) {
44
+ for (const ws of websockets.clients)
45
+ ws.terminate();
46
+ for (const socket of sockets)
47
+ socket.destroy();
48
+ server.closeAllConnections();
49
+ }
50
+ websockets.close();
51
+ server.close();
52
+ },
53
+ requestIP(req) {
54
+ const socket = peers.get(req);
55
+ return socket?.remoteAddress && socket.remotePort
56
+ ? { address: socket.remoteAddress, port: socket.remotePort } : undefined;
57
+ },
58
+ upgrade(req, opts) {
59
+ const pending = upgrades.get(req);
60
+ if (!pending || pending.upgraded || pending.socket.destroyed)
61
+ return false;
62
+ protocols.set(pending.req, opts.headers?.["sec-websocket-protocol"]);
63
+ websockets.handleUpgrade(pending.req, pending.socket, pending.head, ws => {
64
+ pending.upgraded = true;
65
+ const client = {
66
+ data: opts.data,
67
+ send: (data) => ws.send(data),
68
+ close: (code, reason) => ws.close(code, reason),
69
+ };
70
+ ws.on("message", (data, binary) => WS_BRIDGE_HANDLERS.message(client, binary ? Buffer.from(data) : data.toString()));
71
+ ws.on("close", (code, reason) => WS_BRIDGE_HANDLERS.close(client, code, reason.toString()));
72
+ ws.on("error", () => opts.data.upstream.close());
73
+ WS_BRIDGE_HANDLERS.open(client);
74
+ });
75
+ return pending.upgraded;
76
+ },
77
+ };
78
+ async function dispatch(incoming, outgoing, upgrade) {
79
+ const abort = new AbortController();
80
+ const onClose = () => {
81
+ if (!outgoing?.writableFinished && !upgrade?.upgraded)
82
+ abort.abort();
83
+ };
84
+ const connection = outgoing ?? upgrade.socket;
85
+ connection.once("close", onClose);
86
+ const responseWriter = () => {
87
+ if (!outgoing) {
88
+ // Only attach an HTTP response after a refused upgrade. Attaching
89
+ // one before handleUpgrade conflicts with Bun's native handshake.
90
+ outgoing = new http.ServerResponse(incoming);
91
+ outgoing.assignSocket(upgrade.socket);
92
+ outgoing.once("finish", () => upgrade.socket.end());
93
+ }
94
+ return outgoing;
95
+ };
96
+ let response;
97
+ try {
98
+ const headers = new Headers();
99
+ for (let i = 0; i < incoming.rawHeaders.length; i += 2) {
100
+ headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
101
+ }
102
+ const method = incoming.method ?? "GET";
103
+ const req = new Request(new URL(incoming.url ?? "/", `http://${headers.get("host") ?? "localhost"}`), {
104
+ method, headers, signal: abort.signal,
105
+ body: method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(incoming),
106
+ duplex: "half",
107
+ });
108
+ peers.set(req, incoming.socket);
109
+ if (upgrade)
110
+ upgrades.set(req, upgrade);
111
+ response = await options.fetch(req, facade);
112
+ if (upgrade?.upgraded)
113
+ return;
114
+ if (!response)
115
+ throw new Error("Ingress handler returned no response");
116
+ if (abort.signal.aborted) {
117
+ await response.body?.cancel();
118
+ return;
119
+ }
120
+ const writer = responseWriter();
121
+ writer.statusCode = response.status;
122
+ writer.statusMessage = response.statusText;
123
+ response.headers.forEach((value, key) => {
124
+ if (key !== "set-cookie")
125
+ writer.setHeader(key, value);
126
+ });
127
+ const cookies = response.headers.getSetCookie();
128
+ if (cookies.length)
129
+ writer.setHeader("set-cookie", cookies);
130
+ // Flush even for a body already buffered by fetch. An upstream with
131
+ // no length must not acquire an invented Content-Length from end().
132
+ writer.flushHeaders();
133
+ if (method === "HEAD" || response.status === 204 || response.status === 304 || !response.body) {
134
+ await response.body?.cancel();
135
+ writer.end();
136
+ }
137
+ else {
138
+ await pipeline(Readable.fromWeb(response.body), writer);
139
+ }
140
+ }
141
+ catch (error) {
142
+ if (upgrade?.upgraded)
143
+ return;
144
+ outgoing = responseWriter();
145
+ if (!outgoing.headersSent && !outgoing.destroyed) {
146
+ outgoing.writeHead(500, { "content-type": "text/plain" });
147
+ outgoing.end("spectest-daemon: ingress response failed\n");
148
+ }
149
+ else {
150
+ outgoing.destroy(error instanceof Error ? error : new Error(String(error)));
151
+ }
152
+ }
153
+ finally {
154
+ connection.off("close", onClose);
155
+ }
156
+ }
157
+ await new Promise((resolve, reject) => {
158
+ server.once("error", reject);
159
+ server.listen({ port: options.port, host: options.hostname ?? "0.0.0.0", reusePort: options.reusePort }, () => {
160
+ server.off("error", reject);
161
+ resolve();
162
+ });
163
+ });
164
+ facade.port = server.address().port;
165
+ return facade;
166
+ }
@@ -33,15 +33,11 @@
33
33
  * ## The shape, and why it is a byte pipe
34
34
  *
35
35
  * The terminator does TLS and *nothing else*: it accepts on :443, picks a
36
- * leaf by SNI, and pipes the decrypted stream to a plaintext `Bun.serve`
37
- * bound on loopback. Opted-in HTTP/2 routes select a separate stream router
38
- * by ALPN; HTTP/1.1 and WebSockets retain the existing listener. Everything downstream — routing, fakes,
39
- * interceptors, reverse-proxying, and `server.upgrade()` WebSocket
40
- * bridging — is the same `Bun.serve` code that already serves :80, run
41
- * unchanged. Terminating HTTP here instead (`node:https`, `(req, res)`)
42
- * would have meant reimplementing the WebSocket bridge against a
43
- * different API, which is exactly the kind of trade this change exists to
44
- * avoid: one flake removed, another introduced somewhere subtler.
36
+ * leaf by SNI, and pipes the decrypted stream to the plaintext ingress
37
+ * server bound on loopback. Opted-in HTTP/2 routes select a separate stream
38
+ * router by ALPN; HTTP/1.1 and WebSockets use the shared ingress listener.
39
+ * Routing, fakes, interceptors and reverse-proxying stay in that listener;
40
+ * this layer never parses or rewrites HTTP response framing.
45
41
  *
46
42
  * Measured cost of the extra hop on this host (Bun 1.4.0, undici client,
47
43
  * 50 MB bodies): ~11 % fewer small requests per second, download
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.101.0",
3
+ "version": "0.102.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -56,11 +56,13 @@
56
56
  "@kubernetes/client-node": "^1.0.0",
57
57
  "@xterm/headless": "^5.5.0",
58
58
  "dns-packet": "^5.6.1",
59
- "playwright-core": "^1.61.1"
59
+ "playwright-core": "^1.61.1",
60
+ "ws": "^8.20.1"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@types/dns-packet": "^5.6.5",
63
64
  "@types/bun": "^1.3.0",
65
+ "@types/ws": "^8",
64
66
  "typescript": "^5.6.3"
65
67
  },
66
68
  "spectestCapabilities": [
package/src/daemon.ts CHANGED
@@ -131,7 +131,6 @@ import {
131
131
  upstreamRequestHeaders,
132
132
  } from "./harness/http-proxy.js";
133
133
  import {
134
- WS_BRIDGE_HANDLERS,
135
134
  openUpstreamWebSocket,
136
135
  parseSubprotocols,
137
136
  upstreamHandshakeHeaders,
@@ -157,6 +156,7 @@ import {
157
156
  unbindRoute,
158
157
  type Route as IngressRoute,
159
158
  } from "./harness/ingress-table.js";
159
+ import { startIngressServer, type IngressServer } from "./harness/ingress-server.js";
160
160
  import { startHttp2Proxy } from "./harness/http2-proxy.js";
161
161
  import { startTlsTerminator, type TlsTerminator } from "./harness/tls-terminator.js";
162
162
  import { runContainerArgs } from "./harness/container-run.js";
@@ -1920,12 +1920,11 @@ let LOWERED: LoweredIngress = {
1920
1920
  aliasesByService: {},
1921
1921
  wildcards: [],
1922
1922
  };
1923
- /** Running HTTP servers per port (Bun.Server). Rebuilt on /load. */
1924
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1925
- const INGRESS_HTTP_SERVERS = new Map<number, any>();
1923
+ /** Running HTTP servers per port. Rebuilt on /load. */
1924
+ const INGRESS_HTTP_SERVERS = new Map<number, IngressServer>();
1925
+ const INGRESS_HTTP_STARTING = new Map<number, Promise<void>>();
1926
1926
  /** Running HTTPS servers per port (currently always {INGRESS_HTTPS_PORT}). */
1927
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1928
- const INGRESS_HTTPS_SERVERS = new Map<number, any>();
1927
+ const INGRESS_HTTPS_SERVERS = new Map<number, IngressServer>();
1929
1928
  /**
1930
1929
  * The :443 TLS terminator, once per harness process.
1931
1930
  *
@@ -1989,6 +1988,7 @@ function stopIngressServers(): void {
1989
1988
  }
1990
1989
  }
1991
1990
  INGRESS_HTTP_SERVERS.clear();
1991
+ INGRESS_HTTP_STARTING.clear();
1992
1992
  for (const [port, srv] of INGRESS_HTTPS_SERVERS) {
1993
1993
  try {
1994
1994
  srv.stop?.(true);
@@ -2129,7 +2129,7 @@ async function startIngress(): Promise<void> {
2129
2129
  );
2130
2130
  }
2131
2131
 
2132
- const Bun = requireBun();
2132
+ requireBun();
2133
2133
 
2134
2134
  // Resolve every ingress hostname to its handler into the live per-port
2135
2135
  // route tables (module scope, so runtime `tls` can extend them later).
@@ -2168,12 +2168,12 @@ async function startIngress(): Promise<void> {
2168
2168
  // ── HTTP listeners (one per non-443 port).
2169
2169
  for (const [port, byHost] of INGRESS.routesByPort) {
2170
2170
  if (port === INGRESS_HTTPS_PORT) continue;
2171
- INGRESS_HTTP_SERVERS.set(port, bindIngressServer(Bun, port, byHost, `port ${port}`));
2171
+ await ensureHttpIngress(port, byHost);
2172
2172
  // eslint-disable-next-line no-console
2173
2173
  console.log(`[ingress] http :${port} for ${[...byHost.keys()].join(", ")}`);
2174
2174
  }
2175
2175
  // ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
2176
- if (INGRESS.certByHost.size > 0) await ensureHttpsIngress(Bun);
2176
+ if (INGRESS.certByHost.size > 0) await ensureHttpsIngress();
2177
2177
 
2178
2178
  // Seed the resolver's names registry: ingress hostnames (fakes, TLS
2179
2179
  // proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
@@ -2188,7 +2188,7 @@ function requireBun(): any {
2188
2188
  const Bun = (globalThis as any).Bun;
2189
2189
  if (!Bun?.serve) {
2190
2190
  throw new Error(
2191
- "ingress requires Bun.serve; the daemon must run under Bun (it does in-VM)",
2191
+ "ingress requires Bun; the daemon must run under Bun (it does in-VM)",
2192
2192
  );
2193
2193
  }
2194
2194
  return Bun;
@@ -2198,10 +2198,10 @@ function requireBun(): any {
2198
2198
  /**
2199
2199
  * Bring :443 up, once.
2200
2200
  *
2201
- * Two pieces: a plaintext `Bun.serve` on an ephemeral loopback port that
2201
+ * Two pieces: a plaintext HTTP server on an ephemeral loopback port that
2202
2202
  * carries the HTTPS route table (and therefore all the routing, fake
2203
- * dispatch, interception, reverse-proxying and `server.upgrade()`
2204
- * WebSocket bridging that :80 already gets, unchanged), and the TLS
2203
+ * dispatch, interception, reverse-proxying and WebSocket
2204
+ * bridging that :80 already gets, unchanged), and the TLS
2205
2205
  * terminator in front of it on :443.
2206
2206
  *
2207
2207
  * **This is idempotent, and that is the feature.** Adding a certificate
@@ -2222,27 +2222,25 @@ function requireBun(): any {
2222
2222
  * container can reach (containers arrive at the bridge gateway), so the
2223
2223
  * only way in is through the terminator.
2224
2224
  */
2225
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2226
- async function ensureHttpsIngress(Bun: any): Promise<void> {
2225
+ async function ensureHttpsIngress(): Promise<void> {
2227
2226
  if (INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT)) return;
2228
2227
  if (!HTTPS_INGRESS_STARTING) {
2229
2228
  // Cleared on both outcomes: once bound, the check above short-circuits
2230
2229
  // every later caller, and a failed bind must be retryable rather than
2231
2230
  // remembered as a rejection for the life of the project.
2232
- HTTPS_INGRESS_STARTING = startHttpsIngress(Bun).finally(() => {
2231
+ HTTPS_INGRESS_STARTING = startHttpsIngress().finally(() => {
2233
2232
  HTTPS_INGRESS_STARTING = undefined;
2234
2233
  });
2235
2234
  }
2236
2235
  return HTTPS_INGRESS_STARTING;
2237
2236
  }
2238
2237
 
2239
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2240
- async function startHttpsIngress(Bun: any): Promise<void> {
2238
+ async function startHttpsIngress(): Promise<void> {
2241
2239
  const routes = routesFor(INGRESS, INGRESS_HTTPS_PORT);
2242
2240
  // `proto` is passed rather than inferred: this listener speaks plain
2243
2241
  // HTTP, but everything reaching it arrived over TLS, so X-Forwarded-Proto
2244
2242
  // must say https.
2245
- const plain = bindIngressServer(Bun, 0, routes, `https :${INGRESS_HTTPS_PORT}`, {
2243
+ const plain = await bindIngressServer(0, routes, `https :${INGRESS_HTTPS_PORT}`, {
2246
2244
  proto: "https",
2247
2245
  hostname: "127.0.0.1",
2248
2246
  });
@@ -2327,7 +2325,7 @@ async function bindRuntimeTls(hostname: string, service: string, port: number, h
2327
2325
  `runtime tls for ${JSON.stringify(host)} requires the in-VM root CA at ${CA_PATH}`,
2328
2326
  );
2329
2327
  }
2330
- const Bun = requireBun();
2328
+ requireBun();
2331
2329
  const route: Route = { kind: "proxy", service, port, http2, cors };
2332
2330
 
2333
2331
  // Decide the cost before mutating anything: an already-covered hostname
@@ -2344,24 +2342,14 @@ async function bindRuntimeTls(hostname: string, service: string, port: number, h
2344
2342
  // the route is only reachable once the hostname resolves, which is the
2345
2343
  // registry write at the end.
2346
2344
  bindRoute(INGRESS, host, route);
2347
- if (!INGRESS_HTTP_SERVERS.has(INGRESS_HTTP_PORT)) {
2348
- INGRESS_HTTP_SERVERS.set(
2349
- INGRESS_HTTP_PORT,
2350
- bindIngressServer(
2351
- Bun,
2352
- INGRESS_HTTP_PORT,
2353
- routesFor(INGRESS, INGRESS_HTTP_PORT),
2354
- `port ${INGRESS_HTTP_PORT}`,
2355
- ),
2356
- );
2357
- }
2345
+ await ensureHttpIngress(INGRESS_HTTP_PORT, routesFor(INGRESS, INGRESS_HTTP_PORT));
2358
2346
  if (plan.needsCert) {
2359
2347
  // The terminator reads this table on every handshake, so the leaf is
2360
2348
  // live the moment it lands — no listener is touched, and no connection
2361
2349
  // already on :443 notices.
2362
2350
  INGRESS.certByHost.set(host, await generateHostCert(host, [host]));
2363
2351
  }
2364
- if (plan.needsHttpsListener) await ensureHttpsIngress(Bun);
2352
+ if (plan.needsHttpsListener) await ensureHttpsIngress();
2365
2353
 
2366
2354
  // Resolve the hostname to the daemon gateway (where :443/:80 listen).
2367
2355
  // A wildcard can only live in the resolver's suffix table.
@@ -2398,63 +2386,32 @@ async function unbindRuntimeTls(hostname: string): Promise<void> {
2398
2386
  }
2399
2387
  }
2400
2388
 
2401
- /**
2402
- * Spin up one Bun.serve listener bound to (port, optional TLS) that
2403
- * dispatches every request to the matching Route by Host header.
2404
- *
2405
- * Shared by the HTTP and HTTPS branches. Also carries the `websocket`
2406
- * handler set so reverse-proxy targets can transparently bridge WS
2407
- * upgrades through to their upstream service.
2408
- */
2409
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2410
- function bindIngressServer(
2411
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2412
- Bun: any,
2389
+ /** Concurrent runtime routes must share the asynchronous first bind. */
2390
+ async function ensureHttpIngress(port: number, byHost: Map<string, Route>): Promise<void> {
2391
+ if (INGRESS_HTTP_SERVERS.has(port)) return;
2392
+ let starting = INGRESS_HTTP_STARTING.get(port);
2393
+ if (!starting) {
2394
+ starting = bindIngressServer(port, byHost, `port ${port}`).then(server => {
2395
+ INGRESS_HTTP_SERVERS.set(port, server);
2396
+ }).finally(() => INGRESS_HTTP_STARTING.delete(port));
2397
+ INGRESS_HTTP_STARTING.set(port, starting);
2398
+ }
2399
+ await starting;
2400
+ }
2401
+
2402
+ /** Bind the shared HTTP/1 ingress handler, including the listener behind TLS. */
2403
+ async function bindIngressServer(
2413
2404
  port: number,
2414
2405
  byHost: Map<string, Route>,
2415
2406
  listenerLabel: string,
2416
- // Every ingress listener speaks plain HTTP now — the one behind :443
2417
- // sits under the TLS terminator. So the scheme a client actually used
2418
- // can no longer be inferred from the socket and is declared instead; it
2419
- // stamps X-Forwarded-Proto, which upstreams that build absolute URLs or
2420
- // redirect depend on.
2421
2407
  serve: { proto: "http" | "https"; hostname?: string } = { proto: "http" },
2422
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2423
- ): any {
2424
- const proto = serve.proto;
2425
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2426
- const opts: Record<string, any> = {
2408
+ ): Promise<IngressServer> {
2409
+ return startIngressServer({
2427
2410
  port,
2428
2411
  hostname: serve.hostname ?? "0.0.0.0",
2429
- // SO_REUSEPORT on every ingress listener. It was introduced so a
2430
- // replacement :443 could overlap the original during a certificate
2431
- // swap; the terminator removed that swap, and this stays for the
2432
- // remaining case — the `/load` teardown force-closes and rebinds, and
2433
- // a socket the kernel has not finished releasing would otherwise fail
2434
- // the next bind with "Is port 443 in use?". Note the flip side, which
2435
- // {@link stopIngressServers} depends on: a half-alive listener shares
2436
- // the port silently instead of colliding loudly.
2437
2412
  reusePort: true,
2438
- // OCI layer uploads can exceed Bun's default 128 MiB request limit.
2439
- // Large proxy bodies stream through; this limit does not allocate a buffer.
2440
- maxRequestBodySize: 1024 ** 4,
2441
- // Bun.serve defaults to a 10s idleTimeout, which kills any proxied
2442
- // request whose upstream takes >10s to produce bytes — under parallel
2443
- // test load that surfaced as "fetch failed"/"other side closed" on
2444
- // deploy-archive uploads and ERR_EMPTY_RESPONSE in browser tests
2445
- // ([Bun.serve]: request timed out after 10 seconds). Ingress fronts
2446
- // arbitrarily slow app endpoints (deploys can legitimately take
2447
- // minutes), so disable the idle timeout entirely; forked test VMs are
2448
- // short-lived, leaked-connection risk is bounded by the fork.
2449
- idleTimeout: 0,
2450
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2451
- fetch: (req: Request, server: any): Response | Promise<Response> =>
2452
- dispatchIngress(req, server, byHost, listenerLabel, proto),
2453
- // The frame relay for bridged WebSocket upgrades; `proxyToService`
2454
- // opens the upstream before it upgrades the client (harness/ws-bridge.ts).
2455
- websocket: WS_BRIDGE_HANDLERS,
2456
- };
2457
- return Bun.serve(opts);
2413
+ fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, serve.proto),
2414
+ });
2458
2415
  }
2459
2416
 
2460
2417
  /**
@@ -2610,7 +2567,7 @@ async function proxyToService(
2610
2567
  headers: selected ? { "sec-websocket-protocol": selected } : undefined,
2611
2568
  });
2612
2569
  if (ok) {
2613
- // Bun has already taken over the response — return a stub.
2570
+ // The WebSocket has already taken over the response — return a stub.
2614
2571
  return new Response(null, { status: 101 });
2615
2572
  }
2616
2573
  try {
@@ -2676,6 +2633,7 @@ async function proxyToService(
2676
2633
  headers: fwdHeaders,
2677
2634
  body: hasBody ? (bufferedBody ?? req.body) : undefined,
2678
2635
  redirect: "manual",
2636
+ signal: req.signal,
2679
2637
  });
2680
2638
  // decompress:false → forward the encoded body untouched (see fn doc).
2681
2639
  // keepalive:false → fresh connection per request (see fwdHeaders above).
@@ -2685,7 +2643,7 @@ async function proxyToService(
2685
2643
  keepalive: false,
2686
2644
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2687
2645
  } as any);
2688
- // Strip hop-by-hop response headers; let Bun set content-length / TE.
2646
+ // Strip connection framing while preserving the unchanged body's length.
2689
2647
  const respHeaders = new Headers();
2690
2648
  for (const [k, v] of upstreamRes.headers) {
2691
2649
  if (HOP_BY_HOP_HEADERS.has(k.toLowerCase())) continue;
@@ -3,6 +3,7 @@ import { defineEnvironment, defineFake, lowerIngress, provides, proxy, type Cors
3
3
  import { supabase } from "../components/supabase.js";
4
4
  import { withIngressCors, upstreamRequestHeaders } from "./http-proxy.js";
5
5
  import { InterceptRegistry, runChain } from "./intercept.js";
6
+ import { startIngressServer } from "./ingress-server.js";
6
7
 
7
8
  const allowed = "https://app.tangent.test";
8
9
  const denied = "https://untrusted.test";
@@ -56,12 +57,12 @@ for (const kind of ["fake", "service"] as const) {
56
57
  method: req.method, headers: upstreamRequestHeaders(req.headers, { proto: "https" }),
57
58
  body: req.body, redirect: "manual",
58
59
  });
59
- const ingress = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: req =>
60
+ const ingress = await startIngressServer({ port: 0, hostname: "127.0.0.1", fetch: req =>
60
61
  withIngressCors(req, mode, current => runChain(
61
62
  interceptors.chainFor("bucket.test", new URL(current.url).pathname), current, forward)),
62
63
  });
63
64
  try {
64
- const send = (path: string, init: RequestInit) => fetch(new URL(path, ingress.url), init);
65
+ const send = (path: string, init: RequestInit) => fetch(new URL(path, `http://127.0.0.1:${ingress.port}`), init);
65
66
  const ok = await send("/probe", preflight(allowed));
66
67
  expect(ok.status).toBe(204);
67
68
  expect(ok.headers.get("access-control-allow-origin")).toBe(allowed);
@@ -0,0 +1,250 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import http from "node:http";
3
+ import https from "node:https";
4
+ import { once } from "node:events";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { gzipSync } from "node:zlib";
7
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { execFileSync } from "node:child_process";
11
+ import { forwardableHeaders, withIngressCors } from "./http-proxy";
12
+ import { startIngressServer, type IngressServer } from "./ingress-server";
13
+ import { startTlsTerminator, type TlsTerminator } from "./tls-terminator";
14
+
15
+ const object = randomBytes(65536);
16
+ const compressed = gzipSync(object);
17
+ const digest = (body: Uint8Array) => createHash("sha256").update(body).digest("hex");
18
+ let upstream: http.Server;
19
+ let upstreamPort: number;
20
+ let ingress: IngressServer;
21
+ let tls: TlsTerminator;
22
+ let dir: string;
23
+ let release: (() => void) | undefined;
24
+ let upstreamClosed: Promise<unknown>;
25
+
26
+ beforeAll(async () => {
27
+ upstream = http.createServer((req, res) => {
28
+ const path = req.url!;
29
+ let body = path === "/8960" ? object.subarray(0, 8960) : path === "/gzip" ? compressed : object;
30
+ res.setHeader("content-type", "application/octet-stream");
31
+ res.setHeader("etag", '"original"');
32
+ res.setHeader("set-cookie", ["a=1", "b=2"]);
33
+ if (path === "/empty") body = Buffer.alloc(0);
34
+ if (path === "/gzip") res.setHeader("content-encoding", "gzip");
35
+ if (req.headers.range) {
36
+ body = body.subarray(512, 8704);
37
+ res.statusCode = 206;
38
+ res.setHeader("content-range", `bytes 512-8703/${object.length}`);
39
+ }
40
+ if (path !== "/unknown" && path !== "/unknown-small") res.setHeader("content-length", body.length);
41
+ if (path === "/large") res.setHeader("content-length", 1024 ** 3);
42
+ if (path === "/not-modified") res.statusCode = 304;
43
+ if (path === "/no-content") { res.statusCode = 204; res.removeHeader("content-length"); }
44
+ if (req.method === "HEAD" || [204, 304].includes(res.statusCode) || body.length === 0) { res.end(); return; }
45
+ if (path === "/unknown-small") { res.write("small"); res.end(); return; }
46
+ // Force multiple reads by sending the tail after headers + first chunk.
47
+ res.write(body.subarray(0, 1024));
48
+ if (path === "/gated" || path === "/large" || path === "/unknown") {
49
+ upstreamClosed = once(res, "close");
50
+ release = () => res.end(body.subarray(1024));
51
+ } else {
52
+ setTimeout(() => res.end(body.subarray(1024)), 5);
53
+ }
54
+ });
55
+ upstream.listen(0, "127.0.0.1");
56
+ await once(upstream, "listening");
57
+ upstreamPort = (upstream.address() as { port: number }).port;
58
+ ingress = await startIngressServer({
59
+ port: 0, hostname: "127.0.0.1",
60
+ fetch: req => withIngressCors(req, "permissive", async current => {
61
+ const res = await fetch(`http://127.0.0.1:${upstreamPort}${new URL(current.url).pathname}`, {
62
+ method: current.method, headers: current.headers, signal: current.signal,
63
+ decompress: false, keepalive: false,
64
+ });
65
+ return new Response(res.body, {
66
+ status: res.status, statusText: res.statusText, headers: forwardableHeaders(res.headers),
67
+ });
68
+ }),
69
+ });
70
+ dir = mkdtempSync(join(tmpdir(), "spectest-length-"));
71
+ execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2",
72
+ "-subj", "/CN=bucket.test", "-keyout", join(dir, "key.pem"), "-out", join(dir, "cert.pem")], { stdio: "ignore" });
73
+ const leaf = { key: readFileSync(join(dir, "key.pem"), "utf8"), cert: readFileSync(join(dir, "cert.pem"), "utf8") };
74
+ tls = await startTlsTerminator({ port: 0, hostname: "127.0.0.1", upstreamPort: ingress.port, certFor: () => leaf });
75
+ });
76
+
77
+ afterAll(() => {
78
+ release?.();
79
+ tls?.close();
80
+ ingress?.stop(true);
81
+ upstream?.closeAllConnections();
82
+ upstream?.close();
83
+ if (dir) rmSync(dir, { recursive: true, force: true });
84
+ });
85
+
86
+ function request(path: string, options: { boundary?: "storage" | "http" | "https"; method?: string; headers?: Record<string, string> } = {}) {
87
+ const boundary = options.boundary ?? "https";
88
+ return new Promise<http.IncomingMessage>((resolve, reject) => {
89
+ const req = (boundary === "https" ? https : http).request({
90
+ host: "127.0.0.1", port: boundary === "https" ? tls.port : boundary === "http" ? ingress.port : upstreamPort,
91
+ path, method: options.method ?? "GET", headers: { host: "bucket.test", ...options.headers },
92
+ agent: false, rejectUnauthorized: false,
93
+ }, resolve);
94
+ req.once("error", reject);
95
+ req.setTimeout(2000, () => req.destroy(new Error("ingress request timed out")));
96
+ req.end();
97
+ });
98
+ }
99
+
100
+ async function bytes(res: http.IncomingMessage) {
101
+ const chunks: Buffer[] = [];
102
+ for await (const chunk of res) chunks.push(Buffer.from(chunk));
103
+ return Buffer.concat(chunks);
104
+ }
105
+
106
+ describe("ingress response framing", () => {
107
+ for (const boundary of ["storage", "http", "https"] as const) {
108
+ test(`${boundary}: repeated multi-chunk GETs keep length and digest`, async () => {
109
+ for (const size of [8960, 65536]) {
110
+ for (let n = 0; n < 20; n++) {
111
+ const res = await request(`/${size}`, { boundary });
112
+ const body = await bytes(res);
113
+ expect(res.statusCode).toBe(200);
114
+ expect(res.headers["content-length"]).toBe(String(size));
115
+ expect(res.headers["transfer-encoding"]).toBeUndefined();
116
+ expect(body.length).toBe(size);
117
+ expect(digest(body)).toBe(digest(object.subarray(0, size)));
118
+ }
119
+ }
120
+ });
121
+ }
122
+
123
+ test("HEAD, empty objects, ranges, cookies and CORS preserve their metadata", async () => {
124
+ const head = await request("/65536", { method: "HEAD" });
125
+ expect(head.headers["content-length"]).toBe("65536");
126
+ expect((await bytes(head)).length).toBe(0);
127
+ const empty = await request("/empty");
128
+ expect(empty.headers["content-length"]).toBe("0");
129
+ expect((await bytes(empty)).length).toBe(0);
130
+ const range = await request("/65536", { headers: { range: "bytes=512-8703", origin: "https://app.test" } });
131
+ expect(range.statusCode).toBe(206);
132
+ expect(range.headers["content-length"]).toBe("8192");
133
+ expect(range.headers["content-range"]).toBe("bytes 512-8703/65536");
134
+ expect(range.headers.etag).toBe('"original"');
135
+ expect(range.headers["set-cookie"]).toEqual(["a=1", "b=2"]);
136
+ expect(range.headers["access-control-allow-origin"]).toBe("https://app.test");
137
+ expect(digest(await bytes(range))).toBe(digest(object.subarray(512, 8704)));
138
+ });
139
+
140
+ test("compressed bytes and their encoded length remain unchanged", async () => {
141
+ const res = await request("/gzip", { headers: { "accept-encoding": "gzip" } });
142
+ expect(res.headers["content-length"]).toBe(String(compressed.length));
143
+ expect(res.headers["content-encoding"]).toBe("gzip");
144
+ expect(digest(await bytes(res))).toBe(digest(compressed));
145
+ });
146
+
147
+ test("no length is invented for an already-complete unknown-length body", async () => {
148
+ const res = await request("/unknown-small");
149
+ expect(res.headers["content-length"]).toBeUndefined();
150
+ expect((await bytes(res)).toString()).toBe("small");
151
+ });
152
+
153
+ test("bodyless statuses stay bodyless", async () => {
154
+ for (const [path, status] of [["/not-modified", 304], ["/no-content", 204]] as const) {
155
+ const res = await request(path);
156
+ expect(res.statusCode).toBe(status);
157
+ expect(res.headers["transfer-encoding"]).toBeUndefined();
158
+ if (status === 304) expect(res.headers["content-length"]).toBe("65536");
159
+ expect((await bytes(res)).length).toBe(0);
160
+ }
161
+ });
162
+
163
+ for (const path of ["/gated", "/unknown", "/large"]) {
164
+ test(`${path}: first bytes arrive before upstream finishes; disconnect cancels`, async () => {
165
+ // The source cannot finish until the test releases it. This fails if
166
+ // ingress waits for an entire body, regardless of any buffering cap.
167
+ const res = await request(path);
168
+ const first = await once(res, "data");
169
+ expect(Buffer.from(first[0])).toEqual(object.subarray(0, 1024));
170
+ expect(res.headers["content-length"]).toBe(path === "/unknown" ? undefined : path === "/large" ? String(1024 ** 3) : "65536");
171
+ if (path === "/large") {
172
+ res.destroy();
173
+ await upstreamClosed;
174
+ } else {
175
+ const rest = bytes(res);
176
+ release!();
177
+ expect(digest(Buffer.concat([first[0], await rest]))).toBe(digest(object));
178
+ }
179
+ });
180
+ }
181
+ });
182
+
183
+ describe("ingress request lifecycle", () => {
184
+ test("streams uploads and preserves the request URL, headers and peer", async () => {
185
+ const server = await startIngressServer({
186
+ port: 0, hostname: "127.0.0.1",
187
+ async fetch(req, ingress) {
188
+ const body = await req.arrayBuffer();
189
+ return Response.json({ method: req.method, url: req.url, digest: digest(new Uint8Array(body)),
190
+ authorization: req.headers.get("authorization"), peer: ingress.requestIP(req)?.address });
191
+ },
192
+ });
193
+ try {
194
+ for (const chunked of [false, true]) {
195
+ const result = new Promise<http.IncomingMessage>((resolve, reject) => {
196
+ const req = http.request({ host: "127.0.0.1", port: server.port, path: "/upload?part=2", method: "PUT",
197
+ headers: { host: "bucket.test:8443", authorization: "signed", ...(chunked ? {} : { "content-length": object.length }) },
198
+ }, resolve);
199
+ req.on("error", reject);
200
+ req.write(object.subarray(0, 1024));
201
+ setTimeout(() => req.end(object.subarray(1024)), 5);
202
+ });
203
+ expect(JSON.parse((await bytes(await result)).toString())).toEqual({ method: "PUT",
204
+ url: "http://bucket.test:8443/upload?part=2", digest: digest(object), authorization: "signed", peer: "127.0.0.1" });
205
+ }
206
+ } finally { server.stop(true); }
207
+ });
208
+
209
+ test("disconnect before response headers aborts the handler", async () => {
210
+ let entered!: () => void;
211
+ const started = new Promise<void>(resolve => { entered = resolve; });
212
+ let cancelled!: () => void;
213
+ const aborted = new Promise<void>(resolve => { cancelled = resolve; });
214
+ const server = await startIngressServer({
215
+ port: 0, hostname: "127.0.0.1",
216
+ async fetch(req) {
217
+ req.signal.addEventListener("abort", cancelled, { once: true });
218
+ entered();
219
+ await aborted;
220
+ return new Response("cancelled");
221
+ },
222
+ });
223
+ try {
224
+ const req = http.get(`http://127.0.0.1:${server.port}/`);
225
+ req.on("error", () => {});
226
+ await started;
227
+ req.destroy();
228
+ await aborted;
229
+ } finally { server.stop(true); }
230
+ });
231
+
232
+ test("an error after headers closes the stream instead of appending a success or error body", async () => {
233
+ const server = await startIngressServer({
234
+ port: 0, hostname: "127.0.0.1",
235
+ fetch: () => new Response(new ReadableStream({
236
+ start(controller) {
237
+ controller.enqueue(object.subarray(0, 1024));
238
+ setTimeout(() => controller.error(new Error("upstream truncated")), 20);
239
+ },
240
+ }), { headers: { "content-length": String(object.length) } }),
241
+ });
242
+ try {
243
+ const res = await new Promise<http.IncomingMessage>((resolve, reject) => {
244
+ http.get(`http://127.0.0.1:${server.port}/`, resolve).on("error", reject);
245
+ });
246
+ expect(res.headers["content-length"]).toBe(String(object.length));
247
+ await expect(bytes(res)).rejects.toThrow();
248
+ } finally { server.stop(true); }
249
+ });
250
+ });
@@ -0,0 +1,175 @@
1
+ import http, { type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { Readable } from "node:stream";
3
+ import { pipeline } from "node:stream/promises";
4
+ import type { Socket } from "node:net";
5
+ import { createRequire } from "node:module";
6
+ import { dirname, join } from "node:path";
7
+ import { WS_BRIDGE_HANDLERS, type WsBridgeData } from "./ws-bridge.js";
8
+
9
+ // Load the installed implementation, not Bun's built-in `ws` replacement.
10
+ // On Bun 1.4.0 that replacement cannot upgrade node:http sockets after the
11
+ // asynchronous upstream handshake. The package uses the ordinary socket API.
12
+ const require = createRequire(import.meta.url);
13
+ const { WebSocketServer } = require(join(dirname(require.resolve("ws/package.json")), "index.js")) as typeof import("ws");
14
+
15
+ export interface IngressServer {
16
+ port: number;
17
+ stop(force?: boolean): void;
18
+ requestIP(req: Request): { address: string; port: number } | undefined;
19
+ upgrade(req: Request, options: { data: WsBridgeData; headers?: Record<string, string> }): boolean;
20
+ }
21
+
22
+ /**
23
+ * Serve the ingress's Fetch handlers through node:http. Bun.serve discards
24
+ * Content-Length on a still-streaming Response, even when its bytes are
25
+ * unchanged. ServerResponse preserves the declared length while pipeline
26
+ * propagates backpressure and cancellation without buffering the whole body.
27
+ * This is also the HTTP/1 listener behind the TLS terminator.
28
+ */
29
+ export async function startIngressServer(options: {
30
+ port: number;
31
+ hostname?: string;
32
+ reusePort?: boolean;
33
+ fetch(req: Request, server: IngressServer): Response | undefined | Promise<Response | undefined>;
34
+ }): Promise<IngressServer> {
35
+ const peers = new WeakMap<Request, Socket>();
36
+ const upgrades = new WeakMap<Request, { req: IncomingMessage; socket: Socket; head: Buffer; upgraded: boolean }>();
37
+ const protocols = new WeakMap<IncomingMessage, string | undefined>();
38
+ const sockets = new Set<Socket>();
39
+ const websockets = new WebSocketServer({
40
+ noServer: true,
41
+ // The upstream handshake has already selected a protocol. Never select
42
+ // the client's first offer on its behalf.
43
+ handleProtocols: (_offered, req) => protocols.get(req) || false,
44
+ });
45
+ const server = http.createServer({ requestTimeout: 0 }, (req, res) => {
46
+ void dispatch(req, res);
47
+ });
48
+ server.on("connection", socket => {
49
+ sockets.add(socket);
50
+ socket.once("close", () => sockets.delete(socket));
51
+ });
52
+ server.on("upgrade", (req, socket, head) => {
53
+ void dispatch(req, undefined, { req, socket: socket as Socket, head, upgraded: false });
54
+ });
55
+
56
+ const facade: IngressServer = {
57
+ port: 0,
58
+ stop(force = false) {
59
+ if (force) {
60
+ for (const ws of websockets.clients) ws.terminate();
61
+ for (const socket of sockets) socket.destroy();
62
+ server.closeAllConnections();
63
+ }
64
+ websockets.close();
65
+ server.close();
66
+ },
67
+ requestIP(req) {
68
+ const socket = peers.get(req);
69
+ return socket?.remoteAddress && socket.remotePort
70
+ ? { address: socket.remoteAddress, port: socket.remotePort } : undefined;
71
+ },
72
+ upgrade(req, opts) {
73
+ const pending = upgrades.get(req);
74
+ if (!pending || pending.upgraded || pending.socket.destroyed) return false;
75
+ protocols.set(pending.req, opts.headers?.["sec-websocket-protocol"]);
76
+ websockets.handleUpgrade(pending.req, pending.socket, pending.head, ws => {
77
+ pending.upgraded = true;
78
+ const client = {
79
+ data: opts.data,
80
+ send: (data: string | ArrayBuffer | Uint8Array) => ws.send(data),
81
+ close: (code?: number, reason?: string) => ws.close(code, reason),
82
+ };
83
+ ws.on("message", (data, binary) => WS_BRIDGE_HANDLERS.message(client,
84
+ binary ? Buffer.from(data as Buffer) : data.toString()));
85
+ ws.on("close", (code, reason) => WS_BRIDGE_HANDLERS.close(client, code, reason.toString()));
86
+ ws.on("error", () => opts.data.upstream.close());
87
+ WS_BRIDGE_HANDLERS.open(client);
88
+ });
89
+ return pending.upgraded;
90
+ },
91
+ };
92
+
93
+ async function dispatch(
94
+ incoming: IncomingMessage,
95
+ outgoing: ServerResponse | undefined,
96
+ upgrade?: { req: IncomingMessage; socket: Socket; head: Buffer; upgraded: boolean },
97
+ ): Promise<void> {
98
+ const abort = new AbortController();
99
+ const onClose = () => {
100
+ if (!outgoing?.writableFinished && !upgrade?.upgraded) abort.abort();
101
+ };
102
+ const connection = outgoing ?? upgrade!.socket;
103
+ connection.once("close", onClose);
104
+ const responseWriter = () => {
105
+ if (!outgoing) {
106
+ // Only attach an HTTP response after a refused upgrade. Attaching
107
+ // one before handleUpgrade conflicts with Bun's native handshake.
108
+ outgoing = new http.ServerResponse(incoming);
109
+ outgoing.assignSocket(upgrade!.socket);
110
+ outgoing.once("finish", () => upgrade!.socket.end());
111
+ }
112
+ return outgoing;
113
+ };
114
+ let response: Response | undefined;
115
+ try {
116
+ const headers = new Headers();
117
+ for (let i = 0; i < incoming.rawHeaders.length; i += 2) {
118
+ headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
119
+ }
120
+ const method = incoming.method ?? "GET";
121
+ const req = new Request(new URL(incoming.url ?? "/", `http://${headers.get("host") ?? "localhost"}`), {
122
+ method, headers, signal: abort.signal,
123
+ body: method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(incoming) as unknown as ReadableStream<Uint8Array>,
124
+ duplex: "half",
125
+ } as RequestInit);
126
+ peers.set(req, incoming.socket);
127
+ if (upgrade) upgrades.set(req, upgrade);
128
+ response = await options.fetch(req, facade);
129
+ if (upgrade?.upgraded) return;
130
+ if (!response) throw new Error("Ingress handler returned no response");
131
+ if (abort.signal.aborted) {
132
+ await response.body?.cancel();
133
+ return;
134
+ }
135
+ const writer = responseWriter();
136
+ writer.statusCode = response.status;
137
+ writer.statusMessage = response.statusText;
138
+ response.headers.forEach((value, key) => {
139
+ if (key !== "set-cookie") writer.setHeader(key, value);
140
+ });
141
+ const cookies = response.headers.getSetCookie();
142
+ if (cookies.length) writer.setHeader("set-cookie", cookies);
143
+ // Flush even for a body already buffered by fetch. An upstream with
144
+ // no length must not acquire an invented Content-Length from end().
145
+ writer.flushHeaders();
146
+ if (method === "HEAD" || response.status === 204 || response.status === 304 || !response.body) {
147
+ await response.body?.cancel();
148
+ writer.end();
149
+ } else {
150
+ await pipeline(Readable.fromWeb(response.body as unknown as import("node:stream/web").ReadableStream), writer);
151
+ }
152
+ } catch (error) {
153
+ if (upgrade?.upgraded) return;
154
+ outgoing = responseWriter();
155
+ if (!outgoing.headersSent && !outgoing.destroyed) {
156
+ outgoing.writeHead(500, { "content-type": "text/plain" });
157
+ outgoing.end("spectest-daemon: ingress response failed\n");
158
+ } else {
159
+ outgoing.destroy(error instanceof Error ? error : new Error(String(error)));
160
+ }
161
+ } finally {
162
+ connection.off("close", onClose);
163
+ }
164
+ }
165
+
166
+ await new Promise<void>((resolve, reject) => {
167
+ server.once("error", reject);
168
+ server.listen({ port: options.port, host: options.hostname ?? "0.0.0.0", reusePort: options.reusePort }, () => {
169
+ server.off("error", reject);
170
+ resolve();
171
+ });
172
+ });
173
+ facade.port = (server.address() as { port: number }).port;
174
+ return facade;
175
+ }
@@ -28,6 +28,7 @@ import tls from "node:tls";
28
28
  import type { Leaf } from "./ingress-table";
29
29
  import { selectCertName } from "./hostmatch";
30
30
  import { startTlsTerminator, type TlsTerminator } from "./tls-terminator";
31
+ import { startIngressServer } from "./ingress-server";
31
32
 
32
33
  // ── a throwaway CA and leaves, so the client can verify for real ──────────
33
34
  let dir: string;
@@ -80,10 +81,9 @@ let seenClientIp: string | undefined;
80
81
 
81
82
  beforeAll(async () => {
82
83
  certByHost = new Map([["app.test", leaves.get("app.test")!]]);
83
- plain = Bun.serve({
84
+ plain = await startIngressServer({
84
85
  port: 0,
85
86
  hostname: "127.0.0.1",
86
- idleTimeout: 0,
87
87
  fetch(req, server) {
88
88
  const peer = server.requestIP(req);
89
89
  seenClientIp = term.clientIpFor(peer?.port) ?? peer?.address;
@@ -33,15 +33,11 @@
33
33
  * ## The shape, and why it is a byte pipe
34
34
  *
35
35
  * The terminator does TLS and *nothing else*: it accepts on :443, picks a
36
- * leaf by SNI, and pipes the decrypted stream to a plaintext `Bun.serve`
37
- * bound on loopback. Opted-in HTTP/2 routes select a separate stream router
38
- * by ALPN; HTTP/1.1 and WebSockets retain the existing listener. Everything downstream — routing, fakes,
39
- * interceptors, reverse-proxying, and `server.upgrade()` WebSocket
40
- * bridging — is the same `Bun.serve` code that already serves :80, run
41
- * unchanged. Terminating HTTP here instead (`node:https`, `(req, res)`)
42
- * would have meant reimplementing the WebSocket bridge against a
43
- * different API, which is exactly the kind of trade this change exists to
44
- * avoid: one flake removed, another introduced somewhere subtler.
36
+ * leaf by SNI, and pipes the decrypted stream to the plaintext ingress
37
+ * server bound on loopback. Opted-in HTTP/2 routes select a separate stream
38
+ * router by ALPN; HTTP/1.1 and WebSockets use the shared ingress listener.
39
+ * Routing, fakes, interceptors and reverse-proxying stay in that listener;
40
+ * this layer never parses or rewrites HTTP response framing.
45
41
  *
46
42
  * Measured cost of the extra hop on this host (Bun 1.4.0, undici client,
47
43
  * 50 MB bodies): ~11 % fewer small requests per second, download
@@ -1,7 +1,7 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { startIngressServer, type IngressServer } from "./ingress-server";
2
3
 
3
4
  import {
4
- WS_BRIDGE_HANDLERS,
5
5
  openUpstreamWebSocket,
6
6
  parseSubprotocols,
7
7
  upstreamHandshakeHeaders,
@@ -65,11 +65,11 @@ describe("upstreamHandshakeHeaders", () => {
65
65
  */
66
66
  describe("the bridge on loopback", () => {
67
67
  let upstream: ReturnType<typeof Bun.serve>;
68
- let bridge: ReturnType<typeof Bun.serve>;
68
+ let bridge: IngressServer;
69
69
  /** What the upstream saw on its last handshake. */
70
70
  let seen: { protocols: string[]; cookie: string | null; host: string | null; xfh: string | null } | null = null;
71
71
 
72
- beforeAll(() => {
72
+ beforeAll(async () => {
73
73
  upstream = Bun.serve({
74
74
  port: 0,
75
75
  fetch(req, server) {
@@ -102,7 +102,7 @@ describe("the bridge on loopback", () => {
102
102
  },
103
103
  });
104
104
 
105
- bridge = Bun.serve({
105
+ bridge = await startIngressServer({
106
106
  port: 0,
107
107
  async fetch(req, server) {
108
108
  const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
@@ -128,7 +128,6 @@ describe("the bridge on loopback", () => {
128
128
  }
129
129
  return undefined;
130
130
  },
131
- websocket: WS_BRIDGE_HANDLERS,
132
131
  });
133
132
  });
134
133