@specific.dev/spectest 0.100.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.
@@ -14,7 +14,7 @@
14
14
  // - REPLAY (hermetic): the request is matched against the cassette the
15
15
  // test EXPLICITLY loaded (`ctx.fakes.<name>.replay(file)`) and the
16
16
  // recorded response is returned. No network. Nothing is auto-loaded:
17
- // a test that loads no cassette gets a fail-loud 599 on every request.
17
+ // a test that loads no cassette gets a fail-loud 599 on every delegated request.
18
18
  // This is what runs under `spectest test`.
19
19
  // - RECORD (MITM): the request is forwarded to the REAL host and the
20
20
  // request/response pair is captured (decoded, redacted), and the real
@@ -35,6 +35,8 @@
35
35
  // recorder (a `spectest test` case), false in eval/manual. So `auto`
36
36
  // (the default) replays under test and records during manual use — no new
37
37
  // control-plane mode flag. `mode: "record" | "replay"` overrides it.
38
+ // An optional custom handler can return scripted responses or explicitly
39
+ // delegate with ctx.replay(request). Only delegated traffic uses this engine.
38
40
  //
39
41
  // Credential brokering (per-fake): `inject` rules set
40
42
  // headers on the egress forward — overwriting whatever the app sent — so
@@ -133,7 +135,24 @@ export interface AwsSigV4Sign {
133
135
 
134
136
  export type SignConfig = AwsSigV4Sign;
135
137
 
136
- export interface ReplayFakeOptions {
138
+ /** A normal fake context with explicit delegation to recording/replay. */
139
+ export interface ReplayFakeContext extends FakeContext {
140
+ /** Record against the real upstream in record mode, or consume a matching
141
+ * cassette interaction in replay mode. A replay miss returns 599; there
142
+ * is no fallback to scripted logic. Inspect a body's clone before calling. */
143
+ replay(request: Request): Promise<Response>;
144
+ }
145
+
146
+ export type ReplayHandler<S> = (
147
+ request: Request,
148
+ state: S,
149
+ ctx: ReplayFakeContext,
150
+ ) => Response | Promise<Response>;
151
+
152
+ export interface ReplayFakeOptions<
153
+ S = undefined,
154
+ H extends Record<string, unknown> = {},
155
+ > {
137
156
  /** Stable name — the `ctx.fakes` key. */
138
157
  name: string;
139
158
  /** The REAL host to fake, e.g. `"api.stripe.com"`. The fake answers for
@@ -166,6 +185,15 @@ export interface ReplayFakeOptions {
166
185
  /** `"auto"` (default) records under eval and replays under test;
167
186
  * `"record"` / `"replay"` force a mode. */
168
187
  mode?: "auto" | "record" | "replay";
188
+ /** Custom state, with the same lifecycle as a normal fake's state. Kept
189
+ * separate from cassette internals; helpers and handler see only S. */
190
+ state?: () => S | Promise<S>;
191
+ /** Custom helpers exposed alongside replay/dump/recorded/count. Those
192
+ * names are reserved. Loading a cassette does not change custom state. */
193
+ helpers?: (args: { name: string; state: S; ctx: FakeContext }) => H | Promise<H>;
194
+ /** Decide whether to answer in code or call ctx.replay(request). Without
195
+ * a handler, every request delegates. Only delegated requests record. */
196
+ handler?: ReplayHandler<S>;
169
197
  }
170
198
 
171
199
  export interface CassetteRequest {
@@ -230,8 +258,9 @@ export interface ReplaySummary {
230
258
  }
231
259
 
232
260
  export interface ReplayHelpers extends Record<string, unknown> {
233
- /** Load a cassette into THIS fork's replay set and replay from it,
261
+ /** Load a cassette into THIS fork's replay set for delegated requests,
234
262
  * replacing whatever was loaded before and resetting the replay cursor.
263
+ * Does not change custom state or bypass a custom handler.
235
264
  * Cassettes are NOT auto-loaded — call this at the top of a test. Pass a
236
265
  * path relative to `spectest/tests/` (e.g. `"recordings/stripe.json"`; it
237
266
  * must stay under `spectest/tests/`) or a cassette object; a missing file
@@ -627,9 +656,9 @@ function forwardToRealUpstream(args: {
627
656
  // replayFake
628
657
  // ────────────────────────────────────────────────────────────────────────
629
658
 
630
- export function replayFake(
631
- opts: ReplayFakeOptions,
632
- ): FakeDefinition<CassetteState, ReplayHelpers> {
659
+ export function replayFake<S = undefined, H extends Record<string, unknown> = {}>(
660
+ opts: ReplayFakeOptions<S, H>,
661
+ ): FakeDefinition<CassetteState & { custom: S }, ReplayHelpers & H> {
633
662
  if (!opts.name) throw new Error("replayFake: `name` is required");
634
663
  if (!opts.host || typeof opts.host !== "string") {
635
664
  throw new Error(`replayFake(${opts.name}): "host" (the real hostname) is required`);
@@ -914,6 +943,9 @@ export function replayFake(
914
943
  state: CassetteState,
915
944
  _ctx: FakeContext,
916
945
  ): Promise<Response> => {
946
+ if (req.bodyUsed) {
947
+ throw new Error(`replayFake(${opts.name}): cannot delegate a consumed request body; inspect request.clone() before calling ctx.replay(request)`);
948
+ }
917
949
  const url = new URL(req.url);
918
950
  const bodyBytes =
919
951
  req.method === "GET" || req.method === "HEAD"
@@ -940,69 +972,88 @@ export function replayFake(
940
972
  return record(req, url, reqLike, bodyBytes, state);
941
973
  };
942
974
 
943
- const def: FakeDefinition<CassetteState, ReplayHelpers> = {
975
+ const replayHelpers = (state: CassetteState): ReplayHelpers => ({
976
+ replay(file: string | Cassette): ReplaySummary {
977
+ let loaded: Cassette;
978
+ let label: string;
979
+ if (file && typeof file === "object") {
980
+ loaded = coerceCassette(file, opts.name, host);
981
+ label = file.fake || "(inline)";
982
+ } else if (typeof file === "string" && file.length > 0) {
983
+ label = file;
984
+ loaded = readCassetteFile(opts.name, file, host);
985
+ } else {
986
+ throw new Error(
987
+ `replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
988
+ `(e.g. "recordings/${opts.name}.json").`,
989
+ );
990
+ }
991
+ // Build/validate before replacing live state: a bad key/cassette
992
+ // must leave the previous cassette and consumption cursors intact.
993
+ const index = new Map<string, number[]>();
994
+ for (const [i, interaction] of loaded.interactions.entries()) {
995
+ const sig = signature(interaction.request, `${JSON.stringify(label)} interaction #${i + 1}`);
996
+ const queue = index.get(sig);
997
+ if (queue) queue.push(i);
998
+ else index.set(sig, [i]);
999
+ }
1000
+ // Mutate the live state object (the handler reads the same
1001
+ // reference); reset the cursor so replay starts from the top.
1002
+ state.cassette = loaded;
1003
+ state.index = index;
1004
+ state.cursor = new Map();
1005
+ return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
1006
+ },
1007
+ dump(): Cassette {
1008
+ return structuredClone({
1009
+ version: 1,
1010
+ fake: opts.name,
1011
+ host,
1012
+ interactions: [...state.cassette.interactions, ...state.recorded],
1013
+ });
1014
+ },
1015
+ recorded(): CassetteInteraction[] {
1016
+ return structuredClone(state.recorded);
1017
+ },
1018
+ count(): number {
1019
+ return state.recorded.length;
1020
+ },
1021
+ });
1022
+
1023
+ const initialState = (custom: S): CassetteState & { custom: S } => ({
1024
+ cassette: emptyCassette(opts.name, host),
1025
+ index: new Map(),
1026
+ cursor: new Map(),
1027
+ recorded: [],
1028
+ dirty: false,
1029
+ custom,
1030
+ });
1031
+
1032
+ const def: FakeDefinition<CassetteState & { custom: S }, ReplayHelpers & H> = {
944
1033
  name: opts.name,
945
- // Fake = real: we answer for the real host on :80 and :443.
946
1034
  hostnames: [host],
947
1035
  secretRefs,
948
- // Cassettes are NOT auto-loaded — a fork starts with an empty replay
949
- // set, so a test that loads nothing fails loud (599). Tests call
950
- // `replay(...)` to populate it.
951
- state: (): CassetteState => ({
952
- cassette: emptyCassette(opts.name, host),
953
- index: new Map(),
954
- cursor: new Map(),
955
- recorded: [],
956
- dirty: false,
957
- }),
958
- handler,
959
- helpers: ({ state }): ReplayHelpers => ({
960
- replay(file: string | Cassette): ReplaySummary {
961
- let loaded: Cassette;
962
- let label: string;
963
- if (file && typeof file === "object") {
964
- loaded = coerceCassette(file, opts.name, host);
965
- label = file.fake || "(inline)";
966
- } else if (typeof file === "string" && file.length > 0) {
967
- label = file;
968
- loaded = readCassetteFile(opts.name, file, host);
969
- } else {
970
- throw new Error(
971
- `replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
972
- `(e.g. "recordings/${opts.name}.json").`,
973
- );
974
- }
975
- // Build/validate before replacing live state: a bad key/cassette
976
- // must leave the previous cassette and consumption cursors intact.
977
- const index = new Map<string, number[]>();
978
- for (const [i, interaction] of loaded.interactions.entries()) {
979
- const sig = signature(interaction.request, `${JSON.stringify(label)} interaction #${i + 1}`);
980
- const queue = index.get(sig);
981
- if (queue) queue.push(i);
982
- else index.set(sig, [i]);
1036
+ state: () => opts.state
1037
+ ? Promise.resolve(opts.state()).then(initialState)
1038
+ : initialState(undefined as S),
1039
+ handler: (request, state, ctx) => opts.handler
1040
+ ? opts.handler(request, state.custom, {
1041
+ ...ctx,
1042
+ replay: (delegated) => handler(delegated, state, ctx),
1043
+ })
1044
+ : handler(request, state, ctx),
1045
+ helpers: ({ name, state, ctx }) => {
1046
+ const builtins = replayHelpers(state);
1047
+ if (!opts.helpers) return builtins as ReplayHelpers & H;
1048
+ return Promise.resolve(opts.helpers({ name, state: state.custom, ctx })).then((custom) => {
1049
+ for (const key of Object.keys(builtins)) {
1050
+ if (key in custom) {
1051
+ throw new Error(`replayFake(${opts.name}): custom helper ${JSON.stringify(key)} is reserved for recording/replay; choose another name`);
1052
+ }
983
1053
  }
984
- // Mutate the live state object (the handler reads the same
985
- // reference); reset the cursor so replay starts from the top.
986
- state.cassette = loaded;
987
- state.index = index;
988
- state.cursor = new Map();
989
- return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
990
- },
991
- dump(): Cassette {
992
- return structuredClone({
993
- version: 1,
994
- fake: opts.name,
995
- host,
996
- interactions: [...state.cassette.interactions, ...state.recorded],
997
- });
998
- },
999
- recorded(): CassetteInteraction[] {
1000
- return structuredClone(state.recorded);
1001
- },
1002
- count(): number {
1003
- return state.recorded.length;
1004
- },
1005
- }),
1054
+ return { ...custom, ...builtins };
1055
+ });
1056
+ },
1006
1057
  };
1007
1058
  return def;
1008
1059
  }
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);