@alwaysmeticulous/backend-recorder-workerd 2.342.0 → 2.346.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 +9 -3
- package/dist/agent-swarm-testing-fetch.js +40 -0
- package/dist/agent-swarm-testing-fetch.js.map +1 -0
- package/dist/base64.js +25 -0
- package/dist/base64.js.map +1 -0
- package/dist/body-capture.d.ts +18 -3
- package/dist/body-capture.js +68 -24
- package/dist/body-capture.js.map +1 -1
- package/dist/body-encoding.d.ts +49 -0
- package/dist/body-encoding.js +68 -0
- package/dist/body-encoding.js.map +1 -0
- package/dist/capture-buffer.js +3 -3
- package/dist/capture-buffer.js.map +1 -1
- package/dist/captured-result-codec.js +1 -15
- package/dist/captured-result-codec.js.map +1 -1
- package/dist/context.js +1 -1
- package/dist/context.js.map +1 -1
- package/dist/fetch-patch.js +4 -1
- package/dist/fetch-patch.js.map +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/kv-capture.js +1 -1
- package/dist/kv-capture.js.map +1 -1
- package/dist/outbound-capture.js +2 -2
- package/dist/outbound-capture.js.map +1 -1
- package/dist/postgres/capture.js.map +1 -1
- package/dist/protocol.d.ts +60 -8
- package/dist/protocol.js +15 -7
- package/dist/protocol.js.map +1 -1
- package/dist/replay-fetch.js +13 -2
- package/dist/replay-fetch.js.map +1 -1
- package/dist/sidecar-client.js +32 -2
- package/dist/sidecar-client.js.map +1 -1
- package/dist/{replay-sidecar-url.js → sidecar-url.js} +9 -8
- package/dist/sidecar-url.js.map +1 -0
- package/dist/spans/span-builder.js +2 -0
- package/dist/spans/span-builder.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/with-meticulous.js +0 -0
- package/dist/with-meticulous.js.map +1 -1
- package/package.json +1 -1
- package/dist/replay-sidecar-url.js.map +0 -1
package/README.md
CHANGED
|
@@ -96,7 +96,7 @@ A request's capture events are batched into **one** report, sent under `ctx.wait
|
|
|
96
96
|
## What is captured
|
|
97
97
|
|
|
98
98
|
- **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 — or, for the page's initial navigation, via an id the shim mints itself (see [Session ids for the first page render](#session-ids-for-the-first-page-render)).
|
|
99
|
-
- **Outgoing `fetch` calls** made while handling an inbound request: method, URL, headers, status, and request/response bodies (UTF-8, capped at
|
|
99
|
+
- **Outgoing `fetch` calls** made while handling an inbound request: method, URL, headers, status, and request/response bodies (UTF-8, capped at 1 MB; long-lived streams such as SSE are captured truncated).
|
|
100
100
|
- **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.
|
|
101
101
|
- **KV namespace operations** — `get`, `getWithMetadata`, `put`, `delete` and `list` on a namespace found on `env`, recorded with the binding name, the key, the call's arguments and the value as JSON (so one `JSON.parse` reconstructs exactly what the app saw, whichever `type` the read asked for). Also no code change needed. A value read as a stream is never read by the recorder — that would take the bytes from the app — and binary values are skipped; both cases are recorded with the reason instead of the value.
|
|
102
102
|
- **postgres.js queries**, when you apply `withMeticulousPostgres` (see above): the SQL with `$1`-style placeholders, the interpolated parameters, the row shape the query asked for, and the resolved rows — or the error, so a query that legitimately failed replays as the same failure rather than as a gap. Cursors, `.forEach()`, COPY streams and `.describe()` pass through unrecorded, since they do not resolve with a single result.
|
|
@@ -145,7 +145,7 @@ or set the `METICULOUS_BACKEND_PROVISIONAL_SESSION_IDS` var to `"false"` (a work
|
|
|
145
145
|
|
|
146
146
|
`getMeticulousSessionId()` is also how the id reaches another process. A document navigation has no inbound header for your app to forward, so a worker that fans out to its own services has to pass the id on as `x-meticulous-session-id` itself; the shim does not add it to outbound requests.
|
|
147
147
|
|
|
148
|
-
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. A KV `put` value is redacted the same way, for the same reason; a value read back from KV is stored verbatim, like a response body.
|
|
148
|
+
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: as text when they are UTF-8, and otherwise as base64 (marked with `http.response.body.encoding: "base64"`), so a binary response such as an archive or an image replays byte-for-byte. A KV `put` value is redacted the same way, for the same reason; a value read back from KV is stored verbatim, like a response body.
|
|
149
149
|
|
|
150
150
|
## Replay
|
|
151
151
|
|
|
@@ -165,6 +165,12 @@ untagged.
|
|
|
165
165
|
|
|
166
166
|
Calls through bindings and KV namespaces are not served from the recording (see Limitations) and are never failed — they always reach the real binding.
|
|
167
167
|
|
|
168
|
+
## Agent-swarm testing
|
|
169
|
+
|
|
170
|
+
A third mode sits beside replay for Meticulous-driven runs (agentic session generation). It activates on two deployment env vars, `METICULOUS_AGENT_SWARM_TESTING_SIDECAR_URL` and `METICULOUS_AGENT_SWARM_TESTING_RUN_ID`, set by whoever deploys the worker for a swarm run; the URL is validated like the replay sidecar URL. Every outgoing `fetch` call is offered to the interceptor service the URL names: the interceptor either supplies the response to serve, or the call passes through to the real service.
|
|
171
|
+
|
|
172
|
+
The mode is best-effort by design — the opposite of replay's hermeticity. A call the interceptor cannot answer (a miss, an unreachable or misbehaving interceptor, a response the shim cannot represent) is never failed; it simply reaches the real service. There is no frozen clock, no seeded randomness and no recording. The `meticulous-passthrough` header skips the interceptor for a call entirely.
|
|
173
|
+
|
|
168
174
|
## Code coverage
|
|
169
175
|
|
|
170
176
|
Meticulous can report which of your server lines each replayed session executed. Workerd exposes no usable V8 coverage — its inspector never dispatches `Profiler.enable`, so `Profiler.startPreciseCoverage` is unreachable, and the one coverage call it does forward reports binary counts that never reset, which cannot be split per request. So the lines are marked at build time instead.
|
|
@@ -197,7 +203,7 @@ Because each function re-reads the current request's sink, a callback that outli
|
|
|
197
203
|
|
|
198
204
|
## Limitations
|
|
199
205
|
|
|
200
|
-
- Streamed responses are captured truncated: the inbound request is reported once the handler returns, which for a streamed response is before the stream ends, and outbound bodies cap at
|
|
206
|
+
- Streamed responses are captured truncated: the inbound request is reported once the handler returns, which for a streamed response is before the stream ends, and outbound bodies cap at 1 MB. An app whose main endpoints are SSE will not record them faithfully.
|
|
201
207
|
- Bindings other than `fetch`-shaped ones and KV namespaces are not captured: 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.
|
|
202
208
|
- A KV value that is not text is recorded without its value: reading a `stream` would take the bytes away from the app, and binary values are skipped because KV blobs are large and not UTF-8. The operation, key and arguments are still recorded, with `omitted` saying which case it was.
|
|
203
209
|
- 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.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readRequestBodyWithCap } from "./body-capture.js";
|
|
2
|
+
import { getOriginalFetch } from "./original-fetch.js";
|
|
3
|
+
import { postAgentSwarmTestingOutboundFetch } from "./sidecar-client.js";
|
|
4
|
+
import { buildMockResponse, hasPassthroughHeader } from "./replay-fetch.js";
|
|
5
|
+
//#region src/agent-swarm-testing-fetch.ts
|
|
6
|
+
/**
|
|
7
|
+
* Offers one outbound call to the interceptor service, serving its response or passing the
|
|
8
|
+
* call through to the real service.
|
|
9
|
+
*
|
|
10
|
+
* The deliberate opposite of replay's hermeticity: replay fails a call it cannot serve
|
|
11
|
+
* because silently reaching the real service would turn a replay into live traffic, whereas
|
|
12
|
+
* agent-swarm testing exists to be driven live — the interceptor is advisory, and any failure
|
|
13
|
+
* to consult it (a miss, a timeout, an unparseable answer) means the call simply happens.
|
|
14
|
+
*/
|
|
15
|
+
const agentSwarmTestingOutboundCall = async (ctx, request, invoke) => {
|
|
16
|
+
if (hasPassthroughHeader(request)) return invoke(request);
|
|
17
|
+
let result;
|
|
18
|
+
try {
|
|
19
|
+
const requestBody = request.body ? await readRequestBodyWithCap(request.clone().body).catch(() => void 0) : void 0;
|
|
20
|
+
const payload = {
|
|
21
|
+
runId: ctx.runId,
|
|
22
|
+
...ctx.frontendSessionId !== void 0 ? { frontendSessionId: ctx.frontendSessionId } : {},
|
|
23
|
+
method: request.method,
|
|
24
|
+
url: request.url,
|
|
25
|
+
...requestBody !== void 0 ? { requestBody } : {}
|
|
26
|
+
};
|
|
27
|
+
result = await postAgentSwarmTestingOutboundFetch(getOriginalFetch(), ctx.sidecarUrl, payload);
|
|
28
|
+
} catch {
|
|
29
|
+
result = void 0;
|
|
30
|
+
}
|
|
31
|
+
if (result !== void 0 && result.outcome === "mock") {
|
|
32
|
+
const mocked = buildMockResponse(result);
|
|
33
|
+
if (mocked !== void 0) return mocked;
|
|
34
|
+
}
|
|
35
|
+
return invoke(request);
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { agentSwarmTestingOutboundCall };
|
|
39
|
+
|
|
40
|
+
//# sourceMappingURL=agent-swarm-testing-fetch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-swarm-testing-fetch.js","names":[],"sources":["../src/agent-swarm-testing-fetch.ts"],"sourcesContent":["import { readRequestBodyWithCap } from \"./body-capture\";\nimport type { RequestAgentSwarmTestingContext } from \"./context\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport {\n type OutboundFetchAgentSwarmTestingRequest,\n type OutboundFetchAgentSwarmTestingResponse,\n} from \"./protocol\";\nimport { postAgentSwarmTestingOutboundFetch } from \"./sidecar-client\";\nimport { buildMockResponse, hasPassthroughHeader } from \"./replay-fetch\";\n\n/**\n * Offers one outbound call to the interceptor service, serving its response or passing the\n * call through to the real service.\n *\n * The deliberate opposite of replay's hermeticity: replay fails a call it cannot serve\n * because silently reaching the real service would turn a replay into live traffic, whereas\n * agent-swarm testing exists to be driven live — the interceptor is advisory, and any failure\n * to consult it (a miss, a timeout, an unparseable answer) means the call simply happens.\n */\nexport const agentSwarmTestingOutboundCall = async (\n ctx: RequestAgentSwarmTestingContext,\n request: Request,\n invoke: (request: Request) => Promise<Response>,\n): Promise<Response> => {\n if (hasPassthroughHeader(request)) {\n return invoke(request);\n }\n\n let result: OutboundFetchAgentSwarmTestingResponse | undefined;\n try {\n // Read the clone and pass the original through untouched, mirroring the replay path.\n const requestBody = request.body\n ? await readRequestBodyWithCap(request.clone().body).catch(\n () => undefined,\n )\n : undefined;\n const payload: OutboundFetchAgentSwarmTestingRequest = {\n runId: ctx.runId,\n ...(ctx.frontendSessionId !== undefined\n ? { frontendSessionId: ctx.frontendSessionId }\n : {}),\n method: request.method,\n url: request.url,\n ...(requestBody !== undefined ? { requestBody } : {}),\n };\n result = await postAgentSwarmTestingOutboundFetch(\n getOriginalFetch(),\n ctx.sidecarUrl,\n payload,\n );\n } catch {\n result = undefined;\n }\n\n if (result !== undefined && result.outcome === \"mock\") {\n const mocked = buildMockResponse(result);\n if (mocked !== undefined) {\n return mocked;\n }\n }\n\n return invoke(request);\n};\n"],"mappings":";;;;;;;;;;;;;;AAmBA,MAAa,gCAAgC,OAC3C,KACA,SACA,WACsB;AACtB,KAAI,qBAAqB,QAAQ,CAC/B,QAAO,OAAO,QAAQ;CAGxB,IAAI;AACJ,KAAI;EAEF,MAAM,cAAc,QAAQ,OACxB,MAAM,uBAAuB,QAAQ,OAAO,CAAC,KAAK,CAAC,YAC3C,KAAA,EACP,GACD,KAAA;EACJ,MAAM,UAAiD;GACrD,OAAO,IAAI;GACX,GAAI,IAAI,sBAAsB,KAAA,IAC1B,EAAE,mBAAmB,IAAI,mBAAmB,GAC5C,EAAE;GACN,QAAQ,QAAQ;GAChB,KAAK,QAAQ;GACb,GAAI,gBAAgB,KAAA,IAAY,EAAE,aAAa,GAAG,EAAE;GACrD;AACD,WAAS,MAAM,mCACb,kBAAkB,EAClB,IAAI,YACJ,QACD;SACK;AACN,WAAS,KAAA;;AAGX,KAAI,WAAW,KAAA,KAAa,OAAO,YAAY,QAAQ;EACrD,MAAM,SAAS,kBAAkB,OAAO;AACxC,MAAI,WAAW,KAAA,EACb,QAAO;;AAIX,QAAO,OAAO,QAAQ"}
|
package/dist/base64.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//#region src/base64.ts
|
|
2
|
+
const BASE64_CHUNK_SIZE = 32768;
|
|
3
|
+
/** Base64 of the bytes a view covers, through `Buffer` on Node and `btoa` in a Worker. */
|
|
4
|
+
const toBase64 = (view) => {
|
|
5
|
+
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
6
|
+
if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
|
|
7
|
+
let binary = "";
|
|
8
|
+
for (let i = 0; i < bytes.length; i += BASE64_CHUNK_SIZE) binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_SIZE));
|
|
9
|
+
return btoa(binary);
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The bytes a base64 string encodes. On Node this is a `Buffer`, which is also a `Uint8Array` to
|
|
13
|
+
* anything consuming it — so a Prisma `Bytes` field replays as the type pg returned at record time.
|
|
14
|
+
*/
|
|
15
|
+
const fromBase64 = (base64) => {
|
|
16
|
+
if (typeof Buffer !== "undefined") return Buffer.from(base64, "base64");
|
|
17
|
+
const binary = atob(base64);
|
|
18
|
+
const bytes = new Uint8Array(binary.length);
|
|
19
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
20
|
+
return bytes;
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { fromBase64, toBase64 };
|
|
24
|
+
|
|
25
|
+
//# sourceMappingURL=base64.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"base64.js","names":[],"sources":["../src/base64.ts"],"sourcesContent":["const BASE64_CHUNK_SIZE = 0x8000;\n\n/** Base64 of the bytes a view covers, through `Buffer` on Node and `btoa` in a Worker. */\nexport const toBase64 = (view: ArrayBufferView): string => {\n const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);\n if (typeof Buffer !== \"undefined\") {\n return Buffer.from(bytes).toString(\"base64\");\n }\n let binary = \"\";\n for (let i = 0; i < bytes.length; i += BASE64_CHUNK_SIZE) {\n binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_SIZE));\n }\n return btoa(binary);\n};\n\n/**\n * The bytes a base64 string encodes. On Node this is a `Buffer`, which is also a `Uint8Array` to\n * anything consuming it — so a Prisma `Bytes` field replays as the type pg returned at record time.\n */\nexport const fromBase64 = (base64: string): Uint8Array => {\n if (typeof Buffer !== \"undefined\") {\n return Buffer.from(base64, \"base64\");\n }\n const binary = atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return bytes;\n};\n"],"mappings":";AAAA,MAAM,oBAAoB;;AAG1B,MAAa,YAAY,SAAkC;CACzD,MAAM,QAAQ,IAAI,WAAW,KAAK,QAAQ,KAAK,YAAY,KAAK,WAAW;AAC3E,KAAI,OAAO,WAAW,YACpB,QAAO,OAAO,KAAK,MAAM,CAAC,SAAS,SAAS;CAE9C,IAAI,SAAS;AACb,MAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,kBACrC,WAAU,OAAO,aAAa,GAAG,MAAM,SAAS,GAAG,IAAI,kBAAkB,CAAC;AAE5E,QAAO,KAAK,OAAO;;;;;;AAOrB,MAAa,cAAc,WAA+B;AACxD,KAAI,OAAO,WAAW,YACpB,QAAO,OAAO,KAAK,QAAQ,SAAS;CAEtC,MAAM,SAAS,KAAK,OAAO;CAC3B,MAAM,QAAQ,IAAI,WAAW,OAAO,OAAO;AAC3C,MAAK,IAAI,IAAI,GAAG,IAAI,OAAO,QAAQ,IACjC,OAAM,KAAK,OAAO,WAAW,EAAE;AAEjC,QAAO"}
|
package/dist/body-capture.d.ts
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import { CapturedBody } from "./protocol.js";
|
|
2
2
|
|
|
3
3
|
//#region src/body-capture.d.ts
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Cap on the bytes of any one captured body, and so on the size of the span attribute it
|
|
6
|
+
* becomes. Defined here and re-exported by the Node recorder's `constants.ts` (same
|
|
7
|
+
* arrangement as the shared attribute names) so a Worker recording and a Node recording
|
|
8
|
+
* truncate at the same point, and so the replay side — which re-applies the cap to a live
|
|
9
|
+
* body before hashing it — agrees with what was stored.
|
|
10
|
+
*/
|
|
5
11
|
declare const MAX_BODY_CAPTURE_SIZE: number;
|
|
6
12
|
/**
|
|
7
13
|
* Reads a body stream up to {@link MAX_BODY_CAPTURE_SIZE} bytes, decoding as
|
|
8
14
|
* UTF-8. Stops (and marks the capture truncated) on the size cap or the time
|
|
9
15
|
* guard, so a long-lived stream can never pin the capture work. Returns
|
|
10
16
|
* undefined for absent bodies.
|
|
17
|
+
*
|
|
18
|
+
* The decode turns any byte that is not UTF-8 into U+FFFD, which is only acceptable for a body
|
|
19
|
+
* that is hashed rather than served back. Response bodies go through
|
|
20
|
+
* {@link readResponseBodyWithCap} instead.
|
|
11
21
|
*/
|
|
12
22
|
declare const readBodyWithCap: (stream: ReadableStream<Uint8Array> | null) => Promise<CapturedBody | undefined>;
|
|
13
23
|
/**
|
|
@@ -15,10 +25,15 @@ declare const readBodyWithCap: (stream: ReadableStream<Uint8Array> | null) => Pr
|
|
|
15
25
|
*
|
|
16
26
|
* Every request-body capture path goes through here, so record and replay redact
|
|
17
27
|
* identically and any hash derived from the body still agrees on both sides. Response
|
|
18
|
-
* bodies deliberately use {@link
|
|
28
|
+
* bodies deliberately use {@link readResponseBodyWithCap} — they are served back to the
|
|
19
29
|
* app during replay and must stay byte-exact.
|
|
20
30
|
*/
|
|
21
31
|
declare const readRequestBodyWithCap: (stream: ReadableStream<Uint8Array> | null) => Promise<CapturedBody | undefined>;
|
|
32
|
+
/**
|
|
33
|
+
* Reads a *response* body so that replay can serve back exactly the bytes the app received: as
|
|
34
|
+
* text when they are UTF-8, and as base64 otherwise (see `encodeBodyBytes`).
|
|
35
|
+
*/
|
|
36
|
+
declare const readResponseBodyWithCap: (stream: ReadableStream<Uint8Array> | null) => Promise<CapturedBody | undefined>;
|
|
22
37
|
//#endregion
|
|
23
|
-
export { MAX_BODY_CAPTURE_SIZE, readBodyWithCap, readRequestBodyWithCap };
|
|
38
|
+
export { MAX_BODY_CAPTURE_SIZE, readBodyWithCap, readRequestBodyWithCap, readResponseBodyWithCap };
|
|
24
39
|
//# sourceMappingURL=body-capture.d.ts.map
|
package/dist/body-capture.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
import { encodeBodyBytes } from "./body-encoding.js";
|
|
1
2
|
import { redactRequestBody } from "./redact-body.js";
|
|
2
3
|
//#region src/body-capture.ts
|
|
3
|
-
/**
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Cap on the bytes of any one captured body, and so on the size of the span attribute it
|
|
6
|
+
* becomes. Defined here and re-exported by the Node recorder's `constants.ts` (same
|
|
7
|
+
* arrangement as the shared attribute names) so a Worker recording and a Node recording
|
|
8
|
+
* truncate at the same point, and so the replay side — which re-applies the cap to a live
|
|
9
|
+
* body before hashing it — agrees with what was stored.
|
|
10
|
+
*/
|
|
11
|
+
const MAX_BODY_CAPTURE_SIZE = 1024 * 1024;
|
|
5
12
|
/** Cap on how long we keep reading a (possibly never-ending, e.g. SSE) body clone. */
|
|
6
13
|
const BODY_READ_TIMEOUT_MS = 1e4;
|
|
7
14
|
const TIMED_OUT = Symbol("meticulous.bodyReadTimedOut");
|
|
@@ -10,12 +17,56 @@ const TIMED_OUT = Symbol("meticulous.bodyReadTimedOut");
|
|
|
10
17
|
* UTF-8. Stops (and marks the capture truncated) on the size cap or the time
|
|
11
18
|
* guard, so a long-lived stream can never pin the capture work. Returns
|
|
12
19
|
* undefined for absent bodies.
|
|
20
|
+
*
|
|
21
|
+
* The decode turns any byte that is not UTF-8 into U+FFFD, which is only acceptable for a body
|
|
22
|
+
* that is hashed rather than served back. Response bodies go through
|
|
23
|
+
* {@link readResponseBodyWithCap} instead.
|
|
13
24
|
*/
|
|
14
25
|
const readBodyWithCap = async (stream) => {
|
|
26
|
+
const read = await readBodyBytesWithCap(stream);
|
|
27
|
+
if (read === void 0) return;
|
|
28
|
+
return {
|
|
29
|
+
body: new TextDecoder("utf-8").decode(read.bytes),
|
|
30
|
+
truncated: read.truncated
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Reads a *request* body, redacting secret-looking fields before it leaves the worker.
|
|
35
|
+
*
|
|
36
|
+
* Every request-body capture path goes through here, so record and replay redact
|
|
37
|
+
* identically and any hash derived from the body still agrees on both sides. Response
|
|
38
|
+
* bodies deliberately use {@link readResponseBodyWithCap} — they are served back to the
|
|
39
|
+
* app during replay and must stay byte-exact.
|
|
40
|
+
*/
|
|
41
|
+
const readRequestBodyWithCap = async (stream) => {
|
|
42
|
+
const captured = await readBodyWithCap(stream);
|
|
43
|
+
if (captured === void 0) return;
|
|
44
|
+
return {
|
|
45
|
+
...captured,
|
|
46
|
+
body: redactRequestBody(captured.body)
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Reads a *response* body so that replay can serve back exactly the bytes the app received: as
|
|
51
|
+
* text when they are UTF-8, and as base64 otherwise (see `encodeBodyBytes`).
|
|
52
|
+
*/
|
|
53
|
+
const readResponseBodyWithCap = async (stream) => {
|
|
54
|
+
const read = await readBodyBytesWithCap(stream);
|
|
55
|
+
if (read === void 0) return;
|
|
56
|
+
const { body, encoding, storedFraction } = encodeBodyBytes(read.bytes, {
|
|
57
|
+
maxLength: MAX_BODY_CAPTURE_SIZE,
|
|
58
|
+
incompleteTail: read.truncated
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
body,
|
|
62
|
+
truncated: read.truncated || storedFraction < 1,
|
|
63
|
+
...encoding !== void 0 ? { encoding } : {}
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
const readBodyBytesWithCap = async (stream) => {
|
|
15
67
|
if (!stream) return;
|
|
16
68
|
const reader = stream.getReader();
|
|
17
|
-
const
|
|
18
|
-
let text = "";
|
|
69
|
+
const chunks = [];
|
|
19
70
|
let bytes = 0;
|
|
20
71
|
let truncated = false;
|
|
21
72
|
let timer;
|
|
@@ -34,43 +85,36 @@ const readBodyWithCap = async (stream) => {
|
|
|
34
85
|
if (!value || value.byteLength === 0) continue;
|
|
35
86
|
const remaining = MAX_BODY_CAPTURE_SIZE - bytes;
|
|
36
87
|
if (value.byteLength >= remaining) {
|
|
37
|
-
|
|
88
|
+
chunks.push(value.subarray(0, remaining));
|
|
38
89
|
bytes += remaining;
|
|
39
90
|
truncated = truncated || value.byteLength > remaining;
|
|
40
91
|
const next = await Promise.race([reader.read(), timedOut]);
|
|
41
92
|
if (next === TIMED_OUT || !next.done) truncated = true;
|
|
42
93
|
break;
|
|
43
94
|
}
|
|
44
|
-
|
|
95
|
+
chunks.push(value);
|
|
45
96
|
bytes += value.byteLength;
|
|
46
97
|
}
|
|
47
98
|
} finally {
|
|
48
99
|
clearTimeout(timer);
|
|
49
100
|
reader.cancel().catch(() => {});
|
|
50
101
|
}
|
|
51
|
-
text += decoder.decode();
|
|
52
102
|
return {
|
|
53
|
-
|
|
103
|
+
bytes: concatBytes(chunks, bytes),
|
|
54
104
|
truncated
|
|
55
105
|
};
|
|
56
106
|
};
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const captured = await readBodyWithCap(stream);
|
|
67
|
-
if (captured === void 0) return;
|
|
68
|
-
return {
|
|
69
|
-
...captured,
|
|
70
|
-
body: redactRequestBody(captured.body)
|
|
71
|
-
};
|
|
107
|
+
const concatBytes = (chunks, byteLength) => {
|
|
108
|
+
if (chunks.length === 1) return chunks[0];
|
|
109
|
+
const out = new Uint8Array(byteLength);
|
|
110
|
+
let offset = 0;
|
|
111
|
+
for (const chunk of chunks) {
|
|
112
|
+
out.set(chunk, offset);
|
|
113
|
+
offset += chunk.byteLength;
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
72
116
|
};
|
|
73
117
|
//#endregion
|
|
74
|
-
export { MAX_BODY_CAPTURE_SIZE, readBodyWithCap, readRequestBodyWithCap };
|
|
118
|
+
export { MAX_BODY_CAPTURE_SIZE, readBodyWithCap, readRequestBodyWithCap, readResponseBodyWithCap };
|
|
75
119
|
|
|
76
120
|
//# sourceMappingURL=body-capture.js.map
|
package/dist/body-capture.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"body-capture.js","names":[],"sources":["../src/body-capture.ts"],"sourcesContent":["import type { CapturedBody } from \"./protocol\";\nimport { redactRequestBody } from \"./redact-body\";\n\n
|
|
1
|
+
{"version":3,"file":"body-capture.js","names":[],"sources":["../src/body-capture.ts"],"sourcesContent":["import { encodeBodyBytes } from \"./body-encoding\";\nimport type { CapturedBody } from \"./protocol\";\nimport { redactRequestBody } from \"./redact-body\";\n\n/**\n * Cap on the bytes of any one captured body, and so on the size of the span attribute it\n * becomes. Defined here and re-exported by the Node recorder's `constants.ts` (same\n * arrangement as the shared attribute names) so a Worker recording and a Node recording\n * truncate at the same point, and so the replay side — which re-applies the cap to a live\n * body before hashing it — agrees with what was stored.\n */\nexport const MAX_BODY_CAPTURE_SIZE = 1024 * 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 *\n * The decode turns any byte that is not UTF-8 into U+FFFD, which is only acceptable for a body\n * that is hashed rather than served back. Response bodies go through\n * {@link readResponseBodyWithCap} instead.\n */\nexport const readBodyWithCap = async (\n stream: ReadableStream<Uint8Array> | null,\n): Promise<CapturedBody | undefined> => {\n const read = await readBodyBytesWithCap(stream);\n if (read === undefined) {\n return undefined;\n }\n return {\n body: new TextDecoder(\"utf-8\").decode(read.bytes),\n truncated: read.truncated,\n };\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 readResponseBodyWithCap} — 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\n/**\n * Reads a *response* body so that replay can serve back exactly the bytes the app received: as\n * text when they are UTF-8, and as base64 otherwise (see `encodeBodyBytes`).\n */\nexport const readResponseBodyWithCap = async (\n stream: ReadableStream<Uint8Array> | null,\n): Promise<CapturedBody | undefined> => {\n const read = await readBodyBytesWithCap(stream);\n if (read === undefined) {\n return undefined;\n }\n const { body, encoding, storedFraction } = encodeBodyBytes(read.bytes, {\n maxLength: MAX_BODY_CAPTURE_SIZE,\n incompleteTail: read.truncated,\n });\n return {\n body,\n truncated: read.truncated || storedFraction < 1,\n ...(encoding !== undefined ? { encoding } : {}),\n };\n};\n\nconst readBodyBytesWithCap = async (\n stream: ReadableStream<Uint8Array> | null,\n): Promise<{ bytes: Uint8Array; truncated: boolean } | undefined> => {\n if (!stream) {\n return undefined;\n }\n\n const reader = stream.getReader();\n const chunks: Uint8Array[] = [];\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 chunks.push(value.subarray(0, remaining));\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 chunks.push(value);\n bytes += value.byteLength;\n }\n } finally {\n clearTimeout(timer);\n reader.cancel().catch(() => {});\n }\n\n return { bytes: concatBytes(chunks, bytes), truncated };\n};\n\nconst concatBytes = (chunks: Uint8Array[], byteLength: number): Uint8Array => {\n if (chunks.length === 1) {\n return chunks[0];\n }\n const out = new Uint8Array(byteLength);\n let offset = 0;\n for (const chunk of chunks) {\n out.set(chunk, offset);\n offset += chunk.byteLength;\n }\n return out;\n};\n"],"mappings":";;;;;;;;;;AAWA,MAAa,wBAAwB,OAAO;;AAG5C,MAAM,uBAAuB;AAE7B,MAAM,YAAY,OAAO,8BAA8B;;;;;;;;;;;AAYvD,MAAa,kBAAkB,OAC7B,WACsC;CACtC,MAAM,OAAO,MAAM,qBAAqB,OAAO;AAC/C,KAAI,SAAS,KAAA,EACX;AAEF,QAAO;EACL,MAAM,IAAI,YAAY,QAAQ,CAAC,OAAO,KAAK,MAAM;EACjD,WAAW,KAAK;EACjB;;;;;;;;;;AAWH,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;;;;;;AAOhE,MAAa,0BAA0B,OACrC,WACsC;CACtC,MAAM,OAAO,MAAM,qBAAqB,OAAO;AAC/C,KAAI,SAAS,KAAA,EACX;CAEF,MAAM,EAAE,MAAM,UAAU,mBAAmB,gBAAgB,KAAK,OAAO;EACrE,WAAW;EACX,gBAAgB,KAAK;EACtB,CAAC;AACF,QAAO;EACL;EACA,WAAW,KAAK,aAAa,iBAAiB;EAC9C,GAAI,aAAa,KAAA,IAAY,EAAE,UAAU,GAAG,EAAE;EAC/C;;AAGH,MAAM,uBAAuB,OAC3B,WACmE;AACnE,KAAI,CAAC,OACH;CAGF,MAAM,SAAS,OAAO,WAAW;CACjC,MAAM,SAAuB,EAAE;CAC/B,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,WAAO,KAAK,MAAM,SAAS,GAAG,UAAU,CAAC;AACzC,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,UAAO,KAAK,MAAM;AAClB,YAAS,MAAM;;WAET;AACR,eAAa,MAAM;AACnB,SAAO,QAAQ,CAAC,YAAY,GAAG;;AAGjC,QAAO;EAAE,OAAO,YAAY,QAAQ,MAAM;EAAE;EAAW;;AAGzD,MAAM,eAAe,QAAsB,eAAmC;AAC5E,KAAI,OAAO,WAAW,EACpB,QAAO,OAAO;CAEhB,MAAM,MAAM,IAAI,WAAW,WAAW;CACtC,IAAI,SAAS;AACb,MAAK,MAAM,SAAS,QAAQ;AAC1B,MAAI,IAAI,OAAO,OAAO;AACtB,YAAU,MAAM;;AAElB,QAAO"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
//#region src/body-encoding.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* How a captured response body is stored on its span, so that replay can serve back exactly the
|
|
4
|
+
* bytes the app received.
|
|
5
|
+
*
|
|
6
|
+
* A body whose bytes are valid UTF-8 — JSON, HTML, text — is stored as the text they decode to, with
|
|
7
|
+
* no encoding attribute, exactly as every body was before this attribute existed. Any other body is
|
|
8
|
+
* stored as base64 under {@link HTTP_RESPONSE_BODY_ENCODING_ATTR}. That covers a body that is binary
|
|
9
|
+
* by type (a gzip or zip archive, an image, protobuf) or text in another charset. It arrives with no
|
|
10
|
+
* `content-encoding`, so decompressing first does not help, and decoding it as UTF-8 would turn every
|
|
11
|
+
* invalid byte into U+FFFD. That cannot be undone: replay would serve a body the app cannot parse.
|
|
12
|
+
*
|
|
13
|
+
* Only response bodies are stored this way. A request body is never served, only hashed into a
|
|
14
|
+
* match key, and replay hashes the live request through the same lossy decode the recording used,
|
|
15
|
+
* so the two still agree.
|
|
16
|
+
*/
|
|
17
|
+
declare const HTTP_RESPONSE_BODY_ENCODING_ATTR = "http.response.body.encoding";
|
|
18
|
+
declare const BODY_ENCODING_BASE64 = "base64";
|
|
19
|
+
type BodyEncoding = typeof BODY_ENCODING_BASE64;
|
|
20
|
+
interface EncodedBody {
|
|
21
|
+
/** What the span stores: the body's text, or base64 of its bytes when `encoding` is set. */
|
|
22
|
+
body: string;
|
|
23
|
+
encoding?: BodyEncoding;
|
|
24
|
+
/** Share of the input that `body` represents: 1 unless the length cap cut it. */
|
|
25
|
+
storedFraction: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Encodes captured body bytes for storage, capped so the stored string is at most `maxLength`
|
|
29
|
+
* characters — the same bound whichever form the body takes.
|
|
30
|
+
*
|
|
31
|
+
* `incompleteTail` says the bytes are a prefix of a longer body, so a final UTF-8 sequence cut
|
|
32
|
+
* short is expected rather than a sign that the body is not text.
|
|
33
|
+
*/
|
|
34
|
+
declare const encodeBodyBytes: (bytes: Uint8Array, {
|
|
35
|
+
maxLength,
|
|
36
|
+
incompleteTail
|
|
37
|
+
}: {
|
|
38
|
+
maxLength: number;
|
|
39
|
+
incompleteTail: boolean;
|
|
40
|
+
}) => EncodedBody;
|
|
41
|
+
/**
|
|
42
|
+
* The bytes a stored body stands for. A body with no encoding is text, so its bytes are its UTF-8
|
|
43
|
+
* encoding — which reproduces the recorded bytes exactly, since only valid UTF-8 is ever stored as
|
|
44
|
+
* text.
|
|
45
|
+
*/
|
|
46
|
+
declare const decodeBodyBytes: (body: string, encoding: unknown) => Uint8Array;
|
|
47
|
+
//#endregion
|
|
48
|
+
export { BODY_ENCODING_BASE64, BodyEncoding, EncodedBody, HTTP_RESPONSE_BODY_ENCODING_ATTR, decodeBodyBytes, encodeBodyBytes };
|
|
49
|
+
//# sourceMappingURL=body-encoding.d.ts.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { fromBase64, toBase64 } from "./base64.js";
|
|
2
|
+
//#region src/body-encoding.ts
|
|
3
|
+
/**
|
|
4
|
+
* How a captured response body is stored on its span, so that replay can serve back exactly the
|
|
5
|
+
* bytes the app received.
|
|
6
|
+
*
|
|
7
|
+
* A body whose bytes are valid UTF-8 — JSON, HTML, text — is stored as the text they decode to, with
|
|
8
|
+
* no encoding attribute, exactly as every body was before this attribute existed. Any other body is
|
|
9
|
+
* stored as base64 under {@link HTTP_RESPONSE_BODY_ENCODING_ATTR}. That covers a body that is binary
|
|
10
|
+
* by type (a gzip or zip archive, an image, protobuf) or text in another charset. It arrives with no
|
|
11
|
+
* `content-encoding`, so decompressing first does not help, and decoding it as UTF-8 would turn every
|
|
12
|
+
* invalid byte into U+FFFD. That cannot be undone: replay would serve a body the app cannot parse.
|
|
13
|
+
*
|
|
14
|
+
* Only response bodies are stored this way. A request body is never served, only hashed into a
|
|
15
|
+
* match key, and replay hashes the live request through the same lossy decode the recording used,
|
|
16
|
+
* so the two still agree.
|
|
17
|
+
*/
|
|
18
|
+
const HTTP_RESPONSE_BODY_ENCODING_ATTR = "http.response.body.encoding";
|
|
19
|
+
const BODY_ENCODING_BASE64 = "base64";
|
|
20
|
+
/**
|
|
21
|
+
* Encodes captured body bytes for storage, capped so the stored string is at most `maxLength`
|
|
22
|
+
* characters — the same bound whichever form the body takes.
|
|
23
|
+
*
|
|
24
|
+
* `incompleteTail` says the bytes are a prefix of a longer body, so a final UTF-8 sequence cut
|
|
25
|
+
* short is expected rather than a sign that the body is not text.
|
|
26
|
+
*/
|
|
27
|
+
const encodeBodyBytes = (bytes, { maxLength, incompleteTail }) => {
|
|
28
|
+
const text = decodeUtf8Exactly(bytes, incompleteTail);
|
|
29
|
+
if (text !== void 0) {
|
|
30
|
+
const body = text.length > maxLength ? text.substring(0, maxLength) : text;
|
|
31
|
+
return {
|
|
32
|
+
body,
|
|
33
|
+
storedFraction: text.length > 0 ? body.length / text.length : 1
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const maxBytes = Math.floor(maxLength / 4) * 3;
|
|
37
|
+
const kept = bytes.byteLength > maxBytes ? bytes.subarray(0, maxBytes) : bytes;
|
|
38
|
+
return {
|
|
39
|
+
body: toBase64(kept),
|
|
40
|
+
encoding: BODY_ENCODING_BASE64,
|
|
41
|
+
storedFraction: bytes.byteLength > 0 ? kept.byteLength / bytes.byteLength : 1
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* The bytes a stored body stands for. A body with no encoding is text, so its bytes are its UTF-8
|
|
46
|
+
* encoding — which reproduces the recorded bytes exactly, since only valid UTF-8 is ever stored as
|
|
47
|
+
* text.
|
|
48
|
+
*/
|
|
49
|
+
const decodeBodyBytes = (body, encoding) => encoding === "base64" ? fromBase64(body) : new TextEncoder().encode(body);
|
|
50
|
+
/**
|
|
51
|
+
* The text the bytes encode, or undefined when they are not UTF-8. `fatal` makes an invalid
|
|
52
|
+
* sequence throw instead of becoming U+FFFD, and `ignoreBOM` keeps a leading byte-order mark in
|
|
53
|
+
* the text, so the text re-encodes to exactly these bytes.
|
|
54
|
+
*/
|
|
55
|
+
const decodeUtf8Exactly = (bytes, incompleteTail) => {
|
|
56
|
+
try {
|
|
57
|
+
return new TextDecoder("utf-8", {
|
|
58
|
+
fatal: true,
|
|
59
|
+
ignoreBOM: true
|
|
60
|
+
}).decode(bytes, { stream: incompleteTail });
|
|
61
|
+
} catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
//#endregion
|
|
66
|
+
export { BODY_ENCODING_BASE64, HTTP_RESPONSE_BODY_ENCODING_ATTR, decodeBodyBytes, encodeBodyBytes };
|
|
67
|
+
|
|
68
|
+
//# sourceMappingURL=body-encoding.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"body-encoding.js","names":[],"sources":["../src/body-encoding.ts"],"sourcesContent":["import { fromBase64, toBase64 } from \"./base64\";\n\n/**\n * How a captured response body is stored on its span, so that replay can serve back exactly the\n * bytes the app received.\n *\n * A body whose bytes are valid UTF-8 — JSON, HTML, text — is stored as the text they decode to, with\n * no encoding attribute, exactly as every body was before this attribute existed. Any other body is\n * stored as base64 under {@link HTTP_RESPONSE_BODY_ENCODING_ATTR}. That covers a body that is binary\n * by type (a gzip or zip archive, an image, protobuf) or text in another charset. It arrives with no\n * `content-encoding`, so decompressing first does not help, and decoding it as UTF-8 would turn every\n * invalid byte into U+FFFD. That cannot be undone: replay would serve a body the app cannot parse.\n *\n * Only response bodies are stored this way. A request body is never served, only hashed into a\n * match key, and replay hashes the live request through the same lossy decode the recording used,\n * so the two still agree.\n */\nexport const HTTP_RESPONSE_BODY_ENCODING_ATTR = \"http.response.body.encoding\";\n\nexport const BODY_ENCODING_BASE64 = \"base64\";\n\nexport type BodyEncoding = typeof BODY_ENCODING_BASE64;\n\nexport interface EncodedBody {\n /** What the span stores: the body's text, or base64 of its bytes when `encoding` is set. */\n body: string;\n encoding?: BodyEncoding;\n /** Share of the input that `body` represents: 1 unless the length cap cut it. */\n storedFraction: number;\n}\n\n/**\n * Encodes captured body bytes for storage, capped so the stored string is at most `maxLength`\n * characters — the same bound whichever form the body takes.\n *\n * `incompleteTail` says the bytes are a prefix of a longer body, so a final UTF-8 sequence cut\n * short is expected rather than a sign that the body is not text.\n */\nexport const encodeBodyBytes = (\n bytes: Uint8Array,\n { maxLength, incompleteTail }: { maxLength: number; incompleteTail: boolean },\n): EncodedBody => {\n const text = decodeUtf8Exactly(bytes, incompleteTail);\n if (text !== undefined) {\n const body = text.length > maxLength ? text.substring(0, maxLength) : text;\n return {\n body,\n storedFraction: text.length > 0 ? body.length / text.length : 1,\n };\n }\n // Base64 spends four characters on every three bytes.\n const maxBytes = Math.floor(maxLength / 4) * 3;\n const kept =\n bytes.byteLength > maxBytes ? bytes.subarray(0, maxBytes) : bytes;\n return {\n body: toBase64(kept),\n encoding: BODY_ENCODING_BASE64,\n storedFraction:\n bytes.byteLength > 0 ? kept.byteLength / bytes.byteLength : 1,\n };\n};\n\n/**\n * The bytes a stored body stands for. A body with no encoding is text, so its bytes are its UTF-8\n * encoding — which reproduces the recorded bytes exactly, since only valid UTF-8 is ever stored as\n * text.\n */\nexport const decodeBodyBytes = (body: string, encoding: unknown): Uint8Array =>\n encoding === BODY_ENCODING_BASE64\n ? fromBase64(body)\n : new TextEncoder().encode(body);\n\n/**\n * The text the bytes encode, or undefined when they are not UTF-8. `fatal` makes an invalid\n * sequence throw instead of becoming U+FFFD, and `ignoreBOM` keeps a leading byte-order mark in\n * the text, so the text re-encodes to exactly these bytes.\n */\nconst decodeUtf8Exactly = (\n bytes: Uint8Array,\n incompleteTail: boolean,\n): string | undefined => {\n try {\n return new TextDecoder(\"utf-8\", { fatal: true, ignoreBOM: true }).decode(\n bytes,\n // A streaming decode holds back a final sequence cut short instead of rejecting it.\n { stream: incompleteTail },\n );\n } catch {\n return undefined;\n }\n};\n"],"mappings":";;;;;;;;;;;;;;;;;AAiBA,MAAa,mCAAmC;AAEhD,MAAa,uBAAuB;;;;;;;;AAmBpC,MAAa,mBACX,OACA,EAAE,WAAW,qBACG;CAChB,MAAM,OAAO,kBAAkB,OAAO,eAAe;AACrD,KAAI,SAAS,KAAA,GAAW;EACtB,MAAM,OAAO,KAAK,SAAS,YAAY,KAAK,UAAU,GAAG,UAAU,GAAG;AACtE,SAAO;GACL;GACA,gBAAgB,KAAK,SAAS,IAAI,KAAK,SAAS,KAAK,SAAS;GAC/D;;CAGH,MAAM,WAAW,KAAK,MAAM,YAAY,EAAE,GAAG;CAC7C,MAAM,OACJ,MAAM,aAAa,WAAW,MAAM,SAAS,GAAG,SAAS,GAAG;AAC9D,QAAO;EACL,MAAM,SAAS,KAAK;EACpB,UAAU;EACV,gBACE,MAAM,aAAa,IAAI,KAAK,aAAa,MAAM,aAAa;EAC/D;;;;;;;AAQH,MAAa,mBAAmB,MAAc,aAC5C,aAAA,WACI,WAAW,KAAK,GAChB,IAAI,aAAa,CAAC,OAAO,KAAK;;;;;;AAOpC,MAAM,qBACJ,OACA,mBACuB;AACvB,KAAI;AACF,SAAO,IAAI,YAAY,SAAS;GAAE,OAAO;GAAM,WAAW;GAAM,CAAC,CAAC,OAChE,OAEA,EAAE,QAAQ,gBAAgB,CAC3B;SACK;AACN"}
|
package/dist/capture-buffer.js
CHANGED
|
@@ -81,9 +81,9 @@ var CaptureBuffer = class {
|
|
|
81
81
|
}
|
|
82
82
|
};
|
|
83
83
|
/**
|
|
84
|
-
* Buffered-payload ceiling, in JSON characters.
|
|
85
|
-
* body-carrying calls
|
|
86
|
-
* this bound
|
|
84
|
+
* Buffered-payload ceiling, in JSON characters. An event can carry two bodies of
|
|
85
|
+
* `MAX_BODY_CAPTURE_SIZE`, so one or two body-carrying calls can reach this; the sidecar's own
|
|
86
|
+
* payload limit is 8 MB, which leaves the batch this bound builds room for JSON escaping.
|
|
87
87
|
*/
|
|
88
88
|
const MAX_BUFFERED_CHARS = 2 * 1024 * 1024;
|
|
89
89
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"capture-buffer.js","names":[],"sources":["../src/capture-buffer.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\nimport type { CaptureEvent } from \"./protocol\";\nimport { postCaptureEvents } from \"./sidecar-client\";\nimport type { SidecarTransport } from \"./sidecar-transport\";\n\n/**\n * Batches one request's capture events into a single report to the sidecar.\n *\n * A local `wrangler dev` recording could afford a POST per event over loopback. A deployed\n * Worker cannot: each one is a subrequest, and — with a service-binding transport — an extra\n * hop the runtime has to keep the request context alive for. An SSR request making twenty\n * outbound calls should cost one report, not twenty.\n *\n * Batching means the report has to wait for work that finishes *after* the handler returns: a\n * response body is only captured once its clone has been drained. So producers register here\n * with {@link track} instead of each holding its own `waitUntil`, and {@link close} — the single\n * promise the wrapper hands to `waitUntil` — drains them before sending.\n */\nexport class CaptureBuffer {\n private events: CaptureEvent[] = [];\n private bufferedChars = 0;\n private readonly pending = new Set<Promise<void>>();\n /** Chained so two flushes can never interleave and reorder a session's chunks. */\n private sending: Promise<void> = Promise.resolve();\n private closed = false;\n\n constructor(\n private readonly transport: SidecarTransport,\n private readonly waitUntil: (promise: Promise<unknown>) => void,\n ) {}\n\n /**\n * Registers work that will add zero or more events. Never rejects: a producer that throws is\n * warned about once and dropped, exactly as an unbatched capture failure was.\n */\n track(\n producer: () => Promise<void>,\n warnKey: string,\n warnMessage: string,\n ): void {\n const promise = producer()\n .catch((error) => {\n warnOnce(warnKey, warnMessage, error);\n })\n .finally(() => {\n this.pending.delete(promise);\n });\n this.pending.add(promise);\n // A closed buffer has no `close()` left to await this, so it needs its own lifetime\n // extension — see the same reasoning in `add`.\n if (this.closed) {\n this.waitUntil(promise);\n }\n }\n\n /**\n * Buffers one event, sending early if the buffer has grown past {@link MAX_BUFFERED_CHARS}.\n *\n * The early send matters for exactly the requests batching is riskiest for: a long-lived SSE\n * response, or a handler making many body-carrying calls, would otherwise hold every captured\n * body in isolate memory until the request ended.\n */\n add(event: CaptureEvent): void {\n if (this.closed) {\n // Work the app itself queued in `waitUntil` can still make calls after our own `close()`\n // has run. Report those the old way rather than dropping them.\n this.waitUntil(this.send([event]));\n return;\n }\n this.events.push(event);\n this.bufferedChars += estimateChars(event);\n if (this.bufferedChars >= MAX_BUFFERED_CHARS) {\n this.waitUntil(this.flush());\n }\n }\n\n /**\n * Drains every producer and sends what they buffered, repeating while draining produces more\n * work (a body read that starts another capture). Idempotent; after it resolves the buffer is\n * closed and later events are sent individually.\n */\n async close(): Promise<void> {\n for (let round = 0; round < MAX_DRAIN_ROUNDS; round++) {\n if (this.pending.size === 0) {\n break;\n }\n await Promise.all([...this.pending]);\n }\n this.closed = true;\n await this.flush();\n }\n\n private flush(): Promise<void> {\n if (this.events.length === 0) {\n return this.sending;\n }\n const batch = this.events;\n this.events = [];\n this.bufferedChars = 0;\n this.sending = this.sending.then(() => this.send(batch));\n return this.sending;\n }\n\n /** `postCaptureEvents` never rejects — it warns once on an unreachable sidecar. */\n private send(events: CaptureEvent[]): Promise<void> {\n return postCaptureEvents(this.transport, events);\n }\n}\n\n/**\n * Buffered-payload ceiling, in JSON characters.
|
|
1
|
+
{"version":3,"file":"capture-buffer.js","names":[],"sources":["../src/capture-buffer.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\nimport type { CaptureEvent } from \"./protocol\";\nimport { postCaptureEvents } from \"./sidecar-client\";\nimport type { SidecarTransport } from \"./sidecar-transport\";\n\n/**\n * Batches one request's capture events into a single report to the sidecar.\n *\n * A local `wrangler dev` recording could afford a POST per event over loopback. A deployed\n * Worker cannot: each one is a subrequest, and — with a service-binding transport — an extra\n * hop the runtime has to keep the request context alive for. An SSR request making twenty\n * outbound calls should cost one report, not twenty.\n *\n * Batching means the report has to wait for work that finishes *after* the handler returns: a\n * response body is only captured once its clone has been drained. So producers register here\n * with {@link track} instead of each holding its own `waitUntil`, and {@link close} — the single\n * promise the wrapper hands to `waitUntil` — drains them before sending.\n */\nexport class CaptureBuffer {\n private events: CaptureEvent[] = [];\n private bufferedChars = 0;\n private readonly pending = new Set<Promise<void>>();\n /** Chained so two flushes can never interleave and reorder a session's chunks. */\n private sending: Promise<void> = Promise.resolve();\n private closed = false;\n\n constructor(\n private readonly transport: SidecarTransport,\n private readonly waitUntil: (promise: Promise<unknown>) => void,\n ) {}\n\n /**\n * Registers work that will add zero or more events. Never rejects: a producer that throws is\n * warned about once and dropped, exactly as an unbatched capture failure was.\n */\n track(\n producer: () => Promise<void>,\n warnKey: string,\n warnMessage: string,\n ): void {\n const promise = producer()\n .catch((error) => {\n warnOnce(warnKey, warnMessage, error);\n })\n .finally(() => {\n this.pending.delete(promise);\n });\n this.pending.add(promise);\n // A closed buffer has no `close()` left to await this, so it needs its own lifetime\n // extension — see the same reasoning in `add`.\n if (this.closed) {\n this.waitUntil(promise);\n }\n }\n\n /**\n * Buffers one event, sending early if the buffer has grown past {@link MAX_BUFFERED_CHARS}.\n *\n * The early send matters for exactly the requests batching is riskiest for: a long-lived SSE\n * response, or a handler making many body-carrying calls, would otherwise hold every captured\n * body in isolate memory until the request ended.\n */\n add(event: CaptureEvent): void {\n if (this.closed) {\n // Work the app itself queued in `waitUntil` can still make calls after our own `close()`\n // has run. Report those the old way rather than dropping them.\n this.waitUntil(this.send([event]));\n return;\n }\n this.events.push(event);\n this.bufferedChars += estimateChars(event);\n if (this.bufferedChars >= MAX_BUFFERED_CHARS) {\n this.waitUntil(this.flush());\n }\n }\n\n /**\n * Drains every producer and sends what they buffered, repeating while draining produces more\n * work (a body read that starts another capture). Idempotent; after it resolves the buffer is\n * closed and later events are sent individually.\n */\n async close(): Promise<void> {\n for (let round = 0; round < MAX_DRAIN_ROUNDS; round++) {\n if (this.pending.size === 0) {\n break;\n }\n await Promise.all([...this.pending]);\n }\n this.closed = true;\n await this.flush();\n }\n\n private flush(): Promise<void> {\n if (this.events.length === 0) {\n return this.sending;\n }\n const batch = this.events;\n this.events = [];\n this.bufferedChars = 0;\n this.sending = this.sending.then(() => this.send(batch));\n return this.sending;\n }\n\n /** `postCaptureEvents` never rejects — it warns once on an unreachable sidecar. */\n private send(events: CaptureEvent[]): Promise<void> {\n return postCaptureEvents(this.transport, events);\n }\n}\n\n/**\n * Buffered-payload ceiling, in JSON characters. An event can carry two bodies of\n * `MAX_BODY_CAPTURE_SIZE`, so one or two body-carrying calls can reach this; the sidecar's own\n * payload limit is 8 MB, which leaves the batch this bound builds room for JSON escaping.\n */\nconst MAX_BUFFERED_CHARS = 2 * 1024 * 1024;\n\n/**\n * Bounds `close()` in the pathological case where every drained producer starts another. Capture\n * is a leaf operation, so one round is the norm and two the most a body read can cause.\n */\nconst MAX_DRAIN_ROUNDS = 4;\n\n/**\n * Rough size of an event, counting only the fields that can be large. Cheaper than\n * `JSON.stringify` on the whole event, and this only needs to be right to within a factor.\n */\nconst estimateChars = (event: CaptureEvent): number => {\n let chars = 256;\n if (\"requestBody\" in event && event.requestBody !== undefined) {\n chars += event.requestBody.body.length;\n }\n if (\"responseBody\" in event && event.responseBody !== undefined) {\n chars += event.responseBody.body.length;\n }\n if (\"result\" in event && event.result !== undefined) {\n chars += event.result.body.length;\n }\n if (\"value\" in event && event.value !== undefined) {\n chars += event.value.body.length;\n }\n if (\"args\" in event && event.args !== undefined) {\n chars += event.args.body.length;\n }\n if (\"queryText\" in event) {\n chars += event.queryText.length + event.params.length;\n }\n return chars;\n};\n"],"mappings":";;;;;;;;;;;;;;;;AAkBA,IAAa,gBAAb,MAA2B;CACzB,SAAiC,EAAE;CACnC,gBAAwB;CACxB,0BAA2B,IAAI,KAAoB;;CAEnD,UAAiC,QAAQ,SAAS;CAClD,SAAiB;CAEjB,YACE,WACA,WACA;AAFiB,OAAA,YAAA;AACA,OAAA,YAAA;;;;;;CAOnB,MACE,UACA,SACA,aACM;EACN,MAAM,UAAU,UAAU,CACvB,OAAO,UAAU;AAChB,YAAS,SAAS,aAAa,MAAM;IACrC,CACD,cAAc;AACb,QAAK,QAAQ,OAAO,QAAQ;IAC5B;AACJ,OAAK,QAAQ,IAAI,QAAQ;AAGzB,MAAI,KAAK,OACP,MAAK,UAAU,QAAQ;;;;;;;;;CAW3B,IAAI,OAA2B;AAC7B,MAAI,KAAK,QAAQ;AAGf,QAAK,UAAU,KAAK,KAAK,CAAC,MAAM,CAAC,CAAC;AAClC;;AAEF,OAAK,OAAO,KAAK,MAAM;AACvB,OAAK,iBAAiB,cAAc,MAAM;AAC1C,MAAI,KAAK,iBAAiB,mBACxB,MAAK,UAAU,KAAK,OAAO,CAAC;;;;;;;CAShC,MAAM,QAAuB;AAC3B,OAAK,IAAI,QAAQ,GAAG,QAAQ,kBAAkB,SAAS;AACrD,OAAI,KAAK,QAAQ,SAAS,EACxB;AAEF,SAAM,QAAQ,IAAI,CAAC,GAAG,KAAK,QAAQ,CAAC;;AAEtC,OAAK,SAAS;AACd,QAAM,KAAK,OAAO;;CAGpB,QAA+B;AAC7B,MAAI,KAAK,OAAO,WAAW,EACzB,QAAO,KAAK;EAEd,MAAM,QAAQ,KAAK;AACnB,OAAK,SAAS,EAAE;AAChB,OAAK,gBAAgB;AACrB,OAAK,UAAU,KAAK,QAAQ,WAAW,KAAK,KAAK,MAAM,CAAC;AACxD,SAAO,KAAK;;;CAId,KAAa,QAAuC;AAClD,SAAO,kBAAkB,KAAK,WAAW,OAAO;;;;;;;;AASpD,MAAM,qBAAqB,IAAI,OAAO;;;;;AAMtC,MAAM,mBAAmB;;;;;AAMzB,MAAM,iBAAiB,UAAgC;CACrD,IAAI,QAAQ;AACZ,KAAI,iBAAiB,SAAS,MAAM,gBAAgB,KAAA,EAClD,UAAS,MAAM,YAAY,KAAK;AAElC,KAAI,kBAAkB,SAAS,MAAM,iBAAiB,KAAA,EACpD,UAAS,MAAM,aAAa,KAAK;AAEnC,KAAI,YAAY,SAAS,MAAM,WAAW,KAAA,EACxC,UAAS,MAAM,OAAO,KAAK;AAE7B,KAAI,WAAW,SAAS,MAAM,UAAU,KAAA,EACtC,UAAS,MAAM,MAAM,KAAK;AAE5B,KAAI,UAAU,SAAS,MAAM,SAAS,KAAA,EACpC,UAAS,MAAM,KAAK,KAAK;AAE3B,KAAI,eAAe,MACjB,UAAS,MAAM,UAAU,SAAS,MAAM,OAAO;AAEjD,QAAO"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fromBase64, toBase64 } from "./base64.js";
|
|
1
2
|
//#region src/captured-result-codec.ts
|
|
2
3
|
/**
|
|
3
4
|
* JSON codec for the results a database client hands back, used by the Prisma, pg and
|
|
@@ -138,21 +139,6 @@ const reviveLegacyTimestamps = (value) => {
|
|
|
138
139
|
return record;
|
|
139
140
|
};
|
|
140
141
|
const asLegacyTimestamp = (value) => ISO_TIMESTAMP.test(value) ? decodeDate(value) : void 0;
|
|
141
|
-
const BASE64_CHUNK_SIZE = 32768;
|
|
142
|
-
const toBase64 = (view) => {
|
|
143
|
-
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
144
|
-
if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
|
|
145
|
-
let binary = "";
|
|
146
|
-
for (let i = 0; i < bytes.length; i += BASE64_CHUNK_SIZE) binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_SIZE));
|
|
147
|
-
return btoa(binary);
|
|
148
|
-
};
|
|
149
|
-
const fromBase64 = (base64) => {
|
|
150
|
-
if (typeof Buffer !== "undefined") return Buffer.from(base64, "base64");
|
|
151
|
-
const binary = atob(base64);
|
|
152
|
-
const bytes = new Uint8Array(binary.length);
|
|
153
|
-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
154
|
-
return bytes;
|
|
155
|
-
};
|
|
156
142
|
//#endregion
|
|
157
143
|
export { deserializeCapturedResult, serializeCapturedResult };
|
|
158
144
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"captured-result-codec.js","names":[],"sources":["../src/captured-result-codec.ts"],"sourcesContent":["/**\n * JSON codec for the results a database client hands back, used by the Prisma, pg and\n * postgres.js capture paths on BOTH sides so a replayed result carries the same JS types\n * the driver produced at record time.\n *\n * Plain `JSON.stringify` loses those types silently. Every one of these clients returns a\n * `Date` for a timestamp column; it becomes an ISO string in the recording, and the app's\n * `row.createdAt.toISOString()` then throws during replay — while the mock is reported as a\n * clean hit, because the store did find the right recording. A `BigInt` is worse: stringify\n * throws, the record side catches, and the operation is never captured at all.\n *\n * So a value JSON cannot represent is replaced by a tagged wrapper\n * (`{ __meticulousType, value }`) and rebuilt on the way out, and the payload is wrapped once\n * more in an envelope carrying the encoding version. That envelope is also what marks a\n * recording as one this codec wrote: a recording made before it has none, and its timestamps\n * are recovered by shape instead — see {@link reviveLegacyTimestamps}.\n *\n * Lives here rather than in `backend-recorder-js` because a deployed Cloudflare Worker records\n * postgres.js and loses the same types, and a Worker cannot load that package — so the dependency\n * runs this way round, as it already does for `serializeCapturedError` and `serializeKvCaptureFields`.\n * Nothing loads this package at runtime on the Node path: it is bundled into the recorder the\n * launcher fetches.\n *\n * Still unhandled: a `Prisma.Decimal` (`decimal.js`) replays as the string its `toJSON` produced,\n * because rebuilding one needs the class and replay has no `@prisma/client` to reach it through.\n */\n\nconst ENCODING_VERSION = 1;\nconst ENVELOPE_VERSION_KEY = \"__meticulousEncoding\";\nconst TYPE_TAG_KEY = \"__meticulousType\";\n// Both the envelope and a tag carry their payload under this key.\nconst VALUE_KEY = \"value\";\n\ntype TypeTag = \"Date\" | \"BigInt\" | \"Binary\" | \"NonFiniteNumber\";\n\ninterface TaggedValue {\n [TYPE_TAG_KEY]: TypeTag;\n [VALUE_KEY]: string | null;\n}\n\n/**\n * Serializes a captured result to a JSON string, preserving the types JSON has no\n * representation for. Throws on a cyclic value, exactly as `JSON.stringify` does — the record\n * side catches that and captures nothing.\n */\nexport const serializeCapturedResult = (result: unknown): string =>\n JSON.stringify(\n { [ENVELOPE_VERSION_KEY]: ENCODING_VERSION, [VALUE_KEY]: result },\n tagUnrepresentableValues,\n );\n\n/**\n * Reconstructs a captured result, rebuilding the tagged values as the types they were\n * recorded from. A payload without the version envelope was written before this codec\n * existed, and falls back to recovering timestamps by shape.\n */\nexport const deserializeCapturedResult = (json: string): unknown => {\n const parsed: unknown = JSON.parse(json, rebuildTaggedValue);\n if (isEncodedEnvelope(parsed)) {\n return parsed[VALUE_KEY];\n }\n return reviveLegacyTimestamps(parsed);\n};\n\n/**\n * `JSON.stringify` replacer. It has to be a `function` rather than an arrow: `value` has\n * already been through the holder's `toJSON()` by the time a replacer sees it (a `Date`\n * arrives as a string, a `Buffer` as `{ type, data }`), so the only way to see what the\n * driver actually returned is `this[key]` on the bound holder.\n */\nfunction tagUnrepresentableValues(this: unknown, key: string, value: unknown) {\n const raw = (this as Record<string, unknown>)[key];\n if (typeof raw === \"bigint\") {\n return tagged(\"BigInt\", raw.toString());\n }\n if (typeof raw === \"number\" && !Number.isFinite(raw)) {\n return tagged(\"NonFiniteNumber\", String(raw));\n }\n if (raw instanceof Date) {\n return tagged(\n \"Date\",\n Number.isNaN(raw.getTime()) ? null : raw.toISOString(),\n );\n }\n // A DataView is left alone: no driver returns one, and it would come back a Uint8Array.\n if (ArrayBuffer.isView(raw) && !(raw instanceof DataView)) {\n return tagged(\"Binary\", toBase64(raw));\n }\n if (raw instanceof ArrayBuffer) {\n return tagged(\"Binary\", toBase64(new Uint8Array(raw)));\n }\n return value;\n}\n\nconst tagged = (type: TypeTag, value: string | null): TaggedValue => ({\n [TYPE_TAG_KEY]: type,\n [VALUE_KEY]: value,\n});\n\n/**\n * `JSON.parse` reviver. A tag is only honoured when its payload is one this codec could have\n * written, so an app value that happens to be shaped like a tag — and a tag type a later\n * recorder introduced — is passed through untouched rather than decoded into something else.\n * (The shim has no runtime dependencies, so `assertNever` is not available for the switch.)\n */\nconst rebuildTaggedValue = (_key: string, value: unknown): unknown => {\n if (!isTaggedValue(value)) {\n return value;\n }\n const encoded = value[VALUE_KEY];\n switch (value[TYPE_TAG_KEY]) {\n case \"Date\":\n return decodeDate(encoded) ?? value;\n case \"BigInt\":\n return typeof encoded === \"string\" && INTEGER.test(encoded)\n ? BigInt(encoded)\n : value;\n case \"NonFiniteNumber\":\n return typeof encoded === \"string\" && NON_FINITE_NUMBERS.has(encoded)\n ? Number(encoded)\n : value;\n case \"Binary\":\n return typeof encoded === \"string\" && BASE64.test(encoded)\n ? fromBase64(encoded)\n : value;\n }\n};\n\nconst INTEGER = /^-?\\d+$/;\nconst BASE64 =\n /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;\nconst NON_FINITE_NUMBERS: ReadonlySet<string> = new Set([\n \"NaN\",\n \"Infinity\",\n \"-Infinity\",\n]);\n\n// `null` is how an invalid Date was recorded; anything else has to be the exact string\n// `toISOString` would have produced for it.\nconst decodeDate = (encoded: string | null): Date | undefined => {\n if (encoded == null) {\n return new Date(NaN);\n }\n const date = new Date(encoded);\n if (Number.isNaN(date.getTime()) || date.toISOString() !== encoded) {\n return undefined;\n }\n return date;\n};\n\nconst TYPE_TAGS: ReadonlySet<string> = new Set<TypeTag>([\n \"Date\",\n \"BigInt\",\n \"Binary\",\n \"NonFiniteNumber\",\n]);\n\nconst isTaggedValue = (value: unknown): value is TaggedValue => {\n if (value == null || typeof value !== \"object\" || Array.isArray(value)) {\n return false;\n }\n const keys = Object.keys(value);\n if (keys.length !== 2 || !keys.includes(VALUE_KEY)) {\n return false;\n }\n const tag = (value as Record<string, unknown>)[TYPE_TAG_KEY];\n const encoded = (value as Record<string, unknown>)[VALUE_KEY];\n return (\n typeof tag === \"string\" &&\n TYPE_TAGS.has(tag) &&\n (typeof encoded === \"string\" || encoded === null)\n );\n};\n\nconst isEncodedEnvelope = (\n value: unknown,\n): value is Record<string, unknown> => {\n if (value == null || typeof value !== \"object\" || Array.isArray(value)) {\n return false;\n }\n return (\n (value as Record<string, unknown>)[ENVELOPE_VERSION_KEY] ===\n ENCODING_VERSION\n );\n};\n\n/**\n * The compatibility path for every recording made before this codec: the driver's `Date`s\n * are already flattened to strings there, and nothing in the payload says which strings they\n * were. Recovering them by shape is a guess, but a tightly bounded one — only the exact\n * 24-character form `Date.prototype.toJSON` emits, and only when it round-trips back to\n * itself, so a string column can collide with it solely by holding a real UTC millisecond\n * timestamp. Left as a guess rather than declined outright because the alternative is every\n * date-formatting call in the app throwing.\n *\n * Mutates in place: the value has just come out of `JSON.parse` and is owned by the caller.\n */\nconst ISO_TIMESTAMP = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/;\n\nconst reviveLegacyTimestamps = (value: unknown): unknown => {\n if (typeof value === \"string\") {\n return asLegacyTimestamp(value) ?? value;\n }\n if (value == null || typeof value !== \"object\") {\n return value;\n }\n if (Array.isArray(value)) {\n for (let i = 0; i < value.length; i++) {\n value[i] = reviveLegacyTimestamps(value[i]);\n }\n return value;\n }\n const record = value as Record<string, unknown>;\n for (const key of Object.keys(record)) {\n record[key] = reviveLegacyTimestamps(record[key]);\n }\n return record;\n};\n\nconst asLegacyTimestamp = (value: string): Date | undefined =>\n ISO_TIMESTAMP.test(value) ? decodeDate(value) : undefined;\n\nconst BASE64_CHUNK_SIZE = 0x8000;\n\nconst toBase64 = (view: ArrayBufferView): string => {\n const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);\n if (typeof Buffer !== \"undefined\") {\n return Buffer.from(bytes).toString(\"base64\");\n }\n let binary = \"\";\n for (let i = 0; i < bytes.length; i += BASE64_CHUNK_SIZE) {\n binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_SIZE));\n }\n return btoa(binary);\n};\n\n// Replay only ever runs on Node, so a `Buffer` is both what pg returned at record time and a\n// `Uint8Array` as far as a Prisma `Bytes` consumer is concerned.\nconst fromBase64 = (base64: string): Uint8Array => {\n if (typeof Buffer !== \"undefined\") {\n return Buffer.from(base64, \"base64\");\n }\n const binary = atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return bytes;\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"captured-result-codec.js","names":[],"sources":["../src/captured-result-codec.ts"],"sourcesContent":["/**\n * JSON codec for the results a database client hands back, used by the Prisma, pg and\n * postgres.js capture paths on BOTH sides so a replayed result carries the same JS types\n * the driver produced at record time.\n *\n * Plain `JSON.stringify` loses those types silently. Every one of these clients returns a\n * `Date` for a timestamp column; it becomes an ISO string in the recording, and the app's\n * `row.createdAt.toISOString()` then throws during replay — while the mock is reported as a\n * clean hit, because the store did find the right recording. A `BigInt` is worse: stringify\n * throws, the record side catches, and the operation is never captured at all.\n *\n * So a value JSON cannot represent is replaced by a tagged wrapper\n * (`{ __meticulousType, value }`) and rebuilt on the way out, and the payload is wrapped once\n * more in an envelope carrying the encoding version. That envelope is also what marks a\n * recording as one this codec wrote: a recording made before it has none, and its timestamps\n * are recovered by shape instead — see {@link reviveLegacyTimestamps}.\n *\n * Lives here rather than in `backend-recorder-js` because a deployed Cloudflare Worker records\n * postgres.js and loses the same types, and a Worker cannot load that package — so the dependency\n * runs this way round, as it already does for `serializeCapturedError` and `serializeKvCaptureFields`.\n * Nothing loads this package at runtime on the Node path: it is bundled into the recorder the\n * launcher fetches.\n *\n * Still unhandled: a `Prisma.Decimal` (`decimal.js`) replays as the string its `toJSON` produced,\n * because rebuilding one needs the class and replay has no `@prisma/client` to reach it through.\n */\n\nimport { fromBase64, toBase64 } from \"./base64\";\n\nconst ENCODING_VERSION = 1;\nconst ENVELOPE_VERSION_KEY = \"__meticulousEncoding\";\nconst TYPE_TAG_KEY = \"__meticulousType\";\n// Both the envelope and a tag carry their payload under this key.\nconst VALUE_KEY = \"value\";\n\ntype TypeTag = \"Date\" | \"BigInt\" | \"Binary\" | \"NonFiniteNumber\";\n\ninterface TaggedValue {\n [TYPE_TAG_KEY]: TypeTag;\n [VALUE_KEY]: string | null;\n}\n\n/**\n * Serializes a captured result to a JSON string, preserving the types JSON has no\n * representation for. Throws on a cyclic value, exactly as `JSON.stringify` does — the record\n * side catches that and captures nothing.\n */\nexport const serializeCapturedResult = (result: unknown): string =>\n JSON.stringify(\n { [ENVELOPE_VERSION_KEY]: ENCODING_VERSION, [VALUE_KEY]: result },\n tagUnrepresentableValues,\n );\n\n/**\n * Reconstructs a captured result, rebuilding the tagged values as the types they were\n * recorded from. A payload without the version envelope was written before this codec\n * existed, and falls back to recovering timestamps by shape.\n */\nexport const deserializeCapturedResult = (json: string): unknown => {\n const parsed: unknown = JSON.parse(json, rebuildTaggedValue);\n if (isEncodedEnvelope(parsed)) {\n return parsed[VALUE_KEY];\n }\n return reviveLegacyTimestamps(parsed);\n};\n\n/**\n * `JSON.stringify` replacer. It has to be a `function` rather than an arrow: `value` has\n * already been through the holder's `toJSON()` by the time a replacer sees it (a `Date`\n * arrives as a string, a `Buffer` as `{ type, data }`), so the only way to see what the\n * driver actually returned is `this[key]` on the bound holder.\n */\nfunction tagUnrepresentableValues(this: unknown, key: string, value: unknown) {\n const raw = (this as Record<string, unknown>)[key];\n if (typeof raw === \"bigint\") {\n return tagged(\"BigInt\", raw.toString());\n }\n if (typeof raw === \"number\" && !Number.isFinite(raw)) {\n return tagged(\"NonFiniteNumber\", String(raw));\n }\n if (raw instanceof Date) {\n return tagged(\n \"Date\",\n Number.isNaN(raw.getTime()) ? null : raw.toISOString(),\n );\n }\n // A DataView is left alone: no driver returns one, and it would come back a Uint8Array.\n if (ArrayBuffer.isView(raw) && !(raw instanceof DataView)) {\n return tagged(\"Binary\", toBase64(raw));\n }\n if (raw instanceof ArrayBuffer) {\n return tagged(\"Binary\", toBase64(new Uint8Array(raw)));\n }\n return value;\n}\n\nconst tagged = (type: TypeTag, value: string | null): TaggedValue => ({\n [TYPE_TAG_KEY]: type,\n [VALUE_KEY]: value,\n});\n\n/**\n * `JSON.parse` reviver. A tag is only honoured when its payload is one this codec could have\n * written, so an app value that happens to be shaped like a tag — and a tag type a later\n * recorder introduced — is passed through untouched rather than decoded into something else.\n * (The shim has no runtime dependencies, so `assertNever` is not available for the switch.)\n */\nconst rebuildTaggedValue = (_key: string, value: unknown): unknown => {\n if (!isTaggedValue(value)) {\n return value;\n }\n const encoded = value[VALUE_KEY];\n switch (value[TYPE_TAG_KEY]) {\n case \"Date\":\n return decodeDate(encoded) ?? value;\n case \"BigInt\":\n return typeof encoded === \"string\" && INTEGER.test(encoded)\n ? BigInt(encoded)\n : value;\n case \"NonFiniteNumber\":\n return typeof encoded === \"string\" && NON_FINITE_NUMBERS.has(encoded)\n ? Number(encoded)\n : value;\n case \"Binary\":\n return typeof encoded === \"string\" && BASE64.test(encoded)\n ? fromBase64(encoded)\n : value;\n }\n};\n\nconst INTEGER = /^-?\\d+$/;\nconst BASE64 =\n /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;\nconst NON_FINITE_NUMBERS: ReadonlySet<string> = new Set([\n \"NaN\",\n \"Infinity\",\n \"-Infinity\",\n]);\n\n// `null` is how an invalid Date was recorded; anything else has to be the exact string\n// `toISOString` would have produced for it.\nconst decodeDate = (encoded: string | null): Date | undefined => {\n if (encoded == null) {\n return new Date(NaN);\n }\n const date = new Date(encoded);\n if (Number.isNaN(date.getTime()) || date.toISOString() !== encoded) {\n return undefined;\n }\n return date;\n};\n\nconst TYPE_TAGS: ReadonlySet<string> = new Set<TypeTag>([\n \"Date\",\n \"BigInt\",\n \"Binary\",\n \"NonFiniteNumber\",\n]);\n\nconst isTaggedValue = (value: unknown): value is TaggedValue => {\n if (value == null || typeof value !== \"object\" || Array.isArray(value)) {\n return false;\n }\n const keys = Object.keys(value);\n if (keys.length !== 2 || !keys.includes(VALUE_KEY)) {\n return false;\n }\n const tag = (value as Record<string, unknown>)[TYPE_TAG_KEY];\n const encoded = (value as Record<string, unknown>)[VALUE_KEY];\n return (\n typeof tag === \"string\" &&\n TYPE_TAGS.has(tag) &&\n (typeof encoded === \"string\" || encoded === null)\n );\n};\n\nconst isEncodedEnvelope = (\n value: unknown,\n): value is Record<string, unknown> => {\n if (value == null || typeof value !== \"object\" || Array.isArray(value)) {\n return false;\n }\n return (\n (value as Record<string, unknown>)[ENVELOPE_VERSION_KEY] ===\n ENCODING_VERSION\n );\n};\n\n/**\n * The compatibility path for every recording made before this codec: the driver's `Date`s\n * are already flattened to strings there, and nothing in the payload says which strings they\n * were. Recovering them by shape is a guess, but a tightly bounded one — only the exact\n * 24-character form `Date.prototype.toJSON` emits, and only when it round-trips back to\n * itself, so a string column can collide with it solely by holding a real UTC millisecond\n * timestamp. Left as a guess rather than declined outright because the alternative is every\n * date-formatting call in the app throwing.\n *\n * Mutates in place: the value has just come out of `JSON.parse` and is owned by the caller.\n */\nconst ISO_TIMESTAMP = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/;\n\nconst reviveLegacyTimestamps = (value: unknown): unknown => {\n if (typeof value === \"string\") {\n return asLegacyTimestamp(value) ?? value;\n }\n if (value == null || typeof value !== \"object\") {\n return value;\n }\n if (Array.isArray(value)) {\n for (let i = 0; i < value.length; i++) {\n value[i] = reviveLegacyTimestamps(value[i]);\n }\n return value;\n }\n const record = value as Record<string, unknown>;\n for (const key of Object.keys(record)) {\n record[key] = reviveLegacyTimestamps(record[key]);\n }\n return record;\n};\n\nconst asLegacyTimestamp = (value: string): Date | undefined =>\n ISO_TIMESTAMP.test(value) ? decodeDate(value) : undefined;\n"],"mappings":"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"}
|
package/dist/context.js
CHANGED
|
@@ -3,7 +3,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
3
3
|
//#region src/context.ts
|
|
4
4
|
const requestCaptureContext = new AsyncLocalStorage();
|
|
5
5
|
/** The origin the shim's own sidecar traffic goes to, for the self-capture guards. */
|
|
6
|
-
const sidecarOriginOf = (ctx) => ctx.mode === "
|
|
6
|
+
const sidecarOriginOf = (ctx) => ctx.mode === "record" ? transportOrigin(ctx.transport) : ctx.sidecarUrl;
|
|
7
7
|
//#endregion
|
|
8
8
|
export { requestCaptureContext, sidecarOriginOf };
|
|
9
9
|
|