@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.
@@ -34,7 +34,8 @@
34
34
  *
35
35
  * The terminator does TLS and *nothing else*: it accepts on :443, picks a
36
36
  * leaf by SNI, and pipes the decrypted stream to a plaintext `Bun.serve`
37
- * bound on loopback. Everything downstream — routing, fakes,
37
+ * bound on loopback. Opted-in HTTP/2 routes select a separate stream router
38
+ * by ALPN; HTTP/1.1 and WebSockets retain the existing listener. Everything downstream — routing, fakes,
38
39
  * interceptors, reverse-proxying, and `server.upgrade()` WebSocket
39
40
  * bridging — is the same `Bun.serve` code that already serves :80, run
40
41
  * unchanged. Terminating HTTP here instead (`node:https`, `(req, res)`)
@@ -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 upstream = net.connect(opts.upstreamPort, "127.0.0.1");
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 :${opts.upstreamPort} failed`, err);
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: opts.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 ?? []) {