@alwaysmeticulous/backend-recorder-workerd 2.325.0 → 2.327.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.
Files changed (78) hide show
  1. package/README.md +94 -4
  2. package/dist/binding-patch.js +17 -8
  3. package/dist/binding-patch.js.map +1 -1
  4. package/dist/body-capture.js +2 -2
  5. package/dist/body-capture.js.map +1 -1
  6. package/dist/capture-buffer.js +114 -0
  7. package/dist/capture-buffer.js.map +1 -0
  8. package/dist/context.js +6 -3
  9. package/dist/context.js.map +1 -1
  10. package/dist/error-capture.d.ts +44 -0
  11. package/dist/error-capture.js +65 -0
  12. package/dist/error-capture.js.map +1 -0
  13. package/dist/fetch-patch.js +5 -5
  14. package/dist/fetch-patch.js.map +1 -1
  15. package/dist/index.d.ts +62 -39
  16. package/dist/index.js +65 -179
  17. package/dist/index.js.map +1 -1
  18. package/dist/kv-capture.js +2 -2
  19. package/dist/kv-capture.js.map +1 -1
  20. package/dist/kv-patch.js +9 -9
  21. package/dist/kv-patch.js.map +1 -1
  22. package/dist/log.js +2 -2
  23. package/dist/log.js.map +1 -1
  24. package/dist/original-fetch.js +2 -2
  25. package/dist/original-fetch.js.map +1 -1
  26. package/dist/outbound-capture.js +20 -28
  27. package/dist/outbound-capture.js.map +1 -1
  28. package/dist/pages-function.d.ts +33 -0
  29. package/dist/pages-function.js +28 -0
  30. package/dist/pages-function.js.map +1 -0
  31. package/dist/postgres/capture.d.ts +108 -0
  32. package/dist/postgres/capture.js +136 -0
  33. package/dist/postgres/capture.js.map +1 -0
  34. package/dist/postgres/with-meticulous-postgres.d.ts +32 -0
  35. package/dist/postgres/with-meticulous-postgres.js +152 -0
  36. package/dist/postgres/with-meticulous-postgres.js.map +1 -0
  37. package/dist/protocol.d.ts +88 -28
  38. package/dist/protocol.js +15 -10
  39. package/dist/protocol.js.map +1 -1
  40. package/dist/provisional-session-id.d.ts +58 -0
  41. package/dist/provisional-session-id.js +75 -0
  42. package/dist/provisional-session-id.js.map +1 -0
  43. package/dist/publish-session-id.js +41 -0
  44. package/dist/publish-session-id.js.map +1 -0
  45. package/dist/redact-body.js +2 -2
  46. package/dist/redact-body.js.map +1 -1
  47. package/dist/replay-fetch.js +17 -13
  48. package/dist/replay-fetch.js.map +1 -1
  49. package/dist/replay-sidecar-url.js +2 -2
  50. package/dist/replay-sidecar-url.js.map +1 -1
  51. package/dist/sidecar-client.js +31 -20
  52. package/dist/sidecar-client.js.map +1 -1
  53. package/dist/sidecar-transport.d.ts +38 -0
  54. package/dist/sidecar-transport.js +52 -0
  55. package/dist/sidecar-transport.js.map +1 -0
  56. package/dist/spans/attributes.d.ts +84 -0
  57. package/dist/spans/attributes.js +88 -0
  58. package/dist/spans/attributes.js.map +1 -0
  59. package/dist/spans/readable-span.d.ts +78 -0
  60. package/dist/spans/readable-span.js +35 -0
  61. package/dist/spans/readable-span.js.map +1 -0
  62. package/dist/spans/serialize-span.d.ts +14 -0
  63. package/dist/spans/serialize-span.js +38 -0
  64. package/dist/spans/serialize-span.js.map +1 -0
  65. package/dist/spans/span-builder.d.ts +61 -0
  66. package/dist/spans/span-builder.js +250 -0
  67. package/dist/spans/span-builder.js.map +1 -0
  68. package/dist/spans/url-redaction.d.ts +14 -0
  69. package/dist/spans/url-redaction.js +40 -0
  70. package/dist/spans/url-redaction.js.map +1 -0
  71. package/dist/virtual-clock.js +2 -2
  72. package/dist/virtual-clock.js.map +1 -1
  73. package/dist/virtual-random.js +47 -20
  74. package/dist/virtual-random.js.map +1 -1
  75. package/dist/with-meticulous.d.ts +32 -0
  76. package/dist/with-meticulous.js +0 -0
  77. package/dist/with-meticulous.js.map +1 -0
  78. package/package.json +2 -1
@@ -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\n/**\n * A request carrying this header set to `\"true\"` is never served from the recording and\n * never fails on a miss — it goes to the real network. Use it for a call that must be live\n * during replay (a health probe, telemetry, a resource the recording cannot cover).\n *\n * Same convention and value as the frontend network stubbing and the Node backend recorder\n * (`METICULOUS_PASSTHROUGH_HEADER` in `@alwaysmeticulous/backend-recorder-js`); the value\n * must stay in step with both.\n */\nexport const METICULOUS_PASSTHROUGH_HEADER = \"meticulous-passthrough\";\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 * A recorded response to serve, a miss the caller must fail, or an instruction to let the\n * real call through. An unrecognised `outcome` must be treated as `passthrough`, so an older\n * shim keeps 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 /**\n * The store was consulted and holds nothing for this call. Replay is hermetic, so the\n * shim fails the request rather than letting it reach the real service — matching the\n * Node backend recorder's http/undici mocks.\n */\n | { outcome: \"no-mock\" }\n /**\n * The call cannot be looked up at all (an unparseable URL), so there is no miss to report.\n * Also what an older sidecar answers on a miss, in which case the shim degrades to the\n * pre-`no-mock` fail-open behaviour rather than breaking the app.\n */\n | { outcome: \"passthrough\" };\n"],"names":[],"mappings":";;;;;;;;AAMA,MAAa,2BAA2B;;AAGxC,MAAa,kCACX;;;;;AAMF,MAAa,6BAA6B;;;;;;;;;;AAW1C,MAAa,gCAAgC;AAE7C,MAAa,sBAAsB;;;;;;AAOnC,MAAa,8BAA8B;AAC3C,MAAa,qCAAqC;;;;;;;;;AAUlD,MAAa,4BACX;;;;;;;;AASF,MAAa,mBAAmB,CAC9B,gBACA,2BACD","debugId":"40d02ddc-000e-5f33-972c-1fa95ad7e2ea"}
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 = \"2\";\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\n/** Header identifying one replay attempt of a recorded frontend session. */\nexport const REPLAY_ID_HEADER = \"x-meticulous-replay-id\";\n\n/**\n * A request carrying this header set to `\"true\"` is never served from the recording and\n * never fails on a miss — it goes to the real network. Use it for a call that must be live\n * during replay (a health probe, telemetry, a resource the recording cannot cover).\n *\n * Same convention and value as the frontend network stubbing and the Node backend recorder\n * (`METICULOUS_PASSTHROUGH_HEADER` in `@alwaysmeticulous/backend-recorder-js`); the value\n * must stay in step with both.\n */\nexport const METICULOUS_PASSTHROUGH_HEADER = \"meticulous-passthrough\";\n\nexport const SIDECAR_EVENTS_PATH = \"/v1/events\";\n\n/**\n * Replay routes. A sidecar started in record mode answers 404 on both. Replay is hermetic,\n * so an unanswered outbound-fetch lookup fails the app's call rather than letting it reach\n * the real service — the same as any other failure to stub.\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.\n *\n * The Node backend recorder persists the same two, plus the inbound request's\n * `Cookie` header on SERVER spans (`recorded-request-headers.ts`). This list\n * has not followed it there, so a workerd recording of an authenticated server\n * render still carries no session cookie.\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\n/**\n * The fields every capture event carries to say which request, session and trace it belongs to.\n */\ninterface CorrelatedEvent {\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 /**\n * Trace the request's spans belong to (32 hex chars), minted by the shim.\n *\n * The shim mints it, rather than the sidecar deriving it from {@link requestId}, so span\n * assembly is stateless: a sidecar that had to remember `requestId → traceId` would give a\n * request's SERVER span and its CLIENT children different traces whenever it lost that memory\n * between two batches — which, for a Worker-hosted sidecar, an eviction can do at any moment.\n *\n * Optional so a sidecar keeps working against a shim that predates this field, in which case\n * it falls back to its own per-`requestId` map.\n */\n traceId?: string;\n /**\n * Span id of the request's SERVER span (16 hex chars), also minted by the shim: it is the\n * parent of every CLIENT span the request produced, and the inbound event's own span id.\n */\n serverSpanId?: string;\n}\n\ninterface BaseRequestEvent extends CorrelatedEvent {\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 * Where {@link BaseRequestEvent.frontendSessionId} came from. Absent means the browser\n * minted it and sent it on the request, which is the normal case. `\"backend\"` means the\n * shim minted it for a document navigation the browser could not tag, and the page may or\n * may not have gone on to adopt it — a distinction ingestion needs, because an unadopted\n * backend-minted id names a session that will never exist and must be treated as unstamped.\n * The sidecar turns it into `meticulous.session_id_origin`.\n *\n * Optional, and therefore compatible with an older sidecar (which ignores it) — which is\n * why adding it does not bump {@link SIDECAR_PROTOCOL_VERSION}, a strict-equality check\n * whose bump would instead make this shim unusable against one.\n */\n sessionIdOrigin?: \"backend\";\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 extends CorrelatedEvent {\n kind: \"kv\";\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\n/**\n * One postgres.js query made while handling an inbound request (becomes a CLIENT span).\n *\n * Deliberately carries the same fields the Node recorder puts on a postgres.js span rather than\n * anything Worker-specific: `PostgresJsMockStore` builds its match key from those attributes at\n * load time, so a query recorded here replays through the existing Node mock path unchanged.\n * See `postgres/capture.ts` for the serialization, which both surfaces share.\n */\nexport interface PostgresQueryEvent extends CorrelatedEvent {\n kind: \"postgres\";\n /** SQL rebuilt from the tagged template's chunks, with `$1`, `$2`… placeholders. */\n queryText: string;\n /** JSON of the interpolated values, from `serializePostgresJsArgs`. */\n params: string;\n /** `\"\"`, `\"raw\"` or `\"values\"` — the row shape the query asked for. */\n rowMode: string;\n /** JSON of the resolved `Result`, capped at {@link MAX_POSTGRES_JS_RESULT_SIZE}. */\n result?: CapturedBody;\n /**\n * JSON of the rejection, from `serializePostgresJsError`. Set instead of {@link result}, and\n * distinct from the transport-level `error` string on the HTTP-shaped events: this one is a\n * recorded outcome the replay reproduces, not a note that capture failed.\n */\n errorJson?: string;\n startTimeMs: number;\n endTimeMs: number;\n}\n\nexport type CaptureEvent =\n | InboundRequestEvent\n | OutboundRequestEvent\n | BindingRequestEvent\n | KvOperationEvent\n | PostgresQueryEvent;\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 replayId: 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 * A recorded response to serve, or a signal the caller must fail the request. Replay is\n * hermetic, so the shim never turns any of these into a real call — nor an outcome it does\n * not recognise. The only live path is the request-side `meticulous-passthrough` header,\n * handled before the lookup, not any response here.\n */\nexport type OutboundFetchLookupResponse =\n | {\n outcome: \"mock\";\n statusCode: number;\n body: string;\n headers: Record<string, string>;\n }\n /**\n * The store holds nothing for this call — a genuine miss, or a call the sidecar could not\n * look up at all (an unparseable URL). Replay is hermetic, so the shim fails the request\n * rather than letting it reach the real service — matching the Node backend recorder's\n * http/undici mocks. There is deliberately no \"let the call through\" outcome: the only\n * live path is the request-side `meticulous-passthrough` header.\n */\n | { outcome: \"no-mock\" };\n"],"names":[],"mappings":";;;;;;;;AAMA,MAAa,2BAA2B;;AAGxC,MAAa,kCACX;;;;;AAMF,MAAa,6BAA6B;;AAG1C,MAAa,mBAAmB;;;;;;;;;;AAWhC,MAAa,gCAAgC;AAE7C,MAAa,sBAAsB;;;;;;AAOnC,MAAa,8BAA8B;AAC3C,MAAa,qCAAqC;;;;;;;;;AAUlD,MAAa,4BACX;;;;;;;;;;;AAYF,MAAa,mBAAmB,CAC9B,gBACA,2BACD","debugId":"ca4290fa-420e-5b3e-be68-65bc78d7f094"}
@@ -0,0 +1,58 @@
1
+ //#region src/provisional-session-id.d.ts
2
+ /**
3
+ * A session id the *backend* mints, for the one request that can never carry one from the
4
+ * browser: a top-level document navigation. Browsers cannot add custom headers to a
5
+ * navigation, and on the session-starting navigation the frontend has not minted an id yet
6
+ * (it does so only once the HTML has arrived and the snippet has run) — so the server-side
7
+ * render's spans are recorded against no session and nothing about that traffic can be
8
+ * replayed exactly.
9
+ *
10
+ * Everything here is runtime-agnostic on purpose. Both backend recorders mint the same ids
11
+ * by the same rule and publish them under the same `Server-Timing` metric, and all three are
12
+ * wire contracts with the page, so there is exactly one implementation — the same reason
13
+ * kv-capture.ts lives here rather than being written twice. The Node recorder imports these
14
+ * from the package entry; see `provisional-session-id.ts` in
15
+ * `@alwaysmeticulous/backend-recorder-js` for the `http.IncomingMessage` adapter, and
16
+ * `publish-session-id.ts` next door for the workerd one.
17
+ */
18
+ /**
19
+ * Reads one request header by its lower-cased name. Both `IncomingMessage.headers[name]`
20
+ * (after the caller flattens Node's `string | string[]`) and `Headers.get(name)` satisfy it,
21
+ * which is what lets the rules below be shared.
22
+ */
23
+ type HeaderReader = (name: string) => string | null | undefined;
24
+ declare const SERVER_TIMING_HEADER = "server-timing";
25
+ /**
26
+ * The `Server-Timing` metric name carrying a backend-minted session id to the page. A wire
27
+ * contract with the frontend recorder, which matches on it case-insensitively when reading
28
+ * `PerformanceNavigationTiming.serverTiming` (see `resolve-session-id.ts` in
29
+ * packages/recorder). Must be a valid HTTP token — no `.` or `:`, which is why it is not the
30
+ * dotted span-attribute name.
31
+ */
32
+ declare const SERVER_TIMING_SESSION_METRIC = "metsession";
33
+ /**
34
+ * `Server-Timing` is the one response header a document's own JavaScript can read back, and
35
+ * the navigation entry persists for the document's lifetime — so unlike a header-sniffing
36
+ * approach this works no matter how late the snippet loads.
37
+ */
38
+ declare const buildServerTimingSessionEntry: (sessionId: string) => string;
39
+ /**
40
+ * Mints an id in exactly the frontend recorder's format: `<ISO timestamp>_<nanoid>` (see
41
+ * `createConfig` in packages/recorder). The format is a wire contract, not cosmetic —
42
+ * ingestion parses the ISO prefix back out to anchor the inferred-attachment window
43
+ * (`parseTimestampFromFrontendSessionId` in lambda-upload-sessions), and the page uses it to
44
+ * reject an id stale enough to have come from a cache.
45
+ */
46
+ declare const mintProvisionalSessionId: () => string;
47
+ /**
48
+ * Whether to mint for this inbound request. Deliberately narrow: an id nothing adopts is
49
+ * worse than no id at all, because a span stamped with a session that never materialises is
50
+ * excluded from the time-window fallback that would otherwise have caught it. So we mint only
51
+ * for what is plausibly a browser navigating to a page.
52
+ *
53
+ * The caller decides separately whether minting is enabled at all.
54
+ */
55
+ declare const isProvisionalSessionIdCandidate: (method: string, getHeader: HeaderReader) => boolean;
56
+ //#endregion
57
+ export { HeaderReader, SERVER_TIMING_HEADER, SERVER_TIMING_SESSION_METRIC, buildServerTimingSessionEntry, isProvisionalSessionIdCandidate, mintProvisionalSessionId };
58
+ //# sourceMappingURL=provisional-session-id.d.ts.map
@@ -0,0 +1,75 @@
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]="08993983-84c7-52a8-95ae-69986b1ed730")}catch(e){}}();
3
+ import "./protocol.js";
4
+ //#region src/provisional-session-id.ts
5
+ const SERVER_TIMING_HEADER = "server-timing";
6
+ /**
7
+ * The `Server-Timing` metric name carrying a backend-minted session id to the page. A wire
8
+ * contract with the frontend recorder, which matches on it case-insensitively when reading
9
+ * `PerformanceNavigationTiming.serverTiming` (see `resolve-session-id.ts` in
10
+ * packages/recorder). Must be a valid HTTP token — no `.` or `:`, which is why it is not the
11
+ * dotted span-attribute name.
12
+ */
13
+ const SERVER_TIMING_SESSION_METRIC = "metsession";
14
+ /**
15
+ * `Server-Timing` is the one response header a document's own JavaScript can read back, and
16
+ * the navigation entry persists for the document's lifetime — so unlike a header-sniffing
17
+ * approach this works no matter how late the snippet loads.
18
+ */
19
+ const buildServerTimingSessionEntry = (sessionId) => `${SERVER_TIMING_SESSION_METRIC};desc="${sessionId}"`;
20
+ /**
21
+ * nanoid's default alphabet and length, reproduced rather than imported: this package carries
22
+ * no runtime dependencies, and an id that did not look like every other session id would be a
23
+ * gratuitous difference in S3 keys and URLs. 64 characters divides 256 exactly, so masking a
24
+ * random byte with 63 is uniform — which is how nanoid itself generates them.
25
+ */
26
+ const ID_ALPHABET = "ModuleSymbhasOwnPr-0123456789ABCDEFGHNRVfgctiUvz_KqYTJkLxpZXIjQW";
27
+ const ID_LENGTH = 21;
28
+ /**
29
+ * Mints an id in exactly the frontend recorder's format: `<ISO timestamp>_<nanoid>` (see
30
+ * `createConfig` in packages/recorder). The format is a wire contract, not cosmetic —
31
+ * ingestion parses the ISO prefix back out to anchor the inferred-attachment window
32
+ * (`parseTimestampFromFrontendSessionId` in lambda-upload-sessions), and the page uses it to
33
+ * reject an id stale enough to have come from a cache.
34
+ */
35
+ const mintProvisionalSessionId = () => {
36
+ const bytes = new Uint8Array(ID_LENGTH);
37
+ crypto.getRandomValues(bytes);
38
+ let suffix = "";
39
+ for (const byte of bytes) suffix += ID_ALPHABET[byte & 63];
40
+ return `${(/* @__PURE__ */ new Date()).toISOString()}_${suffix}`;
41
+ };
42
+ /**
43
+ * Whether to mint for this inbound request. Deliberately narrow: an id nothing adopts is
44
+ * worse than no id at all, because a span stamped with a session that never materialises is
45
+ * excluded from the time-window fallback that would otherwise have caught it. So we mint only
46
+ * for what is plausibly a browser navigating to a page.
47
+ *
48
+ * The caller decides separately whether minting is enabled at all.
49
+ */
50
+ const isProvisionalSessionIdCandidate = (method, getHeader) => {
51
+ if (getHeader("x-meticulous-session-id") != null) return false;
52
+ const normalizedMethod = method.toUpperCase();
53
+ if (normalizedMethod !== "GET" && normalizedMethod !== "HEAD") return false;
54
+ return isDocumentNavigation(getHeader);
55
+ };
56
+ /**
57
+ * `Sec-Fetch-Dest` is the exact signal and every current browser sends it: `document` for a
58
+ * navigation, `empty` for an in-page fetch (so a Next.js RSC navigation, which is a fetch, is
59
+ * correctly excluded), `iframe` for a subframe — excluded too, since the top frame owns the
60
+ * session and an iframe recorder reports into it.
61
+ *
62
+ * Absent, the caller is an older browser or not a browser at all (curl, a health check, a
63
+ * crawler), and `Accept` is the only hint left.
64
+ */
65
+ const isDocumentNavigation = (getHeader) => {
66
+ const dest = getHeader("sec-fetch-dest");
67
+ if (typeof dest === "string") return dest === "document";
68
+ const accept = getHeader("accept");
69
+ return typeof accept === "string" && accept.includes("text/html");
70
+ };
71
+ //#endregion
72
+ export { SERVER_TIMING_HEADER, SERVER_TIMING_SESSION_METRIC, buildServerTimingSessionEntry, isProvisionalSessionIdCandidate, mintProvisionalSessionId };
73
+
74
+ //# sourceMappingURL=provisional-session-id.js.map
75
+ //# debugId=08993983-84c7-52a8-95ae-69986b1ed730
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provisional-session-id.js","sources":["../src/provisional-session-id.ts"],"sourcesContent":["import { FRONTEND_SESSION_ID_HEADER } from \"./protocol\";\n\n/**\n * A session id the *backend* mints, for the one request that can never carry one from the\n * browser: a top-level document navigation. Browsers cannot add custom headers to a\n * navigation, and on the session-starting navigation the frontend has not minted an id yet\n * (it does so only once the HTML has arrived and the snippet has run) — so the server-side\n * render's spans are recorded against no session and nothing about that traffic can be\n * replayed exactly.\n *\n * Everything here is runtime-agnostic on purpose. Both backend recorders mint the same ids\n * by the same rule and publish them under the same `Server-Timing` metric, and all three are\n * wire contracts with the page, so there is exactly one implementation — the same reason\n * kv-capture.ts lives here rather than being written twice. The Node recorder imports these\n * from the package entry; see `provisional-session-id.ts` in\n * `@alwaysmeticulous/backend-recorder-js` for the `http.IncomingMessage` adapter, and\n * `publish-session-id.ts` next door for the workerd one.\n */\n\n/**\n * Reads one request header by its lower-cased name. Both `IncomingMessage.headers[name]`\n * (after the caller flattens Node's `string | string[]`) and `Headers.get(name)` satisfy it,\n * which is what lets the rules below be shared.\n */\nexport type HeaderReader = (name: string) => string | null | undefined;\n\nexport const SERVER_TIMING_HEADER = \"server-timing\";\n\n/**\n * The `Server-Timing` metric name carrying a backend-minted session id to the page. A wire\n * contract with the frontend recorder, which matches on it case-insensitively when reading\n * `PerformanceNavigationTiming.serverTiming` (see `resolve-session-id.ts` in\n * packages/recorder). Must be a valid HTTP token — no `.` or `:`, which is why it is not the\n * dotted span-attribute name.\n */\nexport const SERVER_TIMING_SESSION_METRIC = \"metsession\";\n\n/**\n * `Server-Timing` is the one response header a document's own JavaScript can read back, and\n * the navigation entry persists for the document's lifetime — so unlike a header-sniffing\n * approach this works no matter how late the snippet loads.\n */\nexport const buildServerTimingSessionEntry = (sessionId: string): string =>\n `${SERVER_TIMING_SESSION_METRIC};desc=\"${sessionId}\"`;\n\n/**\n * nanoid's default alphabet and length, reproduced rather than imported: this package carries\n * no runtime dependencies, and an id that did not look like every other session id would be a\n * gratuitous difference in S3 keys and URLs. 64 characters divides 256 exactly, so masking a\n * random byte with 63 is uniform — which is how nanoid itself generates them.\n */\nconst ID_ALPHABET =\n \"ModuleSymbhasOwnPr-0123456789ABCDEFGHNRVfgctiUvz_KqYTJkLxpZXIjQW\";\nconst ID_LENGTH = 21;\n\n/**\n * Mints an id in exactly the frontend recorder's format: `<ISO timestamp>_<nanoid>` (see\n * `createConfig` in packages/recorder). The format is a wire contract, not cosmetic —\n * ingestion parses the ISO prefix back out to anchor the inferred-attachment window\n * (`parseTimestampFromFrontendSessionId` in lambda-upload-sessions), and the page uses it to\n * reject an id stale enough to have come from a cache.\n */\nexport const mintProvisionalSessionId = (): string => {\n const bytes = new Uint8Array(ID_LENGTH);\n crypto.getRandomValues(bytes);\n let suffix = \"\";\n for (const byte of bytes) {\n suffix += ID_ALPHABET[byte & 63];\n }\n return `${new Date().toISOString()}_${suffix}`;\n};\n\n/**\n * Whether to mint for this inbound request. Deliberately narrow: an id nothing adopts is\n * worse than no id at all, because a span stamped with a session that never materialises is\n * excluded from the time-window fallback that would otherwise have caught it. So we mint only\n * for what is plausibly a browser navigating to a page.\n *\n * The caller decides separately whether minting is enabled at all.\n */\nexport const isProvisionalSessionIdCandidate = (\n method: string,\n getHeader: HeaderReader,\n): boolean => {\n // A request that already names its session needs nothing from us.\n if (getHeader(FRONTEND_SESSION_ID_HEADER) != null) {\n return false;\n }\n const normalizedMethod = method.toUpperCase();\n if (normalizedMethod !== \"GET\" && normalizedMethod !== \"HEAD\") {\n return false;\n }\n return isDocumentNavigation(getHeader);\n};\n\n/**\n * `Sec-Fetch-Dest` is the exact signal and every current browser sends it: `document` for a\n * navigation, `empty` for an in-page fetch (so a Next.js RSC navigation, which is a fetch, is\n * correctly excluded), `iframe` for a subframe — excluded too, since the top frame owns the\n * session and an iframe recorder reports into it.\n *\n * Absent, the caller is an older browser or not a browser at all (curl, a health check, a\n * crawler), and `Accept` is the only hint left.\n */\nconst isDocumentNavigation = (getHeader: HeaderReader): boolean => {\n const dest = getHeader(\"sec-fetch-dest\");\n if (typeof dest === \"string\") {\n return dest === \"document\";\n }\n const accept = getHeader(\"accept\");\n return typeof accept === \"string\" && accept.includes(\"text/html\");\n};\n"],"names":[],"mappings":";;;;AA0BA,MAAa,uBAAuB;;;;;;;;AASpC,MAAa,+BAA+B;;;;;;AAO5C,MAAa,iCAAiC,cAC5C,GAAG,6BAA6B,SAAS,UAAU;;;;;;;AAQrD,MAAM,cACJ;AACF,MAAM,YAAY;;;;;;;;AASlB,MAAa,iCAAyC;CACpD,MAAM,QAAQ,IAAI,WAAW,UAAU;AACvC,QAAO,gBAAgB,MAAM;CAC7B,IAAI,SAAS;AACb,MAAK,MAAM,QAAQ,MACjB,WAAU,YAAY,OAAO;AAE/B,QAAO,oBAAG,IAAI,MAAM,EAAC,aAAa,CAAC,GAAG;;;;;;;;;;AAWxC,MAAa,mCACX,QACA,cACY;AAEZ,KAAI,UAAA,0BAAqC,IAAI,KAC3C,QAAO;CAET,MAAM,mBAAmB,OAAO,aAAa;AAC7C,KAAI,qBAAqB,SAAS,qBAAqB,OACrD,QAAO;AAET,QAAO,qBAAqB,UAAU;;;;;;;;;;;AAYxC,MAAM,wBAAwB,cAAqC;CACjE,MAAM,OAAO,UAAU,iBAAiB;AACxC,KAAI,OAAO,SAAS,SAClB,QAAO,SAAS;CAElB,MAAM,SAAS,UAAU,SAAS;AAClC,QAAO,OAAO,WAAW,YAAY,OAAO,SAAS,YAAY","debugId":"08993983-84c7-52a8-95ae-69986b1ed730"}
@@ -0,0 +1,41 @@
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]="c7b8eed0-1eb8-5c3b-9d7f-7dec5248e263")}catch(e){}}();
3
+ import { warnOnce } from "./log.js";
4
+ import { hasWebSocket } from "./outbound-capture.js";
5
+ import { SERVER_TIMING_HEADER, buildServerTimingSessionEntry } from "./provisional-session-id.js";
6
+ //#region src/publish-session-id.ts
7
+ /**
8
+ * Publishes a backend-minted session id to the page on the response it is about to receive,
9
+ * so the frontend recorder adopts it instead of minting its own and the whole page load —
10
+ * server-side render included — ends up under one session id.
11
+ *
12
+ * Returns the response to send, which is not always the one passed in: a `Response` handed
13
+ * back by `fetch` or by an assets binding has immutable headers, and the only way to add one
14
+ * is to rebuild around the same body. Best-effort throughout — a recorder must never break
15
+ * the customer's app, so anything unexpected returns the original response unchanged.
16
+ *
17
+ * Unlike the Node recorder there is no `headersSent` race to lose: workerd flushes the
18
+ * response only once the handler has returned it to us, so even a streaming SSR body still
19
+ * has its headers in front of it at this point.
20
+ */
21
+ const publishSessionIdOnResponse = (response, sessionId) => {
22
+ const entry = buildServerTimingSessionEntry(sessionId);
23
+ try {
24
+ response.headers.append(SERVER_TIMING_HEADER, entry);
25
+ return response;
26
+ } catch {}
27
+ try {
28
+ if (hasWebSocket(response) || response.status === 101) return response;
29
+ const rebuilt = new Response(response.body, response);
30
+ rebuilt.headers.append(SERVER_TIMING_HEADER, entry);
31
+ return rebuilt;
32
+ } catch (error) {
33
+ warnOnce("publish-session-id", "Failed to publish the backend-minted session id on the response.", error);
34
+ return response;
35
+ }
36
+ };
37
+ //#endregion
38
+ export { publishSessionIdOnResponse };
39
+
40
+ //# sourceMappingURL=publish-session-id.js.map
41
+ //# debugId=c7b8eed0-1eb8-5c3b-9d7f-7dec5248e263
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publish-session-id.js","sources":["../src/publish-session-id.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\nimport { hasWebSocket } from \"./outbound-capture\";\nimport {\n SERVER_TIMING_HEADER,\n buildServerTimingSessionEntry,\n} from \"./provisional-session-id\";\n\n/**\n * Publishes a backend-minted session id to the page on the response it is about to receive,\n * so the frontend recorder adopts it instead of minting its own and the whole page load —\n * server-side render included — ends up under one session id.\n *\n * Returns the response to send, which is not always the one passed in: a `Response` handed\n * back by `fetch` or by an assets binding has immutable headers, and the only way to add one\n * is to rebuild around the same body. Best-effort throughout — a recorder must never break\n * the customer's app, so anything unexpected returns the original response unchanged.\n *\n * Unlike the Node recorder there is no `headersSent` race to lose: workerd flushes the\n * response only once the handler has returned it to us, so even a streaming SSR body still\n * has its headers in front of it at this point.\n */\nexport const publishSessionIdOnResponse = (\n response: Response,\n sessionId: string,\n): Response => {\n const entry = buildServerTimingSessionEntry(sessionId);\n\n try {\n // Appends rather than replaces: apps publish their own server timings, and clobbering\n // them would be a visible regression in their tooling.\n response.headers.append(SERVER_TIMING_HEADER, entry);\n return response;\n } catch {\n // Immutable headers. Fall through and rebuild.\n }\n\n try {\n // A WebSocket upgrade carries the socket rather than a body and cannot survive being\n // rebuilt; it is never a document navigation either, so there is nothing to publish to.\n if (hasWebSocket(response) || response.status === 101) {\n return response;\n }\n const rebuilt = new Response(response.body, response);\n rebuilt.headers.append(SERVER_TIMING_HEADER, entry);\n return rebuilt;\n } catch (error) {\n warnOnce(\n \"publish-session-id\",\n \"Failed to publish the backend-minted session id on the response.\",\n error,\n );\n return response;\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;AAqBA,MAAa,8BACX,UACA,cACa;CACb,MAAM,QAAQ,8BAA8B,UAAU;AAEtD,KAAI;AAGF,WAAS,QAAQ,OAAO,sBAAsB,MAAM;AACpD,SAAO;SACD;AAIR,KAAI;AAGF,MAAI,aAAa,SAAS,IAAI,SAAS,WAAW,IAChD,QAAO;EAET,MAAM,UAAU,IAAI,SAAS,SAAS,MAAM,SAAS;AACrD,UAAQ,QAAQ,OAAO,sBAAsB,MAAM;AACnD,SAAO;UACA,OAAO;AACd,WACE,sBACA,oEACA,MACD;AACD,SAAO","debugId":"c7b8eed0-1eb8-5c3b-9d7f-7dec5248e263"}
@@ -1,7 +1,7 @@
1
1
  //#region src/redact-body.ts
2
2
  /** Replacement for a redacted value. Matches the Node recorder's redaction marker. */
3
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){}}();
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]="c9ea8914-5f33-5a57-a950-20eae083e1d7")}catch(e){}}();
5
5
  const STR_REDACTED = "REDACTED";
6
6
  const secretKeys = new Set([
7
7
  "accesstoken",
@@ -50,4 +50,4 @@ const redactValue = (value) => {
50
50
  export { STR_REDACTED, redactRequestBody };
51
51
 
52
52
  //# sourceMappingURL=redact-body.js.map
53
- //# debugId=36ac86ad-0c78-5b5e-87b8-88acca7728a4
53
+ //# debugId=c9ea8914-5f33-5a57-a950-20eae083e1d7
@@ -1 +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"}
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":"c9ea8914-5f33-5a57-a950-20eae083e1d7"}
@@ -1,9 +1,9 @@
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]="53c696d1-464e-5822-ad00-17a9779dd439")}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]="151bf2a1-75aa-51ce-be4a-6973d2815547")}catch(e){}}();
3
3
  import { warn, warnOnce } from "./log.js";
4
4
  import { readRequestBodyWithCap } from "./body-capture.js";
5
- import { getOriginalFetch } from "./original-fetch.js";
6
5
  import { METICULOUS_PASSTHROUGH_HEADER } from "./protocol.js";
6
+ import { getOriginalFetch } from "./original-fetch.js";
7
7
  import { postOutboundFetchLookup } from "./sidecar-client.js";
8
8
  //#region src/replay-fetch.ts
9
9
  /**
@@ -16,12 +16,13 @@ import { postOutboundFetchLookup } from "./sidecar-client.js";
16
16
  * Replay is hermetic: a call the recording does not cover must not silently reach the real
17
17
  * service, since that turns a replay into live traffic against real credentials and hides
18
18
  * the gap in the recording behind a response no base replay will reproduce. So anything we
19
- * cannot serve from the recording — a miss, an unreachable sidecar, a mock that cannot be
20
- * represented — throws, matching the Node recorder's http/undici mocks.
19
+ * cannot serve from the recording — a miss, a call the sidecar cannot look up, an
20
+ * unreachable sidecar, a mock that cannot be represented, or an outcome this shim does not
21
+ * recognise — throws, matching the Node recorder's http/undici mocks.
21
22
  *
22
- * The two ways out are an explicit `meticulous-passthrough: true` header on the request, and
23
- * a sidecar that answers `passthrough` (it cannot look the call up, or it predates
24
- * `no-mock`).
23
+ * The only way out is an explicit `meticulous-passthrough: true` header on the request: a
24
+ * call the customer has marked as one that must reach the real service. A failure to stub is
25
+ * never treated as such a mark.
25
26
  */
26
27
  const replayOutboundCall = async (ctx, request, invoke) => {
27
28
  if (hasPassthroughHeader(request)) return invoke(request);
@@ -30,6 +31,7 @@ const replayOutboundCall = async (ctx, request, invoke) => {
30
31
  const requestBody = request.body ? await readRequestBodyWithCap(request.clone().body).catch(() => void 0) : void 0;
31
32
  lookup = {
32
33
  frontendSessionId: ctx.frontendSessionId,
34
+ replayId: ctx.replayId,
33
35
  method: request.method,
34
36
  url: request.url,
35
37
  ...requestBody !== void 0 ? { requestBody } : {}
@@ -39,12 +41,14 @@ const replayOutboundCall = async (ctx, request, invoke) => {
39
41
  }
40
42
  const result = await postOutboundFetchLookup(getOriginalFetch(), ctx.sidecarUrl, lookup);
41
43
  if (result === void 0) throw buildReplayError(request, ctx.frontendSessionId, "the replay sidecar did not answer a mock lookup");
42
- if (result.outcome === "no-mock") throw buildReplayError(request, ctx.frontendSessionId, "no recorded response");
43
- if (result.outcome !== "mock") return invoke(request);
44
- const mocked = buildMockResponse(result);
45
- if (mocked === void 0) throw buildReplayError(request, ctx.frontendSessionId, `the recorded response (status ${result.statusCode}) cannot be represented`);
46
- return mocked;
44
+ if (result.outcome === "mock") {
45
+ const mocked = buildMockResponse(result);
46
+ if (mocked === void 0) throw buildReplayError(request, ctx.frontendSessionId, `the recorded response (status ${result.statusCode}) cannot be represented`);
47
+ return mocked;
48
+ }
49
+ throw buildReplayError(request, ctx.frontendSessionId, describeUnservedOutcome(result.outcome));
47
50
  };
51
+ const describeUnservedOutcome = (outcome) => outcome === "no-mock" ? "no recorded response" : `an unrecognised sidecar outcome (${outcome})`;
48
52
  const hasPassthroughHeader = (request) => {
49
53
  try {
50
54
  return request.headers.get(METICULOUS_PASSTHROUGH_HEADER) === "true";
@@ -126,4 +130,4 @@ const buildMockResponse = (mock) => {
126
130
  export { replayOutboundCall };
127
131
 
128
132
  //# sourceMappingURL=replay-fetch.js.map
129
- //# debugId=53c696d1-464e-5822-ad00-17a9779dd439
133
+ //# debugId=151bf2a1-75aa-51ce-be4a-6973d2815547
@@ -1 +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 { warn, warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport {\n METICULOUS_PASSTHROUGH_HEADER,\n type OutboundFetchLookupRequest,\n} from \"./protocol\";\nimport { postOutboundFetchLookup } from \"./sidecar-client\";\n\n/**\n * Serves a recorded response for an outbound call, or fails it.\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.\n *\n * Replay is hermetic: a call the recording does not cover must not silently reach the real\n * service, since that turns a replay into live traffic against real credentials and hides\n * the gap in the recording behind a response no base replay will reproduce. So anything we\n * cannot serve from the recording — a miss, an unreachable sidecar, a mock that cannot be\n * represented — throws, matching the Node recorder's http/undici mocks.\n *\n * The two ways out are an explicit `meticulous-passthrough: true` header on the request, and\n * a sidecar that answers `passthrough` (it cannot look the call up, or it predates\n * `no-mock`).\n */\nexport const replayOutboundCall = async (\n ctx: RequestReplayContext,\n request: Request,\n invoke: (request: Request) => Promise<Response>,\n): Promise<Response> => {\n if (hasPassthroughHeader(request)) {\n return invoke(request);\n }\n\n let lookup: OutboundFetchLookupRequest;\n try {\n // Read the clone and pass the original through untouched, mirroring the record path.\n // The sidecar hashes what we send here, and it was captured by the same\n // readRequestBodyWithCap the recorder used (redaction included), so the hashes on both\n // 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 // Without a lookup there is nothing to serve, and passing through would be exactly the\n // silent real call this path exists to prevent.\n throw buildReplayError(\n request,\n ctx.frontendSessionId,\n \"could not prepare a mock lookup\",\n error,\n );\n }\n\n const result = await postOutboundFetchLookup(\n getOriginalFetch(),\n ctx.sidecarUrl,\n lookup,\n );\n if (result === undefined) {\n throw buildReplayError(\n request,\n ctx.frontendSessionId,\n \"the replay sidecar did not answer a mock lookup\",\n );\n }\n if (result.outcome === \"no-mock\") {\n throw buildReplayError(\n request,\n ctx.frontendSessionId,\n \"no recorded response\",\n );\n }\n // Covers `passthrough` and an unrecognised `outcome` from a newer sidecar.\n if (result.outcome !== \"mock\") {\n return invoke(request);\n }\n const mocked = buildMockResponse(result);\n if (mocked === undefined) {\n throw buildReplayError(\n request,\n ctx.frontendSessionId,\n `the recorded response (status ${result.statusCode}) cannot be represented`,\n );\n }\n return mocked;\n};\n\nconst hasPassthroughHeader = (request: Request): boolean => {\n try {\n return request.headers.get(METICULOUS_PASSTHROUGH_HEADER) === \"true\";\n } catch {\n return false;\n }\n};\n\n/**\n * Builds — and logs — the error the app sees for a call replay cannot serve. Deliberately\n * names the shim and the session so an app-side stack trace is enough to tell a replay gap\n * from a real bug, and logs as well because an app that catches its own fetch errors would\n * otherwise swallow the only signal.\n *\n * The query string is left out on purpose — it routinely carries API keys, and the sidecar\n * logs the full path anyway.\n */\nconst buildReplayError = (\n request: Request,\n frontendSessionId: string,\n reason: string,\n cause?: unknown,\n): Error => {\n const error = new Error(\n `[backend-recorder] workerd replay: ${reason} for ${describeRequest(request)} (session ${frontendSessionId}). Set the \"${METICULOUS_PASSTHROUGH_HEADER}: true\" header on this request if it must reach the real service during replay.`,\n );\n if (cause !== undefined) {\n // `cause` in the constructor options is not universally available in workerd builds.\n (error as { cause?: unknown }).cause = cause;\n }\n warn(error.message);\n return error;\n};\n\nconst describeRequest = (request: Request): string => {\n let method = \"GET\";\n let url = \"\";\n try {\n method = request.method;\n url = request.url;\n } catch {\n return method;\n }\n try {\n const parsed = new URL(url);\n return `${method} ${parsed.origin}${parsed.pathname}`;\n } catch {\n return `${method} ${url}`;\n }\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 fails the request.\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.\",\n error,\n );\n return undefined;\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,MAAa,qBAAqB,OAChC,KACA,SACA,WACsB;AACtB,KAAI,qBAAqB,QAAQ,CAC/B,QAAO,OAAO,QAAQ;CAGxB,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;AAGd,QAAM,iBACJ,SACA,IAAI,mBACJ,mCACA,MACD;;CAGH,MAAM,SAAS,MAAM,wBACnB,kBAAkB,EAClB,IAAI,YACJ,OACD;AACD,KAAI,WAAW,KAAA,EACb,OAAM,iBACJ,SACA,IAAI,mBACJ,kDACD;AAEH,KAAI,OAAO,YAAY,UACrB,OAAM,iBACJ,SACA,IAAI,mBACJ,uBACD;AAGH,KAAI,OAAO,YAAY,OACrB,QAAO,OAAO,QAAQ;CAExB,MAAM,SAAS,kBAAkB,OAAO;AACxC,KAAI,WAAW,KAAA,EACb,OAAM,iBACJ,SACA,IAAI,mBACJ,iCAAiC,OAAO,WAAW,yBACpD;AAEH,QAAO;;AAGT,MAAM,wBAAwB,YAA8B;AAC1D,KAAI;AACF,SAAO,QAAQ,QAAQ,IAAI,8BAA8B,KAAK;SACxD;AACN,SAAO;;;;;;;;;;;;AAaX,MAAM,oBACJ,SACA,mBACA,QACA,UACU;CACV,MAAM,wBAAQ,IAAI,MAChB,sCAAsC,OAAO,OAAO,gBAAgB,QAAQ,CAAC,YAAY,kBAAkB,cAAc,8BAA8B,iFACxJ;AACD,KAAI,UAAU,KAAA,EAEX,OAA8B,QAAQ;AAEzC,MAAK,MAAM,QAAQ;AACnB,QAAO;;AAGT,MAAM,mBAAmB,YAA6B;CACpD,IAAI,SAAS;CACb,IAAI,MAAM;AACV,KAAI;AACF,WAAS,QAAQ;AACjB,QAAM,QAAQ;SACR;AACN,SAAO;;AAET,KAAI;EACF,MAAM,SAAS,IAAI,IAAI,IAAI;AAC3B,SAAO,GAAG,OAAO,GAAG,OAAO,SAAS,OAAO;SACrC;AACN,SAAO,GAAG,OAAO,GAAG;;;AAMxB,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,0CACA,MACD;AACD","debugId":"53c696d1-464e-5822-ad00-17a9779dd439"}
1
+ {"version":3,"file":"replay-fetch.js","sources":["../src/replay-fetch.ts"],"sourcesContent":["import { readRequestBodyWithCap } from \"./body-capture\";\nimport type { RequestReplayContext } from \"./context\";\nimport { warn, warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport {\n METICULOUS_PASSTHROUGH_HEADER,\n type OutboundFetchLookupRequest,\n} from \"./protocol\";\nimport { postOutboundFetchLookup } from \"./sidecar-client\";\n\n/**\n * Serves a recorded response for an outbound call, or fails it.\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.\n *\n * Replay is hermetic: a call the recording does not cover must not silently reach the real\n * service, since that turns a replay into live traffic against real credentials and hides\n * the gap in the recording behind a response no base replay will reproduce. So anything we\n * cannot serve from the recording — a miss, a call the sidecar cannot look up, an\n * unreachable sidecar, a mock that cannot be represented, or an outcome this shim does not\n * recognise — throws, matching the Node recorder's http/undici mocks.\n *\n * The only way out is an explicit `meticulous-passthrough: true` header on the request: a\n * call the customer has marked as one that must reach the real service. A failure to stub is\n * never treated as such a mark.\n */\nexport const replayOutboundCall = async (\n ctx: RequestReplayContext,\n request: Request,\n invoke: (request: Request) => Promise<Response>,\n): Promise<Response> => {\n if (hasPassthroughHeader(request)) {\n return invoke(request);\n }\n\n let lookup: OutboundFetchLookupRequest;\n try {\n // Read the clone and pass the original through untouched, mirroring the record path.\n // The sidecar hashes what we send here, and it was captured by the same\n // readRequestBodyWithCap the recorder used (redaction included), so the hashes on both\n // 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 replayId: ctx.replayId,\n method: request.method,\n url: request.url,\n ...(requestBody !== undefined ? { requestBody } : {}),\n };\n } catch (error) {\n // Without a lookup there is nothing to serve, and passing through would be exactly the\n // silent real call this path exists to prevent.\n throw buildReplayError(\n request,\n ctx.frontendSessionId,\n \"could not prepare a mock lookup\",\n error,\n );\n }\n\n const result = await postOutboundFetchLookup(\n getOriginalFetch(),\n ctx.sidecarUrl,\n lookup,\n );\n if (result === undefined) {\n throw buildReplayError(\n request,\n ctx.frontendSessionId,\n \"the replay sidecar did not answer a mock lookup\",\n );\n }\n if (result.outcome === \"mock\") {\n const mocked = buildMockResponse(result);\n if (mocked === undefined) {\n throw buildReplayError(\n request,\n ctx.frontendSessionId,\n `the recorded response (status ${result.statusCode}) cannot be represented`,\n );\n }\n return mocked;\n }\n // Every other outcome is a failure to stub — a miss (`no-mock`) or an outcome this shim\n // does not recognise (e.g. a legacy `passthrough`). None may reach the real service, so we\n // fail the request; the only path to a live call is the explicit passthrough header\n // handled above.\n throw buildReplayError(\n request,\n ctx.frontendSessionId,\n describeUnservedOutcome(result.outcome),\n );\n};\n\nconst describeUnservedOutcome = (outcome: string): string =>\n outcome === \"no-mock\"\n ? \"no recorded response\"\n : `an unrecognised sidecar outcome (${outcome})`;\n\nconst hasPassthroughHeader = (request: Request): boolean => {\n try {\n return request.headers.get(METICULOUS_PASSTHROUGH_HEADER) === \"true\";\n } catch {\n return false;\n }\n};\n\n/**\n * Builds — and logs — the error the app sees for a call replay cannot serve. Deliberately\n * names the shim and the session so an app-side stack trace is enough to tell a replay gap\n * from a real bug, and logs as well because an app that catches its own fetch errors would\n * otherwise swallow the only signal.\n *\n * The query string is left out on purpose — it routinely carries API keys, and the sidecar\n * logs the full path anyway.\n */\nconst buildReplayError = (\n request: Request,\n frontendSessionId: string,\n reason: string,\n cause?: unknown,\n): Error => {\n const error = new Error(\n `[backend-recorder] workerd replay: ${reason} for ${describeRequest(request)} (session ${frontendSessionId}). Set the \"${METICULOUS_PASSTHROUGH_HEADER}: true\" header on this request if it must reach the real service during replay.`,\n );\n if (cause !== undefined) {\n // `cause` in the constructor options is not universally available in workerd builds.\n (error as { cause?: unknown }).cause = cause;\n }\n warn(error.message);\n return error;\n};\n\nconst describeRequest = (request: Request): string => {\n let method = \"GET\";\n let url = \"\";\n try {\n method = request.method;\n url = request.url;\n } catch {\n return method;\n }\n try {\n const parsed = new URL(url);\n return `${method} ${parsed.origin}${parsed.pathname}`;\n } catch {\n return `${method} ${url}`;\n }\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 fails the request.\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.\",\n error,\n );\n return undefined;\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,MAAa,qBAAqB,OAChC,KACA,SACA,WACsB;AACtB,KAAI,qBAAqB,QAAQ,CAC/B,QAAO,OAAO,QAAQ;CAGxB,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,UAAU,IAAI;GACd,QAAQ,QAAQ;GAChB,KAAK,QAAQ;GACb,GAAI,gBAAgB,KAAA,IAAY,EAAE,aAAa,GAAG,EAAE;GACrD;UACM,OAAO;AAGd,QAAM,iBACJ,SACA,IAAI,mBACJ,mCACA,MACD;;CAGH,MAAM,SAAS,MAAM,wBACnB,kBAAkB,EAClB,IAAI,YACJ,OACD;AACD,KAAI,WAAW,KAAA,EACb,OAAM,iBACJ,SACA,IAAI,mBACJ,kDACD;AAEH,KAAI,OAAO,YAAY,QAAQ;EAC7B,MAAM,SAAS,kBAAkB,OAAO;AACxC,MAAI,WAAW,KAAA,EACb,OAAM,iBACJ,SACA,IAAI,mBACJ,iCAAiC,OAAO,WAAW,yBACpD;AAEH,SAAO;;AAMT,OAAM,iBACJ,SACA,IAAI,mBACJ,wBAAwB,OAAO,QAAQ,CACxC;;AAGH,MAAM,2BAA2B,YAC/B,YAAY,YACR,yBACA,oCAAoC,QAAQ;AAElD,MAAM,wBAAwB,YAA8B;AAC1D,KAAI;AACF,SAAO,QAAQ,QAAQ,IAAI,8BAA8B,KAAK;SACxD;AACN,SAAO;;;;;;;;;;;;AAaX,MAAM,oBACJ,SACA,mBACA,QACA,UACU;CACV,MAAM,wBAAQ,IAAI,MAChB,sCAAsC,OAAO,OAAO,gBAAgB,QAAQ,CAAC,YAAY,kBAAkB,cAAc,8BAA8B,iFACxJ;AACD,KAAI,UAAU,KAAA,EAEX,OAA8B,QAAQ;AAEzC,MAAK,MAAM,QAAQ;AACnB,QAAO;;AAGT,MAAM,mBAAmB,YAA6B;CACpD,IAAI,SAAS;CACb,IAAI,MAAM;AACV,KAAI;AACF,WAAS,QAAQ;AACjB,QAAM,QAAQ;SACR;AACN,SAAO;;AAET,KAAI;EACF,MAAM,SAAS,IAAI,IAAI,IAAI;AAC3B,SAAO,GAAG,OAAO,GAAG,OAAO,SAAS,OAAO;SACrC;AACN,SAAO,GAAG,OAAO,GAAG;;;AAMxB,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,0CACA,MACD;AACD","debugId":"151bf2a1-75aa-51ce-be4a-6973d2815547"}
@@ -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]="94547b8a-bd87-50d7-ac87-204a0a65c1b7")}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]="4f7cf8f6-3a9a-5d41-95a3-192277e578fd")}catch(e){}}();
3
3
  import { warnOnce } from "./log.js";
4
4
  //#region src/replay-sidecar-url.ts
5
5
  /**
@@ -59,4 +59,4 @@ const isPrivateIpv4 = (hostname) => {
59
59
  export { parseReplaySidecarUrl };
60
60
 
61
61
  //# sourceMappingURL=replay-sidecar-url.js.map
62
- //# debugId=94547b8a-bd87-50d7-ac87-204a0a65c1b7
62
+ //# debugId=4f7cf8f6-3a9a-5d41-95a3-192277e578fd
@@ -1 +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
+ {"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":"4f7cf8f6-3a9a-5d41-95a3-192277e578fd"}
@@ -1,7 +1,9 @@
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]="89e7f190-1d19-5653-b03e-60d14140c4fc")}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]="4dc68be9-c4fd-55b2-bd89-c85b8f37cd8d")}catch(e){}}();
3
+ import { sidecarFetch } from "./sidecar-transport.js";
3
4
  import { warnOnce } from "./log.js";
4
5
  import { SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH } from "./protocol.js";
6
+ import { getOriginalFetch } from "./original-fetch.js";
5
7
  //#region src/sidecar-client.ts
6
8
  /**
7
9
  * Replay calls sit in the request path, unlike record mode's fire-and-forget reporting, so
@@ -11,29 +13,38 @@ import { SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OU
11
13
  const SESSION_INFO_TIMEOUT_MS = 1e4;
12
14
  const LOOKUP_TIMEOUT_MS = 5e3;
13
15
  /**
14
- * Capture reporting is off the response path (it runs under `ctx.waitUntil`), so a timeout
15
- * here is not about latency the app can see — it is about not holding the request context
16
- * open on a sidecar that will never answer. A refused connection rejects immediately, but a
17
- * sidecar URL pointing at a host that drops packets rather than refusing them (a firewalled
18
- * container or LAN address instead of loopback) would otherwise leave the POST pending until
19
- * the runtime tears the context down, on every captured call.
16
+ * Capture reporting is off the response path (it runs under `ctx.waitUntil`), so a timeout here
17
+ * is not about latency the app can see — it is about not holding the request context open on a
18
+ * sidecar that will never answer. A refused connection rejects immediately, but a sidecar URL
19
+ * pointing at a host that drops packets rather than refusing them (a firewalled container or LAN
20
+ * address instead of loopback) would otherwise leave the POST pending until the runtime tears the
21
+ * context down, on every captured call.
20
22
  *
21
- * Generous relative to what it bounds: the sidecar is always loopback or the docker gateway
22
- * and answers a single small event in single-digit milliseconds, so this only ever fires on
23
- * a sidecar that is wedged or unreachable — never on one that is merely busy. Being generous
24
- * costs nothing because {@link withTimeout} clears the timer the moment the POST settles.
23
+ * Generous relative to what it bounds: a local sidecar answers a small batch in single-digit
24
+ * milliseconds, so this only ever fires on one that is wedged or unreachable — never on one that
25
+ * is merely busy. Being generous costs nothing because {@link withTimeout} clears the timer the
26
+ * moment the POST settles.
25
27
  */
26
28
  const CAPTURE_POST_TIMEOUT_MS = 2e3;
29
+ /**
30
+ * The same bound for a service-binding transport, where the sidecar is a Worker that hands the
31
+ * batch to a Durable Object before answering. That object may live in another region and is
32
+ * single-threaded, so a busy one can legitimately take far longer than a loopback process — and
33
+ * failing the report early would drop spans a slightly longer wait would have recorded.
34
+ * `waitUntil` allows 30s past the response, so this stays well inside its budget.
35
+ */
36
+ const CAPTURE_POST_BINDING_TIMEOUT_MS = 1e4;
27
37
  /** POSTs capture events to the sidecar. Never rejects — reporting failures only warn (once). */
28
- const postCaptureEvents = async (fetchFn, sidecarUrl, events) => {
38
+ const postCaptureEvents = async (transport, events) => {
39
+ const timeoutMs = transport.kind === "binding" ? CAPTURE_POST_BINDING_TIMEOUT_MS : CAPTURE_POST_TIMEOUT_MS;
29
40
  try {
30
41
  const payload = { events };
31
- await withTimeout(CAPTURE_POST_TIMEOUT_MS, async (signal) => {
32
- const response = await fetchFn(`${sidecarUrl}${SIDECAR_EVENTS_PATH}`, {
42
+ await withTimeout(timeoutMs, async (signal) => {
43
+ const response = await sidecarFetch(transport, getOriginalFetch(), SIDECAR_EVENTS_PATH, {
33
44
  method: "POST",
34
45
  headers: {
35
46
  "content-type": "application/json",
36
- [SIDECAR_PROTOCOL_VERSION_HEADER]: "1"
47
+ [SIDECAR_PROTOCOL_VERSION_HEADER]: "2"
37
48
  },
38
49
  body: JSON.stringify(payload),
39
50
  signal
@@ -49,15 +60,15 @@ const postCaptureEvents = async (fetchFn, sidecarUrl, events) => {
49
60
  * Asks whether the sidecar holds mocks for a session, and at what instant to freeze the
50
61
  * clock while serving it.
51
62
  *
52
- * A 404 is how a record-only sidecar answers, so it counts as a settled `unavailable` and
53
- * the shim degrades to pass-through against an older sidecar rather than failing.
63
+ * A 404 is how a record-only sidecar answers, so it counts as a settled `unavailable`
64
+ * rather than a retryable blip.
54
65
  */
55
66
  const getReplaySessionInfo = async (fetchFn, sidecarUrl, frontendSessionId) => {
56
67
  const url = `${sidecarUrl}${SIDECAR_REPLAY_SESSION_PATH}?sessionId=${encodeURIComponent(frontendSessionId)}`;
57
68
  try {
58
69
  return await withTimeout(SESSION_INFO_TIMEOUT_MS, async (signal) => {
59
70
  const response = await fetchFn(url, {
60
- headers: { [SIDECAR_PROTOCOL_VERSION_HEADER]: "1" },
71
+ headers: { [SIDECAR_PROTOCOL_VERSION_HEADER]: "2" },
61
72
  signal
62
73
  });
63
74
  if (!response.ok) {
@@ -96,7 +107,7 @@ const postOutboundFetchLookup = async (fetchFn, sidecarUrl, payload) => {
96
107
  method: "POST",
97
108
  headers: {
98
109
  "content-type": "application/json",
99
- [SIDECAR_PROTOCOL_VERSION_HEADER]: "1"
110
+ [SIDECAR_PROTOCOL_VERSION_HEADER]: "2"
100
111
  },
101
112
  body: JSON.stringify(payload),
102
113
  signal
@@ -136,4 +147,4 @@ const withTimeout = async (ms, send) => {
136
147
  export { getReplaySessionInfo, postCaptureEvents, postOutboundFetchLookup };
137
148
 
138
149
  //# sourceMappingURL=sidecar-client.js.map
139
- //# debugId=89e7f190-1d19-5653-b03e-60d14140c4fc
150
+ //# debugId=4dc68be9-c4fd-55b2-bd89-c85b8f37cd8d