@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
|
@@ -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)`)
|
|
@@ -69,6 +70,9 @@ export interface TlsTerminatorOptions {
|
|
|
69
70
|
hostname: string;
|
|
70
71
|
/** Loopback port of the plaintext ingress server to pipe into. */
|
|
71
72
|
upstreamPort: number;
|
|
73
|
+
/** Loopback HTTP/2 router, selected only for an opted-in SNI hostname. */
|
|
74
|
+
http2Port?: number;
|
|
75
|
+
supportsHttp2?(serverName: string): boolean;
|
|
72
76
|
/**
|
|
73
77
|
* The leaf to serve for an SNI server name, read live on every
|
|
74
78
|
* handshake — this is what lets a certificate be added with no restart.
|
|
@@ -153,6 +157,9 @@ export function startTlsTerminator(opts: TlsTerminatorOptions): Promise<TlsTermi
|
|
|
153
157
|
const server = tls.createServer(
|
|
154
158
|
{
|
|
155
159
|
...(fallback ? { cert: fallback.cert, key: fallback.key } : {}),
|
|
160
|
+
...(opts.http2Port ? { ALPNCallback: ({ servername, protocols }: { servername: string; protocols: string[] }) =>
|
|
161
|
+
protocols.includes("h2") && opts.supportsHttp2?.(servername) ? "h2"
|
|
162
|
+
: protocols.includes("http/1.1") ? "http/1.1" : undefined } : {}),
|
|
156
163
|
SNICallback: (serverName, cb) => {
|
|
157
164
|
const leaf = opts.certFor(serverName) ?? fallback;
|
|
158
165
|
// No certificate at all is a handshake we cannot complete. Answer
|
|
@@ -175,7 +182,8 @@ export function startTlsTerminator(opts: TlsTerminatorOptions): Promise<TlsTermi
|
|
|
175
182
|
// its bytes are buffered rather than dropped — which is the normal
|
|
176
183
|
// case, since a client sends its first request the instant the
|
|
177
184
|
// handshake completes.
|
|
178
|
-
const
|
|
185
|
+
const upstreamPort = client.alpnProtocol === "h2" ? opts.http2Port! : opts.upstreamPort;
|
|
186
|
+
const upstream = net.connect(upstreamPort, "127.0.0.1");
|
|
179
187
|
upstream.setTimeout(0);
|
|
180
188
|
upstream.setNoDelay(true);
|
|
181
189
|
client.setNoDelay(true);
|
|
@@ -204,7 +212,7 @@ export function startTlsTerminator(opts: TlsTerminatorOptions): Promise<TlsTermi
|
|
|
204
212
|
teardown();
|
|
205
213
|
});
|
|
206
214
|
upstream.on("error", (err) => {
|
|
207
|
-
warn(`ingress upstream :${
|
|
215
|
+
warn(`ingress upstream :${upstreamPort} failed`, err);
|
|
208
216
|
teardown();
|
|
209
217
|
});
|
|
210
218
|
client.on("close", teardown);
|
|
@@ -224,7 +232,7 @@ export function startTlsTerminator(opts: TlsTerminatorOptions): Promise<TlsTermi
|
|
|
224
232
|
server.listen(opts.port, opts.hostname, () => {
|
|
225
233
|
server.removeListener("error", reject);
|
|
226
234
|
resolve({
|
|
227
|
-
port:
|
|
235
|
+
port: (server.address() as net.AddressInfo).port,
|
|
228
236
|
clientIpFor: (upstreamPort) =>
|
|
229
237
|
upstreamPort === undefined ? undefined : clientIpByUpstreamPort.get(upstreamPort),
|
|
230
238
|
connectionCount: () => live.size,
|
package/src/index.ts
CHANGED
|
@@ -1161,6 +1161,9 @@ export interface ServiceTls {
|
|
|
1161
1161
|
/** HTTP port the service listens on inside its container. The
|
|
1162
1162
|
* daemon forwards proxied requests here over `spectest-net`. */
|
|
1163
1163
|
port: number;
|
|
1164
|
+
/** Accept HTTP/2 over TLS and forward it as plaintext HTTP/2 (h2c).
|
|
1165
|
+
* Required for gRPC. HTTP/1.1 continues to use the existing HTTP proxy. */
|
|
1166
|
+
http2?: boolean;
|
|
1164
1167
|
}
|
|
1165
1168
|
|
|
1166
1169
|
/** @internal Normalized wire representation; legacy definitions remain accepted. */
|
|
@@ -1661,6 +1664,7 @@ function validateEnvironmentConfig<S extends ServicesMap>(
|
|
|
1661
1664
|
`service "${name}" tls entry must be { hostname: string, port: number }; got ${JSON.stringify(entry)}`,
|
|
1662
1665
|
);
|
|
1663
1666
|
}
|
|
1667
|
+
if (entry.http2 !== undefined && typeof entry.http2 !== "boolean") throw new Error(`service "${name}" tls http2 must be a boolean`);
|
|
1664
1668
|
const h = entry.hostname.toLowerCase();
|
|
1665
1669
|
if (!HOSTNAME_RE.test(hostPatternBody(h))) {
|
|
1666
1670
|
throw new Error(
|
package/src/ingress.ts
CHANGED
|
@@ -40,7 +40,7 @@ export interface DnsDecl {
|
|
|
40
40
|
export interface ProxyDecl {
|
|
41
41
|
kind: "proxy";
|
|
42
42
|
hostname: string;
|
|
43
|
-
upstream: { service: string; port: number };
|
|
43
|
+
upstream: { service: string; port: number; http2?: boolean };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export type IngressDecl = CertificateDecl | DnsDecl | ProxyDecl;
|
|
@@ -137,7 +137,7 @@ export function dnsName(hostname: string, target: DnsTarget): DnsDecl {
|
|
|
137
137
|
*/
|
|
138
138
|
export function proxy(
|
|
139
139
|
hostname: string,
|
|
140
|
-
upstream: { service: string; port: number },
|
|
140
|
+
upstream: { service: string; port: number; http2?: boolean },
|
|
141
141
|
): ProxyDecl {
|
|
142
142
|
assertNameOrWildcard(hostname, "proxy()");
|
|
143
143
|
if (!upstream || typeof upstream.service !== "string" || typeof upstream.port !== "number") {
|
|
@@ -145,6 +145,7 @@ export function proxy(
|
|
|
145
145
|
`proxy(${JSON.stringify(hostname)}): upstream must be { service: string, port: number }`,
|
|
146
146
|
);
|
|
147
147
|
}
|
|
148
|
+
if (upstream.http2 !== undefined && typeof upstream.http2 !== "boolean") throw new Error("proxy(): http2 must be a boolean");
|
|
148
149
|
return { kind: "proxy", hostname: hostname.toLowerCase(), upstream };
|
|
149
150
|
}
|
|
150
151
|
|
|
@@ -197,7 +198,7 @@ export interface LoweredIngress {
|
|
|
197
198
|
/** One leaf cert per group; SANs = the group's hostnames. */
|
|
198
199
|
certificates: { hostnames: string[] }[];
|
|
199
200
|
/** Reverse-proxy routes: hostname → upstream service:port. */
|
|
200
|
-
proxies: { hostname: string; service: string; port: number }[];
|
|
201
|
+
proxies: { hostname: string; service: string; port: number; http2?: boolean }[];
|
|
201
202
|
/** Hostnames that resolve to the daemon ingress gateway (DNS registry
|
|
202
203
|
* + `--add-host` on every container). Covers fakes, TLS proxies, and
|
|
203
204
|
* any `dnsName(h, { ingress: true })`. */
|
|
@@ -226,7 +227,7 @@ function resolveSelf(service: string, selfKey: string): string {
|
|
|
226
227
|
*/
|
|
227
228
|
export function lowerIngress(project: Project): LoweredIngress {
|
|
228
229
|
const certificates: { hostnames: string[] }[] = [];
|
|
229
|
-
const proxies: { hostname: string; service: string; port: number }[] = [];
|
|
230
|
+
const proxies: { hostname: string; service: string; port: number; http2?: boolean }[] = [];
|
|
230
231
|
const ingressSet = new Set<string>();
|
|
231
232
|
const aliasesByService: Record<string, string[]> = {};
|
|
232
233
|
const wildcards: { pattern: string; target: DnsTarget }[] = [];
|
|
@@ -241,7 +242,7 @@ export function lowerIngress(project: Project): LoweredIngress {
|
|
|
241
242
|
break;
|
|
242
243
|
case "proxy": {
|
|
243
244
|
const service = resolveSelf(decl.upstream.service, selfKey);
|
|
244
|
-
proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port });
|
|
245
|
+
proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port, ...(decl.upstream.http2 ? { http2: true } : {}) });
|
|
245
246
|
// A proxied hostname must route to the daemon. A wildcard one can
|
|
246
247
|
// only be answered by the resolver (there is no `--add-host` for a
|
|
247
248
|
// pattern), so it goes in the wildcard table pointed at the ingress.
|
|
@@ -278,7 +279,7 @@ export function lowerIngress(project: Project): LoweredIngress {
|
|
|
278
279
|
// `tls: [{ hostname, port }]` → cert + TLS-terminated reverse proxy.
|
|
279
280
|
for (const entry of (svc as ServiceConfig).tls ?? []) {
|
|
280
281
|
applyDecl(certificate([entry.hostname]), name);
|
|
281
|
-
applyDecl(proxy(entry.hostname, { service: name, port: entry.port }), name);
|
|
282
|
+
applyDecl(proxy(entry.hostname, { service: name, port: entry.port, ...(entry.http2 ? { http2: true } : {}) }), name);
|
|
282
283
|
}
|
|
283
284
|
// `hostnames: [h]` → plain peer alias (no daemon hop).
|
|
284
285
|
for (const h of (svc as ServiceConfig).hostnames ?? []) {
|