@hexclave/tanstack-start 1.0.26 → 1.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/components/api-key-dialogs.d.ts +1 -1
  2. package/dist/components/elements/ssr-layout-effect.d.ts.map +1 -1
  3. package/dist/components/elements/ssr-layout-effect.js +5 -3
  4. package/dist/components/elements/ssr-layout-effect.js.map +1 -1
  5. package/dist/esm/components/api-key-dialogs.d.ts +1 -1
  6. package/dist/esm/components/elements/ssr-layout-effect.d.ts.map +1 -1
  7. package/dist/esm/components/elements/ssr-layout-effect.js +5 -3
  8. package/dist/esm/components/elements/ssr-layout-effect.js.map +1 -1
  9. package/dist/esm/generated/quetzal-translations.d.ts +2 -2
  10. package/dist/esm/lib/hexclave-app/apps/implementations/common.js +1 -1
  11. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.d.ts +1 -0
  12. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.d.ts.map +1 -1
  13. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.js +44 -19
  14. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.js.map +1 -1
  15. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.test.js +97 -8
  16. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.test.js.map +1 -1
  17. package/dist/generated/quetzal-translations.d.ts +2 -2
  18. package/dist/lib/hexclave-app/apps/implementations/common.js +1 -1
  19. package/dist/lib/hexclave-app/apps/implementations/session-replay.d.ts +1 -0
  20. package/dist/lib/hexclave-app/apps/implementations/session-replay.d.ts.map +1 -1
  21. package/dist/lib/hexclave-app/apps/implementations/session-replay.js +43 -18
  22. package/dist/lib/hexclave-app/apps/implementations/session-replay.js.map +1 -1
  23. package/dist/lib/hexclave-app/apps/implementations/session-replay.test.js +97 -8
  24. package/dist/lib/hexclave-app/apps/implementations/session-replay.test.js.map +1 -1
  25. package/package.json +3 -3
  26. package/src/components/elements/ssr-layout-effect.tsx +15 -3
  27. package/src/lib/hexclave-app/apps/implementations/session-replay.test.ts +123 -4
  28. package/src/lib/hexclave-app/apps/implementations/session-replay.ts +63 -29
@@ -14,7 +14,7 @@ import { envVars } from "../../../../generated/env.js";
14
14
  import { resolveHandlerUrls } from "../../url-targets.js";
15
15
 
16
16
  //#region src/lib/hexclave-app/apps/implementations/common.ts
17
- const clientVersion = "js @hexclave/tanstack-start@1.0.26";
17
+ const clientVersion = "js @hexclave/tanstack-start@1.0.27";
18
18
  if (clientVersion.startsWith("STACK_COMPILE_TIME")) throw new HexclaveAssertionError("Client version was not replaced. Something went wrong during build!");
19
19
  const replaceHexclavePortPrefix = (input) => {
20
20
  if (!input) return input;
@@ -90,6 +90,7 @@ declare class SessionRecorder {
90
90
  private _detachListeners;
91
91
  private _flushTimer;
92
92
  private _events;
93
+ private _eventSizes;
93
94
  private _approxBytes;
94
95
  private _lastPersistActivity;
95
96
  private _recording;
@@ -1 +1 @@
1
- {"version":3,"file":"session-replay.d.ts","names":[],"sources":["../../../../../../src/lib/hexclave-app/apps/implementations/session-replay.ts"],"mappings":";;;KAUY,sBAAA;;AAAZ;;;;EAME,OAAA;EAMA;;;;;EAAA,aAAA;EAiBU;;;;;;EAVV,UAAA,YAAsB,MAAA;EAqBU;;AAGlC;;;;EAjBE,aAAA;AAAA;AAAA,KAGU,gBAAA;EAcmG;;AAa/G;;;EArBE,OAAA;EAqB8C;;;;EAhB9C,OAAA,GAAU,sBAAA;AAAA;AAAA,iBAGI,uBAAA,CAAwB,gBAAA,EAAkB,gBAAA,eAA+B,sBAAA;;;;;;;iBAazE,sBAAA,CAAuB,OAAA,EAAS,gBAAA,eAA+B,gBAAA;AA+C/E;;;;AAAA,iBA7BgB,wBAAA,CAAyB,IAAA,EAAM,gBAAA,eAA+B,gBAAA;AAAA,KA6BlE,aAAA;EACV,UAAA;EACA,aAAA;EACA,gBAAA;AAAA;AAAA,iBAGc,sBAAA,CAAuB,GAAA,kBAAqB,aAAA;AAAA,iBAc5C,cAAA,CAAe,SAAA;AAAA,iBAKf,oBAAA,CAAqB,SAAA;AAAA,iBAIrB,YAAA,CAAA;AAAA,iBAIA,kBAAA,CAAmB,OAAA;EAAW,GAAA;EAAa,SAAA;EAAoB,KAAA;AAAA,IAAkB,aAAA;AAAA,KAiBrF,mBAAA;EACV,SAAA;EACA,SAAA,GAAY,IAAA,UAAc,OAAA;IAAW,SAAA;EAAA,MAAyB,OAAA,CAAQ,MAAA,CAAO,QAAA,EAAU,KAAA;AAAA;AAAA,iBAGzE,0BAAA,CAA2B,KAAA;;;;AAtB3C;;iBA+BgB,uBAAA,CAAwB,KAAA;AAAA,cAU3B,eAAA;EAAA,QACH,QAAA;EAAA,QACA,UAAA;EAAA,QACA,SAAA;EAAA,QACA,cAAA;EAAA,QACA,gBAAA;EAAA,QACA,WAAA;EAAA,QACA,OAAA;EAAA,QACA,YAAA;EAAA,QACA,oBAAA;EAAA,QACA,UAAA;EAAA,QACA,YAAA;EAAA,QACA,qBAAA;EAAA,QACA,eAAA;EAAA,QACA,gBAAA;EAAA,iBACS,uBAAA;EAAA,iBACA,WAAA;EAAA,iBAEA,iBAAA;EAAA,iBACA,KAAA;EAAA,iBACA,cAAA;cAEL,IAAA,EAAM,mBAAA,EAAqB,aAAA,EAAe,sBAAA;EA5C5B;;;EAuD1B,KAAA,CAAA;EAYA,IAAA,CAAA;EAWA,WAAA,CAAA;EAAA,QAKQ,gBAAA;EAAA,QASM,MAAA;EAAA,QAuDN,QAAA;EAAA,QAUM,eAAA;EAAA,QA6DN,qBAAA;EAAA,QAcA,KAAA;AAAA"}
1
+ {"version":3,"file":"session-replay.d.ts","names":[],"sources":["../../../../../../src/lib/hexclave-app/apps/implementations/session-replay.ts"],"mappings":";;;KAUY,sBAAA;;AAAZ;;;;EAME,OAAA;EAMA;;;;;EAAA,aAAA;EAiBU;;;;;;EAVV,UAAA,YAAsB,MAAA;EAqBU;;AAGlC;;;;EAjBE,aAAA;AAAA;AAAA,KAGU,gBAAA;EAcmG;;AAa/G;;;EArBE,OAAA;EAqB8C;;;;EAhB9C,OAAA,GAAU,sBAAA;AAAA;AAAA,iBAGI,uBAAA,CAAwB,gBAAA,EAAkB,gBAAA,eAA+B,sBAAA;;;;;;;iBAazE,sBAAA,CAAuB,OAAA,EAAS,gBAAA,eAA+B,gBAAA;AAkD/E;;;;AAAA,iBAhCgB,wBAAA,CAAyB,IAAA,EAAM,gBAAA,eAA+B,gBAAA;AAAA,KAgClE,aAAA;EACV,UAAA;EACA,aAAA;EACA,gBAAA;AAAA;AAAA,iBAGc,sBAAA,CAAuB,GAAA,kBAAqB,aAAA;AAAA,iBAc5C,cAAA,CAAe,SAAA;AAAA,iBAKf,oBAAA,CAAqB,SAAA;AAAA,iBAIrB,YAAA,CAAA;AAAA,iBAIA,kBAAA,CAAmB,OAAA;EAAW,GAAA;EAAa,SAAA;EAAoB,KAAA;AAAA,IAAkB,aAAA;AAAA,KAiBrF,mBAAA;EACV,SAAA;EACA,SAAA,GAAY,IAAA,UAAc,OAAA;IAAW,SAAA;EAAA,MAAyB,OAAA,CAAQ,MAAA,CAAO,QAAA,EAAU,KAAA;AAAA;AAAA,iBAGzE,0BAAA,CAA2B,KAAA;;;;AAtB3C;;iBA+BgB,uBAAA,CAAwB,KAAA;AAAA,cAU3B,eAAA;EAAA,QACH,QAAA;EAAA,QACA,UAAA;EAAA,QACA,SAAA;EAAA,QACA,cAAA;EAAA,QACA,gBAAA;EAAA,QACA,WAAA;EAAA,QACA,OAAA;EAAA,QACA,WAAA;EAAA,QACA,YAAA;EAAA,QACA,oBAAA;EAAA,QACA,UAAA;EAAA,QACA,YAAA;EAAA,QACA,qBAAA;EAAA,QACA,eAAA;EAAA,QACA,gBAAA;EAAA,iBACS,uBAAA;EAAA,iBACA,WAAA;EAAA,iBAEA,iBAAA;EAAA,iBACA,KAAA;EAAA,iBACA,cAAA;cAEL,IAAA,EAAM,mBAAA,EAAqB,aAAA,EAAe,sBAAA;EA7CQ;;;EAwD9D,KAAA,CAAA;EAYA,IAAA,CAAA;EAWA,WAAA,CAAA;EAAA,QAMQ,gBAAA;EAAA,QASM,MAAA;EAAA,QAiFN,QAAA;EAAA,QAUM,eAAA;EAAA,QA+DN,qBAAA;EAAA,QAeA,KAAA;AAAA"}
@@ -1,5 +1,5 @@
1
1
  import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
2
- import { captureWarning } from "@hexclave/shared/dist/utils/errors";
2
+ import { captureWarning, throwErr } from "@hexclave/shared/dist/utils/errors";
3
3
  import { Result } from "@hexclave/shared/dist/utils/results";
4
4
  import { isBrowserLike } from "@hexclave/shared/dist/utils/env";
5
5
  import { KnownErrors } from "@hexclave/shared/dist/known-errors";
@@ -57,6 +57,7 @@ const IDLE_TTL_MS = 180 * 1e3;
57
57
  const FLUSH_INTERVAL_MS = 5e3;
58
58
  const MAX_EVENTS_PER_BATCH = 200;
59
59
  const MAX_APPROX_BYTES_PER_BATCH = 512e3;
60
+ const MAX_FLUSH_PAYLOAD_BYTES = 9e5;
60
61
  function safeParseStoredSession(raw) {
61
62
  if (!raw) return null;
62
63
  try {
@@ -111,6 +112,7 @@ var SessionRecorder = class {
111
112
  this._detachListeners = null;
112
113
  this._flushTimer = null;
113
114
  this._events = [];
115
+ this._eventSizes = [];
114
116
  this._approxBytes = 0;
115
117
  this._lastPersistActivity = 0;
116
118
  this._recording = false;
@@ -145,6 +147,7 @@ var SessionRecorder = class {
145
147
  }
146
148
  clearBuffer() {
147
149
  this._events = [];
150
+ this._eventSizes = [];
148
151
  this._approxBytes = 0;
149
152
  }
150
153
  _persistActivity(nowMs) {
@@ -172,30 +175,49 @@ var SessionRecorder = class {
172
175
  legacyKey: this._legacyStorageKey,
173
176
  nowMs
174
177
  });
175
- const batchId = generateUuid();
176
- const payload = {
177
- browser_session_id: stored.session_id,
178
- session_replay_segment_id: this._sessionReplaySegmentId,
179
- batch_id: batchId,
180
- started_at_ms: stored.created_at_ms,
181
- sent_at_ms: nowMs,
182
- events: this._events
183
- };
178
+ const allEvents = this._events;
179
+ const allSizes = this._eventSizes;
184
180
  this._events = [];
181
+ this._eventSizes = [];
185
182
  this._approxBytes = 0;
186
183
  this._flushInProgress = true;
187
184
  try {
188
- const res = await this._deps.sendBatch(JSON.stringify(payload), { keepalive: options.keepalive });
189
- if (res.status === "error") {
190
- if (isAnalyticsNotEnabledError(res.error)) {
191
- this._disable();
185
+ let offset = 0;
186
+ while (offset < allEvents.length) {
187
+ let batchBytes = 0;
188
+ let batchEnd = offset;
189
+ for (let i = offset; i < allEvents.length; i++) {
190
+ const nextSize = allSizes[i] ?? throwErr("_eventSizes out of sync with _events — this should never happen");
191
+ if (batchBytes + nextSize > MAX_FLUSH_PAYLOAD_BYTES && batchEnd > offset) break;
192
+ batchBytes += nextSize;
193
+ batchEnd = i + 1;
194
+ }
195
+ const batchEvents = allEvents.slice(offset, batchEnd);
196
+ offset = batchEnd;
197
+ const batchId = generateUuid();
198
+ const payload = {
199
+ browser_session_id: stored.session_id,
200
+ session_replay_segment_id: this._sessionReplaySegmentId,
201
+ batch_id: batchId,
202
+ started_at_ms: stored.created_at_ms,
203
+ sent_at_ms: nowMs,
204
+ events: batchEvents
205
+ };
206
+ const res = await this._deps.sendBatch(JSON.stringify(payload), { keepalive: options.keepalive });
207
+ if (res.status === "error") {
208
+ if (isAnalyticsNotEnabledError(res.error)) {
209
+ this._disable();
210
+ return;
211
+ }
212
+ if (isAdBlockerNetworkError(res.error)) return;
213
+ captureWarning("SessionRecorder.flush", res.error);
214
+ return;
215
+ }
216
+ if (!res.data.ok) {
217
+ captureWarning("SessionRecorder.flush", /* @__PURE__ */ new Error(`SessionRecorder flush failed: ${res.data.status} ${await res.data.text()}`));
192
218
  return;
193
219
  }
194
- if (isAdBlockerNetworkError(res.error)) return;
195
- captureWarning("SessionRecorder.flush", res.error);
196
- return;
197
220
  }
198
- if (!res.data.ok) captureWarning("SessionRecorder.flush", /* @__PURE__ */ new Error(`SessionRecorder flush failed: ${res.data.status} ${await res.data.text()}`));
199
221
  } finally {
200
222
  this._flushInProgress = false;
201
223
  }
@@ -234,8 +256,10 @@ var SessionRecorder = class {
234
256
  this._takingSnapshot = false;
235
257
  }
236
258
  }
259
+ const eventSize = JSON.stringify(event).length;
237
260
  this._events.push(event);
238
- this._approxBytes += JSON.stringify(event).length;
261
+ this._eventSizes.push(eventSize);
262
+ this._approxBytes += eventSize;
239
263
  if (this._events.length >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) runAsynchronously(() => this._flush({ keepalive: false }));
240
264
  },
241
265
  maskAllInputs: this._replayOptions.maskAllInputs ?? true,
@@ -263,6 +287,7 @@ var SessionRecorder = class {
263
287
  this._stopRecording = null;
264
288
  }
265
289
  this._events = [];
290
+ this._eventSizes = [];
266
291
  this._approxBytes = 0;
267
292
  this._recording = false;
268
293
  }
@@ -1 +1 @@
1
- {"version":3,"file":"session-replay.js","names":[],"sources":["../../../../../../src/lib/hexclave-app/apps/implementations/session-replay.ts"],"sourcesContent":["\n//===========================================\n// THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template\n//===========================================\nimport { KnownErrors } from \"@hexclave/shared/dist/known-errors\";\nimport { isBrowserLike } from \"@hexclave/shared/dist/utils/env\";\nimport { captureWarning } from \"@hexclave/shared/dist/utils/errors\";\nimport { runAsynchronously } from \"@hexclave/shared/dist/utils/promises\";\nimport { Result } from \"@hexclave/shared/dist/utils/results\";\n\nexport type AnalyticsReplayOptions = {\n /**\n * Whether session replays are enabled.\n *\n * @default true\n */\n enabled?: boolean,\n /**\n * Whether to mask the content of all `<input>` elements.\n *\n * @default true\n */\n maskAllInputs?: boolean,\n /**\n * A CSS class name or RegExp. Elements with a matching class will be blocked\n * (replaced with a placeholder in the recording).\n *\n * @default undefined\n */\n blockClass?: string | RegExp,\n /**\n * A CSS selector string. Elements matching this selector will be blocked\n * (replaced with a placeholder in the recording).\n *\n * @default undefined\n */\n blockSelector?: string,\n};\n\nexport type AnalyticsOptions = {\n /**\n * Whether SDK-managed analytics capture is enabled.\n *\n * @default true\n */\n enabled?: boolean,\n /**\n * Options for session replay recording. Replays are enabled by default;\n * set `enabled: false` to opt out.\n */\n replays?: AnalyticsReplayOptions,\n};\n\nexport function getSessionReplayOptions(analyticsOptions: AnalyticsOptions | undefined): AnalyticsReplayOptions {\n return {\n ...analyticsOptions?.replays,\n enabled: analyticsOptions?.replays?.enabled ?? true,\n };\n}\n\n/**\n * Converts AnalyticsOptions to a JSON-safe representation.\n * RegExp blockClass values are serialized as `{ __regexp, __flags }` objects.\n * The return type is AnalyticsOptions to keep StackClientAppJson simple;\n * the actual runtime value is JSON-safe.\n */\nexport function analyticsOptionsToJson(options: AnalyticsOptions | undefined): AnalyticsOptions | undefined {\n if (!options?.replays?.blockClass) return options;\n const { blockClass, ...rest } = options.replays;\n if (!(blockClass instanceof RegExp)) return options;\n return {\n ...options,\n replays: {\n ...rest,\n // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment\n blockClass: { __regexp: blockClass.source, __flags: blockClass.flags } as any,\n },\n };\n}\n\n/**\n * Reconstructs AnalyticsOptions from a JSON-deserialized value.\n * Converts `{ __regexp, __flags }` objects back to RegExp instances.\n */\nexport function analyticsOptionsFromJson(json: AnalyticsOptions | undefined): AnalyticsOptions | undefined {\n if (!json?.replays?.blockClass) return json;\n const { blockClass, ...rest } = json.replays;\n if (typeof blockClass === 'object' && '__regexp' in blockClass) {\n const bc = blockClass as unknown as { __regexp: string, __flags: string };\n return {\n ...json,\n replays: {\n ...rest,\n blockClass: new RegExp(bc.__regexp, bc.__flags),\n },\n };\n }\n return json;\n}\n\n// ---------- Recording internals ----------\n\n// Hexclave rebrand: canonical localStorage prefix (colon delimiters preserved).\nconst LOCAL_STORAGE_PREFIX = \"hexclave:session-replay:v1\";\n// Hexclave rebrand: legacy prefix — dual-read only, so a recording session active\n// across an SDK upgrade is not orphaned. Never written.\nconst LEGACY_LOCAL_STORAGE_PREFIX = \"stack:session-replay:v1\";\nconst IDLE_TTL_MS = 3 * 60 * 1000;\n\nconst FLUSH_INTERVAL_MS = 5_000;\nconst MAX_EVENTS_PER_BATCH = 200;\nconst MAX_APPROX_BYTES_PER_BATCH = 512_000;\n\nexport type StoredSession = {\n session_id: string,\n created_at_ms: number,\n last_activity_ms: number,\n};\n\nexport function safeParseStoredSession(raw: string | null): StoredSession | null {\n if (!raw) return null;\n try {\n const parsed = JSON.parse(raw);\n if (typeof parsed !== \"object\" || parsed === null) return null;\n if (typeof parsed.session_id !== \"string\") return null;\n if (typeof parsed.created_at_ms !== \"number\") return null;\n if (typeof parsed.last_activity_ms !== \"number\") return null;\n return parsed as StoredSession;\n } catch {\n return null;\n }\n}\n\nexport function makeStorageKey(projectId: string) {\n return `${LOCAL_STORAGE_PREFIX}:${projectId}`;\n}\n\n// Hexclave rebrand: legacy key, dual-read only (never written).\nexport function makeLegacyStorageKey(projectId: string) {\n return `${LEGACY_LOCAL_STORAGE_PREFIX}:${projectId}`;\n}\n\nexport function generateUuid() {\n return crypto.randomUUID();\n}\n\nexport function getOrRotateSession(options: { key: string, legacyKey?: string, nowMs: number }): StoredSession {\n // Hexclave rebrand: prefer the new key; fall back to the legacy key so a\n // recording session active across an SDK upgrade is not orphaned.\n const existing = safeParseStoredSession(localStorage.getItem(options.key))\n ?? (options.legacyKey ? safeParseStoredSession(localStorage.getItem(options.legacyKey)) : null);\n if (existing && options.nowMs - existing.last_activity_ms <= IDLE_TTL_MS) {\n return existing;\n }\n const next: StoredSession = {\n session_id: generateUuid(),\n created_at_ms: options.nowMs,\n last_activity_ms: options.nowMs,\n };\n localStorage.setItem(options.key, JSON.stringify(next));\n return next;\n}\n\nexport type SessionRecorderDeps = {\n projectId: string,\n sendBatch: (body: string, options: { keepalive: boolean }) => Promise<Result<Response, Error>>,\n};\n\nexport function isAnalyticsNotEnabledError(error: unknown): boolean {\n return KnownErrors.AnalyticsNotEnabled.isInstance(error);\n}\n\n/**\n * Whether the error looks like a network failure caused by an ad blocker or\n * similar extension blocking analytics requests. These are expected in\n * production and should be silently ignored rather than logged as warnings.\n */\nexport function isAdBlockerNetworkError(error: unknown): boolean {\n if (error instanceof Error) {\n return error.message.includes(\"Failed to fetch\")\n || error.message.includes(\"NetworkError\")\n || error.message.includes(\"Load failed\")\n || error.message.includes(\"network connection\");\n }\n return false;\n}\n\nexport class SessionRecorder {\n private _started = false;\n private _cancelled = false;\n private _disabled = false;\n private _stopRecording: (() => void) | null = null;\n private _detachListeners: (() => void) | null = null;\n private _flushTimer: ReturnType<typeof setInterval> | null = null;\n private _events: unknown[] = [];\n private _approxBytes = 0;\n private _lastPersistActivity = 0;\n private _recording = false;\n private _rrwebModule: typeof import(\"rrweb\") | null = null;\n private _lastBrowserSessionId: string | null = null;\n private _takingSnapshot = false;\n private _flushInProgress = false;\n private readonly _sessionReplaySegmentId: string;\n private readonly _storageKey: string;\n // Hexclave rebrand: legacy key used for dual-read fallback only.\n private readonly _legacyStorageKey: string;\n private readonly _deps: SessionRecorderDeps;\n private readonly _replayOptions: AnalyticsReplayOptions;\n\n constructor(deps: SessionRecorderDeps, replayOptions: AnalyticsReplayOptions) {\n this._deps = deps;\n this._replayOptions = replayOptions;\n this._sessionReplaySegmentId = generateUuid();\n this._storageKey = makeStorageKey(deps.projectId);\n this._legacyStorageKey = makeLegacyStorageKey(deps.projectId);\n }\n\n /**\n * Starts recording. Idempotent — calling multiple times is safe.\n */\n start() {\n if (this._started) return;\n if (!isBrowserLike()) return;\n this._started = true;\n\n // Kick off rrweb recording\n runAsynchronously(() => this._startRecording(), { noErrorLogging: true });\n\n // Periodic flush\n this._flushTimer = setInterval(() => this._tick(), FLUSH_INTERVAL_MS);\n }\n\n stop() {\n this._cancelled = true;\n if (this._flushTimer !== null) {\n clearInterval(this._flushTimer);\n this._flushTimer = null;\n }\n // Flush remaining events before cleanup\n runAsynchronously(() => this._flush({ keepalive: true }));\n this._stopCurrentRecording();\n }\n\n clearBuffer() {\n this._events = [];\n this._approxBytes = 0;\n }\n\n private _persistActivity(nowMs: number): StoredSession {\n const stored = getOrRotateSession({ key: this._storageKey, legacyKey: this._legacyStorageKey, nowMs });\n if (nowMs - this._lastPersistActivity < 5_000) return stored;\n this._lastPersistActivity = nowMs;\n const updated: StoredSession = { ...stored, last_activity_ms: nowMs };\n localStorage.setItem(this._storageKey, JSON.stringify(updated));\n return stored;\n }\n\n private async _flush(options: { keepalive: boolean }) {\n if (this._disabled) return;\n if (this._events.length === 0) return;\n // Prevent concurrent in-flight HTTP requests. When a flush is already\n // in-flight, a second batch could race on the server (both call\n // findRecentSessionReplay before either upsert commits) and create\n // duplicate SessionReplay records. Events stay in _events and will be\n // picked up by the next tick or batch-size check.\n if (this._flushInProgress) return;\n\n const nowMs = Date.now();\n const stored = getOrRotateSession({ key: this._storageKey, legacyKey: this._legacyStorageKey, nowMs });\n\n const batchId = generateUuid();\n const payload = {\n browser_session_id: stored.session_id,\n session_replay_segment_id: this._sessionReplaySegmentId,\n batch_id: batchId,\n started_at_ms: stored.created_at_ms,\n sent_at_ms: nowMs,\n events: this._events,\n };\n\n this._events = [];\n this._approxBytes = 0;\n\n this._flushInProgress = true;\n try {\n const res = await this._deps.sendBatch(\n JSON.stringify(payload),\n { keepalive: options.keepalive },\n );\n\n if (res.status === \"error\") {\n if (isAnalyticsNotEnabledError(res.error)) {\n this._disable();\n return;\n }\n // Ad blockers commonly block analytics endpoints, causing network\n // errors. These are expected and should not pollute the console.\n if (isAdBlockerNetworkError(res.error)) {\n return;\n }\n captureWarning(\"SessionRecorder.flush\", res.error);\n return;\n }\n\n if (!res.data.ok) {\n captureWarning(\"SessionRecorder.flush\", new Error(`SessionRecorder flush failed: ${res.data.status} ${await res.data.text()}`));\n }\n } finally {\n this._flushInProgress = false;\n }\n }\n\n private _disable() {\n this._disabled = true;\n this.clearBuffer();\n if (this._flushTimer !== null) {\n clearInterval(this._flushTimer);\n this._flushTimer = null;\n }\n this._stopCurrentRecording();\n }\n\n private async _startRecording() {\n if (this._recording || this._cancelled) return;\n\n if (!this._rrwebModule) {\n const rrwebImport = await Result.fromPromise(import(\"rrweb\"));\n if (rrwebImport.status === \"error\") {\n console.warn(\"SessionRecorder: rrweb import failed. Is rrweb installed?\", rrwebImport.error);\n return;\n }\n this._rrwebModule = rrwebImport.data;\n }\n\n // cancelled may change during the await above\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n if (this._cancelled) return;\n\n this._stopRecording = this._rrwebModule.record({\n emit: (event) => {\n const nowMs = Date.now();\n const stored = this._persistActivity(nowMs);\n\n // Detect session rotation: after 3+ minutes idle, getOrRotateSession\n // creates a new session ID. We need to inject a FullSnapshot so the\n // new server-side SessionReplay record is playable.\n if (this._lastBrowserSessionId === null) {\n this._lastBrowserSessionId = stored.session_id;\n } else if (stored.session_id !== this._lastBrowserSessionId && !this._takingSnapshot) {\n this._lastBrowserSessionId = stored.session_id;\n // Inject a FullSnapshot for the new session (calls emit synchronously)\n this._takingSnapshot = true;\n try {\n this._rrwebModule!.record.takeFullSnapshot();\n } finally {\n this._takingSnapshot = false;\n }\n }\n\n this._events.push(event);\n this._approxBytes += JSON.stringify(event).length;\n if (this._events.length >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) {\n runAsynchronously(() => this._flush({ keepalive: false }));\n }\n },\n maskAllInputs: this._replayOptions.maskAllInputs ?? true,\n ...(this._replayOptions.blockClass !== undefined ? { blockClass: this._replayOptions.blockClass } : {}),\n ...(this._replayOptions.blockSelector !== undefined ? { blockSelector: this._replayOptions.blockSelector } : {}),\n }) ?? null;\n\n this._recording = true;\n\n const onPageHide = () => {\n runAsynchronously(() => this._flush({ keepalive: true }));\n };\n window.addEventListener(\"pagehide\", onPageHide);\n document.addEventListener(\"visibilitychange\", onPageHide);\n this._detachListeners = () => {\n window.removeEventListener(\"pagehide\", onPageHide);\n document.removeEventListener(\"visibilitychange\", onPageHide);\n };\n }\n\n private _stopCurrentRecording() {\n if (this._detachListeners) {\n this._detachListeners();\n this._detachListeners = null;\n }\n if (this._stopRecording) {\n this._stopRecording();\n this._stopRecording = null;\n }\n this._events = [];\n this._approxBytes = 0;\n this._recording = false;\n }\n\n private _tick() {\n if (this._cancelled) return;\n if (this._events.length > 0) {\n runAsynchronously(() => this._flush({ keepalive: false }));\n }\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"session-replay.js","names":[],"sources":["../../../../../../src/lib/hexclave-app/apps/implementations/session-replay.ts"],"sourcesContent":["\n//===========================================\n// THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template\n//===========================================\nimport { KnownErrors } from \"@hexclave/shared/dist/known-errors\";\nimport { isBrowserLike } from \"@hexclave/shared/dist/utils/env\";\nimport { captureWarning, throwErr } from \"@hexclave/shared/dist/utils/errors\";\nimport { runAsynchronously } from \"@hexclave/shared/dist/utils/promises\";\nimport { Result } from \"@hexclave/shared/dist/utils/results\";\n\nexport type AnalyticsReplayOptions = {\n /**\n * Whether session replays are enabled.\n *\n * @default true\n */\n enabled?: boolean,\n /**\n * Whether to mask the content of all `<input>` elements.\n *\n * @default true\n */\n maskAllInputs?: boolean,\n /**\n * A CSS class name or RegExp. Elements with a matching class will be blocked\n * (replaced with a placeholder in the recording).\n *\n * @default undefined\n */\n blockClass?: string | RegExp,\n /**\n * A CSS selector string. Elements matching this selector will be blocked\n * (replaced with a placeholder in the recording).\n *\n * @default undefined\n */\n blockSelector?: string,\n};\n\nexport type AnalyticsOptions = {\n /**\n * Whether SDK-managed analytics capture is enabled.\n *\n * @default true\n */\n enabled?: boolean,\n /**\n * Options for session replay recording. Replays are enabled by default;\n * set `enabled: false` to opt out.\n */\n replays?: AnalyticsReplayOptions,\n};\n\nexport function getSessionReplayOptions(analyticsOptions: AnalyticsOptions | undefined): AnalyticsReplayOptions {\n return {\n ...analyticsOptions?.replays,\n enabled: analyticsOptions?.replays?.enabled ?? true,\n };\n}\n\n/**\n * Converts AnalyticsOptions to a JSON-safe representation.\n * RegExp blockClass values are serialized as `{ __regexp, __flags }` objects.\n * The return type is AnalyticsOptions to keep StackClientAppJson simple;\n * the actual runtime value is JSON-safe.\n */\nexport function analyticsOptionsToJson(options: AnalyticsOptions | undefined): AnalyticsOptions | undefined {\n if (!options?.replays?.blockClass) return options;\n const { blockClass, ...rest } = options.replays;\n if (!(blockClass instanceof RegExp)) return options;\n return {\n ...options,\n replays: {\n ...rest,\n // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment\n blockClass: { __regexp: blockClass.source, __flags: blockClass.flags } as any,\n },\n };\n}\n\n/**\n * Reconstructs AnalyticsOptions from a JSON-deserialized value.\n * Converts `{ __regexp, __flags }` objects back to RegExp instances.\n */\nexport function analyticsOptionsFromJson(json: AnalyticsOptions | undefined): AnalyticsOptions | undefined {\n if (!json?.replays?.blockClass) return json;\n const { blockClass, ...rest } = json.replays;\n if (typeof blockClass === 'object' && '__regexp' in blockClass) {\n const bc = blockClass as unknown as { __regexp: string, __flags: string };\n return {\n ...json,\n replays: {\n ...rest,\n blockClass: new RegExp(bc.__regexp, bc.__flags),\n },\n };\n }\n return json;\n}\n\n// ---------- Recording internals ----------\n\n// Hexclave rebrand: canonical localStorage prefix (colon delimiters preserved).\nconst LOCAL_STORAGE_PREFIX = \"hexclave:session-replay:v1\";\n// Hexclave rebrand: legacy prefix — dual-read only, so a recording session active\n// across an SDK upgrade is not orphaned. Never written.\nconst LEGACY_LOCAL_STORAGE_PREFIX = \"stack:session-replay:v1\";\nconst IDLE_TTL_MS = 3 * 60 * 1000;\n\nconst FLUSH_INTERVAL_MS = 5_000;\nconst MAX_EVENTS_PER_BATCH = 200;\nconst MAX_APPROX_BYTES_PER_BATCH = 512_000;\n// The server rejects payloads > 1MB. Stay well under to account for JSON\n// envelope overhead (browser_session_id, timestamps, wrapper keys, etc.).\nconst MAX_FLUSH_PAYLOAD_BYTES = 900_000;\n\nexport type StoredSession = {\n session_id: string,\n created_at_ms: number,\n last_activity_ms: number,\n};\n\nexport function safeParseStoredSession(raw: string | null): StoredSession | null {\n if (!raw) return null;\n try {\n const parsed = JSON.parse(raw);\n if (typeof parsed !== \"object\" || parsed === null) return null;\n if (typeof parsed.session_id !== \"string\") return null;\n if (typeof parsed.created_at_ms !== \"number\") return null;\n if (typeof parsed.last_activity_ms !== \"number\") return null;\n return parsed as StoredSession;\n } catch {\n return null;\n }\n}\n\nexport function makeStorageKey(projectId: string) {\n return `${LOCAL_STORAGE_PREFIX}:${projectId}`;\n}\n\n// Hexclave rebrand: legacy key, dual-read only (never written).\nexport function makeLegacyStorageKey(projectId: string) {\n return `${LEGACY_LOCAL_STORAGE_PREFIX}:${projectId}`;\n}\n\nexport function generateUuid() {\n return crypto.randomUUID();\n}\n\nexport function getOrRotateSession(options: { key: string, legacyKey?: string, nowMs: number }): StoredSession {\n // Hexclave rebrand: prefer the new key; fall back to the legacy key so a\n // recording session active across an SDK upgrade is not orphaned.\n const existing = safeParseStoredSession(localStorage.getItem(options.key))\n ?? (options.legacyKey ? safeParseStoredSession(localStorage.getItem(options.legacyKey)) : null);\n if (existing && options.nowMs - existing.last_activity_ms <= IDLE_TTL_MS) {\n return existing;\n }\n const next: StoredSession = {\n session_id: generateUuid(),\n created_at_ms: options.nowMs,\n last_activity_ms: options.nowMs,\n };\n localStorage.setItem(options.key, JSON.stringify(next));\n return next;\n}\n\nexport type SessionRecorderDeps = {\n projectId: string,\n sendBatch: (body: string, options: { keepalive: boolean }) => Promise<Result<Response, Error>>,\n};\n\nexport function isAnalyticsNotEnabledError(error: unknown): boolean {\n return KnownErrors.AnalyticsNotEnabled.isInstance(error);\n}\n\n/**\n * Whether the error looks like a network failure caused by an ad blocker or\n * similar extension blocking analytics requests. These are expected in\n * production and should be silently ignored rather than logged as warnings.\n */\nexport function isAdBlockerNetworkError(error: unknown): boolean {\n if (error instanceof Error) {\n return error.message.includes(\"Failed to fetch\")\n || error.message.includes(\"NetworkError\")\n || error.message.includes(\"Load failed\")\n || error.message.includes(\"network connection\");\n }\n return false;\n}\n\nexport class SessionRecorder {\n private _started = false;\n private _cancelled = false;\n private _disabled = false;\n private _stopRecording: (() => void) | null = null;\n private _detachListeners: (() => void) | null = null;\n private _flushTimer: ReturnType<typeof setInterval> | null = null;\n private _events: unknown[] = [];\n private _eventSizes: number[] = [];\n private _approxBytes = 0;\n private _lastPersistActivity = 0;\n private _recording = false;\n private _rrwebModule: typeof import(\"rrweb\") | null = null;\n private _lastBrowserSessionId: string | null = null;\n private _takingSnapshot = false;\n private _flushInProgress = false;\n private readonly _sessionReplaySegmentId: string;\n private readonly _storageKey: string;\n // Hexclave rebrand: legacy key used for dual-read fallback only.\n private readonly _legacyStorageKey: string;\n private readonly _deps: SessionRecorderDeps;\n private readonly _replayOptions: AnalyticsReplayOptions;\n\n constructor(deps: SessionRecorderDeps, replayOptions: AnalyticsReplayOptions) {\n this._deps = deps;\n this._replayOptions = replayOptions;\n this._sessionReplaySegmentId = generateUuid();\n this._storageKey = makeStorageKey(deps.projectId);\n this._legacyStorageKey = makeLegacyStorageKey(deps.projectId);\n }\n\n /**\n * Starts recording. Idempotent — calling multiple times is safe.\n */\n start() {\n if (this._started) return;\n if (!isBrowserLike()) return;\n this._started = true;\n\n // Kick off rrweb recording\n runAsynchronously(() => this._startRecording(), { noErrorLogging: true });\n\n // Periodic flush\n this._flushTimer = setInterval(() => this._tick(), FLUSH_INTERVAL_MS);\n }\n\n stop() {\n this._cancelled = true;\n if (this._flushTimer !== null) {\n clearInterval(this._flushTimer);\n this._flushTimer = null;\n }\n // Flush remaining events before cleanup\n runAsynchronously(() => this._flush({ keepalive: true }));\n this._stopCurrentRecording();\n }\n\n clearBuffer() {\n this._events = [];\n this._eventSizes = [];\n this._approxBytes = 0;\n }\n\n private _persistActivity(nowMs: number): StoredSession {\n const stored = getOrRotateSession({ key: this._storageKey, legacyKey: this._legacyStorageKey, nowMs });\n if (nowMs - this._lastPersistActivity < 5_000) return stored;\n this._lastPersistActivity = nowMs;\n const updated: StoredSession = { ...stored, last_activity_ms: nowMs };\n localStorage.setItem(this._storageKey, JSON.stringify(updated));\n return stored;\n }\n\n private async _flush(options: { keepalive: boolean }) {\n if (this._disabled) return;\n if (this._events.length === 0) return;\n // Prevent concurrent in-flight HTTP requests. When a flush is already\n // in-flight, a second batch could race on the server (both call\n // findRecentSessionReplay before either upsert commits) and create\n // duplicate SessionReplay records. Events stay in _events and will be\n // picked up by the next tick or batch-size check.\n if (this._flushInProgress) return;\n\n const nowMs = Date.now();\n const stored = getOrRotateSession({ key: this._storageKey, legacyKey: this._legacyStorageKey, nowMs });\n\n // Capture all buffered events upfront (before any await) so that\n // stop() / _stopCurrentRecording() clearing this._events cannot race\n // with the async send loop below and silently discard overflow batches.\n const allEvents = this._events;\n const allSizes = this._eventSizes;\n this._events = [];\n this._eventSizes = [];\n this._approxBytes = 0;\n\n this._flushInProgress = true;\n try {\n let offset = 0;\n while (offset < allEvents.length) {\n // Build a batch that fits under the server's payload limit.\n // When _flushInProgress blocked earlier flushes, events can accumulate\n // well past MAX_APPROX_BYTES_PER_BATCH; sending them all at once would\n // exceed the server's 1MB body limit (413).\n let batchBytes = 0;\n let batchEnd = offset;\n for (let i = offset; i < allEvents.length; i++) {\n const nextSize = allSizes[i] ?? throwErr(\"_eventSizes out of sync with _events — this should never happen\");\n if (batchBytes + nextSize > MAX_FLUSH_PAYLOAD_BYTES && batchEnd > offset) break;\n batchBytes += nextSize;\n batchEnd = i + 1;\n }\n\n const batchEvents = allEvents.slice(offset, batchEnd);\n offset = batchEnd;\n\n const batchId = generateUuid();\n const payload = {\n browser_session_id: stored.session_id,\n session_replay_segment_id: this._sessionReplaySegmentId,\n batch_id: batchId,\n started_at_ms: stored.created_at_ms,\n sent_at_ms: nowMs,\n events: batchEvents,\n };\n\n const res = await this._deps.sendBatch(\n JSON.stringify(payload),\n { keepalive: options.keepalive },\n );\n\n if (res.status === \"error\") {\n if (isAnalyticsNotEnabledError(res.error)) {\n this._disable();\n return;\n }\n // Ad blockers commonly block analytics endpoints, causing network\n // errors. These are expected and should not pollute the console.\n if (isAdBlockerNetworkError(res.error)) {\n return;\n }\n captureWarning(\"SessionRecorder.flush\", res.error);\n return;\n }\n\n if (!res.data.ok) {\n captureWarning(\"SessionRecorder.flush\", new Error(`SessionRecorder flush failed: ${res.data.status} ${await res.data.text()}`));\n return;\n }\n }\n } finally {\n this._flushInProgress = false;\n }\n }\n\n private _disable() {\n this._disabled = true;\n this.clearBuffer();\n if (this._flushTimer !== null) {\n clearInterval(this._flushTimer);\n this._flushTimer = null;\n }\n this._stopCurrentRecording();\n }\n\n private async _startRecording() {\n if (this._recording || this._cancelled) return;\n\n if (!this._rrwebModule) {\n const rrwebImport = await Result.fromPromise(import(\"rrweb\"));\n if (rrwebImport.status === \"error\") {\n console.warn(\"SessionRecorder: rrweb import failed. Is rrweb installed?\", rrwebImport.error);\n return;\n }\n this._rrwebModule = rrwebImport.data;\n }\n\n // cancelled may change during the await above\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n if (this._cancelled) return;\n\n this._stopRecording = this._rrwebModule.record({\n emit: (event) => {\n const nowMs = Date.now();\n const stored = this._persistActivity(nowMs);\n\n // Detect session rotation: after 3+ minutes idle, getOrRotateSession\n // creates a new session ID. We need to inject a FullSnapshot so the\n // new server-side SessionReplay record is playable.\n if (this._lastBrowserSessionId === null) {\n this._lastBrowserSessionId = stored.session_id;\n } else if (stored.session_id !== this._lastBrowserSessionId && !this._takingSnapshot) {\n this._lastBrowserSessionId = stored.session_id;\n // Inject a FullSnapshot for the new session (calls emit synchronously)\n this._takingSnapshot = true;\n try {\n this._rrwebModule!.record.takeFullSnapshot();\n } finally {\n this._takingSnapshot = false;\n }\n }\n\n const eventSize = JSON.stringify(event).length;\n this._events.push(event);\n this._eventSizes.push(eventSize);\n this._approxBytes += eventSize;\n if (this._events.length >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) {\n runAsynchronously(() => this._flush({ keepalive: false }));\n }\n },\n maskAllInputs: this._replayOptions.maskAllInputs ?? true,\n ...(this._replayOptions.blockClass !== undefined ? { blockClass: this._replayOptions.blockClass } : {}),\n ...(this._replayOptions.blockSelector !== undefined ? { blockSelector: this._replayOptions.blockSelector } : {}),\n }) ?? null;\n\n this._recording = true;\n\n const onPageHide = () => {\n runAsynchronously(() => this._flush({ keepalive: true }));\n };\n window.addEventListener(\"pagehide\", onPageHide);\n document.addEventListener(\"visibilitychange\", onPageHide);\n this._detachListeners = () => {\n window.removeEventListener(\"pagehide\", onPageHide);\n document.removeEventListener(\"visibilitychange\", onPageHide);\n };\n }\n\n private _stopCurrentRecording() {\n if (this._detachListeners) {\n this._detachListeners();\n this._detachListeners = null;\n }\n if (this._stopRecording) {\n this._stopRecording();\n this._stopRecording = null;\n }\n this._events = [];\n this._eventSizes = [];\n this._approxBytes = 0;\n this._recording = false;\n }\n\n private _tick() {\n if (this._cancelled) return;\n if (this._events.length > 0) {\n runAsynchronously(() => this._flush({ keepalive: false }));\n }\n }\n}\n"],"mappings":"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"}
@@ -55,20 +55,24 @@ describe("SessionRecorder flush", () => {
55
55
  }, {});
56
56
  const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
57
57
  try {
58
- recorder._events = [{
58
+ const event1 = {
59
59
  type: 2,
60
60
  timestamp: Date.now(),
61
61
  data: {}
62
- }];
62
+ };
63
+ recorder._events = [event1];
64
+ recorder._eventSizes = [JSON.stringify(event1).length];
63
65
  recorder._tick();
64
66
  await vi.advanceTimersByTimeAsync(0);
65
67
  expect(sentBodies).toHaveLength(1);
66
68
  expect(warnSpy).not.toHaveBeenCalled();
67
- recorder._events = [{
69
+ const event2 = {
68
70
  type: 3,
69
71
  timestamp: Date.now(),
70
72
  data: {}
71
- }];
73
+ };
74
+ recorder._events = [event2];
75
+ recorder._eventSizes = [JSON.stringify(event2).length];
72
76
  recorder._tick();
73
77
  await vi.advanceTimersByTimeAsync(0);
74
78
  expect(sentBodies).toHaveLength(2);
@@ -80,6 +84,87 @@ describe("SessionRecorder flush", () => {
80
84
  vi.useRealTimers();
81
85
  }
82
86
  });
87
+ it("splits large batches into multiple requests to stay under server 1MB limit", async () => {
88
+ vi.useFakeTimers();
89
+ const storageKey = `hexclave:session-replay:v1:test-project`;
90
+ localStorage.setItem(storageKey, JSON.stringify({
91
+ session_id: "test-session",
92
+ created_at_ms: Date.now(),
93
+ last_activity_ms: Date.now()
94
+ }));
95
+ const sentBodies = [];
96
+ const recorder = new SessionRecorder({
97
+ projectId: "test-project",
98
+ sendBatch: async (body) => {
99
+ sentBodies.push(body);
100
+ return Result.ok(new Response("ok", { status: 200 }));
101
+ }
102
+ }, {});
103
+ try {
104
+ const largeData = "x".repeat(5e5);
105
+ const event1 = {
106
+ type: 2,
107
+ timestamp: Date.now(),
108
+ data: largeData
109
+ };
110
+ const event2 = {
111
+ type: 3,
112
+ timestamp: Date.now(),
113
+ data: largeData
114
+ };
115
+ recorder._events = [event1, event2];
116
+ recorder._eventSizes = [JSON.stringify(event1).length, JSON.stringify(event2).length];
117
+ recorder._approxBytes = JSON.stringify(event1).length + JSON.stringify(event2).length;
118
+ recorder._tick();
119
+ await vi.advanceTimersByTimeAsync(0);
120
+ expect(sentBodies).toHaveLength(2);
121
+ const batch1 = JSON.parse(sentBodies[0]);
122
+ const batch2 = JSON.parse(sentBodies[1]);
123
+ expect(batch1.events).toHaveLength(1);
124
+ expect(batch2.events).toHaveLength(1);
125
+ expect(batch1.batch_id).not.toBe(batch2.batch_id);
126
+ } finally {
127
+ recorder.stop();
128
+ localStorage.removeItem(storageKey);
129
+ vi.useRealTimers();
130
+ }
131
+ });
132
+ it("sends a single oversized event alone without dropping it", async () => {
133
+ vi.useFakeTimers();
134
+ const storageKey = `hexclave:session-replay:v1:test-project`;
135
+ localStorage.setItem(storageKey, JSON.stringify({
136
+ session_id: "test-session",
137
+ created_at_ms: Date.now(),
138
+ last_activity_ms: Date.now()
139
+ }));
140
+ const sentBodies = [];
141
+ const recorder = new SessionRecorder({
142
+ projectId: "test-project",
143
+ sendBatch: async (body) => {
144
+ sentBodies.push(body);
145
+ return Result.ok(new Response("ok", { status: 200 }));
146
+ }
147
+ }, {});
148
+ try {
149
+ const hugeData = "y".repeat(1e6);
150
+ const hugeEvent = {
151
+ type: 2,
152
+ timestamp: Date.now(),
153
+ data: hugeData
154
+ };
155
+ recorder._events = [hugeEvent];
156
+ recorder._eventSizes = [JSON.stringify(hugeEvent).length];
157
+ recorder._approxBytes = JSON.stringify(hugeEvent).length;
158
+ recorder._tick();
159
+ await vi.advanceTimersByTimeAsync(0);
160
+ expect(sentBodies).toHaveLength(1);
161
+ expect(JSON.parse(sentBodies[0]).events).toHaveLength(1);
162
+ } finally {
163
+ recorder.stop();
164
+ localStorage.removeItem(storageKey);
165
+ vi.useRealTimers();
166
+ }
167
+ });
83
168
  it("silently disables when client interface returns ANALYTICS_NOT_ENABLED as an error", async () => {
84
169
  vi.useFakeTimers();
85
170
  const storageKey = `hexclave:session-replay:v1:test-project`;
@@ -98,20 +183,24 @@ describe("SessionRecorder flush", () => {
98
183
  }, {});
99
184
  const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
100
185
  try {
101
- recorder._events = [{
186
+ const event1 = {
102
187
  type: 2,
103
188
  timestamp: Date.now(),
104
189
  data: {}
105
- }];
190
+ };
191
+ recorder._events = [event1];
192
+ recorder._eventSizes = [JSON.stringify(event1).length];
106
193
  recorder._tick();
107
194
  await vi.advanceTimersByTimeAsync(0);
108
195
  expect(sentBodies).toHaveLength(1);
109
196
  expect(warnSpy).not.toHaveBeenCalled();
110
- recorder._events = [{
197
+ const event2 = {
111
198
  type: 3,
112
199
  timestamp: Date.now(),
113
200
  data: {}
114
- }];
201
+ };
202
+ recorder._events = [event2];
203
+ recorder._eventSizes = [JSON.stringify(event2).length];
115
204
  recorder._tick();
116
205
  await vi.advanceTimersByTimeAsync(0);
117
206
  expect(sentBodies).toHaveLength(1);
@@ -1 +1 @@
1
- {"version":3,"file":"session-replay.test.js","names":[],"sources":["../../../../../../src/lib/hexclave-app/apps/implementations/session-replay.test.ts"],"sourcesContent":["\n//===========================================\n// THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template\n//===========================================\n// @vitest-environment jsdom\n\nimport { KnownErrors } from \"@hexclave/shared/dist/known-errors\";\nimport { describe, expect, it, vi } from \"vitest\";\nimport { Result } from \"@hexclave/shared/dist/utils/results\";\nimport { analyticsOptionsFromJson, analyticsOptionsToJson, getSessionReplayOptions, SessionRecorder } from \"./session-replay\";\n\ndescribe(\"session replay options\", () => {\n it(\"enables replays by default\", () => {\n expect(getSessionReplayOptions(undefined).enabled).toBe(true);\n expect(getSessionReplayOptions({}).enabled).toBe(true);\n expect(getSessionReplayOptions({ replays: {} }).enabled).toBe(true);\n });\n\n it(\"preserves explicit replay opt-out\", () => {\n expect(getSessionReplayOptions({ replays: { enabled: false } }).enabled).toBe(false);\n });\n});\n\ndescribe(\"analytics option JSON conversion\", () => {\n it(\"preserves top-level analytics options when serializing replay block classes\", () => {\n const json = analyticsOptionsToJson({\n enabled: false,\n replays: {\n enabled: true,\n blockClass: /stack-sensitive/u,\n },\n });\n\n expect(json?.enabled).toBe(false);\n expect(json?.replays?.enabled).toBe(true);\n });\n\n it(\"preserves top-level analytics options when deserializing replay block classes\", () => {\n const roundTripped = analyticsOptionsFromJson(analyticsOptionsToJson({\n enabled: false,\n replays: {\n blockClass: /stack-sensitive/u,\n },\n }));\n\n expect(roundTripped?.enabled).toBe(false);\n expect(roundTripped?.replays?.blockClass).toEqual(/stack-sensitive/u);\n });\n});\n\ndescribe(\"SessionRecorder flush\", () => {\n it(\"silently ignores network errors caused by ad blockers\", async () => {\n vi.useFakeTimers();\n\n const storageKey = `hexclave:session-replay:v1:test-project`;\n localStorage.setItem(storageKey, JSON.stringify({\n session_id: \"test-session\",\n created_at_ms: Date.now(),\n last_activity_ms: Date.now(),\n }));\n\n const sentBodies: string[] = [];\n const recorder = new SessionRecorder(\n {\n projectId: \"test-project\",\n sendBatch: async (body) => {\n sentBodies.push(body);\n return Result.error(new TypeError(\"Failed to fetch\"));\n },\n },\n {},\n );\n\n const warnSpy = vi.spyOn(console, \"warn\").mockImplementation(() => {});\n\n try {\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._events = [{ type: 2, timestamp: Date.now(), data: {} }];\n\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call\n (recorder as any)._tick();\n await vi.advanceTimersByTimeAsync(0);\n\n expect(sentBodies).toHaveLength(1);\n expect(warnSpy).not.toHaveBeenCalled();\n\n // Unlike ANALYTICS_NOT_ENABLED, ad blocker errors do NOT disable the\n // recorder — subsequent flushes continue attempting delivery.\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._events = [{ type: 3, timestamp: Date.now(), data: {} }];\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call\n (recorder as any)._tick();\n await vi.advanceTimersByTimeAsync(0);\n expect(sentBodies).toHaveLength(2);\n expect(warnSpy).not.toHaveBeenCalled();\n } finally {\n recorder.stop();\n warnSpy.mockRestore();\n localStorage.removeItem(storageKey);\n vi.useRealTimers();\n }\n });\n\n it(\"silently disables when client interface returns ANALYTICS_NOT_ENABLED as an error\", async () => {\n vi.useFakeTimers();\n\n const storageKey = `hexclave:session-replay:v1:test-project`;\n localStorage.setItem(storageKey, JSON.stringify({\n session_id: \"test-session\",\n created_at_ms: Date.now(),\n last_activity_ms: Date.now(),\n }));\n\n const sentBodies: string[] = [];\n const recorder = new SessionRecorder(\n {\n projectId: \"test-project\",\n sendBatch: async (body) => {\n sentBodies.push(body);\n return Result.error(new KnownErrors.AnalyticsNotEnabled());\n },\n },\n {},\n );\n\n const warnSpy = vi.spyOn(console, \"warn\").mockImplementation(() => {});\n\n try {\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._events = [{ type: 2, timestamp: Date.now(), data: {} }];\n\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call\n (recorder as any)._tick();\n await vi.advanceTimersByTimeAsync(0);\n\n expect(sentBodies).toHaveLength(1);\n expect(warnSpy).not.toHaveBeenCalled();\n\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._events = [{ type: 3, timestamp: Date.now(), data: {} }];\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call\n (recorder as any)._tick();\n await vi.advanceTimersByTimeAsync(0);\n expect(sentBodies).toHaveLength(1);\n } finally {\n recorder.stop();\n warnSpy.mockRestore();\n localStorage.removeItem(storageKey);\n vi.useRealTimers();\n }\n });\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"session-replay.test.js","names":[],"sources":["../../../../../../src/lib/hexclave-app/apps/implementations/session-replay.test.ts"],"sourcesContent":["\n//===========================================\n// THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template\n//===========================================\n// @vitest-environment jsdom\n\nimport { KnownErrors } from \"@hexclave/shared/dist/known-errors\";\nimport { describe, expect, it, vi } from \"vitest\";\nimport { Result } from \"@hexclave/shared/dist/utils/results\";\nimport { analyticsOptionsFromJson, analyticsOptionsToJson, getSessionReplayOptions, SessionRecorder } from \"./session-replay\";\n\ndescribe(\"session replay options\", () => {\n it(\"enables replays by default\", () => {\n expect(getSessionReplayOptions(undefined).enabled).toBe(true);\n expect(getSessionReplayOptions({}).enabled).toBe(true);\n expect(getSessionReplayOptions({ replays: {} }).enabled).toBe(true);\n });\n\n it(\"preserves explicit replay opt-out\", () => {\n expect(getSessionReplayOptions({ replays: { enabled: false } }).enabled).toBe(false);\n });\n});\n\ndescribe(\"analytics option JSON conversion\", () => {\n it(\"preserves top-level analytics options when serializing replay block classes\", () => {\n const json = analyticsOptionsToJson({\n enabled: false,\n replays: {\n enabled: true,\n blockClass: /stack-sensitive/u,\n },\n });\n\n expect(json?.enabled).toBe(false);\n expect(json?.replays?.enabled).toBe(true);\n });\n\n it(\"preserves top-level analytics options when deserializing replay block classes\", () => {\n const roundTripped = analyticsOptionsFromJson(analyticsOptionsToJson({\n enabled: false,\n replays: {\n blockClass: /stack-sensitive/u,\n },\n }));\n\n expect(roundTripped?.enabled).toBe(false);\n expect(roundTripped?.replays?.blockClass).toEqual(/stack-sensitive/u);\n });\n});\n\ndescribe(\"SessionRecorder flush\", () => {\n it(\"silently ignores network errors caused by ad blockers\", async () => {\n vi.useFakeTimers();\n\n const storageKey = `hexclave:session-replay:v1:test-project`;\n localStorage.setItem(storageKey, JSON.stringify({\n session_id: \"test-session\",\n created_at_ms: Date.now(),\n last_activity_ms: Date.now(),\n }));\n\n const sentBodies: string[] = [];\n const recorder = new SessionRecorder(\n {\n projectId: \"test-project\",\n sendBatch: async (body) => {\n sentBodies.push(body);\n return Result.error(new TypeError(\"Failed to fetch\"));\n },\n },\n {},\n );\n\n const warnSpy = vi.spyOn(console, \"warn\").mockImplementation(() => {});\n\n try {\n const event1 = { type: 2, timestamp: Date.now(), data: {} };\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._events = [event1];\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._eventSizes = [JSON.stringify(event1).length];\n\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call\n (recorder as any)._tick();\n await vi.advanceTimersByTimeAsync(0);\n\n expect(sentBodies).toHaveLength(1);\n expect(warnSpy).not.toHaveBeenCalled();\n\n // Unlike ANALYTICS_NOT_ENABLED, ad blocker errors do NOT disable the\n // recorder — subsequent flushes continue attempting delivery.\n const event2 = { type: 3, timestamp: Date.now(), data: {} };\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._events = [event2];\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._eventSizes = [JSON.stringify(event2).length];\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call\n (recorder as any)._tick();\n await vi.advanceTimersByTimeAsync(0);\n expect(sentBodies).toHaveLength(2);\n expect(warnSpy).not.toHaveBeenCalled();\n } finally {\n recorder.stop();\n warnSpy.mockRestore();\n localStorage.removeItem(storageKey);\n vi.useRealTimers();\n }\n });\n\n it(\"splits large batches into multiple requests to stay under server 1MB limit\", async () => {\n vi.useFakeTimers();\n\n const storageKey = `hexclave:session-replay:v1:test-project`;\n localStorage.setItem(storageKey, JSON.stringify({\n session_id: \"test-session\",\n created_at_ms: Date.now(),\n last_activity_ms: Date.now(),\n }));\n\n const sentBodies: string[] = [];\n const recorder = new SessionRecorder(\n {\n projectId: \"test-project\",\n sendBatch: async (body) => {\n sentBodies.push(body);\n return Result.ok(new Response(\"ok\", { status: 200 }));\n },\n },\n {},\n );\n\n try {\n // Create events that together exceed 900KB (the per-batch cap).\n // Each event is ~500KB, so two events (~1MB) must be split into two batches.\n const largeData = \"x\".repeat(500_000);\n const event1 = { type: 2, timestamp: Date.now(), data: largeData };\n const event2 = { type: 3, timestamp: Date.now(), data: largeData };\n\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._events = [event1, event2];\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._eventSizes = [JSON.stringify(event1).length, JSON.stringify(event2).length];\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._approxBytes = JSON.stringify(event1).length + JSON.stringify(event2).length;\n\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call\n (recorder as any)._tick();\n await vi.advanceTimersByTimeAsync(0);\n\n // Should have sent two separate batches\n expect(sentBodies).toHaveLength(2);\n\n // Each batch should contain exactly one event\n const batch1 = JSON.parse(sentBodies[0]);\n const batch2 = JSON.parse(sentBodies[1]);\n expect(batch1.events).toHaveLength(1);\n expect(batch2.events).toHaveLength(1);\n\n // They should have different batch IDs\n expect(batch1.batch_id).not.toBe(batch2.batch_id);\n } finally {\n recorder.stop();\n localStorage.removeItem(storageKey);\n vi.useRealTimers();\n }\n });\n\n it(\"sends a single oversized event alone without dropping it\", async () => {\n vi.useFakeTimers();\n\n const storageKey = `hexclave:session-replay:v1:test-project`;\n localStorage.setItem(storageKey, JSON.stringify({\n session_id: \"test-session\",\n created_at_ms: Date.now(),\n last_activity_ms: Date.now(),\n }));\n\n const sentBodies: string[] = [];\n const recorder = new SessionRecorder(\n {\n projectId: \"test-project\",\n sendBatch: async (body) => {\n sentBodies.push(body);\n return Result.ok(new Response(\"ok\", { status: 200 }));\n },\n },\n {},\n );\n\n try {\n // A single event larger than 900KB — should still be sent (not dropped)\n const hugeData = \"y\".repeat(1_000_000);\n const hugeEvent = { type: 2, timestamp: Date.now(), data: hugeData };\n\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._events = [hugeEvent];\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._eventSizes = [JSON.stringify(hugeEvent).length];\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._approxBytes = JSON.stringify(hugeEvent).length;\n\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call\n (recorder as any)._tick();\n await vi.advanceTimersByTimeAsync(0);\n\n // Should still send the event (the server may reject it, but we don't drop it client-side)\n expect(sentBodies).toHaveLength(1);\n const batch = JSON.parse(sentBodies[0]);\n expect(batch.events).toHaveLength(1);\n } finally {\n recorder.stop();\n localStorage.removeItem(storageKey);\n vi.useRealTimers();\n }\n });\n\n it(\"silently disables when client interface returns ANALYTICS_NOT_ENABLED as an error\", async () => {\n vi.useFakeTimers();\n\n const storageKey = `hexclave:session-replay:v1:test-project`;\n localStorage.setItem(storageKey, JSON.stringify({\n session_id: \"test-session\",\n created_at_ms: Date.now(),\n last_activity_ms: Date.now(),\n }));\n\n const sentBodies: string[] = [];\n const recorder = new SessionRecorder(\n {\n projectId: \"test-project\",\n sendBatch: async (body) => {\n sentBodies.push(body);\n return Result.error(new KnownErrors.AnalyticsNotEnabled());\n },\n },\n {},\n );\n\n const warnSpy = vi.spyOn(console, \"warn\").mockImplementation(() => {});\n\n try {\n const event1 = { type: 2, timestamp: Date.now(), data: {} };\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._events = [event1];\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._eventSizes = [JSON.stringify(event1).length];\n\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call\n (recorder as any)._tick();\n await vi.advanceTimersByTimeAsync(0);\n\n expect(sentBodies).toHaveLength(1);\n expect(warnSpy).not.toHaveBeenCalled();\n\n const event2 = { type: 3, timestamp: Date.now(), data: {} };\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._events = [event2];\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n (recorder as any)._eventSizes = [JSON.stringify(event2).length];\n // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call\n (recorder as any)._tick();\n await vi.advanceTimersByTimeAsync(0);\n expect(sentBodies).toHaveLength(1);\n } finally {\n recorder.stop();\n warnSpy.mockRestore();\n localStorage.removeItem(storageKey);\n vi.useRealTimers();\n }\n });\n});\n"],"mappings":";;;;;;;AAWA,SAAS,gCAAgC;AACvC,IAAG,oCAAoC;AACrC,SAAO,wBAAwB,OAAU,CAAC,QAAQ,CAAC,KAAK,KAAK;AAC7D,SAAO,wBAAwB,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,KAAK;AACtD,SAAO,wBAAwB,EAAE,SAAS,EAAE,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,KAAK;GACnE;AAEF,IAAG,2CAA2C;AAC5C,SAAO,wBAAwB,EAAE,SAAS,EAAE,SAAS,OAAO,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,MAAM;GACpF;EACF;AAEF,SAAS,0CAA0C;AACjD,IAAG,qFAAqF;EACtF,MAAM,OAAO,uBAAuB;GAClC,SAAS;GACT,SAAS;IACP,SAAS;IACT,YAAY;IACb;GACF,CAAC;AAEF,SAAO,MAAM,QAAQ,CAAC,KAAK,MAAM;AACjC,SAAO,MAAM,SAAS,QAAQ,CAAC,KAAK,KAAK;GACzC;AAEF,IAAG,uFAAuF;EACxF,MAAM,eAAe,yBAAyB,uBAAuB;GACnE,SAAS;GACT,SAAS,EACP,YAAY,oBACb;GACF,CAAC,CAAC;AAEH,SAAO,cAAc,QAAQ,CAAC,KAAK,MAAM;AACzC,SAAO,cAAc,SAAS,WAAW,CAAC,QAAQ,mBAAmB;GACrE;EACF;AAEF,SAAS,+BAA+B;AACtC,IAAG,yDAAyD,YAAY;AACtE,KAAG,eAAe;EAElB,MAAM,aAAa;AACnB,eAAa,QAAQ,YAAY,KAAK,UAAU;GAC9C,YAAY;GACZ,eAAe,KAAK,KAAK;GACzB,kBAAkB,KAAK,KAAK;GAC7B,CAAC,CAAC;EAEH,MAAM,aAAuB,EAAE;EAC/B,MAAM,WAAW,IAAI,gBACnB;GACE,WAAW;GACX,WAAW,OAAO,SAAS;AACzB,eAAW,KAAK,KAAK;AACrB,WAAO,OAAO,sBAAM,IAAI,UAAU,kBAAkB,CAAC;;GAExD,EACD,EAAE,CACH;EAED,MAAM,UAAU,GAAG,MAAM,SAAS,OAAO,CAAC,yBAAyB,GAAG;AAEtE,MAAI;GACF,MAAM,SAAS;IAAE,MAAM;IAAG,WAAW,KAAK,KAAK;IAAE,MAAM,EAAE;IAAE;AAE3D,GAAC,SAAiB,UAAU,CAAC,OAAO;AAEpC,GAAC,SAAiB,cAAc,CAAC,KAAK,UAAU,OAAO,CAAC,OAAO;AAG/D,GAAC,SAAiB,OAAO;AACzB,SAAM,GAAG,yBAAyB,EAAE;AAEpC,UAAO,WAAW,CAAC,aAAa,EAAE;AAClC,UAAO,QAAQ,CAAC,IAAI,kBAAkB;GAItC,MAAM,SAAS;IAAE,MAAM;IAAG,WAAW,KAAK,KAAK;IAAE,MAAM,EAAE;IAAE;AAE3D,GAAC,SAAiB,UAAU,CAAC,OAAO;AAEpC,GAAC,SAAiB,cAAc,CAAC,KAAK,UAAU,OAAO,CAAC,OAAO;AAE/D,GAAC,SAAiB,OAAO;AACzB,SAAM,GAAG,yBAAyB,EAAE;AACpC,UAAO,WAAW,CAAC,aAAa,EAAE;AAClC,UAAO,QAAQ,CAAC,IAAI,kBAAkB;YAC9B;AACR,YAAS,MAAM;AACf,WAAQ,aAAa;AACrB,gBAAa,WAAW,WAAW;AACnC,MAAG,eAAe;;GAEpB;AAEF,IAAG,8EAA8E,YAAY;AAC3F,KAAG,eAAe;EAElB,MAAM,aAAa;AACnB,eAAa,QAAQ,YAAY,KAAK,UAAU;GAC9C,YAAY;GACZ,eAAe,KAAK,KAAK;GACzB,kBAAkB,KAAK,KAAK;GAC7B,CAAC,CAAC;EAEH,MAAM,aAAuB,EAAE;EAC/B,MAAM,WAAW,IAAI,gBACnB;GACE,WAAW;GACX,WAAW,OAAO,SAAS;AACzB,eAAW,KAAK,KAAK;AACrB,WAAO,OAAO,GAAG,IAAI,SAAS,MAAM,EAAE,QAAQ,KAAK,CAAC,CAAC;;GAExD,EACD,EAAE,CACH;AAED,MAAI;GAGF,MAAM,YAAY,IAAI,OAAO,IAAQ;GACrC,MAAM,SAAS;IAAE,MAAM;IAAG,WAAW,KAAK,KAAK;IAAE,MAAM;IAAW;GAClE,MAAM,SAAS;IAAE,MAAM;IAAG,WAAW,KAAK,KAAK;IAAE,MAAM;IAAW;AAGlE,GAAC,SAAiB,UAAU,CAAC,QAAQ,OAAO;AAE5C,GAAC,SAAiB,cAAc,CAAC,KAAK,UAAU,OAAO,CAAC,QAAQ,KAAK,UAAU,OAAO,CAAC,OAAO;AAE9F,GAAC,SAAiB,eAAe,KAAK,UAAU,OAAO,CAAC,SAAS,KAAK,UAAU,OAAO,CAAC;AAGxF,GAAC,SAAiB,OAAO;AACzB,SAAM,GAAG,yBAAyB,EAAE;AAGpC,UAAO,WAAW,CAAC,aAAa,EAAE;GAGlC,MAAM,SAAS,KAAK,MAAM,WAAW,GAAG;GACxC,MAAM,SAAS,KAAK,MAAM,WAAW,GAAG;AACxC,UAAO,OAAO,OAAO,CAAC,aAAa,EAAE;AACrC,UAAO,OAAO,OAAO,CAAC,aAAa,EAAE;AAGrC,UAAO,OAAO,SAAS,CAAC,IAAI,KAAK,OAAO,SAAS;YACzC;AACR,YAAS,MAAM;AACf,gBAAa,WAAW,WAAW;AACnC,MAAG,eAAe;;GAEpB;AAEF,IAAG,4DAA4D,YAAY;AACzE,KAAG,eAAe;EAElB,MAAM,aAAa;AACnB,eAAa,QAAQ,YAAY,KAAK,UAAU;GAC9C,YAAY;GACZ,eAAe,KAAK,KAAK;GACzB,kBAAkB,KAAK,KAAK;GAC7B,CAAC,CAAC;EAEH,MAAM,aAAuB,EAAE;EAC/B,MAAM,WAAW,IAAI,gBACnB;GACE,WAAW;GACX,WAAW,OAAO,SAAS;AACzB,eAAW,KAAK,KAAK;AACrB,WAAO,OAAO,GAAG,IAAI,SAAS,MAAM,EAAE,QAAQ,KAAK,CAAC,CAAC;;GAExD,EACD,EAAE,CACH;AAED,MAAI;GAEF,MAAM,WAAW,IAAI,OAAO,IAAU;GACtC,MAAM,YAAY;IAAE,MAAM;IAAG,WAAW,KAAK,KAAK;IAAE,MAAM;IAAU;AAGpE,GAAC,SAAiB,UAAU,CAAC,UAAU;AAEvC,GAAC,SAAiB,cAAc,CAAC,KAAK,UAAU,UAAU,CAAC,OAAO;AAElE,GAAC,SAAiB,eAAe,KAAK,UAAU,UAAU,CAAC;AAG3D,GAAC,SAAiB,OAAO;AACzB,SAAM,GAAG,yBAAyB,EAAE;AAGpC,UAAO,WAAW,CAAC,aAAa,EAAE;AAElC,UADc,KAAK,MAAM,WAAW,GAAG,CAC1B,OAAO,CAAC,aAAa,EAAE;YAC5B;AACR,YAAS,MAAM;AACf,gBAAa,WAAW,WAAW;AACnC,MAAG,eAAe;;GAEpB;AAEF,IAAG,qFAAqF,YAAY;AAClG,KAAG,eAAe;EAElB,MAAM,aAAa;AACnB,eAAa,QAAQ,YAAY,KAAK,UAAU;GAC9C,YAAY;GACZ,eAAe,KAAK,KAAK;GACzB,kBAAkB,KAAK,KAAK;GAC7B,CAAC,CAAC;EAEH,MAAM,aAAuB,EAAE;EAC/B,MAAM,WAAW,IAAI,gBACnB;GACE,WAAW;GACX,WAAW,OAAO,SAAS;AACzB,eAAW,KAAK,KAAK;AACrB,WAAO,OAAO,MAAM,IAAI,YAAY,qBAAqB,CAAC;;GAE7D,EACD,EAAE,CACH;EAED,MAAM,UAAU,GAAG,MAAM,SAAS,OAAO,CAAC,yBAAyB,GAAG;AAEtE,MAAI;GACF,MAAM,SAAS;IAAE,MAAM;IAAG,WAAW,KAAK,KAAK;IAAE,MAAM,EAAE;IAAE;AAE3D,GAAC,SAAiB,UAAU,CAAC,OAAO;AAEpC,GAAC,SAAiB,cAAc,CAAC,KAAK,UAAU,OAAO,CAAC,OAAO;AAG/D,GAAC,SAAiB,OAAO;AACzB,SAAM,GAAG,yBAAyB,EAAE;AAEpC,UAAO,WAAW,CAAC,aAAa,EAAE;AAClC,UAAO,QAAQ,CAAC,IAAI,kBAAkB;GAEtC,MAAM,SAAS;IAAE,MAAM;IAAG,WAAW,KAAK,KAAK;IAAE,MAAM,EAAE;IAAE;AAE3D,GAAC,SAAiB,UAAU,CAAC,OAAO;AAEpC,GAAC,SAAiB,cAAc,CAAC,KAAK,UAAU,OAAO,CAAC,OAAO;AAE/D,GAAC,SAAiB,OAAO;AACzB,SAAM,GAAG,yBAAyB,EAAE;AACpC,UAAO,WAAW,CAAC,aAAa,EAAE;YAC1B;AACR,YAAS,MAAM;AACf,WAAQ,aAAa;AACrB,gBAAa,WAAW,WAAW;AACnC,MAAG,eAAe;;GAEpB;EACF"}