@alwaysmeticulous/backend-recorder-workerd 2.308.0 → 2.319.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/dist/binding-patch.js +139 -0
- package/dist/binding-patch.js.map +1 -0
- package/dist/body-capture.d.ts +10 -1
- package/dist/body-capture.js +22 -2
- package/dist/body-capture.js.map +1 -1
- package/dist/context.d.ts +1 -0
- package/dist/context.js +4 -1
- package/dist/context.js.map +1 -1
- package/dist/fetch-patch.js +19 -102
- package/dist/fetch-patch.js.map +1 -1
- package/dist/index.d.ts +32 -12
- package/dist/index.js +0 -0
- package/dist/index.js.map +1 -1
- package/dist/log.js +4 -1
- package/dist/log.js.map +1 -1
- package/dist/original-fetch.js +22 -0
- package/dist/original-fetch.js.map +1 -0
- package/dist/{fetch-patch.d.ts → outbound-capture.d.ts} +2 -2
- package/dist/outbound-capture.js +127 -0
- package/dist/outbound-capture.js.map +1 -0
- package/dist/protocol.d.ts +76 -2
- package/dist/protocol.js +21 -2
- package/dist/protocol.js.map +1 -1
- package/dist/redact-body.d.ts +19 -0
- package/dist/redact-body.js +53 -0
- package/dist/redact-body.js.map +1 -0
- package/dist/replay-fetch.js +81 -0
- package/dist/replay-fetch.js.map +1 -0
- package/dist/replay-sidecar-url.js +62 -0
- package/dist/replay-sidecar-url.js.map +1 -0
- package/dist/sidecar-client.js +85 -3
- package/dist/sidecar-client.js.map +1 -1
- package/dist/virtual-clock.js +122 -0
- package/dist/virtual-clock.js.map +1 -0
- package/package.json +14 -14
package/dist/sidecar-client.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
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]="81b6dfa3-bea2-53e3-a6be-f23b3b050e4c")}catch(e){}}();
|
|
1
3
|
import { warnOnce } from "./log.js";
|
|
2
|
-
import { SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION_HEADER } from "./protocol.js";
|
|
4
|
+
import { SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH } from "./protocol.js";
|
|
3
5
|
//#region src/sidecar-client.ts
|
|
6
|
+
/**
|
|
7
|
+
* Replay calls sit in the request path, unlike record mode's fire-and-forget reporting, so
|
|
8
|
+
* they must be bounded: without a timeout a wedged sidecar would hang every outbound fetch
|
|
9
|
+
* the app makes, and therefore the whole replay, with nothing to look at.
|
|
10
|
+
*/
|
|
11
|
+
const SESSION_INFO_TIMEOUT_MS = 1e4;
|
|
12
|
+
const LOOKUP_TIMEOUT_MS = 5e3;
|
|
4
13
|
/** POSTs capture events to the sidecar. Never rejects — reporting failures only warn (once). */
|
|
5
14
|
const postCaptureEvents = async (fetchFn, sidecarUrl, events) => {
|
|
6
15
|
try {
|
|
@@ -19,7 +28,80 @@ const postCaptureEvents = async (fetchFn, sidecarUrl, events) => {
|
|
|
19
28
|
warnOnce("sidecar-unreachable", "Could not reach the Meticulous backend recorder sidecar — backend session events are not being recorded.", error);
|
|
20
29
|
}
|
|
21
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* Asks whether the sidecar holds mocks for a session, and at what instant to freeze the
|
|
33
|
+
* clock while serving it.
|
|
34
|
+
*
|
|
35
|
+
* A 404 is how a record-only sidecar answers, so it counts as a settled `unavailable` and
|
|
36
|
+
* the shim degrades to pass-through against an older sidecar rather than failing.
|
|
37
|
+
*/
|
|
38
|
+
const getReplaySessionInfo = async (fetchFn, sidecarUrl, frontendSessionId) => {
|
|
39
|
+
const url = `${sidecarUrl}${SIDECAR_REPLAY_SESSION_PATH}?sessionId=${encodeURIComponent(frontendSessionId)}`;
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetchFn(url, {
|
|
42
|
+
headers: { [SIDECAR_PROTOCOL_VERSION_HEADER]: "1" },
|
|
43
|
+
signal: timeoutSignal(SESSION_INFO_TIMEOUT_MS)
|
|
44
|
+
});
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
await response.arrayBuffer().catch(() => void 0);
|
|
47
|
+
if (response.status >= 500) {
|
|
48
|
+
warnOnce("replay-session-unreachable", `Meticulous replay sidecar failed the replay session handshake (HTTP ${response.status}) — will retry on the next request.`);
|
|
49
|
+
return { outcome: "unreachable" };
|
|
50
|
+
}
|
|
51
|
+
warnOnce("replay-session-unavailable", `Meticulous replay sidecar did not recognise the replay session route (HTTP ${response.status}) — outbound calls will not be mocked.`);
|
|
52
|
+
return { outcome: "unavailable" };
|
|
53
|
+
}
|
|
54
|
+
const info = await response.json();
|
|
55
|
+
if (info.found !== true) {
|
|
56
|
+
warnOnce("replay-session-unknown", "Meticulous replay sidecar holds no mocks for this session — outbound calls will not be mocked.");
|
|
57
|
+
return { outcome: "unavailable" };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
outcome: "found",
|
|
61
|
+
clockAnchorMs: typeof info.clockAnchorMs === "number" ? info.clockAnchorMs : void 0
|
|
62
|
+
};
|
|
63
|
+
} catch (error) {
|
|
64
|
+
warnOnce("replay-session-unreachable", "Could not reach the Meticulous replay sidecar — will retry on the next request.", error);
|
|
65
|
+
return { outcome: "unreachable" };
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Looks up a recorded response for one outbound call. Never rejects; returns undefined when
|
|
70
|
+
* the sidecar could not be consulted, which callers treat the same as a miss (let the real
|
|
71
|
+
* call through).
|
|
72
|
+
*/
|
|
73
|
+
const postOutboundFetchLookup = async (fetchFn, sidecarUrl, payload) => {
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetchFn(`${sidecarUrl}${SIDECAR_REPLAY_OUTBOUND_FETCH_PATH}`, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: {
|
|
78
|
+
"content-type": "application/json",
|
|
79
|
+
[SIDECAR_PROTOCOL_VERSION_HEADER]: "1"
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify(payload),
|
|
82
|
+
signal: timeoutSignal(LOOKUP_TIMEOUT_MS)
|
|
83
|
+
});
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
warnOnce("replay-lookup-rejected", `Meticulous replay sidecar rejected a mock lookup (HTTP ${response.status}).`);
|
|
86
|
+
await response.arrayBuffer().catch(() => void 0);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
return await response.json();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
warnOnce("replay-lookup-unreachable", "Could not reach the Meticulous replay sidecar for a mock lookup — letting the real call through.", error);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
/** AbortSignal.timeout, or undefined where it is unavailable. */
|
|
96
|
+
const timeoutSignal = (ms) => {
|
|
97
|
+
try {
|
|
98
|
+
return AbortSignal.timeout(ms);
|
|
99
|
+
} catch {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
22
103
|
//#endregion
|
|
23
|
-
export { postCaptureEvents };
|
|
104
|
+
export { getReplaySessionInfo, postCaptureEvents, postOutboundFetchLookup };
|
|
24
105
|
|
|
25
|
-
//# sourceMappingURL=sidecar-client.js.map
|
|
106
|
+
//# sourceMappingURL=sidecar-client.js.map
|
|
107
|
+
//# debugId=81b6dfa3-bea2-53e3-a6be-f23b3b050e4c
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sidecar-client.js","
|
|
1
|
+
{"version":3,"file":"sidecar-client.js","sources":["../src/sidecar-client.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\nimport {\n type CaptureEvent,\n type CaptureEventsPayload,\n type OutboundFetchLookupRequest,\n type OutboundFetchLookupResponse,\n type ReplaySessionInfoResponse,\n SIDECAR_EVENTS_PATH,\n SIDECAR_PROTOCOL_VERSION,\n SIDECAR_PROTOCOL_VERSION_HEADER,\n SIDECAR_REPLAY_OUTBOUND_FETCH_PATH,\n SIDECAR_REPLAY_SESSION_PATH,\n} from \"./protocol\";\n\ntype FetchFn = typeof globalThis.fetch;\n\n/**\n * Replay calls sit in the request path, unlike record mode's fire-and-forget reporting, so\n * they must be bounded: without a timeout a wedged sidecar would hang every outbound fetch\n * the app makes, and therefore the whole replay, with nothing to look at.\n */\nconst SESSION_INFO_TIMEOUT_MS = 10_000;\nconst LOOKUP_TIMEOUT_MS = 5_000;\n\n/** POSTs capture events to the sidecar. Never rejects — reporting failures only warn (once). */\nexport const postCaptureEvents = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n events: CaptureEvent[],\n): Promise<void> => {\n try {\n const payload: CaptureEventsPayload = { events };\n const response = await fetchFn(`${sidecarUrl}${SIDECAR_EVENTS_PATH}`, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n body: JSON.stringify(payload),\n });\n if (!response.ok) {\n warnOnce(\n \"sidecar-rejected\",\n `Meticulous sidecar rejected capture events (HTTP ${response.status}).`,\n );\n }\n await response.arrayBuffer().catch(() => undefined);\n } catch (error) {\n warnOnce(\n \"sidecar-unreachable\",\n \"Could not reach the Meticulous backend recorder sidecar — backend session events are not being recorded.\",\n error,\n );\n }\n};\n\n/**\n * Outcome of the session handshake.\n *\n * `unavailable` and `unreachable` both mean \"do not replay\", but they are kept apart\n * because only the first is a settled answer. The caller caches per isolate, and caching a\n * one-off network blip would disable mocking for the rest of the session — silently sending\n * every later outbound call to the real service.\n */\nexport type ReplaySessionInfoResult =\n /** The sidecar holds mocks for this session. */\n | { outcome: \"found\"; clockAnchorMs: number | undefined }\n /** The sidecar answered and will never serve this session: no mocks, or no such route. */\n | { outcome: \"unavailable\" }\n /** Could not get an answer — timeout, transport error, 5xx. May succeed later. */\n | { outcome: \"unreachable\" };\n\n/**\n * Asks whether the sidecar holds mocks for a session, and at what instant to freeze the\n * clock while serving it.\n *\n * A 404 is how a record-only sidecar answers, so it counts as a settled `unavailable` and\n * the shim degrades to pass-through against an older sidecar rather than failing.\n */\nexport const getReplaySessionInfo = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n frontendSessionId: string,\n): Promise<ReplaySessionInfoResult> => {\n const url = `${sidecarUrl}${SIDECAR_REPLAY_SESSION_PATH}?sessionId=${encodeURIComponent(\n frontendSessionId,\n )}`;\n try {\n const response = await fetchFn(url, {\n headers: {\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n signal: timeoutSignal(SESSION_INFO_TIMEOUT_MS),\n });\n if (!response.ok) {\n await response.arrayBuffer().catch(() => undefined);\n // 4xx is the sidecar telling us something definitive — no such route (a record-only\n // sidecar), or a protocol version it will not speak. Retrying cannot change either.\n // 5xx may well be a sidecar still finding its feet, so leave it retryable.\n if (response.status >= 500) {\n warnOnce(\n \"replay-session-unreachable\",\n `Meticulous replay sidecar failed the replay session handshake (HTTP ${response.status}) — will retry on the next request.`,\n );\n return { outcome: \"unreachable\" };\n }\n warnOnce(\n \"replay-session-unavailable\",\n `Meticulous replay sidecar did not recognise the replay session route (HTTP ${response.status}) — outbound calls will not be mocked.`,\n );\n return { outcome: \"unavailable\" };\n }\n const info = (await response.json()) as ReplaySessionInfoResponse;\n if (info.found !== true) {\n warnOnce(\n \"replay-session-unknown\",\n \"Meticulous replay sidecar holds no mocks for this session — outbound calls will not be mocked.\",\n );\n return { outcome: \"unavailable\" };\n }\n return {\n outcome: \"found\",\n clockAnchorMs:\n typeof info.clockAnchorMs === \"number\" ? info.clockAnchorMs : undefined,\n };\n } catch (error) {\n warnOnce(\n \"replay-session-unreachable\",\n \"Could not reach the Meticulous replay sidecar — will retry on the next request.\",\n error,\n );\n return { outcome: \"unreachable\" };\n }\n};\n\n/**\n * Looks up a recorded response for one outbound call. Never rejects; returns undefined when\n * the sidecar could not be consulted, which callers treat the same as a miss (let the real\n * call through).\n */\nexport const postOutboundFetchLookup = async (\n fetchFn: FetchFn,\n sidecarUrl: string,\n payload: OutboundFetchLookupRequest,\n): Promise<OutboundFetchLookupResponse | undefined> => {\n try {\n const response = await fetchFn(\n `${sidecarUrl}${SIDECAR_REPLAY_OUTBOUND_FETCH_PATH}`,\n {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n [SIDECAR_PROTOCOL_VERSION_HEADER]: SIDECAR_PROTOCOL_VERSION,\n },\n body: JSON.stringify(payload),\n signal: timeoutSignal(LOOKUP_TIMEOUT_MS),\n },\n );\n if (!response.ok) {\n warnOnce(\n \"replay-lookup-rejected\",\n `Meticulous replay sidecar rejected a mock lookup (HTTP ${response.status}).`,\n );\n await response.arrayBuffer().catch(() => undefined);\n return undefined;\n }\n return (await response.json()) as OutboundFetchLookupResponse;\n } catch (error) {\n warnOnce(\n \"replay-lookup-unreachable\",\n \"Could not reach the Meticulous replay sidecar for a mock lookup — letting the real call through.\",\n error,\n );\n return undefined;\n }\n};\n\n/** AbortSignal.timeout, or undefined where it is unavailable. */\nconst timeoutSignal = (ms: number): AbortSignal | undefined => {\n try {\n return AbortSignal.timeout(ms);\n } catch {\n return undefined;\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;AAqBA,MAAM,0BAA0B;AAChC,MAAM,oBAAoB;;AAG1B,MAAa,oBAAoB,OAC/B,SACA,YACA,WACkB;AAClB,KAAI;EACF,MAAM,UAAgC,EAAE,QAAQ;EAChD,MAAM,WAAW,MAAM,QAAQ,GAAG,aAAa,uBAAuB;GACpE,QAAQ;GACR,SAAS;IACP,gBAAgB;KACf,kCAAA;IACF;GACD,MAAM,KAAK,UAAU,QAAQ;GAC9B,CAAC;AACF,MAAI,CAAC,SAAS,GACZ,UACE,oBACA,oDAAoD,SAAS,OAAO,IACrE;AAEH,QAAM,SAAS,aAAa,CAAC,YAAY,KAAA,EAAU;UAC5C,OAAO;AACd,WACE,uBACA,4GACA,MACD;;;;;;;;;;AA2BL,MAAa,uBAAuB,OAClC,SACA,YACA,sBACqC;CACrC,MAAM,MAAM,GAAG,aAAa,4BAA4B,aAAa,mBACnE,kBACD;AACD,KAAI;EACF,MAAM,WAAW,MAAM,QAAQ,KAAK;GAClC,SAAS,GACN,kCAAA,KACF;GACD,QAAQ,cAAc,wBAAwB;GAC/C,CAAC;AACF,MAAI,CAAC,SAAS,IAAI;AAChB,SAAM,SAAS,aAAa,CAAC,YAAY,KAAA,EAAU;AAInD,OAAI,SAAS,UAAU,KAAK;AAC1B,aACE,8BACA,uEAAuE,SAAS,OAAO,qCACxF;AACD,WAAO,EAAE,SAAS,eAAe;;AAEnC,YACE,8BACA,8EAA8E,SAAS,OAAO,wCAC/F;AACD,UAAO,EAAE,SAAS,eAAe;;EAEnC,MAAM,OAAQ,MAAM,SAAS,MAAM;AACnC,MAAI,KAAK,UAAU,MAAM;AACvB,YACE,0BACA,iGACD;AACD,UAAO,EAAE,SAAS,eAAe;;AAEnC,SAAO;GACL,SAAS;GACT,eACE,OAAO,KAAK,kBAAkB,WAAW,KAAK,gBAAgB,KAAA;GACjE;UACM,OAAO;AACd,WACE,8BACA,mFACA,MACD;AACD,SAAO,EAAE,SAAS,eAAe;;;;;;;;AASrC,MAAa,0BAA0B,OACrC,SACA,YACA,YACqD;AACrD,KAAI;EACF,MAAM,WAAW,MAAM,QACrB,GAAG,aAAa,sCAChB;GACE,QAAQ;GACR,SAAS;IACP,gBAAgB;KACf,kCAAA;IACF;GACD,MAAM,KAAK,UAAU,QAAQ;GAC7B,QAAQ,cAAc,kBAAkB;GACzC,CACF;AACD,MAAI,CAAC,SAAS,IAAI;AAChB,YACE,0BACA,0DAA0D,SAAS,OAAO,IAC3E;AACD,SAAM,SAAS,aAAa,CAAC,YAAY,KAAA,EAAU;AACnD;;AAEF,SAAQ,MAAM,SAAS,MAAM;UACtB,OAAO;AACd,WACE,6BACA,oGACA,MACD;AACD;;;;AAKJ,MAAM,iBAAiB,OAAwC;AAC7D,KAAI;AACF,SAAO,YAAY,QAAQ,GAAG;SACxB;AACN","debugId":"81b6dfa3-bea2-53e3-a6be-f23b3b050e4c"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="ef892b9a-fcab-5f5d-baed-592098a17dd5")}catch(e){}}();
|
|
3
|
+
import { requestCaptureContext } from "./context.js";
|
|
4
|
+
import { warnOnce } from "./log.js";
|
|
5
|
+
//#region src/virtual-clock.ts
|
|
6
|
+
/**
|
|
7
|
+
* Freezes the worker's wall clock at the replayed session's last recorded activity.
|
|
8
|
+
*
|
|
9
|
+
* Without this, replay fails even when every outbound call is mocked correctly: credentials
|
|
10
|
+
* minted during the recording (Firebase session cookies, OAuth access tokens) carry
|
|
11
|
+
* short expiries, so by replay time the app rejects its own recorded credentials as stale.
|
|
12
|
+
* Anchoring at the *end* of the recording means everything minted mid-session is already
|
|
13
|
+
* issued but not yet expired.
|
|
14
|
+
*
|
|
15
|
+
* Ported from the Node backend recorder's replay clock, with two differences:
|
|
16
|
+
* - the anchor comes from the request context rather than a session→anchor map, because
|
|
17
|
+
* workerd's AsyncLocalStorage propagates reliably from the inbound handler. The Node
|
|
18
|
+
* version needs a process-wide "most recently seen session" fallback, which can
|
|
19
|
+
* resolve the wrong session on a replica serving several at once;
|
|
20
|
+
* - the busy-wait breaker schedules on `setTimeout` — workerd has no `setImmediate`.
|
|
21
|
+
*
|
|
22
|
+
* Only `Date` is patched, matching the Node implementation's scope (no `Intl`, no
|
|
23
|
+
* `Temporal`, no timer virtualisation).
|
|
24
|
+
*/
|
|
25
|
+
const OriginalDate = globalThis.Date;
|
|
26
|
+
const BREAK_AFTER = 2e4;
|
|
27
|
+
let lastLoopBreakBaseMs = 0;
|
|
28
|
+
let sameKeyCount = 0;
|
|
29
|
+
let loopBreakResetScheduled = false;
|
|
30
|
+
const applyLoopBreak = (baseMs) => {
|
|
31
|
+
if (!loopBreakResetScheduled) {
|
|
32
|
+
loopBreakResetScheduled = true;
|
|
33
|
+
try {
|
|
34
|
+
setTimeout(() => {
|
|
35
|
+
sameKeyCount = 0;
|
|
36
|
+
loopBreakResetScheduled = false;
|
|
37
|
+
}, 0);
|
|
38
|
+
} catch {
|
|
39
|
+
loopBreakResetScheduled = false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (baseMs === lastLoopBreakBaseMs) sameKeyCount++;
|
|
43
|
+
else {
|
|
44
|
+
lastLoopBreakBaseMs = baseMs;
|
|
45
|
+
sameKeyCount = 0;
|
|
46
|
+
}
|
|
47
|
+
return sameKeyCount <= BREAK_AFTER ? baseMs : baseMs + (sameKeyCount - BREAK_AFTER);
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The virtual "now" for the request currently executing, or null when it is not a replay or
|
|
51
|
+
* carries no anchor — in which case callers use the real clock. Reads outside any request
|
|
52
|
+
* (module evaluation, background work) always get the real clock.
|
|
53
|
+
*/
|
|
54
|
+
const resolveVirtualNow = () => {
|
|
55
|
+
const ctx = requestCaptureContext.getStore();
|
|
56
|
+
if (ctx === void 0 || ctx.mode !== "replay") return null;
|
|
57
|
+
return ctx.clockAnchorMs === void 0 ? null : applyLoopBreak(ctx.clockAnchorMs);
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* What `Date.now()` reports. A standalone function rather than only a class static, so the
|
|
61
|
+
* callable shim below can reuse it without referencing an unbound method — it touches no
|
|
62
|
+
* `this`, so there is nothing to lose.
|
|
63
|
+
*/
|
|
64
|
+
const virtualDateNow = () => {
|
|
65
|
+
const virtualNow = resolveVirtualNow();
|
|
66
|
+
return virtualNow === null ? OriginalDate.now() : virtualNow;
|
|
67
|
+
};
|
|
68
|
+
var VirtualDateClass = class extends OriginalDate {
|
|
69
|
+
constructor(...args) {
|
|
70
|
+
if (args.length > 0) {
|
|
71
|
+
super(...args);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const virtualNow = resolveVirtualNow();
|
|
75
|
+
if (virtualNow === null) {
|
|
76
|
+
super();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
super(virtualNow);
|
|
80
|
+
}
|
|
81
|
+
static now() {
|
|
82
|
+
return virtualDateNow();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
function VirtualDate(...args) {
|
|
86
|
+
if (!(this instanceof VirtualDate)) return new VirtualDateClass().toString();
|
|
87
|
+
return new VirtualDateClass(...args);
|
|
88
|
+
}
|
|
89
|
+
VirtualDate.now = virtualDateNow;
|
|
90
|
+
VirtualDate.parse = OriginalDate.parse;
|
|
91
|
+
VirtualDate.UTC = OriginalDate.UTC;
|
|
92
|
+
Object.defineProperty(VirtualDate, "prototype", {
|
|
93
|
+
value: VirtualDateClass.prototype,
|
|
94
|
+
writable: false,
|
|
95
|
+
configurable: false
|
|
96
|
+
});
|
|
97
|
+
VirtualDateClass.prototype.constructor = VirtualDate;
|
|
98
|
+
let installed = false;
|
|
99
|
+
/**
|
|
100
|
+
* Replaces the global `Date` with the per-request virtual clock. Idempotent, and a no-op
|
|
101
|
+
* failure: a worker that will not let its `Date` be replaced still replays, just without
|
|
102
|
+
* clock virtualisation.
|
|
103
|
+
*
|
|
104
|
+
* Called lazily from the replay branch of the request wrapper rather than at module scope:
|
|
105
|
+
* record mode and deployed production workers then pay nothing, and the package declares no
|
|
106
|
+
* `sideEffects`, so a module-scope side effect could legitimately be tree-shaken away by a
|
|
107
|
+
* customer's bundler.
|
|
108
|
+
*/
|
|
109
|
+
const installVirtualClock = () => {
|
|
110
|
+
if (installed) return;
|
|
111
|
+
installed = true;
|
|
112
|
+
try {
|
|
113
|
+
globalThis.Date = VirtualDate;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
warnOnce("virtual-clock", "Could not install the Meticulous replay clock — recorded credentials may be treated as expired.", error);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
//#endregion
|
|
119
|
+
export { installVirtualClock };
|
|
120
|
+
|
|
121
|
+
//# sourceMappingURL=virtual-clock.js.map
|
|
122
|
+
//# debugId=ef892b9a-fcab-5f5d-baed-592098a17dd5
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"virtual-clock.js","sources":["../src/virtual-clock.ts"],"sourcesContent":["import { requestCaptureContext } from \"./context\";\nimport { warnOnce } from \"./log\";\n\n/**\n * Freezes the worker's wall clock at the replayed session's last recorded activity.\n *\n * Without this, replay fails even when every outbound call is mocked correctly: credentials\n * minted during the recording (Firebase session cookies, OAuth access tokens) carry\n * short expiries, so by replay time the app rejects its own recorded credentials as stale.\n * Anchoring at the *end* of the recording means everything minted mid-session is already\n * issued but not yet expired.\n *\n * Ported from the Node backend recorder's replay clock, with two differences:\n * - the anchor comes from the request context rather than a session→anchor map, because\n * workerd's AsyncLocalStorage propagates reliably from the inbound handler. The Node\n * version needs a process-wide \"most recently seen session\" fallback, which can\n * resolve the wrong session on a replica serving several at once;\n * - the busy-wait breaker schedules on `setTimeout` — workerd has no `setImmediate`.\n *\n * Only `Date` is patched, matching the Node implementation's scope (no `Intl`, no\n * `Temporal`, no timer virtualisation).\n */\n\nconst OriginalDate = globalThis.Date;\n\n/** Real wall clock, immune to the patch below. */\nexport const nativeDateNow = (): number => OriginalDate.now();\n\n// ---------------------------------------------------------------------------\n// Busy-wait loop breaker\n// ---------------------------------------------------------------------------\n\n// A frozen clock makes `while (Date.now() < deadline) {}` spin forever. Count reads that\n// return the same frozen value and, past the threshold, nudge the result forward 1ms per\n// extra read until the loop's deadline is met. The nudge is a pure function of the repeat\n// count, so a base and head replay break an identical loop identically.\nconst BREAK_AFTER = 20_000;\n\nlet lastLoopBreakBaseMs = 0;\nlet sameKeyCount = 0;\nlet loopBreakResetScheduled = false;\n\nconst applyLoopBreak = (baseMs: number): number => {\n // Reset at the end of the current macrotask. A synchronous busy-wait blocks the loop so\n // this cannot fire, letting the counter accumulate until it breaks the spin; once the\n // turn yields, the reset runs and the next turn starts fresh.\n if (!loopBreakResetScheduled) {\n loopBreakResetScheduled = true;\n try {\n setTimeout(() => {\n sameKeyCount = 0;\n loopBreakResetScheduled = false;\n }, 0);\n } catch {\n // Timers throw outside a request context in workerd. Nothing to reset in that case,\n // since clock reads there fall through to the real clock anyway.\n loopBreakResetScheduled = false;\n }\n }\n\n if (baseMs === lastLoopBreakBaseMs) {\n sameKeyCount++;\n } else {\n lastLoopBreakBaseMs = baseMs;\n sameKeyCount = 0;\n }\n return sameKeyCount <= BREAK_AFTER\n ? baseMs\n : baseMs + (sameKeyCount - BREAK_AFTER);\n};\n\n// ---------------------------------------------------------------------------\n// Resolver\n// ---------------------------------------------------------------------------\n\n/**\n * The virtual \"now\" for the request currently executing, or null when it is not a replay or\n * carries no anchor — in which case callers use the real clock. Reads outside any request\n * (module evaluation, background work) always get the real clock.\n */\nconst resolveVirtualNow = (): number | null => {\n const ctx = requestCaptureContext.getStore();\n if (ctx === undefined || ctx.mode !== \"replay\") {\n return null;\n }\n return ctx.clockAnchorMs === undefined\n ? null\n : applyLoopBreak(ctx.clockAnchorMs);\n};\n\n// ---------------------------------------------------------------------------\n// Date shim\n// ---------------------------------------------------------------------------\n\n/**\n * What `Date.now()` reports. A standalone function rather than only a class static, so the\n * callable shim below can reuse it without referencing an unbound method — it touches no\n * `this`, so there is nothing to lose.\n */\nconst virtualDateNow = (): number => {\n const virtualNow = resolveVirtualNow();\n return virtualNow === null ? OriginalDate.now() : virtualNow;\n};\n\n// `Date` is heavily overloaded; capture args loosely and forward every explicit form\n// unchanged. Only the zero-arg form reads \"now\" and is virtualized.\nclass VirtualDateClass extends OriginalDate {\n constructor(...args: unknown[]) {\n if (args.length > 0) {\n super(...(args as ConstructorParameters<typeof OriginalDate>));\n return;\n }\n const virtualNow = resolveVirtualNow();\n if (virtualNow === null) {\n super();\n return;\n }\n super(virtualNow);\n }\n\n static override now(): number {\n return virtualDateNow();\n }\n}\n\n// Classes cannot be called without `new`, but native `Date()` can be (returning a string),\n// so wrap the class in a plain function supporting both.\nfunction VirtualDate(this: unknown, ...args: unknown[]): Date | string {\n if (!(this instanceof VirtualDate)) {\n return new VirtualDateClass().toString();\n }\n return new VirtualDateClass(...args);\n}\n\nVirtualDate.now = virtualDateNow;\nVirtualDate.parse = OriginalDate.parse;\nVirtualDate.UTC = OriginalDate.UTC;\n\n// Share the prototype so `new Date() instanceof Date` holds and every Date.prototype\n// method resolves on instances.\nObject.defineProperty(VirtualDate, \"prototype\", {\n value: VirtualDateClass.prototype,\n writable: false,\n configurable: false,\n});\n(VirtualDateClass.prototype as { constructor: unknown }).constructor =\n VirtualDate;\n\n// ---------------------------------------------------------------------------\n// Install\n// ---------------------------------------------------------------------------\n\nlet installed = false;\n\n/**\n * Replaces the global `Date` with the per-request virtual clock. Idempotent, and a no-op\n * failure: a worker that will not let its `Date` be replaced still replays, just without\n * clock virtualisation.\n *\n * Called lazily from the replay branch of the request wrapper rather than at module scope:\n * record mode and deployed production workers then pay nothing, and the package declares no\n * `sideEffects`, so a module-scope side effect could legitimately be tree-shaken away by a\n * customer's bundler.\n */\nexport const installVirtualClock = (): void => {\n if (installed) {\n return;\n }\n installed = true;\n try {\n globalThis.Date = VirtualDate as unknown as DateConstructor;\n } catch (error) {\n warnOnce(\n \"virtual-clock\",\n \"Could not install the Meticulous replay clock — recorded credentials may be treated as expired.\",\n error,\n );\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAuBA,MAAM,eAAe,WAAW;AAahC,MAAM,cAAc;AAEpB,IAAI,sBAAsB;AAC1B,IAAI,eAAe;AACnB,IAAI,0BAA0B;AAE9B,MAAM,kBAAkB,WAA2B;AAIjD,KAAI,CAAC,yBAAyB;AAC5B,4BAA0B;AAC1B,MAAI;AACF,oBAAiB;AACf,mBAAe;AACf,8BAA0B;MACzB,EAAE;UACC;AAGN,6BAA0B;;;AAI9B,KAAI,WAAW,oBACb;MACK;AACL,wBAAsB;AACtB,iBAAe;;AAEjB,QAAO,gBAAgB,cACnB,SACA,UAAU,eAAe;;;;;;;AAY/B,MAAM,0BAAyC;CAC7C,MAAM,MAAM,sBAAsB,UAAU;AAC5C,KAAI,QAAQ,KAAA,KAAa,IAAI,SAAS,SACpC,QAAO;AAET,QAAO,IAAI,kBAAkB,KAAA,IACzB,OACA,eAAe,IAAI,cAAc;;;;;;;AAYvC,MAAM,uBAA+B;CACnC,MAAM,aAAa,mBAAmB;AACtC,QAAO,eAAe,OAAO,aAAa,KAAK,GAAG;;AAKpD,IAAM,mBAAN,cAA+B,aAAa;CAC1C,YAAY,GAAG,MAAiB;AAC9B,MAAI,KAAK,SAAS,GAAG;AACnB,SAAM,GAAI,KAAoD;AAC9D;;EAEF,MAAM,aAAa,mBAAmB;AACtC,MAAI,eAAe,MAAM;AACvB,UAAO;AACP;;AAEF,QAAM,WAAW;;CAGnB,OAAgB,MAAc;AAC5B,SAAO,gBAAgB;;;AAM3B,SAAS,YAA2B,GAAG,MAAgC;AACrE,KAAI,EAAE,gBAAgB,aACpB,QAAO,IAAI,kBAAkB,CAAC,UAAU;AAE1C,QAAO,IAAI,iBAAiB,GAAG,KAAK;;AAGtC,YAAY,MAAM;AAClB,YAAY,QAAQ,aAAa;AACjC,YAAY,MAAM,aAAa;AAI/B,OAAO,eAAe,aAAa,aAAa;CAC9C,OAAO,iBAAiB;CACxB,UAAU;CACV,cAAc;CACf,CAAC;AACF,iBAAkB,UAAuC,cACvD;AAMF,IAAI,YAAY;;;;;;;;;;;AAYhB,MAAa,4BAAkC;AAC7C,KAAI,UACF;AAEF,aAAY;AACZ,KAAI;AACF,aAAW,OAAO;UACX,OAAO;AACd,WACE,iBACA,mGACA,MACD","debugId":"ef892b9a-fcab-5f5d-baed-592098a17dd5"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alwaysmeticulous/backend-recorder-workerd",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.319.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",
|
|
@@ -20,21 +20,10 @@
|
|
|
20
20
|
"workerd",
|
|
21
21
|
"recorder"
|
|
22
22
|
],
|
|
23
|
-
"scripts": {
|
|
24
|
-
"clean": "rimraf dist tsconfig.tsbuildinfo",
|
|
25
|
-
"build": "tsdown",
|
|
26
|
-
"dev": "tsdown --watch",
|
|
27
|
-
"format": "prettier --write src",
|
|
28
|
-
"lint": "oxlint --type-aware src",
|
|
29
|
-
"lint:commit": "eslint --cache $(git diff --relative --name-only --diff-filter=ACMRTUXB master | grep -E \"(.js$|.ts$|.tsx$)\")",
|
|
30
|
-
"lint:fix": "oxlint --type-aware src --fix",
|
|
31
|
-
"depcheck": "depcheck --ignore-patterns=dist",
|
|
32
|
-
"test": "vitest run --passWithNoTests"
|
|
33
|
-
},
|
|
34
23
|
"devDependencies": {
|
|
35
24
|
"@types/node": "^20.0.0",
|
|
36
25
|
"tsdown": "^0.21.7",
|
|
37
|
-
"vitest": "
|
|
26
|
+
"vitest": "^4.1.7"
|
|
38
27
|
},
|
|
39
28
|
"author": {
|
|
40
29
|
"name": "The Meticulous Team",
|
|
@@ -52,5 +41,16 @@
|
|
|
52
41
|
},
|
|
53
42
|
"bugs": {
|
|
54
43
|
"url": "https://github.com/alwaysmeticulous/meticulous-sdk/issues"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"clean": "rimraf dist tsconfig.tsbuildinfo",
|
|
47
|
+
"build": "tsdown",
|
|
48
|
+
"dev": "tsdown --watch",
|
|
49
|
+
"format": "prettier --write src",
|
|
50
|
+
"lint": "oxlint --type-aware src",
|
|
51
|
+
"lint:commit": "eslint --cache $(git diff --relative --name-only --diff-filter=ACMRTUXB master | grep -E \"(.js$|.ts$|.tsx$)\")",
|
|
52
|
+
"lint:fix": "oxlint --type-aware src --fix",
|
|
53
|
+
"depcheck": "depcheck --ignore-patterns=dist",
|
|
54
|
+
"test": "vitest run --passWithNoTests"
|
|
55
55
|
}
|
|
56
|
-
}
|
|
56
|
+
}
|