@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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +93 -0
- package/README.md +177 -0
- package/build/index.d.ts +11 -0
- package/build/index.js +1 -0
- package/build/next.d.ts +23 -0
- package/build/next.js +27 -0
- package/build/protocol/index.d.ts +74 -0
- package/build/protocol/index.js +22 -0
- package/build/recorder/RecorderProvider.d.ts +17 -0
- package/build/recorder/RecorderProvider.js +126 -0
- package/build/recorder/api-status.d.ts +6 -0
- package/build/recorder/api-status.js +6 -0
- package/build/recorder/api.d.ts +40 -0
- package/build/recorder/api.js +84 -0
- package/build/recorder/capture/click.d.ts +17 -0
- package/build/recorder/capture/click.js +77 -0
- package/build/recorder/capture/console.d.ts +3 -0
- package/build/recorder/capture/console.js +89 -0
- package/build/recorder/capture/nav.d.ts +8 -0
- package/build/recorder/capture/nav.js +54 -0
- package/build/recorder/capture/net.d.ts +22 -0
- package/build/recorder/capture/net.js +506 -0
- package/build/recorder/capture/redact.d.ts +38 -0
- package/build/recorder/capture/redact.js +54 -0
- package/build/recorder/capture/rrweb.d.ts +10 -0
- package/build/recorder/capture/rrweb.js +76 -0
- package/build/recorder/config.d.ts +50 -0
- package/build/recorder/config.js +100 -0
- package/build/recorder/control.d.ts +29 -0
- package/build/recorder/control.js +63 -0
- package/build/recorder/hud/AnnotateOverlay.d.ts +25 -0
- package/build/recorder/hud/AnnotateOverlay.js +122 -0
- package/build/recorder/hud/Hud.d.ts +12 -0
- package/build/recorder/hud/Hud.js +190 -0
- package/build/recorder/hud/LinkSheet.d.ts +26 -0
- package/build/recorder/hud/LinkSheet.js +15 -0
- package/build/recorder/hud/RecorderPill.d.ts +36 -0
- package/build/recorder/hud/RecorderPill.js +73 -0
- package/build/recorder/hud/Sheet.d.ts +20 -0
- package/build/recorder/hud/Sheet.js +36 -0
- package/build/recorder/hud/host.d.ts +27 -0
- package/build/recorder/hud/host.js +170 -0
- package/build/recorder/hud/icons.d.ts +15 -0
- package/build/recorder/hud/icons.js +40 -0
- package/build/recorder/hud/styles.d.ts +13 -0
- package/build/recorder/hud/styles.js +111 -0
- package/build/recorder/index.d.ts +46 -0
- package/build/recorder/index.js +61 -0
- package/build/recorder/link.d.ts +18 -0
- package/build/recorder/link.js +37 -0
- package/build/recorder/queue.d.ts +163 -0
- package/build/recorder/queue.js +642 -0
- package/build/recorder/session.d.ts +73 -0
- package/build/recorder/session.js +246 -0
- package/build/recorder/state.d.ts +26 -0
- package/build/recorder/state.js +42 -0
- package/build/recorder/storage/index.d.ts +35 -0
- package/build/recorder/storage/index.js +69 -0
- package/build/recorder/storage/memory.d.ts +2 -0
- package/build/recorder/storage/memory.js +2 -0
- 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 };
|