@specific.dev/spectest 0.131.18 → 0.131.20

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/dist/browser.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Headless browser handle for tests. Thin wrapper around playwright-core
2
- // driving the guest's system Chromium (pipe transport — `chromium.launch`
2
+ // driving the guest's Headless Shell (pipe transport — `chromium.launch`
3
3
  // with an explicit `executablePath`). Electron attaches to the app-owned
4
4
  // Chromium over CDP instead; its client also lives inside the VM. The wrapper
5
5
  // keeps the public surface stable (we swapped from Bun.WebView to Playwright without
@@ -109,7 +109,7 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
109
109
  // ────────────────────────────────────────────────────────────────────────
110
110
  // Shared Chromium (playwright-core)
111
111
  // ────────────────────────────────────────────────────────────────────────
112
- // Default sessions share one Chromium per daemon, launched lazily and kept
112
+ // Browser and mobile sessions share one Headless Shell per daemon, launched lazily and kept
113
113
  // for the daemon's lifetime. Fixture sessions own a process because the audio
114
114
  // source is a process-wide flag. Both ride snapshots: the process, playwright's
115
115
  // pipe connection to it, and every open page freeze into the VM snapshot
@@ -126,17 +126,18 @@ const VIEW_CLEANUPS = new WeakMap();
126
126
  // (mirroring the old one-Chrome-profile model), which left `close()` closing
127
127
  // the page only — cookies survived and the next session was still
128
128
  // authenticated.
129
- function chromiumPath() {
130
- return (Bun.which("chromium") ??
131
- Bun.which("chromium-browser") ??
132
- Bun.which("google-chrome") ??
133
- "/usr/bin/chromium");
129
+ function browserExecutable() {
130
+ const executable = Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell");
131
+ if (!executable) {
132
+ throw new Error("Chromium Headless Shell is required by this SDK but is missing from the runtime image. Rebuild the environment with a current Spectest golden image; for local development, install chromium-headless-shell on PATH.");
133
+ }
134
+ return executable;
134
135
  }
135
136
  async function ensurePlaywrightBrowser() {
136
137
  if (PW_BROWSER?.isConnected())
137
138
  return PW_BROWSER;
138
139
  PW_BROWSER = await chromium.launch({
139
- executablePath: chromiumPath(),
140
+ executablePath: browserExecutable(),
140
141
  headless: true,
141
142
  args: CHROME_ARGV,
142
143
  });
@@ -167,7 +168,7 @@ async function newViewContext(width, height, device, opts = {}) {
167
168
  let browser;
168
169
  try {
169
170
  browser = fixture ? await chromium.launch({
170
- executablePath: chromiumPath(), headless: true, args: [...CHROME_ARGV, fixture.arg],
171
+ executablePath: browserExecutable(), headless: true, args: [...CHROME_ARGV, fixture.arg],
171
172
  }) : await ensurePlaywrightBrowser();
172
173
  const context = await browser.newContext({
173
174
  ...(device
package/dist/daemon.js CHANGED
@@ -65,12 +65,12 @@ import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, tak
65
65
  import { compactReach, projectReach, renderReachRules, renderReadsLcov } from "./harness/reach.js";
66
66
  import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
67
67
  import { bindInstrumentationScope, createInstrumentationScope, runInstrumented, runUninstrumented, } from "./harness/instrumentation-scope.js";
68
- import { encodeRegistry } from "./harness/names-registry.js";
68
+ import { ServiceDns, ServiceOperations } from "./harness/service-dns.js";
69
69
  import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
70
70
  import { HOP_BY_HOP_HEADERS, withIngressCors, upstreamRequestHeaders, } from "./harness/http-proxy.js";
71
71
  import { openUpstreamWebSocket, parseSubprotocols, upstreamHandshakeHeaders, } from "./harness/ws-bridge.js";
72
72
  import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectCertName, wildcardSuffix, } from "./harness/hostmatch.js";
73
- import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
73
+ import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, routesFor, unbindRoute, } from "./harness/ingress-table.js";
74
74
  import { startIngressServer } from "./harness/ingress-server.js";
75
75
  import { startHttp2Proxy } from "./harness/http2-proxy.js";
76
76
  import { startTlsTerminator } from "./harness/tls-terminator.js";
@@ -1218,7 +1218,9 @@ async function runContainer(svc, tag, volumeFlags,
1218
1218
  extraAliases = []) {
1219
1219
  // Preserve the previous incarnation before the name is reused.
1220
1220
  if (RUNTIME_SERVICES.has(svc.name))
1221
- await stopRuntimeService(svc.name);
1221
+ await stopRuntimeServiceUnlocked(svc.name);
1222
+ PROXY_IP_CACHE.delete(svc.name);
1223
+ await SERVICE_DNS.service(svc.name, null, [...(LOWERED.aliasesByService[svc.name] ?? []), ...extraAliases]);
1222
1224
  STOPPED_LOG_SERVICES.delete(svc.name);
1223
1225
  LOG_MARKERS.delete(`${svc.name}\0stdout`);
1224
1226
  LOG_MARKERS.delete(`${svc.name}\0stderr`);
@@ -1243,6 +1245,11 @@ extraAliases = []) {
1243
1245
  if (r.code !== 0) {
1244
1246
  throw new Error(`docker run ${svc.name} failed: ${r.stderr.trim() || r.stdout.trim()}`);
1245
1247
  }
1248
+ const ip = await serviceContainerIp(svc.name);
1249
+ if (!ip)
1250
+ throw new Error(`Service ${svc.name} has no IP on ${NETWORK_NAME} after docker run`);
1251
+ await SERVICE_DNS.service(svc.name, ip);
1252
+ PROXY_IP_CACHE.set(svc.name, ip);
1246
1253
  }
1247
1254
  async function probeTcp(host, port) {
1248
1255
  return new Promise((resolve) => {
@@ -1403,13 +1410,12 @@ async function withContainerPostMortem(name, err) {
1403
1410
  // same root CA, so `ctx.browser()` and peer services trust it.
1404
1411
  //
1405
1412
  // Both listeners bind on 0.0.0.0 so containers reach them via the
1406
- // bridge gateway IP (also written into /run/spectest-fakes.json for
1413
+ // bridge gateway IP (also published in the DNS registry for
1407
1414
  // spectest-resolver and injected as --add-host on every container).
1408
1415
  //
1409
1416
  // Per-fake `state` is plain JS memory and lives across snapshot/fork
1410
1417
  // along with the rest of the daemon — every fork sees its own copy.
1411
1418
  // ────────────────────────────────────────────────────────────────────────
1412
- const FAKES_REGISTRY_PATH = process.env.SPECTEST_FAKES_REGISTRY ?? "/run/spectest-fakes.json";
1413
1419
  const DEFAULT_FAKE_PORT = 80;
1414
1420
  /** All loaded fakes, keyed by stable name (the `fakes` map key). Holds the
1415
1421
  * in-daemon handler, forked state, and helpers — the parts intrinsic to a
@@ -1581,9 +1587,7 @@ async function startIngress() {
1581
1587
  LOWERED.wildcards.length > 0;
1582
1588
  if (!hasIngress) {
1583
1589
  // Make sure the resolver doesn't see stale entries from a prior project.
1584
- REGISTRY.hosts = {};
1585
- REGISTRY.wildcards = [];
1586
- await writeRegistry();
1590
+ await seedNamesRegistry();
1587
1591
  return;
1588
1592
  }
1589
1593
  const gw = await bridgeGatewayIp();
@@ -1671,9 +1675,9 @@ async function startIngress() {
1671
1675
  await ensureHttpsIngress();
1672
1676
  // Seed the resolver's names registry: ingress hostnames (fakes, TLS
1673
1677
  // proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
1674
- // wildcards. Service-targeted wildcards wait for the post-container pass
1675
- // (their containers aren't up yet). Dynamic ctx.dnsName calls extend this.
1676
- await seedNamesRegistry({ servicesUp: false });
1678
+ // wildcards. Service-targeted names are reserved until runContainer
1679
+ // publishes their IPs. Dynamic ctx.dnsName calls extend this.
1680
+ await seedNamesRegistry();
1677
1681
  }
1678
1682
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1679
1683
  function requireBun() {
@@ -1804,7 +1808,10 @@ function certCovers(hostname) {
1804
1808
  * per-test snapshot exactly like fake state — a `dependsOn` child inherits
1805
1809
  * it, siblings forked from an earlier snapshot never see it.
1806
1810
  */
1807
- async function bindRuntimeTls(hostname, service, port, http2, cors) {
1811
+ function bindRuntimeTls(hostname, service, port, http2, cors) {
1812
+ return TLS_OPERATIONS.run(hostname.toLowerCase(), () => bindRuntimeTlsUnlocked(hostname, service, port, http2, cors));
1813
+ }
1814
+ async function bindRuntimeTlsUnlocked(hostname, service, port, http2, cors) {
1808
1815
  // Reuse the boot primitives for validation + lowercasing; throws on a
1809
1816
  // malformed hostname / upstream just like a boot `tls` would at load.
1810
1817
  const decl = makeProxyDecl(hostname, { service, port, http2, cors });
@@ -1839,15 +1846,7 @@ async function bindRuntimeTls(hostname, service, port, http2, cors) {
1839
1846
  // Resolve the hostname to the daemon gateway (where :443/:80 listen).
1840
1847
  // A wildcard can only live in the resolver's suffix table.
1841
1848
  const gw = await bridgeGatewayIp();
1842
- const target = registryTarget(host);
1843
- if (target.wildcard) {
1844
- REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== target.suffix);
1845
- REGISTRY.wildcards.push({ suffix: target.suffix, ip: gw });
1846
- }
1847
- else {
1848
- REGISTRY.hosts[target.host] = gw;
1849
- }
1850
- await writeRegistry();
1849
+ await SERVICE_DNS.bind(host, { ip: gw }, `tls:${service}`);
1851
1850
  // eslint-disable-next-line no-console
1852
1851
  console.log(`[ingress] runtime https ${host} -> ${service}:${port}`);
1853
1852
  }
@@ -1857,20 +1856,16 @@ async function bindRuntimeTls(hostname, service, port, http2, cors) {
1857
1856
  * left in the SNI table — harmless without a route, and removing it would
1858
1857
  * mean an avoidable :443 rebind.
1859
1858
  */
1860
- async function unbindRuntimeTls(hostname) {
1859
+ function unbindRuntimeTls(hostname, service) {
1860
+ return TLS_OPERATIONS.run(hostname.toLowerCase(), () => unbindRuntimeTlsUnlocked(hostname, service));
1861
+ }
1862
+ async function unbindRuntimeTlsUnlocked(hostname, service) {
1861
1863
  const host = hostname.toLowerCase();
1862
- unbindRoute(INGRESS, host);
1863
- const target = registryTarget(host);
1864
- if (target.wildcard) {
1865
- const before = REGISTRY.wildcards.length;
1866
- REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== target.suffix);
1867
- if (REGISTRY.wildcards.length !== before)
1868
- await writeRegistry();
1869
- }
1870
- else if (target.host in REGISTRY.hosts) {
1871
- delete REGISTRY.hosts[target.host];
1872
- await writeRegistry();
1873
- }
1864
+ // A newer service may have taken this route/name. Do not remove its route.
1865
+ const route = INGRESS.routesByPort.get(INGRESS_HTTP_PORT)?.get(host);
1866
+ if (route?.kind === "proxy" && route.service === service)
1867
+ unbindRoute(INGRESS, host);
1868
+ await SERVICE_DNS.retire(host, `tls:${service}`);
1874
1869
  }
1875
1870
  /** Concurrent runtime routes must share the asynchronous first bind. */
1876
1871
  async function ensureHttpIngress(port, byHost) {
@@ -2125,30 +2120,10 @@ server, service, port, listenerLabel, proto) {
2125
2120
  const e = lastErr;
2126
2121
  return new Response(`spectest-daemon: upstream ${service}:${port} unreachable: ${e?.message ?? String(lastErr)}\n`, { status: 502, headers: { "content-type": "text/plain" } });
2127
2122
  }
2128
- /**
2129
- * In-memory names registry, serialised to FAKES_REGISTRY_PATH for the
2130
- * resolver. `startIngress` seeds it from LOWERED (the static
2131
- * tls/hostnames/fakes/wildcard decls); `registerDnsName` mutates it live
2132
- * when a test calls `ctx.dnsName`. It lives in daemon memory, so it forks
2133
- * with the rest of the snapshot — a test's dynamic registration is
2134
- * isolated to its own fork, exactly like fake state.
2135
- */
2136
- const REGISTRY = { hosts: {}, wildcards: [] };
2137
- async function writeRegistry() {
2138
- // Format + lookup semantics live in `harness/names-registry.ts`, shared
2139
- // with the resolver that reads this file.
2140
- const body = encodeRegistry(REGISTRY, Date.now());
2141
- try {
2142
- await fs.mkdir(path.dirname(FAKES_REGISTRY_PATH), { recursive: true });
2143
- await fs.writeFile(FAKES_REGISTRY_PATH, body);
2144
- }
2145
- catch (err) {
2146
- // Resolver gracefully degrades; just log.
2147
- // eslint-disable-next-line no-console
2148
- console.warn(`[names] failed to write registry at ${FAKES_REGISTRY_PATH}:`, err);
2149
- }
2150
- }
2151
- /** `*.example.com` → `.example.com` — the suffix the resolver matches. */
2123
+ // The SDK owns desired DNS state; the platform owns serving and persistence.
2124
+ const SERVICE_DNS = new ServiceDns();
2125
+ const SERVICE_OPERATIONS = new ServiceOperations();
2126
+ const TLS_OPERATIONS = new ServiceOperations();
2152
2127
  /** A service container's IP on spectest-net. `null` if the container isn't
2153
2128
  * up or isn't attached to the network yet. */
2154
2129
  async function serviceContainerIp(name) {
@@ -2190,44 +2165,25 @@ async function proxyUpstreamHost(service) {
2190
2165
  async function resolveDnsTarget(target) {
2191
2166
  if ("ingress" in target)
2192
2167
  return bridgeGatewayIp();
2193
- const ip = await serviceContainerIp(target.service);
2168
+ const ip = SERVICE_DNS.address(target.service);
2194
2169
  if (!ip) {
2195
2170
  throw new Error(`dnsName target service ${JSON.stringify(target.service)} has no IP on ${NETWORK_NAME} (is it a running service?)`);
2196
2171
  }
2197
2172
  return ip;
2198
2173
  }
2199
- /**
2200
- * Seed REGISTRY from the static lowered decls and write it. Run twice per
2201
- * /bootstrap: once from startIngress (`servicesUp: false`) so ingress
2202
- * hostnames answer during container startup, then once after every
2203
- * container is up (`servicesUp: true`) so service-targeted wildcards (e.g.
2204
- * k3s `ingressDomains`) can resolve their container IPs. The post-container
2205
- * pass is what's captured into the warm template.
2206
- */
2207
- async function seedNamesRegistry(opts) {
2174
+ /** Reserve all declared names before containers start. Each runContainer
2175
+ * publishes its IP before readiness/setup; there is no destructive final reseed. */
2176
+ async function seedNamesRegistry() {
2208
2177
  const gw = await bridgeGatewayIp();
2209
- REGISTRY.hosts = {};
2210
- REGISTRY.wildcards = [];
2211
- for (const h of LOWERED.ingressHosts)
2212
- REGISTRY.hosts[h] = gw;
2213
- for (const w of LOWERED.wildcards) {
2214
- if ("ingress" in w.target) {
2215
- REGISTRY.wildcards.push({ suffix: wildcardSuffix(w.pattern), ip: gw });
2216
- continue;
2217
- }
2218
- // Service target — only resolvable once the container has an IP.
2219
- if (!opts.servicesUp)
2220
- continue;
2221
- const ip = await serviceContainerIp(w.target.service);
2222
- if (ip) {
2223
- REGISTRY.wildcards.push({ suffix: wildcardSuffix(w.pattern), ip });
2224
- }
2225
- else {
2226
- // eslint-disable-next-line no-console
2227
- console.warn(`[names] wildcard ${w.pattern}: service ${JSON.stringify(w.target.service)} has no IP on ${NETWORK_NAME}`);
2228
- }
2229
- }
2230
- await writeRegistry();
2178
+ const services = namedServices(requireLoaded().project.environment).map(svc => ({
2179
+ name: svc.name, aliases: LOWERED.aliasesByService[svc.name] ?? [],
2180
+ }));
2181
+ const bindings = [
2182
+ ...LOWERED.ingressHosts.map(name => ({ name, target: { ip: gw } })),
2183
+ ...LOWERED.wildcards.map(w => ({ name: w.pattern,
2184
+ target: "ingress" in w.target ? { ip: gw } : { service: w.target.service } })),
2185
+ ];
2186
+ await SERVICE_DNS.reset(services, bindings);
2231
2187
  }
2232
2188
  /**
2233
2189
  * The live interceptors (`ctx.intercept`). Module memory, so they fork with
@@ -2399,9 +2355,8 @@ function registerInterceptor(target, description, handler) {
2399
2355
  * Register a hostname at runtime — the implementation behind `ctx.dnsName`.
2400
2356
  * Validates via the same `dnsName` primitive the static path uses, resolves
2401
2357
  * the target to an IP, and updates + persists the registry. Exact names go
2402
- * in `hosts`; `*.suffix` wildcards in `wildcards`. The resolver re-reads on
2403
- * the next query (it watches the file's mtime), so the name is live
2404
- * immediately — answered for VM-host/test/browser code and for peer
2358
+ * in `hosts`; `*.suffix` wildcards in `wildcards`. The resolver acknowledges
2359
+ * activation before this returns, so the name is live immediately — answered for VM-host/test/browser code and for peer
2405
2360
  * containers (Docker forwards unknown names to the host resolver). It does
2406
2361
  * NOT land in any container's /etc/hosts.
2407
2362
  */
@@ -2410,15 +2365,7 @@ async function registerDnsName(hostname, target) {
2410
2365
  // Reuse the primitive purely for validation + lowercasing.
2411
2366
  const decl = makeDnsDecl(hostname, target);
2412
2367
  const ip = await resolveDnsTarget(target);
2413
- if (isWildcard(decl.hostname)) {
2414
- const suffix = wildcardSuffix(decl.hostname);
2415
- REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== suffix);
2416
- REGISTRY.wildcards.push({ suffix, ip });
2417
- }
2418
- else {
2419
- REGISTRY.hosts[decl.hostname] = ip;
2420
- }
2421
- await writeRegistry();
2368
+ await SERVICE_DNS.bind(decl.hostname, "service" in target ? { service: target.service } : { ip });
2422
2369
  recordEnv({ op: "dnsName", hostname: decl.hostname, ip, durationMs: 0 }, resv);
2423
2370
  }
2424
2371
  /**
@@ -2480,7 +2427,10 @@ function specToNamedService(spec) {
2480
2427
  * Prepares the image (pulling on first use through the host cache), runs
2481
2428
  * the container on spectest-net, and waits for its readyCheck. Returns the
2482
2429
  * container's name + IP. */
2483
- async function startRuntimeService(spec) {
2430
+ function startRuntimeService(spec) {
2431
+ return SERVICE_OPERATIONS.run(spec.name, () => startRuntimeServiceUnlocked(spec));
2432
+ }
2433
+ async function startRuntimeServiceUnlocked(spec) {
2484
2434
  if (!spec.name || spec.name.length === 0) {
2485
2435
  throw new Error("startService: `name` is required");
2486
2436
  }
@@ -2499,14 +2449,14 @@ async function startRuntimeService(spec) {
2499
2449
  ...(await ensureCoverage(svc)),
2500
2450
  ];
2501
2451
  await runContainer(svc, tag, flags, aliases);
2452
+ RUNTIME_SERVICES.set(svc.name, svc);
2502
2453
  await waitForReady(svc);
2503
- const ip = (await serviceContainerIp(svc.name)) ?? "";
2454
+ const ip = SERVICE_DNS.address(svc.name);
2504
2455
  // `tls` is the runtime twin of a boot service's: stand up a
2505
2456
  // TLS-terminating reverse proxy at https://<hostname>/ → this container.
2506
2457
  for (const entry of svc.tls ?? []) {
2507
2458
  await bindRuntimeTls(entry.hostname, svc.name, entry.port, entry.http2, entry.cors);
2508
2459
  }
2509
- RUNTIME_SERVICES.set(svc.name, svc);
2510
2460
  recordEnv({
2511
2461
  op: "startService",
2512
2462
  service: svc.name,
@@ -2529,10 +2479,15 @@ async function startRuntimeService(spec) {
2529
2479
  }
2530
2480
  /** Implementation behind `ctx.stopService`. Removes the container and drops
2531
2481
  * it from the runtime registry. No-op (rc ignored) if it's already gone. */
2532
- async function stopRuntimeService(name) {
2482
+ function stopRuntimeService(name) {
2483
+ return SERVICE_OPERATIONS.run(name, () => stopRuntimeServiceUnlocked(name));
2484
+ }
2485
+ async function stopRuntimeServiceUnlocked(name) {
2533
2486
  const t0 = Date.now();
2534
2487
  const resv = reserveEvent();
2535
2488
  const svc = RUNTIME_SERVICES.get(name);
2489
+ await SERVICE_DNS.service(name, null);
2490
+ PROXY_IP_CACHE.delete(name);
2536
2491
  // Freeze log production before reading, while Docker still owns the logs.
2537
2492
  // Keep force-stop semantics; removing first destroys the only copy.
2538
2493
  await docker(["kill", name], 30_000);
@@ -2545,7 +2500,7 @@ async function stopRuntimeService(name) {
2545
2500
  await docker(["rm", "-f", name], 30_000);
2546
2501
  RUNTIME_SERVICES.delete(name);
2547
2502
  for (const entry of svc?.tls ?? [])
2548
- await unbindRuntimeTls(entry.hostname);
2503
+ await unbindRuntimeTls(entry.hostname, name);
2549
2504
  recordEnv({ op: "stopService", service: name, durationMs: Date.now() - t0 }, resv);
2550
2505
  }
2551
2506
  /** The runtime environment-control handle handed to fakes (3rd handler arg /
@@ -2835,10 +2790,6 @@ async function bootstrapInner(prepared, identity) {
2835
2790
  }
2836
2791
  });
2837
2792
  await ensureStickyBuildHeadroom();
2838
- // Containers now have IPs — re-seed so service-targeted wildcards (e.g.
2839
- // k3s ingressDomains → the cluster container) resolve. Captured into the
2840
- // warm template, so warm starts inherit the resolved entries.
2841
- await seedNamesRegistry({ servicesUp: true });
2842
2793
  // Browser pre-warm DISABLED (2026-06-08). We used to pre-open one view
2843
2794
  // into the pool (browser.ts VIEW_POOL) here so every fork inherited a
2844
2795
  // live renderer and the first ctx.browser() skipped the ~1.2-1.5s spawn.
@@ -0,0 +1,23 @@
1
+ export declare const DNS_SOCKET: string;
2
+ export declare const DNS_REGISTRY_PATH: string;
3
+ export declare const MAX_REGISTRY_BYTES: number;
4
+ export interface DnsRegistry {
5
+ version: 1;
6
+ /** Opaque transaction ID, echoed in the acknowledgement. */
7
+ generation: string;
8
+ /** null reserves an unavailable name: it must never escape upstream. */
9
+ hosts: Record<string, string | null>;
10
+ wildcards: Array<{
11
+ suffix: string;
12
+ ip: string | null;
13
+ }>;
14
+ }
15
+ export declare function parseDnsRegistry(text: string): DnsRegistry;
16
+ /** Compile once per update. undefined = unknown; null = owned but unavailable. */
17
+ export declare function compileDnsRegistry(doc: DnsRegistry): (name: string) => string | null | undefined;
18
+ /** Restore before binding DNS. A corrupt persisted registry is fatal, because
19
+ * silently forgetting owned domains could send fake traffic to real services. */
20
+ export declare function serveDnsRegistry(activate: (doc: DnsRegistry) => void): Promise<import("node:http").Server<typeof import("node:http").IncomingMessage, typeof import("node:http").ServerResponse>>;
21
+ /** Unix HTTP bypasses the SDK's instrumented fetch. A missing platform feature
22
+ * fails bootstrap explicitly; never silently switch back to discovery. */
23
+ export declare function publishDnsRegistry(doc: DnsRegistry, socketPath?: string): Promise<void>;
@@ -0,0 +1,129 @@
1
+ /** Platform/SDK DNS control protocol. The platform persists and activates each
2
+ * complete registry before acknowledging it. No project code runs in DNS. */
3
+ import { isIPv4 } from "node:net";
4
+ import { createServer, request } from "node:http";
5
+ import { mkdir, readFile, rename, rm, writeFile, chmod } from "node:fs/promises";
6
+ import { dirname } from "node:path";
7
+ export const DNS_SOCKET = process.env.SPECTEST_DNS_SOCKET ?? "/run/spectest-dns.sock";
8
+ export const DNS_REGISTRY_PATH = process.env.SPECTEST_DNS_REGISTRY ?? "/run/spectest-dns.json";
9
+ export const MAX_REGISTRY_BYTES = 4 * 1024 * 1024;
10
+ function validName(name) {
11
+ return name.length > 0 && name.length <= 253 && name === name.toLowerCase() &&
12
+ name.split(".").every(label => /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/.test(label));
13
+ }
14
+ export function parseDnsRegistry(text) {
15
+ if (Buffer.byteLength(text) > MAX_REGISTRY_BYTES)
16
+ throw new Error("DNS registry too large");
17
+ const doc = JSON.parse(text);
18
+ if (!doc || doc.version !== 1 || typeof doc.generation !== "string" || !doc.generation.length || doc.generation.length > 128 ||
19
+ !doc.hosts || typeof doc.hosts !== "object" || Array.isArray(doc.hosts) || !Array.isArray(doc.wildcards))
20
+ throw new Error("Unsupported or malformed DNS registry (expected version 1)");
21
+ const address = (ip) => ip === null || (typeof ip === "string" && isIPv4(ip));
22
+ for (const [name, ip] of Object.entries(doc.hosts))
23
+ if (!validName(name) || !address(ip))
24
+ throw new Error(`Invalid DNS host: ${name}`);
25
+ const suffixes = new Set();
26
+ for (const w of doc.wildcards) {
27
+ if (!w || typeof w.suffix !== "string" || !w.suffix.startsWith(".") || !validName(w.suffix.slice(1)) || !address(w.ip) || suffixes.has(w.suffix))
28
+ throw new Error("Invalid or duplicate DNS wildcard");
29
+ suffixes.add(w.suffix);
30
+ }
31
+ return { version: 1, generation: doc.generation, hosts: doc.hosts, wildcards: doc.wildcards };
32
+ }
33
+ /** Compile once per update. undefined = unknown; null = owned but unavailable. */
34
+ export function compileDnsRegistry(doc) {
35
+ const hosts = new Map(Object.entries(doc.hosts));
36
+ const wildcards = new Map(doc.wildcards.map(w => [w.suffix, w.ip]));
37
+ return name => {
38
+ if (hosts.has(name))
39
+ return hosts.get(name);
40
+ for (let i = name.indexOf("."); i !== -1; i = name.indexOf(".", i + 1)) {
41
+ const suffix = name.slice(i);
42
+ if (wildcards.has(suffix))
43
+ return wildcards.get(suffix);
44
+ }
45
+ return undefined;
46
+ };
47
+ }
48
+ /** Restore before binding DNS. A corrupt persisted registry is fatal, because
49
+ * silently forgetting owned domains could send fake traffic to real services. */
50
+ export async function serveDnsRegistry(activate) {
51
+ try {
52
+ activate(parseDnsRegistry(await readFile(DNS_REGISTRY_PATH, "utf8")));
53
+ }
54
+ catch (error) {
55
+ if (error.code !== "ENOENT")
56
+ throw error;
57
+ }
58
+ await mkdir(dirname(DNS_SOCKET), { recursive: true });
59
+ await rm(DNS_SOCKET, { force: true });
60
+ let pending = Promise.resolve();
61
+ const server = createServer(async (req, res) => {
62
+ if (req.method !== "PUT" || req.url !== "/v1/registry") {
63
+ res.writeHead(404).end();
64
+ return;
65
+ }
66
+ try {
67
+ const chunks = [];
68
+ let size = 0;
69
+ for await (const chunk of req) {
70
+ size += chunk.length;
71
+ if (size > MAX_REGISTRY_BYTES) {
72
+ res.writeHead(413).end();
73
+ return;
74
+ }
75
+ chunks.push(Buffer.from(chunk));
76
+ }
77
+ const doc = parseDnsRegistry(Buffer.concat(chunks).toString("utf8"));
78
+ const update = pending.then(async () => {
79
+ await mkdir(dirname(DNS_REGISTRY_PATH), { recursive: true });
80
+ const tmp = `${DNS_REGISTRY_PATH}.tmp`;
81
+ await writeFile(tmp, JSON.stringify(doc), { mode: 0o600 });
82
+ await rename(tmp, DNS_REGISTRY_PATH);
83
+ activate(doc);
84
+ });
85
+ pending = update.catch(() => { });
86
+ await update;
87
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ version: 1, generation: doc.generation }));
88
+ }
89
+ catch (error) {
90
+ res.writeHead(400, { "content-type": "text/plain" }).end(String(error));
91
+ }
92
+ });
93
+ server.requestTimeout = 10_000;
94
+ await new Promise((resolve, reject) => { server.once("error", reject); server.listen(DNS_SOCKET, resolve); });
95
+ await chmod(DNS_SOCKET, 0o600);
96
+ return server;
97
+ }
98
+ /** Unix HTTP bypasses the SDK's instrumented fetch. A missing platform feature
99
+ * fails bootstrap explicitly; never silently switch back to discovery. */
100
+ export async function publishDnsRegistry(doc, socketPath = DNS_SOCKET) {
101
+ const body = JSON.stringify(doc);
102
+ parseDnsRegistry(body);
103
+ await new Promise((resolve, reject) => {
104
+ const req = request({ socketPath, method: "PUT", path: "/v1/registry", agent: false,
105
+ headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body) } }, res => {
106
+ let text = "";
107
+ res.setEncoding("utf8");
108
+ res.on("data", chunk => { text += chunk; });
109
+ res.on("error", reject);
110
+ res.on("end", () => {
111
+ try {
112
+ if (res.statusCode !== 200)
113
+ throw new Error(`DNS update rejected (${res.statusCode}): ${text}`);
114
+ const ack = JSON.parse(text);
115
+ if (ack.version !== 1 || ack.generation !== doc.generation)
116
+ throw new Error("DNS acknowledgement mismatch");
117
+ resolve();
118
+ }
119
+ catch (error) {
120
+ reject(error);
121
+ }
122
+ });
123
+ });
124
+ const timer = setTimeout(() => req.destroy(new Error("DNS registry acknowledgement timed out")), 10_000);
125
+ req.on("close", () => clearTimeout(timer));
126
+ req.on("error", error => reject(new Error(`Cannot publish DNS registry; this SDK requires a platform with DNS registry v1: ${error.message}`)));
127
+ req.end(body);
128
+ });
129
+ }
@@ -1,22 +1,7 @@
1
1
  /**
2
- * The DNS names registry: the file the harness writes and
3
- * `spectest-resolver` reads.
4
- *
5
- * Ported out of `daemon.ts`. The point of a shared module is that this
6
- * format had **two independent implementations** — the writer here and
7
- * the reader in `resolver.ts` — with nothing keeping them in step. That
8
- * is the same shape as the `config.rs` ↔ SDK drift the harness split
9
- * exists to remove, just inside one language.
10
- *
11
- * The registry answers names that Docker's own DNS cannot:
12
- * - fake hostnames (`api.stripe.com` → the bridge gateway),
13
- * - names bound at runtime by `ctx.dnsName`,
14
- * - **wildcards** (`*.us-east-1.amazonaws.com`), which have no
15
- * `--add-host` or `--network-alias` equivalent and therefore *only*
16
- * exist here.
17
- *
18
- * Lookup is exact-first, then longest matching suffix — the same
19
- * precedence the ingress routes and the SNI cert table use.
2
+ * Legacy names-file format used by SDKs before 0.131.20. The platform retains
3
+ * its lookup semantics for pinned SDKs that do not publish a complete registry.
4
+ * New SDKs use dns-registry.ts (control protocol) and service-dns.ts (ownership).
20
5
  */
21
6
  export interface RegistryDoc {
22
7
  /** Exact name → IP. */
@@ -1,22 +1,7 @@
1
1
  /**
2
- * The DNS names registry: the file the harness writes and
3
- * `spectest-resolver` reads.
4
- *
5
- * Ported out of `daemon.ts`. The point of a shared module is that this
6
- * format had **two independent implementations** — the writer here and
7
- * the reader in `resolver.ts` — with nothing keeping them in step. That
8
- * is the same shape as the `config.rs` ↔ SDK drift the harness split
9
- * exists to remove, just inside one language.
10
- *
11
- * The registry answers names that Docker's own DNS cannot:
12
- * - fake hostnames (`api.stripe.com` → the bridge gateway),
13
- * - names bound at runtime by `ctx.dnsName`,
14
- * - **wildcards** (`*.us-east-1.amazonaws.com`), which have no
15
- * `--add-host` or `--network-alias` equivalent and therefore *only*
16
- * exist here.
17
- *
18
- * Lookup is exact-first, then longest matching suffix — the same
19
- * precedence the ingress routes and the SNI cert table use.
2
+ * Legacy names-file format used by SDKs before 0.131.20. The platform retains
3
+ * its lookup semantics for pinned SDKs that do not publish a complete registry.
4
+ * New SDKs use dns-registry.ts (control protocol) and service-dns.ts (ownership).
20
5
  */
21
6
  export const EMPTY_REGISTRY = { hosts: {}, wildcards: [] };
22
7
  /** Serialise for the resolver. */
@@ -0,0 +1,35 @@
1
+ import { type DnsRegistry } from "./dns-registry.js";
2
+ export type NameTarget = {
3
+ service: string;
4
+ } | {
5
+ ip: string | null;
6
+ };
7
+ /** SDK-owned desired state. Updates are serialized and publish the whole view.
8
+ * Service bindings retain their target, so replacement IPs and stops also
9
+ * update ctx.dnsName aliases. State and the publication queue fork with the VM. */
10
+ export declare class ServiceDns {
11
+ private publish;
12
+ private services;
13
+ private bindings;
14
+ private pending;
15
+ constructor(publish?: (doc: DnsRegistry) => Promise<void>);
16
+ private update;
17
+ private document;
18
+ address(name: string): string | null;
19
+ reset(services: Array<{
20
+ name: string;
21
+ aliases: string[];
22
+ }>, bindings: Array<{
23
+ name: string;
24
+ target: NameTarget;
25
+ }>): Promise<void>;
26
+ service(name: string, ip: string | null, aliases?: string[]): Promise<void>;
27
+ bind(name: string, target: NameTarget, owner?: string): Promise<void>;
28
+ retire(name: string, owner: string): Promise<void>;
29
+ }
30
+ /** Serialize same-name start/stop operations, while different services can
31
+ * start concurrently. Prevents stale teardown deleting a replacement. */
32
+ export declare class ServiceOperations {
33
+ private pending;
34
+ run<T>(name: string, fn: () => Promise<T>): Promise<T>;
35
+ }