@alwaysmeticulous/backend-recorder-workerd 2.315.0 → 2.319.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 +8 -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 +32 -12
- package/dist/index.js +0 -0
- package/dist/index.js.map +1 -1
- 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 +76 -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/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,8 +50,15 @@ Without `METICULOUS_SIDECAR_URL` (or `options.sidecarUrl`) the wrapper is a comp
|
|
|
50
50
|
|
|
51
51
|
- **Inbound requests** (method, URL, headers, status; no bodies), correlated to the frontend session via the `x-meticulous-session-id` header stamped by the Meticulous frontend recorder.
|
|
52
52
|
- **Outgoing `fetch` calls** made while handling an inbound request: method, URL, headers, status, and request/response bodies (UTF-8, capped at 256 KB; long-lived streams such as SSE are captured truncated).
|
|
53
|
+
- **Calls through `fetch`-shaped bindings** — service bindings and Durable Object stubs — recorded like outgoing fetches, plus the `env` key the call went through. No code change is needed: the binding's `fetch` is instrumented wherever the app reads it from, including via `cloudflare:workers` rather than the handler's `env` argument.
|
|
54
|
+
|
|
55
|
+
Request bodies have secret-looking JSON fields (`clientSecret`, `apiKey`, `token`, `password`, …) replaced with `REDACTED` before they leave the worker, since plenty of APIs carry a credential in the body rather than a header. Response bodies are stored verbatim.
|
|
53
56
|
|
|
54
57
|
## Limitations
|
|
55
58
|
|
|
56
|
-
-
|
|
59
|
+
- Bindings other than `fetch`-shaped ones are not captured: KV, D1, R2, Queues, and RPC method calls on a named entrypoint (`env.SVC.someMethod()`) — an RPC method is not a patchable property, so it cannot be intercepted this way.
|
|
60
|
+
- Assets bindings are skipped by default (`ASSETS`, `__STATIC_CONTENT`): asset bytes are large and often binary, and asset serving is usually a worker's highest-volume call. Use `options.skipBindings` to skip others.
|
|
61
|
+
- A Durable Object stub is recorded without a binding name, since the stub comes from `namespace.get()` rather than being a binding itself.
|
|
62
|
+
- A binding reference bound before the first instrumented request (`const f = env.SVC.fetch.bind(env.SVC)` at module scope) escapes instrumentation. Holding the binding _object_ and calling `.fetch()` on it per request — what SDKs normally do — is captured.
|
|
63
|
+
- WebSocket upgrades and TCP sockets (`connect()`) are not captured.
|
|
57
64
|
- `fetch` calls made outside a request handled by `withMeticulous` (e.g. in `scheduled` handlers) are not captured.
|
|
@@ -0,0 +1,139 @@
|
|
|
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]="70c459b2-f813-5c06-8d88-9bd810926724")}catch(e){}}();
|
|
3
|
+
import { requestCaptureContext } from "./context.js";
|
|
4
|
+
import { warnOnce } from "./log.js";
|
|
5
|
+
import { captureOutboundCall } from "./outbound-capture.js";
|
|
6
|
+
//#region src/binding-patch.ts
|
|
7
|
+
/**
|
|
8
|
+
* Records calls made through Cloudflare bindings (`env.MY_SERVICE.fetch(...)`).
|
|
9
|
+
*
|
|
10
|
+
* The `globalThis.fetch` patch cannot see these: a binding's `fetch` is a method on a host
|
|
11
|
+
* object, and workerd routes it as an internal subrequest that never touches the global. So
|
|
12
|
+
* the binding's *prototype* method is patched instead — the same move the Node recorder makes
|
|
13
|
+
* on `Redis.prototype.sendCommand`.
|
|
14
|
+
*
|
|
15
|
+
* The prototype is reached from an instance found on `env`, never from a global name: in
|
|
16
|
+
* workerd there is no `globalThis.Fetcher` (the runtime type is a bare TypeScript alias, not
|
|
17
|
+
* one of the exposed runtime classes). Reaching it through an instance is also what makes
|
|
18
|
+
* this work at all for an app that reads its bindings from `cloudflare:workers` instead of
|
|
19
|
+
* the handler argument — that module hands out a *different* `env` object but the *same*
|
|
20
|
+
* binding instances, so the prototype we patch is shared either way.
|
|
21
|
+
*
|
|
22
|
+
* Verified against real workerd in
|
|
23
|
+
* `packages/backend-recorder-js/src/__tests__/workerd-binding-patch.spec.ts`, which also
|
|
24
|
+
* pins the properties this relies on: `fetch` is inherited and configurable, the receiver
|
|
25
|
+
* survives, one patch covers every service binding plus Durable Object stubs, and a binding
|
|
26
|
+
* call is not also counted as a `globalThis.fetch` call.
|
|
27
|
+
*/
|
|
28
|
+
const BINDING_FETCH_PATCHED = Symbol.for("meticulous.workerd.bindingPatched");
|
|
29
|
+
/**
|
|
30
|
+
* Bindings never recorded, by name.
|
|
31
|
+
*
|
|
32
|
+
* An assets binding is shape-identical to a service binding — same prototype, same `fetch` —
|
|
33
|
+
* so it cannot be told apart by duck-typing and has to be skipped by name. Recording it would
|
|
34
|
+
* push every static asset through the body capture cap for no benefit: asset bytes are large
|
|
35
|
+
* and often binary, and asset serving is typically the highest-volume call a worker makes.
|
|
36
|
+
*/
|
|
37
|
+
const DEFAULT_SKIPPED_BINDINGS = ["ASSETS", "__STATIC_CONTENT"];
|
|
38
|
+
/** Identifies a binding instance so its calls can be attributed to an `env` key. */
|
|
39
|
+
const bindingNames = /* @__PURE__ */ new WeakMap();
|
|
40
|
+
const skippedBindings = /* @__PURE__ */ new WeakSet();
|
|
41
|
+
/**
|
|
42
|
+
* Discovers Fetcher-shaped bindings on `env` and patches their shared prototype so their
|
|
43
|
+
* `fetch` calls are recorded. Safe to call on every request — discovery is cheap and the
|
|
44
|
+
* patch itself is applied once per prototype per isolate.
|
|
45
|
+
*
|
|
46
|
+
* Never throws: a runtime that does not permit the patch simply goes unrecorded.
|
|
47
|
+
*/
|
|
48
|
+
const installBindingPatch = (env, options) => {
|
|
49
|
+
try {
|
|
50
|
+
for (const { name, value, skip } of fetcherBindings(env, options?.skipBindings)) {
|
|
51
|
+
patchBindingPrototype(value);
|
|
52
|
+
if (skip) {
|
|
53
|
+
skippedBindings.add(value);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
bindingNames.set(value, name);
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
warnOnce("binding-patch-install", "Failed to install the Meticulous binding patch — binding calls will not be recorded.", error);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const fetcherBindings = (env, extraSkipped) => {
|
|
63
|
+
if (env === null || typeof env !== "object") return [];
|
|
64
|
+
const skipped = new Set([...DEFAULT_SKIPPED_BINDINGS, ...extraSkipped ?? []]);
|
|
65
|
+
const found = [];
|
|
66
|
+
for (const [name, value] of Object.entries(env)) if (isFetcherLike(value)) found.push({
|
|
67
|
+
name,
|
|
68
|
+
value,
|
|
69
|
+
skip: skipped.has(name)
|
|
70
|
+
});
|
|
71
|
+
return found;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* A binding we can record: something with a `fetch` method. Durable Object *namespaces* are
|
|
75
|
+
* excluded — they have no `fetch`; it is the stub from `namespace.get()` that does, and that
|
|
76
|
+
* stub inherits the same prototype, so it is covered without being discovered here.
|
|
77
|
+
*/
|
|
78
|
+
const isFetcherLike = (value) => value !== null && typeof value === "object" && typeof value.fetch === "function";
|
|
79
|
+
const patchBindingPrototype = (binding) => {
|
|
80
|
+
const prototype = Object.getPrototypeOf(binding);
|
|
81
|
+
if (prototype === null || prototype[BINDING_FETCH_PATCHED] === true) return;
|
|
82
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, "fetch");
|
|
83
|
+
if (descriptor === void 0 || typeof descriptor.value !== "function" || descriptor.configurable !== true) {
|
|
84
|
+
warnOnce("binding-patch-unsupported", "This runtime does not expose a patchable binding `fetch` — binding calls will not be recorded.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const original = descriptor.value;
|
|
88
|
+
Object.defineProperty(prototype, "fetch", {
|
|
89
|
+
value: patchedBindingFetch(original),
|
|
90
|
+
writable: true,
|
|
91
|
+
enumerable: false,
|
|
92
|
+
configurable: true
|
|
93
|
+
});
|
|
94
|
+
Object.defineProperty(prototype, BINDING_FETCH_PATCHED, {
|
|
95
|
+
value: true,
|
|
96
|
+
enumerable: false,
|
|
97
|
+
configurable: true
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* A `function`, not an arrow: `this` is the binding the call was made on, and it must be
|
|
102
|
+
* forwarded to the original or workerd throws "Illegal invocation".
|
|
103
|
+
*/
|
|
104
|
+
const patchedBindingFetch = (original) => function(...args) {
|
|
105
|
+
const passThrough = () => original.apply(this, args);
|
|
106
|
+
const ctx = requestCaptureContext.getStore();
|
|
107
|
+
if (!ctx || ctx.mode !== "record") return passThrough();
|
|
108
|
+
const receiver = typeof this === "object" && this !== null ? this : void 0;
|
|
109
|
+
if (receiver !== void 0 && skippedBindings.has(receiver)) return passThrough();
|
|
110
|
+
const request = toRequest(args);
|
|
111
|
+
if (request === void 0) return passThrough();
|
|
112
|
+
if (request.url.startsWith(`${ctx.sidecarUrl}/`)) return original.apply(this, [request]);
|
|
113
|
+
return captureOutboundCall(ctx, {
|
|
114
|
+
kind: "binding",
|
|
115
|
+
bindingName: receiver !== void 0 ? bindingNames.get(receiver) : void 0
|
|
116
|
+
}, request, (req) => original.apply(this, [req]));
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Normalizes the call's arguments to a single Request, or undefined if they cannot be
|
|
120
|
+
* represented (in which case the call passes through untouched and the runtime produces its
|
|
121
|
+
* own error).
|
|
122
|
+
*
|
|
123
|
+
* A lone `Request` is passed along as-is rather than rebuilt: `new Request(existing)` drops
|
|
124
|
+
* workerd's `cf` property, which the receiving worker may read.
|
|
125
|
+
*/
|
|
126
|
+
const toRequest = (args) => {
|
|
127
|
+
try {
|
|
128
|
+
const [input, init] = args;
|
|
129
|
+
if (input instanceof Request && init === void 0) return input;
|
|
130
|
+
return new Request(input, init);
|
|
131
|
+
} catch {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
//#endregion
|
|
136
|
+
export { installBindingPatch };
|
|
137
|
+
|
|
138
|
+
//# sourceMappingURL=binding-patch.js.map
|
|
139
|
+
//# debugId=70c459b2-f813-5c06-8d88-9bd810926724
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"binding-patch.js","sources":["../src/binding-patch.ts"],"sourcesContent":["import { requestCaptureContext } from \"./context\";\nimport { warnOnce } from \"./log\";\nimport { captureOutboundCall } from \"./outbound-capture\";\n\n/**\n * Records calls made through Cloudflare bindings (`env.MY_SERVICE.fetch(...)`).\n *\n * The `globalThis.fetch` patch cannot see these: a binding's `fetch` is a method on a host\n * object, and workerd routes it as an internal subrequest that never touches the global. So\n * the binding's *prototype* method is patched instead — the same move the Node recorder makes\n * on `Redis.prototype.sendCommand`.\n *\n * The prototype is reached from an instance found on `env`, never from a global name: in\n * workerd there is no `globalThis.Fetcher` (the runtime type is a bare TypeScript alias, not\n * one of the exposed runtime classes). Reaching it through an instance is also what makes\n * this work at all for an app that reads its bindings from `cloudflare:workers` instead of\n * the handler argument — that module hands out a *different* `env` object but the *same*\n * binding instances, so the prototype we patch is shared either way.\n *\n * Verified against real workerd in\n * `packages/backend-recorder-js/src/__tests__/workerd-binding-patch.spec.ts`, which also\n * pins the properties this relies on: `fetch` is inherited and configurable, the receiver\n * survives, one patch covers every service binding plus Durable Object stubs, and a binding\n * call is not also counted as a `globalThis.fetch` call.\n */\n\n// Symbol.for so a second copy of the shim (e.g. bundled twice) still detects the patch.\nconst BINDING_FETCH_PATCHED = Symbol.for(\"meticulous.workerd.bindingPatched\");\n\n/**\n * Bindings never recorded, by name.\n *\n * An assets binding is shape-identical to a service binding — same prototype, same `fetch` —\n * so it cannot be told apart by duck-typing and has to be skipped by name. Recording it would\n * push every static asset through the body capture cap for no benefit: asset bytes are large\n * and often binary, and asset serving is typically the highest-volume call a worker makes.\n */\nconst DEFAULT_SKIPPED_BINDINGS = [\"ASSETS\", \"__STATIC_CONTENT\"] as const;\n\n/** Identifies a binding instance so its calls can be attributed to an `env` key. */\nconst bindingNames = new WeakMap<object, string>();\nconst skippedBindings = new WeakSet<object>();\n\ninterface FetcherLike {\n fetch: (...args: unknown[]) => Promise<Response>;\n}\n\ntype BindingFetch = (...args: unknown[]) => Promise<Response>;\n\nexport interface InstallBindingPatchOptions {\n /** Extra binding names to leave unrecorded, on top of the defaults. */\n skipBindings?: readonly string[];\n}\n\n/**\n * Discovers Fetcher-shaped bindings on `env` and patches their shared prototype so their\n * `fetch` calls are recorded. Safe to call on every request — discovery is cheap and the\n * patch 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 installBindingPatch = (\n env: unknown,\n options?: InstallBindingPatchOptions,\n): void => {\n try {\n for (const { name, value, skip } of fetcherBindings(\n env,\n options?.skipBindings,\n )) {\n // Patch even a skipped binding's prototype: it is shared with every other\n // Fetcher-shaped binding (and is in a Durable Object stub's prototype chain), so\n // seeding it is what gives those coverage. Skipping is enforced per instance at call\n // time, not by declining to patch.\n patchBindingPrototype(value);\n if (skip) {\n skippedBindings.add(value);\n continue;\n }\n bindingNames.set(value, name);\n }\n } catch (error) {\n warnOnce(\n \"binding-patch-install\",\n \"Failed to install the Meticulous binding patch — binding calls will not be recorded.\",\n error,\n );\n }\n};\n\ninterface DiscoveredBinding {\n name: string;\n value: object;\n skip: boolean;\n}\n\nconst fetcherBindings = (\n env: unknown,\n extraSkipped: readonly string[] | undefined,\n): DiscoveredBinding[] => {\n if (env === null || typeof env !== \"object\") {\n return [];\n }\n const skipped = new Set<string>([\n ...DEFAULT_SKIPPED_BINDINGS,\n ...(extraSkipped ?? []),\n ]);\n const found: DiscoveredBinding[] = [];\n for (const [name, value] of Object.entries(env)) {\n if (isFetcherLike(value)) {\n found.push({ name, value, skip: skipped.has(name) });\n }\n }\n return found;\n};\n\n/**\n * A binding we can record: something with a `fetch` method. Durable Object *namespaces* are\n * excluded — they have no `fetch`; it is the stub from `namespace.get()` that does, and that\n * stub inherits the same prototype, so it is covered without being discovered here.\n */\nconst isFetcherLike = (value: unknown): value is object & FetcherLike =>\n value !== null &&\n typeof value === \"object\" &&\n typeof (value as Partial<FetcherLike>).fetch === \"function\";\n\nconst patchBindingPrototype = (binding: object): void => {\n const prototype = Object.getPrototypeOf(binding) as\n | (object & Record<symbol, unknown>)\n | null;\n if (prototype === null || prototype[BINDING_FETCH_PATCHED] === true) {\n return;\n }\n const descriptor = Object.getOwnPropertyDescriptor(prototype, \"fetch\");\n if (\n descriptor === undefined ||\n typeof descriptor.value !== \"function\" ||\n descriptor.configurable !== true\n ) {\n // Either `fetch` is an own per-instance property or the runtime has locked the\n // prototype. Recording binding calls is not possible; everything else keeps working.\n warnOnce(\n \"binding-patch-unsupported\",\n \"This runtime does not expose a patchable binding `fetch` — binding calls will not be recorded.\",\n );\n return;\n }\n\n const original = descriptor.value as BindingFetch;\n Object.defineProperty(prototype, \"fetch\", {\n value: patchedBindingFetch(original),\n writable: true,\n enumerable: false,\n configurable: true,\n });\n Object.defineProperty(prototype, BINDING_FETCH_PATCHED, {\n value: true,\n enumerable: false,\n configurable: true,\n });\n};\n\n/**\n * A `function`, not an arrow: `this` is the binding the call was made on, and it must be\n * forwarded to the original or workerd throws \"Illegal invocation\".\n */\nconst patchedBindingFetch = (original: BindingFetch): BindingFetch =>\n function (this: unknown, ...args: unknown[]): Promise<Response> {\n const passThrough = (): Promise<Response> => original.apply(this, args);\n\n const ctx = requestCaptureContext.getStore();\n // Record mode only. `installBindingPatch` is not called for a replay request, but the\n // patch is per-isolate: a record request could have installed it earlier, and a replay\n // request must not be teed into a sidecar that has no exporter behind it.\n if (!ctx || ctx.mode !== \"record\") {\n return passThrough();\n }\n const receiver =\n typeof this === \"object\" && this !== null ? this : undefined;\n if (receiver !== undefined && skippedBindings.has(receiver)) {\n return passThrough();\n }\n\n const request = toRequest(args);\n if (request === undefined) {\n return passThrough();\n }\n\n // Defence in depth: a binding pointed at the sidecar must not be recorded. Forwards\n // `request`, not `args` — normalizing may already have consumed the original body.\n if (request.url.startsWith(`${ctx.sidecarUrl}/`)) {\n return original.apply(this, [request]);\n }\n\n const bindingName =\n receiver !== undefined ? bindingNames.get(receiver) : undefined;\n return captureOutboundCall(\n ctx,\n { kind: \"binding\", bindingName },\n request,\n (req) => original.apply(this, [req]),\n );\n };\n\n/**\n * Normalizes the call's arguments to a single Request, or undefined if they cannot be\n * represented (in which case the call passes through untouched and the runtime produces its\n * own error).\n *\n * A lone `Request` is passed along as-is rather than rebuilt: `new Request(existing)` drops\n * workerd's `cf` property, which the receiving worker may read.\n */\nconst toRequest = (args: unknown[]): Request | undefined => {\n try {\n const [input, init] = args;\n if (input instanceof Request && init === undefined) {\n return input;\n }\n return new Request(input as RequestInfo, init as RequestInit | undefined);\n } catch {\n return undefined;\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,MAAM,wBAAwB,OAAO,IAAI,oCAAoC;;;;;;;;;AAU7E,MAAM,2BAA2B,CAAC,UAAU,mBAAmB;;AAG/D,MAAM,+BAAe,IAAI,SAAyB;AAClD,MAAM,kCAAkB,IAAI,SAAiB;;;;;;;;AAoB7C,MAAa,uBACX,KACA,YACS;AACT,KAAI;AACF,OAAK,MAAM,EAAE,MAAM,OAAO,UAAU,gBAClC,KACA,SAAS,aACV,EAAE;AAKD,yBAAsB,MAAM;AAC5B,OAAI,MAAM;AACR,oBAAgB,IAAI,MAAM;AAC1B;;AAEF,gBAAa,IAAI,OAAO,KAAK;;UAExB,OAAO;AACd,WACE,yBACA,wFACA,MACD;;;AAUL,MAAM,mBACJ,KACA,iBACwB;AACxB,KAAI,QAAQ,QAAQ,OAAO,QAAQ,SACjC,QAAO,EAAE;CAEX,MAAM,UAAU,IAAI,IAAY,CAC9B,GAAG,0BACH,GAAI,gBAAgB,EAAE,CACvB,CAAC;CACF,MAAM,QAA6B,EAAE;AACrC,MAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,IAAI,CAC7C,KAAI,cAAc,MAAM,CACtB,OAAM,KAAK;EAAE;EAAM;EAAO,MAAM,QAAQ,IAAI,KAAK;EAAE,CAAC;AAGxD,QAAO;;;;;;;AAQT,MAAM,iBAAiB,UACrB,UAAU,QACV,OAAO,UAAU,YACjB,OAAQ,MAA+B,UAAU;AAEnD,MAAM,yBAAyB,YAA0B;CACvD,MAAM,YAAY,OAAO,eAAe,QAAQ;AAGhD,KAAI,cAAc,QAAQ,UAAU,2BAA2B,KAC7D;CAEF,MAAM,aAAa,OAAO,yBAAyB,WAAW,QAAQ;AACtE,KACE,eAAe,KAAA,KACf,OAAO,WAAW,UAAU,cAC5B,WAAW,iBAAiB,MAC5B;AAGA,WACE,6BACA,iGACD;AACD;;CAGF,MAAM,WAAW,WAAW;AAC5B,QAAO,eAAe,WAAW,SAAS;EACxC,OAAO,oBAAoB,SAAS;EACpC,UAAU;EACV,YAAY;EACZ,cAAc;EACf,CAAC;AACF,QAAO,eAAe,WAAW,uBAAuB;EACtD,OAAO;EACP,YAAY;EACZ,cAAc;EACf,CAAC;;;;;;AAOJ,MAAM,uBAAuB,aAC3B,SAAyB,GAAG,MAAoC;CAC9D,MAAM,oBAAuC,SAAS,MAAM,MAAM,KAAK;CAEvE,MAAM,MAAM,sBAAsB,UAAU;AAI5C,KAAI,CAAC,OAAO,IAAI,SAAS,SACvB,QAAO,aAAa;CAEtB,MAAM,WACJ,OAAO,SAAS,YAAY,SAAS,OAAO,OAAO,KAAA;AACrD,KAAI,aAAa,KAAA,KAAa,gBAAgB,IAAI,SAAS,CACzD,QAAO,aAAa;CAGtB,MAAM,UAAU,UAAU,KAAK;AAC/B,KAAI,YAAY,KAAA,EACd,QAAO,aAAa;AAKtB,KAAI,QAAQ,IAAI,WAAW,GAAG,IAAI,WAAW,GAAG,CAC9C,QAAO,SAAS,MAAM,MAAM,CAAC,QAAQ,CAAC;AAKxC,QAAO,oBACL,KACA;EAAE,MAAM;EAAW,aAHnB,aAAa,KAAA,IAAY,aAAa,IAAI,SAAS,GAAG,KAAA;EAGtB,EAChC,UACC,QAAQ,SAAS,MAAM,MAAM,CAAC,IAAI,CAAC,CACrC;;;;;;;;;;AAWL,MAAM,aAAa,SAAyC;AAC1D,KAAI;EACF,MAAM,CAAC,OAAO,QAAQ;AACtB,MAAI,iBAAiB,WAAW,SAAS,KAAA,EACvC,QAAO;AAET,SAAO,IAAI,QAAQ,OAAsB,KAAgC;SACnE;AACN","debugId":"70c459b2-f813-5c06-8d88-9bd810926724"}
|
package/dist/body-capture.d.ts
CHANGED
|
@@ -10,6 +10,15 @@ declare const MAX_BODY_CAPTURE_SIZE: number;
|
|
|
10
10
|
* undefined for absent bodies.
|
|
11
11
|
*/
|
|
12
12
|
declare const readBodyWithCap: (stream: ReadableStream<Uint8Array> | null) => Promise<CapturedBody | undefined>;
|
|
13
|
+
/**
|
|
14
|
+
* Reads a *request* body, redacting secret-looking fields before it leaves the worker.
|
|
15
|
+
*
|
|
16
|
+
* Every request-body capture path goes through here, so record and replay redact
|
|
17
|
+
* identically and any hash derived from the body still agrees on both sides. Response
|
|
18
|
+
* bodies deliberately use {@link readBodyWithCap} directly — they are served back to the
|
|
19
|
+
* app during replay and must stay byte-exact.
|
|
20
|
+
*/
|
|
21
|
+
declare const readRequestBodyWithCap: (stream: ReadableStream<Uint8Array> | null) => Promise<CapturedBody | undefined>;
|
|
13
22
|
//#endregion
|
|
14
|
-
export { MAX_BODY_CAPTURE_SIZE, readBodyWithCap };
|
|
23
|
+
export { MAX_BODY_CAPTURE_SIZE, readBodyWithCap, readRequestBodyWithCap };
|
|
15
24
|
//# sourceMappingURL=body-capture.d.ts.map
|
package/dist/body-capture.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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]="82f92c5c-a249-5f4f-8a6f-f6494ee6fa7c")}catch(e){}}();
|
|
3
|
+
import { redactRequestBody } from "./redact-body.js";
|
|
1
4
|
//#region src/body-capture.ts
|
|
2
5
|
/** Matches the Node backend recorder's body capture cap (met-http-instrumentation). */
|
|
3
|
-
|
|
4
|
-
!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]="b096087d-759c-5fe6-bdea-cedbc645fade")}catch(e){}}();
|
|
5
6
|
const MAX_BODY_CAPTURE_SIZE = 256 * 1024;
|
|
6
7
|
/** Cap on how long we keep reading a (possibly never-ending, e.g. SSE) body clone. */
|
|
7
8
|
const BODY_READ_TIMEOUT_MS = 1e4;
|
|
@@ -55,8 +56,24 @@ const readBodyWithCap = async (stream) => {
|
|
|
55
56
|
truncated
|
|
56
57
|
};
|
|
57
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* Reads a *request* body, redacting secret-looking fields before it leaves the worker.
|
|
61
|
+
*
|
|
62
|
+
* Every request-body capture path goes through here, so record and replay redact
|
|
63
|
+
* identically and any hash derived from the body still agrees on both sides. Response
|
|
64
|
+
* bodies deliberately use {@link readBodyWithCap} directly — they are served back to the
|
|
65
|
+
* app during replay and must stay byte-exact.
|
|
66
|
+
*/
|
|
67
|
+
const readRequestBodyWithCap = async (stream) => {
|
|
68
|
+
const captured = await readBodyWithCap(stream);
|
|
69
|
+
if (captured === void 0) return;
|
|
70
|
+
return {
|
|
71
|
+
...captured,
|
|
72
|
+
body: redactRequestBody(captured.body)
|
|
73
|
+
};
|
|
74
|
+
};
|
|
58
75
|
//#endregion
|
|
59
|
-
export { MAX_BODY_CAPTURE_SIZE, readBodyWithCap };
|
|
76
|
+
export { MAX_BODY_CAPTURE_SIZE, readBodyWithCap, readRequestBodyWithCap };
|
|
60
77
|
|
|
61
78
|
//# sourceMappingURL=body-capture.js.map
|
|
62
|
-
//# debugId=
|
|
79
|
+
//# debugId=82f92c5c-a249-5f4f-8a6f-f6494ee6fa7c
|
package/dist/body-capture.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"body-capture.js","sources":["../src/body-capture.ts"],"sourcesContent":["import type { CapturedBody } from \"./protocol\";\n\n/** Matches the Node backend recorder's body capture cap (met-http-instrumentation). */\nexport const MAX_BODY_CAPTURE_SIZE = 256 * 1024;\n\n/** Cap on how long we keep reading a (possibly never-ending, e.g. SSE) body clone. */\nconst BODY_READ_TIMEOUT_MS = 10_000;\n\nconst TIMED_OUT = Symbol(\"meticulous.bodyReadTimedOut\");\n\n/**\n * Reads a body stream up to {@link MAX_BODY_CAPTURE_SIZE} bytes, decoding as\n * UTF-8. Stops (and marks the capture truncated) on the size cap or the time\n * guard, so a long-lived stream can never pin the capture work. Returns\n * undefined for absent bodies.\n */\nexport const readBodyWithCap = async (\n stream: ReadableStream<Uint8Array> | null,\n): Promise<CapturedBody | undefined> => {\n if (!stream) {\n return undefined;\n }\n\n const reader = stream.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n let text = \"\";\n let bytes = 0;\n let truncated = false;\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timedOut = new Promise<typeof TIMED_OUT>((resolve) => {\n timer = setTimeout(() => resolve(TIMED_OUT), BODY_READ_TIMEOUT_MS);\n });\n\n try {\n while (true) {\n const result = await Promise.race([reader.read(), timedOut]);\n if (result === TIMED_OUT) {\n truncated = true;\n break;\n }\n const { done, value } = result;\n if (done) {\n break;\n }\n if (!value || value.byteLength === 0) {\n continue;\n }\n const remaining = MAX_BODY_CAPTURE_SIZE - bytes;\n if (value.byteLength >= remaining) {\n text += decoder.decode(value.subarray(0, remaining), { stream: true });\n bytes += remaining;\n truncated = truncated || value.byteLength > remaining;\n // Even an exact-cap chunk means we stop reading, so a longer stream\n // counts as truncated.\n const next = await Promise.race([reader.read(), timedOut]);\n if (next === TIMED_OUT || !next.done) {\n truncated = true;\n }\n break;\n }\n text += decoder.decode(value, { stream: true });\n bytes += value.byteLength;\n }\n } finally {\n clearTimeout(timer);\n reader.cancel().catch(() => {});\n }\n\n text += decoder.decode();\n return { body: text, truncated };\n};\n"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"body-capture.js","sources":["../src/body-capture.ts"],"sourcesContent":["import type { CapturedBody } from \"./protocol\";\nimport { redactRequestBody } from \"./redact-body\";\n\n/** Matches the Node backend recorder's body capture cap (met-http-instrumentation). */\nexport const MAX_BODY_CAPTURE_SIZE = 256 * 1024;\n\n/** Cap on how long we keep reading a (possibly never-ending, e.g. SSE) body clone. */\nconst BODY_READ_TIMEOUT_MS = 10_000;\n\nconst TIMED_OUT = Symbol(\"meticulous.bodyReadTimedOut\");\n\n/**\n * Reads a body stream up to {@link MAX_BODY_CAPTURE_SIZE} bytes, decoding as\n * UTF-8. Stops (and marks the capture truncated) on the size cap or the time\n * guard, so a long-lived stream can never pin the capture work. Returns\n * undefined for absent bodies.\n */\nexport const readBodyWithCap = async (\n stream: ReadableStream<Uint8Array> | null,\n): Promise<CapturedBody | undefined> => {\n if (!stream) {\n return undefined;\n }\n\n const reader = stream.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n let text = \"\";\n let bytes = 0;\n let truncated = false;\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timedOut = new Promise<typeof TIMED_OUT>((resolve) => {\n timer = setTimeout(() => resolve(TIMED_OUT), BODY_READ_TIMEOUT_MS);\n });\n\n try {\n while (true) {\n const result = await Promise.race([reader.read(), timedOut]);\n if (result === TIMED_OUT) {\n truncated = true;\n break;\n }\n const { done, value } = result;\n if (done) {\n break;\n }\n if (!value || value.byteLength === 0) {\n continue;\n }\n const remaining = MAX_BODY_CAPTURE_SIZE - bytes;\n if (value.byteLength >= remaining) {\n text += decoder.decode(value.subarray(0, remaining), { stream: true });\n bytes += remaining;\n truncated = truncated || value.byteLength > remaining;\n // Even an exact-cap chunk means we stop reading, so a longer stream\n // counts as truncated.\n const next = await Promise.race([reader.read(), timedOut]);\n if (next === TIMED_OUT || !next.done) {\n truncated = true;\n }\n break;\n }\n text += decoder.decode(value, { stream: true });\n bytes += value.byteLength;\n }\n } finally {\n clearTimeout(timer);\n reader.cancel().catch(() => {});\n }\n\n text += decoder.decode();\n return { body: text, truncated };\n};\n\n/**\n * Reads a *request* body, redacting secret-looking fields before it leaves the worker.\n *\n * Every request-body capture path goes through here, so record and replay redact\n * identically and any hash derived from the body still agrees on both sides. Response\n * bodies deliberately use {@link readBodyWithCap} directly — they are served back to the\n * app during replay and must stay byte-exact.\n */\nexport const readRequestBodyWithCap = async (\n stream: ReadableStream<Uint8Array> | null,\n): Promise<CapturedBody | undefined> => {\n const captured = await readBodyWithCap(stream);\n if (captured === undefined) {\n return undefined;\n }\n return { ...captured, body: redactRequestBody(captured.body) };\n};\n"],"names":[],"mappings":";;;;;AAIA,MAAa,wBAAwB,MAAM;;AAG3C,MAAM,uBAAuB;AAE7B,MAAM,YAAY,OAAO,8BAA8B;;;;;;;AAQvD,MAAa,kBAAkB,OAC7B,WACsC;AACtC,KAAI,CAAC,OACH;CAGF,MAAM,SAAS,OAAO,WAAW;CACjC,MAAM,UAAU,IAAI,YAAY,QAAQ;CACxC,IAAI,OAAO;CACX,IAAI,QAAQ;CACZ,IAAI,YAAY;CAEhB,IAAI;CACJ,MAAM,WAAW,IAAI,SAA2B,YAAY;AAC1D,UAAQ,iBAAiB,QAAQ,UAAU,EAAE,qBAAqB;GAClE;AAEF,KAAI;AACF,SAAO,MAAM;GACX,MAAM,SAAS,MAAM,QAAQ,KAAK,CAAC,OAAO,MAAM,EAAE,SAAS,CAAC;AAC5D,OAAI,WAAW,WAAW;AACxB,gBAAY;AACZ;;GAEF,MAAM,EAAE,MAAM,UAAU;AACxB,OAAI,KACF;AAEF,OAAI,CAAC,SAAS,MAAM,eAAe,EACjC;GAEF,MAAM,YAAY,wBAAwB;AAC1C,OAAI,MAAM,cAAc,WAAW;AACjC,YAAQ,QAAQ,OAAO,MAAM,SAAS,GAAG,UAAU,EAAE,EAAE,QAAQ,MAAM,CAAC;AACtE,aAAS;AACT,gBAAY,aAAa,MAAM,aAAa;IAG5C,MAAM,OAAO,MAAM,QAAQ,KAAK,CAAC,OAAO,MAAM,EAAE,SAAS,CAAC;AAC1D,QAAI,SAAS,aAAa,CAAC,KAAK,KAC9B,aAAY;AAEd;;AAEF,WAAQ,QAAQ,OAAO,OAAO,EAAE,QAAQ,MAAM,CAAC;AAC/C,YAAS,MAAM;;WAET;AACR,eAAa,MAAM;AACnB,SAAO,QAAQ,CAAC,YAAY,GAAG;;AAGjC,SAAQ,QAAQ,QAAQ;AACxB,QAAO;EAAE,MAAM;EAAM;EAAW;;;;;;;;;;AAWlC,MAAa,yBAAyB,OACpC,WACsC;CACtC,MAAM,WAAW,MAAM,gBAAgB,OAAO;AAC9C,KAAI,aAAa,KAAA,EACf;AAEF,QAAO;EAAE,GAAG;EAAU,MAAM,kBAAkB,SAAS,KAAK;EAAE","debugId":"82f92c5c-a249-5f4f-8a6f-f6494ee6fa7c"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
package/dist/context.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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]="
|
|
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]="66b36970-e102-5f7c-b0d7-2ed1d1681011")}catch(e){}}();
|
|
3
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
4
|
//#region src/context.ts
|
|
5
5
|
const requestCaptureContext = new AsyncLocalStorage();
|
|
@@ -7,4 +7,4 @@ const requestCaptureContext = new AsyncLocalStorage();
|
|
|
7
7
|
export { requestCaptureContext };
|
|
8
8
|
|
|
9
9
|
//# sourceMappingURL=context.js.map
|
|
10
|
-
//# debugId=
|
|
10
|
+
//# debugId=66b36970-e102-5f7c-b0d7-2ed1d1681011
|
package/dist/context.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"context.js","sources":["../src/context.ts"],"sourcesContent":["import { AsyncLocalStorage } from \"node:async_hooks\";\n\n/**\n * Per-inbound-request context, entered by the withMeticulous wrapper and read\n * by the fetch patch. Requires the `nodejs_als` (or `nodejs_compat`)\n * compatibility flag in the worker's wrangler configuration.\n */\
|
|
1
|
+
{"version":3,"file":"context.js","sources":["../src/context.ts"],"sourcesContent":["import { AsyncLocalStorage } from \"node:async_hooks\";\n\n/**\n * Per-inbound-request context, entered by the withMeticulous wrapper and read\n * by the fetch patch. Requires the `nodejs_als` (or `nodejs_compat`)\n * compatibility flag in the worker's wrangler configuration.\n */\ninterface BaseRequestContext {\n requestId: string;\n /** Normalized origin of the sidecar, e.g. \"http://127.0.0.1:9670\". No trailing slash. */\n sidecarUrl: string;\n /** The inbound request's ctx.waitUntil, for post-response capture work. */\n waitUntil: (promise: Promise<unknown>) => void;\n}\n\n/** Recording: capture events are reported to the sidecar, which stays out of the request path. */\nexport interface RequestCaptureContext extends BaseRequestContext {\n mode: \"record\";\n frontendSessionId: string | undefined;\n}\n\n/**\n * Replaying: outbound calls are looked up against the sidecar's mock store before they\n * leave the worker.\n *\n * `frontendSessionId` is required here, unlike recording. Mocks are indexed per session, so\n * a replay context without one could only ever miss — and every miss falls through to the\n * real upstream. Making it non-optional keeps that silently-wrong state unrepresentable.\n */\nexport interface RequestReplayContext extends BaseRequestContext {\n mode: \"replay\";\n frontendSessionId: string;\n /**\n * Instant to freeze the worker's clock at while serving this session, or undefined to\n * leave the real clock alone.\n */\n clockAnchorMs: number | undefined;\n}\n\nexport type RequestContext = RequestCaptureContext | RequestReplayContext;\n\nexport const requestCaptureContext = new AsyncLocalStorage<RequestContext>();\n"],"names":[],"mappings":";;;;AAyCA,MAAa,wBAAwB,IAAI,mBAAmC","debugId":"66b36970-e102-5f7c-b0d7-2ed1d1681011"}
|
package/dist/fetch-patch.js
CHANGED
|
@@ -1,36 +1,27 @@
|
|
|
1
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]="
|
|
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]="860c32d0-1245-5707-9cd4-7680ba2d00aa")}catch(e){}}();
|
|
3
3
|
import { requestCaptureContext } from "./context.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { postCaptureEvents } from "./sidecar-client.js";
|
|
4
|
+
import { getOriginalFetch, setOriginalFetch } from "./original-fetch.js";
|
|
5
|
+
import { captureOutboundCall } from "./outbound-capture.js";
|
|
6
|
+
import { replayOutboundCall } from "./replay-fetch.js";
|
|
8
7
|
//#region src/fetch-patch.ts
|
|
9
8
|
const FETCH_PATCHED = Symbol.for("meticulous.workerd.fetchPatched");
|
|
10
|
-
let originalFetch;
|
|
11
|
-
/** The unpatched fetch — used for the shim's own sidecar requests. */
|
|
12
|
-
const getOriginalFetch = () => originalFetch ?? globalThis.fetch;
|
|
13
|
-
const capturedHeaders = new Set(CAPTURED_HEADERS);
|
|
14
|
-
const headersToRecord = (headers) => {
|
|
15
|
-
const record = {};
|
|
16
|
-
headers.forEach((value, name) => {
|
|
17
|
-
const lowerName = name.toLowerCase();
|
|
18
|
-
if (capturedHeaders.has(lowerName)) (record[lowerName] ??= []).push(value);
|
|
19
|
-
});
|
|
20
|
-
return record;
|
|
21
|
-
};
|
|
22
9
|
/**
|
|
23
|
-
* Replaces globalThis.fetch with
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
10
|
+
* Replaces globalThis.fetch with the mode-appropriate interception:
|
|
11
|
+
*
|
|
12
|
+
* - recording: a passive capture tee. The real fetch is called directly (the sidecar is
|
|
13
|
+
* never in the request path); request and response are cloned and reported in the
|
|
14
|
+
* background.
|
|
15
|
+
* - replaying: decide-then-serve. The call is looked up against the sidecar's mock store
|
|
16
|
+
* and a recorded response is synthesised, or the real call is let through on a miss.
|
|
17
|
+
*
|
|
18
|
+
* Outside a withMeticulous request context the patch is a pure pass-through. Idempotent.
|
|
28
19
|
*/
|
|
29
20
|
const installFetchPatch = () => {
|
|
30
21
|
const holder = globalThis;
|
|
31
22
|
if (holder[FETCH_PATCHED]) return;
|
|
32
23
|
holder[FETCH_PATCHED] = true;
|
|
33
|
-
|
|
24
|
+
setOriginalFetch(globalThis.fetch.bind(globalThis));
|
|
34
25
|
globalThis.fetch = patchedFetch;
|
|
35
26
|
};
|
|
36
27
|
const patchedFetch = async (input, init) => {
|
|
@@ -44,87 +35,10 @@ const patchedFetch = async (input, init) => {
|
|
|
44
35
|
return original(input, init);
|
|
45
36
|
}
|
|
46
37
|
if (request.url.startsWith(`${ctx.sidecarUrl}/`)) return original(request);
|
|
47
|
-
|
|
48
|
-
let meta;
|
|
49
|
-
let requestBodyPromise = Promise.resolve(void 0);
|
|
50
|
-
try {
|
|
51
|
-
meta = {
|
|
52
|
-
method: request.method,
|
|
53
|
-
url: request.url,
|
|
54
|
-
requestHeaders: headersToRecord(request.headers)
|
|
55
|
-
};
|
|
56
|
-
if (request.body) requestBodyPromise = readBodyWithCap(request.clone().body).catch(() => void 0);
|
|
57
|
-
} catch (error) {
|
|
58
|
-
warnOnce("outbound-request-capture", "Failed to capture an outbound request.", error);
|
|
59
|
-
meta = void 0;
|
|
60
|
-
}
|
|
61
|
-
let response;
|
|
62
|
-
try {
|
|
63
|
-
response = await original(request);
|
|
64
|
-
} catch (error) {
|
|
65
|
-
if (meta) {
|
|
66
|
-
const frozenMeta = meta;
|
|
67
|
-
const endTimeMs = Date.now();
|
|
68
|
-
const errorMessage = String(error);
|
|
69
|
-
reportInBackground(ctx, async () => {
|
|
70
|
-
const requestBody = await requestBodyPromise;
|
|
71
|
-
const event = {
|
|
72
|
-
kind: "outbound",
|
|
73
|
-
requestId: ctx.requestId,
|
|
74
|
-
...ctx.frontendSessionId !== void 0 ? { frontendSessionId: ctx.frontendSessionId } : {},
|
|
75
|
-
...frozenMeta,
|
|
76
|
-
...requestBody !== void 0 ? { requestBody } : {},
|
|
77
|
-
startTimeMs,
|
|
78
|
-
endTimeMs,
|
|
79
|
-
error: errorMessage
|
|
80
|
-
};
|
|
81
|
-
await postCaptureEvents(original, ctx.sidecarUrl, [event]);
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
throw error;
|
|
85
|
-
}
|
|
86
|
-
if (meta) {
|
|
87
|
-
const frozenMeta = meta;
|
|
88
|
-
try {
|
|
89
|
-
const responseClone = response.clone();
|
|
90
|
-
const responseHeaders = headersToRecord(response.headers);
|
|
91
|
-
const statusCode = response.status;
|
|
92
|
-
const endTimeMs = Date.now();
|
|
93
|
-
ctx.waitUntil((async () => {
|
|
94
|
-
const [requestBody, responseBody] = await Promise.all([requestBodyPromise, readBodyWithCap(responseClone.body).catch(() => void 0)]);
|
|
95
|
-
const event = {
|
|
96
|
-
kind: "outbound",
|
|
97
|
-
requestId: ctx.requestId,
|
|
98
|
-
...ctx.frontendSessionId !== void 0 ? { frontendSessionId: ctx.frontendSessionId } : {},
|
|
99
|
-
...frozenMeta,
|
|
100
|
-
responseHeaders,
|
|
101
|
-
statusCode,
|
|
102
|
-
...requestBody !== void 0 ? { requestBody } : {},
|
|
103
|
-
...responseBody !== void 0 ? { responseBody } : {},
|
|
104
|
-
startTimeMs,
|
|
105
|
-
endTimeMs
|
|
106
|
-
};
|
|
107
|
-
await postCaptureEvents(original, ctx.sidecarUrl, [event]);
|
|
108
|
-
})().catch((error) => {
|
|
109
|
-
warnOnce("outbound-response-capture", "Failed to capture an outbound response.", error);
|
|
110
|
-
}));
|
|
111
|
-
} catch (error) {
|
|
112
|
-
warnOnce("outbound-response-capture", "Failed to capture an outbound response.", error);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return response;
|
|
116
|
-
};
|
|
117
|
-
const reportInBackground = (ctx, report) => {
|
|
118
|
-
try {
|
|
119
|
-
ctx.waitUntil(report().catch((error) => {
|
|
120
|
-
warnOnce("outbound-error-report", "Failed to report a failed outbound request.", error);
|
|
121
|
-
}));
|
|
122
|
-
} catch (error) {
|
|
123
|
-
warnOnce("outbound-error-report", "Failed to report a failed outbound request.", error);
|
|
124
|
-
}
|
|
38
|
+
return ctx.mode === "replay" ? replayOutboundCall(ctx, request, (req) => original(req)) : captureOutboundCall(ctx, { kind: "outbound" }, request, (req) => original(req));
|
|
125
39
|
};
|
|
126
40
|
//#endregion
|
|
127
|
-
export {
|
|
41
|
+
export { installFetchPatch };
|
|
128
42
|
|
|
129
43
|
//# sourceMappingURL=fetch-patch.js.map
|
|
130
|
-
//# debugId=
|
|
44
|
+
//# debugId=860c32d0-1245-5707-9cd4-7680ba2d00aa
|
package/dist/fetch-patch.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fetch-patch.js","sources":["../src/fetch-patch.ts"],"sourcesContent":["import {
|
|
1
|
+
{"version":3,"file":"fetch-patch.js","sources":["../src/fetch-patch.ts"],"sourcesContent":["import { requestCaptureContext } from \"./context\";\nimport { getOriginalFetch, setOriginalFetch } from \"./original-fetch\";\nimport { captureOutboundCall } from \"./outbound-capture\";\nimport { replayOutboundCall } from \"./replay-fetch\";\n\ntype FetchFn = typeof globalThis.fetch;\n\n// Symbol.for so a second copy of the shim (e.g. bundled twice) still detects the patch.\nconst FETCH_PATCHED = Symbol.for(\"meticulous.workerd.fetchPatched\");\n\nexport { getOriginalFetch } from \"./original-fetch\";\nexport { headersToRecord } from \"./outbound-capture\";\n\n/**\n * Replaces globalThis.fetch with the mode-appropriate interception:\n *\n * - recording: a passive capture tee. The real fetch is called directly (the sidecar is\n * never in the request path); request and response are cloned and reported in the\n * background.\n * - replaying: decide-then-serve. The call is looked up against the sidecar's mock store\n * and a recorded response is synthesised, or the real call is let through on a miss.\n *\n * Outside a withMeticulous request context the patch is a pure pass-through. Idempotent.\n */\nexport const installFetchPatch = (): void => {\n const holder = globalThis as { [FETCH_PATCHED]?: boolean };\n if (holder[FETCH_PATCHED]) {\n return;\n }\n holder[FETCH_PATCHED] = true;\n setOriginalFetch(globalThis.fetch.bind(globalThis));\n globalThis.fetch = patchedFetch;\n};\n\nconst patchedFetch: FetchFn = async (input, init) => {\n const original = getOriginalFetch();\n const ctx = requestCaptureContext.getStore();\n if (!ctx) {\n return original(input, init);\n }\n\n // Normalize to a single Request. This may consume `input`'s body (when\n // `input` is a Request), so from here on the real fetch must be given\n // `request`, never the original arguments.\n let request: Request;\n try {\n request = new Request(input, init);\n } catch {\n // Unconstructable arguments — let the real fetch produce the error.\n return original(input, init);\n }\n\n // Never intercept the shim's own traffic to the sidecar. Defence in depth: every\n // shim → sidecar call already goes through the unpatched fetch.\n if (request.url.startsWith(`${ctx.sidecarUrl}/`)) {\n return original(request);\n }\n\n return ctx.mode === \"replay\"\n ? replayOutboundCall(ctx, request, (req) => original(req))\n : captureOutboundCall(ctx, { kind: \"outbound\" }, request, (req) =>\n original(req),\n );\n};\n"],"names":[],"mappings":";;;;;;;AAQA,MAAM,gBAAgB,OAAO,IAAI,kCAAkC;;;;;;;;;;;;AAgBnE,MAAa,0BAAgC;CAC3C,MAAM,SAAS;AACf,KAAI,OAAO,eACT;AAEF,QAAO,iBAAiB;AACxB,kBAAiB,WAAW,MAAM,KAAK,WAAW,CAAC;AACnD,YAAW,QAAQ;;AAGrB,MAAM,eAAwB,OAAO,OAAO,SAAS;CACnD,MAAM,WAAW,kBAAkB;CACnC,MAAM,MAAM,sBAAsB,UAAU;AAC5C,KAAI,CAAC,IACH,QAAO,SAAS,OAAO,KAAK;CAM9B,IAAI;AACJ,KAAI;AACF,YAAU,IAAI,QAAQ,OAAO,KAAK;SAC5B;AAEN,SAAO,SAAS,OAAO,KAAK;;AAK9B,KAAI,QAAQ,IAAI,WAAW,GAAG,IAAI,WAAW,GAAG,CAC9C,QAAO,SAAS,QAAQ;AAG1B,QAAO,IAAI,SAAS,WAChB,mBAAmB,KAAK,UAAU,QAAQ,SAAS,IAAI,CAAC,GACxD,oBAAoB,KAAK,EAAE,MAAM,YAAY,EAAE,UAAU,QACvD,SAAS,IAAI,CACd","debugId":"860c32d0-1245-5707-9cd4-7680ba2d00aa"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { CAPTURED_HEADERS, CaptureEvent, CaptureEventsPayload, CapturedBody, FRONTEND_SESSION_ID_HEADER, InboundRequestEvent, OutboundRequestEvent, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER } from "./protocol.js";
|
|
2
|
-
import { MAX_BODY_CAPTURE_SIZE, readBodyWithCap } from "./body-capture.js";
|
|
3
|
-
import { headersToRecord } from "./
|
|
1
|
+
import { BindingRequestEvent, CAPTURED_HEADERS, CaptureEvent, CaptureEventsPayload, CapturedBody, FRONTEND_SESSION_ID_HEADER, InboundRequestEvent, 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 } from "./protocol.js";
|
|
2
|
+
import { MAX_BODY_CAPTURE_SIZE, readBodyWithCap, readRequestBodyWithCap } from "./body-capture.js";
|
|
3
|
+
import { headersToRecord } from "./outbound-capture.js";
|
|
4
|
+
import { STR_REDACTED, redactRequestBody } from "./redact-body.js";
|
|
4
5
|
|
|
5
6
|
//#region src/index.d.ts
|
|
6
7
|
/** Structural subset of workerd's ExecutionContext — avoids a @cloudflare/workers-types dependency. */
|
|
@@ -14,23 +15,42 @@ interface MeticulousWorkerHandler<Env = never> {
|
|
|
14
15
|
interface WithMeticulousOptions {
|
|
15
16
|
/** Overrides the sidecar origin; defaults to the METICULOUS_SIDECAR_URL var/binding. */
|
|
16
17
|
sidecarUrl?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Binding names to leave unrecorded, on top of the defaults (`ASSETS`,
|
|
20
|
+
* `__STATIC_CONTENT`). Use this for a binding whose traffic is high-volume or binary.
|
|
21
|
+
*/
|
|
22
|
+
skipBindings?: readonly string[];
|
|
17
23
|
}
|
|
18
24
|
/**
|
|
19
|
-
* Wraps an ES-module Worker handler so
|
|
20
|
-
*
|
|
21
|
-
* reported to it as backend capture events:
|
|
25
|
+
* Wraps an ES-module Worker handler so Meticulous can record, or replay, the app's HTTP
|
|
26
|
+
* behaviour:
|
|
22
27
|
*
|
|
23
28
|
* export default withMeticulous({
|
|
24
29
|
* async fetch(request, env, ctx) { ... },
|
|
25
30
|
* });
|
|
26
31
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
+
* **Recording** activates on a `METICULOUS_SIDECAR_URL` var/binding (e.g. via `.dev.vars`
|
|
33
|
+
* or `wrangler dev --var`) or `options.sidecarUrl`. Inbound requests and outgoing `fetch`
|
|
34
|
+
* calls are reported to the sidecar as capture events, without affecting the app.
|
|
35
|
+
*
|
|
36
|
+
* **Replay** activates on the `x-meticulous-backend-replay-sidecar-url` header, injected by
|
|
37
|
+
* the Meticulous replay runner on requests to the app under test. Outgoing `fetch` calls are
|
|
38
|
+
* then served from the recording instead of reaching the real service, and the clock is
|
|
39
|
+
* frozen at the recorded session's end so recorded credentials are still valid. Workerd
|
|
40
|
+
* cannot read container environment variables, which is why per-replay config arrives as a
|
|
41
|
+
* request header; the shim validates it and only honours a loopback / docker-gateway /
|
|
42
|
+
* private-network `http:` origin.
|
|
43
|
+
*
|
|
44
|
+
* Replay takes precedence over recording when both are configured. A stale
|
|
45
|
+
* `METICULOUS_SIDECAR_URL` baked into an image's `.dev.vars` is far more likely than a
|
|
46
|
+
* spurious replay header (nothing but the replay runner emits it), and letting the env win
|
|
47
|
+
* would silently record a replay instead of mocking it.
|
|
48
|
+
*
|
|
49
|
+
* With neither configured the wrapper is a complete pass-through, so it is safe to keep in
|
|
50
|
+
* deployed code. Requires the `nodejs_als` (or `nodejs_compat`) compatibility flag. A
|
|
51
|
+
* capture failure or unreachable sidecar never affects the app.
|
|
32
52
|
*/
|
|
33
53
|
declare const withMeticulous: <H extends MeticulousWorkerHandler<never>>(handler: H, options?: WithMeticulousOptions) => H;
|
|
34
54
|
//#endregion
|
|
35
|
-
export { CAPTURED_HEADERS, type CaptureEvent, type CaptureEventsPayload, type CapturedBody, FRONTEND_SESSION_ID_HEADER, type InboundRequestEvent, MAX_BODY_CAPTURE_SIZE, MeticulousExecutionContext, MeticulousWorkerHandler, type OutboundRequestEvent, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, WithMeticulousOptions, headersToRecord, readBodyWithCap, withMeticulous };
|
|
55
|
+
export { type BindingRequestEvent, CAPTURED_HEADERS, type CaptureEvent, type CaptureEventsPayload, type CapturedBody, FRONTEND_SESSION_ID_HEADER, type InboundRequestEvent, MAX_BODY_CAPTURE_SIZE, MeticulousExecutionContext, MeticulousWorkerHandler, type OutboundFetchLookupRequest, type OutboundFetchLookupResponse, type OutboundRequestEvent, REPLAY_SIDECAR_URL_HEADER, type ReplaySessionInfoResponse, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH, STR_REDACTED, WithMeticulousOptions, headersToRecord, readBodyWithCap, readRequestBodyWithCap, redactRequestBody, withMeticulous };
|
|
36
56
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
Binary file
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":["../src/index.ts"],"sourcesContent":["import { type RequestCaptureContext, requestCaptureContext } from \"./context\";\nimport {\n getOriginalFetch,\n headersToRecord,\n installFetchPatch,\n} from \"./fetch-patch\";\nimport { warnOnce } from \"./log\";\nimport {\n FRONTEND_SESSION_ID_HEADER,\n type InboundRequestEvent,\n} from \"./protocol\";\nimport { postCaptureEvents } from \"./sidecar-client\";\n\nexport type {\n CaptureEvent,\n CapturedBody,\n CaptureEventsPayload,\n InboundRequestEvent,\n OutboundRequestEvent,\n} from \"./protocol\";\nexport {\n CAPTURED_HEADERS,\n FRONTEND_SESSION_ID_HEADER,\n SIDECAR_EVENTS_PATH,\n SIDECAR_PROTOCOL_VERSION,\n SIDECAR_PROTOCOL_VERSION_HEADER,\n} from \"./protocol\";\nexport { MAX_BODY_CAPTURE_SIZE, readBodyWithCap } from \"./body-capture\";\nexport { headersToRecord } from \"./fetch-patch\";\n\n/** Structural subset of workerd's ExecutionContext — avoids a @cloudflare/workers-types dependency. */\nexport interface MeticulousExecutionContext {\n waitUntil(promise: Promise<unknown>): void;\n}\n\n/** Structural subset of an ES-module Worker handler (`export default {...}`). */\nexport interface MeticulousWorkerHandler<Env = never> {\n fetch(\n request: Request,\n env: Env,\n ctx: MeticulousExecutionContext,\n ): Response | Promise<Response>;\n}\n\nexport interface WithMeticulousOptions {\n /** Overrides the sidecar origin; defaults to the METICULOUS_SIDECAR_URL var/binding. */\n sidecarUrl?: string;\n}\n\n/**\n * Wraps an ES-module Worker handler so that, when a Meticulous recorder\n * sidecar is configured, inbound requests and outgoing `fetch` calls are\n * reported to it as backend capture events:\n *\n * export default withMeticulous({\n * async fetch(request, env, ctx) { ... },\n * });\n *\n * Activation requires a `METICULOUS_SIDECAR_URL` var/binding (e.g. via\n * `.dev.vars` or `wrangler dev --var`) or `options.sidecarUrl`; without one\n * the wrapper is a complete pass-through, so it is safe to keep in deployed\n * code. Requires the `nodejs_als` (or `nodejs_compat`) compatibility flag.\n * A capture failure or unreachable sidecar never affects the app.\n */\nexport const withMeticulous = <H extends MeticulousWorkerHandler<never>>(\n handler: H,\n options?: WithMeticulousOptions,\n): H => {\n const wrappedFetch = async (\n request: Request,\n env: unknown,\n ctx: MeticulousExecutionContext,\n ): Promise<Response> => {\n const invokeHandler = () => handler.fetch(request, env as never, ctx);\n\n let sidecarUrl: string | undefined;\n try {\n sidecarUrl = resolveSidecarUrl(options, env);\n if (sidecarUrl !== undefined) {\n installFetchPatch();\n }\n } catch (error) {\n warnOnce(\n \"shim-init\",\n \"Failed to initialize the Meticulous backend recorder shim — recording disabled.\",\n error,\n );\n sidecarUrl = undefined;\n }\n if (sidecarUrl === undefined) {\n return invokeHandler();\n }\n\n const captureCtx = buildCaptureContext(request, sidecarUrl, ctx);\n const startTimeMs = Date.now();\n let requestHeaders: Record<string, string[]> = {};\n let method = \"GET\";\n let url = \"\";\n try {\n requestHeaders = headersToRecord(request.headers);\n method = request.method;\n url = request.url;\n } catch (error) {\n warnOnce(\n \"inbound-capture\",\n \"Failed to capture the inbound request.\",\n error,\n );\n }\n\n const reportInbound = (\n outcome:\n | { statusCode: number; responseHeaders: Record<string, string[]> }\n | { error: string },\n ): void => {\n try {\n const event: InboundRequestEvent = {\n kind: \"inbound\",\n requestId: captureCtx.requestId,\n ...(captureCtx.frontendSessionId !== undefined\n ? { frontendSessionId: captureCtx.frontendSessionId }\n : {}),\n method,\n url,\n requestHeaders,\n startTimeMs,\n endTimeMs: Date.now(),\n ...outcome,\n };\n captureCtx.waitUntil(\n postCaptureEvents(getOriginalFetch(), sidecarUrl, [event]),\n );\n } catch (error) {\n warnOnce(\n \"inbound-report\",\n \"Failed to report the inbound request.\",\n error,\n );\n }\n };\n\n let response: Response;\n try {\n response = await requestCaptureContext.run(captureCtx, invokeHandler);\n } catch (error) {\n reportInbound({ error: String(error) });\n throw error;\n }\n\n try {\n reportInbound({\n statusCode: response.status,\n responseHeaders: headersToRecord(response.headers),\n });\n } catch (error) {\n warnOnce(\n \"inbound-report\",\n \"Failed to report the inbound request.\",\n error,\n );\n }\n return response;\n };\n\n // The wrapper only replaces `fetch` (same call signature), but TypeScript\n // cannot verify a spread against the bare generic H.\n return { ...handler, fetch: wrappedFetch } as H;\n};\n\nconst resolveSidecarUrl = (\n options: WithMeticulousOptions | undefined,\n env: unknown,\n): string | undefined => {\n const fromEnv =\n env !== null && typeof env === \"object\"\n ? (env as Record<string, unknown>)[\"METICULOUS_SIDECAR_URL\"]\n : undefined;\n const raw = options?.sidecarUrl ?? fromEnv;\n if (typeof raw !== \"string\" || raw.length === 0) {\n return undefined;\n }\n return raw.replace(/\\/+$/, \"\");\n};\n\nconst buildCaptureContext = (\n request: Request,\n sidecarUrl: string,\n ctx: MeticulousExecutionContext,\n): RequestCaptureContext => {\n let frontendSessionId: string | undefined;\n try {\n frontendSessionId =\n request.headers.get(FRONTEND_SESSION_ID_HEADER) ?? undefined;\n } catch {\n frontendSessionId = undefined;\n }\n return {\n requestId: crypto.randomUUID(),\n frontendSessionId,\n sidecarUrl,\n waitUntil: (promise) => {\n try {\n ctx.waitUntil(promise);\n } catch (error) {\n warnOnce(\n \"wait-until\",\n \"ctx.waitUntil failed — capture events may be lost.\",\n error,\n );\n }\n },\n };\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAgEA,MAAa,kBACX,SACA,YACM;CACN,MAAM,eAAe,OACnB,SACA,KACA,QACsB;EACtB,MAAM,sBAAsB,QAAQ,MAAM,SAAS,KAAc,IAAI;EAErE,IAAI;AACJ,MAAI;AACF,gBAAa,kBAAkB,SAAS,IAAI;AAC5C,OAAI,eAAe,KAAA,EACjB,oBAAmB;WAEd,OAAO;AACd,YACE,aACA,mFACA,MACD;AACD,gBAAa,KAAA;;AAEf,MAAI,eAAe,KAAA,EACjB,QAAO,eAAe;EAGxB,MAAM,aAAa,oBAAoB,SAAS,YAAY,IAAI;EAChE,MAAM,cAAc,KAAK,KAAK;EAC9B,IAAI,iBAA2C,EAAE;EACjD,IAAI,SAAS;EACb,IAAI,MAAM;AACV,MAAI;AACF,oBAAiB,gBAAgB,QAAQ,QAAQ;AACjD,YAAS,QAAQ;AACjB,SAAM,QAAQ;WACP,OAAO;AACd,YACE,mBACA,0CACA,MACD;;EAGH,MAAM,iBACJ,YAGS;AACT,OAAI;IACF,MAAM,QAA6B;KACjC,MAAM;KACN,WAAW,WAAW;KACtB,GAAI,WAAW,sBAAsB,KAAA,IACjC,EAAE,mBAAmB,WAAW,mBAAmB,GACnD,EAAE;KACN;KACA;KACA;KACA;KACA,WAAW,KAAK,KAAK;KACrB,GAAG;KACJ;AACD,eAAW,UACT,kBAAkB,kBAAkB,EAAE,YAAY,CAAC,MAAM,CAAC,CAC3D;YACM,OAAO;AACd,aACE,kBACA,yCACA,MACD;;;EAIL,IAAI;AACJ,MAAI;AACF,cAAW,MAAM,sBAAsB,IAAI,YAAY,cAAc;WAC9D,OAAO;AACd,iBAAc,EAAE,OAAO,OAAO,MAAM,EAAE,CAAC;AACvC,SAAM;;AAGR,MAAI;AACF,iBAAc;IACZ,YAAY,SAAS;IACrB,iBAAiB,gBAAgB,SAAS,QAAQ;IACnD,CAAC;WACK,OAAO;AACd,YACE,kBACA,yCACA,MACD;;AAEH,SAAO;;AAKT,QAAO;EAAE,GAAG;EAAS,OAAO;EAAc;;AAG5C,MAAM,qBACJ,SACA,QACuB;CACvB,MAAM,UACJ,QAAQ,QAAQ,OAAO,QAAQ,WAC1B,IAAgC,4BACjC,KAAA;CACN,MAAM,MAAM,SAAS,cAAc;AACnC,KAAI,OAAO,QAAQ,YAAY,IAAI,WAAW,EAC5C;AAEF,QAAO,IAAI,QAAQ,QAAQ,GAAG;;AAGhC,MAAM,uBACJ,SACA,YACA,QAC0B;CAC1B,IAAI;AACJ,KAAI;AACF,sBACE,QAAQ,QAAQ,IAAA,0BAA+B,IAAI,KAAA;SAC/C;AACN,sBAAoB,KAAA;;AAEtB,QAAO;EACL,WAAW,OAAO,YAAY;EAC9B;EACA;EACA,YAAY,YAAY;AACtB,OAAI;AACF,QAAI,UAAU,QAAQ;YACf,OAAO;AACd,aACE,cACA,sDACA,MACD;;;EAGN","debugId":"a80cfad7-b180-56e8-a9ee-ceabd81be885"}
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../src/index.ts"],"sourcesContent":["import { installBindingPatch } from \"./binding-patch\";\nimport {\n type RequestCaptureContext,\n type RequestReplayContext,\n requestCaptureContext,\n} from \"./context\";\nimport { installFetchPatch } from \"./fetch-patch\";\nimport { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport { headersToRecord } from \"./outbound-capture\";\nimport {\n FRONTEND_SESSION_ID_HEADER,\n type InboundRequestEvent,\n REPLAY_SIDECAR_URL_HEADER,\n} from \"./protocol\";\nimport { parseReplaySidecarUrl } from \"./replay-sidecar-url\";\nimport { getReplaySessionInfo, postCaptureEvents } from \"./sidecar-client\";\nimport { installVirtualClock } from \"./virtual-clock\";\n\nexport type {\n BindingRequestEvent,\n CaptureEvent,\n CapturedBody,\n CaptureEventsPayload,\n InboundRequestEvent,\n OutboundFetchLookupRequest,\n OutboundFetchLookupResponse,\n OutboundRequestEvent,\n ReplaySessionInfoResponse,\n} from \"./protocol\";\nexport {\n CAPTURED_HEADERS,\n FRONTEND_SESSION_ID_HEADER,\n REPLAY_SIDECAR_URL_HEADER,\n SIDECAR_EVENTS_PATH,\n SIDECAR_PROTOCOL_VERSION,\n SIDECAR_PROTOCOL_VERSION_HEADER,\n SIDECAR_REPLAY_OUTBOUND_FETCH_PATH,\n SIDECAR_REPLAY_SESSION_PATH,\n} from \"./protocol\";\nexport {\n MAX_BODY_CAPTURE_SIZE,\n readBodyWithCap,\n readRequestBodyWithCap,\n} from \"./body-capture\";\nexport { headersToRecord } from \"./outbound-capture\";\nexport { redactRequestBody, STR_REDACTED } from \"./redact-body\";\n\n/** Structural subset of workerd's ExecutionContext — avoids a @cloudflare/workers-types dependency. */\nexport interface MeticulousExecutionContext {\n waitUntil(promise: Promise<unknown>): void;\n}\n\n/** Structural subset of an ES-module Worker handler (`export default {...}`). */\nexport interface MeticulousWorkerHandler<Env = never> {\n fetch(\n request: Request,\n env: Env,\n ctx: MeticulousExecutionContext,\n ): Response | Promise<Response>;\n}\n\nexport interface WithMeticulousOptions {\n /** Overrides the sidecar origin; defaults to the METICULOUS_SIDECAR_URL var/binding. */\n sidecarUrl?: string;\n /**\n * Binding names to leave unrecorded, on top of the defaults (`ASSETS`,\n * `__STATIC_CONTENT`). Use this for a binding whose traffic is high-volume or binary.\n */\n skipBindings?: readonly string[];\n}\n\n/**\n * Wraps an ES-module Worker handler so Meticulous can record, or replay, the app's HTTP\n * behaviour:\n *\n * export default withMeticulous({\n * async fetch(request, env, ctx) { ... },\n * });\n *\n * **Recording** activates on a `METICULOUS_SIDECAR_URL` var/binding (e.g. via `.dev.vars`\n * or `wrangler dev --var`) or `options.sidecarUrl`. Inbound requests and outgoing `fetch`\n * calls are reported to the sidecar as capture events, without affecting the app.\n *\n * **Replay** activates on the `x-meticulous-backend-replay-sidecar-url` header, injected by\n * the Meticulous replay runner on requests to the app under test. Outgoing `fetch` calls are\n * then served from the recording instead of reaching the real service, and the clock is\n * frozen at the recorded session's end so recorded credentials are still valid. Workerd\n * cannot read container environment variables, which is why per-replay config arrives as a\n * request header; the shim validates it and only honours a loopback / docker-gateway /\n * private-network `http:` origin.\n *\n * Replay takes precedence over recording when both are configured. A stale\n * `METICULOUS_SIDECAR_URL` baked into an image's `.dev.vars` is far more likely than a\n * spurious replay header (nothing but the replay runner emits it), and letting the env win\n * would silently record a replay instead of mocking it.\n *\n * With neither configured the wrapper is a complete pass-through, so it is safe to keep in\n * deployed code. Requires the `nodejs_als` (or `nodejs_compat`) compatibility flag. A\n * capture failure or unreachable sidecar never affects the app.\n */\nexport const withMeticulous = <H extends MeticulousWorkerHandler<never>>(\n handler: H,\n options?: WithMeticulousOptions,\n): H => {\n const wrappedFetch = async (\n request: Request,\n env: unknown,\n ctx: MeticulousExecutionContext,\n ): Promise<Response> => {\n const invokeHandler = () => handler.fetch(request, env as never, ctx);\n\n let sidecarUrl: string | undefined;\n let replayContext: RequestReplayContext | undefined;\n try {\n replayContext = await resolveReplayContext(request, ctx);\n sidecarUrl =\n replayContext === undefined\n ? resolveSidecarUrl(options, env)\n : undefined;\n if (replayContext !== undefined || sidecarUrl !== undefined) {\n installFetchPatch();\n }\n if (sidecarUrl !== undefined) {\n // Lazily, inside the request path, and never at module scope: the package declares\n // no `sideEffects` and is built with `platform: \"neutral\"`, so a module-scope side\n // effect can legitimately be tree-shaken by a customer's bundler. Re-run per\n // request because `env` is the only handle on the binding instances.\n //\n // Record mode only. Binding calls are recorded as their own technology\n // (`workerd-binding`) and the replay sidecar's store holds only `workerd-fetch`\n // spans, so there is nothing to serve them from — and patching would only route\n // them into the capture tee, which POSTs to an events route a replay sidecar 404s.\n installBindingPatch(env, { skipBindings: options?.skipBindings });\n }\n } catch (error) {\n warnOnce(\n \"shim-init\",\n \"Failed to initialize the Meticulous backend recorder shim — recording disabled.\",\n error,\n );\n replayContext = undefined;\n sidecarUrl = undefined;\n }\n\n if (replayContext !== undefined) {\n // Install the clock before any app code runs, so a module that reads Date.now() at\n // call time (the firebase auth libraries do) sees the session's frozen instant.\n installVirtualClock();\n // No inbound reporting in replay mode: there is no exporter behind the replay\n // sidecar, and the events route 404s there.\n return requestCaptureContext.run(replayContext, invokeHandler);\n }\n\n if (sidecarUrl === undefined) {\n return invokeHandler();\n }\n\n const captureCtx = buildCaptureContext(request, sidecarUrl, ctx);\n const startTimeMs = Date.now();\n let requestHeaders: Record<string, string[]> = {};\n let method = \"GET\";\n let url = \"\";\n try {\n requestHeaders = headersToRecord(request.headers);\n method = request.method;\n url = request.url;\n } catch (error) {\n warnOnce(\n \"inbound-capture\",\n \"Failed to capture the inbound request.\",\n error,\n );\n }\n\n const reportInbound = (\n outcome:\n | { statusCode: number; responseHeaders: Record<string, string[]> }\n | { error: string },\n ): void => {\n try {\n const event: InboundRequestEvent = {\n kind: \"inbound\",\n requestId: captureCtx.requestId,\n ...(captureCtx.frontendSessionId !== undefined\n ? { frontendSessionId: captureCtx.frontendSessionId }\n : {}),\n method,\n url,\n requestHeaders,\n startTimeMs,\n endTimeMs: Date.now(),\n ...outcome,\n };\n captureCtx.waitUntil(\n postCaptureEvents(getOriginalFetch(), sidecarUrl, [event]),\n );\n } catch (error) {\n warnOnce(\n \"inbound-report\",\n \"Failed to report the inbound request.\",\n error,\n );\n }\n };\n\n let response: Response;\n try {\n response = await requestCaptureContext.run(captureCtx, invokeHandler);\n } catch (error) {\n reportInbound({ error: String(error) });\n throw error;\n }\n\n try {\n reportInbound({\n statusCode: response.status,\n responseHeaders: headersToRecord(response.headers),\n });\n } catch (error) {\n warnOnce(\n \"inbound-report\",\n \"Failed to report the inbound request.\",\n error,\n );\n }\n return response;\n };\n\n // The wrapper only replaces `fetch` (same call signature), but TypeScript\n // cannot verify a spread against the bare generic H.\n return { ...handler, fetch: wrappedFetch } as H;\n};\n\n/**\n * Whether this request should be replayed, and the context to serve it under.\n *\n * Both the session id and a usable sidecar URL must be present: mocks are indexed per\n * session, so replay without a session id could only miss. The sidecar is consulted once\n * per (sidecar, session) per isolate to confirm it holds mocks and to learn the clock\n * anchor — a sidecar that does not recognise the session, or the route, means replay is\n * unavailable and the request runs normally.\n */\nconst resolveReplayContext = async (\n request: Request,\n ctx: MeticulousExecutionContext,\n): Promise<RequestReplayContext | undefined> => {\n let sidecarUrl: string | undefined;\n let frontendSessionId: string | undefined;\n try {\n sidecarUrl = parseReplaySidecarUrl(\n request.headers.get(REPLAY_SIDECAR_URL_HEADER),\n );\n frontendSessionId =\n request.headers.get(FRONTEND_SESSION_ID_HEADER) ?? undefined;\n } catch {\n return undefined;\n }\n if (sidecarUrl === undefined || frontendSessionId === undefined) {\n return undefined;\n }\n\n const info = await getCachedReplaySessionInfo(sidecarUrl, frontendSessionId);\n if (info === null) {\n return undefined;\n }\n\n return {\n mode: \"replay\",\n requestId: crypto.randomUUID(),\n frontendSessionId,\n sidecarUrl,\n clockAnchorMs: info.clockAnchorMs,\n waitUntil: buildWaitUntil(ctx),\n };\n};\n\n// Caches the promise, not the result, so concurrent first requests share one round trip.\n// Bounded so a stream of unrecognised session ids cannot grow it without limit.\nconst MAX_CACHED_SESSIONS = 50;\nconst replaySessionInfoCache = new Map<\n string,\n Promise<{ clockAnchorMs: number | undefined } | null>\n>();\n\nconst getCachedReplaySessionInfo = (\n sidecarUrl: string,\n frontendSessionId: string,\n): Promise<{ clockAnchorMs: number | undefined } | null> => {\n const key = `${sidecarUrl}\u0000${frontendSessionId}`;\n const cached = replaySessionInfoCache.get(key);\n if (cached !== undefined) {\n return cached;\n }\n const pending = getReplaySessionInfo(\n getOriginalFetch(),\n sidecarUrl,\n frontendSessionId,\n ).then((result) => {\n if (result.outcome === \"unreachable\") {\n // Only settled answers are worth keeping. Caching a timeout or transport blip would\n // disable mocking for this session for the life of the isolate, quietly sending every\n // later outbound call to the real service — the failure this path exists to prevent.\n // Dropping the entry costs one extra handshake and lets the next request recover.\n replaySessionInfoCache.delete(key);\n return null;\n }\n return result.outcome === \"found\"\n ? { clockAnchorMs: result.clockAnchorMs }\n : null;\n });\n if (replaySessionInfoCache.size >= MAX_CACHED_SESSIONS) {\n const oldest = replaySessionInfoCache.keys().next();\n if (!oldest.done) {\n replaySessionInfoCache.delete(oldest.value);\n }\n }\n replaySessionInfoCache.set(key, pending);\n return pending;\n};\n\nconst resolveSidecarUrl = (\n options: WithMeticulousOptions | undefined,\n env: unknown,\n): string | undefined => {\n const fromEnv =\n env !== null && typeof env === \"object\"\n ? (env as Record<string, unknown>)[\"METICULOUS_SIDECAR_URL\"]\n : undefined;\n const raw = options?.sidecarUrl ?? fromEnv;\n if (typeof raw !== \"string\" || raw.length === 0) {\n return undefined;\n }\n return raw.replace(/\\/+$/, \"\");\n};\n\nconst buildCaptureContext = (\n request: Request,\n sidecarUrl: string,\n ctx: MeticulousExecutionContext,\n): RequestCaptureContext => {\n let frontendSessionId: string | undefined;\n try {\n frontendSessionId =\n request.headers.get(FRONTEND_SESSION_ID_HEADER) ?? undefined;\n } catch {\n frontendSessionId = undefined;\n }\n return {\n mode: \"record\",\n requestId: crypto.randomUUID(),\n frontendSessionId,\n sidecarUrl,\n waitUntil: buildWaitUntil(ctx),\n };\n};\n\nconst buildWaitUntil =\n (ctx: MeticulousExecutionContext) =>\n (promise: Promise<unknown>): void => {\n try {\n ctx.waitUntil(promise);\n } catch (error) {\n warnOnce(\n \"wait-until\",\n \"ctx.waitUntil failed — capture events may be lost.\",\n error,\n );\n }\n };\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqGA,MAAa,kBACX,SACA,YACM;CACN,MAAM,eAAe,OACnB,SACA,KACA,QACsB;EACtB,MAAM,sBAAsB,QAAQ,MAAM,SAAS,KAAc,IAAI;EAErE,IAAI;EACJ,IAAI;AACJ,MAAI;AACF,mBAAgB,MAAM,qBAAqB,SAAS,IAAI;AACxD,gBACE,kBAAkB,KAAA,IACd,kBAAkB,SAAS,IAAI,GAC/B,KAAA;AACN,OAAI,kBAAkB,KAAA,KAAa,eAAe,KAAA,EAChD,oBAAmB;AAErB,OAAI,eAAe,KAAA,EAUjB,qBAAoB,KAAK,EAAE,cAAc,SAAS,cAAc,CAAC;WAE5D,OAAO;AACd,YACE,aACA,mFACA,MACD;AACD,mBAAgB,KAAA;AAChB,gBAAa,KAAA;;AAGf,MAAI,kBAAkB,KAAA,GAAW;AAG/B,wBAAqB;AAGrB,UAAO,sBAAsB,IAAI,eAAe,cAAc;;AAGhE,MAAI,eAAe,KAAA,EACjB,QAAO,eAAe;EAGxB,MAAM,aAAa,oBAAoB,SAAS,YAAY,IAAI;EAChE,MAAM,cAAc,KAAK,KAAK;EAC9B,IAAI,iBAA2C,EAAE;EACjD,IAAI,SAAS;EACb,IAAI,MAAM;AACV,MAAI;AACF,oBAAiB,gBAAgB,QAAQ,QAAQ;AACjD,YAAS,QAAQ;AACjB,SAAM,QAAQ;WACP,OAAO;AACd,YACE,mBACA,0CACA,MACD;;EAGH,MAAM,iBACJ,YAGS;AACT,OAAI;IACF,MAAM,QAA6B;KACjC,MAAM;KACN,WAAW,WAAW;KACtB,GAAI,WAAW,sBAAsB,KAAA,IACjC,EAAE,mBAAmB,WAAW,mBAAmB,GACnD,EAAE;KACN;KACA;KACA;KACA;KACA,WAAW,KAAK,KAAK;KACrB,GAAG;KACJ;AACD,eAAW,UACT,kBAAkB,kBAAkB,EAAE,YAAY,CAAC,MAAM,CAAC,CAC3D;YACM,OAAO;AACd,aACE,kBACA,yCACA,MACD;;;EAIL,IAAI;AACJ,MAAI;AACF,cAAW,MAAM,sBAAsB,IAAI,YAAY,cAAc;WAC9D,OAAO;AACd,iBAAc,EAAE,OAAO,OAAO,MAAM,EAAE,CAAC;AACvC,SAAM;;AAGR,MAAI;AACF,iBAAc;IACZ,YAAY,SAAS;IACrB,iBAAiB,gBAAgB,SAAS,QAAQ;IACnD,CAAC;WACK,OAAO;AACd,YACE,kBACA,yCACA,MACD;;AAEH,SAAO;;AAKT,QAAO;EAAE,GAAG;EAAS,OAAO;EAAc;;;;;;;;;;;AAY5C,MAAM,uBAAuB,OAC3B,SACA,QAC8C;CAC9C,IAAI;CACJ,IAAI;AACJ,KAAI;AACF,eAAa,sBACX,QAAQ,QAAQ,IAAI,0BAA0B,CAC/C;AACD,sBACE,QAAQ,QAAQ,IAAA,0BAA+B,IAAI,KAAA;SAC/C;AACN;;AAEF,KAAI,eAAe,KAAA,KAAa,sBAAsB,KAAA,EACpD;CAGF,MAAM,OAAO,MAAM,2BAA2B,YAAY,kBAAkB;AAC5E,KAAI,SAAS,KACX;AAGF,QAAO;EACL,MAAM;EACN,WAAW,OAAO,YAAY;EAC9B;EACA;EACA,eAAe,KAAK;EACpB,WAAW,eAAe,IAAI;EAC/B;;AAKH,MAAM,sBAAsB;AAC5B,MAAM,yCAAyB,IAAI,KAGhC;AAEH,MAAM,8BACJ,YACA,sBAC0D;CAC1D,MAAM,MAAM,GAAG,WAAW,GAAG;CAC7B,MAAM,SAAS,uBAAuB,IAAI,IAAI;AAC9C,KAAI,WAAW,KAAA,EACb,QAAO;CAET,MAAM,UAAU,qBACd,kBAAkB,EAClB,YACA,kBACD,CAAC,MAAM,WAAW;AACjB,MAAI,OAAO,YAAY,eAAe;AAKpC,0BAAuB,OAAO,IAAI;AAClC,UAAO;;AAET,SAAO,OAAO,YAAY,UACtB,EAAE,eAAe,OAAO,eAAe,GACvC;GACJ;AACF,KAAI,uBAAuB,QAAQ,qBAAqB;EACtD,MAAM,SAAS,uBAAuB,MAAM,CAAC,MAAM;AACnD,MAAI,CAAC,OAAO,KACV,wBAAuB,OAAO,OAAO,MAAM;;AAG/C,wBAAuB,IAAI,KAAK,QAAQ;AACxC,QAAO;;AAGT,MAAM,qBACJ,SACA,QACuB;CACvB,MAAM,UACJ,QAAQ,QAAQ,OAAO,QAAQ,WAC1B,IAAgC,4BACjC,KAAA;CACN,MAAM,MAAM,SAAS,cAAc;AACnC,KAAI,OAAO,QAAQ,YAAY,IAAI,WAAW,EAC5C;AAEF,QAAO,IAAI,QAAQ,QAAQ,GAAG;;AAGhC,MAAM,uBACJ,SACA,YACA,QAC0B;CAC1B,IAAI;AACJ,KAAI;AACF,sBACE,QAAQ,QAAQ,IAAA,0BAA+B,IAAI,KAAA;SAC/C;AACN,sBAAoB,KAAA;;AAEtB,QAAO;EACL,MAAM;EACN,WAAW,OAAO,YAAY;EAC9B;EACA;EACA,WAAW,eAAe,IAAI;EAC/B;;AAGH,MAAM,kBACH,SACA,YAAoC;AACnC,KAAI;AACF,MAAI,UAAU,QAAQ;UACf,OAAO;AACd,WACE,cACA,sDACA,MACD","debugId":"8791d2a2-34c5-5ddf-81a1-7f74833160ef"}
|
|
@@ -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
|