@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.
@@ -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 &#39;none&#39;; style-src &#39;unsafe-inline&#39;; 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.code = error.data.code;
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lvce-editor/renderer-process",
3
- "version": "30.56.0",
3
+ "version": "30.57.1",
4
4
  "keywords": [
5
5
  "lvce-editor",
6
6
  "renderer-process"