@alwaysmeticulous/backend-recorder-workerd 2.315.0 → 2.321.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/README.md +10 -1
- package/dist/binding-patch.js +139 -0
- package/dist/binding-patch.js.map +1 -0
- package/dist/body-capture.d.ts +10 -1
- package/dist/body-capture.js +21 -4
- package/dist/body-capture.js.map +1 -1
- package/dist/context.d.ts +1 -0
- package/dist/context.js +2 -2
- package/dist/context.js.map +1 -1
- package/dist/fetch-patch.js +17 -103
- package/dist/fetch-patch.js.map +1 -1
- package/dist/index.d.ts +36 -12
- package/dist/index.js +0 -0
- package/dist/index.js.map +1 -1
- package/dist/kv-patch.js +261 -0
- package/dist/kv-patch.js.map +1 -0
- package/dist/original-fetch.js +22 -0
- package/dist/original-fetch.js.map +1 -0
- package/dist/{fetch-patch.d.ts → outbound-capture.d.ts} +2 -2
- package/dist/outbound-capture.js +127 -0
- package/dist/outbound-capture.js.map +1 -0
- package/dist/protocol.d.ts +134 -2
- package/dist/protocol.js +19 -3
- package/dist/protocol.js.map +1 -1
- package/dist/redact-body.d.ts +19 -0
- package/dist/redact-body.js +53 -0
- package/dist/redact-body.js.map +1 -0
- package/dist/replay-fetch.js +81 -0
- package/dist/replay-fetch.js.map +1 -0
- package/dist/replay-sidecar-url.js +62 -0
- package/dist/replay-sidecar-url.js.map +1 -0
- package/dist/sidecar-client.js +83 -4
- package/dist/sidecar-client.js.map +1 -1
- package/dist/virtual-clock.js +122 -0
- package/dist/virtual-clock.js.map +1 -0
- package/dist/virtual-random.js +238 -0
- package/dist/virtual-random.js.map +1 -0
- package/package.json +1 -1
package/dist/kv-patch.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="93bf09ea-4e78-5ac4-92cd-661f2ce03542")}catch(e){}}();
|
|
3
|
+
import { requestCaptureContext } from "./context.js";
|
|
4
|
+
import { warnOnce } from "./log.js";
|
|
5
|
+
import { redactRequestBody } from "./redact-body.js";
|
|
6
|
+
import { MAX_BODY_CAPTURE_SIZE } from "./body-capture.js";
|
|
7
|
+
import { getOriginalFetch } from "./original-fetch.js";
|
|
8
|
+
import { postCaptureEvents } from "./sidecar-client.js";
|
|
9
|
+
//#region src/kv-patch.ts
|
|
10
|
+
/**
|
|
11
|
+
* Records operations on Cloudflare KV namespace bindings (`env.MY_KV.get(key)`).
|
|
12
|
+
*
|
|
13
|
+
* KV is invisible to both the `globalThis.fetch` patch and the binding patch: a namespace is
|
|
14
|
+
* a host object with no `fetch`, and workerd services its methods internally rather than as
|
|
15
|
+
* a subrequest. So the namespace's *prototype* methods are patched instead — the same move
|
|
16
|
+
* `binding-patch.ts` makes on a Fetcher, and the Node recorder on `Redis.prototype.sendCommand`.
|
|
17
|
+
*
|
|
18
|
+
* The prototype is reached from an instance found on `env`: as with bindings there is no
|
|
19
|
+
* global to name, and going through an instance is what makes this work for an app that reads
|
|
20
|
+
* its bindings from `cloudflare:workers` rather than the handler argument — that module hands
|
|
21
|
+
* out a different `env` object but the same namespace instances, so the prototype is shared
|
|
22
|
+
* either way.
|
|
23
|
+
*
|
|
24
|
+
* Verified against real workerd in `packages/backend-recorder-js/src/__tests__/`:
|
|
25
|
+
* `workerd-binding-patch.spec.ts` pins the property shape this relies on (every recorded
|
|
26
|
+
* method inherited and configurable, one prototype across namespaces), and
|
|
27
|
+
* `workerd-kv-sidecar.spec.ts` drives real KV through the patch end to end.
|
|
28
|
+
*/
|
|
29
|
+
const KV_PATCHED = Symbol.for("meticulous.workerd.kvPatched");
|
|
30
|
+
/**
|
|
31
|
+
* The methods recorded, and the shape a KV namespace is recognised by.
|
|
32
|
+
*
|
|
33
|
+
* `getWithMetadata` is what separates KV from R2: an R2 bucket also has `get`, `put`,
|
|
34
|
+
* `delete` and `list`, so without it a bucket would be duck-typed as a namespace and its
|
|
35
|
+
* (large, binary) objects pushed through value capture.
|
|
36
|
+
*/
|
|
37
|
+
const RECORDED_KV_METHODS = [
|
|
38
|
+
"get",
|
|
39
|
+
"getWithMetadata",
|
|
40
|
+
"put",
|
|
41
|
+
"delete",
|
|
42
|
+
"list"
|
|
43
|
+
];
|
|
44
|
+
/**
|
|
45
|
+
* The namespaces being recorded, and the `env` key each was found under.
|
|
46
|
+
*
|
|
47
|
+
* Doubles as the allow-list: only an instance in here is recorded. A skipped namespace is
|
|
48
|
+
* simply never registered, and — since the patch can land on a prototype shared with another
|
|
49
|
+
* binding type — nothing that is not a discovered KV namespace can be recorded by accident.
|
|
50
|
+
*/
|
|
51
|
+
const kvBindingNames = /* @__PURE__ */ new WeakMap();
|
|
52
|
+
/**
|
|
53
|
+
* Discovers KV namespace bindings on `env` and patches their shared prototype so their
|
|
54
|
+
* operations are recorded. Safe to call on every request — discovery is cheap and the patch
|
|
55
|
+
* itself is applied once per prototype per isolate.
|
|
56
|
+
*
|
|
57
|
+
* Never throws: a runtime that does not permit the patch simply goes unrecorded.
|
|
58
|
+
*/
|
|
59
|
+
const installKvPatch = (env, options) => {
|
|
60
|
+
try {
|
|
61
|
+
for (const { name, value, skip } of kvBindings(env, options?.skipBindings)) {
|
|
62
|
+
patchKvMethods(value);
|
|
63
|
+
if (!skip) kvBindingNames.set(value, name);
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
warnOnce("kv-patch-install", "Failed to install the Meticulous KV patch — KV operations will not be recorded.", error);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const kvBindings = (env, skipBindings) => {
|
|
70
|
+
if (env === null || typeof env !== "object") return [];
|
|
71
|
+
const skipped = new Set(skipBindings ?? []);
|
|
72
|
+
const found = [];
|
|
73
|
+
for (const [name, value] of Object.entries(env)) if (isKvNamespaceLike(value)) found.push({
|
|
74
|
+
name,
|
|
75
|
+
value,
|
|
76
|
+
skip: skipped.has(name)
|
|
77
|
+
});
|
|
78
|
+
return found;
|
|
79
|
+
};
|
|
80
|
+
const isKvNamespaceLike = (value) => value !== null && typeof value === "object" && RECORDED_KV_METHODS.every((method) => typeof value[method] === "function");
|
|
81
|
+
/**
|
|
82
|
+
* Patches every recorded method, or none of them: a half-patched namespace would record a
|
|
83
|
+
* partial view of the app's KV usage, which is worse than recording none of it, because
|
|
84
|
+
* nothing in the recording would say which half is missing. So the whole chain is resolved
|
|
85
|
+
* first, and only then patched.
|
|
86
|
+
*
|
|
87
|
+
* Each method is patched where it actually lives rather than on the instance's immediate
|
|
88
|
+
* prototype — workerd already puts an inherited method further up the chain for Durable
|
|
89
|
+
* Object stubs, so assuming one flat prototype is not safe.
|
|
90
|
+
*/
|
|
91
|
+
const patchKvMethods = (binding) => {
|
|
92
|
+
const owners = /* @__PURE__ */ new Map();
|
|
93
|
+
for (const method of RECORDED_KV_METHODS) {
|
|
94
|
+
const owner = findMethodOwner(binding, method);
|
|
95
|
+
if (owner === void 0) {
|
|
96
|
+
warnOnce("kv-patch-unsupported", `This runtime does not expose a patchable KV \`${method}\` — KV operations will not be recorded.`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
owners.set(method, owner);
|
|
100
|
+
}
|
|
101
|
+
for (const [method, { owner, original }] of owners) {
|
|
102
|
+
if (original[KV_PATCHED] === true) continue;
|
|
103
|
+
Object.defineProperty(owner, method, {
|
|
104
|
+
value: patchedKvMethod(method, original),
|
|
105
|
+
writable: true,
|
|
106
|
+
enumerable: false,
|
|
107
|
+
configurable: true
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* The prototype whose `method` a call on `instance` would reach, or undefined if there is none
|
|
113
|
+
* or it cannot be replaced. The search stops at the first prototype that *has* the property,
|
|
114
|
+
* patchable or not: that is the one a call resolves to, so anything further up is shadowed.
|
|
115
|
+
*/
|
|
116
|
+
const findMethodOwner = (instance, method) => {
|
|
117
|
+
if (Object.hasOwn(instance, method)) return;
|
|
118
|
+
let current = Object.getPrototypeOf(instance);
|
|
119
|
+
while (current !== null) {
|
|
120
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, method);
|
|
121
|
+
if (descriptor !== void 0) return typeof descriptor.value === "function" && descriptor.configurable === true ? {
|
|
122
|
+
owner: current,
|
|
123
|
+
original: descriptor.value
|
|
124
|
+
} : void 0;
|
|
125
|
+
current = Object.getPrototypeOf(current);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* A `function`, not an arrow: `this` is the namespace the call was made on, and it must be
|
|
130
|
+
* forwarded to the original or workerd throws "Illegal invocation".
|
|
131
|
+
*/
|
|
132
|
+
const patchedKvMethod = (operation, original) => {
|
|
133
|
+
const patched = function(...args) {
|
|
134
|
+
const passThrough = () => original.apply(this, args);
|
|
135
|
+
const ctx = requestCaptureContext.getStore();
|
|
136
|
+
if (!ctx || ctx.mode !== "record") return passThrough();
|
|
137
|
+
const receiver = typeof this === "object" && this !== null ? this : void 0;
|
|
138
|
+
const bindingName = receiver === void 0 ? void 0 : kvBindingNames.get(receiver);
|
|
139
|
+
if (bindingName === void 0) return passThrough();
|
|
140
|
+
return captureKvOperation(ctx, operation, bindingName, args, passThrough);
|
|
141
|
+
};
|
|
142
|
+
Object.defineProperty(patched, KV_PATCHED, {
|
|
143
|
+
value: true,
|
|
144
|
+
enumerable: false,
|
|
145
|
+
configurable: true
|
|
146
|
+
});
|
|
147
|
+
return patched;
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* Records an operation without affecting it: the real method runs untouched and its result is
|
|
151
|
+
* returned as-is, with the event reported in the background via `ctx.waitUntil`. Any capture
|
|
152
|
+
* failure is warned about once and swallowed — recording must never break the app.
|
|
153
|
+
*/
|
|
154
|
+
const captureKvOperation = async (ctx, operation, bindingName, args, invoke) => {
|
|
155
|
+
const startTimeMs = Date.now();
|
|
156
|
+
let result;
|
|
157
|
+
try {
|
|
158
|
+
result = await invoke();
|
|
159
|
+
} catch (error) {
|
|
160
|
+
report(ctx, operation, bindingName, args, startTimeMs, { error: String(error) });
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
report(ctx, operation, bindingName, args, startTimeMs, { result });
|
|
164
|
+
return result;
|
|
165
|
+
};
|
|
166
|
+
const report = (ctx, operation, bindingName, args, startTimeMs, outcome) => {
|
|
167
|
+
try {
|
|
168
|
+
const event = buildKvEvent(ctx, operation, bindingName, args, startTimeMs, outcome);
|
|
169
|
+
ctx.waitUntil(postCaptureEvents(getOriginalFetch(), ctx.sidecarUrl, [event]));
|
|
170
|
+
} catch (error) {
|
|
171
|
+
warnOnce("kv-report", "Failed to report a KV operation.", error);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
const buildKvEvent = (ctx, operation, bindingName, args, startTimeMs, outcome) => {
|
|
175
|
+
const [key] = args;
|
|
176
|
+
const written = operation === "put" ? serializeWrittenValue(args[1]) : void 0;
|
|
177
|
+
const read = "result" in outcome ? serializeKvValue(outcome.result) : void 0;
|
|
178
|
+
const omitted = written?.omitted ?? read?.omitted;
|
|
179
|
+
const serializedArgs = serializeKvArgs(operation, args);
|
|
180
|
+
return {
|
|
181
|
+
kind: "kv",
|
|
182
|
+
requestId: ctx.requestId,
|
|
183
|
+
...ctx.frontendSessionId !== void 0 ? { frontendSessionId: ctx.frontendSessionId } : {},
|
|
184
|
+
bindingName,
|
|
185
|
+
operation,
|
|
186
|
+
...typeof key === "string" ? { key } : {},
|
|
187
|
+
...serializedArgs !== void 0 ? { args: serializedArgs } : {},
|
|
188
|
+
...written?.captured !== void 0 ? { value: written.captured } : {},
|
|
189
|
+
...read?.captured !== void 0 ? { result: read.captured } : {},
|
|
190
|
+
...omitted !== void 0 ? { omitted } : {},
|
|
191
|
+
startTimeMs,
|
|
192
|
+
endTimeMs: Date.now(),
|
|
193
|
+
..."error" in outcome ? { error: outcome.error } : {}
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* JSON of a value workerd returned, with anything unpersistable replaced by `null` and the
|
|
198
|
+
* reason reported alongside. A `Map` (what a bulk `get` returns) becomes a plain object,
|
|
199
|
+
* which `JSON.stringify` would otherwise flatten to `{}`.
|
|
200
|
+
*
|
|
201
|
+
* Read values are stored verbatim, deliberately: a future replay would serve them straight
|
|
202
|
+
* back to the app, so redacting one would corrupt what the app receives. Only the write path
|
|
203
|
+
* ({@link serializeWrittenValue}) is redacted — nothing ever replays a `put`.
|
|
204
|
+
*/
|
|
205
|
+
const serializeKvValue = (value) => {
|
|
206
|
+
let omitted;
|
|
207
|
+
try {
|
|
208
|
+
const json = JSON.stringify(value, (_key, nested) => {
|
|
209
|
+
const reason = omittedReason(nested);
|
|
210
|
+
if (reason !== void 0) {
|
|
211
|
+
omitted ??= reason;
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
return nested instanceof Map ? Object.fromEntries(nested) : nested;
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
...json !== void 0 ? { captured: capture(json) } : {},
|
|
218
|
+
...omitted !== void 0 ? { omitted } : {}
|
|
219
|
+
};
|
|
220
|
+
} catch {
|
|
221
|
+
return {};
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* JSON of the value a `put` wrote, redacted like a request body. KV takes a string, an
|
|
226
|
+
* ArrayBuffer(View) or a stream; only the string form can be captured, and it is redacted as
|
|
227
|
+
* text before being JSON-encoded so that secret-looking fields *inside* the app's payload are
|
|
228
|
+
* caught (redacting the JSON-encoded form would only ever see one opaque string).
|
|
229
|
+
*/
|
|
230
|
+
const serializeWrittenValue = (value) => {
|
|
231
|
+
if (typeof value === "string") try {
|
|
232
|
+
return { captured: capture(JSON.stringify(redactRequestBody(value))) };
|
|
233
|
+
} catch {
|
|
234
|
+
return {};
|
|
235
|
+
}
|
|
236
|
+
return serializeKvValue(value);
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* JSON of the call's arguments. A `put` value is replaced by `null` rather than dropped, so
|
|
240
|
+
* the remaining arguments keep their positions — and so the value is only ever persisted via
|
|
241
|
+
* the redacted {@link serializeWrittenValue} path.
|
|
242
|
+
*/
|
|
243
|
+
const serializeKvArgs = (operation, args) => {
|
|
244
|
+
return serializeKvValue(operation === "put" ? args.map((arg, index) => index === 1 ? null : arg) : args).captured;
|
|
245
|
+
};
|
|
246
|
+
const omittedReason = (value) => {
|
|
247
|
+
if (typeof ReadableStream !== "undefined" && value instanceof ReadableStream) return "stream";
|
|
248
|
+
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return "binary";
|
|
249
|
+
};
|
|
250
|
+
const capture = (json) => json.length > 262144 ? {
|
|
251
|
+
body: json.slice(0, MAX_BODY_CAPTURE_SIZE),
|
|
252
|
+
truncated: true
|
|
253
|
+
} : {
|
|
254
|
+
body: json,
|
|
255
|
+
truncated: false
|
|
256
|
+
};
|
|
257
|
+
//#endregion
|
|
258
|
+
export { installKvPatch };
|
|
259
|
+
|
|
260
|
+
//# sourceMappingURL=kv-patch.js.map
|
|
261
|
+
//# debugId=93bf09ea-4e78-5ac4-92cd-661f2ce03542
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"kv-patch.js","sources":["../src/kv-patch.ts"],"sourcesContent":["import { MAX_BODY_CAPTURE_SIZE } from \"./body-capture\";\nimport { type RequestCaptureContext, requestCaptureContext } from \"./context\";\nimport { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport type {\n CapturedBody,\n KvOmittedReason,\n KvOperation,\n KvOperationEvent,\n} from \"./protocol\";\nimport { redactRequestBody } from \"./redact-body\";\nimport { postCaptureEvents } from \"./sidecar-client\";\n\n/**\n * Records operations on Cloudflare KV namespace bindings (`env.MY_KV.get(key)`).\n *\n * KV is invisible to both the `globalThis.fetch` patch and the binding patch: a namespace is\n * a host object with no `fetch`, and workerd services its methods internally rather than as\n * a subrequest. So the namespace's *prototype* methods are patched instead — the same move\n * `binding-patch.ts` makes on a Fetcher, and the Node recorder on `Redis.prototype.sendCommand`.\n *\n * The prototype is reached from an instance found on `env`: as with bindings there is no\n * global to name, and going through an instance is what makes this work for an app that reads\n * its bindings from `cloudflare:workers` rather than the handler argument — that module hands\n * out a different `env` object but the same namespace instances, so the prototype is shared\n * either way.\n *\n * Verified against real workerd in `packages/backend-recorder-js/src/__tests__/`:\n * `workerd-binding-patch.spec.ts` pins the property shape this relies on (every recorded\n * method inherited and configurable, one prototype across namespaces), and\n * `workerd-kv-sidecar.spec.ts` drives real KV through the patch end to end.\n */\n\n// Marks a patched method, rather than the prototype holding it: methods can live at different\n// depths of one chain, and a base prototype can be shared with another binding type, so\n// \"already ours\" has to be answerable per function. Symbol.for so a second copy of the shim\n// (e.g. bundled twice) still detects the patch.\nconst KV_PATCHED = Symbol.for(\"meticulous.workerd.kvPatched\");\n\n/**\n * The methods recorded, and the shape a KV namespace is recognised by.\n *\n * `getWithMetadata` is what separates KV from R2: an R2 bucket also has `get`, `put`,\n * `delete` and `list`, so without it a bucket would be duck-typed as a namespace and its\n * (large, binary) objects pushed through value capture.\n */\nconst RECORDED_KV_METHODS = [\n \"get\",\n \"getWithMetadata\",\n \"put\",\n \"delete\",\n \"list\",\n] as const;\n\n/**\n * The namespaces being recorded, and the `env` key each was found under.\n *\n * Doubles as the allow-list: only an instance in here is recorded. A skipped namespace is\n * simply never registered, and — since the patch can land on a prototype shared with another\n * binding type — nothing that is not a discovered KV namespace can be recorded by accident.\n */\nconst kvBindingNames = new WeakMap<object, string>();\n\ntype KvMethod = (...args: unknown[]) => Promise<unknown>;\n\ntype KvNamespaceLike = Record<KvOperation, KvMethod>;\n\nexport interface InstallKvPatchOptions {\n /** Binding names to leave unrecorded. */\n skipBindings?: readonly string[];\n}\n\n/**\n * Discovers KV namespace bindings on `env` and patches their shared prototype so their\n * operations are recorded. Safe to call on every request — discovery is cheap and the patch\n * itself is applied once per prototype per isolate.\n *\n * Never throws: a runtime that does not permit the patch simply goes unrecorded.\n */\nexport const installKvPatch = (\n env: unknown,\n options?: InstallKvPatchOptions,\n): void => {\n try {\n for (const { name, value, skip } of kvBindings(\n env,\n options?.skipBindings,\n )) {\n // Patch even a skipped namespace's methods: they are shared with every other namespace,\n // so seeding them is what gives those coverage. Skipping is enforced by leaving the\n // instance off the allow-list below, not by declining to patch.\n patchKvMethods(value);\n if (!skip) {\n kvBindingNames.set(value, name);\n }\n }\n } catch (error) {\n warnOnce(\n \"kv-patch-install\",\n \"Failed to install the Meticulous KV patch — KV operations will not be recorded.\",\n error,\n );\n }\n};\n\ninterface DiscoveredKvBinding {\n name: string;\n value: object;\n skip: boolean;\n}\n\nconst kvBindings = (\n env: unknown,\n skipBindings: readonly string[] | undefined,\n): DiscoveredKvBinding[] => {\n if (env === null || typeof env !== \"object\") {\n return [];\n }\n const skipped = new Set<string>(skipBindings ?? []);\n const found: DiscoveredKvBinding[] = [];\n for (const [name, value] of Object.entries(env)) {\n if (isKvNamespaceLike(value)) {\n found.push({ name, value, skip: skipped.has(name) });\n }\n }\n return found;\n};\n\nconst isKvNamespaceLike = (value: unknown): value is object & KvNamespaceLike =>\n value !== null &&\n typeof value === \"object\" &&\n RECORDED_KV_METHODS.every(\n (method) =>\n typeof (value as Record<string, unknown>)[method] === \"function\",\n );\n\n/**\n * Patches every recorded method, or none of them: a half-patched namespace would record a\n * partial view of the app's KV usage, which is worse than recording none of it, because\n * nothing in the recording would say which half is missing. So the whole chain is resolved\n * first, and only then patched.\n *\n * Each method is patched where it actually lives rather than on the instance's immediate\n * prototype — workerd already puts an inherited method further up the chain for Durable\n * Object stubs, so assuming one flat prototype is not safe.\n */\nconst patchKvMethods = (binding: object): void => {\n const owners = new Map<KvOperation, MethodOwner>();\n for (const method of RECORDED_KV_METHODS) {\n const owner = findMethodOwner(binding, method);\n if (owner === undefined) {\n // Either the method is an own per-instance property or the runtime has locked the\n // prototype. Recording KV is not possible; everything else keeps working.\n warnOnce(\n \"kv-patch-unsupported\",\n `This runtime does not expose a patchable KV \\`${method}\\` — KV operations will not be recorded.`,\n );\n return;\n }\n owners.set(method, owner);\n }\n\n for (const [method, { owner, original }] of owners) {\n if ((original as unknown as Record<symbol, unknown>)[KV_PATCHED] === true) {\n continue;\n }\n Object.defineProperty(owner, method, {\n value: patchedKvMethod(method, original),\n writable: true,\n enumerable: false,\n configurable: true,\n });\n }\n};\n\ninterface MethodOwner {\n owner: object;\n original: KvMethod;\n}\n\n/**\n * The prototype whose `method` a call on `instance` would reach, or undefined if there is none\n * or it cannot be replaced. The search stops at the first prototype that *has* the property,\n * patchable or not: that is the one a call resolves to, so anything further up is shadowed.\n */\nconst findMethodOwner = (\n instance: object,\n method: KvOperation,\n): MethodOwner | undefined => {\n if (Object.hasOwn(instance, method)) {\n // An own per-instance method would shadow anything we patched on the prototype.\n return undefined;\n }\n let current: object | null = Object.getPrototypeOf(instance) as object | null;\n while (current !== null) {\n const descriptor = Object.getOwnPropertyDescriptor(current, method);\n if (descriptor !== undefined) {\n return typeof descriptor.value === \"function\" &&\n descriptor.configurable === true\n ? { owner: current, original: descriptor.value as KvMethod }\n : undefined;\n }\n current = Object.getPrototypeOf(current) as object | null;\n }\n return undefined;\n};\n\n/**\n * A `function`, not an arrow: `this` is the namespace the call was made on, and it must be\n * forwarded to the original or workerd throws \"Illegal invocation\".\n */\nconst patchedKvMethod = (\n operation: KvOperation,\n original: KvMethod,\n): KvMethod => {\n const patched = function (\n this: unknown,\n ...args: unknown[]\n ): Promise<unknown> {\n const passThrough = (): Promise<unknown> => original.apply(this, args);\n\n const ctx = requestCaptureContext.getStore();\n // Record mode only. `installKvPatch` is not called for a replay request, but the patch is\n // per-isolate: a record request could have installed it earlier, and no mock store serves\n // KV, so a replay request must reach the real namespace.\n if (!ctx || ctx.mode !== \"record\") {\n return passThrough();\n }\n const receiver =\n typeof this === \"object\" && this !== null ? this : undefined;\n // Only namespaces discovered on `env` are recorded — see `kvBindingNames`.\n const bindingName =\n receiver === undefined ? undefined : kvBindingNames.get(receiver);\n if (bindingName === undefined) {\n return passThrough();\n }\n\n return captureKvOperation(ctx, operation, bindingName, args, passThrough);\n };\n Object.defineProperty(patched, KV_PATCHED, {\n value: true,\n enumerable: false,\n configurable: true,\n });\n return patched;\n};\n\n/**\n * Records an operation without affecting it: the real method runs untouched and its result is\n * returned as-is, with the event reported in the background via `ctx.waitUntil`. Any capture\n * failure is warned about once and swallowed — recording must never break the app.\n */\nconst captureKvOperation = async (\n ctx: RequestCaptureContext,\n operation: KvOperation,\n bindingName: string,\n args: unknown[],\n invoke: () => Promise<unknown>,\n): Promise<unknown> => {\n const startTimeMs = Date.now();\n let result: unknown;\n try {\n result = await invoke();\n } catch (error) {\n report(ctx, operation, bindingName, args, startTimeMs, {\n error: String(error),\n });\n throw error;\n }\n report(ctx, operation, bindingName, args, startTimeMs, { result });\n return result;\n};\n\nconst report = (\n ctx: RequestCaptureContext,\n operation: KvOperation,\n bindingName: string,\n args: unknown[],\n startTimeMs: number,\n outcome: { result: unknown } | { error: string },\n): void => {\n try {\n const event = buildKvEvent(\n ctx,\n operation,\n bindingName,\n args,\n startTimeMs,\n outcome,\n );\n // postCaptureEvents never rejects — it warns once on an unreachable sidecar.\n ctx.waitUntil(\n postCaptureEvents(getOriginalFetch(), ctx.sidecarUrl, [event]),\n );\n } catch (error) {\n warnOnce(\"kv-report\", \"Failed to report a KV operation.\", error);\n }\n};\n\nconst buildKvEvent = (\n ctx: RequestCaptureContext,\n operation: KvOperation,\n bindingName: string,\n args: unknown[],\n startTimeMs: number,\n outcome: { result: unknown } | { error: string },\n): KvOperationEvent => {\n const [key] = args;\n const written =\n operation === \"put\" ? serializeWrittenValue(args[1]) : undefined;\n const read =\n \"result\" in outcome ? serializeKvValue(outcome.result) : undefined;\n const omitted = written?.omitted ?? read?.omitted;\n const serializedArgs = serializeKvArgs(operation, args);\n\n return {\n kind: \"kv\",\n requestId: ctx.requestId,\n ...(ctx.frontendSessionId !== undefined\n ? { frontendSessionId: ctx.frontendSessionId }\n : {}),\n bindingName,\n operation,\n ...(typeof key === \"string\" ? { key } : {}),\n ...(serializedArgs !== undefined ? { args: serializedArgs } : {}),\n ...(written?.captured !== undefined ? { value: written.captured } : {}),\n ...(read?.captured !== undefined ? { result: read.captured } : {}),\n ...(omitted !== undefined ? { omitted } : {}),\n startTimeMs,\n endTimeMs: Date.now(),\n ...(\"error\" in outcome ? { error: outcome.error } : {}),\n };\n};\n\ninterface SerializedKvValue {\n captured?: CapturedBody;\n omitted?: KvOmittedReason;\n}\n\n/**\n * JSON of a value workerd returned, with anything unpersistable replaced by `null` and the\n * reason reported alongside. A `Map` (what a bulk `get` returns) becomes a plain object,\n * which `JSON.stringify` would otherwise flatten to `{}`.\n *\n * Read values are stored verbatim, deliberately: a future replay would serve them straight\n * back to the app, so redacting one would corrupt what the app receives. Only the write path\n * ({@link serializeWrittenValue}) is redacted — nothing ever replays a `put`.\n */\nconst serializeKvValue = (value: unknown): SerializedKvValue => {\n let omitted: KvOmittedReason | undefined;\n try {\n const json = JSON.stringify(value, (_key: string, nested: unknown) => {\n const reason = omittedReason(nested);\n if (reason !== undefined) {\n omitted ??= reason;\n return null;\n }\n return nested instanceof Map\n ? Object.fromEntries(nested as Map<string, unknown>)\n : nested;\n });\n return {\n // `undefined` (what `put`/`delete` resolve to) has no JSON form — nothing to capture.\n ...(json !== undefined ? { captured: capture(json) } : {}),\n ...(omitted !== undefined ? { omitted } : {}),\n };\n } catch {\n // Circular, or a getter that throws. The operation itself is still recorded.\n return {};\n }\n};\n\n/**\n * JSON of the value a `put` wrote, redacted like a request body. KV takes a string, an\n * ArrayBuffer(View) or a stream; only the string form can be captured, and it is redacted as\n * text before being JSON-encoded so that secret-looking fields *inside* the app's payload are\n * caught (redacting the JSON-encoded form would only ever see one opaque string).\n */\nconst serializeWrittenValue = (value: unknown): SerializedKvValue => {\n if (typeof value === \"string\") {\n try {\n return { captured: capture(JSON.stringify(redactRequestBody(value))) };\n } catch {\n return {};\n }\n }\n return serializeKvValue(value);\n};\n\n/**\n * JSON of the call's arguments. A `put` value is replaced by `null` rather than dropped, so\n * the remaining arguments keep their positions — and so the value is only ever persisted via\n * the redacted {@link serializeWrittenValue} path.\n */\nconst serializeKvArgs = (\n operation: KvOperation,\n args: unknown[],\n): CapturedBody | undefined => {\n const withoutValue =\n operation === \"put\"\n ? args.map((arg, index) => (index === 1 ? null : arg))\n : args;\n return serializeKvValue(withoutValue).captured;\n};\n\nconst omittedReason = (value: unknown): KvOmittedReason | undefined => {\n // A stream is never read, only recognised: reading it would consume the app's bytes.\n if (\n typeof ReadableStream !== \"undefined\" &&\n value instanceof ReadableStream\n ) {\n return \"stream\";\n }\n if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {\n return \"binary\";\n }\n return undefined;\n};\n\nconst capture = (json: string): CapturedBody =>\n json.length > MAX_BODY_CAPTURE_SIZE\n ? { body: json.slice(0, MAX_BODY_CAPTURE_SIZE), truncated: true }\n : { body: json, truncated: false };\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAM,aAAa,OAAO,IAAI,+BAA+B;;;;;;;;AAS7D,MAAM,sBAAsB;CAC1B;CACA;CACA;CACA;CACA;CACD;;;;;;;;AASD,MAAM,iCAAiB,IAAI,SAAyB;;;;;;;;AAkBpD,MAAa,kBACX,KACA,YACS;AACT,KAAI;AACF,OAAK,MAAM,EAAE,MAAM,OAAO,UAAU,WAClC,KACA,SAAS,aACV,EAAE;AAID,kBAAe,MAAM;AACrB,OAAI,CAAC,KACH,gBAAe,IAAI,OAAO,KAAK;;UAG5B,OAAO;AACd,WACE,oBACA,mFACA,MACD;;;AAUL,MAAM,cACJ,KACA,iBAC0B;AAC1B,KAAI,QAAQ,QAAQ,OAAO,QAAQ,SACjC,QAAO,EAAE;CAEX,MAAM,UAAU,IAAI,IAAY,gBAAgB,EAAE,CAAC;CACnD,MAAM,QAA+B,EAAE;AACvC,MAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,IAAI,CAC7C,KAAI,kBAAkB,MAAM,CAC1B,OAAM,KAAK;EAAE;EAAM;EAAO,MAAM,QAAQ,IAAI,KAAK;EAAE,CAAC;AAGxD,QAAO;;AAGT,MAAM,qBAAqB,UACzB,UAAU,QACV,OAAO,UAAU,YACjB,oBAAoB,OACjB,WACC,OAAQ,MAAkC,YAAY,WACzD;;;;;;;;;;;AAYH,MAAM,kBAAkB,YAA0B;CAChD,MAAM,yBAAS,IAAI,KAA+B;AAClD,MAAK,MAAM,UAAU,qBAAqB;EACxC,MAAM,QAAQ,gBAAgB,SAAS,OAAO;AAC9C,MAAI,UAAU,KAAA,GAAW;AAGvB,YACE,wBACA,iDAAiD,OAAO,0CACzD;AACD;;AAEF,SAAO,IAAI,QAAQ,MAAM;;AAG3B,MAAK,MAAM,CAAC,QAAQ,EAAE,OAAO,eAAe,QAAQ;AAClD,MAAK,SAAgD,gBAAgB,KACnE;AAEF,SAAO,eAAe,OAAO,QAAQ;GACnC,OAAO,gBAAgB,QAAQ,SAAS;GACxC,UAAU;GACV,YAAY;GACZ,cAAc;GACf,CAAC;;;;;;;;AAcN,MAAM,mBACJ,UACA,WAC4B;AAC5B,KAAI,OAAO,OAAO,UAAU,OAAO,CAEjC;CAEF,IAAI,UAAyB,OAAO,eAAe,SAAS;AAC5D,QAAO,YAAY,MAAM;EACvB,MAAM,aAAa,OAAO,yBAAyB,SAAS,OAAO;AACnE,MAAI,eAAe,KAAA,EACjB,QAAO,OAAO,WAAW,UAAU,cACjC,WAAW,iBAAiB,OAC1B;GAAE,OAAO;GAAS,UAAU,WAAW;GAAmB,GAC1D,KAAA;AAEN,YAAU,OAAO,eAAe,QAAQ;;;;;;;AAS5C,MAAM,mBACJ,WACA,aACa;CACb,MAAM,UAAU,SAEd,GAAG,MACe;EAClB,MAAM,oBAAsC,SAAS,MAAM,MAAM,KAAK;EAEtE,MAAM,MAAM,sBAAsB,UAAU;AAI5C,MAAI,CAAC,OAAO,IAAI,SAAS,SACvB,QAAO,aAAa;EAEtB,MAAM,WACJ,OAAO,SAAS,YAAY,SAAS,OAAO,OAAO,KAAA;EAErD,MAAM,cACJ,aAAa,KAAA,IAAY,KAAA,IAAY,eAAe,IAAI,SAAS;AACnE,MAAI,gBAAgB,KAAA,EAClB,QAAO,aAAa;AAGtB,SAAO,mBAAmB,KAAK,WAAW,aAAa,MAAM,YAAY;;AAE3E,QAAO,eAAe,SAAS,YAAY;EACzC,OAAO;EACP,YAAY;EACZ,cAAc;EACf,CAAC;AACF,QAAO;;;;;;;AAQT,MAAM,qBAAqB,OACzB,KACA,WACA,aACA,MACA,WACqB;CACrB,MAAM,cAAc,KAAK,KAAK;CAC9B,IAAI;AACJ,KAAI;AACF,WAAS,MAAM,QAAQ;UAChB,OAAO;AACd,SAAO,KAAK,WAAW,aAAa,MAAM,aAAa,EACrD,OAAO,OAAO,MAAM,EACrB,CAAC;AACF,QAAM;;AAER,QAAO,KAAK,WAAW,aAAa,MAAM,aAAa,EAAE,QAAQ,CAAC;AAClE,QAAO;;AAGT,MAAM,UACJ,KACA,WACA,aACA,MACA,aACA,YACS;AACT,KAAI;EACF,MAAM,QAAQ,aACZ,KACA,WACA,aACA,MACA,aACA,QACD;AAED,MAAI,UACF,kBAAkB,kBAAkB,EAAE,IAAI,YAAY,CAAC,MAAM,CAAC,CAC/D;UACM,OAAO;AACd,WAAS,aAAa,oCAAoC,MAAM;;;AAIpE,MAAM,gBACJ,KACA,WACA,aACA,MACA,aACA,YACqB;CACrB,MAAM,CAAC,OAAO;CACd,MAAM,UACJ,cAAc,QAAQ,sBAAsB,KAAK,GAAG,GAAG,KAAA;CACzD,MAAM,OACJ,YAAY,UAAU,iBAAiB,QAAQ,OAAO,GAAG,KAAA;CAC3D,MAAM,UAAU,SAAS,WAAW,MAAM;CAC1C,MAAM,iBAAiB,gBAAgB,WAAW,KAAK;AAEvD,QAAO;EACL,MAAM;EACN,WAAW,IAAI;EACf,GAAI,IAAI,sBAAsB,KAAA,IAC1B,EAAE,mBAAmB,IAAI,mBAAmB,GAC5C,EAAE;EACN;EACA;EACA,GAAI,OAAO,QAAQ,WAAW,EAAE,KAAK,GAAG,EAAE;EAC1C,GAAI,mBAAmB,KAAA,IAAY,EAAE,MAAM,gBAAgB,GAAG,EAAE;EAChE,GAAI,SAAS,aAAa,KAAA,IAAY,EAAE,OAAO,QAAQ,UAAU,GAAG,EAAE;EACtE,GAAI,MAAM,aAAa,KAAA,IAAY,EAAE,QAAQ,KAAK,UAAU,GAAG,EAAE;EACjE,GAAI,YAAY,KAAA,IAAY,EAAE,SAAS,GAAG,EAAE;EAC5C;EACA,WAAW,KAAK,KAAK;EACrB,GAAI,WAAW,UAAU,EAAE,OAAO,QAAQ,OAAO,GAAG,EAAE;EACvD;;;;;;;;;;;AAiBH,MAAM,oBAAoB,UAAsC;CAC9D,IAAI;AACJ,KAAI;EACF,MAAM,OAAO,KAAK,UAAU,QAAQ,MAAc,WAAoB;GACpE,MAAM,SAAS,cAAc,OAAO;AACpC,OAAI,WAAW,KAAA,GAAW;AACxB,gBAAY;AACZ,WAAO;;AAET,UAAO,kBAAkB,MACrB,OAAO,YAAY,OAA+B,GAClD;IACJ;AACF,SAAO;GAEL,GAAI,SAAS,KAAA,IAAY,EAAE,UAAU,QAAQ,KAAK,EAAE,GAAG,EAAE;GACzD,GAAI,YAAY,KAAA,IAAY,EAAE,SAAS,GAAG,EAAE;GAC7C;SACK;AAEN,SAAO,EAAE;;;;;;;;;AAUb,MAAM,yBAAyB,UAAsC;AACnE,KAAI,OAAO,UAAU,SACnB,KAAI;AACF,SAAO,EAAE,UAAU,QAAQ,KAAK,UAAU,kBAAkB,MAAM,CAAC,CAAC,EAAE;SAChE;AACN,SAAO,EAAE;;AAGb,QAAO,iBAAiB,MAAM;;;;;;;AAQhC,MAAM,mBACJ,WACA,SAC6B;AAK7B,QAAO,iBAHL,cAAc,QACV,KAAK,KAAK,KAAK,UAAW,UAAU,IAAI,OAAO,IAAK,GACpD,KAC+B,CAAC;;AAGxC,MAAM,iBAAiB,UAAgD;AAErE,KACE,OAAO,mBAAmB,eAC1B,iBAAiB,eAEjB,QAAO;AAET,KAAI,iBAAiB,eAAe,YAAY,OAAO,MAAM,CAC3D,QAAO;;AAKX,MAAM,WAAW,SACf,KAAK,SAAA,SACD;CAAE,MAAM,KAAK,MAAM,GAAG,sBAAsB;CAAE,WAAW;CAAM,GAC/D;CAAE,MAAM;CAAM,WAAW;CAAO","debugId":"93bf09ea-4e78-5ac4-92cd-661f2ce03542"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/original-fetch.ts
|
|
2
|
+
|
|
3
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="57102687-d989-5686-9632-a7d913e39891")}catch(e){}}();
|
|
4
|
+
let originalFetch;
|
|
5
|
+
/**
|
|
6
|
+
* Holds the pre-patch `globalThis.fetch`. Its own module so that both the fetch patch (which
|
|
7
|
+
* captures it) and the capture tee (which uses it to reach the sidecar) can reach it without
|
|
8
|
+
* importing each other.
|
|
9
|
+
*/
|
|
10
|
+
const setOriginalFetch = (fetchFn) => {
|
|
11
|
+
originalFetch = fetchFn;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The unpatched fetch — used for the shim's own sidecar requests, so they are never
|
|
15
|
+
* themselves captured. Falls back to the current global before the patch is installed.
|
|
16
|
+
*/
|
|
17
|
+
const getOriginalFetch = () => originalFetch ?? globalThis.fetch;
|
|
18
|
+
//#endregion
|
|
19
|
+
export { getOriginalFetch, setOriginalFetch };
|
|
20
|
+
|
|
21
|
+
//# sourceMappingURL=original-fetch.js.map
|
|
22
|
+
//# debugId=57102687-d989-5686-9632-a7d913e39891
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"original-fetch.js","sources":["../src/original-fetch.ts"],"sourcesContent":["type FetchFn = typeof globalThis.fetch;\n\nlet originalFetch: FetchFn | undefined;\n\n/**\n * Holds the pre-patch `globalThis.fetch`. Its own module so that both the fetch patch (which\n * captures it) and the capture tee (which uses it to reach the sidecar) can reach it without\n * importing each other.\n */\nexport const setOriginalFetch = (fetchFn: FetchFn): void => {\n originalFetch = fetchFn;\n};\n\n/**\n * The unpatched fetch — used for the shim's own sidecar requests, so they are never\n * themselves captured. Falls back to the current global before the patch is installed.\n */\nexport const getOriginalFetch = (): FetchFn =>\n originalFetch ?? globalThis.fetch;\n"],"names":[],"mappings":";;;AAEA,IAAI;;;;;;AAOJ,MAAa,oBAAoB,YAA2B;AAC1D,iBAAgB;;;;;;AAOlB,MAAa,yBACX,iBAAiB,WAAW","debugId":"57102687-d989-5686-9632-a7d913e39891"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//#region src/
|
|
1
|
+
//#region src/outbound-capture.d.ts
|
|
2
2
|
declare const headersToRecord: (headers: Headers) => Record<string, string[]>;
|
|
3
3
|
//#endregion
|
|
4
4
|
export { headersToRecord };
|
|
5
|
-
//# sourceMappingURL=
|
|
5
|
+
//# sourceMappingURL=outbound-capture.d.ts.map
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="19b144de-8ce4-5457-bc5e-81d4e735a7bf")}catch(e){}}();
|
|
3
|
+
import { warnOnce } from "./log.js";
|
|
4
|
+
import { readBodyWithCap, readRequestBodyWithCap } from "./body-capture.js";
|
|
5
|
+
import { getOriginalFetch } from "./original-fetch.js";
|
|
6
|
+
import { CAPTURED_HEADERS } from "./protocol.js";
|
|
7
|
+
import { postCaptureEvents } from "./sidecar-client.js";
|
|
8
|
+
//#region src/outbound-capture.ts
|
|
9
|
+
const capturedHeaders = new Set(CAPTURED_HEADERS);
|
|
10
|
+
const headersToRecord = (headers) => {
|
|
11
|
+
const record = {};
|
|
12
|
+
headers.forEach((value, name) => {
|
|
13
|
+
const lowerName = name.toLowerCase();
|
|
14
|
+
if (capturedHeaders.has(lowerName)) (record[lowerName] ??= []).push(value);
|
|
15
|
+
});
|
|
16
|
+
return record;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Builds the transport's event. An explicit two-arm branch rather than a spread of the
|
|
20
|
+
* union, so the discriminant and the extra binding field stay obvious. (The shim has no
|
|
21
|
+
* runtime dependencies, so `assertNever` is not available here.)
|
|
22
|
+
*/
|
|
23
|
+
const buildEvent = (transport, fields) => transport.kind === "binding" ? {
|
|
24
|
+
kind: "binding",
|
|
25
|
+
...transport.bindingName !== void 0 ? { bindingName: transport.bindingName } : {},
|
|
26
|
+
...fields
|
|
27
|
+
} : {
|
|
28
|
+
kind: "outbound",
|
|
29
|
+
...fields
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Records an outbound call without affecting it: the real transport is invoked directly
|
|
33
|
+
* (the sidecar is never in the request path), and request/response clones are reported in
|
|
34
|
+
* the background via `ctx.waitUntil`. Any capture failure is warned about once and
|
|
35
|
+
* swallowed — recording must never break the app.
|
|
36
|
+
*
|
|
37
|
+
* `invoke` is what actually performs the call, so the caller keeps ownership of the
|
|
38
|
+
* receiver (a binding's `fetch` must be applied to the binding, not to `globalThis`).
|
|
39
|
+
*/
|
|
40
|
+
const captureOutboundCall = async (ctx, transport, request, invoke) => {
|
|
41
|
+
const startTimeMs = Date.now();
|
|
42
|
+
let meta;
|
|
43
|
+
let requestBodyPromise = Promise.resolve(void 0);
|
|
44
|
+
try {
|
|
45
|
+
meta = {
|
|
46
|
+
method: request.method,
|
|
47
|
+
url: request.url,
|
|
48
|
+
requestHeaders: headersToRecord(request.headers)
|
|
49
|
+
};
|
|
50
|
+
if (request.body) requestBodyPromise = readRequestBodyWithCap(request.clone().body).catch(() => void 0);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
warnOnce("outbound-request-capture", "Failed to capture an outbound request.", error);
|
|
53
|
+
meta = void 0;
|
|
54
|
+
}
|
|
55
|
+
let response;
|
|
56
|
+
try {
|
|
57
|
+
response = await invoke(request);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (meta) {
|
|
60
|
+
const frozenMeta = meta;
|
|
61
|
+
const endTimeMs = Date.now();
|
|
62
|
+
const errorMessage = String(error);
|
|
63
|
+
reportInBackground(ctx, async () => {
|
|
64
|
+
const requestBody = await requestBodyPromise;
|
|
65
|
+
await report(ctx, transport, {
|
|
66
|
+
...frozenMeta,
|
|
67
|
+
requestId: ctx.requestId,
|
|
68
|
+
...ctx.frontendSessionId !== void 0 ? { frontendSessionId: ctx.frontendSessionId } : {},
|
|
69
|
+
...requestBody !== void 0 ? { requestBody } : {},
|
|
70
|
+
startTimeMs,
|
|
71
|
+
endTimeMs,
|
|
72
|
+
error: errorMessage
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
if (meta) {
|
|
79
|
+
const frozenMeta = meta;
|
|
80
|
+
try {
|
|
81
|
+
if (hasWebSocket(response)) return response;
|
|
82
|
+
const responseClone = response.clone();
|
|
83
|
+
const responseHeaders = headersToRecord(response.headers);
|
|
84
|
+
const statusCode = response.status;
|
|
85
|
+
const endTimeMs = Date.now();
|
|
86
|
+
ctx.waitUntil((async () => {
|
|
87
|
+
const [requestBody, responseBody] = await Promise.all([requestBodyPromise, readBodyWithCap(responseClone.body).catch(() => void 0)]);
|
|
88
|
+
await report(ctx, transport, {
|
|
89
|
+
...frozenMeta,
|
|
90
|
+
requestId: ctx.requestId,
|
|
91
|
+
...ctx.frontendSessionId !== void 0 ? { frontendSessionId: ctx.frontendSessionId } : {},
|
|
92
|
+
responseHeaders,
|
|
93
|
+
statusCode,
|
|
94
|
+
...requestBody !== void 0 ? { requestBody } : {},
|
|
95
|
+
...responseBody !== void 0 ? { responseBody } : {},
|
|
96
|
+
startTimeMs,
|
|
97
|
+
endTimeMs
|
|
98
|
+
});
|
|
99
|
+
})().catch((error) => {
|
|
100
|
+
warnOnce("outbound-response-capture", "Failed to capture an outbound response.", error);
|
|
101
|
+
}));
|
|
102
|
+
} catch (error) {
|
|
103
|
+
warnOnce("outbound-response-capture", "Failed to capture an outbound response.", error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return response;
|
|
107
|
+
};
|
|
108
|
+
const report = async (ctx, transport, fields) => {
|
|
109
|
+
const event = buildEvent(transport, fields);
|
|
110
|
+
await postCaptureEvents(getOriginalFetch(), ctx.sidecarUrl, [event]);
|
|
111
|
+
};
|
|
112
|
+
/** Detects a WebSocket upgrade response without assuming the property exists. */
|
|
113
|
+
const hasWebSocket = (response) => response.webSocket != null;
|
|
114
|
+
const reportInBackground = (ctx, reportFn) => {
|
|
115
|
+
try {
|
|
116
|
+
ctx.waitUntil(reportFn().catch((error) => {
|
|
117
|
+
warnOnce("outbound-error-report", "Failed to report a failed outbound request.", error);
|
|
118
|
+
}));
|
|
119
|
+
} catch (error) {
|
|
120
|
+
warnOnce("outbound-error-report", "Failed to report a failed outbound request.", error);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
//#endregion
|
|
124
|
+
export { captureOutboundCall, headersToRecord };
|
|
125
|
+
|
|
126
|
+
//# sourceMappingURL=outbound-capture.js.map
|
|
127
|
+
//# debugId=19b144de-8ce4-5457-bc5e-81d4e735a7bf
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"outbound-capture.js","sources":["../src/outbound-capture.ts"],"sourcesContent":["import { readBodyWithCap, readRequestBodyWithCap } from \"./body-capture\";\nimport type { RequestCaptureContext } from \"./context\";\nimport { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport {\n CAPTURED_HEADERS,\n type BindingRequestEvent,\n type CaptureEvent,\n type CapturedBody,\n type OutboundRequestEvent,\n} from \"./protocol\";\nimport { postCaptureEvents } from \"./sidecar-client\";\n\n/**\n * Which transport an intercepted call left the isolate through. Both are\n * Request/Response-shaped, so they share the capture tee below and differ only in the\n * event they emit.\n */\nexport type OutboundTransport =\n | { kind: \"outbound\" }\n | {\n kind: \"binding\";\n /**\n * The `env` key the binding was found under. Undefined when the instance was never\n * seen on `env` — e.g. a Durable Object stub, which is produced by `namespace.get()`\n * rather than being a binding itself.\n */\n bindingName: string | undefined;\n };\n\nconst capturedHeaders = new Set<string>(CAPTURED_HEADERS);\n\nexport const headersToRecord = (headers: Headers): Record<string, string[]> => {\n const record: Record<string, string[]> = {};\n headers.forEach((value, name) => {\n const lowerName = name.toLowerCase();\n if (capturedHeaders.has(lowerName)) {\n (record[lowerName] ??= []).push(value);\n }\n });\n return record;\n};\n\ninterface OutboundMeta {\n method: string;\n url: string;\n requestHeaders: Record<string, string[]>;\n}\n\ninterface EventFields extends OutboundMeta {\n requestId: string;\n frontendSessionId?: string;\n requestBody?: CapturedBody;\n responseBody?: CapturedBody;\n responseHeaders?: Record<string, string[]>;\n statusCode?: number;\n startTimeMs: number;\n endTimeMs: number;\n error?: string;\n}\n\n/**\n * Builds the transport's event. An explicit two-arm branch rather than a spread of the\n * union, so the discriminant and the extra binding field stay obvious. (The shim has no\n * runtime dependencies, so `assertNever` is not available here.)\n */\nconst buildEvent = (\n transport: OutboundTransport,\n fields: EventFields,\n): OutboundRequestEvent | BindingRequestEvent =>\n transport.kind === \"binding\"\n ? {\n kind: \"binding\",\n ...(transport.bindingName !== undefined\n ? { bindingName: transport.bindingName }\n : {}),\n ...fields,\n }\n : { kind: \"outbound\", ...fields };\n\n/**\n * Records an outbound call without affecting it: the real transport is invoked directly\n * (the sidecar is never in the request path), and request/response clones are reported in\n * the background via `ctx.waitUntil`. Any capture failure is warned about once and\n * swallowed — recording must never break the app.\n *\n * `invoke` is what actually performs the call, so the caller keeps ownership of the\n * receiver (a binding's `fetch` must be applied to the binding, not to `globalThis`).\n */\nexport const captureOutboundCall = async (\n ctx: RequestCaptureContext,\n transport: OutboundTransport,\n request: Request,\n invoke: (request: Request) => Promise<Response>,\n): Promise<Response> => {\n const startTimeMs = Date.now();\n let meta: OutboundMeta | undefined;\n let requestBodyPromise: Promise<CapturedBody | undefined> =\n Promise.resolve(undefined);\n try {\n meta = {\n method: request.method,\n url: request.url,\n requestHeaders: headersToRecord(request.headers),\n };\n if (request.body) {\n const bodyClone = request.clone();\n requestBodyPromise = readRequestBodyWithCap(bodyClone.body).catch(\n () => undefined,\n );\n }\n } catch (error) {\n warnOnce(\n \"outbound-request-capture\",\n \"Failed to capture an outbound request.\",\n error,\n );\n meta = undefined;\n }\n\n let response: Response;\n try {\n response = await invoke(request);\n } catch (error) {\n if (meta) {\n const frozenMeta = meta;\n const endTimeMs = Date.now();\n const errorMessage = String(error);\n reportInBackground(ctx, async () => {\n const requestBody = await requestBodyPromise;\n await report(ctx, transport, {\n ...frozenMeta,\n requestId: ctx.requestId,\n ...(ctx.frontendSessionId !== undefined\n ? { frontendSessionId: ctx.frontendSessionId }\n : {}),\n ...(requestBody !== undefined ? { requestBody } : {}),\n startTimeMs,\n endTimeMs,\n error: errorMessage,\n });\n });\n }\n throw error;\n }\n\n if (meta) {\n const frozenMeta = meta;\n try {\n // A WebSocket upgrade has no readable body and cannot be represented as a recorded\n // response, so it is left entirely alone.\n if (hasWebSocket(response)) {\n return response;\n }\n const responseClone = response.clone();\n const responseHeaders = headersToRecord(response.headers);\n const statusCode = response.status;\n const endTimeMs = Date.now();\n ctx.waitUntil(\n (async () => {\n const [requestBody, responseBody] = await Promise.all([\n requestBodyPromise,\n readBodyWithCap(responseClone.body).catch(() => undefined),\n ]);\n await report(ctx, transport, {\n ...frozenMeta,\n requestId: ctx.requestId,\n ...(ctx.frontendSessionId !== undefined\n ? { frontendSessionId: ctx.frontendSessionId }\n : {}),\n responseHeaders,\n statusCode,\n ...(requestBody !== undefined ? { requestBody } : {}),\n ...(responseBody !== undefined ? { responseBody } : {}),\n startTimeMs,\n endTimeMs,\n });\n })().catch((error) => {\n warnOnce(\n \"outbound-response-capture\",\n \"Failed to capture an outbound response.\",\n error,\n );\n }),\n );\n } catch (error) {\n warnOnce(\n \"outbound-response-capture\",\n \"Failed to capture an outbound response.\",\n error,\n );\n }\n }\n\n return response;\n};\n\nconst report = async (\n ctx: RequestCaptureContext,\n transport: OutboundTransport,\n fields: EventFields,\n): Promise<void> => {\n const event: CaptureEvent = buildEvent(transport, fields);\n await postCaptureEvents(getOriginalFetch(), ctx.sidecarUrl, [event]);\n};\n\n/** Detects a WebSocket upgrade response without assuming the property exists. */\nconst hasWebSocket = (response: Response): boolean =>\n (response as Response & { webSocket?: unknown }).webSocket != null;\n\nconst reportInBackground = (\n ctx: { waitUntil: (p: Promise<unknown>) => void },\n reportFn: () => Promise<void>,\n): void => {\n try {\n ctx.waitUntil(\n reportFn().catch((error) => {\n warnOnce(\n \"outbound-error-report\",\n \"Failed to report a failed outbound request.\",\n error,\n );\n }),\n );\n } catch (error) {\n warnOnce(\n \"outbound-error-report\",\n \"Failed to report a failed outbound request.\",\n error,\n );\n }\n};\n"],"names":[],"mappings":";;;;;;;;AA8BA,MAAM,kBAAkB,IAAI,IAAY,iBAAiB;AAEzD,MAAa,mBAAmB,YAA+C;CAC7E,MAAM,SAAmC,EAAE;AAC3C,SAAQ,SAAS,OAAO,SAAS;EAC/B,MAAM,YAAY,KAAK,aAAa;AACpC,MAAI,gBAAgB,IAAI,UAAU,CAChC,EAAC,OAAO,eAAe,EAAE,EAAE,KAAK,MAAM;GAExC;AACF,QAAO;;;;;;;AA0BT,MAAM,cACJ,WACA,WAEA,UAAU,SAAS,YACf;CACE,MAAM;CACN,GAAI,UAAU,gBAAgB,KAAA,IAC1B,EAAE,aAAa,UAAU,aAAa,GACtC,EAAE;CACN,GAAG;CACJ,GACD;CAAE,MAAM;CAAY,GAAG;CAAQ;;;;;;;;;;AAWrC,MAAa,sBAAsB,OACjC,KACA,WACA,SACA,WACsB;CACtB,MAAM,cAAc,KAAK,KAAK;CAC9B,IAAI;CACJ,IAAI,qBACF,QAAQ,QAAQ,KAAA,EAAU;AAC5B,KAAI;AACF,SAAO;GACL,QAAQ,QAAQ;GAChB,KAAK,QAAQ;GACb,gBAAgB,gBAAgB,QAAQ,QAAQ;GACjD;AACD,MAAI,QAAQ,KAEV,sBAAqB,uBADH,QAAQ,OAC2B,CAAC,KAAK,CAAC,YACpD,KAAA,EACP;UAEI,OAAO;AACd,WACE,4BACA,0CACA,MACD;AACD,SAAO,KAAA;;CAGT,IAAI;AACJ,KAAI;AACF,aAAW,MAAM,OAAO,QAAQ;UACzB,OAAO;AACd,MAAI,MAAM;GACR,MAAM,aAAa;GACnB,MAAM,YAAY,KAAK,KAAK;GAC5B,MAAM,eAAe,OAAO,MAAM;AAClC,sBAAmB,KAAK,YAAY;IAClC,MAAM,cAAc,MAAM;AAC1B,UAAM,OAAO,KAAK,WAAW;KAC3B,GAAG;KACH,WAAW,IAAI;KACf,GAAI,IAAI,sBAAsB,KAAA,IAC1B,EAAE,mBAAmB,IAAI,mBAAmB,GAC5C,EAAE;KACN,GAAI,gBAAgB,KAAA,IAAY,EAAE,aAAa,GAAG,EAAE;KACpD;KACA;KACA,OAAO;KACR,CAAC;KACF;;AAEJ,QAAM;;AAGR,KAAI,MAAM;EACR,MAAM,aAAa;AACnB,MAAI;AAGF,OAAI,aAAa,SAAS,CACxB,QAAO;GAET,MAAM,gBAAgB,SAAS,OAAO;GACtC,MAAM,kBAAkB,gBAAgB,SAAS,QAAQ;GACzD,MAAM,aAAa,SAAS;GAC5B,MAAM,YAAY,KAAK,KAAK;AAC5B,OAAI,WACD,YAAY;IACX,MAAM,CAAC,aAAa,gBAAgB,MAAM,QAAQ,IAAI,CACpD,oBACA,gBAAgB,cAAc,KAAK,CAAC,YAAY,KAAA,EAAU,CAC3D,CAAC;AACF,UAAM,OAAO,KAAK,WAAW;KAC3B,GAAG;KACH,WAAW,IAAI;KACf,GAAI,IAAI,sBAAsB,KAAA,IAC1B,EAAE,mBAAmB,IAAI,mBAAmB,GAC5C,EAAE;KACN;KACA;KACA,GAAI,gBAAgB,KAAA,IAAY,EAAE,aAAa,GAAG,EAAE;KACpD,GAAI,iBAAiB,KAAA,IAAY,EAAE,cAAc,GAAG,EAAE;KACtD;KACA;KACD,CAAC;OACA,CAAC,OAAO,UAAU;AACpB,aACE,6BACA,2CACA,MACD;KACD,CACH;WACM,OAAO;AACd,YACE,6BACA,2CACA,MACD;;;AAIL,QAAO;;AAGT,MAAM,SAAS,OACb,KACA,WACA,WACkB;CAClB,MAAM,QAAsB,WAAW,WAAW,OAAO;AACzD,OAAM,kBAAkB,kBAAkB,EAAE,IAAI,YAAY,CAAC,MAAM,CAAC;;;AAItE,MAAM,gBAAgB,aACnB,SAAgD,aAAa;AAEhE,MAAM,sBACJ,KACA,aACS;AACT,KAAI;AACF,MAAI,UACF,UAAU,CAAC,OAAO,UAAU;AAC1B,YACE,yBACA,+CACA,MACD;IACD,CACH;UACM,OAAO;AACd,WACE,yBACA,+CACA,MACD","debugId":"19b144de-8ce4-5457-bc5e-81d4e735a7bf"}
|
package/dist/protocol.d.ts
CHANGED
|
@@ -13,6 +13,22 @@ declare const SIDECAR_PROTOCOL_VERSION_HEADER = "x-meticulous-sidecar-protocol-v
|
|
|
13
13
|
*/
|
|
14
14
|
declare const FRONTEND_SESSION_ID_HEADER = "x-meticulous-session-id";
|
|
15
15
|
declare const SIDECAR_EVENTS_PATH = "/v1/events";
|
|
16
|
+
/**
|
|
17
|
+
* Replay routes. A sidecar started in record mode answers 404 on both, which the shim
|
|
18
|
+
* treats as "replay unavailable" and falls back to plain pass-through — so a new shim
|
|
19
|
+
* keeps working against an older, record-only sidecar.
|
|
20
|
+
*/
|
|
21
|
+
declare const SIDECAR_REPLAY_SESSION_PATH = "/v1/replay/session";
|
|
22
|
+
declare const SIDECAR_REPLAY_OUTBOUND_FETCH_PATH = "/v1/replay/outbound-fetch";
|
|
23
|
+
/**
|
|
24
|
+
* Header carrying the replay sidecar's origin, injected by the Meticulous replay runner
|
|
25
|
+
* on requests to the app under test. Workerd cannot see container environment variables,
|
|
26
|
+
* so an inbound header is the only way per-replay config can reach the shim.
|
|
27
|
+
*
|
|
28
|
+
* The shim validates the value before using it and only honours a loopback /
|
|
29
|
+
* `host.docker.internal` / private-network `http:` origin — see `replay-sidecar-url.ts`.
|
|
30
|
+
*/
|
|
31
|
+
declare const REPLAY_SIDECAR_URL_HEADER = "x-meticulous-backend-replay-sidecar-url";
|
|
16
32
|
/**
|
|
17
33
|
* The only headers persisted on capture events. Everything else — notably
|
|
18
34
|
* authorization, cookie/set-cookie, and API-key headers — is dropped at
|
|
@@ -60,10 +76,126 @@ interface OutboundRequestEvent extends BaseRequestEvent {
|
|
|
60
76
|
requestBody?: CapturedBody;
|
|
61
77
|
responseBody?: CapturedBody;
|
|
62
78
|
}
|
|
63
|
-
|
|
79
|
+
/**
|
|
80
|
+
* A `fetch` through a Cloudflare binding — a service binding, an assets binding, or a
|
|
81
|
+
* Durable Object stub — made while handling an inbound request (becomes a CLIENT span).
|
|
82
|
+
*
|
|
83
|
+
* Request/Response-shaped like {@link OutboundRequestEvent}, but a distinct kind because the
|
|
84
|
+
* call never leaves the isolate and its URL is whatever the caller invented. Keeping the two
|
|
85
|
+
* apart means a binding call and real egress can never be confused for one another when
|
|
86
|
+
* these spans are eventually mocked.
|
|
87
|
+
*/
|
|
88
|
+
interface BindingRequestEvent extends BaseRequestEvent {
|
|
89
|
+
kind: "binding";
|
|
90
|
+
/**
|
|
91
|
+
* The `env` key the binding was found under. Absent when the instance was never seen on
|
|
92
|
+
* `env` — most commonly a Durable Object stub, which `namespace.get()` produces rather
|
|
93
|
+
* than being a binding in its own right.
|
|
94
|
+
*/
|
|
95
|
+
bindingName?: string;
|
|
96
|
+
requestBody?: CapturedBody;
|
|
97
|
+
responseBody?: CapturedBody;
|
|
98
|
+
}
|
|
99
|
+
/** KV namespace methods the shim records. */
|
|
100
|
+
type KvOperation = "get" | "getWithMetadata" | "put" | "delete" | "list";
|
|
101
|
+
/**
|
|
102
|
+
* Why a KV value is absent from an event: workerd handed it over in a form that cannot be
|
|
103
|
+
* persisted as text. A `"stream"` value must not even be read — consuming it would take the
|
|
104
|
+
* bytes away from the app — and binary values are skipped deliberately, since KV blobs are
|
|
105
|
+
* large and would both bloat the recording and be mangled by UTF-8 capture.
|
|
106
|
+
*/
|
|
107
|
+
type KvOmittedReason = "binary" | "stream";
|
|
108
|
+
/**
|
|
109
|
+
* An operation on a Cloudflare KV namespace binding, made while handling an inbound request
|
|
110
|
+
* (becomes a CLIENT span).
|
|
111
|
+
*
|
|
112
|
+
* KV is not HTTP: there is no method, URL or status code, so this event shares nothing with
|
|
113
|
+
* {@link BaseRequestEvent} beyond correlation and timing. Values are JSON so that a single
|
|
114
|
+
* `JSON.parse` reconstructs exactly what the app saw, whichever `type` the read asked for.
|
|
115
|
+
*/
|
|
116
|
+
interface KvOperationEvent {
|
|
117
|
+
kind: "kv";
|
|
118
|
+
/** Correlates the operation to the inbound request being handled. */
|
|
119
|
+
requestId: string;
|
|
120
|
+
frontendSessionId?: string;
|
|
121
|
+
/**
|
|
122
|
+
* The `env` key the KV namespace was found under. Always present, unlike on a binding
|
|
123
|
+
* event: a namespace has no factory equivalent to `DurableObjectNamespace.get()`, so one
|
|
124
|
+
* that was never seen on `env` is not recorded at all.
|
|
125
|
+
*/
|
|
126
|
+
bindingName: string;
|
|
127
|
+
operation: KvOperation;
|
|
128
|
+
/**
|
|
129
|
+
* The key operated on, when it is a single string. Absent for `list` (which spans keys) and
|
|
130
|
+
* for a bulk `get` (which takes an array) — both keep their arguments in {@link args}.
|
|
131
|
+
*/
|
|
132
|
+
key?: string;
|
|
133
|
+
/**
|
|
134
|
+
* JSON of the call's arguments: the read `type`, the `put` options, the `list` selector.
|
|
135
|
+
* A `put` value appears as `null` here — it is captured in {@link value} instead, so that
|
|
136
|
+
* redaction applies to it.
|
|
137
|
+
*/
|
|
138
|
+
args?: CapturedBody;
|
|
139
|
+
/** JSON of the value a `put` wrote, with secret-looking fields redacted. */
|
|
140
|
+
value?: CapturedBody;
|
|
141
|
+
/**
|
|
142
|
+
* JSON of what the operation returned: the value for `get`, `{value, metadata}` for
|
|
143
|
+
* `getWithMetadata`, the key page for `list`. Absent for `put`/`delete`, which return
|
|
144
|
+
* nothing, and for a value that could not be captured (see {@link omitted}).
|
|
145
|
+
*/
|
|
146
|
+
result?: CapturedBody;
|
|
147
|
+
/**
|
|
148
|
+
* Why the operation's value is missing. Refers to the written value for `put` and to the
|
|
149
|
+
* read value otherwise — a `put` has no result, so this is never ambiguous.
|
|
150
|
+
*/
|
|
151
|
+
omitted?: KvOmittedReason;
|
|
152
|
+
startTimeMs: number;
|
|
153
|
+
endTimeMs: number;
|
|
154
|
+
/** Set when the operation threw. */
|
|
155
|
+
error?: string;
|
|
156
|
+
}
|
|
157
|
+
type CaptureEvent = InboundRequestEvent | OutboundRequestEvent | BindingRequestEvent | KvOperationEvent;
|
|
64
158
|
interface CaptureEventsPayload {
|
|
65
159
|
events: CaptureEvent[];
|
|
66
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Answer to `GET {SIDECAR_REPLAY_SESSION_PATH}?sessionId=<id>`: whether the sidecar holds
|
|
163
|
+
* mocks for a session, and the virtual clock anchor to freeze `Date` at while serving it.
|
|
164
|
+
*/
|
|
165
|
+
interface ReplaySessionInfoResponse {
|
|
166
|
+
found: boolean;
|
|
167
|
+
/**
|
|
168
|
+
* Epoch ms of the session's last recorded activity. The worker freezes its clock here so
|
|
169
|
+
* that credentials minted during the recording are already issued but not yet expired.
|
|
170
|
+
*/
|
|
171
|
+
clockAnchorMs?: number;
|
|
172
|
+
}
|
|
173
|
+
/** Lookup for a single outbound `fetch`, sent to {@link SIDECAR_REPLAY_OUTBOUND_FETCH_PATH}. */
|
|
174
|
+
interface OutboundFetchLookupRequest {
|
|
175
|
+
frontendSessionId: string;
|
|
176
|
+
method: string;
|
|
177
|
+
/** Full URL including query string. */
|
|
178
|
+
url: string;
|
|
179
|
+
/**
|
|
180
|
+
* Request body as captured by `readBodyWithCap`. The sidecar hashes this itself, so the
|
|
181
|
+
* hash it compares against a recording is derived from byte-identical input on both the
|
|
182
|
+
* record and replay sides.
|
|
183
|
+
*/
|
|
184
|
+
requestBody?: CapturedBody;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Either a recorded response to serve, or an instruction to let the real call through.
|
|
188
|
+
* An unrecognised `outcome` must be treated as `passthrough`, so an older shim keeps
|
|
189
|
+
* working against a newer sidecar.
|
|
190
|
+
*/
|
|
191
|
+
type OutboundFetchLookupResponse = {
|
|
192
|
+
outcome: "mock";
|
|
193
|
+
statusCode: number;
|
|
194
|
+
body: string;
|
|
195
|
+
headers: Record<string, string>;
|
|
196
|
+
} | {
|
|
197
|
+
outcome: "passthrough";
|
|
198
|
+
};
|
|
67
199
|
//#endregion
|
|
68
|
-
export { CAPTURED_HEADERS, CaptureEvent, CaptureEventsPayload, CapturedBody, FRONTEND_SESSION_ID_HEADER, InboundRequestEvent, OutboundRequestEvent, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER };
|
|
200
|
+
export { BindingRequestEvent, CAPTURED_HEADERS, CaptureEvent, CaptureEventsPayload, CapturedBody, FRONTEND_SESSION_ID_HEADER, InboundRequestEvent, KvOmittedReason, KvOperation, KvOperationEvent, OutboundFetchLookupRequest, OutboundFetchLookupResponse, OutboundRequestEvent, REPLAY_SIDECAR_URL_HEADER, ReplaySessionInfoResponse, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH };
|
|
69
201
|
//# sourceMappingURL=protocol.d.ts.map
|