@specific.dev/spectest 0.94.0 → 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.
- package/README.md +35 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/pubsub.d.ts +77 -0
- package/dist/components/pubsub.js +139 -0
- package/dist/daemon.js +26 -6
- package/dist/harness/http2-proxy.d.ts +10 -0
- package/dist/harness/http2-proxy.js +108 -0
- package/dist/harness/ingress-table.d.ts +2 -1
- package/dist/harness/tls-terminator.d.ts +3 -0
- package/dist/harness/tls-terminator.js +8 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/ingress.d.ts +3 -0
- package/dist/ingress.js +4 -2
- package/package.json +1 -1
- package/src/components/index.ts +2 -0
- package/src/components/pubsub.test.ts +88 -0
- package/src/components/pubsub.ts +182 -0
- package/src/daemon.ts +25 -6
- package/src/harness/http2-proxy.test.ts +91 -0
- package/src/harness/http2-proxy.ts +96 -0
- package/src/harness/ingress-table.ts +2 -2
- package/src/harness/tls-terminator.test.ts +29 -0
- package/src/harness/tls-terminator.ts +12 -4
- package/src/index.ts +4 -0
- package/src/ingress.ts +7 -6
package/src/components/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/daemon.ts
CHANGED
|
@@ -159,6 +159,7 @@ import {
|
|
|
159
159
|
unbindRoute,
|
|
160
160
|
type Route as IngressRoute,
|
|
161
161
|
} from "./harness/ingress-table.js";
|
|
162
|
+
import { startHttp2Proxy } from "./harness/http2-proxy.js";
|
|
162
163
|
import { startTlsTerminator, type TlsTerminator } from "./harness/tls-terminator.js";
|
|
163
164
|
import { runContainerArgs } from "./harness/container-run.js";
|
|
164
165
|
import { isOnStickyDisk } from "./harness/sticky-paths.js";
|
|
@@ -1940,6 +1941,7 @@ const INGRESS_HTTPS_SERVERS = new Map<number, any>();
|
|
|
1940
1941
|
* the listener is bound once and lives as long as the project does.
|
|
1941
1942
|
*/
|
|
1942
1943
|
let HTTPS_TERMINATOR: TlsTerminator | undefined;
|
|
1944
|
+
let HTTPS_HTTP2_PROXY: Awaited<ReturnType<typeof startHttp2Proxy>> | undefined;
|
|
1943
1945
|
/**
|
|
1944
1946
|
* The in-flight first bind of :443, if one is running.
|
|
1945
1947
|
*
|
|
@@ -2003,6 +2005,8 @@ function stopIngressServers(): void {
|
|
|
2003
2005
|
// has nothing left to reach.
|
|
2004
2006
|
HTTPS_TERMINATOR?.close();
|
|
2005
2007
|
HTTPS_TERMINATOR = undefined;
|
|
2008
|
+
HTTPS_HTTP2_PROXY?.close();
|
|
2009
|
+
HTTPS_HTTP2_PROXY = undefined;
|
|
2006
2010
|
HTTPS_INGRESS_STARTING = undefined;
|
|
2007
2011
|
clearTables(INGRESS);
|
|
2008
2012
|
}
|
|
@@ -2142,7 +2146,7 @@ async function startIngress(): Promise<void> {
|
|
|
2142
2146
|
if (LOWERED.proxies.length > 0) {
|
|
2143
2147
|
const routes = ensurePort(INGRESS_HTTP_PORT);
|
|
2144
2148
|
for (const p of LOWERED.proxies) {
|
|
2145
|
-
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 } : {}) });
|
|
2146
2150
|
}
|
|
2147
2151
|
}
|
|
2148
2152
|
// The :443 route table mirrors every handler whose hostname a cert
|
|
@@ -2158,7 +2162,7 @@ async function startIngress(): Promise<void> {
|
|
|
2158
2162
|
}
|
|
2159
2163
|
for (const p of LOWERED.proxies) {
|
|
2160
2164
|
if (certCovers(p.hostname)) {
|
|
2161
|
-
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 } : {}) });
|
|
2162
2166
|
}
|
|
2163
2167
|
}
|
|
2164
2168
|
}
|
|
@@ -2246,10 +2250,23 @@ async function startHttpsIngress(Bun: any): Promise<void> {
|
|
|
2246
2250
|
});
|
|
2247
2251
|
INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, plain);
|
|
2248
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
|
+
});
|
|
2249
2261
|
HTTPS_TERMINATOR = await startTlsTerminator({
|
|
2250
2262
|
port: INGRESS_HTTPS_PORT,
|
|
2251
2263
|
hostname: "0.0.0.0",
|
|
2252
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
|
+
},
|
|
2253
2270
|
certFor: (serverName) => {
|
|
2254
2271
|
if (serverName === undefined) return INGRESS.certByHost.values().next().value;
|
|
2255
2272
|
const name = selectCertName(INGRESS.certByHost.keys(), serverName.toLowerCase());
|
|
@@ -2259,6 +2276,8 @@ async function startHttpsIngress(Bun: any): Promise<void> {
|
|
|
2259
2276
|
} catch (err) {
|
|
2260
2277
|
// Leave nothing half-built: a registered plaintext server with no
|
|
2261
2278
|
// terminator would make `planBind` believe :443 is up.
|
|
2279
|
+
HTTPS_HTTP2_PROXY?.close();
|
|
2280
|
+
HTTPS_HTTP2_PROXY = undefined;
|
|
2262
2281
|
INGRESS_HTTPS_SERVERS.delete(INGRESS_HTTPS_PORT);
|
|
2263
2282
|
try {
|
|
2264
2283
|
plain.stop(true);
|
|
@@ -2300,10 +2319,10 @@ function certCovers(hostname: string): boolean {
|
|
|
2300
2319
|
* per-test snapshot exactly like fake state — a `dependsOn` child inherits
|
|
2301
2320
|
* it, siblings forked from an earlier snapshot never see it.
|
|
2302
2321
|
*/
|
|
2303
|
-
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> {
|
|
2304
2323
|
// Reuse the boot primitives for validation + lowercasing; throws on a
|
|
2305
2324
|
// malformed hostname / upstream just like a boot `tls` would at load.
|
|
2306
|
-
const decl = makeProxyDecl(hostname, { service, port });
|
|
2325
|
+
const decl = makeProxyDecl(hostname, { service, port, http2 });
|
|
2307
2326
|
const host = decl.hostname;
|
|
2308
2327
|
if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
|
|
2309
2328
|
throw new Error(
|
|
@@ -2311,7 +2330,7 @@ async function bindRuntimeTls(hostname: string, service: string, port: number):
|
|
|
2311
2330
|
);
|
|
2312
2331
|
}
|
|
2313
2332
|
const Bun = requireBun();
|
|
2314
|
-
const route: Route = { kind: "proxy", service, port };
|
|
2333
|
+
const route: Route = { kind: "proxy", service, port, http2 };
|
|
2315
2334
|
|
|
2316
2335
|
// Decide the cost before mutating anything: an already-covered hostname
|
|
2317
2336
|
// (an exact duplicate, or one under a boot wildcard) needs only a route.
|
|
@@ -3133,7 +3152,7 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
|
|
|
3133
3152
|
// `tls` is the runtime twin of a boot service's: stand up a
|
|
3134
3153
|
// TLS-terminating reverse proxy at https://<hostname>/ → this container.
|
|
3135
3154
|
for (const entry of svc.tls ?? []) {
|
|
3136
|
-
await bindRuntimeTls(entry.hostname, svc.name, entry.port);
|
|
3155
|
+
await bindRuntimeTls(entry.hostname, svc.name, entry.port, entry.http2);
|
|
3137
3156
|
}
|
|
3138
3157
|
RUNTIME_SERVICES.set(svc.name, svc);
|
|
3139
3158
|
recordEnv({
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import http2 from "node:http2";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { startHttp2Proxy } from "./http2-proxy";
|
|
5
|
+
|
|
6
|
+
const cleanup: (() => void)[] = [];
|
|
7
|
+
afterEach(() => { for (const close of cleanup.splice(0).reverse()) close(); });
|
|
8
|
+
async function backend(handler: (stream: http2.ServerHttp2Stream, headers: http2.IncomingHttpHeaders) => void) {
|
|
9
|
+
const server = http2.createServer();
|
|
10
|
+
const sessions = new Set<http2.ServerHttp2Session>();
|
|
11
|
+
server.on("session", session => { sessions.add(session); session.on("error", () => {}); });
|
|
12
|
+
server.on("stream", (stream, headers) => { stream.on("error", () => {}); handler(stream, headers); });
|
|
13
|
+
server.listen(0, "127.0.0.1");
|
|
14
|
+
await once(server, "listening");
|
|
15
|
+
cleanup.push(() => { for (const session of sessions) session.destroy(); server.close(); });
|
|
16
|
+
return `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
|
17
|
+
}
|
|
18
|
+
async function frontend(routes: Map<string, string>) {
|
|
19
|
+
const proxy = await startHttp2Proxy({ upstreamFor: async host => routes.get(host), clientIpFor: () => "10.0.0.2" });
|
|
20
|
+
cleanup.push(() => proxy.close());
|
|
21
|
+
const session = http2.connect(`http://127.0.0.1:${proxy.port}`);
|
|
22
|
+
session.on("error", () => {});
|
|
23
|
+
cleanup.push(() => session.destroy());
|
|
24
|
+
return session;
|
|
25
|
+
}
|
|
26
|
+
async function request(session: http2.ClientHttp2Session, authority: string) {
|
|
27
|
+
const stream = session.request({ ":authority": authority, ":path": "/rpc" });
|
|
28
|
+
let text = "";
|
|
29
|
+
stream.setEncoding("utf8"); stream.on("data", chunk => { text += chunk; });
|
|
30
|
+
const [headers] = await once(stream, "response");
|
|
31
|
+
await once(stream, "end");
|
|
32
|
+
return { text, headers };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("routes each HTTP/2 authority, including routes added to an existing connection", async () => {
|
|
36
|
+
const a = await backend(s => { s.respond(); s.end("a"); });
|
|
37
|
+
const b = await backend(s => { s.respond(); s.end("b"); });
|
|
38
|
+
const routes = new Map([["a.test", a]]);
|
|
39
|
+
const client = await frontend(routes);
|
|
40
|
+
expect((await request(client, "a.test")).text).toBe("a");
|
|
41
|
+
routes.set("b.test", b);
|
|
42
|
+
expect((await request(client, "b.test:443")).text).toBe("b");
|
|
43
|
+
expect((await request(client, "missing.test")).headers[":status"]).toBe(421);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("streams both directions before request end and forwards request/response trailers", async () => {
|
|
47
|
+
let upstreamHeaders: http2.IncomingHttpHeaders | undefined;
|
|
48
|
+
let requestTrailers: http2.IncomingHttpHeaders | undefined;
|
|
49
|
+
const url = await backend((s, h) => {
|
|
50
|
+
upstreamHeaders = h;
|
|
51
|
+
s.respond({ ":status": 200, "content-type": "application/grpc", "metadata-bin": "AQID" }, { waitForTrailers: true });
|
|
52
|
+
s.on("trailers", t => { requestTrailers = t; });
|
|
53
|
+
s.on("data", data => s.write(data));
|
|
54
|
+
s.on("end", () => s.end());
|
|
55
|
+
s.on("wantTrailers", () => s.sendTrailers({ "grpc-status": "7", "grpc-message": "Permission%20denied" }));
|
|
56
|
+
});
|
|
57
|
+
const client = await frontend(new Map([["grpc.test", url]]));
|
|
58
|
+
const stream = client.request({ ":authority": "grpc.test", ":path": "/package.Service/Call", ":method": "POST", "content-type": "application/grpc", te: "trailers" }, { waitForTrailers: true });
|
|
59
|
+
stream.on("wantTrailers", () => stream.sendTrailers({ "request-trailer": "preserved" }));
|
|
60
|
+
const response = once(stream, "response");
|
|
61
|
+
const first = once(stream, "data");
|
|
62
|
+
stream.write(Buffer.from([0, 0, 0, 0, 3, 0, 128, 255]));
|
|
63
|
+
expect((await first)[0]).toEqual(Buffer.from([0, 0, 0, 0, 3, 0, 128, 255]));
|
|
64
|
+
const trailers = once(stream, "trailers");
|
|
65
|
+
stream.end();
|
|
66
|
+
expect((await trailers)[0]["grpc-status"]).toBe("7");
|
|
67
|
+
expect((await response)[0]["metadata-bin"]).toBe("AQID");
|
|
68
|
+
expect(requestTrailers?.["request-trailer"]).toBe("preserved");
|
|
69
|
+
expect(upstreamHeaders?.[":authority"]).toBe("grpc.test");
|
|
70
|
+
expect(upstreamHeaders?.["x-forwarded-proto"]).toBe("https");
|
|
71
|
+
expect(upstreamHeaders?.["x-forwarded-for"]).toBe("10.0.0.2");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("client cancellation reaches upstream without breaking another multiplexed RPC", async () => {
|
|
75
|
+
let cancelled!: Promise<unknown[]>;
|
|
76
|
+
const url = await backend((s, h) => {
|
|
77
|
+
if (h[":path"] === "/cancel") { cancelled = once(s, "aborted"); s.respond(); s.write("ready"); }
|
|
78
|
+
else { s.respond(); s.end("healthy"); }
|
|
79
|
+
});
|
|
80
|
+
const client = await frontend(new Map([["grpc.test", url]]));
|
|
81
|
+
const stream = client.request({ ":authority": "grpc.test", ":path": "/cancel" });
|
|
82
|
+
await once(stream, "data");
|
|
83
|
+
stream.close(http2.constants.NGHTTP2_CANCEL);
|
|
84
|
+
await cancelled;
|
|
85
|
+
expect((await request(client, "grpc.test")).text).toBe("healthy");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("an unavailable upstream produces a bounded error response", async () => {
|
|
89
|
+
const client = await frontend(new Map([["grpc.test", "http://127.0.0.1:1"]]));
|
|
90
|
+
expect((await request(client, "grpc.test")).headers[":status"]).toBe(502);
|
|
91
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import http2, { type IncomingHttpHeaders, type OutgoingHttpHeaders, type ServerHttp2Stream } from "node:http2";
|
|
2
|
+
import { isHopByHop } from "./http-proxy";
|
|
3
|
+
import { hostWithoutPort } from "./hostmatch";
|
|
4
|
+
|
|
5
|
+
export interface Http2ProxyOptions {
|
|
6
|
+
/** Resolve each stream independently; one connection can carry multiple authorities. */
|
|
7
|
+
upstreamFor(hostname: string): Promise<string | undefined>;
|
|
8
|
+
clientIpFor?(port: number | undefined): string | undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function headersForHop(headers: IncomingHttpHeaders, pseudo: boolean): OutgoingHttpHeaders {
|
|
12
|
+
const result: OutgoingHttpHeaders = {};
|
|
13
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
14
|
+
if ((!pseudo && name.startsWith(":")) || (isHopByHop(name) && !(name === "te" && value === "trailers"))) continue;
|
|
15
|
+
result[name] = value;
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A stream proxy rather than fetch: gRPC depends on duplex bodies and trailers. */
|
|
21
|
+
export async function startHttp2Proxy(options: Http2ProxyOptions) {
|
|
22
|
+
const server = http2.createServer();
|
|
23
|
+
const sessions = new Set<http2.Http2Session>();
|
|
24
|
+
const track = (session: http2.Http2Session) => {
|
|
25
|
+
sessions.add(session);
|
|
26
|
+
session.on("error", () => {});
|
|
27
|
+
session.once("close", () => sessions.delete(session));
|
|
28
|
+
};
|
|
29
|
+
server.on("session", track);
|
|
30
|
+
server.on("stream", (downstream: ServerHttp2Stream, headers) => {
|
|
31
|
+
downstream.on("error", () => {});
|
|
32
|
+
void forward(downstream, headers).catch(() => {
|
|
33
|
+
if (downstream.destroyed || downstream.closed) return;
|
|
34
|
+
if (downstream.headersSent) downstream.close(http2.constants.NGHTTP2_INTERNAL_ERROR);
|
|
35
|
+
else { downstream.respond({ ":status": 502 }); downstream.end("HTTP/2 upstream unavailable"); }
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
async function forward(downstream: ServerHttp2Stream, headers: IncomingHttpHeaders) {
|
|
40
|
+
const authority = String(headers[":authority"] ?? "");
|
|
41
|
+
const upstreamUrl = await options.upstreamFor(hostWithoutPort(authority.toLowerCase()));
|
|
42
|
+
if (downstream.destroyed || downstream.closed) return;
|
|
43
|
+
if (!upstreamUrl) {
|
|
44
|
+
// A coalesced connection must not silently route an authority to its SNI backend.
|
|
45
|
+
downstream.respond({ ":status": 421 });
|
|
46
|
+
downstream.end("No HTTP/2 route for this authority");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const session = http2.connect(upstreamUrl);
|
|
50
|
+
track(session);
|
|
51
|
+
let requestTrailers: OutgoingHttpHeaders = {};
|
|
52
|
+
let responseTrailers: OutgoingHttpHeaders = {};
|
|
53
|
+
const requestHeaders = headersForHop(headers, true);
|
|
54
|
+
requestHeaders[":scheme"] = "http";
|
|
55
|
+
requestHeaders["x-forwarded-proto"] = "https";
|
|
56
|
+
requestHeaders["x-forwarded-host"] = authority;
|
|
57
|
+
const clientIp = options.clientIpFor?.(downstream.session?.socket.remotePort);
|
|
58
|
+
if (clientIp) requestHeaders["x-forwarded-for"] = [headers["x-forwarded-for"], clientIp].filter(Boolean).join(", ");
|
|
59
|
+
let upstream: http2.ClientHttp2Stream;
|
|
60
|
+
try { upstream = session.request(requestHeaders, { endStream: false, waitForTrailers: true }); }
|
|
61
|
+
catch (error) { session.destroy(); throw error; }
|
|
62
|
+
const fail = () => {
|
|
63
|
+
if (!downstream.closed && !downstream.destroyed) {
|
|
64
|
+
if (downstream.headersSent) downstream.close(http2.constants.NGHTTP2_INTERNAL_ERROR);
|
|
65
|
+
else { downstream.respond({ ":status": 502 }); downstream.end("HTTP/2 upstream unavailable"); }
|
|
66
|
+
}
|
|
67
|
+
session.destroy();
|
|
68
|
+
};
|
|
69
|
+
session.on("error", fail);
|
|
70
|
+
upstream.on("error", fail);
|
|
71
|
+
downstream.on("trailers", trailers => { requestTrailers = headersForHop(trailers, false); });
|
|
72
|
+
upstream.on("wantTrailers", () => { if (!upstream.destroyed && !upstream.closed) upstream.sendTrailers(requestTrailers); });
|
|
73
|
+
upstream.on("trailers", trailers => { responseTrailers = headersForHop(trailers, false); });
|
|
74
|
+
downstream.on("wantTrailers", () => {
|
|
75
|
+
if (!downstream.destroyed && !downstream.closed) downstream.sendTrailers(responseTrailers);
|
|
76
|
+
});
|
|
77
|
+
upstream.on("response", response => {
|
|
78
|
+
if (downstream.destroyed || downstream.closed) return;
|
|
79
|
+
downstream.respond(headersForHop(response, true), { waitForTrailers: true });
|
|
80
|
+
upstream.pipe(downstream);
|
|
81
|
+
});
|
|
82
|
+
downstream.on("aborted", () => upstream.close(downstream.rstCode || http2.constants.NGHTTP2_CANCEL));
|
|
83
|
+
upstream.on("aborted", () => { if (!downstream.closed) downstream.close(upstream.rstCode || http2.constants.NGHTTP2_CANCEL); });
|
|
84
|
+
downstream.once("close", () => session.destroy());
|
|
85
|
+
downstream.pipe(upstream);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await new Promise<void>((resolve, reject) => {
|
|
89
|
+
server.once("error", reject);
|
|
90
|
+
server.listen(0, "127.0.0.1", () => { server.removeListener("error", reject); resolve(); });
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
port: (server.address() as { port: number }).port,
|
|
94
|
+
close() { for (const session of sessions) session.destroy(); server.close(); },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -53,7 +53,7 @@ import { certCovers, isWildcard, wildcardSuffix } from "./hostmatch";
|
|
|
53
53
|
*/
|
|
54
54
|
export type Route<F = unknown> =
|
|
55
55
|
| { kind: "fake"; fake: F }
|
|
56
|
-
| { kind: "proxy"; service: string; port: number };
|
|
56
|
+
| { kind: "proxy"; service: string; port: number; http2?: boolean };
|
|
57
57
|
|
|
58
58
|
/** A PEM leaf + key, as handed to `Bun.serve`'s TLS config. */
|
|
59
59
|
export interface Leaf {
|
|
@@ -70,7 +70,7 @@ export interface Leaf {
|
|
|
70
70
|
export interface IngressTables<F = unknown> {
|
|
71
71
|
/** Listen port → (hostname → route). Values are mutated in place. */
|
|
72
72
|
routesByPort: Map<number, Map<string, Route<F>>>;
|
|
73
|
-
/** SNI server name → leaf.
|
|
73
|
+
/** SNI server name → leaf. Read live by the TLS terminator without rebinding. */
|
|
74
74
|
certByHost: Map<string, Leaf>;
|
|
75
75
|
}
|
|
76
76
|
|
|
@@ -18,6 +18,9 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test
|
|
|
18
18
|
import { spawnSync } from "node:child_process";
|
|
19
19
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
20
20
|
import net from "node:net";
|
|
21
|
+
import http2 from "node:http2";
|
|
22
|
+
import { once } from "node:events";
|
|
23
|
+
import { startHttp2Proxy } from "./http2-proxy";
|
|
21
24
|
import { tmpdir } from "node:os";
|
|
22
25
|
import { join } from "node:path";
|
|
23
26
|
import tls from "node:tls";
|
|
@@ -238,3 +241,29 @@ describe("the pipe", () => {
|
|
|
238
241
|
expect(term.connectionCount()).toBe(0);
|
|
239
242
|
});
|
|
240
243
|
});
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
test("ALPN selects HTTP/2 only for opted-in routes and keeps HTTP/1.1 working", async () => {
|
|
247
|
+
const backend = http2.createServer();
|
|
248
|
+
const sessions = new Set<http2.Http2Session>();
|
|
249
|
+
backend.on("session", s => { sessions.add(s); s.on("error", () => {}); });
|
|
250
|
+
backend.on("stream", (s: http2.ServerHttp2Stream) => { s.respond({ ":status": 200 }); s.end("grpc backend"); });
|
|
251
|
+
backend.listen(0, "127.0.0.1");
|
|
252
|
+
await once(backend, "listening");
|
|
253
|
+
const h2 = await startHttp2Proxy({ upstreamFor: async () => `http://127.0.0.1:${(backend.address() as net.AddressInfo).port}` });
|
|
254
|
+
const optedIn = new Set(["app.test"]);
|
|
255
|
+
const terminator = await startTlsTerminator({ port: 0, hostname: "127.0.0.1", upstreamPort: plain.port,
|
|
256
|
+
http2Port: h2.port, supportsHttp2: name => optedIn.has(name), certFor: name => leaves.get(name ?? "app.test") });
|
|
257
|
+
const client = http2.connect(`https://127.0.0.1:${terminator.port}`, { servername: "app.test", ca: caPem });
|
|
258
|
+
try {
|
|
259
|
+
const stream = client.request({ ":authority": "app.test", ":path": "/" });
|
|
260
|
+
stream.setEncoding("utf8"); let body = ""; stream.on("data", chunk => { body += chunk; });
|
|
261
|
+
await once(stream, "end");
|
|
262
|
+
expect(client.alpnProtocol).toBe("h2"); expect(body).toBe("grpc backend");
|
|
263
|
+
const socket = tls.connect({ host: "127.0.0.1", port: terminator.port, servername: "late.test", ca: caPem, ALPNProtocols: ["h2", "http/1.1"] });
|
|
264
|
+
try { await once(socket, "secureConnect"); expect(socket.alpnProtocol).toBe("http/1.1"); } finally { socket.destroy(); }
|
|
265
|
+
optedIn.add("late.test");
|
|
266
|
+
const late = tls.connect({ host: "127.0.0.1", port: terminator.port, servername: "late.test", ca: caPem, ALPNProtocols: ["h2", "http/1.1"] });
|
|
267
|
+
try { await once(late, "secureConnect"); expect(late.alpnProtocol).toBe("h2"); } finally { late.destroy(); }
|
|
268
|
+
} finally { client.destroy(); terminator.close(); h2.close(); for (const s of sessions) s.destroy(); backend.close(); }
|
|
269
|
+
});
|