@harness-fe/runtime 3.0.1

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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -0
  3. package/dist/buffer.d.ts +13 -0
  4. package/dist/buffer.js +26 -0
  5. package/dist/capture.d.ts +47 -0
  6. package/dist/capture.js +112 -0
  7. package/dist/client.d.ts +82 -0
  8. package/dist/client.js +364 -0
  9. package/dist/commands.d.ts +10 -0
  10. package/dist/commands.js +304 -0
  11. package/dist/dashboardUrl.d.ts +18 -0
  12. package/dist/dashboardUrl.js +20 -0
  13. package/dist/fetchPatch.d.ts +39 -0
  14. package/dist/fetchPatch.js +311 -0
  15. package/dist/index.d.ts +8 -0
  16. package/dist/index.js +23 -0
  17. package/dist/outbox.d.ts +37 -0
  18. package/dist/outbox.js +80 -0
  19. package/dist/overlay.d.ts +68 -0
  20. package/dist/overlay.js +1946 -0
  21. package/dist/parent-inherit.d.ts +25 -0
  22. package/dist/parent-inherit.js +43 -0
  23. package/dist/recording.d.ts +27 -0
  24. package/dist/recording.js +86 -0
  25. package/dist/rrweb-types.d.ts +13 -0
  26. package/dist/rrweb-types.js +20 -0
  27. package/dist/selectors.d.ts +14 -0
  28. package/dist/selectors.js +91 -0
  29. package/dist/snapshot.d.ts +12 -0
  30. package/dist/snapshot.js +111 -0
  31. package/dist/visitor.d.ts +28 -0
  32. package/dist/visitor.js +107 -0
  33. package/dist/xhrPatch.d.ts +26 -0
  34. package/dist/xhrPatch.js +269 -0
  35. package/package.json +50 -0
  36. package/src/buffer.test.ts +26 -0
  37. package/src/buffer.ts +29 -0
  38. package/src/capture.ts +126 -0
  39. package/src/client.test.ts +89 -0
  40. package/src/client.ts +423 -0
  41. package/src/commands.test.ts +128 -0
  42. package/src/commands.ts +335 -0
  43. package/src/dashboardUrl.test.ts +59 -0
  44. package/src/dashboardUrl.ts +36 -0
  45. package/src/fetchPatch.test.ts +203 -0
  46. package/src/fetchPatch.ts +371 -0
  47. package/src/index.ts +32 -0
  48. package/src/outbox.test.ts +115 -0
  49. package/src/outbox.ts +84 -0
  50. package/src/overlay.test.ts +319 -0
  51. package/src/overlay.ts +2070 -0
  52. package/src/parent-inherit.ts +54 -0
  53. package/src/recording.ts +88 -0
  54. package/src/rrweb-types.test.ts +40 -0
  55. package/src/rrweb-types.ts +24 -0
  56. package/src/selectors.test.ts +50 -0
  57. package/src/selectors.ts +103 -0
  58. package/src/snapshot.ts +112 -0
  59. package/src/visitor.ts +116 -0
  60. package/src/xhrPatch.test.ts +191 -0
  61. package/src/xhrPatch.ts +314 -0
@@ -0,0 +1,371 @@
1
+ /**
2
+ * fetch monkey-patch — captures URL/method/headers/body for request and
3
+ * response, including streaming SSE responses, without changing
4
+ * business-observable fetch behavior.
5
+ *
6
+ * Safety contract (do not weaken without updating the spec):
7
+ * 1. Identity-preserving: replacement is a named `fetch`, with
8
+ * defineProperty'd name/length/toString so library fingerprint checks
9
+ * still pass. Response / Request instances are NOT wrapped.
10
+ * 2. Error-isolated: capture failures are swallowed via `safeEmit`; they
11
+ * NEVER propagate to business code.
12
+ * 3. No timing or value change: the original Promise is returned to the
13
+ * caller unchanged. body capture reads `response.clone()` on a side
14
+ * branch — the business path retains an untouched stream.
15
+ * 4. Self-traffic guard: requests carrying `init.__hfeInternal === true`
16
+ * short-circuit to the original fetch. A URL denylist also skips HMR /
17
+ * dev-server traffic to prevent capture feedback loops.
18
+ * 5. Bounded memory: bodies are capped at BODY_CAP per request. SSE
19
+ * streams stop accumulating and `cancel()` the cloned reader once
20
+ * the cap is hit.
21
+ *
22
+ * The patch is idempotent (re-install is a no-op) and returns a dispose
23
+ * function that restores the original `window.fetch`.
24
+ */
25
+
26
+ import type { NetworkEntry } from '@harness-fe/protocol';
27
+
28
+ const DEFAULT_BODY_CAP = 256 * 1024;
29
+ const INTERNAL_FLAG = '__hfeInternal';
30
+ const PATCHED_FLAG = '__hfePatched';
31
+ const DEFAULT_DENYLIST: RegExp[] = [/\/__hfe\//, /sockjs-node/, /\.hot-update\./];
32
+ const SENSITIVE_HEADER = /^(authorization|cookie|x-api-key|x-auth-.+)$/i;
33
+
34
+ export interface FetchPatchOptions {
35
+ /** Called once for each emitted record (request and response are separate calls). */
36
+ onEntry: (entry: NetworkEntry) => void;
37
+ /** Per-body byte cap. Default 256 KB. */
38
+ bodyCap?: number;
39
+ /** URL patterns to skip capture entirely. */
40
+ denylist?: RegExp[];
41
+ }
42
+
43
+ /**
44
+ * Install the fetch patch. Returns a dispose function that restores the
45
+ * original window.fetch. Safe to call multiple times (subsequent calls
46
+ * are no-ops while a patch is active).
47
+ */
48
+ export function installFetchPatch(opts: FetchPatchOptions): () => void {
49
+ if (typeof window === 'undefined' || typeof window.fetch !== 'function') {
50
+ return () => {};
51
+ }
52
+ const original = window.fetch;
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ if ((original as any)[PATCHED_FLAG]) return () => {};
55
+
56
+ const bodyCap = opts.bodyCap ?? DEFAULT_BODY_CAP;
57
+ const denylist = opts.denylist ?? DEFAULT_DENYLIST;
58
+ const emit = (entry: NetworkEntry): void => safeEmit(opts.onEntry, entry);
59
+
60
+ // Named function so .name === 'fetch'.
61
+ const patched = function fetch(
62
+ this: typeof globalThis,
63
+ input: RequestInfo | URL,
64
+ init?: RequestInit,
65
+ ): Promise<Response> {
66
+ // Self-traffic short-circuit — internal requests bypass capture.
67
+ if (init && (init as Record<string, unknown>)[INTERNAL_FLAG]) {
68
+ return original.call(this, input as RequestInfo, init);
69
+ }
70
+
71
+ const meta = extractRequestMeta(input, init);
72
+ if (denylist.some((re) => re.test(meta.url))) {
73
+ return original.call(this, input as RequestInfo, init);
74
+ }
75
+
76
+ const id = generateId();
77
+ const startedAt = performance.now();
78
+ const startedTs = Date.now();
79
+
80
+ // Emit request record eagerly (req body is read async — second emit
81
+ // updates the record once body is serialized; consumers join by id).
82
+ const reqRecord: NetworkEntry = {
83
+ ts: startedTs,
84
+ id,
85
+ phase: 'req',
86
+ method: meta.method,
87
+ url: meta.url,
88
+ requestHeaders: meta.headers,
89
+ };
90
+ emit(reqRecord);
91
+ cloneRequestBody(input, init, bodyCap).then(
92
+ ({ body, truncated }) => {
93
+ if (body === undefined && !truncated) return;
94
+ emit({
95
+ ...reqRecord,
96
+ requestBody: body,
97
+ requestBodyTruncated: truncated || undefined,
98
+ });
99
+ },
100
+ () => {
101
+ /* serialization error — ignore, req already emitted */
102
+ },
103
+ );
104
+
105
+ const promise = original.call(this, input as RequestInfo, init);
106
+
107
+ promise.then(
108
+ (response) => {
109
+ let cloned: Response | undefined;
110
+ try {
111
+ cloned = response.clone();
112
+ } catch {
113
+ emit({
114
+ ts: Date.now(),
115
+ id,
116
+ phase: 'res',
117
+ method: meta.method,
118
+ url: meta.url,
119
+ status: response.status,
120
+ responseHeaders: headersToObject(response.headers),
121
+ durationMs: performance.now() - startedAt,
122
+ });
123
+ return;
124
+ }
125
+ const finalize = (body: unknown, truncated: boolean): void => {
126
+ emit({
127
+ ts: Date.now(),
128
+ id,
129
+ phase: 'res',
130
+ method: meta.method,
131
+ url: meta.url,
132
+ status: response.status,
133
+ responseHeaders: headersToObject(response.headers),
134
+ responseBody: body,
135
+ responseBodyTruncated: truncated || undefined,
136
+ durationMs: performance.now() - startedAt,
137
+ });
138
+ };
139
+
140
+ const ct = response.headers.get('content-type') ?? '';
141
+ if (isSSE(ct)) {
142
+ pumpSSE(cloned, bodyCap, finalize);
143
+ } else if (isTextLike(ct)) {
144
+ readTextWithCap(cloned, bodyCap).then(
145
+ ({ body, truncated }) =>
146
+ finalize(maybeParseJson(body, ct), truncated),
147
+ () => finalize(undefined, false),
148
+ );
149
+ } else {
150
+ // Binary or unknown: only record size, do not pull bytes.
151
+ cloned.arrayBuffer().then(
152
+ (buf) => finalize(`[binary ${buf.byteLength}B]`, false),
153
+ () => finalize(undefined, false),
154
+ );
155
+ }
156
+ },
157
+ (err: unknown) => {
158
+ emit({
159
+ ts: Date.now(),
160
+ id,
161
+ phase: 'res',
162
+ method: meta.method,
163
+ url: meta.url,
164
+ durationMs: performance.now() - startedAt,
165
+ error: err instanceof Error ? err.message : String(err),
166
+ });
167
+ },
168
+ );
169
+
170
+ return promise;
171
+ };
172
+
173
+ // Preserve fingerprint — library detection commonly inspects these.
174
+ try {
175
+ Object.defineProperty(patched, 'name', { value: 'fetch' });
176
+ Object.defineProperty(patched, 'length', { value: original.length });
177
+ Object.defineProperty(patched, 'toString', {
178
+ value: () => original.toString(),
179
+ });
180
+ } catch {
181
+ /* sealed property — safe to skip */
182
+ }
183
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
184
+ (patched as any)[PATCHED_FLAG] = true;
185
+
186
+ window.fetch = patched as typeof fetch;
187
+ return () => {
188
+ if (window.fetch === (patched as typeof fetch)) {
189
+ window.fetch = original;
190
+ }
191
+ };
192
+ }
193
+
194
+ // ─── helpers ────────────────────────────────────────────────────────────────
195
+
196
+ function generateId(): string {
197
+ try {
198
+ return crypto.randomUUID();
199
+ } catch {
200
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
201
+ }
202
+ }
203
+
204
+ interface RequestMeta {
205
+ url: string;
206
+ method: string;
207
+ headers?: Record<string, string>;
208
+ }
209
+
210
+ function extractRequestMeta(input: RequestInfo | URL, init?: RequestInit): RequestMeta {
211
+ let url: string;
212
+ let method = 'GET';
213
+ let headers: Record<string, string> | undefined;
214
+
215
+ if (typeof input === 'string') {
216
+ url = input;
217
+ } else if (input instanceof URL) {
218
+ url = input.toString();
219
+ } else {
220
+ url = input.url;
221
+ method = input.method;
222
+ headers = headersToObject(input.headers);
223
+ }
224
+ if (init?.method) method = init.method;
225
+ if (init?.headers) {
226
+ headers = { ...(headers ?? {}), ...(headersToObject(init.headers) ?? {}) };
227
+ }
228
+ return { url, method, headers };
229
+ }
230
+
231
+ function headersToObject(
232
+ h: HeadersInit | Headers | undefined,
233
+ ): Record<string, string> | undefined {
234
+ if (!h) return undefined;
235
+ const out: Record<string, string> = {};
236
+ if (h instanceof Headers) {
237
+ h.forEach((v, k) => {
238
+ out[k] = v;
239
+ });
240
+ } else if (Array.isArray(h)) {
241
+ for (const [k, v] of h) out[k] = v;
242
+ } else {
243
+ Object.assign(out, h);
244
+ }
245
+ return redactHeaders(out);
246
+ }
247
+
248
+ function redactHeaders(h: Record<string, string>): Record<string, string> {
249
+ const out: Record<string, string> = {};
250
+ for (const [k, v] of Object.entries(h)) {
251
+ out[k] = SENSITIVE_HEADER.test(k) ? `[redacted ${String(v).length}]` : v;
252
+ }
253
+ return out;
254
+ }
255
+
256
+ async function cloneRequestBody(
257
+ input: RequestInfo | URL,
258
+ init: RequestInit | undefined,
259
+ cap: number,
260
+ ): Promise<{ body?: unknown; truncated: boolean }> {
261
+ if (init?.body !== undefined && init.body !== null) {
262
+ return serializeBodyInit(init.body, cap);
263
+ }
264
+ if (input instanceof Request && input.body) {
265
+ try {
266
+ const text = await input.clone().text();
267
+ return capText(text, cap);
268
+ } catch {
269
+ return { truncated: false };
270
+ }
271
+ }
272
+ return { truncated: false };
273
+ }
274
+
275
+ function serializeBodyInit(
276
+ body: BodyInit,
277
+ cap: number,
278
+ ): { body?: unknown; truncated: boolean } {
279
+ if (typeof body === 'string') return capText(body, cap);
280
+ if (typeof FormData !== 'undefined' && body instanceof FormData) {
281
+ const obj: Record<string, unknown> = {};
282
+ body.forEach((v, k) => {
283
+ obj[k] = typeof v === 'string'
284
+ ? v
285
+ : `[File ${(v as File).name} ${(v as File).size}B]`;
286
+ });
287
+ return { body: obj, truncated: false };
288
+ }
289
+ if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
290
+ return { body: body.toString(), truncated: false };
291
+ }
292
+ if (typeof Blob !== 'undefined' && body instanceof Blob) {
293
+ return { body: `[Blob ${body.size}B]`, truncated: false };
294
+ }
295
+ if (body instanceof ArrayBuffer) {
296
+ return { body: `[ArrayBuffer ${body.byteLength}B]`, truncated: false };
297
+ }
298
+ if (ArrayBuffer.isView(body)) {
299
+ return { body: `[${(body.constructor as { name: string }).name} ${body.byteLength}B]`, truncated: false };
300
+ }
301
+ return { body: '[unknown body]', truncated: false };
302
+ }
303
+
304
+ function capText(text: string, cap: number): { body: string; truncated: boolean } {
305
+ if (text.length > cap) return { body: text.slice(0, cap), truncated: true };
306
+ return { body: text, truncated: false };
307
+ }
308
+
309
+ async function readTextWithCap(
310
+ res: Response,
311
+ cap: number,
312
+ ): Promise<{ body: string; truncated: boolean }> {
313
+ const text = await res.text();
314
+ return capText(text, cap);
315
+ }
316
+
317
+ function maybeParseJson(text: string, contentType: string): unknown {
318
+ if (!/json/i.test(contentType)) return text;
319
+ try {
320
+ return JSON.parse(text);
321
+ } catch {
322
+ return text;
323
+ }
324
+ }
325
+
326
+ function isSSE(ct: string): boolean {
327
+ return /text\/event-stream/i.test(ct);
328
+ }
329
+
330
+ function isTextLike(ct: string): boolean {
331
+ return /json|text|xml|javascript|x-www-form-urlencoded/i.test(ct);
332
+ }
333
+
334
+ function pumpSSE(
335
+ res: Response,
336
+ cap: number,
337
+ done: (body: string, truncated: boolean) => void,
338
+ ): void {
339
+ if (!res.body || typeof res.body.getReader !== 'function') {
340
+ return done('', false);
341
+ }
342
+ const reader = res.body.getReader();
343
+ const dec = new TextDecoder();
344
+ let total = '';
345
+ let truncated = false;
346
+ const step = (): void => {
347
+ reader.read().then(
348
+ ({ done: end, value }) => {
349
+ if (end) return done(total, truncated);
350
+ total += dec.decode(value, { stream: true });
351
+ if (total.length > cap) {
352
+ total = total.slice(0, cap);
353
+ truncated = true;
354
+ reader.cancel().catch(() => {});
355
+ return done(total, truncated);
356
+ }
357
+ step();
358
+ },
359
+ () => done(total, truncated),
360
+ );
361
+ };
362
+ step();
363
+ }
364
+
365
+ function safeEmit(fn: (entry: NetworkEntry) => void, entry: NetworkEntry): void {
366
+ try {
367
+ fn(entry);
368
+ } catch {
369
+ /* swallow — capture must not break business */
370
+ }
371
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Auto-start entry. Importing this module (as the Vite plugin does)
3
+ * boots a RuntimeClient using the config planted on `window.__HARNESS_FE__`.
4
+ *
5
+ * Idempotent: importing twice is a no-op.
6
+ */
7
+
8
+ import { installOverlay } from './overlay.js';
9
+ import { RuntimeClient, readInjectedConfig } from './client.js';
10
+
11
+ const w = window as unknown as {
12
+ __harness_fe_started__?: boolean;
13
+ __harness_fe_client__?: RuntimeClient;
14
+ __hfe_session_id__?: string;
15
+ };
16
+
17
+ if (typeof window !== 'undefined' && !w.__harness_fe_started__) {
18
+ w.__harness_fe_started__ = true;
19
+ const cfg = readInjectedConfig();
20
+ const client = new RuntimeClient(cfg);
21
+ client.start();
22
+ installOverlay(client);
23
+ // Expose for debugging + same-origin iframe inheritance.
24
+ // Same-origin children read `window.parent.__hfe_session_id__` and
25
+ // `window.parent.__harness_fe_client__.tabId` in tryInheritFromParent()
26
+ // so all iframes within one pageload share identity.
27
+ w.__harness_fe_client__ = client;
28
+ w.__hfe_session_id__ = client.sessionId;
29
+ }
30
+
31
+ export { RuntimeClient, tryInheritFromParent } from './client.js';
32
+ export type { ClientOptions, ParentInheritance } from './client.js';
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Outbox } from './outbox.js';
3
+
4
+ describe('Outbox', () => {
5
+ describe('FIFO eviction', () => {
6
+ it('evicts oldest non-sticky frame when frame cap is exceeded', () => {
7
+ const outbox = new Outbox(3, 1024);
8
+ outbox.enqueue('a', false);
9
+ outbox.enqueue('b', false);
10
+ outbox.enqueue('c', false);
11
+ outbox.enqueue('d', false); // triggers eviction
12
+ const payloads = outbox.snapshot().map((e) => e.payload);
13
+ expect(payloads).toEqual(['b', 'c', 'd']);
14
+ });
15
+
16
+ it('evicts oldest non-sticky frame when byte cap is exceeded', () => {
17
+ const outbox = new Outbox(100, 6); // 6 bytes total
18
+ outbox.enqueue('aa', false); // 2
19
+ outbox.enqueue('bb', false); // 4
20
+ outbox.enqueue('cc', false); // 6
21
+ outbox.enqueue('dd', false); // 8 → evict 'aa'
22
+ const payloads = outbox.snapshot().map((e) => e.payload);
23
+ expect(payloads).toEqual(['bb', 'cc', 'dd']);
24
+ expect(outbox.byteSize).toBe(6);
25
+ });
26
+ });
27
+
28
+ describe('sticky protection (the core fix)', () => {
29
+ it('keeps a sticky frame even when older non-sticky frames must yield', () => {
30
+ const outbox = new Outbox(3, 1024);
31
+ outbox.enqueue('fullsnap', true); // sticky — must survive
32
+ outbox.enqueue('inc-1', false);
33
+ outbox.enqueue('inc-2', false);
34
+ outbox.enqueue('inc-3', false); // triggers eviction
35
+ const payloads = outbox.snapshot().map((e) => e.payload);
36
+ expect(payloads).toContain('fullsnap');
37
+ // The sticky frame stays; the oldest non-sticky goes.
38
+ expect(payloads).toEqual(['fullsnap', 'inc-2', 'inc-3']);
39
+ });
40
+
41
+ it('evicts non-sticky from the middle to preserve a sticky older frame', () => {
42
+ const outbox = new Outbox(2, 1024);
43
+ outbox.enqueue('fullsnap', true);
44
+ outbox.enqueue('inc-1', false);
45
+ outbox.enqueue('inc-2', false); // cap=2 → evict inc-1, keep [fullsnap, inc-2]
46
+ const payloads = outbox.snapshot().map((e) => e.payload);
47
+ expect(payloads).toEqual(['fullsnap', 'inc-2']);
48
+ });
49
+
50
+ it('falls back to dropping oldest sticky when ALL frames are sticky and cap busted', () => {
51
+ // Pathological: many ws reconnects, each adding a sticky frame,
52
+ // daemon never staying up to drain.
53
+ const outbox = new Outbox(2, 1024);
54
+ outbox.enqueue('fs-1', true);
55
+ outbox.enqueue('fs-2', true);
56
+ outbox.enqueue('fs-3', true); // cap=2 → must drop oldest sticky
57
+ const payloads = outbox.snapshot().map((e) => e.payload);
58
+ expect(payloads).toEqual(['fs-2', 'fs-3']);
59
+ });
60
+
61
+ it('regression: the original FIFO bug — without sticky protection, FullSnapshot would be dropped', () => {
62
+ // Reproduce the original bug shape, but with sticky=true to prove the fix.
63
+ const outbox = new Outbox(500, 8 * 1024 * 1024);
64
+ const BIG_PAYLOAD = 'x'.repeat(20 * 1024); // 20 KB "FullSnapshot"
65
+ outbox.enqueue(BIG_PAYLOAD, true);
66
+ // Flood with 600 incrementals, 20 KB each — bytes cap ~8 MB, frame
67
+ // cap = 500. Without sticky protection the FullSnapshot would be
68
+ // the first evicted (oldest by FIFO).
69
+ for (let i = 0; i < 600; i++) {
70
+ outbox.enqueue('x'.repeat(20 * 1024), false);
71
+ }
72
+ const snap = outbox.snapshot();
73
+ expect(snap[0]!.payload).toBe(BIG_PAYLOAD); // FullSnapshot retained
74
+ expect(snap[0]!.sticky).toBe(true);
75
+ // Outbox stays within frame cap.
76
+ expect(snap.length).toBeLessThanOrEqual(500);
77
+ });
78
+ });
79
+
80
+ describe('flush', () => {
81
+ it('sends FIFO in order and clears the queue', () => {
82
+ const outbox = new Outbox(10, 1024);
83
+ outbox.enqueue('a', false);
84
+ outbox.enqueue('b', true);
85
+ outbox.enqueue('c', false);
86
+ const sent: string[] = [];
87
+ outbox.flush((p) => { sent.push(p); });
88
+ expect(sent).toEqual(['a', 'b', 'c']);
89
+ expect(outbox.size).toBe(0);
90
+ expect(outbox.byteSize).toBe(0);
91
+ });
92
+
93
+ it('halts on send throw and retains remaining frames for next flush', () => {
94
+ const outbox = new Outbox(10, 1024);
95
+ outbox.enqueue('a', false);
96
+ outbox.enqueue('b', false);
97
+ outbox.enqueue('c', false);
98
+ let calls = 0;
99
+ outbox.flush(() => {
100
+ calls++;
101
+ if (calls === 2) throw new Error('socket died');
102
+ });
103
+ // 'a' sent, 'b' failed → keep 'b' and 'c'
104
+ expect(outbox.snapshot().map((e) => e.payload)).toEqual(['b', 'c']);
105
+ });
106
+
107
+ it('treats explicit `false` return as send-failed', () => {
108
+ const outbox = new Outbox(10, 1024);
109
+ outbox.enqueue('a', false);
110
+ outbox.enqueue('b', false);
111
+ outbox.flush((p) => (p === 'b' ? false : true));
112
+ expect(outbox.snapshot().map((e) => e.payload)).toEqual(['b']);
113
+ });
114
+ });
115
+ });
package/src/outbox.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Pre-handshake / reconnect outbox.
3
+ *
4
+ * Buffers frames produced while the WebSocket isn't OPEN and replays them
5
+ * in FIFO order on `flush(send)`. Capped to defend against OOM if the
6
+ * bridge is unreachable for an extended period.
7
+ *
8
+ * `sticky` marks frames that must survive eviction even when the outbox
9
+ * is full — currently used for rrweb chunks containing a FullSnapshot
10
+ * (type:2) baseline. Without this protection, the FullSnapshot — being
11
+ * the oldest frame in the outbox — was always the first to be FIFO-
12
+ * evicted under load, leaving the session permanently unreplayable.
13
+ * Eviction only touches sticky frames as a last resort (all remaining
14
+ * frames are sticky and we still exceed cap).
15
+ */
16
+ export class Outbox {
17
+ private entries: Array<{ payload: string; sticky: boolean }> = [];
18
+ private bytes = 0;
19
+
20
+ constructor(
21
+ private readonly maxFrames: number,
22
+ private readonly maxBytes: number,
23
+ ) {}
24
+
25
+ /** Current buffered frame count. */
26
+ get size(): number {
27
+ return this.entries.length;
28
+ }
29
+
30
+ /** Current buffered byte size. */
31
+ get byteSize(): number {
32
+ return this.bytes;
33
+ }
34
+
35
+ /** Inspect entries without exposing the underlying mutable array. */
36
+ snapshot(): ReadonlyArray<{ payload: string; sticky: boolean }> {
37
+ return this.entries.slice();
38
+ }
39
+
40
+ enqueue(payload: string, sticky: boolean): void {
41
+ this.entries.push({ payload, sticky });
42
+ this.bytes += payload.length;
43
+
44
+ // Pass 1: evict only non-sticky frames, oldest first.
45
+ let i = 0;
46
+ while ((this.entries.length > this.maxFrames || this.bytes > this.maxBytes) && i < this.entries.length) {
47
+ const entry = this.entries[i]!;
48
+ if (entry.sticky) {
49
+ i++;
50
+ continue;
51
+ }
52
+ this.entries.splice(i, 1);
53
+ this.bytes -= entry.payload.length;
54
+ }
55
+
56
+ // Pass 2 (last resort): drop oldest sticky frames if we still bust
57
+ // the cap. Replay only needs the *most recent* baseline, so dropping
58
+ // older sticky frames is acceptable.
59
+ while ((this.entries.length > this.maxFrames || this.bytes > this.maxBytes) && this.entries.length > 0) {
60
+ const dropped = this.entries.shift()!;
61
+ this.bytes -= dropped.payload.length;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Drain FIFO into `send`. If `send` throws (or returns false), keep the
67
+ * frame in the queue and bail — caller will retry on next flush.
68
+ */
69
+ flush(send: (payload: string) => boolean | void): void {
70
+ while (this.entries.length > 0) {
71
+ const entry = this.entries[0]!;
72
+ let ok = false;
73
+ try {
74
+ const result = send(entry.payload);
75
+ ok = result !== false;
76
+ } catch {
77
+ ok = false;
78
+ }
79
+ if (!ok) return;
80
+ this.entries.shift();
81
+ this.bytes -= entry.payload.length;
82
+ }
83
+ }
84
+ }