@atolljs/devtools 0.1.7 → 0.1.8

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 (48) hide show
  1. package/README.md +408 -71
  2. package/app/api.js +68 -0
  3. package/app/index.html +161 -2
  4. package/app/main.js +877 -127
  5. package/app/panels/actions.js +324 -0
  6. package/app/panels/audit-rules.js +728 -0
  7. package/app/panels/audits.js +438 -0
  8. package/app/panels/inspect-island.js +351 -0
  9. package/app/panels/memory-values.js +287 -0
  10. package/app/panels/performance.js +304 -0
  11. package/app/panels/reactivity.js +271 -0
  12. package/app/panels/recorder.js +344 -0
  13. package/app/panels/recording-format.js +166 -0
  14. package/app/panels/route.d.ts +44 -0
  15. package/app/panels/route.js +264 -0
  16. package/app/panels/shell.js +789 -0
  17. package/app/panels/toast.js +38 -0
  18. package/dist/bin.js +704 -5
  19. package/dist/chunk-57NGHQUL.js +344 -0
  20. package/dist/{chunk-4N3NBFL3.js → chunk-AGT6VNZI.js} +179 -217
  21. package/dist/chunk-WIL2P7HG.js +661 -0
  22. package/dist/client.d.ts +7 -0
  23. package/dist/control.d.ts +4 -0
  24. package/dist/index.js +2 -1
  25. package/dist/node.js +2 -1
  26. package/dist/nodeWs.d.ts +1 -0
  27. package/dist/otel.d.ts +35 -0
  28. package/dist/otel.js +87 -0
  29. package/dist/otelMap.d.ts +232 -0
  30. package/dist/otelTransport.d.ts +69 -0
  31. package/dist/overlay.d.ts +16 -2
  32. package/dist/probes.d.ts +7 -0
  33. package/dist/protocol.d.ts +46 -4
  34. package/dist/server.d.ts +19 -0
  35. package/dist/server.js +61 -3
  36. package/package.json +6 -2
  37. package/src/bin.ts +27 -2
  38. package/src/broadcast.ts +6 -0
  39. package/src/client.ts +26 -2
  40. package/src/control.ts +51 -0
  41. package/src/nodeWs.ts +7 -1
  42. package/src/otel.ts +140 -0
  43. package/src/otelMap.ts +661 -0
  44. package/src/otelTransport.ts +286 -0
  45. package/src/overlay.ts +153 -27
  46. package/src/probes.ts +155 -0
  47. package/src/protocol.ts +52 -4
  48. package/src/server.ts +83 -3
@@ -0,0 +1,344 @@
1
+ // Record / export / import / replay. Every received batch lands in a
2
+ // bounded rolling capture; ● Rec marks an explicit recording. Export writes
3
+ // the recording when there is one, else the rolling buffer. Import pauses
4
+ // the live transport (api.livePaused), resets the dashboard, and re-ingests
5
+ // the file's frames through api.ingest under closed '⏺ ' sessions, so the
6
+ // views rebuild exactly as they did live and no control command can fire.
7
+ // File format and pacing math: recording-format.js.
8
+
9
+ import {
10
+ SPEEDS, countEvents, createFrameBuffer, fmtClock, indexAt, nextChunk, playheadAt,
11
+ recordingDuration, recordingFileName, replaySessions, serializeRecording, validateRecording,
12
+ } from './recording-format.js';
13
+ import { toast } from './toast.js';
14
+
15
+ const ROLLING_EVENTS = 50_000;
16
+ const RECORDING_EVENTS = 500_000;
17
+ /** Seek ingests synchronously; chunk it only to keep one step from stalling. */
18
+ const PLAY_CHUNK_EVENTS = 2000;
19
+
20
+ const CSS = `
21
+ #rec-banner { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin:0 0 10px; padding:6px 10px;
22
+ border:1px solid #d2992266; border-left:4px solid #d29922; border-radius:6px; background:#1f1a0e; font-size:12px; }
23
+ #rec-banner b { color:#e3b341; }
24
+ #rec-banner .meta { color:#8b949e; }
25
+ #rec-banner input[type=range] { flex:1 1 220px; min-width:160px; accent-color:#d29922; }
26
+ #rec-banner select { background:#0d1117; color:#c9d1d9; border:1px solid #30363d; border-radius:4px; font:inherit; }
27
+ #rec-banner .clock { font-variant-numeric:tabular-nums; color:#c9d1d9; min-width:96px; }
28
+ #rec-drop { position:fixed; inset:0; z-index:9998; display:none; align-items:center; justify-content:center;
29
+ background:#010409cc; color:#e6edf3; font:600 18px system-ui,sans-serif; border:3px dashed #58a6ff; }
30
+ body.rec-dragging #rec-drop { display:flex; }
31
+ button.act.rec-on { border-color:#f85149; color:#ff7b72; }
32
+ `;
33
+
34
+ export function setup(api) {
35
+ const style = document.createElement('style');
36
+ style.textContent = CSS;
37
+ document.head.appendChild(style);
38
+
39
+ /* ── capture ──────────────────────────────────────────────────────────── */
40
+
41
+ const rolling = createFrameBuffer(ROLLING_EVENTS);
42
+ /** Explicit recording: { frames, events, active, capped } or null. */
43
+ let rec = null;
44
+ /** True while this panel itself is feeding api.ingest (replay/seek). */
45
+ let feeding = false;
46
+
47
+ api.onBatch((session, events) => {
48
+ if (feeding || api.livePaused || !events.length) return;
49
+ const frame = { t: performance.now(), session, events };
50
+ rolling.push(frame);
51
+ if (rec?.active) {
52
+ if (rec.events + events.length > RECORDING_EVENTS) {
53
+ if (!rec.capped) toast(`Recording hit ${RECORDING_EVENTS.toLocaleString()} events: stopped`, 'info');
54
+ rec.capped = true;
55
+ stopRec();
56
+ return;
57
+ }
58
+ rec.frames.push(frame);
59
+ rec.events += events.length;
60
+ }
61
+ });
62
+
63
+ /* ── toolbar ──────────────────────────────────────────────────────────── */
64
+
65
+ const recBtn = api.addToolbarButton({
66
+ id: 'rec-toggle', label: '● Rec', order: 20,
67
+ title: 'Start an explicit recording (Export then saves just the recording)',
68
+ onClick: () => (rec?.active ? stopRec() : startRec()),
69
+ });
70
+ const exportBtn = api.addToolbarButton({
71
+ id: 'rec-export', label: 'Export', order: 21,
72
+ title: 'Download the recording, or the rolling capture of recent frames',
73
+ onClick: () => exportFrames(),
74
+ });
75
+ api.addToolbarButton({
76
+ id: 'rec-import', label: 'Import', order: 22,
77
+ title: 'Open a recording (.json) for replay; you can also drop the file on the window',
78
+ onClick: () => picker.click(),
79
+ });
80
+
81
+ const syncButtons = () => {
82
+ recBtn.innerHTML = rec?.active ? '■ Stop' : '● Rec';
83
+ recBtn.classList.toggle('rec-on', !!rec?.active);
84
+ recBtn.title = rec?.active
85
+ ? `Recording: ${rec.events.toLocaleString()} events so far (click to stop)`
86
+ : 'Start an explicit recording (Export then saves just the recording)';
87
+ exportBtn.innerHTML = rec?.frames.length ? 'Export rec' : 'Export';
88
+ };
89
+
90
+ function startRec() {
91
+ if (api.livePaused) return toast('Go back to live before recording', 'info');
92
+ rec = { frames: [], events: 0, active: true, capped: false };
93
+ syncButtons();
94
+ toast('Recording started', 'info');
95
+ }
96
+
97
+ function stopRec() {
98
+ if (!rec) return;
99
+ rec.active = false;
100
+ syncButtons();
101
+ toast(`Recording stopped: ${rec.events.toLocaleString()} events`);
102
+ }
103
+
104
+ api.onTick(() => { if (rec?.active) syncButtons(); });
105
+
106
+ function download(name, data) {
107
+ const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
108
+ const a = document.createElement('a');
109
+ a.href = URL.createObjectURL(blob);
110
+ a.download = name;
111
+ document.body.appendChild(a);
112
+ a.click();
113
+ a.remove();
114
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
115
+ }
116
+
117
+ function exportFrames({ useRolling = false } = {}) {
118
+ const fromRec = !useRolling && rec?.frames.length;
119
+ const source = fromRec ? rec.frames : rolling.frames();
120
+ const sid = api.state.sel && api.state.sessions.has(api.state.sel) ? api.state.sel : null;
121
+ const data = serializeRecording(source, { sessionId: sid });
122
+ if (!data.frames.length) return toast('Nothing captured yet', 'info');
123
+ const name = sid ? (api.state.sessions.get(sid)?.name ?? sid) : null;
124
+ download(recordingFileName(name), data);
125
+ toast(`Exported ${countEvents(data.frames).toLocaleString()} events${fromRec ? ' (recording)' : ''}`);
126
+ }
127
+
128
+ /* ── import ───────────────────────────────────────────────────────────── */
129
+
130
+ const picker = document.createElement('input');
131
+ picker.type = 'file';
132
+ picker.accept = '.json,application/json';
133
+ picker.style.display = 'none';
134
+ picker.onchange = () => {
135
+ const f = picker.files?.[0];
136
+ picker.value = '';
137
+ if (f) importFile(f);
138
+ };
139
+ document.body.appendChild(picker);
140
+
141
+ const drop = document.createElement('div');
142
+ drop.id = 'rec-drop';
143
+ drop.textContent = 'Drop an atoll devtools recording to replay it';
144
+ document.body.appendChild(drop);
145
+ const hasFiles = (e) => [...(e.dataTransfer?.types ?? [])].includes('Files');
146
+ let dragDepth = 0;
147
+ window.addEventListener('dragenter', (e) => {
148
+ if (!hasFiles(e)) return;
149
+ dragDepth++;
150
+ document.body.classList.add('rec-dragging');
151
+ });
152
+ window.addEventListener('dragleave', () => {
153
+ if (dragDepth && --dragDepth === 0) document.body.classList.remove('rec-dragging');
154
+ });
155
+ window.addEventListener('dragover', (e) => { if (hasFiles(e)) e.preventDefault(); });
156
+ window.addEventListener('drop', (e) => {
157
+ if (!hasFiles(e)) return;
158
+ e.preventDefault();
159
+ dragDepth = 0;
160
+ document.body.classList.remove('rec-dragging');
161
+ const f = [...e.dataTransfer.files].find((x) => /\.json$/i.test(x.name) || x.type === 'application/json');
162
+ if (f) importFile(f);
163
+ else toast('Drop a .json recording', 'error');
164
+ });
165
+
166
+ async function importFile(file) {
167
+ let recording;
168
+ try {
169
+ recording = validateRecording(JSON.parse(await file.text()));
170
+ } catch (err) {
171
+ return toast(`Import failed: ${err.message}`, 'error');
172
+ }
173
+ if (rec?.active) stopRec();
174
+ startReplay(recording, file.name);
175
+ }
176
+
177
+ /* ── replay ───────────────────────────────────────────────────────────── */
178
+
179
+ /** { frames, sessions, name, duration, events, cursor, playhead, playing, speed, wallStart, fromMs, timer, els } */
180
+ let rp = null;
181
+
182
+ function startReplay(recording, name) {
183
+ stopPlayback();
184
+ api.livePaused = true;
185
+ const frames = recording.frames;
186
+ rp = {
187
+ frames,
188
+ sessions: replaySessions(recording),
189
+ name,
190
+ duration: recordingDuration(frames),
191
+ events: countEvents(frames),
192
+ cursor: 0,
193
+ playhead: 0,
194
+ playing: false,
195
+ speed: 1,
196
+ timer: 0,
197
+ els: null,
198
+ };
199
+ buildBanner();
200
+ seek(rp.duration); // the full picture first; Play restarts from 0
201
+ toast(`Loaded ${name}: ${rp.events.toLocaleString()} events`);
202
+ }
203
+
204
+ /** Feed frames [rp.cursor, to) into the dashboard. */
205
+ function feed(to) {
206
+ feeding = true;
207
+ try {
208
+ for (let i = rp.cursor; i < to; i++) {
209
+ const f = rp.frames[i];
210
+ api.ingest(rp.sessions.get(f.sessionId), f.events);
211
+ }
212
+ } finally {
213
+ feeding = false;
214
+ }
215
+ rp.cursor = to;
216
+ }
217
+
218
+ /** Jump to recording time `at`: reset, then re-ingest everything up to it. */
219
+ function seek(at) {
220
+ rp.seeking = true;
221
+ try {
222
+ api.reset();
223
+ } finally {
224
+ rp.seeking = false;
225
+ }
226
+ rp.cursor = 0;
227
+ rp.playhead = Math.max(0, Math.min(at, rp.duration));
228
+ feed(indexAt(rp.frames, rp.playhead));
229
+ syncBanner();
230
+ }
231
+
232
+ function play() {
233
+ if (!rp) return;
234
+ if (rp.cursor >= rp.frames.length) seek(0);
235
+ rp.playing = true;
236
+ rp.fromMs = rp.playhead;
237
+ rp.wallStart = performance.now();
238
+ step();
239
+ syncBanner();
240
+ }
241
+
242
+ function stopPlayback() {
243
+ if (!rp) return;
244
+ rp.playing = false;
245
+ clearTimeout(rp.timer);
246
+ syncBanner();
247
+ }
248
+
249
+ function step() {
250
+ if (!rp?.playing) return;
251
+ const target = playheadAt(rp.fromMs, performance.now() - rp.wallStart, rp.speed, rp.duration);
252
+ const chunk = nextChunk(rp.frames, rp.cursor, target, PLAY_CHUNK_EVENTS);
253
+ feed(chunk.to);
254
+ // A capped chunk lags the wall clock: the playhead is what was ingested.
255
+ rp.playhead = chunk.to < indexAt(rp.frames, target) ? rp.frames[chunk.to - 1]?.t ?? target : target;
256
+ if (chunk.done && rp.playhead >= rp.duration) {
257
+ rp.playhead = rp.duration;
258
+ rp.playing = false;
259
+ syncBanner();
260
+ return;
261
+ }
262
+ syncBanner();
263
+ rp.timer = setTimeout(step, rp.speed === Infinity ? 0 : 33);
264
+ }
265
+
266
+ function setSpeed(speed) {
267
+ if (!rp) return;
268
+ if (rp.playing) {
269
+ rp.fromMs = rp.playhead;
270
+ rp.wallStart = performance.now();
271
+ }
272
+ rp.speed = speed;
273
+ }
274
+
275
+ function backToLive() {
276
+ stopPlayback();
277
+ rp?.els?.banner.remove();
278
+ rp = null;
279
+ api.reset();
280
+ api.livePaused = false;
281
+ api.reannounce();
282
+ toast('Back to live', 'info');
283
+ }
284
+
285
+ function buildBanner() {
286
+ document.getElementById('rec-banner')?.remove();
287
+ const banner = document.createElement('div');
288
+ banner.id = 'rec-banner';
289
+ banner.innerHTML = `
290
+ <span>⏺ Replay <b></b></span>
291
+ <span class="meta"></span>
292
+ <button class="act" data-r="play">▶ Play</button>
293
+ <select data-r="speed" title="Playback speed">${SPEEDS.map((s) => `<option value="${s}">${s === Infinity ? 'max' : `${s}x`}</option>`).join('')}</select>
294
+ <input type="range" data-r="scrub" min="0" step="1" title="Seek: rebuilds the dashboard up to this point">
295
+ <span class="clock"></span>
296
+ <button class="act" data-r="live" title="Discard the replay and reconnect to live apps">Back to live</button>`;
297
+ const q = (r) => banner.querySelector(`[data-r="${r}"]`);
298
+ const els = { banner, name: banner.querySelector('b'), meta: banner.querySelector('.meta'), play: q('play'), speed: q('speed'), scrub: q('scrub'), clock: banner.querySelector('.clock'), live: q('live') };
299
+ els.name.textContent = rp.name;
300
+ els.meta.textContent = `${fmtClock(rp.duration)} · ${rp.events.toLocaleString()} events · ${rp.sessions.size} session(s)`;
301
+ els.scrub.max = String(Math.max(1, Math.ceil(rp.duration)));
302
+ els.play.onclick = () => (rp.playing ? stopPlayback() : play());
303
+ els.speed.onchange = () => setSpeed(Number(els.speed.value));
304
+ els.scrub.oninput = () => { els.clock.textContent = `${fmtClock(Number(els.scrub.value))} / ${fmtClock(rp.duration)}`; };
305
+ els.scrub.onchange = () => {
306
+ const wasPlaying = rp.playing;
307
+ stopPlayback();
308
+ seek(Number(els.scrub.value));
309
+ if (wasPlaying) play();
310
+ };
311
+ els.live.onclick = backToLive;
312
+ rp.els = els;
313
+ const panels = document.querySelector('.panels');
314
+ if (panels) panels.prepend(banner);
315
+ else document.body.prepend(banner);
316
+ }
317
+
318
+ function syncBanner() {
319
+ const els = rp?.els;
320
+ if (!els) return;
321
+ els.play.textContent = rp.playing ? '❚❚ Pause' : '▶ Play';
322
+ if (document.activeElement !== els.scrub) els.scrub.value = String(Math.round(rp.playhead));
323
+ els.clock.textContent = `${fmtClock(rp.playhead)} / ${fmtClock(rp.duration)}`;
324
+ }
325
+
326
+ // An outside reset (not our seek) while replaying keeps the replay but
327
+ // rewinds its cursor so the next Play/seek re-ingests from a clean slate.
328
+ api.onReset(() => {
329
+ if (rp && !rp.seeking) {
330
+ rp.cursor = 0;
331
+ rp.playhead = 0;
332
+ stopPlayback();
333
+ }
334
+ });
335
+
336
+ /* ── palette ──────────────────────────────────────────────────────────── */
337
+
338
+ api.addPaletteItem({ id: 'recorder.toggle', title: 'Recording: start / stop', group: 'Recorder', run: () => (rec?.active ? stopRec() : startRec()) });
339
+ api.addPaletteItem({ id: 'recorder.export', title: 'Export recording', group: 'Recorder', hint: 'The explicit recording when there is one, else the rolling capture', run: () => exportFrames() });
340
+ api.addPaletteItem({ id: 'recorder.export-rolling', title: 'Export rolling capture', group: 'Recorder', hint: `The last ~${ROLLING_EVENTS.toLocaleString()} received events`, run: () => exportFrames({ useRolling: true }) });
341
+ api.addPaletteItem({ id: 'recorder.discard', title: 'Discard recording', group: 'Recorder', run: () => { rec = null; syncButtons(); toast('Recording discarded', 'info'); } });
342
+ api.addPaletteItem({ id: 'recorder.import', title: 'Import recording…', group: 'Recorder', run: () => picker.click() });
343
+ api.addPaletteItem({ id: 'recorder.live', title: 'Replay: back to live', group: 'Recorder', run: () => { if (rp) backToLive(); else toast('Not replaying', 'info'); } });
344
+ }
@@ -0,0 +1,166 @@
1
+ // Pure helpers for devtools recordings: the file format, validation, the
2
+ // bounded rolling capture, and replay pacing. No DOM, no api: recorder.js
3
+ // owns the UI; vitest imports this module directly.
4
+ //
5
+ // File shape (version 1):
6
+ // { format: 'atoll-devtools-recording', version: 1, exportedAt,
7
+ // sessions: SessionInfo[], frames: [{ t, sessionId, events }] }
8
+ // `t` is the dashboard's performance.now() at receipt; replay only uses
9
+ // differences, so recordings from any clock origin play back the same.
10
+
11
+ export const FORMAT = 'atoll-devtools-recording';
12
+ export const VERSION = 1;
13
+ /** Prefix marking replayed sessions in the dashboard. */
14
+ export const REPLAY_PREFIX = '⏺ ';
15
+ /** Playback speeds offered by the replay banner; Infinity = as fast as possible. */
16
+ export const SPEEDS = [1, 4, 16, Infinity];
17
+
18
+ /**
19
+ * Bounded capture of received frames, evicting oldest whole frames once
20
+ * the total event count passes `maxEvents`.
21
+ */
22
+ export function createFrameBuffer(maxEvents = 50_000) {
23
+ let frames = [];
24
+ let head = 0;
25
+ let events = 0;
26
+ return {
27
+ push(frame) {
28
+ frames.push(frame);
29
+ events += frame.events.length;
30
+ while (events > maxEvents && head < frames.length - 1) {
31
+ events -= frames[head].events.length;
32
+ frames[head++] = undefined;
33
+ }
34
+ if (head > 1024 && head > frames.length / 2) {
35
+ frames = frames.slice(head);
36
+ head = 0;
37
+ }
38
+ },
39
+ frames: () => frames.slice(head),
40
+ get eventCount() { return events; },
41
+ get frameCount() { return frames.length - head; },
42
+ clear() { frames = []; head = 0; events = 0; },
43
+ };
44
+ }
45
+
46
+ const pad = (n) => String(n).padStart(2, '0');
47
+
48
+ /** `atoll-devtools-<session-or-all>-<yyyymmdd-hhmmss>.json` (local time). */
49
+ export function recordingFileName(sessionName, date = new Date()) {
50
+ const slug = (sessionName ?? 'all').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'session';
51
+ const stamp = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
52
+ return `atoll-devtools-${slug}-${stamp}.json`;
53
+ }
54
+
55
+ /**
56
+ * Build the export object from captured frames ({ t, session, events } as
57
+ * the recorder holds them). `sessionId` narrows to one session.
58
+ */
59
+ export function serializeRecording(captured, { sessionId = null, exportedAt = new Date().toISOString() } = {}) {
60
+ const sessions = new Map();
61
+ const frames = [];
62
+ for (const f of captured) {
63
+ if (sessionId && f.session.id !== sessionId) continue;
64
+ sessions.set(f.session.id, f.session);
65
+ frames.push({ t: f.t, sessionId: f.session.id, events: f.events });
66
+ }
67
+ return { format: FORMAT, version: VERSION, exportedAt, sessions: [...sessions.values()], frames };
68
+ }
69
+
70
+ /**
71
+ * Validate a parsed file; returns the recording with frames sorted by `t`
72
+ * and times rebased to start at 0. Throws an Error naming the first problem.
73
+ */
74
+ export function validateRecording(data) {
75
+ if (!data || typeof data !== 'object') throw new Error('not a JSON object');
76
+ if (data.format !== FORMAT) throw new Error(`not an atoll devtools recording (format: ${JSON.stringify(data.format)})`);
77
+ if (data.version !== VERSION) throw new Error(`unsupported recording version ${JSON.stringify(data.version)} (expected ${VERSION})`);
78
+ if (!Array.isArray(data.sessions)) throw new Error('sessions must be an array');
79
+ if (!Array.isArray(data.frames)) throw new Error('frames must be an array');
80
+ for (const [i, s] of data.sessions.entries()) {
81
+ if (!s || typeof s.id !== 'string') throw new Error(`sessions[${i}] has no string id`);
82
+ }
83
+ for (const [i, f] of data.frames.entries()) {
84
+ if (!f || typeof f.t !== 'number' || !Number.isFinite(f.t)) throw new Error(`frames[${i}].t must be a finite number`);
85
+ if (typeof f.sessionId !== 'string') throw new Error(`frames[${i}].sessionId must be a string`);
86
+ if (!Array.isArray(f.events)) throw new Error(`frames[${i}].events must be an array`);
87
+ }
88
+ const sorted = data.frames.slice().sort((a, b) => a.t - b.t);
89
+ const t0 = sorted.length ? sorted[0].t : 0;
90
+ return {
91
+ ...data,
92
+ frames: sorted.map((f) => ({ t: f.t - t0, sessionId: f.sessionId, events: f.events })),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Sessions for replay, keyed by id: renamed with the replay prefix and
98
+ * closed (the dashboard sends no control commands to closed sessions).
99
+ * Frames naming an unlisted session get a stub entry.
100
+ */
101
+ export function replaySessions(recording) {
102
+ const out = new Map();
103
+ for (const s of recording.sessions) {
104
+ out.set(s.id, { ...s, name: `${REPLAY_PREFIX}${s.name ?? s.id}`, closed: true });
105
+ }
106
+ for (const f of recording.frames) {
107
+ if (!out.has(f.sessionId)) {
108
+ out.set(f.sessionId, { id: f.sessionId, name: `${REPLAY_PREFIX}${f.sessionId}`, runtime: 'browser', closed: true });
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /** Recording length in ms (frames rebased by validateRecording). */
115
+ export const recordingDuration = (frames) => (frames.length ? frames[frames.length - 1].t : 0);
116
+
117
+ export const countEvents = (frames) => frames.reduce((n, f) => n + f.events.length, 0);
118
+
119
+ /** Number of frames with `t <= at` — the replay cursor for a playhead (binary search). */
120
+ export function indexAt(frames, at) {
121
+ let lo = 0;
122
+ let hi = frames.length;
123
+ while (lo < hi) {
124
+ const mid = (lo + hi) >> 1;
125
+ if (frames[mid].t <= at) lo = mid + 1;
126
+ else hi = mid;
127
+ }
128
+ return lo;
129
+ }
130
+
131
+ /** Frames up to and including playhead `at`. */
132
+ export const framesUpTo = (frames, at) => frames.slice(0, indexAt(frames, at));
133
+
134
+ /**
135
+ * Playhead for timed playback: recording ms reached after `wallMs` of
136
+ * wall time at `speed`, starting from `fromMs`, clamped to `duration`.
137
+ * Infinity speed jumps to the end (callers chunk the ingest instead).
138
+ */
139
+ export function playheadAt(fromMs, wallMs, speed, duration) {
140
+ if (speed === Infinity) return duration;
141
+ return Math.min(duration, fromMs + Math.max(0, wallMs) * speed);
142
+ }
143
+
144
+ /**
145
+ * The next slice to ingest during playback: frames [from, to) are due at
146
+ * `playhead`, capped at `maxEvents` per step (at least one frame) so a dense
147
+ * stretch or max speed never blocks the dashboard for long.
148
+ */
149
+ export function nextChunk(frames, from, playhead, maxEvents = 2000) {
150
+ const due = indexAt(frames, playhead);
151
+ let to = from;
152
+ let n = 0;
153
+ while (to < due && (to === from || n + frames[to].events.length <= maxEvents)) {
154
+ n += frames[to].events.length;
155
+ to++;
156
+ }
157
+ return { from, to, events: n, done: to >= frames.length };
158
+ }
159
+
160
+ /** `1:05.3` style clock for the banner. */
161
+ export function fmtClock(ms) {
162
+ const s = Math.max(0, ms) / 1000;
163
+ const m = Math.floor(s / 60);
164
+ const rest = s - m * 60;
165
+ return `${m}:${rest < 10 ? '0' : ''}${rest.toFixed(1)}`;
166
+ }
@@ -0,0 +1,44 @@
1
+ // Types for route.js (plain JS, served without a build step).
2
+
3
+ export interface Route {
4
+ view: string;
5
+ sub: string | null;
6
+ params: Record<string, string>;
7
+ }
8
+ export interface RouteInput {
9
+ view: string;
10
+ sub?: string | null;
11
+ params?: Record<string, string | null | undefined>;
12
+ }
13
+ export interface PaletteLike {
14
+ title: string;
15
+ group?: string;
16
+ hint?: string;
17
+ keywords?: string;
18
+ }
19
+ export interface Ranked<T> {
20
+ item: T;
21
+ score: number;
22
+ idx: number[];
23
+ }
24
+
25
+ export const ROUTE_PARAMS: string[];
26
+ export function parseRoute(hash: string): Route | null;
27
+ export function formatRoute(route: RouteInput): string;
28
+ export function keyTail(k: string): string;
29
+ export function resolveKey(
30
+ keys: Iterable<string>,
31
+ want: string | null | undefined,
32
+ isLive?: (k: string) => boolean,
33
+ ): string | null;
34
+ export function fuzzyMatch(query: string, text: string): { score: number; idx: number[] } | null;
35
+ export function rankItems<T extends PaletteLike>(query: string, items: T[]): Ranked<T>[];
36
+ export function groupResults<T extends PaletteLike>(
37
+ ranked: Ranked<T>[],
38
+ opts?: { order?: string[]; perGroup?: number; byScore?: boolean },
39
+ ): { group: string; items: Ranked<T>[] }[];
40
+ export const G_VIEWS: Record<string, string>;
41
+ export function viewForLetter(letter: string, views: { id: string; label?: string }[]): string | null;
42
+ export function letterForView(id: string, views: { id: string; label?: string }[]): string | null;
43
+ export function isTypingTarget(el: unknown): boolean;
44
+ export function layoutNav(widths: number[], avail: number, active: number, moreW: number): number[];