@specific.dev/spectest 0.6.0 → 0.7.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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/daemon.ts +207 -55
  3. package/src/index.ts +15 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/daemon.ts CHANGED
@@ -24,7 +24,15 @@ import net from "node:net";
24
24
  import path from "node:path";
25
25
  import { pathToFileURL } from "node:url";
26
26
 
27
- import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard } from "./index.js";
27
+ import {
28
+ assert,
29
+ expect,
30
+ expectRaw,
31
+ lowerIngress,
32
+ dnsName as makeDnsDecl,
33
+ isWildcard,
34
+ proxy as makeProxyDecl,
35
+ } from "./index.js";
28
36
  import type { DnsTarget, LoweredIngress } from "./index.js";
29
37
  import { openBrowser } from "./browser.js";
30
38
  import { openMobile, isMobileApp } from "./mobile.js";
@@ -1222,6 +1230,24 @@ const INGRESS_HTTP_SERVERS = new Map<number, any>();
1222
1230
  /** Running HTTPS servers per port (currently always {INGRESS_HTTPS_PORT}). */
1223
1231
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1224
1232
  const INGRESS_HTTPS_SERVERS = new Map<number, any>();
1233
+ /**
1234
+ * Live per-port route tables, keyed by listen port (80 / 443 / fake ports).
1235
+ * Each listener's `fetch` closure captures *this* Map object, so adding an
1236
+ * entry takes effect immediately with no rebind — that's what lets a runtime
1237
+ * `tls` (a `ctx.startService({ tls })`) bind a new ingress route after boot.
1238
+ * Held at module scope so it's part of the live daemon process and forks
1239
+ * with the snapshot, exactly like fake state / the names REGISTRY. Rebuilt on
1240
+ * /load (cleared by {@link stopIngressServers}).
1241
+ */
1242
+ const INGRESS_ROUTES_BY_PORT = new Map<number, Map<string, Route>>();
1243
+ /**
1244
+ * The :443 SNI cert table: serverName → leaf. Unlike the route table, Bun's
1245
+ * TLS config is fixed at `Bun.serve` time (reload won't add an SNI entry), so
1246
+ * minting a cert for a *new* hostname requires rebinding the :443 listener
1247
+ * (cheap, ~1ms — see {@link rebindHttpsListener}). A hostname already covered
1248
+ * by an existing exact or wildcard cert needs no rebind, just a route entry.
1249
+ */
1250
+ const HTTPS_CERT_BY_HOST = new Map<string, { cert: string; key: string }>();
1225
1251
 
1226
1252
  /**
1227
1253
  * Tear down listener servers between /load calls so the new project's
@@ -1246,6 +1272,8 @@ function stopIngressServers(): void {
1246
1272
  }
1247
1273
  }
1248
1274
  INGRESS_HTTPS_SERVERS.clear();
1275
+ INGRESS_ROUTES_BY_PORT.clear();
1276
+ HTTPS_CERT_BY_HOST.clear();
1249
1277
  }
1250
1278
 
1251
1279
  function buildIngress(project: Project): void {
@@ -1441,12 +1469,11 @@ async function startIngress(): Promise<void> {
1441
1469
  // before binding so the HTTPS listener has certs ready and a startup
1442
1470
  // failure aborts /bootstrap cleanly.
1443
1471
  const caPresent = existsSync(CA_PATH) && existsSync(CA_KEY_PATH);
1444
- const certByHost = new Map<string, { cert: string; key: string }>();
1445
1472
  if (caPresent) {
1446
1473
  for (const group of LOWERED.certificates) {
1447
1474
  if (group.hostnames.length === 0) continue;
1448
1475
  const leaf = await generateHostCert(group.hostnames[0], group.hostnames);
1449
- for (const h of group.hostnames) certByHost.set(h, leaf);
1476
+ for (const h of group.hostnames) HTTPS_CERT_BY_HOST.set(h, leaf);
1450
1477
  }
1451
1478
  } else if (LOWERED.certificates.length > 0) {
1452
1479
  // eslint-disable-next-line no-console
@@ -1455,73 +1482,49 @@ async function startIngress(): Promise<void> {
1455
1482
  );
1456
1483
  }
1457
1484
 
1458
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1459
- const Bun = (globalThis as any).Bun;
1460
- if (!Bun?.serve) {
1461
- throw new Error(
1462
- "ingress requires Bun.serve; the daemon must run under Bun (it does in-VM)",
1463
- );
1464
- }
1465
-
1466
- // Resolve every ingress hostname to its handler. Fakes run an in-daemon
1467
- // handler; proxies reverse-proxy to a service:port. This single table
1468
- // drives both the HTTP and HTTPS listeners.
1469
- const routeByHost = new Map<string, Route>();
1470
- for (const fake of FAKES.values()) {
1471
- for (const h of fake.hostnames) routeByHost.set(h, { kind: "fake", fake });
1472
- }
1473
- for (const p of LOWERED.proxies) {
1474
- routeByHost.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
1475
- }
1485
+ const Bun = requireBun();
1476
1486
 
1477
- // ── HTTP listeners: group routes by port, dispatch per-request by Host.
1478
- // Proxies bind :80 (HTTPS, if any, is on :443); fakes use their
1479
- // declared port. Skip :443 in the HTTP map — HTTPS wins.
1480
- const httpRoutesByPort = new Map<number, Map<string, Route>>();
1487
+ // Resolve every ingress hostname to its handler into the live per-port
1488
+ // route tables (module scope, so runtime `tls` can extend them later).
1489
+ // Fakes run an in-daemon handler on their declared port; proxies
1490
+ // reverse-proxy to a service:port and bind :80 (HTTPS, if any, is :443).
1481
1491
  const ensurePort = (port: number): Map<string, Route> => {
1482
- const m = httpRoutesByPort.get(port) ?? new Map<string, Route>();
1483
- httpRoutesByPort.set(port, m);
1492
+ const m = INGRESS_ROUTES_BY_PORT.get(port) ?? new Map<string, Route>();
1493
+ INGRESS_ROUTES_BY_PORT.set(port, m);
1484
1494
  return m;
1485
1495
  };
1486
1496
  for (const fake of FAKES.values()) {
1487
1497
  if (fake.port === INGRESS_HTTPS_PORT) continue;
1488
1498
  const routes = ensurePort(fake.port);
1489
- for (const h of fake.hostnames) routes.set(h, routeByHost.get(h)!);
1499
+ for (const h of fake.hostnames) routes.set(h, { kind: "fake", fake });
1490
1500
  }
1491
1501
  if (LOWERED.proxies.length > 0) {
1492
1502
  const routes = ensurePort(INGRESS_HTTP_PORT);
1493
- for (const p of LOWERED.proxies) routes.set(p.hostname, routeByHost.get(p.hostname)!);
1503
+ for (const p of LOWERED.proxies) {
1504
+ routes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
1505
+ }
1494
1506
  }
1495
- for (const [port, byHost] of httpRoutesByPort) {
1496
- const label = `port ${port}`;
1497
- INGRESS_HTTP_SERVERS.set(port, bindIngressServer(Bun, port, byHost, label));
1498
- const hosts = [...byHost.keys()].join(", ");
1499
- // eslint-disable-next-line no-console
1500
- console.log(`[ingress] http :${port} for ${hosts}`);
1507
+ // The :443 route table mirrors every certificated hostname's handler.
1508
+ if (HTTPS_CERT_BY_HOST.size > 0) {
1509
+ const httpsRoutes = ensurePort(INGRESS_HTTPS_PORT);
1510
+ for (const h of HTTPS_CERT_BY_HOST.keys()) {
1511
+ for (const fake of FAKES.values()) {
1512
+ if (fake.hostnames.includes(h)) httpsRoutes.set(h, { kind: "fake", fake });
1513
+ }
1514
+ const proxy = LOWERED.proxies.find((p) => p.hostname === h);
1515
+ if (proxy) httpsRoutes.set(h, { kind: "proxy", service: proxy.service, port: proxy.port });
1516
+ }
1501
1517
  }
1502
1518
 
1503
- // ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
1504
- if (certByHost.size > 0) {
1505
- const tlsEntries: Array<{ cert: string; key: string; serverName: string }> = [];
1506
- const byHostHttps = new Map<string, Route>();
1507
- for (const [h, leaf] of certByHost) {
1508
- tlsEntries.push({ cert: leaf.cert, key: leaf.key, serverName: h });
1509
- const route = routeByHost.get(h);
1510
- if (route) byHostHttps.set(h, route);
1511
- }
1512
- const label = `https :${INGRESS_HTTPS_PORT}`;
1513
- const server = bindIngressServer(
1514
- Bun,
1515
- INGRESS_HTTPS_PORT,
1516
- byHostHttps,
1517
- label,
1518
- tlsEntries,
1519
- );
1520
- INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, server);
1521
- const hosts = [...byHostHttps.keys()].join(", ");
1519
+ // ── HTTP listeners (one per non-443 port).
1520
+ for (const [port, byHost] of INGRESS_ROUTES_BY_PORT) {
1521
+ if (port === INGRESS_HTTPS_PORT) continue;
1522
+ INGRESS_HTTP_SERVERS.set(port, bindIngressServer(Bun, port, byHost, `port ${port}`));
1522
1523
  // eslint-disable-next-line no-console
1523
- console.log(`[ingress] https :${INGRESS_HTTPS_PORT} for ${hosts}`);
1524
+ console.log(`[ingress] http :${port} for ${[...byHost.keys()].join(", ")}`);
1524
1525
  }
1526
+ // ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
1527
+ if (HTTPS_CERT_BY_HOST.size > 0) rebindHttpsListener(Bun);
1525
1528
 
1526
1529
  // Seed the resolver's names registry: ingress hostnames (fakes, TLS
1527
1530
  // proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
@@ -1530,6 +1533,148 @@ async function startIngress(): Promise<void> {
1530
1533
  await seedNamesRegistry({ servicesUp: false });
1531
1534
  }
1532
1535
 
1536
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1537
+ function requireBun(): any {
1538
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1539
+ const Bun = (globalThis as any).Bun;
1540
+ if (!Bun?.serve) {
1541
+ throw new Error(
1542
+ "ingress requires Bun.serve; the daemon must run under Bun (it does in-VM)",
1543
+ );
1544
+ }
1545
+ return Bun;
1546
+ }
1547
+
1548
+ /** Flatten {@link HTTPS_CERT_BY_HOST} into Bun's TLS-entry SNI array. */
1549
+ function tlsEntriesFromCerts(): Array<{ cert: string; key: string; serverName: string }> {
1550
+ return [...HTTPS_CERT_BY_HOST].map(([serverName, leaf]) => ({
1551
+ cert: leaf.cert,
1552
+ key: leaf.key,
1553
+ serverName,
1554
+ }));
1555
+ }
1556
+
1557
+ /**
1558
+ * (Re)bind the :443 listener from the current cert table + route map. Bun's
1559
+ * TLS config is immutable per `Bun.serve`, so adding an SNI cert means
1560
+ * stopping the old listener and serving a fresh one — cheap (~1ms) and the
1561
+ * window is sub-millisecond. The route Map is the persistent module object,
1562
+ * so the new listener closes over the same table (later route additions need
1563
+ * no rebind). No-ops to a plain rebind when only routes changed.
1564
+ */
1565
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1566
+ function rebindHttpsListener(Bun: any): void {
1567
+ const routes = INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT) ?? new Map<string, Route>();
1568
+ INGRESS_ROUTES_BY_PORT.set(INGRESS_HTTPS_PORT, routes);
1569
+ const old = INGRESS_HTTPS_SERVERS.get(INGRESS_HTTPS_PORT);
1570
+ if (old) {
1571
+ try {
1572
+ old.stop(true);
1573
+ } catch (err) {
1574
+ // eslint-disable-next-line no-console
1575
+ console.warn("[ingress] failed to stop https listener for rebind:", err);
1576
+ }
1577
+ }
1578
+ const server = bindIngressServer(
1579
+ Bun,
1580
+ INGRESS_HTTPS_PORT,
1581
+ routes,
1582
+ `https :${INGRESS_HTTPS_PORT}`,
1583
+ tlsEntriesFromCerts(),
1584
+ );
1585
+ INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, server);
1586
+ // eslint-disable-next-line no-console
1587
+ console.log(`[ingress] https :${INGRESS_HTTPS_PORT} for ${[...routes.keys()].join(", ")}`);
1588
+ }
1589
+
1590
+ /** True if an exact or wildcard cert already covers `hostname` for SNI. */
1591
+ function certCovers(hostname: string): boolean {
1592
+ if (HTTPS_CERT_BY_HOST.has(hostname)) return true;
1593
+ for (const serverName of HTTPS_CERT_BY_HOST.keys()) {
1594
+ if (isWildcard(serverName) && hostname.endsWith(wildcardSuffix(serverName))) {
1595
+ return true;
1596
+ }
1597
+ }
1598
+ return false;
1599
+ }
1600
+
1601
+ /**
1602
+ * Bind a runtime ingress route for one `tls: [{ hostname, port }]` entry on a
1603
+ * {@link RuntimeServiceSpec} — the runtime twin of a boot service's `tls`.
1604
+ * Mints a leaf cert (unless one already covers the hostname), stands up a
1605
+ * TLS-terminating reverse proxy at `https://<hostname>/` → `service:port`,
1606
+ * also serves plain `http://<hostname>/`, and points the hostname at the
1607
+ * daemon gateway in the resolver registry. Idempotent per hostname.
1608
+ *
1609
+ * Everything it mutates (the live route tables, the :443 cert table, the
1610
+ * names REGISTRY) is daemon-process state, so the binding forks with the
1611
+ * per-test snapshot exactly like fake state — a `dependsOn` child inherits
1612
+ * it, siblings forked from an earlier snapshot never see it.
1613
+ */
1614
+ async function bindRuntimeTls(hostname: string, service: string, port: number): Promise<void> {
1615
+ // Reuse the boot primitives for validation + lowercasing; throws on a
1616
+ // malformed hostname / upstream just like a boot `tls` would at load.
1617
+ const decl = makeProxyDecl(hostname, { service, port });
1618
+ const host = decl.hostname;
1619
+ if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
1620
+ throw new Error(
1621
+ `runtime tls for ${JSON.stringify(host)} requires the in-VM root CA at ${CA_PATH}`,
1622
+ );
1623
+ }
1624
+ const Bun = requireBun();
1625
+ const route: Route = { kind: "proxy", service, port };
1626
+
1627
+ // Plain HTTP on :80 (parity with boot `tls`, which serves both schemes).
1628
+ let httpRoutes = INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTP_PORT);
1629
+ if (!httpRoutes) {
1630
+ httpRoutes = new Map<string, Route>();
1631
+ INGRESS_ROUTES_BY_PORT.set(INGRESS_HTTP_PORT, httpRoutes);
1632
+ }
1633
+ httpRoutes.set(host, route);
1634
+ if (!INGRESS_HTTP_SERVERS.has(INGRESS_HTTP_PORT)) {
1635
+ INGRESS_HTTP_SERVERS.set(
1636
+ INGRESS_HTTP_PORT,
1637
+ bindIngressServer(Bun, INGRESS_HTTP_PORT, httpRoutes, `port ${INGRESS_HTTP_PORT}`),
1638
+ );
1639
+ }
1640
+
1641
+ // HTTPS on :443. A new cert forces a listener rebind; an already-covered
1642
+ // hostname (exact dup or a boot wildcard) just needs the route entry.
1643
+ const needCert = !certCovers(host);
1644
+ if (needCert) {
1645
+ HTTPS_CERT_BY_HOST.set(host, await generateHostCert(host, [host]));
1646
+ }
1647
+ const httpsRoutes = INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT) ?? new Map<string, Route>();
1648
+ INGRESS_ROUTES_BY_PORT.set(INGRESS_HTTPS_PORT, httpsRoutes);
1649
+ httpsRoutes.set(host, route);
1650
+ if (needCert || !INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT)) {
1651
+ rebindHttpsListener(Bun);
1652
+ }
1653
+
1654
+ // Resolve the hostname to the daemon gateway (where :443/:80 listen).
1655
+ const gw = await bridgeGatewayIp();
1656
+ REGISTRY.hosts[host] = gw;
1657
+ await writeRegistry();
1658
+ // eslint-disable-next-line no-console
1659
+ console.log(`[ingress] runtime https ${host} -> ${service}:${port}`);
1660
+ }
1661
+
1662
+ /**
1663
+ * Undo {@link bindRuntimeTls} for one hostname when its runtime service is
1664
+ * stopped: drop the route (so it 404s) and the registry entry. The cert is
1665
+ * left in the SNI table — harmless without a route, and removing it would
1666
+ * mean an avoidable :443 rebind.
1667
+ */
1668
+ async function unbindRuntimeTls(hostname: string): Promise<void> {
1669
+ const host = hostname.toLowerCase();
1670
+ INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTP_PORT)?.delete(host);
1671
+ INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT)?.delete(host);
1672
+ if (host in REGISTRY.hosts) {
1673
+ delete REGISTRY.hosts[host];
1674
+ await writeRegistry();
1675
+ }
1676
+ }
1677
+
1533
1678
  /**
1534
1679
  * Spin up one Bun.serve listener bound to (port, optional TLS) that
1535
1680
  * dispatches every request to the matching Route by Host header.
@@ -2020,6 +2165,11 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
2020
2165
  await runContainer(svc, tag, flags, aliases);
2021
2166
  await waitForReady(svc);
2022
2167
  const ip = (await serviceContainerIp(svc.name)) ?? "";
2168
+ // `tls` is the runtime twin of a boot service's: stand up a
2169
+ // TLS-terminating reverse proxy at https://<hostname>/ → this container.
2170
+ for (const entry of svc.tls ?? []) {
2171
+ await bindRuntimeTls(entry.hostname, svc.name, entry.port);
2172
+ }
2023
2173
  RUNTIME_SERVICES.set(svc.name, svc);
2024
2174
  recordEnv({
2025
2175
  op: "startService",
@@ -2046,8 +2196,10 @@ async function startRuntimeService(spec: RuntimeServiceSpec): Promise<RuntimeSer
2046
2196
  async function stopRuntimeService(name: string): Promise<void> {
2047
2197
  const t0 = Date.now();
2048
2198
  const resv = reserveEvent();
2199
+ const svc = RUNTIME_SERVICES.get(name);
2049
2200
  await docker(["rm", "-f", name], 30_000);
2050
2201
  RUNTIME_SERVICES.delete(name);
2202
+ for (const entry of svc?.tls ?? []) await unbindRuntimeTls(entry.hostname);
2051
2203
  recordEnv({ op: "stopService", service: name, durationMs: Date.now() - t0 }, resv);
2052
2204
  }
2053
2205
 
package/src/index.ts CHANGED
@@ -348,22 +348,28 @@ export type ReadyCheck =
348
348
  /**
349
349
  * Spec for a service started at runtime via {@link TestContext.startService}
350
350
  * (or the `ctx` handed to a fake). It's a normal {@link ServiceConfig} plus a
351
- * required `name`, minus the two fields that only make sense at boot:
352
- *
353
- * - `tls` — runtime services are reached *directly* by their own IP on
354
- * `spectest-net` (like a real machine on a network), not through the
355
- * daemon's HTTP ingress, so there's no proxy/cert to configure. Map a
356
- * friendly DNS name onto one with {@link TestContext.dnsName}.
357
- * - `dependsOn` — there is no boot DAG at runtime; the caller orders
358
- * `startService` calls itself with `await`.
351
+ * required `name`, minus only `dependsOn` (there is no boot DAG at runtime;
352
+ * the caller orders `startService` calls itself with `await`).
359
353
  *
360
354
  * The container joins `spectest-net` with its own IP and is resolvable by
361
355
  * `name` (single-label, via the resolver / docker embedded DNS) and by any
362
356
  * `hostnames` (extra `--network-alias`es). Like everything else in the VM it
363
357
  * is captured by the per-test post-state snapshot, so a `dependsOn` child
364
358
  * inherits the live container while siblings never see it.
359
+ *
360
+ * `tls: [{ hostname, port }]` works exactly as it does for a boot service:
361
+ * the daemon mints a leaf cert from the in-VM root CA and stands up a
362
+ * TLS-terminating reverse proxy at `https://<hostname>/` (and plain
363
+ * `http://<hostname>/`) → the container's `port`, binding it onto the live
364
+ * `:443`/`:80` ingress listeners the moment the container is ready. The
365
+ * hostname resolves to the daemon gateway for tests, `ctx.browser()`, and
366
+ * peer containers. Because the route lives in daemon memory (and the
367
+ * resolver registry file), it forks with the per-test snapshot like fake
368
+ * state, and is torn down when the service is stopped. This lets a runtime
369
+ * provider mint a CA-trusted HTTPS endpoint on demand — e.g. a per-DB proxy
370
+ * the Neon serverless driver reaches at its *default* `https://<host>/sql`.
365
371
  */
366
- export interface RuntimeServiceSpec extends Omit<ServiceConfig, "tls" | "dependsOn"> {
372
+ export interface RuntimeServiceSpec extends Omit<ServiceConfig, "dependsOn"> {
367
373
  /**
368
374
  * Container name and primary DNS name on `spectest-net`. Must be unique
369
375
  * within the current fork — generate a fresh one per provisioned instance