@hexclave/js 1.0.26 → 1.0.28

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 (29) hide show
  1. package/dist/dev-tool/dev-tool-core.js +1 -1
  2. package/dist/dev-tool/dev-tool-core.js.map +1 -1
  3. package/dist/esm/dev-tool/dev-tool-core.js +1 -1
  4. package/dist/esm/dev-tool/dev-tool-core.js.map +1 -1
  5. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  6. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.js +6 -1
  7. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.js.map +1 -1
  8. package/dist/esm/lib/hexclave-app/apps/implementations/common.js +1 -1
  9. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.d.ts +1 -0
  10. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.d.ts.map +1 -1
  11. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.js +44 -19
  12. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.js.map +1 -1
  13. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.test.js +97 -8
  14. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.test.js.map +1 -1
  15. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  16. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.js +6 -1
  17. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.js.map +1 -1
  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/dev-tool/dev-tool-core.ts +1 -1
  27. package/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts +2 -1
  28. package/src/lib/hexclave-app/apps/implementations/session-replay.test.ts +123 -4
  29. package/src/lib/hexclave-app/apps/implementations/session-replay.ts +63 -29
@@ -56,20 +56,24 @@ let _hexclave_shared_dist_known_errors = require("@hexclave/shared/dist/known-er
56
56
  }, {});
57
57
  const warnSpy = vitest.vi.spyOn(console, "warn").mockImplementation(() => {});
58
58
  try {
59
- recorder._events = [{
59
+ const event1 = {
60
60
  type: 2,
61
61
  timestamp: Date.now(),
62
62
  data: {}
63
- }];
63
+ };
64
+ recorder._events = [event1];
65
+ recorder._eventSizes = [JSON.stringify(event1).length];
64
66
  recorder._tick();
65
67
  await vitest.vi.advanceTimersByTimeAsync(0);
66
68
  (0, vitest.expect)(sentBodies).toHaveLength(1);
67
69
  (0, vitest.expect)(warnSpy).not.toHaveBeenCalled();
68
- recorder._events = [{
70
+ const event2 = {
69
71
  type: 3,
70
72
  timestamp: Date.now(),
71
73
  data: {}
72
- }];
74
+ };
75
+ recorder._events = [event2];
76
+ recorder._eventSizes = [JSON.stringify(event2).length];
73
77
  recorder._tick();
74
78
  await vitest.vi.advanceTimersByTimeAsync(0);
75
79
  (0, vitest.expect)(sentBodies).toHaveLength(2);
@@ -81,6 +85,87 @@ let _hexclave_shared_dist_known_errors = require("@hexclave/shared/dist/known-er
81
85
  vitest.vi.useRealTimers();
82
86
  }
83
87
  });
88
+ (0, vitest.it)("splits large batches into multiple requests to stay under server 1MB limit", async () => {
89
+ vitest.vi.useFakeTimers();
90
+ const storageKey = `hexclave:session-replay:v1:test-project`;
91
+ localStorage.setItem(storageKey, JSON.stringify({
92
+ session_id: "test-session",
93
+ created_at_ms: Date.now(),
94
+ last_activity_ms: Date.now()
95
+ }));
96
+ const sentBodies = [];
97
+ const recorder = new __session_replay_js.SessionRecorder({
98
+ projectId: "test-project",
99
+ sendBatch: async (body) => {
100
+ sentBodies.push(body);
101
+ return _hexclave_shared_dist_utils_results.Result.ok(new Response("ok", { status: 200 }));
102
+ }
103
+ }, {});
104
+ try {
105
+ const largeData = "x".repeat(5e5);
106
+ const event1 = {
107
+ type: 2,
108
+ timestamp: Date.now(),
109
+ data: largeData
110
+ };
111
+ const event2 = {
112
+ type: 3,
113
+ timestamp: Date.now(),
114
+ data: largeData
115
+ };
116
+ recorder._events = [event1, event2];
117
+ recorder._eventSizes = [JSON.stringify(event1).length, JSON.stringify(event2).length];
118
+ recorder._approxBytes = JSON.stringify(event1).length + JSON.stringify(event2).length;
119
+ recorder._tick();
120
+ await vitest.vi.advanceTimersByTimeAsync(0);
121
+ (0, vitest.expect)(sentBodies).toHaveLength(2);
122
+ const batch1 = JSON.parse(sentBodies[0]);
123
+ const batch2 = JSON.parse(sentBodies[1]);
124
+ (0, vitest.expect)(batch1.events).toHaveLength(1);
125
+ (0, vitest.expect)(batch2.events).toHaveLength(1);
126
+ (0, vitest.expect)(batch1.batch_id).not.toBe(batch2.batch_id);
127
+ } finally {
128
+ recorder.stop();
129
+ localStorage.removeItem(storageKey);
130
+ vitest.vi.useRealTimers();
131
+ }
132
+ });
133
+ (0, vitest.it)("sends a single oversized event alone without dropping it", async () => {
134
+ vitest.vi.useFakeTimers();
135
+ const storageKey = `hexclave:session-replay:v1:test-project`;
136
+ localStorage.setItem(storageKey, JSON.stringify({
137
+ session_id: "test-session",
138
+ created_at_ms: Date.now(),
139
+ last_activity_ms: Date.now()
140
+ }));
141
+ const sentBodies = [];
142
+ const recorder = new __session_replay_js.SessionRecorder({
143
+ projectId: "test-project",
144
+ sendBatch: async (body) => {
145
+ sentBodies.push(body);
146
+ return _hexclave_shared_dist_utils_results.Result.ok(new Response("ok", { status: 200 }));
147
+ }
148
+ }, {});
149
+ try {
150
+ const hugeData = "y".repeat(1e6);
151
+ const hugeEvent = {
152
+ type: 2,
153
+ timestamp: Date.now(),
154
+ data: hugeData
155
+ };
156
+ recorder._events = [hugeEvent];
157
+ recorder._eventSizes = [JSON.stringify(hugeEvent).length];
158
+ recorder._approxBytes = JSON.stringify(hugeEvent).length;
159
+ recorder._tick();
160
+ await vitest.vi.advanceTimersByTimeAsync(0);
161
+ (0, vitest.expect)(sentBodies).toHaveLength(1);
162
+ (0, vitest.expect)(JSON.parse(sentBodies[0]).events).toHaveLength(1);
163
+ } finally {
164
+ recorder.stop();
165
+ localStorage.removeItem(storageKey);
166
+ vitest.vi.useRealTimers();
167
+ }
168
+ });
84
169
  (0, vitest.it)("silently disables when client interface returns ANALYTICS_NOT_ENABLED as an error", async () => {
85
170
  vitest.vi.useFakeTimers();
86
171
  const storageKey = `hexclave:session-replay:v1:test-project`;
@@ -99,20 +184,24 @@ let _hexclave_shared_dist_known_errors = require("@hexclave/shared/dist/known-er
99
184
  }, {});
100
185
  const warnSpy = vitest.vi.spyOn(console, "warn").mockImplementation(() => {});
101
186
  try {
102
- recorder._events = [{
187
+ const event1 = {
103
188
  type: 2,
104
189
  timestamp: Date.now(),
105
190
  data: {}
106
- }];
191
+ };
192
+ recorder._events = [event1];
193
+ recorder._eventSizes = [JSON.stringify(event1).length];
107
194
  recorder._tick();
108
195
  await vitest.vi.advanceTimersByTimeAsync(0);
109
196
  (0, vitest.expect)(sentBodies).toHaveLength(1);
110
197
  (0, vitest.expect)(warnSpy).not.toHaveBeenCalled();
111
- recorder._events = [{
198
+ const event2 = {
112
199
  type: 3,
113
200
  timestamp: Date.now(),
114
201
  data: {}
115
- }];
202
+ };
203
+ recorder._events = [event2];
204
+ recorder._eventSizes = [JSON.stringify(event2).length];
116
205
  recorder._tick();
117
206
  await vitest.vi.advanceTimersByTimeAsync(0);
118
207
  (0, vitest.expect)(sentBodies).toHaveLength(1);
@@ -1 +1 @@
1
- {"version":3,"file":"session-replay.test.js","names":["SessionRecorder","Result","vi","KnownErrors"],"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":["SessionRecorder","Result","vi","KnownErrors"],"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":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
3
3
  "name": "@hexclave/js",
4
- "version": "1.0.26",
4
+ "version": "1.0.28",
5
5
  "repository": "https://github.com/hexclave/hexclave",
6
6
  "sideEffects": false,
7
7
  "main": "./dist/index.js",
@@ -64,9 +64,8 @@
64
64
  "@oslojs/otp": "^1.1.0",
65
65
  "qrcode": "^1.5.4",
66
66
  "rrweb": "^1.1.3",
67
- "tsx": "^4.21.0",
68
67
  "yup": "^1.7.1",
69
- "@hexclave/shared": "1.0.26"
68
+ "@hexclave/shared": "1.0.28"
70
69
  },
71
70
  "devDependencies": {
72
71
  "@quetzallabs/i18n": "^0.1.19",
@@ -86,6 +85,7 @@
86
85
  "rimraf": "^6.1.2",
87
86
  "tailwindcss": "^3.4.4",
88
87
  "tsdown": "^0.20.3",
88
+ "tsx": "^4.21.0",
89
89
  "convex": "^1.27.0"
90
90
  },
91
91
  "scripts": {
@@ -248,7 +248,7 @@ function generateRandomEmail(): string {
248
248
  for (let i = 0; i < 8; i++) {
249
249
  id += chars[Math.floor(Math.random() * chars.length)];
250
250
  }
251
- return `dev-${id}@test.hexclave.com`;
251
+ return `dev-${id}@devtool-quick-sign-up.example.com`;
252
252
  }
253
253
 
254
254
  // ---------------------------------------------------------------------------
@@ -717,7 +717,8 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
717
717
  }
718
718
 
719
719
  async getEmailPreview(options: { themeId?: string | null | false, themeTsxSource?: string, templateId?: string, templateTsxSource?: string }): Promise<string> {
720
- return (await this._interface.renderEmailPreview(options)).html;
720
+ const result = Result.orThrow(await this._emailPreviewCache.getOrWait([options.themeId, options.themeTsxSource, options.templateId, options.templateTsxSource], "write-only"));
721
+ return result.html;
721
722
  }
722
723
  async getEmailPreviewWithEditableMarkers(options: { themeId?: string | null | false, themeTsxSource?: string, templateId?: string, templateTsxSource?: string, editableSource?: 'template' | 'theme' | 'both' }): Promise<{ html: string, editableRegions?: Record<string, unknown> }> {
723
724
  const result = await this._interface.renderEmailPreview({ ...options, editableMarkers: true, editableSource: options.editableSource });
@@ -74,8 +74,11 @@ describe("SessionRecorder flush", () => {
74
74
  const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
75
75
 
76
76
  try {
77
+ const event1 = { type: 2, timestamp: Date.now(), data: {} };
77
78
  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
78
- (recorder as any)._events = [{ type: 2, timestamp: Date.now(), data: {} }];
79
+ (recorder as any)._events = [event1];
80
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
81
+ (recorder as any)._eventSizes = [JSON.stringify(event1).length];
79
82
 
80
83
  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
81
84
  (recorder as any)._tick();
@@ -86,8 +89,11 @@ describe("SessionRecorder flush", () => {
86
89
 
87
90
  // Unlike ANALYTICS_NOT_ENABLED, ad blocker errors do NOT disable the
88
91
  // recorder — subsequent flushes continue attempting delivery.
92
+ const event2 = { type: 3, timestamp: Date.now(), data: {} };
93
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
94
+ (recorder as any)._events = [event2];
89
95
  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
90
- (recorder as any)._events = [{ type: 3, timestamp: Date.now(), data: {} }];
96
+ (recorder as any)._eventSizes = [JSON.stringify(event2).length];
91
97
  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
92
98
  (recorder as any)._tick();
93
99
  await vi.advanceTimersByTimeAsync(0);
@@ -101,6 +107,113 @@ describe("SessionRecorder flush", () => {
101
107
  }
102
108
  });
103
109
 
110
+ it("splits large batches into multiple requests to stay under server 1MB limit", async () => {
111
+ vi.useFakeTimers();
112
+
113
+ const storageKey = `hexclave:session-replay:v1:test-project`;
114
+ localStorage.setItem(storageKey, JSON.stringify({
115
+ session_id: "test-session",
116
+ created_at_ms: Date.now(),
117
+ last_activity_ms: Date.now(),
118
+ }));
119
+
120
+ const sentBodies: string[] = [];
121
+ const recorder = new SessionRecorder(
122
+ {
123
+ projectId: "test-project",
124
+ sendBatch: async (body) => {
125
+ sentBodies.push(body);
126
+ return Result.ok(new Response("ok", { status: 200 }));
127
+ },
128
+ },
129
+ {},
130
+ );
131
+
132
+ try {
133
+ // Create events that together exceed 900KB (the per-batch cap).
134
+ // Each event is ~500KB, so two events (~1MB) must be split into two batches.
135
+ const largeData = "x".repeat(500_000);
136
+ const event1 = { type: 2, timestamp: Date.now(), data: largeData };
137
+ const event2 = { type: 3, timestamp: Date.now(), data: largeData };
138
+
139
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
140
+ (recorder as any)._events = [event1, event2];
141
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
142
+ (recorder as any)._eventSizes = [JSON.stringify(event1).length, JSON.stringify(event2).length];
143
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
144
+ (recorder as any)._approxBytes = JSON.stringify(event1).length + JSON.stringify(event2).length;
145
+
146
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
147
+ (recorder as any)._tick();
148
+ await vi.advanceTimersByTimeAsync(0);
149
+
150
+ // Should have sent two separate batches
151
+ expect(sentBodies).toHaveLength(2);
152
+
153
+ // Each batch should contain exactly one event
154
+ const batch1 = JSON.parse(sentBodies[0]);
155
+ const batch2 = JSON.parse(sentBodies[1]);
156
+ expect(batch1.events).toHaveLength(1);
157
+ expect(batch2.events).toHaveLength(1);
158
+
159
+ // They should have different batch IDs
160
+ expect(batch1.batch_id).not.toBe(batch2.batch_id);
161
+ } finally {
162
+ recorder.stop();
163
+ localStorage.removeItem(storageKey);
164
+ vi.useRealTimers();
165
+ }
166
+ });
167
+
168
+ it("sends a single oversized event alone without dropping it", async () => {
169
+ vi.useFakeTimers();
170
+
171
+ const storageKey = `hexclave:session-replay:v1:test-project`;
172
+ localStorage.setItem(storageKey, JSON.stringify({
173
+ session_id: "test-session",
174
+ created_at_ms: Date.now(),
175
+ last_activity_ms: Date.now(),
176
+ }));
177
+
178
+ const sentBodies: string[] = [];
179
+ const recorder = new SessionRecorder(
180
+ {
181
+ projectId: "test-project",
182
+ sendBatch: async (body) => {
183
+ sentBodies.push(body);
184
+ return Result.ok(new Response("ok", { status: 200 }));
185
+ },
186
+ },
187
+ {},
188
+ );
189
+
190
+ try {
191
+ // A single event larger than 900KB — should still be sent (not dropped)
192
+ const hugeData = "y".repeat(1_000_000);
193
+ const hugeEvent = { type: 2, timestamp: Date.now(), data: hugeData };
194
+
195
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
196
+ (recorder as any)._events = [hugeEvent];
197
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
198
+ (recorder as any)._eventSizes = [JSON.stringify(hugeEvent).length];
199
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
200
+ (recorder as any)._approxBytes = JSON.stringify(hugeEvent).length;
201
+
202
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
203
+ (recorder as any)._tick();
204
+ await vi.advanceTimersByTimeAsync(0);
205
+
206
+ // Should still send the event (the server may reject it, but we don't drop it client-side)
207
+ expect(sentBodies).toHaveLength(1);
208
+ const batch = JSON.parse(sentBodies[0]);
209
+ expect(batch.events).toHaveLength(1);
210
+ } finally {
211
+ recorder.stop();
212
+ localStorage.removeItem(storageKey);
213
+ vi.useRealTimers();
214
+ }
215
+ });
216
+
104
217
  it("silently disables when client interface returns ANALYTICS_NOT_ENABLED as an error", async () => {
105
218
  vi.useFakeTimers();
106
219
 
@@ -126,8 +239,11 @@ describe("SessionRecorder flush", () => {
126
239
  const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
127
240
 
128
241
  try {
242
+ const event1 = { type: 2, timestamp: Date.now(), data: {} };
129
243
  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
130
- (recorder as any)._events = [{ type: 2, timestamp: Date.now(), data: {} }];
244
+ (recorder as any)._events = [event1];
245
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
246
+ (recorder as any)._eventSizes = [JSON.stringify(event1).length];
131
247
 
132
248
  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
133
249
  (recorder as any)._tick();
@@ -136,8 +252,11 @@ describe("SessionRecorder flush", () => {
136
252
  expect(sentBodies).toHaveLength(1);
137
253
  expect(warnSpy).not.toHaveBeenCalled();
138
254
 
255
+ const event2 = { type: 3, timestamp: Date.now(), data: {} };
256
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
257
+ (recorder as any)._events = [event2];
139
258
  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
140
- (recorder as any)._events = [{ type: 3, timestamp: Date.now(), data: {} }];
259
+ (recorder as any)._eventSizes = [JSON.stringify(event2).length];
141
260
  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
142
261
  (recorder as any)._tick();
143
262
  await vi.advanceTimersByTimeAsync(0);
@@ -4,7 +4,7 @@
4
4
  //===========================================
5
5
  import { KnownErrors } from "@hexclave/shared/dist/known-errors";
6
6
  import { isBrowserLike } from "@hexclave/shared/dist/utils/env";
7
- import { captureWarning } from "@hexclave/shared/dist/utils/errors";
7
+ import { captureWarning, throwErr } from "@hexclave/shared/dist/utils/errors";
8
8
  import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
9
9
  import { Result } from "@hexclave/shared/dist/utils/results";
10
10
 
@@ -110,6 +110,9 @@ const IDLE_TTL_MS = 3 * 60 * 1000;
110
110
  const FLUSH_INTERVAL_MS = 5_000;
111
111
  const MAX_EVENTS_PER_BATCH = 200;
112
112
  const MAX_APPROX_BYTES_PER_BATCH = 512_000;
113
+ // The server rejects payloads > 1MB. Stay well under to account for JSON
114
+ // envelope overhead (browser_session_id, timestamps, wrapper keys, etc.).
115
+ const MAX_FLUSH_PAYLOAD_BYTES = 900_000;
113
116
 
114
117
  export type StoredSession = {
115
118
  session_id: string,
@@ -193,6 +196,7 @@ export class SessionRecorder {
193
196
  private _detachListeners: (() => void) | null = null;
194
197
  private _flushTimer: ReturnType<typeof setInterval> | null = null;
195
198
  private _events: unknown[] = [];
199
+ private _eventSizes: number[] = [];
196
200
  private _approxBytes = 0;
197
201
  private _lastPersistActivity = 0;
198
202
  private _recording = false;
@@ -243,6 +247,7 @@ export class SessionRecorder {
243
247
 
244
248
  clearBuffer() {
245
249
  this._events = [];
250
+ this._eventSizes = [];
246
251
  this._approxBytes = 0;
247
252
  }
248
253
 
@@ -268,42 +273,68 @@ export class SessionRecorder {
268
273
  const nowMs = Date.now();
269
274
  const stored = getOrRotateSession({ key: this._storageKey, legacyKey: this._legacyStorageKey, nowMs });
270
275
 
271
- const batchId = generateUuid();
272
- const payload = {
273
- browser_session_id: stored.session_id,
274
- session_replay_segment_id: this._sessionReplaySegmentId,
275
- batch_id: batchId,
276
- started_at_ms: stored.created_at_ms,
277
- sent_at_ms: nowMs,
278
- events: this._events,
279
- };
280
-
276
+ // Capture all buffered events upfront (before any await) so that
277
+ // stop() / _stopCurrentRecording() clearing this._events cannot race
278
+ // with the async send loop below and silently discard overflow batches.
279
+ const allEvents = this._events;
280
+ const allSizes = this._eventSizes;
281
281
  this._events = [];
282
+ this._eventSizes = [];
282
283
  this._approxBytes = 0;
283
284
 
284
285
  this._flushInProgress = true;
285
286
  try {
286
- const res = await this._deps.sendBatch(
287
- JSON.stringify(payload),
288
- { keepalive: options.keepalive },
289
- );
290
-
291
- if (res.status === "error") {
292
- if (isAnalyticsNotEnabledError(res.error)) {
293
- this._disable();
294
- return;
287
+ let offset = 0;
288
+ while (offset < allEvents.length) {
289
+ // Build a batch that fits under the server's payload limit.
290
+ // When _flushInProgress blocked earlier flushes, events can accumulate
291
+ // well past MAX_APPROX_BYTES_PER_BATCH; sending them all at once would
292
+ // exceed the server's 1MB body limit (413).
293
+ let batchBytes = 0;
294
+ let batchEnd = offset;
295
+ for (let i = offset; i < allEvents.length; i++) {
296
+ const nextSize = allSizes[i] ?? throwErr("_eventSizes out of sync with _events — this should never happen");
297
+ if (batchBytes + nextSize > MAX_FLUSH_PAYLOAD_BYTES && batchEnd > offset) break;
298
+ batchBytes += nextSize;
299
+ batchEnd = i + 1;
295
300
  }
296
- // Ad blockers commonly block analytics endpoints, causing network
297
- // errors. These are expected and should not pollute the console.
298
- if (isAdBlockerNetworkError(res.error)) {
301
+
302
+ const batchEvents = allEvents.slice(offset, batchEnd);
303
+ offset = batchEnd;
304
+
305
+ const batchId = generateUuid();
306
+ const payload = {
307
+ browser_session_id: stored.session_id,
308
+ session_replay_segment_id: this._sessionReplaySegmentId,
309
+ batch_id: batchId,
310
+ started_at_ms: stored.created_at_ms,
311
+ sent_at_ms: nowMs,
312
+ events: batchEvents,
313
+ };
314
+
315
+ const res = await this._deps.sendBatch(
316
+ JSON.stringify(payload),
317
+ { keepalive: options.keepalive },
318
+ );
319
+
320
+ if (res.status === "error") {
321
+ if (isAnalyticsNotEnabledError(res.error)) {
322
+ this._disable();
323
+ return;
324
+ }
325
+ // Ad blockers commonly block analytics endpoints, causing network
326
+ // errors. These are expected and should not pollute the console.
327
+ if (isAdBlockerNetworkError(res.error)) {
328
+ return;
329
+ }
330
+ captureWarning("SessionRecorder.flush", res.error);
299
331
  return;
300
332
  }
301
- captureWarning("SessionRecorder.flush", res.error);
302
- return;
303
- }
304
333
 
305
- if (!res.data.ok) {
306
- captureWarning("SessionRecorder.flush", new Error(`SessionRecorder flush failed: ${res.data.status} ${await res.data.text()}`));
334
+ if (!res.data.ok) {
335
+ captureWarning("SessionRecorder.flush", new Error(`SessionRecorder flush failed: ${res.data.status} ${await res.data.text()}`));
336
+ return;
337
+ }
307
338
  }
308
339
  } finally {
309
340
  this._flushInProgress = false;
@@ -357,8 +388,10 @@ export class SessionRecorder {
357
388
  }
358
389
  }
359
390
 
391
+ const eventSize = JSON.stringify(event).length;
360
392
  this._events.push(event);
361
- this._approxBytes += JSON.stringify(event).length;
393
+ this._eventSizes.push(eventSize);
394
+ this._approxBytes += eventSize;
362
395
  if (this._events.length >= MAX_EVENTS_PER_BATCH || this._approxBytes >= MAX_APPROX_BYTES_PER_BATCH) {
363
396
  runAsynchronously(() => this._flush({ keepalive: false }));
364
397
  }
@@ -391,6 +424,7 @@ export class SessionRecorder {
391
424
  this._stopRecording = null;
392
425
  }
393
426
  this._events = [];
427
+ this._eventSizes = [];
394
428
  this._approxBytes = 0;
395
429
  this._recording = false;
396
430
  }