@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.
@@ -13,6 +13,6 @@ export { github, type GithubOptions } from "./github.js";
13
13
  export { apple, type AppleOptions } from "./apple.js";
14
14
  export { microsoft, type MicrosoftOptions } from "./microsoft.js";
15
15
  export { okta, type OktaOptions } from "./okta.js";
16
- export { replayFake, type ReplayFakeOptions, type ReplayKey, type ReplayRequest, type MatchKey, type JsonValue, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
16
+ export { replayFake, type ReplayFakeOptions, type ReplayFakeContext, type ReplayHandler, type ReplayKey, type ReplayRequest, type MatchKey, type JsonValue, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
17
17
  export { electron, type ElectronOptions } from "./electron.js";
18
18
  export { android, type AndroidOptions } from "./android.js";
@@ -1,4 +1,4 @@
1
- import type { FakeDefinition } from "../index.js";
1
+ import type { FakeContext, FakeDefinition } from "../index.js";
2
2
  import { type ReplayKey } from "./replay-key.js";
3
3
  export type { JsonValue, MatchKey, ReplayRequest, ReplayKey } from "./replay-key.js";
4
4
  /** Match one request dimension. A bare string is an exact match; the
@@ -54,7 +54,15 @@ export interface AwsSigV4Sign {
54
54
  sessionToken?: string;
55
55
  }
56
56
  export type SignConfig = AwsSigV4Sign;
57
- export interface ReplayFakeOptions {
57
+ /** A normal fake context with explicit delegation to recording/replay. */
58
+ export interface ReplayFakeContext extends FakeContext {
59
+ /** Record against the real upstream in record mode, or consume a matching
60
+ * cassette interaction in replay mode. A replay miss returns 599; there
61
+ * is no fallback to scripted logic. Inspect a body's clone before calling. */
62
+ replay(request: Request): Promise<Response>;
63
+ }
64
+ export type ReplayHandler<S> = (request: Request, state: S, ctx: ReplayFakeContext) => Response | Promise<Response>;
65
+ export interface ReplayFakeOptions<S = undefined, H extends Record<string, unknown> = {}> {
58
66
  /** Stable name — the `ctx.fakes` key. */
59
67
  name: string;
60
68
  /** The REAL host to fake, e.g. `"api.stripe.com"`. The fake answers for
@@ -87,6 +95,19 @@ export interface ReplayFakeOptions {
87
95
  /** `"auto"` (default) records under eval and replays under test;
88
96
  * `"record"` / `"replay"` force a mode. */
89
97
  mode?: "auto" | "record" | "replay";
98
+ /** Custom state, with the same lifecycle as a normal fake's state. Kept
99
+ * separate from cassette internals; helpers and handler see only S. */
100
+ state?: () => S | Promise<S>;
101
+ /** Custom helpers exposed alongside replay/dump/recorded/count. Those
102
+ * names are reserved. Loading a cassette does not change custom state. */
103
+ helpers?: (args: {
104
+ name: string;
105
+ state: S;
106
+ ctx: FakeContext;
107
+ }) => H | Promise<H>;
108
+ /** Decide whether to answer in code or call ctx.replay(request). Without
109
+ * a handler, every request delegates. Only delegated requests record. */
110
+ handler?: ReplayHandler<S>;
90
111
  }
91
112
  export interface CassetteRequest {
92
113
  method: string;
@@ -144,8 +165,9 @@ export interface ReplaySummary {
144
165
  interactions: number;
145
166
  }
146
167
  export interface ReplayHelpers extends Record<string, unknown> {
147
- /** Load a cassette into THIS fork's replay set and replay from it,
168
+ /** Load a cassette into THIS fork's replay set for delegated requests,
148
169
  * replacing whatever was loaded before and resetting the replay cursor.
170
+ * Does not change custom state or bypass a custom handler.
149
171
  * Cassettes are NOT auto-loaded — call this at the top of a test. Pass a
150
172
  * path relative to `spectest/tests/` (e.g. `"recordings/stripe.json"`; it
151
173
  * must stay under `spectest/tests/`) or a cassette object; a missing file
@@ -161,4 +183,6 @@ export interface ReplayHelpers extends Record<string, unknown> {
161
183
  /** Count of interactions captured this record session. */
162
184
  count(): number;
163
185
  }
164
- export declare function replayFake(opts: ReplayFakeOptions): FakeDefinition<CassetteState, ReplayHelpers>;
186
+ export declare function replayFake<S = undefined, H extends Record<string, unknown> = {}>(opts: ReplayFakeOptions<S, H>): FakeDefinition<CassetteState & {
187
+ custom: S;
188
+ }, ReplayHelpers & H>;
@@ -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
@@ -644,6 +646,9 @@ export function replayFake(opts) {
644
646
  }
645
647
  // ── handler ─────────────────────────────────────────────────────────
646
648
  const handler = async (req, state, _ctx) => {
649
+ if (req.bodyUsed) {
650
+ throw new Error(`replayFake(${opts.name}): cannot delegate a consumed request body; inspect request.clone() before calling ctx.replay(request)`);
651
+ }
647
652
  const url = new URL(req.url);
648
653
  const bodyBytes = req.method === "GET" || req.method === "HEAD"
649
654
  ? new Uint8Array(0)
@@ -668,71 +673,89 @@ export function replayFake(opts) {
668
673
  return replay(reqLike, state);
669
674
  return record(req, url, reqLike, bodyBytes, state);
670
675
  };
676
+ const replayHelpers = (state) => ({
677
+ replay(file) {
678
+ let loaded;
679
+ let label;
680
+ if (file && typeof file === "object") {
681
+ loaded = coerceCassette(file, opts.name, host);
682
+ label = file.fake || "(inline)";
683
+ }
684
+ else if (typeof file === "string" && file.length > 0) {
685
+ label = file;
686
+ loaded = readCassetteFile(opts.name, file, host);
687
+ }
688
+ else {
689
+ throw new Error(`replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
690
+ `(e.g. "recordings/${opts.name}.json").`);
691
+ }
692
+ // Build/validate before replacing live state: a bad key/cassette
693
+ // must leave the previous cassette and consumption cursors intact.
694
+ const index = new Map();
695
+ for (const [i, interaction] of loaded.interactions.entries()) {
696
+ const sig = signature(interaction.request, `${JSON.stringify(label)} interaction #${i + 1}`);
697
+ const queue = index.get(sig);
698
+ if (queue)
699
+ queue.push(i);
700
+ else
701
+ index.set(sig, [i]);
702
+ }
703
+ // Mutate the live state object (the handler reads the same
704
+ // reference); reset the cursor so replay starts from the top.
705
+ state.cassette = loaded;
706
+ state.index = index;
707
+ state.cursor = new Map();
708
+ return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
709
+ },
710
+ dump() {
711
+ return structuredClone({
712
+ version: 1,
713
+ fake: opts.name,
714
+ host,
715
+ interactions: [...state.cassette.interactions, ...state.recorded],
716
+ });
717
+ },
718
+ recorded() {
719
+ return structuredClone(state.recorded);
720
+ },
721
+ count() {
722
+ return state.recorded.length;
723
+ },
724
+ });
725
+ const initialState = (custom) => ({
726
+ cassette: emptyCassette(opts.name, host),
727
+ index: new Map(),
728
+ cursor: new Map(),
729
+ recorded: [],
730
+ dirty: false,
731
+ custom,
732
+ });
671
733
  const def = {
672
734
  name: opts.name,
673
- // Fake = real: we answer for the real host on :80 and :443.
674
735
  hostnames: [host],
675
736
  secretRefs,
676
- // Cassettes are NOT auto-loaded — a fork starts with an empty replay
677
- // set, so a test that loads nothing fails loud (599). Tests call
678
- // `replay(...)` to populate it.
679
- state: () => ({
680
- cassette: emptyCassette(opts.name, host),
681
- index: new Map(),
682
- cursor: new Map(),
683
- recorded: [],
684
- dirty: false,
685
- }),
686
- handler,
687
- helpers: ({ state }) => ({
688
- replay(file) {
689
- let loaded;
690
- let label;
691
- if (file && typeof file === "object") {
692
- loaded = coerceCassette(file, opts.name, host);
693
- label = file.fake || "(inline)";
694
- }
695
- else if (typeof file === "string" && file.length > 0) {
696
- label = file;
697
- loaded = readCassetteFile(opts.name, file, host);
698
- }
699
- else {
700
- throw new Error(`replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
701
- `(e.g. "recordings/${opts.name}.json").`);
702
- }
703
- // Build/validate before replacing live state: a bad key/cassette
704
- // must leave the previous cassette and consumption cursors intact.
705
- const index = new Map();
706
- for (const [i, interaction] of loaded.interactions.entries()) {
707
- const sig = signature(interaction.request, `${JSON.stringify(label)} interaction #${i + 1}`);
708
- const queue = index.get(sig);
709
- if (queue)
710
- queue.push(i);
711
- else
712
- index.set(sig, [i]);
737
+ state: () => opts.state
738
+ ? Promise.resolve(opts.state()).then(initialState)
739
+ : initialState(undefined),
740
+ handler: (request, state, ctx) => opts.handler
741
+ ? opts.handler(request, state.custom, {
742
+ ...ctx,
743
+ replay: (delegated) => handler(delegated, state, ctx),
744
+ })
745
+ : handler(request, state, ctx),
746
+ helpers: ({ name, state, ctx }) => {
747
+ const builtins = replayHelpers(state);
748
+ if (!opts.helpers)
749
+ return builtins;
750
+ return Promise.resolve(opts.helpers({ name, state: state.custom, ctx })).then((custom) => {
751
+ for (const key of Object.keys(builtins)) {
752
+ if (key in custom) {
753
+ throw new Error(`replayFake(${opts.name}): custom helper ${JSON.stringify(key)} is reserved for recording/replay; choose another name`);
754
+ }
713
755
  }
714
- // Mutate the live state object (the handler reads the same
715
- // reference); reset the cursor so replay starts from the top.
716
- state.cassette = loaded;
717
- state.index = index;
718
- state.cursor = new Map();
719
- return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
720
- },
721
- dump() {
722
- return structuredClone({
723
- version: 1,
724
- fake: opts.name,
725
- host,
726
- interactions: [...state.cassette.interactions, ...state.recorded],
727
- });
728
- },
729
- recorded() {
730
- return structuredClone(state.recorded);
731
- },
732
- count() {
733
- return state.recorded.length;
734
- },
735
- }),
756
+ return { ...custom, ...builtins };
757
+ });
758
+ },
736
759
  };
737
760
  return def;
738
761
  }
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>;