@dynamicagents/core 0.9.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/LICENSE +201 -0
- package/README.md +522 -0
- package/dist/a2a/agent-stub.d.ts +60 -0
- package/dist/a2a/agent-stub.js +1 -0
- package/dist/a2a/caller-token.d.ts +44 -0
- package/dist/a2a/caller-token.js +61 -0
- package/dist/a2a/caller.d.ts +23 -0
- package/dist/a2a/caller.js +33 -0
- package/dist/a2a/card.d.ts +158 -0
- package/dist/a2a/card.js +163 -0
- package/dist/a2a/context.d.ts +19 -0
- package/dist/a2a/context.js +61 -0
- package/dist/a2a/deliver.d.ts +159 -0
- package/dist/a2a/deliver.js +185 -0
- package/dist/a2a/executor.d.ts +84 -0
- package/dist/a2a/executor.js +106 -0
- package/dist/a2a/index.d.ts +35 -0
- package/dist/a2a/index.js +34 -0
- package/dist/a2a/notify.d.ts +106 -0
- package/dist/a2a/notify.js +180 -0
- package/dist/a2a/parts.d.ts +45 -0
- package/dist/a2a/parts.js +94 -0
- package/dist/a2a/push.d.ts +70 -0
- package/dist/a2a/push.js +53 -0
- package/dist/a2a/self-origin.d.ts +91 -0
- package/dist/a2a/self-origin.js +114 -0
- package/dist/a2a/task-store.d.ts +44 -0
- package/dist/a2a/task-store.js +99 -0
- package/dist/a2a/task.d.ts +85 -0
- package/dist/a2a/task.js +15 -0
- package/dist/a2a/verify.d.ts +80 -0
- package/dist/a2a/verify.js +143 -0
- package/dist/agent/budget.d.ts +46 -0
- package/dist/agent/budget.js +42 -0
- package/dist/agent/control.d.ts +109 -0
- package/dist/agent/control.js +115 -0
- package/dist/agent/errors.d.ts +85 -0
- package/dist/agent/errors.js +64 -0
- package/dist/agent/final-reply.d.ts +49 -0
- package/dist/agent/final-reply.js +68 -0
- package/dist/agent/history.d.ts +97 -0
- package/dist/agent/history.js +133 -0
- package/dist/agent/index.d.ts +29 -0
- package/dist/agent/index.js +29 -0
- package/dist/agent/inference.d.ts +110 -0
- package/dist/agent/inference.js +120 -0
- package/dist/agent/model.d.ts +90 -0
- package/dist/agent/model.js +1 -0
- package/dist/agent/session.d.ts +100 -0
- package/dist/agent/session.js +82 -0
- package/dist/agent/workers-ai/index.d.ts +23 -0
- package/dist/agent/workers-ai/index.js +23 -0
- package/dist/agent/workers-ai/runtime.d.ts +42 -0
- package/dist/agent/workers-ai/runtime.js +63 -0
- package/dist/alarm/index.d.ts +77 -0
- package/dist/alarm/index.js +116 -0
- package/dist/config.d.ts +202 -0
- package/dist/config.js +135 -0
- package/dist/contract/index.d.ts +9 -0
- package/dist/contract/index.js +8 -0
- package/dist/contract/plugin.d.ts +324 -0
- package/dist/contract/plugin.js +114 -0
- package/dist/contract/recipe.d.ts +180 -0
- package/dist/contract/recipe.js +1 -0
- package/dist/contract/validation.d.ts +91 -0
- package/dist/contract/validation.js +84 -0
- package/dist/db/db.d.ts +147 -0
- package/dist/db/db.js +90 -0
- package/dist/db/index.d.ts +8 -0
- package/dist/db/index.js +8 -0
- package/dist/db/migrations/index.d.ts +20 -0
- package/dist/db/migrations/index.js +60 -0
- package/dist/db/models/subtasks.d.ts +100 -0
- package/dist/db/models/subtasks.js +241 -0
- package/dist/db/models/tasks.d.ts +118 -0
- package/dist/db/models/tasks.js +274 -0
- package/dist/db/schema.d.ts +468 -0
- package/dist/db/schema.js +88 -0
- package/dist/env.d.ts +53 -0
- package/dist/env.js +47 -0
- package/dist/host/agent.d.ts +305 -0
- package/dist/host/agent.js +400 -0
- package/dist/host/index.d.ts +20 -0
- package/dist/host/index.js +19 -0
- package/dist/host/plugin-host.d.ts +42 -0
- package/dist/host/plugin-host.js +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +17 -0
- package/dist/job/index.d.ts +21 -0
- package/dist/job/index.js +21 -0
- package/dist/job/lifecycle.d.ts +176 -0
- package/dist/job/lifecycle.js +230 -0
- package/dist/job/state.d.ts +92 -0
- package/dist/job/state.js +40 -0
- package/dist/platform.d.ts +138 -0
- package/dist/platform.js +140 -0
- package/dist/round/agent.d.ts +271 -0
- package/dist/round/agent.js +678 -0
- package/dist/round/index.d.ts +25 -0
- package/dist/round/index.js +23 -0
- package/dist/round/policy.d.ts +98 -0
- package/dist/round/policy.js +1 -0
- package/dist/round/subagent.d.ts +87 -0
- package/dist/round/subagent.js +108 -0
- package/dist/round/turn.d.ts +249 -0
- package/dist/round/turn.js +564 -0
- package/dist/round/workflow.d.ts +147 -0
- package/dist/round/workflow.js +387 -0
- package/dist/runtime/index.d.ts +127 -0
- package/dist/runtime/index.js +186 -0
- package/dist/runtime/tool-families.d.ts +32 -0
- package/dist/runtime/tool-families.js +61 -0
- package/dist/subagent/fingerprint.d.ts +37 -0
- package/dist/subagent/fingerprint.js +92 -0
- package/dist/subagent/index.d.ts +169 -0
- package/dist/subagent/index.js +330 -0
- package/dist/subagent/prompt.d.ts +32 -0
- package/dist/subagent/prompt.js +37 -0
- package/dist/subagent/run.d.ts +157 -0
- package/dist/subagent/run.js +540 -0
- package/dist/subagent/workspace.d.ts +85 -0
- package/dist/subagent/workspace.js +127 -0
- package/dist/subtasks/catalog.d.ts +40 -0
- package/dist/subtasks/catalog.js +36 -0
- package/dist/subtasks/decomposition.d.ts +85 -0
- package/dist/subtasks/decomposition.js +156 -0
- package/dist/subtasks/delegate.d.ts +120 -0
- package/dist/subtasks/delegate.js +131 -0
- package/dist/subtasks/index.d.ts +9 -0
- package/dist/subtasks/index.js +9 -0
- package/dist/subtasks/subtask-types.d.ts +91 -0
- package/dist/subtasks/subtask-types.js +103 -0
- package/dist/subtasks/types.d.ts +295 -0
- package/dist/subtasks/types.js +15 -0
- package/dist/testing/auth.d.ts +34 -0
- package/dist/testing/auth.js +35 -0
- package/dist/testing/do.d.ts +29 -0
- package/dist/testing/do.js +25 -0
- package/dist/testing/fake-session.d.ts +26 -0
- package/dist/testing/fake-session.js +37 -0
- package/dist/testing/fixtures.d.ts +64 -0
- package/dist/testing/fixtures.js +104 -0
- package/dist/testing/harness.d.ts +97 -0
- package/dist/testing/harness.js +138 -0
- package/dist/testing/index.d.ts +31 -0
- package/dist/testing/index.js +35 -0
- package/dist/testing/mock-model.d.ts +77 -0
- package/dist/testing/mock-model.js +136 -0
- package/dist/testing/node.d.ts +56 -0
- package/dist/testing/node.js +56 -0
- package/dist/testing/vcr-global-setup.d.ts +12 -0
- package/dist/testing/vcr-global-setup.js +15 -0
- package/dist/testing/vcr-shared.d.ts +38 -0
- package/dist/testing/vcr-shared.js +33 -0
- package/dist/testing/vcr-spec.d.ts +25 -0
- package/dist/testing/vcr-spec.js +124 -0
- package/dist/testing/vcr-store.d.ts +86 -0
- package/dist/testing/vcr-store.js +191 -0
- package/dist/testing/vcr.d.ts +117 -0
- package/dist/testing/vcr.js +275 -0
- package/dist/worker/define-agent.d.ts +123 -0
- package/dist/worker/define-agent.js +20 -0
- package/dist/worker/index.d.ts +218 -0
- package/dist/worker/index.js +369 -0
- package/eslint-rules/index.js +31 -0
- package/eslint-rules/no-deprecated-object-properties.js +81 -0
- package/package.json +178 -0
- package/scripts/generate-keys.mjs +48 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cassette load / match / write. **Node realm** (`node:fs`, `node:crypto`), no
|
|
3
|
+
* network and no `undici` — the recorder that drives this lives in `vcr.ts`.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this replaced undici's `SnapshotAgent`
|
|
6
|
+
*
|
|
7
|
+
* `SnapshotAgent` keys every entry on a hash of method + URL + *every
|
|
8
|
+
* non-excluded request header*. In a Worker test those headers are runtime
|
|
9
|
+
* artifacts: a cassette recorded through the pool carries `user-agent: undici`,
|
|
10
|
+
* `cf-worker: vitest-pool-workers-runner-.example.com`, `sec-fetch-mode` and
|
|
11
|
+
* `accept-encoding`. Bump miniflare or workerd, any one of them changes, and
|
|
12
|
+
* every entry in every committed cassette misses at once — a whole suite going
|
|
13
|
+
* red with `No snapshot found` and nothing to point at.
|
|
14
|
+
*
|
|
15
|
+
* So the key here is **method + URL + request body**, and nothing else. Headers
|
|
16
|
+
* are still stored (minus secrets, see {@link CassetteOptions.excludeHeaders})
|
|
17
|
+
* because they are useful to read in a diff, but they cannot break a replay.
|
|
18
|
+
*
|
|
19
|
+
* The on-disk format owes `SnapshotAgent` nothing: a flat array of
|
|
20
|
+
* {@link CassetteEntry}. Cassettes it wrote are not readable, and deliberately
|
|
21
|
+
* so — its recorder keyed on `String(opts.body)`, and a Worker's POST body
|
|
22
|
+
* reaches a dispatcher as a `ReadableStream`, so every streamed request it ever
|
|
23
|
+
* captured stored the literal text `[object ReadableStream]` where the payload
|
|
24
|
+
* belonged. Those bodies are not recoverable from the file, and a reader that
|
|
25
|
+
* papered over it would have to match such entries on method + URL alone —
|
|
26
|
+
* quietly reintroducing the ambiguity this store exists to remove. Re-record
|
|
27
|
+
* instead.
|
|
28
|
+
*/
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
/**
|
|
33
|
+
* Headers that describe how a body was framed on the wire, which is not how it
|
|
34
|
+
* is framed on replay: bodies are stored decoded and are handed back whole, so
|
|
35
|
+
* replaying `transfer-encoding: chunked` or a stale `content-length` mis-frames
|
|
36
|
+
* the response, and `content-encoding: gzip` makes the consumer try to inflate
|
|
37
|
+
* plaintext. Stripped on the way out, kept in the file.
|
|
38
|
+
*/
|
|
39
|
+
const HOP_BY_HOP = new Set([
|
|
40
|
+
"connection",
|
|
41
|
+
"content-encoding",
|
|
42
|
+
"content-length",
|
|
43
|
+
"keep-alive",
|
|
44
|
+
"proxy-authenticate",
|
|
45
|
+
"proxy-authorization",
|
|
46
|
+
"te",
|
|
47
|
+
"trailer",
|
|
48
|
+
"transfer-encoding",
|
|
49
|
+
"upgrade"
|
|
50
|
+
]);
|
|
51
|
+
/** `${METHOD}\n${url}\n${sha256(body)}` — the whole matching rule. */
|
|
52
|
+
export function requestKey(method, url, body) {
|
|
53
|
+
const digest = createHash("sha256").update(body, "utf8").digest("base64url");
|
|
54
|
+
return `${method.toUpperCase()}\n${url}\n${digest}`;
|
|
55
|
+
}
|
|
56
|
+
function filterHeaders(headers, exclude) {
|
|
57
|
+
const kept = {};
|
|
58
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
59
|
+
if (!exclude.has(name.toLowerCase()))
|
|
60
|
+
kept[name.toLowerCase()] = value;
|
|
61
|
+
}
|
|
62
|
+
return kept;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Turn a stored response into the pieces a `Response` is built from: body
|
|
66
|
+
* decoded from base64, headers flattened (a repeated header such as
|
|
67
|
+
* `set-cookie` becomes one entry per value) and {@link HOP_BY_HOP} dropped.
|
|
68
|
+
*
|
|
69
|
+
* Used on both paths, so a response served straight after recording is framed
|
|
70
|
+
* exactly as the same response will be on replay.
|
|
71
|
+
*/
|
|
72
|
+
export function replayable(response) {
|
|
73
|
+
const headers = [];
|
|
74
|
+
for (const [name, value] of Object.entries(response.headers)) {
|
|
75
|
+
if (HOP_BY_HOP.has(name.toLowerCase()))
|
|
76
|
+
continue;
|
|
77
|
+
for (const one of Array.isArray(value) ? value : [value]) {
|
|
78
|
+
headers.push([name, one]);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
status: response.statusCode,
|
|
83
|
+
headers,
|
|
84
|
+
body: Buffer.from(response.body, "base64")
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* One cassette file, loaded lazily and written only when it has changed.
|
|
89
|
+
*
|
|
90
|
+
* Playback is **read-only**: nothing here mutates on a replay, which is what
|
|
91
|
+
* stops a plain `npm test` from dirtying committed cassettes. The sequence
|
|
92
|
+
* counter ({@link #calls}) is in memory and dies with the process, deliberately
|
|
93
|
+
* — `SnapshotAgent` persisted its equivalent and re-saved on close, so every
|
|
94
|
+
* run left every cassette modified in git.
|
|
95
|
+
*/
|
|
96
|
+
export class Cassette {
|
|
97
|
+
#file;
|
|
98
|
+
#exclude;
|
|
99
|
+
/** Insertion-ordered, keyed by {@link requestKey}. */
|
|
100
|
+
#entries = new Map();
|
|
101
|
+
/** How many times each key has been replayed *this run*. Never persisted. */
|
|
102
|
+
#calls = new Map();
|
|
103
|
+
#dirty = false;
|
|
104
|
+
constructor(file, options = {}) {
|
|
105
|
+
this.#file = file;
|
|
106
|
+
this.#exclude = new Set((options.excludeHeaders ?? []).map((h) => h.toLowerCase()));
|
|
107
|
+
}
|
|
108
|
+
get file() {
|
|
109
|
+
return this.#file;
|
|
110
|
+
}
|
|
111
|
+
/** Every request in the file, as `METHOD url`, for error messages. */
|
|
112
|
+
get recorded() {
|
|
113
|
+
return [...this.#entries.values()].map((e) => `${e.request.method} ${e.request.url}`);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Read the file into memory, keying each entry by its own request rather than
|
|
117
|
+
* trusting anything stored alongside it.
|
|
118
|
+
*
|
|
119
|
+
* Two entries that key the same are a mistake rather than a merge: a request
|
|
120
|
+
* that was issued twice belongs in one entry with two `responses`, which is
|
|
121
|
+
* what {@link match} walks. Saying so here turns a hand-edit slip into a named
|
|
122
|
+
* error instead of a replay that silently serves the wrong response second.
|
|
123
|
+
*/
|
|
124
|
+
load() {
|
|
125
|
+
if (!existsSync(this.#file))
|
|
126
|
+
return;
|
|
127
|
+
const raw = JSON.parse(readFileSync(this.#file, "utf8"));
|
|
128
|
+
if (!Array.isArray(raw)) {
|
|
129
|
+
throw new Error(`VCR cassette ${this.#file} is not a JSON array`);
|
|
130
|
+
}
|
|
131
|
+
for (const entry of raw) {
|
|
132
|
+
const { request, responses } = entry;
|
|
133
|
+
const key = requestKey(request.method, request.url, request.body ?? "");
|
|
134
|
+
if (this.#entries.has(key)) {
|
|
135
|
+
throw new Error(`VCR cassette ${path.basename(this.#file)} has two entries for ` +
|
|
136
|
+
`${request.method} ${request.url} with the same body. Put the ` +
|
|
137
|
+
`responses in one entry's \`responses\` array instead.`);
|
|
138
|
+
}
|
|
139
|
+
this.#entries.set(key, { request, responses: [...responses] });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The next recorded response for this request, or `null` if it was never
|
|
144
|
+
* recorded.
|
|
145
|
+
*
|
|
146
|
+
* Repeated identical requests walk `responses` in order and then **hold on
|
|
147
|
+
* the last one**, so a recording that captured a single response replays it
|
|
148
|
+
* for every call — which is what a poll loop needs when it runs a different
|
|
149
|
+
* number of times on replay than it did while recording.
|
|
150
|
+
*/
|
|
151
|
+
match(method, url, body) {
|
|
152
|
+
const key = requestKey(method, url, body);
|
|
153
|
+
const entry = this.#entries.get(key);
|
|
154
|
+
if (!entry)
|
|
155
|
+
return null;
|
|
156
|
+
const call = this.#calls.get(key) ?? 0;
|
|
157
|
+
this.#calls.set(key, call + 1);
|
|
158
|
+
return replayable(entry.responses[Math.min(call, entry.responses.length - 1)]);
|
|
159
|
+
}
|
|
160
|
+
/** Append a live response. Repeats of a request extend its `responses`. */
|
|
161
|
+
record(request, response) {
|
|
162
|
+
const key = requestKey(request.method, request.url, request.body);
|
|
163
|
+
const stored = {
|
|
164
|
+
...response,
|
|
165
|
+
headers: filterHeaders(response.headers, this.#exclude)
|
|
166
|
+
};
|
|
167
|
+
const entry = this.#entries.get(key);
|
|
168
|
+
if (entry) {
|
|
169
|
+
entry.responses.push(stored);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
this.#entries.set(key, {
|
|
173
|
+
request: {
|
|
174
|
+
...request,
|
|
175
|
+
headers: filterHeaders(request.headers, this.#exclude)
|
|
176
|
+
},
|
|
177
|
+
responses: [stored]
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
this.#dirty = true;
|
|
181
|
+
}
|
|
182
|
+
/** Write if anything was recorded since the last flush. A no-op in playback. */
|
|
183
|
+
flush() {
|
|
184
|
+
if (!this.#dirty)
|
|
185
|
+
return;
|
|
186
|
+
mkdirSync(path.dirname(this.#file), { recursive: true });
|
|
187
|
+
const entries = [...this.#entries.values()];
|
|
188
|
+
writeFileSync(this.#file, `${JSON.stringify(entries, null, 2)}\n`);
|
|
189
|
+
this.#dirty = false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The **Node realm** half of the VCR harness: a Miniflare `outboundService`.
|
|
3
|
+
*
|
|
4
|
+
* ## Why `outboundService` and not `fetchMock`
|
|
5
|
+
*
|
|
6
|
+
* Miniflare 4 implemented `fetchMock` as one line of sugar over this same hook —
|
|
7
|
+
* `outboundService = (req) => fetch(req, { dispatcher: fetchMock })` — and
|
|
8
|
+
* Miniflare 5 dropped `fetchMock` entirely, keeping only `outboundService`.
|
|
9
|
+
* `@cloudflare/vitest-pool-workers` 0.20 removed it from its override options to
|
|
10
|
+
* match, saying so in as many words: *"`fetchMock`: you should use
|
|
11
|
+
* `outboundService` instead"*. Targeting the hook rather than the sugar is what
|
|
12
|
+
* lets one harness serve pool 0.18 through 0.20+.
|
|
13
|
+
*
|
|
14
|
+
* It also drops two constraints that had nothing to do with recording:
|
|
15
|
+
*
|
|
16
|
+
* - **No `undici` peer.** `fetchMock` was validated with
|
|
17
|
+
* `z.instanceof(MockAgent)` against *Miniflare's own* undici, so a second copy
|
|
18
|
+
* in `node_modules` failed with `Input not instance of MockAgent` and the peer
|
|
19
|
+
* range had to track Miniflare's exact pin. `outboundService` has no
|
|
20
|
+
* `instanceof` check in either direction — a foreign `Response` is re-wrapped.
|
|
21
|
+
* - **No silent no-op.** An unknown key in the `miniflare` options is ignored,
|
|
22
|
+
* which is how a harness wired to `fetchMock` on pool 0.20 failed: every
|
|
23
|
+
* request escaped to the real network and died as `internal error;
|
|
24
|
+
* reference = …`, naming nothing. `setupRecording()` now proves the recorder
|
|
25
|
+
* answered before a test runs — see {@link VCR_MARKER_HEADER}.
|
|
26
|
+
*
|
|
27
|
+
* ## How a request is routed
|
|
28
|
+
*
|
|
29
|
+
* Every outbound `fetch()` from workerd arrives here as a standard `Request`,
|
|
30
|
+
* with the true target URL already restored by Miniflare, and is dispatched on
|
|
31
|
+
* host alone:
|
|
32
|
+
*
|
|
33
|
+
* - **The control channel** ({@link VCR_CONTROL_ORIGIN}) — specs run in workerd
|
|
34
|
+
* and have no filesystem, so an in-band `fetch` is the only way to say which
|
|
35
|
+
* cassette the current test is using. See `setupRecording` (vcr-spec.ts).
|
|
36
|
+
* - **A {@link VcrOptions.handlers} host** — a consumer-supplied stub, for
|
|
37
|
+
* things that are fixtures rather than recordings (a gatekeeper JWKS).
|
|
38
|
+
* - **An {@link VcrOptions.allowNetworkHosts} host** — the real network, always.
|
|
39
|
+
* - **Anything else, cassette active** — recorded or replayed against it.
|
|
40
|
+
* - **Anything else, no cassette** — blocked. A test that was never wired for
|
|
41
|
+
* recording cannot reach the network by accident.
|
|
42
|
+
*/
|
|
43
|
+
/** Minimal shape of the `Request` Miniflare hands an outbound service. */
|
|
44
|
+
export interface VcrRequestHeaders {
|
|
45
|
+
forEach(callback: (value: string, name: string) => void): void;
|
|
46
|
+
get(name: string): string | null;
|
|
47
|
+
}
|
|
48
|
+
/** Minimal shape of the `Request` Miniflare hands an outbound service. */
|
|
49
|
+
export interface VcrRequest {
|
|
50
|
+
readonly url: string;
|
|
51
|
+
readonly method: string;
|
|
52
|
+
readonly headers: VcrRequestHeaders;
|
|
53
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Structural on purpose: core does not depend on `miniflare`, and this type
|
|
57
|
+
* assigns to `WorkerOptions["outboundService"]` under both Miniflare 4 and 5.
|
|
58
|
+
*/
|
|
59
|
+
export type VcrOutboundService = (request: VcrRequest) => Promise<Response>;
|
|
60
|
+
export interface VcrOptions {
|
|
61
|
+
/** Absolute path of the cassettes directory. */
|
|
62
|
+
snapshotsDir: string;
|
|
63
|
+
/**
|
|
64
|
+
* true → every activated cassette captures live traffic, replacing whatever
|
|
65
|
+
* was there. Recording never loads the existing file first, so a recording run
|
|
66
|
+
* always reflects the live API rather than half of a stale one.
|
|
67
|
+
* false → replay, and no request ever leaves the machine.
|
|
68
|
+
*/
|
|
69
|
+
record: boolean;
|
|
70
|
+
/** Hosts answered by a stub instead of a cassette (e.g. a gatekeeper JWKS). */
|
|
71
|
+
handlers?: Record<string, (request: VcrRequest) => Response | Promise<Response>>;
|
|
72
|
+
/** Hosts that always reach the real network, recorded or not. */
|
|
73
|
+
allowNetworkHosts?: string[];
|
|
74
|
+
/** Request *and* response headers never written to a cassette (API keys, auth). */
|
|
75
|
+
excludeHeaders?: string[];
|
|
76
|
+
}
|
|
77
|
+
export interface Vcr {
|
|
78
|
+
/** Hand this to `cloudflareTest({ miniflare: { outboundService } })`. */
|
|
79
|
+
readonly outboundService: VcrOutboundService;
|
|
80
|
+
/** Flush every cassette. Safe to call more than once. */
|
|
81
|
+
close(): Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Build the recorder and register it for {@link closeVcr}.
|
|
85
|
+
*
|
|
86
|
+
* ```ts
|
|
87
|
+
* // vitest.config.ts
|
|
88
|
+
* const vcr = createVcr({
|
|
89
|
+
* snapshotsDir: path.resolve(import.meta.dirname, "test/snapshots"),
|
|
90
|
+
* record: recordFromEnv(),
|
|
91
|
+
* excludeHeaders: ["x-api-key", "authorization", "cookie", "set-cookie"]
|
|
92
|
+
* });
|
|
93
|
+
*
|
|
94
|
+
* export default defineConfig({
|
|
95
|
+
* plugins: [cloudflareTest({ miniflare: { outboundService: vcr.outboundService } })],
|
|
96
|
+
* test: { globalSetup: ["@dynamicagents/core/testing/vcr-global-setup"] }
|
|
97
|
+
* });
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export declare function createVcr(options: VcrOptions): Vcr;
|
|
101
|
+
/**
|
|
102
|
+
* Whether this run records, from `RECORD`.
|
|
103
|
+
*
|
|
104
|
+
* Compared against `"1"` rather than tested for truthiness, because every
|
|
105
|
+
* non-empty string is truthy: `RECORD=0` and `RECORD=false` — the two things
|
|
106
|
+
* someone reaches for to turn recording *off* — would otherwise turn it on and
|
|
107
|
+
* overwrite committed cassettes with live traffic. The opposite mistake
|
|
108
|
+
* (`RECORD=true` not recording) fails loudly on the next assertion and costs
|
|
109
|
+
* nothing.
|
|
110
|
+
*/
|
|
111
|
+
export declare function recordFromEnv(): boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Flush every cassette. Cassettes are already written when each one is
|
|
114
|
+
* released, so this is a safety net for a run that ended without releasing —
|
|
115
|
+
* and a no-op if no recorder was created.
|
|
116
|
+
*/
|
|
117
|
+
export declare function closeVcr(): Promise<void>;
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Cassette, replayable } from "./vcr-store.js";
|
|
4
|
+
import { VCR_CONTROL_ORIGIN, VCR_MARKER_HEADER, CASSETTE_NAME_RE } from "./vcr-shared.js";
|
|
5
|
+
const CONTROL_HOST = new URL(VCR_CONTROL_ORIGIN).host;
|
|
6
|
+
/**
|
|
7
|
+
* Request headers dropped before a live call. `host` and `content-length` are
|
|
8
|
+
* recomputed by the outgoing fetch and a stale value corrupts it;
|
|
9
|
+
* `accept-encoding` is dropped so the body arrives as plaintext to store; the
|
|
10
|
+
* rest are hop-by-hop or Miniflare's own loopback bookkeeping.
|
|
11
|
+
*/
|
|
12
|
+
const SKIP_REQUEST_HEADERS = new Set([
|
|
13
|
+
"accept-encoding",
|
|
14
|
+
"connection",
|
|
15
|
+
"content-length",
|
|
16
|
+
"host",
|
|
17
|
+
"keep-alive",
|
|
18
|
+
"te",
|
|
19
|
+
"trailer",
|
|
20
|
+
"transfer-encoding",
|
|
21
|
+
"upgrade"
|
|
22
|
+
]);
|
|
23
|
+
function headerRecord(headers) {
|
|
24
|
+
const record = {};
|
|
25
|
+
headers.forEach((value, name) => {
|
|
26
|
+
record[name.toLowerCase()] = value;
|
|
27
|
+
});
|
|
28
|
+
return record;
|
|
29
|
+
}
|
|
30
|
+
/** `set-cookie` is the one header `forEach` folds together destructively. */
|
|
31
|
+
function responseHeaderRecord(headers) {
|
|
32
|
+
const record = {};
|
|
33
|
+
headers.forEach((value, name) => {
|
|
34
|
+
record[name.toLowerCase()] = value;
|
|
35
|
+
});
|
|
36
|
+
const cookies = headers.getSetCookie?.() ?? [];
|
|
37
|
+
if (cookies.length > 0)
|
|
38
|
+
record["set-cookie"] = cookies;
|
|
39
|
+
return record;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The one place a `Response` is built, so recording and replaying a given
|
|
43
|
+
* cassette entry cannot disagree about it.
|
|
44
|
+
*
|
|
45
|
+
* `Response` rejects *any* non-null body on 204/205/304 — an empty `Uint8Array`
|
|
46
|
+
* throws just as a full one does — so a recorded 204 has to be handed back with
|
|
47
|
+
* a null body or it cannot be replayed at all.
|
|
48
|
+
*/
|
|
49
|
+
function toResponse({ status, headers, body }) {
|
|
50
|
+
const nullBody = status === 204 || status === 205 || status === 304;
|
|
51
|
+
return new Response(nullBody ? null : body, { status, headers });
|
|
52
|
+
}
|
|
53
|
+
function json(status, value) {
|
|
54
|
+
return new Response(JSON.stringify(value), {
|
|
55
|
+
status,
|
|
56
|
+
headers: {
|
|
57
|
+
"content-type": "application/json",
|
|
58
|
+
[VCR_MARKER_HEADER]: "1"
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
class Recorder {
|
|
63
|
+
#snapshotsDir;
|
|
64
|
+
#record;
|
|
65
|
+
#handlers;
|
|
66
|
+
#allowNetwork;
|
|
67
|
+
#excludeHeaders;
|
|
68
|
+
/** One cassette per name, created on first activation. */
|
|
69
|
+
#cassettes = new Map();
|
|
70
|
+
#active = null;
|
|
71
|
+
#activeName = null;
|
|
72
|
+
/** Requests that could not be served since the active cassette was set. */
|
|
73
|
+
#misses = [];
|
|
74
|
+
constructor(options) {
|
|
75
|
+
this.#snapshotsDir = options.snapshotsDir;
|
|
76
|
+
this.#record = options.record;
|
|
77
|
+
this.#handlers = options.handlers ?? {};
|
|
78
|
+
this.#allowNetwork = new Set(options.allowNetworkHosts ?? []);
|
|
79
|
+
this.#excludeHeaders = options.excludeHeaders ?? [];
|
|
80
|
+
}
|
|
81
|
+
handle = async (request) => {
|
|
82
|
+
const url = new URL(request.url);
|
|
83
|
+
if (url.host === CONTROL_HOST)
|
|
84
|
+
return this.#control(url);
|
|
85
|
+
const handler = this.#handlers[url.host];
|
|
86
|
+
if (handler)
|
|
87
|
+
return handler(request);
|
|
88
|
+
// Read once: the body is needed to key the cassette *and* to forward.
|
|
89
|
+
const bytes = Buffer.from(await request.arrayBuffer());
|
|
90
|
+
if (this.#allowNetwork.has(url.host))
|
|
91
|
+
return this.#live(request, bytes);
|
|
92
|
+
const body = bytes.toString("utf8");
|
|
93
|
+
const what = `${request.method} ${request.url}`;
|
|
94
|
+
if (this.#active === null) {
|
|
95
|
+
this.#misses.push(what);
|
|
96
|
+
return this.#blocked(`VCR blocked ${what}: no cassette is active for this test. ` +
|
|
97
|
+
`Call setupRecording() at the top of the spec, or add the host to ` +
|
|
98
|
+
`allowNetworkHosts / handlers.`);
|
|
99
|
+
}
|
|
100
|
+
if (this.#record)
|
|
101
|
+
return this.#recordOne(request, bytes);
|
|
102
|
+
const hit = this.#active.match(request.method, request.url, body);
|
|
103
|
+
if (hit)
|
|
104
|
+
return toResponse(hit);
|
|
105
|
+
this.#misses.push(what);
|
|
106
|
+
return this.#blocked(`VCR has no recording of ${what} in ${path.basename(this.#active.file)}. ` +
|
|
107
|
+
`Re-record with RECORD=1. The cassette holds: ` +
|
|
108
|
+
`${this.#active.recorded.join(", ") || "(nothing)"}.`);
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* A miss cannot be made to *reject* the Worker's `fetch()` — Miniflare catches
|
|
112
|
+
* whatever an outbound service throws and turns it into a 500 anyway. So the
|
|
113
|
+
* message goes in the body where it can be read, and the miss is also reported
|
|
114
|
+
* on `/release` so the test itself fails with it. That is the half the old
|
|
115
|
+
* harness lacked: `disableNetConnect()` rejected, and the rejection surfaced
|
|
116
|
+
* as `internal error; reference = …` naming nothing.
|
|
117
|
+
*/
|
|
118
|
+
#blocked(message) {
|
|
119
|
+
return new Response(message, {
|
|
120
|
+
status: 500,
|
|
121
|
+
headers: { [VCR_MARKER_HEADER]: "1" }
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
#control(url) {
|
|
125
|
+
if (url.pathname === "/release") {
|
|
126
|
+
const misses = this.#misses;
|
|
127
|
+
// Write here rather than only at teardown, so a run that is interrupted
|
|
128
|
+
// still keeps every cassette whose test finished. A no-op in playback:
|
|
129
|
+
// `flush()` returns immediately unless something was recorded.
|
|
130
|
+
this.#active?.flush();
|
|
131
|
+
this.#active = null;
|
|
132
|
+
this.#activeName = null;
|
|
133
|
+
this.#misses = [];
|
|
134
|
+
return json(200, { misses });
|
|
135
|
+
}
|
|
136
|
+
if (url.pathname !== "/use")
|
|
137
|
+
return json(404, { error: "unknown" });
|
|
138
|
+
const name = url.searchParams.get("cassette") ?? "";
|
|
139
|
+
if (!CASSETTE_NAME_RE.test(name)) {
|
|
140
|
+
return json(400, { error: `invalid cassette name: ${name}` });
|
|
141
|
+
}
|
|
142
|
+
// Recorded specs are expected to run sequentially: a second cassette
|
|
143
|
+
// activating while one is held means two of them are sharing this recorder,
|
|
144
|
+
// which mis-records rather than failing, so say so instead.
|
|
145
|
+
if (this.#activeName !== null && this.#activeName !== name) {
|
|
146
|
+
return json(409, { error: `cassette ${this.#activeName} still active` });
|
|
147
|
+
}
|
|
148
|
+
const file = path.join(this.#snapshotsDir, name);
|
|
149
|
+
// Playback with no cassette on disk → 404, which the spec turns into a
|
|
150
|
+
// "record it" failure. Recording creates the file on first write.
|
|
151
|
+
if (!this.#record && !existsSync(file)) {
|
|
152
|
+
return json(404, { error: `no cassette ${name}` });
|
|
153
|
+
}
|
|
154
|
+
this.#active = this.#cassetteFor(name, file);
|
|
155
|
+
this.#activeName = name;
|
|
156
|
+
this.#misses = [];
|
|
157
|
+
return json(200, { cassette: name });
|
|
158
|
+
}
|
|
159
|
+
#cassetteFor(name, file) {
|
|
160
|
+
let cassette = this.#cassettes.get(name);
|
|
161
|
+
if (!cassette) {
|
|
162
|
+
cassette = new Cassette(file, { excludeHeaders: this.#excludeHeaders });
|
|
163
|
+
// Recording deliberately starts from nothing rather than topping up an
|
|
164
|
+
// existing file: a cassette that mixes today's traffic with last month's
|
|
165
|
+
// is stale in a way no assertion can see.
|
|
166
|
+
if (!this.#record)
|
|
167
|
+
cassette.load();
|
|
168
|
+
this.#cassettes.set(name, cassette);
|
|
169
|
+
}
|
|
170
|
+
return cassette;
|
|
171
|
+
}
|
|
172
|
+
/** The real call, with the headers the Worker sent minus the unforwardable. */
|
|
173
|
+
#live(request, body) {
|
|
174
|
+
const headers = [];
|
|
175
|
+
request.headers.forEach((value, name) => {
|
|
176
|
+
if (!SKIP_REQUEST_HEADERS.has(name.toLowerCase())) {
|
|
177
|
+
headers.push([name, value]);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
return fetch(request.url, {
|
|
181
|
+
method: request.method,
|
|
182
|
+
headers,
|
|
183
|
+
body: body.byteLength > 0 ? body : undefined,
|
|
184
|
+
// Matches what Miniflare's own `fetch(req, { dispatcher })` sugar did, so
|
|
185
|
+
// a recording captures the same final response the Worker used to see.
|
|
186
|
+
redirect: "follow"
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* `requestBytes` is forwarded to the live endpoint **as received**. The
|
|
191
|
+
* cassette stores the utf-8 text of it, which is what the format has always
|
|
192
|
+
* held and what keys the entry — but a payload that is not valid utf-8 must
|
|
193
|
+
* not be re-encoded on its way to a real API, so the two are kept separate
|
|
194
|
+
* rather than the string being round-tripped back into bytes.
|
|
195
|
+
*/
|
|
196
|
+
async #recordOne(request, requestBytes) {
|
|
197
|
+
const live = await this.#live(request, requestBytes);
|
|
198
|
+
const bytes = Buffer.from(await live.arrayBuffer());
|
|
199
|
+
const recordedRequest = {
|
|
200
|
+
method: request.method,
|
|
201
|
+
url: request.url,
|
|
202
|
+
headers: headerRecord(request.headers),
|
|
203
|
+
body: requestBytes.toString("utf8")
|
|
204
|
+
};
|
|
205
|
+
const recordedResponse = {
|
|
206
|
+
statusCode: live.status,
|
|
207
|
+
headers: responseHeaderRecord(live.headers),
|
|
208
|
+
body: bytes.toString("base64")
|
|
209
|
+
};
|
|
210
|
+
this.#active.record(recordedRequest, recordedResponse);
|
|
211
|
+
// Serve the response through the same path a replay takes, so a recording
|
|
212
|
+
// run and the replay of what it just wrote cannot disagree.
|
|
213
|
+
return toResponse(replayable(recordedResponse));
|
|
214
|
+
}
|
|
215
|
+
async close() {
|
|
216
|
+
for (const cassette of this.#cassettes.values())
|
|
217
|
+
cassette.flush();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Build the recorder and register it for {@link closeVcr}.
|
|
222
|
+
*
|
|
223
|
+
* ```ts
|
|
224
|
+
* // vitest.config.ts
|
|
225
|
+
* const vcr = createVcr({
|
|
226
|
+
* snapshotsDir: path.resolve(import.meta.dirname, "test/snapshots"),
|
|
227
|
+
* record: recordFromEnv(),
|
|
228
|
+
* excludeHeaders: ["x-api-key", "authorization", "cookie", "set-cookie"]
|
|
229
|
+
* });
|
|
230
|
+
*
|
|
231
|
+
* export default defineConfig({
|
|
232
|
+
* plugins: [cloudflareTest({ miniflare: { outboundService: vcr.outboundService } })],
|
|
233
|
+
* test: { globalSetup: ["@dynamicagents/core/testing/vcr-global-setup"] }
|
|
234
|
+
* });
|
|
235
|
+
* ```
|
|
236
|
+
*/
|
|
237
|
+
export function createVcr(options) {
|
|
238
|
+
const recorder = new Recorder(options);
|
|
239
|
+
const vcr = {
|
|
240
|
+
outboundService: recorder.handle,
|
|
241
|
+
close: () => recorder.close()
|
|
242
|
+
};
|
|
243
|
+
globalThis[VCR_KEY] = vcr;
|
|
244
|
+
return vcr;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Whether this run records, from `RECORD`.
|
|
248
|
+
*
|
|
249
|
+
* Compared against `"1"` rather than tested for truthiness, because every
|
|
250
|
+
* non-empty string is truthy: `RECORD=0` and `RECORD=false` — the two things
|
|
251
|
+
* someone reaches for to turn recording *off* — would otherwise turn it on and
|
|
252
|
+
* overwrite committed cassettes with live traffic. The opposite mistake
|
|
253
|
+
* (`RECORD=true` not recording) fails loudly on the next assertion and costs
|
|
254
|
+
* nothing.
|
|
255
|
+
*/
|
|
256
|
+
export function recordFromEnv() {
|
|
257
|
+
return process.env.RECORD === "1";
|
|
258
|
+
}
|
|
259
|
+
/** globalThis slot so the Vitest globalSetup teardown can reach the recorder
|
|
260
|
+
* created during config evaluation (same process, possibly a different module
|
|
261
|
+
* realm — globalThis is the reliable channel). */
|
|
262
|
+
const VCR_KEY = "__VCR_AGENT__";
|
|
263
|
+
/**
|
|
264
|
+
* Flush every cassette. Cassettes are already written when each one is
|
|
265
|
+
* released, so this is a safety net for a run that ended without releasing —
|
|
266
|
+
* and a no-op if no recorder was created.
|
|
267
|
+
*/
|
|
268
|
+
export async function closeVcr() {
|
|
269
|
+
const g = globalThis;
|
|
270
|
+
const vcr = g[VCR_KEY];
|
|
271
|
+
if (!vcr)
|
|
272
|
+
return;
|
|
273
|
+
g[VCR_KEY] = undefined;
|
|
274
|
+
await vcr.close();
|
|
275
|
+
}
|