@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.
- package/dist/components/index.d.ts +1 -1
- package/dist/components/replayFake.d.ts +28 -4
- package/dist/components/replayFake.js +84 -61
- package/dist/daemon.js +40 -72
- package/dist/harness/ingress-server.d.ts +26 -0
- package/dist/harness/ingress-server.js +166 -0
- package/dist/harness/tls-terminator.js +5 -9
- package/package.json +4 -2
- package/src/components/index.ts +2 -0
- package/src/components/replayFake-handler.test.ts +217 -0
- package/src/components/replayFake-record.test.ts +42 -0
- package/src/components/replayFake.ts +116 -65
- package/src/daemon.ts +42 -84
- package/src/harness/ingress-cors.test.ts +3 -2
- package/src/harness/ingress-server.test.ts +250 -0
- package/src/harness/ingress-server.ts +175 -0
- package/src/harness/tls-terminator.test.ts +2 -2
- package/src/harness/tls-terminator.ts +5 -9
- package/src/harness/ws-bridge.test.ts +4 -5
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
})
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
1763
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
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
|
-
|
|
1982
|
-
|
|
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
|
-
//
|
|
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
|
|
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>;
|