@specific.dev/spectest 0.94.0 → 0.96.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.
package/README.md CHANGED
@@ -15,6 +15,34 @@ 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
+ Google Cloud components are available from `@specific.dev/spectest/components/gcp`.
21
+ The existing Pub/Sub exports from `/components` remain supported.
22
+
23
+ `pubsub()` runs Google's local emulator and creates topics/subscriptions before
24
+ dependent services start:
25
+
26
+ ```ts
27
+ import { pubsub } from "@specific.dev/spectest/components/gcp";
28
+
29
+ const services = {
30
+ messages: pubsub({
31
+ projectId: "local-orders",
32
+ subscriptions: { "orders-worker": { topic: "orders" } },
33
+ }),
34
+ };
35
+ ```
36
+
37
+ The default endpoint is `https://pubsub.googleapis.com`, with trusted TLS and
38
+ gRPC support. Keep your application's normal endpoint configuration, supply
39
+ local test credentials and project `local-orders`, and use
40
+ `dependsOn: ["messages"]`. Tests can call
41
+ `ctx.svc.messages.publish()`, `pull()` and `acknowledge()`. VM snapshots preserve
42
+ the emulator's live state. See `examples/google-pubsub` and
43
+ `spectest docs /components/pubsub` for test credential setup and options.
44
+ Set `tls: false` to use explicit plaintext emulator mode instead.
45
+
18
46
  ## HTTP assertions
19
47
 
20
48
  `ctx.fetch()` returns a wrapped response. Status fields, header lookups, and
@@ -113,3 +141,13 @@ Dockerfile builds in `images` targeting `registry.test/...` are published and
113
141
  preloaded from the same cached build, preserving digests without an export/push
114
142
  step. Other images keep direct preloading. Registry writes stay isolated across
115
143
  test forks; client and containerd CA trust are configured automatically.
144
+
145
+ ### HTTP/2 and gRPC ingress
146
+
147
+ For a service listening on plaintext HTTP/2 (h2c), declare
148
+ `tls: [{ hostname: "grpc.example.test", port: 50051, http2: true }]`.
149
+ Clients use trusted TLS on port 443. The ingress selects HTTP/2 with ALPN,
150
+ routes each stream by `:authority`, and preserves streaming bodies, metadata,
151
+ trailers, and cancellation. HTTP/1.1 requests still use the existing HTTP proxy.
152
+ Runtime services support the same option. HTTP/2 streams currently bypass
153
+ `ctx.intercept` and HTTP request recording. `pubsub()` enables this by default.
@@ -0,0 +1,2 @@
1
+ /** Google Cloud components for local Spectest environments. */
2
+ export { pubsub, pubsubClient, type PubSubOptions, type PubSubClient, type PubSubHelpers, type PubSubSubscriptionOptions, type PubSubMessage, type PubSubReceivedMessage, } from "./pubsub.js";
@@ -0,0 +1,2 @@
1
+ /** Google Cloud components for local Spectest environments. */
2
+ export { pubsub, pubsubClient, } from "./pubsub.js";
@@ -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
+ }
package/dist/daemon.js CHANGED
@@ -60,6 +60,7 @@ import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsP
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, {
@@ -2599,7 +2619,7 @@ async function startRuntimeService(spec) {
2599
2619
  // `tls` is the runtime twin of a boot service's: stand up a
2600
2620
  // TLS-terminating reverse proxy at https://<hostname>/ → this container.
2601
2621
  for (const entry of svc.tls ?? []) {
2602
- await bindRuntimeTls(entry.hostname, svc.name, entry.port);
2622
+ await bindRuntimeTls(entry.hostname, svc.name, entry.port, entry.http2);
2603
2623
  }
2604
2624
  RUNTIME_SERVICES.set(svc.name, svc);
2605
2625
  recordEnv({
@@ -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`). */
@@ -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: () => {
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