@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 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`. Defaults are `npm ci` with a
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 +1,2 @@
1
+ export declare const ELECTRON_CA_TRUST: string;
1
2
  export declare const ELECTRON_CHROME_HOOK: string;
@@ -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 and Electron. */
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 require = createRequire('/app/package.json');
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: '/app', stdio: 'inherit', env: process.env });
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
- const pkg = JSON.parse(readFileSync(manifest, "utf8"));
62
- if (!pkg.dependencies?.electron && !pkg.devDependencies?.electron) {
63
- throw new Error("electron(): declare electron in the app's dependencies or devDependencies");
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
- const locked = existsSync(resolveProjectPath(`${appDir}/package-lock.json`));
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: appDir,
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 node node_modules/electron/install.js
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: { ...options.env, SPECTEST_ELECTRON_ENTRY: options.entry ?? "." },
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 exports a `websocket`
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
- websocket: {
2020
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2021
- async open(ws) {
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 get routed through `server.upgrade()`, with
2185
- * the upstream URL stashed on `ws.data`. The shared `websocket`
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
- const wsData = {
2209
- upstreamUrl,
2210
- upstream: null,
2211
- pending: [],
2212
- };
2213
- const ok = server.upgrade(req, { data: wsData });
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();
@@ -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: a wrapped `res.ok` is an object, and the wrapper reads every
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
- if (textual === false) {
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 {};