@alwaysmeticulous/backend-recorder-workerd 2.342.0 → 2.346.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/dist/agent-swarm-testing-fetch.js +40 -0
- package/dist/agent-swarm-testing-fetch.js.map +1 -0
- package/dist/base64.js +25 -0
- package/dist/base64.js.map +1 -0
- package/dist/body-capture.d.ts +18 -3
- package/dist/body-capture.js +68 -24
- package/dist/body-capture.js.map +1 -1
- package/dist/body-encoding.d.ts +49 -0
- package/dist/body-encoding.js +68 -0
- package/dist/body-encoding.js.map +1 -0
- package/dist/capture-buffer.js +3 -3
- package/dist/capture-buffer.js.map +1 -1
- package/dist/captured-result-codec.js +1 -15
- package/dist/captured-result-codec.js.map +1 -1
- package/dist/context.js +1 -1
- package/dist/context.js.map +1 -1
- package/dist/fetch-patch.js +4 -1
- package/dist/fetch-patch.js.map +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/kv-capture.js +1 -1
- package/dist/kv-capture.js.map +1 -1
- package/dist/outbound-capture.js +2 -2
- package/dist/outbound-capture.js.map +1 -1
- package/dist/postgres/capture.js.map +1 -1
- package/dist/protocol.d.ts +60 -8
- package/dist/protocol.js +15 -7
- package/dist/protocol.js.map +1 -1
- package/dist/replay-fetch.js +13 -2
- package/dist/replay-fetch.js.map +1 -1
- package/dist/sidecar-client.js +32 -2
- package/dist/sidecar-client.js.map +1 -1
- package/dist/{replay-sidecar-url.js → sidecar-url.js} +9 -8
- package/dist/sidecar-url.js.map +1 -0
- package/dist/spans/span-builder.js +2 -0
- package/dist/spans/span-builder.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/with-meticulous.js +0 -0
- package/dist/with-meticulous.js.map +1 -1
- package/package.json +1 -1
- package/dist/replay-sidecar-url.js.map +0 -1
package/dist/protocol.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"protocol.js","names":[],"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 * Elapsed virtual time (ms from replay start) of the triggering frontend\n * request. Injected by the replay runner on app-url requests during backend\n * testing; the sidecar uses it to pick the closest recorded burst when several\n * candidates share a loosened match key.\n */\nexport const VIRTUAL_TIME_HEADER = \"x-meticulous-virtual-time\";\n\n/**\n * Parses {@link VIRTUAL_TIME_HEADER}. `0` is a valid start-of-session time;\n * anything non-finite or negative is ignored so a garbage header cannot skew matching.\n */\nexport const parseVirtualTimeMs = (\n value: string | null | undefined,\n): number | undefined => {\n if (value == null || value === \"\") {\n return undefined;\n }\n const parsed = Number(value);\n if (!Number.isFinite(parsed) || parsed < 0) {\n return undefined;\n }\n return parsed;\n};\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 * Line-coverage reporting. Additive: an older sidecar answers 404, which the\n * shim treats as \"coverage not collected here\" and stops reporting for the life\n * of the isolate. Deliberately NOT guarded by a\n * {@link SIDECAR_PROTOCOL_VERSION} bump — that check is exact equality, so a\n * bump would stop every already-built customer image from mocking at all.\n */\nexport const SIDECAR_REPLAY_COVERAGE_PATH = \"/v1/replay/coverage\";\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 * Header the in-worker shim stamps on every app response (record and replay)\n * carrying the shim's npm package version. The replay runner reads this from\n * Puppeteer — workerd cannot see container env, and the execute pod cannot\n * reach the in-pod sidecar, so a response header is the only channel that\n * reports which bundled shim produced a replay's After screenshots.\n */\nexport const WORKERD_SHIM_VERSION_HEADER = \"x-meticulous-workerd-shim-version\";\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/**\n * One request's line coverage, sent to {@link SIDECAR_REPLAY_COVERAGE_PATH}\n * after the response has been produced.\n *\n * `files` carries the build-time id→line map and is sent only on the first\n * report of an isolate: it is the same for every request the isolate serves and\n * is far larger than the hits themselves.\n */\nexport interface CoverageReportRequest {\n frontendSessionId: string;\n /** Global line ids marked during this request, ascending. */\n hitIds: number[];\n /** Present on an isolate's first report only. */\n files?: Array<{\n path: string;\n firstId: number;\n /** Inclusive 1-based line ranges, flattened as `[s1, e1, s2, e2, ...]`. */\n lineRanges: number[];\n }>;\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 * Elapsed virtual time (ms from replay start) of the inbound request that\n * dispatched this outbound call, forwarded from {@link VIRTUAL_TIME_HEADER}.\n * The sidecar clusters candidates by recorded timestamp against this value.\n *\n * Additive and non-version-bumping: an older shim omits it, and matching\n * then hashes across every candidate at the matched key.\n */\n virtualTimeMs?: number;\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"],"mappings":";;;;;;AAMA,MAAa,2BAA2B;;AAGxC,MAAa,kCACX;;;;;AAMF,MAAa,6BAA6B;;AAG1C,MAAa,mBAAmB;;;;;;;AAQhC,MAAa,sBAAsB;;;;;AAMnC,MAAa,sBACX,UACuB;AACvB,KAAI,SAAS,QAAQ,UAAU,GAC7B;CAEF,MAAM,SAAS,OAAO,MAAM;AAC5B,KAAI,CAAC,OAAO,SAAS,OAAO,IAAI,SAAS,EACvC;AAEF,QAAO;;;;;;;;;;;AAYT,MAAa,gCAAgC;AAE7C,MAAa,sBAAsB;;;;;;AAOnC,MAAa,8BAA8B;AAC3C,MAAa,qCAAqC;;;;;;;;AASlD,MAAa,+BAA+B;;;;;;;;;AAU5C,MAAa,4BACX;;;;;;;;AASF,MAAa,8BAA8B;;;;;;;;;;;AAY3C,MAAa,mBAAmB,CAC9B,gBACA,2BACD"}
|
|
1
|
+
{"version":3,"file":"protocol.js","names":[],"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\nimport type { BodyEncoding } from \"./body-encoding\";\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 * Elapsed virtual time (ms from replay start) of the triggering frontend\n * request. Injected by the replay runner on app-url requests during backend\n * testing; the sidecar uses it to pick the closest recorded burst when several\n * candidates share a loosened match key.\n */\nexport const VIRTUAL_TIME_HEADER = \"x-meticulous-virtual-time\";\n\n/**\n * Parses {@link VIRTUAL_TIME_HEADER}. `0` is a valid start-of-session time;\n * anything non-finite or negative is ignored so a garbage header cannot skew matching.\n */\nexport const parseVirtualTimeMs = (\n value: string | null | undefined,\n): number | undefined => {\n if (value == null || value === \"\") {\n return undefined;\n }\n const parsed = Number(value);\n if (!Number.isFinite(parsed) || parsed < 0) {\n return undefined;\n }\n return parsed;\n};\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 * Line-coverage reporting. Additive: an older sidecar answers 404, which the\n * shim treats as \"coverage not collected here\" and stops reporting for the life\n * of the isolate. Deliberately NOT guarded by a\n * {@link SIDECAR_PROTOCOL_VERSION} bump — that check is exact equality, so a\n * bump would stop every already-built customer image from mocking at all.\n */\nexport const SIDECAR_REPLAY_COVERAGE_PATH = \"/v1/replay/coverage\";\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 `sidecar-url.ts`.\n */\nexport const REPLAY_SIDECAR_URL_HEADER =\n \"x-meticulous-backend-replay-sidecar-url\";\n\n/**\n * Header the in-worker shim stamps on every app response (record and replay)\n * carrying the shim's npm package version. The replay runner reads this from\n * Puppeteer — workerd cannot see container env, and the execute pod cannot\n * reach the in-pod sidecar, so a response header is the only channel that\n * reports which bundled shim produced a replay's After screenshots.\n */\nexport const WORKERD_SHIM_VERSION_HEADER = \"x-meticulous-workerd-shim-version\";\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 /**\n * The body truncated to the capture cap: its UTF-8 text, or base64 of its bytes when\n * `encoding` is set.\n */\n body: string;\n /** True when the body exceeded the capture cap or the read was cut short. */\n truncated: boolean;\n /**\n * Set only on a response body whose bytes are not UTF-8, which is stored base64 so that replay\n * can serve it back exactly. Additive and non-version-bumping: an older shim omits it, and its\n * bodies are text as before.\n */\n encoding?: BodyEncoding;\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/**\n * One request's line coverage, sent to {@link SIDECAR_REPLAY_COVERAGE_PATH}\n * after the response has been produced.\n *\n * `files` carries the build-time id→line map and is sent only on the first\n * report of an isolate: it is the same for every request the isolate serves and\n * is far larger than the hits themselves.\n */\nexport interface CoverageReportRequest {\n frontendSessionId: string;\n /** Global line ids marked during this request, ascending. */\n hitIds: number[];\n /** Present on an isolate's first report only. */\n files?: Array<{\n path: string;\n firstId: number;\n /** Inclusive 1-based line ranges, flattened as `[s1, e1, s2, e2, ...]`. */\n lineRanges: number[];\n }>;\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 * Elapsed virtual time (ms from replay start) of the inbound request that\n * dispatched this outbound call, forwarded from {@link VIRTUAL_TIME_HEADER}.\n * The sidecar clusters candidates by recorded timestamp against this value.\n *\n * Additive and non-version-bumping: an older shim omits it, and matching\n * then hashes across every candidate at the matched key.\n */\n virtualTimeMs?: number;\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 /**\n * Set when `body` is base64 of a recorded response that was not UTF-8, so the shim must\n * serve the decoded bytes rather than the text. An older shim ignores it.\n */\n bodyEncoding?: BodyEncoding;\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\n/**\n * Env var holding the origin of the agent-swarm-testing interceptor service, as a wrangler\n * var on the deployment. Validated exactly like the replay sidecar URL — a loopback /\n * docker-gateway / private-network `http:` origin only — see `sidecar-url.ts`.\n */\nexport const AGENT_SWARM_TESTING_SIDECAR_URL_ENV_KEY =\n \"METICULOUS_AGENT_SWARM_TESTING_SIDECAR_URL\";\n\n/**\n * Env var identifying the agent-swarm run, so an interceptor shared by several runs can tell\n * whose traffic a call belongs to.\n */\nexport const AGENT_SWARM_TESTING_RUN_ID_ENV_KEY =\n \"METICULOUS_AGENT_SWARM_TESTING_RUN_ID\";\n\n/** Agent-swarm-testing route. */\nexport const SIDECAR_AGENT_SWARM_TESTING_OUTBOUND_FETCH_PATH =\n \"/v1/agent-swarm-testing/outbound-fetch\";\n\n/**\n * One outbound `fetch` offered to the interceptor, sent to\n * {@link SIDECAR_AGENT_SWARM_TESTING_OUTBOUND_FETCH_PATH}. Unlike replay's lookup there is no\n * consume-once mock state, so a single run id suffices.\n */\nexport interface OutboundFetchAgentSwarmTestingRequest {\n runId: 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 /** Request body as captured by `readBodyWithCap`, mirroring the replay lookup. */\n requestBody?: CapturedBody;\n}\n\n/**\n * A response for the interceptor to serve, or a signal to let the call through. Unlike\n * replay's lookup response there is no failure outcome: agent-swarm testing is best-effort,\n * so anything the shim cannot act on becomes a passthrough, never a failed call.\n */\nexport type OutboundFetchAgentSwarmTestingResponse =\n | {\n outcome: \"mock\";\n statusCode: number;\n body: string;\n headers: Record<string, string>;\n }\n | { outcome: \"passthrough\" };\n"],"mappings":";AAQA,MAAa,2BAA2B;;AAGxC,MAAa,kCACX;;;;;AAMF,MAAa,6BAA6B;;AAG1C,MAAa,mBAAmB;;;;;;;AAQhC,MAAa,sBAAsB;;;;;AAMnC,MAAa,sBACX,UACuB;AACvB,KAAI,SAAS,QAAQ,UAAU,GAC7B;CAEF,MAAM,SAAS,OAAO,MAAM;AAC5B,KAAI,CAAC,OAAO,SAAS,OAAO,IAAI,SAAS,EACvC;AAEF,QAAO;;;;;;;;;;;AAYT,MAAa,gCAAgC;AAE7C,MAAa,sBAAsB;;;;;;AAOnC,MAAa,8BAA8B;AAC3C,MAAa,qCAAqC;;;;;;;;AASlD,MAAa,+BAA+B;;;;;;;;;AAU5C,MAAa,4BACX;;;;;;;;AASF,MAAa,8BAA8B;;;;;;;;;;;AAY3C,MAAa,mBAAmB,CAC9B,gBACA,2BACD;;;;;;AA0SD,MAAa,0CACX;;;;;AAMF,MAAa,qCACX;;AAGF,MAAa,kDACX"}
|
package/dist/replay-fetch.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { warn, warnOnce } from "./log.js";
|
|
2
|
+
import { decodeBodyBytes } from "./body-encoding.js";
|
|
2
3
|
import { readRequestBodyWithCap } from "./body-capture.js";
|
|
3
4
|
import { METICULOUS_PASSTHROUGH_HEADER } from "./protocol.js";
|
|
4
5
|
import { getOriginalFetch } from "./original-fetch.js";
|
|
@@ -48,6 +49,11 @@ const replayOutboundCall = async (ctx, request, invoke) => {
|
|
|
48
49
|
throw buildReplayError(request, ctx.frontendSessionId, describeUnservedOutcome(result.outcome));
|
|
49
50
|
};
|
|
50
51
|
const describeUnservedOutcome = (outcome) => outcome === "no-mock" ? "no recorded response" : `an unrecognised sidecar outcome (${outcome})`;
|
|
52
|
+
/**
|
|
53
|
+
* Whether the request is explicitly marked as one that must reach the real service. Shared
|
|
54
|
+
* with agent-swarm testing, which honours the same mark — a rule both modes must agree on, so it
|
|
55
|
+
* has exactly one implementation.
|
|
56
|
+
*/
|
|
51
57
|
const hasPassthroughHeader = (request) => {
|
|
52
58
|
try {
|
|
53
59
|
return request.headers.get(METICULOUS_PASSTHROUGH_HEADER) === "true";
|
|
@@ -115,7 +121,7 @@ const buildMockResponse = (mock) => {
|
|
|
115
121
|
headers.set(name, value);
|
|
116
122
|
} catch {}
|
|
117
123
|
}
|
|
118
|
-
const body = NULL_BODY_STATUSES.has(mock.statusCode) ? null : mock
|
|
124
|
+
const body = NULL_BODY_STATUSES.has(mock.statusCode) ? null : servedBody(mock);
|
|
119
125
|
return new Response(body, {
|
|
120
126
|
status: mock.statusCode,
|
|
121
127
|
headers
|
|
@@ -125,7 +131,12 @@ const buildMockResponse = (mock) => {
|
|
|
125
131
|
return;
|
|
126
132
|
}
|
|
127
133
|
};
|
|
134
|
+
/**
|
|
135
|
+
* A text body is passed as text, so the runtime defaults its content type as it always has; a
|
|
136
|
+
* base64 body is served as the bytes it encodes.
|
|
137
|
+
*/
|
|
138
|
+
const servedBody = (mock) => mock.bodyEncoding === "base64" ? decodeBodyBytes(mock.body, mock.bodyEncoding) : mock.body;
|
|
128
139
|
//#endregion
|
|
129
|
-
export { replayOutboundCall };
|
|
140
|
+
export { buildMockResponse, hasPassthroughHeader, replayOutboundCall };
|
|
130
141
|
|
|
131
142
|
//# sourceMappingURL=replay-fetch.js.map
|
package/dist/replay-fetch.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"replay-fetch.js","names":[],"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 ...(ctx.virtualTimeMs !== undefined\n ? { virtualTimeMs: ctx.virtualTimeMs }\n : {}),\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"],"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,IAAI,kBAAkB,KAAA,IACtB,EAAE,eAAe,IAAI,eAAe,GACpC,EAAE;GACN,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"}
|
|
1
|
+
{"version":3,"file":"replay-fetch.js","names":[],"sources":["../src/replay-fetch.ts"],"sourcesContent":["import { readRequestBodyWithCap } from \"./body-capture\";\nimport {\n BODY_ENCODING_BASE64,\n type BodyEncoding,\n decodeBodyBytes,\n} from \"./body-encoding\";\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 ...(ctx.virtualTimeMs !== undefined\n ? { virtualTimeMs: ctx.virtualTimeMs }\n : {}),\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\n/**\n * Whether the request is explicitly marked as one that must reach the real service. Shared\n * with agent-swarm testing, which honours the same mark — a rule both modes must agree on, so it\n * has exactly one implementation.\n */\nexport const 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 */\nexport const buildMockResponse = (mock: {\n statusCode: number;\n body: string;\n bodyEncoding?: BodyEncoding;\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)\n ? null\n : servedBody(mock);\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\n/**\n * A text body is passed as text, so the runtime defaults its content type as it always has; a\n * base64 body is served as the bytes it encodes.\n */\nconst servedBody = (mock: {\n body: string;\n bodyEncoding?: BodyEncoding;\n}): string | Uint8Array =>\n mock.bodyEncoding === BODY_ENCODING_BASE64\n ? decodeBodyBytes(mock.body, mock.bodyEncoding)\n : mock.body;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,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,IAAI,kBAAkB,KAAA,IACtB,EAAE,eAAe,IAAI,eAAe,GACpC,EAAE;GACN,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;;;;;;AAOlD,MAAa,wBAAwB,YAA8B;AACjE,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,MAAa,qBAAqB,SAKN;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,GAChD,OACA,WAAW,KAAK;AACpB,SAAO,IAAI,SAAS,MAAM;GAAE,QAAQ,KAAK;GAAY;GAAS,CAAC;UACxD,OAAO;AACd,WACE,wBACA,0CACA,MACD;AACD;;;;;;;AAQJ,MAAM,cAAc,SAIlB,KAAK,iBAAA,WACD,gBAAgB,KAAK,MAAM,KAAK,aAAa,GAC7C,KAAK"}
|
package/dist/sidecar-client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { sidecarFetch } from "./sidecar-transport.js";
|
|
2
2
|
import { warnOnce } from "./log.js";
|
|
3
|
-
import { SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_COVERAGE_PATH, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH } from "./protocol.js";
|
|
3
|
+
import { SIDECAR_AGENT_SWARM_TESTING_OUTBOUND_FETCH_PATH, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_COVERAGE_PATH, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH } from "./protocol.js";
|
|
4
4
|
import { getOriginalFetch } from "./original-fetch.js";
|
|
5
5
|
//#region src/sidecar-client.ts
|
|
6
6
|
/**
|
|
@@ -10,6 +10,7 @@ import { getOriginalFetch } from "./original-fetch.js";
|
|
|
10
10
|
*/
|
|
11
11
|
const SESSION_INFO_TIMEOUT_MS = 1e4;
|
|
12
12
|
const LOOKUP_TIMEOUT_MS = 5e3;
|
|
13
|
+
const AGENT_SWARM_TESTING_TIMEOUT_MS = 5e3;
|
|
13
14
|
/**
|
|
14
15
|
* Capture reporting is off the response path (it runs under `ctx.waitUntil`), so a timeout here
|
|
15
16
|
* is not about latency the app can see — it is about not holding the request context open on a
|
|
@@ -164,6 +165,35 @@ const postCoverageReport = async (fetchFn, sidecarUrl, payload) => {
|
|
|
164
165
|
}
|
|
165
166
|
};
|
|
166
167
|
/**
|
|
168
|
+
* Offers one outbound call to the interceptor service. Never rejects; returns undefined when
|
|
169
|
+
* the interceptor could not be consulted, which the caller turns into a passthrough — never
|
|
170
|
+
* a failed call.
|
|
171
|
+
*/
|
|
172
|
+
const postAgentSwarmTestingOutboundFetch = async (fetchFn, sidecarUrl, payload) => {
|
|
173
|
+
try {
|
|
174
|
+
return await withTimeout(AGENT_SWARM_TESTING_TIMEOUT_MS, async (signal) => {
|
|
175
|
+
const response = await fetchFn(`${sidecarUrl}${SIDECAR_AGENT_SWARM_TESTING_OUTBOUND_FETCH_PATH}`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: {
|
|
178
|
+
"content-type": "application/json",
|
|
179
|
+
[SIDECAR_PROTOCOL_VERSION_HEADER]: "2"
|
|
180
|
+
},
|
|
181
|
+
body: JSON.stringify(payload),
|
|
182
|
+
signal
|
|
183
|
+
});
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
warnOnce("intercept-lookup-rejected", `Meticulous interceptor rejected an outbound fetch intercept (HTTP ${response.status}) - the call will pass through.`);
|
|
186
|
+
await response.arrayBuffer().catch(() => void 0);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
return await response.json();
|
|
190
|
+
});
|
|
191
|
+
} catch (error) {
|
|
192
|
+
warnOnce("agent-swarm-testing-lookup-unreachable", "Could not reach the Meticulous interceptor for an outbound fetch - the call will pass through.", error);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
167
197
|
* Runs `send` under an abort signal that fires after `ms`, clearing the timer as soon as the
|
|
168
198
|
* call settles — including the response body read, which an abort must still cut short.
|
|
169
199
|
*
|
|
@@ -183,6 +213,6 @@ const withTimeout = async (ms, send) => {
|
|
|
183
213
|
}
|
|
184
214
|
};
|
|
185
215
|
//#endregion
|
|
186
|
-
export { getReplaySessionInfo, postCaptureEvents, postCoverageReport, postOutboundFetchLookup };
|
|
216
|
+
export { getReplaySessionInfo, postAgentSwarmTestingOutboundFetch, postCaptureEvents, postCoverageReport, postOutboundFetchLookup };
|
|
187
217
|
|
|
188
218
|
//# sourceMappingURL=sidecar-client.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sidecar-client.js","names":[],"sources":["../src/sidecar-client.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport {\n type CaptureEvent,\n type CaptureEventsPayload,\n type CoverageReportRequest,\n type OutboundFetchLookupRequest,\n type OutboundFetchLookupResponse,\n type ReplaySessionInfoResponse,\n SIDECAR_EVENTS_PATH,\n SIDECAR_PROTOCOL_VERSION,\n SIDECAR_PROTOCOL_VERSION_HEADER,\n SIDECAR_REPLAY_COVERAGE_PATH,\n SIDECAR_REPLAY_OUTBOUND_FETCH_PATH,\n SIDECAR_REPLAY_SESSION_PATH,\n} from \"./protocol\";\nimport { sidecarFetch, type SidecarTransport } from \"./sidecar-transport\";\n\ntype FetchFn = typeof globalThis.fetch;\n\n/**\n * Replay calls sit in the request path, unlike record mode's fire-and-forget reporting, so\n * they must be bounded: without a timeout a wedged sidecar would hang every outbound fetch\n * the app makes, and therefore the whole replay, with nothing to look at.\n */\nconst SESSION_INFO_TIMEOUT_MS = 10_000;\nconst LOOKUP_TIMEOUT_MS = 5_000;\n\n/**\n * Capture reporting is off the response path (it runs under `ctx.waitUntil`), so a timeout here\n * is not about latency the app can see — it is about not holding the request context open on a\n * sidecar that will never answer. A refused connection rejects immediately, but a sidecar URL\n * pointing at a host that drops packets rather than refusing them (a firewalled container or LAN\n * address instead of loopback) would otherwise leave the POST pending until the runtime tears the\n * context down, on every captured call.\n *\n * Generous relative to what it bounds: a local sidecar answers a small batch in single-digit\n * milliseconds, so this only ever fires on one that is wedged or unreachable — never on one that\n * is merely busy. Being generous costs nothing because {@link withTimeout} clears the timer the\n * moment the POST settles.\n */\nconst CAPTURE_POST_TIMEOUT_MS = 2_000;\n\n/**\n * The same bound for a service-binding transport, where the sidecar is a Worker that hands the\n * batch to a Durable Object before answering. That object may live in another region and is\n * single-threaded, so a busy one can legitimately take far longer than a loopback process — and\n * failing the report early would drop spans a slightly longer wait would have recorded.\n * `waitUntil` allows 30s past the response, so this stays well inside its budget.\n */\nconst CAPTURE_POST_BINDING_TIMEOUT_MS = 10_000;\n\n/**\n * Coverage reports are larger than capture events — the first one of an isolate\n * carries the whole id→line map — so they get more headroom than\n * {@link CAPTURE_POST_TIMEOUT_MS}. Still bounded, and for the same reason: an\n * unreachable sidecar must not hold the request context open.\n */\nconst COVERAGE_POST_TIMEOUT_MS = 5_000;\n\n/** POSTs capture events to the sidecar. Never rejects — reporting failures only warn (once). */\nexport const postCaptureEvents = async (\n transport: SidecarTransport,\n events: CaptureEvent[],\n): Promise<void> => {\n const timeoutMs =\n transport.kind === \"binding\"\n ? CAPTURE_POST_BINDING_TIMEOUT_MS\n : CAPTURE_POST_TIMEOUT_MS;\n try {\n const payload: CaptureEventsPayload = { events };\n await withTimeout(timeoutMs, async (signal) => {\n const response = await sidecarFetch(\n transport,\n getOriginalFetch(),\n SIDECAR_EVENTS_PATH,\n {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n body: JSON.stringify(payload),\n signal,\n },\n );\n if (!response.ok) {\n warnOnce(\n \"sidecar-rejected\",\n `Meticulous sidecar rejected capture events (HTTP ${response.status}).`,\n );\n }\n await response.arrayBuffer().catch(() => undefined);\n });\n } catch (error) {\n warnOnce(\n \"sidecar-unreachable\",\n \"Could not reach the Meticulous backend recorder sidecar — backend session events are not being recorded.\",\n error,\n );\n }\n};\n\n/**\n * Outcome of the session handshake.\n *\n * `unavailable` and `unreachable` both mean \"do not replay\", but they are kept apart\n * because only the first is a settled answer. The caller caches per isolate, and caching a\n * one-off network blip would disable mocking for the rest of the session — silently sending\n * every later outbound call to the real service.\n */\nexport type ReplaySessionInfoResult =\n /** The sidecar holds mocks for this session. */\n | { outcome: \"found\"; clockAnchorMs: number | undefined }\n /** The sidecar answered and will never serve this session: no mocks, or no such route. */\n | { outcome: \"unavailable\" }\n /** Could not get an answer — timeout, transport error, 5xx. May succeed later. */\n | { outcome: \"unreachable\" };\n\n/**\n * Asks whether the sidecar holds mocks for a session, and at what instant to freeze the\n * clock while serving it.\n *\n * A 404 is how a record-only sidecar answers, so it counts as a settled `unavailable`\n * rather than a retryable blip.\n */\nexport const getReplaySessionInfo = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n frontendSessionId: string,\n): Promise<ReplaySessionInfoResult> => {\n const url = `${sidecarUrl}${SIDECAR_REPLAY_SESSION_PATH}?sessionId=${encodeURIComponent(\n frontendSessionId,\n )}`;\n try {\n return await withTimeout(SESSION_INFO_TIMEOUT_MS, async (signal) => {\n const response = await fetchFn(url, {\n headers: {\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n signal,\n });\n if (!response.ok) {\n await response.arrayBuffer().catch(() => undefined);\n // 4xx is the sidecar telling us something definitive — no such route (a record-only\n // sidecar), or a protocol version it will not speak. Retrying cannot change either.\n // 5xx may well be a sidecar still finding its feet, so leave it retryable.\n if (response.status >= 500) {\n warnOnce(\n \"replay-session-unreachable\",\n `Meticulous replay sidecar failed the replay session handshake (HTTP ${response.status}) — will retry on the next request.`,\n );\n return { outcome: \"unreachable\" };\n }\n warnOnce(\n \"replay-session-unavailable\",\n `Meticulous replay sidecar did not recognise the replay session route (HTTP ${response.status}) — outbound calls will not be mocked.`,\n );\n return { outcome: \"unavailable\" };\n }\n const info = (await response.json()) as ReplaySessionInfoResponse;\n if (info.found !== true) {\n warnOnce(\n \"replay-session-unknown\",\n \"Meticulous replay sidecar holds no mocks for this session — outbound calls will not be mocked.\",\n );\n return { outcome: \"unavailable\" };\n }\n return {\n outcome: \"found\",\n clockAnchorMs:\n typeof info.clockAnchorMs === \"number\"\n ? info.clockAnchorMs\n : undefined,\n };\n });\n } catch (error) {\n warnOnce(\n \"replay-session-unreachable\",\n \"Could not reach the Meticulous replay sidecar — will retry on the next request.\",\n error,\n );\n return { outcome: \"unreachable\" };\n }\n};\n\n/**\n * Looks up a recorded response for one outbound call. Never rejects; returns undefined when\n * the sidecar could not be consulted, which the caller fails the request on — an unanswered\n * lookup is not evidence that a real call is safe.\n */\nexport const postOutboundFetchLookup = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n payload: OutboundFetchLookupRequest,\n): Promise<OutboundFetchLookupResponse | undefined> => {\n try {\n return await withTimeout(LOOKUP_TIMEOUT_MS, async (signal) => {\n const response = await fetchFn(\n `${sidecarUrl}${SIDECAR_REPLAY_OUTBOUND_FETCH_PATH}`,\n {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n body: JSON.stringify(payload),\n signal,\n },\n );\n if (!response.ok) {\n warnOnce(\n \"replay-lookup-rejected\",\n `Meticulous replay sidecar rejected a mock lookup (HTTP ${response.status}).`,\n );\n await response.arrayBuffer().catch(() => undefined);\n return undefined;\n }\n return (await response.json()) as OutboundFetchLookupResponse;\n });\n } catch (error) {\n warnOnce(\n \"replay-lookup-unreachable\",\n \"Could not reach the Meticulous replay sidecar for a mock lookup — the call will fail.\",\n error,\n );\n return undefined;\n }\n};\n\n/**\n * Outcome of a coverage report.\n *\n * `unsupported` and `failed` both mean the report did not land, but only the\n * first is settled: the caller latches `unsupported` and stops collecting, while\n * `failed` leaves the id→line map queued for the next report. Conflating them\n * would either disable coverage on one blip or lose a map permanently.\n */\nexport type CoverageReportOutcome = \"accepted\" | \"unsupported\" | \"failed\";\n\n/**\n * Reports one request's line coverage. Runs under `ctx.waitUntil`, so it is off\n * the response path like record-mode capture reporting.\n *\n * Returns `unsupported` when the sidecar will never accept coverage (404 — an\n * older sidecar, or one started without coverage enabled), which the caller\n * latches so it stops paying to serialise hits for the rest of the isolate's\n * life.\n */\nexport const postCoverageReport = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n payload: CoverageReportRequest,\n): Promise<CoverageReportOutcome> => {\n try {\n return await withTimeout(COVERAGE_POST_TIMEOUT_MS, async (signal) => {\n const response = await fetchFn(\n `${sidecarUrl}${SIDECAR_REPLAY_COVERAGE_PATH}`,\n {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n body: JSON.stringify(payload),\n signal,\n },\n );\n await response.arrayBuffer().catch(() => undefined);\n if (response.status === 404) {\n return \"unsupported\";\n }\n if (!response.ok) {\n warnOnce(\n \"coverage-rejected\",\n `Meticulous replay sidecar rejected a coverage report (HTTP ${response.status}).`,\n );\n return \"failed\";\n }\n return \"accepted\";\n });\n } catch (error) {\n warnOnce(\n \"coverage-unreachable\",\n \"Could not report line coverage to the Meticulous replay sidecar.\",\n error,\n );\n return \"failed\";\n }\n};\n\n/**\n * Runs `send` under an abort signal that fires after `ms`, clearing the timer as soon as the\n * call settles — including the response body read, which an abort must still cut short.\n *\n * Deliberately not `AbortSignal.timeout`: its timer cannot be cancelled, and a pending timer\n * keeps workerd's request context alive. Bounding a 5ms POST at 2s would then pin the context\n * for the remaining ~2s on every captured call — worse than the unbounded wait it exists to\n * prevent, and on the healthy path rather than the broken one. `body-capture.ts` uses this\n * same clearable shape, for the same reason.\n */\nconst withTimeout = async <T>(\n ms: number,\n send: (signal: AbortSignal) => Promise<T>,\n): Promise<T> => {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), ms);\n try {\n return await send(controller.signal);\n } finally {\n clearTimeout(timer);\n }\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"sidecar-client.js","names":[],"sources":["../src/sidecar-client.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport {\n type CaptureEvent,\n type CaptureEventsPayload,\n type CoverageReportRequest,\n type OutboundFetchAgentSwarmTestingRequest,\n type OutboundFetchAgentSwarmTestingResponse,\n type OutboundFetchLookupRequest,\n type OutboundFetchLookupResponse,\n type ReplaySessionInfoResponse,\n SIDECAR_EVENTS_PATH,\n SIDECAR_AGENT_SWARM_TESTING_OUTBOUND_FETCH_PATH,\n SIDECAR_PROTOCOL_VERSION,\n SIDECAR_PROTOCOL_VERSION_HEADER,\n SIDECAR_REPLAY_COVERAGE_PATH,\n SIDECAR_REPLAY_OUTBOUND_FETCH_PATH,\n SIDECAR_REPLAY_SESSION_PATH,\n} from \"./protocol\";\nimport { sidecarFetch, type SidecarTransport } from \"./sidecar-transport\";\n\ntype FetchFn = typeof globalThis.fetch;\n\n/**\n * Replay calls sit in the request path, unlike record mode's fire-and-forget reporting, so\n * they must be bounded: without a timeout a wedged sidecar would hang every outbound fetch\n * the app makes, and therefore the whole replay, with nothing to look at.\n */\nconst SESSION_INFO_TIMEOUT_MS = 10_000;\nconst LOOKUP_TIMEOUT_MS = 5_000;\n\nconst AGENT_SWARM_TESTING_TIMEOUT_MS = 5_000;\n\n/**\n * Capture reporting is off the response path (it runs under `ctx.waitUntil`), so a timeout here\n * is not about latency the app can see — it is about not holding the request context open on a\n * sidecar that will never answer. A refused connection rejects immediately, but a sidecar URL\n * pointing at a host that drops packets rather than refusing them (a firewalled container or LAN\n * address instead of loopback) would otherwise leave the POST pending until the runtime tears the\n * context down, on every captured call.\n *\n * Generous relative to what it bounds: a local sidecar answers a small batch in single-digit\n * milliseconds, so this only ever fires on one that is wedged or unreachable — never on one that\n * is merely busy. Being generous costs nothing because {@link withTimeout} clears the timer the\n * moment the POST settles.\n */\nconst CAPTURE_POST_TIMEOUT_MS = 2_000;\n\n/**\n * The same bound for a service-binding transport, where the sidecar is a Worker that hands the\n * batch to a Durable Object before answering. That object may live in another region and is\n * single-threaded, so a busy one can legitimately take far longer than a loopback process — and\n * failing the report early would drop spans a slightly longer wait would have recorded.\n * `waitUntil` allows 30s past the response, so this stays well inside its budget.\n */\nconst CAPTURE_POST_BINDING_TIMEOUT_MS = 10_000;\n\n/**\n * Coverage reports are larger than capture events — the first one of an isolate\n * carries the whole id→line map — so they get more headroom than\n * {@link CAPTURE_POST_TIMEOUT_MS}. Still bounded, and for the same reason: an\n * unreachable sidecar must not hold the request context open.\n */\nconst COVERAGE_POST_TIMEOUT_MS = 5_000;\n\n/** POSTs capture events to the sidecar. Never rejects — reporting failures only warn (once). */\nexport const postCaptureEvents = async (\n transport: SidecarTransport,\n events: CaptureEvent[],\n): Promise<void> => {\n const timeoutMs =\n transport.kind === \"binding\"\n ? CAPTURE_POST_BINDING_TIMEOUT_MS\n : CAPTURE_POST_TIMEOUT_MS;\n try {\n const payload: CaptureEventsPayload = { events };\n await withTimeout(timeoutMs, async (signal) => {\n const response = await sidecarFetch(\n transport,\n getOriginalFetch(),\n SIDECAR_EVENTS_PATH,\n {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n body: JSON.stringify(payload),\n signal,\n },\n );\n if (!response.ok) {\n warnOnce(\n \"sidecar-rejected\",\n `Meticulous sidecar rejected capture events (HTTP ${response.status}).`,\n );\n }\n await response.arrayBuffer().catch(() => undefined);\n });\n } catch (error) {\n warnOnce(\n \"sidecar-unreachable\",\n \"Could not reach the Meticulous backend recorder sidecar — backend session events are not being recorded.\",\n error,\n );\n }\n};\n\n/**\n * Outcome of the session handshake.\n *\n * `unavailable` and `unreachable` both mean \"do not replay\", but they are kept apart\n * because only the first is a settled answer. The caller caches per isolate, and caching a\n * one-off network blip would disable mocking for the rest of the session — silently sending\n * every later outbound call to the real service.\n */\nexport type ReplaySessionInfoResult =\n /** The sidecar holds mocks for this session. */\n | { outcome: \"found\"; clockAnchorMs: number | undefined }\n /** The sidecar answered and will never serve this session: no mocks, or no such route. */\n | { outcome: \"unavailable\" }\n /** Could not get an answer — timeout, transport error, 5xx. May succeed later. */\n | { outcome: \"unreachable\" };\n\n/**\n * Asks whether the sidecar holds mocks for a session, and at what instant to freeze the\n * clock while serving it.\n *\n * A 404 is how a record-only sidecar answers, so it counts as a settled `unavailable`\n * rather than a retryable blip.\n */\nexport const getReplaySessionInfo = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n frontendSessionId: string,\n): Promise<ReplaySessionInfoResult> => {\n const url = `${sidecarUrl}${SIDECAR_REPLAY_SESSION_PATH}?sessionId=${encodeURIComponent(\n frontendSessionId,\n )}`;\n try {\n return await withTimeout(SESSION_INFO_TIMEOUT_MS, async (signal) => {\n const response = await fetchFn(url, {\n headers: {\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n signal,\n });\n if (!response.ok) {\n await response.arrayBuffer().catch(() => undefined);\n // 4xx is the sidecar telling us something definitive — no such route (a record-only\n // sidecar), or a protocol version it will not speak. Retrying cannot change either.\n // 5xx may well be a sidecar still finding its feet, so leave it retryable.\n if (response.status >= 500) {\n warnOnce(\n \"replay-session-unreachable\",\n `Meticulous replay sidecar failed the replay session handshake (HTTP ${response.status}) — will retry on the next request.`,\n );\n return { outcome: \"unreachable\" };\n }\n warnOnce(\n \"replay-session-unavailable\",\n `Meticulous replay sidecar did not recognise the replay session route (HTTP ${response.status}) — outbound calls will not be mocked.`,\n );\n return { outcome: \"unavailable\" };\n }\n const info = (await response.json()) as ReplaySessionInfoResponse;\n if (info.found !== true) {\n warnOnce(\n \"replay-session-unknown\",\n \"Meticulous replay sidecar holds no mocks for this session — outbound calls will not be mocked.\",\n );\n return { outcome: \"unavailable\" };\n }\n return {\n outcome: \"found\",\n clockAnchorMs:\n typeof info.clockAnchorMs === \"number\"\n ? info.clockAnchorMs\n : undefined,\n };\n });\n } catch (error) {\n warnOnce(\n \"replay-session-unreachable\",\n \"Could not reach the Meticulous replay sidecar — will retry on the next request.\",\n error,\n );\n return { outcome: \"unreachable\" };\n }\n};\n\n/**\n * Looks up a recorded response for one outbound call. Never rejects; returns undefined when\n * the sidecar could not be consulted, which the caller fails the request on — an unanswered\n * lookup is not evidence that a real call is safe.\n */\nexport const postOutboundFetchLookup = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n payload: OutboundFetchLookupRequest,\n): Promise<OutboundFetchLookupResponse | undefined> => {\n try {\n return await withTimeout(LOOKUP_TIMEOUT_MS, async (signal) => {\n const response = await fetchFn(\n `${sidecarUrl}${SIDECAR_REPLAY_OUTBOUND_FETCH_PATH}`,\n {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n body: JSON.stringify(payload),\n signal,\n },\n );\n if (!response.ok) {\n warnOnce(\n \"replay-lookup-rejected\",\n `Meticulous replay sidecar rejected a mock lookup (HTTP ${response.status}).`,\n );\n await response.arrayBuffer().catch(() => undefined);\n return undefined;\n }\n return (await response.json()) as OutboundFetchLookupResponse;\n });\n } catch (error) {\n warnOnce(\n \"replay-lookup-unreachable\",\n \"Could not reach the Meticulous replay sidecar for a mock lookup — the call will fail.\",\n error,\n );\n return undefined;\n }\n};\n\n/**\n * Outcome of a coverage report.\n *\n * `unsupported` and `failed` both mean the report did not land, but only the\n * first is settled: the caller latches `unsupported` and stops collecting, while\n * `failed` leaves the id→line map queued for the next report. Conflating them\n * would either disable coverage on one blip or lose a map permanently.\n */\nexport type CoverageReportOutcome = \"accepted\" | \"unsupported\" | \"failed\";\n\n/**\n * Reports one request's line coverage. Runs under `ctx.waitUntil`, so it is off\n * the response path like record-mode capture reporting.\n *\n * Returns `unsupported` when the sidecar will never accept coverage (404 — an\n * older sidecar, or one started without coverage enabled), which the caller\n * latches so it stops paying to serialise hits for the rest of the isolate's\n * life.\n */\nexport const postCoverageReport = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n payload: CoverageReportRequest,\n): Promise<CoverageReportOutcome> => {\n try {\n return await withTimeout(COVERAGE_POST_TIMEOUT_MS, async (signal) => {\n const response = await fetchFn(\n `${sidecarUrl}${SIDECAR_REPLAY_COVERAGE_PATH}`,\n {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n body: JSON.stringify(payload),\n signal,\n },\n );\n await response.arrayBuffer().catch(() => undefined);\n if (response.status === 404) {\n return \"unsupported\";\n }\n if (!response.ok) {\n warnOnce(\n \"coverage-rejected\",\n `Meticulous replay sidecar rejected a coverage report (HTTP ${response.status}).`,\n );\n return \"failed\";\n }\n return \"accepted\";\n });\n } catch (error) {\n warnOnce(\n \"coverage-unreachable\",\n \"Could not report line coverage to the Meticulous replay sidecar.\",\n error,\n );\n return \"failed\";\n }\n};\n\n/**\n * Offers one outbound call to the interceptor service. Never rejects; returns undefined when\n * the interceptor could not be consulted, which the caller turns into a passthrough — never\n * a failed call.\n */\nexport const postAgentSwarmTestingOutboundFetch = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n payload: OutboundFetchAgentSwarmTestingRequest,\n): Promise<OutboundFetchAgentSwarmTestingResponse | undefined> => {\n try {\n return await withTimeout(AGENT_SWARM_TESTING_TIMEOUT_MS, async (signal) => {\n const response = await fetchFn(\n `${sidecarUrl}${SIDECAR_AGENT_SWARM_TESTING_OUTBOUND_FETCH_PATH}`,\n {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n body: JSON.stringify(payload),\n signal,\n },\n );\n if (!response.ok) {\n warnOnce(\n \"intercept-lookup-rejected\",\n `Meticulous interceptor rejected an outbound fetch intercept (HTTP ${response.status}) - the call will pass through.`,\n );\n await response.arrayBuffer().catch(() => undefined);\n return undefined;\n }\n return (await response.json()) as OutboundFetchAgentSwarmTestingResponse;\n });\n } catch (error) {\n warnOnce(\n \"agent-swarm-testing-lookup-unreachable\",\n \"Could not reach the Meticulous interceptor for an outbound fetch - the call will pass through.\",\n error,\n );\n return undefined;\n }\n};\n\n/**\n * Runs `send` under an abort signal that fires after `ms`, clearing the timer as soon as the\n * call settles — including the response body read, which an abort must still cut short.\n *\n * Deliberately not `AbortSignal.timeout`: its timer cannot be cancelled, and a pending timer\n * keeps workerd's request context alive. Bounding a 5ms POST at 2s would then pin the context\n * for the remaining ~2s on every captured call — worse than the unbounded wait it exists to\n * prevent, and on the healthy path rather than the broken one. `body-capture.ts` uses this\n * same clearable shape, for the same reason.\n */\nconst withTimeout = async <T>(\n ms: number,\n send: (signal: AbortSignal) => Promise<T>,\n): Promise<T> => {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), ms);\n try {\n return await send(controller.signal);\n } finally {\n clearTimeout(timer);\n }\n};\n"],"mappings":"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"}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { warnOnce } from "./log.js";
|
|
2
|
-
//#region src/
|
|
2
|
+
//#region src/sidecar-url.ts
|
|
3
3
|
/**
|
|
4
|
-
* Validation for
|
|
4
|
+
* Validation for a sidecar origin, whether it arrives on a request header (replay) or in a
|
|
5
|
+
* deployment env var (agent-swarm testing).
|
|
5
6
|
*
|
|
6
7
|
* The value arrives in a request header, so in principle anything that can reach the worker
|
|
7
8
|
* can propose one — and the shim POSTs outbound request bodies to whatever it accepts. The
|
|
@@ -19,21 +20,21 @@ const ALLOWED_HOSTNAMES = new Set([
|
|
|
19
20
|
"host.docker.internal",
|
|
20
21
|
"host.containers.internal"
|
|
21
22
|
]);
|
|
22
|
-
const
|
|
23
|
+
const parseSidecarUrl = (raw) => {
|
|
23
24
|
if (typeof raw !== "string" || raw.length === 0) return;
|
|
24
25
|
if (raw.length > MAX_URL_LENGTH) {
|
|
25
|
-
warnOnce("
|
|
26
|
+
warnOnce("sidecar-url", "Ignoring an implausibly long Meticulous sidecar URL.");
|
|
26
27
|
return;
|
|
27
28
|
}
|
|
28
29
|
let url;
|
|
29
30
|
try {
|
|
30
31
|
url = new URL(raw);
|
|
31
32
|
} catch {
|
|
32
|
-
warnOnce("
|
|
33
|
+
warnOnce("sidecar-url", "Ignoring an unparseable Meticulous sidecar URL.");
|
|
33
34
|
return;
|
|
34
35
|
}
|
|
35
36
|
if (!(url.protocol === "http:" && url.username === "" && url.password === "" && (url.pathname === "" || url.pathname === "/") && url.search === "" && url.hash === "" && isLocalHostname(url.hostname))) {
|
|
36
|
-
warnOnce("
|
|
37
|
+
warnOnce("sidecar-url", `Ignoring the Meticulous sidecar URL "${raw}" — only a plain http:// origin on a loopback, docker-gateway or private-network host is honoured.`);
|
|
37
38
|
return;
|
|
38
39
|
}
|
|
39
40
|
return url.origin;
|
|
@@ -54,6 +55,6 @@ const isPrivateIpv4 = (hostname) => {
|
|
|
54
55
|
return first === 127 || first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
|
|
55
56
|
};
|
|
56
57
|
//#endregion
|
|
57
|
-
export {
|
|
58
|
+
export { parseSidecarUrl };
|
|
58
59
|
|
|
59
|
-
//# sourceMappingURL=
|
|
60
|
+
//# sourceMappingURL=sidecar-url.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sidecar-url.js","names":[],"sources":["../src/sidecar-url.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\n\n/**\n * Validation for a sidecar origin, whether it arrives on a request header (replay) or in a\n * deployment env var (agent-swarm testing).\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 parseSidecarUrl = (\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 \"sidecar-url\",\n \"Ignoring an implausibly long Meticulous sidecar URL.\",\n );\n return undefined;\n }\n\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n warnOnce(\"sidecar-url\", \"Ignoring an unparseable Meticulous sidecar URL.\");\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 \"sidecar-url\",\n `Ignoring the Meticulous 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"],"mappings":";;;;;;;;;;;;;;;;AAiBA,MAAM,iBAAiB;AAEvB,MAAM,oBAAoB,IAAI,IAAI;CAChC;CAEA;CACA;CACD,CAAC;AAEF,MAAa,mBACX,QACuB;AACvB,KAAI,OAAO,QAAQ,YAAY,IAAI,WAAW,EAC5C;AAEF,KAAI,IAAI,SAAS,gBAAgB;AAC/B,WACE,eACA,uDACD;AACD;;CAGF,IAAI;AACJ,KAAI;AACF,QAAM,IAAI,IAAI,IAAI;SACZ;AACN,WAAS,eAAe,kDAAkD;AAC1E;;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,eACA,wCAAwC,IAAI,oGAC7C;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"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HTTP_RESPONSE_BODY_ENCODING_ATTR } from "../body-encoding.js";
|
|
1
2
|
import { CAPTURED_HEADERS } from "../protocol.js";
|
|
2
3
|
import { MAX_POSTGRES_JS_RESULT_SIZE, POSTGRES_JS_ERROR_ATTR, POSTGRES_JS_QUERY_PARAMS_ATTR, POSTGRES_JS_QUERY_TEXT_ATTR, POSTGRES_JS_RESULT_ATTR, POSTGRES_JS_RESULT_TRUNCATED_ATTR, POSTGRES_JS_ROW_MODE_ATTR, postgresJsCommandOf } from "../postgres/capture.js";
|
|
3
4
|
import { CLIENT_TECHNOLOGY_ATTR, CLIENT_TECHNOLOGY_POSTGRES_JS, CLIENT_TECHNOLOGY_WORKERD_BINDING, CLIENT_TECHNOLOGY_WORKERD_FETCH, CLIENT_TECHNOLOGY_WORKERD_KV, FRONTEND_SESSION_ID_ATTR, SESSION_ID_ORIGIN_ATTR, WORKERD_BINDING_NAME_ATTR, WORKERD_KV_ARGS_ATTR, WORKERD_KV_ARGS_TRUNCATED_ATTR, WORKERD_KV_KEY_ATTR, WORKERD_KV_OMITTED_ATTR, WORKERD_KV_OPERATION_ATTR, WORKERD_KV_RESULT_ATTR, WORKERD_KV_RESULT_TRUNCATED_ATTR, WORKERD_KV_VALUE_ATTR, WORKERD_KV_VALUE_TRUNCATED_ATTR } from "./attributes.js";
|
|
@@ -130,6 +131,7 @@ var SpanBuilder = class {
|
|
|
130
131
|
if (event.responseBody !== void 0) {
|
|
131
132
|
attributes["http.response.body"] = event.responseBody.body;
|
|
132
133
|
attributes["http.response.body.truncated"] = event.responseBody.truncated;
|
|
134
|
+
if (event.responseBody.encoding !== void 0) attributes[HTTP_RESPONSE_BODY_ENCODING_ATTR] = event.responseBody.encoding;
|
|
133
135
|
}
|
|
134
136
|
return this.buildClientSpan(event, name, attributes, buildStatus(event, 2));
|
|
135
137
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"span-builder.js","names":[],"sources":["../../src/spans/span-builder.ts"],"sourcesContent":["import {\n type BindingRequestEvent,\n CAPTURED_HEADERS,\n type CaptureEvent,\n type InboundRequestEvent,\n type KvOperationEvent,\n type OutboundRequestEvent,\n type PostgresQueryEvent,\n} from \"../protocol\";\nimport {\n MAX_POSTGRES_JS_RESULT_SIZE,\n POSTGRES_JS_ERROR_ATTR,\n POSTGRES_JS_QUERY_PARAMS_ATTR,\n POSTGRES_JS_QUERY_TEXT_ATTR,\n POSTGRES_JS_RESULT_ATTR,\n POSTGRES_JS_RESULT_TRUNCATED_ATTR,\n POSTGRES_JS_ROW_MODE_ATTR,\n postgresJsCommandOf,\n} from \"../postgres/capture\";\nimport {\n CLIENT_TECHNOLOGY_ATTR,\n CLIENT_TECHNOLOGY_POSTGRES_JS,\n CLIENT_TECHNOLOGY_WORKERD_BINDING,\n CLIENT_TECHNOLOGY_WORKERD_FETCH,\n CLIENT_TECHNOLOGY_WORKERD_KV,\n FRONTEND_SESSION_ID_ATTR,\n SESSION_ID_ORIGIN_ATTR,\n WORKERD_BINDING_NAME_ATTR,\n WORKERD_KV_ARGS_ATTR,\n WORKERD_KV_ARGS_TRUNCATED_ATTR,\n WORKERD_KV_KEY_ATTR,\n WORKERD_KV_OMITTED_ATTR,\n WORKERD_KV_OPERATION_ATTR,\n WORKERD_KV_RESULT_ATTR,\n WORKERD_KV_RESULT_TRUNCATED_ATTR,\n WORKERD_KV_VALUE_ATTR,\n WORKERD_KV_VALUE_TRUNCATED_ATTR,\n} from \"./attributes\";\nimport {\n msToHrTime,\n type ReadableSpan,\n SPAN_KIND_CLIENT,\n SPAN_KIND_SERVER,\n SPAN_STATUS_ERROR,\n SPAN_STATUS_UNSET,\n type SpanStatus,\n} from \"./readable-span\";\nimport { redactUrlCredentials } from \"./url-redaction\";\n\n/**\n * Builds spans from shim capture events.\n *\n * Each inbound request becomes a SERVER span and the root of its own trace; every other event of\n * that request becomes a CLIENT span parented under it. The shim supplies the trace and\n * server-span ids (see `CorrelatedEvent`), so building a span needs no memory of earlier events\n * — which is what lets a Worker-hosted sidecar be evicted between two batches of one request\n * without splitting its trace.\n *\n * The `requestId → ids` fallback below exists only for a shim that predates those fields. It is\n * the reason this is a class rather than a function.\n *\n * Shared with the Node recorder's sidecar (`packages/backend-recorder-js/src/sidecar/`), which\n * re-exports it: both sidecars must produce identical spans or a recording made through one\n * cannot be replayed by the machinery built for the other.\n */\nexport class SpanBuilder {\n /**\n * Legacy fallback ids, keyed by `requestId`. Outbound events usually arrive BEFORE their\n * inbound event (the inbound is only reported once the handler completes), so an entry is\n * created lazily on first sight from either event kind.\n */\n private readonly traceContexts = new Map<string, TraceContextEntry>();\n\n build(event: CaptureEvent): ReadableSpan | null {\n switch (event.kind) {\n case \"inbound\":\n return this.buildInboundSpan(event);\n case \"outbound\":\n return this.buildOutboundSpan(event);\n case \"binding\":\n return this.buildBindingSpan(event);\n case \"kv\":\n return this.buildKvSpan(event);\n case \"postgres\":\n return this.buildPostgresSpan(event);\n default:\n // Wire input, possibly from a newer shim — skip rather than crash.\n return null;\n }\n }\n\n private buildInboundSpan(event: InboundRequestEvent): ReadableSpan {\n const ids = this.resolveIds(event);\n const url = parseUrl(event.url);\n const attributes = buildHttpAttributes(event, url);\n\n // Only the SERVER span carries it, matching MetHttpInstrumentation on the Node path: the\n // marker describes where this request's session id came from, and the CLIENT spans under\n // it share the same trace.\n if (event.sessionIdOrigin !== undefined) {\n attributes[SESSION_ID_ORIGIN_ATTR] = event.sessionIdOrigin;\n }\n\n return {\n name: `${event.method} ${url.path}`,\n kind: SPAN_KIND_SERVER,\n status: buildStatus(event, SPAN_KIND_SERVER),\n startTime: msToHrTime(event.startTimeMs),\n endTime: msToHrTime(event.endTimeMs),\n duration: msToHrTime(Math.max(0, event.endTimeMs - event.startTimeMs)),\n attributes,\n spanContext: () => ({\n traceId: ids.traceId,\n spanId: ids.serverSpanId,\n }),\n };\n }\n\n private buildOutboundSpan(event: OutboundRequestEvent): ReadableSpan {\n return this.buildHttpClientSpan(\n event,\n CLIENT_TECHNOLOGY_WORKERD_FETCH,\n event.method,\n );\n }\n\n /**\n * A binding call. Named after the binding rather than the bare method, since the `env` key is\n * what identifies the call — the URL is whatever the app invented.\n */\n private buildBindingSpan(event: BindingRequestEvent): ReadableSpan {\n return this.buildHttpClientSpan(\n event,\n CLIENT_TECHNOLOGY_WORKERD_BINDING,\n `${event.method} ${event.bindingName ?? \"$unknown\"}`,\n event.bindingName !== undefined\n ? { [WORKERD_BINDING_NAME_ATTR]: event.bindingName }\n : undefined,\n );\n }\n\n /**\n * A KV operation. Named `kv.<binding>.<operation>` after the Prisma convention\n * (`prisma.<model>.<operation>`) rather than the `METHOD TARGET` shape the HTTP-like spans use\n * — nothing about a KV call is an HTTP method, and the dotted name keeps a `get` on a\n * namespace distinguishable from a `GET` through a service binding at a glance.\n */\n private buildKvSpan(event: KvOperationEvent): ReadableSpan {\n const attributes: Record<string, unknown> = {\n [CLIENT_TECHNOLOGY_ATTR]: CLIENT_TECHNOLOGY_WORKERD_KV,\n [WORKERD_KV_OPERATION_ATTR]: event.operation,\n ...(event.bindingName !== undefined\n ? { [WORKERD_BINDING_NAME_ATTR]: event.bindingName }\n : {}),\n ...(event.key !== undefined ? { [WORKERD_KV_KEY_ATTR]: event.key } : {}),\n ...(event.omitted !== undefined\n ? { [WORKERD_KV_OMITTED_ATTR]: event.omitted }\n : {}),\n ...(event.error !== undefined ? { \"error.type\": event.error } : {}),\n ...frontendSessionAttribute(event),\n };\n if (event.args !== undefined) {\n attributes[WORKERD_KV_ARGS_ATTR] = event.args.body;\n attributes[WORKERD_KV_ARGS_TRUNCATED_ATTR] = event.args.truncated;\n }\n if (event.value !== undefined) {\n attributes[WORKERD_KV_VALUE_ATTR] = event.value.body;\n attributes[WORKERD_KV_VALUE_TRUNCATED_ATTR] = event.value.truncated;\n }\n if (event.result !== undefined) {\n attributes[WORKERD_KV_RESULT_ATTR] = event.result.body;\n attributes[WORKERD_KV_RESULT_TRUNCATED_ATTR] = event.result.truncated;\n }\n\n return this.buildClientSpan(\n event,\n `kv.${event.bindingName ?? \"$unknown\"}.${event.operation}`,\n attributes,\n event.error !== undefined\n ? { code: SPAN_STATUS_ERROR, message: event.error }\n : { code: SPAN_STATUS_UNSET },\n );\n }\n\n /**\n * A postgres.js query. Name and attributes match what the Node instrumentation emits\n * (`postgresjs.<command>`), because the same store loads both.\n */\n private buildPostgresSpan(event: PostgresQueryEvent): ReadableSpan {\n const attributes: Record<string, unknown> = {\n [CLIENT_TECHNOLOGY_ATTR]: CLIENT_TECHNOLOGY_POSTGRES_JS,\n [POSTGRES_JS_QUERY_TEXT_ATTR]: event.queryText,\n [POSTGRES_JS_QUERY_PARAMS_ATTR]: event.params,\n [POSTGRES_JS_ROW_MODE_ATTR]: event.rowMode,\n ...frontendSessionAttribute(event),\n };\n if (event.result !== undefined) {\n // Re-cap here as well as at capture time: the attribute contract is what the store\n // reads, and a shim that capped differently must not widen it.\n const truncated =\n event.result.truncated ||\n event.result.body.length > MAX_POSTGRES_JS_RESULT_SIZE;\n attributes[POSTGRES_JS_RESULT_ATTR] = truncated\n ? event.result.body.slice(0, MAX_POSTGRES_JS_RESULT_SIZE)\n : event.result.body;\n attributes[POSTGRES_JS_RESULT_TRUNCATED_ATTR] = truncated;\n }\n if (event.errorJson !== undefined) {\n attributes[POSTGRES_JS_ERROR_ATTR] = event.errorJson;\n }\n\n return this.buildClientSpan(\n event,\n `postgresjs.${postgresJsCommandOf(event.queryText)}`,\n attributes,\n event.errorJson !== undefined\n ? { code: SPAN_STATUS_ERROR }\n : { code: SPAN_STATUS_UNSET },\n );\n }\n\n private buildHttpClientSpan(\n event: OutboundRequestEvent | BindingRequestEvent,\n technology: string,\n name: string,\n extraAttributes?: Record<string, unknown>,\n ): ReadableSpan {\n const url = parseUrl(event.url);\n const attributes = buildHttpAttributes(event, url);\n attributes[CLIENT_TECHNOLOGY_ATTR] = technology;\n Object.assign(attributes, extraAttributes);\n if (event.requestBody !== undefined) {\n attributes[\"http.request.body\"] = event.requestBody.body;\n attributes[\"http.request.body.truncated\"] = event.requestBody.truncated;\n }\n if (event.responseBody !== undefined) {\n attributes[\"http.response.body\"] = event.responseBody.body;\n attributes[\"http.response.body.truncated\"] = event.responseBody.truncated;\n }\n\n return this.buildClientSpan(\n event,\n name,\n attributes,\n buildStatus(event, SPAN_KIND_CLIENT),\n );\n }\n\n private buildClientSpan(\n event: CaptureEvent,\n name: string,\n attributes: Record<string, unknown>,\n status: SpanStatus,\n ): ReadableSpan {\n const ids = this.resolveIds(event);\n const spanId = randomHex(8);\n return {\n name,\n parentSpanId: ids.serverSpanId,\n kind: SPAN_KIND_CLIENT,\n status,\n startTime: msToHrTime(event.startTimeMs),\n endTime: msToHrTime(event.endTimeMs),\n duration: msToHrTime(Math.max(0, event.endTimeMs - event.startTimeMs)),\n attributes,\n spanContext: () => ({ traceId: ids.traceId, spanId }),\n };\n }\n\n /**\n * The trace and server-span ids for an event: the shim's, when it sent them, and otherwise a\n * pair invented per `requestId` and remembered — the pre-`traceId` behaviour.\n */\n private resolveIds(event: CaptureEvent): TraceContextEntry {\n if (event.traceId !== undefined && event.serverSpanId !== undefined) {\n return {\n traceId: event.traceId,\n serverSpanId: event.serverSpanId,\n lastSeenMs: event.endTimeMs,\n };\n }\n const existing = this.traceContexts.get(event.requestId);\n if (existing) {\n existing.lastSeenMs = event.endTimeMs;\n return existing;\n }\n this.pruneTraceContexts(event.endTimeMs);\n const entry: TraceContextEntry = {\n traceId: randomHex(16),\n serverSpanId: randomHex(8),\n lastSeenMs: event.endTimeMs,\n };\n this.traceContexts.set(event.requestId, entry);\n return entry;\n }\n\n private pruneTraceContexts(nowMs: number): void {\n if (this.traceContexts.size < TRACE_CONTEXT_PRUNE_THRESHOLD) {\n return;\n }\n for (const [requestId, entry] of this.traceContexts) {\n if (nowMs - entry.lastSeenMs > TRACE_CONTEXT_TTL_MS) {\n this.traceContexts.delete(requestId);\n }\n }\n }\n}\n\ninterface TraceContextEntry {\n traceId: string;\n serverSpanId: string;\n lastSeenMs: number;\n}\n\nconst TRACE_CONTEXT_TTL_MS = 10 * 60 * 1000;\nconst TRACE_CONTEXT_PRUNE_THRESHOLD = 1_000;\n\nconst HEX = \"0123456789abcdef\";\n\n/** WebCrypto rather than `node:crypto`, so this runs in a Worker as well as in Node. */\nexport const randomHex = (bytes: number): string => {\n const buffer = crypto.getRandomValues(new Uint8Array(bytes));\n let hex = \"\";\n for (const byte of buffer) {\n hex += HEX[byte >> 4] + HEX[byte & 0x0f];\n }\n return hex;\n};\n\nconst capturedHeaders = new Set<string>(CAPTURED_HEADERS);\n\ninterface ParsedUrl {\n full: string;\n path: string;\n query: string | undefined;\n scheme: string | undefined;\n host: string | undefined;\n port: number | undefined;\n}\n\nconst parseUrl = (raw: string): ParsedUrl => {\n try {\n const url = redactUrlCredentials(new URL(raw));\n const defaultPort = url.protocol === \"https:\" ? 443 : 80;\n return {\n full: url.toString(),\n path: url.pathname,\n query: url.search ? url.search.slice(1) : undefined,\n scheme: url.protocol.replace(/:$/, \"\"),\n host: url.hostname,\n port: url.port ? parseInt(url.port, 10) : defaultPort,\n };\n } catch {\n return {\n full: raw,\n path: raw.split(\"?\")[0] || \"/\",\n query: undefined,\n scheme: undefined,\n host: undefined,\n port: undefined,\n };\n }\n};\n\nconst frontendSessionAttribute = (\n event: CaptureEvent,\n): Record<string, string> =>\n event.frontendSessionId !== undefined\n ? { [FRONTEND_SESSION_ID_ATTR]: event.frontendSessionId }\n : {};\n\nconst buildHttpAttributes = (\n event: InboundRequestEvent | OutboundRequestEvent | BindingRequestEvent,\n url: ParsedUrl,\n): Record<string, unknown> => {\n const attributes: Record<string, unknown> = {\n \"http.request.method\": event.method,\n \"url.full\": url.full,\n \"url.path\": url.path,\n ...(url.query !== undefined ? { \"url.query\": url.query } : {}),\n ...(url.scheme !== undefined ? { \"url.scheme\": url.scheme } : {}),\n ...(url.host !== undefined ? { \"server.address\": url.host } : {}),\n ...(url.port !== undefined ? { \"server.port\": url.port } : {}),\n ...(event.statusCode !== undefined\n ? { \"http.response.status_code\": event.statusCode }\n : {}),\n ...(event.error !== undefined ? { \"error.type\": event.error } : {}),\n ...frontendSessionAttribute(event),\n };\n // The shim already filters to CAPTURED_HEADERS; re-filter here so a stale or third-party\n // shim can't persist sensitive headers via the sidecar.\n for (const [name, values] of Object.entries(event.requestHeaders)) {\n if (capturedHeaders.has(name.toLowerCase())) {\n attributes[`http.request.header.${name}`] = values;\n }\n }\n for (const [name, values] of Object.entries(event.responseHeaders ?? {})) {\n if (capturedHeaders.has(name.toLowerCase())) {\n attributes[`http.response.header.${name}`] = values;\n }\n }\n return attributes;\n};\n\nconst buildStatus = (\n event: InboundRequestEvent | OutboundRequestEvent | BindingRequestEvent,\n kind: number,\n): SpanStatus => {\n if (event.error !== undefined) {\n return { code: SPAN_STATUS_ERROR, message: event.error };\n }\n // Standard OTel HTTP semantics: client spans error on 4xx/5xx, server spans only on 5xx.\n const errorThreshold = kind === SPAN_KIND_CLIENT ? 400 : 500;\n if (event.statusCode !== undefined && event.statusCode >= errorThreshold) {\n return { code: SPAN_STATUS_ERROR };\n }\n return { code: SPAN_STATUS_UNSET };\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"span-builder.js","names":[],"sources":["../../src/spans/span-builder.ts"],"sourcesContent":["import { HTTP_RESPONSE_BODY_ENCODING_ATTR } from \"../body-encoding\";\nimport {\n type BindingRequestEvent,\n CAPTURED_HEADERS,\n type CaptureEvent,\n type InboundRequestEvent,\n type KvOperationEvent,\n type OutboundRequestEvent,\n type PostgresQueryEvent,\n} from \"../protocol\";\nimport {\n MAX_POSTGRES_JS_RESULT_SIZE,\n POSTGRES_JS_ERROR_ATTR,\n POSTGRES_JS_QUERY_PARAMS_ATTR,\n POSTGRES_JS_QUERY_TEXT_ATTR,\n POSTGRES_JS_RESULT_ATTR,\n POSTGRES_JS_RESULT_TRUNCATED_ATTR,\n POSTGRES_JS_ROW_MODE_ATTR,\n postgresJsCommandOf,\n} from \"../postgres/capture\";\nimport {\n CLIENT_TECHNOLOGY_ATTR,\n CLIENT_TECHNOLOGY_POSTGRES_JS,\n CLIENT_TECHNOLOGY_WORKERD_BINDING,\n CLIENT_TECHNOLOGY_WORKERD_FETCH,\n CLIENT_TECHNOLOGY_WORKERD_KV,\n FRONTEND_SESSION_ID_ATTR,\n SESSION_ID_ORIGIN_ATTR,\n WORKERD_BINDING_NAME_ATTR,\n WORKERD_KV_ARGS_ATTR,\n WORKERD_KV_ARGS_TRUNCATED_ATTR,\n WORKERD_KV_KEY_ATTR,\n WORKERD_KV_OMITTED_ATTR,\n WORKERD_KV_OPERATION_ATTR,\n WORKERD_KV_RESULT_ATTR,\n WORKERD_KV_RESULT_TRUNCATED_ATTR,\n WORKERD_KV_VALUE_ATTR,\n WORKERD_KV_VALUE_TRUNCATED_ATTR,\n} from \"./attributes\";\nimport {\n msToHrTime,\n type ReadableSpan,\n SPAN_KIND_CLIENT,\n SPAN_KIND_SERVER,\n SPAN_STATUS_ERROR,\n SPAN_STATUS_UNSET,\n type SpanStatus,\n} from \"./readable-span\";\nimport { redactUrlCredentials } from \"./url-redaction\";\n\n/**\n * Builds spans from shim capture events.\n *\n * Each inbound request becomes a SERVER span and the root of its own trace; every other event of\n * that request becomes a CLIENT span parented under it. The shim supplies the trace and\n * server-span ids (see `CorrelatedEvent`), so building a span needs no memory of earlier events\n * — which is what lets a Worker-hosted sidecar be evicted between two batches of one request\n * without splitting its trace.\n *\n * The `requestId → ids` fallback below exists only for a shim that predates those fields. It is\n * the reason this is a class rather than a function.\n *\n * Shared with the Node recorder's sidecar (`packages/backend-recorder-js/src/sidecar/`), which\n * re-exports it: both sidecars must produce identical spans or a recording made through one\n * cannot be replayed by the machinery built for the other.\n */\nexport class SpanBuilder {\n /**\n * Legacy fallback ids, keyed by `requestId`. Outbound events usually arrive BEFORE their\n * inbound event (the inbound is only reported once the handler completes), so an entry is\n * created lazily on first sight from either event kind.\n */\n private readonly traceContexts = new Map<string, TraceContextEntry>();\n\n build(event: CaptureEvent): ReadableSpan | null {\n switch (event.kind) {\n case \"inbound\":\n return this.buildInboundSpan(event);\n case \"outbound\":\n return this.buildOutboundSpan(event);\n case \"binding\":\n return this.buildBindingSpan(event);\n case \"kv\":\n return this.buildKvSpan(event);\n case \"postgres\":\n return this.buildPostgresSpan(event);\n default:\n // Wire input, possibly from a newer shim — skip rather than crash.\n return null;\n }\n }\n\n private buildInboundSpan(event: InboundRequestEvent): ReadableSpan {\n const ids = this.resolveIds(event);\n const url = parseUrl(event.url);\n const attributes = buildHttpAttributes(event, url);\n\n // Only the SERVER span carries it, matching MetHttpInstrumentation on the Node path: the\n // marker describes where this request's session id came from, and the CLIENT spans under\n // it share the same trace.\n if (event.sessionIdOrigin !== undefined) {\n attributes[SESSION_ID_ORIGIN_ATTR] = event.sessionIdOrigin;\n }\n\n return {\n name: `${event.method} ${url.path}`,\n kind: SPAN_KIND_SERVER,\n status: buildStatus(event, SPAN_KIND_SERVER),\n startTime: msToHrTime(event.startTimeMs),\n endTime: msToHrTime(event.endTimeMs),\n duration: msToHrTime(Math.max(0, event.endTimeMs - event.startTimeMs)),\n attributes,\n spanContext: () => ({\n traceId: ids.traceId,\n spanId: ids.serverSpanId,\n }),\n };\n }\n\n private buildOutboundSpan(event: OutboundRequestEvent): ReadableSpan {\n return this.buildHttpClientSpan(\n event,\n CLIENT_TECHNOLOGY_WORKERD_FETCH,\n event.method,\n );\n }\n\n /**\n * A binding call. Named after the binding rather than the bare method, since the `env` key is\n * what identifies the call — the URL is whatever the app invented.\n */\n private buildBindingSpan(event: BindingRequestEvent): ReadableSpan {\n return this.buildHttpClientSpan(\n event,\n CLIENT_TECHNOLOGY_WORKERD_BINDING,\n `${event.method} ${event.bindingName ?? \"$unknown\"}`,\n event.bindingName !== undefined\n ? { [WORKERD_BINDING_NAME_ATTR]: event.bindingName }\n : undefined,\n );\n }\n\n /**\n * A KV operation. Named `kv.<binding>.<operation>` after the Prisma convention\n * (`prisma.<model>.<operation>`) rather than the `METHOD TARGET` shape the HTTP-like spans use\n * — nothing about a KV call is an HTTP method, and the dotted name keeps a `get` on a\n * namespace distinguishable from a `GET` through a service binding at a glance.\n */\n private buildKvSpan(event: KvOperationEvent): ReadableSpan {\n const attributes: Record<string, unknown> = {\n [CLIENT_TECHNOLOGY_ATTR]: CLIENT_TECHNOLOGY_WORKERD_KV,\n [WORKERD_KV_OPERATION_ATTR]: event.operation,\n ...(event.bindingName !== undefined\n ? { [WORKERD_BINDING_NAME_ATTR]: event.bindingName }\n : {}),\n ...(event.key !== undefined ? { [WORKERD_KV_KEY_ATTR]: event.key } : {}),\n ...(event.omitted !== undefined\n ? { [WORKERD_KV_OMITTED_ATTR]: event.omitted }\n : {}),\n ...(event.error !== undefined ? { \"error.type\": event.error } : {}),\n ...frontendSessionAttribute(event),\n };\n if (event.args !== undefined) {\n attributes[WORKERD_KV_ARGS_ATTR] = event.args.body;\n attributes[WORKERD_KV_ARGS_TRUNCATED_ATTR] = event.args.truncated;\n }\n if (event.value !== undefined) {\n attributes[WORKERD_KV_VALUE_ATTR] = event.value.body;\n attributes[WORKERD_KV_VALUE_TRUNCATED_ATTR] = event.value.truncated;\n }\n if (event.result !== undefined) {\n attributes[WORKERD_KV_RESULT_ATTR] = event.result.body;\n attributes[WORKERD_KV_RESULT_TRUNCATED_ATTR] = event.result.truncated;\n }\n\n return this.buildClientSpan(\n event,\n `kv.${event.bindingName ?? \"$unknown\"}.${event.operation}`,\n attributes,\n event.error !== undefined\n ? { code: SPAN_STATUS_ERROR, message: event.error }\n : { code: SPAN_STATUS_UNSET },\n );\n }\n\n /**\n * A postgres.js query. Name and attributes match what the Node instrumentation emits\n * (`postgresjs.<command>`), because the same store loads both.\n */\n private buildPostgresSpan(event: PostgresQueryEvent): ReadableSpan {\n const attributes: Record<string, unknown> = {\n [CLIENT_TECHNOLOGY_ATTR]: CLIENT_TECHNOLOGY_POSTGRES_JS,\n [POSTGRES_JS_QUERY_TEXT_ATTR]: event.queryText,\n [POSTGRES_JS_QUERY_PARAMS_ATTR]: event.params,\n [POSTGRES_JS_ROW_MODE_ATTR]: event.rowMode,\n ...frontendSessionAttribute(event),\n };\n if (event.result !== undefined) {\n // Re-cap here as well as at capture time: the attribute contract is what the store\n // reads, and a shim that capped differently must not widen it.\n const truncated =\n event.result.truncated ||\n event.result.body.length > MAX_POSTGRES_JS_RESULT_SIZE;\n attributes[POSTGRES_JS_RESULT_ATTR] = truncated\n ? event.result.body.slice(0, MAX_POSTGRES_JS_RESULT_SIZE)\n : event.result.body;\n attributes[POSTGRES_JS_RESULT_TRUNCATED_ATTR] = truncated;\n }\n if (event.errorJson !== undefined) {\n attributes[POSTGRES_JS_ERROR_ATTR] = event.errorJson;\n }\n\n return this.buildClientSpan(\n event,\n `postgresjs.${postgresJsCommandOf(event.queryText)}`,\n attributes,\n event.errorJson !== undefined\n ? { code: SPAN_STATUS_ERROR }\n : { code: SPAN_STATUS_UNSET },\n );\n }\n\n private buildHttpClientSpan(\n event: OutboundRequestEvent | BindingRequestEvent,\n technology: string,\n name: string,\n extraAttributes?: Record<string, unknown>,\n ): ReadableSpan {\n const url = parseUrl(event.url);\n const attributes = buildHttpAttributes(event, url);\n attributes[CLIENT_TECHNOLOGY_ATTR] = technology;\n Object.assign(attributes, extraAttributes);\n if (event.requestBody !== undefined) {\n attributes[\"http.request.body\"] = event.requestBody.body;\n attributes[\"http.request.body.truncated\"] = event.requestBody.truncated;\n }\n if (event.responseBody !== undefined) {\n attributes[\"http.response.body\"] = event.responseBody.body;\n attributes[\"http.response.body.truncated\"] = event.responseBody.truncated;\n if (event.responseBody.encoding !== undefined) {\n attributes[HTTP_RESPONSE_BODY_ENCODING_ATTR] =\n event.responseBody.encoding;\n }\n }\n\n return this.buildClientSpan(\n event,\n name,\n attributes,\n buildStatus(event, SPAN_KIND_CLIENT),\n );\n }\n\n private buildClientSpan(\n event: CaptureEvent,\n name: string,\n attributes: Record<string, unknown>,\n status: SpanStatus,\n ): ReadableSpan {\n const ids = this.resolveIds(event);\n const spanId = randomHex(8);\n return {\n name,\n parentSpanId: ids.serverSpanId,\n kind: SPAN_KIND_CLIENT,\n status,\n startTime: msToHrTime(event.startTimeMs),\n endTime: msToHrTime(event.endTimeMs),\n duration: msToHrTime(Math.max(0, event.endTimeMs - event.startTimeMs)),\n attributes,\n spanContext: () => ({ traceId: ids.traceId, spanId }),\n };\n }\n\n /**\n * The trace and server-span ids for an event: the shim's, when it sent them, and otherwise a\n * pair invented per `requestId` and remembered — the pre-`traceId` behaviour.\n */\n private resolveIds(event: CaptureEvent): TraceContextEntry {\n if (event.traceId !== undefined && event.serverSpanId !== undefined) {\n return {\n traceId: event.traceId,\n serverSpanId: event.serverSpanId,\n lastSeenMs: event.endTimeMs,\n };\n }\n const existing = this.traceContexts.get(event.requestId);\n if (existing) {\n existing.lastSeenMs = event.endTimeMs;\n return existing;\n }\n this.pruneTraceContexts(event.endTimeMs);\n const entry: TraceContextEntry = {\n traceId: randomHex(16),\n serverSpanId: randomHex(8),\n lastSeenMs: event.endTimeMs,\n };\n this.traceContexts.set(event.requestId, entry);\n return entry;\n }\n\n private pruneTraceContexts(nowMs: number): void {\n if (this.traceContexts.size < TRACE_CONTEXT_PRUNE_THRESHOLD) {\n return;\n }\n for (const [requestId, entry] of this.traceContexts) {\n if (nowMs - entry.lastSeenMs > TRACE_CONTEXT_TTL_MS) {\n this.traceContexts.delete(requestId);\n }\n }\n }\n}\n\ninterface TraceContextEntry {\n traceId: string;\n serverSpanId: string;\n lastSeenMs: number;\n}\n\nconst TRACE_CONTEXT_TTL_MS = 10 * 60 * 1000;\nconst TRACE_CONTEXT_PRUNE_THRESHOLD = 1_000;\n\nconst HEX = \"0123456789abcdef\";\n\n/** WebCrypto rather than `node:crypto`, so this runs in a Worker as well as in Node. */\nexport const randomHex = (bytes: number): string => {\n const buffer = crypto.getRandomValues(new Uint8Array(bytes));\n let hex = \"\";\n for (const byte of buffer) {\n hex += HEX[byte >> 4] + HEX[byte & 0x0f];\n }\n return hex;\n};\n\nconst capturedHeaders = new Set<string>(CAPTURED_HEADERS);\n\ninterface ParsedUrl {\n full: string;\n path: string;\n query: string | undefined;\n scheme: string | undefined;\n host: string | undefined;\n port: number | undefined;\n}\n\nconst parseUrl = (raw: string): ParsedUrl => {\n try {\n const url = redactUrlCredentials(new URL(raw));\n const defaultPort = url.protocol === \"https:\" ? 443 : 80;\n return {\n full: url.toString(),\n path: url.pathname,\n query: url.search ? url.search.slice(1) : undefined,\n scheme: url.protocol.replace(/:$/, \"\"),\n host: url.hostname,\n port: url.port ? parseInt(url.port, 10) : defaultPort,\n };\n } catch {\n return {\n full: raw,\n path: raw.split(\"?\")[0] || \"/\",\n query: undefined,\n scheme: undefined,\n host: undefined,\n port: undefined,\n };\n }\n};\n\nconst frontendSessionAttribute = (\n event: CaptureEvent,\n): Record<string, string> =>\n event.frontendSessionId !== undefined\n ? { [FRONTEND_SESSION_ID_ATTR]: event.frontendSessionId }\n : {};\n\nconst buildHttpAttributes = (\n event: InboundRequestEvent | OutboundRequestEvent | BindingRequestEvent,\n url: ParsedUrl,\n): Record<string, unknown> => {\n const attributes: Record<string, unknown> = {\n \"http.request.method\": event.method,\n \"url.full\": url.full,\n \"url.path\": url.path,\n ...(url.query !== undefined ? { \"url.query\": url.query } : {}),\n ...(url.scheme !== undefined ? { \"url.scheme\": url.scheme } : {}),\n ...(url.host !== undefined ? { \"server.address\": url.host } : {}),\n ...(url.port !== undefined ? { \"server.port\": url.port } : {}),\n ...(event.statusCode !== undefined\n ? { \"http.response.status_code\": event.statusCode }\n : {}),\n ...(event.error !== undefined ? { \"error.type\": event.error } : {}),\n ...frontendSessionAttribute(event),\n };\n // The shim already filters to CAPTURED_HEADERS; re-filter here so a stale or third-party\n // shim can't persist sensitive headers via the sidecar.\n for (const [name, values] of Object.entries(event.requestHeaders)) {\n if (capturedHeaders.has(name.toLowerCase())) {\n attributes[`http.request.header.${name}`] = values;\n }\n }\n for (const [name, values] of Object.entries(event.responseHeaders ?? {})) {\n if (capturedHeaders.has(name.toLowerCase())) {\n attributes[`http.response.header.${name}`] = values;\n }\n }\n return attributes;\n};\n\nconst buildStatus = (\n event: InboundRequestEvent | OutboundRequestEvent | BindingRequestEvent,\n kind: number,\n): SpanStatus => {\n if (event.error !== undefined) {\n return { code: SPAN_STATUS_ERROR, message: event.error };\n }\n // Standard OTel HTTP semantics: client spans error on 4xx/5xx, server spans only on 5xx.\n const errorThreshold = kind === SPAN_KIND_CLIENT ? 400 : 500;\n if (event.statusCode !== undefined && event.statusCode >= errorThreshold) {\n return { code: SPAN_STATUS_ERROR };\n }\n return { code: SPAN_STATUS_UNSET };\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAkEA,IAAa,cAAb,MAAyB;;;;;;CAMvB,gCAAiC,IAAI,KAAgC;CAErE,MAAM,OAA0C;AAC9C,UAAQ,MAAM,MAAd;GACE,KAAK,UACH,QAAO,KAAK,iBAAiB,MAAM;GACrC,KAAK,WACH,QAAO,KAAK,kBAAkB,MAAM;GACtC,KAAK,UACH,QAAO,KAAK,iBAAiB,MAAM;GACrC,KAAK,KACH,QAAO,KAAK,YAAY,MAAM;GAChC,KAAK,WACH,QAAO,KAAK,kBAAkB,MAAM;GACtC,QAEE,QAAO;;;CAIb,iBAAyB,OAA0C;EACjE,MAAM,MAAM,KAAK,WAAW,MAAM;EAClC,MAAM,MAAM,SAAS,MAAM,IAAI;EAC/B,MAAM,aAAa,oBAAoB,OAAO,IAAI;AAKlD,MAAI,MAAM,oBAAoB,KAAA,EAC5B,YAAW,0BAA0B,MAAM;AAG7C,SAAO;GACL,MAAM,GAAG,MAAM,OAAO,GAAG,IAAI;GAC7B,MAAA;GACA,QAAQ,YAAY,OAAA,EAAwB;GAC5C,WAAW,WAAW,MAAM,YAAY;GACxC,SAAS,WAAW,MAAM,UAAU;GACpC,UAAU,WAAW,KAAK,IAAI,GAAG,MAAM,YAAY,MAAM,YAAY,CAAC;GACtE;GACA,oBAAoB;IAClB,SAAS,IAAI;IACb,QAAQ,IAAI;IACb;GACF;;CAGH,kBAA0B,OAA2C;AACnE,SAAO,KAAK,oBACV,OACA,iCACA,MAAM,OACP;;;;;;CAOH,iBAAyB,OAA0C;AACjE,SAAO,KAAK,oBACV,OACA,mCACA,GAAG,MAAM,OAAO,GAAG,MAAM,eAAe,cACxC,MAAM,gBAAgB,KAAA,IAClB,GAAG,4BAA4B,MAAM,aAAa,GAClD,KAAA,EACL;;;;;;;;CASH,YAAoB,OAAuC;EACzD,MAAM,aAAsC;IACzC,yBAAyB;IACzB,4BAA4B,MAAM;GACnC,GAAI,MAAM,gBAAgB,KAAA,IACtB,GAAG,4BAA4B,MAAM,aAAa,GAClD,EAAE;GACN,GAAI,MAAM,QAAQ,KAAA,IAAY,GAAG,sBAAsB,MAAM,KAAK,GAAG,EAAE;GACvE,GAAI,MAAM,YAAY,KAAA,IAClB,GAAG,0BAA0B,MAAM,SAAS,GAC5C,EAAE;GACN,GAAI,MAAM,UAAU,KAAA,IAAY,EAAE,cAAc,MAAM,OAAO,GAAG,EAAE;GAClE,GAAG,yBAAyB,MAAM;GACnC;AACD,MAAI,MAAM,SAAS,KAAA,GAAW;AAC5B,cAAW,wBAAwB,MAAM,KAAK;AAC9C,cAAW,kCAAkC,MAAM,KAAK;;AAE1D,MAAI,MAAM,UAAU,KAAA,GAAW;AAC7B,cAAW,yBAAyB,MAAM,MAAM;AAChD,cAAW,mCAAmC,MAAM,MAAM;;AAE5D,MAAI,MAAM,WAAW,KAAA,GAAW;AAC9B,cAAW,0BAA0B,MAAM,OAAO;AAClD,cAAW,oCAAoC,MAAM,OAAO;;AAG9D,SAAO,KAAK,gBACV,OACA,MAAM,MAAM,eAAe,WAAW,GAAG,MAAM,aAC/C,YACA,MAAM,UAAU,KAAA,IACZ;GAAE,MAAA;GAAyB,SAAS,MAAM;GAAO,GACjD,EAAE,MAAA,GAAyB,CAChC;;;;;;CAOH,kBAA0B,OAAyC;EACjE,MAAM,aAAsC;IACzC,yBAAyB;IACzB,8BAA8B,MAAM;IACpC,gCAAgC,MAAM;IACtC,4BAA4B,MAAM;GACnC,GAAG,yBAAyB,MAAM;GACnC;AACD,MAAI,MAAM,WAAW,KAAA,GAAW;GAG9B,MAAM,YACJ,MAAM,OAAO,aACb,MAAM,OAAO,KAAK,SAAA;AACpB,cAAW,2BAA2B,YAClC,MAAM,OAAO,KAAK,MAAM,GAAG,4BAA4B,GACvD,MAAM,OAAO;AACjB,cAAW,qCAAqC;;AAElD,MAAI,MAAM,cAAc,KAAA,EACtB,YAAW,0BAA0B,MAAM;AAG7C,SAAO,KAAK,gBACV,OACA,cAAc,oBAAoB,MAAM,UAAU,IAClD,YACA,MAAM,cAAc,KAAA,IAChB,EAAE,MAAA,GAAyB,GAC3B,EAAE,MAAA,GAAyB,CAChC;;CAGH,oBACE,OACA,YACA,MACA,iBACc;EAEd,MAAM,aAAa,oBAAoB,OAD3B,SAAS,MAAM,IACsB,CAAC;AAClD,aAAW,0BAA0B;AACrC,SAAO,OAAO,YAAY,gBAAgB;AAC1C,MAAI,MAAM,gBAAgB,KAAA,GAAW;AACnC,cAAW,uBAAuB,MAAM,YAAY;AACpD,cAAW,iCAAiC,MAAM,YAAY;;AAEhE,MAAI,MAAM,iBAAiB,KAAA,GAAW;AACpC,cAAW,wBAAwB,MAAM,aAAa;AACtD,cAAW,kCAAkC,MAAM,aAAa;AAChE,OAAI,MAAM,aAAa,aAAa,KAAA,EAClC,YAAW,oCACT,MAAM,aAAa;;AAIzB,SAAO,KAAK,gBACV,OACA,MACA,YACA,YAAY,OAAA,EAAwB,CACrC;;CAGH,gBACE,OACA,MACA,YACA,QACc;EACd,MAAM,MAAM,KAAK,WAAW,MAAM;EAClC,MAAM,SAAS,UAAU,EAAE;AAC3B,SAAO;GACL;GACA,cAAc,IAAI;GAClB,MAAA;GACA;GACA,WAAW,WAAW,MAAM,YAAY;GACxC,SAAS,WAAW,MAAM,UAAU;GACpC,UAAU,WAAW,KAAK,IAAI,GAAG,MAAM,YAAY,MAAM,YAAY,CAAC;GACtE;GACA,oBAAoB;IAAE,SAAS,IAAI;IAAS;IAAQ;GACrD;;;;;;CAOH,WAAmB,OAAwC;AACzD,MAAI,MAAM,YAAY,KAAA,KAAa,MAAM,iBAAiB,KAAA,EACxD,QAAO;GACL,SAAS,MAAM;GACf,cAAc,MAAM;GACpB,YAAY,MAAM;GACnB;EAEH,MAAM,WAAW,KAAK,cAAc,IAAI,MAAM,UAAU;AACxD,MAAI,UAAU;AACZ,YAAS,aAAa,MAAM;AAC5B,UAAO;;AAET,OAAK,mBAAmB,MAAM,UAAU;EACxC,MAAM,QAA2B;GAC/B,SAAS,UAAU,GAAG;GACtB,cAAc,UAAU,EAAE;GAC1B,YAAY,MAAM;GACnB;AACD,OAAK,cAAc,IAAI,MAAM,WAAW,MAAM;AAC9C,SAAO;;CAGT,mBAA2B,OAAqB;AAC9C,MAAI,KAAK,cAAc,OAAO,8BAC5B;AAEF,OAAK,MAAM,CAAC,WAAW,UAAU,KAAK,cACpC,KAAI,QAAQ,MAAM,aAAa,qBAC7B,MAAK,cAAc,OAAO,UAAU;;;AAY5C,MAAM,uBAAuB,MAAU;AACvC,MAAM,gCAAgC;AAEtC,MAAM,MAAM;;AAGZ,MAAa,aAAa,UAA0B;CAClD,MAAM,SAAS,OAAO,gBAAgB,IAAI,WAAW,MAAM,CAAC;CAC5D,IAAI,MAAM;AACV,MAAK,MAAM,QAAQ,OACjB,QAAO,IAAI,QAAQ,KAAK,IAAI,OAAO;AAErC,QAAO;;AAGT,MAAM,kBAAkB,IAAI,IAAY,iBAAiB;AAWzD,MAAM,YAAY,QAA2B;AAC3C,KAAI;EACF,MAAM,MAAM,qBAAqB,IAAI,IAAI,IAAI,CAAC;EAC9C,MAAM,cAAc,IAAI,aAAa,WAAW,MAAM;AACtD,SAAO;GACL,MAAM,IAAI,UAAU;GACpB,MAAM,IAAI;GACV,OAAO,IAAI,SAAS,IAAI,OAAO,MAAM,EAAE,GAAG,KAAA;GAC1C,QAAQ,IAAI,SAAS,QAAQ,MAAM,GAAG;GACtC,MAAM,IAAI;GACV,MAAM,IAAI,OAAO,SAAS,IAAI,MAAM,GAAG,GAAG;GAC3C;SACK;AACN,SAAO;GACL,MAAM;GACN,MAAM,IAAI,MAAM,IAAI,CAAC,MAAM;GAC3B,OAAO,KAAA;GACP,QAAQ,KAAA;GACR,MAAM,KAAA;GACN,MAAM,KAAA;GACP;;;AAIL,MAAM,4BACJ,UAEA,MAAM,sBAAsB,KAAA,IACxB,GAAG,2BAA2B,MAAM,mBAAmB,GACvD,EAAE;AAER,MAAM,uBACJ,OACA,QAC4B;CAC5B,MAAM,aAAsC;EAC1C,uBAAuB,MAAM;EAC7B,YAAY,IAAI;EAChB,YAAY,IAAI;EAChB,GAAI,IAAI,UAAU,KAAA,IAAY,EAAE,aAAa,IAAI,OAAO,GAAG,EAAE;EAC7D,GAAI,IAAI,WAAW,KAAA,IAAY,EAAE,cAAc,IAAI,QAAQ,GAAG,EAAE;EAChE,GAAI,IAAI,SAAS,KAAA,IAAY,EAAE,kBAAkB,IAAI,MAAM,GAAG,EAAE;EAChE,GAAI,IAAI,SAAS,KAAA,IAAY,EAAE,eAAe,IAAI,MAAM,GAAG,EAAE;EAC7D,GAAI,MAAM,eAAe,KAAA,IACrB,EAAE,6BAA6B,MAAM,YAAY,GACjD,EAAE;EACN,GAAI,MAAM,UAAU,KAAA,IAAY,EAAE,cAAc,MAAM,OAAO,GAAG,EAAE;EAClE,GAAG,yBAAyB,MAAM;EACnC;AAGD,MAAK,MAAM,CAAC,MAAM,WAAW,OAAO,QAAQ,MAAM,eAAe,CAC/D,KAAI,gBAAgB,IAAI,KAAK,aAAa,CAAC,CACzC,YAAW,uBAAuB,UAAU;AAGhD,MAAK,MAAM,CAAC,MAAM,WAAW,OAAO,QAAQ,MAAM,mBAAmB,EAAE,CAAC,CACtE,KAAI,gBAAgB,IAAI,KAAK,aAAa,CAAC,CACzC,YAAW,wBAAwB,UAAU;AAGjD,QAAO;;AAGT,MAAM,eACJ,OACA,SACe;AACf,KAAI,MAAM,UAAU,KAAA,EAClB,QAAO;EAAE,MAAA;EAAyB,SAAS,MAAM;EAAO;CAG1D,MAAM,iBAAiB,SAAA,IAA4B,MAAM;AACzD,KAAI,MAAM,eAAe,KAAA,KAAa,MAAM,cAAc,eACxD,QAAO,EAAE,MAAA,GAAyB;AAEpC,QAAO,EAAE,MAAA,GAAyB"}
|
package/dist/version.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Replaced by tsdown's `define`. The guard keeps a direct source import
|
|
9
9
|
* (vitest, typecheck) working, where no bundler has substituted the value.
|
|
10
10
|
*/
|
|
11
|
-
const WORKERD_SHIM_VERSION = "2.
|
|
11
|
+
const WORKERD_SHIM_VERSION = "2.346.0";
|
|
12
12
|
//#endregion
|
|
13
13
|
export { WORKERD_SHIM_VERSION };
|
|
14
14
|
|
package/dist/with-meticulous.js
CHANGED
|
Binary file
|