80 lines
3.5 KiB
JavaScript
80 lines
3.5 KiB
JavaScript
// 加密视频/音频的边下边播。
|
|
// 播放器请求 /__wd_stream/<id>(带 Range),这里不联网:把「要哪一段」告诉发起请求的页面,
|
|
// 页面把一个 MessagePort 交给加密线程,加密线程解密出这一段后一块一块从端口推回来,
|
|
// 这里再把它们作为 206 响应流式交给播放器。密钥始终只在加密线程里。
|
|
const PREFIX = '/__wd_stream/';
|
|
/** 一次最多让加密线程领先播放器多少块,避免整个文件都堆在内存里 */
|
|
const WINDOW = 6;
|
|
|
|
self.addEventListener('install', () => self.skipWaiting());
|
|
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
|
|
|
self.addEventListener('fetch', (event) => {
|
|
const url = new URL(event.request.url);
|
|
if (url.origin !== self.location.origin || !url.pathname.startsWith(PREFIX)) return;
|
|
event.respondWith(serve(event).catch((error) => new Response(String(error && error.message || error), { status: 500 })));
|
|
});
|
|
|
|
function parseRange(header) {
|
|
const m = /^bytes=(\d*)-(\d*)$/.exec(header || '');
|
|
if (!m) return { start: 0, end: undefined };
|
|
if (m[1] === '' && m[2] !== '') return { suffix: Number(m[2]) };
|
|
return { start: Number(m[1] || 0), end: m[2] === '' ? undefined : Number(m[2]) };
|
|
}
|
|
|
|
async function serve(event) {
|
|
const id = decodeURIComponent(new URL(event.request.url).pathname.slice(PREFIX.length));
|
|
const client = event.clientId ? await self.clients.get(event.clientId) : undefined;
|
|
const target = client ?? (await self.clients.matchAll({ type: 'window' }))[0];
|
|
if (!target) return new Response('no client', { status: 503 });
|
|
const range = parseRange(event.request.headers.get('Range'));
|
|
|
|
const channel = new MessageChannel();
|
|
const port = channel.port1;
|
|
// 第一条消息:{ total, mime, start, end } 或 { error };之后是 { data } / { done } / { error }
|
|
const first = new Promise((resolve) => {
|
|
port.onmessage = (message) => resolve(message.data);
|
|
});
|
|
target.postMessage({ type: 'wd-stream', id, range, port: channel.port2 }, [channel.port2]);
|
|
const head = await Promise.race([first, new Promise((resolve) => setTimeout(() => resolve({ error: 'timeout' }), 20000))]);
|
|
if (head.error) return new Response(head.error, { status: head.status || 502 });
|
|
|
|
const { total, mime, start, end } = head;
|
|
let pending = [];
|
|
let waiting;
|
|
let finished = false;
|
|
let failure;
|
|
port.onmessage = (message) => {
|
|
const m = message.data;
|
|
if (m.data) pending.push(m.data);
|
|
else if (m.done) finished = true;
|
|
else if (m.error) failure = m.error;
|
|
if (waiting) { const wake = waiting; waiting = undefined; wake(); }
|
|
};
|
|
port.postMessage({ credit: WINDOW });
|
|
|
|
const body = new ReadableStream({
|
|
async pull(controller) {
|
|
while (!pending.length && !finished && !failure) await new Promise((resolve) => { waiting = resolve; });
|
|
if (pending.length) {
|
|
controller.enqueue(new Uint8Array(pending.shift()));
|
|
port.postMessage({ credit: 1 });
|
|
} else if (failure) { controller.error(new Error(failure)); port.close(); }
|
|
else { controller.close(); port.close(); }
|
|
},
|
|
cancel() { port.postMessage({ cancel: true }); port.close(); },
|
|
});
|
|
|
|
const partial = !(start === 0 && end === total - 1 && !event.request.headers.get('Range'));
|
|
return new Response(body, {
|
|
status: partial ? 206 : 200,
|
|
headers: {
|
|
'Content-Type': mime || 'application/octet-stream',
|
|
'Content-Length': String(end - start + 1),
|
|
'Accept-Ranges': 'bytes',
|
|
'Cache-Control': 'no-store',
|
|
...(partial ? { 'Content-Range': `bytes ${start}-${end}/${total}` } : {}),
|
|
},
|
|
});
|
|
}
|