@vitrinka/web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +93 -0
  3. package/README.md +177 -0
  4. package/build/index.d.ts +11 -0
  5. package/build/index.js +1 -0
  6. package/build/next.d.ts +23 -0
  7. package/build/next.js +27 -0
  8. package/build/protocol/index.d.ts +74 -0
  9. package/build/protocol/index.js +22 -0
  10. package/build/recorder/RecorderProvider.d.ts +17 -0
  11. package/build/recorder/RecorderProvider.js +126 -0
  12. package/build/recorder/api-status.d.ts +6 -0
  13. package/build/recorder/api-status.js +6 -0
  14. package/build/recorder/api.d.ts +40 -0
  15. package/build/recorder/api.js +84 -0
  16. package/build/recorder/capture/click.d.ts +17 -0
  17. package/build/recorder/capture/click.js +77 -0
  18. package/build/recorder/capture/console.d.ts +3 -0
  19. package/build/recorder/capture/console.js +89 -0
  20. package/build/recorder/capture/nav.d.ts +8 -0
  21. package/build/recorder/capture/nav.js +54 -0
  22. package/build/recorder/capture/net.d.ts +22 -0
  23. package/build/recorder/capture/net.js +506 -0
  24. package/build/recorder/capture/redact.d.ts +38 -0
  25. package/build/recorder/capture/redact.js +54 -0
  26. package/build/recorder/capture/rrweb.d.ts +10 -0
  27. package/build/recorder/capture/rrweb.js +76 -0
  28. package/build/recorder/config.d.ts +50 -0
  29. package/build/recorder/config.js +100 -0
  30. package/build/recorder/control.d.ts +29 -0
  31. package/build/recorder/control.js +63 -0
  32. package/build/recorder/hud/AnnotateOverlay.d.ts +25 -0
  33. package/build/recorder/hud/AnnotateOverlay.js +122 -0
  34. package/build/recorder/hud/Hud.d.ts +12 -0
  35. package/build/recorder/hud/Hud.js +190 -0
  36. package/build/recorder/hud/LinkSheet.d.ts +26 -0
  37. package/build/recorder/hud/LinkSheet.js +15 -0
  38. package/build/recorder/hud/RecorderPill.d.ts +36 -0
  39. package/build/recorder/hud/RecorderPill.js +73 -0
  40. package/build/recorder/hud/Sheet.d.ts +20 -0
  41. package/build/recorder/hud/Sheet.js +36 -0
  42. package/build/recorder/hud/host.d.ts +27 -0
  43. package/build/recorder/hud/host.js +170 -0
  44. package/build/recorder/hud/icons.d.ts +15 -0
  45. package/build/recorder/hud/icons.js +40 -0
  46. package/build/recorder/hud/styles.d.ts +13 -0
  47. package/build/recorder/hud/styles.js +111 -0
  48. package/build/recorder/index.d.ts +46 -0
  49. package/build/recorder/index.js +61 -0
  50. package/build/recorder/link.d.ts +18 -0
  51. package/build/recorder/link.js +37 -0
  52. package/build/recorder/queue.d.ts +163 -0
  53. package/build/recorder/queue.js +642 -0
  54. package/build/recorder/session.d.ts +73 -0
  55. package/build/recorder/session.js +246 -0
  56. package/build/recorder/state.d.ts +26 -0
  57. package/build/recorder/state.js +42 -0
  58. package/build/recorder/storage/index.d.ts +35 -0
  59. package/build/recorder/storage/index.js +69 -0
  60. package/build/recorder/storage/memory.d.ts +2 -0
  61. package/build/recorder/storage/memory.js +2 -0
  62. package/package.json +77 -0
@@ -0,0 +1,642 @@
1
+ import { api, permanentStatus, uploadChunk, VitrinkaApiError } from './api';
2
+ import { notify } from './state';
3
+ import { getRecorderStorage } from './storage';
4
+ const FLUSH_MS = 2000;
5
+ const MAX_BUFFER = 20000;
6
+ const MAX_PENDING = 200; // rrweb chunks in memory; oldest evicted loudly
7
+ /**
8
+ * Byte budgets for ONE events POST, mirroring the extension's pack-margin /
9
+ * hard-cap split. The server rejects bodies over 4 MiB and 400 is a
10
+ * PERMANENT verdict — so an oversized batch must never be assembled. An event
11
+ * bigger than the pack margin but under the wire cap is still deliverable —
12
+ * it rides ALONE; only one beyond the wire cap can never upload and is
13
+ * dropped.
14
+ */
15
+ const WIRE_BODY_CAP = 4 * 1024 * 1024;
16
+ const MAX_BATCH_BYTES = 3 * 1024 * 1024; // pack margin
17
+ const MAX_EVENT_BYTES = WIRE_BODY_CAP - 1024; // solo cap; headroom for the {"events":[…]} envelope
18
+ /**
19
+ * rrweb chunk budgets (extension `splitRRWebEvents`): the server's chunk cap
20
+ * is 12 MiB; batches pack under 10 MiB, an event too big to pack rides alone
21
+ * up to the wire cap, and one beyond even that is undeliverable.
22
+ */
23
+ const CHUNK_WIRE_CAP = 12 * 1024 * 1024;
24
+ const CHUNK_PACK_BYTES = 10 * 1024 * 1024;
25
+ const CHUNK_HARD_BYTES = CHUNK_WIRE_CAP - 64;
26
+ /** Pending chunks are persisted only while their total stays under this. */
27
+ const CHUNK_PERSIST_BUDGET = 1024 * 1024;
28
+ const encoder = new TextEncoder();
29
+ /** UTF-8 byte length of a string — the unit the server's body limit counts. */
30
+ function utf8Bytes(s) {
31
+ return encoder.encode(s).length;
32
+ }
33
+ /** How often a live session reconciles against the server (extension D5). */
34
+ export const RECONCILE_MS = 10_000;
35
+ // Health thresholds (extension D4): quiet until one of these trips.
36
+ const OFFLINE_AFTER_MS = 15_000;
37
+ const BACKLOG_ITEMS = 40;
38
+ const kv = () => getRecorderStorage();
39
+ function readJson(key, fallback) {
40
+ const raw = kv().getString(key);
41
+ if (!raw)
42
+ return fallback;
43
+ try {
44
+ return JSON.parse(raw);
45
+ }
46
+ catch {
47
+ return fallback;
48
+ }
49
+ }
50
+ /** A quota-refusing store must never break capture — warn once and go on. */
51
+ let storageWarned = false;
52
+ function safeSet(key, value) {
53
+ try {
54
+ kv().set(key, value);
55
+ return true;
56
+ }
57
+ catch (e) {
58
+ if (!storageWarned) {
59
+ storageWarned = true;
60
+ console.warn('vitrinka: storage write failed — the queue tail is memory-only until it clears', e);
61
+ }
62
+ return false;
63
+ }
64
+ }
65
+ // The session record is small and read on every event — cache it in memory and
66
+ // write through, so the capture hot path never parses JSON.
67
+ let recCache;
68
+ /**
69
+ * The LIVE session record — a mutable alias of the cache, not a copy. Treat it
70
+ * as READ-ONLY unless you pass the object you mutated straight to `setState()`
71
+ * in the same tick.
72
+ */
73
+ export function getState() {
74
+ if (recCache === undefined)
75
+ recCache = readJson('rec', null);
76
+ return recCache;
77
+ }
78
+ export function setState(rec) {
79
+ recCache = rec;
80
+ if (rec === null)
81
+ kv().remove('rec');
82
+ else
83
+ safeSet('rec', JSON.stringify(rec));
84
+ }
85
+ /**
86
+ * The event buffer lives in memory and is FLUSHED TO STORAGE on a debounce
87
+ * (plus synchronously whenever it is read for upload or the session ends).
88
+ */
89
+ const PERSIST_DEBOUNCE_MS = 700;
90
+ let bufferCache;
91
+ let persistTimer = null;
92
+ function getBuffer() {
93
+ if (bufferCache === undefined)
94
+ bufferCache = readJson('buffer', []);
95
+ return bufferCache;
96
+ }
97
+ /** Write the in-memory buffer (and the pending chunks) to storage NOW. */
98
+ export function persistNow() {
99
+ persistBuffer();
100
+ persistChunks();
101
+ }
102
+ function persistBuffer() {
103
+ if (persistTimer) {
104
+ clearTimeout(persistTimer);
105
+ persistTimer = null;
106
+ }
107
+ if (bufferCache !== undefined)
108
+ safeSet('buffer', JSON.stringify(bufferCache));
109
+ }
110
+ function schedulePersist() {
111
+ if (persistTimer)
112
+ return;
113
+ persistTimer = setTimeout(() => {
114
+ persistTimer = null;
115
+ persistBuffer();
116
+ persistChunks();
117
+ }, PERSIST_DEBOUNCE_MS);
118
+ }
119
+ function setBuffer(buffer) {
120
+ bufferCache = buffer;
121
+ persistBuffer();
122
+ }
123
+ // -- pending rrweb chunks ----------------------------------------------------
124
+ let chunkCache;
125
+ let chunkBytes = 0;
126
+ function getChunks() {
127
+ if (chunkCache === undefined) {
128
+ chunkCache = readJson('chunks', []);
129
+ chunkBytes = chunkCache.reduce((n, c) => n + c.body.length, 0);
130
+ }
131
+ return chunkCache;
132
+ }
133
+ /** Best-effort: persisted while small, memory-only (and said so) beyond the budget. */
134
+ function persistChunks() {
135
+ const chunks = getChunks();
136
+ if (chunks.length === 0) {
137
+ kv().remove('chunks');
138
+ return;
139
+ }
140
+ if (chunkBytes > CHUNK_PERSIST_BUDGET) {
141
+ kv().remove('chunks');
142
+ return;
143
+ }
144
+ safeSet('chunks', JSON.stringify(chunks));
145
+ }
146
+ function setChunks(chunks) {
147
+ chunkCache = chunks;
148
+ chunkBytes = chunks.reduce((n, c) => n + c.body.length, 0);
149
+ persistChunks();
150
+ }
151
+ export function queuedCount() {
152
+ return getBuffer().length + getChunks().length;
153
+ }
154
+ // -- health + server reconciliation (extension D4/D5/D9) ---------------------
155
+ let lastSyncAt = 0;
156
+ let lastError = '';
157
+ let failures = 0;
158
+ /** Highest seq the SERVER confirmed (events-POST 200 or reconcile GET). */
159
+ let serverMaxSeq = -1;
160
+ function noteSync() {
161
+ lastSyncAt = Date.now();
162
+ failures = 0;
163
+ lastError = '';
164
+ }
165
+ function noteFailure(e) {
166
+ failures++;
167
+ lastError = String(e instanceof Error ? e.message : e).slice(0, 200);
168
+ }
169
+ /** Fresh-session baseline; called by startSession before capture begins. */
170
+ export function resetHealth(baseSeq = 0) {
171
+ lastSyncAt = Date.now();
172
+ lastError = '';
173
+ failures = 0;
174
+ serverMaxSeq = baseSeq;
175
+ }
176
+ export function health() {
177
+ const rec = getState();
178
+ const queued = queuedCount();
179
+ const sinceSync = lastSyncAt ? Date.now() - lastSyncAt : null;
180
+ let state = 'idle';
181
+ if (rec) {
182
+ if (rec.dead)
183
+ state = 'dead';
184
+ else if (failures >= 2 || (queued > 0 && sinceSync !== null && sinceSync > OFFLINE_AFTER_MS))
185
+ state = 'offline';
186
+ else if (queued > BACKLOG_ITEMS)
187
+ state = 'backlog';
188
+ else
189
+ state = 'ok';
190
+ }
191
+ return {
192
+ state,
193
+ queued,
194
+ failures,
195
+ error: lastError,
196
+ sinceSyncMs: sinceSync,
197
+ localSeq: rec?.seq ?? 0,
198
+ serverMaxSeq,
199
+ synced: rec !== null && !rec.dead && serverMaxSeq >= rec.seq && queued === 0,
200
+ deadReason: rec?.dead ? (rec.deadReason ?? '') : '',
201
+ };
202
+ }
203
+ /**
204
+ * Record that the SERVER will not accept this session's events any more
205
+ * (extension D9). Freezes the HUD clock and stops the retry loop; the durable
206
+ * tail stays until Stop.
207
+ */
208
+ export function markSessionDead(reason) {
209
+ const rec = getState();
210
+ if (!rec || rec.dead)
211
+ return;
212
+ rec.dead = true;
213
+ rec.deadReason = reason;
214
+ if (!rec.paused && rec.resumeAt) {
215
+ rec.activeMs = (rec.activeMs || 0) + (Date.now() - Date.parse(rec.resumeAt));
216
+ rec.resumeAt = null;
217
+ }
218
+ setState(rec);
219
+ console.warn('vitrinka: session marked dead —', reason);
220
+ notify();
221
+ }
222
+ /** Ask the server what it actually holds (extension D5/D9). */
223
+ export async function reconcile() {
224
+ const rec = getState();
225
+ if (!rec || rec.dead)
226
+ return;
227
+ let ses;
228
+ try {
229
+ ses = await api('GET', `/api/v1/sessions/${rec.sessionId}`);
230
+ }
231
+ catch (e) {
232
+ if (e instanceof VitrinkaApiError && e.status === 404) {
233
+ markSessionDead('session no longer exists on the server');
234
+ return;
235
+ }
236
+ noteFailure(e);
237
+ notify();
238
+ return;
239
+ }
240
+ if (getState()?.sessionId !== rec.sessionId)
241
+ return; // stopped while the GET was in flight
242
+ noteSync();
243
+ serverMaxSeq = Math.max(serverMaxSeq, Number(ses.maxSeq ?? 0));
244
+ if (ses.status === 'done' || ses.deletedAt) {
245
+ markSessionDead(ses.deletedAt ? 'session was deleted' : 'session was closed on the server');
246
+ return;
247
+ }
248
+ notify();
249
+ }
250
+ let reconcileTimer = null;
251
+ export function armReconcile() {
252
+ if (reconcileTimer)
253
+ return;
254
+ reconcileTimer = setInterval(() => {
255
+ void reconcile();
256
+ }, RECONCILE_MS);
257
+ }
258
+ export function disarmReconcile() {
259
+ if (reconcileTimer)
260
+ clearInterval(reconcileTimer);
261
+ reconcileTimer = null;
262
+ }
263
+ /** Allocate `count` consecutive seqs without emitting events; null when not capturing. */
264
+ export function allocSeq(count = 1) {
265
+ const rec = getState();
266
+ if (!rec || rec.paused || rec.dead || count < 1)
267
+ return null;
268
+ const first = rec.seq + 1;
269
+ rec.seq += count;
270
+ setState(rec);
271
+ return { seq: first, sessionId: rec.sessionId };
272
+ }
273
+ /**
274
+ * In-flight CAPTURES (async network body reads) that have not yet appended
275
+ * their event. Stop must settle these before draining.
276
+ */
277
+ const inFlightCaptures = new Set();
278
+ export function trackCapture(p) {
279
+ inFlightCaptures.add(p);
280
+ return p
281
+ .catch(() => undefined)
282
+ .finally(() => {
283
+ inFlightCaptures.delete(p);
284
+ });
285
+ }
286
+ export const CAPTURES_SETTLE_MS = 5000;
287
+ /** Wait for in-flight captures, bounded; false when stragglers were abandoned. */
288
+ export async function capturesSettled(deadlineMs = CAPTURES_SETTLE_MS) {
289
+ const t0 = Date.now();
290
+ let guard = 0;
291
+ while (inFlightCaptures.size > 0 && guard++ < 50) {
292
+ const remaining = deadlineMs - (Date.now() - t0);
293
+ if (remaining <= 0)
294
+ break;
295
+ const timeout = new Promise((res) => setTimeout(res, remaining));
296
+ await Promise.race([Promise.allSettled([...inFlightCaptures]), timeout]);
297
+ }
298
+ if (inFlightCaptures.size > 0) {
299
+ const stuck = inFlightCaptures.size;
300
+ inFlightCaptures.clear();
301
+ console.warn(`vitrinka: ${stuck} capture(s) still in flight after ${Date.now() - t0}ms — abandoned, proceeding without them`);
302
+ return false;
303
+ }
304
+ return true;
305
+ }
306
+ /** Is `id` the session currently in state AND still accepting capture? */
307
+ export function isSessionLive(id) {
308
+ const rec = getState();
309
+ return rec !== null && rec.sessionId === id && !rec.dead;
310
+ }
311
+ /**
312
+ * Append an event to the durable buffer (drops when no live session or
313
+ * paused). `tabId`/`tabHost`/`ts`/`seq` are filled here; capture layers pass
314
+ * kind+payload plus the route they observed. Returns the stamped ts (null
315
+ * when dropped).
316
+ */
317
+ export function pushEvent(kind, payload, route) {
318
+ const rec = getState();
319
+ if (!rec || rec.paused || rec.dead)
320
+ return null;
321
+ rec.seq++;
322
+ const ts = new Date().toISOString();
323
+ const buffer = getBuffer();
324
+ buffer.push({ seq: rec.seq, ts, tabId: route.tabId, tabHost: route.tabHost, kind, payload });
325
+ if (buffer.length > MAX_BUFFER) {
326
+ const dropped = buffer.length - MAX_BUFFER;
327
+ buffer.splice(0, dropped);
328
+ console.warn(`vitrinka: retry buffer full — dropped ${dropped} oldest events`);
329
+ }
330
+ noteActivity();
331
+ schedulePersist();
332
+ setState(rec);
333
+ scheduleFlush();
334
+ return ts;
335
+ }
336
+ /** Push a fully-formed event (chunk rows carry a pre-allocated seq). */
337
+ export function pushRawEvent(ev) {
338
+ getBuffer().push(ev);
339
+ noteActivity();
340
+ schedulePersist();
341
+ scheduleFlush();
342
+ }
343
+ // -- idle tracking -----------------------------------------------------------
344
+ let lastEventAt = Date.now();
345
+ function noteActivity() {
346
+ lastEventAt = Date.now();
347
+ }
348
+ export function idleMs() {
349
+ return Date.now() - lastEventAt;
350
+ }
351
+ export function resetIdle() {
352
+ noteActivity();
353
+ }
354
+ // -- rrweb chunks ------------------------------------------------------------
355
+ /**
356
+ * Split a batch of rrweb events into size-bounded, in-order parts (extension
357
+ * `splitRRWebEvents`). Returns the serialized bodies and the byte sizes of
358
+ * events that are alone beyond the wire cap (undeliverable).
359
+ */
360
+ export function splitRRWebEvents(events, packBytes = CHUNK_PACK_BYTES, hardBytes = CHUNK_HARD_BYTES) {
361
+ const parts = [];
362
+ const dropped = [];
363
+ let curStrs = [];
364
+ let curBytes = 2; // "[]"
365
+ const flushPart = () => {
366
+ if (!curStrs.length)
367
+ return;
368
+ parts.push({ count: curStrs.length, body: `[${curStrs.join(',')}]` });
369
+ curStrs = [];
370
+ curBytes = 2;
371
+ };
372
+ for (const ev of events) {
373
+ const s = JSON.stringify(ev);
374
+ const b = utf8Bytes(s) + 1;
375
+ if (b > hardBytes) {
376
+ dropped.push(b);
377
+ continue;
378
+ }
379
+ if (b > packBytes) {
380
+ flushPart();
381
+ parts.push({ count: 1, body: `[${s}]` });
382
+ continue;
383
+ }
384
+ if (curStrs.length && curBytes + b > packBytes)
385
+ flushPart();
386
+ curStrs.push(s);
387
+ curBytes += b;
388
+ }
389
+ flushPart();
390
+ return { parts, dropped };
391
+ }
392
+ /**
393
+ * Queue a batch of rrweb events as chunks under pre-allocated seqs. Dropped
394
+ * (undeliverable) events surface as a ⚠ note on the timeline, as the
395
+ * extension does. Returns the number of chunks queued (0 when not capturing).
396
+ */
397
+ export function pushRRWebBatch(events, route) {
398
+ if (!events.length)
399
+ return 0;
400
+ const rec = getState();
401
+ if (!rec || rec.paused || rec.dead)
402
+ return 0;
403
+ const { parts, dropped } = splitRRWebEvents(events);
404
+ for (const b of dropped) {
405
+ console.warn(`vitrinka: rrweb event (${b} bytes) exceeds the chunk cap — dropped`);
406
+ pushEvent('note', {
407
+ text: `⚠ rrweb event dropped (${(b / 1048576).toFixed(1)} MiB > chunk cap) — replay may be incomplete from here`,
408
+ }, route);
409
+ }
410
+ const alloc = parts.length ? allocSeq(parts.length) : null;
411
+ if (!alloc)
412
+ return 0;
413
+ const chunks = getChunks();
414
+ const ts = new Date().toISOString();
415
+ parts.forEach((p, i) => {
416
+ chunks.push({
417
+ seq: alloc.seq + i,
418
+ ts,
419
+ tabId: route.tabId,
420
+ tabHost: route.tabHost,
421
+ sessionId: alloc.sessionId,
422
+ count: p.count,
423
+ body: p.body,
424
+ });
425
+ chunkBytes += p.body.length;
426
+ });
427
+ while (chunks.length > MAX_PENDING) {
428
+ const drop = chunks.shift();
429
+ if (drop)
430
+ chunkBytes -= drop.body.length;
431
+ console.warn(`vitrinka: pending chunk queue full — dropped rrweb chunk seq ${drop?.seq}`);
432
+ }
433
+ noteActivity();
434
+ schedulePersist();
435
+ scheduleFlush();
436
+ return parts.length;
437
+ }
438
+ /**
439
+ * Upload queued chunks oldest-first. Items are BOUND to their originating
440
+ * session; stale-session items drop loudly. Returns true when the queue is
441
+ * empty.
442
+ */
443
+ async function drainPending(limit = 5) {
444
+ const chunks = getChunks();
445
+ if (chunks.length === 0)
446
+ return true;
447
+ const rec = getState();
448
+ let done = 0;
449
+ const gone = new Set();
450
+ for (const item of chunks) {
451
+ if (done >= limit)
452
+ break;
453
+ done++;
454
+ if (!rec || item.sessionId !== rec.sessionId) {
455
+ console.warn(`vitrinka: dropping rrweb chunk seq ${item.seq} from ended session ${item.sessionId}`);
456
+ gone.add(item.seq);
457
+ continue;
458
+ }
459
+ try {
460
+ const up = await uploadChunk(rec.sessionId, item.seq, item.body);
461
+ // The event row joins the buffer only now, keeping its original seq —
462
+ // a late retry fills the stream's gap.
463
+ pushRawEvent({
464
+ seq: item.seq,
465
+ ts: item.ts,
466
+ tabId: item.tabId,
467
+ tabHost: item.tabHost,
468
+ kind: 'rrweb',
469
+ payload: { count: item.count },
470
+ blobKey: up.blobKey,
471
+ });
472
+ noteSync();
473
+ gone.add(item.seq);
474
+ }
475
+ catch (e) {
476
+ if (e instanceof VitrinkaApiError && permanentStatus(e.status)) {
477
+ console.warn(`vitrinka: rrweb chunk seq ${item.seq} rejected permanently (${e.status}) — dropped`);
478
+ gone.add(item.seq);
479
+ continue;
480
+ }
481
+ noteFailure(e);
482
+ console.warn('vitrinka: chunk upload failed', e);
483
+ break; // transient — stop the pass, the next flush retries
484
+ }
485
+ }
486
+ if (gone.size)
487
+ setChunks(getChunks().filter((c) => !gone.has(c.seq)));
488
+ return getChunks().length === 0;
489
+ }
490
+ let flushTimer = null;
491
+ export function scheduleFlush() {
492
+ if (flushTimer)
493
+ return;
494
+ flushTimer = setTimeout(() => {
495
+ flushTimer = null;
496
+ void flush();
497
+ }, FLUSH_MS);
498
+ }
499
+ let flushBusy = false;
500
+ /** Single-flight flush; true when the events POST succeeded (or nothing to send). */
501
+ export async function flush(opts = {}) {
502
+ if (flushBusy)
503
+ return false;
504
+ flushBusy = true;
505
+ try {
506
+ return await flushInner(opts);
507
+ }
508
+ finally {
509
+ flushBusy = false;
510
+ }
511
+ }
512
+ async function flushInner(opts) {
513
+ if (getState()?.dead)
514
+ return false;
515
+ const pendingClear = await drainPending();
516
+ if (!pendingClear)
517
+ scheduleFlush();
518
+ const rec = getState();
519
+ const buffer = getBuffer();
520
+ if (!rec || buffer.length === 0)
521
+ return pendingClear;
522
+ const batch = [];
523
+ const oversized = new Set();
524
+ let batchBytes = 0;
525
+ // A keepalive POST (pagehide) is capped by the browser at 64 KiB.
526
+ const packCap = opts.keepalive ? 60 * 1024 : MAX_BATCH_BYTES;
527
+ for (const ev of buffer) {
528
+ if (batch.length >= 500)
529
+ break;
530
+ const b = utf8Bytes(JSON.stringify(ev)) + 1;
531
+ if (b > MAX_EVENT_BYTES) {
532
+ console.warn(`vitrinka: event seq ${ev.seq} (${b} bytes) exceeds the wire cap — dropped`);
533
+ oversized.add(ev.seq);
534
+ continue;
535
+ }
536
+ if (b > packCap) {
537
+ if (opts.keepalive)
538
+ break; // too big for keepalive — the next document's flush takes it
539
+ if (batch.length === 0) {
540
+ batch.push(ev);
541
+ batchBytes = b;
542
+ }
543
+ break; // send what precedes it first (or it alone) — FIFO preserved
544
+ }
545
+ if (batch.length > 0 && batchBytes + b > packCap)
546
+ break;
547
+ batch.push(ev);
548
+ batchBytes += b;
549
+ }
550
+ if (oversized.size) {
551
+ setBuffer(getBuffer().filter((ev) => !oversized.has(ev.seq)));
552
+ if (!batch.length) {
553
+ scheduleFlush();
554
+ return false;
555
+ }
556
+ }
557
+ if (!batch.length)
558
+ return false;
559
+ persistBuffer();
560
+ try {
561
+ await api('POST', `/api/v1/sessions/${rec.sessionId}/events`, { events: batch }, opts);
562
+ }
563
+ catch (e) {
564
+ if (e instanceof VitrinkaApiError && permanentStatus(e.status)) {
565
+ markSessionDead(`server rejected this session (${e.status})`);
566
+ return false;
567
+ }
568
+ noteFailure(e);
569
+ notify();
570
+ console.warn('vitrinka: flush failed, retrying', e);
571
+ scheduleFlush();
572
+ return false;
573
+ }
574
+ noteSync();
575
+ serverMaxSeq = Math.max(serverMaxSeq, batch[batch.length - 1]?.seq ?? serverMaxSeq);
576
+ notify();
577
+ // Remove EXACTLY the sent seqs — events pushed during the in-flight POST survive.
578
+ const sent = new Set(batch.map((e) => e.seq));
579
+ const rest = getBuffer().filter((e) => !sent.has(e.seq));
580
+ setBuffer(rest);
581
+ if (rest.length)
582
+ scheduleFlush();
583
+ return true;
584
+ }
585
+ /** Drain until buffer + chunks are empty or the deadline passes. */
586
+ export async function drainBuffer(deadlineMs = 60000) {
587
+ const t0 = Date.now();
588
+ while (Date.now() - t0 < deadlineMs) {
589
+ if (getBuffer().length === 0 && getChunks().length === 0)
590
+ return true;
591
+ const sent = await flush();
592
+ if (getState()?.dead)
593
+ return false;
594
+ if (!sent)
595
+ await new Promise((res) => setTimeout(res, 1000));
596
+ }
597
+ console.warn('vitrinka: drain timed out — remaining events stay queued');
598
+ return false;
599
+ }
600
+ /** Reset buffers for a fresh session. */
601
+ export function resetQueues() {
602
+ setBuffer([]);
603
+ setChunks([]);
604
+ }
605
+ /** Test-only: drop all module state so suites cannot leak into each other. */
606
+ export function __resetForTests() {
607
+ __dropCachesForTests();
608
+ inFlightCaptures.clear();
609
+ kv().remove('rec');
610
+ kv().remove('buffer');
611
+ kv().remove('chunks');
612
+ resetIdle();
613
+ }
614
+ /** Test-only: the recorded events currently buffered (in order). */
615
+ export function __bufferForTests() {
616
+ return getBuffer();
617
+ }
618
+ /** Test-only: the pending chunks (in order). */
619
+ export function __chunksForTests() {
620
+ return getChunks().map((c) => ({ seq: c.seq, count: c.count, sessionId: c.sessionId }));
621
+ }
622
+ /** Test-only: drop the in-memory caches while LEAVING storage intact (a reload). */
623
+ export function __dropCachesForTests() {
624
+ if (persistTimer) {
625
+ clearTimeout(persistTimer);
626
+ persistTimer = null;
627
+ }
628
+ if (flushTimer) {
629
+ clearTimeout(flushTimer);
630
+ flushTimer = null;
631
+ }
632
+ flushBusy = false;
633
+ recCache = undefined;
634
+ bufferCache = undefined;
635
+ chunkCache = undefined;
636
+ chunkBytes = 0;
637
+ disarmReconcile();
638
+ lastSyncAt = 0;
639
+ lastError = '';
640
+ failures = 0;
641
+ serverMaxSeq = -1;
642
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Session lifecycle + shared recorder state, ported from the Expo recorder
3
+ * (itself from the extension's start/pause/stop semantics):
4
+ *
5
+ * - start: POST /sessions {host, title, meta} — the recorder key pins the
6
+ * project; the server resolves the environment (or honours an explicit one).
7
+ * - stop: drain FIRST; a timed-out drain REFUSES the stop (capture freezes
8
+ * paused, durable tail kept, "stop again once online"). A permanent PATCH
9
+ * verdict completes the stop locally so the tester isn't wedged.
10
+ * - pause: freezes the HUD clock via activeMs/resumeAt bookkeeping.
11
+ * - reconcile (extension D5/D9): armed for the life of the session; a dead
12
+ * verdict freezes capture, and Stop on a dead session completes locally.
13
+ */
14
+ import type { RedactionPolicy } from '@vitrinka/redact';
15
+ import type { SessionDone } from '../protocol';
16
+ import { type SessionState } from './queue';
17
+ export { currentRoute, notify, subscribe } from './state';
18
+ export type { SessionDone } from '../protocol';
19
+ /** Sent as `meta.recorder`; bumped with the package version. */
20
+ export declare const RECORDER_VERSION = "0.1.0";
21
+ export declare const RECORDER_ID = "web/0.1.0";
22
+ /** What `POST /api/v1/sessions` answers (the fields this recorder keeps). */
23
+ export interface SessionOut {
24
+ id: string;
25
+ project: string;
26
+ environment: string;
27
+ title: string;
28
+ boardUrl?: string;
29
+ boardSlug?: string;
30
+ workspace?: string;
31
+ }
32
+ export declare function recoverRedactionPolicy(): void;
33
+ export declare function elapsedOf(rec: SessionState | null): number;
34
+ export interface StartOptions {
35
+ title?: string;
36
+ /** Server lane; omitted = the project's rule decides (config's default applies). */
37
+ environment?: string;
38
+ /** Marks a machine-driven run (recorded in session meta). */
39
+ driver?: 'ai';
40
+ /** Tags to attach right after create (non-fatal). */
41
+ tags?: string[];
42
+ }
43
+ export declare function startSession(opts?: StartOptions): Promise<SessionState>;
44
+ export declare function togglePause(): Promise<boolean>;
45
+ /** A plain note — `{text, route}`, the extension's shape. */
46
+ export declare function addNote(text: string): void;
47
+ /** A rect in CSS pixels (viewport coordinates). */
48
+ export interface ViewRect {
49
+ x: number;
50
+ y: number;
51
+ w: number;
52
+ h: number;
53
+ }
54
+ /** Scale a viewport rect to device pixels — the extension's `imageRect` space. */
55
+ export declare function imagePixels(r: ViewRect): ViewRect;
56
+ /**
57
+ * An annotation — the extension's annotate-note `{text, rect, selector,
58
+ * annotate: true}` (+ `task` when the tester chose the task destination);
59
+ * vitrinka projects it into a board annotation. `selector` is '' for a free
60
+ * region. The rect is in device pixels. An empty note is still a valid
61
+ * annotation, matching the extension.
62
+ */
63
+ export declare function addAnnotation(text: string, rect: ViewRect, selector: string, opts?: {
64
+ task?: boolean;
65
+ }): void;
66
+ export declare function onBeforeStop(fn: () => void): () => void;
67
+ /**
68
+ * Stop the session. Throws with the queued-item count when the server is
69
+ * unreachable — the durable tail is NEVER deleted; capture freezes paused and
70
+ * a later Stop finishes the job once online.
71
+ */
72
+ export declare function stopSession(): Promise<SessionDone | null>;
73
+ export type { RedactionPolicy };