@specific.dev/spectest 0.93.1 → 0.95.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.
Files changed (39) hide show
  1. package/README.md +35 -0
  2. package/dist/components/emulate/entry.mjs +2 -3
  3. package/dist/components/index.d.ts +1 -0
  4. package/dist/components/index.js +1 -0
  5. package/dist/components/pubsub.d.ts +77 -0
  6. package/dist/components/pubsub.js +139 -0
  7. package/dist/components/s3.d.ts +3 -3
  8. package/dist/daemon.js +31 -31
  9. package/dist/harness/http-proxy.d.ts +7 -2
  10. package/dist/harness/http-proxy.js +17 -3
  11. package/dist/harness/http2-proxy.d.ts +10 -0
  12. package/dist/harness/http2-proxy.js +108 -0
  13. package/dist/harness/ingress-table.d.ts +2 -1
  14. package/dist/harness/tls-terminator.d.ts +3 -0
  15. package/dist/harness/tls-terminator.js +8 -4
  16. package/dist/harness/ws-bridge.d.ts +1 -3
  17. package/dist/harness/ws-bridge.js +3 -16
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.js +2 -0
  20. package/dist/ingress.d.ts +3 -0
  21. package/dist/ingress.js +4 -2
  22. package/package.json +1 -1
  23. package/src/components/emulate/entry.mjs +2 -3
  24. package/src/components/index.ts +2 -0
  25. package/src/components/pubsub.test.ts +88 -0
  26. package/src/components/pubsub.ts +182 -0
  27. package/src/components/s3.ts +3 -3
  28. package/src/daemon.ts +30 -27
  29. package/src/harness/http-proxy.test.ts +66 -4
  30. package/src/harness/http-proxy.ts +19 -3
  31. package/src/harness/http2-proxy.test.ts +91 -0
  32. package/src/harness/http2-proxy.ts +96 -0
  33. package/src/harness/ingress-table.ts +2 -2
  34. package/src/harness/tls-terminator.test.ts +29 -0
  35. package/src/harness/tls-terminator.ts +12 -4
  36. package/src/harness/ws-bridge.test.ts +8 -12
  37. package/src/harness/ws-bridge.ts +3 -16
  38. package/src/index.ts +4 -0
  39. package/src/ingress.ts +7 -6
@@ -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
@@ -0,0 +1,91 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import http2 from "node:http2";
3
+ import { once } from "node:events";
4
+ import { startHttp2Proxy } from "./http2-proxy";
5
+
6
+ const cleanup: (() => void)[] = [];
7
+ afterEach(() => { for (const close of cleanup.splice(0).reverse()) close(); });
8
+ async function backend(handler: (stream: http2.ServerHttp2Stream, headers: http2.IncomingHttpHeaders) => void) {
9
+ const server = http2.createServer();
10
+ const sessions = new Set<http2.ServerHttp2Session>();
11
+ server.on("session", session => { sessions.add(session); session.on("error", () => {}); });
12
+ server.on("stream", (stream, headers) => { stream.on("error", () => {}); handler(stream, headers); });
13
+ server.listen(0, "127.0.0.1");
14
+ await once(server, "listening");
15
+ cleanup.push(() => { for (const session of sessions) session.destroy(); server.close(); });
16
+ return `http://127.0.0.1:${(server.address() as { port: number }).port}`;
17
+ }
18
+ async function frontend(routes: Map<string, string>) {
19
+ const proxy = await startHttp2Proxy({ upstreamFor: async host => routes.get(host), clientIpFor: () => "10.0.0.2" });
20
+ cleanup.push(() => proxy.close());
21
+ const session = http2.connect(`http://127.0.0.1:${proxy.port}`);
22
+ session.on("error", () => {});
23
+ cleanup.push(() => session.destroy());
24
+ return session;
25
+ }
26
+ async function request(session: http2.ClientHttp2Session, authority: string) {
27
+ const stream = session.request({ ":authority": authority, ":path": "/rpc" });
28
+ let text = "";
29
+ stream.setEncoding("utf8"); stream.on("data", chunk => { text += chunk; });
30
+ const [headers] = await once(stream, "response");
31
+ await once(stream, "end");
32
+ return { text, headers };
33
+ }
34
+
35
+ test("routes each HTTP/2 authority, including routes added to an existing connection", async () => {
36
+ const a = await backend(s => { s.respond(); s.end("a"); });
37
+ const b = await backend(s => { s.respond(); s.end("b"); });
38
+ const routes = new Map([["a.test", a]]);
39
+ const client = await frontend(routes);
40
+ expect((await request(client, "a.test")).text).toBe("a");
41
+ routes.set("b.test", b);
42
+ expect((await request(client, "b.test:443")).text).toBe("b");
43
+ expect((await request(client, "missing.test")).headers[":status"]).toBe(421);
44
+ });
45
+
46
+ test("streams both directions before request end and forwards request/response trailers", async () => {
47
+ let upstreamHeaders: http2.IncomingHttpHeaders | undefined;
48
+ let requestTrailers: http2.IncomingHttpHeaders | undefined;
49
+ const url = await backend((s, h) => {
50
+ upstreamHeaders = h;
51
+ s.respond({ ":status": 200, "content-type": "application/grpc", "metadata-bin": "AQID" }, { waitForTrailers: true });
52
+ s.on("trailers", t => { requestTrailers = t; });
53
+ s.on("data", data => s.write(data));
54
+ s.on("end", () => s.end());
55
+ s.on("wantTrailers", () => s.sendTrailers({ "grpc-status": "7", "grpc-message": "Permission%20denied" }));
56
+ });
57
+ const client = await frontend(new Map([["grpc.test", url]]));
58
+ const stream = client.request({ ":authority": "grpc.test", ":path": "/package.Service/Call", ":method": "POST", "content-type": "application/grpc", te: "trailers" }, { waitForTrailers: true });
59
+ stream.on("wantTrailers", () => stream.sendTrailers({ "request-trailer": "preserved" }));
60
+ const response = once(stream, "response");
61
+ const first = once(stream, "data");
62
+ stream.write(Buffer.from([0, 0, 0, 0, 3, 0, 128, 255]));
63
+ expect((await first)[0]).toEqual(Buffer.from([0, 0, 0, 0, 3, 0, 128, 255]));
64
+ const trailers = once(stream, "trailers");
65
+ stream.end();
66
+ expect((await trailers)[0]["grpc-status"]).toBe("7");
67
+ expect((await response)[0]["metadata-bin"]).toBe("AQID");
68
+ expect(requestTrailers?.["request-trailer"]).toBe("preserved");
69
+ expect(upstreamHeaders?.[":authority"]).toBe("grpc.test");
70
+ expect(upstreamHeaders?.["x-forwarded-proto"]).toBe("https");
71
+ expect(upstreamHeaders?.["x-forwarded-for"]).toBe("10.0.0.2");
72
+ });
73
+
74
+ test("client cancellation reaches upstream without breaking another multiplexed RPC", async () => {
75
+ let cancelled!: Promise<unknown[]>;
76
+ const url = await backend((s, h) => {
77
+ if (h[":path"] === "/cancel") { cancelled = once(s, "aborted"); s.respond(); s.write("ready"); }
78
+ else { s.respond(); s.end("healthy"); }
79
+ });
80
+ const client = await frontend(new Map([["grpc.test", url]]));
81
+ const stream = client.request({ ":authority": "grpc.test", ":path": "/cancel" });
82
+ await once(stream, "data");
83
+ stream.close(http2.constants.NGHTTP2_CANCEL);
84
+ await cancelled;
85
+ expect((await request(client, "grpc.test")).text).toBe("healthy");
86
+ });
87
+
88
+ test("an unavailable upstream produces a bounded error response", async () => {
89
+ const client = await frontend(new Map([["grpc.test", "http://127.0.0.1:1"]]));
90
+ expect((await request(client, "grpc.test")).headers[":status"]).toBe(502);
91
+ });
@@ -0,0 +1,96 @@
1
+ import http2, { type IncomingHttpHeaders, type OutgoingHttpHeaders, type ServerHttp2Stream } from "node:http2";
2
+ import { isHopByHop } from "./http-proxy";
3
+ import { hostWithoutPort } from "./hostmatch";
4
+
5
+ export interface Http2ProxyOptions {
6
+ /** Resolve each stream independently; one connection can carry multiple authorities. */
7
+ upstreamFor(hostname: string): Promise<string | undefined>;
8
+ clientIpFor?(port: number | undefined): string | undefined;
9
+ }
10
+
11
+ function headersForHop(headers: IncomingHttpHeaders, pseudo: boolean): OutgoingHttpHeaders {
12
+ const result: OutgoingHttpHeaders = {};
13
+ for (const [name, value] of Object.entries(headers)) {
14
+ if ((!pseudo && name.startsWith(":")) || (isHopByHop(name) && !(name === "te" && value === "trailers"))) continue;
15
+ result[name] = value;
16
+ }
17
+ return result;
18
+ }
19
+
20
+ /** A stream proxy rather than fetch: gRPC depends on duplex bodies and trailers. */
21
+ export async function startHttp2Proxy(options: Http2ProxyOptions) {
22
+ const server = http2.createServer();
23
+ const sessions = new Set<http2.Http2Session>();
24
+ const track = (session: http2.Http2Session) => {
25
+ sessions.add(session);
26
+ session.on("error", () => {});
27
+ session.once("close", () => sessions.delete(session));
28
+ };
29
+ server.on("session", track);
30
+ server.on("stream", (downstream: ServerHttp2Stream, headers) => {
31
+ downstream.on("error", () => {});
32
+ void forward(downstream, headers).catch(() => {
33
+ if (downstream.destroyed || downstream.closed) return;
34
+ if (downstream.headersSent) downstream.close(http2.constants.NGHTTP2_INTERNAL_ERROR);
35
+ else { downstream.respond({ ":status": 502 }); downstream.end("HTTP/2 upstream unavailable"); }
36
+ });
37
+ });
38
+
39
+ async function forward(downstream: ServerHttp2Stream, headers: IncomingHttpHeaders) {
40
+ const authority = String(headers[":authority"] ?? "");
41
+ const upstreamUrl = await options.upstreamFor(hostWithoutPort(authority.toLowerCase()));
42
+ if (downstream.destroyed || downstream.closed) return;
43
+ if (!upstreamUrl) {
44
+ // A coalesced connection must not silently route an authority to its SNI backend.
45
+ downstream.respond({ ":status": 421 });
46
+ downstream.end("No HTTP/2 route for this authority");
47
+ return;
48
+ }
49
+ const session = http2.connect(upstreamUrl);
50
+ track(session);
51
+ let requestTrailers: OutgoingHttpHeaders = {};
52
+ let responseTrailers: OutgoingHttpHeaders = {};
53
+ const requestHeaders = headersForHop(headers, true);
54
+ requestHeaders[":scheme"] = "http";
55
+ requestHeaders["x-forwarded-proto"] = "https";
56
+ requestHeaders["x-forwarded-host"] = authority;
57
+ const clientIp = options.clientIpFor?.(downstream.session?.socket.remotePort);
58
+ if (clientIp) requestHeaders["x-forwarded-for"] = [headers["x-forwarded-for"], clientIp].filter(Boolean).join(", ");
59
+ let upstream: http2.ClientHttp2Stream;
60
+ try { upstream = session.request(requestHeaders, { endStream: false, waitForTrailers: true }); }
61
+ catch (error) { session.destroy(); throw error; }
62
+ const fail = () => {
63
+ if (!downstream.closed && !downstream.destroyed) {
64
+ if (downstream.headersSent) downstream.close(http2.constants.NGHTTP2_INTERNAL_ERROR);
65
+ else { downstream.respond({ ":status": 502 }); downstream.end("HTTP/2 upstream unavailable"); }
66
+ }
67
+ session.destroy();
68
+ };
69
+ session.on("error", fail);
70
+ upstream.on("error", fail);
71
+ downstream.on("trailers", trailers => { requestTrailers = headersForHop(trailers, false); });
72
+ upstream.on("wantTrailers", () => { if (!upstream.destroyed && !upstream.closed) upstream.sendTrailers(requestTrailers); });
73
+ upstream.on("trailers", trailers => { responseTrailers = headersForHop(trailers, false); });
74
+ downstream.on("wantTrailers", () => {
75
+ if (!downstream.destroyed && !downstream.closed) downstream.sendTrailers(responseTrailers);
76
+ });
77
+ upstream.on("response", response => {
78
+ if (downstream.destroyed || downstream.closed) return;
79
+ downstream.respond(headersForHop(response, true), { waitForTrailers: true });
80
+ upstream.pipe(downstream);
81
+ });
82
+ downstream.on("aborted", () => upstream.close(downstream.rstCode || http2.constants.NGHTTP2_CANCEL));
83
+ upstream.on("aborted", () => { if (!downstream.closed) downstream.close(upstream.rstCode || http2.constants.NGHTTP2_CANCEL); });
84
+ downstream.once("close", () => session.destroy());
85
+ downstream.pipe(upstream);
86
+ }
87
+
88
+ await new Promise<void>((resolve, reject) => {
89
+ server.once("error", reject);
90
+ server.listen(0, "127.0.0.1", () => { server.removeListener("error", reject); resolve(); });
91
+ });
92
+ return {
93
+ port: (server.address() as { port: number }).port,
94
+ close() { for (const session of sessions) session.destroy(); server.close(); },
95
+ };
96
+ }
@@ -53,7 +53,7 @@ import { certCovers, isWildcard, wildcardSuffix } from "./hostmatch";
53
53
  */
54
54
  export type Route<F = unknown> =
55
55
  | { kind: "fake"; fake: F }
56
- | { kind: "proxy"; service: string; port: number };
56
+ | { kind: "proxy"; service: string; port: number; http2?: boolean };
57
57
 
58
58
  /** A PEM leaf + key, as handed to `Bun.serve`'s TLS config. */
59
59
  export interface Leaf {
@@ -70,7 +70,7 @@ export interface Leaf {
70
70
  export interface IngressTables<F = unknown> {
71
71
  /** Listen port → (hostname → route). Values are mutated in place. */
72
72
  routesByPort: Map<number, Map<string, Route<F>>>;
73
- /** SNI server name → leaf. Adding to this is what forces a rebind. */
73
+ /** SNI server name → leaf. Read live by the TLS terminator without rebinding. */
74
74
  certByHost: Map<string, Leaf>;
75
75
  }
76
76
 
@@ -18,6 +18,9 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test
18
18
  import { spawnSync } from "node:child_process";
19
19
  import { mkdtempSync, readFileSync, rmSync } from "node:fs";
20
20
  import net from "node:net";
21
+ import http2 from "node:http2";
22
+ import { once } from "node:events";
23
+ import { startHttp2Proxy } from "./http2-proxy";
21
24
  import { tmpdir } from "node:os";
22
25
  import { join } from "node:path";
23
26
  import tls from "node:tls";
@@ -238,3 +241,29 @@ describe("the pipe", () => {
238
241
  expect(term.connectionCount()).toBe(0);
239
242
  });
240
243
  });
244
+
245
+
246
+ test("ALPN selects HTTP/2 only for opted-in routes and keeps HTTP/1.1 working", async () => {
247
+ const backend = http2.createServer();
248
+ const sessions = new Set<http2.Http2Session>();
249
+ backend.on("session", s => { sessions.add(s); s.on("error", () => {}); });
250
+ backend.on("stream", (s: http2.ServerHttp2Stream) => { s.respond({ ":status": 200 }); s.end("grpc backend"); });
251
+ backend.listen(0, "127.0.0.1");
252
+ await once(backend, "listening");
253
+ const h2 = await startHttp2Proxy({ upstreamFor: async () => `http://127.0.0.1:${(backend.address() as net.AddressInfo).port}` });
254
+ const optedIn = new Set(["app.test"]);
255
+ const terminator = await startTlsTerminator({ port: 0, hostname: "127.0.0.1", upstreamPort: plain.port,
256
+ http2Port: h2.port, supportsHttp2: name => optedIn.has(name), certFor: name => leaves.get(name ?? "app.test") });
257
+ const client = http2.connect(`https://127.0.0.1:${terminator.port}`, { servername: "app.test", ca: caPem });
258
+ try {
259
+ const stream = client.request({ ":authority": "app.test", ":path": "/" });
260
+ stream.setEncoding("utf8"); let body = ""; stream.on("data", chunk => { body += chunk; });
261
+ await once(stream, "end");
262
+ expect(client.alpnProtocol).toBe("h2"); expect(body).toBe("grpc backend");
263
+ const socket = tls.connect({ host: "127.0.0.1", port: terminator.port, servername: "late.test", ca: caPem, ALPNProtocols: ["h2", "http/1.1"] });
264
+ try { await once(socket, "secureConnect"); expect(socket.alpnProtocol).toBe("http/1.1"); } finally { socket.destroy(); }
265
+ optedIn.add("late.test");
266
+ const late = tls.connect({ host: "127.0.0.1", port: terminator.port, servername: "late.test", ca: caPem, ALPNProtocols: ["h2", "http/1.1"] });
267
+ try { await once(late, "secureConnect"); expect(late.alpnProtocol).toBe("h2"); } finally { late.destroy(); }
268
+ } finally { client.destroy(); terminator.close(); h2.close(); for (const s of sessions) s.destroy(); backend.close(); }
269
+ });
@@ -34,7 +34,8 @@
34
34
  *
35
35
  * The terminator does TLS and *nothing else*: it accepts on :443, picks a
36
36
  * leaf by SNI, and pipes the decrypted stream to a plaintext `Bun.serve`
37
- * bound on loopback. Everything downstream — routing, fakes,
37
+ * bound on loopback. Opted-in HTTP/2 routes select a separate stream router
38
+ * by ALPN; HTTP/1.1 and WebSockets retain the existing listener. Everything downstream — routing, fakes,
38
39
  * interceptors, reverse-proxying, and `server.upgrade()` WebSocket
39
40
  * bridging — is the same `Bun.serve` code that already serves :80, run
40
41
  * unchanged. Terminating HTTP here instead (`node:https`, `(req, res)`)
@@ -69,6 +70,9 @@ export interface TlsTerminatorOptions {
69
70
  hostname: string;
70
71
  /** Loopback port of the plaintext ingress server to pipe into. */
71
72
  upstreamPort: number;
73
+ /** Loopback HTTP/2 router, selected only for an opted-in SNI hostname. */
74
+ http2Port?: number;
75
+ supportsHttp2?(serverName: string): boolean;
72
76
  /**
73
77
  * The leaf to serve for an SNI server name, read live on every
74
78
  * handshake — this is what lets a certificate be added with no restart.
@@ -153,6 +157,9 @@ export function startTlsTerminator(opts: TlsTerminatorOptions): Promise<TlsTermi
153
157
  const server = tls.createServer(
154
158
  {
155
159
  ...(fallback ? { cert: fallback.cert, key: fallback.key } : {}),
160
+ ...(opts.http2Port ? { ALPNCallback: ({ servername, protocols }: { servername: string; protocols: string[] }) =>
161
+ protocols.includes("h2") && opts.supportsHttp2?.(servername) ? "h2"
162
+ : protocols.includes("http/1.1") ? "http/1.1" : undefined } : {}),
156
163
  SNICallback: (serverName, cb) => {
157
164
  const leaf = opts.certFor(serverName) ?? fallback;
158
165
  // No certificate at all is a handshake we cannot complete. Answer
@@ -175,7 +182,8 @@ export function startTlsTerminator(opts: TlsTerminatorOptions): Promise<TlsTermi
175
182
  // its bytes are buffered rather than dropped — which is the normal
176
183
  // case, since a client sends its first request the instant the
177
184
  // handshake completes.
178
- const upstream = net.connect(opts.upstreamPort, "127.0.0.1");
185
+ const upstreamPort = client.alpnProtocol === "h2" ? opts.http2Port! : opts.upstreamPort;
186
+ const upstream = net.connect(upstreamPort, "127.0.0.1");
179
187
  upstream.setTimeout(0);
180
188
  upstream.setNoDelay(true);
181
189
  client.setNoDelay(true);
@@ -204,7 +212,7 @@ export function startTlsTerminator(opts: TlsTerminatorOptions): Promise<TlsTermi
204
212
  teardown();
205
213
  });
206
214
  upstream.on("error", (err) => {
207
- warn(`ingress upstream :${opts.upstreamPort} failed`, err);
215
+ warn(`ingress upstream :${upstreamPort} failed`, err);
208
216
  teardown();
209
217
  });
210
218
  client.on("close", teardown);
@@ -224,7 +232,7 @@ export function startTlsTerminator(opts: TlsTerminatorOptions): Promise<TlsTermi
224
232
  server.listen(opts.port, opts.hostname, () => {
225
233
  server.removeListener("error", reject);
226
234
  resolve({
227
- port: opts.port,
235
+ port: (server.address() as net.AddressInfo).port,
228
236
  clientIpFor: (upstreamPort) =>
229
237
  upstreamPort === undefined ? undefined : clientIpByUpstreamPort.get(upstreamPort),
230
238
  connectionCount: () => live.size,
@@ -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
 
package/src/index.ts CHANGED
@@ -1161,6 +1161,9 @@ export interface ServiceTls {
1161
1161
  /** HTTP port the service listens on inside its container. The
1162
1162
  * daemon forwards proxied requests here over `spectest-net`. */
1163
1163
  port: number;
1164
+ /** Accept HTTP/2 over TLS and forward it as plaintext HTTP/2 (h2c).
1165
+ * Required for gRPC. HTTP/1.1 continues to use the existing HTTP proxy. */
1166
+ http2?: boolean;
1164
1167
  }
1165
1168
 
1166
1169
  /** @internal Normalized wire representation; legacy definitions remain accepted. */
@@ -1661,6 +1664,7 @@ function validateEnvironmentConfig<S extends ServicesMap>(
1661
1664
  `service "${name}" tls entry must be { hostname: string, port: number }; got ${JSON.stringify(entry)}`,
1662
1665
  );
1663
1666
  }
1667
+ if (entry.http2 !== undefined && typeof entry.http2 !== "boolean") throw new Error(`service "${name}" tls http2 must be a boolean`);
1664
1668
  const h = entry.hostname.toLowerCase();
1665
1669
  if (!HOSTNAME_RE.test(hostPatternBody(h))) {
1666
1670
  throw new Error(
package/src/ingress.ts CHANGED
@@ -40,7 +40,7 @@ export interface DnsDecl {
40
40
  export interface ProxyDecl {
41
41
  kind: "proxy";
42
42
  hostname: string;
43
- upstream: { service: string; port: number };
43
+ upstream: { service: string; port: number; http2?: boolean };
44
44
  }
45
45
 
46
46
  export type IngressDecl = CertificateDecl | DnsDecl | ProxyDecl;
@@ -137,7 +137,7 @@ export function dnsName(hostname: string, target: DnsTarget): DnsDecl {
137
137
  */
138
138
  export function proxy(
139
139
  hostname: string,
140
- upstream: { service: string; port: number },
140
+ upstream: { service: string; port: number; http2?: boolean },
141
141
  ): ProxyDecl {
142
142
  assertNameOrWildcard(hostname, "proxy()");
143
143
  if (!upstream || typeof upstream.service !== "string" || typeof upstream.port !== "number") {
@@ -145,6 +145,7 @@ export function proxy(
145
145
  `proxy(${JSON.stringify(hostname)}): upstream must be { service: string, port: number }`,
146
146
  );
147
147
  }
148
+ if (upstream.http2 !== undefined && typeof upstream.http2 !== "boolean") throw new Error("proxy(): http2 must be a boolean");
148
149
  return { kind: "proxy", hostname: hostname.toLowerCase(), upstream };
149
150
  }
150
151
 
@@ -197,7 +198,7 @@ export interface LoweredIngress {
197
198
  /** One leaf cert per group; SANs = the group's hostnames. */
198
199
  certificates: { hostnames: string[] }[];
199
200
  /** Reverse-proxy routes: hostname → upstream service:port. */
200
- proxies: { hostname: string; service: string; port: number }[];
201
+ proxies: { hostname: string; service: string; port: number; http2?: boolean }[];
201
202
  /** Hostnames that resolve to the daemon ingress gateway (DNS registry
202
203
  * + `--add-host` on every container). Covers fakes, TLS proxies, and
203
204
  * any `dnsName(h, { ingress: true })`. */
@@ -226,7 +227,7 @@ function resolveSelf(service: string, selfKey: string): string {
226
227
  */
227
228
  export function lowerIngress(project: Project): LoweredIngress {
228
229
  const certificates: { hostnames: string[] }[] = [];
229
- const proxies: { hostname: string; service: string; port: number }[] = [];
230
+ const proxies: { hostname: string; service: string; port: number; http2?: boolean }[] = [];
230
231
  const ingressSet = new Set<string>();
231
232
  const aliasesByService: Record<string, string[]> = {};
232
233
  const wildcards: { pattern: string; target: DnsTarget }[] = [];
@@ -241,7 +242,7 @@ export function lowerIngress(project: Project): LoweredIngress {
241
242
  break;
242
243
  case "proxy": {
243
244
  const service = resolveSelf(decl.upstream.service, selfKey);
244
- proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port });
245
+ proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port, ...(decl.upstream.http2 ? { http2: true } : {}) });
245
246
  // A proxied hostname must route to the daemon. A wildcard one can
246
247
  // only be answered by the resolver (there is no `--add-host` for a
247
248
  // pattern), so it goes in the wildcard table pointed at the ingress.
@@ -278,7 +279,7 @@ export function lowerIngress(project: Project): LoweredIngress {
278
279
  // `tls: [{ hostname, port }]` → cert + TLS-terminated reverse proxy.
279
280
  for (const entry of (svc as ServiceConfig).tls ?? []) {
280
281
  applyDecl(certificate([entry.hostname]), name);
281
- applyDecl(proxy(entry.hostname, { service: name, port: entry.port }), name);
282
+ applyDecl(proxy(entry.hostname, { service: name, port: entry.port, ...(entry.http2 ? { http2: true } : {}) }), name);
282
283
  }
283
284
  // `hostnames: [h]` → plain peer alias (no daemon hop).
284
285
  for (const h of (svc as ServiceConfig).hostnames ?? []) {