@specific.dev/spectest 0.32.0 → 0.33.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.js +5 -3
- package/dist/daemon.js +97 -14
- package/dist/index.d.ts +20 -0
- package/dist/index.js +12 -4
- package/dist/ingress.d.ts +11 -0
- package/dist/ingress.js +22 -4
- package/dist/recorder.d.ts +13 -2
- package/dist/recorder.js +13 -0
- package/package.json +1 -1
package/dist/browser.js
CHANGED
|
@@ -32,7 +32,7 @@ import { readFileSync } from "node:fs";
|
|
|
32
32
|
import path from "node:path";
|
|
33
33
|
import { fileURLToPath } from "node:url";
|
|
34
34
|
import { generateId } from "./ids.js";
|
|
35
|
-
import { recordBrowser, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
35
|
+
import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
36
36
|
import { wrap } from "./inspect.js";
|
|
37
37
|
import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
38
38
|
import { attachBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, desktopStrategy, makeLocator, mobileStrategy, } from "./locator.js";
|
|
@@ -1407,7 +1407,9 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1407
1407
|
// No page work — the matcher already read the value via silentRead. We
|
|
1408
1408
|
// only mint the timeline anchor: the seq the assertion nests under, plus
|
|
1409
1409
|
// `sessionTimestamp` (post-settle wall clock) so the dashboard seeks the
|
|
1410
|
-
// replay to the frame the assertion observed.
|
|
1410
|
+
// replay to the frame the assertion observed. The offset is backdated to
|
|
1411
|
+
// where the matcher started polling, since that's what `tOffsetMs` means
|
|
1412
|
+
// everywhere else (the ops that reserve up front stamp their start).
|
|
1411
1413
|
const endT = Date.now();
|
|
1412
1414
|
const seq = recordBrowser({
|
|
1413
1415
|
action,
|
|
@@ -1417,7 +1419,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1417
1419
|
: {}),
|
|
1418
1420
|
durationMs: waitedMs,
|
|
1419
1421
|
...(error ? { error } : {}),
|
|
1420
|
-
});
|
|
1422
|
+
}, reserveBackdated(waitedMs));
|
|
1421
1423
|
// Drain the rrweb the page buffered while the matcher waited into this
|
|
1422
1424
|
// step's chunk, so `settledTarget` has bounds to seek into.
|
|
1423
1425
|
await drain(action);
|
package/dist/daemon.js
CHANGED
|
@@ -511,6 +511,17 @@ async function recordAbsoluteVolumeDir(host) {
|
|
|
511
511
|
ABS_VOLUME_DIRS.add(host);
|
|
512
512
|
await fs.writeFile(VOLUME_DIRS_MANIFEST, [...ABS_VOLUME_DIRS].join("\n") + "\n");
|
|
513
513
|
}
|
|
514
|
+
/// True when `p` exists and is not a directory (a socket, a regular file,
|
|
515
|
+
/// a device). Used to tell "a volume dir we must create" from "a VM-host
|
|
516
|
+
/// object the service asked to bind-mount as-is".
|
|
517
|
+
async function isExistingNonDirectory(p) {
|
|
518
|
+
try {
|
|
519
|
+
return !(await fs.lstat(p)).isDirectory();
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
514
525
|
async function ensureVolumes(svc) {
|
|
515
526
|
const flags = [];
|
|
516
527
|
if (!svc.volumes || svc.volumes.length === 0)
|
|
@@ -522,9 +533,18 @@ async function ensureVolumes(svc) {
|
|
|
522
533
|
throw new Error(`service "${svc.name}" volume for ${JSON.stringify(vol.target)} sets both \`name\` and \`source\``);
|
|
523
534
|
}
|
|
524
535
|
const host = resolveHostPath(svc.name, vol);
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
536
|
+
// An absolute `source` can name something that already exists and is
|
|
537
|
+
// NOT a directory — the canonical case is the VM's docker socket
|
|
538
|
+
// (`/var/run/docker.sock`), bind-mounted into a service that drives
|
|
539
|
+
// docker itself (LocalStack's Lambda executor, dind-style builders).
|
|
540
|
+
// `mkdir -p` fails on those with EEXIST. They are also VM-host
|
|
541
|
+
// infrastructure rather than environment state, so they are neither
|
|
542
|
+
// created here nor recorded for the delta-restore wipe.
|
|
543
|
+
if (!(await isExistingNonDirectory(host))) {
|
|
544
|
+
await fs.mkdir(host, { recursive: true });
|
|
545
|
+
if (vol.source?.startsWith("/") && !host.startsWith("/var/cache/spectest/")) {
|
|
546
|
+
await recordAbsoluteVolumeDir(host);
|
|
547
|
+
}
|
|
528
548
|
}
|
|
529
549
|
flags.push(`--volume=${host}:${vol.target}${vol.readOnly ? ":ro" : ""}`);
|
|
530
550
|
}
|
|
@@ -1575,17 +1595,22 @@ async function startIngress() {
|
|
|
1575
1595
|
routes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
|
|
1576
1596
|
}
|
|
1577
1597
|
}
|
|
1578
|
-
// The :443 route table mirrors every
|
|
1598
|
+
// The :443 route table mirrors every handler whose hostname a cert
|
|
1599
|
+
// covers — exactly, or through a wildcard SAN, so `*.example.com` in a
|
|
1600
|
+
// service's `tls` also puts an unrelated exact `api.example.com` route on
|
|
1601
|
+
// HTTPS. Proxies are applied after fakes so a proxy wins a shared host.
|
|
1579
1602
|
if (HTTPS_CERT_BY_HOST.size > 0) {
|
|
1580
1603
|
const httpsRoutes = ensurePort(INGRESS_HTTPS_PORT);
|
|
1581
|
-
for (const
|
|
1582
|
-
for (const
|
|
1583
|
-
if (
|
|
1604
|
+
for (const fake of FAKES.values()) {
|
|
1605
|
+
for (const h of fake.hostnames) {
|
|
1606
|
+
if (certCovers(h))
|
|
1584
1607
|
httpsRoutes.set(h, { kind: "fake", fake });
|
|
1585
1608
|
}
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1609
|
+
}
|
|
1610
|
+
for (const p of LOWERED.proxies) {
|
|
1611
|
+
if (certCovers(p.hostname)) {
|
|
1612
|
+
httpsRoutes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
|
|
1613
|
+
}
|
|
1589
1614
|
}
|
|
1590
1615
|
}
|
|
1591
1616
|
// ── HTTP listeners (one per non-443 port).
|
|
@@ -1649,12 +1674,26 @@ function rebindHttpsListener(Bun) {
|
|
|
1649
1674
|
// eslint-disable-next-line no-console
|
|
1650
1675
|
console.log(`[ingress] https :${INGRESS_HTTPS_PORT} for ${[...routes.keys()].join(", ")}`);
|
|
1651
1676
|
}
|
|
1677
|
+
/**
|
|
1678
|
+
* RFC 6125 wildcard match: `*.example.com` covers `api.example.com` but NOT
|
|
1679
|
+
* `a.b.example.com`. Deliberately stricter than {@link matchIngressRoute}'s
|
|
1680
|
+
* suffix matching — this one has to agree with the TLS *client*, and every
|
|
1681
|
+
* client stops at one label. Claiming a deeper name is covered would skip
|
|
1682
|
+
* minting the leaf it actually needs and hand it a cert it rejects.
|
|
1683
|
+
*/
|
|
1684
|
+
function wildcardCoversHost(pattern, hostname) {
|
|
1685
|
+
const suffix = wildcardSuffix(pattern); // "*.example.com" → ".example.com"
|
|
1686
|
+
if (!hostname.endsWith(suffix))
|
|
1687
|
+
return false;
|
|
1688
|
+
const label = hostname.slice(0, -suffix.length);
|
|
1689
|
+
return label.length > 0 && !label.includes(".");
|
|
1690
|
+
}
|
|
1652
1691
|
/** True if an exact or wildcard cert already covers `hostname` for SNI. */
|
|
1653
1692
|
function certCovers(hostname) {
|
|
1654
1693
|
if (HTTPS_CERT_BY_HOST.has(hostname))
|
|
1655
1694
|
return true;
|
|
1656
1695
|
for (const serverName of HTTPS_CERT_BY_HOST.keys()) {
|
|
1657
|
-
if (isWildcard(serverName) &&
|
|
1696
|
+
if (isWildcard(serverName) && wildcardCoversHost(serverName, hostname)) {
|
|
1658
1697
|
return true;
|
|
1659
1698
|
}
|
|
1660
1699
|
}
|
|
@@ -1706,8 +1745,16 @@ async function bindRuntimeTls(hostname, service, port) {
|
|
|
1706
1745
|
rebindHttpsListener(Bun);
|
|
1707
1746
|
}
|
|
1708
1747
|
// Resolve the hostname to the daemon gateway (where :443/:80 listen).
|
|
1748
|
+
// A wildcard can only live in the resolver's suffix table.
|
|
1709
1749
|
const gw = await bridgeGatewayIp();
|
|
1710
|
-
|
|
1750
|
+
if (isWildcard(host)) {
|
|
1751
|
+
const suffix = wildcardSuffix(host);
|
|
1752
|
+
REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== suffix);
|
|
1753
|
+
REGISTRY.wildcards.push({ suffix, ip: gw });
|
|
1754
|
+
}
|
|
1755
|
+
else {
|
|
1756
|
+
REGISTRY.hosts[host] = gw;
|
|
1757
|
+
}
|
|
1711
1758
|
await writeRegistry();
|
|
1712
1759
|
// eslint-disable-next-line no-console
|
|
1713
1760
|
console.log(`[ingress] runtime https ${host} -> ${service}:${port}`);
|
|
@@ -1722,7 +1769,14 @@ async function unbindRuntimeTls(hostname) {
|
|
|
1722
1769
|
const host = hostname.toLowerCase();
|
|
1723
1770
|
INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTP_PORT)?.delete(host);
|
|
1724
1771
|
INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT)?.delete(host);
|
|
1725
|
-
if (host
|
|
1772
|
+
if (isWildcard(host)) {
|
|
1773
|
+
const suffix = wildcardSuffix(host);
|
|
1774
|
+
const before = REGISTRY.wildcards.length;
|
|
1775
|
+
REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== suffix);
|
|
1776
|
+
if (REGISTRY.wildcards.length !== before)
|
|
1777
|
+
await writeRegistry();
|
|
1778
|
+
}
|
|
1779
|
+
else if (host in REGISTRY.hosts) {
|
|
1726
1780
|
delete REGISTRY.hosts[host];
|
|
1727
1781
|
await writeRegistry();
|
|
1728
1782
|
}
|
|
@@ -1838,6 +1892,35 @@ Bun, port, byHost, listenerLabel, tlsEntries) {
|
|
|
1838
1892
|
opts.tls = tlsEntries;
|
|
1839
1893
|
return Bun.serve(opts);
|
|
1840
1894
|
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Resolve a Host header to a route: an exact entry first, then the longest
|
|
1897
|
+
* matching `*.suffix` wildcard. Same precedence the resolver applies to DNS
|
|
1898
|
+
* (exact beats wildcard, longest suffix beats shorter), so a name that DNS
|
|
1899
|
+
* pointed at the ingress finds the route that claimed it.
|
|
1900
|
+
*
|
|
1901
|
+
* A wildcard *route* matches any depth of subdomain, while a wildcard
|
|
1902
|
+
* *cert* covers exactly one label ({@link wildcardCoversHost} — RFC 6125,
|
|
1903
|
+
* what TLS clients enforce). So `a.b.example.com` under a `*.example.com`
|
|
1904
|
+
* proxy reverse-proxies over `http://` but needs its own `tls` entry to
|
|
1905
|
+
* present a valid cert over `https://`.
|
|
1906
|
+
*/
|
|
1907
|
+
function matchIngressRoute(byHost, host) {
|
|
1908
|
+
const exact = byHost.get(host);
|
|
1909
|
+
if (exact)
|
|
1910
|
+
return exact;
|
|
1911
|
+
let best;
|
|
1912
|
+
let bestLen = -1;
|
|
1913
|
+
for (const [pattern, route] of byHost) {
|
|
1914
|
+
if (!isWildcard(pattern))
|
|
1915
|
+
continue;
|
|
1916
|
+
const suffix = wildcardSuffix(pattern);
|
|
1917
|
+
if (host.endsWith(suffix) && suffix.length > bestLen) {
|
|
1918
|
+
best = route;
|
|
1919
|
+
bestLen = suffix.length;
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
return best;
|
|
1923
|
+
}
|
|
1841
1924
|
/**
|
|
1842
1925
|
* Per-request dispatch shared by every ingress listener. Looks up the
|
|
1843
1926
|
* Route by Host header (port stripped) and either:
|
|
@@ -1852,7 +1935,7 @@ server, byHost, listenerLabel, proto) {
|
|
|
1852
1935
|
.toLowerCase()
|
|
1853
1936
|
.split(":")[0]
|
|
1854
1937
|
.trim();
|
|
1855
|
-
const route = byHost
|
|
1938
|
+
const route = matchIngressRoute(byHost, host);
|
|
1856
1939
|
if (!route) {
|
|
1857
1940
|
return new Response(`spectest-daemon: no ingress route bound to Host=${JSON.stringify(host)} on ${listenerLabel}\n`, { status: 404, headers: { "content-type": "text/plain" } });
|
|
1858
1941
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -72,6 +72,11 @@ export interface ServiceConfig {
|
|
|
72
72
|
* instead — those names terminate TLS in the daemon and reverse-proxy
|
|
73
73
|
* to the service's HTTP port.
|
|
74
74
|
*
|
|
75
|
+
* A `*.suffix` wildcard (e.g. `"*.example.com"`) points a whole domain
|
|
76
|
+
* at this service. Wildcards are answered by spectest-resolver only —
|
|
77
|
+
* they never land in a container's `/etc/hosts` — which peer containers
|
|
78
|
+
* still reach, since Docker forwards unknown names to that resolver.
|
|
79
|
+
*
|
|
75
80
|
* The `.internal` TLD is reserved: every service automatically
|
|
76
81
|
* answers to `<name>.internal` in addition to its bare `<name>`, and
|
|
77
82
|
* user-supplied hostnames may not end in `.internal`.
|
|
@@ -92,6 +97,15 @@ export interface ServiceConfig {
|
|
|
92
97
|
* tests and from peer services. Hostname rules match {@link hostnames}:
|
|
93
98
|
* multi-label, lowercase, no `.internal` suffix, no collision with
|
|
94
99
|
* services, other service TLS hostnames, or fakes.
|
|
100
|
+
*
|
|
101
|
+
* A `*.suffix` wildcard claims a whole domain with one entry — e.g.
|
|
102
|
+
* `{ hostname: "*.us-east-1.amazonaws.com", port: 4566 }` puts every
|
|
103
|
+
* AWS regional endpoint on one emulator container, so an unmodified SDK
|
|
104
|
+
* reaches it at its production URL. The leaf cert gets a wildcard SAN,
|
|
105
|
+
* which (as in every TLS client) covers exactly **one** label: declare
|
|
106
|
+
* a separate entry for anything deeper. An exact `tls` hostname always
|
|
107
|
+
* beats a wildcard, so a single endpoint can be split off to another
|
|
108
|
+
* service.
|
|
95
109
|
*/
|
|
96
110
|
tls?: readonly ServiceTls[];
|
|
97
111
|
/** Bind-mounted volumes for state that survives snapshot/fork. */
|
|
@@ -625,6 +639,12 @@ export interface VolumeMount {
|
|
|
625
639
|
/**
|
|
626
640
|
* Host path. Relative paths resolve under
|
|
627
641
|
* `.spectest/volumes/<service>/`. Defaults to a path derived from `target`.
|
|
642
|
+
*
|
|
643
|
+
* An absolute path that already exists and is not a directory is
|
|
644
|
+
* bind-mounted as-is — the way to hand a service the VM's docker socket
|
|
645
|
+
* (`source: "/var/run/docker.sock"`), which LocalStack's Lambda executor
|
|
646
|
+
* and dind-style builders need. Such a mount is VM-host infrastructure,
|
|
647
|
+
* so unlike a volume directory it is never created or wiped by spectest.
|
|
628
648
|
*/
|
|
629
649
|
source?: string;
|
|
630
650
|
/** Container path. */
|
package/dist/index.js
CHANGED
|
@@ -31,6 +31,7 @@ import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
|
31
31
|
// `tls` / `hostnames` fields and `defineFake(...)` are built on. See
|
|
32
32
|
// `ingress.ts`.
|
|
33
33
|
export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
|
|
34
|
+
import { isWildcard as isWildcardHost } from "./ingress.js";
|
|
34
35
|
// ──────────────────────────────────────────────────────────────────────────
|
|
35
36
|
// Service groups — one services-map entry that expands to several services.
|
|
36
37
|
//
|
|
@@ -234,8 +235,8 @@ function validateEnvironmentConfig(config) {
|
|
|
234
235
|
}
|
|
235
236
|
for (const raw of svc.hostnames ?? []) {
|
|
236
237
|
const h = raw.toLowerCase();
|
|
237
|
-
if (!HOSTNAME_RE.test(h)) {
|
|
238
|
-
throw new Error(`service "${name}" declares invalid hostname ${JSON.stringify(raw)} — must be a multi-label DNS name (e.g. "api.stripe.com")`);
|
|
238
|
+
if (!HOSTNAME_RE.test(hostPatternBody(h))) {
|
|
239
|
+
throw new Error(`service "${name}" declares invalid hostname ${JSON.stringify(raw)} — must be a multi-label DNS name or a wildcard (e.g. "api.stripe.com", "*.stripe.com")`);
|
|
239
240
|
}
|
|
240
241
|
if (h === "internal" || h.endsWith(".internal")) {
|
|
241
242
|
throw new Error(`service "${name}" declares hostname ${JSON.stringify(raw)} — the ".internal" TLD is reserved; every service already answers to "<name>.internal" automatically`);
|
|
@@ -247,8 +248,8 @@ function validateEnvironmentConfig(config) {
|
|
|
247
248
|
throw new Error(`service "${name}" tls entry must be { hostname: string, port: number }; got ${JSON.stringify(entry)}`);
|
|
248
249
|
}
|
|
249
250
|
const h = entry.hostname.toLowerCase();
|
|
250
|
-
if (!HOSTNAME_RE.test(h)) {
|
|
251
|
-
throw new Error(`service "${name}" tls hostname ${JSON.stringify(entry.hostname)} is not a multi-label DNS name (e.g. "app.test")`);
|
|
251
|
+
if (!HOSTNAME_RE.test(hostPatternBody(h))) {
|
|
252
|
+
throw new Error(`service "${name}" tls hostname ${JSON.stringify(entry.hostname)} is not a multi-label DNS name or wildcard (e.g. "app.test", "*.us-east-1.amazonaws.com")`);
|
|
252
253
|
}
|
|
253
254
|
if (h === "internal" || h.endsWith(".internal")) {
|
|
254
255
|
throw new Error(`service "${name}" tls hostname ${JSON.stringify(entry.hostname)} ends in reserved ".internal" TLD`);
|
|
@@ -263,6 +264,13 @@ function validateEnvironmentConfig(config) {
|
|
|
263
264
|
// Multi-label hostname: at least one dot, each label 1–63 chars of
|
|
264
265
|
// [a-z0-9-], no leading/trailing hyphen.
|
|
265
266
|
const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
267
|
+
/** The part of a declared name that must be a valid hostname: a `*.suffix`
|
|
268
|
+
* wildcard is checked by its suffix, so `*.example.com` passes but `*.com`
|
|
269
|
+
* (single-label, too broad) and `*` do not. Keeps the `.internal` checks
|
|
270
|
+
* honest too — `*.foo.internal` reduces to `foo.internal`. */
|
|
271
|
+
function hostPatternBody(lowercased) {
|
|
272
|
+
return isWildcardHost(lowercased) ? lowercased.slice(2) : lowercased;
|
|
273
|
+
}
|
|
266
274
|
/**
|
|
267
275
|
* Define a fake. `defineFake({ ... })` is a thin wrapper that pins the
|
|
268
276
|
* generic `S` and `H` so `helpers`/`handler` see the inferred state type
|
package/dist/ingress.d.ts
CHANGED
|
@@ -38,6 +38,11 @@ export declare function isWildcard(hostname: string): boolean;
|
|
|
38
38
|
* `hostnames`. The daemon binds it on the HTTPS ingress (:443, SNI per
|
|
39
39
|
* hostname). Pairs with a `proxy(...)` (TLS-terminated reverse proxy) or a
|
|
40
40
|
* fake handler; on its own it just makes those hostnames serve HTTPS.
|
|
41
|
+
*
|
|
42
|
+
* A `*.suffix` wildcard is allowed and becomes a wildcard SAN, so one cert
|
|
43
|
+
* covers a whole domain (`*.us-east-1.amazonaws.com`). As in any TLS
|
|
44
|
+
* client, a wildcard SAN covers exactly **one** label — `*.example.com`
|
|
45
|
+
* matches `api.example.com`, not `a.b.example.com`.
|
|
41
46
|
*/
|
|
42
47
|
export declare function certificate(hostnames: string[]): CertificateDecl;
|
|
43
48
|
/**
|
|
@@ -54,6 +59,12 @@ export declare function dnsName(hostname: string, target: DnsTarget): DnsDecl;
|
|
|
54
59
|
* Implies the hostname resolves to the daemon ingress, so containers reach
|
|
55
60
|
* it without any extra `dnsName(...)`. Add a `certificate([hostname])` to
|
|
56
61
|
* serve it over HTTPS as well as HTTP.
|
|
62
|
+
*
|
|
63
|
+
* `hostname` may be a `*.suffix` wildcard, which sends every name under
|
|
64
|
+
* that domain to the same upstream — one route for a whole API surface
|
|
65
|
+
* (`*.us-east-1.amazonaws.com` → a LocalStack container). Exact routes
|
|
66
|
+
* always win over wildcards, and the longest wildcard suffix wins among
|
|
67
|
+
* wildcards.
|
|
57
68
|
*/
|
|
58
69
|
export declare function proxy(hostname: string, upstream: {
|
|
59
70
|
service: string;
|
package/dist/ingress.js
CHANGED
|
@@ -53,13 +53,18 @@ function assertNameOrWildcard(h, ctx) {
|
|
|
53
53
|
* `hostnames`. The daemon binds it on the HTTPS ingress (:443, SNI per
|
|
54
54
|
* hostname). Pairs with a `proxy(...)` (TLS-terminated reverse proxy) or a
|
|
55
55
|
* fake handler; on its own it just makes those hostnames serve HTTPS.
|
|
56
|
+
*
|
|
57
|
+
* A `*.suffix` wildcard is allowed and becomes a wildcard SAN, so one cert
|
|
58
|
+
* covers a whole domain (`*.us-east-1.amazonaws.com`). As in any TLS
|
|
59
|
+
* client, a wildcard SAN covers exactly **one** label — `*.example.com`
|
|
60
|
+
* matches `api.example.com`, not `a.b.example.com`.
|
|
56
61
|
*/
|
|
57
62
|
export function certificate(hostnames) {
|
|
58
63
|
if (!Array.isArray(hostnames) || hostnames.length === 0) {
|
|
59
64
|
throw new Error("certificate(): at least one hostname is required");
|
|
60
65
|
}
|
|
61
66
|
for (const h of hostnames)
|
|
62
|
-
|
|
67
|
+
assertNameOrWildcard(h, "certificate()");
|
|
63
68
|
return { kind: "certificate", hostnames: hostnames.map((h) => h.toLowerCase()) };
|
|
64
69
|
}
|
|
65
70
|
/**
|
|
@@ -82,9 +87,15 @@ export function dnsName(hostname, target) {
|
|
|
82
87
|
* Implies the hostname resolves to the daemon ingress, so containers reach
|
|
83
88
|
* it without any extra `dnsName(...)`. Add a `certificate([hostname])` to
|
|
84
89
|
* serve it over HTTPS as well as HTTP.
|
|
90
|
+
*
|
|
91
|
+
* `hostname` may be a `*.suffix` wildcard, which sends every name under
|
|
92
|
+
* that domain to the same upstream — one route for a whole API surface
|
|
93
|
+
* (`*.us-east-1.amazonaws.com` → a LocalStack container). Exact routes
|
|
94
|
+
* always win over wildcards, and the longest wildcard suffix wins among
|
|
95
|
+
* wildcards.
|
|
85
96
|
*/
|
|
86
97
|
export function proxy(hostname, upstream) {
|
|
87
|
-
|
|
98
|
+
assertNameOrWildcard(hostname, "proxy()");
|
|
88
99
|
if (!upstream || typeof upstream.service !== "string" || typeof upstream.port !== "number") {
|
|
89
100
|
throw new Error(`proxy(${JSON.stringify(hostname)}): upstream must be { service: string, port: number }`);
|
|
90
101
|
}
|
|
@@ -153,8 +164,15 @@ export function lowerIngress(project) {
|
|
|
153
164
|
case "proxy": {
|
|
154
165
|
const service = resolveSelf(decl.upstream.service, selfKey);
|
|
155
166
|
proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port });
|
|
156
|
-
// A proxied hostname must route to the daemon.
|
|
157
|
-
|
|
167
|
+
// A proxied hostname must route to the daemon. A wildcard one can
|
|
168
|
+
// only be answered by the resolver (there is no `--add-host` for a
|
|
169
|
+
// pattern), so it goes in the wildcard table pointed at the ingress.
|
|
170
|
+
if (isWildcard(decl.hostname)) {
|
|
171
|
+
wildcards.push({ pattern: decl.hostname, target: { ingress: true } });
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
ingressSet.add(decl.hostname);
|
|
175
|
+
}
|
|
158
176
|
break;
|
|
159
177
|
}
|
|
160
178
|
case "dns": {
|
package/dist/recorder.d.ts
CHANGED
|
@@ -5,8 +5,11 @@ interface BaseEvent {
|
|
|
5
5
|
* before it does still sorts ahead of them. Ops that don't reserve get
|
|
6
6
|
* their seq at record (= finish) time. */
|
|
7
7
|
seq: number;
|
|
8
|
-
/** Milliseconds since the recorder started, captured when the op began
|
|
9
|
-
*
|
|
8
|
+
/** Milliseconds since the recorder started, captured when the op *began* —
|
|
9
|
+
* either reserved up front, or backdated at record time by an op that could
|
|
10
|
+
* only reserve at finish (`reserveBackdated`). Consumers read the step's
|
|
11
|
+
* completion time as `tOffsetMs + durationMs`, so an op that stamps a bare
|
|
12
|
+
* finish offset while also reporting a duration will render late. */
|
|
10
13
|
tOffsetMs: number;
|
|
11
14
|
/**
|
|
12
15
|
* Optional grouping pointer. When set, this event was emitted inside
|
|
@@ -475,6 +478,14 @@ export declare function isRecording(): boolean;
|
|
|
475
478
|
* when nothing is recording, in which case `record*` falls back to allocating
|
|
476
479
|
* the seq at record time. */
|
|
477
480
|
export declare function reserveEvent(): EventReservation | undefined;
|
|
481
|
+
/** Reserve at op *finish* but backdate the offset by `elapsedMs` — for ops that
|
|
482
|
+
* can't reserve up front because they only know they were an op once they're
|
|
483
|
+
* done (the locator matchers: they poll silently and emit one settled step at
|
|
484
|
+
* the end). Keeps `tOffsetMs` meaning "when the op started" for every kind,
|
|
485
|
+
* which is what lets the dashboard render `tOffsetMs + durationMs` as the
|
|
486
|
+
* step's completion time. Ordering is unaffected — the caller records
|
|
487
|
+
* immediately, so the seq is the one it would have got anyway. */
|
|
488
|
+
export declare function reserveBackdated(elapsedMs: number): EventReservation | undefined;
|
|
478
489
|
export declare function recordExec(ev: Omit<ExecEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
|
|
479
490
|
export declare function recordAssertion(ev: Omit<AssertionEvent, "seq" | "tOffsetMs" | "kind">): number | undefined;
|
|
480
491
|
export declare function recordHttp(ev: Omit<HttpEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
|
package/dist/recorder.js
CHANGED
|
@@ -132,6 +132,19 @@ export function isRecording() {
|
|
|
132
132
|
export function reserveEvent() {
|
|
133
133
|
return active() ? current.reserve() : undefined;
|
|
134
134
|
}
|
|
135
|
+
/** Reserve at op *finish* but backdate the offset by `elapsedMs` — for ops that
|
|
136
|
+
* can't reserve up front because they only know they were an op once they're
|
|
137
|
+
* done (the locator matchers: they poll silently and emit one settled step at
|
|
138
|
+
* the end). Keeps `tOffsetMs` meaning "when the op started" for every kind,
|
|
139
|
+
* which is what lets the dashboard render `tOffsetMs + durationMs` as the
|
|
140
|
+
* step's completion time. Ordering is unaffected — the caller records
|
|
141
|
+
* immediately, so the seq is the one it would have got anyway. */
|
|
142
|
+
export function reserveBackdated(elapsedMs) {
|
|
143
|
+
const resv = reserveEvent();
|
|
144
|
+
if (!resv)
|
|
145
|
+
return undefined;
|
|
146
|
+
return { seq: resv.seq, tOffsetMs: Math.max(0, resv.tOffsetMs - Math.max(0, elapsedMs)) };
|
|
147
|
+
}
|
|
135
148
|
export function recordExec(ev, reservation) {
|
|
136
149
|
return active() ? current.push({ kind: "exec", ...ev }, reservation) : undefined;
|
|
137
150
|
}
|