@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,9 @@ export interface TlsTerminatorOptions {
6
6
  hostname: string;
7
7
  /** Loopback port of the plaintext ingress server to pipe into. */
8
8
  upstreamPort: number;
9
+ /** Loopback HTTP/2 router, selected only for an opted-in SNI hostname. */
10
+ http2Port?: number;
11
+ supportsHttp2?(serverName: string): boolean;
9
12
  /**
10
13
  * The leaf to serve for an SNI server name, read live on every
11
14
  * handshake — this is what lets a certificate be added with no restart.
@@ -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)`)
@@ -113,6 +114,8 @@ export function startTlsTerminator(opts) {
113
114
  const fallback = opts.certFor(undefined);
114
115
  const server = tls.createServer({
115
116
  ...(fallback ? { cert: fallback.cert, key: fallback.key } : {}),
117
+ ...(opts.http2Port ? { ALPNCallback: ({ servername, protocols }) => protocols.includes("h2") && opts.supportsHttp2?.(servername) ? "h2"
118
+ : protocols.includes("http/1.1") ? "http/1.1" : undefined } : {}),
116
119
  SNICallback: (serverName, cb) => {
117
120
  const leaf = opts.certFor(serverName) ?? fallback;
118
121
  // No certificate at all is a handshake we cannot complete. Answer
@@ -133,7 +136,8 @@ export function startTlsTerminator(opts) {
133
136
  // its bytes are buffered rather than dropped — which is the normal
134
137
  // case, since a client sends its first request the instant the
135
138
  // handshake completes.
136
- const upstream = net.connect(opts.upstreamPort, "127.0.0.1");
139
+ const upstreamPort = client.alpnProtocol === "h2" ? opts.http2Port : opts.upstreamPort;
140
+ const upstream = net.connect(upstreamPort, "127.0.0.1");
137
141
  upstream.setTimeout(0);
138
142
  upstream.setNoDelay(true);
139
143
  client.setNoDelay(true);
@@ -161,7 +165,7 @@ export function startTlsTerminator(opts) {
161
165
  teardown();
162
166
  });
163
167
  upstream.on("error", (err) => {
164
- warn(`ingress upstream :${opts.upstreamPort} failed`, err);
168
+ warn(`ingress upstream :${upstreamPort} failed`, err);
165
169
  teardown();
166
170
  });
167
171
  client.on("close", teardown);
@@ -178,7 +182,7 @@ export function startTlsTerminator(opts) {
178
182
  server.listen(opts.port, opts.hostname, () => {
179
183
  server.removeListener("error", reject);
180
184
  resolve({
181
- port: opts.port,
185
+ port: server.address().port,
182
186
  clientIpFor: (upstreamPort) => upstreamPort === undefined ? undefined : clientIpByUpstreamPort.get(upstreamPort),
183
187
  connectionCount: () => live.size,
184
188
  close: () => {
@@ -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/dist/index.d.ts CHANGED
@@ -741,6 +741,9 @@ export interface ServiceTls {
741
741
  /** HTTP port the service listens on inside its container. The
742
742
  * daemon forwards proxied requests here over `spectest-net`. */
743
743
  port: number;
744
+ /** Accept HTTP/2 over TLS and forward it as plaintext HTTP/2 (h2c).
745
+ * Required for gRPC. HTTP/1.1 continues to use the existing HTTP proxy. */
746
+ http2?: boolean;
744
747
  }
745
748
  /** @internal Normalized wire representation; legacy definitions remain accepted. */
746
749
  export type NormalizedServiceImage = {
package/dist/index.js CHANGED
@@ -361,6 +361,8 @@ function validateEnvironmentConfig(config) {
361
361
  if (!entry || typeof entry.hostname !== "string" || typeof entry.port !== "number") {
362
362
  throw new Error(`service "${name}" tls entry must be { hostname: string, port: number }; got ${JSON.stringify(entry)}`);
363
363
  }
364
+ if (entry.http2 !== undefined && typeof entry.http2 !== "boolean")
365
+ throw new Error(`service "${name}" tls http2 must be a boolean`);
364
366
  const h = entry.hostname.toLowerCase();
365
367
  if (!HOSTNAME_RE.test(hostPatternBody(h))) {
366
368
  throw new Error(`service "${name}" tls hostname ${JSON.stringify(entry.hostname)} is not a multi-label DNS name or wildcard (e.g. "app.test", "*.us-east-1.amazonaws.com")`);
package/dist/ingress.d.ts CHANGED
@@ -23,6 +23,7 @@ export interface ProxyDecl {
23
23
  upstream: {
24
24
  service: string;
25
25
  port: number;
26
+ http2?: boolean;
26
27
  };
27
28
  }
28
29
  export type IngressDecl = CertificateDecl | DnsDecl | ProxyDecl;
@@ -69,6 +70,7 @@ export declare function dnsName(hostname: string, target: DnsTarget): DnsDecl;
69
70
  export declare function proxy(hostname: string, upstream: {
70
71
  service: string;
71
72
  port: number;
73
+ http2?: boolean;
72
74
  }): ProxyDecl;
73
75
  /**
74
76
  * Attach low-level ingress decls to a service definition a component
@@ -97,6 +99,7 @@ export interface LoweredIngress {
97
99
  hostname: string;
98
100
  service: string;
99
101
  port: number;
102
+ http2?: boolean;
100
103
  }[];
101
104
  /** Hostnames that resolve to the daemon ingress gateway (DNS registry
102
105
  * + `--add-host` on every container). Covers fakes, TLS proxies, and
package/dist/ingress.js CHANGED
@@ -99,6 +99,8 @@ export function proxy(hostname, upstream) {
99
99
  if (!upstream || typeof upstream.service !== "string" || typeof upstream.port !== "number") {
100
100
  throw new Error(`proxy(${JSON.stringify(hostname)}): upstream must be { service: string, port: number }`);
101
101
  }
102
+ if (upstream.http2 !== undefined && typeof upstream.http2 !== "boolean")
103
+ throw new Error("proxy(): http2 must be a boolean");
102
104
  return { kind: "proxy", hostname: hostname.toLowerCase(), upstream };
103
105
  }
104
106
  /** Symbol key under which `provides(...)` stashes a component's ingress
@@ -163,7 +165,7 @@ export function lowerIngress(project) {
163
165
  break;
164
166
  case "proxy": {
165
167
  const service = resolveSelf(decl.upstream.service, selfKey);
166
- proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port });
168
+ proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port, ...(decl.upstream.http2 ? { http2: true } : {}) });
167
169
  // A proxied hostname must route to the daemon. A wildcard one can
168
170
  // only be answered by the resolver (there is no `--add-host` for a
169
171
  // pattern), so it goes in the wildcard table pointed at the ingress.
@@ -202,7 +204,7 @@ export function lowerIngress(project) {
202
204
  // `tls: [{ hostname, port }]` → cert + TLS-terminated reverse proxy.
203
205
  for (const entry of svc.tls ?? []) {
204
206
  applyDecl(certificate([entry.hostname]), name);
205
- applyDecl(proxy(entry.hostname, { service: name, port: entry.port }), name);
207
+ applyDecl(proxy(entry.hostname, { service: name, port: entry.port, ...(entry.http2 ? { http2: true } : {}) }), name);
206
208
  }
207
209
  // `hostnames: [h]` → plain peer alias (no daemon hop).
208
210
  for (const h of svc.hostnames ?? []) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.93.1",
3
+ "version": "0.95.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+$/, "");
@@ -1,4 +1,6 @@
1
1
  // Reusable, pre-built service definitions for common dependencies.
2
+ export { pubsub, pubsubClient, type PubSubOptions, type PubSubClient, type PubSubHelpers,
3
+ type PubSubSubscriptionOptions, type PubSubMessage, type PubSubReceivedMessage } from "./pubsub.js";
2
4
  export { temporal, temporalClient, type TemporalOptions, type TemporalClient,
3
5
  type TemporalNamespace, type TemporalWorkerOptions, type TemporalPoller,
4
6
  type TemporalSearchAttributeType, type TemporalTaskType } from "./temporal.js";
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineEnvironment } from "../index.js";
3
+ import { pubsub, pubsubClient } from "./pubsub.js";
4
+
5
+ describe("Pub/Sub", () => {
6
+ test("custom port and project configure both the emulator and API readiness", () => {
7
+ const env = defineEnvironment({ name: "test", services: { messaging: pubsub({ projectId: "local-project", port: 9095 }) } });
8
+ const service = env.config.services.messaging;
9
+ expect(service.args).toContain("--host-port=0.0.0.0:9095");
10
+ expect(service.args).toContain("--project=local-project");
11
+ expect(service.tls).toEqual([{ hostname: "pubsub.googleapis.com", port: 9095, http2: true }]);
12
+ expect(Object.keys(env.config.services)).toEqual(["messaging"]);
13
+ const direct = defineEnvironment({ name: "test", services: { messaging: pubsub({ tls: false }) } });
14
+ expect(Object.keys(direct.config.services)).toEqual(["messaging"]);
15
+ expect(direct.config.services.messaging.certificates).toBeUndefined();
16
+ const custom = defineEnvironment({ name: "test", services: { messaging: pubsub({ tls: { hostname: "other.pubsub.test" } }) } });
17
+ expect(custom.config.services.messaging.tls).toEqual([{ hostname: "other.pubsub.test", port: 8085, http2: true }]);
18
+ });
19
+
20
+ test("rejects invalid configuration before launching a container", () => {
21
+ for (const port of [0, 65536, 1.5, NaN]) expect(() => pubsub({ port })).toThrow("port");
22
+ expect(() => pubsub({ projectId: "../bad" })).toThrow("projectId");
23
+ expect(() => pubsub({ port: 443 })).toThrow("TLS port");
24
+ expect(() => pubsub({ tls: { hostname: "invalid/path" } })).toThrow("hostname");
25
+ expect(() => pubsub({ tls: { hostname: "" } })).toThrow("hostname");
26
+ expect(() => pubsub({ tls: { hostname: "*.pubsub.test" } })).toThrow("exact hostname");
27
+ for (const topic of ["ab", "goog-topic", "projects/another/topics/topic", "../topic"]) expect(() => pubsub({ topics: [topic] })).toThrow("ID");
28
+ expect(() => pubsub({ subscriptions: { worker: { topic: "orders", ackDeadlineSeconds: 601 } } })).toThrow("ackDeadlineSeconds");
29
+ expect(() => pubsub({ subscriptions: { worker: { topic: "orders", pushEndpoint: "ftp://app" } } })).toThrow("HTTP");
30
+ });
31
+
32
+ test("publish encodes UTF-8 and arbitrary bytes exactly once and preserves attributes", async () => {
33
+ const bodies: any[] = [];
34
+ const client = pubsubClient("http://messaging:8085", "spectest", async (_, init) => {
35
+ bodies.push(JSON.parse(init!.body as string));
36
+ return Response.json({ messageIds: ["123"] });
37
+ });
38
+ expect(await client.publish("orders", "café ☕", { source: "test" })).toBe("123");
39
+ expect(Buffer.from(bodies[0].messages[0].data, "base64").toString()).toBe("café ☕");
40
+ expect(bodies[0].messages[0].attributes).toEqual({ source: "test" });
41
+ await client.publish("orders", new Uint8Array([0, 128, 255]));
42
+ expect([...Buffer.from(bodies[1].messages[0].data, "base64")]).toEqual([0, 128, 255]);
43
+ });
44
+
45
+ test("pull returns ack IDs and leaves acknowledgement explicit", async () => {
46
+ const calls: { url: string; body: any }[] = [];
47
+ const client = pubsubClient("http://messaging:8085/", "spectest", async (url, init) => {
48
+ calls.push({ url: String(url), body: JSON.parse(init!.body as string) });
49
+ return Response.json(calls.length === 1 ? { receivedMessages: [{ ackId: "ack-1", message: { data: "aGk=", messageId: "1" } }] } : {});
50
+ });
51
+ const messages = await client.pull("worker", { maxMessages: 1 });
52
+ expect(messages[0].ackId).toBe("ack-1");
53
+ expect(calls).toHaveLength(1);
54
+ expect(calls[0].body).toEqual({ maxMessages: 1, returnImmediately: true });
55
+ await client.acknowledge("worker", [messages[0].ackId]);
56
+ expect(calls[1]).toEqual({ url: "http://messaging:8085/v1/projects/spectest/subscriptions/worker:acknowledge", body: { ackIds: ["ack-1"] } });
57
+ expect(await client.pull("worker")).toEqual([]);
58
+ await expect(client.pull("worker", { maxMessages: 0 })).rejects.toThrow("maxMessages");
59
+ });
60
+
61
+ test("API errors include operation and status and never retry a publish", async () => {
62
+ let calls = 0;
63
+ const client = pubsubClient("http://messaging:8085", "spectest", async () => {
64
+ calls++;
65
+ return Response.json({ error: { message: "Topic not found" } }, { status: 404 });
66
+ });
67
+ await expect(client.publish("missing", "data")).rejects.toThrow("HTTP 404");
68
+ expect(calls).toBe(1);
69
+ });
70
+
71
+ test("resource IDs are escaped in URLs but remain canonical in subscription bodies", async () => {
72
+ let requestUrl = "", body: any;
73
+ const client = pubsubClient("http://messaging:8085", "spectest", async (url, init) => {
74
+ requestUrl = String(url); body = JSON.parse(init!.body as string);
75
+ return Response.json({});
76
+ });
77
+ await client.createSubscription("worker%one", { topic: "topic+one", pushEndpoint: "http://app:3000/push" });
78
+ expect(requestUrl).toEndWith("/subscriptions/worker%25one");
79
+ expect(body).toEqual({ topic: "projects/spectest/topics/topic+one", ackDeadlineSeconds: 10, pushConfig: { pushEndpoint: "http://app:3000/push" } });
80
+ });
81
+
82
+ test("every request has a bounded timeout", async () => {
83
+ const client = pubsubClient("http://messaging:8085", "spectest", async (_, init) => {
84
+ return new Promise<Response>((_, reject) => init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true }));
85
+ });
86
+ await expect(client.pull("worker", { timeoutMs: 10 })).rejects.toThrow();
87
+ });
88
+ });
@@ -0,0 +1,182 @@
1
+ import type { ServiceDefinition, ServiceHelpersContext, ServiceSetupContext, ServicesMap } from "../index.js";
2
+ import { serviceGroup, certificate } from "../index.js";
3
+ import { rawFetch } from "../harness/raw-fetch.js";
4
+ import { readRaw } from "../inspect.js";
5
+
6
+ export interface PubSubSubscriptionOptions {
7
+ /** Topic ID in this component's project. */
8
+ topic: string;
9
+ ackDeadlineSeconds?: number;
10
+ /** HTTP is supported by the emulator, including other service names. */
11
+ pushEndpoint?: string;
12
+ }
13
+
14
+ export interface PubSubOptions {
15
+ /** Local project ID; no Google Cloud account is needed. Default "spectest". */
16
+ projectId?: string;
17
+ /** Official Cloud CLI image with the Pub/Sub emulator installed. */
18
+ image?: string;
19
+ /** Emulator HTTP and gRPC port. Default 8085. */
20
+ port?: number;
21
+ /** TLS on pubsub.googleapis.com by default. Set false for direct emulator
22
+ * access, or choose a different hostname for a second isolated instance. */
23
+ tls?: false | { hostname: string };
24
+ topics?: string[];
25
+ /** Subscription ID → options. Referenced topics are created automatically. */
26
+ subscriptions?: Record<string, PubSubSubscriptionOptions>;
27
+ /** Runs after the declared topics and subscriptions have been created. */
28
+ setup?: (ctx: ServiceSetupContext<PubSubHelpers>) => void | Promise<void>;
29
+ }
30
+
31
+ export interface PubSubMessage {
32
+ /** Base64 encoded data, as returned by the Pub/Sub REST API. */
33
+ data?: string;
34
+ attributes?: Record<string, string>;
35
+ messageId: string;
36
+ publishTime: string;
37
+ orderingKey?: string;
38
+ }
39
+
40
+ export interface PubSubReceivedMessage {
41
+ ackId: string;
42
+ message: PubSubMessage;
43
+ deliveryAttempt?: number;
44
+ }
45
+
46
+ function resourceId(id: string): string {
47
+ if (!/^[A-Za-z][A-Za-z0-9._~+%-]{2,254}$/.test(id) || id.startsWith("goog")) {
48
+ throw new Error(`pubsub(): invalid topic or subscription ID ${JSON.stringify(id)}; use a 3–255 character ID starting with a letter, not "goog"`);
49
+ }
50
+ return id;
51
+ }
52
+
53
+ function projectName(id: string): string {
54
+ if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(id)) throw new Error("pubsub(): projectId must be a 6–30 character Google Cloud project ID");
55
+ return id;
56
+ }
57
+
58
+ function subscriptionOptions(options: PubSubSubscriptionOptions) {
59
+ resourceId(options.topic);
60
+ const seconds = options.ackDeadlineSeconds ?? 10;
61
+ if (!Number.isInteger(seconds) || seconds < 10 || seconds > 600) throw new Error("pubsub(): ackDeadlineSeconds must be an integer between 10 and 600");
62
+ if (options.pushEndpoint && !/^https?:$/.test(new URL(options.pushEndpoint).protocol)) throw new Error("pubsub(): pushEndpoint must use HTTP or HTTPS");
63
+ return seconds;
64
+ }
65
+
66
+ /** Stateless emulator REST client. No credentials or gRPC channel to preserve across forks.
67
+ * Resource arguments are short IDs in projectId; publish accepts UTF-8 text or bytes.
68
+ */
69
+ export function pubsubClient(
70
+ endpoint: string,
71
+ projectId = "spectest",
72
+ fetcher: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> = rawFetch,
73
+ ) {
74
+ projectName(projectId);
75
+ const base = endpoint.replace(/\/$/, "");
76
+ const resource = (kind: "topics" | "subscriptions", id: string) => `projects/${projectId}/${kind}/${resourceId(id)}`;
77
+ const path = (kind: "topics" | "subscriptions", id: string) => `/v1/projects/${projectId}/${kind}/${encodeURIComponent(resourceId(id))}`;
78
+ async function request<T>(url: string, method: string, body?: unknown, timeoutMs = 10000): Promise<T> {
79
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("pubsub(): timeoutMs must be positive");
80
+ const response = await fetcher(`${base}${url}`, {
81
+ method, headers: { "content-type": "application/json" },
82
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
83
+ signal: AbortSignal.timeout(Math.ceil(timeoutMs)),
84
+ });
85
+ const text = await response.text();
86
+ if (!response.ok) throw new Error(`Pub/Sub ${method} ${url}: HTTP ${response.status}: ${text.slice(0, 1000)}`);
87
+ return (text ? JSON.parse(text) : {}) as T;
88
+ }
89
+ return {
90
+ projectId, endpoint: base,
91
+ topicName: (id: string) => resource("topics", id),
92
+ subscriptionName: (id: string) => resource("subscriptions", id),
93
+ createTopic(id: string) {
94
+ return request<{ name: string }>(path("topics", id), "PUT", {});
95
+ },
96
+ createSubscription(id: string, options: PubSubSubscriptionOptions) {
97
+ const ackDeadlineSeconds = subscriptionOptions(options);
98
+ return request<{ name: string; topic: string; ackDeadlineSeconds: number }>(path("subscriptions", id), "PUT", {
99
+ topic: resource("topics", options.topic), ackDeadlineSeconds,
100
+ ...(options.pushEndpoint ? { pushConfig: { pushEndpoint: options.pushEndpoint } } : {}),
101
+ });
102
+ },
103
+ async publish(topic: string, data: string | Uint8Array, attributes?: Record<string, string>): Promise<string> {
104
+ const result = await request<{ messageIds: string[] }>(`${path("topics", topic)}:publish`, "POST", {
105
+ messages: [{ data: Buffer.from(data).toString("base64"), ...(attributes ? { attributes } : {}) }],
106
+ });
107
+ return result.messageIds[0];
108
+ },
109
+ /** A pull leases messages; call acknowledge() to remove them. Empty is valid.
110
+ * Uses the emulator's immediate pull mode for bounded test polling.
111
+ */
112
+ async pull(subscription: string, options: { maxMessages?: number; timeoutMs?: number } = {}): Promise<PubSubReceivedMessage[]> {
113
+ const maxMessages = options.maxMessages ?? 10;
114
+ if (!Number.isInteger(maxMessages) || maxMessages < 1) throw new Error("pubsub(): maxMessages must be a positive integer");
115
+ const result = await request<{ receivedMessages?: PubSubReceivedMessage[] }>(`${path("subscriptions", subscription)}:pull`, "POST",
116
+ { maxMessages, returnImmediately: true }, options.timeoutMs);
117
+ return result.receivedMessages ?? [];
118
+ },
119
+ async acknowledge(subscription: string, ackIds: string[]): Promise<void> {
120
+ await request(`${path("subscriptions", subscription)}:acknowledge`, "POST", { ackIds });
121
+ },
122
+ };
123
+ }
124
+
125
+ export type PubSubClient = ReturnType<typeof pubsubClient>;
126
+ export interface PubSubHelpers extends PubSubClient {
127
+ /** Direct plaintext address for clients explicitly using emulator mode. */
128
+ emulatorHost: string;
129
+ }
130
+
131
+ /** Google Cloud's real local Pub/Sub emulator, ready for Google client libraries.
132
+ * Mount directly in services: { pubsub: pubsub({ topics: ["orders"] }) }.
133
+ * Applications use their default pubsub.googleapis.com:443 endpoint with
134
+ * verified TLS and local test credentials. No emulator endpoint override.
135
+ * State survives VM snapshots/forks, but not an emulator process restart.
136
+ */
137
+ export function pubsub(opts: PubSubOptions = {}) {
138
+ const projectId = projectName(opts.projectId ?? "spectest");
139
+ const port = opts.port ?? 8085;
140
+ const hostname = opts.tls === false ? undefined : opts.tls?.hostname ?? "pubsub.googleapis.com";
141
+ if (hostname !== undefined) {
142
+ certificate([hostname]);
143
+ if (hostname.startsWith("*.")) throw new Error("pubsub(): TLS hostname must be an exact hostname");
144
+ }
145
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("pubsub(): port must be an integer between 1 and 65535");
146
+ const subscriptions = Object.entries(opts.subscriptions ?? {});
147
+ for (const [id, options] of subscriptions) { resourceId(id); subscriptionOptions(options); }
148
+ const topics = [...new Set([...(opts.topics ?? []), ...subscriptions.map(([, options]) => options.topic)])].map(resourceId);
149
+ if (hostname && port === 443) throw new Error("pubsub(): the plaintext emulator port must differ from TLS port 443");
150
+ return serviceGroup({
151
+ primary: "emulator",
152
+ services: (): ServicesMap => {
153
+ const emulator: ServiceDefinition = {
154
+ image: { type: "registry", reference: opts.image ?? "gcr.io/google.com/cloudsdktool/google-cloud-cli:573.0.0-emulators" },
155
+ args: ["gcloud", "beta", "emulators", "pubsub", "start", `--project=${projectId}`, `--host-port=0.0.0.0:${port}`, "--quiet"],
156
+ ports: [port],
157
+ ...(hostname ? { tls: [{ hostname, port, http2: true }] } : {}),
158
+ readyCheck: { type: "http", port, path: `/v1/projects/${projectId}/topics`, timeoutSecs: 60 },
159
+ setup: async ({ name }) => {
160
+ const client = pubsubClient(`http://${name}:${port}`, projectId);
161
+ for (const topic of topics) await client.createTopic(topic);
162
+ for (const [id, options] of subscriptions) await client.createSubscription(id, options);
163
+ },
164
+ };
165
+ return { emulator };
166
+ },
167
+ helpers: (ctx: ServiceHelpersContext): PubSubHelpers => ({
168
+ ...pubsubClient(hostname ? `https://${hostname}` : `http://${ctx.name}:${port}`, projectId,
169
+ async (input, init) => readRaw(await ctx.fetch(String(input), init)) as unknown as Response),
170
+ emulatorHost: `${ctx.name}:${port}`,
171
+ }),
172
+ setup: async (ctx: ServiceSetupContext<PubSubHelpers>) => {
173
+ // Prove the TLS endpoint and certificate are ready before dependents.
174
+ if (hostname) {
175
+ const response = await rawFetch(`https://${hostname}/v1/projects/${projectId}/topics`, { signal: AbortSignal.timeout(10000) });
176
+ if (!response.ok) throw new Error(`pubsub(): TLS readiness failed: HTTP ${response.status}`);
177
+ await response.text();
178
+ }
179
+ await opts.setup?.(ctx);
180
+ },
181
+ });
182
+ }
@@ -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,
@@ -158,6 +159,7 @@ import {
158
159
  unbindRoute,
159
160
  type Route as IngressRoute,
160
161
  } from "./harness/ingress-table.js";
162
+ import { startHttp2Proxy } from "./harness/http2-proxy.js";
161
163
  import { startTlsTerminator, type TlsTerminator } from "./harness/tls-terminator.js";
162
164
  import { runContainerArgs } from "./harness/container-run.js";
163
165
  import { isOnStickyDisk } from "./harness/sticky-paths.js";
@@ -1939,6 +1941,7 @@ const INGRESS_HTTPS_SERVERS = new Map<number, any>();
1939
1941
  * the listener is bound once and lives as long as the project does.
1940
1942
  */
1941
1943
  let HTTPS_TERMINATOR: TlsTerminator | undefined;
1944
+ let HTTPS_HTTP2_PROXY: Awaited<ReturnType<typeof startHttp2Proxy>> | undefined;
1942
1945
  /**
1943
1946
  * The in-flight first bind of :443, if one is running.
1944
1947
  *
@@ -2002,6 +2005,8 @@ function stopIngressServers(): void {
2002
2005
  // has nothing left to reach.
2003
2006
  HTTPS_TERMINATOR?.close();
2004
2007
  HTTPS_TERMINATOR = undefined;
2008
+ HTTPS_HTTP2_PROXY?.close();
2009
+ HTTPS_HTTP2_PROXY = undefined;
2005
2010
  HTTPS_INGRESS_STARTING = undefined;
2006
2011
  clearTables(INGRESS);
2007
2012
  }
@@ -2141,7 +2146,7 @@ async function startIngress(): Promise<void> {
2141
2146
  if (LOWERED.proxies.length > 0) {
2142
2147
  const routes = ensurePort(INGRESS_HTTP_PORT);
2143
2148
  for (const p of LOWERED.proxies) {
2144
- routes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
2149
+ routes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port, ...(p.http2 ? { http2: true } : {}) });
2145
2150
  }
2146
2151
  }
2147
2152
  // The :443 route table mirrors every handler whose hostname a cert
@@ -2157,7 +2162,7 @@ async function startIngress(): Promise<void> {
2157
2162
  }
2158
2163
  for (const p of LOWERED.proxies) {
2159
2164
  if (certCovers(p.hostname)) {
2160
- httpsRoutes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
2165
+ httpsRoutes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port, ...(p.http2 ? { http2: true } : {}) });
2161
2166
  }
2162
2167
  }
2163
2168
  }
@@ -2245,10 +2250,23 @@ async function startHttpsIngress(Bun: any): Promise<void> {
2245
2250
  });
2246
2251
  INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, plain);
2247
2252
  try {
2253
+ HTTPS_HTTP2_PROXY = await startHttp2Proxy({
2254
+ upstreamFor: async (host) => {
2255
+ const route = matchIngressRoute(routes, host);
2256
+ if (route?.kind !== "proxy" || !route.http2) return undefined;
2257
+ return `http://${await proxyUpstreamHost(route.service)}:${route.port}`;
2258
+ },
2259
+ clientIpFor: (port) => HTTPS_TERMINATOR?.clientIpFor(port),
2260
+ });
2248
2261
  HTTPS_TERMINATOR = await startTlsTerminator({
2249
2262
  port: INGRESS_HTTPS_PORT,
2250
2263
  hostname: "0.0.0.0",
2251
2264
  upstreamPort: plain.port,
2265
+ http2Port: HTTPS_HTTP2_PROXY.port,
2266
+ supportsHttp2: (name) => {
2267
+ const route = matchIngressRoute(routes, (name ?? "").toLowerCase());
2268
+ return route?.kind === "proxy" && route.http2 === true;
2269
+ },
2252
2270
  certFor: (serverName) => {
2253
2271
  if (serverName === undefined) return INGRESS.certByHost.values().next().value;
2254
2272
  const name = selectCertName(INGRESS.certByHost.keys(), serverName.toLowerCase());
@@ -2258,6 +2276,8 @@ async function startHttpsIngress(Bun: any): Promise<void> {
2258
2276
  } catch (err) {
2259
2277
  // Leave nothing half-built: a registered plaintext server with no
2260
2278
  // terminator would make `planBind` believe :443 is up.
2279
+ HTTPS_HTTP2_PROXY?.close();
2280
+ HTTPS_HTTP2_PROXY = undefined;
2261
2281
  INGRESS_HTTPS_SERVERS.delete(INGRESS_HTTPS_PORT);
2262
2282
  try {
2263
2283
  plain.stop(true);
@@ -2299,10 +2319,10 @@ function certCovers(hostname: string): boolean {
2299
2319
  * per-test snapshot exactly like fake state — a `dependsOn` child inherits
2300
2320
  * it, siblings forked from an earlier snapshot never see it.
2301
2321
  */
2302
- async function bindRuntimeTls(hostname: string, service: string, port: number): Promise<void> {
2322
+ async function bindRuntimeTls(hostname: string, service: string, port: number, http2?: boolean): Promise<void> {
2303
2323
  // Reuse the boot primitives for validation + lowercasing; throws on a
2304
2324
  // malformed hostname / upstream just like a boot `tls` would at load.
2305
- const decl = makeProxyDecl(hostname, { service, port });
2325
+ const decl = makeProxyDecl(hostname, { service, port, http2 });
2306
2326
  const host = decl.hostname;
2307
2327
  if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
2308
2328
  throw new Error(
@@ -2310,7 +2330,7 @@ async function bindRuntimeTls(hostname: string, service: string, port: number):
2310
2330
  );
2311
2331
  }
2312
2332
  const Bun = requireBun();
2313
- const route: Route = { kind: "proxy", service, port };
2333
+ const route: Route = { kind: "proxy", service, port, http2 };
2314
2334
 
2315
2335
  // Decide the cost before mutating anything: an already-covered hostname
2316
2336
  // (an exact duplicate, or one under a boot wildcard) needs only a route.
@@ -2579,8 +2599,6 @@ async function proxyToService(
2579
2599
  // and the forwardable headers. Same provenance stamps as the HTTP path.
2580
2600
  const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
2581
2601
  const headers = upstreamHandshakeHeaders(req.headers, {
2582
- service,
2583
- port,
2584
2602
  proto,
2585
2603
  clientIp: ingressClientIp(server, req, proto),
2586
2604
  });
@@ -2614,25 +2632,10 @@ async function proxyToService(
2614
2632
  );
2615
2633
  }
2616
2634
 
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}`);
2635
+ const fwdHeaders = upstreamRequestHeaders(req.headers, {
2636
+ proto,
2637
+ clientIp: ingressClientIp(server, req, proto),
2638
+ });
2636
2639
  // Fresh connection per upstream request — never reuse a pooled
2637
2640
  // keep-alive conn. Upstreams with short idle timeouts (uvicorn defaults
2638
2641
  // to 5s) close pooled connections under Bun's fetch, and the next
@@ -3149,7 +3152,7 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
3149
3152
  // `tls` is the runtime twin of a boot service's: stand up a
3150
3153
  // TLS-terminating reverse proxy at https://<hostname>/ → this container.
3151
3154
  for (const entry of svc.tls ?? []) {
3152
- await bindRuntimeTls(entry.hostname, svc.name, entry.port);
3155
+ await bindRuntimeTls(entry.hostname, svc.name, entry.port, entry.http2);
3153
3156
  }
3154
3157
  RUNTIME_SERVICES.set(svc.name, svc);
3155
3158
  recordEnv({