@ermis-network/ermis-chat-sdk 2.0.0 → 2.0.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/README.md +330 -0
- package/dist/encryption/index.browser.cjs +13045 -0
- package/dist/encryption/index.browser.cjs.map +1 -0
- package/dist/encryption/index.browser.mjs +12959 -0
- package/dist/encryption/index.browser.mjs.map +1 -0
- package/dist/encryption/index.cjs +13045 -0
- package/dist/encryption/index.cjs.map +1 -0
- package/dist/encryption/index.d.mts +3 -0
- package/dist/encryption/index.d.ts +3 -0
- package/dist/encryption/index.mjs +12959 -0
- package/dist/encryption/index.mjs.map +1 -0
- package/dist/index-CcvHIY5q.d.mts +4988 -0
- package/dist/index-CcvHIY5q.d.ts +4988 -0
- package/dist/index.browser.cjs +20192 -5766
- package/dist/index.browser.cjs.map +1 -1
- package/dist/index.browser.full-bundle.min.js +20 -16
- package/dist/index.browser.full-bundle.min.js.map +1 -1
- package/dist/index.browser.mjs +20106 -5731
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.cjs +20191 -5765
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +15 -1337
- package/dist/index.d.ts +15 -1337
- package/dist/index.mjs +20106 -5731
- package/dist/index.mjs.map +1 -1
- package/dist/wasm_worker.worker.mjs +8 -4
- package/dist/wasm_worker.worker.mjs.map +1 -1
- package/package.json +21 -6
- package/public/e2ee-media-stream-worker.js +627 -0
- package/public/openmls_wasm_bg.wasm +0 -0
- package/src/attachment_utils.ts +0 -148
- package/src/auth.ts +0 -352
- package/src/channel.ts +0 -1879
- package/src/channel_state.ts +0 -612
- package/src/client.ts +0 -1759
- package/src/client_state.ts +0 -55
- package/src/connection.ts +0 -587
- package/src/ermis_call_node.ts +0 -1046
- package/src/errors.ts +0 -60
- package/src/events.ts +0 -46
- package/src/hevc_decoder_config.ts +0 -305
- package/src/index.ts +0 -17
- package/src/media_stream_receiver.ts +0 -593
- package/src/media_stream_sender.ts +0 -465
- package/src/shims/empty.ts +0 -1
- package/src/signal_message.ts +0 -171
- package/src/system_message.ts +0 -259
- package/src/token_manager.ts +0 -48
- package/src/types.ts +0 -594
- package/src/utils.ts +0 -553
- package/src/wasm/ermis_call_node_wasm.d.ts +0 -156
- package/src/wasm/ermis_call_node_wasm.js +0 -1568
- package/src/wasm_worker.ts +0 -219
- package/src/wasm_worker_proxy.ts +0 -244
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/* E2EE media streaming service worker. Keeps decrypted frames in memory only. */
|
|
2
|
+
const VIRTUAL_PREFIX = '/__ermis/e2ee-media/';
|
|
3
|
+
const SMOKE_PATH = '/__ermis/e2ee-media-smoke';
|
|
4
|
+
const FRAME_HEADER_BYTES = 8;
|
|
5
|
+
const GCM_TAG_BYTES = 16;
|
|
6
|
+
const IDLE_TTL_MS = 30 * 60 * 1000;
|
|
7
|
+
const BASE_SESSION_CACHE_LIMIT = 16 * 1024 * 1024;
|
|
8
|
+
const FULL_REPLAY_CACHE_LIMIT = 128 * 1024 * 1024;
|
|
9
|
+
const GLOBAL_CACHE_LIMIT = 256 * 1024 * 1024;
|
|
10
|
+
const MAX_R2_RETRIES = 2;
|
|
11
|
+
const RETRY_BASE_MS = 250;
|
|
12
|
+
const PREFETCH_FRAMES = 8;
|
|
13
|
+
const PREFETCH_THRESHOLD_FRAMES = 2;
|
|
14
|
+
const SEQUENTIAL_PREFETCH_HITS = 2;
|
|
15
|
+
const SEQUENTIAL_FRAME_GAP = 2;
|
|
16
|
+
const DEFAULT_GRANT_RENEWAL_SAFETY_MARGIN_MS = 30 * 1000;
|
|
17
|
+
|
|
18
|
+
const sessions = new Map();
|
|
19
|
+
let globalCacheBytes = 0;
|
|
20
|
+
const grantRenewals = new Map();
|
|
21
|
+
|
|
22
|
+
self.addEventListener('install', () => {
|
|
23
|
+
self.skipWaiting();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
self.addEventListener('activate', (event) => {
|
|
27
|
+
// The worker uses root scope so the chat page can request virtual media URLs.
|
|
28
|
+
// Fetch handling below is still limited to /__ermis/e2ee-media/* and the smoke route.
|
|
29
|
+
event.waitUntil(self.clients.claim());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function ack(source, requestId, ok, payload, error) {
|
|
33
|
+
if (!source || !requestId) return;
|
|
34
|
+
source.postMessage({ type: 'ERMIS_E2EE_MEDIA_STREAM_ACK', requestId, ok, payload, error });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function reportWorkerError(event, error) {
|
|
38
|
+
const message = error?.message || String(error || 'E2EE media stream error');
|
|
39
|
+
console.error('[E2EE media stream worker] fetch failed', message, error);
|
|
40
|
+
try {
|
|
41
|
+
const client = event.clientId ? await clients.get(event.clientId) : undefined;
|
|
42
|
+
client?.postMessage({
|
|
43
|
+
type: 'ERMIS_E2EE_MEDIA_STREAM_ERROR',
|
|
44
|
+
url: event.request.url,
|
|
45
|
+
range: event.request.headers.get('Range'),
|
|
46
|
+
error: message,
|
|
47
|
+
});
|
|
48
|
+
} catch {
|
|
49
|
+
// Best-effort diagnostics only.
|
|
50
|
+
}
|
|
51
|
+
return new Response(message, { status: 502, headers: { 'Cache-Control': 'no-store' } });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function base64ToBytes(value) {
|
|
55
|
+
const binary = atob(value);
|
|
56
|
+
const bytes = new Uint8Array(binary.length);
|
|
57
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
58
|
+
return bytes;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readU32(bytes, offset) {
|
|
62
|
+
return ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function nonceForFrame(noncePrefix, frameIndex) {
|
|
66
|
+
const nonce = new Uint8Array(12);
|
|
67
|
+
nonce.set(noncePrefix, 0);
|
|
68
|
+
nonce[8] = (frameIndex >>> 24) & 0xff;
|
|
69
|
+
nonce[9] = (frameIndex >>> 16) & 0xff;
|
|
70
|
+
nonce[10] = (frameIndex >>> 8) & 0xff;
|
|
71
|
+
nonce[11] = frameIndex & 0xff;
|
|
72
|
+
return nonce;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function importAesKey(session) {
|
|
76
|
+
if (!session.cryptoKeyPromise) {
|
|
77
|
+
session.cryptoKeyPromise = crypto.subtle.importKey(
|
|
78
|
+
'raw',
|
|
79
|
+
base64ToBytes(session.contentKey),
|
|
80
|
+
{ name: 'AES-GCM' },
|
|
81
|
+
false,
|
|
82
|
+
['decrypt'],
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return await session.cryptoKeyPromise;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function touchSession(session) {
|
|
89
|
+
session.lastAccess = Date.now();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function framePlainLength(session, frameIndex) {
|
|
93
|
+
const start = frameIndex * session.frameSize;
|
|
94
|
+
return Math.max(0, Math.min(session.frameSize, session.plaintextSize - start));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function frameEncryptedLength(session, frameIndex) {
|
|
98
|
+
return FRAME_HEADER_BYTES + framePlainLength(session, frameIndex) + GCM_TAG_BYTES;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function frameStartEncrypted(session, frameIndex) {
|
|
102
|
+
// This closed-form offset works because every frame before the last has a fixed plaintext size.
|
|
103
|
+
return frameIndex * (FRAME_HEADER_BYTES + session.frameSize + GCM_TAG_BYTES);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function frameCount(session) {
|
|
107
|
+
return Math.max(1, Math.ceil(session.plaintextSize / session.frameSize));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function cacheKey(sessionId, frameIndex) {
|
|
111
|
+
return `${sessionId}:${frameIndex}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function evictFrame(session, key) {
|
|
115
|
+
const frame = session.frameCache.get(key);
|
|
116
|
+
if (!frame) return;
|
|
117
|
+
session.frameCache.delete(key);
|
|
118
|
+
session.cacheBytes -= frame.byteLength;
|
|
119
|
+
globalCacheBytes -= frame.byteLength;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sessionCacheLimit(session) {
|
|
123
|
+
const plaintextSize = Number(session.plaintextSize || 0);
|
|
124
|
+
if (Number.isFinite(plaintextSize) && plaintextSize > 0 && plaintextSize <= FULL_REPLAY_CACHE_LIMIT) {
|
|
125
|
+
return Math.max(BASE_SESSION_CACHE_LIMIT, plaintextSize);
|
|
126
|
+
}
|
|
127
|
+
return BASE_SESSION_CACHE_LIMIT;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function cacheFrame(session, frameIndex, plain) {
|
|
131
|
+
const key = cacheKey(session.sessionId, frameIndex);
|
|
132
|
+
if (session.frameCache.has(key)) evictFrame(session, key);
|
|
133
|
+
session.frameCache.set(key, plain);
|
|
134
|
+
session.cacheBytes += plain.byteLength;
|
|
135
|
+
globalCacheBytes += plain.byteLength;
|
|
136
|
+
while (session.cacheBytes > sessionCacheLimit(session) && session.frameCache.size > 0) {
|
|
137
|
+
const oldest = session.frameCache.keys().next().value;
|
|
138
|
+
evictFrame(session, oldest);
|
|
139
|
+
}
|
|
140
|
+
while (globalCacheBytes > GLOBAL_CACHE_LIMIT) {
|
|
141
|
+
let evicted = false;
|
|
142
|
+
for (const candidate of sessions.values()) {
|
|
143
|
+
const oldest = candidate.frameCache.keys().next().value;
|
|
144
|
+
if (oldest) {
|
|
145
|
+
evictFrame(candidate, oldest);
|
|
146
|
+
evicted = true;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!evicted) break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getCachedFrame(session, frameIndex) {
|
|
155
|
+
const key = cacheKey(session.sessionId, frameIndex);
|
|
156
|
+
const value = session.frameCache.get(key);
|
|
157
|
+
if (!value) return undefined;
|
|
158
|
+
session.frameCache.delete(key);
|
|
159
|
+
session.frameCache.set(key, value);
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseRange(header, size) {
|
|
164
|
+
if (!header) return null;
|
|
165
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
166
|
+
if (!match) return { invalid: true };
|
|
167
|
+
const startRaw = match[1];
|
|
168
|
+
const endRaw = match[2];
|
|
169
|
+
if (!startRaw && !endRaw) return { invalid: true };
|
|
170
|
+
if (!startRaw) {
|
|
171
|
+
const suffix = Number(endRaw);
|
|
172
|
+
if (!Number.isFinite(suffix) || suffix <= 0) return { invalid: true };
|
|
173
|
+
const start = Math.max(0, size - suffix);
|
|
174
|
+
return { start, endExclusive: size };
|
|
175
|
+
}
|
|
176
|
+
const start = Number(startRaw);
|
|
177
|
+
const inclusiveEnd = endRaw ? Number(endRaw) : size - 1;
|
|
178
|
+
if (
|
|
179
|
+
!Number.isFinite(start) ||
|
|
180
|
+
!Number.isFinite(inclusiveEnd) ||
|
|
181
|
+
start < 0 ||
|
|
182
|
+
inclusiveEnd < start ||
|
|
183
|
+
start >= size
|
|
184
|
+
) {
|
|
185
|
+
return { invalid: true };
|
|
186
|
+
}
|
|
187
|
+
return { start, endExclusive: Math.min(size, inclusiveEnd + 1) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function abortError() {
|
|
191
|
+
try {
|
|
192
|
+
return new DOMException('E2EE media stream request aborted', 'AbortError');
|
|
193
|
+
} catch {
|
|
194
|
+
const error = new Error('E2EE media stream request aborted');
|
|
195
|
+
error.name = 'AbortError';
|
|
196
|
+
return error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function assertNotAborted(signal) {
|
|
201
|
+
if (signal?.aborted) throw abortError();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isAbortError(error) {
|
|
205
|
+
return error?.name === 'AbortError';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function sleep(ms, signal) {
|
|
209
|
+
assertNotAborted(signal);
|
|
210
|
+
return new Promise((resolve, reject) => {
|
|
211
|
+
const cleanup = () => signal?.removeEventListener?.('abort', onAbort);
|
|
212
|
+
const onAbort = () => {
|
|
213
|
+
clearTimeout(timeout);
|
|
214
|
+
cleanup();
|
|
215
|
+
reject(abortError());
|
|
216
|
+
};
|
|
217
|
+
const timeout = setTimeout(() => {
|
|
218
|
+
cleanup();
|
|
219
|
+
resolve();
|
|
220
|
+
}, ms);
|
|
221
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function renewGrant(session, clientId) {
|
|
226
|
+
if (session.renewalPromise) return await session.renewalPromise;
|
|
227
|
+
const requestId = `${session.sessionId}:${Date.now()}:${Math.random().toString(16).slice(2)}`;
|
|
228
|
+
session.renewalPromise = new Promise(async (resolve, reject) => {
|
|
229
|
+
const client = clientId ? await clients.get(clientId) : undefined;
|
|
230
|
+
if (!client) {
|
|
231
|
+
reject(new Error('E2EE media stream client is unavailable'));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const timeout = setTimeout(() => {
|
|
235
|
+
grantRenewals.delete(requestId);
|
|
236
|
+
reject(new Error('E2EE media stream grant renewal timed out'));
|
|
237
|
+
}, 15000);
|
|
238
|
+
grantRenewals.set(requestId, { resolve, reject, timeout });
|
|
239
|
+
client.postMessage({ type: 'ERMIS_E2EE_MEDIA_STREAM_RENEW_GRANT', requestId, sessionId: session.sessionId });
|
|
240
|
+
}).finally(() => {
|
|
241
|
+
session.renewalPromise = null;
|
|
242
|
+
});
|
|
243
|
+
return await session.renewalPromise;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function grantRenewalSafetyMargin(session) {
|
|
247
|
+
const value = Number(session.safetyMarginMs);
|
|
248
|
+
return Number.isFinite(value) && value >= 0 ? value : DEFAULT_GRANT_RENEWAL_SAFETY_MARGIN_MS;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function ensureFreshGrant(session, clientId, signal) {
|
|
252
|
+
assertNotAborted(signal);
|
|
253
|
+
if (Date.now() + grantRenewalSafetyMargin(session) < Number(session.expiresAtMs || 0)) return;
|
|
254
|
+
await renewGrant(session, clientId);
|
|
255
|
+
assertNotAborted(signal);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function fetchEncryptedRange(session, start, endExclusive, clientId, signal) {
|
|
259
|
+
await ensureFreshGrant(session, clientId, signal);
|
|
260
|
+
const headers = { Range: `bytes=${start}-${endExclusive - 1}` };
|
|
261
|
+
let transientAttempts = 0;
|
|
262
|
+
let renewedAfterAuthFailure = false;
|
|
263
|
+
while (true) {
|
|
264
|
+
assertNotAborted(signal);
|
|
265
|
+
const response = await fetch(session.grantUrl, { headers, cache: 'no-store', signal });
|
|
266
|
+
assertNotAborted(signal);
|
|
267
|
+
if (response.status === 206) {
|
|
268
|
+
const encrypted = new Uint8Array(await response.arrayBuffer());
|
|
269
|
+
assertNotAborted(signal);
|
|
270
|
+
return encrypted;
|
|
271
|
+
}
|
|
272
|
+
if (response.status === 200) throw new Error('E2EE media stream unsupported: R2 ignored Range request');
|
|
273
|
+
if (response.status === 401 || response.status === 403) {
|
|
274
|
+
if (renewedAfterAuthFailure) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`E2EE media encrypted range fetch failed after grant renewal: HTTP ${
|
|
277
|
+
response.status
|
|
278
|
+
}; requested=${start}-${endExclusive - 1}`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
renewedAfterAuthFailure = true;
|
|
282
|
+
await renewGrant(session, clientId);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if ((response.status === 429 || response.status >= 500) && transientAttempts < MAX_R2_RETRIES) {
|
|
286
|
+
await sleep(RETRY_BASE_MS * Math.pow(2, transientAttempts), signal);
|
|
287
|
+
transientAttempts += 1;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
throw new Error(
|
|
291
|
+
`E2EE media encrypted range fetch failed: HTTP ${response.status}; requested=${start}-${
|
|
292
|
+
endExclusive - 1
|
|
293
|
+
}; content-range=${response.headers.get('Content-Range') || ''}`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function decryptFrame(session, frameIndex, encrypted, offset) {
|
|
299
|
+
const header = encrypted.slice(offset, offset + FRAME_HEADER_BYTES);
|
|
300
|
+
if (header.length !== FRAME_HEADER_BYTES) throw new Error('Invalid E2EE media frame header');
|
|
301
|
+
const plainLength = readU32(header, 0);
|
|
302
|
+
const cipherLength = readU32(header, 4);
|
|
303
|
+
const expectedPlainLength = framePlainLength(session, frameIndex);
|
|
304
|
+
if (plainLength !== expectedPlainLength || cipherLength !== plainLength + GCM_TAG_BYTES) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`Invalid E2EE media frame lengths: frame=${frameIndex}; offset=${offset}; plain=${plainLength}; cipher=${cipherLength}; expectedPlain=${expectedPlainLength}; encryptedBytes=${encrypted.length}`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
const cipher = encrypted.slice(offset + FRAME_HEADER_BYTES, offset + FRAME_HEADER_BYTES + cipherLength);
|
|
310
|
+
const key = await importAesKey(session);
|
|
311
|
+
const plain = new Uint8Array(
|
|
312
|
+
await crypto.subtle.decrypt(
|
|
313
|
+
{ name: 'AES-GCM', iv: nonceForFrame(session.noncePrefix, frameIndex) },
|
|
314
|
+
key,
|
|
315
|
+
cipher,
|
|
316
|
+
),
|
|
317
|
+
);
|
|
318
|
+
if (plain.length !== plainLength) throw new Error('Invalid E2EE media plaintext frame length');
|
|
319
|
+
return plain;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function loadFrames(session, firstFrame, lastFrame, clientId, signal) {
|
|
323
|
+
const missing = [];
|
|
324
|
+
for (let frame = firstFrame; frame <= lastFrame; frame += 1) {
|
|
325
|
+
if (!getCachedFrame(session, frame)) missing.push(frame);
|
|
326
|
+
}
|
|
327
|
+
if (missing.length === 0) return;
|
|
328
|
+
let groupStart = missing[0];
|
|
329
|
+
let previous = missing[0];
|
|
330
|
+
const flush = async (startFrame, endFrame) => {
|
|
331
|
+
assertNotAborted(signal);
|
|
332
|
+
const encryptedStart = frameStartEncrypted(session, startFrame);
|
|
333
|
+
const encryptedEnd = frameStartEncrypted(session, endFrame) + frameEncryptedLength(session, endFrame);
|
|
334
|
+
const encrypted = await fetchEncryptedRange(session, encryptedStart, encryptedEnd, clientId, signal);
|
|
335
|
+
assertNotAborted(signal);
|
|
336
|
+
let offset = 0;
|
|
337
|
+
for (let frame = startFrame; frame <= endFrame; frame += 1) {
|
|
338
|
+
assertNotAborted(signal);
|
|
339
|
+
const plain = await decryptFrame(session, frame, encrypted, offset);
|
|
340
|
+
assertNotAborted(signal);
|
|
341
|
+
cacheFrame(session, frame, plain);
|
|
342
|
+
offset += frameEncryptedLength(session, frame);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
for (let i = 1; i < missing.length; i += 1) {
|
|
346
|
+
const frame = missing[i];
|
|
347
|
+
if (frame === previous + 1) {
|
|
348
|
+
previous = frame;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
await flush(groupStart, previous);
|
|
352
|
+
groupStart = frame;
|
|
353
|
+
previous = frame;
|
|
354
|
+
}
|
|
355
|
+
await flush(groupStart, previous);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function shouldPrefetchRange(session, start, endExclusive, firstFrame, lastFrame) {
|
|
359
|
+
const requestedFrameSpan = lastFrame - firstFrame + 1;
|
|
360
|
+
const previous = session.lastRangeAccess;
|
|
361
|
+
let sequentialHits = 0;
|
|
362
|
+
if (previous) {
|
|
363
|
+
const movingForward = start >= previous.start && firstFrame >= previous.firstFrame;
|
|
364
|
+
const nearby =
|
|
365
|
+
firstFrame <= previous.lastFrame + SEQUENTIAL_FRAME_GAP ||
|
|
366
|
+
start <= previous.endExclusive + session.frameSize * SEQUENTIAL_FRAME_GAP;
|
|
367
|
+
const sequential = movingForward && nearby;
|
|
368
|
+
sequentialHits = sequential ? previous.sequentialHits + 1 : 0;
|
|
369
|
+
if (!sequential) session.prefetchedUntilFrame = -1;
|
|
370
|
+
}
|
|
371
|
+
session.lastRangeAccess = {
|
|
372
|
+
start,
|
|
373
|
+
endExclusive,
|
|
374
|
+
firstFrame,
|
|
375
|
+
lastFrame,
|
|
376
|
+
sequentialHits,
|
|
377
|
+
};
|
|
378
|
+
return requestedFrameSpan > PREFETCH_THRESHOLD_FRAMES || sequentialHits >= SEQUENTIAL_PREFETCH_HITS;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function plannedBatchLastFrame(session, currentFrame, maxFrame, shouldPrefetch) {
|
|
382
|
+
if (!shouldPrefetch) return currentFrame;
|
|
383
|
+
const prefetchedUntilFrame = Number(session.prefetchedUntilFrame);
|
|
384
|
+
if (Number.isFinite(prefetchedUntilFrame) && currentFrame <= prefetchedUntilFrame) return currentFrame;
|
|
385
|
+
return Math.min(maxFrame, currentFrame + PREFETCH_FRAMES - 1);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function markPrefetchedUntil(session, frameIndex) {
|
|
389
|
+
const prefetchedUntilFrame = Number(session.prefetchedUntilFrame);
|
|
390
|
+
session.prefetchedUntilFrame = Math.max(
|
|
391
|
+
Number.isFinite(prefetchedUntilFrame) ? prefetchedUntilFrame : -1,
|
|
392
|
+
frameIndex,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function buildRangeStream(session, start, endExclusive, clientId, requestSignal) {
|
|
397
|
+
let frame = Math.floor(start / session.frameSize);
|
|
398
|
+
const responseLastFrame = Math.floor((endExclusive - 1) / session.frameSize);
|
|
399
|
+
const maxFrame = frameCount(session) - 1;
|
|
400
|
+
const shouldPrefetch = shouldPrefetchRange(session, start, endExclusive, frame, responseLastFrame);
|
|
401
|
+
const abort = new AbortController();
|
|
402
|
+
let cleanedUp = false;
|
|
403
|
+
const cleanup = () => {
|
|
404
|
+
if (cleanedUp) return;
|
|
405
|
+
cleanedUp = true;
|
|
406
|
+
requestSignal?.removeEventListener?.('abort', abortFromRequest);
|
|
407
|
+
};
|
|
408
|
+
const abortFromRequest = () => abort.abort();
|
|
409
|
+
if (requestSignal?.aborted) abort.abort();
|
|
410
|
+
else requestSignal?.addEventListener?.('abort', abortFromRequest, { once: true });
|
|
411
|
+
|
|
412
|
+
return new ReadableStream({
|
|
413
|
+
async pull(controller) {
|
|
414
|
+
if (abort.signal.aborted || frame > responseLastFrame) {
|
|
415
|
+
cleanup();
|
|
416
|
+
controller.close();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
const batchLastFrame = plannedBatchLastFrame(session, frame, maxFrame, shouldPrefetch);
|
|
421
|
+
await loadFrames(session, frame, batchLastFrame, clientId, abort.signal);
|
|
422
|
+
if (shouldPrefetch && batchLastFrame > frame) markPrefetchedUntil(session, batchLastFrame);
|
|
423
|
+
if (abort.signal.aborted) {
|
|
424
|
+
cleanup();
|
|
425
|
+
controller.close();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const plain = getCachedFrame(session, frame);
|
|
429
|
+
if (!plain) throw new Error('E2EE media decrypted frame missing from cache');
|
|
430
|
+
const plainStart = frame * session.frameSize;
|
|
431
|
+
const sliceStart = Math.max(0, start - plainStart);
|
|
432
|
+
const sliceEnd = Math.min(plain.length, endExclusive - plainStart);
|
|
433
|
+
if (sliceEnd > sliceStart) controller.enqueue(plain.slice(sliceStart, sliceEnd));
|
|
434
|
+
frame += 1;
|
|
435
|
+
if (frame > responseLastFrame) {
|
|
436
|
+
cleanup();
|
|
437
|
+
controller.close();
|
|
438
|
+
}
|
|
439
|
+
} catch (err) {
|
|
440
|
+
cleanup();
|
|
441
|
+
if (abort.signal.aborted || isAbortError(err)) {
|
|
442
|
+
controller.close();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
controller.error(err);
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
cancel() {
|
|
449
|
+
abort.abort();
|
|
450
|
+
cleanup();
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function responseHeaders(session, contentLength, extra = {}) {
|
|
456
|
+
return new Headers({
|
|
457
|
+
'Accept-Ranges': 'bytes',
|
|
458
|
+
'Content-Length': String(contentLength),
|
|
459
|
+
'Content-Type': session.mimeType || 'application/octet-stream',
|
|
460
|
+
'Cache-Control': 'no-store',
|
|
461
|
+
...extra,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function handleRangeRequest(event, session, range) {
|
|
466
|
+
const body = buildRangeStream(session, range.start, range.endExclusive, event.clientId, event.request.signal);
|
|
467
|
+
return new Response(body, {
|
|
468
|
+
status: 206,
|
|
469
|
+
headers: responseHeaders(session, range.endExclusive - range.start, {
|
|
470
|
+
'Content-Range': `bytes ${range.start}-${range.endExclusive - 1}/${session.plaintextSize}`,
|
|
471
|
+
}),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function handleNoRangeRequest(event, session) {
|
|
476
|
+
let frame = 0;
|
|
477
|
+
const abort = new AbortController();
|
|
478
|
+
event.request.signal.addEventListener('abort', () => abort.abort(), { once: true });
|
|
479
|
+
const stream = new ReadableStream({
|
|
480
|
+
async pull(controller) {
|
|
481
|
+
if (abort.signal.aborted || frame >= frameCount(session)) {
|
|
482
|
+
controller.close();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const batchLastFrame = plannedBatchLastFrame(session, frame, frameCount(session) - 1, true);
|
|
487
|
+
await loadFrames(session, frame, batchLastFrame, event.clientId, abort.signal);
|
|
488
|
+
if (batchLastFrame > frame) markPrefetchedUntil(session, batchLastFrame);
|
|
489
|
+
if (abort.signal.aborted) {
|
|
490
|
+
controller.close();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const plain = getCachedFrame(session, frame);
|
|
494
|
+
if (!plain) throw new Error('E2EE media decrypted frame missing from cache');
|
|
495
|
+
controller.enqueue(plain);
|
|
496
|
+
frame += 1;
|
|
497
|
+
} catch (err) {
|
|
498
|
+
if (abort.signal.aborted || isAbortError(err)) {
|
|
499
|
+
controller.close();
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
controller.error(err);
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
cancel() {
|
|
506
|
+
abort.abort();
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
return new Response(stream, {
|
|
510
|
+
status: 200,
|
|
511
|
+
headers: responseHeaders(session, session.plaintextSize),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function handleMediaFetch(event) {
|
|
516
|
+
const url = new URL(event.request.url);
|
|
517
|
+
if (url.pathname === SMOKE_PATH)
|
|
518
|
+
return new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } });
|
|
519
|
+
if (!url.pathname.startsWith(VIRTUAL_PREFIX)) return fetch(event.request);
|
|
520
|
+
const sessionId = decodeURIComponent(url.pathname.slice(VIRTUAL_PREFIX.length));
|
|
521
|
+
const session = sessions.get(sessionId);
|
|
522
|
+
if (!session) return new Response('E2EE media stream session missing', { status: 410 });
|
|
523
|
+
touchSession(session);
|
|
524
|
+
const range = parseRange(event.request.headers.get('Range'), session.plaintextSize);
|
|
525
|
+
if (range && range.invalid) {
|
|
526
|
+
return new Response('Invalid range', {
|
|
527
|
+
status: 416,
|
|
528
|
+
headers: { 'Content-Range': `bytes */${session.plaintextSize}`, 'Cache-Control': 'no-store' },
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
if (range) return await handleRangeRequest(event, session, range);
|
|
532
|
+
return handleNoRangeRequest(event, session);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
self.addEventListener('fetch', (event) => {
|
|
536
|
+
const url = new URL(event.request.url);
|
|
537
|
+
if (url.pathname === SMOKE_PATH || url.pathname.startsWith(VIRTUAL_PREFIX)) {
|
|
538
|
+
event.respondWith(handleMediaFetch(event).catch((err) => reportWorkerError(event, err)));
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
self.addEventListener('message', (event) => {
|
|
543
|
+
const data = event.data || {};
|
|
544
|
+
if (data.type === 'ERMIS_E2EE_MEDIA_STREAM_PING') {
|
|
545
|
+
ack(event.source, data.requestId, true, { ok: true });
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (data.type === 'ERMIS_E2EE_MEDIA_STREAM_CREATE_SESSION') {
|
|
549
|
+
const raw = data.session || {};
|
|
550
|
+
const session = {
|
|
551
|
+
...raw,
|
|
552
|
+
sessionId: String(raw.sessionId),
|
|
553
|
+
noncePrefix: base64ToBytes(String(raw.noncePrefix || '')),
|
|
554
|
+
frameCache: new Map(),
|
|
555
|
+
cacheBytes: 0,
|
|
556
|
+
lastAccess: Date.now(),
|
|
557
|
+
cryptoKeyPromise: null,
|
|
558
|
+
renewalPromise: null,
|
|
559
|
+
prefetchedUntilFrame: -1,
|
|
560
|
+
};
|
|
561
|
+
session.safetyMarginMs = grantRenewalSafetyMargin(session);
|
|
562
|
+
if (!session.sessionId || !session.grantUrl || !session.contentKey || session.noncePrefix.length !== 8) {
|
|
563
|
+
ack(event.source, data.requestId, false, undefined, 'Invalid E2EE media stream session');
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
sessions.set(session.sessionId, session);
|
|
567
|
+
ack(event.source, data.requestId, true, { sessionId: session.sessionId });
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (data.type === 'ERMIS_E2EE_MEDIA_STREAM_TEST_SESSION') {
|
|
571
|
+
const session = sessions.get(data.sessionId);
|
|
572
|
+
if (!session) {
|
|
573
|
+
ack(event.source, data.requestId, false, undefined, 'E2EE media stream session missing');
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
loadFrames(session, 0, 0, undefined, undefined)
|
|
577
|
+
.then(() => ack(event.source, data.requestId, true, { ok: true }))
|
|
578
|
+
.catch((err) => {
|
|
579
|
+
const message = err?.message || String(err || 'E2EE media stream session test failed');
|
|
580
|
+
console.error('[E2EE media stream worker] session test failed', message, err);
|
|
581
|
+
ack(event.source, data.requestId, false, undefined, message);
|
|
582
|
+
});
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (data.type === 'ERMIS_E2EE_MEDIA_STREAM_DISPOSE_SESSION') {
|
|
586
|
+
const session = sessions.get(data.sessionId);
|
|
587
|
+
if (session) {
|
|
588
|
+
for (const key of Array.from(session.frameCache.keys())) evictFrame(session, key);
|
|
589
|
+
sessions.delete(data.sessionId);
|
|
590
|
+
}
|
|
591
|
+
ack(event.source, data.requestId, true, { ok: true });
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (data.type === 'ERMIS_E2EE_MEDIA_STREAM_DISPOSE_ALL') {
|
|
595
|
+
for (const session of sessions.values()) {
|
|
596
|
+
for (const key of Array.from(session.frameCache.keys())) evictFrame(session, key);
|
|
597
|
+
}
|
|
598
|
+
sessions.clear();
|
|
599
|
+
ack(event.source, data.requestId, true, { ok: true });
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (data.type === 'ERMIS_E2EE_MEDIA_STREAM_GRANT_RENEWED' || data.type === 'ERMIS_E2EE_MEDIA_STREAM_GRANT_FAILED') {
|
|
603
|
+
const renewal = grantRenewals.get(data.requestId);
|
|
604
|
+
if (!renewal) return;
|
|
605
|
+
grantRenewals.delete(data.requestId);
|
|
606
|
+
clearTimeout(renewal.timeout);
|
|
607
|
+
if (data.type === 'ERMIS_E2EE_MEDIA_STREAM_GRANT_FAILED') {
|
|
608
|
+
renewal.reject(new Error(data.error || 'E2EE media stream grant renewal failed'));
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const session = sessions.get(data.sessionId);
|
|
612
|
+
if (session) {
|
|
613
|
+
session.grantUrl = data.grantUrl;
|
|
614
|
+
session.expiresAtMs = data.expiresAtMs;
|
|
615
|
+
}
|
|
616
|
+
renewal.resolve(true);
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
setInterval(() => {
|
|
621
|
+
const now = Date.now();
|
|
622
|
+
for (const [sessionId, session] of sessions) {
|
|
623
|
+
if (now - session.lastAccess <= IDLE_TTL_MS) continue;
|
|
624
|
+
for (const key of Array.from(session.frameCache.keys())) evictFrame(session, key);
|
|
625
|
+
sessions.delete(sessionId);
|
|
626
|
+
}
|
|
627
|
+
}, 60 * 1000);
|
|
Binary file
|