@specific.dev/spectest 0.93.1 → 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.
@@ -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
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.1",
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",
@@ -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+$/, "");
@@ -20,9 +20,9 @@ export interface S3Options {
20
20
  * via the daemon's TLS-terminating reverse proxy. Optional — defaults to none;
21
21
  * the store is always reachable at `http://<key>:9090` regardless. Set this
22
22
  * only when the app under test hardcodes a specific prod S3 endpoint it can't
23
- * be told to override (`https://<bucket>.s3.amazonaws.com`, a provider's
24
- * storage host): the daemon mints a cert for each host and proxies to the
25
- * 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.
26
26
  */
27
27
  hosts?: string[];
28
28
  /** Access key id the helper client signs with. The store ignores SigV4, so
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