@alwaysmeticulous/backend-recorder-workerd 2.315.0 → 2.321.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/protocol.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * is the single source of truth for the shape of capture events.
6
6
  */
7
7
 
8
- !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]="f28d3e21-60a1-55c4-8586-5db3a0ec194b")}catch(e){}}();
8
+ !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]="532e3e0d-6b1c-51c2-8086-a05de2625e79")}catch(e){}}();
9
9
  const SIDECAR_PROTOCOL_VERSION = "1";
10
10
  /** Header carrying {@link SIDECAR_PROTOCOL_VERSION} on every shim → sidecar request. */
11
11
  const SIDECAR_PROTOCOL_VERSION_HEADER = "x-meticulous-sidecar-protocol-version";
@@ -16,6 +16,22 @@ const SIDECAR_PROTOCOL_VERSION_HEADER = "x-meticulous-sidecar-protocol-version";
16
16
  const FRONTEND_SESSION_ID_HEADER = "x-meticulous-session-id";
17
17
  const SIDECAR_EVENTS_PATH = "/v1/events";
18
18
  /**
19
+ * Replay routes. A sidecar started in record mode answers 404 on both, which the shim
20
+ * treats as "replay unavailable" and falls back to plain pass-through — so a new shim
21
+ * keeps working against an older, record-only sidecar.
22
+ */
23
+ const SIDECAR_REPLAY_SESSION_PATH = "/v1/replay/session";
24
+ const SIDECAR_REPLAY_OUTBOUND_FETCH_PATH = "/v1/replay/outbound-fetch";
25
+ /**
26
+ * Header carrying the replay sidecar's origin, injected by the Meticulous replay runner
27
+ * on requests to the app under test. Workerd cannot see container environment variables,
28
+ * so an inbound header is the only way per-replay config can reach the shim.
29
+ *
30
+ * The shim validates the value before using it and only honours a loopback /
31
+ * `host.docker.internal` / private-network `http:` origin — see `replay-sidecar-url.ts`.
32
+ */
33
+ const REPLAY_SIDECAR_URL_HEADER = "x-meticulous-backend-replay-sidecar-url";
34
+ /**
19
35
  * The only headers persisted on capture events. Everything else — notably
20
36
  * authorization, cookie/set-cookie, and API-key headers — is dropped at
21
37
  * capture time, before it ever leaves the worker. Mirrors the Node backend
@@ -24,7 +40,7 @@ const SIDECAR_EVENTS_PATH = "/v1/events";
24
40
  */
25
41
  const CAPTURED_HEADERS = ["content-type", FRONTEND_SESSION_ID_HEADER];
26
42
  //#endregion
27
- export { CAPTURED_HEADERS, FRONTEND_SESSION_ID_HEADER, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER };
43
+ export { CAPTURED_HEADERS, FRONTEND_SESSION_ID_HEADER, REPLAY_SIDECAR_URL_HEADER, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH };
28
44
 
29
45
  //# sourceMappingURL=protocol.js.map
30
- //# debugId=f28d3e21-60a1-55c4-8586-5db3a0ec194b
46
+ //# debugId=532e3e0d-6b1c-51c2-8086-a05de2625e79
@@ -1 +1 @@
1
- {"version":3,"file":"protocol.js","sources":["../src/protocol.ts"],"sourcesContent":["/**\n * Wire protocol between the in-worker shim and the Meticulous backend\n * recorder sidecar. The sidecar imports these types (type-only), so this file\n * is the single source of truth for the shape of capture events.\n */\n\nexport const SIDECAR_PROTOCOL_VERSION = \"1\";\n\n/** Header carrying {@link SIDECAR_PROTOCOL_VERSION} on every shim → sidecar request. */\nexport const SIDECAR_PROTOCOL_VERSION_HEADER =\n \"x-meticulous-sidecar-protocol-version\";\n\n/**\n * Header the Meticulous frontend recorder stamps on requests made by the app\n * under recording; its value ties backend spans to the frontend session.\n */\nexport const FRONTEND_SESSION_ID_HEADER = \"x-meticulous-session-id\";\n\nexport const SIDECAR_EVENTS_PATH = \"/v1/events\";\n\n/**\n * The only headers persisted on capture events. Everything else — notably\n * authorization, cookie/set-cookie, and API-key headers — is dropped at\n * capture time, before it ever leaves the worker. Mirrors the Node backend\n * recorder, which persists only content-type plus the Meticulous session\n * header.\n */\nexport const CAPTURED_HEADERS = [\n \"content-type\",\n FRONTEND_SESSION_ID_HEADER,\n] as const;\n\nexport interface CapturedBody {\n /** UTF-8 decoded body, truncated to the capture cap. */\n body: string;\n /** True when the body exceeded the capture cap or the read was cut short. */\n truncated: boolean;\n}\n\ninterface BaseRequestEvent {\n /** Correlates outbound events to the inbound request being handled. */\n requestId: string;\n /** Value of {@link FRONTEND_SESSION_ID_HEADER} on the inbound request, if any. */\n frontendSessionId?: string;\n method: string;\n /** Full URL including query string. */\n url: string;\n /** Lower-cased header name → values. */\n requestHeaders: Record<string, string[]>;\n responseHeaders?: Record<string, string[]>;\n statusCode?: number;\n startTimeMs: number;\n endTimeMs: number;\n /** Set when the request failed before a response was produced. */\n error?: string;\n}\n\n/**\n * The worker's own inbound request (becomes a SERVER span). Bodies are not\n * captured, matching the Node backend recorder's SERVER-span contract.\n */\nexport interface InboundRequestEvent extends BaseRequestEvent {\n kind: \"inbound\";\n}\n\n/**\n * An outgoing `fetch` call made while handling an inbound request (becomes a\n * CLIENT span).\n */\nexport interface OutboundRequestEvent extends BaseRequestEvent {\n kind: \"outbound\";\n requestBody?: CapturedBody;\n responseBody?: CapturedBody;\n}\n\nexport type CaptureEvent = InboundRequestEvent | OutboundRequestEvent;\n\nexport interface CaptureEventsPayload {\n events: CaptureEvent[];\n}\n"],"names":[],"mappings":";;;;;;;;AAMA,MAAa,2BAA2B;;AAGxC,MAAa,kCACX;;;;;AAMF,MAAa,6BAA6B;AAE1C,MAAa,sBAAsB;;;;;;;;AASnC,MAAa,mBAAmB,CAC9B,gBACA,2BACD","debugId":"f28d3e21-60a1-55c4-8586-5db3a0ec194b"}
1
+ {"version":3,"file":"protocol.js","sources":["../src/protocol.ts"],"sourcesContent":["/**\n * Wire protocol between the in-worker shim and the Meticulous backend\n * recorder sidecar. The sidecar imports these types (type-only), so this file\n * is the single source of truth for the shape of capture events.\n */\n\nexport const SIDECAR_PROTOCOL_VERSION = \"1\";\n\n/** Header carrying {@link SIDECAR_PROTOCOL_VERSION} on every shim → sidecar request. */\nexport const SIDECAR_PROTOCOL_VERSION_HEADER =\n \"x-meticulous-sidecar-protocol-version\";\n\n/**\n * Header the Meticulous frontend recorder stamps on requests made by the app\n * under recording; its value ties backend spans to the frontend session.\n */\nexport const FRONTEND_SESSION_ID_HEADER = \"x-meticulous-session-id\";\n\nexport const SIDECAR_EVENTS_PATH = \"/v1/events\";\n\n/**\n * Replay routes. A sidecar started in record mode answers 404 on both, which the shim\n * treats as \"replay unavailable\" and falls back to plain pass-through — so a new shim\n * keeps working against an older, record-only sidecar.\n */\nexport const SIDECAR_REPLAY_SESSION_PATH = \"/v1/replay/session\";\nexport const SIDECAR_REPLAY_OUTBOUND_FETCH_PATH = \"/v1/replay/outbound-fetch\";\n\n/**\n * Header carrying the replay sidecar's origin, injected by the Meticulous replay runner\n * on requests to the app under test. Workerd cannot see container environment variables,\n * so an inbound header is the only way per-replay config can reach the shim.\n *\n * The shim validates the value before using it and only honours a loopback /\n * `host.docker.internal` / private-network `http:` origin — see `replay-sidecar-url.ts`.\n */\nexport const REPLAY_SIDECAR_URL_HEADER =\n \"x-meticulous-backend-replay-sidecar-url\";\n\n/**\n * The only headers persisted on capture events. Everything else — notably\n * authorization, cookie/set-cookie, and API-key headers — is dropped at\n * capture time, before it ever leaves the worker. Mirrors the Node backend\n * recorder, which persists only content-type plus the Meticulous session\n * header.\n */\nexport const CAPTURED_HEADERS = [\n \"content-type\",\n FRONTEND_SESSION_ID_HEADER,\n] as const;\n\nexport interface CapturedBody {\n /** UTF-8 decoded body, truncated to the capture cap. */\n body: string;\n /** True when the body exceeded the capture cap or the read was cut short. */\n truncated: boolean;\n}\n\ninterface BaseRequestEvent {\n /** Correlates outbound events to the inbound request being handled. */\n requestId: string;\n /** Value of {@link FRONTEND_SESSION_ID_HEADER} on the inbound request, if any. */\n frontendSessionId?: string;\n method: string;\n /** Full URL including query string. */\n url: string;\n /** Lower-cased header name → values. */\n requestHeaders: Record<string, string[]>;\n responseHeaders?: Record<string, string[]>;\n statusCode?: number;\n startTimeMs: number;\n endTimeMs: number;\n /** Set when the request failed before a response was produced. */\n error?: string;\n}\n\n/**\n * The worker's own inbound request (becomes a SERVER span). Bodies are not\n * captured, matching the Node backend recorder's SERVER-span contract.\n */\nexport interface InboundRequestEvent extends BaseRequestEvent {\n kind: \"inbound\";\n}\n\n/**\n * An outgoing `fetch` call made while handling an inbound request (becomes a\n * CLIENT span).\n */\nexport interface OutboundRequestEvent extends BaseRequestEvent {\n kind: \"outbound\";\n requestBody?: CapturedBody;\n responseBody?: CapturedBody;\n}\n\n/**\n * A `fetch` through a Cloudflare binding — a service binding, an assets binding, or a\n * Durable Object stub — made while handling an inbound request (becomes a CLIENT span).\n *\n * Request/Response-shaped like {@link OutboundRequestEvent}, but a distinct kind because the\n * call never leaves the isolate and its URL is whatever the caller invented. Keeping the two\n * apart means a binding call and real egress can never be confused for one another when\n * these spans are eventually mocked.\n */\nexport interface BindingRequestEvent extends BaseRequestEvent {\n kind: \"binding\";\n /**\n * The `env` key the binding was found under. Absent when the instance was never seen on\n * `env` — most commonly a Durable Object stub, which `namespace.get()` produces rather\n * than being a binding in its own right.\n */\n bindingName?: string;\n requestBody?: CapturedBody;\n responseBody?: CapturedBody;\n}\n\n/** KV namespace methods the shim records. */\nexport type KvOperation = \"get\" | \"getWithMetadata\" | \"put\" | \"delete\" | \"list\";\n\n/**\n * Why a KV value is absent from an event: workerd handed it over in a form that cannot be\n * persisted as text. A `\"stream\"` value must not even be read — consuming it would take the\n * bytes away from the app — and binary values are skipped deliberately, since KV blobs are\n * large and would both bloat the recording and be mangled by UTF-8 capture.\n */\nexport type KvOmittedReason = \"binary\" | \"stream\";\n\n/**\n * An operation on a Cloudflare KV namespace binding, made while handling an inbound request\n * (becomes a CLIENT span).\n *\n * KV is not HTTP: there is no method, URL or status code, so this event shares nothing with\n * {@link BaseRequestEvent} beyond correlation and timing. Values are JSON so that a single\n * `JSON.parse` reconstructs exactly what the app saw, whichever `type` the read asked for.\n */\nexport interface KvOperationEvent {\n kind: \"kv\";\n /** Correlates the operation to the inbound request being handled. */\n requestId: string;\n frontendSessionId?: string;\n /**\n * The `env` key the KV namespace was found under. Always present, unlike on a binding\n * event: a namespace has no factory equivalent to `DurableObjectNamespace.get()`, so one\n * that was never seen on `env` is not recorded at all.\n */\n bindingName: string;\n operation: KvOperation;\n /**\n * The key operated on, when it is a single string. Absent for `list` (which spans keys) and\n * for a bulk `get` (which takes an array) — both keep their arguments in {@link args}.\n */\n key?: string;\n /**\n * JSON of the call's arguments: the read `type`, the `put` options, the `list` selector.\n * A `put` value appears as `null` here — it is captured in {@link value} instead, so that\n * redaction applies to it.\n */\n args?: CapturedBody;\n /** JSON of the value a `put` wrote, with secret-looking fields redacted. */\n value?: CapturedBody;\n /**\n * JSON of what the operation returned: the value for `get`, `{value, metadata}` for\n * `getWithMetadata`, the key page for `list`. Absent for `put`/`delete`, which return\n * nothing, and for a value that could not be captured (see {@link omitted}).\n */\n result?: CapturedBody;\n /**\n * Why the operation's value is missing. Refers to the written value for `put` and to the\n * read value otherwise — a `put` has no result, so this is never ambiguous.\n */\n omitted?: KvOmittedReason;\n startTimeMs: number;\n endTimeMs: number;\n /** Set when the operation threw. */\n error?: string;\n}\n\nexport type CaptureEvent =\n | InboundRequestEvent\n | OutboundRequestEvent\n | BindingRequestEvent\n | KvOperationEvent;\n\nexport interface CaptureEventsPayload {\n events: CaptureEvent[];\n}\n\n/**\n * Answer to `GET {SIDECAR_REPLAY_SESSION_PATH}?sessionId=<id>`: whether the sidecar holds\n * mocks for a session, and the virtual clock anchor to freeze `Date` at while serving it.\n */\nexport interface ReplaySessionInfoResponse {\n found: boolean;\n /**\n * Epoch ms of the session's last recorded activity. The worker freezes its clock here so\n * that credentials minted during the recording are already issued but not yet expired.\n */\n clockAnchorMs?: number;\n}\n\n/** Lookup for a single outbound `fetch`, sent to {@link SIDECAR_REPLAY_OUTBOUND_FETCH_PATH}. */\nexport interface OutboundFetchLookupRequest {\n frontendSessionId: string;\n method: string;\n /** Full URL including query string. */\n url: string;\n /**\n * Request body as captured by `readBodyWithCap`. The sidecar hashes this itself, so the\n * hash it compares against a recording is derived from byte-identical input on both the\n * record and replay sides.\n */\n requestBody?: CapturedBody;\n}\n\n/**\n * Either a recorded response to serve, or an instruction to let the real call through.\n * An unrecognised `outcome` must be treated as `passthrough`, so an older shim keeps\n * working against a newer sidecar.\n */\nexport type OutboundFetchLookupResponse =\n | {\n outcome: \"mock\";\n statusCode: number;\n body: string;\n headers: Record<string, string>;\n }\n | { outcome: \"passthrough\" };\n"],"names":[],"mappings":";;;;;;;;AAMA,MAAa,2BAA2B;;AAGxC,MAAa,kCACX;;;;;AAMF,MAAa,6BAA6B;AAE1C,MAAa,sBAAsB;;;;;;AAOnC,MAAa,8BAA8B;AAC3C,MAAa,qCAAqC;;;;;;;;;AAUlD,MAAa,4BACX;;;;;;;;AASF,MAAa,mBAAmB,CAC9B,gBACA,2BACD","debugId":"532e3e0d-6b1c-51c2-8086-a05de2625e79"}
@@ -0,0 +1,19 @@
1
+ //#region src/redact-body.d.ts
2
+ /** Replacement for a redacted value. Matches the Node recorder's redaction marker. */
3
+ declare const STR_REDACTED = "REDACTED";
4
+ /**
5
+ * Redacts secret-looking fields from a captured request body, returning the body unchanged
6
+ * if it is not JSON.
7
+ *
8
+ * Applied to request bodies only. Response bodies must stay byte-exact: replay serves them
9
+ * back to the app, so redacting one would corrupt what the app receives. Request bodies are
10
+ * only ever used to key a lookup, and because the same function runs on both the record and
11
+ * the replay side the keys still agree.
12
+ *
13
+ * Never throws — a body that cannot be parsed or re-serialized is returned as-is, which is
14
+ * no worse than today's behaviour.
15
+ */
16
+ declare const redactRequestBody: (body: string) => string;
17
+ //#endregion
18
+ export { STR_REDACTED, redactRequestBody };
19
+ //# sourceMappingURL=redact-body.d.ts.map
@@ -0,0 +1,53 @@
1
+ //#region src/redact-body.ts
2
+ /** Replacement for a redacted value. Matches the Node recorder's redaction marker. */
3
+
4
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="36ac86ad-0c78-5b5e-87b8-88acca7728a4")}catch(e){}}();
5
+ const STR_REDACTED = "REDACTED";
6
+ const secretKeys = new Set([
7
+ "accesstoken",
8
+ "apikey",
9
+ "authorization",
10
+ "clientsecret",
11
+ "credential",
12
+ "idtoken",
13
+ "password",
14
+ "privatekey",
15
+ "refreshtoken",
16
+ "secret",
17
+ "token"
18
+ ]);
19
+ const normalizeKey = (key) => key.toLowerCase().replace(/[_-]/g, "");
20
+ const isSecretKey = (key) => secretKeys.has(normalizeKey(key));
21
+ /**
22
+ * Redacts secret-looking fields from a captured request body, returning the body unchanged
23
+ * if it is not JSON.
24
+ *
25
+ * Applied to request bodies only. Response bodies must stay byte-exact: replay serves them
26
+ * back to the app, so redacting one would corrupt what the app receives. Request bodies are
27
+ * only ever used to key a lookup, and because the same function runs on both the record and
28
+ * the replay side the keys still agree.
29
+ *
30
+ * Never throws — a body that cannot be parsed or re-serialized is returned as-is, which is
31
+ * no worse than today's behaviour.
32
+ */
33
+ const redactRequestBody = (body) => {
34
+ if (body.length === 0) return body;
35
+ try {
36
+ const redacted = redactValue(JSON.parse(body));
37
+ return JSON.stringify(redacted) ?? body;
38
+ } catch {
39
+ return body;
40
+ }
41
+ };
42
+ const redactValue = (value) => {
43
+ if (Array.isArray(value)) return value.map(redactValue);
44
+ if (value === null || typeof value !== "object") return value;
45
+ const result = {};
46
+ for (const [key, nested] of Object.entries(value)) result[key] = isSecretKey(key) ? STR_REDACTED : redactValue(nested);
47
+ return result;
48
+ };
49
+ //#endregion
50
+ export { STR_REDACTED, redactRequestBody };
51
+
52
+ //# sourceMappingURL=redact-body.js.map
53
+ //# debugId=36ac86ad-0c78-5b5e-87b8-88acca7728a4
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redact-body.js","sources":["../src/redact-body.ts"],"sourcesContent":["/** Replacement for a redacted value. Matches the Node recorder's redaction marker. */\nexport const STR_REDACTED = \"REDACTED\";\n\n/**\n * Object keys whose values are replaced before a captured request body leaves the worker.\n *\n * Request bodies are captured verbatim, and plenty of APIs carry a credential in the body\n * rather than a header — an OAuth2 token exchange, or a feature-flag resolver that takes its\n * client secret as a JSON field. Header redaction (an allow-list of two) does nothing for\n * those, so without this the credential would be persisted in the recorded span.\n *\n * Compared case-insensitively, ignoring `_` and `-`, so `clientSecret`, `client_secret` and\n * `CLIENT-SECRET` all match one entry.\n */\nconst SECRET_KEYS = [\n \"accesstoken\",\n \"apikey\",\n \"authorization\",\n \"clientsecret\",\n \"credential\",\n \"idtoken\",\n \"password\",\n \"privatekey\",\n \"refreshtoken\",\n \"secret\",\n \"token\",\n] as const;\n\nconst secretKeys = new Set<string>(SECRET_KEYS);\n\nconst normalizeKey = (key: string): string =>\n key.toLowerCase().replace(/[_-]/g, \"\");\n\nconst isSecretKey = (key: string): boolean => secretKeys.has(normalizeKey(key));\n\n/**\n * Redacts secret-looking fields from a captured request body, returning the body unchanged\n * if it is not JSON.\n *\n * Applied to request bodies only. Response bodies must stay byte-exact: replay serves them\n * back to the app, so redacting one would corrupt what the app receives. Request bodies are\n * only ever used to key a lookup, and because the same function runs on both the record and\n * the replay side the keys still agree.\n *\n * Never throws — a body that cannot be parsed or re-serialized is returned as-is, which is\n * no worse than today's behaviour.\n */\nexport const redactRequestBody = (body: string): string => {\n if (body.length === 0) {\n return body;\n }\n try {\n const parsed: unknown = JSON.parse(body);\n const redacted = redactValue(parsed);\n const serialized = JSON.stringify(redacted);\n return serialized ?? body;\n } catch {\n // Not JSON (form-encoded, protobuf, plain text) — left alone rather than mangled.\n return body;\n }\n};\n\nconst redactValue = (value: unknown): unknown => {\n if (Array.isArray(value)) {\n return value.map(redactValue);\n }\n if (value === null || typeof value !== \"object\") {\n return value;\n }\n const result: Record<string, unknown> = {};\n for (const [key, nested] of Object.entries(value)) {\n result[key] = isSecretKey(key) ? STR_REDACTED : redactValue(nested);\n }\n return result;\n};\n"],"names":[],"mappings":";;;;AACA,MAAa,eAAe;AA2B5B,MAAM,aAAa,IAAI,IAAY;CAbjC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAG4C,CAAC;AAE/C,MAAM,gBAAgB,QACpB,IAAI,aAAa,CAAC,QAAQ,SAAS,GAAG;AAExC,MAAM,eAAe,QAAyB,WAAW,IAAI,aAAa,IAAI,CAAC;;;;;;;;;;;;;AAc/E,MAAa,qBAAqB,SAAyB;AACzD,KAAI,KAAK,WAAW,EAClB,QAAO;AAET,KAAI;EAEF,MAAM,WAAW,YADO,KAAK,MAAM,KACA,CAAC;AAEpC,SADmB,KAAK,UAAU,SACjB,IAAI;SACf;AAEN,SAAO;;;AAIX,MAAM,eAAe,UAA4B;AAC/C,KAAI,MAAM,QAAQ,MAAM,CACtB,QAAO,MAAM,IAAI,YAAY;AAE/B,KAAI,UAAU,QAAQ,OAAO,UAAU,SACrC,QAAO;CAET,MAAM,SAAkC,EAAE;AAC1C,MAAK,MAAM,CAAC,KAAK,WAAW,OAAO,QAAQ,MAAM,CAC/C,QAAO,OAAO,YAAY,IAAI,GAAG,eAAe,YAAY,OAAO;AAErE,QAAO","debugId":"36ac86ad-0c78-5b5e-87b8-88acca7728a4"}
@@ -0,0 +1,81 @@
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]="378556a4-8a50-5671-a66d-eda7ae1c0626")}catch(e){}}();
3
+ import { warnOnce } from "./log.js";
4
+ import { readRequestBodyWithCap } from "./body-capture.js";
5
+ import { getOriginalFetch } from "./original-fetch.js";
6
+ import { postOutboundFetchLookup } from "./sidecar-client.js";
7
+ //#region src/replay-fetch.ts
8
+ /**
9
+ * Serves a recorded response for an outbound call, or lets the real one through.
10
+ *
11
+ * The mirror image of `captureOutboundCall`: recording tees a call that happens anyway,
12
+ * whereas replay decides first and only then calls — so the sidecar sits in the request
13
+ * path here, and every failure mode below falls back to `invoke`.
14
+ *
15
+ * A miss always falls through to the real service rather than erroring. Recordings are
16
+ * legitimately incomplete — anything the app cached during recording (e.g. public JWKs held
17
+ * in a KV namespace) is never captured, and is fetched for real when replay starts with a
18
+ * cold cache.
19
+ */
20
+ const replayOutboundCall = async (ctx, request, invoke) => {
21
+ let lookup;
22
+ try {
23
+ const requestBody = request.body ? await readRequestBodyWithCap(request.clone().body).catch(() => void 0) : void 0;
24
+ lookup = {
25
+ frontendSessionId: ctx.frontendSessionId,
26
+ method: request.method,
27
+ url: request.url,
28
+ ...requestBody !== void 0 ? { requestBody } : {}
29
+ };
30
+ } catch (error) {
31
+ warnOnce("replay-lookup-build", "Failed to prepare a mock lookup — letting the real call through.", error);
32
+ return invoke(request);
33
+ }
34
+ const result = await postOutboundFetchLookup(getOriginalFetch(), ctx.sidecarUrl, lookup);
35
+ if (result?.outcome !== "mock") return invoke(request);
36
+ return buildMockResponse(result) ?? invoke(request);
37
+ };
38
+ const HEADERS_TO_DROP = new Set([
39
+ "content-encoding",
40
+ "content-length",
41
+ "transfer-encoding"
42
+ ]);
43
+ const NULL_BODY_STATUSES = new Set([
44
+ 101,
45
+ 204,
46
+ 205,
47
+ 304
48
+ ]);
49
+ /**
50
+ * Turns a recorded response into a real one, or undefined if it cannot be represented (the
51
+ * constructor throws a RangeError on an out-of-range status, or a body on a null-body
52
+ * status), in which case the caller passes through.
53
+ *
54
+ * Note recorded headers are limited to CAPTURED_HEADERS, so a mocked response carries
55
+ * essentially just content-type. In particular it can never set cookies — fine for the API
56
+ * calls this path serves, but a real limitation for a backend that authenticates that way.
57
+ */
58
+ const buildMockResponse = (mock) => {
59
+ try {
60
+ const headers = new Headers();
61
+ for (const [name, value] of Object.entries(mock.headers)) {
62
+ if (HEADERS_TO_DROP.has(name.toLowerCase())) continue;
63
+ try {
64
+ headers.set(name, value);
65
+ } catch {}
66
+ }
67
+ const body = NULL_BODY_STATUSES.has(mock.statusCode) ? null : mock.body;
68
+ return new Response(body, {
69
+ status: mock.statusCode,
70
+ headers
71
+ });
72
+ } catch (error) {
73
+ warnOnce("replay-mock-response", "Could not construct a mocked response — letting the real call through.", error);
74
+ return;
75
+ }
76
+ };
77
+ //#endregion
78
+ export { replayOutboundCall };
79
+
80
+ //# sourceMappingURL=replay-fetch.js.map
81
+ //# debugId=378556a4-8a50-5671-a66d-eda7ae1c0626
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay-fetch.js","sources":["../src/replay-fetch.ts"],"sourcesContent":["import { readRequestBodyWithCap } from \"./body-capture\";\nimport type { RequestReplayContext } from \"./context\";\nimport { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport type { OutboundFetchLookupRequest } from \"./protocol\";\nimport { postOutboundFetchLookup } from \"./sidecar-client\";\n\n/**\n * Serves a recorded response for an outbound call, or lets the real one through.\n *\n * The mirror image of `captureOutboundCall`: recording tees a call that happens anyway,\n * whereas replay decides first and only then calls — so the sidecar sits in the request\n * path here, and every failure mode below falls back to `invoke`.\n *\n * A miss always falls through to the real service rather than erroring. Recordings are\n * legitimately incomplete — anything the app cached during recording (e.g. public JWKs held\n * in a KV namespace) is never captured, and is fetched for real when replay starts with a\n * cold cache.\n */\nexport const replayOutboundCall = async (\n ctx: RequestReplayContext,\n request: Request,\n invoke: (request: Request) => Promise<Response>,\n): Promise<Response> => {\n let lookup: OutboundFetchLookupRequest;\n try {\n // Read the clone and pass the original through untouched, mirroring the record path —\n // so the miss branch needs no body rebuild. The sidecar hashes what we send here, and\n // it was captured by the same readRequestBodyWithCap the recorder used (redaction\n // included), so the hashes on both sides derive from byte-identical input.\n const requestBody = request.body\n ? await readRequestBodyWithCap(request.clone().body).catch(\n () => undefined,\n )\n : undefined;\n lookup = {\n frontendSessionId: ctx.frontendSessionId,\n method: request.method,\n url: request.url,\n ...(requestBody !== undefined ? { requestBody } : {}),\n };\n } catch (error) {\n warnOnce(\n \"replay-lookup-build\",\n \"Failed to prepare a mock lookup — letting the real call through.\",\n error,\n );\n return invoke(request);\n }\n\n const result = await postOutboundFetchLookup(\n getOriginalFetch(),\n ctx.sidecarUrl,\n lookup,\n );\n // Also covers an unrecognised `outcome` from a newer sidecar.\n if (result?.outcome !== \"mock\") {\n return invoke(request);\n }\n return buildMockResponse(result) ?? invoke(request);\n};\n\n// Recorded bodies are already decoded and may have been truncated, so the recorded framing\n// headers no longer describe them; the Response constructor recomputes what it needs.\nconst HEADERS_TO_DROP = new Set([\n \"content-encoding\",\n \"content-length\",\n \"transfer-encoding\",\n]);\n\n// Statuses the Response constructor refuses to pair with a body.\nconst NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);\n\n/**\n * Turns a recorded response into a real one, or undefined if it cannot be represented (the\n * constructor throws a RangeError on an out-of-range status, or a body on a null-body\n * status), in which case the caller passes through.\n *\n * Note recorded headers are limited to CAPTURED_HEADERS, so a mocked response carries\n * essentially just content-type. In particular it can never set cookies — fine for the API\n * calls this path serves, but a real limitation for a backend that authenticates that way.\n */\nconst buildMockResponse = (mock: {\n statusCode: number;\n body: string;\n headers: Record<string, string>;\n}): Response | undefined => {\n try {\n const headers = new Headers();\n for (const [name, value] of Object.entries(mock.headers)) {\n if (HEADERS_TO_DROP.has(name.toLowerCase())) {\n continue;\n }\n try {\n headers.set(name, value);\n } catch {\n // Skip a header name/value the runtime rejects rather than losing the whole mock.\n }\n }\n const body = NULL_BODY_STATUSES.has(mock.statusCode) ? null : mock.body;\n return new Response(body, { status: mock.statusCode, headers });\n } catch (error) {\n warnOnce(\n \"replay-mock-response\",\n \"Could not construct a mocked response — letting the real call through.\",\n error,\n );\n return undefined;\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;AAmBA,MAAa,qBAAqB,OAChC,KACA,SACA,WACsB;CACtB,IAAI;AACJ,KAAI;EAKF,MAAM,cAAc,QAAQ,OACxB,MAAM,uBAAuB,QAAQ,OAAO,CAAC,KAAK,CAAC,YAC3C,KAAA,EACP,GACD,KAAA;AACJ,WAAS;GACP,mBAAmB,IAAI;GACvB,QAAQ,QAAQ;GAChB,KAAK,QAAQ;GACb,GAAI,gBAAgB,KAAA,IAAY,EAAE,aAAa,GAAG,EAAE;GACrD;UACM,OAAO;AACd,WACE,uBACA,oEACA,MACD;AACD,SAAO,OAAO,QAAQ;;CAGxB,MAAM,SAAS,MAAM,wBACnB,kBAAkB,EAClB,IAAI,YACJ,OACD;AAED,KAAI,QAAQ,YAAY,OACtB,QAAO,OAAO,QAAQ;AAExB,QAAO,kBAAkB,OAAO,IAAI,OAAO,QAAQ;;AAKrD,MAAM,kBAAkB,IAAI,IAAI;CAC9B;CACA;CACA;CACD,CAAC;AAGF,MAAM,qBAAqB,IAAI,IAAI;CAAC;CAAK;CAAK;CAAK;CAAI,CAAC;;;;;;;;;;AAWxD,MAAM,qBAAqB,SAIC;AAC1B,KAAI;EACF,MAAM,UAAU,IAAI,SAAS;AAC7B,OAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,KAAK,QAAQ,EAAE;AACxD,OAAI,gBAAgB,IAAI,KAAK,aAAa,CAAC,CACzC;AAEF,OAAI;AACF,YAAQ,IAAI,MAAM,MAAM;WAClB;;EAIV,MAAM,OAAO,mBAAmB,IAAI,KAAK,WAAW,GAAG,OAAO,KAAK;AACnE,SAAO,IAAI,SAAS,MAAM;GAAE,QAAQ,KAAK;GAAY;GAAS,CAAC;UACxD,OAAO;AACd,WACE,wBACA,0EACA,MACD;AACD","debugId":"378556a4-8a50-5671-a66d-eda7ae1c0626"}
@@ -0,0 +1,62 @@
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]="94547b8a-bd87-50d7-ac87-204a0a65c1b7")}catch(e){}}();
3
+ import { warnOnce } from "./log.js";
4
+ //#region src/replay-sidecar-url.ts
5
+ /**
6
+ * Validation for the replay sidecar origin carried on inbound requests.
7
+ *
8
+ * The value arrives in a request header, so in principle anything that can reach the worker
9
+ * can propose one — and the shim POSTs outbound request bodies to whatever it accepts. The
10
+ * guard therefore only honours an origin that could plausibly be a Meticulous replay
11
+ * sidecar running alongside the app: plaintext HTTP on a loopback, docker-gateway or
12
+ * private-network host.
13
+ *
14
+ * `https:` is rejected deliberately. A sidecar reachable over loopback or a pod network has
15
+ * no TLS to offer, so permitting `https:` would buy nothing except a way for a forged header
16
+ * to exfiltrate request bodies to an arbitrary internet host.
17
+ */
18
+ const MAX_URL_LENGTH = 2048;
19
+ const ALLOWED_HOSTNAMES = new Set([
20
+ "localhost",
21
+ "host.docker.internal",
22
+ "host.containers.internal"
23
+ ]);
24
+ const parseReplaySidecarUrl = (raw) => {
25
+ if (typeof raw !== "string" || raw.length === 0) return;
26
+ if (raw.length > MAX_URL_LENGTH) {
27
+ warnOnce("replay-sidecar-url", "Ignoring an implausibly long Meticulous replay sidecar URL.");
28
+ return;
29
+ }
30
+ let url;
31
+ try {
32
+ url = new URL(raw);
33
+ } catch {
34
+ warnOnce("replay-sidecar-url", "Ignoring an unparseable Meticulous replay sidecar URL.");
35
+ return;
36
+ }
37
+ if (!(url.protocol === "http:" && url.username === "" && url.password === "" && (url.pathname === "" || url.pathname === "/") && url.search === "" && url.hash === "" && isLocalHostname(url.hostname))) {
38
+ warnOnce("replay-sidecar-url", `Ignoring the Meticulous replay sidecar URL "${raw}" — only a plain http:// origin on a loopback, docker-gateway or private-network host is honoured.`);
39
+ return;
40
+ }
41
+ return url.origin;
42
+ };
43
+ const isLocalHostname = (hostname) => {
44
+ const lower = hostname.toLowerCase();
45
+ if (ALLOWED_HOSTNAMES.has(lower)) return true;
46
+ const unbracketed = lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : void 0;
47
+ if (unbracketed !== void 0) return unbracketed === "::1";
48
+ return isPrivateIpv4(lower);
49
+ };
50
+ const isPrivateIpv4 = (hostname) => {
51
+ const parts = hostname.split(".");
52
+ if (parts.length !== 4) return false;
53
+ const octets = parts.map((part) => /^\d{1,3}$/.test(part) ? Number(part) : NaN);
54
+ if (octets.some((octet) => Number.isNaN(octet) || octet > 255)) return false;
55
+ const [first, second] = octets;
56
+ return first === 127 || first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
57
+ };
58
+ //#endregion
59
+ export { parseReplaySidecarUrl };
60
+
61
+ //# sourceMappingURL=replay-sidecar-url.js.map
62
+ //# debugId=94547b8a-bd87-50d7-ac87-204a0a65c1b7
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay-sidecar-url.js","sources":["../src/replay-sidecar-url.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\n\n/**\n * Validation for the replay sidecar origin carried on inbound requests.\n *\n * The value arrives in a request header, so in principle anything that can reach the worker\n * can propose one — and the shim POSTs outbound request bodies to whatever it accepts. The\n * guard therefore only honours an origin that could plausibly be a Meticulous replay\n * sidecar running alongside the app: plaintext HTTP on a loopback, docker-gateway or\n * private-network host.\n *\n * `https:` is rejected deliberately. A sidecar reachable over loopback or a pod network has\n * no TLS to offer, so permitting `https:` would buy nothing except a way for a forged header\n * to exfiltrate request bodies to an arbitrary internet host.\n */\n\nconst MAX_URL_LENGTH = 2048;\n\nconst ALLOWED_HOSTNAMES = new Set([\n \"localhost\",\n // Docker Desktop and Podman expose the host to containers under these names.\n \"host.docker.internal\",\n \"host.containers.internal\",\n]);\n\nexport const parseReplaySidecarUrl = (\n raw: string | null | undefined,\n): string | undefined => {\n if (typeof raw !== \"string\" || raw.length === 0) {\n return undefined;\n }\n if (raw.length > MAX_URL_LENGTH) {\n warnOnce(\n \"replay-sidecar-url\",\n \"Ignoring an implausibly long Meticulous replay sidecar URL.\",\n );\n return undefined;\n }\n\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n warnOnce(\n \"replay-sidecar-url\",\n \"Ignoring an unparseable Meticulous replay sidecar URL.\",\n );\n return undefined;\n }\n\n const isAcceptable =\n url.protocol === \"http:\" &&\n url.username === \"\" &&\n url.password === \"\" &&\n (url.pathname === \"\" || url.pathname === \"/\") &&\n url.search === \"\" &&\n url.hash === \"\" &&\n isLocalHostname(url.hostname);\n\n if (!isAcceptable) {\n warnOnce(\n \"replay-sidecar-url\",\n `Ignoring the Meticulous replay sidecar URL \"${raw}\" — only a plain http:// origin on a loopback, docker-gateway or private-network host is honoured.`,\n );\n return undefined;\n }\n\n // Return the parsed origin rather than the raw string: it is what `fetch` would resolve\n // the value to anyway, so the host that was validated is exactly the host that gets\n // called (alternative IPv4 spellings like \"0177.0.0.1\" normalize here, rather than being\n // checked in one form and dialled in another). Carries no trailing slash, matching\n // resolveSidecarUrl's normalization so the self-capture prefix check lines up.\n return url.origin;\n};\n\nconst isLocalHostname = (hostname: string): boolean => {\n const lower = hostname.toLowerCase();\n if (ALLOWED_HOSTNAMES.has(lower)) {\n return true;\n }\n // URL keeps IPv6 literals bracketed.\n const unbracketed =\n lower.startsWith(\"[\") && lower.endsWith(\"]\")\n ? lower.slice(1, -1)\n : undefined;\n if (unbracketed !== undefined) {\n return unbracketed === \"::1\";\n }\n return isPrivateIpv4(lower);\n};\n\nconst isPrivateIpv4 = (hostname: string): boolean => {\n const parts = hostname.split(\".\");\n if (parts.length !== 4) {\n return false;\n }\n const octets = parts.map((part) =>\n // Reject anything that is not a plain decimal octet: \"010\" and \"0x7f\" are parsed\n // inconsistently across resolvers, and \"1e2\" would pass a bare Number() check.\n /^\\d{1,3}$/.test(part) ? Number(part) : Number.NaN,\n );\n if (octets.some((octet) => Number.isNaN(octet) || octet > 255)) {\n return false;\n }\n const [first, second] = octets;\n // Deliberately excludes link-local (169.254.0.0/16): no sidecar is ever reachable there,\n // but the cloud metadata endpoints are (169.254.169.254), so allowing it would only widen\n // the SSRF surface. The rest of the codebase blocks that range for the same reason — see\n // `staging-backend-url.ts` and `assets-app-server.ts`.\n return (\n first === 127 || // loopback\n first === 10 || // RFC1918\n (first === 172 && second >= 16 && second <= 31) || // RFC1918\n (first === 192 && second === 168) // RFC1918\n );\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;AAgBA,MAAM,iBAAiB;AAEvB,MAAM,oBAAoB,IAAI,IAAI;CAChC;CAEA;CACA;CACD,CAAC;AAEF,MAAa,yBACX,QACuB;AACvB,KAAI,OAAO,QAAQ,YAAY,IAAI,WAAW,EAC5C;AAEF,KAAI,IAAI,SAAS,gBAAgB;AAC/B,WACE,sBACA,8DACD;AACD;;CAGF,IAAI;AACJ,KAAI;AACF,QAAM,IAAI,IAAI,IAAI;SACZ;AACN,WACE,sBACA,yDACD;AACD;;AAYF,KAAI,EARF,IAAI,aAAa,WACjB,IAAI,aAAa,MACjB,IAAI,aAAa,OAChB,IAAI,aAAa,MAAM,IAAI,aAAa,QACzC,IAAI,WAAW,MACf,IAAI,SAAS,MACb,gBAAgB,IAAI,SAAS,GAEZ;AACjB,WACE,sBACA,+CAA+C,IAAI,oGACpD;AACD;;AAQF,QAAO,IAAI;;AAGb,MAAM,mBAAmB,aAA8B;CACrD,MAAM,QAAQ,SAAS,aAAa;AACpC,KAAI,kBAAkB,IAAI,MAAM,CAC9B,QAAO;CAGT,MAAM,cACJ,MAAM,WAAW,IAAI,IAAI,MAAM,SAAS,IAAI,GACxC,MAAM,MAAM,GAAG,GAAG,GAClB,KAAA;AACN,KAAI,gBAAgB,KAAA,EAClB,QAAO,gBAAgB;AAEzB,QAAO,cAAc,MAAM;;AAG7B,MAAM,iBAAiB,aAA8B;CACnD,MAAM,QAAQ,SAAS,MAAM,IAAI;AACjC,KAAI,MAAM,WAAW,EACnB,QAAO;CAET,MAAM,SAAS,MAAM,KAAK,SAGxB,YAAY,KAAK,KAAK,GAAG,OAAO,KAAK,GAAG,IACzC;AACD,KAAI,OAAO,MAAM,UAAU,OAAO,MAAM,MAAM,IAAI,QAAQ,IAAI,CAC5D,QAAO;CAET,MAAM,CAAC,OAAO,UAAU;AAKxB,QACE,UAAU,OACV,UAAU,MACT,UAAU,OAAO,UAAU,MAAM,UAAU,MAC3C,UAAU,OAAO,WAAW","debugId":"94547b8a-bd87-50d7-ac87-204a0a65c1b7"}
@@ -1,8 +1,15 @@
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]="4fd3fc64-97ce-5a60-ae98-b07904e43cf9")}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]="81b6dfa3-bea2-53e3-a6be-f23b3b050e4c")}catch(e){}}();
3
3
  import { warnOnce } from "./log.js";
4
- import { SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION_HEADER } from "./protocol.js";
4
+ import { SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH } from "./protocol.js";
5
5
  //#region src/sidecar-client.ts
6
+ /**
7
+ * Replay calls sit in the request path, unlike record mode's fire-and-forget reporting, so
8
+ * they must be bounded: without a timeout a wedged sidecar would hang every outbound fetch
9
+ * the app makes, and therefore the whole replay, with nothing to look at.
10
+ */
11
+ const SESSION_INFO_TIMEOUT_MS = 1e4;
12
+ const LOOKUP_TIMEOUT_MS = 5e3;
6
13
  /** POSTs capture events to the sidecar. Never rejects — reporting failures only warn (once). */
7
14
  const postCaptureEvents = async (fetchFn, sidecarUrl, events) => {
8
15
  try {
@@ -21,8 +28,80 @@ const postCaptureEvents = async (fetchFn, sidecarUrl, events) => {
21
28
  warnOnce("sidecar-unreachable", "Could not reach the Meticulous backend recorder sidecar — backend session events are not being recorded.", error);
22
29
  }
23
30
  };
31
+ /**
32
+ * Asks whether the sidecar holds mocks for a session, and at what instant to freeze the
33
+ * clock while serving it.
34
+ *
35
+ * A 404 is how a record-only sidecar answers, so it counts as a settled `unavailable` and
36
+ * the shim degrades to pass-through against an older sidecar rather than failing.
37
+ */
38
+ const getReplaySessionInfo = async (fetchFn, sidecarUrl, frontendSessionId) => {
39
+ const url = `${sidecarUrl}${SIDECAR_REPLAY_SESSION_PATH}?sessionId=${encodeURIComponent(frontendSessionId)}`;
40
+ try {
41
+ const response = await fetchFn(url, {
42
+ headers: { [SIDECAR_PROTOCOL_VERSION_HEADER]: "1" },
43
+ signal: timeoutSignal(SESSION_INFO_TIMEOUT_MS)
44
+ });
45
+ if (!response.ok) {
46
+ await response.arrayBuffer().catch(() => void 0);
47
+ if (response.status >= 500) {
48
+ warnOnce("replay-session-unreachable", `Meticulous replay sidecar failed the replay session handshake (HTTP ${response.status}) — will retry on the next request.`);
49
+ return { outcome: "unreachable" };
50
+ }
51
+ warnOnce("replay-session-unavailable", `Meticulous replay sidecar did not recognise the replay session route (HTTP ${response.status}) — outbound calls will not be mocked.`);
52
+ return { outcome: "unavailable" };
53
+ }
54
+ const info = await response.json();
55
+ if (info.found !== true) {
56
+ warnOnce("replay-session-unknown", "Meticulous replay sidecar holds no mocks for this session — outbound calls will not be mocked.");
57
+ return { outcome: "unavailable" };
58
+ }
59
+ return {
60
+ outcome: "found",
61
+ clockAnchorMs: typeof info.clockAnchorMs === "number" ? info.clockAnchorMs : void 0
62
+ };
63
+ } catch (error) {
64
+ warnOnce("replay-session-unreachable", "Could not reach the Meticulous replay sidecar — will retry on the next request.", error);
65
+ return { outcome: "unreachable" };
66
+ }
67
+ };
68
+ /**
69
+ * Looks up a recorded response for one outbound call. Never rejects; returns undefined when
70
+ * the sidecar could not be consulted, which callers treat the same as a miss (let the real
71
+ * call through).
72
+ */
73
+ const postOutboundFetchLookup = async (fetchFn, sidecarUrl, payload) => {
74
+ try {
75
+ const response = await fetchFn(`${sidecarUrl}${SIDECAR_REPLAY_OUTBOUND_FETCH_PATH}`, {
76
+ method: "POST",
77
+ headers: {
78
+ "content-type": "application/json",
79
+ [SIDECAR_PROTOCOL_VERSION_HEADER]: "1"
80
+ },
81
+ body: JSON.stringify(payload),
82
+ signal: timeoutSignal(LOOKUP_TIMEOUT_MS)
83
+ });
84
+ if (!response.ok) {
85
+ warnOnce("replay-lookup-rejected", `Meticulous replay sidecar rejected a mock lookup (HTTP ${response.status}).`);
86
+ await response.arrayBuffer().catch(() => void 0);
87
+ return;
88
+ }
89
+ return await response.json();
90
+ } catch (error) {
91
+ warnOnce("replay-lookup-unreachable", "Could not reach the Meticulous replay sidecar for a mock lookup — letting the real call through.", error);
92
+ return;
93
+ }
94
+ };
95
+ /** AbortSignal.timeout, or undefined where it is unavailable. */
96
+ const timeoutSignal = (ms) => {
97
+ try {
98
+ return AbortSignal.timeout(ms);
99
+ } catch {
100
+ return;
101
+ }
102
+ };
24
103
  //#endregion
25
- export { postCaptureEvents };
104
+ export { getReplaySessionInfo, postCaptureEvents, postOutboundFetchLookup };
26
105
 
27
106
  //# sourceMappingURL=sidecar-client.js.map
28
- //# debugId=4fd3fc64-97ce-5a60-ae98-b07904e43cf9
107
+ //# debugId=81b6dfa3-bea2-53e3-a6be-f23b3b050e4c
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-client.js","sources":["../src/sidecar-client.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\nimport {\n type CaptureEvent,\n type CaptureEventsPayload,\n SIDECAR_EVENTS_PATH,\n SIDECAR_PROTOCOL_VERSION,\n SIDECAR_PROTOCOL_VERSION_HEADER,\n} from \"./protocol\";\n\ntype FetchFn = typeof globalThis.fetch;\n\n/** POSTs capture events to the sidecar. Never rejects — reporting failures only warn (once). */\nexport const postCaptureEvents = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n events: CaptureEvent[],\n): Promise<void> => {\n try {\n const payload: CaptureEventsPayload = { events };\n const response = await fetchFn(`${sidecarUrl}${SIDECAR_EVENTS_PATH}`, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n body: JSON.stringify(payload),\n });\n if (!response.ok) {\n warnOnce(\n \"sidecar-rejected\",\n `Meticulous sidecar rejected capture events (HTTP ${response.status}).`,\n );\n }\n await response.arrayBuffer().catch(() => undefined);\n } catch (error) {\n warnOnce(\n \"sidecar-unreachable\",\n \"Could not reach the Meticulous backend recorder sidecar — backend session events are not being recorded.\",\n error,\n );\n }\n};\n"],"names":[],"mappings":";;;;;;AAYA,MAAa,oBAAoB,OAC/B,SACA,YACA,WACkB;AAClB,KAAI;EACF,MAAM,UAAgC,EAAE,QAAQ;EAChD,MAAM,WAAW,MAAM,QAAQ,GAAG,aAAa,uBAAuB;GACpE,QAAQ;GACR,SAAS;IACP,gBAAgB;KACf,kCAAA;IACF;GACD,MAAM,KAAK,UAAU,QAAQ;GAC9B,CAAC;AACF,MAAI,CAAC,SAAS,GACZ,UACE,oBACA,oDAAoD,SAAS,OAAO,IACrE;AAEH,QAAM,SAAS,aAAa,CAAC,YAAY,KAAA,EAAU;UAC5C,OAAO;AACd,WACE,uBACA,4GACA,MACD","debugId":"4fd3fc64-97ce-5a60-ae98-b07904e43cf9"}
1
+ {"version":3,"file":"sidecar-client.js","sources":["../src/sidecar-client.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\nimport {\n type CaptureEvent,\n type CaptureEventsPayload,\n type OutboundFetchLookupRequest,\n type OutboundFetchLookupResponse,\n type ReplaySessionInfoResponse,\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\";\n\ntype FetchFn = typeof globalThis.fetch;\n\n/**\n * Replay calls sit in the request path, unlike record mode's fire-and-forget reporting, so\n * they must be bounded: without a timeout a wedged sidecar would hang every outbound fetch\n * the app makes, and therefore the whole replay, with nothing to look at.\n */\nconst SESSION_INFO_TIMEOUT_MS = 10_000;\nconst LOOKUP_TIMEOUT_MS = 5_000;\n\n/** POSTs capture events to the sidecar. Never rejects — reporting failures only warn (once). */\nexport const postCaptureEvents = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n events: CaptureEvent[],\n): Promise<void> => {\n try {\n const payload: CaptureEventsPayload = { events };\n const response = await fetchFn(`${sidecarUrl}${SIDECAR_EVENTS_PATH}`, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n body: JSON.stringify(payload),\n });\n if (!response.ok) {\n warnOnce(\n \"sidecar-rejected\",\n `Meticulous sidecar rejected capture events (HTTP ${response.status}).`,\n );\n }\n await response.arrayBuffer().catch(() => undefined);\n } catch (error) {\n warnOnce(\n \"sidecar-unreachable\",\n \"Could not reach the Meticulous backend recorder sidecar — backend session events are not being recorded.\",\n error,\n );\n }\n};\n\n/**\n * Outcome of the session handshake.\n *\n * `unavailable` and `unreachable` both mean \"do not replay\", but they are kept apart\n * because only the first is a settled answer. The caller caches per isolate, and caching a\n * one-off network blip would disable mocking for the rest of the session — silently sending\n * every later outbound call to the real service.\n */\nexport type ReplaySessionInfoResult =\n /** The sidecar holds mocks for this session. */\n | { outcome: \"found\"; clockAnchorMs: number | undefined }\n /** The sidecar answered and will never serve this session: no mocks, or no such route. */\n | { outcome: \"unavailable\" }\n /** Could not get an answer — timeout, transport error, 5xx. May succeed later. */\n | { outcome: \"unreachable\" };\n\n/**\n * Asks whether the sidecar holds mocks for a session, and at what instant to freeze the\n * clock while serving it.\n *\n * A 404 is how a record-only sidecar answers, so it counts as a settled `unavailable` and\n * the shim degrades to pass-through against an older sidecar rather than failing.\n */\nexport const getReplaySessionInfo = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n frontendSessionId: string,\n): Promise<ReplaySessionInfoResult> => {\n const url = `${sidecarUrl}${SIDECAR_REPLAY_SESSION_PATH}?sessionId=${encodeURIComponent(\n frontendSessionId,\n )}`;\n try {\n const response = await fetchFn(url, {\n headers: {\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n signal: timeoutSignal(SESSION_INFO_TIMEOUT_MS),\n });\n if (!response.ok) {\n await response.arrayBuffer().catch(() => undefined);\n // 4xx is the sidecar telling us something definitive — no such route (a record-only\n // sidecar), or a protocol version it will not speak. Retrying cannot change either.\n // 5xx may well be a sidecar still finding its feet, so leave it retryable.\n if (response.status >= 500) {\n warnOnce(\n \"replay-session-unreachable\",\n `Meticulous replay sidecar failed the replay session handshake (HTTP ${response.status}) — will retry on the next request.`,\n );\n return { outcome: \"unreachable\" };\n }\n warnOnce(\n \"replay-session-unavailable\",\n `Meticulous replay sidecar did not recognise the replay session route (HTTP ${response.status}) — outbound calls will not be mocked.`,\n );\n return { outcome: \"unavailable\" };\n }\n const info = (await response.json()) as ReplaySessionInfoResponse;\n if (info.found !== true) {\n warnOnce(\n \"replay-session-unknown\",\n \"Meticulous replay sidecar holds no mocks for this session — outbound calls will not be mocked.\",\n );\n return { outcome: \"unavailable\" };\n }\n return {\n outcome: \"found\",\n clockAnchorMs:\n typeof info.clockAnchorMs === \"number\" ? info.clockAnchorMs : undefined,\n };\n } catch (error) {\n warnOnce(\n \"replay-session-unreachable\",\n \"Could not reach the Meticulous replay sidecar — will retry on the next request.\",\n error,\n );\n return { outcome: \"unreachable\" };\n }\n};\n\n/**\n * Looks up a recorded response for one outbound call. Never rejects; returns undefined when\n * the sidecar could not be consulted, which callers treat the same as a miss (let the real\n * call through).\n */\nexport const postOutboundFetchLookup = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n payload: OutboundFetchLookupRequest,\n): Promise<OutboundFetchLookupResponse | undefined> => {\n try {\n const response = await fetchFn(\n `${sidecarUrl}${SIDECAR_REPLAY_OUTBOUND_FETCH_PATH}`,\n {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n body: JSON.stringify(payload),\n signal: timeoutSignal(LOOKUP_TIMEOUT_MS),\n },\n );\n if (!response.ok) {\n warnOnce(\n \"replay-lookup-rejected\",\n `Meticulous replay sidecar rejected a mock lookup (HTTP ${response.status}).`,\n );\n await response.arrayBuffer().catch(() => undefined);\n return undefined;\n }\n return (await response.json()) as OutboundFetchLookupResponse;\n } catch (error) {\n warnOnce(\n \"replay-lookup-unreachable\",\n \"Could not reach the Meticulous replay sidecar for a mock lookup — letting the real call through.\",\n error,\n );\n return undefined;\n }\n};\n\n/** AbortSignal.timeout, or undefined where it is unavailable. */\nconst timeoutSignal = (ms: number): AbortSignal | undefined => {\n try {\n return AbortSignal.timeout(ms);\n } catch {\n return undefined;\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;AAqBA,MAAM,0BAA0B;AAChC,MAAM,oBAAoB;;AAG1B,MAAa,oBAAoB,OAC/B,SACA,YACA,WACkB;AAClB,KAAI;EACF,MAAM,UAAgC,EAAE,QAAQ;EAChD,MAAM,WAAW,MAAM,QAAQ,GAAG,aAAa,uBAAuB;GACpE,QAAQ;GACR,SAAS;IACP,gBAAgB;KACf,kCAAA;IACF;GACD,MAAM,KAAK,UAAU,QAAQ;GAC9B,CAAC;AACF,MAAI,CAAC,SAAS,GACZ,UACE,oBACA,oDAAoD,SAAS,OAAO,IACrE;AAEH,QAAM,SAAS,aAAa,CAAC,YAAY,KAAA,EAAU;UAC5C,OAAO;AACd,WACE,uBACA,4GACA,MACD;;;;;;;;;;AA2BL,MAAa,uBAAuB,OAClC,SACA,YACA,sBACqC;CACrC,MAAM,MAAM,GAAG,aAAa,4BAA4B,aAAa,mBACnE,kBACD;AACD,KAAI;EACF,MAAM,WAAW,MAAM,QAAQ,KAAK;GAClC,SAAS,GACN,kCAAA,KACF;GACD,QAAQ,cAAc,wBAAwB;GAC/C,CAAC;AACF,MAAI,CAAC,SAAS,IAAI;AAChB,SAAM,SAAS,aAAa,CAAC,YAAY,KAAA,EAAU;AAInD,OAAI,SAAS,UAAU,KAAK;AAC1B,aACE,8BACA,uEAAuE,SAAS,OAAO,qCACxF;AACD,WAAO,EAAE,SAAS,eAAe;;AAEnC,YACE,8BACA,8EAA8E,SAAS,OAAO,wCAC/F;AACD,UAAO,EAAE,SAAS,eAAe;;EAEnC,MAAM,OAAQ,MAAM,SAAS,MAAM;AACnC,MAAI,KAAK,UAAU,MAAM;AACvB,YACE,0BACA,iGACD;AACD,UAAO,EAAE,SAAS,eAAe;;AAEnC,SAAO;GACL,SAAS;GACT,eACE,OAAO,KAAK,kBAAkB,WAAW,KAAK,gBAAgB,KAAA;GACjE;UACM,OAAO;AACd,WACE,8BACA,mFACA,MACD;AACD,SAAO,EAAE,SAAS,eAAe;;;;;;;;AASrC,MAAa,0BAA0B,OACrC,SACA,YACA,YACqD;AACrD,KAAI;EACF,MAAM,WAAW,MAAM,QACrB,GAAG,aAAa,sCAChB;GACE,QAAQ;GACR,SAAS;IACP,gBAAgB;KACf,kCAAA;IACF;GACD,MAAM,KAAK,UAAU,QAAQ;GAC7B,QAAQ,cAAc,kBAAkB;GACzC,CACF;AACD,MAAI,CAAC,SAAS,IAAI;AAChB,YACE,0BACA,0DAA0D,SAAS,OAAO,IAC3E;AACD,SAAM,SAAS,aAAa,CAAC,YAAY,KAAA,EAAU;AACnD;;AAEF,SAAQ,MAAM,SAAS,MAAM;UACtB,OAAO;AACd,WACE,6BACA,oGACA,MACD;AACD;;;;AAKJ,MAAM,iBAAiB,OAAwC;AAC7D,KAAI;AACF,SAAO,YAAY,QAAQ,GAAG;SACxB;AACN","debugId":"81b6dfa3-bea2-53e3-a6be-f23b3b050e4c"}
@@ -0,0 +1,122 @@
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){}}();
3
+ import { requestCaptureContext } from "./context.js";
4
+ import { warnOnce } from "./log.js";
5
+ //#region src/virtual-clock.ts
6
+ /**
7
+ * Freezes the worker's wall clock at the replayed session's last recorded activity.
8
+ *
9
+ * Without this, replay fails even when every outbound call is mocked correctly: credentials
10
+ * minted during the recording (Firebase session cookies, OAuth access tokens) carry
11
+ * short expiries, so by replay time the app rejects its own recorded credentials as stale.
12
+ * Anchoring at the *end* of the recording means everything minted mid-session is already
13
+ * issued but not yet expired.
14
+ *
15
+ * Ported from the Node backend recorder's replay clock, with two differences:
16
+ * - the anchor comes from the request context rather than a session→anchor map, because
17
+ * workerd's AsyncLocalStorage propagates reliably from the inbound handler. The Node
18
+ * version needs a process-wide "most recently seen session" fallback, which can
19
+ * resolve the wrong session on a replica serving several at once;
20
+ * - the busy-wait breaker schedules on `setTimeout` — workerd has no `setImmediate`.
21
+ *
22
+ * Only `Date` is patched, matching the Node implementation's scope (no `Intl`, no
23
+ * `Temporal`, no timer virtualisation).
24
+ */
25
+ const OriginalDate = globalThis.Date;
26
+ const BREAK_AFTER = 2e4;
27
+ let lastLoopBreakBaseMs = 0;
28
+ let sameKeyCount = 0;
29
+ let loopBreakResetScheduled = false;
30
+ const applyLoopBreak = (baseMs) => {
31
+ if (!loopBreakResetScheduled) {
32
+ loopBreakResetScheduled = true;
33
+ try {
34
+ setTimeout(() => {
35
+ sameKeyCount = 0;
36
+ loopBreakResetScheduled = false;
37
+ }, 0);
38
+ } catch {
39
+ loopBreakResetScheduled = false;
40
+ }
41
+ }
42
+ if (baseMs === lastLoopBreakBaseMs) sameKeyCount++;
43
+ else {
44
+ lastLoopBreakBaseMs = baseMs;
45
+ sameKeyCount = 0;
46
+ }
47
+ return sameKeyCount <= BREAK_AFTER ? baseMs : baseMs + (sameKeyCount - BREAK_AFTER);
48
+ };
49
+ /**
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.
53
+ */
54
+ const resolveVirtualNow = () => {
55
+ const ctx = requestCaptureContext.getStore();
56
+ if (ctx === void 0 || ctx.mode !== "replay") return null;
57
+ return ctx.clockAnchorMs === void 0 ? null : applyLoopBreak(ctx.clockAnchorMs);
58
+ };
59
+ /**
60
+ * What `Date.now()` reports. A standalone function rather than only a class static, so the
61
+ * callable shim below can reuse it without referencing an unbound method — it touches no
62
+ * `this`, so there is nothing to lose.
63
+ */
64
+ const virtualDateNow = () => {
65
+ const virtualNow = resolveVirtualNow();
66
+ return virtualNow === null ? OriginalDate.now() : virtualNow;
67
+ };
68
+ var VirtualDateClass = class extends OriginalDate {
69
+ constructor(...args) {
70
+ if (args.length > 0) {
71
+ super(...args);
72
+ return;
73
+ }
74
+ const virtualNow = resolveVirtualNow();
75
+ if (virtualNow === null) {
76
+ super();
77
+ return;
78
+ }
79
+ super(virtualNow);
80
+ }
81
+ static now() {
82
+ return virtualDateNow();
83
+ }
84
+ };
85
+ function VirtualDate(...args) {
86
+ if (!(this instanceof VirtualDate)) return new VirtualDateClass().toString();
87
+ return new VirtualDateClass(...args);
88
+ }
89
+ VirtualDate.now = virtualDateNow;
90
+ VirtualDate.parse = OriginalDate.parse;
91
+ VirtualDate.UTC = OriginalDate.UTC;
92
+ Object.defineProperty(VirtualDate, "prototype", {
93
+ value: VirtualDateClass.prototype,
94
+ writable: false,
95
+ configurable: false
96
+ });
97
+ VirtualDateClass.prototype.constructor = VirtualDate;
98
+ let installed = false;
99
+ /**
100
+ * Replaces the global `Date` with the per-request virtual clock. Idempotent, and a no-op
101
+ * failure: a worker that will not let its `Date` be replaced still replays, just without
102
+ * clock virtualisation.
103
+ *
104
+ * Called lazily from the replay branch of the request wrapper rather than at module scope:
105
+ * record mode and deployed production workers then pay nothing, and the package declares no
106
+ * `sideEffects`, so a module-scope side effect could legitimately be tree-shaken away by a
107
+ * customer's bundler.
108
+ */
109
+ const installVirtualClock = () => {
110
+ if (installed) return;
111
+ installed = true;
112
+ try {
113
+ globalThis.Date = VirtualDate;
114
+ } catch (error) {
115
+ warnOnce("virtual-clock", "Could not install the Meticulous replay clock — recorded credentials may be treated as expired.", error);
116
+ }
117
+ };
118
+ //#endregion
119
+ export { installVirtualClock };
120
+
121
+ //# sourceMappingURL=virtual-clock.js.map
122
+ //# debugId=ef892b9a-fcab-5f5d-baed-592098a17dd5
@@ -0,0 +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"}