@alwaysmeticulous/backend-recorder-workerd 2.321.0 → 2.323.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 +14 -0
- package/dist/fetch-patch.js +4 -3
- package/dist/fetch-patch.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +0 -0
- package/dist/index.js.map +1 -1
- package/dist/log.js +11 -3
- package/dist/log.js.map +1 -1
- package/dist/protocol.d.ts +29 -5
- package/dist/protocol.js +13 -3
- package/dist/protocol.js.map +1 -1
- package/dist/replay-fetch.js +63 -15
- package/dist/replay-fetch.js.map +1 -1
- package/dist/sidecar-client.js +86 -54
- package/dist/sidecar-client.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,6 +36,8 @@ The shim is passive: outgoing requests go straight to their real destination; th
|
|
|
36
36
|
npx @alwaysmeticulous/cli record backend -- npx wrangler dev
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
If the recorder cannot start — no credentials, no network to fetch the sidecar bundle, an unusable port — the CLI warns and runs your dev command unrecorded rather than failing, so this is safe to leave in a `dev` script.
|
|
40
|
+
|
|
39
41
|
Or run the sidecar standalone with `npx @alwaysmeticulous/cli record backend` and point the shim at it yourself — the value must be a worker var/binding (host environment variables are not visible inside workerd), e.g. in `.dev.vars`:
|
|
40
42
|
|
|
41
43
|
```
|
|
@@ -55,6 +57,18 @@ Without `METICULOUS_SIDECAR_URL` (or `options.sidecarUrl`) the wrapper is a comp
|
|
|
55
57
|
|
|
56
58
|
Request bodies have secret-looking JSON fields (`clientSecret`, `apiKey`, `token`, `password`, …) replaced with `REDACTED` before they leave the worker, since plenty of APIs carry a credential in the body rather than a header. Response bodies are stored verbatim. A KV `put` value is redacted the same way, for the same reason; a value read back from KV is stored verbatim, like a response body.
|
|
57
59
|
|
|
60
|
+
## Replay
|
|
61
|
+
|
|
62
|
+
The same wrapper serves the recording back when Meticulous replays a session against your app: outgoing `fetch` calls are answered from the recording instead of reaching the real service, the clock is frozen at the recorded session's end, and `Math.random` / `crypto.randomUUID` / `crypto.getRandomValues` are seeded so ids the app mints are identical in every replay. Replay is activated by a header the Meticulous replay runner injects, so nothing extra is needed to enable it.
|
|
63
|
+
|
|
64
|
+
Replay is **hermetic**: a call the recording does not cover fails with a `[backend-recorder] workerd replay: …` error rather than quietly reaching the real service, so a gap in the recording surfaces instead of turning a replay into live traffic. For a call that must stay live during a replay, set the `meticulous-passthrough` header to `true` on the request:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
await fetch(url, { headers: { "meticulous-passthrough": "true" } });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Calls through bindings and KV namespaces are not served from the recording (see Limitations) and are never failed — they always reach the real binding.
|
|
71
|
+
|
|
58
72
|
## Limitations
|
|
59
73
|
|
|
60
74
|
- Bindings other than `fetch`-shaped ones and KV namespaces are not captured: D1, R2, Queues, and RPC method calls on a named entrypoint (`env.SVC.someMethod()`) — an RPC method is not a patchable property, so it cannot be intercepted this way.
|
package/dist/fetch-patch.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
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]="e73e6f17-4a76-5ec4-a107-3aa991c55b22")}catch(e){}}();
|
|
3
3
|
import { requestCaptureContext } from "./context.js";
|
|
4
4
|
import { getOriginalFetch, setOriginalFetch } from "./original-fetch.js";
|
|
5
5
|
import { captureOutboundCall } from "./outbound-capture.js";
|
|
@@ -13,7 +13,8 @@ const FETCH_PATCHED = Symbol.for("meticulous.workerd.fetchPatched");
|
|
|
13
13
|
* never in the request path); request and response are cloned and reported in the
|
|
14
14
|
* background.
|
|
15
15
|
* - replaying: decide-then-serve. The call is looked up against the sidecar's mock store
|
|
16
|
-
* and a recorded response is synthesised, or the
|
|
16
|
+
* and a recorded response is synthesised, or the call fails — replay is hermetic unless
|
|
17
|
+
* the request opts out with the `meticulous-passthrough` header.
|
|
17
18
|
*
|
|
18
19
|
* Outside a withMeticulous request context the patch is a pure pass-through. Idempotent.
|
|
19
20
|
*/
|
|
@@ -41,4 +42,4 @@ const patchedFetch = async (input, init) => {
|
|
|
41
42
|
export { installFetchPatch };
|
|
42
43
|
|
|
43
44
|
//# sourceMappingURL=fetch-patch.js.map
|
|
44
|
-
//# debugId=
|
|
45
|
+
//# debugId=e73e6f17-4a76-5ec4-a107-3aa991c55b22
|
package/dist/fetch-patch.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fetch-patch.js","sources":["../src/fetch-patch.ts"],"sourcesContent":["import { requestCaptureContext } from \"./context\";\nimport { getOriginalFetch, setOriginalFetch } from \"./original-fetch\";\nimport { captureOutboundCall } from \"./outbound-capture\";\nimport { replayOutboundCall } from \"./replay-fetch\";\n\ntype FetchFn = typeof globalThis.fetch;\n\n// Symbol.for so a second copy of the shim (e.g. bundled twice) still detects the patch.\nconst FETCH_PATCHED = Symbol.for(\"meticulous.workerd.fetchPatched\");\n\nexport { getOriginalFetch } from \"./original-fetch\";\nexport { headersToRecord } from \"./outbound-capture\";\n\n/**\n * Replaces globalThis.fetch with the mode-appropriate interception:\n *\n * - recording: a passive capture tee. The real fetch is called directly (the sidecar is\n * never in the request path); request and response are cloned and reported in the\n * background.\n * - replaying: decide-then-serve. The call is looked up against the sidecar's mock store\n * and a recorded response is synthesised, or the
|
|
1
|
+
{"version":3,"file":"fetch-patch.js","sources":["../src/fetch-patch.ts"],"sourcesContent":["import { requestCaptureContext } from \"./context\";\nimport { getOriginalFetch, setOriginalFetch } from \"./original-fetch\";\nimport { captureOutboundCall } from \"./outbound-capture\";\nimport { replayOutboundCall } from \"./replay-fetch\";\n\ntype FetchFn = typeof globalThis.fetch;\n\n// Symbol.for so a second copy of the shim (e.g. bundled twice) still detects the patch.\nconst FETCH_PATCHED = Symbol.for(\"meticulous.workerd.fetchPatched\");\n\nexport { getOriginalFetch } from \"./original-fetch\";\nexport { headersToRecord } from \"./outbound-capture\";\n\n/**\n * Replaces globalThis.fetch with the mode-appropriate interception:\n *\n * - recording: a passive capture tee. The real fetch is called directly (the sidecar is\n * never in the request path); request and response are cloned and reported in the\n * background.\n * - replaying: decide-then-serve. The call is looked up against the sidecar's mock store\n * and a recorded response is synthesised, or the call fails — replay is hermetic unless\n * the request opts out with the `meticulous-passthrough` header.\n *\n * Outside a withMeticulous request context the patch is a pure pass-through. Idempotent.\n */\nexport const installFetchPatch = (): void => {\n const holder = globalThis as { [FETCH_PATCHED]?: boolean };\n if (holder[FETCH_PATCHED]) {\n return;\n }\n holder[FETCH_PATCHED] = true;\n setOriginalFetch(globalThis.fetch.bind(globalThis));\n globalThis.fetch = patchedFetch;\n};\n\nconst patchedFetch: FetchFn = async (input, init) => {\n const original = getOriginalFetch();\n const ctx = requestCaptureContext.getStore();\n if (!ctx) {\n return original(input, init);\n }\n\n // Normalize to a single Request. This may consume `input`'s body (when\n // `input` is a Request), so from here on the real fetch must be given\n // `request`, never the original arguments.\n let request: Request;\n try {\n request = new Request(input, init);\n } catch {\n // Unconstructable arguments — let the real fetch produce the error.\n return original(input, init);\n }\n\n // Never intercept the shim's own traffic to the sidecar. Defence in depth: every\n // shim → sidecar call already goes through the unpatched fetch.\n if (request.url.startsWith(`${ctx.sidecarUrl}/`)) {\n return original(request);\n }\n\n return ctx.mode === \"replay\"\n ? replayOutboundCall(ctx, request, (req) => original(req))\n : captureOutboundCall(ctx, { kind: \"outbound\" }, request, (req) =>\n original(req),\n );\n};\n"],"names":[],"mappings":";;;;;;;AAQA,MAAM,gBAAgB,OAAO,IAAI,kCAAkC;;;;;;;;;;;;;AAiBnE,MAAa,0BAAgC;CAC3C,MAAM,SAAS;AACf,KAAI,OAAO,eACT;AAEF,QAAO,iBAAiB;AACxB,kBAAiB,WAAW,MAAM,KAAK,WAAW,CAAC;AACnD,YAAW,QAAQ;;AAGrB,MAAM,eAAwB,OAAO,OAAO,SAAS;CACnD,MAAM,WAAW,kBAAkB;CACnC,MAAM,MAAM,sBAAsB,UAAU;AAC5C,KAAI,CAAC,IACH,QAAO,SAAS,OAAO,KAAK;CAM9B,IAAI;AACJ,KAAI;AACF,YAAU,IAAI,QAAQ,OAAO,KAAK;SAC5B;AAEN,SAAO,SAAS,OAAO,KAAK;;AAK9B,KAAI,QAAQ,IAAI,WAAW,GAAG,IAAI,WAAW,GAAG,CAC9C,QAAO,SAAS,QAAQ;AAG1B,QAAO,IAAI,SAAS,WAChB,mBAAmB,KAAK,UAAU,QAAQ,SAAS,IAAI,CAAC,GACxD,oBAAoB,KAAK,EAAE,MAAM,YAAY,EAAE,UAAU,QACvD,SAAS,IAAI,CACd","debugId":"e73e6f17-4a76-5ec4-a107-3aa991c55b22"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BindingRequestEvent, CAPTURED_HEADERS, CaptureEvent, CaptureEventsPayload, CapturedBody, FRONTEND_SESSION_ID_HEADER, InboundRequestEvent, KvOmittedReason, KvOperation, KvOperationEvent, 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 } from "./protocol.js";
|
|
1
|
+
import { BindingRequestEvent, CAPTURED_HEADERS, CaptureEvent, CaptureEventsPayload, CapturedBody, FRONTEND_SESSION_ID_HEADER, InboundRequestEvent, KvOmittedReason, KvOperation, KvOperationEvent, METICULOUS_PASSTHROUGH_HEADER, 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 } from "./protocol.js";
|
|
2
2
|
import { MAX_BODY_CAPTURE_SIZE, readBodyWithCap, readRequestBodyWithCap } from "./body-capture.js";
|
|
3
3
|
import { headersToRecord } from "./outbound-capture.js";
|
|
4
4
|
import { STR_REDACTED, redactRequestBody } from "./redact-body.js";
|
|
@@ -37,8 +37,10 @@ interface WithMeticulousOptions {
|
|
|
37
37
|
*
|
|
38
38
|
* **Replay** activates on the `x-meticulous-backend-replay-sidecar-url` header, injected by
|
|
39
39
|
* the Meticulous replay runner on requests to the app under test. Outgoing `fetch` calls are
|
|
40
|
-
* then served from the recording instead of reaching the real service
|
|
41
|
-
*
|
|
40
|
+
* then served from the recording instead of reaching the real service — and a call the
|
|
41
|
+
* recording does not cover fails, rather than quietly becoming live traffic; put a
|
|
42
|
+
* `meticulous-passthrough: true` header on a request that must stay real. The clock is frozen
|
|
43
|
+
* at the recorded session's end so recorded credentials are still valid, and `Math.random` /
|
|
42
44
|
* `crypto.randomUUID` / `crypto.getRandomValues` are seeded so ids the app mints are the same
|
|
43
45
|
* in every replay of that session. Workerd
|
|
44
46
|
* cannot read container environment variables, which is why per-replay config arrives as a
|
|
@@ -56,5 +58,5 @@ interface WithMeticulousOptions {
|
|
|
56
58
|
*/
|
|
57
59
|
declare const withMeticulous: <H extends MeticulousWorkerHandler<never>>(handler: H, options?: WithMeticulousOptions) => H;
|
|
58
60
|
//#endregion
|
|
59
|
-
export { type BindingRequestEvent, CAPTURED_HEADERS, type CaptureEvent, type CaptureEventsPayload, type CapturedBody, FRONTEND_SESSION_ID_HEADER, type InboundRequestEvent, type KvOmittedReason, type KvOperation, type KvOperationEvent, MAX_BODY_CAPTURE_SIZE, MeticulousExecutionContext, MeticulousWorkerHandler, type OutboundFetchLookupRequest, type OutboundFetchLookupResponse, type OutboundRequestEvent, REPLAY_SIDECAR_URL_HEADER, type ReplaySessionInfoResponse, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH, STR_REDACTED, WithMeticulousOptions, headersToRecord, readBodyWithCap, readRequestBodyWithCap, redactRequestBody, withMeticulous };
|
|
61
|
+
export { type BindingRequestEvent, CAPTURED_HEADERS, type CaptureEvent, type CaptureEventsPayload, type CapturedBody, FRONTEND_SESSION_ID_HEADER, type InboundRequestEvent, type KvOmittedReason, type KvOperation, type KvOperationEvent, MAX_BODY_CAPTURE_SIZE, METICULOUS_PASSTHROUGH_HEADER, MeticulousExecutionContext, MeticulousWorkerHandler, type OutboundFetchLookupRequest, type OutboundFetchLookupResponse, type OutboundRequestEvent, REPLAY_SIDECAR_URL_HEADER, type ReplaySessionInfoResponse, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH, STR_REDACTED, WithMeticulousOptions, headersToRecord, readBodyWithCap, readRequestBodyWithCap, redactRequestBody, withMeticulous };
|
|
60
62
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
Binary file
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":["../src/index.ts"],"sourcesContent":["import { installBindingPatch } from \"./binding-patch\";\nimport {\n type RequestCaptureContext,\n type RequestReplayContext,\n requestCaptureContext,\n} from \"./context\";\nimport { installFetchPatch } from \"./fetch-patch\";\nimport { installKvPatch } from \"./kv-patch\";\nimport { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport { headersToRecord } from \"./outbound-capture\";\nimport {\n FRONTEND_SESSION_ID_HEADER,\n type InboundRequestEvent,\n REPLAY_SIDECAR_URL_HEADER,\n} from \"./protocol\";\nimport { parseReplaySidecarUrl } from \"./replay-sidecar-url\";\nimport { getReplaySessionInfo, postCaptureEvents } from \"./sidecar-client\";\nimport { installVirtualClock } from \"./virtual-clock\";\nimport { installVirtualRandom } from \"./virtual-random\";\n\nexport type {\n BindingRequestEvent,\n CaptureEvent,\n CapturedBody,\n CaptureEventsPayload,\n InboundRequestEvent,\n KvOmittedReason,\n KvOperation,\n KvOperationEvent,\n OutboundFetchLookupRequest,\n OutboundFetchLookupResponse,\n OutboundRequestEvent,\n ReplaySessionInfoResponse,\n} from \"./protocol\";\nexport {\n CAPTURED_HEADERS,\n FRONTEND_SESSION_ID_HEADER,\n REPLAY_SIDECAR_URL_HEADER,\n SIDECAR_EVENTS_PATH,\n SIDECAR_PROTOCOL_VERSION,\n SIDECAR_PROTOCOL_VERSION_HEADER,\n SIDECAR_REPLAY_OUTBOUND_FETCH_PATH,\n SIDECAR_REPLAY_SESSION_PATH,\n} from \"./protocol\";\nexport {\n MAX_BODY_CAPTURE_SIZE,\n readBodyWithCap,\n readRequestBodyWithCap,\n} from \"./body-capture\";\nexport { headersToRecord } from \"./outbound-capture\";\nexport { redactRequestBody, STR_REDACTED } from \"./redact-body\";\n\n/** Structural subset of workerd's ExecutionContext — avoids a @cloudflare/workers-types dependency. */\nexport interface MeticulousExecutionContext {\n waitUntil(promise: Promise<unknown>): void;\n}\n\n/** Structural subset of an ES-module Worker handler (`export default {...}`). */\nexport interface MeticulousWorkerHandler<Env = never> {\n fetch(\n request: Request,\n env: Env,\n ctx: MeticulousExecutionContext,\n ): Response | Promise<Response>;\n}\n\nexport interface WithMeticulousOptions {\n /** Overrides the sidecar origin; defaults to the METICULOUS_SIDECAR_URL var/binding. */\n sidecarUrl?: string;\n /**\n * Binding names to leave unrecorded, on top of the defaults (`ASSETS`,\n * `__STATIC_CONTENT`). Use this for a binding whose traffic is high-volume or binary.\n * Applies to KV namespaces as well as `fetch`-shaped bindings.\n */\n skipBindings?: readonly string[];\n}\n\n/**\n * Wraps an ES-module Worker handler so Meticulous can record, or replay, the app's HTTP\n * behaviour:\n *\n * export default withMeticulous({\n * async fetch(request, env, ctx) { ... },\n * });\n *\n * **Recording** activates on a `METICULOUS_SIDECAR_URL` var/binding (e.g. via `.dev.vars`\n * or `wrangler dev --var`) or `options.sidecarUrl`. Inbound requests, outgoing `fetch`\n * calls, calls through `fetch`-shaped bindings and KV namespace operations are reported to\n * the sidecar as capture events, without affecting the app.\n *\n * **Replay** activates on the `x-meticulous-backend-replay-sidecar-url` header, injected by\n * the Meticulous replay runner on requests to the app under test. Outgoing `fetch` calls are\n * then served from the recording instead of reaching the real service, the clock is frozen at\n * the recorded session's end so recorded credentials are still valid, and `Math.random` /\n * `crypto.randomUUID` / `crypto.getRandomValues` are seeded so ids the app mints are the same\n * in every replay of that session. Workerd\n * cannot read container environment variables, which is why per-replay config arrives as a\n * request header; the shim validates it and only honours a loopback / docker-gateway /\n * private-network `http:` origin.\n *\n * Replay takes precedence over recording when both are configured. A stale\n * `METICULOUS_SIDECAR_URL` baked into an image's `.dev.vars` is far more likely than a\n * spurious replay header (nothing but the replay runner emits it), and letting the env win\n * would silently record a replay instead of mocking it.\n *\n * With neither configured the wrapper is a complete pass-through, so it is safe to keep in\n * deployed code. Requires the `nodejs_als` (or `nodejs_compat`) compatibility flag. A\n * capture failure or unreachable sidecar never affects the app.\n */\nexport const withMeticulous = <H extends MeticulousWorkerHandler<never>>(\n handler: H,\n options?: WithMeticulousOptions,\n): H => {\n const wrappedFetch = async (\n request: Request,\n env: unknown,\n ctx: MeticulousExecutionContext,\n ): Promise<Response> => {\n const invokeHandler = () => handler.fetch(request, env as never, ctx);\n\n let sidecarUrl: string | undefined;\n let replayContext: RequestReplayContext | undefined;\n try {\n replayContext = await resolveReplayContext(request, ctx);\n sidecarUrl =\n replayContext === undefined\n ? resolveSidecarUrl(options, env)\n : undefined;\n if (replayContext !== undefined || sidecarUrl !== undefined) {\n installFetchPatch();\n }\n if (sidecarUrl !== undefined) {\n // Lazily, inside the request path, and never at module scope: the package declares\n // no `sideEffects` and is built with `platform: \"neutral\"`, so a module-scope side\n // effect can legitimately be tree-shaken by a customer's bundler. Re-run per\n // request because `env` is the only handle on the binding instances.\n //\n // Record mode only. Binding and KV calls are recorded as their own technologies\n // (`workerd-binding`, `workerd-kv`) and the replay sidecar's store holds only\n // `workerd-fetch` spans, so there is nothing to serve them from — and patching would\n // only route them into the capture tee, which POSTs to an events route a replay\n // sidecar 404s.\n installBindingPatch(env, { skipBindings: options?.skipBindings });\n installKvPatch(env, { skipBindings: options?.skipBindings });\n }\n } catch (error) {\n warnOnce(\n \"shim-init\",\n \"Failed to initialize the Meticulous backend recorder shim — recording disabled.\",\n error,\n );\n replayContext = undefined;\n sidecarUrl = undefined;\n }\n\n if (replayContext !== undefined) {\n // Install the clock before any app code runs, so a module that reads Date.now() at\n // call time (the firebase auth libraries do) sees the session's frozen instant.\n installVirtualClock();\n // Likewise the random generators: an id the app mints during SSR (a guest id, a\n // request id) otherwise differs between a base and a head replay, and every rendered\n // page carrying it diffs forever.\n installVirtualRandom();\n // No inbound reporting in replay mode: there is no exporter behind the replay\n // sidecar, and the events route 404s there.\n return requestCaptureContext.run(replayContext, invokeHandler);\n }\n\n if (sidecarUrl === undefined) {\n return invokeHandler();\n }\n\n const captureCtx = buildCaptureContext(request, sidecarUrl, ctx);\n const startTimeMs = Date.now();\n let requestHeaders: Record<string, string[]> = {};\n let method = \"GET\";\n let url = \"\";\n try {\n requestHeaders = headersToRecord(request.headers);\n method = request.method;\n url = request.url;\n } catch (error) {\n warnOnce(\n \"inbound-capture\",\n \"Failed to capture the inbound request.\",\n error,\n );\n }\n\n const reportInbound = (\n outcome:\n | { statusCode: number; responseHeaders: Record<string, string[]> }\n | { error: string },\n ): void => {\n try {\n const event: InboundRequestEvent = {\n kind: \"inbound\",\n requestId: captureCtx.requestId,\n ...(captureCtx.frontendSessionId !== undefined\n ? { frontendSessionId: captureCtx.frontendSessionId }\n : {}),\n method,\n url,\n requestHeaders,\n startTimeMs,\n endTimeMs: Date.now(),\n ...outcome,\n };\n captureCtx.waitUntil(\n postCaptureEvents(getOriginalFetch(), sidecarUrl, [event]),\n );\n } catch (error) {\n warnOnce(\n \"inbound-report\",\n \"Failed to report the inbound request.\",\n error,\n );\n }\n };\n\n let response: Response;\n try {\n response = await requestCaptureContext.run(captureCtx, invokeHandler);\n } catch (error) {\n reportInbound({ error: String(error) });\n throw error;\n }\n\n try {\n reportInbound({\n statusCode: response.status,\n responseHeaders: headersToRecord(response.headers),\n });\n } catch (error) {\n warnOnce(\n \"inbound-report\",\n \"Failed to report the inbound request.\",\n error,\n );\n }\n return response;\n };\n\n // The wrapper only replaces `fetch` (same call signature), but TypeScript\n // cannot verify a spread against the bare generic H.\n return { ...handler, fetch: wrappedFetch } as H;\n};\n\n/**\n * Whether this request should be replayed, and the context to serve it under.\n *\n * Both the session id and a usable sidecar URL must be present: mocks are indexed per\n * session, so replay without a session id could only miss. The sidecar is consulted once\n * per (sidecar, session) per isolate to confirm it holds mocks and to learn the clock\n * anchor — a sidecar that does not recognise the session, or the route, means replay is\n * unavailable and the request runs normally.\n */\nconst resolveReplayContext = async (\n request: Request,\n ctx: MeticulousExecutionContext,\n): Promise<RequestReplayContext | undefined> => {\n let sidecarUrl: string | undefined;\n let frontendSessionId: string | undefined;\n try {\n sidecarUrl = parseReplaySidecarUrl(\n request.headers.get(REPLAY_SIDECAR_URL_HEADER),\n );\n frontendSessionId =\n request.headers.get(FRONTEND_SESSION_ID_HEADER) ?? undefined;\n } catch {\n return undefined;\n }\n if (sidecarUrl === undefined || frontendSessionId === undefined) {\n return undefined;\n }\n\n const info = await getCachedReplaySessionInfo(sidecarUrl, frontendSessionId);\n if (info === null) {\n return undefined;\n }\n\n return {\n mode: \"replay\",\n requestId: crypto.randomUUID(),\n frontendSessionId,\n sidecarUrl,\n clockAnchorMs: info.clockAnchorMs,\n waitUntil: buildWaitUntil(ctx),\n };\n};\n\n// Caches the promise, not the result, so concurrent first requests share one round trip.\n// Bounded so a stream of unrecognised session ids cannot grow it without limit.\nconst MAX_CACHED_SESSIONS = 50;\nconst replaySessionInfoCache = new Map<\n string,\n Promise<{ clockAnchorMs: number | undefined } | null>\n>();\n\nconst getCachedReplaySessionInfo = (\n sidecarUrl: string,\n frontendSessionId: string,\n): Promise<{ clockAnchorMs: number | undefined } | null> => {\n const key = `${sidecarUrl}\u0000${frontendSessionId}`;\n const cached = replaySessionInfoCache.get(key);\n if (cached !== undefined) {\n return cached;\n }\n const pending = getReplaySessionInfo(\n getOriginalFetch(),\n sidecarUrl,\n frontendSessionId,\n ).then((result) => {\n if (result.outcome === \"unreachable\") {\n // Only settled answers are worth keeping. Caching a timeout or transport blip would\n // disable mocking for this session for the life of the isolate, quietly sending every\n // later outbound call to the real service — the failure this path exists to prevent.\n // Dropping the entry costs one extra handshake and lets the next request recover.\n replaySessionInfoCache.delete(key);\n return null;\n }\n return result.outcome === \"found\"\n ? { clockAnchorMs: result.clockAnchorMs }\n : null;\n });\n if (replaySessionInfoCache.size >= MAX_CACHED_SESSIONS) {\n const oldest = replaySessionInfoCache.keys().next();\n if (!oldest.done) {\n replaySessionInfoCache.delete(oldest.value);\n }\n }\n replaySessionInfoCache.set(key, pending);\n return pending;\n};\n\nconst resolveSidecarUrl = (\n options: WithMeticulousOptions | undefined,\n env: unknown,\n): string | undefined => {\n const fromEnv =\n env !== null && typeof env === \"object\"\n ? (env as Record<string, unknown>)[\"METICULOUS_SIDECAR_URL\"]\n : undefined;\n const raw = options?.sidecarUrl ?? fromEnv;\n if (typeof raw !== \"string\" || raw.length === 0) {\n return undefined;\n }\n return raw.replace(/\\/+$/, \"\");\n};\n\nconst buildCaptureContext = (\n request: Request,\n sidecarUrl: string,\n ctx: MeticulousExecutionContext,\n): RequestCaptureContext => {\n let frontendSessionId: string | undefined;\n try {\n frontendSessionId =\n request.headers.get(FRONTEND_SESSION_ID_HEADER) ?? undefined;\n } catch {\n frontendSessionId = undefined;\n }\n return {\n mode: \"record\",\n requestId: crypto.randomUUID(),\n frontendSessionId,\n sidecarUrl,\n waitUntil: buildWaitUntil(ctx),\n };\n};\n\nconst buildWaitUntil =\n (ctx: MeticulousExecutionContext) =>\n (promise: Promise<unknown>): void => {\n try {\n ctx.waitUntil(promise);\n } catch (error) {\n warnOnce(\n \"wait-until\",\n \"ctx.waitUntil failed — capture events may be lost.\",\n error,\n );\n }\n };\n"],"names":[],"mappings":"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","debugId":"79e05547-78af-52ae-aac6-9c8d71abbba6"}
|
|
1
|
+
{"version":3,"file":"index.js","sources":["../src/index.ts"],"sourcesContent":["import { installBindingPatch } from \"./binding-patch\";\nimport {\n type RequestCaptureContext,\n type RequestReplayContext,\n requestCaptureContext,\n} from \"./context\";\nimport { installFetchPatch } from \"./fetch-patch\";\nimport { installKvPatch } from \"./kv-patch\";\nimport { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport { headersToRecord } from \"./outbound-capture\";\nimport {\n FRONTEND_SESSION_ID_HEADER,\n type InboundRequestEvent,\n REPLAY_SIDECAR_URL_HEADER,\n} from \"./protocol\";\nimport { parseReplaySidecarUrl } from \"./replay-sidecar-url\";\nimport { getReplaySessionInfo, postCaptureEvents } from \"./sidecar-client\";\nimport { installVirtualClock } from \"./virtual-clock\";\nimport { installVirtualRandom } from \"./virtual-random\";\n\nexport type {\n BindingRequestEvent,\n CaptureEvent,\n CapturedBody,\n CaptureEventsPayload,\n InboundRequestEvent,\n KvOmittedReason,\n KvOperation,\n KvOperationEvent,\n OutboundFetchLookupRequest,\n OutboundFetchLookupResponse,\n OutboundRequestEvent,\n ReplaySessionInfoResponse,\n} from \"./protocol\";\nexport {\n CAPTURED_HEADERS,\n FRONTEND_SESSION_ID_HEADER,\n METICULOUS_PASSTHROUGH_HEADER,\n REPLAY_SIDECAR_URL_HEADER,\n SIDECAR_EVENTS_PATH,\n SIDECAR_PROTOCOL_VERSION,\n SIDECAR_PROTOCOL_VERSION_HEADER,\n SIDECAR_REPLAY_OUTBOUND_FETCH_PATH,\n SIDECAR_REPLAY_SESSION_PATH,\n} from \"./protocol\";\nexport {\n MAX_BODY_CAPTURE_SIZE,\n readBodyWithCap,\n readRequestBodyWithCap,\n} from \"./body-capture\";\nexport { headersToRecord } from \"./outbound-capture\";\nexport { redactRequestBody, STR_REDACTED } from \"./redact-body\";\n\n/** Structural subset of workerd's ExecutionContext — avoids a @cloudflare/workers-types dependency. */\nexport interface MeticulousExecutionContext {\n waitUntil(promise: Promise<unknown>): void;\n}\n\n/** Structural subset of an ES-module Worker handler (`export default {...}`). */\nexport interface MeticulousWorkerHandler<Env = never> {\n fetch(\n request: Request,\n env: Env,\n ctx: MeticulousExecutionContext,\n ): Response | Promise<Response>;\n}\n\nexport interface WithMeticulousOptions {\n /** Overrides the sidecar origin; defaults to the METICULOUS_SIDECAR_URL var/binding. */\n sidecarUrl?: string;\n /**\n * Binding names to leave unrecorded, on top of the defaults (`ASSETS`,\n * `__STATIC_CONTENT`). Use this for a binding whose traffic is high-volume or binary.\n * Applies to KV namespaces as well as `fetch`-shaped bindings.\n */\n skipBindings?: readonly string[];\n}\n\n/**\n * Wraps an ES-module Worker handler so Meticulous can record, or replay, the app's HTTP\n * behaviour:\n *\n * export default withMeticulous({\n * async fetch(request, env, ctx) { ... },\n * });\n *\n * **Recording** activates on a `METICULOUS_SIDECAR_URL` var/binding (e.g. via `.dev.vars`\n * or `wrangler dev --var`) or `options.sidecarUrl`. Inbound requests, outgoing `fetch`\n * calls, calls through `fetch`-shaped bindings and KV namespace operations are reported to\n * the sidecar as capture events, without affecting the app.\n *\n * **Replay** activates on the `x-meticulous-backend-replay-sidecar-url` header, injected by\n * the Meticulous replay runner on requests to the app under test. Outgoing `fetch` calls are\n * then served from the recording instead of reaching the real service — and a call the\n * recording does not cover fails, rather than quietly becoming live traffic; put a\n * `meticulous-passthrough: true` header on a request that must stay real. The clock is frozen\n * at the recorded session's end so recorded credentials are still valid, and `Math.random` /\n * `crypto.randomUUID` / `crypto.getRandomValues` are seeded so ids the app mints are the same\n * in every replay of that session. Workerd\n * cannot read container environment variables, which is why per-replay config arrives as a\n * request header; the shim validates it and only honours a loopback / docker-gateway /\n * private-network `http:` origin.\n *\n * Replay takes precedence over recording when both are configured. A stale\n * `METICULOUS_SIDECAR_URL` baked into an image's `.dev.vars` is far more likely than a\n * spurious replay header (nothing but the replay runner emits it), and letting the env win\n * would silently record a replay instead of mocking it.\n *\n * With neither configured the wrapper is a complete pass-through, so it is safe to keep in\n * deployed code. Requires the `nodejs_als` (or `nodejs_compat`) compatibility flag. A\n * capture failure or unreachable sidecar never affects the app.\n */\nexport const withMeticulous = <H extends MeticulousWorkerHandler<never>>(\n handler: H,\n options?: WithMeticulousOptions,\n): H => {\n const wrappedFetch = async (\n request: Request,\n env: unknown,\n ctx: MeticulousExecutionContext,\n ): Promise<Response> => {\n const invokeHandler = () => handler.fetch(request, env as never, ctx);\n\n let sidecarUrl: string | undefined;\n let replayContext: RequestReplayContext | undefined;\n try {\n replayContext = await resolveReplayContext(request, ctx);\n sidecarUrl =\n replayContext === undefined\n ? resolveSidecarUrl(options, env)\n : undefined;\n if (replayContext !== undefined || sidecarUrl !== undefined) {\n installFetchPatch();\n }\n if (sidecarUrl !== undefined) {\n // Lazily, inside the request path, and never at module scope: the package declares\n // no `sideEffects` and is built with `platform: \"neutral\"`, so a module-scope side\n // effect can legitimately be tree-shaken by a customer's bundler. Re-run per\n // request because `env` is the only handle on the binding instances.\n //\n // Record mode only. Binding and KV calls are recorded as their own technologies\n // (`workerd-binding`, `workerd-kv`) and the replay sidecar's store holds only\n // `workerd-fetch` spans, so there is nothing to serve them from — and patching would\n // only route them into the capture tee, which POSTs to an events route a replay\n // sidecar 404s.\n installBindingPatch(env, { skipBindings: options?.skipBindings });\n installKvPatch(env, { skipBindings: options?.skipBindings });\n }\n } catch (error) {\n warnOnce(\n \"shim-init\",\n \"Failed to initialize the Meticulous backend recorder shim — recording disabled.\",\n error,\n );\n replayContext = undefined;\n sidecarUrl = undefined;\n }\n\n if (replayContext !== undefined) {\n // Install the clock before any app code runs, so a module that reads Date.now() at\n // call time (the firebase auth libraries do) sees the session's frozen instant.\n installVirtualClock();\n // Likewise the random generators: an id the app mints during SSR (a guest id, a\n // request id) otherwise differs between a base and a head replay, and every rendered\n // page carrying it diffs forever.\n installVirtualRandom();\n // No inbound reporting in replay mode: there is no exporter behind the replay\n // sidecar, and the events route 404s there.\n return requestCaptureContext.run(replayContext, invokeHandler);\n }\n\n if (sidecarUrl === undefined) {\n return invokeHandler();\n }\n\n const captureCtx = buildCaptureContext(request, sidecarUrl, ctx);\n const startTimeMs = Date.now();\n let requestHeaders: Record<string, string[]> = {};\n let method = \"GET\";\n let url = \"\";\n try {\n requestHeaders = headersToRecord(request.headers);\n method = request.method;\n url = request.url;\n } catch (error) {\n warnOnce(\n \"inbound-capture\",\n \"Failed to capture the inbound request.\",\n error,\n );\n }\n\n const reportInbound = (\n outcome:\n | { statusCode: number; responseHeaders: Record<string, string[]> }\n | { error: string },\n ): void => {\n try {\n const event: InboundRequestEvent = {\n kind: \"inbound\",\n requestId: captureCtx.requestId,\n ...(captureCtx.frontendSessionId !== undefined\n ? { frontendSessionId: captureCtx.frontendSessionId }\n : {}),\n method,\n url,\n requestHeaders,\n startTimeMs,\n endTimeMs: Date.now(),\n ...outcome,\n };\n captureCtx.waitUntil(\n postCaptureEvents(getOriginalFetch(), sidecarUrl, [event]),\n );\n } catch (error) {\n warnOnce(\n \"inbound-report\",\n \"Failed to report the inbound request.\",\n error,\n );\n }\n };\n\n let response: Response;\n try {\n response = await requestCaptureContext.run(captureCtx, invokeHandler);\n } catch (error) {\n reportInbound({ error: String(error) });\n throw error;\n }\n\n try {\n reportInbound({\n statusCode: response.status,\n responseHeaders: headersToRecord(response.headers),\n });\n } catch (error) {\n warnOnce(\n \"inbound-report\",\n \"Failed to report the inbound request.\",\n error,\n );\n }\n return response;\n };\n\n // The wrapper only replaces `fetch` (same call signature), but TypeScript\n // cannot verify a spread against the bare generic H.\n return { ...handler, fetch: wrappedFetch } as H;\n};\n\n/**\n * Whether this request should be replayed, and the context to serve it under.\n *\n * Both the session id and a usable sidecar URL must be present: mocks are indexed per\n * session, so replay without a session id could only miss. The sidecar is consulted once\n * per (sidecar, session) per isolate to confirm it holds mocks and to learn the clock\n * anchor — a sidecar that does not recognise the session, or the route, means replay is\n * unavailable and the request runs normally.\n */\nconst resolveReplayContext = async (\n request: Request,\n ctx: MeticulousExecutionContext,\n): Promise<RequestReplayContext | undefined> => {\n let sidecarUrl: string | undefined;\n let frontendSessionId: string | undefined;\n try {\n sidecarUrl = parseReplaySidecarUrl(\n request.headers.get(REPLAY_SIDECAR_URL_HEADER),\n );\n frontendSessionId =\n request.headers.get(FRONTEND_SESSION_ID_HEADER) ?? undefined;\n } catch {\n return undefined;\n }\n if (sidecarUrl === undefined || frontendSessionId === undefined) {\n return undefined;\n }\n\n const info = await getCachedReplaySessionInfo(sidecarUrl, frontendSessionId);\n if (info === null) {\n return undefined;\n }\n\n return {\n mode: \"replay\",\n requestId: crypto.randomUUID(),\n frontendSessionId,\n sidecarUrl,\n clockAnchorMs: info.clockAnchorMs,\n waitUntil: buildWaitUntil(ctx),\n };\n};\n\n// Caches the promise, not the result, so concurrent first requests share one round trip.\n// Bounded so a stream of unrecognised session ids cannot grow it without limit.\nconst MAX_CACHED_SESSIONS = 50;\nconst replaySessionInfoCache = new Map<\n string,\n Promise<{ clockAnchorMs: number | undefined } | null>\n>();\n\nconst getCachedReplaySessionInfo = (\n sidecarUrl: string,\n frontendSessionId: string,\n): Promise<{ clockAnchorMs: number | undefined } | null> => {\n const key = `${sidecarUrl}\u0000${frontendSessionId}`;\n const cached = replaySessionInfoCache.get(key);\n if (cached !== undefined) {\n return cached;\n }\n const pending = getReplaySessionInfo(\n getOriginalFetch(),\n sidecarUrl,\n frontendSessionId,\n ).then((result) => {\n if (result.outcome === \"unreachable\") {\n // Only settled answers are worth keeping. Caching a timeout or transport blip would\n // disable mocking for this session for the life of the isolate, quietly sending every\n // later outbound call to the real service — the failure this path exists to prevent.\n // Dropping the entry costs one extra handshake and lets the next request recover.\n replaySessionInfoCache.delete(key);\n return null;\n }\n return result.outcome === \"found\"\n ? { clockAnchorMs: result.clockAnchorMs }\n : null;\n });\n if (replaySessionInfoCache.size >= MAX_CACHED_SESSIONS) {\n const oldest = replaySessionInfoCache.keys().next();\n if (!oldest.done) {\n replaySessionInfoCache.delete(oldest.value);\n }\n }\n replaySessionInfoCache.set(key, pending);\n return pending;\n};\n\nconst resolveSidecarUrl = (\n options: WithMeticulousOptions | undefined,\n env: unknown,\n): string | undefined => {\n const fromEnv =\n env !== null && typeof env === \"object\"\n ? (env as Record<string, unknown>)[\"METICULOUS_SIDECAR_URL\"]\n : undefined;\n const raw = options?.sidecarUrl ?? fromEnv;\n if (typeof raw !== \"string\" || raw.length === 0) {\n return undefined;\n }\n return raw.replace(/\\/+$/, \"\");\n};\n\nconst buildCaptureContext = (\n request: Request,\n sidecarUrl: string,\n ctx: MeticulousExecutionContext,\n): RequestCaptureContext => {\n let frontendSessionId: string | undefined;\n try {\n frontendSessionId =\n request.headers.get(FRONTEND_SESSION_ID_HEADER) ?? undefined;\n } catch {\n frontendSessionId = undefined;\n }\n return {\n mode: \"record\",\n requestId: crypto.randomUUID(),\n frontendSessionId,\n sidecarUrl,\n waitUntil: buildWaitUntil(ctx),\n };\n};\n\nconst buildWaitUntil =\n (ctx: MeticulousExecutionContext) =>\n (promise: Promise<unknown>): void => {\n try {\n ctx.waitUntil(promise);\n } catch (error) {\n warnOnce(\n \"wait-until\",\n \"ctx.waitUntil failed — capture events may be lost.\",\n error,\n );\n }\n };\n"],"names":[],"mappings":"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","debugId":"00301fec-a17c-5e8e-8e2f-f16608d3b611"}
|
package/dist/log.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
//#region src/log.ts
|
|
2
|
+
/**
|
|
3
|
+
* console.warn every time. Only for something the app is about to see anyway (a replay call
|
|
4
|
+
* that cannot be served, which throws), where knowing _which_ call it was matters more than
|
|
5
|
+
* keeping the log short — a deduped key would hide every miss after the first.
|
|
6
|
+
*/
|
|
2
7
|
|
|
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]="
|
|
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]="af311c85-9c8c-5901-81b9-33d469149fa7")}catch(e){}}();
|
|
9
|
+
const warn = (message) => {
|
|
10
|
+
console.warn(`[meticulous] ${message}`);
|
|
11
|
+
};
|
|
4
12
|
const warned = /* @__PURE__ */ new Set();
|
|
5
13
|
/** console.warn once per unique key — capture problems must never spam or break the app. */
|
|
6
14
|
const warnOnce = (key, message, error) => {
|
|
@@ -10,7 +18,7 @@ const warnOnce = (key, message, error) => {
|
|
|
10
18
|
else console.warn(`[meticulous] ${message}`, error);
|
|
11
19
|
};
|
|
12
20
|
//#endregion
|
|
13
|
-
export { warnOnce };
|
|
21
|
+
export { warn, warnOnce };
|
|
14
22
|
|
|
15
23
|
//# sourceMappingURL=log.js.map
|
|
16
|
-
//# debugId=
|
|
24
|
+
//# debugId=af311c85-9c8c-5901-81b9-33d469149fa7
|
package/dist/log.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"log.js","sources":["../src/log.ts"],"sourcesContent":["const warned = new Set<string>();\n\n/** console.warn once per unique key — capture problems must never spam or break the app. */\nexport const warnOnce = (\n key: string,\n message: string,\n error?: unknown,\n): void => {\n if (warned.has(key)) {\n return;\n }\n warned.add(key);\n if (error === undefined) {\n console.warn(`[meticulous] ${message}`);\n } else {\n console.warn(`[meticulous] ${message}`, error);\n }\n};\n"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"log.js","sources":["../src/log.ts"],"sourcesContent":["/**\n * console.warn every time. Only for something the app is about to see anyway (a replay call\n * that cannot be served, which throws), where knowing _which_ call it was matters more than\n * keeping the log short — a deduped key would hide every miss after the first.\n */\nexport const warn = (message: string): void => {\n console.warn(`[meticulous] ${message}`);\n};\n\nconst warned = new Set<string>();\n\n/** console.warn once per unique key — capture problems must never spam or break the app. */\nexport const warnOnce = (\n key: string,\n message: string,\n error?: unknown,\n): void => {\n if (warned.has(key)) {\n return;\n }\n warned.add(key);\n if (error === undefined) {\n console.warn(`[meticulous] ${message}`);\n } else {\n console.warn(`[meticulous] ${message}`, error);\n }\n};\n"],"names":[],"mappings":";;;;;;;;AAKA,MAAa,QAAQ,YAA0B;AAC7C,SAAQ,KAAK,gBAAgB,UAAU;;AAGzC,MAAM,yBAAS,IAAI,KAAa;;AAGhC,MAAa,YACX,KACA,SACA,UACS;AACT,KAAI,OAAO,IAAI,IAAI,CACjB;AAEF,QAAO,IAAI,IAAI;AACf,KAAI,UAAU,KAAA,EACZ,SAAQ,KAAK,gBAAgB,UAAU;KAEvC,SAAQ,KAAK,gBAAgB,WAAW,MAAM","debugId":"af311c85-9c8c-5901-81b9-33d469149fa7"}
|
package/dist/protocol.d.ts
CHANGED
|
@@ -12,6 +12,16 @@ declare const SIDECAR_PROTOCOL_VERSION_HEADER = "x-meticulous-sidecar-protocol-v
|
|
|
12
12
|
* under recording; its value ties backend spans to the frontend session.
|
|
13
13
|
*/
|
|
14
14
|
declare const FRONTEND_SESSION_ID_HEADER = "x-meticulous-session-id";
|
|
15
|
+
/**
|
|
16
|
+
* A request carrying this header set to `"true"` is never served from the recording and
|
|
17
|
+
* never fails on a miss — it goes to the real network. Use it for a call that must be live
|
|
18
|
+
* during replay (a health probe, telemetry, a resource the recording cannot cover).
|
|
19
|
+
*
|
|
20
|
+
* Same convention and value as the frontend network stubbing and the Node backend recorder
|
|
21
|
+
* (`METICULOUS_PASSTHROUGH_HEADER` in `@alwaysmeticulous/backend-recorder-js`); the value
|
|
22
|
+
* must stay in step with both.
|
|
23
|
+
*/
|
|
24
|
+
declare const METICULOUS_PASSTHROUGH_HEADER = "meticulous-passthrough";
|
|
15
25
|
declare const SIDECAR_EVENTS_PATH = "/v1/events";
|
|
16
26
|
/**
|
|
17
27
|
* Replay routes. A sidecar started in record mode answers 404 on both, which the shim
|
|
@@ -184,18 +194,32 @@ interface OutboundFetchLookupRequest {
|
|
|
184
194
|
requestBody?: CapturedBody;
|
|
185
195
|
}
|
|
186
196
|
/**
|
|
187
|
-
*
|
|
188
|
-
* An unrecognised `outcome` must be treated as `passthrough`, so an older
|
|
189
|
-
* working against a newer sidecar.
|
|
197
|
+
* A recorded response to serve, a miss the caller must fail, or an instruction to let the
|
|
198
|
+
* real call through. An unrecognised `outcome` must be treated as `passthrough`, so an older
|
|
199
|
+
* shim keeps working against a newer sidecar.
|
|
190
200
|
*/
|
|
191
201
|
type OutboundFetchLookupResponse = {
|
|
192
202
|
outcome: "mock";
|
|
193
203
|
statusCode: number;
|
|
194
204
|
body: string;
|
|
195
205
|
headers: Record<string, string>;
|
|
196
|
-
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The store was consulted and holds nothing for this call. Replay is hermetic, so the
|
|
209
|
+
* shim fails the request rather than letting it reach the real service — matching the
|
|
210
|
+
* Node backend recorder's http/undici mocks.
|
|
211
|
+
*/
|
|
212
|
+
| {
|
|
213
|
+
outcome: "no-mock";
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The call cannot be looked up at all (an unparseable URL), so there is no miss to report.
|
|
217
|
+
* Also what an older sidecar answers on a miss, in which case the shim degrades to the
|
|
218
|
+
* pre-`no-mock` fail-open behaviour rather than breaking the app.
|
|
219
|
+
*/
|
|
220
|
+
| {
|
|
197
221
|
outcome: "passthrough";
|
|
198
222
|
};
|
|
199
223
|
//#endregion
|
|
200
|
-
export { BindingRequestEvent, CAPTURED_HEADERS, CaptureEvent, CaptureEventsPayload, CapturedBody, FRONTEND_SESSION_ID_HEADER, InboundRequestEvent, KvOmittedReason, KvOperation, KvOperationEvent, 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 };
|
|
224
|
+
export { BindingRequestEvent, CAPTURED_HEADERS, CaptureEvent, CaptureEventsPayload, CapturedBody, FRONTEND_SESSION_ID_HEADER, InboundRequestEvent, KvOmittedReason, KvOperation, KvOperationEvent, METICULOUS_PASSTHROUGH_HEADER, 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 };
|
|
201
225
|
//# sourceMappingURL=protocol.d.ts.map
|
package/dist/protocol.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* is the single source of truth for the shape of capture events.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
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]="40d02ddc-000e-5f33-972c-1fa95ad7e2ea")}catch(e){}}();
|
|
9
9
|
const SIDECAR_PROTOCOL_VERSION = "1";
|
|
10
10
|
/** Header carrying {@link SIDECAR_PROTOCOL_VERSION} on every shim → sidecar request. */
|
|
11
11
|
const SIDECAR_PROTOCOL_VERSION_HEADER = "x-meticulous-sidecar-protocol-version";
|
|
@@ -14,6 +14,16 @@ const SIDECAR_PROTOCOL_VERSION_HEADER = "x-meticulous-sidecar-protocol-version";
|
|
|
14
14
|
* under recording; its value ties backend spans to the frontend session.
|
|
15
15
|
*/
|
|
16
16
|
const FRONTEND_SESSION_ID_HEADER = "x-meticulous-session-id";
|
|
17
|
+
/**
|
|
18
|
+
* A request carrying this header set to `"true"` is never served from the recording and
|
|
19
|
+
* never fails on a miss — it goes to the real network. Use it for a call that must be live
|
|
20
|
+
* during replay (a health probe, telemetry, a resource the recording cannot cover).
|
|
21
|
+
*
|
|
22
|
+
* Same convention and value as the frontend network stubbing and the Node backend recorder
|
|
23
|
+
* (`METICULOUS_PASSTHROUGH_HEADER` in `@alwaysmeticulous/backend-recorder-js`); the value
|
|
24
|
+
* must stay in step with both.
|
|
25
|
+
*/
|
|
26
|
+
const METICULOUS_PASSTHROUGH_HEADER = "meticulous-passthrough";
|
|
17
27
|
const SIDECAR_EVENTS_PATH = "/v1/events";
|
|
18
28
|
/**
|
|
19
29
|
* Replay routes. A sidecar started in record mode answers 404 on both, which the shim
|
|
@@ -40,7 +50,7 @@ const REPLAY_SIDECAR_URL_HEADER = "x-meticulous-backend-replay-sidecar-url";
|
|
|
40
50
|
*/
|
|
41
51
|
const CAPTURED_HEADERS = ["content-type", FRONTEND_SESSION_ID_HEADER];
|
|
42
52
|
//#endregion
|
|
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 };
|
|
53
|
+
export { CAPTURED_HEADERS, FRONTEND_SESSION_ID_HEADER, METICULOUS_PASSTHROUGH_HEADER, REPLAY_SIDECAR_URL_HEADER, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH };
|
|
44
54
|
|
|
45
55
|
//# sourceMappingURL=protocol.js.map
|
|
46
|
-
//# debugId=
|
|
56
|
+
//# debugId=40d02ddc-000e-5f33-972c-1fa95ad7e2ea
|
package/dist/protocol.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"protocol.js","sources":["../src/protocol.ts"],"sourcesContent":["/**\n * Wire protocol between the in-worker shim and the Meticulous backend\n * recorder sidecar. The sidecar imports these types (type-only), so this file\n * is the single source of truth for the shape of capture events.\n */\n\nexport const SIDECAR_PROTOCOL_VERSION = \"1\";\n\n/** Header carrying {@link SIDECAR_PROTOCOL_VERSION} on every shim → sidecar request. */\nexport const SIDECAR_PROTOCOL_VERSION_HEADER =\n \"x-meticulous-sidecar-protocol-version\";\n\n/**\n * Header the Meticulous frontend recorder stamps on requests made by the app\n * under recording; its value ties backend spans to the frontend session.\n */\nexport const FRONTEND_SESSION_ID_HEADER = \"x-meticulous-session-id\";\n\nexport const SIDECAR_EVENTS_PATH = \"/v1/events\";\n\n/**\n * Replay routes. A sidecar started in record mode answers 404 on both, which the shim\n * treats as \"replay unavailable\" and falls back to plain pass-through — so a new shim\n * keeps working against an older, record-only sidecar.\n */\nexport const SIDECAR_REPLAY_SESSION_PATH = \"/v1/replay/session\";\nexport const SIDECAR_REPLAY_OUTBOUND_FETCH_PATH = \"/v1/replay/outbound-fetch\";\n\n/**\n * Header carrying the replay sidecar's origin, injected by the Meticulous replay runner\n * on requests to the app under test. Workerd cannot see container environment variables,\n * so an inbound header is the only way per-replay config can reach the shim.\n *\n * The shim validates the value before using it and only honours a loopback /\n * `host.docker.internal` / private-network `http:` origin — see `replay-sidecar-url.ts`.\n */\nexport const REPLAY_SIDECAR_URL_HEADER =\n \"x-meticulous-backend-replay-sidecar-url\";\n\n/**\n * The only headers persisted on capture events. Everything else — notably\n * authorization, cookie/set-cookie, and API-key headers — is dropped at\n * capture time, before it ever leaves the worker. Mirrors the Node backend\n * recorder, which persists only content-type plus the Meticulous session\n * header.\n */\nexport const CAPTURED_HEADERS = [\n \"content-type\",\n FRONTEND_SESSION_ID_HEADER,\n] as const;\n\nexport interface CapturedBody {\n /** UTF-8 decoded body, truncated to the capture cap. */\n body: string;\n /** True when the body exceeded the capture cap or the read was cut short. */\n truncated: boolean;\n}\n\ninterface BaseRequestEvent {\n /** Correlates outbound events to the inbound request being handled. */\n requestId: string;\n /** Value of {@link FRONTEND_SESSION_ID_HEADER} on the inbound request, if any. */\n frontendSessionId?: string;\n method: string;\n /** Full URL including query string. */\n url: string;\n /** Lower-cased header name → values. */\n requestHeaders: Record<string, string[]>;\n responseHeaders?: Record<string, string[]>;\n statusCode?: number;\n startTimeMs: number;\n endTimeMs: number;\n /** Set when the request failed before a response was produced. */\n error?: string;\n}\n\n/**\n * The worker's own inbound request (becomes a SERVER span). Bodies are not\n * captured, matching the Node backend recorder's SERVER-span contract.\n */\nexport interface InboundRequestEvent extends BaseRequestEvent {\n kind: \"inbound\";\n}\n\n/**\n * An outgoing `fetch` call made while handling an inbound request (becomes a\n * CLIENT span).\n */\nexport interface OutboundRequestEvent extends BaseRequestEvent {\n kind: \"outbound\";\n requestBody?: CapturedBody;\n responseBody?: CapturedBody;\n}\n\n/**\n * A `fetch` through a Cloudflare binding — a service binding, an assets binding, or a\n * Durable Object stub — made while handling an inbound request (becomes a CLIENT span).\n *\n * Request/Response-shaped like {@link OutboundRequestEvent}, but a distinct kind because the\n * call never leaves the isolate and its URL is whatever the caller invented. Keeping the two\n * apart means a binding call and real egress can never be confused for one another when\n * these spans are eventually mocked.\n */\nexport interface BindingRequestEvent extends BaseRequestEvent {\n kind: \"binding\";\n /**\n * The `env` key the binding was found under. Absent when the instance was never seen on\n * `env` — most commonly a Durable Object stub, which `namespace.get()` produces rather\n * than being a binding in its own right.\n */\n bindingName?: string;\n requestBody?: CapturedBody;\n responseBody?: CapturedBody;\n}\n\n/** KV namespace methods the shim records. */\nexport type KvOperation = \"get\" | \"getWithMetadata\" | \"put\" | \"delete\" | \"list\";\n\n/**\n * Why a KV value is absent from an event: workerd handed it over in a form that cannot be\n * persisted as text. A `\"stream\"` value must not even be read — consuming it would take the\n * bytes away from the app — and binary values are skipped deliberately, since KV blobs are\n * large and would both bloat the recording and be mangled by UTF-8 capture.\n */\nexport type KvOmittedReason = \"binary\" | \"stream\";\n\n/**\n * An operation on a Cloudflare KV namespace binding, made while handling an inbound request\n * (becomes a CLIENT span).\n *\n * KV is not HTTP: there is no method, URL or status code, so this event shares nothing with\n * {@link BaseRequestEvent} beyond correlation and timing. Values are JSON so that a single\n * `JSON.parse` reconstructs exactly what the app saw, whichever `type` the read asked for.\n */\nexport interface KvOperationEvent {\n kind: \"kv\";\n /** Correlates the operation to the inbound request being handled. */\n requestId: string;\n frontendSessionId?: string;\n /**\n * The `env` key the KV namespace was found under. Always present, unlike on a binding\n * event: a namespace has no factory equivalent to `DurableObjectNamespace.get()`, so one\n * that was never seen on `env` is not recorded at all.\n */\n bindingName: string;\n operation: KvOperation;\n /**\n * The key operated on, when it is a single string. Absent for `list` (which spans keys) and\n * for a bulk `get` (which takes an array) — both keep their arguments in {@link args}.\n */\n key?: string;\n /**\n * JSON of the call's arguments: the read `type`, the `put` options, the `list` selector.\n * A `put` value appears as `null` here — it is captured in {@link value} instead, so that\n * redaction applies to it.\n */\n args?: CapturedBody;\n /** JSON of the value a `put` wrote, with secret-looking fields redacted. */\n value?: CapturedBody;\n /**\n * JSON of what the operation returned: the value for `get`, `{value, metadata}` for\n * `getWithMetadata`, the key page for `list`. Absent for `put`/`delete`, which return\n * nothing, and for a value that could not be captured (see {@link omitted}).\n */\n result?: CapturedBody;\n /**\n * Why the operation's value is missing. Refers to the written value for `put` and to the\n * read value otherwise — a `put` has no result, so this is never ambiguous.\n */\n omitted?: KvOmittedReason;\n startTimeMs: number;\n endTimeMs: number;\n /** Set when the operation threw. */\n error?: string;\n}\n\nexport type CaptureEvent =\n | InboundRequestEvent\n | OutboundRequestEvent\n | BindingRequestEvent\n | KvOperationEvent;\n\nexport interface CaptureEventsPayload {\n events: CaptureEvent[];\n}\n\n/**\n * Answer to `GET {SIDECAR_REPLAY_SESSION_PATH}?sessionId=<id>`: whether the sidecar holds\n * mocks for a session, and the virtual clock anchor to freeze `Date` at while serving it.\n */\nexport interface ReplaySessionInfoResponse {\n found: boolean;\n /**\n * Epoch ms of the session's last recorded activity. The worker freezes its clock here so\n * that credentials minted during the recording are already issued but not yet expired.\n */\n clockAnchorMs?: number;\n}\n\n/** Lookup for a single outbound `fetch`, sent to {@link SIDECAR_REPLAY_OUTBOUND_FETCH_PATH}. */\nexport interface OutboundFetchLookupRequest {\n frontendSessionId: string;\n method: string;\n /** Full URL including query string. */\n url: string;\n /**\n * Request body as captured by `readBodyWithCap`. The sidecar hashes this itself, so the\n * hash it compares against a recording is derived from byte-identical input on both the\n * record and replay sides.\n */\n requestBody?: CapturedBody;\n}\n\n/**\n * Either a recorded response to serve, or an instruction to let the real call through.\n * An unrecognised `outcome` must be treated as `passthrough`, so an older shim keeps\n * working against a newer sidecar.\n */\nexport type OutboundFetchLookupResponse =\n | {\n outcome: \"mock\";\n statusCode: number;\n body: string;\n headers: Record<string, string>;\n }\n | { outcome: \"passthrough\" };\n"],"names":[],"mappings":";;;;;;;;AAMA,MAAa,2BAA2B;;AAGxC,MAAa,kCACX;;;;;AAMF,MAAa,6BAA6B;AAE1C,MAAa,sBAAsB;;;;;;AAOnC,MAAa,8BAA8B;AAC3C,MAAa,qCAAqC;;;;;;;;;AAUlD,MAAa,4BACX;;;;;;;;AASF,MAAa,mBAAmB,CAC9B,gBACA,2BACD","debugId":"532e3e0d-6b1c-51c2-8086-a05de2625e79"}
|
|
1
|
+
{"version":3,"file":"protocol.js","sources":["../src/protocol.ts"],"sourcesContent":["/**\n * Wire protocol between the in-worker shim and the Meticulous backend\n * recorder sidecar. The sidecar imports these types (type-only), so this file\n * is the single source of truth for the shape of capture events.\n */\n\nexport const SIDECAR_PROTOCOL_VERSION = \"1\";\n\n/** Header carrying {@link SIDECAR_PROTOCOL_VERSION} on every shim → sidecar request. */\nexport const SIDECAR_PROTOCOL_VERSION_HEADER =\n \"x-meticulous-sidecar-protocol-version\";\n\n/**\n * Header the Meticulous frontend recorder stamps on requests made by the app\n * under recording; its value ties backend spans to the frontend session.\n */\nexport const FRONTEND_SESSION_ID_HEADER = \"x-meticulous-session-id\";\n\n/**\n * A request carrying this header set to `\"true\"` is never served from the recording and\n * never fails on a miss — it goes to the real network. Use it for a call that must be live\n * during replay (a health probe, telemetry, a resource the recording cannot cover).\n *\n * Same convention and value as the frontend network stubbing and the Node backend recorder\n * (`METICULOUS_PASSTHROUGH_HEADER` in `@alwaysmeticulous/backend-recorder-js`); the value\n * must stay in step with both.\n */\nexport const METICULOUS_PASSTHROUGH_HEADER = \"meticulous-passthrough\";\n\nexport const SIDECAR_EVENTS_PATH = \"/v1/events\";\n\n/**\n * Replay routes. A sidecar started in record mode answers 404 on both, which the shim\n * treats as \"replay unavailable\" and falls back to plain pass-through — so a new shim\n * keeps working against an older, record-only sidecar.\n */\nexport const SIDECAR_REPLAY_SESSION_PATH = \"/v1/replay/session\";\nexport const SIDECAR_REPLAY_OUTBOUND_FETCH_PATH = \"/v1/replay/outbound-fetch\";\n\n/**\n * Header carrying the replay sidecar's origin, injected by the Meticulous replay runner\n * on requests to the app under test. Workerd cannot see container environment variables,\n * so an inbound header is the only way per-replay config can reach the shim.\n *\n * The shim validates the value before using it and only honours a loopback /\n * `host.docker.internal` / private-network `http:` origin — see `replay-sidecar-url.ts`.\n */\nexport const REPLAY_SIDECAR_URL_HEADER =\n \"x-meticulous-backend-replay-sidecar-url\";\n\n/**\n * The only headers persisted on capture events. Everything else — notably\n * authorization, cookie/set-cookie, and API-key headers — is dropped at\n * capture time, before it ever leaves the worker. Mirrors the Node backend\n * recorder, which persists only content-type plus the Meticulous session\n * header.\n */\nexport const CAPTURED_HEADERS = [\n \"content-type\",\n FRONTEND_SESSION_ID_HEADER,\n] as const;\n\nexport interface CapturedBody {\n /** UTF-8 decoded body, truncated to the capture cap. */\n body: string;\n /** True when the body exceeded the capture cap or the read was cut short. */\n truncated: boolean;\n}\n\ninterface BaseRequestEvent {\n /** Correlates outbound events to the inbound request being handled. */\n requestId: string;\n /** Value of {@link FRONTEND_SESSION_ID_HEADER} on the inbound request, if any. */\n frontendSessionId?: string;\n method: string;\n /** Full URL including query string. */\n url: string;\n /** Lower-cased header name → values. */\n requestHeaders: Record<string, string[]>;\n responseHeaders?: Record<string, string[]>;\n statusCode?: number;\n startTimeMs: number;\n endTimeMs: number;\n /** Set when the request failed before a response was produced. */\n error?: string;\n}\n\n/**\n * The worker's own inbound request (becomes a SERVER span). Bodies are not\n * captured, matching the Node backend recorder's SERVER-span contract.\n */\nexport interface InboundRequestEvent extends BaseRequestEvent {\n kind: \"inbound\";\n}\n\n/**\n * An outgoing `fetch` call made while handling an inbound request (becomes a\n * CLIENT span).\n */\nexport interface OutboundRequestEvent extends BaseRequestEvent {\n kind: \"outbound\";\n requestBody?: CapturedBody;\n responseBody?: CapturedBody;\n}\n\n/**\n * A `fetch` through a Cloudflare binding — a service binding, an assets binding, or a\n * Durable Object stub — made while handling an inbound request (becomes a CLIENT span).\n *\n * Request/Response-shaped like {@link OutboundRequestEvent}, but a distinct kind because the\n * call never leaves the isolate and its URL is whatever the caller invented. Keeping the two\n * apart means a binding call and real egress can never be confused for one another when\n * these spans are eventually mocked.\n */\nexport interface BindingRequestEvent extends BaseRequestEvent {\n kind: \"binding\";\n /**\n * The `env` key the binding was found under. Absent when the instance was never seen on\n * `env` — most commonly a Durable Object stub, which `namespace.get()` produces rather\n * than being a binding in its own right.\n */\n bindingName?: string;\n requestBody?: CapturedBody;\n responseBody?: CapturedBody;\n}\n\n/** KV namespace methods the shim records. */\nexport type KvOperation = \"get\" | \"getWithMetadata\" | \"put\" | \"delete\" | \"list\";\n\n/**\n * Why a KV value is absent from an event: workerd handed it over in a form that cannot be\n * persisted as text. A `\"stream\"` value must not even be read — consuming it would take the\n * bytes away from the app — and binary values are skipped deliberately, since KV blobs are\n * large and would both bloat the recording and be mangled by UTF-8 capture.\n */\nexport type KvOmittedReason = \"binary\" | \"stream\";\n\n/**\n * An operation on a Cloudflare KV namespace binding, made while handling an inbound request\n * (becomes a CLIENT span).\n *\n * KV is not HTTP: there is no method, URL or status code, so this event shares nothing with\n * {@link BaseRequestEvent} beyond correlation and timing. Values are JSON so that a single\n * `JSON.parse` reconstructs exactly what the app saw, whichever `type` the read asked for.\n */\nexport interface KvOperationEvent {\n kind: \"kv\";\n /** Correlates the operation to the inbound request being handled. */\n requestId: string;\n frontendSessionId?: string;\n /**\n * The `env` key the KV namespace was found under. Always present, unlike on a binding\n * event: a namespace has no factory equivalent to `DurableObjectNamespace.get()`, so one\n * that was never seen on `env` is not recorded at all.\n */\n bindingName: string;\n operation: KvOperation;\n /**\n * The key operated on, when it is a single string. Absent for `list` (which spans keys) and\n * for a bulk `get` (which takes an array) — both keep their arguments in {@link args}.\n */\n key?: string;\n /**\n * JSON of the call's arguments: the read `type`, the `put` options, the `list` selector.\n * A `put` value appears as `null` here — it is captured in {@link value} instead, so that\n * redaction applies to it.\n */\n args?: CapturedBody;\n /** JSON of the value a `put` wrote, with secret-looking fields redacted. */\n value?: CapturedBody;\n /**\n * JSON of what the operation returned: the value for `get`, `{value, metadata}` for\n * `getWithMetadata`, the key page for `list`. Absent for `put`/`delete`, which return\n * nothing, and for a value that could not be captured (see {@link omitted}).\n */\n result?: CapturedBody;\n /**\n * Why the operation's value is missing. Refers to the written value for `put` and to the\n * read value otherwise — a `put` has no result, so this is never ambiguous.\n */\n omitted?: KvOmittedReason;\n startTimeMs: number;\n endTimeMs: number;\n /** Set when the operation threw. */\n error?: string;\n}\n\nexport type CaptureEvent =\n | InboundRequestEvent\n | OutboundRequestEvent\n | BindingRequestEvent\n | KvOperationEvent;\n\nexport interface CaptureEventsPayload {\n events: CaptureEvent[];\n}\n\n/**\n * Answer to `GET {SIDECAR_REPLAY_SESSION_PATH}?sessionId=<id>`: whether the sidecar holds\n * mocks for a session, and the virtual clock anchor to freeze `Date` at while serving it.\n */\nexport interface ReplaySessionInfoResponse {\n found: boolean;\n /**\n * Epoch ms of the session's last recorded activity. The worker freezes its clock here so\n * that credentials minted during the recording are already issued but not yet expired.\n */\n clockAnchorMs?: number;\n}\n\n/** Lookup for a single outbound `fetch`, sent to {@link SIDECAR_REPLAY_OUTBOUND_FETCH_PATH}. */\nexport interface OutboundFetchLookupRequest {\n frontendSessionId: string;\n method: string;\n /** Full URL including query string. */\n url: string;\n /**\n * Request body as captured by `readBodyWithCap`. The sidecar hashes this itself, so the\n * hash it compares against a recording is derived from byte-identical input on both the\n * record and replay sides.\n */\n requestBody?: CapturedBody;\n}\n\n/**\n * A recorded response to serve, a miss the caller must fail, or an instruction to let the\n * real call through. An unrecognised `outcome` must be treated as `passthrough`, so an older\n * shim keeps working against a newer sidecar.\n */\nexport type OutboundFetchLookupResponse =\n | {\n outcome: \"mock\";\n statusCode: number;\n body: string;\n headers: Record<string, string>;\n }\n /**\n * The store was consulted and holds nothing for this call. Replay is hermetic, so the\n * shim fails the request rather than letting it reach the real service — matching the\n * Node backend recorder's http/undici mocks.\n */\n | { outcome: \"no-mock\" }\n /**\n * The call cannot be looked up at all (an unparseable URL), so there is no miss to report.\n * Also what an older sidecar answers on a miss, in which case the shim degrades to the\n * pre-`no-mock` fail-open behaviour rather than breaking the app.\n */\n | { outcome: \"passthrough\" };\n"],"names":[],"mappings":";;;;;;;;AAMA,MAAa,2BAA2B;;AAGxC,MAAa,kCACX;;;;;AAMF,MAAa,6BAA6B;;;;;;;;;;AAW1C,MAAa,gCAAgC;AAE7C,MAAa,sBAAsB;;;;;;AAOnC,MAAa,8BAA8B;AAC3C,MAAa,qCAAqC;;;;;;;;;AAUlD,MAAa,4BACX;;;;;;;;AASF,MAAa,mBAAmB,CAC9B,gBACA,2BACD","debugId":"40d02ddc-000e-5f33-972c-1fa95ad7e2ea"}
|
package/dist/replay-fetch.js
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
3
|
-
import { warnOnce } from "./log.js";
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="53c696d1-464e-5822-ad00-17a9779dd439")}catch(e){}}();
|
|
3
|
+
import { warn, warnOnce } from "./log.js";
|
|
4
4
|
import { readRequestBodyWithCap } from "./body-capture.js";
|
|
5
5
|
import { getOriginalFetch } from "./original-fetch.js";
|
|
6
|
+
import { METICULOUS_PASSTHROUGH_HEADER } from "./protocol.js";
|
|
6
7
|
import { postOutboundFetchLookup } from "./sidecar-client.js";
|
|
7
8
|
//#region src/replay-fetch.ts
|
|
8
9
|
/**
|
|
9
|
-
* Serves a recorded response for an outbound call, or
|
|
10
|
+
* Serves a recorded response for an outbound call, or fails it.
|
|
10
11
|
*
|
|
11
12
|
* The mirror image of `captureOutboundCall`: recording tees a call that happens anyway,
|
|
12
13
|
* whereas replay decides first and only then calls — so the sidecar sits in the request
|
|
13
|
-
* path here
|
|
14
|
+
* path here.
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Replay is hermetic: a call the recording does not cover must not silently reach the real
|
|
17
|
+
* service, since that turns a replay into live traffic against real credentials and hides
|
|
18
|
+
* the gap in the recording behind a response no base replay will reproduce. So anything we
|
|
19
|
+
* cannot serve from the recording — a miss, an unreachable sidecar, a mock that cannot be
|
|
20
|
+
* represented — throws, matching the Node recorder's http/undici mocks.
|
|
21
|
+
*
|
|
22
|
+
* The two ways out are an explicit `meticulous-passthrough: true` header on the request, and
|
|
23
|
+
* a sidecar that answers `passthrough` (it cannot look the call up, or it predates
|
|
24
|
+
* `no-mock`).
|
|
19
25
|
*/
|
|
20
26
|
const replayOutboundCall = async (ctx, request, invoke) => {
|
|
27
|
+
if (hasPassthroughHeader(request)) return invoke(request);
|
|
21
28
|
let lookup;
|
|
22
29
|
try {
|
|
23
30
|
const requestBody = request.body ? await readRequestBodyWithCap(request.clone().body).catch(() => void 0) : void 0;
|
|
@@ -28,12 +35,53 @@ const replayOutboundCall = async (ctx, request, invoke) => {
|
|
|
28
35
|
...requestBody !== void 0 ? { requestBody } : {}
|
|
29
36
|
};
|
|
30
37
|
} catch (error) {
|
|
31
|
-
|
|
32
|
-
return invoke(request);
|
|
38
|
+
throw buildReplayError(request, ctx.frontendSessionId, "could not prepare a mock lookup", error);
|
|
33
39
|
}
|
|
34
40
|
const result = await postOutboundFetchLookup(getOriginalFetch(), ctx.sidecarUrl, lookup);
|
|
35
|
-
if (result
|
|
36
|
-
|
|
41
|
+
if (result === void 0) throw buildReplayError(request, ctx.frontendSessionId, "the replay sidecar did not answer a mock lookup");
|
|
42
|
+
if (result.outcome === "no-mock") throw buildReplayError(request, ctx.frontendSessionId, "no recorded response");
|
|
43
|
+
if (result.outcome !== "mock") return invoke(request);
|
|
44
|
+
const mocked = buildMockResponse(result);
|
|
45
|
+
if (mocked === void 0) throw buildReplayError(request, ctx.frontendSessionId, `the recorded response (status ${result.statusCode}) cannot be represented`);
|
|
46
|
+
return mocked;
|
|
47
|
+
};
|
|
48
|
+
const hasPassthroughHeader = (request) => {
|
|
49
|
+
try {
|
|
50
|
+
return request.headers.get(METICULOUS_PASSTHROUGH_HEADER) === "true";
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Builds — and logs — the error the app sees for a call replay cannot serve. Deliberately
|
|
57
|
+
* names the shim and the session so an app-side stack trace is enough to tell a replay gap
|
|
58
|
+
* from a real bug, and logs as well because an app that catches its own fetch errors would
|
|
59
|
+
* otherwise swallow the only signal.
|
|
60
|
+
*
|
|
61
|
+
* The query string is left out on purpose — it routinely carries API keys, and the sidecar
|
|
62
|
+
* logs the full path anyway.
|
|
63
|
+
*/
|
|
64
|
+
const buildReplayError = (request, frontendSessionId, reason, cause) => {
|
|
65
|
+
const error = /* @__PURE__ */ new Error(`[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.`);
|
|
66
|
+
if (cause !== void 0) error.cause = cause;
|
|
67
|
+
warn(error.message);
|
|
68
|
+
return error;
|
|
69
|
+
};
|
|
70
|
+
const describeRequest = (request) => {
|
|
71
|
+
let method = "GET";
|
|
72
|
+
let url = "";
|
|
73
|
+
try {
|
|
74
|
+
method = request.method;
|
|
75
|
+
url = request.url;
|
|
76
|
+
} catch {
|
|
77
|
+
return method;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const parsed = new URL(url);
|
|
81
|
+
return `${method} ${parsed.origin}${parsed.pathname}`;
|
|
82
|
+
} catch {
|
|
83
|
+
return `${method} ${url}`;
|
|
84
|
+
}
|
|
37
85
|
};
|
|
38
86
|
const HEADERS_TO_DROP = new Set([
|
|
39
87
|
"content-encoding",
|
|
@@ -49,7 +97,7 @@ const NULL_BODY_STATUSES = new Set([
|
|
|
49
97
|
/**
|
|
50
98
|
* Turns a recorded response into a real one, or undefined if it cannot be represented (the
|
|
51
99
|
* constructor throws a RangeError on an out-of-range status, or a body on a null-body
|
|
52
|
-
* status), in which case the caller
|
|
100
|
+
* status), in which case the caller fails the request.
|
|
53
101
|
*
|
|
54
102
|
* Note recorded headers are limited to CAPTURED_HEADERS, so a mocked response carries
|
|
55
103
|
* essentially just content-type. In particular it can never set cookies — fine for the API
|
|
@@ -70,7 +118,7 @@ const buildMockResponse = (mock) => {
|
|
|
70
118
|
headers
|
|
71
119
|
});
|
|
72
120
|
} catch (error) {
|
|
73
|
-
warnOnce("replay-mock-response", "Could not construct a mocked response
|
|
121
|
+
warnOnce("replay-mock-response", "Could not construct a mocked response.", error);
|
|
74
122
|
return;
|
|
75
123
|
}
|
|
76
124
|
};
|
|
@@ -78,4 +126,4 @@ const buildMockResponse = (mock) => {
|
|
|
78
126
|
export { replayOutboundCall };
|
|
79
127
|
|
|
80
128
|
//# sourceMappingURL=replay-fetch.js.map
|
|
81
|
-
//# debugId=
|
|
129
|
+
//# debugId=53c696d1-464e-5822-ad00-17a9779dd439
|
package/dist/replay-fetch.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"replay-fetch.js","sources":["../src/replay-fetch.ts"],"sourcesContent":["import { readRequestBodyWithCap } from \"./body-capture\";\nimport type { RequestReplayContext } from \"./context\";\nimport { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport type
|
|
1
|
+
{"version":3,"file":"replay-fetch.js","sources":["../src/replay-fetch.ts"],"sourcesContent":["import { readRequestBodyWithCap } from \"./body-capture\";\nimport type { RequestReplayContext } from \"./context\";\nimport { warn, warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport {\n METICULOUS_PASSTHROUGH_HEADER,\n type OutboundFetchLookupRequest,\n} from \"./protocol\";\nimport { postOutboundFetchLookup } from \"./sidecar-client\";\n\n/**\n * Serves a recorded response for an outbound call, or fails it.\n *\n * The mirror image of `captureOutboundCall`: recording tees a call that happens anyway,\n * whereas replay decides first and only then calls — so the sidecar sits in the request\n * path here.\n *\n * Replay is hermetic: a call the recording does not cover must not silently reach the real\n * service, since that turns a replay into live traffic against real credentials and hides\n * the gap in the recording behind a response no base replay will reproduce. So anything we\n * cannot serve from the recording — a miss, an unreachable sidecar, a mock that cannot be\n * represented — throws, matching the Node recorder's http/undici mocks.\n *\n * The two ways out are an explicit `meticulous-passthrough: true` header on the request, and\n * a sidecar that answers `passthrough` (it cannot look the call up, or it predates\n * `no-mock`).\n */\nexport const replayOutboundCall = async (\n ctx: RequestReplayContext,\n request: Request,\n invoke: (request: Request) => Promise<Response>,\n): Promise<Response> => {\n if (hasPassthroughHeader(request)) {\n return invoke(request);\n }\n\n let lookup: OutboundFetchLookupRequest;\n try {\n // Read the clone and pass the original through untouched, mirroring the record path.\n // The sidecar hashes what we send here, and it was captured by the same\n // readRequestBodyWithCap the recorder used (redaction included), so the hashes on both\n // sides derive from byte-identical input.\n const requestBody = request.body\n ? await readRequestBodyWithCap(request.clone().body).catch(\n () => undefined,\n )\n : undefined;\n lookup = {\n frontendSessionId: ctx.frontendSessionId,\n method: request.method,\n url: request.url,\n ...(requestBody !== undefined ? { requestBody } : {}),\n };\n } catch (error) {\n // Without a lookup there is nothing to serve, and passing through would be exactly the\n // silent real call this path exists to prevent.\n throw buildReplayError(\n request,\n ctx.frontendSessionId,\n \"could not prepare a mock lookup\",\n error,\n );\n }\n\n const result = await postOutboundFetchLookup(\n getOriginalFetch(),\n ctx.sidecarUrl,\n lookup,\n );\n if (result === undefined) {\n throw buildReplayError(\n request,\n ctx.frontendSessionId,\n \"the replay sidecar did not answer a mock lookup\",\n );\n }\n if (result.outcome === \"no-mock\") {\n throw buildReplayError(\n request,\n ctx.frontendSessionId,\n \"no recorded response\",\n );\n }\n // Covers `passthrough` and an unrecognised `outcome` from a newer sidecar.\n if (result.outcome !== \"mock\") {\n return invoke(request);\n }\n const mocked = buildMockResponse(result);\n if (mocked === undefined) {\n throw buildReplayError(\n request,\n ctx.frontendSessionId,\n `the recorded response (status ${result.statusCode}) cannot be represented`,\n );\n }\n return mocked;\n};\n\nconst hasPassthroughHeader = (request: Request): boolean => {\n try {\n return request.headers.get(METICULOUS_PASSTHROUGH_HEADER) === \"true\";\n } catch {\n return false;\n }\n};\n\n/**\n * Builds — and logs — the error the app sees for a call replay cannot serve. Deliberately\n * names the shim and the session so an app-side stack trace is enough to tell a replay gap\n * from a real bug, and logs as well because an app that catches its own fetch errors would\n * otherwise swallow the only signal.\n *\n * The query string is left out on purpose — it routinely carries API keys, and the sidecar\n * logs the full path anyway.\n */\nconst buildReplayError = (\n request: Request,\n frontendSessionId: string,\n reason: string,\n cause?: unknown,\n): Error => {\n const error = new Error(\n `[backend-recorder] workerd replay: ${reason} for ${describeRequest(request)} (session ${frontendSessionId}). Set the \"${METICULOUS_PASSTHROUGH_HEADER}: true\" header on this request if it must reach the real service during replay.`,\n );\n if (cause !== undefined) {\n // `cause` in the constructor options is not universally available in workerd builds.\n (error as { cause?: unknown }).cause = cause;\n }\n warn(error.message);\n return error;\n};\n\nconst describeRequest = (request: Request): string => {\n let method = \"GET\";\n let url = \"\";\n try {\n method = request.method;\n url = request.url;\n } catch {\n return method;\n }\n try {\n const parsed = new URL(url);\n return `${method} ${parsed.origin}${parsed.pathname}`;\n } catch {\n return `${method} ${url}`;\n }\n};\n\n// Recorded bodies are already decoded and may have been truncated, so the recorded framing\n// headers no longer describe them; the Response constructor recomputes what it needs.\nconst HEADERS_TO_DROP = new Set([\n \"content-encoding\",\n \"content-length\",\n \"transfer-encoding\",\n]);\n\n// Statuses the Response constructor refuses to pair with a body.\nconst NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);\n\n/**\n * Turns a recorded response into a real one, or undefined if it cannot be represented (the\n * constructor throws a RangeError on an out-of-range status, or a body on a null-body\n * status), in which case the caller fails the request.\n *\n * Note recorded headers are limited to CAPTURED_HEADERS, so a mocked response carries\n * essentially just content-type. In particular it can never set cookies — fine for the API\n * calls this path serves, but a real limitation for a backend that authenticates that way.\n */\nconst buildMockResponse = (mock: {\n statusCode: number;\n body: string;\n headers: Record<string, string>;\n}): Response | undefined => {\n try {\n const headers = new Headers();\n for (const [name, value] of Object.entries(mock.headers)) {\n if (HEADERS_TO_DROP.has(name.toLowerCase())) {\n continue;\n }\n try {\n headers.set(name, value);\n } catch {\n // Skip a header name/value the runtime rejects rather than losing the whole mock.\n }\n }\n const body = NULL_BODY_STATUSES.has(mock.statusCode) ? null : mock.body;\n return new Response(body, { status: mock.statusCode, headers });\n } catch (error) {\n warnOnce(\n \"replay-mock-response\",\n \"Could not construct a mocked response.\",\n error,\n );\n return undefined;\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,MAAa,qBAAqB,OAChC,KACA,SACA,WACsB;AACtB,KAAI,qBAAqB,QAAQ,CAC/B,QAAO,OAAO,QAAQ;CAGxB,IAAI;AACJ,KAAI;EAKF,MAAM,cAAc,QAAQ,OACxB,MAAM,uBAAuB,QAAQ,OAAO,CAAC,KAAK,CAAC,YAC3C,KAAA,EACP,GACD,KAAA;AACJ,WAAS;GACP,mBAAmB,IAAI;GACvB,QAAQ,QAAQ;GAChB,KAAK,QAAQ;GACb,GAAI,gBAAgB,KAAA,IAAY,EAAE,aAAa,GAAG,EAAE;GACrD;UACM,OAAO;AAGd,QAAM,iBACJ,SACA,IAAI,mBACJ,mCACA,MACD;;CAGH,MAAM,SAAS,MAAM,wBACnB,kBAAkB,EAClB,IAAI,YACJ,OACD;AACD,KAAI,WAAW,KAAA,EACb,OAAM,iBACJ,SACA,IAAI,mBACJ,kDACD;AAEH,KAAI,OAAO,YAAY,UACrB,OAAM,iBACJ,SACA,IAAI,mBACJ,uBACD;AAGH,KAAI,OAAO,YAAY,OACrB,QAAO,OAAO,QAAQ;CAExB,MAAM,SAAS,kBAAkB,OAAO;AACxC,KAAI,WAAW,KAAA,EACb,OAAM,iBACJ,SACA,IAAI,mBACJ,iCAAiC,OAAO,WAAW,yBACpD;AAEH,QAAO;;AAGT,MAAM,wBAAwB,YAA8B;AAC1D,KAAI;AACF,SAAO,QAAQ,QAAQ,IAAI,8BAA8B,KAAK;SACxD;AACN,SAAO;;;;;;;;;;;;AAaX,MAAM,oBACJ,SACA,mBACA,QACA,UACU;CACV,MAAM,wBAAQ,IAAI,MAChB,sCAAsC,OAAO,OAAO,gBAAgB,QAAQ,CAAC,YAAY,kBAAkB,cAAc,8BAA8B,iFACxJ;AACD,KAAI,UAAU,KAAA,EAEX,OAA8B,QAAQ;AAEzC,MAAK,MAAM,QAAQ;AACnB,QAAO;;AAGT,MAAM,mBAAmB,YAA6B;CACpD,IAAI,SAAS;CACb,IAAI,MAAM;AACV,KAAI;AACF,WAAS,QAAQ;AACjB,QAAM,QAAQ;SACR;AACN,SAAO;;AAET,KAAI;EACF,MAAM,SAAS,IAAI,IAAI,IAAI;AAC3B,SAAO,GAAG,OAAO,GAAG,OAAO,SAAS,OAAO;SACrC;AACN,SAAO,GAAG,OAAO,GAAG;;;AAMxB,MAAM,kBAAkB,IAAI,IAAI;CAC9B;CACA;CACA;CACD,CAAC;AAGF,MAAM,qBAAqB,IAAI,IAAI;CAAC;CAAK;CAAK;CAAK;CAAI,CAAC;;;;;;;;;;AAWxD,MAAM,qBAAqB,SAIC;AAC1B,KAAI;EACF,MAAM,UAAU,IAAI,SAAS;AAC7B,OAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,KAAK,QAAQ,EAAE;AACxD,OAAI,gBAAgB,IAAI,KAAK,aAAa,CAAC,CACzC;AAEF,OAAI;AACF,YAAQ,IAAI,MAAM,MAAM;WAClB;;EAIV,MAAM,OAAO,mBAAmB,IAAI,KAAK,WAAW,GAAG,OAAO,KAAK;AACnE,SAAO,IAAI,SAAS,MAAM;GAAE,QAAQ,KAAK;GAAY;GAAS,CAAC;UACxD,OAAO;AACd,WACE,wBACA,0CACA,MACD;AACD","debugId":"53c696d1-464e-5822-ad00-17a9779dd439"}
|
package/dist/sidecar-client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="89e7f190-1d19-5653-b03e-60d14140c4fc")}catch(e){}}();
|
|
3
3
|
import { warnOnce } from "./log.js";
|
|
4
4
|
import { SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH } from "./protocol.js";
|
|
5
5
|
//#region src/sidecar-client.ts
|
|
@@ -10,20 +10,37 @@ import { SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OU
|
|
|
10
10
|
*/
|
|
11
11
|
const SESSION_INFO_TIMEOUT_MS = 1e4;
|
|
12
12
|
const LOOKUP_TIMEOUT_MS = 5e3;
|
|
13
|
+
/**
|
|
14
|
+
* Capture reporting is off the response path (it runs under `ctx.waitUntil`), so a timeout
|
|
15
|
+
* here is not about latency the app can see — it is about not holding the request context
|
|
16
|
+
* open on a sidecar that will never answer. A refused connection rejects immediately, but a
|
|
17
|
+
* sidecar URL pointing at a host that drops packets rather than refusing them (a firewalled
|
|
18
|
+
* container or LAN address instead of loopback) would otherwise leave the POST pending until
|
|
19
|
+
* the runtime tears the context down, on every captured call.
|
|
20
|
+
*
|
|
21
|
+
* Generous relative to what it bounds: the sidecar is always loopback or the docker gateway
|
|
22
|
+
* and answers a single small event in single-digit milliseconds, so this only ever fires on
|
|
23
|
+
* a sidecar that is wedged or unreachable — never on one that is merely busy. Being generous
|
|
24
|
+
* costs nothing because {@link withTimeout} clears the timer the moment the POST settles.
|
|
25
|
+
*/
|
|
26
|
+
const CAPTURE_POST_TIMEOUT_MS = 2e3;
|
|
13
27
|
/** POSTs capture events to the sidecar. Never rejects — reporting failures only warn (once). */
|
|
14
28
|
const postCaptureEvents = async (fetchFn, sidecarUrl, events) => {
|
|
15
29
|
try {
|
|
16
30
|
const payload = { events };
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
await withTimeout(CAPTURE_POST_TIMEOUT_MS, async (signal) => {
|
|
32
|
+
const response = await fetchFn(`${sidecarUrl}${SIDECAR_EVENTS_PATH}`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: {
|
|
35
|
+
"content-type": "application/json",
|
|
36
|
+
[SIDECAR_PROTOCOL_VERSION_HEADER]: "1"
|
|
37
|
+
},
|
|
38
|
+
body: JSON.stringify(payload),
|
|
39
|
+
signal
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) warnOnce("sidecar-rejected", `Meticulous sidecar rejected capture events (HTTP ${response.status}).`);
|
|
42
|
+
await response.arrayBuffer().catch(() => void 0);
|
|
24
43
|
});
|
|
25
|
-
if (!response.ok) warnOnce("sidecar-rejected", `Meticulous sidecar rejected capture events (HTTP ${response.status}).`);
|
|
26
|
-
await response.arrayBuffer().catch(() => void 0);
|
|
27
44
|
} catch (error) {
|
|
28
45
|
warnOnce("sidecar-unreachable", "Could not reach the Meticulous backend recorder sidecar — backend session events are not being recorded.", error);
|
|
29
46
|
}
|
|
@@ -38,28 +55,30 @@ const postCaptureEvents = async (fetchFn, sidecarUrl, events) => {
|
|
|
38
55
|
const getReplaySessionInfo = async (fetchFn, sidecarUrl, frontendSessionId) => {
|
|
39
56
|
const url = `${sidecarUrl}${SIDECAR_REPLAY_SESSION_PATH}?sessionId=${encodeURIComponent(frontendSessionId)}`;
|
|
40
57
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
58
|
+
return await withTimeout(SESSION_INFO_TIMEOUT_MS, async (signal) => {
|
|
59
|
+
const response = await fetchFn(url, {
|
|
60
|
+
headers: { [SIDECAR_PROTOCOL_VERSION_HEADER]: "1" },
|
|
61
|
+
signal
|
|
62
|
+
});
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
await response.arrayBuffer().catch(() => void 0);
|
|
65
|
+
if (response.status >= 500) {
|
|
66
|
+
warnOnce("replay-session-unreachable", `Meticulous replay sidecar failed the replay session handshake (HTTP ${response.status}) — will retry on the next request.`);
|
|
67
|
+
return { outcome: "unreachable" };
|
|
68
|
+
}
|
|
69
|
+
warnOnce("replay-session-unavailable", `Meticulous replay sidecar did not recognise the replay session route (HTTP ${response.status}) — outbound calls will not be mocked.`);
|
|
70
|
+
return { outcome: "unavailable" };
|
|
71
|
+
}
|
|
72
|
+
const info = await response.json();
|
|
73
|
+
if (info.found !== true) {
|
|
74
|
+
warnOnce("replay-session-unknown", "Meticulous replay sidecar holds no mocks for this session — outbound calls will not be mocked.");
|
|
75
|
+
return { outcome: "unavailable" };
|
|
50
76
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
warnOnce("replay-session-unknown", "Meticulous replay sidecar holds no mocks for this session — outbound calls will not be mocked.");
|
|
57
|
-
return { outcome: "unavailable" };
|
|
58
|
-
}
|
|
59
|
-
return {
|
|
60
|
-
outcome: "found",
|
|
61
|
-
clockAnchorMs: typeof info.clockAnchorMs === "number" ? info.clockAnchorMs : void 0
|
|
62
|
-
};
|
|
77
|
+
return {
|
|
78
|
+
outcome: "found",
|
|
79
|
+
clockAnchorMs: typeof info.clockAnchorMs === "number" ? info.clockAnchorMs : void 0
|
|
80
|
+
};
|
|
81
|
+
});
|
|
63
82
|
} catch (error) {
|
|
64
83
|
warnOnce("replay-session-unreachable", "Could not reach the Meticulous replay sidecar — will retry on the next request.", error);
|
|
65
84
|
return { outcome: "unreachable" };
|
|
@@ -67,41 +86,54 @@ const getReplaySessionInfo = async (fetchFn, sidecarUrl, frontendSessionId) => {
|
|
|
67
86
|
};
|
|
68
87
|
/**
|
|
69
88
|
* Looks up a recorded response for one outbound call. Never rejects; returns undefined when
|
|
70
|
-
* the sidecar could not be consulted, which
|
|
71
|
-
* call
|
|
89
|
+
* the sidecar could not be consulted, which the caller fails the request on — an unanswered
|
|
90
|
+
* lookup is not evidence that a real call is safe.
|
|
72
91
|
*/
|
|
73
92
|
const postOutboundFetchLookup = async (fetchFn, sidecarUrl, payload) => {
|
|
74
93
|
try {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
94
|
+
return await withTimeout(LOOKUP_TIMEOUT_MS, async (signal) => {
|
|
95
|
+
const response = await fetchFn(`${sidecarUrl}${SIDECAR_REPLAY_OUTBOUND_FETCH_PATH}`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: {
|
|
98
|
+
"content-type": "application/json",
|
|
99
|
+
[SIDECAR_PROTOCOL_VERSION_HEADER]: "1"
|
|
100
|
+
},
|
|
101
|
+
body: JSON.stringify(payload),
|
|
102
|
+
signal
|
|
103
|
+
});
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
warnOnce("replay-lookup-rejected", `Meticulous replay sidecar rejected a mock lookup (HTTP ${response.status}).`);
|
|
106
|
+
await response.arrayBuffer().catch(() => void 0);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
return await response.json();
|
|
83
110
|
});
|
|
84
|
-
if (!response.ok) {
|
|
85
|
-
warnOnce("replay-lookup-rejected", `Meticulous replay sidecar rejected a mock lookup (HTTP ${response.status}).`);
|
|
86
|
-
await response.arrayBuffer().catch(() => void 0);
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
return await response.json();
|
|
90
111
|
} catch (error) {
|
|
91
|
-
warnOnce("replay-lookup-unreachable", "Could not reach the Meticulous replay sidecar for a mock lookup —
|
|
112
|
+
warnOnce("replay-lookup-unreachable", "Could not reach the Meticulous replay sidecar for a mock lookup — the call will fail.", error);
|
|
92
113
|
return;
|
|
93
114
|
}
|
|
94
115
|
};
|
|
95
|
-
/**
|
|
96
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Runs `send` under an abort signal that fires after `ms`, clearing the timer as soon as the
|
|
118
|
+
* call settles — including the response body read, which an abort must still cut short.
|
|
119
|
+
*
|
|
120
|
+
* Deliberately not `AbortSignal.timeout`: its timer cannot be cancelled, and a pending timer
|
|
121
|
+
* keeps workerd's request context alive. Bounding a 5ms POST at 2s would then pin the context
|
|
122
|
+
* for the remaining ~2s on every captured call — worse than the unbounded wait it exists to
|
|
123
|
+
* prevent, and on the healthy path rather than the broken one. `body-capture.ts` uses this
|
|
124
|
+
* same clearable shape, for the same reason.
|
|
125
|
+
*/
|
|
126
|
+
const withTimeout = async (ms, send) => {
|
|
127
|
+
const controller = new AbortController();
|
|
128
|
+
const timer = setTimeout(() => controller.abort(), ms);
|
|
97
129
|
try {
|
|
98
|
-
return
|
|
99
|
-
}
|
|
100
|
-
|
|
130
|
+
return await send(controller.signal);
|
|
131
|
+
} finally {
|
|
132
|
+
clearTimeout(timer);
|
|
101
133
|
}
|
|
102
134
|
};
|
|
103
135
|
//#endregion
|
|
104
136
|
export { getReplaySessionInfo, postCaptureEvents, postOutboundFetchLookup };
|
|
105
137
|
|
|
106
138
|
//# sourceMappingURL=sidecar-client.js.map
|
|
107
|
-
//# debugId=
|
|
139
|
+
//# debugId=89e7f190-1d19-5653-b03e-60d14140c4fc
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sidecar-client.js","sources":["../src/sidecar-client.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\nimport {\n type CaptureEvent,\n type CaptureEventsPayload,\n type OutboundFetchLookupRequest,\n type OutboundFetchLookupResponse,\n type ReplaySessionInfoResponse,\n SIDECAR_EVENTS_PATH,\n SIDECAR_PROTOCOL_VERSION,\n SIDECAR_PROTOCOL_VERSION_HEADER,\n SIDECAR_REPLAY_OUTBOUND_FETCH_PATH,\n SIDECAR_REPLAY_SESSION_PATH,\n} from \"./protocol\";\n\ntype FetchFn = typeof globalThis.fetch;\n\n/**\n * Replay calls sit in the request path, unlike record mode's fire-and-forget reporting, so\n * they must be bounded: without a timeout a wedged sidecar would hang every outbound fetch\n * the app makes, and therefore the whole replay, with nothing to look at.\n */\nconst SESSION_INFO_TIMEOUT_MS = 10_000;\nconst LOOKUP_TIMEOUT_MS = 5_000;\n\n/** POSTs capture events to the sidecar. Never rejects — reporting failures only warn (once). */\nexport const postCaptureEvents = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n events: CaptureEvent[],\n): Promise<void> => {\n try {\n const payload: CaptureEventsPayload = { events };\n const response = await fetchFn(`${sidecarUrl}${SIDECAR_EVENTS_PATH}`, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n body: JSON.stringify(payload),\n });\n if (!response.ok) {\n warnOnce(\n \"sidecar-rejected\",\n `Meticulous sidecar rejected capture events (HTTP ${response.status}).`,\n );\n }\n await response.arrayBuffer().catch(() => undefined);\n } catch (error) {\n warnOnce(\n \"sidecar-unreachable\",\n \"Could not reach the Meticulous backend recorder sidecar — backend session events are not being recorded.\",\n error,\n );\n }\n};\n\n/**\n * Outcome of the session handshake.\n *\n * `unavailable` and `unreachable` both mean \"do not replay\", but they are kept apart\n * because only the first is a settled answer. The caller caches per isolate, and caching a\n * one-off network blip would disable mocking for the rest of the session — silently sending\n * every later outbound call to the real service.\n */\nexport type ReplaySessionInfoResult =\n /** The sidecar holds mocks for this session. */\n | { outcome: \"found\"; clockAnchorMs: number | undefined }\n /** The sidecar answered and will never serve this session: no mocks, or no such route. */\n | { outcome: \"unavailable\" }\n /** Could not get an answer — timeout, transport error, 5xx. May succeed later. */\n | { outcome: \"unreachable\" };\n\n/**\n * Asks whether the sidecar holds mocks for a session, and at what instant to freeze the\n * clock while serving it.\n *\n * A 404 is how a record-only sidecar answers, so it counts as a settled `unavailable` and\n * the shim degrades to pass-through against an older sidecar rather than failing.\n */\nexport const getReplaySessionInfo = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n frontendSessionId: string,\n): Promise<ReplaySessionInfoResult> => {\n const url = `${sidecarUrl}${SIDECAR_REPLAY_SESSION_PATH}?sessionId=${encodeURIComponent(\n frontendSessionId,\n )}`;\n try {\n const response = await fetchFn(url, {\n headers: {\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n signal: timeoutSignal(SESSION_INFO_TIMEOUT_MS),\n });\n if (!response.ok) {\n await response.arrayBuffer().catch(() => undefined);\n // 4xx is the sidecar telling us something definitive — no such route (a record-only\n // sidecar), or a protocol version it will not speak. Retrying cannot change either.\n // 5xx may well be a sidecar still finding its feet, so leave it retryable.\n if (response.status >= 500) {\n warnOnce(\n \"replay-session-unreachable\",\n `Meticulous replay sidecar failed the replay session handshake (HTTP ${response.status}) — will retry on the next request.`,\n );\n return { outcome: \"unreachable\" };\n }\n warnOnce(\n \"replay-session-unavailable\",\n `Meticulous replay sidecar did not recognise the replay session route (HTTP ${response.status}) — outbound calls will not be mocked.`,\n );\n return { outcome: \"unavailable\" };\n }\n const info = (await response.json()) as ReplaySessionInfoResponse;\n if (info.found !== true) {\n warnOnce(\n \"replay-session-unknown\",\n \"Meticulous replay sidecar holds no mocks for this session — outbound calls will not be mocked.\",\n );\n return { outcome: \"unavailable\" };\n }\n return {\n outcome: \"found\",\n clockAnchorMs:\n typeof info.clockAnchorMs === \"number\" ? info.clockAnchorMs : undefined,\n };\n } catch (error) {\n warnOnce(\n \"replay-session-unreachable\",\n \"Could not reach the Meticulous replay sidecar — will retry on the next request.\",\n error,\n );\n return { outcome: \"unreachable\" };\n }\n};\n\n/**\n * Looks up a recorded response for one outbound call. Never rejects; returns undefined when\n * the sidecar could not be consulted, which callers treat the same as a miss (let the real\n * call through).\n */\nexport const postOutboundFetchLookup = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n payload: OutboundFetchLookupRequest,\n): Promise<OutboundFetchLookupResponse | undefined> => {\n try {\n const response = await fetchFn(\n `${sidecarUrl}${SIDECAR_REPLAY_OUTBOUND_FETCH_PATH}`,\n {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n body: JSON.stringify(payload),\n signal: timeoutSignal(LOOKUP_TIMEOUT_MS),\n },\n );\n if (!response.ok) {\n warnOnce(\n \"replay-lookup-rejected\",\n `Meticulous replay sidecar rejected a mock lookup (HTTP ${response.status}).`,\n );\n await response.arrayBuffer().catch(() => undefined);\n return undefined;\n }\n return (await response.json()) as OutboundFetchLookupResponse;\n } catch (error) {\n warnOnce(\n \"replay-lookup-unreachable\",\n \"Could not reach the Meticulous replay sidecar for a mock lookup — letting the real call through.\",\n error,\n );\n return undefined;\n }\n};\n\n/** AbortSignal.timeout, or undefined where it is unavailable. */\nconst timeoutSignal = (ms: number): AbortSignal | undefined => {\n try {\n return AbortSignal.timeout(ms);\n } catch {\n return undefined;\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;AAqBA,MAAM,0BAA0B;AAChC,MAAM,oBAAoB;;AAG1B,MAAa,oBAAoB,OAC/B,SACA,YACA,WACkB;AAClB,KAAI;EACF,MAAM,UAAgC,EAAE,QAAQ;EAChD,MAAM,WAAW,MAAM,QAAQ,GAAG,aAAa,uBAAuB;GACpE,QAAQ;GACR,SAAS;IACP,gBAAgB;KACf,kCAAA;IACF;GACD,MAAM,KAAK,UAAU,QAAQ;GAC9B,CAAC;AACF,MAAI,CAAC,SAAS,GACZ,UACE,oBACA,oDAAoD,SAAS,OAAO,IACrE;AAEH,QAAM,SAAS,aAAa,CAAC,YAAY,KAAA,EAAU;UAC5C,OAAO;AACd,WACE,uBACA,4GACA,MACD;;;;;;;;;;AA2BL,MAAa,uBAAuB,OAClC,SACA,YACA,sBACqC;CACrC,MAAM,MAAM,GAAG,aAAa,4BAA4B,aAAa,mBACnE,kBACD;AACD,KAAI;EACF,MAAM,WAAW,MAAM,QAAQ,KAAK;GAClC,SAAS,GACN,kCAAA,KACF;GACD,QAAQ,cAAc,wBAAwB;GAC/C,CAAC;AACF,MAAI,CAAC,SAAS,IAAI;AAChB,SAAM,SAAS,aAAa,CAAC,YAAY,KAAA,EAAU;AAInD,OAAI,SAAS,UAAU,KAAK;AAC1B,aACE,8BACA,uEAAuE,SAAS,OAAO,qCACxF;AACD,WAAO,EAAE,SAAS,eAAe;;AAEnC,YACE,8BACA,8EAA8E,SAAS,OAAO,wCAC/F;AACD,UAAO,EAAE,SAAS,eAAe;;EAEnC,MAAM,OAAQ,MAAM,SAAS,MAAM;AACnC,MAAI,KAAK,UAAU,MAAM;AACvB,YACE,0BACA,iGACD;AACD,UAAO,EAAE,SAAS,eAAe;;AAEnC,SAAO;GACL,SAAS;GACT,eACE,OAAO,KAAK,kBAAkB,WAAW,KAAK,gBAAgB,KAAA;GACjE;UACM,OAAO;AACd,WACE,8BACA,mFACA,MACD;AACD,SAAO,EAAE,SAAS,eAAe;;;;;;;;AASrC,MAAa,0BAA0B,OACrC,SACA,YACA,YACqD;AACrD,KAAI;EACF,MAAM,WAAW,MAAM,QACrB,GAAG,aAAa,sCAChB;GACE,QAAQ;GACR,SAAS;IACP,gBAAgB;KACf,kCAAA;IACF;GACD,MAAM,KAAK,UAAU,QAAQ;GAC7B,QAAQ,cAAc,kBAAkB;GACzC,CACF;AACD,MAAI,CAAC,SAAS,IAAI;AAChB,YACE,0BACA,0DAA0D,SAAS,OAAO,IAC3E;AACD,SAAM,SAAS,aAAa,CAAC,YAAY,KAAA,EAAU;AACnD;;AAEF,SAAQ,MAAM,SAAS,MAAM;UACtB,OAAO;AACd,WACE,6BACA,oGACA,MACD;AACD;;;;AAKJ,MAAM,iBAAiB,OAAwC;AAC7D,KAAI;AACF,SAAO,YAAY,QAAQ,GAAG;SACxB;AACN","debugId":"81b6dfa3-bea2-53e3-a6be-f23b3b050e4c"}
|
|
1
|
+
{"version":3,"file":"sidecar-client.js","sources":["../src/sidecar-client.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\nimport {\n type CaptureEvent,\n type CaptureEventsPayload,\n type OutboundFetchLookupRequest,\n type OutboundFetchLookupResponse,\n type ReplaySessionInfoResponse,\n SIDECAR_EVENTS_PATH,\n SIDECAR_PROTOCOL_VERSION,\n SIDECAR_PROTOCOL_VERSION_HEADER,\n SIDECAR_REPLAY_OUTBOUND_FETCH_PATH,\n SIDECAR_REPLAY_SESSION_PATH,\n} from \"./protocol\";\n\ntype FetchFn = typeof globalThis.fetch;\n\n/**\n * Replay calls sit in the request path, unlike record mode's fire-and-forget reporting, so\n * they must be bounded: without a timeout a wedged sidecar would hang every outbound fetch\n * the app makes, and therefore the whole replay, with nothing to look at.\n */\nconst SESSION_INFO_TIMEOUT_MS = 10_000;\nconst LOOKUP_TIMEOUT_MS = 5_000;\n\n/**\n * Capture reporting is off the response path (it runs under `ctx.waitUntil`), so a timeout\n * here is not about latency the app can see — it is about not holding the request context\n * open on a sidecar that will never answer. A refused connection rejects immediately, but a\n * sidecar URL pointing at a host that drops packets rather than refusing them (a firewalled\n * container or LAN address instead of loopback) would otherwise leave the POST pending until\n * the runtime tears the context down, on every captured call.\n *\n * Generous relative to what it bounds: the sidecar is always loopback or the docker gateway\n * and answers a single small event in single-digit milliseconds, so this only ever fires on\n * a sidecar that is wedged or unreachable — never on one that is merely busy. Being generous\n * costs nothing because {@link withTimeout} clears the timer the moment the POST settles.\n */\nconst CAPTURE_POST_TIMEOUT_MS = 2_000;\n\n/** POSTs capture events to the sidecar. Never rejects — reporting failures only warn (once). */\nexport const postCaptureEvents = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n events: CaptureEvent[],\n): Promise<void> => {\n try {\n const payload: CaptureEventsPayload = { events };\n await withTimeout(CAPTURE_POST_TIMEOUT_MS, async (signal) => {\n const response = await fetchFn(`${sidecarUrl}${SIDECAR_EVENTS_PATH}`, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n body: JSON.stringify(payload),\n signal,\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` and\n * the shim degrades to pass-through against an older sidecar rather than failing.\n */\nexport const getReplaySessionInfo = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n frontendSessionId: string,\n): Promise<ReplaySessionInfoResult> => {\n const url = `${sidecarUrl}${SIDECAR_REPLAY_SESSION_PATH}?sessionId=${encodeURIComponent(\n frontendSessionId,\n )}`;\n try {\n 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 * 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"],"names":[],"mappings":";;;;;;;;;;AAqBA,MAAM,0BAA0B;AAChC,MAAM,oBAAoB;;;;;;;;;;;;;;AAe1B,MAAM,0BAA0B;;AAGhC,MAAa,oBAAoB,OAC/B,SACA,YACA,WACkB;AAClB,KAAI;EACF,MAAM,UAAgC,EAAE,QAAQ;AAChD,QAAM,YAAY,yBAAyB,OAAO,WAAW;GAC3D,MAAM,WAAW,MAAM,QAAQ,GAAG,aAAa,uBAAuB;IACpE,QAAQ;IACR,SAAS;KACP,gBAAgB;MACf,kCAAA;KACF;IACD,MAAM,KAAK,UAAU,QAAQ;IAC7B;IACD,CAAC;AACF,OAAI,CAAC,SAAS,GACZ,UACE,oBACA,oDAAoD,SAAS,OAAO,IACrE;AAEH,SAAM,SAAS,aAAa,CAAC,YAAY,KAAA,EAAU;IACnD;UACK,OAAO;AACd,WACE,uBACA,4GACA,MACD;;;;;;;;;;AA2BL,MAAa,uBAAuB,OAClC,SACA,YACA,sBACqC;CACrC,MAAM,MAAM,GAAG,aAAa,4BAA4B,aAAa,mBACnE,kBACD;AACD,KAAI;AACF,SAAO,MAAM,YAAY,yBAAyB,OAAO,WAAW;GAClE,MAAM,WAAW,MAAM,QAAQ,KAAK;IAClC,SAAS,GACN,kCAAA,KACF;IACD;IACD,CAAC;AACF,OAAI,CAAC,SAAS,IAAI;AAChB,UAAM,SAAS,aAAa,CAAC,YAAY,KAAA,EAAU;AAInD,QAAI,SAAS,UAAU,KAAK;AAC1B,cACE,8BACA,uEAAuE,SAAS,OAAO,qCACxF;AACD,YAAO,EAAE,SAAS,eAAe;;AAEnC,aACE,8BACA,8EAA8E,SAAS,OAAO,wCAC/F;AACD,WAAO,EAAE,SAAS,eAAe;;GAEnC,MAAM,OAAQ,MAAM,SAAS,MAAM;AACnC,OAAI,KAAK,UAAU,MAAM;AACvB,aACE,0BACA,iGACD;AACD,WAAO,EAAE,SAAS,eAAe;;AAEnC,UAAO;IACL,SAAS;IACT,eACE,OAAO,KAAK,kBAAkB,WAC1B,KAAK,gBACL,KAAA;IACP;IACD;UACK,OAAO;AACd,WACE,8BACA,mFACA,MACD;AACD,SAAO,EAAE,SAAS,eAAe;;;;;;;;AASrC,MAAa,0BAA0B,OACrC,SACA,YACA,YACqD;AACrD,KAAI;AACF,SAAO,MAAM,YAAY,mBAAmB,OAAO,WAAW;GAC5D,MAAM,WAAW,MAAM,QACrB,GAAG,aAAa,sCAChB;IACE,QAAQ;IACR,SAAS;KACP,gBAAgB;MACf,kCAAA;KACF;IACD,MAAM,KAAK,UAAU,QAAQ;IAC7B;IACD,CACF;AACD,OAAI,CAAC,SAAS,IAAI;AAChB,aACE,0BACA,0DAA0D,SAAS,OAAO,IAC3E;AACD,UAAM,SAAS,aAAa,CAAC,YAAY,KAAA,EAAU;AACnD;;AAEF,UAAQ,MAAM,SAAS,MAAM;IAC7B;UACK,OAAO;AACd,WACE,6BACA,yFACA,MACD;AACD;;;;;;;;;;;;;AAcJ,MAAM,cAAc,OAClB,IACA,SACe;CACf,MAAM,aAAa,IAAI,iBAAiB;CACxC,MAAM,QAAQ,iBAAiB,WAAW,OAAO,EAAE,GAAG;AACtD,KAAI;AACF,SAAO,MAAM,KAAK,WAAW,OAAO;WAC5B;AACR,eAAa,MAAM","debugId":"89e7f190-1d19-5653-b03e-60d14140c4fc"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alwaysmeticulous/backend-recorder-workerd",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.323.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Meticulous backend recorder shim for Cloudflare Workers (workerd). Reports inbound and outbound HTTP capture events to a local Meticulous recorder sidecar during development recording.",
|
|
6
6
|
"license": "ISC",
|