Files

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}` } : {}),
},
});
}