@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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
})
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
|
1924
|
-
|
|
1925
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
2204
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
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
|
-
|
|
2423
|
-
|
|
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
|
-
|
|
2439
|
-
|
|
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
|
-
//
|
|
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
|
|
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 =
|
|
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.
|
|
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);
|