@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
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Staging `files` and `certificates` for bind-mounting into a container.
3
+ *
4
+ * Ported out of `daemon.ts` as part of the harness split. These are the
5
+ * *pre-entrypoint* injection point — the only way to put something in place
6
+ * before the container's own process starts, which is what a config file a
7
+ * daemon reads at startup, or a TLS key it refuses to run without, actually
8
+ * needs. `setup` and `helpers` both run too late.
9
+ *
10
+ * What lives here is the part that decides *what* is staged and with which
11
+ * permissions. Writing the bytes, minting the certificate and running chown
12
+ * stay with the caller — but every rule that has ever been reported as a bug
13
+ * is a pure function below, with a test.
14
+ *
15
+ * ## The permission rules, and why they are not obvious
16
+ *
17
+ * **A bind mount carries the host inode's mode and ownership straight
18
+ * through, and the harness writes as root.** So `mode` on its own is not
19
+ * enough: locking a file down to `0600` gives the container a file owned by
20
+ * root that whoever it runs as cannot open. Postgres refusing to start on
21
+ * its own TLS key is the canonical report. `user`/`group` exist to name the
22
+ * reader, and naming one is what makes a strict mode usable.
23
+ *
24
+ * **Declaring a reader also implies the mode.** A key whose owner is named
25
+ * gets the strictest mode that owner can still open — `0600` when a user is
26
+ * named, `0640` when only a group is. A server that checks (postgres, ssh)
27
+ * refuses a lax key, so defaulting to something permissive would just move
28
+ * the failure. This only applies when an owner was named, so no existing
29
+ * environment changes behaviour.
30
+ *
31
+ * **`-1` means "leave this half alone", and Bun will not accept it.**
32
+ * chown(2) and node both read `-1` as unchanged, which is how plain `chown`
33
+ * lets you set a user without touching the group. Bun's `fs.chown` rejects
34
+ * it with `EPERM` (measured on Bun 1.3.14), so the untouched half has to be
35
+ * filled in from the file's current owner before the call —
36
+ * {@link resolveChownIds} is that, kept separate so the rule is testable
37
+ * without a filesystem.
38
+ */
39
+ /** `{{SPECTEST_SERVICE}}` → the service's map key.
40
+ *
41
+ * Lets a component author self-referential config without knowing the key
42
+ * the user will choose for it — k3s's `registries.yaml` pointing at
43
+ * `<key>.internal:5000` is the reason this exists. */
44
+ export function expandServiceToken(text, service) {
45
+ return text.replaceAll("{{SPECTEST_SERVICE}}", service);
46
+ }
47
+ /** Parse a `user`/`group` value that may be a numeric id already. */
48
+ export function numericId(value) {
49
+ return value !== undefined && /^[0-9]+$/.test(value) ? Number(value) : undefined;
50
+ }
51
+ /**
52
+ * Does resolving this owner need the image's `/etc/passwd` + `/etc/group`?
53
+ *
54
+ * Probing an image costs a `docker create` + `docker cp`, so it is worth
55
+ * knowing that two numeric ids need no probe at all.
56
+ */
57
+ export function needsIdTables(user, group) {
58
+ return ((user !== undefined && numericId(user) === undefined) ||
59
+ (group !== undefined && numericId(group) === undefined));
60
+ }
61
+ /**
62
+ * The mode a certificate's key should get.
63
+ *
64
+ * `undefined` when no owner was named — the file keeps whatever the harness
65
+ * wrote, which is the pre-existing behaviour for every environment that
66
+ * never asked about ownership.
67
+ */
68
+ export function defaultKeyMode(explicit, owner) {
69
+ if (explicit)
70
+ return explicit;
71
+ if (!owner)
72
+ return undefined;
73
+ // Only a group was named, so the group has to be able to read it.
74
+ return owner.uid === -1 ? "0640" : "0600";
75
+ }
76
+ /**
77
+ * Fill in the halves of a chown that were meant to be left alone.
78
+ *
79
+ * Takes the file's current ids rather than reading them, so the rule is a
80
+ * pure function. See the module header for why `-1` cannot simply be passed
81
+ * through under Bun.
82
+ */
83
+ export function resolveChownIds(owner, current) {
84
+ return {
85
+ uid: owner.uid < 0 ? current.uid : owner.uid,
86
+ gid: owner.gid < 0 ? current.gid : owner.gid,
87
+ };
88
+ }
89
+ /** True when the owner asks for no change at all, so the chown can be
90
+ * skipped entirely rather than resolved and re-applied. */
91
+ export function isNoopChown(owner) {
92
+ return owner.uid < 0 && owner.gid < 0;
93
+ }
94
+ /**
95
+ * Reject a container path that isn't absolute.
96
+ *
97
+ * A relative path would be resolved by docker against the container's
98
+ * working directory, so the file would land somewhere that depends on the
99
+ * image rather than where the project said — a mount that appears to work
100
+ * and puts the file in the wrong place is worse than one that refuses.
101
+ */
102
+ export function assertAbsolute(service, label, p) {
103
+ if (!p.startsWith("/")) {
104
+ throw new Error(`service ${JSON.stringify(service)}: ${label} ${JSON.stringify(p)} must be absolute`);
105
+ }
106
+ }
107
+ /** The hostnames a certificate entry covers, with the service token
108
+ * expanded and the empty case refused. */
109
+ export function certificateHostnames(service, index, hostnames) {
110
+ const expanded = hostnames.map((h) => expandServiceToken(h, service));
111
+ if (expanded.length === 0) {
112
+ throw new Error(`service ${JSON.stringify(service)}: certificate entry ${index} lists no hostnames`);
113
+ }
114
+ return expanded;
115
+ }
116
+ /** A read-only single-file bind mount. */
117
+ export function mountFlag(hostPath, containerPath) {
118
+ return `--volume=${hostPath}:${containerPath}:ro`;
119
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Hostname matching for ingress: which route serves a request, and which
3
+ * certificate may serve a TLS handshake.
4
+ *
5
+ * Ported out of `daemon.ts` as part of the harness split. These are pure
6
+ * functions guarding two rules that are easy to state and easy to get
7
+ * subtly wrong, and until now they had no tests at all.
8
+ *
9
+ * ## The two rules
10
+ *
11
+ * **Precedence: exact first, then longest suffix.** A `*.example.com`
12
+ * route and an `api.example.com` route can both match `api.example.com`;
13
+ * the exact one wins. Among competing wildcards the longest suffix wins,
14
+ * so `*.eu.example.com` beats `*.example.com` for `a.eu.example.com`.
15
+ * The same ordering applies at all three layers — DNS in the resolver,
16
+ * routes here, and SNI in Bun's cert table.
17
+ *
18
+ * **Routes and certificates match differently, deliberately.** A wildcard
19
+ * *route* matches any depth of subdomain, but a wildcard *certificate*
20
+ * covers exactly one label — which is what RFC 6125 says and what every
21
+ * TLS client enforces. So `a.b.example.com` under `*.example.com` is
22
+ * reverse-proxied happily over http, and needs its own cert entry for
23
+ * https.
24
+ *
25
+ * That asymmetry is load-bearing, not an oversight. Loosening
26
+ * {@link wildcardCoversHost} to a plain suffix test would make
27
+ * {@link certCovers} report that a deep name is already covered, so the
28
+ * runtime-TLS path would skip minting the leaf that name actually needs
29
+ * and hand the client a certificate it rejects — a TLS failure at request
30
+ * time, far from the code that caused it.
31
+ */
32
+ export { isWildcard } from "../ingress";
33
+ /** `"*.example.com"` → `".example.com"`. */
34
+ export declare function wildcardSuffix(pattern: string): string;
35
+ /**
36
+ * Does a wildcard *certificate* pattern cover `hostname`?
37
+ *
38
+ * One label only (RFC 6125): `*.example.com` covers `api.example.com` and
39
+ * NOT `a.b.example.com`. See the module header for why this must not be
40
+ * relaxed into a suffix test.
41
+ */
42
+ export declare function wildcardCoversHost(pattern: string, hostname: string): boolean;
43
+ /**
44
+ * Is `hostname` already served by an exact or wildcard certificate?
45
+ *
46
+ * Takes the set of configured server names rather than reading module
47
+ * state, so the rule is testable on its own — the caller passes
48
+ * `HTTPS_CERT_BY_HOST.keys()`.
49
+ */
50
+ export declare function certCovers(serverNames: Iterable<string>, hostname: string): boolean;
51
+ /**
52
+ * Pick the route for `host`: exact match first, then the longest matching
53
+ * wildcard suffix.
54
+ *
55
+ * Unlike a certificate, a wildcard route matches any depth — `*.test`
56
+ * serves `a.b.test`.
57
+ */
58
+ export declare function matchRoute<T>(byHost: ReadonlyMap<string, T>, host: string): T | undefined;
59
+ /**
60
+ * Strip the port from a `Host` header value.
61
+ *
62
+ * IPv6 literals arrive bracketed (`[::1]:8080`), so a naive split on the
63
+ * last `:` would mangle them; the brackets are what disambiguate.
64
+ */
65
+ export declare function hostWithoutPort(hostHeader: string): string;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Hostname matching for ingress: which route serves a request, and which
3
+ * certificate may serve a TLS handshake.
4
+ *
5
+ * Ported out of `daemon.ts` as part of the harness split. These are pure
6
+ * functions guarding two rules that are easy to state and easy to get
7
+ * subtly wrong, and until now they had no tests at all.
8
+ *
9
+ * ## The two rules
10
+ *
11
+ * **Precedence: exact first, then longest suffix.** A `*.example.com`
12
+ * route and an `api.example.com` route can both match `api.example.com`;
13
+ * the exact one wins. Among competing wildcards the longest suffix wins,
14
+ * so `*.eu.example.com` beats `*.example.com` for `a.eu.example.com`.
15
+ * The same ordering applies at all three layers — DNS in the resolver,
16
+ * routes here, and SNI in Bun's cert table.
17
+ *
18
+ * **Routes and certificates match differently, deliberately.** A wildcard
19
+ * *route* matches any depth of subdomain, but a wildcard *certificate*
20
+ * covers exactly one label — which is what RFC 6125 says and what every
21
+ * TLS client enforces. So `a.b.example.com` under `*.example.com` is
22
+ * reverse-proxied happily over http, and needs its own cert entry for
23
+ * https.
24
+ *
25
+ * That asymmetry is load-bearing, not an oversight. Loosening
26
+ * {@link wildcardCoversHost} to a plain suffix test would make
27
+ * {@link certCovers} report that a deep name is already covered, so the
28
+ * runtime-TLS path would skip minting the leaf that name actually needs
29
+ * and hand the client a certificate it rejects — a TLS failure at request
30
+ * time, far from the code that caused it.
31
+ */
32
+ // Re-exported rather than redefined: `ingress.ts` owns the wildcard shape
33
+ // for the declarative lowering, and two copies of "what counts as a
34
+ // wildcard" is precisely the drift this port exists to remove.
35
+ export { isWildcard } from "../ingress";
36
+ import { isWildcard } from "../ingress";
37
+ /** `"*.example.com"` → `".example.com"`. */
38
+ export function wildcardSuffix(pattern) {
39
+ return pattern.slice(1);
40
+ }
41
+ /**
42
+ * Does a wildcard *certificate* pattern cover `hostname`?
43
+ *
44
+ * One label only (RFC 6125): `*.example.com` covers `api.example.com` and
45
+ * NOT `a.b.example.com`. See the module header for why this must not be
46
+ * relaxed into a suffix test.
47
+ */
48
+ export function wildcardCoversHost(pattern, hostname) {
49
+ const suffix = wildcardSuffix(pattern);
50
+ if (!hostname.endsWith(suffix))
51
+ return false;
52
+ const label = hostname.slice(0, -suffix.length);
53
+ return label.length > 0 && !label.includes(".");
54
+ }
55
+ /**
56
+ * Is `hostname` already served by an exact or wildcard certificate?
57
+ *
58
+ * Takes the set of configured server names rather than reading module
59
+ * state, so the rule is testable on its own — the caller passes
60
+ * `HTTPS_CERT_BY_HOST.keys()`.
61
+ */
62
+ export function certCovers(serverNames, hostname) {
63
+ const names = [...serverNames];
64
+ if (names.includes(hostname))
65
+ return true;
66
+ return names.some((n) => isWildcard(n) && wildcardCoversHost(n, hostname));
67
+ }
68
+ /**
69
+ * Pick the route for `host`: exact match first, then the longest matching
70
+ * wildcard suffix.
71
+ *
72
+ * Unlike a certificate, a wildcard route matches any depth — `*.test`
73
+ * serves `a.b.test`.
74
+ */
75
+ export function matchRoute(byHost, host) {
76
+ const exact = byHost.get(host);
77
+ if (exact !== undefined)
78
+ return exact;
79
+ let best;
80
+ let bestLen = -1;
81
+ for (const [pattern, route] of byHost) {
82
+ if (!isWildcard(pattern))
83
+ continue;
84
+ const suffix = wildcardSuffix(pattern);
85
+ if (host.endsWith(suffix) && suffix.length > bestLen) {
86
+ best = route;
87
+ bestLen = suffix.length;
88
+ }
89
+ }
90
+ return best;
91
+ }
92
+ /**
93
+ * Strip the port from a `Host` header value.
94
+ *
95
+ * IPv6 literals arrive bracketed (`[::1]:8080`), so a naive split on the
96
+ * last `:` would mangle them; the brackets are what disambiguate.
97
+ */
98
+ export function hostWithoutPort(hostHeader) {
99
+ const h = hostHeader.trim();
100
+ if (h.startsWith("[")) {
101
+ const close = h.indexOf("]");
102
+ if (close !== -1)
103
+ return h.slice(0, close + 1);
104
+ return h;
105
+ }
106
+ const colon = h.lastIndexOf(":");
107
+ return colon === -1 ? h : h.slice(0, colon);
108
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * HTTP semantics the ingress proxy has to get right: which headers must
3
+ * not be forwarded, and how CORS is answered.
4
+ *
5
+ * Ported out of `daemon.ts`. Pure request→response logic, so the rules
6
+ * below are now assertable rather than inferred from a header list.
7
+ */
8
+ /**
9
+ * Headers that describe **this** connection rather than the message, and
10
+ * so must never be copied to the next hop (RFC 9110 §7.6.1).
11
+ *
12
+ * Forwarding `transfer-encoding` or `content-length` from a re-encoded
13
+ * body is the classic way to produce a response the client cannot frame;
14
+ * forwarding `connection` leaks our keep-alive intent to the upstream.
15
+ * `host` is here because the proxy sets its own — the upstream must see
16
+ * the service it actually is, not the public name the client asked for.
17
+ */
18
+ export declare const HOP_BY_HOP_HEADERS: ReadonlySet<string>;
19
+ export declare function isHopByHop(name: string): boolean;
20
+ /** Copy headers, dropping the ones that belong to this connection. */
21
+ export declare function forwardableHeaders(headers: Headers): Headers;
22
+ /**
23
+ * A CORS preflight is an `OPTIONS` carrying both `Origin` and
24
+ * `Access-Control-Request-Method`. An `OPTIONS` without them is a normal
25
+ * request (an API asking what it supports) and must be proxied, not
26
+ * answered here.
27
+ */
28
+ export declare function isCorsPreflight(req: {
29
+ method: string;
30
+ headers: {
31
+ has(n: string): boolean;
32
+ };
33
+ }): boolean;
34
+ /**
35
+ * Answer a preflight permissively — the environment is hermetic, so
36
+ * there is nothing here to protect from itself, and a test failing on
37
+ * CORS teaches nothing about the app.
38
+ *
39
+ * The one rule that is not a matter of taste: the **specific** origin is
40
+ * echoed rather than `*`. `Allow-Origin: *` together with
41
+ * `Allow-Credentials: true` is a spec violation that browsers reject
42
+ * outright, so a wildcard would break exactly the credentialed requests
43
+ * this is meant to permit.
44
+ */
45
+ export declare function corsPreflightResponse(req: {
46
+ headers: {
47
+ get(n: string): string | null;
48
+ };
49
+ }): Response;
50
+ /**
51
+ * Add CORS headers to a proxied response, without overriding the
52
+ * upstream's own.
53
+ *
54
+ * An app that already sets `Access-Control-Allow-Origin` has an opinion —
55
+ * possibly a deliberately restrictive one that a test is checking — and
56
+ * replacing it would make the environment lie about the app's behaviour.
57
+ */
58
+ export declare function augmentCorsResponse(req: {
59
+ headers: {
60
+ get(n: string): string | null;
61
+ };
62
+ }, res: Response): Response;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * HTTP semantics the ingress proxy has to get right: which headers must
3
+ * not be forwarded, and how CORS is answered.
4
+ *
5
+ * Ported out of `daemon.ts`. Pure request→response logic, so the rules
6
+ * below are now assertable rather than inferred from a header list.
7
+ */
8
+ /**
9
+ * Headers that describe **this** connection rather than the message, and
10
+ * so must never be copied to the next hop (RFC 9110 §7.6.1).
11
+ *
12
+ * Forwarding `transfer-encoding` or `content-length` from a re-encoded
13
+ * body is the classic way to produce a response the client cannot frame;
14
+ * forwarding `connection` leaks our keep-alive intent to the upstream.
15
+ * `host` is here because the proxy sets its own — the upstream must see
16
+ * the service it actually is, not the public name the client asked for.
17
+ */
18
+ export const HOP_BY_HOP_HEADERS = new Set([
19
+ "connection",
20
+ "keep-alive",
21
+ "proxy-authenticate",
22
+ "proxy-authorization",
23
+ "te",
24
+ "trailers",
25
+ "transfer-encoding",
26
+ "upgrade",
27
+ "host",
28
+ ]);
29
+ export function isHopByHop(name) {
30
+ return HOP_BY_HOP_HEADERS.has(name.toLowerCase());
31
+ }
32
+ /** Copy headers, dropping the ones that belong to this connection. */
33
+ export function forwardableHeaders(headers) {
34
+ const out = new Headers();
35
+ headers.forEach((value, key) => {
36
+ if (!isHopByHop(key))
37
+ out.append(key, value);
38
+ });
39
+ return out;
40
+ }
41
+ /**
42
+ * A CORS preflight is an `OPTIONS` carrying both `Origin` and
43
+ * `Access-Control-Request-Method`. An `OPTIONS` without them is a normal
44
+ * request (an API asking what it supports) and must be proxied, not
45
+ * answered here.
46
+ */
47
+ export function isCorsPreflight(req) {
48
+ return (req.method === "OPTIONS" &&
49
+ req.headers.has("origin") &&
50
+ req.headers.has("access-control-request-method"));
51
+ }
52
+ /**
53
+ * Answer a preflight permissively — the environment is hermetic, so
54
+ * there is nothing here to protect from itself, and a test failing on
55
+ * CORS teaches nothing about the app.
56
+ *
57
+ * The one rule that is not a matter of taste: the **specific** origin is
58
+ * echoed rather than `*`. `Allow-Origin: *` together with
59
+ * `Allow-Credentials: true` is a spec violation that browsers reject
60
+ * outright, so a wildcard would break exactly the credentialed requests
61
+ * this is meant to permit.
62
+ */
63
+ export function corsPreflightResponse(req) {
64
+ const origin = req.headers.get("origin") ?? "*";
65
+ const reqHeaders = req.headers.get("access-control-request-headers");
66
+ const reqMethod = req.headers.get("access-control-request-method");
67
+ const headers = new Headers();
68
+ headers.set("access-control-allow-origin", origin);
69
+ headers.set("access-control-allow-credentials", "true");
70
+ headers.set("access-control-allow-methods", reqMethod && reqMethod.length > 0 ? reqMethod : "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS");
71
+ headers.set("access-control-allow-headers", reqHeaders && reqHeaders.length > 0 ? reqHeaders : "*");
72
+ headers.set("access-control-max-age", "600");
73
+ // The answer depends on what was asked — say so, or a cache will serve
74
+ // one origin's preflight to another.
75
+ headers.append("vary", "Origin");
76
+ headers.append("vary", "Access-Control-Request-Headers");
77
+ return new Response(null, { status: 204, headers });
78
+ }
79
+ /**
80
+ * Add CORS headers to a proxied response, without overriding the
81
+ * upstream's own.
82
+ *
83
+ * An app that already sets `Access-Control-Allow-Origin` has an opinion —
84
+ * possibly a deliberately restrictive one that a test is checking — and
85
+ * replacing it would make the environment lie about the app's behaviour.
86
+ */
87
+ export function augmentCorsResponse(req, res) {
88
+ const origin = req.headers.get("origin");
89
+ if (!origin)
90
+ return res;
91
+ if (res.headers.has("access-control-allow-origin"))
92
+ return res;
93
+ try {
94
+ res.headers.set("access-control-allow-origin", origin);
95
+ res.headers.set("access-control-allow-credentials", "true");
96
+ res.headers.append("vary", "Origin");
97
+ }
98
+ catch {
99
+ // Some responses carry guarded/immutable headers — a 101 upgrade stub,
100
+ // for instance. Leave those exactly as they are rather than failing the
101
+ // request over a cosmetic header.
102
+ }
103
+ return res;
104
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The live ingress tables: which hostname reaches which upstream, and
3
+ * which certificate serves the TLS handshake for it.
4
+ *
5
+ * Ported out of `daemon.ts` as part of the harness split. This module owns
6
+ * the *tables and the decisions*; the listeners themselves (`Bun.serve`)
7
+ * stay with the process that binds them, because a socket is not something
8
+ * you can hand across a module boundary usefully.
9
+ *
10
+ * ## Why this state is special
11
+ *
12
+ * These tables are ordinary module-scope objects, and that is the whole
13
+ * design. They live in the harness process, so they are captured by the
14
+ * memory snapshot and **fork with it** — exactly like fake state and the
15
+ * names registry. A `dependsOn` child inherits every route its parent
16
+ * bound; a sibling forked from an earlier snapshot never sees them. That
17
+ * is what lets a fake provision a real service mid-test and hand the app a
18
+ * CA-trusted `https://…` endpoint that only that branch of the test DAG
19
+ * can reach.
20
+ *
21
+ * The failure mode to design against is therefore *silence*: a route that
22
+ * fails to survive doesn't raise anything, it just isn't inherited, and
23
+ * the test that depended on it fails somewhere else entirely.
24
+ *
25
+ * ## The two rules worth stating
26
+ *
27
+ * **A route table is identified by its object, not its contents.** Every
28
+ * listener's request handler closes over the `Map` it was bound with, so
29
+ * adding an entry takes effect with no rebind — that is what makes a
30
+ * runtime route possible at all. Replacing the `Map` (rather than mutating
31
+ * it) silently orphans every listener still holding the old one, and the
32
+ * routes added afterwards go nowhere. {@link routesFor} is the only way to
33
+ * reach a table so that this can't be done by accident.
34
+ *
35
+ * **A rebind is not free, so it must be earned.** Bun fixes a server's TLS
36
+ * config at `Bun.serve` time — `reload` will not add an SNI entry — so a
37
+ * genuinely new certificate means stopping and re-serving :443. It's cheap
38
+ * (~1 ms) but it is a real interruption of live traffic, and a hostname
39
+ * already covered by an existing exact or wildcard cert needs nothing but a
40
+ * route entry. {@link planBind} is that judgement, separated from the
41
+ * mutation so it can be tested without a network stack.
42
+ */
43
+ /**
44
+ * One hostname's upstream: a fake handled in-process, or a container.
45
+ *
46
+ * Generic in the fake's runtime record so the harness keeps its own type
47
+ * through the tables — the alternative, `unknown` plus a cast at each
48
+ * dispatch site, would trade a real type for a comment.
49
+ */
50
+ export type Route<F = unknown> = {
51
+ kind: "fake";
52
+ fake: F;
53
+ } | {
54
+ kind: "proxy";
55
+ service: string;
56
+ port: number;
57
+ };
58
+ /** A PEM leaf + key, as handed to `Bun.serve`'s TLS config. */
59
+ export interface Leaf {
60
+ cert: string;
61
+ key: string;
62
+ }
63
+ /**
64
+ * The mutable ingress state of one harness process.
65
+ *
66
+ * Held by the caller rather than this module so there is no hidden
67
+ * singleton: tests build one per case, and the harness holds exactly one.
68
+ */
69
+ export interface IngressTables<F = unknown> {
70
+ /** Listen port → (hostname → route). Values are mutated in place. */
71
+ routesByPort: Map<number, Map<string, Route<F>>>;
72
+ /** SNI server name → leaf. Adding to this is what forces a rebind. */
73
+ certByHost: Map<string, Leaf>;
74
+ }
75
+ /** Fixed HTTPS port shared by every TLS route (fakes + service `tls`). */
76
+ export declare const INGRESS_HTTPS_PORT = 443;
77
+ /** Fixed HTTP port, always bound alongside :443 so both schemes work. */
78
+ export declare const INGRESS_HTTP_PORT = 80;
79
+ export declare function emptyTables<F = unknown>(): IngressTables<F>;
80
+ /**
81
+ * The route table for `port`, creating it if absent.
82
+ *
83
+ * Always returns the *same* object for a given port for as long as the
84
+ * tables live. Callers must mutate what they get back and must never swap
85
+ * in a replacement — see the module header for what that breaks.
86
+ */
87
+ export declare function routesFor<F>(tables: IngressTables<F>, port: number): Map<string, Route<F>>;
88
+ /** What binding `hostname` will require, before anything is mutated. */
89
+ export interface BindPlan {
90
+ /** No existing cert covers the hostname, so a leaf must be minted. */
91
+ needsCert: boolean;
92
+ /** The :443 listener must be stopped and re-served. */
93
+ needsHttpsRebind: boolean;
94
+ }
95
+ /**
96
+ * Decide what binding `hostname` for TLS costs.
97
+ *
98
+ * A rebind is needed when a new certificate is going into the SNI table,
99
+ * or when :443 isn't listening yet. It is deliberately *not* needed for a
100
+ * hostname an existing wildcard already covers — the common case when a
101
+ * component claims a whole domain up front and services appear under it
102
+ * later, and the reason a fake can mint endpoints in a loop without
103
+ * restarting the listener once per iteration.
104
+ */
105
+ export declare function planBind<F>(tables: IngressTables<F>, hostname: string, opts: {
106
+ httpsListening: boolean;
107
+ }): BindPlan;
108
+ /**
109
+ * Point `hostname` at `route` on both :80 and :443.
110
+ *
111
+ * Boot `tls` serves both schemes, and runtime `tls` matches it — a service
112
+ * reachable only over https would differ from its boot-time twin in a way
113
+ * nothing declares.
114
+ */
115
+ export declare function bindRoute<F>(tables: IngressTables<F>, hostname: string, route: Route<F>): void;
116
+ /**
117
+ * Drop `hostname`'s routes, so it 404s.
118
+ *
119
+ * The certificate is deliberately left in the SNI table: it is harmless
120
+ * without a route, and removing it would force an otherwise unnecessary
121
+ * :443 rebind at exactly the moment a service is going away.
122
+ */
123
+ export declare function unbindRoute<F>(tables: IngressTables<F>, hostname: string): void;
124
+ /** The SNI entries for `Bun.serve`'s TLS config. */
125
+ export declare function certEntries<F>(tables: IngressTables<F>): Array<{
126
+ cert: string;
127
+ key: string;
128
+ serverName: string;
129
+ }>;
130
+ /** Clear every table — used between `/load` calls so a new project's
131
+ * routes bind against a clean slate rather than the old project's. */
132
+ export declare function clearTables<F>(tables: IngressTables<F>): void;
133
+ /**
134
+ * Point a hostname at an IP in a names registry document, choosing the
135
+ * exact or wildcard table by the hostname's shape.
136
+ *
137
+ * A wildcard has no `--add-host` or `--network-alias` equivalent, so the
138
+ * resolver's suffix table is the only place it can exist; an exact name
139
+ * could live in either, and goes in the exact table so it keeps winning
140
+ * over any wildcard that also matches.
141
+ */
142
+ export declare function registryTarget(hostname: string): {
143
+ wildcard: true;
144
+ suffix: string;
145
+ } | {
146
+ wildcard: false;
147
+ host: string;
148
+ };