@alwaysmeticulous/backend-recorder-workerd 2.323.0 → 2.325.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/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import { BindingRequestEvent, CAPTURED_HEADERS, CaptureEvent, CaptureEventsPaylo
2
2
  import { MAX_BODY_CAPTURE_SIZE, readBodyWithCap, readRequestBodyWithCap } from "./body-capture.js";
3
3
  import { headersToRecord } from "./outbound-capture.js";
4
4
  import { STR_REDACTED, redactRequestBody } from "./redact-body.js";
5
+ import { KvCaptureFields, KvCaptureOutcome, serializeKvArgs, serializeKvCaptureFields } from "./kv-capture.js";
5
6
 
6
7
  //#region src/index.d.ts
7
8
  /** Structural subset of workerd's ExecutionContext — avoids a @cloudflare/workers-types dependency. */
@@ -58,5 +59,5 @@ interface WithMeticulousOptions {
58
59
  */
59
60
  declare const withMeticulous: <H extends MeticulousWorkerHandler<never>>(handler: H, options?: WithMeticulousOptions) => H;
60
61
  //#endregion
61
- export { type BindingRequestEvent, CAPTURED_HEADERS, type CaptureEvent, type CaptureEventsPayload, type CapturedBody, FRONTEND_SESSION_ID_HEADER, type InboundRequestEvent, type KvOmittedReason, type KvOperation, type KvOperationEvent, MAX_BODY_CAPTURE_SIZE, METICULOUS_PASSTHROUGH_HEADER, 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 };
62
+ export { type BindingRequestEvent, CAPTURED_HEADERS, type CaptureEvent, type CaptureEventsPayload, type CapturedBody, FRONTEND_SESSION_ID_HEADER, type InboundRequestEvent, type KvCaptureFields, type KvCaptureOutcome, type KvOmittedReason, type KvOperation, type KvOperationEvent, MAX_BODY_CAPTURE_SIZE, METICULOUS_PASSTHROUGH_HEADER, 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, serializeKvArgs, serializeKvCaptureFields, withMeticulous };
62
63
  //# 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 { installBindingPatch } from \"./binding-patch\";\nimport {\n type RequestCaptureContext,\n type RequestReplayContext,\n requestCaptureContext,\n} from \"./context\";\nimport { installFetchPatch } from \"./fetch-patch\";\nimport { installKvPatch } from \"./kv-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\";\nimport { installVirtualRandom } from \"./virtual-random\";\n\nexport type {\n BindingRequestEvent,\n CaptureEvent,\n CapturedBody,\n CaptureEventsPayload,\n InboundRequestEvent,\n KvOmittedReason,\n KvOperation,\n KvOperationEvent,\n OutboundFetchLookupRequest,\n OutboundFetchLookupResponse,\n OutboundRequestEvent,\n ReplaySessionInfoResponse,\n} from \"./protocol\";\nexport {\n CAPTURED_HEADERS,\n FRONTEND_SESSION_ID_HEADER,\n METICULOUS_PASSTHROUGH_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 * Applies to KV namespaces as well as `fetch`-shaped bindings.\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, outgoing `fetch`\n * calls, calls through `fetch`-shaped bindings and KV namespace operations are reported to\n * 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 a call the\n * recording does not cover fails, rather than quietly becoming live traffic; put a\n * `meticulous-passthrough: true` header on a request that must stay real. The clock is frozen\n * at the recorded session's end so recorded credentials are still valid, and `Math.random` /\n * `crypto.randomUUID` / `crypto.getRandomValues` are seeded so ids the app mints are the same\n * in every replay of that session. 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 and KV calls are recorded as their own technologies\n // (`workerd-binding`, `workerd-kv`) and the replay sidecar's store holds only\n // `workerd-fetch` spans, so there is nothing to serve them from — and patching would\n // only route them into the capture tee, which POSTs to an events route a replay\n // sidecar 404s.\n installBindingPatch(env, { skipBindings: options?.skipBindings });\n installKvPatch(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 // Likewise the random generators: an id the app mints during SSR (a guest id, a\n // request id) otherwise differs between a base and a head replay, and every rendered\n // page carrying it diffs forever.\n installVirtualRandom();\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":"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","debugId":"00301fec-a17c-5e8e-8e2f-f16608d3b611"}
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 { installKvPatch } from \"./kv-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\";\nimport { installVirtualRandom } from \"./virtual-random\";\n\nexport type {\n BindingRequestEvent,\n CaptureEvent,\n CapturedBody,\n CaptureEventsPayload,\n InboundRequestEvent,\n KvOmittedReason,\n KvOperation,\n KvOperationEvent,\n OutboundFetchLookupRequest,\n OutboundFetchLookupResponse,\n OutboundRequestEvent,\n ReplaySessionInfoResponse,\n} from \"./protocol\";\nexport {\n CAPTURED_HEADERS,\n FRONTEND_SESSION_ID_HEADER,\n METICULOUS_PASSTHROUGH_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 * Exported for the Node recorder's `withMeticulousCloudflareEnv`, which records the same KV\n * operations from a process whose bindings come from wrangler's `getPlatformProxy`. Both\n * surfaces must produce identical fields, so the contract has exactly one implementation.\n */\nexport {\n type KvCaptureFields,\n type KvCaptureOutcome,\n serializeKvArgs,\n serializeKvCaptureFields,\n} from \"./kv-capture\";\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 * Applies to KV namespaces as well as `fetch`-shaped bindings.\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, outgoing `fetch`\n * calls, calls through `fetch`-shaped bindings and KV namespace operations are reported to\n * 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 a call the\n * recording does not cover fails, rather than quietly becoming live traffic; put a\n * `meticulous-passthrough: true` header on a request that must stay real. The clock is frozen\n * at the recorded session's end so recorded credentials are still valid, and `Math.random` /\n * `crypto.randomUUID` / `crypto.getRandomValues` are seeded so ids the app mints are the same\n * in every replay of that session. 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 and KV calls are recorded as their own technologies\n // (`workerd-binding`, `workerd-kv`) and the replay sidecar's store holds only\n // `workerd-fetch` spans, so there is nothing to serve them from — and patching would\n // only route them into the capture tee, which POSTs to an events route a replay\n // sidecar 404s.\n installBindingPatch(env, { skipBindings: options?.skipBindings });\n installKvPatch(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 // Likewise the random generators: an id the app mints during SSR (a guest id, a\n // request id) otherwise differs between a base and a head replay, and every rendered\n // page carrying it diffs forever.\n installVirtualRandom();\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":"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","debugId":"fc6d90d7-9399-56bc-9c2a-5135d61580c8"}
@@ -0,0 +1,39 @@
1
+ import { CapturedBody, KvOmittedReason, KvOperation } from "./protocol.js";
2
+
3
+ //#region src/kv-capture.d.ts
4
+ /** The outcome of a KV operation: what it returned, or how it failed. */
5
+ type KvCaptureOutcome = {
6
+ result: unknown;
7
+ } | {
8
+ error: string;
9
+ };
10
+ /** The persistable fields of one KV operation, as `KvOperationEvent` carries them. */
11
+ interface KvCaptureFields {
12
+ /** The key operated on, when it is a single string. Absent for `list` and a bulk `get`. */
13
+ key?: string;
14
+ /** JSON of the call's arguments, with a `put`'s value slot nulled out. */
15
+ args?: CapturedBody;
16
+ /** JSON of the value a `put` wrote, redacted. */
17
+ value?: CapturedBody;
18
+ /** JSON of what the operation returned. Absent for `put`/`delete` and for an error. */
19
+ result?: CapturedBody;
20
+ /** Why a value is missing. Refers to the written value for `put`, the read value otherwise. */
21
+ omitted?: KvOmittedReason;
22
+ }
23
+ /**
24
+ * Serializes one KV operation's fields. Never throws: a value that cannot be serialized is
25
+ * simply absent, so the operation is still recorded and only its payload is missing.
26
+ */
27
+ declare const serializeKvCaptureFields: (operation: KvOperation, args: unknown[], outcome: KvCaptureOutcome) => KvCaptureFields;
28
+ /**
29
+ * JSON of the call's arguments. A `put` value is replaced by `null` rather than dropped, so the
30
+ * remaining arguments keep their positions — and so the value is only ever persisted via the
31
+ * redacted {@link serializeWrittenValue} path.
32
+ *
33
+ * Exported because it is a KV operation's whole identity: a replay derives its lookup key from the
34
+ * live arguments and compares it against this, so the two must be produced by one function.
35
+ */
36
+ declare const serializeKvArgs: (operation: KvOperation, args: unknown[]) => CapturedBody | undefined;
37
+ //#endregion
38
+ export { KvCaptureFields, KvCaptureOutcome, serializeKvArgs, serializeKvCaptureFields };
39
+ //# sourceMappingURL=kv-capture.d.ts.map
@@ -0,0 +1,92 @@
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]="8c90b03e-9d0f-5403-a848-7872052017fa")}catch(e){}}();
3
+ import { redactRequestBody } from "./redact-body.js";
4
+ import { MAX_BODY_CAPTURE_SIZE } from "./body-capture.js";
5
+ //#region src/kv-capture.ts
6
+ /**
7
+ * Serializes one KV operation's fields. Never throws: a value that cannot be serialized is
8
+ * simply absent, so the operation is still recorded and only its payload is missing.
9
+ */
10
+ const serializeKvCaptureFields = (operation, args, outcome) => {
11
+ const [key] = args;
12
+ const written = operation === "put" ? serializeWrittenValue(args[1]) : void 0;
13
+ const read = "result" in outcome ? serializeKvValue(outcome.result) : void 0;
14
+ const omitted = written?.omitted ?? read?.omitted;
15
+ const serializedArgs = serializeKvArgs(operation, args);
16
+ return {
17
+ ...typeof key === "string" ? { key } : {},
18
+ ...serializedArgs !== void 0 ? { args: serializedArgs } : {},
19
+ ...written?.captured !== void 0 ? { value: written.captured } : {},
20
+ ...read?.captured !== void 0 ? { result: read.captured } : {},
21
+ ...omitted !== void 0 ? { omitted } : {}
22
+ };
23
+ };
24
+ /**
25
+ * JSON of a value the runtime returned, with anything unpersistable replaced by `null` and the
26
+ * reason reported alongside. A `Map` (what a bulk `get` returns) becomes a plain object, which
27
+ * `JSON.stringify` would otherwise flatten to `{}`.
28
+ *
29
+ * Read values are stored verbatim, deliberately: replay serves them straight back to the app,
30
+ * so redacting one would corrupt what the app receives. Only the write path
31
+ * ({@link serializeWrittenValue}) is redacted — nothing ever replays a `put`.
32
+ */
33
+ const serializeKvValue = (value) => {
34
+ let omitted;
35
+ try {
36
+ const json = JSON.stringify(value, (_key, nested) => {
37
+ const reason = omittedReason(nested);
38
+ if (reason !== void 0) {
39
+ omitted ??= reason;
40
+ return null;
41
+ }
42
+ return nested instanceof Map ? Object.fromEntries(nested) : nested;
43
+ });
44
+ return {
45
+ ...json !== void 0 ? { captured: capture(json) } : {},
46
+ ...omitted !== void 0 ? { omitted } : {}
47
+ };
48
+ } catch {
49
+ return {};
50
+ }
51
+ };
52
+ /**
53
+ * JSON of the value a `put` wrote, redacted like a request body. KV takes a string, an
54
+ * ArrayBuffer(View) or a stream; only the string form can be captured, and it is redacted as
55
+ * text before being JSON-encoded so that secret-looking fields *inside* the app's payload are
56
+ * caught (redacting the JSON-encoded form would only ever see one opaque string).
57
+ */
58
+ const serializeWrittenValue = (value) => {
59
+ if (typeof value === "string") try {
60
+ return { captured: capture(JSON.stringify(redactRequestBody(value))) };
61
+ } catch {
62
+ return {};
63
+ }
64
+ return serializeKvValue(value);
65
+ };
66
+ /**
67
+ * JSON of the call's arguments. A `put` value is replaced by `null` rather than dropped, so the
68
+ * remaining arguments keep their positions — and so the value is only ever persisted via the
69
+ * redacted {@link serializeWrittenValue} path.
70
+ *
71
+ * Exported because it is a KV operation's whole identity: a replay derives its lookup key from the
72
+ * live arguments and compares it against this, so the two must be produced by one function.
73
+ */
74
+ const serializeKvArgs = (operation, args) => {
75
+ return serializeKvValue(operation === "put" ? args.map((arg, index) => index === 1 ? null : arg) : args).captured;
76
+ };
77
+ const omittedReason = (value) => {
78
+ if (typeof ReadableStream !== "undefined" && value instanceof ReadableStream) return "stream";
79
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return "binary";
80
+ };
81
+ const capture = (json) => json.length > 262144 ? {
82
+ body: json.slice(0, MAX_BODY_CAPTURE_SIZE),
83
+ truncated: true
84
+ } : {
85
+ body: json,
86
+ truncated: false
87
+ };
88
+ //#endregion
89
+ export { serializeKvArgs, serializeKvCaptureFields };
90
+
91
+ //# sourceMappingURL=kv-capture.js.map
92
+ //# debugId=8c90b03e-9d0f-5403-a848-7872052017fa
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kv-capture.js","sources":["../src/kv-capture.ts"],"sourcesContent":["import { MAX_BODY_CAPTURE_SIZE } from \"./body-capture\";\nimport type { CapturedBody, KvOmittedReason, KvOperation } from \"./protocol\";\nimport { redactRequestBody } from \"./redact-body\";\n\n/**\n * How a KV operation's key, arguments and value are turned into persistable fields.\n *\n * This lives apart from `kv-patch.ts` because it is not workerd-specific: the same operations\n * are recorded from a **Node** process whose bindings come from wrangler's `getPlatformProxy`\n * (`withMeticulousCloudflareEnv` in `@alwaysmeticulous/backend-recorder-js`), and the two\n * surfaces have to produce byte-identical fields or a session recorded through one cannot be\n * replayed through the other. So the contract is defined once, here, and both call it.\n */\n\ninterface SerializedKvValue {\n captured?: CapturedBody;\n omitted?: KvOmittedReason;\n}\n\n/** The outcome of a KV operation: what it returned, or how it failed. */\nexport type KvCaptureOutcome = { result: unknown } | { error: string };\n\n/** The persistable fields of one KV operation, as `KvOperationEvent` carries them. */\nexport interface KvCaptureFields {\n /** The key operated on, when it is a single string. Absent for `list` and a bulk `get`. */\n key?: string;\n /** JSON of the call's arguments, with a `put`'s value slot nulled out. */\n args?: CapturedBody;\n /** JSON of the value a `put` wrote, redacted. */\n value?: CapturedBody;\n /** JSON of what the operation returned. Absent for `put`/`delete` and for an error. */\n result?: CapturedBody;\n /** Why a value is missing. Refers to the written value for `put`, the read value otherwise. */\n omitted?: KvOmittedReason;\n}\n\n/**\n * Serializes one KV operation's fields. Never throws: a value that cannot be serialized is\n * simply absent, so the operation is still recorded and only its payload is missing.\n */\nexport const serializeKvCaptureFields = (\n operation: KvOperation,\n args: unknown[],\n outcome: KvCaptureOutcome,\n): KvCaptureFields => {\n const [key] = args;\n const written =\n operation === \"put\" ? serializeWrittenValue(args[1]) : undefined;\n const read =\n \"result\" in outcome ? serializeKvValue(outcome.result) : undefined;\n const omitted = written?.omitted ?? read?.omitted;\n const serializedArgs = serializeKvArgs(operation, args);\n\n return {\n ...(typeof key === \"string\" ? { key } : {}),\n ...(serializedArgs !== undefined ? { args: serializedArgs } : {}),\n ...(written?.captured !== undefined ? { value: written.captured } : {}),\n ...(read?.captured !== undefined ? { result: read.captured } : {}),\n ...(omitted !== undefined ? { omitted } : {}),\n };\n};\n\n/**\n * JSON of a value the runtime returned, with anything unpersistable replaced by `null` and the\n * reason reported alongside. A `Map` (what a bulk `get` returns) becomes a plain object, which\n * `JSON.stringify` would otherwise flatten to `{}`.\n *\n * Read values are stored verbatim, deliberately: replay serves them straight back to the app,\n * so redacting one would corrupt what the app receives. Only the write path\n * ({@link serializeWrittenValue}) is redacted — nothing ever replays a `put`.\n */\nconst serializeKvValue = (value: unknown): SerializedKvValue => {\n let omitted: KvOmittedReason | undefined;\n try {\n const json = JSON.stringify(value, (_key: string, nested: unknown) => {\n const reason = omittedReason(nested);\n if (reason !== undefined) {\n omitted ??= reason;\n return null;\n }\n return nested instanceof Map\n ? Object.fromEntries(nested as Map<string, unknown>)\n : nested;\n });\n return {\n // `undefined` (what `put`/`delete` resolve to) has no JSON form — nothing to capture.\n ...(json !== undefined ? { captured: capture(json) } : {}),\n ...(omitted !== undefined ? { omitted } : {}),\n };\n } catch {\n // Circular, or a getter that throws. The operation itself is still recorded.\n return {};\n }\n};\n\n/**\n * JSON of the value a `put` wrote, redacted like a request body. KV takes a string, an\n * ArrayBuffer(View) or a stream; only the string form can be captured, and it is redacted as\n * text before being JSON-encoded so that secret-looking fields *inside* the app's payload are\n * caught (redacting the JSON-encoded form would only ever see one opaque string).\n */\nconst serializeWrittenValue = (value: unknown): SerializedKvValue => {\n if (typeof value === \"string\") {\n try {\n return { captured: capture(JSON.stringify(redactRequestBody(value))) };\n } catch {\n return {};\n }\n }\n return serializeKvValue(value);\n};\n\n/**\n * JSON of the call's arguments. A `put` value is replaced by `null` rather than dropped, so the\n * remaining arguments keep their positions — and so the value is only ever persisted via the\n * redacted {@link serializeWrittenValue} path.\n *\n * Exported because it is a KV operation's whole identity: a replay derives its lookup key from the\n * live arguments and compares it against this, so the two must be produced by one function.\n */\nexport const serializeKvArgs = (\n operation: KvOperation,\n args: unknown[],\n): CapturedBody | undefined => {\n const withoutValue =\n operation === \"put\"\n ? args.map((arg, index) => (index === 1 ? null : arg))\n : args;\n return serializeKvValue(withoutValue).captured;\n};\n\nconst omittedReason = (value: unknown): KvOmittedReason | undefined => {\n // A stream is never read, only recognised: reading it would consume the app's bytes.\n if (\n typeof ReadableStream !== \"undefined\" &&\n value instanceof ReadableStream\n ) {\n return \"stream\";\n }\n if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {\n return \"binary\";\n }\n return undefined;\n};\n\nconst capture = (json: string): CapturedBody =>\n json.length > MAX_BODY_CAPTURE_SIZE\n ? { body: json.slice(0, MAX_BODY_CAPTURE_SIZE), truncated: true }\n : { body: json, truncated: false };\n"],"names":[],"mappings":";;;;;;;;;AAwCA,MAAa,4BACX,WACA,MACA,YACoB;CACpB,MAAM,CAAC,OAAO;CACd,MAAM,UACJ,cAAc,QAAQ,sBAAsB,KAAK,GAAG,GAAG,KAAA;CACzD,MAAM,OACJ,YAAY,UAAU,iBAAiB,QAAQ,OAAO,GAAG,KAAA;CAC3D,MAAM,UAAU,SAAS,WAAW,MAAM;CAC1C,MAAM,iBAAiB,gBAAgB,WAAW,KAAK;AAEvD,QAAO;EACL,GAAI,OAAO,QAAQ,WAAW,EAAE,KAAK,GAAG,EAAE;EAC1C,GAAI,mBAAmB,KAAA,IAAY,EAAE,MAAM,gBAAgB,GAAG,EAAE;EAChE,GAAI,SAAS,aAAa,KAAA,IAAY,EAAE,OAAO,QAAQ,UAAU,GAAG,EAAE;EACtE,GAAI,MAAM,aAAa,KAAA,IAAY,EAAE,QAAQ,KAAK,UAAU,GAAG,EAAE;EACjE,GAAI,YAAY,KAAA,IAAY,EAAE,SAAS,GAAG,EAAE;EAC7C;;;;;;;;;;;AAYH,MAAM,oBAAoB,UAAsC;CAC9D,IAAI;AACJ,KAAI;EACF,MAAM,OAAO,KAAK,UAAU,QAAQ,MAAc,WAAoB;GACpE,MAAM,SAAS,cAAc,OAAO;AACpC,OAAI,WAAW,KAAA,GAAW;AACxB,gBAAY;AACZ,WAAO;;AAET,UAAO,kBAAkB,MACrB,OAAO,YAAY,OAA+B,GAClD;IACJ;AACF,SAAO;GAEL,GAAI,SAAS,KAAA,IAAY,EAAE,UAAU,QAAQ,KAAK,EAAE,GAAG,EAAE;GACzD,GAAI,YAAY,KAAA,IAAY,EAAE,SAAS,GAAG,EAAE;GAC7C;SACK;AAEN,SAAO,EAAE;;;;;;;;;AAUb,MAAM,yBAAyB,UAAsC;AACnE,KAAI,OAAO,UAAU,SACnB,KAAI;AACF,SAAO,EAAE,UAAU,QAAQ,KAAK,UAAU,kBAAkB,MAAM,CAAC,CAAC,EAAE;SAChE;AACN,SAAO,EAAE;;AAGb,QAAO,iBAAiB,MAAM;;;;;;;;;;AAWhC,MAAa,mBACX,WACA,SAC6B;AAK7B,QAAO,iBAHL,cAAc,QACV,KAAK,KAAK,KAAK,UAAW,UAAU,IAAI,OAAO,IAAK,GACpD,KAC+B,CAAC;;AAGxC,MAAM,iBAAiB,UAAgD;AAErE,KACE,OAAO,mBAAmB,eAC1B,iBAAiB,eAEjB,QAAO;AAET,KAAI,iBAAiB,eAAe,YAAY,OAAO,MAAM,CAC3D,QAAO;;AAKX,MAAM,WAAW,SACf,KAAK,SAAA,SACD;CAAE,MAAM,KAAK,MAAM,GAAG,sBAAsB;CAAE,WAAW;CAAM,GAC/D;CAAE,MAAM;CAAM,WAAW;CAAO","debugId":"8c90b03e-9d0f-5403-a848-7872052017fa"}
package/dist/kv-patch.js CHANGED
@@ -1,11 +1,10 @@
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]="93bf09ea-4e78-5ac4-92cd-661f2ce03542")}catch(e){}}();
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]="b871e5bd-a858-5783-9618-2ec0bd2a52bf")}catch(e){}}();
3
3
  import { requestCaptureContext } from "./context.js";
4
4
  import { warnOnce } from "./log.js";
5
- import { redactRequestBody } from "./redact-body.js";
6
- import { MAX_BODY_CAPTURE_SIZE } from "./body-capture.js";
7
5
  import { getOriginalFetch } from "./original-fetch.js";
8
6
  import { postCaptureEvents } from "./sidecar-client.js";
7
+ import { serializeKvCaptureFields } from "./kv-capture.js";
9
8
  //#region src/kv-patch.ts
10
9
  /**
11
10
  * Records operations on Cloudflare KV namespace bindings (`env.MY_KV.get(key)`).
@@ -171,91 +170,19 @@ const report = (ctx, operation, bindingName, args, startTimeMs, outcome) => {
171
170
  warnOnce("kv-report", "Failed to report a KV operation.", error);
172
171
  }
173
172
  };
174
- const buildKvEvent = (ctx, operation, bindingName, args, startTimeMs, outcome) => {
175
- const [key] = args;
176
- const written = operation === "put" ? serializeWrittenValue(args[1]) : void 0;
177
- const read = "result" in outcome ? serializeKvValue(outcome.result) : void 0;
178
- const omitted = written?.omitted ?? read?.omitted;
179
- const serializedArgs = serializeKvArgs(operation, args);
180
- return {
181
- kind: "kv",
182
- requestId: ctx.requestId,
183
- ...ctx.frontendSessionId !== void 0 ? { frontendSessionId: ctx.frontendSessionId } : {},
184
- bindingName,
185
- operation,
186
- ...typeof key === "string" ? { key } : {},
187
- ...serializedArgs !== void 0 ? { args: serializedArgs } : {},
188
- ...written?.captured !== void 0 ? { value: written.captured } : {},
189
- ...read?.captured !== void 0 ? { result: read.captured } : {},
190
- ...omitted !== void 0 ? { omitted } : {},
191
- startTimeMs,
192
- endTimeMs: Date.now(),
193
- ..."error" in outcome ? { error: outcome.error } : {}
194
- };
195
- };
196
- /**
197
- * JSON of a value workerd returned, with anything unpersistable replaced by `null` and the
198
- * reason reported alongside. A `Map` (what a bulk `get` returns) becomes a plain object,
199
- * which `JSON.stringify` would otherwise flatten to `{}`.
200
- *
201
- * Read values are stored verbatim, deliberately: a future replay would serve them straight
202
- * back to the app, so redacting one would corrupt what the app receives. Only the write path
203
- * ({@link serializeWrittenValue}) is redacted — nothing ever replays a `put`.
204
- */
205
- const serializeKvValue = (value) => {
206
- let omitted;
207
- try {
208
- const json = JSON.stringify(value, (_key, nested) => {
209
- const reason = omittedReason(nested);
210
- if (reason !== void 0) {
211
- omitted ??= reason;
212
- return null;
213
- }
214
- return nested instanceof Map ? Object.fromEntries(nested) : nested;
215
- });
216
- return {
217
- ...json !== void 0 ? { captured: capture(json) } : {},
218
- ...omitted !== void 0 ? { omitted } : {}
219
- };
220
- } catch {
221
- return {};
222
- }
223
- };
224
- /**
225
- * JSON of the value a `put` wrote, redacted like a request body. KV takes a string, an
226
- * ArrayBuffer(View) or a stream; only the string form can be captured, and it is redacted as
227
- * text before being JSON-encoded so that secret-looking fields *inside* the app's payload are
228
- * caught (redacting the JSON-encoded form would only ever see one opaque string).
229
- */
230
- const serializeWrittenValue = (value) => {
231
- if (typeof value === "string") try {
232
- return { captured: capture(JSON.stringify(redactRequestBody(value))) };
233
- } catch {
234
- return {};
235
- }
236
- return serializeKvValue(value);
237
- };
238
- /**
239
- * JSON of the call's arguments. A `put` value is replaced by `null` rather than dropped, so
240
- * the remaining arguments keep their positions — and so the value is only ever persisted via
241
- * the redacted {@link serializeWrittenValue} path.
242
- */
243
- const serializeKvArgs = (operation, args) => {
244
- return serializeKvValue(operation === "put" ? args.map((arg, index) => index === 1 ? null : arg) : args).captured;
245
- };
246
- const omittedReason = (value) => {
247
- if (typeof ReadableStream !== "undefined" && value instanceof ReadableStream) return "stream";
248
- if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return "binary";
249
- };
250
- const capture = (json) => json.length > 262144 ? {
251
- body: json.slice(0, MAX_BODY_CAPTURE_SIZE),
252
- truncated: true
253
- } : {
254
- body: json,
255
- truncated: false
256
- };
173
+ const buildKvEvent = (ctx, operation, bindingName, args, startTimeMs, outcome) => ({
174
+ kind: "kv",
175
+ requestId: ctx.requestId,
176
+ ...ctx.frontendSessionId !== void 0 ? { frontendSessionId: ctx.frontendSessionId } : {},
177
+ bindingName,
178
+ operation,
179
+ ...serializeKvCaptureFields(operation, args, outcome),
180
+ startTimeMs,
181
+ endTimeMs: Date.now(),
182
+ ..."error" in outcome ? { error: outcome.error } : {}
183
+ });
257
184
  //#endregion
258
185
  export { installKvPatch };
259
186
 
260
187
  //# sourceMappingURL=kv-patch.js.map
261
- //# debugId=93bf09ea-4e78-5ac4-92cd-661f2ce03542
188
+ //# debugId=b871e5bd-a858-5783-9618-2ec0bd2a52bf
@@ -1 +1 @@
1
- {"version":3,"file":"kv-patch.js","sources":["../src/kv-patch.ts"],"sourcesContent":["import { MAX_BODY_CAPTURE_SIZE } from \"./body-capture\";\nimport { type RequestCaptureContext, requestCaptureContext } from \"./context\";\nimport { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport type {\n CapturedBody,\n KvOmittedReason,\n KvOperation,\n KvOperationEvent,\n} from \"./protocol\";\nimport { redactRequestBody } from \"./redact-body\";\nimport { postCaptureEvents } from \"./sidecar-client\";\n\n/**\n * Records operations on Cloudflare KV namespace bindings (`env.MY_KV.get(key)`).\n *\n * KV is invisible to both the `globalThis.fetch` patch and the binding patch: a namespace is\n * a host object with no `fetch`, and workerd services its methods internally rather than as\n * a subrequest. So the namespace's *prototype* methods are patched instead — the same move\n * `binding-patch.ts` makes on a Fetcher, and the Node recorder on `Redis.prototype.sendCommand`.\n *\n * The prototype is reached from an instance found on `env`: as with bindings there is no\n * global to name, and going through an instance is what makes this work for an app that reads\n * its bindings from `cloudflare:workers` rather than the handler argument — that module hands\n * out a different `env` object but the same namespace instances, so the prototype is shared\n * either way.\n *\n * Verified against real workerd in `packages/backend-recorder-js/src/__tests__/`:\n * `workerd-binding-patch.spec.ts` pins the property shape this relies on (every recorded\n * method inherited and configurable, one prototype across namespaces), and\n * `workerd-kv-sidecar.spec.ts` drives real KV through the patch end to end.\n */\n\n// Marks a patched method, rather than the prototype holding it: methods can live at different\n// depths of one chain, and a base prototype can be shared with another binding type, so\n// \"already ours\" has to be answerable per function. Symbol.for so a second copy of the shim\n// (e.g. bundled twice) still detects the patch.\nconst KV_PATCHED = Symbol.for(\"meticulous.workerd.kvPatched\");\n\n/**\n * The methods recorded, and the shape a KV namespace is recognised by.\n *\n * `getWithMetadata` is what separates KV from R2: an R2 bucket also has `get`, `put`,\n * `delete` and `list`, so without it a bucket would be duck-typed as a namespace and its\n * (large, binary) objects pushed through value capture.\n */\nconst RECORDED_KV_METHODS = [\n \"get\",\n \"getWithMetadata\",\n \"put\",\n \"delete\",\n \"list\",\n] as const;\n\n/**\n * The namespaces being recorded, and the `env` key each was found under.\n *\n * Doubles as the allow-list: only an instance in here is recorded. A skipped namespace is\n * simply never registered, and — since the patch can land on a prototype shared with another\n * binding type — nothing that is not a discovered KV namespace can be recorded by accident.\n */\nconst kvBindingNames = new WeakMap<object, string>();\n\ntype KvMethod = (...args: unknown[]) => Promise<unknown>;\n\ntype KvNamespaceLike = Record<KvOperation, KvMethod>;\n\nexport interface InstallKvPatchOptions {\n /** Binding names to leave unrecorded. */\n skipBindings?: readonly string[];\n}\n\n/**\n * Discovers KV namespace bindings on `env` and patches their shared prototype so their\n * operations are recorded. Safe to call on every request — discovery is cheap and the patch\n * itself is applied once per prototype per isolate.\n *\n * Never throws: a runtime that does not permit the patch simply goes unrecorded.\n */\nexport const installKvPatch = (\n env: unknown,\n options?: InstallKvPatchOptions,\n): void => {\n try {\n for (const { name, value, skip } of kvBindings(\n env,\n options?.skipBindings,\n )) {\n // Patch even a skipped namespace's methods: they are shared with every other namespace,\n // so seeding them is what gives those coverage. Skipping is enforced by leaving the\n // instance off the allow-list below, not by declining to patch.\n patchKvMethods(value);\n if (!skip) {\n kvBindingNames.set(value, name);\n }\n }\n } catch (error) {\n warnOnce(\n \"kv-patch-install\",\n \"Failed to install the Meticulous KV patch — KV operations will not be recorded.\",\n error,\n );\n }\n};\n\ninterface DiscoveredKvBinding {\n name: string;\n value: object;\n skip: boolean;\n}\n\nconst kvBindings = (\n env: unknown,\n skipBindings: readonly string[] | undefined,\n): DiscoveredKvBinding[] => {\n if (env === null || typeof env !== \"object\") {\n return [];\n }\n const skipped = new Set<string>(skipBindings ?? []);\n const found: DiscoveredKvBinding[] = [];\n for (const [name, value] of Object.entries(env)) {\n if (isKvNamespaceLike(value)) {\n found.push({ name, value, skip: skipped.has(name) });\n }\n }\n return found;\n};\n\nconst isKvNamespaceLike = (value: unknown): value is object & KvNamespaceLike =>\n value !== null &&\n typeof value === \"object\" &&\n RECORDED_KV_METHODS.every(\n (method) =>\n typeof (value as Record<string, unknown>)[method] === \"function\",\n );\n\n/**\n * Patches every recorded method, or none of them: a half-patched namespace would record a\n * partial view of the app's KV usage, which is worse than recording none of it, because\n * nothing in the recording would say which half is missing. So the whole chain is resolved\n * first, and only then patched.\n *\n * Each method is patched where it actually lives rather than on the instance's immediate\n * prototype — workerd already puts an inherited method further up the chain for Durable\n * Object stubs, so assuming one flat prototype is not safe.\n */\nconst patchKvMethods = (binding: object): void => {\n const owners = new Map<KvOperation, MethodOwner>();\n for (const method of RECORDED_KV_METHODS) {\n const owner = findMethodOwner(binding, method);\n if (owner === undefined) {\n // Either the method is an own per-instance property or the runtime has locked the\n // prototype. Recording KV is not possible; everything else keeps working.\n warnOnce(\n \"kv-patch-unsupported\",\n `This runtime does not expose a patchable KV \\`${method}\\` — KV operations will not be recorded.`,\n );\n return;\n }\n owners.set(method, owner);\n }\n\n for (const [method, { owner, original }] of owners) {\n if ((original as unknown as Record<symbol, unknown>)[KV_PATCHED] === true) {\n continue;\n }\n Object.defineProperty(owner, method, {\n value: patchedKvMethod(method, original),\n writable: true,\n enumerable: false,\n configurable: true,\n });\n }\n};\n\ninterface MethodOwner {\n owner: object;\n original: KvMethod;\n}\n\n/**\n * The prototype whose `method` a call on `instance` would reach, or undefined if there is none\n * or it cannot be replaced. The search stops at the first prototype that *has* the property,\n * patchable or not: that is the one a call resolves to, so anything further up is shadowed.\n */\nconst findMethodOwner = (\n instance: object,\n method: KvOperation,\n): MethodOwner | undefined => {\n if (Object.hasOwn(instance, method)) {\n // An own per-instance method would shadow anything we patched on the prototype.\n return undefined;\n }\n let current: object | null = Object.getPrototypeOf(instance) as object | null;\n while (current !== null) {\n const descriptor = Object.getOwnPropertyDescriptor(current, method);\n if (descriptor !== undefined) {\n return typeof descriptor.value === \"function\" &&\n descriptor.configurable === true\n ? { owner: current, original: descriptor.value as KvMethod }\n : undefined;\n }\n current = Object.getPrototypeOf(current) as object | null;\n }\n return undefined;\n};\n\n/**\n * A `function`, not an arrow: `this` is the namespace the call was made on, and it must be\n * forwarded to the original or workerd throws \"Illegal invocation\".\n */\nconst patchedKvMethod = (\n operation: KvOperation,\n original: KvMethod,\n): KvMethod => {\n const patched = function (\n this: unknown,\n ...args: unknown[]\n ): Promise<unknown> {\n const passThrough = (): Promise<unknown> => original.apply(this, args);\n\n const ctx = requestCaptureContext.getStore();\n // Record mode only. `installKvPatch` is not called for a replay request, but the patch is\n // per-isolate: a record request could have installed it earlier, and no mock store serves\n // KV, so a replay request must reach the real namespace.\n if (!ctx || ctx.mode !== \"record\") {\n return passThrough();\n }\n const receiver =\n typeof this === \"object\" && this !== null ? this : undefined;\n // Only namespaces discovered on `env` are recorded — see `kvBindingNames`.\n const bindingName =\n receiver === undefined ? undefined : kvBindingNames.get(receiver);\n if (bindingName === undefined) {\n return passThrough();\n }\n\n return captureKvOperation(ctx, operation, bindingName, args, passThrough);\n };\n Object.defineProperty(patched, KV_PATCHED, {\n value: true,\n enumerable: false,\n configurable: true,\n });\n return patched;\n};\n\n/**\n * Records an operation without affecting it: the real method runs untouched and its result is\n * returned as-is, with the event reported in the background via `ctx.waitUntil`. Any capture\n * failure is warned about once and swallowed — recording must never break the app.\n */\nconst captureKvOperation = async (\n ctx: RequestCaptureContext,\n operation: KvOperation,\n bindingName: string,\n args: unknown[],\n invoke: () => Promise<unknown>,\n): Promise<unknown> => {\n const startTimeMs = Date.now();\n let result: unknown;\n try {\n result = await invoke();\n } catch (error) {\n report(ctx, operation, bindingName, args, startTimeMs, {\n error: String(error),\n });\n throw error;\n }\n report(ctx, operation, bindingName, args, startTimeMs, { result });\n return result;\n};\n\nconst report = (\n ctx: RequestCaptureContext,\n operation: KvOperation,\n bindingName: string,\n args: unknown[],\n startTimeMs: number,\n outcome: { result: unknown } | { error: string },\n): void => {\n try {\n const event = buildKvEvent(\n ctx,\n operation,\n bindingName,\n args,\n startTimeMs,\n outcome,\n );\n // postCaptureEvents never rejects — it warns once on an unreachable sidecar.\n ctx.waitUntil(\n postCaptureEvents(getOriginalFetch(), ctx.sidecarUrl, [event]),\n );\n } catch (error) {\n warnOnce(\"kv-report\", \"Failed to report a KV operation.\", error);\n }\n};\n\nconst buildKvEvent = (\n ctx: RequestCaptureContext,\n operation: KvOperation,\n bindingName: string,\n args: unknown[],\n startTimeMs: number,\n outcome: { result: unknown } | { error: string },\n): KvOperationEvent => {\n const [key] = args;\n const written =\n operation === \"put\" ? serializeWrittenValue(args[1]) : undefined;\n const read =\n \"result\" in outcome ? serializeKvValue(outcome.result) : undefined;\n const omitted = written?.omitted ?? read?.omitted;\n const serializedArgs = serializeKvArgs(operation, args);\n\n return {\n kind: \"kv\",\n requestId: ctx.requestId,\n ...(ctx.frontendSessionId !== undefined\n ? { frontendSessionId: ctx.frontendSessionId }\n : {}),\n bindingName,\n operation,\n ...(typeof key === \"string\" ? { key } : {}),\n ...(serializedArgs !== undefined ? { args: serializedArgs } : {}),\n ...(written?.captured !== undefined ? { value: written.captured } : {}),\n ...(read?.captured !== undefined ? { result: read.captured } : {}),\n ...(omitted !== undefined ? { omitted } : {}),\n startTimeMs,\n endTimeMs: Date.now(),\n ...(\"error\" in outcome ? { error: outcome.error } : {}),\n };\n};\n\ninterface SerializedKvValue {\n captured?: CapturedBody;\n omitted?: KvOmittedReason;\n}\n\n/**\n * JSON of a value workerd returned, with anything unpersistable replaced by `null` and the\n * reason reported alongside. A `Map` (what a bulk `get` returns) becomes a plain object,\n * which `JSON.stringify` would otherwise flatten to `{}`.\n *\n * Read values are stored verbatim, deliberately: a future replay would serve them straight\n * back to the app, so redacting one would corrupt what the app receives. Only the write path\n * ({@link serializeWrittenValue}) is redacted — nothing ever replays a `put`.\n */\nconst serializeKvValue = (value: unknown): SerializedKvValue => {\n let omitted: KvOmittedReason | undefined;\n try {\n const json = JSON.stringify(value, (_key: string, nested: unknown) => {\n const reason = omittedReason(nested);\n if (reason !== undefined) {\n omitted ??= reason;\n return null;\n }\n return nested instanceof Map\n ? Object.fromEntries(nested as Map<string, unknown>)\n : nested;\n });\n return {\n // `undefined` (what `put`/`delete` resolve to) has no JSON form — nothing to capture.\n ...(json !== undefined ? { captured: capture(json) } : {}),\n ...(omitted !== undefined ? { omitted } : {}),\n };\n } catch {\n // Circular, or a getter that throws. The operation itself is still recorded.\n return {};\n }\n};\n\n/**\n * JSON of the value a `put` wrote, redacted like a request body. KV takes a string, an\n * ArrayBuffer(View) or a stream; only the string form can be captured, and it is redacted as\n * text before being JSON-encoded so that secret-looking fields *inside* the app's payload are\n * caught (redacting the JSON-encoded form would only ever see one opaque string).\n */\nconst serializeWrittenValue = (value: unknown): SerializedKvValue => {\n if (typeof value === \"string\") {\n try {\n return { captured: capture(JSON.stringify(redactRequestBody(value))) };\n } catch {\n return {};\n }\n }\n return serializeKvValue(value);\n};\n\n/**\n * JSON of the call's arguments. A `put` value is replaced by `null` rather than dropped, so\n * the remaining arguments keep their positions — and so the value is only ever persisted via\n * the redacted {@link serializeWrittenValue} path.\n */\nconst serializeKvArgs = (\n operation: KvOperation,\n args: unknown[],\n): CapturedBody | undefined => {\n const withoutValue =\n operation === \"put\"\n ? args.map((arg, index) => (index === 1 ? null : arg))\n : args;\n return serializeKvValue(withoutValue).captured;\n};\n\nconst omittedReason = (value: unknown): KvOmittedReason | undefined => {\n // A stream is never read, only recognised: reading it would consume the app's bytes.\n if (\n typeof ReadableStream !== \"undefined\" &&\n value instanceof ReadableStream\n ) {\n return \"stream\";\n }\n if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {\n return \"binary\";\n }\n return undefined;\n};\n\nconst capture = (json: string): CapturedBody =>\n json.length > MAX_BODY_CAPTURE_SIZE\n ? { body: json.slice(0, MAX_BODY_CAPTURE_SIZE), truncated: true }\n : { body: json, truncated: false };\n"],"names":[],"mappings":"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","debugId":"93bf09ea-4e78-5ac4-92cd-661f2ce03542"}
1
+ {"version":3,"file":"kv-patch.js","sources":["../src/kv-patch.ts"],"sourcesContent":["import { type RequestCaptureContext, requestCaptureContext } from \"./context\";\nimport { type KvCaptureOutcome, serializeKvCaptureFields } from \"./kv-capture\";\nimport { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport type { KvOperation, KvOperationEvent } from \"./protocol\";\nimport { postCaptureEvents } from \"./sidecar-client\";\n\n/**\n * Records operations on Cloudflare KV namespace bindings (`env.MY_KV.get(key)`).\n *\n * KV is invisible to both the `globalThis.fetch` patch and the binding patch: a namespace is\n * a host object with no `fetch`, and workerd services its methods internally rather than as\n * a subrequest. So the namespace's *prototype* methods are patched instead — the same move\n * `binding-patch.ts` makes on a Fetcher, and the Node recorder on `Redis.prototype.sendCommand`.\n *\n * The prototype is reached from an instance found on `env`: as with bindings there is no\n * global to name, and going through an instance is what makes this work for an app that reads\n * its bindings from `cloudflare:workers` rather than the handler argument — that module hands\n * out a different `env` object but the same namespace instances, so the prototype is shared\n * either way.\n *\n * Verified against real workerd in `packages/backend-recorder-js/src/__tests__/`:\n * `workerd-binding-patch.spec.ts` pins the property shape this relies on (every recorded\n * method inherited and configurable, one prototype across namespaces), and\n * `workerd-kv-sidecar.spec.ts` drives real KV through the patch end to end.\n */\n\n// Marks a patched method, rather than the prototype holding it: methods can live at different\n// depths of one chain, and a base prototype can be shared with another binding type, so\n// \"already ours\" has to be answerable per function. Symbol.for so a second copy of the shim\n// (e.g. bundled twice) still detects the patch.\nconst KV_PATCHED = Symbol.for(\"meticulous.workerd.kvPatched\");\n\n/**\n * The methods recorded, and the shape a KV namespace is recognised by.\n *\n * `getWithMetadata` is what separates KV from R2: an R2 bucket also has `get`, `put`,\n * `delete` and `list`, so without it a bucket would be duck-typed as a namespace and its\n * (large, binary) objects pushed through value capture.\n */\nconst RECORDED_KV_METHODS = [\n \"get\",\n \"getWithMetadata\",\n \"put\",\n \"delete\",\n \"list\",\n] as const;\n\n/**\n * The namespaces being recorded, and the `env` key each was found under.\n *\n * Doubles as the allow-list: only an instance in here is recorded. A skipped namespace is\n * simply never registered, and — since the patch can land on a prototype shared with another\n * binding type — nothing that is not a discovered KV namespace can be recorded by accident.\n */\nconst kvBindingNames = new WeakMap<object, string>();\n\ntype KvMethod = (...args: unknown[]) => Promise<unknown>;\n\ntype KvNamespaceLike = Record<KvOperation, KvMethod>;\n\nexport interface InstallKvPatchOptions {\n /** Binding names to leave unrecorded. */\n skipBindings?: readonly string[];\n}\n\n/**\n * Discovers KV namespace bindings on `env` and patches their shared prototype so their\n * operations are recorded. Safe to call on every request — discovery is cheap and the patch\n * itself is applied once per prototype per isolate.\n *\n * Never throws: a runtime that does not permit the patch simply goes unrecorded.\n */\nexport const installKvPatch = (\n env: unknown,\n options?: InstallKvPatchOptions,\n): void => {\n try {\n for (const { name, value, skip } of kvBindings(\n env,\n options?.skipBindings,\n )) {\n // Patch even a skipped namespace's methods: they are shared with every other namespace,\n // so seeding them is what gives those coverage. Skipping is enforced by leaving the\n // instance off the allow-list below, not by declining to patch.\n patchKvMethods(value);\n if (!skip) {\n kvBindingNames.set(value, name);\n }\n }\n } catch (error) {\n warnOnce(\n \"kv-patch-install\",\n \"Failed to install the Meticulous KV patch — KV operations will not be recorded.\",\n error,\n );\n }\n};\n\ninterface DiscoveredKvBinding {\n name: string;\n value: object;\n skip: boolean;\n}\n\nconst kvBindings = (\n env: unknown,\n skipBindings: readonly string[] | undefined,\n): DiscoveredKvBinding[] => {\n if (env === null || typeof env !== \"object\") {\n return [];\n }\n const skipped = new Set<string>(skipBindings ?? []);\n const found: DiscoveredKvBinding[] = [];\n for (const [name, value] of Object.entries(env)) {\n if (isKvNamespaceLike(value)) {\n found.push({ name, value, skip: skipped.has(name) });\n }\n }\n return found;\n};\n\nconst isKvNamespaceLike = (value: unknown): value is object & KvNamespaceLike =>\n value !== null &&\n typeof value === \"object\" &&\n RECORDED_KV_METHODS.every(\n (method) =>\n typeof (value as Record<string, unknown>)[method] === \"function\",\n );\n\n/**\n * Patches every recorded method, or none of them: a half-patched namespace would record a\n * partial view of the app's KV usage, which is worse than recording none of it, because\n * nothing in the recording would say which half is missing. So the whole chain is resolved\n * first, and only then patched.\n *\n * Each method is patched where it actually lives rather than on the instance's immediate\n * prototype — workerd already puts an inherited method further up the chain for Durable\n * Object stubs, so assuming one flat prototype is not safe.\n */\nconst patchKvMethods = (binding: object): void => {\n const owners = new Map<KvOperation, MethodOwner>();\n for (const method of RECORDED_KV_METHODS) {\n const owner = findMethodOwner(binding, method);\n if (owner === undefined) {\n // Either the method is an own per-instance property or the runtime has locked the\n // prototype. Recording KV is not possible; everything else keeps working.\n warnOnce(\n \"kv-patch-unsupported\",\n `This runtime does not expose a patchable KV \\`${method}\\` — KV operations will not be recorded.`,\n );\n return;\n }\n owners.set(method, owner);\n }\n\n for (const [method, { owner, original }] of owners) {\n if ((original as unknown as Record<symbol, unknown>)[KV_PATCHED] === true) {\n continue;\n }\n Object.defineProperty(owner, method, {\n value: patchedKvMethod(method, original),\n writable: true,\n enumerable: false,\n configurable: true,\n });\n }\n};\n\ninterface MethodOwner {\n owner: object;\n original: KvMethod;\n}\n\n/**\n * The prototype whose `method` a call on `instance` would reach, or undefined if there is none\n * or it cannot be replaced. The search stops at the first prototype that *has* the property,\n * patchable or not: that is the one a call resolves to, so anything further up is shadowed.\n */\nconst findMethodOwner = (\n instance: object,\n method: KvOperation,\n): MethodOwner | undefined => {\n if (Object.hasOwn(instance, method)) {\n // An own per-instance method would shadow anything we patched on the prototype.\n return undefined;\n }\n let current: object | null = Object.getPrototypeOf(instance) as object | null;\n while (current !== null) {\n const descriptor = Object.getOwnPropertyDescriptor(current, method);\n if (descriptor !== undefined) {\n return typeof descriptor.value === \"function\" &&\n descriptor.configurable === true\n ? { owner: current, original: descriptor.value as KvMethod }\n : undefined;\n }\n current = Object.getPrototypeOf(current) as object | null;\n }\n return undefined;\n};\n\n/**\n * A `function`, not an arrow: `this` is the namespace the call was made on, and it must be\n * forwarded to the original or workerd throws \"Illegal invocation\".\n */\nconst patchedKvMethod = (\n operation: KvOperation,\n original: KvMethod,\n): KvMethod => {\n const patched = function (\n this: unknown,\n ...args: unknown[]\n ): Promise<unknown> {\n const passThrough = (): Promise<unknown> => original.apply(this, args);\n\n const ctx = requestCaptureContext.getStore();\n // Record mode only. `installKvPatch` is not called for a replay request, but the patch is\n // per-isolate: a record request could have installed it earlier, and no mock store serves\n // KV, so a replay request must reach the real namespace.\n if (!ctx || ctx.mode !== \"record\") {\n return passThrough();\n }\n const receiver =\n typeof this === \"object\" && this !== null ? this : undefined;\n // Only namespaces discovered on `env` are recorded — see `kvBindingNames`.\n const bindingName =\n receiver === undefined ? undefined : kvBindingNames.get(receiver);\n if (bindingName === undefined) {\n return passThrough();\n }\n\n return captureKvOperation(ctx, operation, bindingName, args, passThrough);\n };\n Object.defineProperty(patched, KV_PATCHED, {\n value: true,\n enumerable: false,\n configurable: true,\n });\n return patched;\n};\n\n/**\n * Records an operation without affecting it: the real method runs untouched and its result is\n * returned as-is, with the event reported in the background via `ctx.waitUntil`. Any capture\n * failure is warned about once and swallowed — recording must never break the app.\n */\nconst captureKvOperation = async (\n ctx: RequestCaptureContext,\n operation: KvOperation,\n bindingName: string,\n args: unknown[],\n invoke: () => Promise<unknown>,\n): Promise<unknown> => {\n const startTimeMs = Date.now();\n let result: unknown;\n try {\n result = await invoke();\n } catch (error) {\n report(ctx, operation, bindingName, args, startTimeMs, {\n error: String(error),\n });\n throw error;\n }\n report(ctx, operation, bindingName, args, startTimeMs, { result });\n return result;\n};\n\nconst report = (\n ctx: RequestCaptureContext,\n operation: KvOperation,\n bindingName: string,\n args: unknown[],\n startTimeMs: number,\n outcome: KvCaptureOutcome,\n): void => {\n try {\n const event = buildKvEvent(\n ctx,\n operation,\n bindingName,\n args,\n startTimeMs,\n outcome,\n );\n // postCaptureEvents never rejects — it warns once on an unreachable sidecar.\n ctx.waitUntil(\n postCaptureEvents(getOriginalFetch(), ctx.sidecarUrl, [event]),\n );\n } catch (error) {\n warnOnce(\"kv-report\", \"Failed to report a KV operation.\", error);\n }\n};\n\nconst buildKvEvent = (\n ctx: RequestCaptureContext,\n operation: KvOperation,\n bindingName: string,\n args: unknown[],\n startTimeMs: number,\n outcome: KvCaptureOutcome,\n): KvOperationEvent => ({\n kind: \"kv\",\n requestId: ctx.requestId,\n ...(ctx.frontendSessionId !== undefined\n ? { frontendSessionId: ctx.frontendSessionId }\n : {}),\n bindingName,\n operation,\n ...serializeKvCaptureFields(operation, args, outcome),\n startTimeMs,\n endTimeMs: Date.now(),\n ...(\"error\" in outcome ? { error: outcome.error } : {}),\n});\n"],"names":[],"mappings":"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","debugId":"b871e5bd-a858-5783-9618-2ec0bd2a52bf"}
@@ -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]="ef892b9a-fcab-5f5d-baed-592098a17dd5")}catch(e){}}();
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]="cebf437b-2463-5219-a284-882488673f5e")}catch(e){}}();
3
3
  import { requestCaptureContext } from "./context.js";
4
4
  import { warnOnce } from "./log.js";
5
5
  //#region src/virtual-clock.ts
@@ -23,6 +23,18 @@ import { warnOnce } from "./log.js";
23
23
  * `Temporal`, no timer virtualisation).
24
24
  */
25
25
  const OriginalDate = globalThis.Date;
26
+ /**
27
+ * The frozen time a replayed request with no anchor gets: 13 May 2026, World Cocktail Day.
28
+ * A replay whose anchor is missing is already degraded, and live time there is the exact
29
+ * non-determinism this file exists to remove — two replays of that session would render
30
+ * different timestamps. A fixed recent date rather than the epoch, since app code routinely
31
+ * renders or rejects an implausibly old timestamp differently. Matches the Node recorder's
32
+ * unattributed fallback (`replay/virtual-clock.ts`).
33
+ *
34
+ * Exported for the tests: this is the time such a replay renders, so changing it changes
35
+ * that replay's output.
36
+ */
37
+ const UNANCHORED_BASE_TIME_MS = OriginalDate.UTC(2026, 4, 13);
26
38
  const BREAK_AFTER = 2e4;
27
39
  let lastLoopBreakBaseMs = 0;
28
40
  let sameKeyCount = 0;
@@ -47,14 +59,16 @@ const applyLoopBreak = (baseMs) => {
47
59
  return sameKeyCount <= BREAK_AFTER ? baseMs : baseMs + (sameKeyCount - BREAK_AFTER);
48
60
  };
49
61
  /**
50
- * The virtual "now" for the request currently executing, or null when it is not a replay or
51
- * carries no anchor — in which case callers use the real clock. Reads outside any request
52
- * (module evaluation, background work) always get the real clock.
62
+ * The virtual "now" for the request currently executing, or null when this is not a replayed
63
+ * request — a recording, a deployed production worker, or a read outside any request (module
64
+ * evaluation, background work) — in which case callers use the real clock. A replayed request
65
+ * always gets a frozen time: its own anchor, or {@link UNANCHORED_BASE_TIME_MS} if it carries
66
+ * none.
53
67
  */
54
68
  const resolveVirtualNow = () => {
55
69
  const ctx = requestCaptureContext.getStore();
56
70
  if (ctx === void 0 || ctx.mode !== "replay") return null;
57
- return ctx.clockAnchorMs === void 0 ? null : applyLoopBreak(ctx.clockAnchorMs);
71
+ return applyLoopBreak(ctx.clockAnchorMs ?? UNANCHORED_BASE_TIME_MS);
58
72
  };
59
73
  /**
60
74
  * What `Date.now()` reports. A standalone function rather than only a class static, so the
@@ -119,4 +133,4 @@ const installVirtualClock = () => {
119
133
  export { installVirtualClock };
120
134
 
121
135
  //# sourceMappingURL=virtual-clock.js.map
122
- //# debugId=ef892b9a-fcab-5f5d-baed-592098a17dd5
136
+ //# debugId=cebf437b-2463-5219-a284-882488673f5e
@@ -1 +1 @@
1
- {"version":3,"file":"virtual-clock.js","sources":["../src/virtual-clock.ts"],"sourcesContent":["import { requestCaptureContext } from \"./context\";\nimport { warnOnce } from \"./log\";\n\n/**\n * Freezes the worker's wall clock at the replayed session's last recorded activity.\n *\n * Without this, replay fails even when every outbound call is mocked correctly: credentials\n * minted during the recording (Firebase session cookies, OAuth access tokens) carry\n * short expiries, so by replay time the app rejects its own recorded credentials as stale.\n * Anchoring at the *end* of the recording means everything minted mid-session is already\n * issued but not yet expired.\n *\n * Ported from the Node backend recorder's replay clock, with two differences:\n * - the anchor comes from the request context rather than a session→anchor map, because\n * workerd's AsyncLocalStorage propagates reliably from the inbound handler. The Node\n * version needs a process-wide \"most recently seen session\" fallback, which can\n * resolve the wrong session on a replica serving several at once;\n * - the busy-wait breaker schedules on `setTimeout` — workerd has no `setImmediate`.\n *\n * Only `Date` is patched, matching the Node implementation's scope (no `Intl`, no\n * `Temporal`, no timer virtualisation).\n */\n\nconst OriginalDate = globalThis.Date;\n\n/** Real wall clock, immune to the patch below. */\nexport const nativeDateNow = (): number => OriginalDate.now();\n\n// ---------------------------------------------------------------------------\n// Busy-wait loop breaker\n// ---------------------------------------------------------------------------\n\n// A frozen clock makes `while (Date.now() < deadline) {}` spin forever. Count reads that\n// return the same frozen value and, past the threshold, nudge the result forward 1ms per\n// extra read until the loop's deadline is met. The nudge is a pure function of the repeat\n// count, so a base and head replay break an identical loop identically.\nconst BREAK_AFTER = 20_000;\n\nlet lastLoopBreakBaseMs = 0;\nlet sameKeyCount = 0;\nlet loopBreakResetScheduled = false;\n\nconst applyLoopBreak = (baseMs: number): number => {\n // Reset at the end of the current macrotask. A synchronous busy-wait blocks the loop so\n // this cannot fire, letting the counter accumulate until it breaks the spin; once the\n // turn yields, the reset runs and the next turn starts fresh.\n if (!loopBreakResetScheduled) {\n loopBreakResetScheduled = true;\n try {\n setTimeout(() => {\n sameKeyCount = 0;\n loopBreakResetScheduled = false;\n }, 0);\n } catch {\n // Timers throw outside a request context in workerd. Nothing to reset in that case,\n // since clock reads there fall through to the real clock anyway.\n loopBreakResetScheduled = false;\n }\n }\n\n if (baseMs === lastLoopBreakBaseMs) {\n sameKeyCount++;\n } else {\n lastLoopBreakBaseMs = baseMs;\n sameKeyCount = 0;\n }\n return sameKeyCount <= BREAK_AFTER\n ? baseMs\n : baseMs + (sameKeyCount - BREAK_AFTER);\n};\n\n// ---------------------------------------------------------------------------\n// Resolver\n// ---------------------------------------------------------------------------\n\n/**\n * The virtual \"now\" for the request currently executing, or null when it is not a replay or\n * carries no anchor — in which case callers use the real clock. Reads outside any request\n * (module evaluation, background work) always get the real clock.\n */\nconst resolveVirtualNow = (): number | null => {\n const ctx = requestCaptureContext.getStore();\n if (ctx === undefined || ctx.mode !== \"replay\") {\n return null;\n }\n return ctx.clockAnchorMs === undefined\n ? null\n : applyLoopBreak(ctx.clockAnchorMs);\n};\n\n// ---------------------------------------------------------------------------\n// Date shim\n// ---------------------------------------------------------------------------\n\n/**\n * What `Date.now()` reports. A standalone function rather than only a class static, so the\n * callable shim below can reuse it without referencing an unbound method — it touches no\n * `this`, so there is nothing to lose.\n */\nconst virtualDateNow = (): number => {\n const virtualNow = resolveVirtualNow();\n return virtualNow === null ? OriginalDate.now() : virtualNow;\n};\n\n// `Date` is heavily overloaded; capture args loosely and forward every explicit form\n// unchanged. Only the zero-arg form reads \"now\" and is virtualized.\nclass VirtualDateClass extends OriginalDate {\n constructor(...args: unknown[]) {\n if (args.length > 0) {\n super(...(args as ConstructorParameters<typeof OriginalDate>));\n return;\n }\n const virtualNow = resolveVirtualNow();\n if (virtualNow === null) {\n super();\n return;\n }\n super(virtualNow);\n }\n\n static override now(): number {\n return virtualDateNow();\n }\n}\n\n// Classes cannot be called without `new`, but native `Date()` can be (returning a string),\n// so wrap the class in a plain function supporting both.\nfunction VirtualDate(this: unknown, ...args: unknown[]): Date | string {\n if (!(this instanceof VirtualDate)) {\n return new VirtualDateClass().toString();\n }\n return new VirtualDateClass(...args);\n}\n\nVirtualDate.now = virtualDateNow;\nVirtualDate.parse = OriginalDate.parse;\nVirtualDate.UTC = OriginalDate.UTC;\n\n// Share the prototype so `new Date() instanceof Date` holds and every Date.prototype\n// method resolves on instances.\nObject.defineProperty(VirtualDate, \"prototype\", {\n value: VirtualDateClass.prototype,\n writable: false,\n configurable: false,\n});\n(VirtualDateClass.prototype as { constructor: unknown }).constructor =\n VirtualDate;\n\n// ---------------------------------------------------------------------------\n// Install\n// ---------------------------------------------------------------------------\n\nlet installed = false;\n\n/**\n * Replaces the global `Date` with the per-request virtual clock. Idempotent, and a no-op\n * failure: a worker that will not let its `Date` be replaced still replays, just without\n * clock virtualisation.\n *\n * Called lazily from the replay branch of the request wrapper rather than at module scope:\n * record mode and deployed production workers then pay nothing, and the package declares no\n * `sideEffects`, so a module-scope side effect could legitimately be tree-shaken away by a\n * customer's bundler.\n */\nexport const installVirtualClock = (): void => {\n if (installed) {\n return;\n }\n installed = true;\n try {\n globalThis.Date = VirtualDate as unknown as DateConstructor;\n } catch (error) {\n warnOnce(\n \"virtual-clock\",\n \"Could not install the Meticulous replay clock — recorded credentials may be treated as expired.\",\n error,\n );\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAuBA,MAAM,eAAe,WAAW;AAahC,MAAM,cAAc;AAEpB,IAAI,sBAAsB;AAC1B,IAAI,eAAe;AACnB,IAAI,0BAA0B;AAE9B,MAAM,kBAAkB,WAA2B;AAIjD,KAAI,CAAC,yBAAyB;AAC5B,4BAA0B;AAC1B,MAAI;AACF,oBAAiB;AACf,mBAAe;AACf,8BAA0B;MACzB,EAAE;UACC;AAGN,6BAA0B;;;AAI9B,KAAI,WAAW,oBACb;MACK;AACL,wBAAsB;AACtB,iBAAe;;AAEjB,QAAO,gBAAgB,cACnB,SACA,UAAU,eAAe;;;;;;;AAY/B,MAAM,0BAAyC;CAC7C,MAAM,MAAM,sBAAsB,UAAU;AAC5C,KAAI,QAAQ,KAAA,KAAa,IAAI,SAAS,SACpC,QAAO;AAET,QAAO,IAAI,kBAAkB,KAAA,IACzB,OACA,eAAe,IAAI,cAAc;;;;;;;AAYvC,MAAM,uBAA+B;CACnC,MAAM,aAAa,mBAAmB;AACtC,QAAO,eAAe,OAAO,aAAa,KAAK,GAAG;;AAKpD,IAAM,mBAAN,cAA+B,aAAa;CAC1C,YAAY,GAAG,MAAiB;AAC9B,MAAI,KAAK,SAAS,GAAG;AACnB,SAAM,GAAI,KAAoD;AAC9D;;EAEF,MAAM,aAAa,mBAAmB;AACtC,MAAI,eAAe,MAAM;AACvB,UAAO;AACP;;AAEF,QAAM,WAAW;;CAGnB,OAAgB,MAAc;AAC5B,SAAO,gBAAgB;;;AAM3B,SAAS,YAA2B,GAAG,MAAgC;AACrE,KAAI,EAAE,gBAAgB,aACpB,QAAO,IAAI,kBAAkB,CAAC,UAAU;AAE1C,QAAO,IAAI,iBAAiB,GAAG,KAAK;;AAGtC,YAAY,MAAM;AAClB,YAAY,QAAQ,aAAa;AACjC,YAAY,MAAM,aAAa;AAI/B,OAAO,eAAe,aAAa,aAAa;CAC9C,OAAO,iBAAiB;CACxB,UAAU;CACV,cAAc;CACf,CAAC;AACF,iBAAkB,UAAuC,cACvD;AAMF,IAAI,YAAY;;;;;;;;;;;AAYhB,MAAa,4BAAkC;AAC7C,KAAI,UACF;AAEF,aAAY;AACZ,KAAI;AACF,aAAW,OAAO;UACX,OAAO;AACd,WACE,iBACA,mGACA,MACD","debugId":"ef892b9a-fcab-5f5d-baed-592098a17dd5"}
1
+ {"version":3,"file":"virtual-clock.js","sources":["../src/virtual-clock.ts"],"sourcesContent":["import { requestCaptureContext } from \"./context\";\nimport { warnOnce } from \"./log\";\n\n/**\n * Freezes the worker's wall clock at the replayed session's last recorded activity.\n *\n * Without this, replay fails even when every outbound call is mocked correctly: credentials\n * minted during the recording (Firebase session cookies, OAuth access tokens) carry\n * short expiries, so by replay time the app rejects its own recorded credentials as stale.\n * Anchoring at the *end* of the recording means everything minted mid-session is already\n * issued but not yet expired.\n *\n * Ported from the Node backend recorder's replay clock, with two differences:\n * - the anchor comes from the request context rather than a session→anchor map, because\n * workerd's AsyncLocalStorage propagates reliably from the inbound handler. The Node\n * version needs a process-wide \"most recently seen session\" fallback, which can\n * resolve the wrong session on a replica serving several at once;\n * - the busy-wait breaker schedules on `setTimeout` — workerd has no `setImmediate`.\n *\n * Only `Date` is patched, matching the Node implementation's scope (no `Intl`, no\n * `Temporal`, no timer virtualisation).\n */\n\nconst OriginalDate = globalThis.Date;\n\n/** Real wall clock, immune to the patch below. */\nexport const nativeDateNow = (): number => OriginalDate.now();\n\n/**\n * The frozen time a replayed request with no anchor gets: 13 May 2026, World Cocktail Day.\n * A replay whose anchor is missing is already degraded, and live time there is the exact\n * non-determinism this file exists to remove — two replays of that session would render\n * different timestamps. A fixed recent date rather than the epoch, since app code routinely\n * renders or rejects an implausibly old timestamp differently. Matches the Node recorder's\n * unattributed fallback (`replay/virtual-clock.ts`).\n *\n * Exported for the tests: this is the time such a replay renders, so changing it changes\n * that replay's output.\n */\nexport const UNANCHORED_BASE_TIME_MS = OriginalDate.UTC(2026, 4, 13);\n\n// ---------------------------------------------------------------------------\n// Busy-wait loop breaker\n// ---------------------------------------------------------------------------\n\n// A frozen clock makes `while (Date.now() < deadline) {}` spin forever. Count reads that\n// return the same frozen value and, past the threshold, nudge the result forward 1ms per\n// extra read until the loop's deadline is met. The nudge is a pure function of the repeat\n// count, so a base and head replay break an identical loop identically.\nconst BREAK_AFTER = 20_000;\n\nlet lastLoopBreakBaseMs = 0;\nlet sameKeyCount = 0;\nlet loopBreakResetScheduled = false;\n\nconst applyLoopBreak = (baseMs: number): number => {\n // Reset at the end of the current macrotask. A synchronous busy-wait blocks the loop so\n // this cannot fire, letting the counter accumulate until it breaks the spin; once the\n // turn yields, the reset runs and the next turn starts fresh.\n if (!loopBreakResetScheduled) {\n loopBreakResetScheduled = true;\n try {\n setTimeout(() => {\n sameKeyCount = 0;\n loopBreakResetScheduled = false;\n }, 0);\n } catch {\n // Timers throw outside a request context in workerd. Nothing to reset in that case,\n // since clock reads there fall through to the real clock anyway.\n loopBreakResetScheduled = false;\n }\n }\n\n if (baseMs === lastLoopBreakBaseMs) {\n sameKeyCount++;\n } else {\n lastLoopBreakBaseMs = baseMs;\n sameKeyCount = 0;\n }\n return sameKeyCount <= BREAK_AFTER\n ? baseMs\n : baseMs + (sameKeyCount - BREAK_AFTER);\n};\n\n// ---------------------------------------------------------------------------\n// Resolver\n// ---------------------------------------------------------------------------\n\n/**\n * The virtual \"now\" for the request currently executing, or null when this is not a replayed\n * request — a recording, a deployed production worker, or a read outside any request (module\n * evaluation, background work) — in which case callers use the real clock. A replayed request\n * always gets a frozen time: its own anchor, or {@link UNANCHORED_BASE_TIME_MS} if it carries\n * none.\n */\nconst resolveVirtualNow = (): number | null => {\n const ctx = requestCaptureContext.getStore();\n if (ctx === undefined || ctx.mode !== \"replay\") {\n return null;\n }\n return applyLoopBreak(ctx.clockAnchorMs ?? UNANCHORED_BASE_TIME_MS);\n};\n\n// ---------------------------------------------------------------------------\n// Date shim\n// ---------------------------------------------------------------------------\n\n/**\n * What `Date.now()` reports. A standalone function rather than only a class static, so the\n * callable shim below can reuse it without referencing an unbound method — it touches no\n * `this`, so there is nothing to lose.\n */\nconst virtualDateNow = (): number => {\n const virtualNow = resolveVirtualNow();\n return virtualNow === null ? OriginalDate.now() : virtualNow;\n};\n\n// `Date` is heavily overloaded; capture args loosely and forward every explicit form\n// unchanged. Only the zero-arg form reads \"now\" and is virtualized.\nclass VirtualDateClass extends OriginalDate {\n constructor(...args: unknown[]) {\n if (args.length > 0) {\n super(...(args as ConstructorParameters<typeof OriginalDate>));\n return;\n }\n const virtualNow = resolveVirtualNow();\n if (virtualNow === null) {\n super();\n return;\n }\n super(virtualNow);\n }\n\n static override now(): number {\n return virtualDateNow();\n }\n}\n\n// Classes cannot be called without `new`, but native `Date()` can be (returning a string),\n// so wrap the class in a plain function supporting both.\nfunction VirtualDate(this: unknown, ...args: unknown[]): Date | string {\n if (!(this instanceof VirtualDate)) {\n return new VirtualDateClass().toString();\n }\n return new VirtualDateClass(...args);\n}\n\nVirtualDate.now = virtualDateNow;\nVirtualDate.parse = OriginalDate.parse;\nVirtualDate.UTC = OriginalDate.UTC;\n\n// Share the prototype so `new Date() instanceof Date` holds and every Date.prototype\n// method resolves on instances.\nObject.defineProperty(VirtualDate, \"prototype\", {\n value: VirtualDateClass.prototype,\n writable: false,\n configurable: false,\n});\n(VirtualDateClass.prototype as { constructor: unknown }).constructor =\n VirtualDate;\n\n// ---------------------------------------------------------------------------\n// Install\n// ---------------------------------------------------------------------------\n\nlet installed = false;\n\n/**\n * Replaces the global `Date` with the per-request virtual clock. Idempotent, and a no-op\n * failure: a worker that will not let its `Date` be replaced still replays, just without\n * clock virtualisation.\n *\n * Called lazily from the replay branch of the request wrapper rather than at module scope:\n * record mode and deployed production workers then pay nothing, and the package declares no\n * `sideEffects`, so a module-scope side effect could legitimately be tree-shaken away by a\n * customer's bundler.\n */\nexport const installVirtualClock = (): void => {\n if (installed) {\n return;\n }\n installed = true;\n try {\n globalThis.Date = VirtualDate as unknown as DateConstructor;\n } catch (error) {\n warnOnce(\n \"virtual-clock\",\n \"Could not install the Meticulous replay clock — recorded credentials may be treated as expired.\",\n error,\n );\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAuBA,MAAM,eAAe,WAAW;;;;;;;;;;;;AAgBhC,MAAa,0BAA0B,aAAa,IAAI,MAAM,GAAG,GAAG;AAUpE,MAAM,cAAc;AAEpB,IAAI,sBAAsB;AAC1B,IAAI,eAAe;AACnB,IAAI,0BAA0B;AAE9B,MAAM,kBAAkB,WAA2B;AAIjD,KAAI,CAAC,yBAAyB;AAC5B,4BAA0B;AAC1B,MAAI;AACF,oBAAiB;AACf,mBAAe;AACf,8BAA0B;MACzB,EAAE;UACC;AAGN,6BAA0B;;;AAI9B,KAAI,WAAW,oBACb;MACK;AACL,wBAAsB;AACtB,iBAAe;;AAEjB,QAAO,gBAAgB,cACnB,SACA,UAAU,eAAe;;;;;;;;;AAc/B,MAAM,0BAAyC;CAC7C,MAAM,MAAM,sBAAsB,UAAU;AAC5C,KAAI,QAAQ,KAAA,KAAa,IAAI,SAAS,SACpC,QAAO;AAET,QAAO,eAAe,IAAI,iBAAiB,wBAAwB;;;;;;;AAYrE,MAAM,uBAA+B;CACnC,MAAM,aAAa,mBAAmB;AACtC,QAAO,eAAe,OAAO,aAAa,KAAK,GAAG;;AAKpD,IAAM,mBAAN,cAA+B,aAAa;CAC1C,YAAY,GAAG,MAAiB;AAC9B,MAAI,KAAK,SAAS,GAAG;AACnB,SAAM,GAAI,KAAoD;AAC9D;;EAEF,MAAM,aAAa,mBAAmB;AACtC,MAAI,eAAe,MAAM;AACvB,UAAO;AACP;;AAEF,QAAM,WAAW;;CAGnB,OAAgB,MAAc;AAC5B,SAAO,gBAAgB;;;AAM3B,SAAS,YAA2B,GAAG,MAAgC;AACrE,KAAI,EAAE,gBAAgB,aACpB,QAAO,IAAI,kBAAkB,CAAC,UAAU;AAE1C,QAAO,IAAI,iBAAiB,GAAG,KAAK;;AAGtC,YAAY,MAAM;AAClB,YAAY,QAAQ,aAAa;AACjC,YAAY,MAAM,aAAa;AAI/B,OAAO,eAAe,aAAa,aAAa;CAC9C,OAAO,iBAAiB;CACxB,UAAU;CACV,cAAc;CACf,CAAC;AACF,iBAAkB,UAAuC,cACvD;AAMF,IAAI,YAAY;;;;;;;;;;;AAYhB,MAAa,4BAAkC;AAC7C,KAAI,UACF;AAEF,aAAY;AACZ,KAAI;AACF,aAAW,OAAO;UACX,OAAO;AACd,WACE,iBACA,mGACA,MACD","debugId":"cebf437b-2463-5219-a284-882488673f5e"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alwaysmeticulous/backend-recorder-workerd",
3
- "version": "2.323.0",
3
+ "version": "2.325.0",
4
4
  "type": "module",
5
5
  "description": "Meticulous backend recorder shim for Cloudflare Workers (workerd). Reports inbound and outbound HTTP capture events to a local Meticulous recorder sidecar during development recording.",
6
6
  "license": "ISC",