@specific.dev/spectest 0.101.0 → 0.103.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/README.md +46 -0
- package/dist/browser.d.ts +6 -0
- package/dist/browser.js +81 -28
- package/dist/daemon.js +42 -79
- package/dist/fake-websocket.d.ts +16 -0
- package/dist/fake-websocket.js +1 -0
- package/dist/harness/fake-websocket-connection.d.ts +5 -0
- package/dist/harness/fake-websocket-connection.js +58 -0
- package/dist/harness/fake-websocket.d.ts +4 -0
- package/dist/harness/fake-websocket.js +32 -0
- package/dist/harness/ingress-server.d.ts +30 -0
- package/dist/harness/ingress-server.js +179 -0
- package/dist/harness/microphone-fixture.d.ts +13 -0
- package/dist/harness/microphone-fixture.js +67 -0
- package/dist/harness/tls-terminator.js +5 -9
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/package.json +4 -2
- package/src/browser.ts +69 -26
- package/src/daemon.ts +44 -95
- package/src/fake-websocket.ts +17 -0
- package/src/harness/fake-websocket-connection.ts +54 -0
- package/src/harness/fake-websocket.test.ts +219 -0
- package/src/harness/fake-websocket.ts +39 -0
- package/src/harness/ingress-cors.test.ts +3 -2
- package/src/harness/ingress-server.test.ts +250 -0
- package/src/harness/ingress-server.ts +188 -0
- package/src/harness/microphone-fixture.ts +71 -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
- package/src/index.ts +11 -0
- package/src/microphone-fixture.test.ts +143 -0
package/README.md
CHANGED
|
@@ -20,6 +20,52 @@ Pub/Sub integrations are project-owned as of SDK 0.98.0. The former
|
|
|
20
20
|
Use a local service definition or `serviceGroup()` wrapper; the repository’s
|
|
21
21
|
`examples/google-pubsub` demonstrates this using public SDK APIs.
|
|
22
22
|
|
|
23
|
+
|
|
24
|
+
## Microphone fixtures and WebSocket fakes (0.103.0)
|
|
25
|
+
|
|
26
|
+
Supply a 16-bit PCM mono/stereo WAV when creating a browser session:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
const browser = await ctx.browser({
|
|
30
|
+
microphone: { path: "spectest/tests/fixtures/speech.wav" },
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The microphone uses Chromium's native capture pipeline. It plays once and then
|
|
35
|
+
supplies silence; add `loop: true` to repeat. Paths are relative to the repository
|
|
36
|
+
root. Set the fixture on the first call for that browser session; reattaching
|
|
37
|
+
preserves its source. Fixture sessions use a dedicated Chromium process to keep
|
|
38
|
+
named browsers' audio isolated.
|
|
39
|
+
|
|
40
|
+
A fake can serve HTTP and WebSockets on the same hostname, sharing state and
|
|
41
|
+
helpers:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
const speech = defineFake({
|
|
45
|
+
name: "speech",
|
|
46
|
+
hostnames: ["speech.example.com"],
|
|
47
|
+
state: () => ({ messages: [] as string[] }),
|
|
48
|
+
handler: () => Response.json({ ready: true }),
|
|
49
|
+
websocket(req, state) {
|
|
50
|
+
if (new URL(req.url).pathname !== "/realtime") {
|
|
51
|
+
return new Response("Not found", { status: 404 });
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
message(socket, message) {
|
|
55
|
+
if (typeof message === "string") state.messages.push(message);
|
|
56
|
+
socket.send(message);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
helpers: ({ state }) => ({ messages: () => [...state.messages] }),
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The handshake returns a rejection `Response` or callbacks (`open`, `message`,
|
|
65
|
+
`close`), with an optional selected `protocol`. Callbacks may be async and run
|
|
66
|
+
in order per connection. WS and trusted WSS support text/binary messages;
|
|
67
|
+
observe traffic through the fake's helpers.
|
|
68
|
+
|
|
23
69
|
## HTTP assertions
|
|
24
70
|
|
|
25
71
|
`ctx.fetch()` returns a wrapped response. Status fields, header lookups, and
|
package/dist/browser.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type MicrophoneFixture } from "./harness/microphone-fixture.js";
|
|
2
|
+
export type { MicrophoneFixture } from "./harness/microphone-fixture.js";
|
|
1
3
|
import type { DesktopChrome } from "./desktop.js";
|
|
2
4
|
import type { Wrapped } from "./inspect.js";
|
|
3
5
|
import type { UrlPattern } from "./url-match.js";
|
|
@@ -20,6 +22,10 @@ export interface BrowserOptions {
|
|
|
20
22
|
frame?: "browser" | "mobile";
|
|
21
23
|
/** Initial URL to navigate to before the constructor returns. */
|
|
22
24
|
url?: string;
|
|
25
|
+
/** Feed a WAV fixture through Chromium's native microphone capture.
|
|
26
|
+
* Set on the session's first creation; inherited sessions keep their source.
|
|
27
|
+
* Fixture sessions use a dedicated Chromium process for audio isolation. */
|
|
28
|
+
microphone?: MicrophoneFixture;
|
|
23
29
|
/**
|
|
24
30
|
* Script installed (before the session's first navigation) to run in every
|
|
25
31
|
* document loaded from now on, BEFORE the document's own scripts — the
|
package/dist/browser.js
CHANGED
|
@@ -33,6 +33,7 @@ import path from "node:path";
|
|
|
33
33
|
import { fileURLToPath } from "node:url";
|
|
34
34
|
import { generateId } from "./ids.js";
|
|
35
35
|
import { rawFetch } from "./harness/raw-fetch.js";
|
|
36
|
+
import { prepareMicrophoneFixture } from "./harness/microphone-fixture.js";
|
|
36
37
|
import { DESKTOP_INPUT_SYNC_SCRIPT } from "./desktop-recorder.js";
|
|
37
38
|
import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
38
39
|
import { wrap } from "./inspect.js";
|
|
@@ -74,8 +75,8 @@ function base64ByteLength(b64) {
|
|
|
74
75
|
// What the app gets is Chromium's own synthetic devices: the microphone is a
|
|
75
76
|
// generated beep pattern (a live, real MediaStreamTrack — MediaRecorder,
|
|
76
77
|
// WebRTC and AudioContext analysis all work on it), the camera a rolling
|
|
77
|
-
// test pattern at 640x480.
|
|
78
|
-
// in;
|
|
78
|
+
// test pattern at 640x480. A microphone fixture adds Chromium's file-capture
|
|
79
|
+
// flag in a dedicated process; getUserMedia and track behavior stay native.
|
|
79
80
|
//
|
|
80
81
|
// --use-fake-device-for-media-stream: the synthetic camera + microphone.
|
|
81
82
|
// Also what makes `enumerateDevices()` return anything at all.
|
|
@@ -106,14 +107,16 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
|
|
|
106
107
|
// ────────────────────────────────────────────────────────────────────────
|
|
107
108
|
// Shared Chromium (playwright-core)
|
|
108
109
|
// ────────────────────────────────────────────────────────────────────────
|
|
109
|
-
//
|
|
110
|
-
// for the daemon's lifetime.
|
|
110
|
+
// Default sessions share one Chromium per daemon, launched lazily and kept
|
|
111
|
+
// for the daemon's lifetime. Fixture sessions own a process because the audio
|
|
112
|
+
// source is a process-wide flag. Both ride snapshots: the process, playwright's
|
|
111
113
|
// pipe connection to it, and every open page freeze into the VM snapshot
|
|
112
114
|
// and resume in each fork (like dockerd and the daemon itself). Profile
|
|
113
115
|
// state — cookies, localStorage — is per-Chromium, and the in-VM CA trust
|
|
114
116
|
// comes from the HOME-scoped NSS user DB, so neither cares that playwright
|
|
115
117
|
// runs a temp --user-data-dir.
|
|
116
118
|
let PW_BROWSER = null;
|
|
119
|
+
const VIEW_CLEANUPS = new WeakMap();
|
|
117
120
|
// Every view gets its OWN context, desktop included. A context is the cookie
|
|
118
121
|
// jar + storage, so one-context-per-view is what makes `close()` mean what
|
|
119
122
|
// the docs promise: the browsing session is destroyed and the next
|
|
@@ -157,18 +160,56 @@ function deviceContextOptions(d) {
|
|
|
157
160
|
}
|
|
158
161
|
/** Create the context one view lives in — device emulation when the view is
|
|
159
162
|
* phone-emulated, a plain viewport otherwise. */
|
|
160
|
-
async function newViewContext(width, height, device) {
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
163
|
+
async function newViewContext(width, height, device, microphone) {
|
|
164
|
+
const fixture = microphone === undefined ? undefined : await prepareMicrophoneFixture(microphone);
|
|
165
|
+
let browser;
|
|
166
|
+
try {
|
|
167
|
+
browser = fixture ? await chromium.launch({
|
|
168
|
+
executablePath: chromiumPath(), headless: true, args: [...CHROME_ARGV, fixture.arg],
|
|
169
|
+
}) : await ensurePlaywrightBrowser();
|
|
170
|
+
const context = await browser.newContext(device
|
|
171
|
+
? deviceContextOptions(device)
|
|
172
|
+
: {
|
|
173
|
+
viewport: { width, height },
|
|
174
|
+
// Reduced-motion for replay fidelity — see deviceContextOptions.
|
|
175
|
+
reducedMotion: "reduce",
|
|
176
|
+
});
|
|
177
|
+
context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
|
|
178
|
+
context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
179
|
+
if (fixture) {
|
|
180
|
+
const owned = browser;
|
|
181
|
+
VIEW_CLEANUPS.set(context, async () => {
|
|
182
|
+
try {
|
|
183
|
+
await owned.close();
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
await fixture.cleanup();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return context;
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
if (fixture) {
|
|
194
|
+
try {
|
|
195
|
+
await browser?.close();
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
await fixture.cleanup();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function closeViewContext(context) {
|
|
205
|
+
try {
|
|
206
|
+
await context.close();
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
const cleanup = VIEW_CLEANUPS.get(context);
|
|
210
|
+
VIEW_CLEANUPS.delete(context);
|
|
211
|
+
await cleanup?.();
|
|
212
|
+
}
|
|
172
213
|
}
|
|
173
214
|
/**
|
|
174
215
|
* Make a user script evaluable by the page: Bun's `view.evaluate` accepts a
|
|
@@ -872,7 +913,7 @@ export async function openMobileBackend(opts = {}) {
|
|
|
872
913
|
// Mobile views are never pooled — the pool holds only default-desktop
|
|
873
914
|
// views (in the shared desktop context), and a mobile view needs its own
|
|
874
915
|
// emulated context anyway.
|
|
875
|
-
const pooled = !device && wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
|
|
916
|
+
const pooled = !device && opts.microphone === undefined && wantW === 1280 && wantH === 720 ? VIEW_POOL.pop() : undefined;
|
|
876
917
|
let holder;
|
|
877
918
|
if (pooled) {
|
|
878
919
|
holder = {
|
|
@@ -889,7 +930,7 @@ export async function openMobileBackend(opts = {}) {
|
|
|
889
930
|
};
|
|
890
931
|
}
|
|
891
932
|
else {
|
|
892
|
-
holder = await newHolder(wantW, wantH, device);
|
|
933
|
+
holder = await newHolder(wantW, wantH, device, opts.microphone);
|
|
893
934
|
}
|
|
894
935
|
const { backend } = buildBackend(holder, opts.recorder ?? null, {
|
|
895
936
|
persistent: false,
|
|
@@ -897,12 +938,17 @@ export async function openMobileBackend(opts = {}) {
|
|
|
897
938
|
// We deliberately don't forward `opts.url` to the constructor — going
|
|
898
939
|
// through our own `navigate()` keeps the recorder log uniform (one
|
|
899
940
|
// event per navigation, with timing) and drains rrweb after the load.
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
941
|
+
try {
|
|
942
|
+
if (opts.initScript !== undefined)
|
|
943
|
+
await installInitScript(holder, opts.initScript);
|
|
944
|
+
if (opts.url !== undefined)
|
|
945
|
+
await backend.goto(opts.url);
|
|
946
|
+
return backend;
|
|
947
|
+
}
|
|
948
|
+
catch (err) {
|
|
949
|
+
await backend.close();
|
|
950
|
+
throw err;
|
|
904
951
|
}
|
|
905
|
-
return backend;
|
|
906
952
|
}
|
|
907
953
|
// ────────────────────────────────────────────────────────────────────────
|
|
908
954
|
// Persistent sessions (the default behind ctx.browser / ctx.mobile)
|
|
@@ -946,9 +992,16 @@ function checkSessionCap(kind, live, name) {
|
|
|
946
992
|
`Each named session is a live browser captured in every snapshot from here on — name them for the ` +
|
|
947
993
|
`roles under test (e.g. "buyer"/"seller") rather than per row of data, or close() the ones you're done with.`);
|
|
948
994
|
}
|
|
949
|
-
async function newHolder(width, height, device) {
|
|
950
|
-
const context = await newViewContext(width, height, device);
|
|
951
|
-
|
|
995
|
+
async function newHolder(width, height, device, microphone) {
|
|
996
|
+
const context = await newViewContext(width, height, device, microphone);
|
|
997
|
+
let spawned;
|
|
998
|
+
try {
|
|
999
|
+
spawned = await spawnPage(context);
|
|
1000
|
+
}
|
|
1001
|
+
catch (err) {
|
|
1002
|
+
await closeViewContext(context);
|
|
1003
|
+
throw err;
|
|
1004
|
+
}
|
|
952
1005
|
const holder = {
|
|
953
1006
|
context,
|
|
954
1007
|
page: spawned.page,
|
|
@@ -1020,7 +1073,7 @@ export async function acquirePersistentBrowser(name = "", opts = {}) {
|
|
|
1020
1073
|
}
|
|
1021
1074
|
else {
|
|
1022
1075
|
checkSessionCap("browser", SHARED_BROWSERS.size, name);
|
|
1023
|
-
holder = await newHolder(device ? device.viewport.width : opts.width ?? 1280, device ? device.viewport.height : opts.height ?? 720, device);
|
|
1076
|
+
holder = await newHolder(device ? device.viewport.width : opts.width ?? 1280, device ? device.viewport.height : opts.height ?? 720, device, opts.microphone);
|
|
1024
1077
|
SHARED_BROWSERS.set(name, holder);
|
|
1025
1078
|
}
|
|
1026
1079
|
const view = holder;
|
|
@@ -1617,7 +1670,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1617
1670
|
// only the page would leave the next session signed in as this one.
|
|
1618
1671
|
try {
|
|
1619
1672
|
if (buildOpts.ownsContext !== false)
|
|
1620
|
-
await holder.context
|
|
1673
|
+
await closeViewContext(holder.context);
|
|
1621
1674
|
}
|
|
1622
1675
|
catch {
|
|
1623
1676
|
/* context already gone (browser died) */
|
package/dist/daemon.js
CHANGED
|
@@ -41,6 +41,7 @@ import { isDesktopApp } from "./desktop.js";
|
|
|
41
41
|
// each easy to restate subtly differently.
|
|
42
42
|
import { buildArgFlags, buildContentKey as computeBuildContentKey, buildTargetFlags, imageTag, isGeneratedDockerignore, pickProjectIgnore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
|
|
43
43
|
import { startServices } from "./harness/service-graph.js";
|
|
44
|
+
import { dispatchFake } from "./harness/fake-websocket.js";
|
|
44
45
|
import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
|
|
45
46
|
import { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
46
47
|
import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
|
|
@@ -57,9 +58,10 @@ import { bindInstrumentationScope, createInstrumentationScope, runInstrumented,
|
|
|
57
58
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
58
59
|
import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
|
|
59
60
|
import { HOP_BY_HOP_HEADERS, withIngressCors, upstreamRequestHeaders, } from "./harness/http-proxy.js";
|
|
60
|
-
import {
|
|
61
|
+
import { openUpstreamWebSocket, parseSubprotocols, upstreamHandshakeHeaders, } from "./harness/ws-bridge.js";
|
|
61
62
|
import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectCertName, wildcardSuffix, } from "./harness/hostmatch.js";
|
|
62
63
|
import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
|
|
64
|
+
import { startIngressServer } from "./harness/ingress-server.js";
|
|
63
65
|
import { startHttp2Proxy } from "./harness/http2-proxy.js";
|
|
64
66
|
import { startTlsTerminator } from "./harness/tls-terminator.js";
|
|
65
67
|
import { runContainerArgs } from "./harness/container-run.js";
|
|
@@ -1495,11 +1497,10 @@ let LOWERED = {
|
|
|
1495
1497
|
aliasesByService: {},
|
|
1496
1498
|
wildcards: [],
|
|
1497
1499
|
};
|
|
1498
|
-
/** Running HTTP servers per port
|
|
1499
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1500
|
+
/** Running HTTP servers per port. Rebuilt on /load. */
|
|
1500
1501
|
const INGRESS_HTTP_SERVERS = new Map();
|
|
1502
|
+
const INGRESS_HTTP_STARTING = new Map();
|
|
1501
1503
|
/** Running HTTPS servers per port (currently always {INGRESS_HTTPS_PORT}). */
|
|
1502
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1503
1504
|
const INGRESS_HTTPS_SERVERS = new Map();
|
|
1504
1505
|
/**
|
|
1505
1506
|
* The :443 TLS terminator, once per harness process.
|
|
@@ -1564,6 +1565,7 @@ function stopIngressServers() {
|
|
|
1564
1565
|
}
|
|
1565
1566
|
}
|
|
1566
1567
|
INGRESS_HTTP_SERVERS.clear();
|
|
1568
|
+
INGRESS_HTTP_STARTING.clear();
|
|
1567
1569
|
for (const [port, srv] of INGRESS_HTTPS_SERVERS) {
|
|
1568
1570
|
try {
|
|
1569
1571
|
srv.stop?.(true);
|
|
@@ -1691,7 +1693,7 @@ async function startIngress() {
|
|
|
1691
1693
|
// eslint-disable-next-line no-console
|
|
1692
1694
|
console.warn(`[ingress] root CA missing at ${CA_PATH}; skipping HTTPS bind (fakes/proxies will be HTTP-only)`);
|
|
1693
1695
|
}
|
|
1694
|
-
|
|
1696
|
+
requireBun();
|
|
1695
1697
|
// Resolve every ingress hostname to its handler into the live per-port
|
|
1696
1698
|
// route tables (module scope, so runtime `tls` can extend them later).
|
|
1697
1699
|
// Fakes run an in-daemon handler on their declared port; proxies
|
|
@@ -1732,13 +1734,13 @@ async function startIngress() {
|
|
|
1732
1734
|
for (const [port, byHost] of INGRESS.routesByPort) {
|
|
1733
1735
|
if (port === INGRESS_HTTPS_PORT)
|
|
1734
1736
|
continue;
|
|
1735
|
-
|
|
1737
|
+
await ensureHttpIngress(port, byHost);
|
|
1736
1738
|
// eslint-disable-next-line no-console
|
|
1737
1739
|
console.log(`[ingress] http :${port} for ${[...byHost.keys()].join(", ")}`);
|
|
1738
1740
|
}
|
|
1739
1741
|
// ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
|
|
1740
1742
|
if (INGRESS.certByHost.size > 0)
|
|
1741
|
-
await ensureHttpsIngress(
|
|
1743
|
+
await ensureHttpsIngress();
|
|
1742
1744
|
// Seed the resolver's names registry: ingress hostnames (fakes, TLS
|
|
1743
1745
|
// proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
|
|
1744
1746
|
// wildcards. Service-targeted wildcards wait for the post-container pass
|
|
@@ -1750,17 +1752,17 @@ function requireBun() {
|
|
|
1750
1752
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1751
1753
|
const Bun = globalThis.Bun;
|
|
1752
1754
|
if (!Bun?.serve) {
|
|
1753
|
-
throw new Error("ingress requires Bun
|
|
1755
|
+
throw new Error("ingress requires Bun; the daemon must run under Bun (it does in-VM)");
|
|
1754
1756
|
}
|
|
1755
1757
|
return Bun;
|
|
1756
1758
|
}
|
|
1757
1759
|
/**
|
|
1758
1760
|
* Bring :443 up, once.
|
|
1759
1761
|
*
|
|
1760
|
-
* Two pieces: a plaintext
|
|
1762
|
+
* Two pieces: a plaintext HTTP server on an ephemeral loopback port that
|
|
1761
1763
|
* carries the HTTPS route table (and therefore all the routing, fake
|
|
1762
|
-
* dispatch, interception, reverse-proxying and
|
|
1763
|
-
*
|
|
1764
|
+
* dispatch, interception, reverse-proxying and WebSocket
|
|
1765
|
+
* bridging that :80 already gets, unchanged), and the TLS
|
|
1764
1766
|
* terminator in front of it on :443.
|
|
1765
1767
|
*
|
|
1766
1768
|
* **This is idempotent, and that is the feature.** Adding a certificate
|
|
@@ -1781,27 +1783,25 @@ function requireBun() {
|
|
|
1781
1783
|
* container can reach (containers arrive at the bridge gateway), so the
|
|
1782
1784
|
* only way in is through the terminator.
|
|
1783
1785
|
*/
|
|
1784
|
-
|
|
1785
|
-
async function ensureHttpsIngress(Bun) {
|
|
1786
|
+
async function ensureHttpsIngress() {
|
|
1786
1787
|
if (INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT))
|
|
1787
1788
|
return;
|
|
1788
1789
|
if (!HTTPS_INGRESS_STARTING) {
|
|
1789
1790
|
// Cleared on both outcomes: once bound, the check above short-circuits
|
|
1790
1791
|
// every later caller, and a failed bind must be retryable rather than
|
|
1791
1792
|
// remembered as a rejection for the life of the project.
|
|
1792
|
-
HTTPS_INGRESS_STARTING = startHttpsIngress(
|
|
1793
|
+
HTTPS_INGRESS_STARTING = startHttpsIngress().finally(() => {
|
|
1793
1794
|
HTTPS_INGRESS_STARTING = undefined;
|
|
1794
1795
|
});
|
|
1795
1796
|
}
|
|
1796
1797
|
return HTTPS_INGRESS_STARTING;
|
|
1797
1798
|
}
|
|
1798
|
-
|
|
1799
|
-
async function startHttpsIngress(Bun) {
|
|
1799
|
+
async function startHttpsIngress() {
|
|
1800
1800
|
const routes = routesFor(INGRESS, INGRESS_HTTPS_PORT);
|
|
1801
1801
|
// `proto` is passed rather than inferred: this listener speaks plain
|
|
1802
1802
|
// HTTP, but everything reaching it arrived over TLS, so X-Forwarded-Proto
|
|
1803
1803
|
// must say https.
|
|
1804
|
-
const plain = bindIngressServer(
|
|
1804
|
+
const plain = await bindIngressServer(0, routes, `https :${INGRESS_HTTPS_PORT}`, {
|
|
1805
1805
|
proto: "https",
|
|
1806
1806
|
hostname: "127.0.0.1",
|
|
1807
1807
|
});
|
|
@@ -1884,7 +1884,7 @@ async function bindRuntimeTls(hostname, service, port, http2, cors) {
|
|
|
1884
1884
|
if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
|
|
1885
1885
|
throw new Error(`runtime tls for ${JSON.stringify(host)} requires the in-VM root CA at ${CA_PATH}`);
|
|
1886
1886
|
}
|
|
1887
|
-
|
|
1887
|
+
requireBun();
|
|
1888
1888
|
const route = { kind: "proxy", service, port, http2, cors };
|
|
1889
1889
|
// Decide the cost before mutating anything: an already-covered hostname
|
|
1890
1890
|
// (an exact duplicate, or one under a boot wildcard) needs only a route.
|
|
@@ -1899,9 +1899,7 @@ async function bindRuntimeTls(hostname, service, port, http2, cors) {
|
|
|
1899
1899
|
// the route is only reachable once the hostname resolves, which is the
|
|
1900
1900
|
// registry write at the end.
|
|
1901
1901
|
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
|
-
}
|
|
1902
|
+
await ensureHttpIngress(INGRESS_HTTP_PORT, routesFor(INGRESS, INGRESS_HTTP_PORT));
|
|
1905
1903
|
if (plan.needsCert) {
|
|
1906
1904
|
// The terminator reads this table on every handshake, so the leaf is
|
|
1907
1905
|
// live the moment it lands — no listener is touched, and no connection
|
|
@@ -1909,7 +1907,7 @@ async function bindRuntimeTls(hostname, service, port, http2, cors) {
|
|
|
1909
1907
|
INGRESS.certByHost.set(host, await generateHostCert(host, [host]));
|
|
1910
1908
|
}
|
|
1911
1909
|
if (plan.needsHttpsListener)
|
|
1912
|
-
await ensureHttpsIngress(
|
|
1910
|
+
await ensureHttpsIngress();
|
|
1913
1911
|
// Resolve the hostname to the daemon gateway (where :443/:80 listen).
|
|
1914
1912
|
// A wildcard can only live in the resolver's suffix table.
|
|
1915
1913
|
const gw = await bridgeGatewayIp();
|
|
@@ -1946,57 +1944,27 @@ async function unbindRuntimeTls(hostname) {
|
|
|
1946
1944
|
await writeRegistry();
|
|
1947
1945
|
}
|
|
1948
1946
|
}
|
|
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 = {
|
|
1947
|
+
/** Concurrent runtime routes must share the asynchronous first bind. */
|
|
1948
|
+
async function ensureHttpIngress(port, byHost) {
|
|
1949
|
+
if (INGRESS_HTTP_SERVERS.has(port))
|
|
1950
|
+
return;
|
|
1951
|
+
let starting = INGRESS_HTTP_STARTING.get(port);
|
|
1952
|
+
if (!starting) {
|
|
1953
|
+
starting = bindIngressServer(port, byHost, `port ${port}`).then(server => {
|
|
1954
|
+
INGRESS_HTTP_SERVERS.set(port, server);
|
|
1955
|
+
}).finally(() => INGRESS_HTTP_STARTING.delete(port));
|
|
1956
|
+
INGRESS_HTTP_STARTING.set(port, starting);
|
|
1957
|
+
}
|
|
1958
|
+
await starting;
|
|
1959
|
+
}
|
|
1960
|
+
/** Bind the shared HTTP/1 ingress handler, including the listener behind TLS. */
|
|
1961
|
+
async function bindIngressServer(port, byHost, listenerLabel, serve = { proto: "http" }) {
|
|
1962
|
+
return startIngressServer({
|
|
1970
1963
|
port,
|
|
1971
1964
|
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
1965
|
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);
|
|
1966
|
+
fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, serve.proto),
|
|
1967
|
+
});
|
|
2000
1968
|
}
|
|
2001
1969
|
/**
|
|
2002
1970
|
* Resolve a Host header to a route: an exact entry first, then the longest
|
|
@@ -2033,13 +2001,7 @@ server, byHost, listenerLabel, proto) {
|
|
|
2033
2001
|
// The real answer — a fake handled in-process, or the proxied container.
|
|
2034
2002
|
const upstream = async (current) => {
|
|
2035
2003
|
if (route.kind === "fake") {
|
|
2036
|
-
|
|
2037
|
-
return await runUninstrumented(() => route.fake.def.handler(current, route.fake.state, FAKE_CTX));
|
|
2038
|
-
}
|
|
2039
|
-
catch (err) {
|
|
2040
|
-
const e = err;
|
|
2041
|
-
return new Response(`spectest-daemon: fake ${route.fake.def.name} threw: ${e?.message ?? String(err)}\n`, { status: 500, headers: { "content-type": "text/plain" } });
|
|
2042
|
-
}
|
|
2004
|
+
return dispatchFake(current, server, route.fake.def, route.fake.state, FAKE_CTX);
|
|
2043
2005
|
}
|
|
2044
2006
|
return proxyToService(current, server, route.service, route.port, listenerLabel, proto);
|
|
2045
2007
|
};
|
|
@@ -2128,7 +2090,7 @@ server, service, port, listenerLabel, proto) {
|
|
|
2128
2090
|
headers: selected ? { "sec-websocket-protocol": selected } : undefined,
|
|
2129
2091
|
});
|
|
2130
2092
|
if (ok) {
|
|
2131
|
-
//
|
|
2093
|
+
// The WebSocket has already taken over the response — return a stub.
|
|
2132
2094
|
return new Response(null, { status: 101 });
|
|
2133
2095
|
}
|
|
2134
2096
|
try {
|
|
@@ -2189,6 +2151,7 @@ server, service, port, listenerLabel, proto) {
|
|
|
2189
2151
|
headers: fwdHeaders,
|
|
2190
2152
|
body: hasBody ? (bufferedBody ?? req.body) : undefined,
|
|
2191
2153
|
redirect: "manual",
|
|
2154
|
+
signal: req.signal,
|
|
2192
2155
|
});
|
|
2193
2156
|
// decompress:false → forward the encoded body untouched (see fn doc).
|
|
2194
2157
|
// keepalive:false → fresh connection per request (see fwdHeaders above).
|
|
@@ -2198,7 +2161,7 @@ server, service, port, listenerLabel, proto) {
|
|
|
2198
2161
|
keepalive: false,
|
|
2199
2162
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2200
2163
|
});
|
|
2201
|
-
// Strip
|
|
2164
|
+
// Strip connection framing while preserving the unchanged body's length.
|
|
2202
2165
|
const respHeaders = new Headers();
|
|
2203
2166
|
for (const [k, v] of upstreamRes.headers) {
|
|
2204
2167
|
if (HOP_BY_HOP_HEADERS.has(k.toLowerCase()))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** A connection accepted by a fake. Text and binary messages retain their type. */
|
|
2
|
+
export interface FakeWebSocket {
|
|
3
|
+
readonly readyState: number;
|
|
4
|
+
send(message: string | Uint8Array | ArrayBuffer): void;
|
|
5
|
+
close(code?: number, reason?: string): void;
|
|
6
|
+
}
|
|
7
|
+
/** Returned by a fake's websocket handshake callback to accept a connection.
|
|
8
|
+
* Callbacks run in order per connection, including async callbacks. Capture
|
|
9
|
+
* fake state and connection-local state in the handshake callback's closure. */
|
|
10
|
+
export interface FakeWebSocketHandlers {
|
|
11
|
+
/** An offered Sec-WebSocket-Protocol to select, if the client requires one. */
|
|
12
|
+
protocol?: string;
|
|
13
|
+
open?(socket: FakeWebSocket): void | Promise<void>;
|
|
14
|
+
message?(socket: FakeWebSocket, message: string | Uint8Array): void | Promise<void>;
|
|
15
|
+
close?(socket: FakeWebSocket, code: number, reason: string): void | Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type WebSocket from "ws";
|
|
2
|
+
import type { FakeWebSocketHandlers } from "../fake-websocket.js";
|
|
3
|
+
/** Serialize async fake callbacks without allowing a slow handler or peer to
|
|
4
|
+
* retain unbounded audio. A handler failure closes just this connection. */
|
|
5
|
+
export declare function attachFakeWebSocket(ws: WebSocket, handlers: FakeWebSocketHandlers): void;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const MAX_BUFFER = 16 * 1024 * 1024;
|
|
2
|
+
const MAX_PENDING = 1024;
|
|
3
|
+
/** Serialize async fake callbacks without allowing a slow handler or peer to
|
|
4
|
+
* retain unbounded audio. A handler failure closes just this connection. */
|
|
5
|
+
export function attachFakeWebSocket(ws, handlers) {
|
|
6
|
+
let tail = Promise.resolve();
|
|
7
|
+
let pending = 0;
|
|
8
|
+
let bytes = 0;
|
|
9
|
+
let failed = false;
|
|
10
|
+
const socket = {
|
|
11
|
+
get readyState() { return ws.readyState; },
|
|
12
|
+
send(message) {
|
|
13
|
+
if (ws.readyState !== 1)
|
|
14
|
+
throw new Error("WebSocket is not open");
|
|
15
|
+
const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
|
|
16
|
+
if (ws.bufferedAmount + size > MAX_BUFFER) {
|
|
17
|
+
failed = true;
|
|
18
|
+
ws.close(1009, "fake output buffer exceeded");
|
|
19
|
+
throw new Error("fake WebSocket output buffer exceeded");
|
|
20
|
+
}
|
|
21
|
+
ws.send(message);
|
|
22
|
+
},
|
|
23
|
+
close: (code, reason) => ws.close(code, reason),
|
|
24
|
+
};
|
|
25
|
+
const fail = (err) => {
|
|
26
|
+
failed = true;
|
|
27
|
+
console.error("[spectest] fake WebSocket callback failed:", err);
|
|
28
|
+
try {
|
|
29
|
+
ws.close(1011, "fake handler failed");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
ws.terminate();
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const enqueue = (fn, size = 0, closing = false) => {
|
|
36
|
+
if (failed && !closing)
|
|
37
|
+
return;
|
|
38
|
+
if (!closing && (pending >= MAX_PENDING || bytes + size > MAX_BUFFER)) {
|
|
39
|
+
failed = true;
|
|
40
|
+
ws.close(1009, "fake input buffer exceeded");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
pending++;
|
|
44
|
+
bytes += size;
|
|
45
|
+
tail = tail.then(async () => {
|
|
46
|
+
if (!failed || closing)
|
|
47
|
+
await fn();
|
|
48
|
+
}).catch(fail).finally(() => { pending--; bytes -= size; });
|
|
49
|
+
};
|
|
50
|
+
ws.on("message", (data, binary) => {
|
|
51
|
+
const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
|
|
52
|
+
const message = binary ? new Uint8Array(buffer) : buffer.toString();
|
|
53
|
+
enqueue(() => handlers.message?.(socket, message), buffer.length);
|
|
54
|
+
});
|
|
55
|
+
ws.on("close", (code, reason) => enqueue(() => handlers.close?.(socket, code, reason.toString()), 0, true));
|
|
56
|
+
ws.on("error", fail);
|
|
57
|
+
enqueue(() => handlers.open?.(socket));
|
|
58
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { FakeContext, FakeDefinition } from "../index.js";
|
|
2
|
+
import type { IngressServer } from "./ingress-server.js";
|
|
3
|
+
/** HTTP and WebSocket dispatch share fake state, helpers, and execution scope. */
|
|
4
|
+
export declare function dispatchFake<S>(req: Request, server: IngressServer, def: FakeDefinition<S, any>, state: S, ctx: FakeContext): Promise<Response>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { runUninstrumented } from "./instrumentation-scope.js";
|
|
2
|
+
import { parseSubprotocols } from "./ws-bridge.js";
|
|
3
|
+
/** HTTP and WebSocket dispatch share fake state, helpers, and execution scope. */
|
|
4
|
+
export async function dispatchFake(req, server, def, state, ctx) {
|
|
5
|
+
try {
|
|
6
|
+
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket" || !def.websocket) {
|
|
7
|
+
return await runUninstrumented(() => def.handler(req, state, ctx));
|
|
8
|
+
}
|
|
9
|
+
const accepted = await runUninstrumented(() => def.websocket(req, state, ctx));
|
|
10
|
+
if (accepted instanceof Response)
|
|
11
|
+
return accepted;
|
|
12
|
+
if (!accepted || typeof accepted !== "object")
|
|
13
|
+
throw new Error("websocket must return handlers or a Response");
|
|
14
|
+
if (accepted.protocol !== undefined && !parseSubprotocols(req.headers.get("sec-websocket-protocol")).includes(accepted.protocol)) {
|
|
15
|
+
throw new Error("websocket selected a protocol the client did not offer");
|
|
16
|
+
}
|
|
17
|
+
const handlers = {
|
|
18
|
+
open: socket => runUninstrumented(() => accepted.open?.(socket)),
|
|
19
|
+
message: (socket, message) => runUninstrumented(() => accepted.message?.(socket, message)),
|
|
20
|
+
close: (socket, code, reason) => runUninstrumented(() => accepted.close?.(socket, code, reason)),
|
|
21
|
+
};
|
|
22
|
+
return server.upgrade(req, {
|
|
23
|
+
handlers,
|
|
24
|
+
headers: accepted.protocol ? { "sec-websocket-protocol": accepted.protocol } : undefined,
|
|
25
|
+
}) ? new Response(null, { status: 101 }) : new Response("WebSocket upgrade refused", { status: 426 });
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
return new Response(`spectest-daemon: fake ${def.name} threw: ${err instanceof Error ? err.message : String(err)}\n`, {
|
|
29
|
+
status: 500, headers: { "content-type": "text/plain" },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|