@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.
- package/README.md +35 -0
- package/dist/components/emulate/entry.mjs +2 -3
- 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/components/s3.d.ts +3 -3
- package/dist/daemon.js +31 -31
- package/dist/harness/http-proxy.d.ts +7 -2
- package/dist/harness/http-proxy.js +17 -3
- 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/harness/ws-bridge.d.ts +1 -3
- package/dist/harness/ws-bridge.js +3 -16
- 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/emulate/entry.mjs +2 -3
- package/src/components/index.ts +2 -0
- package/src/components/pubsub.test.ts +88 -0
- package/src/components/pubsub.ts +182 -0
- package/src/components/s3.ts +3 -3
- package/src/daemon.ts +30 -27
- package/src/harness/http-proxy.test.ts +66 -4
- package/src/harness/http-proxy.ts +19 -3
- 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/harness/ws-bridge.test.ts +8 -12
- package/src/harness/ws-bridge.ts +3 -16
- package/src/index.ts +4 -0
- 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.
|
|
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
|
|
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 :${
|
|
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:
|
|
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
|
|
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 {
|
|
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
|
|
49
|
+
* headers and the original Host.
|
|
50
50
|
*/
|
|
51
51
|
export function upstreamHandshakeHeaders(req, opts) {
|
|
52
52
|
const out = {};
|
|
53
|
-
|
|
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
|
@@ -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
|
-
//
|
|
183
|
-
//
|
|
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+$/, "");
|
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/components/s3.ts
CHANGED
|
@@ -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 (
|
|
24
|
-
*
|
|
25
|
-
* store's plain HTTP
|
|
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 =
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
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({
|