@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.
- package/dist/browser.d.ts +21 -8
- package/dist/browser.js +78 -36
- package/dist/components/supabase.d.ts +87 -27
- package/dist/components/supabase.js +352 -69
- package/dist/daemon.d.ts +38 -0
- package/dist/daemon.js +464 -987
- package/dist/harness/build-context.d.ts +82 -0
- package/dist/harness/build-context.js +113 -0
- package/dist/harness/buildkit-progress.d.ts +37 -0
- package/dist/harness/buildkit-progress.js +66 -0
- package/dist/harness/container-run.d.ts +89 -0
- package/dist/harness/container-run.js +118 -0
- package/dist/harness/file-mounts.d.ts +91 -0
- package/dist/harness/file-mounts.js +119 -0
- package/dist/harness/hostmatch.d.ts +65 -0
- package/dist/harness/hostmatch.js +108 -0
- package/dist/harness/http-proxy.d.ts +62 -0
- package/dist/harness/http-proxy.js +104 -0
- package/dist/harness/ingress-table.d.ts +148 -0
- package/dist/harness/ingress-table.js +129 -0
- package/dist/harness/log-delta.d.ts +54 -0
- package/dist/harness/log-delta.js +83 -0
- package/dist/harness/main.d.ts +47 -0
- package/dist/harness/main.js +164 -0
- package/dist/harness/methods.d.ts +54 -0
- package/dist/harness/methods.js +65 -0
- package/dist/harness/names-registry.d.ts +63 -0
- package/dist/harness/names-registry.js +90 -0
- package/dist/harness/protocol.d.ts +88 -0
- package/dist/harness/protocol.js +96 -0
- package/dist/harness/ready-poll.d.ts +47 -0
- package/dist/harness/ready-poll.js +67 -0
- package/dist/harness/service-graph.d.ts +29 -0
- package/dist/harness/service-graph.js +92 -0
- package/dist/harness/volume-paths.d.ts +70 -0
- package/dist/harness/volume-paths.js +81 -0
- package/dist/index.d.ts +58 -16
- package/dist/ingress.d.ts +1 -1
- package/dist/mobile.d.ts +9 -5
- package/dist/mobile.js +7 -6
- package/dist/recorder.d.ts +10 -0
- package/dist/resolver.js +5 -8
- package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/dist/vendor/rrweb-record.min.js +5061 -0
- package/package.json +7 -1
- package/src/aws-sigv4.ts +218 -0
- package/src/browser.ts +2095 -0
- package/src/components/aws.ts +554 -0
- package/src/components/email.ts +398 -0
- package/src/components/expo.ts +167 -0
- package/src/components/index.ts +81 -0
- package/src/components/k3s.ts +2061 -0
- package/src/components/postgres.ts +132 -0
- package/src/components/replayFake.ts +1015 -0
- package/src/components/s3.ts +132 -0
- package/src/components/supabase.ts +1699 -0
- package/src/daemon.ts +5537 -0
- package/src/harness/build-context.test.ts +0 -0
- package/src/harness/build-context.ts +146 -0
- package/src/harness/buildkit-progress.test.ts +98 -0
- package/src/harness/buildkit-progress.ts +74 -0
- package/src/harness/container-run.test.ts +209 -0
- package/src/harness/container-run.ts +158 -0
- package/src/harness/file-mounts.test.ts +185 -0
- package/src/harness/file-mounts.ts +145 -0
- package/src/harness/hostmatch.test.ts +148 -0
- package/src/harness/hostmatch.ts +109 -0
- package/src/harness/http-proxy.test.ts +156 -0
- package/src/harness/http-proxy.ts +119 -0
- package/src/harness/ingress-rebind.test.ts +125 -0
- package/src/harness/ingress-table.test.ts +172 -0
- package/src/harness/ingress-table.ts +186 -0
- package/src/harness/log-delta.test.ts +125 -0
- package/src/harness/log-delta.ts +100 -0
- package/src/harness/main.test.ts +211 -0
- package/src/harness/main.ts +196 -0
- package/src/harness/methods.test.ts +63 -0
- package/src/harness/methods.ts +92 -0
- package/src/harness/names-registry.test.ts +137 -0
- package/src/harness/names-registry.ts +108 -0
- package/src/harness/protocol.test.ts +148 -0
- package/src/harness/protocol.ts +163 -0
- package/src/harness/ready-poll.test.ts +172 -0
- package/src/harness/ready-poll.ts +93 -0
- package/src/harness/service-graph.test.ts +97 -0
- package/src/harness/service-graph.ts +97 -0
- package/src/harness/volume-paths.test.ts +102 -0
- package/src/harness/volume-paths.ts +112 -0
- package/src/ids.ts +89 -0
- package/src/index.ts +2767 -0
- package/src/ingress.ts +305 -0
- package/src/inspect.ts +739 -0
- package/src/locator.ts +716 -0
- package/src/mobile.ts +138 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +856 -0
- package/src/redis.ts +202 -0
- package/src/replay-bundle.ts +108 -0
- package/src/resolver.ts +348 -0
- package/src/s3.ts +333 -0
- package/src/sql.ts +243 -0
- package/src/terminal.ts +740 -0
- package/src/url-match.ts +67 -0
- package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
- 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
|
+
};
|