@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
package/README.md CHANGED
@@ -15,6 +15,31 @@ Install the [Spectest CLI](https://github.com/specific-dev/spectest/releases/lat
15
15
  and run `spectest docs /installation` for project setup and authentication.
16
16
  `spectest docs` contains the complete offline authoring guide.
17
17
 
18
+ ## Google Cloud Pub/Sub
19
+
20
+ `pubsub()` runs Google's local emulator and creates topics/subscriptions before
21
+ dependent services start:
22
+
23
+ ```ts
24
+ import { pubsub } from "@specific.dev/spectest/components";
25
+
26
+ const services = {
27
+ messages: pubsub({
28
+ projectId: "local-orders",
29
+ subscriptions: { "orders-worker": { topic: "orders" } },
30
+ }),
31
+ };
32
+ ```
33
+
34
+ The default endpoint is `https://pubsub.googleapis.com`, with trusted TLS and
35
+ gRPC support. Keep your application's normal endpoint configuration, supply
36
+ local test credentials and project `local-orders`, and use
37
+ `dependsOn: ["messages"]`. Tests can call
38
+ `ctx.svc.messages.publish()`, `pull()` and `acknowledge()`. VM snapshots preserve
39
+ the emulator's live state. See `examples/google-pubsub` and
40
+ `spectest docs /components/pubsub` for test credential setup and options.
41
+ Set `tls: false` to use explicit plaintext emulator mode instead.
42
+
18
43
  ## HTTP assertions
19
44
 
20
45
  `ctx.fetch()` returns a wrapped response. Status fields, header lookups, and
@@ -113,3 +138,13 @@ Dockerfile builds in `images` targeting `registry.test/...` are published and
113
138
  preloaded from the same cached build, preserving digests without an export/push
114
139
  step. Other images keep direct preloading. Registry writes stay isolated across
115
140
  test forks; client and containerd CA trust are configured automatically.
141
+
142
+ ### HTTP/2 and gRPC ingress
143
+
144
+ For a service listening on plaintext HTTP/2 (h2c), declare
145
+ `tls: [{ hostname: "grpc.example.test", port: 50051, http2: true }]`.
146
+ Clients use trusted TLS on port 443. The ingress selects HTTP/2 with ALPN,
147
+ routes each stream by `:authority`, and preserves streaming bodies, metadata,
148
+ trailers, and cancellation. HTTP/1.1 requests still use the existing HTTP proxy.
149
+ Runtime services support the same option. HTTP/2 streams currently bypass
150
+ `ctx.intercept` and HTTP request recording. `pubsub()` enables this by default.
@@ -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+$/, "");
@@ -1,3 +1,4 @@
1
+ export { pubsub, pubsubClient, type PubSubOptions, type PubSubClient, type PubSubHelpers, type PubSubSubscriptionOptions, type PubSubMessage, type PubSubReceivedMessage } from "./pubsub.js";
1
2
  export { temporal, temporalClient, type TemporalOptions, type TemporalClient, type TemporalNamespace, type TemporalWorkerOptions, type TemporalPoller, type TemporalSearchAttributeType, type TemporalTaskType } from "./temporal.js";
2
3
  export { postgres, type PostgresOptions, type PostgresHelpers, } from "./postgres.js";
3
4
  export { s3, type S3Options, type S3Helpers, } from "./s3.js";
@@ -1,4 +1,5 @@
1
1
  // Reusable, pre-built service definitions for common dependencies.
2
+ export { pubsub, pubsubClient } from "./pubsub.js";
2
3
  export { temporal, temporalClient } from "./temporal.js";
3
4
  //
4
5
  // Each component returns a fully-formed `ServiceDefinition` ready to
@@ -0,0 +1,77 @@
1
+ import type { ServiceSetupContext, ServicesMap } from "../index.js";
2
+ export interface PubSubSubscriptionOptions {
3
+ /** Topic ID in this component's project. */
4
+ topic: string;
5
+ ackDeadlineSeconds?: number;
6
+ /** HTTP is supported by the emulator, including other service names. */
7
+ pushEndpoint?: string;
8
+ }
9
+ export interface PubSubOptions {
10
+ /** Local project ID; no Google Cloud account is needed. Default "spectest". */
11
+ projectId?: string;
12
+ /** Official Cloud CLI image with the Pub/Sub emulator installed. */
13
+ image?: string;
14
+ /** Emulator HTTP and gRPC port. Default 8085. */
15
+ port?: number;
16
+ /** TLS on pubsub.googleapis.com by default. Set false for direct emulator
17
+ * access, or choose a different hostname for a second isolated instance. */
18
+ tls?: false | {
19
+ hostname: string;
20
+ };
21
+ topics?: string[];
22
+ /** Subscription ID → options. Referenced topics are created automatically. */
23
+ subscriptions?: Record<string, PubSubSubscriptionOptions>;
24
+ /** Runs after the declared topics and subscriptions have been created. */
25
+ setup?: (ctx: ServiceSetupContext<PubSubHelpers>) => void | Promise<void>;
26
+ }
27
+ export interface PubSubMessage {
28
+ /** Base64 encoded data, as returned by the Pub/Sub REST API. */
29
+ data?: string;
30
+ attributes?: Record<string, string>;
31
+ messageId: string;
32
+ publishTime: string;
33
+ orderingKey?: string;
34
+ }
35
+ export interface PubSubReceivedMessage {
36
+ ackId: string;
37
+ message: PubSubMessage;
38
+ deliveryAttempt?: number;
39
+ }
40
+ /** Stateless emulator REST client. No credentials or gRPC channel to preserve across forks.
41
+ * Resource arguments are short IDs in projectId; publish accepts UTF-8 text or bytes.
42
+ */
43
+ export declare function pubsubClient(endpoint: string, projectId?: string, fetcher?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>): {
44
+ projectId: string;
45
+ endpoint: string;
46
+ topicName: (id: string) => string;
47
+ subscriptionName: (id: string) => string;
48
+ createTopic(id: string): Promise<{
49
+ name: string;
50
+ }>;
51
+ createSubscription(id: string, options: PubSubSubscriptionOptions): Promise<{
52
+ name: string;
53
+ topic: string;
54
+ ackDeadlineSeconds: number;
55
+ }>;
56
+ publish(topic: string, data: string | Uint8Array, attributes?: Record<string, string>): Promise<string>;
57
+ /** A pull leases messages; call acknowledge() to remove them. Empty is valid.
58
+ * Uses the emulator's immediate pull mode for bounded test polling.
59
+ */
60
+ pull(subscription: string, options?: {
61
+ maxMessages?: number;
62
+ timeoutMs?: number;
63
+ }): Promise<PubSubReceivedMessage[]>;
64
+ acknowledge(subscription: string, ackIds: string[]): Promise<void>;
65
+ };
66
+ export type PubSubClient = ReturnType<typeof pubsubClient>;
67
+ export interface PubSubHelpers extends PubSubClient {
68
+ /** Direct plaintext address for clients explicitly using emulator mode. */
69
+ emulatorHost: string;
70
+ }
71
+ /** Google Cloud's real local Pub/Sub emulator, ready for Google client libraries.
72
+ * Mount directly in services: { pubsub: pubsub({ topics: ["orders"] }) }.
73
+ * Applications use their default pubsub.googleapis.com:443 endpoint with
74
+ * verified TLS and local test credentials. No emulator endpoint override.
75
+ * State survives VM snapshots/forks, but not an emulator process restart.
76
+ */
77
+ export declare function pubsub(opts?: PubSubOptions): import("../index.js").ServiceGroup<ServicesMap, PubSubHelpers, "emulator">;
@@ -0,0 +1,139 @@
1
+ import { serviceGroup, certificate } from "../index.js";
2
+ import { rawFetch } from "../harness/raw-fetch.js";
3
+ import { readRaw } from "../inspect.js";
4
+ function resourceId(id) {
5
+ if (!/^[A-Za-z][A-Za-z0-9._~+%-]{2,254}$/.test(id) || id.startsWith("goog")) {
6
+ throw new Error(`pubsub(): invalid topic or subscription ID ${JSON.stringify(id)}; use a 3–255 character ID starting with a letter, not "goog"`);
7
+ }
8
+ return id;
9
+ }
10
+ function projectName(id) {
11
+ if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(id))
12
+ throw new Error("pubsub(): projectId must be a 6–30 character Google Cloud project ID");
13
+ return id;
14
+ }
15
+ function subscriptionOptions(options) {
16
+ resourceId(options.topic);
17
+ const seconds = options.ackDeadlineSeconds ?? 10;
18
+ if (!Number.isInteger(seconds) || seconds < 10 || seconds > 600)
19
+ throw new Error("pubsub(): ackDeadlineSeconds must be an integer between 10 and 600");
20
+ if (options.pushEndpoint && !/^https?:$/.test(new URL(options.pushEndpoint).protocol))
21
+ throw new Error("pubsub(): pushEndpoint must use HTTP or HTTPS");
22
+ return seconds;
23
+ }
24
+ /** Stateless emulator REST client. No credentials or gRPC channel to preserve across forks.
25
+ * Resource arguments are short IDs in projectId; publish accepts UTF-8 text or bytes.
26
+ */
27
+ export function pubsubClient(endpoint, projectId = "spectest", fetcher = rawFetch) {
28
+ projectName(projectId);
29
+ const base = endpoint.replace(/\/$/, "");
30
+ const resource = (kind, id) => `projects/${projectId}/${kind}/${resourceId(id)}`;
31
+ const path = (kind, id) => `/v1/projects/${projectId}/${kind}/${encodeURIComponent(resourceId(id))}`;
32
+ async function request(url, method, body, timeoutMs = 10000) {
33
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
34
+ throw new Error("pubsub(): timeoutMs must be positive");
35
+ const response = await fetcher(`${base}${url}`, {
36
+ method, headers: { "content-type": "application/json" },
37
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
38
+ signal: AbortSignal.timeout(Math.ceil(timeoutMs)),
39
+ });
40
+ const text = await response.text();
41
+ if (!response.ok)
42
+ throw new Error(`Pub/Sub ${method} ${url}: HTTP ${response.status}: ${text.slice(0, 1000)}`);
43
+ return (text ? JSON.parse(text) : {});
44
+ }
45
+ return {
46
+ projectId, endpoint: base,
47
+ topicName: (id) => resource("topics", id),
48
+ subscriptionName: (id) => resource("subscriptions", id),
49
+ createTopic(id) {
50
+ return request(path("topics", id), "PUT", {});
51
+ },
52
+ createSubscription(id, options) {
53
+ const ackDeadlineSeconds = subscriptionOptions(options);
54
+ return request(path("subscriptions", id), "PUT", {
55
+ topic: resource("topics", options.topic), ackDeadlineSeconds,
56
+ ...(options.pushEndpoint ? { pushConfig: { pushEndpoint: options.pushEndpoint } } : {}),
57
+ });
58
+ },
59
+ async publish(topic, data, attributes) {
60
+ const result = await request(`${path("topics", topic)}:publish`, "POST", {
61
+ messages: [{ data: Buffer.from(data).toString("base64"), ...(attributes ? { attributes } : {}) }],
62
+ });
63
+ return result.messageIds[0];
64
+ },
65
+ /** A pull leases messages; call acknowledge() to remove them. Empty is valid.
66
+ * Uses the emulator's immediate pull mode for bounded test polling.
67
+ */
68
+ async pull(subscription, options = {}) {
69
+ const maxMessages = options.maxMessages ?? 10;
70
+ if (!Number.isInteger(maxMessages) || maxMessages < 1)
71
+ throw new Error("pubsub(): maxMessages must be a positive integer");
72
+ const result = await request(`${path("subscriptions", subscription)}:pull`, "POST", { maxMessages, returnImmediately: true }, options.timeoutMs);
73
+ return result.receivedMessages ?? [];
74
+ },
75
+ async acknowledge(subscription, ackIds) {
76
+ await request(`${path("subscriptions", subscription)}:acknowledge`, "POST", { ackIds });
77
+ },
78
+ };
79
+ }
80
+ /** Google Cloud's real local Pub/Sub emulator, ready for Google client libraries.
81
+ * Mount directly in services: { pubsub: pubsub({ topics: ["orders"] }) }.
82
+ * Applications use their default pubsub.googleapis.com:443 endpoint with
83
+ * verified TLS and local test credentials. No emulator endpoint override.
84
+ * State survives VM snapshots/forks, but not an emulator process restart.
85
+ */
86
+ export function pubsub(opts = {}) {
87
+ const projectId = projectName(opts.projectId ?? "spectest");
88
+ const port = opts.port ?? 8085;
89
+ const hostname = opts.tls === false ? undefined : opts.tls?.hostname ?? "pubsub.googleapis.com";
90
+ if (hostname !== undefined) {
91
+ certificate([hostname]);
92
+ if (hostname.startsWith("*."))
93
+ throw new Error("pubsub(): TLS hostname must be an exact hostname");
94
+ }
95
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
96
+ throw new Error("pubsub(): port must be an integer between 1 and 65535");
97
+ const subscriptions = Object.entries(opts.subscriptions ?? {});
98
+ for (const [id, options] of subscriptions) {
99
+ resourceId(id);
100
+ subscriptionOptions(options);
101
+ }
102
+ const topics = [...new Set([...(opts.topics ?? []), ...subscriptions.map(([, options]) => options.topic)])].map(resourceId);
103
+ if (hostname && port === 443)
104
+ throw new Error("pubsub(): the plaintext emulator port must differ from TLS port 443");
105
+ return serviceGroup({
106
+ primary: "emulator",
107
+ services: () => {
108
+ const emulator = {
109
+ image: { type: "registry", reference: opts.image ?? "gcr.io/google.com/cloudsdktool/google-cloud-cli:573.0.0-emulators" },
110
+ args: ["gcloud", "beta", "emulators", "pubsub", "start", `--project=${projectId}`, `--host-port=0.0.0.0:${port}`, "--quiet"],
111
+ ports: [port],
112
+ ...(hostname ? { tls: [{ hostname, port, http2: true }] } : {}),
113
+ readyCheck: { type: "http", port, path: `/v1/projects/${projectId}/topics`, timeoutSecs: 60 },
114
+ setup: async ({ name }) => {
115
+ const client = pubsubClient(`http://${name}:${port}`, projectId);
116
+ for (const topic of topics)
117
+ await client.createTopic(topic);
118
+ for (const [id, options] of subscriptions)
119
+ await client.createSubscription(id, options);
120
+ },
121
+ };
122
+ return { emulator };
123
+ },
124
+ helpers: (ctx) => ({
125
+ ...pubsubClient(hostname ? `https://${hostname}` : `http://${ctx.name}:${port}`, projectId, async (input, init) => readRaw(await ctx.fetch(String(input), init))),
126
+ emulatorHost: `${ctx.name}:${port}`,
127
+ }),
128
+ setup: async (ctx) => {
129
+ // Prove the TLS endpoint and certificate are ready before dependents.
130
+ if (hostname) {
131
+ const response = await rawFetch(`https://${hostname}/v1/projects/${projectId}/topics`, { signal: AbortSignal.timeout(10000) });
132
+ if (!response.ok)
133
+ throw new Error(`pubsub(): TLS readiness failed: HTTP ${response.status}`);
134
+ await response.text();
135
+ }
136
+ await opts.setup?.(ctx);
137
+ },
138
+ });
139
+ }
@@ -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,10 +56,11 @@ 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";
63
+ import { startHttp2Proxy } from "./harness/http2-proxy.js";
63
64
  import { startTlsTerminator } from "./harness/tls-terminator.js";
64
65
  import { runContainerArgs } from "./harness/container-run.js";
65
66
  import { isOnStickyDisk } from "./harness/sticky-paths.js";
@@ -1513,6 +1514,7 @@ const INGRESS_HTTPS_SERVERS = new Map();
1513
1514
  * the listener is bound once and lives as long as the project does.
1514
1515
  */
1515
1516
  let HTTPS_TERMINATOR;
1517
+ let HTTPS_HTTP2_PROXY;
1516
1518
  /**
1517
1519
  * The in-flight first bind of :443, if one is running.
1518
1520
  *
@@ -1577,6 +1579,8 @@ function stopIngressServers() {
1577
1579
  // has nothing left to reach.
1578
1580
  HTTPS_TERMINATOR?.close();
1579
1581
  HTTPS_TERMINATOR = undefined;
1582
+ HTTPS_HTTP2_PROXY?.close();
1583
+ HTTPS_HTTP2_PROXY = undefined;
1580
1584
  HTTPS_INGRESS_STARTING = undefined;
1581
1585
  clearTables(INGRESS);
1582
1586
  }
@@ -1703,7 +1707,7 @@ async function startIngress() {
1703
1707
  if (LOWERED.proxies.length > 0) {
1704
1708
  const routes = ensurePort(INGRESS_HTTP_PORT);
1705
1709
  for (const p of LOWERED.proxies) {
1706
- routes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
1710
+ routes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port, ...(p.http2 ? { http2: true } : {}) });
1707
1711
  }
1708
1712
  }
1709
1713
  // The :443 route table mirrors every handler whose hostname a cert
@@ -1720,7 +1724,7 @@ async function startIngress() {
1720
1724
  }
1721
1725
  for (const p of LOWERED.proxies) {
1722
1726
  if (certCovers(p.hostname)) {
1723
- httpsRoutes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
1727
+ httpsRoutes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port, ...(p.http2 ? { http2: true } : {}) });
1724
1728
  }
1725
1729
  }
1726
1730
  }
@@ -1803,10 +1807,24 @@ async function startHttpsIngress(Bun) {
1803
1807
  });
1804
1808
  INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, plain);
1805
1809
  try {
1810
+ HTTPS_HTTP2_PROXY = await startHttp2Proxy({
1811
+ upstreamFor: async (host) => {
1812
+ const route = matchIngressRoute(routes, host);
1813
+ if (route?.kind !== "proxy" || !route.http2)
1814
+ return undefined;
1815
+ return `http://${await proxyUpstreamHost(route.service)}:${route.port}`;
1816
+ },
1817
+ clientIpFor: (port) => HTTPS_TERMINATOR?.clientIpFor(port),
1818
+ });
1806
1819
  HTTPS_TERMINATOR = await startTlsTerminator({
1807
1820
  port: INGRESS_HTTPS_PORT,
1808
1821
  hostname: "0.0.0.0",
1809
1822
  upstreamPort: plain.port,
1823
+ http2Port: HTTPS_HTTP2_PROXY.port,
1824
+ supportsHttp2: (name) => {
1825
+ const route = matchIngressRoute(routes, (name ?? "").toLowerCase());
1826
+ return route?.kind === "proxy" && route.http2 === true;
1827
+ },
1810
1828
  certFor: (serverName) => {
1811
1829
  if (serverName === undefined)
1812
1830
  return INGRESS.certByHost.values().next().value;
@@ -1818,6 +1836,8 @@ async function startHttpsIngress(Bun) {
1818
1836
  catch (err) {
1819
1837
  // Leave nothing half-built: a registered plaintext server with no
1820
1838
  // terminator would make `planBind` believe :443 is up.
1839
+ HTTPS_HTTP2_PROXY?.close();
1840
+ HTTPS_HTTP2_PROXY = undefined;
1821
1841
  INGRESS_HTTPS_SERVERS.delete(INGRESS_HTTPS_PORT);
1822
1842
  try {
1823
1843
  plain.stop(true);
@@ -1856,16 +1876,16 @@ function certCovers(hostname) {
1856
1876
  * per-test snapshot exactly like fake state — a `dependsOn` child inherits
1857
1877
  * it, siblings forked from an earlier snapshot never see it.
1858
1878
  */
1859
- async function bindRuntimeTls(hostname, service, port) {
1879
+ async function bindRuntimeTls(hostname, service, port, http2) {
1860
1880
  // Reuse the boot primitives for validation + lowercasing; throws on a
1861
1881
  // malformed hostname / upstream just like a boot `tls` would at load.
1862
- const decl = makeProxyDecl(hostname, { service, port });
1882
+ const decl = makeProxyDecl(hostname, { service, port, http2 });
1863
1883
  const host = decl.hostname;
1864
1884
  if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
1865
1885
  throw new Error(`runtime tls for ${JSON.stringify(host)} requires the in-VM root CA at ${CA_PATH}`);
1866
1886
  }
1867
1887
  const Bun = requireBun();
1868
- const route = { kind: "proxy", service, port };
1888
+ const route = { kind: "proxy", service, port, http2 };
1869
1889
  // Decide the cost before mutating anything: an already-covered hostname
1870
1890
  // (an exact duplicate, or one under a boot wildcard) needs only a route.
1871
1891
  const plan = planBind(INGRESS, host, {
@@ -2098,8 +2118,6 @@ server, service, port, listenerLabel, proto) {
2098
2118
  // and the forwardable headers. Same provenance stamps as the HTTP path.
2099
2119
  const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
2100
2120
  const headers = upstreamHandshakeHeaders(req.headers, {
2101
- service,
2102
- port,
2103
2121
  proto,
2104
2122
  clientIp: ingressClientIp(server, req, proto),
2105
2123
  });
@@ -2127,28 +2145,10 @@ server, service, port, listenerLabel, proto) {
2127
2145
  }
2128
2146
  return new Response(`spectest-daemon: ws upgrade refused on ${listenerLabel}\n`, { status: 426, headers: { "content-type": "text/plain" } });
2129
2147
  }
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}`);
2148
+ const fwdHeaders = upstreamRequestHeaders(req.headers, {
2149
+ proto,
2150
+ clientIp: ingressClientIp(server, req, proto),
2151
+ });
2152
2152
  // Fresh connection per upstream request — never reuse a pooled
2153
2153
  // keep-alive conn. Upstreams with short idle timeouts (uvicorn defaults
2154
2154
  // to 5s) close pooled connections under Bun's fetch, and the next
@@ -2619,7 +2619,7 @@ async function startRuntimeService(spec) {
2619
2619
  // `tls` is the runtime twin of a boot service's: stand up a
2620
2620
  // TLS-terminating reverse proxy at https://<hostname>/ → this container.
2621
2621
  for (const entry of svc.tls ?? []) {
2622
- await bindRuntimeTls(entry.hostname, svc.name, entry.port);
2622
+ await bindRuntimeTls(entry.hostname, svc.name, entry.port, entry.http2);
2623
2623
  }
2624
2624
  RUNTIME_SERVICES.set(svc.name, svc);
2625
2625
  recordEnv({
@@ -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
@@ -0,0 +1,10 @@
1
+ export interface Http2ProxyOptions {
2
+ /** Resolve each stream independently; one connection can carry multiple authorities. */
3
+ upstreamFor(hostname: string): Promise<string | undefined>;
4
+ clientIpFor?(port: number | undefined): string | undefined;
5
+ }
6
+ /** A stream proxy rather than fetch: gRPC depends on duplex bodies and trailers. */
7
+ export declare function startHttp2Proxy(options: Http2ProxyOptions): Promise<{
8
+ port: number;
9
+ close(): void;
10
+ }>;
@@ -0,0 +1,108 @@
1
+ import http2 from "node:http2";
2
+ import { isHopByHop } from "./http-proxy";
3
+ import { hostWithoutPort } from "./hostmatch";
4
+ function headersForHop(headers, pseudo) {
5
+ const result = {};
6
+ for (const [name, value] of Object.entries(headers)) {
7
+ if ((!pseudo && name.startsWith(":")) || (isHopByHop(name) && !(name === "te" && value === "trailers")))
8
+ continue;
9
+ result[name] = value;
10
+ }
11
+ return result;
12
+ }
13
+ /** A stream proxy rather than fetch: gRPC depends on duplex bodies and trailers. */
14
+ export async function startHttp2Proxy(options) {
15
+ const server = http2.createServer();
16
+ const sessions = new Set();
17
+ const track = (session) => {
18
+ sessions.add(session);
19
+ session.on("error", () => { });
20
+ session.once("close", () => sessions.delete(session));
21
+ };
22
+ server.on("session", track);
23
+ server.on("stream", (downstream, headers) => {
24
+ downstream.on("error", () => { });
25
+ void forward(downstream, headers).catch(() => {
26
+ if (downstream.destroyed || downstream.closed)
27
+ return;
28
+ if (downstream.headersSent)
29
+ downstream.close(http2.constants.NGHTTP2_INTERNAL_ERROR);
30
+ else {
31
+ downstream.respond({ ":status": 502 });
32
+ downstream.end("HTTP/2 upstream unavailable");
33
+ }
34
+ });
35
+ });
36
+ async function forward(downstream, headers) {
37
+ const authority = String(headers[":authority"] ?? "");
38
+ const upstreamUrl = await options.upstreamFor(hostWithoutPort(authority.toLowerCase()));
39
+ if (downstream.destroyed || downstream.closed)
40
+ return;
41
+ if (!upstreamUrl) {
42
+ // A coalesced connection must not silently route an authority to its SNI backend.
43
+ downstream.respond({ ":status": 421 });
44
+ downstream.end("No HTTP/2 route for this authority");
45
+ return;
46
+ }
47
+ const session = http2.connect(upstreamUrl);
48
+ track(session);
49
+ let requestTrailers = {};
50
+ let responseTrailers = {};
51
+ const requestHeaders = headersForHop(headers, true);
52
+ requestHeaders[":scheme"] = "http";
53
+ requestHeaders["x-forwarded-proto"] = "https";
54
+ requestHeaders["x-forwarded-host"] = authority;
55
+ const clientIp = options.clientIpFor?.(downstream.session?.socket.remotePort);
56
+ if (clientIp)
57
+ requestHeaders["x-forwarded-for"] = [headers["x-forwarded-for"], clientIp].filter(Boolean).join(", ");
58
+ let upstream;
59
+ try {
60
+ upstream = session.request(requestHeaders, { endStream: false, waitForTrailers: true });
61
+ }
62
+ catch (error) {
63
+ session.destroy();
64
+ throw error;
65
+ }
66
+ const fail = () => {
67
+ if (!downstream.closed && !downstream.destroyed) {
68
+ if (downstream.headersSent)
69
+ downstream.close(http2.constants.NGHTTP2_INTERNAL_ERROR);
70
+ else {
71
+ downstream.respond({ ":status": 502 });
72
+ downstream.end("HTTP/2 upstream unavailable");
73
+ }
74
+ }
75
+ session.destroy();
76
+ };
77
+ session.on("error", fail);
78
+ upstream.on("error", fail);
79
+ downstream.on("trailers", trailers => { requestTrailers = headersForHop(trailers, false); });
80
+ upstream.on("wantTrailers", () => { if (!upstream.destroyed && !upstream.closed)
81
+ upstream.sendTrailers(requestTrailers); });
82
+ upstream.on("trailers", trailers => { responseTrailers = headersForHop(trailers, false); });
83
+ downstream.on("wantTrailers", () => {
84
+ if (!downstream.destroyed && !downstream.closed)
85
+ downstream.sendTrailers(responseTrailers);
86
+ });
87
+ upstream.on("response", response => {
88
+ if (downstream.destroyed || downstream.closed)
89
+ return;
90
+ downstream.respond(headersForHop(response, true), { waitForTrailers: true });
91
+ upstream.pipe(downstream);
92
+ });
93
+ downstream.on("aborted", () => upstream.close(downstream.rstCode || http2.constants.NGHTTP2_CANCEL));
94
+ upstream.on("aborted", () => { if (!downstream.closed)
95
+ downstream.close(upstream.rstCode || http2.constants.NGHTTP2_CANCEL); });
96
+ downstream.once("close", () => session.destroy());
97
+ downstream.pipe(upstream);
98
+ }
99
+ await new Promise((resolve, reject) => {
100
+ server.once("error", reject);
101
+ server.listen(0, "127.0.0.1", () => { server.removeListener("error", reject); resolve(); });
102
+ });
103
+ return {
104
+ port: server.address().port,
105
+ close() { for (const session of sessions)
106
+ session.destroy(); server.close(); },
107
+ };
108
+ }
@@ -55,6 +55,7 @@ export type Route<F = unknown> = {
55
55
  kind: "proxy";
56
56
  service: string;
57
57
  port: number;
58
+ http2?: boolean;
58
59
  };
59
60
  /** A PEM leaf + key, as handed to `Bun.serve`'s TLS config. */
60
61
  export interface Leaf {
@@ -70,7 +71,7 @@ export interface Leaf {
70
71
  export interface IngressTables<F = unknown> {
71
72
  /** Listen port → (hostname → route). Values are mutated in place. */
72
73
  routesByPort: Map<number, Map<string, Route<F>>>;
73
- /** SNI server name → leaf. Adding to this is what forces a rebind. */
74
+ /** SNI server name → leaf. Read live by the TLS terminator without rebinding. */
74
75
  certByHost: Map<string, Leaf>;
75
76
  }
76
77
  /** Fixed HTTPS port shared by every TLS route (fakes + service `tls`). */