@specific.dev/spectest 0.93.0 → 0.94.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.
@@ -1,22 +1,5 @@
1
- // `email()` — a real SMTP server as a spectest service. The app under test
2
- // points its SMTP transport at `<key>:1025` and sends mail exactly as it
3
- // would in production; every message is captured instead of delivered.
4
- // Tests read the mailbox through typed helpers on `ctx.svc.<key>`
5
- // (`lastEmail`, `emails`, `clear`), each recorded as an `email` event on
6
- // the test timeline — single-message ops embed the full captured message
7
- // (HTML body included) so the dashboard renders the actual email the test
8
- // asserted against, and returns come back inspect-wrapped so
9
- // `expect(mail.subject)` links under the step. To wait for a message to
10
- // *arrive*, use the standard `ctx.poll` with `lastEmail` as the predicate
11
- // (it returns `undefined` while the mailbox is empty); the winning
12
- // iteration's `email` event survives poll truncation, so the message still
13
- // renders nested under the wait step.
14
- //
15
- // Captured mail lives in the server's process memory, so it snapshots and
16
- // forks with the rest of the environment: a `dependsOn` child inherits the
17
- // parent's mailbox, sibling forks never see each other's messages — the
18
- // same isolation contract as fake state.
19
- import { pauseRecording, recordEmail, reserveEvent, resumeRecording, truncateUtf8, } from "../recorder.js";
1
+ import { rawFetch } from "../harness/raw-fetch.js";
2
+ import { recordEmail, reserveEvent, truncateUtf8, } from "../recorder.js";
20
3
  import { wrap } from "../inspect.js";
21
4
  const DEFAULT_IMAGE = "axllent/mailpit:v1.30";
22
5
  const SMTP_PORT = 1025;
@@ -140,29 +123,21 @@ async function instrumented(service, op, query, body) {
140
123
  throw err;
141
124
  }
142
125
  }
143
- /** Query the mail server's HTTP API. Recording is paused around the fetch
144
- * so these internal polls don't land as `http` events on the timeline —
145
- * the helper records one consolidated `email` event instead. */
126
+ /** Internal HTTP stays native; the public helper records one email event. */
146
127
  async function api(base, path, init) {
147
- pauseRecording();
128
+ const res = await rawFetch(`${base}${path}`, init);
129
+ if (!res.ok) {
130
+ throw new Error(`email server API ${path} failed: HTTP ${res.status}`);
131
+ }
132
+ const text = await res.text();
133
+ if (text === "")
134
+ return undefined;
148
135
  try {
149
- const res = await fetch(`${base}${path}`, init);
150
- if (!res.ok) {
151
- throw new Error(`email server API ${path} failed: HTTP ${res.status}`);
152
- }
153
- const text = await res.text();
154
- if (text === "")
155
- return undefined;
156
- try {
157
- return JSON.parse(text);
158
- }
159
- catch {
160
- // Mutating endpoints reply with a plain-text acknowledgement.
161
- return text;
162
- }
136
+ return JSON.parse(text);
163
137
  }
164
- finally {
165
- resumeRecording();
138
+ catch {
139
+ // Mutating endpoints reply with a plain-text acknowledgement.
140
+ return text;
166
141
  }
167
142
  }
168
143
  function addresses(v) {
@@ -179,9 +179,8 @@ Bun.serve({
179
179
  // own IP and never sends a provider name.
180
180
  if (url.pathname === "/healthz") return new Response("ok\n");
181
181
 
182
- // The daemon reverse-proxies us and rewrites `Host` to the service-net
183
- // name, so the provider the client asked for only survives in
184
- // `X-Forwarded-Host`. Read that first; `Host` covers a direct call.
182
+ // Ingress preserves Host and also stamps X-Forwarded-Host with the
183
+ // requested authority. Host covers direct calls to the emulator.
185
184
  const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "")
186
185
  .toLowerCase()
187
186
  .replace(/:\d+$/, "");
@@ -17,9 +17,9 @@ export interface S3Options {
17
17
  * via the daemon's TLS-terminating reverse proxy. Optional — defaults to none;
18
18
  * the store is always reachable at `http://<key>:9090` regardless. Set this
19
19
  * only when the app under test hardcodes a specific prod S3 endpoint it can't
20
- * be told to override (`https://<bucket>.s3.amazonaws.com`, a provider's
21
- * storage host): the daemon mints a cert for each host and proxies to the
22
- * store's plain HTTP. The store ignores SigV4, so the Host rewrite is harmless.
20
+ * be told to override (a provider's storage host; requests must still use
21
+ * path-style addressing): the daemon mints a cert for each host and proxies to the
22
+ * store's plain HTTP while preserving the requested Host.
23
23
  */
24
24
  hosts?: string[];
25
25
  /** Access key id the helper client signs with. The store ignores SigV4, so
@@ -1,3 +1,4 @@
1
+ import { rawFetch } from "../harness/raw-fetch.js";
1
2
  import { S3Client } from "../s3.js";
2
3
  // Backing image, pinned internally (not user-configurable). adobe/s3mock 5.1.0
3
4
  // is the current release; avoid exactly 5.0.0, which crashed at startup on
@@ -75,7 +76,7 @@ export function s3(opts = {}) {
75
76
  // CreateBucket is a plain `PUT` on the bucket root; the store
76
77
  // returns 200 (or 409 if it already exists — idempotent enough to
77
78
  // ignore).
78
- const res = await fetch(`http://${name}:${S3_PORT}/${bucket}`, {
79
+ const res = await rawFetch(`http://${name}:${S3_PORT}/${bucket}`, {
79
80
  method: "PUT",
80
81
  });
81
82
  if (!res.ok && res.status !== 409) {
@@ -1,3 +1,4 @@
1
+ import { rawFetch } from "../harness/raw-fetch.js";
1
2
  // A ready-to-use, self-hosted **Supabase** stack, expressed as a set of
2
3
  // spectest services (one container each) wired together on spectest-net.
3
4
  //
@@ -1807,7 +1808,7 @@ export function supabase(opts = {}) {
1807
1808
  // snapshot and a test's diff is what its requests mutate.
1808
1809
  setup: async () => {
1809
1810
  const t0 = Date.now();
1810
- const res = await fetch(`http://${g.key("functions")}:9000/_spectest/warm`);
1811
+ const res = await rawFetch(`http://${g.key("functions")}:9000/_spectest/warm`);
1811
1812
  if (!res.ok) {
1812
1813
  console.warn(`supabase(): edge functions warm-up answered ${res.status}; functions boot on first call instead.`);
1813
1814
  return;
package/dist/daemon.js CHANGED
@@ -56,7 +56,7 @@ import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
56
56
  import { bindInstrumentationScope, createInstrumentationScope, runInstrumented, runUninstrumented, } from "./harness/instrumentation-scope.js";
57
57
  import { encodeRegistry } from "./harness/names-registry.js";
58
58
  import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
59
- import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
59
+ import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, upstreamRequestHeaders, } from "./harness/http-proxy.js";
60
60
  import { WS_BRIDGE_HANDLERS, openUpstreamWebSocket, parseSubprotocols, upstreamHandshakeHeaders, } from "./harness/ws-bridge.js";
61
61
  import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectCertName, wildcardSuffix, } from "./harness/hostmatch.js";
62
62
  import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
@@ -2098,8 +2098,6 @@ server, service, port, listenerLabel, proto) {
2098
2098
  // and the forwardable headers. Same provenance stamps as the HTTP path.
2099
2099
  const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
2100
2100
  const headers = upstreamHandshakeHeaders(req.headers, {
2101
- service,
2102
- port,
2103
2101
  proto,
2104
2102
  clientIp: ingressClientIp(server, req, proto),
2105
2103
  });
@@ -2127,28 +2125,10 @@ server, service, port, listenerLabel, proto) {
2127
2125
  }
2128
2126
  return new Response(`spectest-daemon: ws upgrade refused on ${listenerLabel}\n`, { status: 426, headers: { "content-type": "text/plain" } });
2129
2127
  }
2130
- const fwdHeaders = new Headers();
2131
- for (const [k, v] of req.headers) {
2132
- if (HOP_BY_HOP_HEADERS.has(k.toLowerCase()))
2133
- continue;
2134
- fwdHeaders.append(k, v);
2135
- }
2136
- // Standard reverse-proxy provenance headers: the upstream sees the
2137
- // public scheme/host it was reached through and the client's address,
2138
- // even though we rewrite Host below to the service-net name.
2139
- const clientIp = ingressClientIp(server, req, proto);
2140
- const priorXff = req.headers.get("x-forwarded-for");
2141
- const xff = clientIp ? (priorXff ? `${priorXff}, ${clientIp}` : clientIp) : priorXff;
2142
- if (xff)
2143
- fwdHeaders.set("x-forwarded-for", xff);
2144
- fwdHeaders.set("x-forwarded-proto", proto);
2145
- const publicHost = req.headers.get("host");
2146
- if (publicHost)
2147
- fwdHeaders.set("x-forwarded-host", publicHost);
2148
- // Override Host so the upstream sees its own service-net name, not
2149
- // the public hostname. Lets origin servers that vhost by Host header
2150
- // continue to find the right virtual host.
2151
- fwdHeaders.set("host", `${service}:${port}`);
2128
+ const fwdHeaders = upstreamRequestHeaders(req.headers, {
2129
+ proto,
2130
+ clientIp: ingressClientIp(server, req, proto),
2131
+ });
2152
2132
  // Fresh connection per upstream request — never reuse a pooled
2153
2133
  // keep-alive conn. Upstreams with short idle timeouts (uvicorn defaults
2154
2134
  // to 5s) close pooled connections under Bun's fetch, and the next
@@ -12,13 +12,18 @@
12
12
  * Forwarding `transfer-encoding` or `content-length` from a re-encoded
13
13
  * body is the classic way to produce a response the client cannot frame;
14
14
  * forwarding `connection` leaks our keep-alive intent to the upstream.
15
- * `host` is here because the proxy sets its own — the upstream must see
16
- * the service it actually is, not the public name the client asked for.
15
+ * `host` identifies the requested origin and must survive the proxy hop.
17
16
  */
18
17
  export declare const HOP_BY_HOP_HEADERS: ReadonlySet<string>;
19
18
  export declare function isHopByHop(name: string): boolean;
20
19
  /** Copy headers, dropping the ones that belong to this connection. */
21
20
  export declare function forwardableHeaders(headers: Headers): Headers;
21
+ /** Preserve the requested Host (including its port) while dialing the service's
22
+ * internal address. HTTP and WebSocket forwarding share this policy. */
23
+ export declare function upstreamRequestHeaders(headers: Headers, opts: {
24
+ proto: string;
25
+ clientIp?: string;
26
+ }): Headers;
22
27
  /**
23
28
  * A CORS preflight is an `OPTIONS` carrying both `Origin` and
24
29
  * `Access-Control-Request-Method`. An `OPTIONS` without them is a normal
@@ -12,8 +12,7 @@
12
12
  * Forwarding `transfer-encoding` or `content-length` from a re-encoded
13
13
  * body is the classic way to produce a response the client cannot frame;
14
14
  * forwarding `connection` leaks our keep-alive intent to the upstream.
15
- * `host` is here because the proxy sets its own — the upstream must see
16
- * the service it actually is, not the public name the client asked for.
15
+ * `host` identifies the requested origin and must survive the proxy hop.
17
16
  */
18
17
  export const HOP_BY_HOP_HEADERS = new Set([
19
18
  "connection",
@@ -24,7 +23,6 @@ export const HOP_BY_HOP_HEADERS = new Set([
24
23
  "trailers",
25
24
  "transfer-encoding",
26
25
  "upgrade",
27
- "host",
28
26
  ]);
29
27
  export function isHopByHop(name) {
30
28
  return HOP_BY_HOP_HEADERS.has(name.toLowerCase());
@@ -38,6 +36,22 @@ export function forwardableHeaders(headers) {
38
36
  });
39
37
  return out;
40
38
  }
39
+ /** Preserve the requested Host (including its port) while dialing the service's
40
+ * internal address. HTTP and WebSocket forwarding share this policy. */
41
+ export function upstreamRequestHeaders(headers, opts) {
42
+ const out = forwardableHeaders(headers);
43
+ const priorXff = headers.get("x-forwarded-for");
44
+ const xff = opts.clientIp
45
+ ? priorXff ? `${priorXff}, ${opts.clientIp}` : opts.clientIp
46
+ : priorXff;
47
+ if (xff)
48
+ out.set("x-forwarded-for", xff);
49
+ out.set("x-forwarded-proto", opts.proto);
50
+ const host = headers.get("host");
51
+ if (host)
52
+ out.set("x-forwarded-host", host);
53
+ return out;
54
+ }
41
55
  /**
42
56
  * A CORS preflight is an `OPTIONS` carrying both `Origin` and
43
57
  * `Access-Control-Request-Method`. An `OPTIONS` without them is a normal
@@ -33,11 +33,9 @@ export declare function parseSubprotocols(header: string | null | undefined): st
33
33
  * The headers the upstream handshake carries: everything forwardable from
34
34
  * the client's request (cookies, authorization, origin, user-agent …),
35
35
  * minus the client's own handshake fields, plus the proxy provenance
36
- * headers and a Host that names the service.
36
+ * headers and the original Host.
37
37
  */
38
38
  export declare function upstreamHandshakeHeaders(req: Headers, opts: {
39
- service: string;
40
- port: number;
41
39
  proto: string;
42
40
  clientIp?: string;
43
41
  }): Record<string, string>;
@@ -19,7 +19,7 @@
19
19
  * the handshake and the frame relay so the two can be tested on loopback
20
20
  * without a VM.
21
21
  */
22
- import { forwardableHeaders } from "./http-proxy.js";
22
+ import { upstreamRequestHeaders } from "./http-proxy.js";
23
23
  /** Headers that belong to the client's own WebSocket handshake. Bun's
24
24
  * WebSocket client mints its own key and version, and the protocol list
25
25
  * goes through the constructor option, so these never travel as plain
@@ -46,28 +46,15 @@ export function parseSubprotocols(header) {
46
46
  * The headers the upstream handshake carries: everything forwardable from
47
47
  * the client's request (cookies, authorization, origin, user-agent …),
48
48
  * minus the client's own handshake fields, plus the proxy provenance
49
- * headers and a Host that names the service.
49
+ * headers and the original Host.
50
50
  */
51
51
  export function upstreamHandshakeHeaders(req, opts) {
52
52
  const out = {};
53
- forwardableHeaders(req).forEach((value, key) => {
53
+ upstreamRequestHeaders(req, opts).forEach((value, key) => {
54
54
  if (HANDSHAKE_HEADERS.has(key))
55
55
  return;
56
56
  out[key] = value;
57
57
  });
58
- const priorXff = req.get("x-forwarded-for");
59
- const xff = opts.clientIp
60
- ? priorXff
61
- ? `${priorXff}, ${opts.clientIp}`
62
- : opts.clientIp
63
- : priorXff;
64
- if (xff)
65
- out["x-forwarded-for"] = xff;
66
- out["x-forwarded-proto"] = opts.proto;
67
- const publicHost = req.get("host");
68
- if (publicHost)
69
- out["x-forwarded-host"] = publicHost;
70
- out["host"] = `${opts.service}:${opts.port}`;
71
58
  return out;
72
59
  }
73
60
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.93.0",
3
+ "version": "0.94.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,3 +1,4 @@
1
+ import { rawFetch } from "../harness/raw-fetch.js";
1
2
  // `email()` — a real SMTP server as a spectest service. The app under test
2
3
  // points its SMTP transport at `<key>:1025` and sends mail exactly as it
3
4
  // would in production; every message is captured instead of delivered.
@@ -19,10 +20,8 @@
19
20
 
20
21
  import type { ServiceDefinition } from "../index.js";
21
22
  import {
22
- pauseRecording,
23
23
  recordEmail,
24
24
  reserveEvent,
25
- resumeRecording,
26
25
  truncateUtf8,
27
26
  type EmailEventMessage,
28
27
  type EmailEventSummary,
@@ -262,26 +261,19 @@ async function instrumented<T>(
262
261
  }
263
262
  }
264
263
 
265
- /** Query the mail server's HTTP API. Recording is paused around the fetch
266
- * so these internal polls don't land as `http` events on the timeline —
267
- * the helper records one consolidated `email` event instead. */
264
+ /** Internal HTTP stays native; the public helper records one email event. */
268
265
  async function api(base: string, path: string, init?: RequestInit): Promise<unknown> {
269
- pauseRecording();
266
+ const res = await rawFetch(`${base}${path}`, init);
267
+ if (!res.ok) {
268
+ throw new Error(`email server API ${path} failed: HTTP ${res.status}`);
269
+ }
270
+ const text = await res.text();
271
+ if (text === "") return undefined;
270
272
  try {
271
- const res = await fetch(`${base}${path}`, init);
272
- if (!res.ok) {
273
- throw new Error(`email server API ${path} failed: HTTP ${res.status}`);
274
- }
275
- const text = await res.text();
276
- if (text === "") return undefined;
277
- try {
278
- return JSON.parse(text);
279
- } catch {
280
- // Mutating endpoints reply with a plain-text acknowledgement.
281
- return text;
282
- }
283
- } finally {
284
- resumeRecording();
273
+ return JSON.parse(text);
274
+ } catch {
275
+ // Mutating endpoints reply with a plain-text acknowledgement.
276
+ return text;
285
277
  }
286
278
  }
287
279
 
@@ -179,9 +179,8 @@ Bun.serve({
179
179
  // own IP and never sends a provider name.
180
180
  if (url.pathname === "/healthz") return new Response("ok\n");
181
181
 
182
- // The daemon reverse-proxies us and rewrites `Host` to the service-net
183
- // name, so the provider the client asked for only survives in
184
- // `X-Forwarded-Host`. Read that first; `Host` covers a direct call.
182
+ // Ingress preserves Host and also stamps X-Forwarded-Host with the
183
+ // requested authority. Host covers direct calls to the emulator.
185
184
  const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "")
186
185
  .toLowerCase()
187
186
  .replace(/:\d+$/, "");
@@ -0,0 +1,109 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // Exercise public component hooks with the same dispatcher and async scope as
7
+ // bootstrap. A child process isolates global fetch and recorder state.
8
+ async function check(code: string, workspace?: string): Promise<void> {
9
+ const moduleUrl = (file: string) => JSON.stringify(new URL(file, import.meta.url).href);
10
+ const script = `
11
+ import { strict as assert } from "node:assert";
12
+ import { s3 } from ${moduleUrl("./s3.ts")};
13
+ import { supabase } from ${moduleUrl("./supabase.ts")};
14
+ import { emailHelpers } from ${moduleUrl("./email.ts")};
15
+ import { installFetchWrapper } from ${moduleUrl("../harness/fetch.ts")};
16
+ import { createInstrumentationScope, runInstrumented } from ${moduleUrl("../harness/instrumentation-scope.ts")};
17
+ import { startRecording, stopRecording } from ${moduleUrl("../recorder.ts")};
18
+ let respond = () => Response.json({});
19
+ const requests = [];
20
+ globalThis.fetch = async (input, init) => {
21
+ requests.push({ url: String(input), method: init?.method ?? "GET" });
22
+ return respond();
23
+ };
24
+ const restore = installFetchWrapper();
25
+ const scope = createInstrumentationScope(false);
26
+ try {
27
+ await runInstrumented(scope, async () => {
28
+ assert.equal((await fetch("http://probe/")).status.unwrap(), 200);
29
+ requests.length = 0;
30
+ ${code}
31
+ });
32
+ } finally { scope.active = false; restore(); }
33
+ `;
34
+ const child = Bun.spawn([process.execPath, "--eval", script], {
35
+ env: { ...process.env, ...(workspace ? { SPECTEST_WORKSPACE: workspace } : {}) },
36
+ stdout: "pipe", stderr: "pipe",
37
+ });
38
+ const [exit, stdout, stderr] = await Promise.all([
39
+ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
40
+ ]);
41
+ expect(exit, stdout + stderr).toBe(0);
42
+ }
43
+
44
+ test.each([200, 201, 409, 403, 500])("S3 setup handles HTTP %i inside a wrapped setup scope", async (status) => {
45
+ await check(`
46
+ respond = () => new Response("bucket response", { status: ${status} });
47
+ const setup = s3({ buckets: ["uploads", "assets"] }).setup;
48
+ ${status === 403 || status === 500
49
+ ? `await assert.rejects(() => setup({ name: "storage" }), /failed to create bucket uploads: ${status}/);
50
+ assert.equal(requests.length, 1);`
51
+ : `await setup({ name: "storage" });
52
+ assert.deepEqual(requests, [
53
+ { url: "http://storage:9090/uploads", method: "PUT" },
54
+ { url: "http://storage:9090/assets", method: "PUT" },
55
+ ]);`}
56
+ `);
57
+ });
58
+
59
+ test("S3 setup propagates transport failures", async () => {
60
+ await check(`
61
+ respond = () => { throw new Error("connection refused"); };
62
+ await assert.rejects(() => s3({ buckets: ["uploads"] }).setup({ name: "storage" }), /connection refused/);
63
+ `);
64
+ });
65
+
66
+ test("Supabase warm-up handles errors and reads native JSON in a wrapped setup scope", async () => {
67
+ const root = await mkdtemp(join(tmpdir(), "spectest-component-fetch-"));
68
+ try {
69
+ await mkdir(join(root, "supabase/functions/hello"), { recursive: true });
70
+ await writeFile(join(root, "supabase/functions/hello/index.ts"), "export default {};");
71
+ await check(`
72
+ const parts = supabase({ dir: "supabase" }).group.services({
73
+ name: "supabase", key: part => part === "gateway" ? "supabase" : "supabase-" + part,
74
+ });
75
+ const warnings = [], logs = [];
76
+ console.warn = (...args) => warnings.push(args.join(" "));
77
+ console.log = (...args) => logs.push(args.join(" "));
78
+ respond = () => new Response("unavailable", { status: 503 });
79
+ await parts.functions.setup();
80
+ assert.equal(warnings.length, 1);
81
+ assert.match(warnings[0], /warm-up answered 503/);
82
+ respond = () => Response.json({ mode: "shared", warmed: ["hello"], failed: {} });
83
+ await parts.functions.setup();
84
+ assert.equal(logs.length, 1);
85
+ assert.match(logs[0], /warmed 1 edge function in one isolate/);
86
+ `, root);
87
+ } finally { await rm(root, { recursive: true, force: true }); }
88
+ });
89
+
90
+ test("email helpers reject HTTP errors and retain their public event without internal HTTP events", async () => {
91
+ await check(`
92
+ const mail = emailHelpers("mail");
93
+ scope.recording = true;
94
+ startRecording();
95
+ respond = () => new Response("unavailable", { status: 503 });
96
+ await assert.rejects(() => mail.clear(), /email server API .* failed: HTTP 503/);
97
+ let events = stopRecording();
98
+ assert.deepEqual(events.map(e => e.kind), ["email"]);
99
+ assert.match(events[0].error, /HTTP 503/);
100
+ startRecording();
101
+ respond = () => Response.json({ messages: [] });
102
+ assert.deepEqual((await mail.emails()).unwrap(), []);
103
+ respond = () => new Response(null, { status: 204 });
104
+ await mail.clear();
105
+ events = stopRecording();
106
+ assert.deepEqual(events.map(e => e.kind), ["email", "email"]);
107
+ assert.equal(events.some(e => e.error), false);
108
+ `);
109
+ });
@@ -1,3 +1,4 @@
1
+ import { rawFetch } from "../harness/raw-fetch.js";
1
2
  import type { ServiceDefinition } from "../index.js";
2
3
  import { S3Client, type S3ClientLike } from "../s3.js";
3
4
 
@@ -19,9 +20,9 @@ export interface S3Options {
19
20
  * via the daemon's TLS-terminating reverse proxy. Optional — defaults to none;
20
21
  * the store is always reachable at `http://<key>:9090` regardless. Set this
21
22
  * only when the app under test hardcodes a specific prod S3 endpoint it can't
22
- * be told to override (`https://<bucket>.s3.amazonaws.com`, a provider's
23
- * storage host): the daemon mints a cert for each host and proxies to the
24
- * store's plain HTTP. The store ignores SigV4, so the Host rewrite is harmless.
23
+ * be told to override (a provider's storage host; requests must still use
24
+ * path-style addressing): the daemon mints a cert for each host and proxies to the
25
+ * store's plain HTTP while preserving the requested Host.
25
26
  */
26
27
  hosts?: string[];
27
28
  /** Access key id the helper client signs with. The store ignores SigV4, so
@@ -123,7 +124,7 @@ export function s3(opts: S3Options = {}) {
123
124
  // CreateBucket is a plain `PUT` on the bucket root; the store
124
125
  // returns 200 (or 409 if it already exists — idempotent enough to
125
126
  // ignore).
126
- const res = await fetch(`http://${name}:${S3_PORT}/${bucket}`, {
127
+ const res = await rawFetch(`http://${name}:${S3_PORT}/${bucket}`, {
127
128
  method: "PUT",
128
129
  });
129
130
  if (!res.ok && res.status !== 409) {
@@ -1,3 +1,4 @@
1
+ import { rawFetch } from "../harness/raw-fetch.js";
1
2
  // A ready-to-use, self-hosted **Supabase** stack, expressed as a set of
2
3
  // spectest services (one container each) wired together on spectest-net.
3
4
  //
@@ -2300,7 +2301,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2300
2301
  // snapshot and a test's diff is what its requests mutate.
2301
2302
  setup: async () => {
2302
2303
  const t0 = Date.now();
2303
- const res = await fetch(`http://${g.key("functions")}:9000/_spectest/warm`);
2304
+ const res = await rawFetch(`http://${g.key("functions")}:9000/_spectest/warm`);
2304
2305
  if (!res.ok) {
2305
2306
  console.warn(`supabase(): edge functions warm-up answered ${res.status}; functions boot on first call instead.`);
2306
2307
  return;
package/src/daemon.ts CHANGED
@@ -130,6 +130,7 @@ import {
130
130
  augmentCorsResponse,
131
131
  corsPreflightResponse,
132
132
  isCorsPreflight,
133
+ upstreamRequestHeaders,
133
134
  } from "./harness/http-proxy.js";
134
135
  import {
135
136
  WS_BRIDGE_HANDLERS,
@@ -2579,8 +2580,6 @@ async function proxyToService(
2579
2580
  // and the forwardable headers. Same provenance stamps as the HTTP path.
2580
2581
  const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
2581
2582
  const headers = upstreamHandshakeHeaders(req.headers, {
2582
- service,
2583
- port,
2584
2583
  proto,
2585
2584
  clientIp: ingressClientIp(server, req, proto),
2586
2585
  });
@@ -2614,25 +2613,10 @@ async function proxyToService(
2614
2613
  );
2615
2614
  }
2616
2615
 
2617
- const fwdHeaders = new Headers();
2618
- for (const [k, v] of req.headers) {
2619
- if (HOP_BY_HOP_HEADERS.has(k.toLowerCase())) continue;
2620
- fwdHeaders.append(k, v);
2621
- }
2622
- // Standard reverse-proxy provenance headers: the upstream sees the
2623
- // public scheme/host it was reached through and the client's address,
2624
- // even though we rewrite Host below to the service-net name.
2625
- const clientIp = ingressClientIp(server, req, proto);
2626
- const priorXff = req.headers.get("x-forwarded-for");
2627
- const xff = clientIp ? (priorXff ? `${priorXff}, ${clientIp}` : clientIp) : priorXff;
2628
- if (xff) fwdHeaders.set("x-forwarded-for", xff);
2629
- fwdHeaders.set("x-forwarded-proto", proto);
2630
- const publicHost = req.headers.get("host");
2631
- if (publicHost) fwdHeaders.set("x-forwarded-host", publicHost);
2632
- // Override Host so the upstream sees its own service-net name, not
2633
- // the public hostname. Lets origin servers that vhost by Host header
2634
- // continue to find the right virtual host.
2635
- fwdHeaders.set("host", `${service}:${port}`);
2616
+ const fwdHeaders = upstreamRequestHeaders(req.headers, {
2617
+ proto,
2618
+ clientIp: ingressClientIp(server, req, proto),
2619
+ });
2636
2620
  // Fresh connection per upstream request — never reuse a pooled
2637
2621
  // keep-alive conn. Upstreams with short idle timeouts (uvicorn defaults
2638
2622
  // to 5s) close pooled connections under Bun's fetch, and the next
@@ -6,6 +6,7 @@ import {
6
6
  forwardableHeaders,
7
7
  isCorsPreflight,
8
8
  isHopByHop,
9
+ upstreamRequestHeaders,
9
10
  } from "./http-proxy";
10
11
 
11
12
  describe("hop-by-hop headers", () => {
@@ -13,7 +14,7 @@ describe("hop-by-hop headers", () => {
13
14
  expect(isHopByHop("Connection")).toBe(true);
14
15
  expect(isHopByHop("TRANSFER-ENCODING")).toBe(true);
15
16
  expect(isHopByHop("upgrade")).toBe(true);
16
- expect(isHopByHop("host")).toBe(true);
17
+ expect(isHopByHop("host")).toBe(false);
17
18
  });
18
19
 
19
20
  test("ordinary headers are forwardable", () => {
@@ -23,8 +24,7 @@ describe("hop-by-hop headers", () => {
23
24
  });
24
25
 
25
26
  /** Forwarding transfer-encoding from a re-encoded body produces a
26
- * response the client cannot frame; forwarding host would hide which
27
- * service the upstream actually is. */
27
+ * response the client cannot frame. Host identifies the requested origin. */
28
28
  test("forwardableHeaders drops them and keeps the rest", () => {
29
29
  const h = new Headers({
30
30
  host: "app.test",
@@ -34,7 +34,7 @@ describe("hop-by-hop headers", () => {
34
34
  "content-type": "application/json",
35
35
  });
36
36
  const out = forwardableHeaders(h);
37
- expect(out.get("host")).toBeNull();
37
+ expect(out.get("host")).toBe("app.test");
38
38
  expect(out.get("connection")).toBeNull();
39
39
  expect(out.get("transfer-encoding")).toBeNull();
40
40
  expect(out.get("authorization")).toBe("Bearer x");
@@ -49,6 +49,68 @@ describe("hop-by-hop headers", () => {
49
49
  });
50
50
  });
51
51
 
52
+ describe("upstream request headers", () => {
53
+ test("preserves authority and stamps forwarding headers from the incoming request", () => {
54
+ const headers = upstreamRequestHeaders(new Headers({
55
+ host: "bucket.storage.test:8443",
56
+ "x-forwarded-host": "unrelated.test",
57
+ "x-forwarded-proto": "http",
58
+ "x-forwarded-for": "10.0.0.1",
59
+ authorization: "signed-request",
60
+ }), { proto: "https", clientIp: "10.0.0.2" });
61
+ expect(headers.get("host")).toBe("bucket.storage.test:8443");
62
+ expect(headers.get("x-forwarded-host")).toBe("bucket.storage.test:8443");
63
+ expect(headers.get("x-forwarded-proto")).toBe("https");
64
+ expect(headers.get("x-forwarded-for")).toBe("10.0.0.1, 10.0.0.2");
65
+ expect(headers.get("authorization")).toBe("signed-request");
66
+ });
67
+
68
+ test.each(["http", "https"])("%s ingress forwards Host to a separately addressed upstream", async (proto) => {
69
+ const upstream = Bun.serve({
70
+ port: 0, hostname: "127.0.0.1",
71
+ async fetch(req) {
72
+ const url = new URL(req.url);
73
+ return Response.json({
74
+ host: req.headers.get("host"),
75
+ forwardedHost: req.headers.get("x-forwarded-host"),
76
+ forwardedProto: req.headers.get("x-forwarded-proto"),
77
+ authorization: req.headers.get("authorization"),
78
+ method: req.method, path: url.pathname + url.search, body: await req.text(),
79
+ });
80
+ },
81
+ });
82
+ // Both listeners in the daemon speak HTTP; HTTPS reaches one through the
83
+ // TLS terminator, with proto supplied explicitly to the forwarding path.
84
+ const proxy = Bun.serve({
85
+ port: 0, hostname: "127.0.0.1",
86
+ fetch(req) {
87
+ const url = new URL(req.url);
88
+ const headers = upstreamRequestHeaders(req.headers, { proto });
89
+ return fetch(new Request(new URL(url.pathname + url.search, upstream.url), {
90
+ method: req.method, headers, body: req.body, redirect: "manual",
91
+ }));
92
+ },
93
+ });
94
+ try {
95
+ for (const host of ["storage.test", "bucket.storage.test:8443"]) {
96
+ const res = await fetch(new URL("/uploads/file?generation=7", proxy.url), {
97
+ method: "PUT", body: "contents",
98
+ headers: { host, authorization: "signed-request" },
99
+ });
100
+ expect(res.status).toBe(200);
101
+ expect(await res.json()).toEqual({
102
+ host, forwardedHost: host, forwardedProto: proto,
103
+ authorization: "signed-request", method: "PUT",
104
+ path: "/uploads/file?generation=7", body: "contents",
105
+ });
106
+ }
107
+ } finally {
108
+ proxy.stop(true);
109
+ upstream.stop(true);
110
+ }
111
+ });
112
+ });
113
+
52
114
  describe("preflight detection", () => {
53
115
  const req = (method: string, headers: Record<string, string>) =>
54
116
  new Request("http://app.test/", { method, headers });
@@ -13,8 +13,7 @@
13
13
  * Forwarding `transfer-encoding` or `content-length` from a re-encoded
14
14
  * body is the classic way to produce a response the client cannot frame;
15
15
  * forwarding `connection` leaks our keep-alive intent to the upstream.
16
- * `host` is here because the proxy sets its own — the upstream must see
17
- * the service it actually is, not the public name the client asked for.
16
+ * `host` identifies the requested origin and must survive the proxy hop.
18
17
  */
19
18
  export const HOP_BY_HOP_HEADERS: ReadonlySet<string> = new Set([
20
19
  "connection",
@@ -25,7 +24,6 @@ export const HOP_BY_HOP_HEADERS: ReadonlySet<string> = new Set([
25
24
  "trailers",
26
25
  "transfer-encoding",
27
26
  "upgrade",
28
- "host",
29
27
  ]);
30
28
 
31
29
  export function isHopByHop(name: string): boolean {
@@ -41,6 +39,24 @@ export function forwardableHeaders(headers: Headers): Headers {
41
39
  return out;
42
40
  }
43
41
 
42
+ /** Preserve the requested Host (including its port) while dialing the service's
43
+ * internal address. HTTP and WebSocket forwarding share this policy. */
44
+ export function upstreamRequestHeaders(
45
+ headers: Headers,
46
+ opts: { proto: string; clientIp?: string },
47
+ ): Headers {
48
+ const out = forwardableHeaders(headers);
49
+ const priorXff = headers.get("x-forwarded-for");
50
+ const xff = opts.clientIp
51
+ ? priorXff ? `${priorXff}, ${opts.clientIp}` : opts.clientIp
52
+ : priorXff;
53
+ if (xff) out.set("x-forwarded-for", xff);
54
+ out.set("x-forwarded-proto", opts.proto);
55
+ const host = headers.get("host");
56
+ if (host) out.set("x-forwarded-host", host);
57
+ return out;
58
+ }
59
+
44
60
  /**
45
61
  * A CORS preflight is an `OPTIONS` carrying both `Origin` and
46
62
  * `Access-Control-Request-Method`. An `OPTIONS` without them is a normal
@@ -20,7 +20,7 @@ describe("parseSubprotocols", () => {
20
20
  describe("upstreamHandshakeHeaders", () => {
21
21
  test("forwards cookies and origin, drops the client's handshake fields, stamps provenance", () => {
22
22
  const req = new Headers({
23
- host: "api.prod.specific.dev",
23
+ host: "api.prod.specific.dev:8443",
24
24
  connection: "Upgrade",
25
25
  upgrade: "websocket",
26
26
  "sec-websocket-key": "abc==",
@@ -32,16 +32,14 @@ describe("upstreamHandshakeHeaders", () => {
32
32
  authorization: "Bearer t",
33
33
  });
34
34
  const out = upstreamHandshakeHeaders(req, {
35
- service: "api",
36
- port: 4000,
37
35
  proto: "https",
38
36
  clientIp: "10.0.0.7",
39
37
  });
40
38
  expect(out["cookie"]).toBe("sid=1");
41
39
  expect(out["origin"]).toBe("https://app.test");
42
40
  expect(out["authorization"]).toBe("Bearer t");
43
- expect(out["host"]).toBe("api:4000");
44
- expect(out["x-forwarded-host"]).toBe("api.prod.specific.dev");
41
+ expect(out["host"]).toBe("api.prod.specific.dev:8443");
42
+ expect(out["x-forwarded-host"]).toBe("api.prod.specific.dev:8443");
45
43
  expect(out["x-forwarded-proto"]).toBe("https");
46
44
  expect(out["x-forwarded-for"]).toBe("10.0.0.7");
47
45
  for (const k of Object.keys(out)) {
@@ -52,8 +50,6 @@ describe("upstreamHandshakeHeaders", () => {
52
50
 
53
51
  test("appends the client to an existing x-forwarded-for", () => {
54
52
  const out = upstreamHandshakeHeaders(new Headers({ "x-forwarded-for": "1.2.3.4" }), {
55
- service: "api",
56
- port: 1,
57
53
  proto: "http",
58
54
  clientIp: "10.0.0.7",
59
55
  });
@@ -71,7 +67,7 @@ describe("the bridge on loopback", () => {
71
67
  let upstream: ReturnType<typeof Bun.serve>;
72
68
  let bridge: ReturnType<typeof Bun.serve>;
73
69
  /** What the upstream saw on its last handshake. */
74
- let seen: { protocols: string[]; cookie: string | null; xfh: string | null } | null = null;
70
+ let seen: { protocols: string[]; cookie: string | null; host: string | null; xfh: string | null } | null = null;
75
71
 
76
72
  beforeAll(() => {
77
73
  upstream = Bun.serve({
@@ -81,6 +77,7 @@ describe("the bridge on loopback", () => {
81
77
  seen = {
82
78
  protocols,
83
79
  cookie: req.headers.get("cookie"),
80
+ host: req.headers.get("host"),
84
81
  xfh: req.headers.get("x-forwarded-host"),
85
82
  };
86
83
  if (protocols[1] !== "ticket-ok") return new Response("Unauthorized", { status: 401 });
@@ -110,8 +107,6 @@ describe("the bridge on loopback", () => {
110
107
  async fetch(req, server) {
111
108
  const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
112
109
  const headers = upstreamHandshakeHeaders(req.headers, {
113
- service: "api",
114
- port: upstream.port!,
115
110
  proto: "https",
116
111
  });
117
112
  const url = new URL(req.url);
@@ -153,7 +148,7 @@ describe("the bridge on loopback", () => {
153
148
  ) => WebSocket;
154
149
  const ws = new Ctor(`ws://127.0.0.1:${bridge.port}/console?x=1`, {
155
150
  protocols,
156
- headers: { cookie: "sid=abc", host: "api.prod.specific.dev" },
151
+ headers: { cookie: "sid=abc", host: "api.prod.specific.dev:8443" },
157
152
  });
158
153
  ws.binaryType = "arraybuffer";
159
154
  const events: Array<{ kind: "open" | "message" | "close" | "error"; value?: unknown }> = [];
@@ -180,7 +175,8 @@ describe("the bridge on loopback", () => {
180
175
  expect(seen).toEqual({
181
176
  protocols: ["console", "ticket-ok"],
182
177
  cookie: "sid=abc",
183
- xfh: "api.prod.specific.dev",
178
+ host: "api.prod.specific.dev:8443",
179
+ xfh: "api.prod.specific.dev:8443",
184
180
  });
185
181
  await Bun.sleep(20);
186
182
  ws.send("hi");
@@ -20,7 +20,7 @@
20
20
  * without a VM.
21
21
  */
22
22
 
23
- import { forwardableHeaders } from "./http-proxy.js";
23
+ import { upstreamRequestHeaders } from "./http-proxy.js";
24
24
 
25
25
  /** Headers that belong to the client's own WebSocket handshake. Bun's
26
26
  * WebSocket client mints its own key and version, and the protocol list
@@ -56,33 +56,20 @@ export function parseSubprotocols(header: string | null | undefined): string[] {
56
56
  * The headers the upstream handshake carries: everything forwardable from
57
57
  * the client's request (cookies, authorization, origin, user-agent …),
58
58
  * minus the client's own handshake fields, plus the proxy provenance
59
- * headers and a Host that names the service.
59
+ * headers and the original Host.
60
60
  */
61
61
  export function upstreamHandshakeHeaders(
62
62
  req: Headers,
63
63
  opts: {
64
- service: string;
65
- port: number;
66
64
  proto: string;
67
65
  clientIp?: string;
68
66
  },
69
67
  ): Record<string, string> {
70
68
  const out: Record<string, string> = {};
71
- forwardableHeaders(req).forEach((value, key) => {
69
+ upstreamRequestHeaders(req, opts).forEach((value, key) => {
72
70
  if (HANDSHAKE_HEADERS.has(key)) return;
73
71
  out[key] = value;
74
72
  });
75
- const priorXff = req.get("x-forwarded-for");
76
- const xff = opts.clientIp
77
- ? priorXff
78
- ? `${priorXff}, ${opts.clientIp}`
79
- : opts.clientIp
80
- : priorXff;
81
- if (xff) out["x-forwarded-for"] = xff;
82
- out["x-forwarded-proto"] = opts.proto;
83
- const publicHost = req.get("host");
84
- if (publicHost) out["x-forwarded-host"] = publicHost;
85
- out["host"] = `${opts.service}:${opts.port}`;
86
73
  return out;
87
74
  }
88
75