@lightworkai.official/debug-capture 0.19.8 → 0.19.10
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/README.md +26 -9
- package/dist/debug-capture.js +17 -16
- package/dist/index.mjs +22 -21
- package/dist/replayPack.d.ts +56 -3
- package/dist/replayPictures.d.ts +33 -0
- package/dist/types.d.ts +10 -2
- package/dist/ui/chatBubble.d.ts +13 -6
- package/package.json +1 -1
- package/src/replayPack.ts +369 -44
- package/src/replayPictures.ts +200 -0
- package/src/submit.ts +1 -1
- package/src/types.ts +14 -2
- package/src/ui/chatBubble.ts +56 -25
package/src/replayPack.ts
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* rrweb events compress about ten to one — a full snapshot is mostly
|
|
5
5
|
* repeated markup — so the browser gzips them before they travel, with the
|
|
6
6
|
* stream API every current browser has. Where it is missing the JSON goes as
|
|
7
|
-
* it is, if it fits
|
|
8
|
-
* than failing the report over.
|
|
7
|
+
* it is, if it fits.
|
|
9
8
|
*
|
|
10
9
|
* Its own file, like the screenshot and for the same reason: `context` is a
|
|
11
10
|
* column on the ticket row, read by every query that returns the ticket, and
|
|
@@ -14,6 +13,7 @@
|
|
|
14
13
|
*/
|
|
15
14
|
import { toBase64 } from "./base64";
|
|
16
15
|
import { redactReplay } from "./capture/replayRedact";
|
|
16
|
+
import { browserJpeg, hasHeavyPngs, lightenPictures, pictureStrings, type PictureEncoder } from "./replayPictures";
|
|
17
17
|
import type { ReplayCapture, ReplayEvent, ReplaySummary } from "./types";
|
|
18
18
|
|
|
19
19
|
/** The name the desk looks for. Changing it here changes it nowhere else. */
|
|
@@ -26,6 +26,13 @@ export interface PackedReplay {
|
|
|
26
26
|
summary: ReplaySummary;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
export interface PackOptions {
|
|
30
|
+
/** JPEG quality for a canvas picture the browser could only make as PNG. Default 0.5. */
|
|
31
|
+
quality?: number;
|
|
32
|
+
/** How such a picture is made lighter. Default the browser's own encoder; null skips the step. */
|
|
33
|
+
lighten?: PictureEncoder | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
/** Is there something to attach, before the work of packing it. */
|
|
30
37
|
export function hasReplayEvents(replay: ReplayCapture | null | undefined): replay is ReplayCapture {
|
|
31
38
|
return Boolean(replay && replay.events.length > 0);
|
|
@@ -41,51 +48,249 @@ async function gzip(bytes: Uint8Array): Promise<Uint8Array | null> {
|
|
|
41
48
|
}
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
interface Encoded {
|
|
52
|
+
bytes: Uint8Array;
|
|
53
|
+
compressed: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function encode(events: ReplayEvent[]): Promise<Encoded> {
|
|
57
|
+
const plain = new TextEncoder().encode(JSON.stringify({ events }));
|
|
58
|
+
const zipped = await gzip(plain);
|
|
59
|
+
return zipped ? { bytes: zipped, compressed: true } : { bytes: plain, compressed: false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** How much of each picture the estimate reads, and from how many. A megabyte at most. */
|
|
63
|
+
const SAMPLE_EACH = 65_536;
|
|
64
|
+
const SAMPLE_PICTURES = 16;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The least the canvas pictures alone will weigh compressed — without
|
|
68
|
+
* compressing them. Gzip is most of a pack's time: in WebKit 6 of 10.6
|
|
69
|
+
* seconds for an 82 MB recording, measured 2026-11-02 — and a recording that
|
|
70
|
+
* large is over the cap before it starts. A sample of the pictures is
|
|
71
|
+
* gzipped instead (Safari's PNG came out at 0.75 of its base64, but a
|
|
72
|
+
* browser's encoder is not ours to assume), and the estimate kept a tenth
|
|
73
|
+
* under what the sample says. Zero when there are none.
|
|
74
|
+
*/
|
|
75
|
+
async function pictureFloor(events: readonly ReplayEvent[]): Promise<number> {
|
|
76
|
+
const pictures = pictureStrings(events);
|
|
77
|
+
const total = pictures.reduce((sum, p) => sum + p.length, 0);
|
|
78
|
+
if (total === 0) return 0;
|
|
79
|
+
const step = Math.max(1, Math.floor(pictures.length / SAMPLE_PICTURES));
|
|
80
|
+
let sample = "";
|
|
81
|
+
for (let i = 0; i < pictures.length && sample.length < SAMPLE_EACH * SAMPLE_PICTURES; i += step) sample += pictures[i]!.slice(0, SAMPLE_EACH);
|
|
82
|
+
const plain = new TextEncoder().encode(sample);
|
|
83
|
+
const zipped = await gzip(plain);
|
|
84
|
+
const ratio = zipped ? zipped.byteLength / plain.byteLength : 1;
|
|
85
|
+
return total * ratio * 0.9;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What was given up to fit, said on the ticket. */
|
|
89
|
+
interface Cuts {
|
|
90
|
+
canvasDropped?: true;
|
|
91
|
+
picturesDropped?: true;
|
|
92
|
+
/** Long text and styles shortened — the last resort before the snapshot itself. */
|
|
93
|
+
simplified?: true;
|
|
94
|
+
/** "start": only the latest part went. "end": not even that whole, the recording stops before the report. */
|
|
95
|
+
trimmed?: "start" | "end";
|
|
96
|
+
}
|
|
97
|
+
|
|
44
98
|
/**
|
|
45
99
|
* Pack, or say why not. Never throws: the report goes on without it.
|
|
46
100
|
*
|
|
47
101
|
* The cap is applied to what is SENT — compressed bytes when compression is
|
|
48
102
|
* available — because that is the number the server's per-file limit is
|
|
49
103
|
* measured against.
|
|
104
|
+
*
|
|
105
|
+
* Over the cap, a report still carries a recording. Each step below is taken
|
|
106
|
+
* only when the one before it did not fit, and each gives up less than the
|
|
107
|
+
* next:
|
|
108
|
+
*
|
|
109
|
+
* 1. Safari's PNG canvas pictures saved again as JPEG (replayPictures) —
|
|
110
|
+
* nothing visible is lost.
|
|
111
|
+
* 2. Canvas pictures kept from the latest moment that fits, the earlier
|
|
112
|
+
* ones left out: the document as it last looked, over the whole
|
|
113
|
+
* recording. A canvas whose picture went plays blank until it is next
|
|
114
|
+
* drawn.
|
|
115
|
+
* 3. Every canvas picture left out: the canvases stay, blank; every
|
|
116
|
+
* click, page and field is still there.
|
|
117
|
+
* 4. Large pictures embedded in the page itself (a `data:` image, a font
|
|
118
|
+
* inlined in a stylesheet) swapped for an empty one.
|
|
119
|
+
* 5. The oldest part left out: the recording is kept in stretches that
|
|
120
|
+
* each begin with a full picture of the page (capture/replay — a
|
|
121
|
+
* checkout every half of the configured length), so the latest
|
|
122
|
+
* stretches play on their own. As many as fit, newest first.
|
|
123
|
+
* 6. The latest stretch from its start, as far as fits — it then stops
|
|
124
|
+
* before the report, and the ticket says by how much.
|
|
125
|
+
* 7. Long text and styles shortened, when not even the page's own picture
|
|
126
|
+
* fits.
|
|
127
|
+
*
|
|
128
|
+
* Only a page whose single picture is over the cap after all of that goes
|
|
129
|
+
* without — millions of elements. CIB-5880 (2026-11-02) went without at
|
|
130
|
+
* step 3: the strip missed every canvas added after the snapshot, and
|
|
131
|
+
* Safari's pictures are PNG.
|
|
50
132
|
*/
|
|
51
|
-
export async function packReplay(
|
|
133
|
+
export async function packReplay(
|
|
134
|
+
replay: ReplayCapture | null | undefined,
|
|
135
|
+
maxBytes: number,
|
|
136
|
+
options: PackOptions = {},
|
|
137
|
+
): Promise<PackedReplay | { summary: ReplaySummary }> {
|
|
52
138
|
if (!hasReplayEvents(replay)) return { summary: { omitted: "empty" } };
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
const
|
|
71
|
-
|
|
139
|
+
const recordedSeconds = Math.round(replay.durationMs / 1000);
|
|
140
|
+
// The same URL policy as the network log — see capture/replayRedact. A copy: every step below works on it in place.
|
|
141
|
+
const events = redactReplay(replay.events);
|
|
142
|
+
const fitted = await fit(events, maxBytes, options);
|
|
143
|
+
if (!("events" in fitted)) {
|
|
144
|
+
return {
|
|
145
|
+
summary: {
|
|
146
|
+
seconds: recordedSeconds,
|
|
147
|
+
events: replay.events.length,
|
|
148
|
+
startedAt: replay.startedAt,
|
|
149
|
+
endedAt: replay.endedAt,
|
|
150
|
+
bytes: fitted.bytes,
|
|
151
|
+
omitted: "too-large",
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const { bytes, compressed } = fitted.encoded;
|
|
156
|
+
const sent = fitted.events;
|
|
157
|
+
const first = sent[0]!.timestamp;
|
|
158
|
+
const last = sent[sent.length - 1]!.timestamp;
|
|
159
|
+
const { trimmed, ...dropped } = fitted.cuts;
|
|
160
|
+
const recordedEnd = Date.parse(replay.endedAt);
|
|
72
161
|
return {
|
|
73
162
|
dataUri: `data:${compressed ? "application/gzip" : "application/json"};base64,${toBase64(bytes)}`,
|
|
74
163
|
filename: compressed ? REPLAY_FILENAME : PLAIN_FILENAME,
|
|
75
|
-
summary: {
|
|
164
|
+
summary: {
|
|
165
|
+
seconds: Math.round((last - first) / 1000),
|
|
166
|
+
events: sent.length,
|
|
167
|
+
startedAt: new Date(first).toISOString(),
|
|
168
|
+
endedAt: new Date(last).toISOString(),
|
|
169
|
+
bytes: bytes.byteLength,
|
|
170
|
+
compressed,
|
|
171
|
+
...dropped,
|
|
172
|
+
...(trimmed ? { recordedSeconds } : {}),
|
|
173
|
+
...(trimmed === "end" && !Number.isNaN(recordedEnd) ? { endsEarlySeconds: Math.round((recordedEnd - last) / 1000) } : {}),
|
|
174
|
+
},
|
|
76
175
|
};
|
|
77
176
|
}
|
|
78
177
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
178
|
+
type Fitted = { events: ReplayEvent[]; encoded: Encoded; cuts: Cuts } | { bytes: number };
|
|
179
|
+
|
|
180
|
+
async function fit(events: ReplayEvent[], maxBytes: number, options: PackOptions): Promise<Fitted> {
|
|
181
|
+
const cuts: Cuts = {};
|
|
182
|
+
/** What the recording weighed whole — the summary's number when nothing fits. */
|
|
183
|
+
let whole = 0;
|
|
184
|
+
/** These events encoded, when they fit; not even encoded when their pictures alone cannot. */
|
|
185
|
+
const attempt = async (list: ReplayEvent[]): Promise<Encoded | null> => {
|
|
186
|
+
const floor = await pictureFloor(list);
|
|
187
|
+
if (floor > maxBytes) {
|
|
188
|
+
whole ||= Math.round(floor);
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const encoded = await encode(list);
|
|
192
|
+
whole ||= encoded.bytes.byteLength;
|
|
193
|
+
return encoded.bytes.byteLength <= maxBytes ? encoded : null;
|
|
194
|
+
};
|
|
195
|
+
let encoded = await attempt(events);
|
|
196
|
+
if (encoded) return { events, encoded, cuts };
|
|
197
|
+
|
|
198
|
+
// 1. Safari's PNGs as JPEG.
|
|
199
|
+
const lighten = options.lighten === undefined ? browserJpeg(options.quality ?? 0.5) : options.lighten;
|
|
200
|
+
if (lighten && hasHeavyPngs(events) && (await lightenPictures(events, lighten)) > 0) {
|
|
201
|
+
encoded = await attempt(events);
|
|
202
|
+
if (encoded) return { events, encoded, cuts };
|
|
203
|
+
}
|
|
204
|
+
// 2. Canvas pictures kept from the latest moment that fits — the document as it last looked, over the whole recording.
|
|
205
|
+
const moments = [...new Set(events.filter(hasCanvasPicture).map((e) => e.timestamp))].sort((x, y) => x - y);
|
|
206
|
+
if (moments.length > 1) {
|
|
207
|
+
// Keeping from moments[0] is the whole, which did not fit; from past the last is step 3.
|
|
208
|
+
let lo = 1;
|
|
209
|
+
let hi = moments.length;
|
|
210
|
+
let best: { events: ReplayEvent[]; encoded: Encoded } | null = null;
|
|
211
|
+
while (lo < hi) {
|
|
212
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
213
|
+
const kept = picturesFrom(events, moments[mid]!);
|
|
214
|
+
const e = await attempt(kept);
|
|
215
|
+
if (e) {
|
|
216
|
+
best = { events: kept, encoded: e };
|
|
217
|
+
hi = mid;
|
|
218
|
+
} else lo = mid + 1;
|
|
219
|
+
}
|
|
220
|
+
if (best) return { ...best, cuts: { ...cuts, canvasDropped: true } };
|
|
221
|
+
}
|
|
222
|
+
// 3. Every canvas picture.
|
|
223
|
+
if (stripCanvas(events)) {
|
|
224
|
+
cuts.canvasDropped = true;
|
|
225
|
+
events = events.filter((e) => !isCanvasFrame(e));
|
|
226
|
+
encoded = await attempt(events);
|
|
227
|
+
if (encoded) return { events, encoded, cuts };
|
|
228
|
+
}
|
|
229
|
+
// 4. Pictures embedded in the page.
|
|
230
|
+
if (dropEmbedded(events)) {
|
|
231
|
+
cuts.picturesDropped = true;
|
|
232
|
+
encoded = await attempt(events);
|
|
233
|
+
if (encoded) return { events, encoded, cuts };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 5. The oldest stretches.
|
|
237
|
+
const windows = splitWindows(events);
|
|
238
|
+
if (windows.length > 1) {
|
|
239
|
+
const sizes: number[] = [];
|
|
240
|
+
for (const w of windows) sizes.push((await encode(w)).bytes.byteLength);
|
|
241
|
+
// Newest first, as many as their sizes allow; then confirmed whole, one fewer at a time.
|
|
242
|
+
let from = windows.length;
|
|
243
|
+
let total = 0;
|
|
244
|
+
while (from > 0 && total + sizes[from - 1]! <= maxBytes) total += sizes[--from]!;
|
|
245
|
+
for (let k = Math.max(from, 1); k < windows.length; k++) {
|
|
246
|
+
const kept = windows.slice(k).flat();
|
|
247
|
+
encoded = await encode(kept);
|
|
248
|
+
if (encoded.bytes.byteLength <= maxBytes) return { events: kept, encoded, cuts: { ...cuts, trimmed: "start" } };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 6. The latest stretch from its start, as far as fits; 7. shortened, if not even its picture does.
|
|
253
|
+
const latest = windows[windows.length - 1]!;
|
|
254
|
+
let head = await fitHead(latest, maxBytes);
|
|
255
|
+
if (!head && cutLongStrings(latest)) {
|
|
256
|
+
cuts.simplified = true;
|
|
257
|
+
head = await fitHead(latest, maxBytes);
|
|
258
|
+
}
|
|
259
|
+
if (!head) return { bytes: whole };
|
|
260
|
+
const trimmed = head.events.length < latest.length ? "end" : windows.length > 1 ? "start" : undefined;
|
|
261
|
+
return { ...head, cuts: { ...cuts, ...(trimmed ? { trimmed } : {}) } };
|
|
83
262
|
}
|
|
84
263
|
|
|
85
|
-
|
|
86
|
-
*
|
|
87
|
-
|
|
264
|
+
/**
|
|
265
|
+
* The longest start of one stretch that fits — never shorter than its Meta
|
|
266
|
+
* and full snapshot, without which nothing plays. Null when not even those do.
|
|
267
|
+
*/
|
|
268
|
+
async function fitHead(stretch: ReplayEvent[], maxBytes: number): Promise<{ events: ReplayEvent[]; encoded: Encoded } | null> {
|
|
269
|
+
// `lo` events always fit, more than `hi` never do.
|
|
270
|
+
let lo = stretch.findIndex((e) => e.type === FULL_SNAPSHOT) + 1 || 1;
|
|
271
|
+
let hi = stretch.length;
|
|
272
|
+
let best = { events: stretch.slice(0, lo), encoded: await encode(stretch.slice(0, lo)) };
|
|
273
|
+
if (best.encoded.bytes.byteLength > maxBytes) return null;
|
|
274
|
+
while (lo < hi) {
|
|
275
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
276
|
+
const prefix = stretch.slice(0, mid);
|
|
277
|
+
const encoded = await encode(prefix);
|
|
278
|
+
if (encoded.bytes.byteLength <= maxBytes) {
|
|
279
|
+
lo = mid;
|
|
280
|
+
best = { events: prefix, encoded };
|
|
281
|
+
} else hi = mid - 1;
|
|
282
|
+
}
|
|
283
|
+
return best;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* rrweb: a Meta (4) opens every full snapshot (2); a canvas frame is an
|
|
287
|
+
* IncrementalSnapshot (3) from the CanvasMutation source (9); a canvas's
|
|
288
|
+
* picture is `rr_dataURL`, on the full snapshot's node and on a node a
|
|
289
|
+
* Mutation (source 0) adds later. */
|
|
290
|
+
const META = 4;
|
|
88
291
|
const FULL_SNAPSHOT = 2;
|
|
292
|
+
const INCREMENTAL = 3;
|
|
293
|
+
const MUTATION = 0;
|
|
89
294
|
const CANVAS_MUTATION = 9;
|
|
90
295
|
|
|
91
296
|
interface SerializedNode {
|
|
@@ -97,29 +302,149 @@ function isCanvasFrame(e: ReplayEvent): boolean {
|
|
|
97
302
|
return e.type === INCREMENTAL && (e.data as { source?: number } | null)?.source === CANVAS_MUTATION;
|
|
98
303
|
}
|
|
99
304
|
|
|
100
|
-
|
|
101
|
-
|
|
305
|
+
/** Every serialized node an event carries: a snapshot's tree, a mutation's added nodes. */
|
|
306
|
+
function eachNodeTree(e: ReplayEvent, visit: (node: SerializedNode | undefined) => void): void {
|
|
307
|
+
const data = e.data as Record<string, unknown> | null;
|
|
308
|
+
if (!data || typeof data !== "object") return;
|
|
309
|
+
if (e.type === FULL_SNAPSHOT) visit(data.node as SerializedNode | undefined);
|
|
310
|
+
else if (e.type === INCREMENTAL && data.source === MUTATION) {
|
|
311
|
+
for (const add of (data.adds as Array<{ node?: SerializedNode }> | undefined) ?? []) visit(add.node);
|
|
312
|
+
}
|
|
102
313
|
}
|
|
103
314
|
|
|
104
|
-
|
|
315
|
+
/** Removes every `rr_dataURL` below `node`; whether there was one. */
|
|
316
|
+
function dropDataUrls(node: SerializedNode | undefined): boolean {
|
|
105
317
|
if (!node) return false;
|
|
106
|
-
|
|
107
|
-
|
|
318
|
+
let found = false;
|
|
319
|
+
if (node.attributes && "rr_dataURL" in node.attributes) {
|
|
320
|
+
delete node.attributes.rr_dataURL;
|
|
321
|
+
found = true;
|
|
322
|
+
}
|
|
323
|
+
for (const child of node.childNodes ?? []) found = dropDataUrls(child) || found;
|
|
324
|
+
return found;
|
|
108
325
|
}
|
|
109
326
|
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
if (node.attributes && "rr_dataURL" in node.attributes) delete node.attributes.rr_dataURL;
|
|
113
|
-
for (const child of node.childNodes ?? []) dropDataUrls(child);
|
|
327
|
+
function hasDataUrl(node: SerializedNode | undefined): boolean {
|
|
328
|
+
return !!node && ((node.attributes !== undefined && "rr_dataURL" in node.attributes) || (node.childNodes ?? []).some(hasDataUrl));
|
|
114
329
|
}
|
|
115
330
|
|
|
116
|
-
/**
|
|
117
|
-
|
|
331
|
+
/** Whether an event carries a canvas picture: a frame, or a node with one. */
|
|
332
|
+
function hasCanvasPicture(e: ReplayEvent): boolean {
|
|
333
|
+
if (isCanvasFrame(e)) return true;
|
|
334
|
+
let found = false;
|
|
335
|
+
eachNodeTree(e, (node) => (found ||= hasDataUrl(node)));
|
|
336
|
+
return found;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** A node tree without its pictures, sharing everything else — the strings are not copied. */
|
|
340
|
+
function withoutDataUrls(node: SerializedNode | undefined): SerializedNode | undefined {
|
|
341
|
+
if (!node || !hasDataUrl(node)) return node;
|
|
342
|
+
const { rr_dataURL: _picture, ...attributes } = node.attributes ?? {};
|
|
343
|
+
return { ...node, ...(node.attributes ? { attributes } : {}), ...(node.childNodes ? { childNodes: node.childNodes.map((c) => withoutDataUrls(c)!) } : {}) };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The recording with only the canvas pictures from `since` on: an earlier
|
|
348
|
+
* frame left out, an earlier snapshot's or added canvas's picture removed —
|
|
349
|
+
* those canvases play blank until they are next drawn. New events where one
|
|
350
|
+
* changes; the events handed in are not touched.
|
|
351
|
+
*/
|
|
352
|
+
function picturesFrom(events: readonly ReplayEvent[], since: number): ReplayEvent[] {
|
|
118
353
|
const out: ReplayEvent[] = [];
|
|
119
354
|
for (const e of events) {
|
|
120
|
-
if (
|
|
121
|
-
if (
|
|
122
|
-
|
|
355
|
+
if (e.timestamp >= since || !hasCanvasPicture(e)) out.push(e);
|
|
356
|
+
else if (isCanvasFrame(e)) continue;
|
|
357
|
+
else if (e.type === FULL_SNAPSHOT) {
|
|
358
|
+
const data = e.data as { node?: SerializedNode };
|
|
359
|
+
out.push({ ...e, data: { ...data, node: withoutDataUrls(data.node) } });
|
|
360
|
+
} else {
|
|
361
|
+
const data = e.data as { adds?: Array<{ node?: SerializedNode }> };
|
|
362
|
+
out.push({ ...e, data: { ...data, adds: (data.adds ?? []).map((add) => ({ ...add, node: withoutDataUrls(add.node) })) } });
|
|
363
|
+
}
|
|
123
364
|
}
|
|
124
365
|
return out;
|
|
125
366
|
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Every canvas picture removed, in place — the canvases stay, blank. The
|
|
370
|
+
* snapshot's and those on a canvas added later alike: missing the second
|
|
371
|
+
* kind is how CIB-5749 went out marked `canvasDropped` with 1.05 MB of
|
|
372
|
+
* pictures on fourteen canvases still in it. Canvas frames are dropped by
|
|
373
|
+
* the caller (`isCanvasFrame`). Whether there was anything to remove.
|
|
374
|
+
*/
|
|
375
|
+
export function stripCanvas(events: readonly ReplayEvent[]): boolean {
|
|
376
|
+
let found = false;
|
|
377
|
+
for (const e of events) {
|
|
378
|
+
if (isCanvasFrame(e)) found = true;
|
|
379
|
+
else eachNodeTree(e, (node) => (found = dropDataUrls(node) || found));
|
|
380
|
+
}
|
|
381
|
+
return found;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Larger than this, an embedded `data:` URL is a picture or a font rather than an icon. */
|
|
385
|
+
const EMBED_LIMIT = 16_384;
|
|
386
|
+
/** What a dropped picture becomes: a transparent pixel, so the element keeps its box and shows no broken-image icon. */
|
|
387
|
+
const BLANK_PICTURE = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
|
388
|
+
const CSS_DATA_URL = new RegExp(`url\\(\\s*(['"]?)data:[^'")]{${EMBED_LIMIT},}\\1\\s*\\)`, "g");
|
|
389
|
+
|
|
390
|
+
/** Each string in an event, through `change`, in place. Whether any changed. */
|
|
391
|
+
function mapStrings(value: unknown, change: (s: string) => string): boolean {
|
|
392
|
+
if (!value || typeof value !== "object") return false;
|
|
393
|
+
let changed = false;
|
|
394
|
+
const entries = Array.isArray(value) ? value.map((v, i) => [i, v] as const) : Object.entries(value);
|
|
395
|
+
for (const [key, v] of entries) {
|
|
396
|
+
if (typeof v === "string") {
|
|
397
|
+
const next = change(v);
|
|
398
|
+
if (next !== v) {
|
|
399
|
+
(value as Record<string | number, unknown>)[key] = next;
|
|
400
|
+
changed = true;
|
|
401
|
+
}
|
|
402
|
+
} else if (v && typeof v === "object") changed = mapStrings(v, change) || changed;
|
|
403
|
+
}
|
|
404
|
+
return changed;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Large pictures the page itself holds — an `<img src="data:…">`, a font or
|
|
409
|
+
* background inlined in a stylesheet — swapped for an empty one, in place,
|
|
410
|
+
* wherever the events carry them: attributes, styles, style rules.
|
|
411
|
+
*/
|
|
412
|
+
export function dropEmbedded(events: readonly ReplayEvent[]): boolean {
|
|
413
|
+
let found = false;
|
|
414
|
+
for (const e of events) {
|
|
415
|
+
found =
|
|
416
|
+
mapStrings(e.data, (s) => {
|
|
417
|
+
if (s.length < EMBED_LIMIT) return s;
|
|
418
|
+
if (s.startsWith("data:")) return s.startsWith("data:image/") ? BLANK_PICTURE : "";
|
|
419
|
+
return s.includes("data:") ? s.replace(CSS_DATA_URL, "url()") : s;
|
|
420
|
+
}) || found;
|
|
421
|
+
}
|
|
422
|
+
return found;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Longer than this, a string is cut — the last resort, for a page whose own picture will not fit otherwise. */
|
|
426
|
+
const STRING_LIMIT = 2_000;
|
|
427
|
+
|
|
428
|
+
function cutLongStrings(stretch: readonly ReplayEvent[]): boolean {
|
|
429
|
+
let found = false;
|
|
430
|
+
for (const e of stretch) found = mapStrings(e.data, (s) => (s.length > STRING_LIMIT ? s.slice(0, STRING_LIMIT) : s)) || found;
|
|
431
|
+
return found;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The recording in its stretches, each from a Meta and full snapshot to the
|
|
436
|
+
* next — the units it can be shortened by and still play. Anything before
|
|
437
|
+
* the first Meta rides with the first stretch.
|
|
438
|
+
*/
|
|
439
|
+
export function splitWindows(events: readonly ReplayEvent[]): ReplayEvent[][] {
|
|
440
|
+
const windows: ReplayEvent[][] = [[]];
|
|
441
|
+
let opened = false;
|
|
442
|
+
for (const e of events) {
|
|
443
|
+
if (e.type === META) {
|
|
444
|
+
if (opened) windows.push([]);
|
|
445
|
+
opened = true;
|
|
446
|
+
}
|
|
447
|
+
windows[windows.length - 1]!.push(e);
|
|
448
|
+
}
|
|
449
|
+
return windows;
|
|
450
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas pictures made lighter, for the browsers that cannot make WebP.
|
|
3
|
+
*
|
|
4
|
+
* The recorder asks for WebP (`dataURLOptions`), and Safari answers every such
|
|
5
|
+
* request with a PNG — the same bytes `toDataURL("image/png")` gives, the
|
|
6
|
+
* quality ignored. A PDF page drawn at a Retina display's size is then a
|
|
7
|
+
* megabyte or more where Chrome's WebP is a tenth of that, and PNG's bytes do
|
|
8
|
+
* not gzip. CIB-5880 (2026-11-02): a 76-second recording of signing a
|
|
9
|
+
* document in Safari was 36 MB compressed against a 4 MB cap, and the report
|
|
10
|
+
* went without it.
|
|
11
|
+
*
|
|
12
|
+
* Over the cap, each large PNG is drawn again and saved as JPEG — but only
|
|
13
|
+
* one that is opaque throughout. JPEG has no transparency, and a canvas drawn
|
|
14
|
+
* over another (a signature layer, a chart on a coloured card) would play
|
|
15
|
+
* back as a solid box hiding what was under it; such a picture stays a PNG.
|
|
16
|
+
*
|
|
17
|
+
* Two places hold a picture: a canvas's `rr_dataURL` attribute, in the full
|
|
18
|
+
* snapshot and on a canvas added after it, and a canvas frame's `drawImage`
|
|
19
|
+
* of a Blob. Both are rewritten here, on the events handed in — the caller's
|
|
20
|
+
* copy, never the recorder's buffers.
|
|
21
|
+
*/
|
|
22
|
+
import { toBase64 } from "./base64";
|
|
23
|
+
import type { ReplayEvent } from "./types";
|
|
24
|
+
|
|
25
|
+
export interface Picture {
|
|
26
|
+
base64: string;
|
|
27
|
+
type: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A picture as JPEG, or null to keep it as it is. */
|
|
31
|
+
export type PictureEncoder = (picture: Picture) => Promise<Picture | null>;
|
|
32
|
+
|
|
33
|
+
/** Below this a PNG is not worth the work: an icon, a blank-ish chart. ~22 KB of bytes. */
|
|
34
|
+
const WORTH_IT = 30_000;
|
|
35
|
+
|
|
36
|
+
const FULL_SNAPSHOT = 2;
|
|
37
|
+
const INCREMENTAL = 3;
|
|
38
|
+
const MUTATION = 0;
|
|
39
|
+
const CANVAS_MUTATION = 9;
|
|
40
|
+
|
|
41
|
+
interface SerializedNode {
|
|
42
|
+
attributes?: Record<string, unknown>;
|
|
43
|
+
childNodes?: SerializedNode[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One place a picture is kept, and how to put a new one there. */
|
|
47
|
+
interface Slot {
|
|
48
|
+
picture: Picture;
|
|
49
|
+
put: (next: Picture) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DATA_URL = /^data:([^;,]+);base64,/;
|
|
53
|
+
|
|
54
|
+
function attributeSlots(node: SerializedNode | undefined, out: Slot[]): void {
|
|
55
|
+
if (!node) return;
|
|
56
|
+
const attributes = node.attributes;
|
|
57
|
+
const url = attributes?.rr_dataURL;
|
|
58
|
+
if (attributes && typeof url === "string") {
|
|
59
|
+
const match = DATA_URL.exec(url);
|
|
60
|
+
if (match) {
|
|
61
|
+
out.push({
|
|
62
|
+
picture: { type: match[1]!, base64: url.slice(match[0].length) },
|
|
63
|
+
put: (next) => (attributes.rr_dataURL = `data:${next.type};base64,${next.base64}`),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const child of node.childNodes ?? []) attributeSlots(child, out);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface BlobArg {
|
|
71
|
+
rr_type?: string;
|
|
72
|
+
type?: string;
|
|
73
|
+
data?: Array<{ rr_type?: string; base64?: string }>;
|
|
74
|
+
args?: unknown[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function frameSlots(commands: unknown, out: Slot[]): void {
|
|
78
|
+
if (!Array.isArray(commands)) return;
|
|
79
|
+
for (const command of commands as Array<{ args?: unknown[] }>) {
|
|
80
|
+
for (const arg of command.args ?? []) {
|
|
81
|
+
const bitmap = arg as BlobArg | null;
|
|
82
|
+
if (bitmap?.rr_type !== "ImageBitmap") continue;
|
|
83
|
+
const blob = bitmap.args?.[0] as BlobArg | undefined;
|
|
84
|
+
const buffer = blob?.rr_type === "Blob" ? blob.data?.[0] : undefined;
|
|
85
|
+
if (!blob || buffer?.rr_type !== "ArrayBuffer" || typeof buffer.base64 !== "string" || typeof blob.type !== "string") continue;
|
|
86
|
+
out.push({
|
|
87
|
+
picture: { type: blob.type, base64: buffer.base64 },
|
|
88
|
+
put: (next) => {
|
|
89
|
+
blob.type = next.type;
|
|
90
|
+
buffer.base64 = next.base64;
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every canvas picture in the events, wherever the recorder put it. */
|
|
98
|
+
export function pictureSlots(events: readonly ReplayEvent[]): Slot[] {
|
|
99
|
+
const out: Slot[] = [];
|
|
100
|
+
for (const event of events) {
|
|
101
|
+
const data = event.data as Record<string, unknown> | null;
|
|
102
|
+
if (!data || typeof data !== "object") continue;
|
|
103
|
+
if (event.type === FULL_SNAPSHOT) attributeSlots(data.node as SerializedNode | undefined, out);
|
|
104
|
+
else if (event.type === INCREMENTAL && data.source === MUTATION) {
|
|
105
|
+
for (const add of (data.adds as Array<{ node?: SerializedNode }> | undefined) ?? []) attributeSlots(add.node, out);
|
|
106
|
+
} else if (event.type === INCREMENTAL && data.source === CANVAS_MUTATION) frameSlots(data.commands, out);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Every picture's base64, as often as it occurs — gzip's 32 KB window never sees a picture's earlier copy. */
|
|
112
|
+
export function pictureStrings(events: readonly ReplayEvent[]): string[] {
|
|
113
|
+
return pictureSlots(events).map((slot) => slot.picture.base64);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const heavyPng = (slot: Slot) => slot.picture.type === "image/png" && slot.picture.base64.length >= WORTH_IT;
|
|
117
|
+
|
|
118
|
+
/** Whether there is anything for `lightenPictures` to do — before the work of decoding. */
|
|
119
|
+
export function hasHeavyPngs(events: readonly ReplayEvent[]): boolean {
|
|
120
|
+
return pictureSlots(events).some(heavyPng);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Each large PNG replaced by what `encode` makes of it, when that is smaller.
|
|
125
|
+
* The same picture is encoded once however often it recurs — a page's
|
|
126
|
+
* picture is in every snapshot the recording holds. Returns how many changed.
|
|
127
|
+
* A picture the encoder fails on stays as it was: this never throws.
|
|
128
|
+
*/
|
|
129
|
+
export async function lightenPictures(events: ReplayEvent[], encode: PictureEncoder): Promise<number> {
|
|
130
|
+
const done = new Map<string, Picture | null>();
|
|
131
|
+
let changed = 0;
|
|
132
|
+
for (const slot of pictureSlots(events).filter(heavyPng)) {
|
|
133
|
+
const key = slot.picture.base64;
|
|
134
|
+
if (!done.has(key)) {
|
|
135
|
+
let next: Picture | null = null;
|
|
136
|
+
try {
|
|
137
|
+
next = await encode(slot.picture);
|
|
138
|
+
} catch {
|
|
139
|
+
next = null;
|
|
140
|
+
}
|
|
141
|
+
done.set(key, next && next.base64.length < key.length ? next : null);
|
|
142
|
+
}
|
|
143
|
+
const next = done.get(key);
|
|
144
|
+
if (next) {
|
|
145
|
+
slot.put(next);
|
|
146
|
+
changed += 1;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return changed;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function fromBase64(base64: string): Uint8Array {
|
|
153
|
+
const binary = atob(base64);
|
|
154
|
+
const bytes = new Uint8Array(binary.length);
|
|
155
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
156
|
+
return bytes;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type AnyCanvas = OffscreenCanvas | HTMLCanvasElement;
|
|
160
|
+
|
|
161
|
+
function canvasOf(width: number, height: number): AnyCanvas | null {
|
|
162
|
+
if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(width, height);
|
|
163
|
+
if (typeof document === "undefined") return null;
|
|
164
|
+
const canvas = document.createElement("canvas");
|
|
165
|
+
canvas.width = width;
|
|
166
|
+
canvas.height = height;
|
|
167
|
+
return canvas;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function jpegOf(canvas: AnyCanvas, quality: number): Promise<Blob | null> {
|
|
171
|
+
if ("convertToBlob" in canvas) return canvas.convertToBlob({ type: "image/jpeg", quality });
|
|
172
|
+
return new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", quality));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The browser's own encoder: the PNG decoded, checked opaque pixel by pixel,
|
|
177
|
+
* and saved as JPEG at `quality`. Null where the browser lacks what it takes
|
|
178
|
+
* (no `createImageBitmap`), for a picture anything shows through, and for a
|
|
179
|
+
* browser that hands back something other than a JPEG.
|
|
180
|
+
*/
|
|
181
|
+
export function browserJpeg(quality: number): PictureEncoder | null {
|
|
182
|
+
if (typeof createImageBitmap !== "function" || (typeof OffscreenCanvas === "undefined" && typeof document === "undefined")) return null;
|
|
183
|
+
return async ({ base64, type }) => {
|
|
184
|
+
const bitmap = await createImageBitmap(new Blob([fromBase64(base64) as BlobPart], { type }));
|
|
185
|
+
try {
|
|
186
|
+
const { width, height } = bitmap;
|
|
187
|
+
const canvas = canvasOf(width, height);
|
|
188
|
+
const ctx = canvas?.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
|
|
189
|
+
if (!canvas || !ctx) return null;
|
|
190
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
191
|
+
const pixels = ctx.getImageData(0, 0, width, height).data;
|
|
192
|
+
for (let i = 3; i < pixels.length; i += 4) if (pixels[i] !== 255) return null;
|
|
193
|
+
const blob = await jpegOf(canvas, quality);
|
|
194
|
+
if (!blob || blob.type !== "image/jpeg") return null;
|
|
195
|
+
return { base64: toBase64(new Uint8Array(await blob.arrayBuffer())), type: "image/jpeg" };
|
|
196
|
+
} finally {
|
|
197
|
+
bitmap.close();
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|