@specific.dev/spectest 0.38.0 → 0.41.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 (103) hide show
  1. package/dist/components/k3s.js +1 -24
  2. package/dist/components/supabase.d.ts +87 -27
  3. package/dist/components/supabase.js +352 -69
  4. package/dist/daemon.d.ts +38 -0
  5. package/dist/daemon.js +405 -946
  6. package/dist/harness/build-context.d.ts +82 -0
  7. package/dist/harness/build-context.js +113 -0
  8. package/dist/harness/buildkit-progress.d.ts +37 -0
  9. package/dist/harness/buildkit-progress.js +66 -0
  10. package/dist/harness/container-run.d.ts +89 -0
  11. package/dist/harness/container-run.js +118 -0
  12. package/dist/harness/file-mounts.d.ts +91 -0
  13. package/dist/harness/file-mounts.js +119 -0
  14. package/dist/harness/hostmatch.d.ts +65 -0
  15. package/dist/harness/hostmatch.js +108 -0
  16. package/dist/harness/http-proxy.d.ts +62 -0
  17. package/dist/harness/http-proxy.js +104 -0
  18. package/dist/harness/ingress-table.d.ts +148 -0
  19. package/dist/harness/ingress-table.js +129 -0
  20. package/dist/harness/log-delta.d.ts +54 -0
  21. package/dist/harness/log-delta.js +83 -0
  22. package/dist/harness/main.d.ts +47 -0
  23. package/dist/harness/main.js +164 -0
  24. package/dist/harness/methods.d.ts +54 -0
  25. package/dist/harness/methods.js +65 -0
  26. package/dist/harness/names-registry.d.ts +63 -0
  27. package/dist/harness/names-registry.js +90 -0
  28. package/dist/harness/protocol.d.ts +88 -0
  29. package/dist/harness/protocol.js +96 -0
  30. package/dist/harness/ready-poll.d.ts +47 -0
  31. package/dist/harness/ready-poll.js +67 -0
  32. package/dist/harness/service-graph.d.ts +29 -0
  33. package/dist/harness/service-graph.js +92 -0
  34. package/dist/harness/volume-paths.d.ts +70 -0
  35. package/dist/harness/volume-paths.js +81 -0
  36. package/dist/index.d.ts +3 -3
  37. package/dist/ingress.d.ts +1 -1
  38. package/dist/inspect.d.ts +23 -0
  39. package/dist/inspect.js +65 -0
  40. package/dist/resolver.js +5 -8
  41. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  42. package/dist/vendor/rrweb-record.min.js +5061 -0
  43. package/package.json +7 -1
  44. package/src/aws-sigv4.ts +218 -0
  45. package/src/browser.ts +2040 -0
  46. package/src/components/aws.ts +554 -0
  47. package/src/components/email.ts +398 -0
  48. package/src/components/expo.ts +167 -0
  49. package/src/components/index.ts +81 -0
  50. package/src/components/k3s.ts +2061 -0
  51. package/src/components/postgres.ts +132 -0
  52. package/src/components/replayFake.ts +1015 -0
  53. package/src/components/s3.ts +132 -0
  54. package/src/components/supabase.ts +1699 -0
  55. package/src/daemon.ts +5489 -0
  56. package/src/harness/build-context.test.ts +0 -0
  57. package/src/harness/build-context.ts +146 -0
  58. package/src/harness/buildkit-progress.test.ts +98 -0
  59. package/src/harness/buildkit-progress.ts +74 -0
  60. package/src/harness/container-run.test.ts +209 -0
  61. package/src/harness/container-run.ts +158 -0
  62. package/src/harness/file-mounts.test.ts +185 -0
  63. package/src/harness/file-mounts.ts +145 -0
  64. package/src/harness/hostmatch.test.ts +148 -0
  65. package/src/harness/hostmatch.ts +109 -0
  66. package/src/harness/http-proxy.test.ts +156 -0
  67. package/src/harness/http-proxy.ts +119 -0
  68. package/src/harness/ingress-rebind.test.ts +125 -0
  69. package/src/harness/ingress-table.test.ts +172 -0
  70. package/src/harness/ingress-table.ts +186 -0
  71. package/src/harness/log-delta.test.ts +125 -0
  72. package/src/harness/log-delta.ts +100 -0
  73. package/src/harness/main.test.ts +211 -0
  74. package/src/harness/main.ts +196 -0
  75. package/src/harness/methods.test.ts +63 -0
  76. package/src/harness/methods.ts +92 -0
  77. package/src/harness/names-registry.test.ts +137 -0
  78. package/src/harness/names-registry.ts +108 -0
  79. package/src/harness/protocol.test.ts +148 -0
  80. package/src/harness/protocol.ts +163 -0
  81. package/src/harness/ready-poll.test.ts +172 -0
  82. package/src/harness/ready-poll.ts +93 -0
  83. package/src/harness/service-graph.test.ts +97 -0
  84. package/src/harness/service-graph.ts +97 -0
  85. package/src/harness/volume-paths.test.ts +102 -0
  86. package/src/harness/volume-paths.ts +112 -0
  87. package/src/ids.ts +89 -0
  88. package/src/index.ts +2725 -0
  89. package/src/ingress.ts +305 -0
  90. package/src/inspect.ts +739 -0
  91. package/src/locator.ts +716 -0
  92. package/src/mobile.ts +133 -0
  93. package/src/record-secrets.ts +41 -0
  94. package/src/recorder.ts +846 -0
  95. package/src/redis.ts +202 -0
  96. package/src/replay-bundle.ts +108 -0
  97. package/src/resolver.ts +348 -0
  98. package/src/s3.ts +333 -0
  99. package/src/sql.ts +243 -0
  100. package/src/terminal.ts +740 -0
  101. package/src/url-match.ts +67 -0
  102. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  103. package/src/vendor/rrweb-record.min.js +5061 -0
package/src/ingress.ts ADDED
@@ -0,0 +1,305 @@
1
+ // Low-level ingress primitives — the first-class building blocks the
2
+ // daemon's networking is expressed in terms of. The friendly
3
+ // `services.<name>.tls` / `services.<name>.hostnames` fields and
4
+ // `defineFake(...)` are all *sugar* that lower onto these three:
5
+ //
6
+ // certificate(hostnames) — mint a leaf cert from the in-VM root CA
7
+ // dnsName(hostname, target) — register a name → a container or the daemon
8
+ // proxy(hostname, upstream) — reverse-proxy a hostname to a service:port
9
+ //
10
+ // The daemon consumes only the lowered result (see `lowerIngress`), so it
11
+ // never special-cases tls/hostnames/fakes — it just executes a generic set
12
+ // of certs, proxy routes, DNS registrations, and aliases. A user-authored
13
+ // component emits the same primitives by attaching them with `provides(...)`,
14
+ // so it can do anything the built-ins do without any daemon change.
15
+ //
16
+ // None of this is part of the wire `EnvironmentConfig` the control plane
17
+ // deserialises: the decls live on the `Project` (daemon-side only) and,
18
+ // when attached to a service, ride on a Symbol key that `JSON.stringify`
19
+ // drops — so Rust's `deny_unknown_fields` never sees them.
20
+
21
+ import type { FakeDefinition, Project, ServiceConfig } from "./index.js";
22
+
23
+ /** Where a DNS name points. `ingress` = the harness's listener on the
24
+ * bridge gateway (fakes, TLS-terminated proxies); `service` = a container's
25
+ * live IP on spectest-net (a plain peer alias, no daemon hop). */
26
+ export type DnsTarget = { ingress: true } | { service: string };
27
+
28
+ export interface CertificateDecl {
29
+ kind: "certificate";
30
+ /** Hostnames the leaf cert's SANs cover. One cert is minted per decl. */
31
+ hostnames: string[];
32
+ }
33
+
34
+ export interface DnsDecl {
35
+ kind: "dns";
36
+ hostname: string;
37
+ target: DnsTarget;
38
+ }
39
+
40
+ export interface ProxyDecl {
41
+ kind: "proxy";
42
+ hostname: string;
43
+ upstream: { service: string; port: number };
44
+ }
45
+
46
+ export type IngressDecl = CertificateDecl | DnsDecl | ProxyDecl;
47
+
48
+ /** Token a component can use in a decl where it can't know its own
49
+ * services-map key yet — resolved to that key during `lowerIngress`.
50
+ * Mirrors the `{{SPECTEST_SERVICE}}` token honoured in `files`. */
51
+ export const SELF_SERVICE_TOKEN = "{{SPECTEST_SERVICE}}";
52
+
53
+ // Multi-label hostname: at least one dot, each label 1–63 chars of
54
+ // [a-z0-9-], no leading/trailing hyphen. Kept in lockstep with index.ts.
55
+ const HOSTNAME_RE =
56
+ /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
57
+
58
+ function assertHostname(h: string, ctx: string): void {
59
+ const lower = h.toLowerCase();
60
+ if (!HOSTNAME_RE.test(lower)) {
61
+ throw new Error(
62
+ `${ctx}: invalid hostname ${JSON.stringify(h)} — must be a multi-label DNS name (e.g. "api.stripe.com")`,
63
+ );
64
+ }
65
+ if (lower === "internal" || lower.endsWith(".internal")) {
66
+ throw new Error(
67
+ `${ctx}: hostname ${JSON.stringify(h)} ends in the reserved ".internal" TLD`,
68
+ );
69
+ }
70
+ }
71
+
72
+ /** A wildcard is `*.` + a normal multi-label hostname (e.g.
73
+ * `*.example.com`). It matches any name ending in that suffix. */
74
+ export function isWildcard(hostname: string): boolean {
75
+ return hostname.startsWith("*.");
76
+ }
77
+
78
+ /** Validate an exact hostname OR a `*.suffix` wildcard. */
79
+ function assertNameOrWildcard(h: string, ctx: string): void {
80
+ if (isWildcard(h)) {
81
+ // The bit after `*.` must itself be a valid multi-label hostname, so
82
+ // `*.example.com` is fine but `*.com` (too broad) and `*` are not.
83
+ assertHostname(h.slice(2), `${ctx} wildcard`);
84
+ return;
85
+ }
86
+ assertHostname(h, ctx);
87
+ }
88
+
89
+ /**
90
+ * Request a leaf certificate (signed by the in-VM root CA) covering
91
+ * `hostnames`. The daemon binds it on the HTTPS ingress (:443, SNI per
92
+ * hostname). Pairs with a `proxy(...)` (TLS-terminated reverse proxy) or a
93
+ * fake handler; on its own it just makes those hostnames serve HTTPS.
94
+ *
95
+ * A `*.suffix` wildcard is allowed and becomes a wildcard SAN, so one cert
96
+ * covers a whole domain (`*.us-east-1.amazonaws.com`). As in any TLS
97
+ * client, a wildcard SAN covers exactly **one** label — `*.example.com`
98
+ * matches `api.example.com`, not `a.b.example.com`.
99
+ */
100
+ export function certificate(hostnames: string[]): CertificateDecl {
101
+ if (!Array.isArray(hostnames) || hostnames.length === 0) {
102
+ throw new Error("certificate(): at least one hostname is required");
103
+ }
104
+ for (const h of hostnames) assertNameOrWildcard(h, "certificate()");
105
+ return { kind: "certificate", hostnames: hostnames.map((h) => h.toLowerCase()) };
106
+ }
107
+
108
+ /**
109
+ * Register `hostname` in spectest's DNS. `{ ingress: true }` points it at
110
+ * the daemon (for fakes / TLS proxies — resolved to the bridge gateway and
111
+ * injected as `--add-host` into every container); `{ service }` makes it an
112
+ * extra peer alias for that container (resolved live to its IP). The
113
+ * `SELF_SERVICE_TOKEN` may be used for `service` when a component can't yet
114
+ * know its own key.
115
+ */
116
+ export function dnsName(hostname: string, target: DnsTarget): DnsDecl {
117
+ assertNameOrWildcard(hostname, "dnsName()");
118
+ if (!("ingress" in target) && !("service" in target)) {
119
+ throw new Error(
120
+ `dnsName(${JSON.stringify(hostname)}): target must be { ingress: true } or { service }`,
121
+ );
122
+ }
123
+ return { kind: "dns", hostname: hostname.toLowerCase(), target };
124
+ }
125
+
126
+ /**
127
+ * Reverse-proxy `hostname` to `http://<service>:<port>` on spectest-net.
128
+ * Implies the hostname resolves to the daemon ingress, so containers reach
129
+ * it without any extra `dnsName(...)`. Add a `certificate([hostname])` to
130
+ * serve it over HTTPS as well as HTTP.
131
+ *
132
+ * `hostname` may be a `*.suffix` wildcard, which sends every name under
133
+ * that domain to the same upstream — one route for a whole API surface
134
+ * (`*.us-east-1.amazonaws.com` → a LocalStack container). Exact routes
135
+ * always win over wildcards, and the longest wildcard suffix wins among
136
+ * wildcards.
137
+ */
138
+ export function proxy(
139
+ hostname: string,
140
+ upstream: { service: string; port: number },
141
+ ): ProxyDecl {
142
+ assertNameOrWildcard(hostname, "proxy()");
143
+ if (!upstream || typeof upstream.service !== "string" || typeof upstream.port !== "number") {
144
+ throw new Error(
145
+ `proxy(${JSON.stringify(hostname)}): upstream must be { service: string, port: number }`,
146
+ );
147
+ }
148
+ return { kind: "proxy", hostname: hostname.toLowerCase(), upstream };
149
+ }
150
+
151
+ /** Symbol key under which `provides(...)` stashes a component's ingress
152
+ * decls on its service object. A Symbol so `JSON.stringify(environment)`
153
+ * (the wire config shipped to the Rust control plane) drops it — symbol
154
+ * keys are never serialised, regardless of enumerability. Kept
155
+ * *enumerable* so it survives the common `{ ...component() }` spread (object
156
+ * spread copies enumerable symbol props but skips non-enumerable ones). */
157
+ const INGRESS_DECLS: unique symbol = Symbol.for("spectest.ingress.decls");
158
+
159
+ /**
160
+ * Attach low-level ingress decls to a service definition a component
161
+ * returns. The decls are read by `lowerIngress` at load time and never
162
+ * serialised into the wire config.
163
+ *
164
+ * ```ts
165
+ * return provides({ image, command }, [
166
+ * certificate(["app.test"]),
167
+ * dnsName("app.test", { ingress: true }),
168
+ * proxy("app.test", { service: SELF_SERVICE_TOKEN, port: 3000 }),
169
+ * ]);
170
+ * ```
171
+ */
172
+ export function provides<T extends ServiceConfig>(
173
+ service: T,
174
+ decls: IngressDecl[],
175
+ ): T {
176
+ // Merge with any decls already attached (e.g. a component built via
177
+ // provides() that the caller then wraps again) so neither layer is lost.
178
+ const prior = readProvided(service);
179
+ Object.defineProperty(service, INGRESS_DECLS, {
180
+ value: [...prior, ...decls],
181
+ enumerable: true,
182
+ configurable: true,
183
+ writable: true,
184
+ });
185
+ return service;
186
+ }
187
+
188
+ function readProvided(service: ServiceConfig): IngressDecl[] {
189
+ const decls = (service as unknown as Record<symbol, unknown>)[INGRESS_DECLS];
190
+ return Array.isArray(decls) ? (decls as IngressDecl[]) : [];
191
+ }
192
+
193
+ /** Everything the daemon needs to stand up ingress, derived once per
194
+ * /load. The daemon executes this generically — it reads neither
195
+ * `svc.tls` nor `svc.hostnames` directly. */
196
+ export interface LoweredIngress {
197
+ /** One leaf cert per group; SANs = the group's hostnames. */
198
+ certificates: { hostnames: string[] }[];
199
+ /** Reverse-proxy routes: hostname → upstream service:port. */
200
+ proxies: { hostname: string; service: string; port: number }[];
201
+ /** Hostnames that resolve to the daemon ingress gateway (DNS registry
202
+ * + `--add-host` on every container). Covers fakes, TLS proxies, and
203
+ * any `dnsName(h, { ingress: true })`. */
204
+ ingressHosts: string[];
205
+ /** service key → extra `--network-alias`es (from `hostnames` /
206
+ * `dnsName(h, { service })`). */
207
+ aliasesByService: Record<string, string[]>;
208
+ /** Wildcard suffixes (`*.example.com` → `.example.com`) and where they
209
+ * point. Resolved to a concrete IP in the daemon (service → that
210
+ * container's IP, ingress → the bridge gateway) and written to the
211
+ * resolver's wildcard table. Only the resolver answers these — they
212
+ * can't be expressed as `--network-alias`/`--add-host`. */
213
+ wildcards: { pattern: string; target: DnsTarget }[];
214
+ }
215
+
216
+ function resolveSelf(service: string, selfKey: string): string {
217
+ return service === SELF_SERVICE_TOKEN ? selfKey : service;
218
+ }
219
+
220
+ /**
221
+ * Lower a project's friendly surface (`services.*.tls`,
222
+ * `services.*.hostnames`, component `provides(...)`, and `fakes`) into the
223
+ * generic `LoweredIngress` the daemon executes. This is the framework
224
+ * abstraction the user asked for: the special-casing lives here, in one
225
+ * pure function, instead of being scattered through the daemon.
226
+ */
227
+ export function lowerIngress(project: Project): LoweredIngress {
228
+ const certificates: { hostnames: string[] }[] = [];
229
+ const proxies: { hostname: string; service: string; port: number }[] = [];
230
+ const ingressSet = new Set<string>();
231
+ const aliasesByService: Record<string, string[]> = {};
232
+ const wildcards: { pattern: string; target: DnsTarget }[] = [];
233
+
234
+ const addAlias = (service: string, host: string): void => {
235
+ (aliasesByService[service] ??= []).push(host.toLowerCase());
236
+ };
237
+ const applyDecl = (decl: IngressDecl, selfKey: string): void => {
238
+ switch (decl.kind) {
239
+ case "certificate":
240
+ certificates.push({ hostnames: decl.hostnames });
241
+ break;
242
+ case "proxy": {
243
+ const service = resolveSelf(decl.upstream.service, selfKey);
244
+ proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port });
245
+ // A proxied hostname must route to the daemon. A wildcard one can
246
+ // only be answered by the resolver (there is no `--add-host` for a
247
+ // pattern), so it goes in the wildcard table pointed at the ingress.
248
+ if (isWildcard(decl.hostname)) {
249
+ wildcards.push({ pattern: decl.hostname, target: { ingress: true } });
250
+ } else {
251
+ ingressSet.add(decl.hostname);
252
+ }
253
+ break;
254
+ }
255
+ case "dns": {
256
+ // A wildcard can only be answered by the resolver (no
257
+ // --network-alias / --add-host equivalent), so route either kind
258
+ // of target through the wildcard table; the daemon resolves the IP.
259
+ if (isWildcard(decl.hostname)) {
260
+ const target =
261
+ "service" in decl.target
262
+ ? { service: resolveSelf(decl.target.service, selfKey) }
263
+ : decl.target;
264
+ wildcards.push({ pattern: decl.hostname, target });
265
+ } else if ("ingress" in decl.target) {
266
+ ingressSet.add(decl.hostname);
267
+ } else {
268
+ addAlias(resolveSelf(decl.target.service, selfKey), decl.hostname);
269
+ }
270
+ break;
271
+ }
272
+ }
273
+ };
274
+
275
+ for (const [name, svc] of Object.entries(project.environment.services)) {
276
+ // Component-provided explicit decls.
277
+ for (const decl of readProvided(svc)) applyDecl(decl, name);
278
+ // `tls: [{ hostname, port }]` → cert + TLS-terminated reverse proxy.
279
+ for (const entry of (svc as ServiceConfig).tls ?? []) {
280
+ applyDecl(certificate([entry.hostname]), name);
281
+ applyDecl(proxy(entry.hostname, { service: name, port: entry.port }), name);
282
+ }
283
+ // `hostnames: [h]` → plain peer alias (no daemon hop).
284
+ for (const h of (svc as ServiceConfig).hostnames ?? []) {
285
+ applyDecl(dnsName(h, { service: name }), name);
286
+ }
287
+ }
288
+
289
+ // Fakes: in-daemon handlers. The handler routing stays keyed by hostname
290
+ // in the daemon's FAKES map; here we only contribute their networking
291
+ // (a leaf cert for HTTPS + ingress DNS).
292
+ for (const fake of Object.values(project.fakes ?? {}) as FakeDefinition[]) {
293
+ const hostnames = fake.hostnames.map((h) => h.toLowerCase());
294
+ certificates.push({ hostnames });
295
+ for (const h of hostnames) ingressSet.add(h);
296
+ }
297
+
298
+ return {
299
+ certificates,
300
+ proxies,
301
+ ingressHosts: [...ingressSet],
302
+ aliasesByService,
303
+ wildcards,
304
+ };
305
+ }