@lvce-editor/renderer-process 30.56.0 → 30.57.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/rendererProcessMain.js +530 -4
- package/dist/sessionReplayWorkerMain.js +219 -0
- package/package.json +1 -1
|
@@ -1,3 +1,499 @@
|
|
|
1
|
+
const omitted = new Set(['SCRIPT', 'STYLE', 'LINK', 'META', 'BASE', 'NOSCRIPT']);
|
|
2
|
+
const unsupported = new Set(['IFRAME', 'WEBVIEW', 'CANVAS', 'OBJECT', 'EMBED', 'VIDEO', 'AUDIO']);
|
|
3
|
+
const attributes$1 = /^(class|style|id|title|role|type|checked|disabled|selected|placeholder|width|height|viewBox|d|fill|stroke|cx|cy|r|x|y|x1|x2|y1|y2|points|transform|xmlns|aria-[\w-]+)$/i;
|
|
4
|
+
const capture = document => {
|
|
5
|
+
const visit = node => {
|
|
6
|
+
if (node.nodeType === 3) return {
|
|
7
|
+
text: node.textContent
|
|
8
|
+
};
|
|
9
|
+
if (node.nodeType !== 1 || omitted.has(node.tagName)) return undefined;
|
|
10
|
+
if (node.hasAttribute('data-session-replay-ignore')) return undefined;
|
|
11
|
+
if (unsupported.has(node.tagName) || node.matches('.Terminal, .TerminalView, .xterm, [data-session-replay-placeholder]')) {
|
|
12
|
+
const {
|
|
13
|
+
width,
|
|
14
|
+
height
|
|
15
|
+
} = node.getBoundingClientRect();
|
|
16
|
+
return {
|
|
17
|
+
tag: 'div',
|
|
18
|
+
attrs: {
|
|
19
|
+
class: 'SessionReplayPlaceholder',
|
|
20
|
+
style: `background:#808080;color:white;width:${width}px;height:${height}px;overflow:hidden`
|
|
21
|
+
},
|
|
22
|
+
children: [{
|
|
23
|
+
text: 'Content unavailable in replay'
|
|
24
|
+
}]
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (node.matches('[data-session-replay-mask], input[type=password]')) return {
|
|
28
|
+
tag: 'span',
|
|
29
|
+
children: [{
|
|
30
|
+
text: '••••••••'
|
|
31
|
+
}]
|
|
32
|
+
};
|
|
33
|
+
const attrs = Object.fromEntries([...node.attributes].filter(({
|
|
34
|
+
name
|
|
35
|
+
}) => attributes$1.test(name)).map(({
|
|
36
|
+
name,
|
|
37
|
+
value
|
|
38
|
+
}) => [name, value]));
|
|
39
|
+
if (node.tagName === 'IMG' && node.src.startsWith('data:image/')) attrs.src = node.src;
|
|
40
|
+
return {
|
|
41
|
+
tag: node.localName,
|
|
42
|
+
attrs,
|
|
43
|
+
...(node.namespaceURI === 'http://www.w3.org/2000/svg' ? {
|
|
44
|
+
svg: true
|
|
45
|
+
} : {}),
|
|
46
|
+
...(typeof node.value === 'string' ? {
|
|
47
|
+
value: node.value
|
|
48
|
+
} : {}),
|
|
49
|
+
...(typeof node.checked === 'boolean' ? {
|
|
50
|
+
checked: node.checked
|
|
51
|
+
} : {}),
|
|
52
|
+
...(node.scrollTop || node.scrollLeft ? {
|
|
53
|
+
scroll: [node.scrollLeft, node.scrollTop]
|
|
54
|
+
} : {}),
|
|
55
|
+
children: [...node.childNodes].map(visit).filter(Boolean)
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
const styles = [];
|
|
59
|
+
const visitSheet = (sheet, seen = new Set()) => {
|
|
60
|
+
if (!sheet || seen.has(sheet)) return '';
|
|
61
|
+
seen.add(sheet);
|
|
62
|
+
try {
|
|
63
|
+
return [...sheet.cssRules].map(rule => {
|
|
64
|
+
if (rule.type !== 3) return rule.cssText;
|
|
65
|
+
const imported = visitSheet(rule.styleSheet, seen);
|
|
66
|
+
return rule.media.mediaText ? `@media ${rule.media.mediaText} { ${imported} }` : imported;
|
|
67
|
+
}).join('\n');
|
|
68
|
+
} catch {
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
for (const sheet of [...document.styleSheets, ...document.adoptedStyleSheets]) styles.push(visitSheet(sheet));
|
|
73
|
+
return {
|
|
74
|
+
dom: visit(document.body),
|
|
75
|
+
styles,
|
|
76
|
+
documentElement: {
|
|
77
|
+
className: document.documentElement.className,
|
|
78
|
+
style: document.documentElement.style.cssText
|
|
79
|
+
},
|
|
80
|
+
viewport: [document.defaultView.innerWidth, document.defaultView.innerHeight]
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
const observe = (document, record, onError) => {
|
|
84
|
+
let dirty = true;
|
|
85
|
+
let busy = false;
|
|
86
|
+
let stopped = false;
|
|
87
|
+
const mark = () => {
|
|
88
|
+
dirty = true;
|
|
89
|
+
};
|
|
90
|
+
const observer = new MutationObserver(mark);
|
|
91
|
+
observer.observe(document.documentElement, {
|
|
92
|
+
subtree: true,
|
|
93
|
+
childList: true,
|
|
94
|
+
attributes: true,
|
|
95
|
+
characterData: true
|
|
96
|
+
});
|
|
97
|
+
for (const type of ['input', 'change', 'scroll']) document.addEventListener(type, mark, true);
|
|
98
|
+
document.defaultView.addEventListener('resize', mark);
|
|
99
|
+
let lastStyles = '';
|
|
100
|
+
const snapshot = async (force = false) => {
|
|
101
|
+
if (busy || stopped) return;
|
|
102
|
+
busy = true;
|
|
103
|
+
try {
|
|
104
|
+
// CSSStyleSheet.replaceSync/insertRule don't produce DOM mutation records.
|
|
105
|
+
const frame = capture(document);
|
|
106
|
+
const styles = frame.styles.join('\n');
|
|
107
|
+
if (force || dirty || styles !== lastStyles) {
|
|
108
|
+
dirty = false;
|
|
109
|
+
lastStyles = styles;
|
|
110
|
+
await record('frame', frame);
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
onError(error);
|
|
114
|
+
} finally {
|
|
115
|
+
busy = false;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
void snapshot(true);
|
|
119
|
+
const timer = setInterval(snapshot, 100);
|
|
120
|
+
return () => {
|
|
121
|
+
stopped = true;
|
|
122
|
+
clearInterval(timer);
|
|
123
|
+
observer.disconnect();
|
|
124
|
+
for (const type of ['input', 'change', 'scroll']) document.removeEventListener(type, mark, true);
|
|
125
|
+
document.defaultView.removeEventListener('resize', mark);
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
const serializeMessage = message => {
|
|
129
|
+
const seen = new WeakSet();
|
|
130
|
+
return JSON.parse(JSON.stringify(message, (key, value) => {
|
|
131
|
+
if (/password|token|secret|authorization|cookie/i.test(key)) return '[redacted]';
|
|
132
|
+
if (typeof value === 'bigint') return String(value);
|
|
133
|
+
if (typeof value !== 'object' || value === null) return value;
|
|
134
|
+
if (seen.has(value)) return '[circular]';
|
|
135
|
+
seen.add(value);
|
|
136
|
+
if (value instanceof Error) return {
|
|
137
|
+
name: value.name,
|
|
138
|
+
message: value.message,
|
|
139
|
+
stack: value.stack
|
|
140
|
+
};
|
|
141
|
+
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return {
|
|
142
|
+
type: value.constructor.name,
|
|
143
|
+
byteLength: value.byteLength
|
|
144
|
+
};
|
|
145
|
+
if (value.constructor?.name === 'MessagePort') return {
|
|
146
|
+
type: 'MessagePort'
|
|
147
|
+
};
|
|
148
|
+
return value;
|
|
149
|
+
}));
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const createClient = url => {
|
|
153
|
+
const worker = new Worker(url, {
|
|
154
|
+
type: 'module',
|
|
155
|
+
name: 'Session Replay Worker'
|
|
156
|
+
});
|
|
157
|
+
const callbacks = new Map();
|
|
158
|
+
let nextId = 0;
|
|
159
|
+
let disposed = false;
|
|
160
|
+
const fail = error => {
|
|
161
|
+
for (const {
|
|
162
|
+
reject
|
|
163
|
+
} of callbacks.values()) reject(error);
|
|
164
|
+
callbacks.clear();
|
|
165
|
+
};
|
|
166
|
+
worker.onmessage = ({
|
|
167
|
+
data
|
|
168
|
+
}) => {
|
|
169
|
+
const callback = callbacks.get(data.id);
|
|
170
|
+
if (!callback) return;
|
|
171
|
+
callbacks.delete(data.id);
|
|
172
|
+
if (data.error) callback.reject(new Error(data.error));else callback.resolve(data.result);
|
|
173
|
+
};
|
|
174
|
+
worker.onerror = () => {
|
|
175
|
+
disposed = true;
|
|
176
|
+
worker.terminate();
|
|
177
|
+
fail(new Error('Session replay worker failed to load'));
|
|
178
|
+
};
|
|
179
|
+
return {
|
|
180
|
+
invoke(method, ...params) {
|
|
181
|
+
if (disposed) return Promise.reject(new Error('Session replay worker is closed'));
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
const id = nextId++;
|
|
184
|
+
callbacks.set(id, {
|
|
185
|
+
resolve,
|
|
186
|
+
reject
|
|
187
|
+
});
|
|
188
|
+
try {
|
|
189
|
+
worker.postMessage({
|
|
190
|
+
id,
|
|
191
|
+
method,
|
|
192
|
+
params
|
|
193
|
+
});
|
|
194
|
+
} catch (error) {
|
|
195
|
+
callbacks.delete(id);
|
|
196
|
+
reject(error);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
dispose() {
|
|
201
|
+
disposed = true;
|
|
202
|
+
worker.terminate();
|
|
203
|
+
fail(new Error('Session replay worker is closed'));
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const tags = new Set('body div span p pre code main section article header footer nav aside h1 h2 h3 h4 h5 h6 ul ol li table thead tbody tr td th button input textarea select option label form fieldset legend a img br hr strong em b i u s small details summary svg path rect circle ellipse line polyline polygon g defs clipPath text tspan'.split(' '));
|
|
209
|
+
const attributes = /^(class|style|id|title|role|type|checked|disabled|selected|placeholder|width|height|viewBox|d|fill|stroke|cx|cy|r|x|y|x1|x2|y1|y2|points|transform|xmlns|aria-[\w-]+)$/i;
|
|
210
|
+
const renderFrame = (document, frame) => {
|
|
211
|
+
let count = 0;
|
|
212
|
+
const scrolls = [];
|
|
213
|
+
const visit = (value, depth = 0) => {
|
|
214
|
+
if (++count > 100_000 || depth > 150 || !value || typeof value !== 'object') throw new Error('Invalid replay DOM');
|
|
215
|
+
if (typeof value.text === 'string') return document.createTextNode(value.text);
|
|
216
|
+
const tag = tags.has(value.tag) ? value.tag : 'div';
|
|
217
|
+
const node = value.svg ? document.createElementNS('http://www.w3.org/2000/svg', tag) : document.createElement(tag);
|
|
218
|
+
for (const [key, val] of Object.entries(value.attrs || {})) {
|
|
219
|
+
if (attributes.test(key) && typeof val === 'string') node.setAttribute(key, val);
|
|
220
|
+
if (key === 'src' && tag === 'img' && typeof val === 'string' && /^data:image\/(png|jpeg|gif|webp);base64,/.test(val)) node.setAttribute(key, val);
|
|
221
|
+
}
|
|
222
|
+
if (typeof value.value === 'string' && 'value' in node && node.type !== 'file') node.value = value.value;
|
|
223
|
+
if (typeof value.checked === 'boolean') node.checked = value.checked;
|
|
224
|
+
for (const child of value.children || []) node.append(visit(child, depth + 1));
|
|
225
|
+
if (Array.isArray(value.scroll)) scrolls.push([node, value.scroll]);
|
|
226
|
+
return node;
|
|
227
|
+
};
|
|
228
|
+
const root = visit(frame.dom);
|
|
229
|
+
const style = document.createElement('style');
|
|
230
|
+
style.textContent = (frame.styles || []).filter(value => typeof value === 'string').join('\n');
|
|
231
|
+
document.head.querySelectorAll('style').forEach(node => node.remove());
|
|
232
|
+
document.head.append(style);
|
|
233
|
+
document.documentElement.className = typeof frame.documentElement?.className === 'string' ? frame.documentElement.className : '';
|
|
234
|
+
document.documentElement.style.cssText = typeof frame.documentElement?.style === 'string' ? frame.documentElement.style : '';
|
|
235
|
+
if (root.nodeName === 'BODY') document.body.replaceWith(root);else document.body.replaceChildren(root);
|
|
236
|
+
for (const [node, [x, y]] of scrolls) node.scrollTo(x, y);
|
|
237
|
+
};
|
|
238
|
+
const mountPlayer = async (container, {
|
|
239
|
+
workerUrl,
|
|
240
|
+
source
|
|
241
|
+
}) => {
|
|
242
|
+
const client = createClient(workerUrl);
|
|
243
|
+
container.replaceChildren();
|
|
244
|
+
container.className = 'SessionReplay';
|
|
245
|
+
container.style.cssText = 'position:fixed;inset:0;display:flex;flex-direction:column;background:#202020;color:white;z-index:2147483647';
|
|
246
|
+
const document = container.ownerDocument;
|
|
247
|
+
const viewport = document.createElement('div');
|
|
248
|
+
viewport.style.cssText = 'flex:1;min-height:0;overflow:auto;position:relative';
|
|
249
|
+
const iframe = document.createElement('iframe');
|
|
250
|
+
iframe.title = 'Recorded session';
|
|
251
|
+
iframe.setAttribute('sandbox', 'allow-same-origin');
|
|
252
|
+
iframe.style.cssText = 'border:0;pointer-events:none;display:block';
|
|
253
|
+
const loaded = new Promise(resolve => {
|
|
254
|
+
iframe.onload = resolve;
|
|
255
|
+
});
|
|
256
|
+
// An opaque, inert visual document: recorded markup cannot run scripts, submit forms or fetch URLs.
|
|
257
|
+
iframe.srcdoc = '<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:"></head><body></body></html>';
|
|
258
|
+
viewport.append(iframe);
|
|
259
|
+
const controls = document.createElement('div');
|
|
260
|
+
controls.style.cssText = 'display:flex;gap:12px;align-items:center;padding:12px;font:14px sans-serif;background:#202020;color:white';
|
|
261
|
+
const play = document.createElement('button');
|
|
262
|
+
play.textContent = 'Play';
|
|
263
|
+
const slider = document.createElement('input');
|
|
264
|
+
slider.type = 'range';
|
|
265
|
+
slider.min = '0';
|
|
266
|
+
slider.step = '1';
|
|
267
|
+
slider.value = '0';
|
|
268
|
+
slider.setAttribute('aria-label', 'Session replay position');
|
|
269
|
+
slider.style.flex = '1';
|
|
270
|
+
const status = document.createElement('output');
|
|
271
|
+
status.setAttribute('aria-live', 'polite');
|
|
272
|
+
controls.append(play, slider, status);
|
|
273
|
+
container.append(viewport, controls);
|
|
274
|
+
let disposed = false;
|
|
275
|
+
let playing = false;
|
|
276
|
+
let timer;
|
|
277
|
+
let requestId = 0;
|
|
278
|
+
let position = 0;
|
|
279
|
+
let duration = 0;
|
|
280
|
+
const show = result => {
|
|
281
|
+
position = result.position;
|
|
282
|
+
duration = result.duration;
|
|
283
|
+
slider.max = String(Math.ceil(duration));
|
|
284
|
+
slider.value = String(Math.round(position));
|
|
285
|
+
status.textContent = `${(position / 1000).toFixed(1)} / ${(duration / 1000).toFixed(1)} s`;
|
|
286
|
+
const [width, height] = result.frame.viewport || [1280, 720];
|
|
287
|
+
iframe.style.width = `${Math.max(1, Math.min(16384, width))}px`;
|
|
288
|
+
iframe.style.height = `${Math.max(1, Math.min(16384, height))}px`;
|
|
289
|
+
renderFrame(iframe.contentDocument, result.frame);
|
|
290
|
+
};
|
|
291
|
+
const pause = () => {
|
|
292
|
+
playing = false;
|
|
293
|
+
clearTimeout(timer);
|
|
294
|
+
play.textContent = 'Play';
|
|
295
|
+
};
|
|
296
|
+
const seek = async time => {
|
|
297
|
+
const id = ++requestId;
|
|
298
|
+
const result = await client.invoke('seek', time);
|
|
299
|
+
if (!disposed && id === requestId) show(result);
|
|
300
|
+
};
|
|
301
|
+
const report = error => {
|
|
302
|
+
pause();
|
|
303
|
+
status.textContent = error.message;
|
|
304
|
+
};
|
|
305
|
+
slider.oninput = () => {
|
|
306
|
+
pause();
|
|
307
|
+
void seek(Number(slider.value)).catch(report);
|
|
308
|
+
};
|
|
309
|
+
play.onclick = () => {
|
|
310
|
+
if (playing) {
|
|
311
|
+
pause();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
playing = true;
|
|
315
|
+
play.textContent = 'Pause';
|
|
316
|
+
const origin = performance.now() - (position >= duration ? 0 : position);
|
|
317
|
+
const tick = async () => {
|
|
318
|
+
if (!playing || disposed) return;
|
|
319
|
+
try {
|
|
320
|
+
await seek(performance.now() - origin);
|
|
321
|
+
if (position >= duration) pause();else if (playing) timer = setTimeout(tick, 50);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
report(error);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
void tick();
|
|
327
|
+
};
|
|
328
|
+
try {
|
|
329
|
+
const [initial] = await Promise.all([client.invoke('load', source), loaded]);
|
|
330
|
+
show(initial);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
play.disabled = slider.disabled = true;
|
|
333
|
+
report(error);
|
|
334
|
+
}
|
|
335
|
+
return () => {
|
|
336
|
+
disposed = true;
|
|
337
|
+
pause();
|
|
338
|
+
client.dispose();
|
|
339
|
+
container.replaceChildren();
|
|
340
|
+
};
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const state$h = {
|
|
344
|
+
client: undefined,
|
|
345
|
+
inFlight: 0,
|
|
346
|
+
lastError: '',
|
|
347
|
+
stopObserving: undefined
|
|
348
|
+
};
|
|
349
|
+
const attached = new WeakSet();
|
|
350
|
+
const workerUrl = new URL('sessionReplayWorkerMain.js', import.meta.url);
|
|
351
|
+
const report = error => {
|
|
352
|
+
state$h.lastError = error.message;
|
|
353
|
+
state$h.stopObserving?.();
|
|
354
|
+
state$h.stopObserving = undefined;
|
|
355
|
+
console.warn(`Session replay: ${error.message}`);
|
|
356
|
+
};
|
|
357
|
+
const record$1 = (direction, message) => {
|
|
358
|
+
if (!state$h.client || state$h.lastError) return;
|
|
359
|
+
if (state$h.inFlight >= 500) {
|
|
360
|
+
report(new Error('Recording cannot keep up with renderer messages'));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
const value = serializeMessage({
|
|
365
|
+
direction,
|
|
366
|
+
message
|
|
367
|
+
});
|
|
368
|
+
state$h.inFlight++;
|
|
369
|
+
void state$h.client.invoke('record', 'message', value).catch(report).finally(() => {
|
|
370
|
+
state$h.inFlight--;
|
|
371
|
+
});
|
|
372
|
+
} catch (error) {
|
|
373
|
+
report(error);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// Observe the actual transports, including messages on direct view-worker ports.
|
|
378
|
+
// The recording path never changes or delays the original message or transfer list.
|
|
379
|
+
const attach = rpc => {
|
|
380
|
+
const ipc = rpc.ipc;
|
|
381
|
+
if (!ipc || attached.has(ipc)) return;
|
|
382
|
+
attached.add(ipc);
|
|
383
|
+
const ignoredReplies = {
|
|
384
|
+
received: new Set(),
|
|
385
|
+
sent: new Set()
|
|
386
|
+
};
|
|
387
|
+
const trace = (direction, message) => {
|
|
388
|
+
if (message?.method?.startsWith('SessionReplay.')) {
|
|
389
|
+
if (message.id !== undefined) ignoredReplies[direction === 'received' ? 'sent' : 'received'].add(message.id);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (message && ('result' in message || 'error' in message) && ignoredReplies[direction].delete(message.id)) return;
|
|
393
|
+
record$1(direction, message);
|
|
394
|
+
};
|
|
395
|
+
if (typeof ipc.addEventListener === 'function') {
|
|
396
|
+
ipc.addEventListener('message', event => trace('received', ipc.getData ? ipc.getData(event) : event.data));
|
|
397
|
+
}
|
|
398
|
+
for (const name of ['send', 'sendAndTransfer']) {
|
|
399
|
+
if (typeof ipc[name] !== 'function') continue;
|
|
400
|
+
const original = ipc[name].bind(ipc);
|
|
401
|
+
ipc[name] = (...args) => {
|
|
402
|
+
trace('sent', args[0]);
|
|
403
|
+
return original(...args);
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
const configure = async options => {
|
|
408
|
+
state$h.stopObserving?.();
|
|
409
|
+
state$h.stopObserving = undefined;
|
|
410
|
+
if (state$h.client) {
|
|
411
|
+
try {
|
|
412
|
+
await state$h.client.invoke('stop');
|
|
413
|
+
} catch {
|
|
414
|
+
/* Preserve already saved local data when an upload is unavailable. */
|
|
415
|
+
}
|
|
416
|
+
state$h.client.dispose();
|
|
417
|
+
state$h.client = undefined;
|
|
418
|
+
}
|
|
419
|
+
state$h.lastError = '';
|
|
420
|
+
if (!options.local && !options.upload) return '';
|
|
421
|
+
const next = createClient(workerUrl);
|
|
422
|
+
try {
|
|
423
|
+
const id = await next.invoke('start', options);
|
|
424
|
+
state$h.client = next;
|
|
425
|
+
state$h.stopObserving = observe(document, (type, data) => next.invoke('record', type, data), report);
|
|
426
|
+
return id;
|
|
427
|
+
} catch (error) {
|
|
428
|
+
next.dispose();
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
const getSession = async () => {
|
|
433
|
+
if (!state$h.client) throw new Error('Session replay is disabled in settings');
|
|
434
|
+
return state$h.client.invoke('export');
|
|
435
|
+
};
|
|
436
|
+
const getStatus = async () => {
|
|
437
|
+
if (!state$h.client) return {
|
|
438
|
+
enabled: false,
|
|
439
|
+
error: state$h.lastError
|
|
440
|
+
};
|
|
441
|
+
return {
|
|
442
|
+
enabled: true,
|
|
443
|
+
...(await state$h.client.invoke('status')),
|
|
444
|
+
captureError: state$h.lastError
|
|
445
|
+
};
|
|
446
|
+
};
|
|
447
|
+
const flush = async () => state$h.client?.invoke('flush');
|
|
448
|
+
const openLocalFile = async () => {
|
|
449
|
+
const input = document.createElement('input');
|
|
450
|
+
input.type = 'file';
|
|
451
|
+
input.accept = '.json,application/json';
|
|
452
|
+
input.onchange = async () => {
|
|
453
|
+
const file = input.files?.[0];
|
|
454
|
+
if (!file) return;
|
|
455
|
+
try {
|
|
456
|
+
if (file.size > 64 * 1024 * 1024) throw new Error('Session replay file is too large');
|
|
457
|
+
const session = JSON.parse(await file.text());
|
|
458
|
+
await configure({
|
|
459
|
+
endpoint: '',
|
|
460
|
+
local: false,
|
|
461
|
+
upload: false
|
|
462
|
+
});
|
|
463
|
+
await mountPlayer(document.body, {
|
|
464
|
+
source: {
|
|
465
|
+
session
|
|
466
|
+
},
|
|
467
|
+
workerUrl
|
|
468
|
+
});
|
|
469
|
+
} catch (error) {
|
|
470
|
+
console.error(error);
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
input.click();
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
// Replay gets its own layout before any editor, extension or shared worker starts.
|
|
477
|
+
const initializeLayout = async href => {
|
|
478
|
+
const url = new URL(href);
|
|
479
|
+
const localId = url.searchParams.get('replayId');
|
|
480
|
+
if (!localId && !url.searchParams.has('sessionReplay')) return false;
|
|
481
|
+
if (localId) await mountPlayer(document.body, {
|
|
482
|
+
source: {
|
|
483
|
+
localId
|
|
484
|
+
},
|
|
485
|
+
workerUrl
|
|
486
|
+
});else {
|
|
487
|
+
const button = document.createElement('button');
|
|
488
|
+
button.textContent = 'Open session replay file';
|
|
489
|
+
button.onclick = () => {
|
|
490
|
+
void openLocalFile();
|
|
491
|
+
};
|
|
492
|
+
document.body.replaceChildren(button);
|
|
493
|
+
}
|
|
494
|
+
return true;
|
|
495
|
+
};
|
|
496
|
+
|
|
1
497
|
const play = async src => {
|
|
2
498
|
const audio = new Audio(src);
|
|
3
499
|
await audio.play();
|
|
@@ -2094,6 +2590,7 @@ const registerRpc = (rpcId, rpc) => {
|
|
|
2094
2590
|
if (previous && previous !== rpc) {
|
|
2095
2591
|
previous.dispose();
|
|
2096
2592
|
}
|
|
2593
|
+
attach(rpc);
|
|
2097
2594
|
rpcs.set(rpcId, rpc);
|
|
2098
2595
|
};
|
|
2099
2596
|
const registerView = (uid, rpcId) => {
|
|
@@ -2797,6 +3294,9 @@ const restoreExistingError = (error, currentStack) => {
|
|
|
2797
3294
|
};
|
|
2798
3295
|
const restoreMethodNotFoundError = (error, currentStack) => {
|
|
2799
3296
|
const restoredError = new JsonRpcError(error.message);
|
|
3297
|
+
Object.assign(restoredError, {
|
|
3298
|
+
code: error.code
|
|
3299
|
+
});
|
|
2800
3300
|
const parentStack = getParentStack(error);
|
|
2801
3301
|
setStack(restoredError, `${parentStack}${NewLine$1}${currentStack}`);
|
|
2802
3302
|
return restoredError;
|
|
@@ -2816,9 +3316,13 @@ const applyDataProperties = (restoredError, error) => {
|
|
|
2816
3316
|
// @ts-ignore
|
|
2817
3317
|
restoredError.codeFrame = error.data.codeFrame;
|
|
2818
3318
|
}
|
|
2819
|
-
if (error.data.code) {
|
|
3319
|
+
if (typeof error.data.code === 'string' || typeof error.data.code === 'number') {
|
|
2820
3320
|
// @ts-ignore
|
|
2821
|
-
restoredError
|
|
3321
|
+
Object.defineProperty(restoredError, 'code', {
|
|
3322
|
+
configurable: true,
|
|
3323
|
+
value: error.data.code,
|
|
3324
|
+
writable: true
|
|
3325
|
+
});
|
|
2822
3326
|
}
|
|
2823
3327
|
if (error.data.type) {
|
|
2824
3328
|
// @ts-ignore
|
|
@@ -2840,6 +3344,13 @@ const applyDirectProperties = (restoredError, error) => {
|
|
|
2840
3344
|
};
|
|
2841
3345
|
const restoreMessageError = (error, _currentStack) => {
|
|
2842
3346
|
const restoredError = constructError(error.message, error.type, error.name);
|
|
3347
|
+
if (typeof error.code === 'string' || typeof error.code === 'number') {
|
|
3348
|
+
Object.defineProperty(restoredError, 'code', {
|
|
3349
|
+
configurable: true,
|
|
3350
|
+
value: error.code,
|
|
3351
|
+
writable: true
|
|
3352
|
+
});
|
|
3353
|
+
}
|
|
2843
3354
|
if (error.data) {
|
|
2844
3355
|
applyDataProperties(restoredError, error);
|
|
2845
3356
|
} else {
|
|
@@ -2918,7 +3429,7 @@ const getErrorProperty = (error, prettyError) => {
|
|
|
2918
3429
|
return {
|
|
2919
3430
|
code: Custom,
|
|
2920
3431
|
data: {
|
|
2921
|
-
code: prettyError.code,
|
|
3432
|
+
code: prettyError.code ?? error?.code,
|
|
2922
3433
|
codeFrame: prettyError.codeFrame,
|
|
2923
3434
|
name: prettyError.name,
|
|
2924
3435
|
stack: getStack(prettyError),
|
|
@@ -2955,7 +3466,12 @@ const getErrorResponseSimple = (id, error) => {
|
|
|
2955
3466
|
return {
|
|
2956
3467
|
error: {
|
|
2957
3468
|
code: Custom,
|
|
2958
|
-
data: error
|
|
3469
|
+
data: error instanceof Error ? {
|
|
3470
|
+
...error,
|
|
3471
|
+
code: 'code' in error ? error.code : undefined,
|
|
3472
|
+
stack: error.stack,
|
|
3473
|
+
type: error.name
|
|
3474
|
+
} : error,
|
|
2959
3475
|
// @ts-ignore
|
|
2960
3476
|
message: error.message
|
|
2961
3477
|
},
|
|
@@ -3343,6 +3859,7 @@ const launchDragAndDropWorker = async () => {
|
|
|
3343
3859
|
commandMap: commandMapRef,
|
|
3344
3860
|
messagePort: port2
|
|
3345
3861
|
});
|
|
3862
|
+
attach(rpc);
|
|
3346
3863
|
return success(rpc);
|
|
3347
3864
|
} catch (error$1) {
|
|
3348
3865
|
return error(error$1);
|
|
@@ -3521,6 +4038,7 @@ const launchWorker = async ({
|
|
|
3521
4038
|
name,
|
|
3522
4039
|
url
|
|
3523
4040
|
});
|
|
4041
|
+
attach(rpc);
|
|
3524
4042
|
return success(rpc);
|
|
3525
4043
|
} catch (error$1) {
|
|
3526
4044
|
return error(error$1);
|
|
@@ -4084,6 +4602,7 @@ const create$v = async ({
|
|
|
4084
4602
|
commandMap: {},
|
|
4085
4603
|
window
|
|
4086
4604
|
});
|
|
4605
|
+
attach(rpc);
|
|
4087
4606
|
const webContentsIds = await rpc.invokeAndTransfer('CreateMessagePort.createMessagePort', ipcId, port);
|
|
4088
4607
|
return webContentsIds;
|
|
4089
4608
|
};
|
|
@@ -10410,6 +10929,7 @@ const handleMessagePort = async (port, rpcId) => {
|
|
|
10410
10929
|
},
|
|
10411
10930
|
messagePort: port
|
|
10412
10931
|
});
|
|
10932
|
+
attach(rpc);
|
|
10413
10933
|
if (rpcId !== undefined) {
|
|
10414
10934
|
registerRpc(rpcId, rpc);
|
|
10415
10935
|
}
|
|
@@ -10789,6 +11309,11 @@ const commandMap = {
|
|
|
10789
11309
|
'PointerCapture.unmock': unmock,
|
|
10790
11310
|
'Prompt.prompt': prompt,
|
|
10791
11311
|
'ScreenCapture.start': start,
|
|
11312
|
+
'SessionReplay.configure': configure,
|
|
11313
|
+
'SessionReplay.flush': flush,
|
|
11314
|
+
'SessionReplay.getSession': getSession,
|
|
11315
|
+
'SessionReplay.getStatus': getStatus,
|
|
11316
|
+
'SessionReplay.openLocalFile': openLocalFile,
|
|
10792
11317
|
'TestFrameWork.checkConditionError': checkConditionError,
|
|
10793
11318
|
'TestFrameWork.checkMultiElementCondition': checkMultiElementCondition,
|
|
10794
11319
|
'TestFrameWork.checkSingleElementCondition': checkSingleElementCondition,
|
|
@@ -11305,6 +11830,7 @@ const enable = async window => {
|
|
|
11305
11830
|
};
|
|
11306
11831
|
|
|
11307
11832
|
const main = async () => {
|
|
11833
|
+
if (await initializeLayout(location.href)) return;
|
|
11308
11834
|
initialize(location.search);
|
|
11309
11835
|
Object.assign(commandMapRef, commandMap);
|
|
11310
11836
|
enable(window);
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
const version = 1;
|
|
2
|
+
const maxEventBytes = 750_000;
|
|
3
|
+
const maxSessionBytes = 64 * 1024 * 1024;
|
|
4
|
+
const bytes = (value) => new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
5
|
+
|
|
6
|
+
const validateSession = (session) => {
|
|
7
|
+
if (session?.version !== version || !Array.isArray(session.events) || session.events.length > 200_000 || bytes(session) > maxSessionBytes) {
|
|
8
|
+
throw new Error('Unsupported or oversized session replay')
|
|
9
|
+
}
|
|
10
|
+
let time = 0;
|
|
11
|
+
for (const [sequence, event] of session.events.entries()) {
|
|
12
|
+
if (event.sequence !== sequence || !Number.isFinite(event.timestamp) || event.timestamp < time || !['frame', 'message'].includes(event.type)) {
|
|
13
|
+
throw new Error('Invalid session replay event sequence')
|
|
14
|
+
}
|
|
15
|
+
if (bytes(event) > maxEventBytes) throw new Error('Session replay event is too large')
|
|
16
|
+
time = event.timestamp;
|
|
17
|
+
}
|
|
18
|
+
return session
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Binary search makes dragging independent of the number of diagnostic messages.
|
|
22
|
+
const loadContent = (session) => {
|
|
23
|
+
validateSession(session);
|
|
24
|
+
const frames = session.events.filter((event) => event.type === 'frame');
|
|
25
|
+
if (!frames.length) throw new Error('This session has no visual frames')
|
|
26
|
+
const duration = session.events.at(-1)?.timestamp || 0;
|
|
27
|
+
const seek = (timestamp) => {
|
|
28
|
+
const position = Math.max(0, Math.min(duration, Number(timestamp) || 0));
|
|
29
|
+
let low = 0;
|
|
30
|
+
let high = frames.length;
|
|
31
|
+
while (low < high) {
|
|
32
|
+
const middle = (low + high) >>> 1;
|
|
33
|
+
if (frames[middle].timestamp <= position) low = middle + 1;
|
|
34
|
+
else high = middle;
|
|
35
|
+
}
|
|
36
|
+
return { position, duration, frame: frames[Math.max(0, low - 1)].data }
|
|
37
|
+
};
|
|
38
|
+
return { duration, seek }
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const createRecorder = ({ storage, fetch: request = globalThis.fetch, now = () => performance.now() }) => {
|
|
42
|
+
let metadata;
|
|
43
|
+
let options;
|
|
44
|
+
let events = [];
|
|
45
|
+
let pending = [];
|
|
46
|
+
let size = 0;
|
|
47
|
+
let origin = 0;
|
|
48
|
+
let remote;
|
|
49
|
+
let uploadPromise;
|
|
50
|
+
let lastError = '';
|
|
51
|
+
let saved = 0;
|
|
52
|
+
let uploaded = 0;
|
|
53
|
+
const start = async (config) => {
|
|
54
|
+
if (metadata) throw new Error('A session is already recording')
|
|
55
|
+
options = config;
|
|
56
|
+
origin = now();
|
|
57
|
+
metadata = { id: crypto.randomUUID(), version, createdAt: new Date().toISOString() };
|
|
58
|
+
if (options.local) await storage.save(metadata, []);
|
|
59
|
+
return metadata.id
|
|
60
|
+
};
|
|
61
|
+
const record = async (type, data) => {
|
|
62
|
+
if (!metadata) return
|
|
63
|
+
const event = { sequence: events.length, timestamp: Math.max(events.at(-1)?.timestamp || 0, now() - origin), type, data };
|
|
64
|
+
const eventSize = bytes(event);
|
|
65
|
+
if (eventSize > maxEventBytes || size + eventSize > maxSessionBytes)
|
|
66
|
+
throw new Error('Session replay storage limit reached; download this session and start a new one')
|
|
67
|
+
size += eventSize;
|
|
68
|
+
events.push(event);
|
|
69
|
+
if (options.upload) pending.push(event);
|
|
70
|
+
if (options.local) {
|
|
71
|
+
await storage.save(metadata, [event]);
|
|
72
|
+
saved++;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const doUpload = async () => {
|
|
76
|
+
if (!options?.upload || !pending.length) return
|
|
77
|
+
const endpoint = new URL(options.endpoint);
|
|
78
|
+
if (!['https:', 'http:'].includes(endpoint.protocol)) throw new Error('Invalid session replay backend URL')
|
|
79
|
+
const headers = { 'Content-Type': 'application/json', ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}) };
|
|
80
|
+
const send = async (url, body) => {
|
|
81
|
+
const response = await request(url, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
credentials: 'include',
|
|
84
|
+
headers,
|
|
85
|
+
body: JSON.stringify(body),
|
|
86
|
+
signal: AbortSignal.timeout(10_000),
|
|
87
|
+
});
|
|
88
|
+
if (!response.ok) throw new Error(`Session replay upload failed (${response.status})`)
|
|
89
|
+
return response.json()
|
|
90
|
+
};
|
|
91
|
+
if (!remote) remote = await send(endpoint, { version, createdAt: metadata.createdAt });
|
|
92
|
+
while (pending.length) {
|
|
93
|
+
const batch = [];
|
|
94
|
+
let batchBytes = 0;
|
|
95
|
+
for (const event of pending) {
|
|
96
|
+
const length = bytes(event);
|
|
97
|
+
if (batch.length && (batchBytes + length > 800_000 || batch.length >= 250)) break
|
|
98
|
+
batch.push(event);
|
|
99
|
+
batchBytes += length;
|
|
100
|
+
}
|
|
101
|
+
const url = new URL(endpoint);
|
|
102
|
+
url.pathname = `${url.pathname.replace(/\/$/, '')}/${encodeURIComponent(remote.id)}/events`;
|
|
103
|
+
await send(url, { uploadToken: remote.uploadToken, events: batch });
|
|
104
|
+
pending.splice(0, batch.length);
|
|
105
|
+
uploaded += batch.length;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const flush = () => {
|
|
109
|
+
uploadPromise ||= doUpload()
|
|
110
|
+
.then(
|
|
111
|
+
() => {
|
|
112
|
+
lastError = '';
|
|
113
|
+
},
|
|
114
|
+
(error) => {
|
|
115
|
+
lastError = error.message;
|
|
116
|
+
throw error
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
.finally(() => {
|
|
120
|
+
uploadPromise = undefined;
|
|
121
|
+
});
|
|
122
|
+
return uploadPromise
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
start,
|
|
126
|
+
record,
|
|
127
|
+
flush,
|
|
128
|
+
export: () => ({ ...metadata, events }),
|
|
129
|
+
status: () => ({ id: metadata?.id, events: events.length, saved, uploaded, pending: pending.length, bytes: size, error: lastError }),
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const open = () =>
|
|
134
|
+
new Promise((resolve, reject) => {
|
|
135
|
+
const request = indexedDB.open('lvce-session-replays', 1);
|
|
136
|
+
request.onupgradeneeded = () => {
|
|
137
|
+
request.result.createObjectStore('sessions', { keyPath: 'id' });
|
|
138
|
+
request.result.createObjectStore('events', { keyPath: ['sessionId', 'sequence'] });
|
|
139
|
+
};
|
|
140
|
+
request.onsuccess = () => resolve(request.result);
|
|
141
|
+
request.onerror = () => reject(request.error);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const createStorage = async () => {
|
|
145
|
+
const db = await open();
|
|
146
|
+
const transaction = (stores, action) =>
|
|
147
|
+
new Promise((resolve, reject) => {
|
|
148
|
+
const tx = db.transaction(stores, 'readwrite');
|
|
149
|
+
action(tx);
|
|
150
|
+
tx.oncomplete = () => resolve();
|
|
151
|
+
tx.onerror = tx.onabort = () => reject(tx.error || new Error('Replay storage transaction aborted'));
|
|
152
|
+
});
|
|
153
|
+
return {
|
|
154
|
+
save: (session, events) =>
|
|
155
|
+
transaction(['sessions', 'events'], (tx) => {
|
|
156
|
+
tx.objectStore('sessions').put(session);
|
|
157
|
+
for (const event of events) tx.objectStore('events').put({ ...event, sessionId: session.id });
|
|
158
|
+
}),
|
|
159
|
+
read: (id) =>
|
|
160
|
+
new Promise((resolve, reject) => {
|
|
161
|
+
const tx = db.transaction(['sessions', 'events']);
|
|
162
|
+
const metadata = tx.objectStore('sessions').get(id);
|
|
163
|
+
const events = tx.objectStore('events').getAll(IDBKeyRange.bound([id, 0], [id, Number.MAX_SAFE_INTEGER]));
|
|
164
|
+
tx.oncomplete = () =>
|
|
165
|
+
metadata.result
|
|
166
|
+
? resolve({ ...metadata.result, events: events.result.map(({ sessionId, ...event }) => event) })
|
|
167
|
+
: reject(new Error('Session replay not found'));
|
|
168
|
+
tx.onerror = () => reject(tx.error);
|
|
169
|
+
}),
|
|
170
|
+
close: () => db.close(),
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
let storage;
|
|
175
|
+
let recorder;
|
|
176
|
+
let content;
|
|
177
|
+
let queue = Promise.resolve();
|
|
178
|
+
let timer;
|
|
179
|
+
const getStorage = async () => (storage ||= await createStorage());
|
|
180
|
+
const commands = {
|
|
181
|
+
async start(options) {
|
|
182
|
+
recorder = createRecorder({ storage: options.local ? await getStorage() : undefined });
|
|
183
|
+
const id = await recorder.start(options);
|
|
184
|
+
clearInterval(timer);
|
|
185
|
+
timer = setInterval(() => recorder.flush().catch(() => {}), 2000);
|
|
186
|
+
return id
|
|
187
|
+
},
|
|
188
|
+
record: (type, data) => recorder.record(type, data),
|
|
189
|
+
flush: () => recorder.flush(),
|
|
190
|
+
export: () => recorder.export(),
|
|
191
|
+
status: () => recorder.status(),
|
|
192
|
+
async load(source) {
|
|
193
|
+
let session;
|
|
194
|
+
if (source.localId) session = await (await getStorage()).read(source.localId);
|
|
195
|
+
else if (source.url) {
|
|
196
|
+
const response = await fetch(source.url, { credentials: 'include', headers: { Accept: 'application/json' } });
|
|
197
|
+
if (!response.ok) throw new Error(`Cannot load session replay (${response.status})`)
|
|
198
|
+
session = await response.json();
|
|
199
|
+
} else session = source.session;
|
|
200
|
+
content = loadContent(session);
|
|
201
|
+
return content.seek(0)
|
|
202
|
+
},
|
|
203
|
+
seek: (timestamp) => content.seek(timestamp),
|
|
204
|
+
async stop() {
|
|
205
|
+
clearInterval(timer);
|
|
206
|
+
await recorder?.flush();
|
|
207
|
+
storage?.close();
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
self.onmessage = ({ data: { id, method, params = [] } }) => {
|
|
211
|
+
queue = queue.then(async () => {
|
|
212
|
+
try {
|
|
213
|
+
if (!Object.hasOwn(commands, method)) throw new Error('Unknown session replay command')
|
|
214
|
+
self.postMessage({ id, result: await commands[method](...params) });
|
|
215
|
+
} catch (error) {
|
|
216
|
+
self.postMessage({ id, error: error.message });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
};
|