@specific.dev/spectest 0.39.0 → 0.43.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 (105) hide show
  1. package/dist/browser.d.ts +21 -8
  2. package/dist/browser.js +78 -36
  3. package/dist/components/supabase.d.ts +87 -27
  4. package/dist/components/supabase.js +352 -69
  5. package/dist/daemon.d.ts +38 -0
  6. package/dist/daemon.js +464 -987
  7. package/dist/harness/build-context.d.ts +82 -0
  8. package/dist/harness/build-context.js +113 -0
  9. package/dist/harness/buildkit-progress.d.ts +37 -0
  10. package/dist/harness/buildkit-progress.js +66 -0
  11. package/dist/harness/container-run.d.ts +89 -0
  12. package/dist/harness/container-run.js +118 -0
  13. package/dist/harness/file-mounts.d.ts +91 -0
  14. package/dist/harness/file-mounts.js +119 -0
  15. package/dist/harness/hostmatch.d.ts +65 -0
  16. package/dist/harness/hostmatch.js +108 -0
  17. package/dist/harness/http-proxy.d.ts +62 -0
  18. package/dist/harness/http-proxy.js +104 -0
  19. package/dist/harness/ingress-table.d.ts +148 -0
  20. package/dist/harness/ingress-table.js +129 -0
  21. package/dist/harness/log-delta.d.ts +54 -0
  22. package/dist/harness/log-delta.js +83 -0
  23. package/dist/harness/main.d.ts +47 -0
  24. package/dist/harness/main.js +164 -0
  25. package/dist/harness/methods.d.ts +54 -0
  26. package/dist/harness/methods.js +65 -0
  27. package/dist/harness/names-registry.d.ts +63 -0
  28. package/dist/harness/names-registry.js +90 -0
  29. package/dist/harness/protocol.d.ts +88 -0
  30. package/dist/harness/protocol.js +96 -0
  31. package/dist/harness/ready-poll.d.ts +47 -0
  32. package/dist/harness/ready-poll.js +67 -0
  33. package/dist/harness/service-graph.d.ts +29 -0
  34. package/dist/harness/service-graph.js +92 -0
  35. package/dist/harness/volume-paths.d.ts +70 -0
  36. package/dist/harness/volume-paths.js +81 -0
  37. package/dist/index.d.ts +58 -16
  38. package/dist/ingress.d.ts +1 -1
  39. package/dist/mobile.d.ts +9 -5
  40. package/dist/mobile.js +7 -6
  41. package/dist/recorder.d.ts +10 -0
  42. package/dist/resolver.js +5 -8
  43. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  44. package/dist/vendor/rrweb-record.min.js +5061 -0
  45. package/package.json +7 -1
  46. package/src/aws-sigv4.ts +218 -0
  47. package/src/browser.ts +2095 -0
  48. package/src/components/aws.ts +554 -0
  49. package/src/components/email.ts +398 -0
  50. package/src/components/expo.ts +167 -0
  51. package/src/components/index.ts +81 -0
  52. package/src/components/k3s.ts +2061 -0
  53. package/src/components/postgres.ts +132 -0
  54. package/src/components/replayFake.ts +1015 -0
  55. package/src/components/s3.ts +132 -0
  56. package/src/components/supabase.ts +1699 -0
  57. package/src/daemon.ts +5537 -0
  58. package/src/harness/build-context.test.ts +0 -0
  59. package/src/harness/build-context.ts +146 -0
  60. package/src/harness/buildkit-progress.test.ts +98 -0
  61. package/src/harness/buildkit-progress.ts +74 -0
  62. package/src/harness/container-run.test.ts +209 -0
  63. package/src/harness/container-run.ts +158 -0
  64. package/src/harness/file-mounts.test.ts +185 -0
  65. package/src/harness/file-mounts.ts +145 -0
  66. package/src/harness/hostmatch.test.ts +148 -0
  67. package/src/harness/hostmatch.ts +109 -0
  68. package/src/harness/http-proxy.test.ts +156 -0
  69. package/src/harness/http-proxy.ts +119 -0
  70. package/src/harness/ingress-rebind.test.ts +125 -0
  71. package/src/harness/ingress-table.test.ts +172 -0
  72. package/src/harness/ingress-table.ts +186 -0
  73. package/src/harness/log-delta.test.ts +125 -0
  74. package/src/harness/log-delta.ts +100 -0
  75. package/src/harness/main.test.ts +211 -0
  76. package/src/harness/main.ts +196 -0
  77. package/src/harness/methods.test.ts +63 -0
  78. package/src/harness/methods.ts +92 -0
  79. package/src/harness/names-registry.test.ts +137 -0
  80. package/src/harness/names-registry.ts +108 -0
  81. package/src/harness/protocol.test.ts +148 -0
  82. package/src/harness/protocol.ts +163 -0
  83. package/src/harness/ready-poll.test.ts +172 -0
  84. package/src/harness/ready-poll.ts +93 -0
  85. package/src/harness/service-graph.test.ts +97 -0
  86. package/src/harness/service-graph.ts +97 -0
  87. package/src/harness/volume-paths.test.ts +102 -0
  88. package/src/harness/volume-paths.ts +112 -0
  89. package/src/ids.ts +89 -0
  90. package/src/index.ts +2767 -0
  91. package/src/ingress.ts +305 -0
  92. package/src/inspect.ts +739 -0
  93. package/src/locator.ts +716 -0
  94. package/src/mobile.ts +138 -0
  95. package/src/record-secrets.ts +41 -0
  96. package/src/recorder.ts +856 -0
  97. package/src/redis.ts +202 -0
  98. package/src/replay-bundle.ts +108 -0
  99. package/src/resolver.ts +348 -0
  100. package/src/s3.ts +333 -0
  101. package/src/sql.ts +243 -0
  102. package/src/terminal.ts +740 -0
  103. package/src/url-match.ts +67 -0
  104. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  105. package/src/vendor/rrweb-record.min.js +5061 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The harness process.
3
+ *
4
+ * Started by the supervisor (`crates/spectest-vm-agent/src/supervisor.rs`)
5
+ * from the app dir's `node_modules`, so it is the *user's* SDK version
6
+ * that runs the user's project. It dials the socket the supervisor
7
+ * already bound, completes the `hello` handshake, and then serves
8
+ * requests until told to shut down.
9
+ *
10
+ * ## This process is long-lived and is never restarted
11
+ *
12
+ * It holds the state that makes forking a product feature — fake `state`,
13
+ * the ingress listeners with their per-fork route and certificate tables,
14
+ * the DNS registry, containers started mid-test, live browser sessions,
15
+ * the recorder. The supervisor deliberately has no respawn path: if this
16
+ * process dies, the environment is dead and says so, rather than silently
17
+ * coming back empty.
18
+ *
19
+ * So the rules here are: never call `process.exit` on a request failure
20
+ * (answer with an error instead), and never let an unhandled rejection
21
+ * take the process down.
22
+ *
23
+ * ## One implementation, two transports
24
+ *
25
+ * Everything this serves comes from the same method table the legacy HTTP
26
+ * daemon serves (`METHODS`, built in `daemon.ts`). That is deliberate and
27
+ * temporary: during the cutover both transports are live, and the failure
28
+ * being designed out is the one where a fix lands on the transport that is
29
+ * being replaced and never reaches the new one.
30
+ *
31
+ * The table is built **once** per process, so the mutual-exclusion slots
32
+ * that stop two tests running at the same time are shared across both
33
+ * transports. Two tables would each enforce the rule and together break it.
34
+ */
35
+
36
+ import net from "node:net";
37
+
38
+ import { METHODS } from "../daemon";
39
+ import { codeOf } from "./methods";
40
+ import {
41
+ PROTOCOL_VERSION,
42
+ decodeFrame,
43
+ encodeFrame,
44
+ fail,
45
+ ok,
46
+ type Frame,
47
+ type RequestFrame,
48
+ } from "./protocol";
49
+
50
+ /** Capabilities this build announces. Empty is fine — the slot is what
51
+ * matters, so a later release has something to negotiate against. */
52
+ const CAPABILITIES: string[] = [];
53
+
54
+ type Handler = (params: Record<string, unknown>) => Promise<unknown>;
55
+
56
+ /** Methods this build can serve. Everything else is answered with an
57
+ * error naming the method, which is far easier to diagnose than a
58
+ * request that hangs. */
59
+ export function handlers(
60
+ sdkVersion: string,
61
+ onShutdown: () => void,
62
+ methods: Record<string, Handler> = METHODS as Record<string, Handler>,
63
+ ): Record<string, Handler> {
64
+ return {
65
+ // Everything the environment actually does.
66
+ ...methods,
67
+ // Transport-level methods, which the method table has no business
68
+ // knowing about: they concern this connection, not this environment.
69
+ // Declared after the spread so the table can never shadow them — a
70
+ // `shutdown` that didn't shut down would be an unkillable environment.
71
+ hello: async () => ({
72
+ protocolVersion: PROTOCOL_VERSION,
73
+ capabilities: CAPABILITIES,
74
+ sdkVersion,
75
+ }),
76
+ shutdown: async () => {
77
+ // Reply first, exit after — the supervisor is waiting on this
78
+ // response, and dropping the connection instead would surface as a
79
+ // transport error rather than a clean stop.
80
+ queueMicrotask(onShutdown);
81
+ return {};
82
+ },
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Serve one connection until it closes.
88
+ *
89
+ * Split from `main` so it can be driven over any duplex stream in tests.
90
+ */
91
+ export function serve(
92
+ socket: NodeJS.ReadWriteStream,
93
+ registry: Record<string, Handler>,
94
+ ): void {
95
+ let buffered = "";
96
+ const send = (frame: Frame) => socket.write(encodeFrame(frame));
97
+
98
+ const dispatch = async (req: RequestFrame): Promise<void> => {
99
+ const handler = registry[req.method];
100
+ if (!handler) {
101
+ send(
102
+ fail(
103
+ req.id,
104
+ `harness does not implement ${JSON.stringify(req.method)}`,
105
+ "unimplemented",
106
+ ),
107
+ );
108
+ return;
109
+ }
110
+ try {
111
+ send(ok(req.id, (await handler(req.params ?? {})) as Record<string, unknown>));
112
+ } catch (err) {
113
+ // A failing request must not take the process down: the harness holds
114
+ // the environment's live state, so its death is unrecoverable.
115
+ //
116
+ // A refusal the method meant to make (an unknown case, a second test
117
+ // while one is running) reports its own kind and just its message. An
118
+ // unexpected throw is a bug in the method and carries the stack,
119
+ // which is the only place that information exists.
120
+ const e = err as Error;
121
+ const code = codeOf(err);
122
+ const detail =
123
+ code === "handler_error" ? (e?.stack ?? e?.message ?? String(err)) : e.message;
124
+ send(fail(req.id, detail, code));
125
+ }
126
+ };
127
+
128
+ socket.on("data", (chunk: Buffer | string) => {
129
+ buffered += chunk.toString();
130
+ // NDJSON: a chunk can hold several frames, or half of one.
131
+ for (;;) {
132
+ const nl = buffered.indexOf("\n");
133
+ if (nl === -1) break;
134
+ const line = buffered.slice(0, nl);
135
+ buffered = buffered.slice(nl + 1);
136
+ if (!line.trim()) continue;
137
+ let frame: Frame;
138
+ try {
139
+ frame = decodeFrame(line);
140
+ } catch (e) {
141
+ // Unroutable — no id to answer on. Log and keep serving rather
142
+ // than dropping a connection that is otherwise healthy.
143
+ console.error(`harness: ${(e as Error).message}`);
144
+ continue;
145
+ }
146
+ if (frame.kind === "request") void dispatch(frame);
147
+ // Responses to our own up-calls and events are handled elsewhere;
148
+ // ignoring them here keeps this loop about serving.
149
+ }
150
+ });
151
+ }
152
+
153
+ /** Read the SDK's own version, for the handshake. */
154
+ async function sdkVersion(): Promise<string> {
155
+ try {
156
+ const pkg = await import("../../package.json", { with: { type: "json" } });
157
+ return (pkg as { default?: { version?: string } }).default?.version ?? "unknown";
158
+ } catch {
159
+ return "unknown";
160
+ }
161
+ }
162
+
163
+ export async function main(): Promise<void> {
164
+ const path = process.env.SPECTEST_HARNESS_SOCKET;
165
+ if (!path) {
166
+ console.error("harness: SPECTEST_HARNESS_SOCKET is not set");
167
+ process.exit(2);
168
+ }
169
+
170
+ // An unhandled rejection anywhere in user code must not kill the
171
+ // environment — report it and keep serving.
172
+ process.on("unhandledRejection", (reason) => {
173
+ console.error("harness: unhandled rejection:", reason);
174
+ });
175
+
176
+ const version = await sdkVersion();
177
+ const socket = net.createConnection(path);
178
+
179
+ socket.on("error", (e) => {
180
+ console.error(`harness: socket error: ${e.message}`);
181
+ process.exit(1);
182
+ });
183
+ socket.on("close", () => {
184
+ // The supervisor went away; there is nothing left to serve.
185
+ process.exit(0);
186
+ });
187
+
188
+ await new Promise<void>((resolve) => socket.once("connect", () => resolve()));
189
+ serve(socket, handlers(version, () => socket.end(() => process.exit(0))));
190
+ }
191
+
192
+ // Only run when executed directly, so importing this module in a test
193
+ // doesn't try to dial a socket.
194
+ if (import.meta.main) {
195
+ void main();
196
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ HarnessError,
5
+ badRequest,
6
+ codeOf,
7
+ conflict,
8
+ notFound,
9
+ requireString,
10
+ type MethodTable,
11
+ } from "./methods";
12
+
13
+ const table = (names: string[]): MethodTable =>
14
+ Object.fromEntries(names.map((n) => [n, async () => ({})]));
15
+
16
+ describe("error kinds", () => {
17
+
18
+ test("the constructors carry their kind", () => {
19
+ expect(badRequest("x").code).toBe("bad_request");
20
+ expect(notFound("x").code).toBe("not_found");
21
+ expect(conflict("x").code).toBe("conflict");
22
+ });
23
+
24
+ /** A method body that throws an ordinary Error is a bug in the method,
25
+ * not a statement about the request — reporting it as a 400 would blame
26
+ * the caller for our own failure. */
27
+ test("an ordinary error is a handler failure, not a bad request", () => {
28
+ expect(codeOf(new Error("boom"))).toBe("handler_error");
29
+ expect(codeOf("a string")).toBe("handler_error");
30
+ expect(codeOf(new HarnessError("conflict", "busy"))).toBe("conflict");
31
+ });
32
+
33
+ test("the message survives, since it is all the caller gets", () => {
34
+ expect(conflict("another test is already running").message).toBe(
35
+ "another test is already running",
36
+ );
37
+ });
38
+ });
39
+
40
+ describe("requireString", () => {
41
+ test("returns the value when present", () => {
42
+ expect(requireString({ code: "1+1" }, "code")).toBe("1+1");
43
+ });
44
+
45
+ /** An empty string is not a usable snippet or case id, and letting it
46
+ * through would fail deeper in with a worse message. */
47
+ test("rejects missing, empty and wrong-typed values", () => {
48
+ expect(() => requireString({}, "code")).toThrow(/code \(string\) is required/);
49
+ expect(() => requireString({ code: "" }, "code")).toThrow();
50
+ expect(() => requireString({ code: 42 }, "code")).toThrow();
51
+ });
52
+
53
+ test("the failure is a bad request, not a handler error", () => {
54
+ try {
55
+ requireString({}, "caseId", "caseId");
56
+ throw new Error("should have thrown");
57
+ } catch (e) {
58
+ expect(codeOf(e)).toBe("bad_request");
59
+ expect((e as Error).message).toBe("caseId is required");
60
+ }
61
+ });
62
+ });
63
+
@@ -0,0 +1,92 @@
1
+ /**
2
+ * What the harness can be asked to do, independent of how it was asked.
3
+ *
4
+ * The daemon grew as an HTTP server, so every operation was written against
5
+ * `IncomingMessage`/`ServerResponse`: parsing a body, choosing a status
6
+ * code, and writing a reply were tangled up with deciding what to actually
7
+ * do. That is the coupling this module removes. A method here takes plain
8
+ * params and returns a plain object; a *transport* — the legacy HTTP router,
9
+ * or the supervisor's frame protocol — adapts to it.
10
+ *
11
+ * The point is that both transports run the **same** implementation. During
12
+ * the cutover the two are live at once, and the failure this design refuses
13
+ * to allow is the one where a fix lands on the transport that is being
14
+ * replaced and quietly doesn't reach the new one.
15
+ *
16
+ * ## Errors carry a kind, not a status code
17
+ *
18
+ * HTTP status codes were the daemon's error vocabulary: 400 for a bad body,
19
+ * 404 for an unknown case, 409 for "already running". The frame protocol has
20
+ * no statuses, so the kind is named ({@link HarnessErrorCode}) and each
21
+ * transport renders it — the frame protocol carries it as the error's
22
+ * `code`. Naming them also makes the one that matters legible: `conflict` is
23
+ * not a client mistake, it is the harness refusing to run two tests in one
24
+ * process at once, which is a correctness property rather than a validation
25
+ * failure.
26
+ */
27
+
28
+ /** The kinds of failure a method can report. */
29
+ export type HarnessErrorCode =
30
+ /** The request itself was malformed — missing or wrong-typed params. */
31
+ | "bad_request"
32
+ /** The thing named does not exist here (an unknown case id). */
33
+ | "not_found"
34
+ /** Refused because something incompatible is already running. */
35
+ | "conflict"
36
+ /** The method ran and threw. */
37
+ | "handler_error";
38
+
39
+ /** An error a transport can render without knowing what failed. */
40
+ export class HarnessError extends Error {
41
+ readonly code: HarnessErrorCode;
42
+
43
+ constructor(code: HarnessErrorCode, message: string) {
44
+ super(message);
45
+ this.name = "HarnessError";
46
+ this.code = code;
47
+ }
48
+ }
49
+
50
+ export const badRequest = (m: string) => new HarnessError("bad_request", m);
51
+ export const notFound = (m: string) => new HarnessError("not_found", m);
52
+ export const conflict = (m: string) => new HarnessError("conflict", m);
53
+
54
+ /** One operation: plain params in, plain result out. */
55
+ export type Method = (params: Record<string, unknown>) => Promise<unknown>;
56
+
57
+ /** Everything the harness can serve, by method name. */
58
+ export type MethodTable = Record<string, Method>;
59
+
60
+ /** How a transport that speaks HTTP renders an error kind. */
61
+ function unusedHttpStatusFor(code: HarnessErrorCode): number {
62
+ switch (code) {
63
+ case "bad_request":
64
+ return 400;
65
+ case "not_found":
66
+ return 404;
67
+ case "conflict":
68
+ return 409;
69
+ case "handler_error":
70
+ return 500;
71
+ }
72
+ }
73
+
74
+ /** The kind to report for a value thrown from a method body. Anything that
75
+ * isn't already a {@link HarnessError} is an unexpected failure inside the
76
+ * method, not a statement about the request. */
77
+ export function codeOf(err: unknown): HarnessErrorCode {
78
+ return err instanceof HarnessError ? err.code : "handler_error";
79
+ }
80
+
81
+ /** Require a non-empty string param, with the message the caller had. */
82
+ export function requireString(
83
+ params: Record<string, unknown>,
84
+ name: string,
85
+ label = `${name} (string)`,
86
+ ): string {
87
+ const v = params[name];
88
+ if (typeof v !== "string" || v.length === 0) {
89
+ throw badRequest(`${label} is required`);
90
+ }
91
+ return v;
92
+ }
@@ -0,0 +1,137 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ decodeRegistry,
5
+ encodeRegistry,
6
+ lookup,
7
+ suffixOf,
8
+ upsert,
9
+ type RegistryDoc,
10
+ } from "./names-registry";
11
+
12
+ const doc = (d: Partial<RegistryDoc> = {}): RegistryDoc => ({
13
+ hosts: {},
14
+ wildcards: [],
15
+ ...d,
16
+ });
17
+
18
+ describe("encode/decode round-trip", () => {
19
+ test("survives a round-trip", () => {
20
+ const d = doc({
21
+ hosts: { "api.stripe.com": "10.42.0.1" },
22
+ wildcards: [{ suffix: ".example.com", ip: "10.42.0.2" }],
23
+ });
24
+ const back = decodeRegistry(encodeRegistry(d, 123));
25
+ expect(back.hosts).toEqual(d.hosts);
26
+ expect(back.wildcards).toEqual(d.wildcards);
27
+ expect(back.updatedAt).toBe(123);
28
+ });
29
+
30
+ /** The resolver reads this on a hot path while the harness may be
31
+ * rewriting it. Throwing would take out DNS for the WHOLE environment,
32
+ * not just the name being added; degrading to "no custom names" is
33
+ * recoverable and the next read restores everything. */
34
+ test("a truncated file yields an empty registry, not an exception", () => {
35
+ expect(() => decodeRegistry('{"hosts":{"a":')).not.toThrow();
36
+ expect(decodeRegistry('{"hosts":{"a":')).toEqual({ hosts: {}, wildcards: [] });
37
+ });
38
+
39
+ test("garbage and empty input are both survivable", () => {
40
+ expect(decodeRegistry("")).toEqual({ hosts: {}, wildcards: [] });
41
+ expect(decodeRegistry("null")).toEqual({ hosts: {}, wildcards: [] });
42
+ expect(decodeRegistry("[]")).toEqual({ hosts: {}, wildcards: [] });
43
+ });
44
+
45
+ test("malformed wildcard entries are dropped, not propagated", () => {
46
+ const parsed = decodeRegistry(
47
+ JSON.stringify({ hosts: {}, wildcards: [{ suffix: ".ok.com", ip: "1.2.3.4" }, { suffix: 5 }, null] }),
48
+ );
49
+ expect(parsed.wildcards).toEqual([{ suffix: ".ok.com", ip: "1.2.3.4" }]);
50
+ });
51
+ });
52
+
53
+ describe("lookup precedence", () => {
54
+ test("an exact name wins over a wildcard that also matches", () => {
55
+ const d = doc({
56
+ hosts: { "api.example.com": "10.0.0.1" },
57
+ wildcards: [{ suffix: ".example.com", ip: "10.0.0.2" }],
58
+ });
59
+ expect(lookup(d, "api.example.com")).toBe("10.0.0.1");
60
+ });
61
+
62
+ test("the longest matching suffix wins", () => {
63
+ const d = doc({
64
+ wildcards: [
65
+ { suffix: ".example.com", ip: "10.0.0.1" },
66
+ { suffix: ".eu.example.com", ip: "10.0.0.2" },
67
+ ],
68
+ });
69
+ expect(lookup(d, "a.eu.example.com")).toBe("10.0.0.2");
70
+ expect(lookup(d, "a.us.example.com")).toBe("10.0.0.1");
71
+ });
72
+
73
+ test("order in the file does not decide the winner", () => {
74
+ const a = doc({
75
+ wildcards: [
76
+ { suffix: ".eu.example.com", ip: "long" },
77
+ { suffix: ".example.com", ip: "short" },
78
+ ],
79
+ });
80
+ const b = doc({ wildcards: [...a.wildcards].reverse() });
81
+ expect(lookup(a, "x.eu.example.com")).toBe("long");
82
+ expect(lookup(b, "x.eu.example.com")).toBe("long");
83
+ });
84
+
85
+ /** A wildcard here matches any depth — unlike a certificate, which
86
+ * covers exactly one label. Same asymmetry as the ingress routes. */
87
+ test("a wildcard matches any depth of subdomain", () => {
88
+ const d = doc({ wildcards: [{ suffix: ".amazonaws.com", ip: "10.0.0.9" }] });
89
+ expect(lookup(d, "states.us-east-1.amazonaws.com")).toBe("10.0.0.9");
90
+ });
91
+
92
+ test("an unmatched name yields null, so the caller falls through to DNS", () => {
93
+ expect(lookup(doc(), "nothing.test")).toBeNull();
94
+ });
95
+
96
+ test("a name that merely ends similarly does not match", () => {
97
+ const d = doc({ wildcards: [{ suffix: ".example.com", ip: "10.0.0.1" }] });
98
+ // ".example.com" does not suffix-match "notexample.com".
99
+ expect(lookup(d, "notexample.com")).toBeNull();
100
+ });
101
+ });
102
+
103
+ describe("upsert", () => {
104
+ test("adds an exact host", () => {
105
+ expect(upsert(doc(), "a.test", "1.1.1.1").hosts["a.test"]).toBe("1.1.1.1");
106
+ });
107
+
108
+ test("re-registering an exact host replaces it", () => {
109
+ let d = upsert(doc(), "a.test", "1.1.1.1");
110
+ d = upsert(d, "a.test", "2.2.2.2");
111
+ expect(d.hosts["a.test"]).toBe("2.2.2.2");
112
+ expect(Object.keys(d.hosts)).toHaveLength(1);
113
+ });
114
+
115
+ test("stores a wildcard by its suffix form", () => {
116
+ const d = upsert(doc(), "*.example.com", "1.1.1.1");
117
+ expect(d.wildcards).toEqual([{ suffix: ".example.com", ip: "1.1.1.1" }]);
118
+ expect(suffixOf("*.example.com")).toBe(".example.com");
119
+ });
120
+
121
+ /** Re-registering must replace, not append: ctx.dnsName allows it and a
122
+ * re-run setup hook does it, so appending would grow the list without
123
+ * bound and leave two entries racing on equal suffix length. */
124
+ test("re-registering a wildcard replaces rather than appends", () => {
125
+ let d = upsert(doc(), "*.example.com", "1.1.1.1");
126
+ d = upsert(d, "*.example.com", "2.2.2.2");
127
+ expect(d.wildcards).toHaveLength(1);
128
+ expect(lookup(d, "x.example.com")).toBe("2.2.2.2");
129
+ });
130
+
131
+ test("exact and wildcard registrations coexist", () => {
132
+ let d = upsert(doc(), "*.example.com", "1.1.1.1");
133
+ d = upsert(d, "api.example.com", "3.3.3.3");
134
+ expect(lookup(d, "api.example.com")).toBe("3.3.3.3");
135
+ expect(lookup(d, "other.example.com")).toBe("1.1.1.1");
136
+ });
137
+ });
@@ -0,0 +1,108 @@
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.
20
+ */
21
+
22
+ export interface RegistryDoc {
23
+ /** Exact name → IP. */
24
+ hosts: Record<string, string>;
25
+ /** Suffix (with leading dot) → IP, longest match wins. */
26
+ wildcards: Array<{ suffix: string; ip: string }>;
27
+ /** Written by the harness; the resolver re-reads on mtime change, so
28
+ * this is diagnostic rather than load-bearing. */
29
+ updatedAt?: number;
30
+ }
31
+
32
+ export const EMPTY_REGISTRY: RegistryDoc = { hosts: {}, wildcards: [] };
33
+
34
+ /** Serialise for the resolver. */
35
+ export function encodeRegistry(doc: RegistryDoc, now: number): string {
36
+ return JSON.stringify({
37
+ hosts: doc.hosts,
38
+ wildcards: doc.wildcards,
39
+ updatedAt: now,
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Parse a registry file.
45
+ *
46
+ * Deliberately total: a truncated or malformed file yields an **empty**
47
+ * registry rather than throwing. The resolver reads this on a hot path
48
+ * while the harness may be rewriting it, and a parse error there would
49
+ * take out DNS for the whole environment — every name, not just the one
50
+ * being added. Degrading to "no custom names" is recoverable; the next
51
+ * successful read restores everything.
52
+ */
53
+ export function decodeRegistry(text: string): RegistryDoc {
54
+ try {
55
+ const raw = JSON.parse(text) as Partial<RegistryDoc>;
56
+ const hosts =
57
+ raw.hosts && typeof raw.hosts === "object" ? (raw.hosts as Record<string, string>) : {};
58
+ const wildcards = Array.isArray(raw.wildcards)
59
+ ? raw.wildcards.filter(
60
+ (w): w is { suffix: string; ip: string } =>
61
+ !!w && typeof w.suffix === "string" && typeof w.ip === "string",
62
+ )
63
+ : [];
64
+ return { hosts, wildcards, updatedAt: raw.updatedAt };
65
+ } catch {
66
+ return { hosts: {}, wildcards: [] };
67
+ }
68
+ }
69
+
70
+ /** `"*.example.com"` → `".example.com"`, the form stored in `wildcards`. */
71
+ export function suffixOf(pattern: string): string {
72
+ return pattern.startsWith("*.") ? pattern.slice(1) : pattern;
73
+ }
74
+
75
+ /**
76
+ * Resolve a name: exact entry first, then the longest matching wildcard
77
+ * suffix. Returns `null` when nothing matches, which is the resolver's
78
+ * signal to fall through to Docker's DNS and then upstream.
79
+ */
80
+ export function lookup(doc: RegistryDoc, name: string): string | null {
81
+ const exact = doc.hosts[name];
82
+ if (exact) return exact;
83
+ let best: { suffix: string; ip: string } | null = null;
84
+ for (const w of doc.wildcards) {
85
+ if (name.endsWith(w.suffix) && (!best || w.suffix.length > best.suffix.length)) {
86
+ best = w;
87
+ }
88
+ }
89
+ return best?.ip ?? null;
90
+ }
91
+
92
+ /**
93
+ * Add or replace a name.
94
+ *
95
+ * A wildcard replaces any existing entry for the same suffix rather than
96
+ * appending, so re-registering a name (which `ctx.dnsName` allows, and
97
+ * which a re-run of a `setup` hook does) cannot grow the list without
98
+ * bound or leave two entries racing on equal suffix length.
99
+ */
100
+ export function upsert(doc: RegistryDoc, hostname: string, ip: string): RegistryDoc {
101
+ if (hostname.startsWith("*.")) {
102
+ const suffix = suffixOf(hostname);
103
+ const wildcards = doc.wildcards.filter((w) => w.suffix !== suffix);
104
+ wildcards.push({ suffix, ip });
105
+ return { ...doc, wildcards };
106
+ }
107
+ return { ...doc, hosts: { ...doc.hosts, [hostname]: ip } };
108
+ }