@lvce-editor/renderer-process 30.55.2 → 30.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.55.2",
3
+ "version": "30.57.0",
4
4
  "keywords": [
5
5
  "lvce-editor",
6
6
  "renderer-process"