@alwaysmeticulous/backend-recorder-workerd 2.308.0 → 2.319.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.
@@ -0,0 +1,22 @@
1
+ //#region src/original-fetch.ts
2
+
3
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="57102687-d989-5686-9632-a7d913e39891")}catch(e){}}();
4
+ let originalFetch;
5
+ /**
6
+ * Holds the pre-patch `globalThis.fetch`. Its own module so that both the fetch patch (which
7
+ * captures it) and the capture tee (which uses it to reach the sidecar) can reach it without
8
+ * importing each other.
9
+ */
10
+ const setOriginalFetch = (fetchFn) => {
11
+ originalFetch = fetchFn;
12
+ };
13
+ /**
14
+ * The unpatched fetch — used for the shim's own sidecar requests, so they are never
15
+ * themselves captured. Falls back to the current global before the patch is installed.
16
+ */
17
+ const getOriginalFetch = () => originalFetch ?? globalThis.fetch;
18
+ //#endregion
19
+ export { getOriginalFetch, setOriginalFetch };
20
+
21
+ //# sourceMappingURL=original-fetch.js.map
22
+ //# debugId=57102687-d989-5686-9632-a7d913e39891
@@ -0,0 +1 @@
1
+ {"version":3,"file":"original-fetch.js","sources":["../src/original-fetch.ts"],"sourcesContent":["type FetchFn = typeof globalThis.fetch;\n\nlet originalFetch: FetchFn | undefined;\n\n/**\n * Holds the pre-patch `globalThis.fetch`. Its own module so that both the fetch patch (which\n * captures it) and the capture tee (which uses it to reach the sidecar) can reach it without\n * importing each other.\n */\nexport const setOriginalFetch = (fetchFn: FetchFn): void => {\n originalFetch = fetchFn;\n};\n\n/**\n * The unpatched fetch — used for the shim's own sidecar requests, so they are never\n * themselves captured. Falls back to the current global before the patch is installed.\n */\nexport const getOriginalFetch = (): FetchFn =>\n originalFetch ?? globalThis.fetch;\n"],"names":[],"mappings":";;;AAEA,IAAI;;;;;;AAOJ,MAAa,oBAAoB,YAA2B;AAC1D,iBAAgB;;;;;;AAOlB,MAAa,yBACX,iBAAiB,WAAW","debugId":"57102687-d989-5686-9632-a7d913e39891"}
@@ -1,5 +1,5 @@
1
- //#region src/fetch-patch.d.ts
1
+ //#region src/outbound-capture.d.ts
2
2
  declare const headersToRecord: (headers: Headers) => Record<string, string[]>;
3
3
  //#endregion
4
4
  export { headersToRecord };
5
- //# sourceMappingURL=fetch-patch.d.ts.map
5
+ //# sourceMappingURL=outbound-capture.d.ts.map
@@ -0,0 +1,127 @@
1
+
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="19b144de-8ce4-5457-bc5e-81d4e735a7bf")}catch(e){}}();
3
+ import { warnOnce } from "./log.js";
4
+ import { readBodyWithCap, readRequestBodyWithCap } from "./body-capture.js";
5
+ import { getOriginalFetch } from "./original-fetch.js";
6
+ import { CAPTURED_HEADERS } from "./protocol.js";
7
+ import { postCaptureEvents } from "./sidecar-client.js";
8
+ //#region src/outbound-capture.ts
9
+ const capturedHeaders = new Set(CAPTURED_HEADERS);
10
+ const headersToRecord = (headers) => {
11
+ const record = {};
12
+ headers.forEach((value, name) => {
13
+ const lowerName = name.toLowerCase();
14
+ if (capturedHeaders.has(lowerName)) (record[lowerName] ??= []).push(value);
15
+ });
16
+ return record;
17
+ };
18
+ /**
19
+ * Builds the transport's event. An explicit two-arm branch rather than a spread of the
20
+ * union, so the discriminant and the extra binding field stay obvious. (The shim has no
21
+ * runtime dependencies, so `assertNever` is not available here.)
22
+ */
23
+ const buildEvent = (transport, fields) => transport.kind === "binding" ? {
24
+ kind: "binding",
25
+ ...transport.bindingName !== void 0 ? { bindingName: transport.bindingName } : {},
26
+ ...fields
27
+ } : {
28
+ kind: "outbound",
29
+ ...fields
30
+ };
31
+ /**
32
+ * Records an outbound call without affecting it: the real transport is invoked directly
33
+ * (the sidecar is never in the request path), and request/response clones are reported in
34
+ * the background via `ctx.waitUntil`. Any capture failure is warned about once and
35
+ * swallowed — recording must never break the app.
36
+ *
37
+ * `invoke` is what actually performs the call, so the caller keeps ownership of the
38
+ * receiver (a binding's `fetch` must be applied to the binding, not to `globalThis`).
39
+ */
40
+ const captureOutboundCall = async (ctx, transport, request, invoke) => {
41
+ const startTimeMs = Date.now();
42
+ let meta;
43
+ let requestBodyPromise = Promise.resolve(void 0);
44
+ try {
45
+ meta = {
46
+ method: request.method,
47
+ url: request.url,
48
+ requestHeaders: headersToRecord(request.headers)
49
+ };
50
+ if (request.body) requestBodyPromise = readRequestBodyWithCap(request.clone().body).catch(() => void 0);
51
+ } catch (error) {
52
+ warnOnce("outbound-request-capture", "Failed to capture an outbound request.", error);
53
+ meta = void 0;
54
+ }
55
+ let response;
56
+ try {
57
+ response = await invoke(request);
58
+ } catch (error) {
59
+ if (meta) {
60
+ const frozenMeta = meta;
61
+ const endTimeMs = Date.now();
62
+ const errorMessage = String(error);
63
+ reportInBackground(ctx, async () => {
64
+ const requestBody = await requestBodyPromise;
65
+ await report(ctx, transport, {
66
+ ...frozenMeta,
67
+ requestId: ctx.requestId,
68
+ ...ctx.frontendSessionId !== void 0 ? { frontendSessionId: ctx.frontendSessionId } : {},
69
+ ...requestBody !== void 0 ? { requestBody } : {},
70
+ startTimeMs,
71
+ endTimeMs,
72
+ error: errorMessage
73
+ });
74
+ });
75
+ }
76
+ throw error;
77
+ }
78
+ if (meta) {
79
+ const frozenMeta = meta;
80
+ try {
81
+ if (hasWebSocket(response)) return response;
82
+ const responseClone = response.clone();
83
+ const responseHeaders = headersToRecord(response.headers);
84
+ const statusCode = response.status;
85
+ const endTimeMs = Date.now();
86
+ ctx.waitUntil((async () => {
87
+ const [requestBody, responseBody] = await Promise.all([requestBodyPromise, readBodyWithCap(responseClone.body).catch(() => void 0)]);
88
+ await report(ctx, transport, {
89
+ ...frozenMeta,
90
+ requestId: ctx.requestId,
91
+ ...ctx.frontendSessionId !== void 0 ? { frontendSessionId: ctx.frontendSessionId } : {},
92
+ responseHeaders,
93
+ statusCode,
94
+ ...requestBody !== void 0 ? { requestBody } : {},
95
+ ...responseBody !== void 0 ? { responseBody } : {},
96
+ startTimeMs,
97
+ endTimeMs
98
+ });
99
+ })().catch((error) => {
100
+ warnOnce("outbound-response-capture", "Failed to capture an outbound response.", error);
101
+ }));
102
+ } catch (error) {
103
+ warnOnce("outbound-response-capture", "Failed to capture an outbound response.", error);
104
+ }
105
+ }
106
+ return response;
107
+ };
108
+ const report = async (ctx, transport, fields) => {
109
+ const event = buildEvent(transport, fields);
110
+ await postCaptureEvents(getOriginalFetch(), ctx.sidecarUrl, [event]);
111
+ };
112
+ /** Detects a WebSocket upgrade response without assuming the property exists. */
113
+ const hasWebSocket = (response) => response.webSocket != null;
114
+ const reportInBackground = (ctx, reportFn) => {
115
+ try {
116
+ ctx.waitUntil(reportFn().catch((error) => {
117
+ warnOnce("outbound-error-report", "Failed to report a failed outbound request.", error);
118
+ }));
119
+ } catch (error) {
120
+ warnOnce("outbound-error-report", "Failed to report a failed outbound request.", error);
121
+ }
122
+ };
123
+ //#endregion
124
+ export { captureOutboundCall, headersToRecord };
125
+
126
+ //# sourceMappingURL=outbound-capture.js.map
127
+ //# debugId=19b144de-8ce4-5457-bc5e-81d4e735a7bf
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outbound-capture.js","sources":["../src/outbound-capture.ts"],"sourcesContent":["import { readBodyWithCap, readRequestBodyWithCap } from \"./body-capture\";\nimport type { RequestCaptureContext } from \"./context\";\nimport { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport {\n CAPTURED_HEADERS,\n type BindingRequestEvent,\n type CaptureEvent,\n type CapturedBody,\n type OutboundRequestEvent,\n} from \"./protocol\";\nimport { postCaptureEvents } from \"./sidecar-client\";\n\n/**\n * Which transport an intercepted call left the isolate through. Both are\n * Request/Response-shaped, so they share the capture tee below and differ only in the\n * event they emit.\n */\nexport type OutboundTransport =\n | { kind: \"outbound\" }\n | {\n kind: \"binding\";\n /**\n * The `env` key the binding was found under. Undefined when the instance was never\n * seen on `env` — e.g. a Durable Object stub, which is produced by `namespace.get()`\n * rather than being a binding itself.\n */\n bindingName: string | undefined;\n };\n\nconst capturedHeaders = new Set<string>(CAPTURED_HEADERS);\n\nexport const headersToRecord = (headers: Headers): Record<string, string[]> => {\n const record: Record<string, string[]> = {};\n headers.forEach((value, name) => {\n const lowerName = name.toLowerCase();\n if (capturedHeaders.has(lowerName)) {\n (record[lowerName] ??= []).push(value);\n }\n });\n return record;\n};\n\ninterface OutboundMeta {\n method: string;\n url: string;\n requestHeaders: Record<string, string[]>;\n}\n\ninterface EventFields extends OutboundMeta {\n requestId: string;\n frontendSessionId?: string;\n requestBody?: CapturedBody;\n responseBody?: CapturedBody;\n responseHeaders?: Record<string, string[]>;\n statusCode?: number;\n startTimeMs: number;\n endTimeMs: number;\n error?: string;\n}\n\n/**\n * Builds the transport's event. An explicit two-arm branch rather than a spread of the\n * union, so the discriminant and the extra binding field stay obvious. (The shim has no\n * runtime dependencies, so `assertNever` is not available here.)\n */\nconst buildEvent = (\n transport: OutboundTransport,\n fields: EventFields,\n): OutboundRequestEvent | BindingRequestEvent =>\n transport.kind === \"binding\"\n ? {\n kind: \"binding\",\n ...(transport.bindingName !== undefined\n ? { bindingName: transport.bindingName }\n : {}),\n ...fields,\n }\n : { kind: \"outbound\", ...fields };\n\n/**\n * Records an outbound call without affecting it: the real transport is invoked directly\n * (the sidecar is never in the request path), and request/response clones are reported in\n * the background via `ctx.waitUntil`. Any capture failure is warned about once and\n * swallowed — recording must never break the app.\n *\n * `invoke` is what actually performs the call, so the caller keeps ownership of the\n * receiver (a binding's `fetch` must be applied to the binding, not to `globalThis`).\n */\nexport const captureOutboundCall = async (\n ctx: RequestCaptureContext,\n transport: OutboundTransport,\n request: Request,\n invoke: (request: Request) => Promise<Response>,\n): Promise<Response> => {\n const startTimeMs = Date.now();\n let meta: OutboundMeta | undefined;\n let requestBodyPromise: Promise<CapturedBody | undefined> =\n Promise.resolve(undefined);\n try {\n meta = {\n method: request.method,\n url: request.url,\n requestHeaders: headersToRecord(request.headers),\n };\n if (request.body) {\n const bodyClone = request.clone();\n requestBodyPromise = readRequestBodyWithCap(bodyClone.body).catch(\n () => undefined,\n );\n }\n } catch (error) {\n warnOnce(\n \"outbound-request-capture\",\n \"Failed to capture an outbound request.\",\n error,\n );\n meta = undefined;\n }\n\n let response: Response;\n try {\n response = await invoke(request);\n } catch (error) {\n if (meta) {\n const frozenMeta = meta;\n const endTimeMs = Date.now();\n const errorMessage = String(error);\n reportInBackground(ctx, async () => {\n const requestBody = await requestBodyPromise;\n await report(ctx, transport, {\n ...frozenMeta,\n requestId: ctx.requestId,\n ...(ctx.frontendSessionId !== undefined\n ? { frontendSessionId: ctx.frontendSessionId }\n : {}),\n ...(requestBody !== undefined ? { requestBody } : {}),\n startTimeMs,\n endTimeMs,\n error: errorMessage,\n });\n });\n }\n throw error;\n }\n\n if (meta) {\n const frozenMeta = meta;\n try {\n // A WebSocket upgrade has no readable body and cannot be represented as a recorded\n // response, so it is left entirely alone.\n if (hasWebSocket(response)) {\n return response;\n }\n const responseClone = response.clone();\n const responseHeaders = headersToRecord(response.headers);\n const statusCode = response.status;\n const endTimeMs = Date.now();\n ctx.waitUntil(\n (async () => {\n const [requestBody, responseBody] = await Promise.all([\n requestBodyPromise,\n readBodyWithCap(responseClone.body).catch(() => undefined),\n ]);\n await report(ctx, transport, {\n ...frozenMeta,\n requestId: ctx.requestId,\n ...(ctx.frontendSessionId !== undefined\n ? { frontendSessionId: ctx.frontendSessionId }\n : {}),\n responseHeaders,\n statusCode,\n ...(requestBody !== undefined ? { requestBody } : {}),\n ...(responseBody !== undefined ? { responseBody } : {}),\n startTimeMs,\n endTimeMs,\n });\n })().catch((error) => {\n warnOnce(\n \"outbound-response-capture\",\n \"Failed to capture an outbound response.\",\n error,\n );\n }),\n );\n } catch (error) {\n warnOnce(\n \"outbound-response-capture\",\n \"Failed to capture an outbound response.\",\n error,\n );\n }\n }\n\n return response;\n};\n\nconst report = async (\n ctx: RequestCaptureContext,\n transport: OutboundTransport,\n fields: EventFields,\n): Promise<void> => {\n const event: CaptureEvent = buildEvent(transport, fields);\n await postCaptureEvents(getOriginalFetch(), ctx.sidecarUrl, [event]);\n};\n\n/** Detects a WebSocket upgrade response without assuming the property exists. */\nconst hasWebSocket = (response: Response): boolean =>\n (response as Response & { webSocket?: unknown }).webSocket != null;\n\nconst reportInBackground = (\n ctx: { waitUntil: (p: Promise<unknown>) => void },\n reportFn: () => Promise<void>,\n): void => {\n try {\n ctx.waitUntil(\n reportFn().catch((error) => {\n warnOnce(\n \"outbound-error-report\",\n \"Failed to report a failed outbound request.\",\n error,\n );\n }),\n );\n } catch (error) {\n warnOnce(\n \"outbound-error-report\",\n \"Failed to report a failed outbound request.\",\n error,\n );\n }\n};\n"],"names":[],"mappings":";;;;;;;;AA8BA,MAAM,kBAAkB,IAAI,IAAY,iBAAiB;AAEzD,MAAa,mBAAmB,YAA+C;CAC7E,MAAM,SAAmC,EAAE;AAC3C,SAAQ,SAAS,OAAO,SAAS;EAC/B,MAAM,YAAY,KAAK,aAAa;AACpC,MAAI,gBAAgB,IAAI,UAAU,CAChC,EAAC,OAAO,eAAe,EAAE,EAAE,KAAK,MAAM;GAExC;AACF,QAAO;;;;;;;AA0BT,MAAM,cACJ,WACA,WAEA,UAAU,SAAS,YACf;CACE,MAAM;CACN,GAAI,UAAU,gBAAgB,KAAA,IAC1B,EAAE,aAAa,UAAU,aAAa,GACtC,EAAE;CACN,GAAG;CACJ,GACD;CAAE,MAAM;CAAY,GAAG;CAAQ;;;;;;;;;;AAWrC,MAAa,sBAAsB,OACjC,KACA,WACA,SACA,WACsB;CACtB,MAAM,cAAc,KAAK,KAAK;CAC9B,IAAI;CACJ,IAAI,qBACF,QAAQ,QAAQ,KAAA,EAAU;AAC5B,KAAI;AACF,SAAO;GACL,QAAQ,QAAQ;GAChB,KAAK,QAAQ;GACb,gBAAgB,gBAAgB,QAAQ,QAAQ;GACjD;AACD,MAAI,QAAQ,KAEV,sBAAqB,uBADH,QAAQ,OAC2B,CAAC,KAAK,CAAC,YACpD,KAAA,EACP;UAEI,OAAO;AACd,WACE,4BACA,0CACA,MACD;AACD,SAAO,KAAA;;CAGT,IAAI;AACJ,KAAI;AACF,aAAW,MAAM,OAAO,QAAQ;UACzB,OAAO;AACd,MAAI,MAAM;GACR,MAAM,aAAa;GACnB,MAAM,YAAY,KAAK,KAAK;GAC5B,MAAM,eAAe,OAAO,MAAM;AAClC,sBAAmB,KAAK,YAAY;IAClC,MAAM,cAAc,MAAM;AAC1B,UAAM,OAAO,KAAK,WAAW;KAC3B,GAAG;KACH,WAAW,IAAI;KACf,GAAI,IAAI,sBAAsB,KAAA,IAC1B,EAAE,mBAAmB,IAAI,mBAAmB,GAC5C,EAAE;KACN,GAAI,gBAAgB,KAAA,IAAY,EAAE,aAAa,GAAG,EAAE;KACpD;KACA;KACA,OAAO;KACR,CAAC;KACF;;AAEJ,QAAM;;AAGR,KAAI,MAAM;EACR,MAAM,aAAa;AACnB,MAAI;AAGF,OAAI,aAAa,SAAS,CACxB,QAAO;GAET,MAAM,gBAAgB,SAAS,OAAO;GACtC,MAAM,kBAAkB,gBAAgB,SAAS,QAAQ;GACzD,MAAM,aAAa,SAAS;GAC5B,MAAM,YAAY,KAAK,KAAK;AAC5B,OAAI,WACD,YAAY;IACX,MAAM,CAAC,aAAa,gBAAgB,MAAM,QAAQ,IAAI,CACpD,oBACA,gBAAgB,cAAc,KAAK,CAAC,YAAY,KAAA,EAAU,CAC3D,CAAC;AACF,UAAM,OAAO,KAAK,WAAW;KAC3B,GAAG;KACH,WAAW,IAAI;KACf,GAAI,IAAI,sBAAsB,KAAA,IAC1B,EAAE,mBAAmB,IAAI,mBAAmB,GAC5C,EAAE;KACN;KACA;KACA,GAAI,gBAAgB,KAAA,IAAY,EAAE,aAAa,GAAG,EAAE;KACpD,GAAI,iBAAiB,KAAA,IAAY,EAAE,cAAc,GAAG,EAAE;KACtD;KACA;KACD,CAAC;OACA,CAAC,OAAO,UAAU;AACpB,aACE,6BACA,2CACA,MACD;KACD,CACH;WACM,OAAO;AACd,YACE,6BACA,2CACA,MACD;;;AAIL,QAAO;;AAGT,MAAM,SAAS,OACb,KACA,WACA,WACkB;CAClB,MAAM,QAAsB,WAAW,WAAW,OAAO;AACzD,OAAM,kBAAkB,kBAAkB,EAAE,IAAI,YAAY,CAAC,MAAM,CAAC;;;AAItE,MAAM,gBAAgB,aACnB,SAAgD,aAAa;AAEhE,MAAM,sBACJ,KACA,aACS;AACT,KAAI;AACF,MAAI,UACF,UAAU,CAAC,OAAO,UAAU;AAC1B,YACE,yBACA,+CACA,MACD;IACD,CACH;UACM,OAAO;AACd,WACE,yBACA,+CACA,MACD","debugId":"19b144de-8ce4-5457-bc5e-81d4e735a7bf"}
@@ -13,6 +13,22 @@ declare const SIDECAR_PROTOCOL_VERSION_HEADER = "x-meticulous-sidecar-protocol-v
13
13
  */
14
14
  declare const FRONTEND_SESSION_ID_HEADER = "x-meticulous-session-id";
15
15
  declare const SIDECAR_EVENTS_PATH = "/v1/events";
16
+ /**
17
+ * Replay routes. A sidecar started in record mode answers 404 on both, which the shim
18
+ * treats as "replay unavailable" and falls back to plain pass-through — so a new shim
19
+ * keeps working against an older, record-only sidecar.
20
+ */
21
+ declare const SIDECAR_REPLAY_SESSION_PATH = "/v1/replay/session";
22
+ declare const SIDECAR_REPLAY_OUTBOUND_FETCH_PATH = "/v1/replay/outbound-fetch";
23
+ /**
24
+ * Header carrying the replay sidecar's origin, injected by the Meticulous replay runner
25
+ * on requests to the app under test. Workerd cannot see container environment variables,
26
+ * so an inbound header is the only way per-replay config can reach the shim.
27
+ *
28
+ * The shim validates the value before using it and only honours a loopback /
29
+ * `host.docker.internal` / private-network `http:` origin — see `replay-sidecar-url.ts`.
30
+ */
31
+ declare const REPLAY_SIDECAR_URL_HEADER = "x-meticulous-backend-replay-sidecar-url";
16
32
  /**
17
33
  * The only headers persisted on capture events. Everything else — notably
18
34
  * authorization, cookie/set-cookie, and API-key headers — is dropped at
@@ -60,10 +76,68 @@ interface OutboundRequestEvent extends BaseRequestEvent {
60
76
  requestBody?: CapturedBody;
61
77
  responseBody?: CapturedBody;
62
78
  }
63
- type CaptureEvent = InboundRequestEvent | OutboundRequestEvent;
79
+ /**
80
+ * A `fetch` through a Cloudflare binding — a service binding, an assets binding, or a
81
+ * Durable Object stub — made while handling an inbound request (becomes a CLIENT span).
82
+ *
83
+ * Request/Response-shaped like {@link OutboundRequestEvent}, but a distinct kind because the
84
+ * call never leaves the isolate and its URL is whatever the caller invented. Keeping the two
85
+ * apart means a binding call and real egress can never be confused for one another when
86
+ * these spans are eventually mocked.
87
+ */
88
+ interface BindingRequestEvent extends BaseRequestEvent {
89
+ kind: "binding";
90
+ /**
91
+ * The `env` key the binding was found under. Absent when the instance was never seen on
92
+ * `env` — most commonly a Durable Object stub, which `namespace.get()` produces rather
93
+ * than being a binding in its own right.
94
+ */
95
+ bindingName?: string;
96
+ requestBody?: CapturedBody;
97
+ responseBody?: CapturedBody;
98
+ }
99
+ type CaptureEvent = InboundRequestEvent | OutboundRequestEvent | BindingRequestEvent;
64
100
  interface CaptureEventsPayload {
65
101
  events: CaptureEvent[];
66
102
  }
103
+ /**
104
+ * Answer to `GET {SIDECAR_REPLAY_SESSION_PATH}?sessionId=<id>`: whether the sidecar holds
105
+ * mocks for a session, and the virtual clock anchor to freeze `Date` at while serving it.
106
+ */
107
+ interface ReplaySessionInfoResponse {
108
+ found: boolean;
109
+ /**
110
+ * Epoch ms of the session's last recorded activity. The worker freezes its clock here so
111
+ * that credentials minted during the recording are already issued but not yet expired.
112
+ */
113
+ clockAnchorMs?: number;
114
+ }
115
+ /** Lookup for a single outbound `fetch`, sent to {@link SIDECAR_REPLAY_OUTBOUND_FETCH_PATH}. */
116
+ interface OutboundFetchLookupRequest {
117
+ frontendSessionId: string;
118
+ method: string;
119
+ /** Full URL including query string. */
120
+ url: string;
121
+ /**
122
+ * Request body as captured by `readBodyWithCap`. The sidecar hashes this itself, so the
123
+ * hash it compares against a recording is derived from byte-identical input on both the
124
+ * record and replay sides.
125
+ */
126
+ requestBody?: CapturedBody;
127
+ }
128
+ /**
129
+ * Either a recorded response to serve, or an instruction to let the real call through.
130
+ * An unrecognised `outcome` must be treated as `passthrough`, so an older shim keeps
131
+ * working against a newer sidecar.
132
+ */
133
+ type OutboundFetchLookupResponse = {
134
+ outcome: "mock";
135
+ statusCode: number;
136
+ body: string;
137
+ headers: Record<string, string>;
138
+ } | {
139
+ outcome: "passthrough";
140
+ };
67
141
  //#endregion
68
- export { CAPTURED_HEADERS, CaptureEvent, CaptureEventsPayload, CapturedBody, FRONTEND_SESSION_ID_HEADER, InboundRequestEvent, OutboundRequestEvent, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER };
142
+ export { BindingRequestEvent, CAPTURED_HEADERS, CaptureEvent, CaptureEventsPayload, CapturedBody, FRONTEND_SESSION_ID_HEADER, InboundRequestEvent, OutboundFetchLookupRequest, OutboundFetchLookupResponse, OutboundRequestEvent, REPLAY_SIDECAR_URL_HEADER, ReplaySessionInfoResponse, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH };
69
143
  //# sourceMappingURL=protocol.d.ts.map
package/dist/protocol.js CHANGED
@@ -4,6 +4,8 @@
4
4
  * recorder sidecar. The sidecar imports these types (type-only), so this file
5
5
  * is the single source of truth for the shape of capture events.
6
6
  */
7
+
8
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="58075d9c-7ce8-5f51-94e8-b073da0ecb33")}catch(e){}}();
7
9
  const SIDECAR_PROTOCOL_VERSION = "1";
8
10
  /** Header carrying {@link SIDECAR_PROTOCOL_VERSION} on every shim → sidecar request. */
9
11
  const SIDECAR_PROTOCOL_VERSION_HEADER = "x-meticulous-sidecar-protocol-version";
@@ -14,6 +16,22 @@ const SIDECAR_PROTOCOL_VERSION_HEADER = "x-meticulous-sidecar-protocol-version";
14
16
  const FRONTEND_SESSION_ID_HEADER = "x-meticulous-session-id";
15
17
  const SIDECAR_EVENTS_PATH = "/v1/events";
16
18
  /**
19
+ * Replay routes. A sidecar started in record mode answers 404 on both, which the shim
20
+ * treats as "replay unavailable" and falls back to plain pass-through — so a new shim
21
+ * keeps working against an older, record-only sidecar.
22
+ */
23
+ const SIDECAR_REPLAY_SESSION_PATH = "/v1/replay/session";
24
+ const SIDECAR_REPLAY_OUTBOUND_FETCH_PATH = "/v1/replay/outbound-fetch";
25
+ /**
26
+ * Header carrying the replay sidecar's origin, injected by the Meticulous replay runner
27
+ * on requests to the app under test. Workerd cannot see container environment variables,
28
+ * so an inbound header is the only way per-replay config can reach the shim.
29
+ *
30
+ * The shim validates the value before using it and only honours a loopback /
31
+ * `host.docker.internal` / private-network `http:` origin — see `replay-sidecar-url.ts`.
32
+ */
33
+ const REPLAY_SIDECAR_URL_HEADER = "x-meticulous-backend-replay-sidecar-url";
34
+ /**
17
35
  * The only headers persisted on capture events. Everything else — notably
18
36
  * authorization, cookie/set-cookie, and API-key headers — is dropped at
19
37
  * capture time, before it ever leaves the worker. Mirrors the Node backend
@@ -22,6 +40,7 @@ const SIDECAR_EVENTS_PATH = "/v1/events";
22
40
  */
23
41
  const CAPTURED_HEADERS = ["content-type", FRONTEND_SESSION_ID_HEADER];
24
42
  //#endregion
25
- export { CAPTURED_HEADERS, FRONTEND_SESSION_ID_HEADER, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER };
43
+ export { CAPTURED_HEADERS, FRONTEND_SESSION_ID_HEADER, REPLAY_SIDECAR_URL_HEADER, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH };
26
44
 
27
- //# sourceMappingURL=protocol.js.map
45
+ //# sourceMappingURL=protocol.js.map
46
+ //# debugId=58075d9c-7ce8-5f51-94e8-b073da0ecb33
@@ -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 = \"1\";\n\n/** Header carrying {@link SIDECAR_PROTOCOL_VERSION} on every shim → sidecar request. */\nexport const SIDECAR_PROTOCOL_VERSION_HEADER =\n \"x-meticulous-sidecar-protocol-version\";\n\n/**\n * Header the Meticulous frontend recorder stamps on requests made by the app\n * under recording; its value ties backend spans to the frontend session.\n */\nexport const FRONTEND_SESSION_ID_HEADER = \"x-meticulous-session-id\";\n\nexport const SIDECAR_EVENTS_PATH = \"/v1/events\";\n\n/**\n * The only headers persisted on capture events. Everything else — notably\n * authorization, cookie/set-cookie, and API-key headers — is dropped at\n * capture time, before it ever leaves the worker. Mirrors the Node backend\n * recorder, which persists only content-type plus the Meticulous session\n * header.\n */\nexport const CAPTURED_HEADERS = [\n \"content-type\",\n FRONTEND_SESSION_ID_HEADER,\n] as const;\n\nexport interface CapturedBody {\n /** UTF-8 decoded body, truncated to the capture cap. */\n body: string;\n /** True when the body exceeded the capture cap or the read was cut short. */\n truncated: boolean;\n}\n\ninterface BaseRequestEvent {\n /** Correlates outbound events to the inbound request being handled. */\n requestId: string;\n /** Value of {@link FRONTEND_SESSION_ID_HEADER} on the inbound request, if any. */\n frontendSessionId?: string;\n method: string;\n /** Full URL including query string. */\n url: string;\n /** Lower-cased header name → values. */\n requestHeaders: Record<string, string[]>;\n responseHeaders?: Record<string, string[]>;\n statusCode?: number;\n startTimeMs: number;\n endTimeMs: number;\n /** Set when the request failed before a response was produced. */\n error?: string;\n}\n\n/**\n * The worker's own inbound request (becomes a SERVER span). Bodies are not\n * captured, matching the Node backend recorder's SERVER-span contract.\n */\nexport interface InboundRequestEvent extends BaseRequestEvent {\n kind: \"inbound\";\n}\n\n/**\n * An outgoing `fetch` call made while handling an inbound request (becomes a\n * CLIENT span).\n */\nexport interface OutboundRequestEvent extends BaseRequestEvent {\n kind: \"outbound\";\n requestBody?: CapturedBody;\n responseBody?: CapturedBody;\n}\n\nexport type CaptureEvent = InboundRequestEvent | OutboundRequestEvent;\n\nexport interface CaptureEventsPayload {\n events: CaptureEvent[];\n}\n"],"mappings":";;;;;;AAMA,MAAa,2BAA2B;;AAGxC,MAAa,kCACX;;;;;AAMF,MAAa,6BAA6B;AAE1C,MAAa,sBAAsB;;;;;;;;AASnC,MAAa,mBAAmB,CAC9B,gBACA,2BACD"}
1
+ {"version":3,"file":"protocol.js","sources":["../src/protocol.ts"],"sourcesContent":["/**\n * Wire protocol between the in-worker shim and the Meticulous backend\n * recorder sidecar. The sidecar imports these types (type-only), so this file\n * is the single source of truth for the shape of capture events.\n */\n\nexport const SIDECAR_PROTOCOL_VERSION = \"1\";\n\n/** Header carrying {@link SIDECAR_PROTOCOL_VERSION} on every shim → sidecar request. */\nexport const SIDECAR_PROTOCOL_VERSION_HEADER =\n \"x-meticulous-sidecar-protocol-version\";\n\n/**\n * Header the Meticulous frontend recorder stamps on requests made by the app\n * under recording; its value ties backend spans to the frontend session.\n */\nexport const FRONTEND_SESSION_ID_HEADER = \"x-meticulous-session-id\";\n\nexport const SIDECAR_EVENTS_PATH = \"/v1/events\";\n\n/**\n * Replay routes. A sidecar started in record mode answers 404 on both, which the shim\n * treats as \"replay unavailable\" and falls back to plain pass-through — so a new shim\n * keeps working against an older, record-only sidecar.\n */\nexport const SIDECAR_REPLAY_SESSION_PATH = \"/v1/replay/session\";\nexport const SIDECAR_REPLAY_OUTBOUND_FETCH_PATH = \"/v1/replay/outbound-fetch\";\n\n/**\n * Header carrying the replay sidecar's origin, injected by the Meticulous replay runner\n * on requests to the app under test. Workerd cannot see container environment variables,\n * so an inbound header is the only way per-replay config can reach the shim.\n *\n * The shim validates the value before using it and only honours a loopback /\n * `host.docker.internal` / private-network `http:` origin — see `replay-sidecar-url.ts`.\n */\nexport const REPLAY_SIDECAR_URL_HEADER =\n \"x-meticulous-backend-replay-sidecar-url\";\n\n/**\n * The only headers persisted on capture events. Everything else — notably\n * authorization, cookie/set-cookie, and API-key headers — is dropped at\n * capture time, before it ever leaves the worker. Mirrors the Node backend\n * recorder, which persists only content-type plus the Meticulous session\n * header.\n */\nexport const CAPTURED_HEADERS = [\n \"content-type\",\n FRONTEND_SESSION_ID_HEADER,\n] as const;\n\nexport interface CapturedBody {\n /** UTF-8 decoded body, truncated to the capture cap. */\n body: string;\n /** True when the body exceeded the capture cap or the read was cut short. */\n truncated: boolean;\n}\n\ninterface BaseRequestEvent {\n /** Correlates outbound events to the inbound request being handled. */\n requestId: string;\n /** Value of {@link FRONTEND_SESSION_ID_HEADER} on the inbound request, if any. */\n frontendSessionId?: string;\n method: string;\n /** Full URL including query string. */\n url: string;\n /** Lower-cased header name → values. */\n requestHeaders: Record<string, string[]>;\n responseHeaders?: Record<string, string[]>;\n statusCode?: number;\n startTimeMs: number;\n endTimeMs: number;\n /** Set when the request failed before a response was produced. */\n error?: string;\n}\n\n/**\n * The worker's own inbound request (becomes a SERVER span). Bodies are not\n * captured, matching the Node backend recorder's SERVER-span contract.\n */\nexport interface InboundRequestEvent extends BaseRequestEvent {\n kind: \"inbound\";\n}\n\n/**\n * An outgoing `fetch` call made while handling an inbound request (becomes a\n * CLIENT span).\n */\nexport interface OutboundRequestEvent extends BaseRequestEvent {\n kind: \"outbound\";\n requestBody?: CapturedBody;\n responseBody?: CapturedBody;\n}\n\n/**\n * A `fetch` through a Cloudflare binding — a service binding, an assets binding, or a\n * Durable Object stub — made while handling an inbound request (becomes a CLIENT span).\n *\n * Request/Response-shaped like {@link OutboundRequestEvent}, but a distinct kind because the\n * call never leaves the isolate and its URL is whatever the caller invented. Keeping the two\n * apart means a binding call and real egress can never be confused for one another when\n * these spans are eventually mocked.\n */\nexport interface BindingRequestEvent extends BaseRequestEvent {\n kind: \"binding\";\n /**\n * The `env` key the binding was found under. Absent when the instance was never seen on\n * `env` — most commonly a Durable Object stub, which `namespace.get()` produces rather\n * than being a binding in its own right.\n */\n bindingName?: string;\n requestBody?: CapturedBody;\n responseBody?: CapturedBody;\n}\n\nexport type CaptureEvent =\n | InboundRequestEvent\n | OutboundRequestEvent\n | BindingRequestEvent;\n\nexport interface CaptureEventsPayload {\n events: CaptureEvent[];\n}\n\n/**\n * Answer to `GET {SIDECAR_REPLAY_SESSION_PATH}?sessionId=<id>`: whether the sidecar holds\n * mocks for a session, and the virtual clock anchor to freeze `Date` at while serving it.\n */\nexport interface ReplaySessionInfoResponse {\n found: boolean;\n /**\n * Epoch ms of the session's last recorded activity. The worker freezes its clock here so\n * that credentials minted during the recording are already issued but not yet expired.\n */\n clockAnchorMs?: number;\n}\n\n/** Lookup for a single outbound `fetch`, sent to {@link SIDECAR_REPLAY_OUTBOUND_FETCH_PATH}. */\nexport interface OutboundFetchLookupRequest {\n frontendSessionId: string;\n method: string;\n /** Full URL including query string. */\n url: string;\n /**\n * Request body as captured by `readBodyWithCap`. The sidecar hashes this itself, so the\n * hash it compares against a recording is derived from byte-identical input on both the\n * record and replay sides.\n */\n requestBody?: CapturedBody;\n}\n\n/**\n * Either a recorded response to serve, or an instruction to let the real call through.\n * An unrecognised `outcome` must be treated as `passthrough`, so an older shim keeps\n * working against a newer sidecar.\n */\nexport type OutboundFetchLookupResponse =\n | {\n outcome: \"mock\";\n statusCode: number;\n body: string;\n headers: Record<string, string>;\n }\n | { outcome: \"passthrough\" };\n"],"names":[],"mappings":";;;;;;;;AAMA,MAAa,2BAA2B;;AAGxC,MAAa,kCACX;;;;;AAMF,MAAa,6BAA6B;AAE1C,MAAa,sBAAsB;;;;;;AAOnC,MAAa,8BAA8B;AAC3C,MAAa,qCAAqC;;;;;;;;;AAUlD,MAAa,4BACX;;;;;;;;AASF,MAAa,mBAAmB,CAC9B,gBACA,2BACD","debugId":"58075d9c-7ce8-5f51-94e8-b073da0ecb33"}
@@ -0,0 +1,19 @@
1
+ //#region src/redact-body.d.ts
2
+ /** Replacement for a redacted value. Matches the Node recorder's redaction marker. */
3
+ declare const STR_REDACTED = "REDACTED";
4
+ /**
5
+ * Redacts secret-looking fields from a captured request body, returning the body unchanged
6
+ * if it is not JSON.
7
+ *
8
+ * Applied to request bodies only. Response bodies must stay byte-exact: replay serves them
9
+ * back to the app, so redacting one would corrupt what the app receives. Request bodies are
10
+ * only ever used to key a lookup, and because the same function runs on both the record and
11
+ * the replay side the keys still agree.
12
+ *
13
+ * Never throws — a body that cannot be parsed or re-serialized is returned as-is, which is
14
+ * no worse than today's behaviour.
15
+ */
16
+ declare const redactRequestBody: (body: string) => string;
17
+ //#endregion
18
+ export { STR_REDACTED, redactRequestBody };
19
+ //# sourceMappingURL=redact-body.d.ts.map
@@ -0,0 +1,53 @@
1
+ //#region src/redact-body.ts
2
+ /** Replacement for a redacted value. Matches the Node recorder's redaction marker. */
3
+
4
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="36ac86ad-0c78-5b5e-87b8-88acca7728a4")}catch(e){}}();
5
+ const STR_REDACTED = "REDACTED";
6
+ const secretKeys = new Set([
7
+ "accesstoken",
8
+ "apikey",
9
+ "authorization",
10
+ "clientsecret",
11
+ "credential",
12
+ "idtoken",
13
+ "password",
14
+ "privatekey",
15
+ "refreshtoken",
16
+ "secret",
17
+ "token"
18
+ ]);
19
+ const normalizeKey = (key) => key.toLowerCase().replace(/[_-]/g, "");
20
+ const isSecretKey = (key) => secretKeys.has(normalizeKey(key));
21
+ /**
22
+ * Redacts secret-looking fields from a captured request body, returning the body unchanged
23
+ * if it is not JSON.
24
+ *
25
+ * Applied to request bodies only. Response bodies must stay byte-exact: replay serves them
26
+ * back to the app, so redacting one would corrupt what the app receives. Request bodies are
27
+ * only ever used to key a lookup, and because the same function runs on both the record and
28
+ * the replay side the keys still agree.
29
+ *
30
+ * Never throws — a body that cannot be parsed or re-serialized is returned as-is, which is
31
+ * no worse than today's behaviour.
32
+ */
33
+ const redactRequestBody = (body) => {
34
+ if (body.length === 0) return body;
35
+ try {
36
+ const redacted = redactValue(JSON.parse(body));
37
+ return JSON.stringify(redacted) ?? body;
38
+ } catch {
39
+ return body;
40
+ }
41
+ };
42
+ const redactValue = (value) => {
43
+ if (Array.isArray(value)) return value.map(redactValue);
44
+ if (value === null || typeof value !== "object") return value;
45
+ const result = {};
46
+ for (const [key, nested] of Object.entries(value)) result[key] = isSecretKey(key) ? STR_REDACTED : redactValue(nested);
47
+ return result;
48
+ };
49
+ //#endregion
50
+ export { STR_REDACTED, redactRequestBody };
51
+
52
+ //# sourceMappingURL=redact-body.js.map
53
+ //# debugId=36ac86ad-0c78-5b5e-87b8-88acca7728a4
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redact-body.js","sources":["../src/redact-body.ts"],"sourcesContent":["/** Replacement for a redacted value. Matches the Node recorder's redaction marker. */\nexport const STR_REDACTED = \"REDACTED\";\n\n/**\n * Object keys whose values are replaced before a captured request body leaves the worker.\n *\n * Request bodies are captured verbatim, and plenty of APIs carry a credential in the body\n * rather than a header — an OAuth2 token exchange, or a feature-flag resolver that takes its\n * client secret as a JSON field. Header redaction (an allow-list of two) does nothing for\n * those, so without this the credential would be persisted in the recorded span.\n *\n * Compared case-insensitively, ignoring `_` and `-`, so `clientSecret`, `client_secret` and\n * `CLIENT-SECRET` all match one entry.\n */\nconst SECRET_KEYS = [\n \"accesstoken\",\n \"apikey\",\n \"authorization\",\n \"clientsecret\",\n \"credential\",\n \"idtoken\",\n \"password\",\n \"privatekey\",\n \"refreshtoken\",\n \"secret\",\n \"token\",\n] as const;\n\nconst secretKeys = new Set<string>(SECRET_KEYS);\n\nconst normalizeKey = (key: string): string =>\n key.toLowerCase().replace(/[_-]/g, \"\");\n\nconst isSecretKey = (key: string): boolean => secretKeys.has(normalizeKey(key));\n\n/**\n * Redacts secret-looking fields from a captured request body, returning the body unchanged\n * if it is not JSON.\n *\n * Applied to request bodies only. Response bodies must stay byte-exact: replay serves them\n * back to the app, so redacting one would corrupt what the app receives. Request bodies are\n * only ever used to key a lookup, and because the same function runs on both the record and\n * the replay side the keys still agree.\n *\n * Never throws — a body that cannot be parsed or re-serialized is returned as-is, which is\n * no worse than today's behaviour.\n */\nexport const redactRequestBody = (body: string): string => {\n if (body.length === 0) {\n return body;\n }\n try {\n const parsed: unknown = JSON.parse(body);\n const redacted = redactValue(parsed);\n const serialized = JSON.stringify(redacted);\n return serialized ?? body;\n } catch {\n // Not JSON (form-encoded, protobuf, plain text) — left alone rather than mangled.\n return body;\n }\n};\n\nconst redactValue = (value: unknown): unknown => {\n if (Array.isArray(value)) {\n return value.map(redactValue);\n }\n if (value === null || typeof value !== \"object\") {\n return value;\n }\n const result: Record<string, unknown> = {};\n for (const [key, nested] of Object.entries(value)) {\n result[key] = isSecretKey(key) ? STR_REDACTED : redactValue(nested);\n }\n return result;\n};\n"],"names":[],"mappings":";;;;AACA,MAAa,eAAe;AA2B5B,MAAM,aAAa,IAAI,IAAY;CAbjC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAG4C,CAAC;AAE/C,MAAM,gBAAgB,QACpB,IAAI,aAAa,CAAC,QAAQ,SAAS,GAAG;AAExC,MAAM,eAAe,QAAyB,WAAW,IAAI,aAAa,IAAI,CAAC;;;;;;;;;;;;;AAc/E,MAAa,qBAAqB,SAAyB;AACzD,KAAI,KAAK,WAAW,EAClB,QAAO;AAET,KAAI;EAEF,MAAM,WAAW,YADO,KAAK,MAAM,KACA,CAAC;AAEpC,SADmB,KAAK,UAAU,SACjB,IAAI;SACf;AAEN,SAAO;;;AAIX,MAAM,eAAe,UAA4B;AAC/C,KAAI,MAAM,QAAQ,MAAM,CACtB,QAAO,MAAM,IAAI,YAAY;AAE/B,KAAI,UAAU,QAAQ,OAAO,UAAU,SACrC,QAAO;CAET,MAAM,SAAkC,EAAE;AAC1C,MAAK,MAAM,CAAC,KAAK,WAAW,OAAO,QAAQ,MAAM,CAC/C,QAAO,OAAO,YAAY,IAAI,GAAG,eAAe,YAAY,OAAO;AAErE,QAAO","debugId":"36ac86ad-0c78-5b5e-87b8-88acca7728a4"}
@@ -0,0 +1,81 @@
1
+
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="378556a4-8a50-5671-a66d-eda7ae1c0626")}catch(e){}}();
3
+ import { warnOnce } from "./log.js";
4
+ import { readRequestBodyWithCap } from "./body-capture.js";
5
+ import { getOriginalFetch } from "./original-fetch.js";
6
+ import { postOutboundFetchLookup } from "./sidecar-client.js";
7
+ //#region src/replay-fetch.ts
8
+ /**
9
+ * Serves a recorded response for an outbound call, or lets the real one through.
10
+ *
11
+ * The mirror image of `captureOutboundCall`: recording tees a call that happens anyway,
12
+ * whereas replay decides first and only then calls — so the sidecar sits in the request
13
+ * path here, and every failure mode below falls back to `invoke`.
14
+ *
15
+ * A miss always falls through to the real service rather than erroring. Recordings are
16
+ * legitimately incomplete — anything the app cached during recording (e.g. public JWKs held
17
+ * in a KV namespace) is never captured, and is fetched for real when replay starts with a
18
+ * cold cache.
19
+ */
20
+ const replayOutboundCall = async (ctx, request, invoke) => {
21
+ let lookup;
22
+ try {
23
+ const requestBody = request.body ? await readRequestBodyWithCap(request.clone().body).catch(() => void 0) : void 0;
24
+ lookup = {
25
+ frontendSessionId: ctx.frontendSessionId,
26
+ method: request.method,
27
+ url: request.url,
28
+ ...requestBody !== void 0 ? { requestBody } : {}
29
+ };
30
+ } catch (error) {
31
+ warnOnce("replay-lookup-build", "Failed to prepare a mock lookup — letting the real call through.", error);
32
+ return invoke(request);
33
+ }
34
+ const result = await postOutboundFetchLookup(getOriginalFetch(), ctx.sidecarUrl, lookup);
35
+ if (result?.outcome !== "mock") return invoke(request);
36
+ return buildMockResponse(result) ?? invoke(request);
37
+ };
38
+ const HEADERS_TO_DROP = new Set([
39
+ "content-encoding",
40
+ "content-length",
41
+ "transfer-encoding"
42
+ ]);
43
+ const NULL_BODY_STATUSES = new Set([
44
+ 101,
45
+ 204,
46
+ 205,
47
+ 304
48
+ ]);
49
+ /**
50
+ * Turns a recorded response into a real one, or undefined if it cannot be represented (the
51
+ * constructor throws a RangeError on an out-of-range status, or a body on a null-body
52
+ * status), in which case the caller passes through.
53
+ *
54
+ * Note recorded headers are limited to CAPTURED_HEADERS, so a mocked response carries
55
+ * essentially just content-type. In particular it can never set cookies — fine for the API
56
+ * calls this path serves, but a real limitation for a backend that authenticates that way.
57
+ */
58
+ const buildMockResponse = (mock) => {
59
+ try {
60
+ const headers = new Headers();
61
+ for (const [name, value] of Object.entries(mock.headers)) {
62
+ if (HEADERS_TO_DROP.has(name.toLowerCase())) continue;
63
+ try {
64
+ headers.set(name, value);
65
+ } catch {}
66
+ }
67
+ const body = NULL_BODY_STATUSES.has(mock.statusCode) ? null : mock.body;
68
+ return new Response(body, {
69
+ status: mock.statusCode,
70
+ headers
71
+ });
72
+ } catch (error) {
73
+ warnOnce("replay-mock-response", "Could not construct a mocked response — letting the real call through.", error);
74
+ return;
75
+ }
76
+ };
77
+ //#endregion
78
+ export { replayOutboundCall };
79
+
80
+ //# sourceMappingURL=replay-fetch.js.map
81
+ //# debugId=378556a4-8a50-5671-a66d-eda7ae1c0626
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay-fetch.js","sources":["../src/replay-fetch.ts"],"sourcesContent":["import { readRequestBodyWithCap } from \"./body-capture\";\nimport type { RequestReplayContext } from \"./context\";\nimport { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport type { OutboundFetchLookupRequest } from \"./protocol\";\nimport { postOutboundFetchLookup } from \"./sidecar-client\";\n\n/**\n * Serves a recorded response for an outbound call, or lets the real one through.\n *\n * The mirror image of `captureOutboundCall`: recording tees a call that happens anyway,\n * whereas replay decides first and only then calls — so the sidecar sits in the request\n * path here, and every failure mode below falls back to `invoke`.\n *\n * A miss always falls through to the real service rather than erroring. Recordings are\n * legitimately incomplete — anything the app cached during recording (e.g. public JWKs held\n * in a KV namespace) is never captured, and is fetched for real when replay starts with a\n * cold cache.\n */\nexport const replayOutboundCall = async (\n ctx: RequestReplayContext,\n request: Request,\n invoke: (request: Request) => Promise<Response>,\n): Promise<Response> => {\n let lookup: OutboundFetchLookupRequest;\n try {\n // Read the clone and pass the original through untouched, mirroring the record path —\n // so the miss branch needs no body rebuild. The sidecar hashes what we send here, and\n // it was captured by the same readRequestBodyWithCap the recorder used (redaction\n // included), so the hashes on both sides derive from byte-identical input.\n const requestBody = request.body\n ? await readRequestBodyWithCap(request.clone().body).catch(\n () => undefined,\n )\n : undefined;\n lookup = {\n frontendSessionId: ctx.frontendSessionId,\n method: request.method,\n url: request.url,\n ...(requestBody !== undefined ? { requestBody } : {}),\n };\n } catch (error) {\n warnOnce(\n \"replay-lookup-build\",\n \"Failed to prepare a mock lookup — letting the real call through.\",\n error,\n );\n return invoke(request);\n }\n\n const result = await postOutboundFetchLookup(\n getOriginalFetch(),\n ctx.sidecarUrl,\n lookup,\n );\n // Also covers an unrecognised `outcome` from a newer sidecar.\n if (result?.outcome !== \"mock\") {\n return invoke(request);\n }\n return buildMockResponse(result) ?? invoke(request);\n};\n\n// Recorded bodies are already decoded and may have been truncated, so the recorded framing\n// headers no longer describe them; the Response constructor recomputes what it needs.\nconst HEADERS_TO_DROP = new Set([\n \"content-encoding\",\n \"content-length\",\n \"transfer-encoding\",\n]);\n\n// Statuses the Response constructor refuses to pair with a body.\nconst NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);\n\n/**\n * Turns a recorded response into a real one, or undefined if it cannot be represented (the\n * constructor throws a RangeError on an out-of-range status, or a body on a null-body\n * status), in which case the caller passes through.\n *\n * Note recorded headers are limited to CAPTURED_HEADERS, so a mocked response carries\n * essentially just content-type. In particular it can never set cookies — fine for the API\n * calls this path serves, but a real limitation for a backend that authenticates that way.\n */\nconst buildMockResponse = (mock: {\n statusCode: number;\n body: string;\n headers: Record<string, string>;\n}): Response | undefined => {\n try {\n const headers = new Headers();\n for (const [name, value] of Object.entries(mock.headers)) {\n if (HEADERS_TO_DROP.has(name.toLowerCase())) {\n continue;\n }\n try {\n headers.set(name, value);\n } catch {\n // Skip a header name/value the runtime rejects rather than losing the whole mock.\n }\n }\n const body = NULL_BODY_STATUSES.has(mock.statusCode) ? null : mock.body;\n return new Response(body, { status: mock.statusCode, headers });\n } catch (error) {\n warnOnce(\n \"replay-mock-response\",\n \"Could not construct a mocked response — letting the real call through.\",\n error,\n );\n return undefined;\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;AAmBA,MAAa,qBAAqB,OAChC,KACA,SACA,WACsB;CACtB,IAAI;AACJ,KAAI;EAKF,MAAM,cAAc,QAAQ,OACxB,MAAM,uBAAuB,QAAQ,OAAO,CAAC,KAAK,CAAC,YAC3C,KAAA,EACP,GACD,KAAA;AACJ,WAAS;GACP,mBAAmB,IAAI;GACvB,QAAQ,QAAQ;GAChB,KAAK,QAAQ;GACb,GAAI,gBAAgB,KAAA,IAAY,EAAE,aAAa,GAAG,EAAE;GACrD;UACM,OAAO;AACd,WACE,uBACA,oEACA,MACD;AACD,SAAO,OAAO,QAAQ;;CAGxB,MAAM,SAAS,MAAM,wBACnB,kBAAkB,EAClB,IAAI,YACJ,OACD;AAED,KAAI,QAAQ,YAAY,OACtB,QAAO,OAAO,QAAQ;AAExB,QAAO,kBAAkB,OAAO,IAAI,OAAO,QAAQ;;AAKrD,MAAM,kBAAkB,IAAI,IAAI;CAC9B;CACA;CACA;CACD,CAAC;AAGF,MAAM,qBAAqB,IAAI,IAAI;CAAC;CAAK;CAAK;CAAK;CAAI,CAAC;;;;;;;;;;AAWxD,MAAM,qBAAqB,SAIC;AAC1B,KAAI;EACF,MAAM,UAAU,IAAI,SAAS;AAC7B,OAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,KAAK,QAAQ,EAAE;AACxD,OAAI,gBAAgB,IAAI,KAAK,aAAa,CAAC,CACzC;AAEF,OAAI;AACF,YAAQ,IAAI,MAAM,MAAM;WAClB;;EAIV,MAAM,OAAO,mBAAmB,IAAI,KAAK,WAAW,GAAG,OAAO,KAAK;AACnE,SAAO,IAAI,SAAS,MAAM;GAAE,QAAQ,KAAK;GAAY;GAAS,CAAC;UACxD,OAAO;AACd,WACE,wBACA,0EACA,MACD;AACD","debugId":"378556a4-8a50-5671-a66d-eda7ae1c0626"}
@@ -0,0 +1,62 @@
1
+
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="94547b8a-bd87-50d7-ac87-204a0a65c1b7")}catch(e){}}();
3
+ import { warnOnce } from "./log.js";
4
+ //#region src/replay-sidecar-url.ts
5
+ /**
6
+ * Validation for the replay sidecar origin carried on inbound requests.
7
+ *
8
+ * The value arrives in a request header, so in principle anything that can reach the worker
9
+ * can propose one — and the shim POSTs outbound request bodies to whatever it accepts. The
10
+ * guard therefore only honours an origin that could plausibly be a Meticulous replay
11
+ * sidecar running alongside the app: plaintext HTTP on a loopback, docker-gateway or
12
+ * private-network host.
13
+ *
14
+ * `https:` is rejected deliberately. A sidecar reachable over loopback or a pod network has
15
+ * no TLS to offer, so permitting `https:` would buy nothing except a way for a forged header
16
+ * to exfiltrate request bodies to an arbitrary internet host.
17
+ */
18
+ const MAX_URL_LENGTH = 2048;
19
+ const ALLOWED_HOSTNAMES = new Set([
20
+ "localhost",
21
+ "host.docker.internal",
22
+ "host.containers.internal"
23
+ ]);
24
+ const parseReplaySidecarUrl = (raw) => {
25
+ if (typeof raw !== "string" || raw.length === 0) return;
26
+ if (raw.length > MAX_URL_LENGTH) {
27
+ warnOnce("replay-sidecar-url", "Ignoring an implausibly long Meticulous replay sidecar URL.");
28
+ return;
29
+ }
30
+ let url;
31
+ try {
32
+ url = new URL(raw);
33
+ } catch {
34
+ warnOnce("replay-sidecar-url", "Ignoring an unparseable Meticulous replay sidecar URL.");
35
+ return;
36
+ }
37
+ if (!(url.protocol === "http:" && url.username === "" && url.password === "" && (url.pathname === "" || url.pathname === "/") && url.search === "" && url.hash === "" && isLocalHostname(url.hostname))) {
38
+ warnOnce("replay-sidecar-url", `Ignoring the Meticulous replay sidecar URL "${raw}" — only a plain http:// origin on a loopback, docker-gateway or private-network host is honoured.`);
39
+ return;
40
+ }
41
+ return url.origin;
42
+ };
43
+ const isLocalHostname = (hostname) => {
44
+ const lower = hostname.toLowerCase();
45
+ if (ALLOWED_HOSTNAMES.has(lower)) return true;
46
+ const unbracketed = lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : void 0;
47
+ if (unbracketed !== void 0) return unbracketed === "::1";
48
+ return isPrivateIpv4(lower);
49
+ };
50
+ const isPrivateIpv4 = (hostname) => {
51
+ const parts = hostname.split(".");
52
+ if (parts.length !== 4) return false;
53
+ const octets = parts.map((part) => /^\d{1,3}$/.test(part) ? Number(part) : NaN);
54
+ if (octets.some((octet) => Number.isNaN(octet) || octet > 255)) return false;
55
+ const [first, second] = octets;
56
+ return first === 127 || first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
57
+ };
58
+ //#endregion
59
+ export { parseReplaySidecarUrl };
60
+
61
+ //# sourceMappingURL=replay-sidecar-url.js.map
62
+ //# debugId=94547b8a-bd87-50d7-ac87-204a0a65c1b7
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay-sidecar-url.js","sources":["../src/replay-sidecar-url.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\n\n/**\n * Validation for the replay sidecar origin carried on inbound requests.\n *\n * The value arrives in a request header, so in principle anything that can reach the worker\n * can propose one — and the shim POSTs outbound request bodies to whatever it accepts. The\n * guard therefore only honours an origin that could plausibly be a Meticulous replay\n * sidecar running alongside the app: plaintext HTTP on a loopback, docker-gateway or\n * private-network host.\n *\n * `https:` is rejected deliberately. A sidecar reachable over loopback or a pod network has\n * no TLS to offer, so permitting `https:` would buy nothing except a way for a forged header\n * to exfiltrate request bodies to an arbitrary internet host.\n */\n\nconst MAX_URL_LENGTH = 2048;\n\nconst ALLOWED_HOSTNAMES = new Set([\n \"localhost\",\n // Docker Desktop and Podman expose the host to containers under these names.\n \"host.docker.internal\",\n \"host.containers.internal\",\n]);\n\nexport const parseReplaySidecarUrl = (\n raw: string | null | undefined,\n): string | undefined => {\n if (typeof raw !== \"string\" || raw.length === 0) {\n return undefined;\n }\n if (raw.length > MAX_URL_LENGTH) {\n warnOnce(\n \"replay-sidecar-url\",\n \"Ignoring an implausibly long Meticulous replay sidecar URL.\",\n );\n return undefined;\n }\n\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n warnOnce(\n \"replay-sidecar-url\",\n \"Ignoring an unparseable Meticulous replay sidecar URL.\",\n );\n return undefined;\n }\n\n const isAcceptable =\n url.protocol === \"http:\" &&\n url.username === \"\" &&\n url.password === \"\" &&\n (url.pathname === \"\" || url.pathname === \"/\") &&\n url.search === \"\" &&\n url.hash === \"\" &&\n isLocalHostname(url.hostname);\n\n if (!isAcceptable) {\n warnOnce(\n \"replay-sidecar-url\",\n `Ignoring the Meticulous replay sidecar URL \"${raw}\" — only a plain http:// origin on a loopback, docker-gateway or private-network host is honoured.`,\n );\n return undefined;\n }\n\n // Return the parsed origin rather than the raw string: it is what `fetch` would resolve\n // the value to anyway, so the host that was validated is exactly the host that gets\n // called (alternative IPv4 spellings like \"0177.0.0.1\" normalize here, rather than being\n // checked in one form and dialled in another). Carries no trailing slash, matching\n // resolveSidecarUrl's normalization so the self-capture prefix check lines up.\n return url.origin;\n};\n\nconst isLocalHostname = (hostname: string): boolean => {\n const lower = hostname.toLowerCase();\n if (ALLOWED_HOSTNAMES.has(lower)) {\n return true;\n }\n // URL keeps IPv6 literals bracketed.\n const unbracketed =\n lower.startsWith(\"[\") && lower.endsWith(\"]\")\n ? lower.slice(1, -1)\n : undefined;\n if (unbracketed !== undefined) {\n return unbracketed === \"::1\";\n }\n return isPrivateIpv4(lower);\n};\n\nconst isPrivateIpv4 = (hostname: string): boolean => {\n const parts = hostname.split(\".\");\n if (parts.length !== 4) {\n return false;\n }\n const octets = parts.map((part) =>\n // Reject anything that is not a plain decimal octet: \"010\" and \"0x7f\" are parsed\n // inconsistently across resolvers, and \"1e2\" would pass a bare Number() check.\n /^\\d{1,3}$/.test(part) ? Number(part) : Number.NaN,\n );\n if (octets.some((octet) => Number.isNaN(octet) || octet > 255)) {\n return false;\n }\n const [first, second] = octets;\n // Deliberately excludes link-local (169.254.0.0/16): no sidecar is ever reachable there,\n // but the cloud metadata endpoints are (169.254.169.254), so allowing it would only widen\n // the SSRF surface. The rest of the codebase blocks that range for the same reason — see\n // `staging-backend-url.ts` and `assets-app-server.ts`.\n return (\n first === 127 || // loopback\n first === 10 || // RFC1918\n (first === 172 && second >= 16 && second <= 31) || // RFC1918\n (first === 192 && second === 168) // RFC1918\n );\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;AAgBA,MAAM,iBAAiB;AAEvB,MAAM,oBAAoB,IAAI,IAAI;CAChC;CAEA;CACA;CACD,CAAC;AAEF,MAAa,yBACX,QACuB;AACvB,KAAI,OAAO,QAAQ,YAAY,IAAI,WAAW,EAC5C;AAEF,KAAI,IAAI,SAAS,gBAAgB;AAC/B,WACE,sBACA,8DACD;AACD;;CAGF,IAAI;AACJ,KAAI;AACF,QAAM,IAAI,IAAI,IAAI;SACZ;AACN,WACE,sBACA,yDACD;AACD;;AAYF,KAAI,EARF,IAAI,aAAa,WACjB,IAAI,aAAa,MACjB,IAAI,aAAa,OAChB,IAAI,aAAa,MAAM,IAAI,aAAa,QACzC,IAAI,WAAW,MACf,IAAI,SAAS,MACb,gBAAgB,IAAI,SAAS,GAEZ;AACjB,WACE,sBACA,+CAA+C,IAAI,oGACpD;AACD;;AAQF,QAAO,IAAI;;AAGb,MAAM,mBAAmB,aAA8B;CACrD,MAAM,QAAQ,SAAS,aAAa;AACpC,KAAI,kBAAkB,IAAI,MAAM,CAC9B,QAAO;CAGT,MAAM,cACJ,MAAM,WAAW,IAAI,IAAI,MAAM,SAAS,IAAI,GACxC,MAAM,MAAM,GAAG,GAAG,GAClB,KAAA;AACN,KAAI,gBAAgB,KAAA,EAClB,QAAO,gBAAgB;AAEzB,QAAO,cAAc,MAAM;;AAG7B,MAAM,iBAAiB,aAA8B;CACnD,MAAM,QAAQ,SAAS,MAAM,IAAI;AACjC,KAAI,MAAM,WAAW,EACnB,QAAO;CAET,MAAM,SAAS,MAAM,KAAK,SAGxB,YAAY,KAAK,KAAK,GAAG,OAAO,KAAK,GAAG,IACzC;AACD,KAAI,OAAO,MAAM,UAAU,OAAO,MAAM,MAAM,IAAI,QAAQ,IAAI,CAC5D,QAAO;CAET,MAAM,CAAC,OAAO,UAAU;AAKxB,QACE,UAAU,OACV,UAAU,MACT,UAAU,OAAO,UAAU,MAAM,UAAU,MAC3C,UAAU,OAAO,WAAW","debugId":"94547b8a-bd87-50d7-ac87-204a0a65c1b7"}