@specific.dev/spectest 0.115.1 → 0.116.1
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/README.md +44 -0
- package/dist/components/replay-websocket.d.ts +2 -3
- package/dist/components/replay-websocket.js +19 -185
- package/dist/components/replayFake.d.ts +2 -53
- package/dist/components/replayFake.js +7 -348
- package/dist/daemon.js +4 -1
- package/dist/fake-upstream-websocket.d.ts +27 -0
- package/dist/fake-upstream-websocket.js +184 -0
- package/dist/fake-upstream.d.ts +114 -0
- package/dist/fake-upstream.js +348 -0
- package/dist/harness/fake-websocket.js +6 -3
- package/dist/harness/live-fake.d.ts +3 -0
- package/dist/harness/live-fake.js +66 -0
- package/dist/harness/sticky-consumer.d.ts +11 -3
- package/dist/harness/sticky-consumer.js +61 -38
- package/dist/harness/sticky-image-retention.d.ts +15 -0
- package/dist/harness/sticky-image-retention.js +32 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +3 -0
- package/dist/record-secrets.d.ts +1 -1
- package/dist/record-secrets.js +3 -3
- package/dist/replay-mode.d.ts +2 -0
- package/dist/replay-mode.js +6 -2
- package/package.json +1 -1
- package/src/components/replay-websocket.ts +25 -145
- package/src/components/replayFake.ts +8 -459
- package/src/daemon.ts +3 -1
- package/src/fake-upstream-websocket.ts +157 -0
- package/src/fake-upstream.ts +465 -0
- package/src/harness/fake-websocket.ts +6 -3
- package/src/harness/live-fake.test.ts +227 -0
- package/src/harness/live-fake.ts +62 -0
- package/src/harness/sticky-consumer.test.ts +93 -23
- package/src/harness/sticky-consumer.ts +62 -40
- package/src/harness/sticky-image-retention.test.ts +59 -0
- package/src/harness/sticky-image-retention.ts +43 -0
- package/src/index.ts +19 -1
- package/src/record-secrets.ts +3 -3
- package/src/replay-mode.ts +7 -2
|
@@ -3,6 +3,7 @@ import { execFile } from "node:child_process";
|
|
|
3
3
|
import { mkdir, readFile, statfs, writeFile } from "node:fs/promises";
|
|
4
4
|
import { STICKY_RUNTIME } from "./sticky-paths.js";
|
|
5
5
|
import { openKubernetesCacheMaintenance } from "./sticky-kubernetes.js";
|
|
6
|
+
import { recordStickyImageUse } from "./sticky-image-retention.js";
|
|
6
7
|
export const STICKY_CAPABILITY = "sticky-disks-v1";
|
|
7
8
|
export const STICKY_COMPATIBILITY = "docker29-ctr21-buildkit032-overlayfs-256g-v2";
|
|
8
9
|
export { STICKY_RUNTIME } from "./sticky-paths.js";
|
|
@@ -35,15 +36,40 @@ export function validateStickyConfig(config) {
|
|
|
35
36
|
if (config.path !== "/var/lib/spectest-sticky" || config.compatibility !== STICKY_COMPATIBILITY ||
|
|
36
37
|
!Number.isSafeInteger(config.capacityBytes) || config.capacityBytes <= 0 ||
|
|
37
38
|
!Number.isSafeInteger(config.headroomBytes) || config.headroomBytes < 16 * 1024 * 1024 ||
|
|
38
|
-
|
|
39
|
-
!Number.isSafeInteger(config.buildkitTargetBytes) || config.buildkitTargetBytes <= 0 ||
|
|
40
|
-
config.buildkitTargetBytes > config.cacheTargetBytes ||
|
|
41
|
-
config.cacheTargetBytes + config.headroomBytes >= config.capacityBytes) {
|
|
39
|
+
config.headroomBytes >= config.capacityBytes) {
|
|
42
40
|
throw new Error("unsupported sticky consumer profile or budget");
|
|
43
41
|
}
|
|
44
42
|
}
|
|
43
|
+
/** Explicit policy avoids BuildKit's default size caps (including its separate
|
|
44
|
+
* cache-mount/source cap). Use physical capacity as the nonbinding max: with
|
|
45
|
+
* only minFreeSpace, BuildKit 0.32.2 sets keepBytes == totalSize when healthy,
|
|
46
|
+
* and pruneOnce's strict '<' check can still evict a record on every GC pass.
|
|
47
|
+
* Actual filesystem capacity is smaller than the device due to metadata. */
|
|
48
|
+
export function stickyBuildkitConfig(config) {
|
|
49
|
+
validateStickyConfig(config);
|
|
50
|
+
return `root = "${config.path}/buildkit"
|
|
51
|
+
[grpc]
|
|
52
|
+
address = ["tcp://127.0.0.1:1234"]
|
|
53
|
+
[worker.oci]
|
|
54
|
+
enabled = false
|
|
55
|
+
[worker.containerd]
|
|
56
|
+
enabled = true
|
|
57
|
+
address = "/run/containerd/containerd.sock"
|
|
58
|
+
namespace = "moby"
|
|
59
|
+
snapshotter = "overlayfs"
|
|
60
|
+
gc = true
|
|
61
|
+
[[worker.containerd.gcpolicy]]
|
|
62
|
+
all = true
|
|
63
|
+
maxUsedSpace = ${config.capacityBytes}
|
|
64
|
+
minFreeSpace = ${config.headroomBytes}
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
45
67
|
export async function initializeStickyConsumer(config) {
|
|
46
68
|
validateStickyConfig(config);
|
|
69
|
+
// Do not persist obsolete caps supplied by an older controller. Maintenance
|
|
70
|
+
// also ignores them when restoring a snapshot written by an older SDK.
|
|
71
|
+
config = { path: config.path, capacityBytes: config.capacityBytes,
|
|
72
|
+
headroomBytes: config.headroomBytes, compatibility: config.compatibility };
|
|
47
73
|
await exec("mountpoint", ["-q", config.path]);
|
|
48
74
|
// The base must have stopped these before its RAM was captured. Refuse an
|
|
49
75
|
// unexpected writer rather than racing it for the standard daemon sockets.
|
|
@@ -72,21 +98,7 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
72
98
|
dockerConfig.features = { "containerd-snapshotter": true };
|
|
73
99
|
dockerConfig["storage-driver"] = "overlayfs";
|
|
74
100
|
await writeFile(`${STICKY_RUNTIME}/docker.json`, JSON.stringify(dockerConfig));
|
|
75
|
-
await writeFile(`${STICKY_RUNTIME}/buildkit.toml`,
|
|
76
|
-
[grpc]
|
|
77
|
-
address = ["tcp://127.0.0.1:1234"]
|
|
78
|
-
[worker.oci]
|
|
79
|
-
enabled = false
|
|
80
|
-
[worker.containerd]
|
|
81
|
-
enabled = true
|
|
82
|
-
address = "/run/containerd/containerd.sock"
|
|
83
|
-
namespace = "moby"
|
|
84
|
-
snapshotter = "overlayfs"
|
|
85
|
-
gc = true
|
|
86
|
-
reservedSpace = "1GB"
|
|
87
|
-
maxUsedSpace = ${config.buildkitTargetBytes}
|
|
88
|
-
minFreeSpace = ${config.headroomBytes}
|
|
89
|
-
`);
|
|
101
|
+
await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config));
|
|
90
102
|
const start = async (unit, program, args) => {
|
|
91
103
|
await exec("systemd-run", ["--quiet", "--unit", unit, "-p", "TimeoutStopSec=30",
|
|
92
104
|
// Pinned BuildKit 0.32.2 returns 1 after its normal first-SIGTERM
|
|
@@ -109,22 +121,33 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
109
121
|
* Only called after runtime containers/volumes have been removed on the
|
|
110
122
|
* isolated publication clone. Never removes files behind the daemons' backs.
|
|
111
123
|
*/
|
|
112
|
-
export async function collectStickyCache(config, run = exec, usage = async () => {
|
|
124
|
+
export async function collectStickyCache(config, retention, run = exec, usage = async () => {
|
|
113
125
|
const fs = await statfs(config.path);
|
|
114
126
|
return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize };
|
|
115
127
|
}, kubernetes) {
|
|
116
128
|
validateStickyConfig(config);
|
|
117
129
|
const buildctl = ["--addr", "tcp://127.0.0.1:1234", "prune"];
|
|
118
130
|
await run("buildctl", [...buildctl, "--keep-duration", "168h"], 120_000);
|
|
119
|
-
|
|
120
|
-
|
|
131
|
+
// Docker's until filter and Created field refer to the publisher's build
|
|
132
|
+
// date, not our use. Never prune a just-used image merely because it is old.
|
|
133
|
+
const protectedImage = (id) => retention.protectedImages.has(id) || kubernetes?.protectedImages.has(id);
|
|
134
|
+
const images = [...retention.images].sort((a, b) => a.lastUsed - b.lastUsed || a.id.localeCompare(b.id));
|
|
135
|
+
const expired = new Set();
|
|
136
|
+
for (const image of images) {
|
|
137
|
+
if (protectedImage(image.id) || image.lastUsed >= retention.observedAt - 7 * 24 * 60 * 60 * 1000)
|
|
138
|
+
continue;
|
|
139
|
+
await run("docker", ["image", "rm", "-f", image.id], 120_000);
|
|
140
|
+
expired.add(image.id);
|
|
141
|
+
}
|
|
142
|
+
const withinTarget = (fs) => fs.freeBytes >= config.headroomBytes;
|
|
121
143
|
let fs = await usage();
|
|
122
|
-
|
|
123
|
-
|
|
144
|
+
if (withinTarget(fs))
|
|
145
|
+
return;
|
|
146
|
+
// All consumers share this filesystem. Reclaim only the missing headroom;
|
|
147
|
+
// a per-consumer cap evicts useful cache even when the disk is mostly empty.
|
|
124
148
|
const pruneBuildkit = async () => {
|
|
125
|
-
await run("buildctl", [...buildctl, "--all", "--
|
|
126
|
-
String(Math.
|
|
127
|
-
String(Math.ceil(Math.max(config.headroomBytes, fs.totalBytes - config.cacheTargetBytes) / 1e6))], 120_000);
|
|
149
|
+
await run("buildctl", [...buildctl, "--all", "--free-storage",
|
|
150
|
+
String(Math.ceil(config.headroomBytes / 1e6))], 120_000);
|
|
128
151
|
fs = await usage();
|
|
129
152
|
};
|
|
130
153
|
await pruneBuildkit();
|
|
@@ -135,16 +158,15 @@ export async function collectStickyCache(config, run = exec, usage = async () =>
|
|
|
135
158
|
if (withinTarget(fs))
|
|
136
159
|
return;
|
|
137
160
|
}
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
const ids = [...new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"])).split(/\s+/).filter(Boolean))];
|
|
141
|
-
const images = ids.length
|
|
142
|
-
? JSON.parse(await run("docker", ["image", "inspect", ...ids])) : [];
|
|
143
|
-
images.sort((a, b) => a.Created.localeCompare(b.Created) || a.Id.localeCompare(b.Id));
|
|
161
|
+
// Under pressure, evict least recently used images, including within the
|
|
162
|
+
// age window, but preserve this environment's images for the next build.
|
|
144
163
|
for (const image of images) {
|
|
145
|
-
if (
|
|
164
|
+
if (expired.has(image.id) || protectedImage(image.id))
|
|
146
165
|
continue;
|
|
147
|
-
await run("docker", ["image", "rm", "-f", image.
|
|
166
|
+
await run("docker", ["image", "rm", "-f", image.id], 120_000);
|
|
167
|
+
fs = await usage();
|
|
168
|
+
if (withinTarget(fs))
|
|
169
|
+
return;
|
|
148
170
|
await pruneBuildkit();
|
|
149
171
|
if (withinTarget(fs))
|
|
150
172
|
return;
|
|
@@ -153,14 +175,15 @@ export async function collectStickyCache(config, run = exec, usage = async () =>
|
|
|
153
175
|
for (let pass = 0; pass < 3 && !withinTarget(fs); pass++)
|
|
154
176
|
await pruneBuildkit();
|
|
155
177
|
if (!withinTarget(fs))
|
|
156
|
-
throw new Error("sticky cache cannot
|
|
178
|
+
throw new Error("sticky cache cannot reclaim required write headroom");
|
|
157
179
|
}
|
|
158
180
|
/** Only on an isolated maintenance clone; never on the live ready environment. */
|
|
159
181
|
export async function prepareStickyConsumer(artifact = false) {
|
|
160
182
|
const config = JSON.parse(await readFile(`${STICKY_RUNTIME}/consumer.json`, "utf8"));
|
|
161
183
|
validateStickyConfig(config);
|
|
162
184
|
if (!artifact) {
|
|
163
|
-
const
|
|
185
|
+
const retention = await recordStickyImageUse(config.path, exec);
|
|
186
|
+
const { containers } = retention;
|
|
164
187
|
if (containers.length)
|
|
165
188
|
await exec("docker", ["rm", "-f", ...containers]);
|
|
166
189
|
const volumes = (await exec("docker", ["volume", "ls", "-q"])).split(/\s+/).filter(Boolean);
|
|
@@ -168,7 +191,7 @@ export async function prepareStickyConsumer(artifact = false) {
|
|
|
168
191
|
await exec("docker", ["volume", "rm", ...volumes]);
|
|
169
192
|
const kubernetes = await openKubernetesCacheMaintenance(exec);
|
|
170
193
|
try {
|
|
171
|
-
await collectStickyCache(config, exec, undefined, kubernetes);
|
|
194
|
+
await collectStickyCache(config, retention, exec, undefined, kubernetes);
|
|
172
195
|
}
|
|
173
196
|
finally {
|
|
174
197
|
await kubernetes.close();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CacheCommand } from "./sticky-kubernetes.js";
|
|
2
|
+
export interface StickyImageRetention {
|
|
3
|
+
images: {
|
|
4
|
+
id: string;
|
|
5
|
+
lastUsed: number;
|
|
6
|
+
}[];
|
|
7
|
+
protectedImages: Set<string>;
|
|
8
|
+
containers: string[];
|
|
9
|
+
observedAt: number;
|
|
10
|
+
}
|
|
11
|
+
/** Call before removing containers from the isolated publication clone.
|
|
12
|
+
* New/legacy images get a first observation; inherited unused images retain
|
|
13
|
+
* their previous timestamp. Rewriting from the inventory drops stale IDs.
|
|
14
|
+
*/
|
|
15
|
+
export declare function recordStickyImageUse(root: string, run: CacheCommand, now?: number): Promise<StickyImageRetention>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Image use belongs to the cache lineage, not the publisher's image clock. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
/** Call before removing containers from the isolated publication clone.
|
|
5
|
+
* New/legacy images get a first observation; inherited unused images retain
|
|
6
|
+
* their previous timestamp. Rewriting from the inventory drops stale IDs.
|
|
7
|
+
*/
|
|
8
|
+
export async function recordStickyImageUse(root, run, now = Date.now()) {
|
|
9
|
+
const path = `${root}/.spectest-image-use.json`;
|
|
10
|
+
const saved = await readFile(path, "utf8").catch((error) => {
|
|
11
|
+
if (error.code === "ENOENT")
|
|
12
|
+
return undefined;
|
|
13
|
+
throw error;
|
|
14
|
+
});
|
|
15
|
+
const previous = saved === undefined ? { schema: 1, images: {} } : JSON.parse(saved);
|
|
16
|
+
if (previous.schema !== 1 || !previous.images || typeof previous.images !== "object" ||
|
|
17
|
+
Array.isArray(previous.images) || Object.values(previous.images).some(value => typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)) {
|
|
18
|
+
throw new Error("invalid sticky image-use ledger");
|
|
19
|
+
}
|
|
20
|
+
const ids = [...new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"]))
|
|
21
|
+
.split(/\s+/).filter(Boolean))];
|
|
22
|
+
const containers = (await run("docker", ["ps", "-aq"])).split(/\s+/).filter(Boolean);
|
|
23
|
+
const protectedImages = new Set(containers.length
|
|
24
|
+
? (await run("docker", ["container", "inspect", "--format", "{{.Image}}", ...containers]))
|
|
25
|
+
.split(/\s+/).filter(Boolean) : []);
|
|
26
|
+
const images = ids.map(id => ({ id, lastUsed: protectedImages.has(id) ? now : previous.images[id] ?? now }));
|
|
27
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
28
|
+
await writeFile(temporary, JSON.stringify({ schema: 1,
|
|
29
|
+
images: Object.fromEntries(images.map(({ id, lastUsed }) => [id, lastUsed])) }));
|
|
30
|
+
await rename(temporary, path);
|
|
31
|
+
return { images, protectedImages, containers, observedAt: now };
|
|
32
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { InjectRule, SignConfig } from "./fake-upstream.js";
|
|
2
|
+
export type { InjectRule, InjectMatch, StringMatch, SignConfig, AwsSigV4Sign } from "./fake-upstream.js";
|
|
1
3
|
export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
|
|
2
4
|
import type { Phone, PhoneOptions } from "./phone.js";
|
|
3
5
|
import type { Android, AndroidApp } from "./android.js";
|
|
@@ -1456,6 +1458,14 @@ export interface ExecOpts {
|
|
|
1456
1458
|
export interface TestSuite<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
|
|
1457
1459
|
tests: TestCase<unknown, S, F>[];
|
|
1458
1460
|
}
|
|
1461
|
+
/** Forward the entire fake to real HTTPS/WSS upstreams in interactive environments.
|
|
1462
|
+
* Tests and shared baselines always use the scripted handlers. No traffic is recorded. */
|
|
1463
|
+
export interface FakeLiveOptions {
|
|
1464
|
+
/** Same header injection rules as replayFake, applied to HTTP and WebSocket handshakes. */
|
|
1465
|
+
inject?: InjectRule[];
|
|
1466
|
+
/** Re-sign HTTP requests using brokered AWS credentials. Not supported for WebSockets. */
|
|
1467
|
+
sign?: SignConfig;
|
|
1468
|
+
}
|
|
1459
1469
|
/**
|
|
1460
1470
|
* A fake server hosted in the harness. Use this to stand in for
|
|
1461
1471
|
* external HTTP APIs (auth providers, payment gateways, …) that you can't
|
|
@@ -1489,6 +1499,10 @@ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Rec
|
|
|
1489
1499
|
* other fakes.
|
|
1490
1500
|
*/
|
|
1491
1501
|
hostnames: readonly string[];
|
|
1502
|
+
/** Opt in to forwarding all HTTP/WebSocket traffic to the declared real hosts
|
|
1503
|
+
* over HTTPS/WSS (port 443) in interactive environments. Errors never fall back
|
|
1504
|
+
* to scripted handlers. Omit to keep the fake scripted in every environment. */
|
|
1505
|
+
live?: FakeLiveOptions;
|
|
1492
1506
|
/** TCP port the fake listens on. Default `80`. */
|
|
1493
1507
|
port?: number;
|
|
1494
1508
|
/** Preserve handler/interceptor CORS policy by default. "permissive"
|
|
@@ -1546,7 +1560,7 @@ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Rec
|
|
|
1546
1560
|
ctx: FakeContext;
|
|
1547
1561
|
}) => H | Promise<H>;
|
|
1548
1562
|
/**
|
|
1549
|
-
* Internal. Platform secret references this fake needs
|
|
1563
|
+
* Internal. Platform secret references this fake needs for upstream access
|
|
1550
1564
|
* (set by {@link replayFake}). The daemon reports the union of these to
|
|
1551
1565
|
* the control plane, which resolves each via its `SecretResolver` and
|
|
1552
1566
|
* supplies them for the environment lifetime, including snapshots. Values
|
package/dist/index.js
CHANGED
|
@@ -407,6 +407,9 @@ export function defineFake(opts) {
|
|
|
407
407
|
throw new Error(`defineFake(${opts.name}): hostname ${JSON.stringify(h)} ends in reserved ".internal" TLD`);
|
|
408
408
|
}
|
|
409
409
|
}
|
|
410
|
+
if (opts.live !== undefined && (!opts.live || typeof opts.live !== "object" || Array.isArray(opts.live))) {
|
|
411
|
+
throw new Error(`defineFake(${opts.name}): \`live\` must be an options object`);
|
|
412
|
+
}
|
|
410
413
|
validateCorsMode(opts.cors);
|
|
411
414
|
if (typeof opts.handler !== "function") {
|
|
412
415
|
throw new Error(`defineFake(${opts.name}): \`handler\` is required`);
|
package/dist/record-secrets.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** Replace the complete environment secret set, including removing stale refs. */
|
|
2
2
|
export declare function setRecordSecrets(secrets: Record<string, string>): void;
|
|
3
|
-
/** Resolve a declared project secret for an outbound recording request. */
|
|
3
|
+
/** Resolve a declared project secret for an outbound live or recording request. */
|
|
4
4
|
export declare function getRecordSecret(ref: string): string | undefined;
|
package/dist/record-secrets.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Environment-lifetime credentials for record-mode fakes. The control plane
|
|
1
|
+
// Environment-lifetime credentials for live and record-mode fakes. The control plane
|
|
2
2
|
// resolves declared references from the owning project's Secrets store before
|
|
3
3
|
// service startup and refreshes them on warm starts and interactive forks.
|
|
4
4
|
// They persist across evals, background requests and memory snapshots, just
|
|
5
5
|
// like other environment state. Test forks inherit their parent's credentials;
|
|
6
|
-
//
|
|
6
|
+
// test mode still uses scripted handlers or cassettes and never forwards upstream.
|
|
7
7
|
// Values stay out of project files, config hashes and recorded cassettes.
|
|
8
8
|
const SECRETS = new Map();
|
|
9
9
|
/** Replace the complete environment secret set, including removing stale refs. */
|
|
@@ -12,7 +12,7 @@ export function setRecordSecrets(secrets) {
|
|
|
12
12
|
for (const [ref, value] of Object.entries(secrets))
|
|
13
13
|
SECRETS.set(ref, value);
|
|
14
14
|
}
|
|
15
|
-
/** Resolve a declared project secret for an outbound recording request. */
|
|
15
|
+
/** Resolve a declared project secret for an outbound live or recording request. */
|
|
16
16
|
export function getRecordSecret(ref) {
|
|
17
17
|
return SECRETS.get(ref);
|
|
18
18
|
}
|
package/dist/replay-mode.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export declare function setReplayEnvironmentMode(value: "test" | "interactive"): void;
|
|
2
2
|
export declare function resolveReplayMode(requested?: "auto" | "record" | "replay"): "record" | "replay";
|
|
3
|
+
/** Shared gate for live and replay fakes. Credentials never enable upstream access. */
|
|
4
|
+
export declare function isInteractiveEnvironment(): boolean;
|
package/dist/replay-mode.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Environment state: travels with memory snapshots, independently of the
|
|
2
|
-
// per-case event recorder. Shared/warm baselines must never
|
|
3
|
-
// The control plane enables
|
|
2
|
+
// per-case event recorder. Shared/warm baselines must never forward upstream.
|
|
3
|
+
// The control plane enables upstream access only on an interactive environment,
|
|
4
4
|
// after capturing/restoring the shared baseline and before interactive setup.
|
|
5
5
|
let mode = "test";
|
|
6
6
|
export function setReplayEnvironmentMode(value) {
|
|
@@ -10,3 +10,7 @@ export function resolveReplayMode(requested = "auto") {
|
|
|
10
10
|
// Even an explicit record option cannot send test traffic to real providers.
|
|
11
11
|
return mode === "test" || requested === "replay" ? "replay" : "record";
|
|
12
12
|
}
|
|
13
|
+
/** Shared gate for live and replay fakes. Credentials never enable upstream access. */
|
|
14
|
+
export function isInteractiveEnvironment() {
|
|
15
|
+
return mode === "interactive";
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { createRequire } from "node:module";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
import type WS from "ws";
|
|
5
2
|
import type { FakeWebSocket, FakeWebSocketHandlers } from "../fake-websocket.js";
|
|
6
3
|
import type { CassetteRequest } from "./replayFake.js";
|
|
7
4
|
import { FakeWebSocketLifetime, bindWebSocketLifetime } from "../harness/fake-websocket-lifetime.js";
|
|
8
5
|
import { hashKey, type MatchKey } from "./replay-key.js";
|
|
9
6
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const { WebSocket } = require(join(dirname(require.resolve("ws/package.json")), "index.js")) as typeof import("ws");
|
|
13
|
-
const MAX_BUFFER = 16 * 1024 * 1024;
|
|
7
|
+
import { MAX_BUFFER, closeSocket, forwardWebSocket, type WebSocketSession } from "../fake-upstream-websocket.js";
|
|
8
|
+
export type { WebSocketSession } from "../fake-upstream-websocket.js";
|
|
14
9
|
const MAX_PENDING = 1024;
|
|
15
10
|
export type ReplayWebSocketMessage = string | Uint8Array;
|
|
16
11
|
export type WebSocketKey<S = unknown> =
|
|
@@ -181,17 +176,6 @@ export function validateWebSocketRecording(record: CassetteWebSocket): void {
|
|
|
181
176
|
function validCloseCode(code: number): boolean {
|
|
182
177
|
return Number.isInteger(code) && ((code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code)) || (code >= 3000 && code <= 4999) || code === 1005 || code === 1006);
|
|
183
178
|
}
|
|
184
|
-
function closeSocket(socket: FakeWebSocket | WS, code: number, reason: string): void {
|
|
185
|
-
// Reserved status codes describe transport closure and cannot go on the wire.
|
|
186
|
-
if (code === 1006 && socket.terminate) socket.terminate();
|
|
187
|
-
else if (code === 1005 || code === 1006) socket.close();
|
|
188
|
-
else socket.close(code, reason);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export interface WebSocketSession {
|
|
192
|
-
cancel(): void;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
179
|
export function replayWebSocket(args: {
|
|
196
180
|
recording: CassetteWebSocket;
|
|
197
181
|
match: WebSocketMatcher;
|
|
@@ -352,24 +336,12 @@ export async function recordWebSocket(args: {
|
|
|
352
336
|
lifetime?: FakeWebSocketLifetime;
|
|
353
337
|
finished(session: WebSocketSession, error?: Error): void;
|
|
354
338
|
}): Promise<{ handlers: FakeWebSocketHandlers; session: WebSocketSession }> {
|
|
355
|
-
const lifetime = args.lifetime ?? new FakeWebSocketLifetime();
|
|
356
|
-
if (lifetime.ended) throw new Error("WebSocket handshake aborted");
|
|
357
|
-
let detach = () => {};
|
|
358
|
-
const upstream = new WebSocket(args.url, args.protocols, {
|
|
359
|
-
headers: args.headers, maxPayload: MAX_BUFFER,
|
|
360
|
-
handshakeTimeout: 10_000,
|
|
361
|
-
lookup: ((_hostname: string, options: { all?: boolean }, callback: Function) => {
|
|
362
|
-
if (options.all) callback(null, [{ address: args.ip, family: 4 }]);
|
|
363
|
-
else callback(null, args.ip, 4);
|
|
364
|
-
}) as any,
|
|
365
|
-
});
|
|
366
339
|
const record: CassetteWebSocket = { request: args.request, inputs: [], messages: [],
|
|
367
340
|
...(args.match.candidates ? { inputMode: "anchors" as const, inputOccurrences: [] } : {}),
|
|
368
341
|
};
|
|
369
342
|
const occurrences = new Map<string, number>();
|
|
370
343
|
let candidate: { key: string; count: number; at: number; index: number } | undefined;
|
|
371
344
|
let candidateIndex = 0, selectedIndex = 0;
|
|
372
|
-
let socket: FakeWebSocket | undefined;
|
|
373
345
|
let anchor = performance.now();
|
|
374
346
|
const selectCandidate = () => {
|
|
375
347
|
if (!candidate || candidate.index === selectedIndex) return;
|
|
@@ -378,131 +350,39 @@ export async function recordWebSocket(args: {
|
|
|
378
350
|
anchor = candidate.at;
|
|
379
351
|
selectedIndex = candidate.index;
|
|
380
352
|
};
|
|
381
|
-
let stopped = false;
|
|
382
|
-
let clientClosed = false;
|
|
383
|
-
let upstreamClosure: { code: number; reason: string } | undefined;
|
|
384
|
-
let queue: ReplayWebSocketMessage[] = [];
|
|
385
|
-
let queuedBytes = 0;
|
|
386
|
-
let openingTimer: ReturnType<typeof setTimeout> | undefined;
|
|
387
|
-
let opened = false;
|
|
388
|
-
let rejectOpen: (error: Error) => void = () => {};
|
|
389
353
|
const timing = () => ({ afterInput: record.inputs.length, delayMs: Math.max(0, performance.now() - anchor) });
|
|
390
354
|
const scrub = (value: string) => {
|
|
391
355
|
const result = args.redact(value);
|
|
392
356
|
for (const secret of args.secrets) if (secret.value && result.includes(secret.value)) throw new Error(`secret ${secret.ref} survived WebSocket redaction`);
|
|
393
357
|
return result;
|
|
394
358
|
};
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
finish(failure);
|
|
410
|
-
socket?.close(1011, "WebSocket recording failed");
|
|
411
|
-
};
|
|
412
|
-
const session: WebSocketSession = {
|
|
413
|
-
cancel() { rejectOpen(new Error("WebSocket handshake aborted")); finish(); socket?.close(1001, "recording cleanup"); },
|
|
414
|
-
};
|
|
415
|
-
upstream.on("error", fail);
|
|
416
|
-
upstream.on("message", (data, binary) => {
|
|
417
|
-
if (stopped || clientClosed) return;
|
|
418
|
-
try {
|
|
419
|
-
const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer);
|
|
420
|
-
const message = binary ? new Uint8Array(bytes) : bytes.toString();
|
|
421
|
-
// Redact bytes too: base64 encoding must not hide a brokered secret.
|
|
422
|
-
const redacted = binary ? redactBinary(bytes, args.redact, args.secrets) : scrub(bytes.toString());
|
|
423
|
-
// Apply the same redacted output reducer that replay will see.
|
|
424
|
-
if (args.match.output?.(binary ? new Uint8Array(Buffer.from(redacted, "base64")) : redacted)) selectCandidate();
|
|
425
|
-
record.messages.push({ ...timing(), type: binary ? "binary" : "text", data: redacted });
|
|
426
|
-
if (socket) socket.send(message);
|
|
427
|
-
else {
|
|
428
|
-
queuedBytes += bytes.length;
|
|
429
|
-
if (queue.length >= MAX_PENDING || queuedBytes > MAX_BUFFER) throw new Error("WebSocket opening buffer exceeded");
|
|
430
|
-
queue.push(message);
|
|
431
|
-
}
|
|
432
|
-
} catch (error) { fail(error); }
|
|
433
|
-
});
|
|
434
|
-
upstream.on("close", (code, reason) => {
|
|
435
|
-
if (stopped) return;
|
|
436
|
-
if (!opened) { fail(new Error("upstream closed before handshake completed")); return; }
|
|
437
|
-
try {
|
|
438
|
-
if (!clientClosed) {
|
|
439
|
-
selectCandidate();
|
|
440
|
-
upstreamClosure = { code, reason: reason.toString() };
|
|
441
|
-
record.close = { ...timing(), initiator: "server", code, reason: scrub(reason.toString()) };
|
|
359
|
+
return forwardWebSocket({ ...args, observe: {
|
|
360
|
+
open(protocol) {
|
|
361
|
+
anchor = performance.now();
|
|
362
|
+
if (protocol) record.protocol = scrub(protocol);
|
|
363
|
+
args.captured(record);
|
|
364
|
+
},
|
|
365
|
+
input(message) {
|
|
366
|
+
const input = typeof message === "string" ? message : Uint8Array.from(message);
|
|
367
|
+
if (args.match.candidates) {
|
|
368
|
+
const at = performance.now();
|
|
369
|
+
for (const key of args.match.candidates(input)) candidate = { key, count: occurrence(occurrences, key), at, index: ++candidateIndex };
|
|
370
|
+
} else {
|
|
371
|
+
const signature = args.match(input);
|
|
372
|
+
if (signature !== null) { record.inputs.push(signature); anchor = performance.now(); }
|
|
442
373
|
}
|
|
443
|
-
if (socket) { closeSocket(socket, code, reason.toString()); finish(); }
|
|
444
|
-
// If the upstream closes before downstream open, open() flushes the queue.
|
|
445
|
-
} catch (error) { fail(error); }
|
|
446
|
-
});
|
|
447
|
-
const opening = new Promise<void>((resolve, reject) => {
|
|
448
|
-
rejectOpen = reject;
|
|
449
|
-
upstream.once("open", () => {
|
|
450
|
-
try {
|
|
451
|
-
opened = true;
|
|
452
|
-
anchor = performance.now();
|
|
453
|
-
if (upstream.protocol) record.protocol = scrub(upstream.protocol);
|
|
454
|
-
args.captured(record);
|
|
455
|
-
openingTimer = setTimeout(() => fail(new Error("downstream did not open")), 10_000);
|
|
456
|
-
resolve();
|
|
457
|
-
} catch (error) { fail(error); }
|
|
458
|
-
});
|
|
459
|
-
});
|
|
460
|
-
const handlers: FakeWebSocketHandlers = {
|
|
461
|
-
open(ws) {
|
|
462
|
-
socket = ws;
|
|
463
|
-
clearTimeout(openingTimer);
|
|
464
|
-
if (stopped) { ws.close(1011, "WebSocket recording failed"); return; }
|
|
465
|
-
try {
|
|
466
|
-
for (const message of queue) ws.send(message);
|
|
467
|
-
queue = []; queuedBytes = 0;
|
|
468
|
-
if (upstreamClosure) { closeSocket(ws, upstreamClosure.code, upstreamClosure.reason); finish(); }
|
|
469
|
-
} catch (error) { fail(error); }
|
|
470
374
|
},
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
const at = performance.now();
|
|
477
|
-
for (const key of args.match.candidates(input)) candidate = { key, count: occurrence(occurrences, key), at, index: ++candidateIndex };
|
|
478
|
-
} else {
|
|
479
|
-
const signature = args.match(input);
|
|
480
|
-
if (signature !== null) { record.inputs.push(signature); anchor = performance.now(); }
|
|
481
|
-
}
|
|
482
|
-
const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
|
|
483
|
-
if (size + upstream.bufferedAmount > MAX_BUFFER) throw new Error("WebSocket upstream buffer exceeded");
|
|
484
|
-
if (upstream.readyState !== WebSocket.OPEN) throw new Error("upstream is not open");
|
|
485
|
-
upstream.send(message);
|
|
486
|
-
} catch (error) { fail(error); throw error; }
|
|
375
|
+
output(message) {
|
|
376
|
+
const binary = typeof message !== "string";
|
|
377
|
+
const redacted = binary ? redactBinary(Buffer.from(message), args.redact, args.secrets) : scrub(message);
|
|
378
|
+
if (args.match.output?.(binary ? new Uint8Array(Buffer.from(redacted, "base64")) : redacted)) selectCandidate();
|
|
379
|
+
record.messages.push({ ...timing(), type: binary ? "binary" : "text", data: redacted });
|
|
487
380
|
},
|
|
488
|
-
close(
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
clientClosed = true;
|
|
492
|
-
selectCandidate();
|
|
493
|
-
record.close = { ...timing(), initiator: "client", code, reason: scrub(reason) };
|
|
494
|
-
closeSocket(upstream, code, reason);
|
|
495
|
-
openingTimer = setTimeout(() => finish(), 1_000);
|
|
496
|
-
} catch (error) { fail(error); }
|
|
381
|
+
close(code, reason, initiator) {
|
|
382
|
+
selectCandidate();
|
|
383
|
+
record.close = { ...timing(), initiator, code, reason: scrub(reason) };
|
|
497
384
|
},
|
|
498
|
-
};
|
|
499
|
-
detach = lifetime.onEnd(event => {
|
|
500
|
-
if (event.kind === "closed" && socket) handlers.close!(socket, event.code, event.reason);
|
|
501
|
-
else session.cancel();
|
|
502
|
-
});
|
|
503
|
-
await opening;
|
|
504
|
-
handlers.protocol = upstream.protocol || undefined;
|
|
505
|
-
return { session, handlers: bindWebSocketLifetime(handlers, lifetime) };
|
|
385
|
+
} });
|
|
506
386
|
}
|
|
507
387
|
|
|
508
388
|
function redactBinary(bytes: Buffer, redact: (text: string) => string, secrets: { ref: string; value: string }[]): string {
|