@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 +38 -0
- package/dist/components/gcp.d.ts +2 -0
- package/dist/components/gcp.js +2 -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 +6 -1
- package/src/components/gcp.ts +11 -0
- 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/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.
|
|
@@ -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";
|
package/dist/components/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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: () => {
|
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
|