@specific.dev/spectest 0.88.3 → 0.89.1
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 +42 -2
- package/dist/components/electron-chrome.d.ts +1 -0
- package/dist/components/electron-chrome.js +47 -0
- package/dist/components/electron.d.ts +11 -1
- package/dist/components/electron.js +42 -10
- package/dist/daemon.js +40 -85
- package/dist/harness/fetch.js +7 -3
- package/dist/harness/ws-bridge.d.ts +78 -0
- package/dist/harness/ws-bridge.js +180 -0
- package/dist/index.d.ts +6 -2
- package/dist/inspect.d.ts +85 -12
- package/dist/inspect.js +154 -5
- package/package.json +1 -1
- package/src/components/electron-chrome.ts +48 -0
- package/src/components/electron.test.ts +84 -1
- package/src/components/electron.ts +54 -11
- package/src/daemon.ts +48 -86
- package/src/harness/fetch.test.ts +108 -2
- package/src/harness/fetch.ts +8 -5
- package/src/harness/ws-bridge.test.ts +224 -0
- package/src/harness/ws-bridge.ts +210 -0
- package/src/index.ts +17 -1
- package/src/inspect-fetch.test.ts +202 -0
- package/src/inspect.ts +245 -19
package/README.md
CHANGED
|
@@ -15,6 +15,42 @@ Install the [Spectest CLI](https://github.com/specific-dev/spectest/releases/lat
|
|
|
15
15
|
and run `spectest docs /installation` for project setup and authentication.
|
|
16
16
|
`spectest docs` contains the complete offline authoring guide.
|
|
17
17
|
|
|
18
|
+
## HTTP assertions
|
|
19
|
+
|
|
20
|
+
`ctx.fetch()` returns a wrapped response. Status fields, header lookups, and
|
|
21
|
+
all body readers retain the HTTP call's provenance, so assertions appear under
|
|
22
|
+
that call on the timeline:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
const response = await ctx.fetch("http://app:3000/image.png");
|
|
26
|
+
expect(response.status).toBe(200);
|
|
27
|
+
expect(response.headers.get("content-type")).toBe("image/png");
|
|
28
|
+
const bytes = await response.bytes();
|
|
29
|
+
expect(bytes).toHaveLength(70);
|
|
30
|
+
expect(bytes[0]).toBe(0x89);
|
|
31
|
+
expect(bytes.slice(0, 8)).toEqual(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10]));
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`arrayBuffer()` wraps buffer metadata and slices. `blob()` wraps metadata and
|
|
35
|
+
its body readers. `formData()` wraps `get()`, `getAll()`, and `has()`, including
|
|
36
|
+
uploaded files and their metadata. Headers also support wrapped `getSetCookie()`
|
|
37
|
+
and Bun's `getAll()`, `toJSON()`, and `count`. Missing fields remain `null`, with
|
|
38
|
+
provenance recovered by an immediately following `expect`.
|
|
39
|
+
|
|
40
|
+
Use `.unwrap()` to pass a wrapped value to a native API, such as
|
|
41
|
+
`new TextDecoder().decode(bytes.unwrap())`. Use `.transform("text", bytes =>
|
|
42
|
+
new TextDecoder().decode(bytes))` when the derived value should retain provenance.
|
|
43
|
+
Iteration and callback arguments remain raw, as do array/typed-array `length`
|
|
44
|
+
and stream control flags (`done`, `locked`). Use `expect(bytes).toHaveLength(n)`
|
|
45
|
+
for a linked length assertion. `bodyUsed` is wrapped: unwrap it for control flow.
|
|
46
|
+
|
|
47
|
+
For an open-ended stream, pass `{ captureBody: false }` to return without waiting
|
|
48
|
+
for recorder body capture. SSE (`text/event-stream`) skips capture automatically.
|
|
49
|
+
The HTTP event and assertions are still recorded; the event omits the body.
|
|
50
|
+
`response.body.getReader().read()` returns a raw `done` flag and wrapped `value`.
|
|
51
|
+
BYOB readers, `tee()`, and `pipeThrough()` preserve provenance too. Release reader
|
|
52
|
+
locks and cancel streams when finished, as with native fetch.
|
|
53
|
+
|
|
18
54
|
## Electron apps
|
|
19
55
|
|
|
20
56
|
Use `electron()` to build and run a real Linux Electron app under Xvfb, with
|
|
@@ -54,11 +90,15 @@ interactions and replays the app with window chrome derived from its Electron
|
|
|
54
90
|
configuration. Child tests using `dependsOn` inherit the live app state,
|
|
55
91
|
including unsaved input, in their own isolated environment.
|
|
56
92
|
|
|
57
|
-
The app must declare Electron in `package.json
|
|
93
|
+
The app must declare Electron in `package.json` (its own or, with `context`
|
|
94
|
+
set to a monorepo root, the root one). Defaults are `npm ci` with a
|
|
58
95
|
package lock (otherwise `npm install`), `npm run build`, and the package's
|
|
59
96
|
`main` entry. Use `appDir`, `installCommand`, `buildCommand`, `entry`, and
|
|
60
97
|
`nodeVersion` to customize the build. `env` supplies runtime variables;
|
|
61
|
-
`dependsOn` waits for backend services in the same environment.
|
|
98
|
+
`dependsOn` waits for backend services in the same environment. Expose the
|
|
99
|
+
backend through the built-in proxy (`tls: [{ hostname, port }]`) and give the
|
|
100
|
+
app its `https://` URL: a renderer Content Security Policy usually refuses a
|
|
101
|
+
plain `http://` service address.
|
|
62
102
|
|
|
63
103
|
Applications need Linux-compatible dependencies and binaries. Native OS dialogs
|
|
64
104
|
and macOS-specific APIs are outside this basic support. Replays capture the
|
|
@@ -1,7 +1,54 @@
|
|
|
1
|
+
// Trust for the environment CA, applied per session. Chromium on Linux
|
|
2
|
+
// reads its own root store (NSS), never the container's mounted bundle, so
|
|
3
|
+
// the renderer rejects every certificate the environment mints while Node
|
|
4
|
+
// in the main process accepts them through NODE_EXTRA_CA_CERTS. This verify
|
|
5
|
+
// procedure accepts exactly the chains that CA issued for the requested
|
|
6
|
+
// host, and defers to Chromium's own verdict for everything else. An app
|
|
7
|
+
// that installs its own procedure later replaces it, as in production.
|
|
8
|
+
// Kept free of Electron so the unit test can run it under Bun.
|
|
9
|
+
export const ELECTRON_CA_TRUST = String.raw `
|
|
10
|
+
const { X509Certificate } = require('node:crypto');
|
|
11
|
+
function loadEnvironmentCa(path) {
|
|
12
|
+
try { return path ? new X509Certificate(require('node:fs').readFileSync(path)) : null; }
|
|
13
|
+
catch { return null; }
|
|
14
|
+
}
|
|
15
|
+
// request: { hostname, errorCode, certificate: { data, issuerCert? } }.
|
|
16
|
+
// Returns true only for a chain that ends at the CA and names the host.
|
|
17
|
+
function issuedByEnvironmentCa(ca, request) {
|
|
18
|
+
try {
|
|
19
|
+
const leaf = new X509Certificate(request.certificate.data);
|
|
20
|
+
const host = request.hostname;
|
|
21
|
+
const named = leaf.checkHost(host) !== undefined || leaf.checkIP(host) !== undefined;
|
|
22
|
+
if (!named) return false;
|
|
23
|
+
const chain = [leaf];
|
|
24
|
+
for (let c = request.certificate; c.issuerCert && c.issuerCert !== c && chain.length < 8; c = c.issuerCert) {
|
|
25
|
+
chain.push(new X509Certificate(c.issuerCert.data));
|
|
26
|
+
}
|
|
27
|
+
for (let i = 0; i < chain.length; i++) {
|
|
28
|
+
const cert = chain[i];
|
|
29
|
+
if (cert.fingerprint256 === ca.fingerprint256) return i > 0;
|
|
30
|
+
const issuer = chain[i + 1] ?? ca;
|
|
31
|
+
if (!cert.checkIssued(issuer) || !cert.verify(issuer.publicKey)) return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
} catch { return false; }
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
1
37
|
// Runs before the application's main entry, like Playwright's Electron loader.
|
|
2
38
|
// Observe constructor options; do not replace the app entry, preload, IPC, or OS.
|
|
3
39
|
export const ELECTRON_CHROME_HOOK = String.raw `
|
|
4
40
|
const electron = require('electron');
|
|
41
|
+
${ELECTRON_CA_TRUST}
|
|
42
|
+
const environmentCa = loadEnvironmentCa(process.env.NODE_EXTRA_CA_CERTS);
|
|
43
|
+
if (environmentCa) {
|
|
44
|
+
// Every session, including the default one and app-created partitions.
|
|
45
|
+
electron.app.on('session-created', session => {
|
|
46
|
+
session.setCertificateVerifyProc((request, callback) => {
|
|
47
|
+
if (request.errorCode === 0) return callback(0);
|
|
48
|
+
callback(issuedByEnvironmentCa(environmentCa, request) ? 0 : -3);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
5
52
|
const NativeBrowserWindow = electron.BrowserWindow;
|
|
6
53
|
const WrappedBrowserWindow = new Proxy(NativeBrowserWindow, {
|
|
7
54
|
construct(Target, args, NewTarget) {
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { type DesktopApp } from "../desktop.js";
|
|
2
2
|
export interface ElectronOptions {
|
|
3
|
-
/** Project-relative app directory; must contain package.json
|
|
3
|
+
/** Project-relative app directory; must contain package.json. Electron must
|
|
4
|
+
* be declared here or in the context's package.json. */
|
|
4
5
|
appDir?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Image build context, relative to the project root. Default `appDir`.
|
|
8
|
+
* Set it to the repository root when the app is one workspace of a
|
|
9
|
+
* monorepo and resolves packages from above its own directory. The
|
|
10
|
+
* context is copied to `/app`; `installCommand` runs at the context root,
|
|
11
|
+
* `buildCommand` and Electron run in the app directory below it.
|
|
12
|
+
*/
|
|
13
|
+
context?: string;
|
|
5
14
|
/** Debian Node image tag, default 24-bookworm-slim. */
|
|
6
15
|
nodeVersion?: string;
|
|
7
16
|
/** Build the main/preload/renderer output; default npm run build. */
|
|
@@ -31,6 +40,7 @@ export declare function electron(options?: ElectronOptions): {
|
|
|
31
40
|
command: string;
|
|
32
41
|
env: {
|
|
33
42
|
SPECTEST_ELECTRON_ENTRY: string;
|
|
43
|
+
SPECTEST_ELECTRON_APP_DIR: string;
|
|
34
44
|
};
|
|
35
45
|
dependsOn: string[] | undefined;
|
|
36
46
|
ports: number[];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { posix } from "node:path";
|
|
2
3
|
import { resolveProjectPath } from "../project-files.js";
|
|
3
4
|
import { desktopApp } from "../desktop.js";
|
|
4
5
|
import { ELECTRON_CHROME_HOOK } from "./electron-chrome.js";
|
|
@@ -9,13 +10,14 @@ export const ELECTRON_LAUNCHER = String.raw `
|
|
|
9
10
|
import http from 'node:http';
|
|
10
11
|
import { spawn } from 'node:child_process';
|
|
11
12
|
import { createRequire } from 'node:module';
|
|
12
|
-
const
|
|
13
|
+
const appDir = process.env.SPECTEST_ELECTRON_APP_DIR || '/app';
|
|
14
|
+
const require = createRequire(appDir + '/package.json');
|
|
13
15
|
const child = spawn(require('electron'), [
|
|
14
16
|
...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
|
|
15
17
|
'--disable-dev-shm-usage', '--remote-debugging-port=9222',
|
|
16
18
|
'-r', '/spectest-electron-chrome.cjs',
|
|
17
19
|
process.env.SPECTEST_ELECTRON_ENTRY || '.',
|
|
18
|
-
], { cwd:
|
|
20
|
+
], { cwd: appDir, stdio: 'inherit', env: process.env });
|
|
19
21
|
const upstream = req => ({
|
|
20
22
|
host: '127.0.0.1', port: 9222, path: req.url, method: req.method,
|
|
21
23
|
headers: { ...req.headers, host: '127.0.0.1:9222' },
|
|
@@ -50,19 +52,41 @@ child.on('error', error => { console.error(error); process.exit(1); });
|
|
|
50
52
|
child.on('exit', code => process.exit(code ?? 1));
|
|
51
53
|
for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => child.kill(signal));
|
|
52
54
|
`;
|
|
55
|
+
/** Read a package.json and tell whether it declares electron. */
|
|
56
|
+
function declaresElectron(manifest) {
|
|
57
|
+
if (!existsSync(manifest))
|
|
58
|
+
return false;
|
|
59
|
+
const pkg = JSON.parse(readFileSync(manifest, "utf8"));
|
|
60
|
+
return Boolean(pkg.dependencies?.electron || pkg.devDependencies?.electron);
|
|
61
|
+
}
|
|
62
|
+
/** The app directory as a path inside the build context: "." or "a/b". */
|
|
63
|
+
function appPathInContext(appDir, context) {
|
|
64
|
+
const rel = posix.relative(posix.normalize(context), posix.normalize(appDir));
|
|
65
|
+
if (rel === "")
|
|
66
|
+
return ".";
|
|
67
|
+
if (rel.startsWith("..") || posix.isAbsolute(rel)) {
|
|
68
|
+
throw new Error(`electron(): appDir ${JSON.stringify(appDir)} must be inside context ${JSON.stringify(context)}`);
|
|
69
|
+
}
|
|
70
|
+
return rel;
|
|
71
|
+
}
|
|
53
72
|
/** Real Linux Electron under Xvfb, with a generated image and graphical replay.
|
|
54
73
|
* Like expo(), returns an app handle for the matching context method.
|
|
55
74
|
* Requires Linux-compatible application dependencies and binaries. */
|
|
56
75
|
export function electron(options = {}) {
|
|
57
76
|
const appDir = options.appDir ?? ".";
|
|
77
|
+
const context = options.context ?? appDir;
|
|
78
|
+
const appPath = appPathInContext(appDir, context);
|
|
58
79
|
const manifest = resolveProjectPath(`${appDir}/package.json`);
|
|
59
80
|
if (!existsSync(manifest))
|
|
60
81
|
throw new Error(`electron(): no package.json in ${appDir}`);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
82
|
+
// A monorepo declares electron in the workspace or at the root; both work
|
|
83
|
+
// because the app resolves `electron` upward from its own directory.
|
|
84
|
+
if (!declaresElectron(manifest) && !declaresElectron(resolveProjectPath(`${context}/package.json`))) {
|
|
85
|
+
throw new Error("electron(): declare electron in the app's dependencies or devDependencies" +
|
|
86
|
+
(context === appDir ? "" : ` (in ${appDir} or in the context ${context})`));
|
|
64
87
|
}
|
|
65
|
-
|
|
88
|
+
// The install runs at the context root, so that is where the lockfile counts.
|
|
89
|
+
const locked = existsSync(resolveProjectPath(`${context}/package-lock.json`));
|
|
66
90
|
const node = options.nodeVersion ?? "24-bookworm-slim";
|
|
67
91
|
if (!/^[\w.-]+$/.test(node))
|
|
68
92
|
throw new Error("electron(): invalid Node image tag");
|
|
@@ -71,18 +95,22 @@ export function electron(options = {}) {
|
|
|
71
95
|
if ([install, build].some(command => /[\r\n]/.test(command))) {
|
|
72
96
|
throw new Error("electron(): build/install commands must be single-line shell commands");
|
|
73
97
|
}
|
|
98
|
+
// Electron can be hoisted to the context root, so find its install script
|
|
99
|
+
// through Node's resolution from the app directory instead of a fixed path.
|
|
100
|
+
const inApp = appPath === "." ? "" : `cd ${appPath} && `;
|
|
101
|
+
const fetchElectron = `${inApp}node "$(node -p "require.resolve('electron/install.js')")"`;
|
|
74
102
|
return {
|
|
75
103
|
image: {
|
|
76
104
|
type: "dockerfile",
|
|
77
|
-
context
|
|
105
|
+
context,
|
|
78
106
|
exclude: ["node_modules", "**/node_modules", "out", "test-results", ".data"],
|
|
79
107
|
content: `FROM node:${node}
|
|
80
108
|
RUN apt-get update && apt-get install -y --no-install-recommends xvfb xauth libgtk-3-0 libnss3 libgbm1 libasound2 fonts-liberation ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
81
109
|
WORKDIR /app
|
|
82
110
|
COPY . .
|
|
83
111
|
RUN ${install}
|
|
84
|
-
RUN
|
|
85
|
-
RUN ${build}
|
|
112
|
+
RUN ${fetchElectron}
|
|
113
|
+
RUN ${inApp}${build}
|
|
86
114
|
`,
|
|
87
115
|
},
|
|
88
116
|
files: [
|
|
@@ -90,7 +118,11 @@ RUN ${build}
|
|
|
90
118
|
{ path: "/spectest-electron-chrome.cjs", content: ELECTRON_CHROME_HOOK },
|
|
91
119
|
],
|
|
92
120
|
command: "xvfb-run -a node /spectest-electron.mjs",
|
|
93
|
-
env: {
|
|
121
|
+
env: {
|
|
122
|
+
...options.env,
|
|
123
|
+
SPECTEST_ELECTRON_ENTRY: options.entry ?? ".",
|
|
124
|
+
SPECTEST_ELECTRON_APP_DIR: appPath === "." ? "/app" : `/app/${appPath}`,
|
|
125
|
+
},
|
|
94
126
|
dependsOn: options.dependsOn,
|
|
95
127
|
ports: [9223],
|
|
96
128
|
readyCheck: { type: "http", port: 9223, path: "/json/version", timeoutSecs: 60 },
|
package/dist/daemon.js
CHANGED
|
@@ -51,6 +51,7 @@ import { bindInstrumentationScope, createInstrumentationScope, runInstrumented,
|
|
|
51
51
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
52
52
|
import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
|
|
53
53
|
import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
|
|
54
|
+
import { WS_BRIDGE_HANDLERS, openUpstreamWebSocket, parseSubprotocols, upstreamHandshakeHeaders, } from "./harness/ws-bridge.js";
|
|
54
55
|
import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectCertName, wildcardSuffix, } from "./harness/hostmatch.js";
|
|
55
56
|
import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
|
|
56
57
|
import { startTlsTerminator } from "./harness/tls-terminator.js";
|
|
@@ -1977,8 +1978,8 @@ async function unbindRuntimeTls(hostname) {
|
|
|
1977
1978
|
* Spin up one Bun.serve listener bound to (port, optional TLS) that
|
|
1978
1979
|
* dispatches every request to the matching Route by Host header.
|
|
1979
1980
|
*
|
|
1980
|
-
* Shared by the HTTP and HTTPS branches. Also
|
|
1981
|
-
* handler so reverse-proxy targets can transparently bridge WS
|
|
1981
|
+
* Shared by the HTTP and HTTPS branches. Also carries the `websocket`
|
|
1982
|
+
* handler set so reverse-proxy targets can transparently bridge WS
|
|
1982
1983
|
* upgrades through to their upstream service.
|
|
1983
1984
|
*/
|
|
1984
1985
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -2016,81 +2017,9 @@ serve = { proto: "http" }) {
|
|
|
2016
2017
|
idleTimeout: 0,
|
|
2017
2018
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2018
2019
|
fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, proto),
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
const data = ws.data;
|
|
2023
|
-
try {
|
|
2024
|
-
const upstream = new WebSocket(data.upstreamUrl);
|
|
2025
|
-
// ArrayBuffer so binary frames can be ws.send()'d to the
|
|
2026
|
-
// downstream client verbatim — Blob would need an extra
|
|
2027
|
-
// .arrayBuffer() round-trip on every message.
|
|
2028
|
-
upstream.binaryType = "arraybuffer";
|
|
2029
|
-
data.upstream = upstream;
|
|
2030
|
-
upstream.addEventListener("open", () => {
|
|
2031
|
-
for (const m of data.pending)
|
|
2032
|
-
upstream.send(m);
|
|
2033
|
-
data.pending = [];
|
|
2034
|
-
});
|
|
2035
|
-
upstream.addEventListener("message", (ev) => {
|
|
2036
|
-
try {
|
|
2037
|
-
ws.send(ev.data);
|
|
2038
|
-
}
|
|
2039
|
-
catch {
|
|
2040
|
-
/* client gone */
|
|
2041
|
-
}
|
|
2042
|
-
});
|
|
2043
|
-
upstream.addEventListener("close", (ev) => {
|
|
2044
|
-
try {
|
|
2045
|
-
ws.close(ev.code, ev.reason);
|
|
2046
|
-
}
|
|
2047
|
-
catch {
|
|
2048
|
-
/* already closed */
|
|
2049
|
-
}
|
|
2050
|
-
});
|
|
2051
|
-
upstream.addEventListener("error", () => {
|
|
2052
|
-
try {
|
|
2053
|
-
ws.close(1011, "upstream error");
|
|
2054
|
-
}
|
|
2055
|
-
catch {
|
|
2056
|
-
/* already closed */
|
|
2057
|
-
}
|
|
2058
|
-
});
|
|
2059
|
-
}
|
|
2060
|
-
catch (err) {
|
|
2061
|
-
// eslint-disable-next-line no-console
|
|
2062
|
-
console.warn(`[ingress] ws upstream open failed for ${data.upstreamUrl}:`, err);
|
|
2063
|
-
try {
|
|
2064
|
-
ws.close(1011, "upstream open failed");
|
|
2065
|
-
}
|
|
2066
|
-
catch {
|
|
2067
|
-
/* ignore */
|
|
2068
|
-
}
|
|
2069
|
-
}
|
|
2070
|
-
},
|
|
2071
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2072
|
-
message(ws, message) {
|
|
2073
|
-
const data = ws.data;
|
|
2074
|
-
const payload = typeof message === "string" ? message : new Uint8Array(message);
|
|
2075
|
-
if (data.upstream && data.upstream.readyState === WebSocket.OPEN) {
|
|
2076
|
-
data.upstream.send(payload);
|
|
2077
|
-
}
|
|
2078
|
-
else {
|
|
2079
|
-
// Buffer until the upstream finishes its handshake.
|
|
2080
|
-
data.pending.push(payload);
|
|
2081
|
-
}
|
|
2082
|
-
},
|
|
2083
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2084
|
-
close(ws, code, reason) {
|
|
2085
|
-
const data = ws.data;
|
|
2086
|
-
try {
|
|
2087
|
-
data.upstream?.close(code, reason);
|
|
2088
|
-
}
|
|
2089
|
-
catch {
|
|
2090
|
-
/* ignore */
|
|
2091
|
-
}
|
|
2092
|
-
},
|
|
2093
|
-
},
|
|
2020
|
+
// The frame relay for bridged WebSocket upgrades; `proxyToService`
|
|
2021
|
+
// opens the upstream before it upgrades the client (harness/ws-bridge.ts).
|
|
2022
|
+
websocket: WS_BRIDGE_HANDLERS,
|
|
2094
2023
|
};
|
|
2095
2024
|
return Bun.serve(opts);
|
|
2096
2025
|
}
|
|
@@ -2181,8 +2110,12 @@ function ingressClientIp(server, req, proto) {
|
|
|
2181
2110
|
* Reverse-proxy a request to `http://<service>:<port>` on
|
|
2182
2111
|
* `spectest-net`. Handles plain HTTP/1.1 + 2 and WebSocket upgrades:
|
|
2183
2112
|
*
|
|
2184
|
-
* - WS upgrade requests
|
|
2185
|
-
*
|
|
2113
|
+
* - WS upgrade requests open the upstream WebSocket first, with the
|
|
2114
|
+
* client's full `Sec-WebSocket-Protocol` list and its forwardable
|
|
2115
|
+
* headers (cookies, authorization, origin, x-forwarded-*). Only after
|
|
2116
|
+
* that handshake succeeds is the client upgraded, with the protocol
|
|
2117
|
+
* the upstream selected; a refused upstream handshake is a 502, not a
|
|
2118
|
+
* 101 followed by a close. The open upstream rides on `ws.data`; the shared `websocket`
|
|
2186
2119
|
* handler opens the upstream and bridges frames both ways.
|
|
2187
2120
|
* - Plain requests pass through via `fetch()` with hop-by-hop
|
|
2188
2121
|
* headers stripped; the response body is a ReadableStream returned
|
|
@@ -2205,16 +2138,38 @@ server, service, port, listenerLabel, proto) {
|
|
|
2205
2138
|
const upgrade = req.headers.get("upgrade")?.toLowerCase() ?? "";
|
|
2206
2139
|
if (upgrade === "websocket") {
|
|
2207
2140
|
const upstreamUrl = `ws://${await proxyUpstreamHost(service)}:${port}${upstreamPath}`;
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2141
|
+
// The upstream handshake carries what the client's did: the whole
|
|
2142
|
+
// subprotocol list (a console ticket often rides as the second entry)
|
|
2143
|
+
// and the forwardable headers. Same provenance stamps as the HTTP path.
|
|
2144
|
+
const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
|
|
2145
|
+
const headers = upstreamHandshakeHeaders(req.headers, {
|
|
2146
|
+
service,
|
|
2147
|
+
port,
|
|
2148
|
+
proto,
|
|
2149
|
+
clientIp: ingressClientIp(server, req, proto),
|
|
2150
|
+
});
|
|
2151
|
+
const opened = await openUpstreamWebSocket(upstreamUrl, { protocols, headers });
|
|
2152
|
+
if (!opened.ok) {
|
|
2153
|
+
return new Response(`spectest-daemon: ${opened.reason} (${upstreamUrl}) on ${listenerLabel}\n`, { status: 502, headers: { "content-type": "text/plain" } });
|
|
2154
|
+
}
|
|
2155
|
+
const wsData = { upstreamUrl, upstream: opened.socket };
|
|
2156
|
+
// Echo the protocol the upstream selected; Bun would otherwise pick
|
|
2157
|
+
// the client's first entry by itself, which can differ.
|
|
2158
|
+
const selected = opened.socket.protocol;
|
|
2159
|
+
const ok = server.upgrade(req, {
|
|
2160
|
+
data: wsData,
|
|
2161
|
+
headers: selected ? { "sec-websocket-protocol": selected } : undefined,
|
|
2162
|
+
});
|
|
2214
2163
|
if (ok) {
|
|
2215
2164
|
// Bun has already taken over the response — return a stub.
|
|
2216
2165
|
return new Response(null, { status: 101 });
|
|
2217
2166
|
}
|
|
2167
|
+
try {
|
|
2168
|
+
opened.socket.close(1011, "client upgrade refused");
|
|
2169
|
+
}
|
|
2170
|
+
catch {
|
|
2171
|
+
/* ignore */
|
|
2172
|
+
}
|
|
2218
2173
|
return new Response(`spectest-daemon: ws upgrade refused on ${listenerLabel}\n`, { status: 426, headers: { "content-type": "text/plain" } });
|
|
2219
2174
|
}
|
|
2220
2175
|
const fwdHeaders = new Headers();
|
package/dist/harness/fetch.js
CHANGED
|
@@ -61,8 +61,7 @@ export function isTransportError(err) {
|
|
|
61
61
|
export function installFetchWrapper() {
|
|
62
62
|
const original = globalThis.fetch;
|
|
63
63
|
// SDK internals (the MCP client, its OAuth flow) must not see the
|
|
64
|
-
// wrapper:
|
|
65
|
-
// body to the end, which never finishes for an SSE stream.
|
|
64
|
+
// wrapper: its response properties and body readers return wrapped values.
|
|
66
65
|
setRawFetch(original);
|
|
67
66
|
const wrappedFn = async (input, init) => {
|
|
68
67
|
const scope = currentInstrumentationScope();
|
|
@@ -85,7 +84,12 @@ export function installFetchWrapper() {
|
|
|
85
84
|
const contentType = res.headers.get("content-type");
|
|
86
85
|
const contentLength = () => parseContentLength(res.headers.get("content-length"));
|
|
87
86
|
const textual = isTextualContentType(contentType);
|
|
88
|
-
|
|
87
|
+
const streaming = contentType?.split(";")[0]?.trim().toLowerCase() === "text/event-stream";
|
|
88
|
+
if (init?.captureBody === false || streaming) {
|
|
89
|
+
// Return on headers: an open-ended stream must not be drained (or
|
|
90
|
+
// cloned into an unbounded background buffer) by the recorder.
|
|
91
|
+
}
|
|
92
|
+
else if (textual === false) {
|
|
89
93
|
responseBody = omittedBody("binary", contentType, contentLength());
|
|
90
94
|
}
|
|
91
95
|
else {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ingress WebSocket bridge: one client WebSocket on an ingress
|
|
3
|
+
* listener, one upstream WebSocket to the service, frames copied in both
|
|
4
|
+
* directions.
|
|
5
|
+
*
|
|
6
|
+
* The handshake is the part that matters. A client that sends
|
|
7
|
+
* `Sec-WebSocket-Protocol: console, <ticket>` expects the upstream to see
|
|
8
|
+
* the full list, and expects to get back the one the upstream selected.
|
|
9
|
+
* The first version of this bridge opened the upstream with no protocols
|
|
10
|
+
* and no headers, so a ticket or a cookie that rode on the handshake never
|
|
11
|
+
* reached the service, and the service refused the connection after the
|
|
12
|
+
* client already saw a 101. So the bridge now opens the upstream FIRST,
|
|
13
|
+
* with the client's protocol list and forwardable headers, waits for the
|
|
14
|
+
* upstream handshake, and only then upgrades the client with the protocol
|
|
15
|
+
* the upstream selected. An upstream that refuses the handshake becomes a
|
|
16
|
+
* 502 to the client, the same as the HTTP proxy path.
|
|
17
|
+
*
|
|
18
|
+
* `daemon.ts` owns the listener and the route lookup; this module owns
|
|
19
|
+
* the handshake and the frame relay so the two can be tested on loopback
|
|
20
|
+
* without a VM.
|
|
21
|
+
*/
|
|
22
|
+
/** Per-bridge context stored on `ws.data` of the client socket. */
|
|
23
|
+
export interface WsBridgeData {
|
|
24
|
+
upstreamUrl: string;
|
|
25
|
+
/** Open by the time the client socket exists: the handshake awaited it. */
|
|
26
|
+
upstream: WebSocket;
|
|
27
|
+
}
|
|
28
|
+
/** `Sec-WebSocket-Protocol` is a comma-separated list; each token is
|
|
29
|
+
* trimmed, empty tokens are dropped, order is kept (RFC 6455 §4.1 lets
|
|
30
|
+
* the server pick from the list, so order is the client's preference). */
|
|
31
|
+
export declare function parseSubprotocols(header: string | null | undefined): string[];
|
|
32
|
+
/**
|
|
33
|
+
* The headers the upstream handshake carries: everything forwardable from
|
|
34
|
+
* the client's request (cookies, authorization, origin, user-agent …),
|
|
35
|
+
* minus the client's own handshake fields, plus the proxy provenance
|
|
36
|
+
* headers and a Host that names the service.
|
|
37
|
+
*/
|
|
38
|
+
export declare function upstreamHandshakeHeaders(req: Headers, opts: {
|
|
39
|
+
service: string;
|
|
40
|
+
port: number;
|
|
41
|
+
proto: string;
|
|
42
|
+
clientIp?: string;
|
|
43
|
+
}): Record<string, string>;
|
|
44
|
+
export type UpstreamOpen = {
|
|
45
|
+
ok: true;
|
|
46
|
+
socket: WebSocket;
|
|
47
|
+
} | {
|
|
48
|
+
ok: false;
|
|
49
|
+
reason: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Open the upstream WebSocket and wait for its handshake. Resolves once
|
|
53
|
+
* the socket is open, or once it failed; the handshake listeners are
|
|
54
|
+
* removed either way so a later close does not fire them again.
|
|
55
|
+
*/
|
|
56
|
+
export declare function openUpstreamWebSocket(url: string, opts: {
|
|
57
|
+
protocols: string[];
|
|
58
|
+
headers: Record<string, string>;
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
}): Promise<UpstreamOpen>;
|
|
61
|
+
/** The minimum of Bun's ServerWebSocket that the relay uses. */
|
|
62
|
+
interface ClientSocket {
|
|
63
|
+
data: WsBridgeData;
|
|
64
|
+
send(data: string | ArrayBuffer | Uint8Array): unknown;
|
|
65
|
+
close(code?: number, reason?: string): unknown;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The `websocket` handler set for `Bun.serve`. The upstream is already
|
|
69
|
+
* open when `open()` runs, so frames go straight through in both
|
|
70
|
+
* directions, and a close on either side closes the other with the same
|
|
71
|
+
* code and reason.
|
|
72
|
+
*/
|
|
73
|
+
export declare const WS_BRIDGE_HANDLERS: {
|
|
74
|
+
open(ws: ClientSocket): void;
|
|
75
|
+
message(ws: ClientSocket, message: string | Buffer): void;
|
|
76
|
+
close(ws: ClientSocket, code: number, reason: string): void;
|
|
77
|
+
};
|
|
78
|
+
export {};
|