Download Chunk via ServiceWorker

JavaScript

现在 Web 端的视频播放,大多采用基于 HLS 或是 MPEG Dash 的方案,将视频内容分解成一系列小型 HTTP 文件片段,每段都包含很短长度的可播放片段,由前端逐个拉取片段并播放,最终形成完整的播放视频。

对于一些云存储网站来说,也可以通过类似的方案来为用户提供下载服务。在分片下载文件的过程中,服务商可以对下载的用户进行校验。同时,由于需要分段下载内容并拼接,避免了单一 URL 造成盗链等问题。然而,一个用户体验的问题是,这种形式的下载如何可以给用户一个更好的用户体验:显然不能将分段的下载内容直接呈现给用户,用户也不应该关心这些分片的内容;如果要等到前端将所有内容下载完成并拼接后再呈现给客户,那么在文件较大的时候会让用户等待很久,用户体验不佳。

这时候,就可以用到 Service Worker 的 Proxy 功能了,可以在前端进行拼接数据的过程中,给用户等同于一般下载文件的体验。

大致的流程代码如下:

首先,需要在 Service Worker 和 Main 线程见建立一个通信机制。比如,可以选择使用 MessageChannel。在 Main 线程创建一个 MessageChannel,然后将 Channel 发送给 Service Worker。之后两者通过这个 Channel 进行数据的沟通(主要是 Main 将下载好的文件片段发送给 Service Worker)。

接着,在 Service Worker 端的 MessageChannel 收到新的数据之后,创建一个 ReadableStream 并将数据写入这个 Stream。

最后,Main 会通过 JavaScript 访问一个不存在的下载链接,里面应该包含一个 ID,用于指明需要的文件具体是哪一个(主要是考虑到多个文件同时下载的情况)。Service Worker 通过 fetch 事件拦截这个请求,并通过 URL 中的 ID 找到对应的 ReadableStream,并将这个 Stream 作为 Response 返回。这样,在浏览器的下载页面就可以看到该文件正在被下载。和原生的下载体验一致,这里也可以看到下载的名称、当前的速度、剩余的时间等信息。

如此,一个完整的流程就走完了。前端下载文件分片,将分片数据发送给 Service Worker,Service Worker 收到数据之后,将数据写入到 ReadableStream 中去;同时,这个 ReadableStream 以 Response 的形式返回给 Main 线程,将这个拼接中的文件逐步下载到本地。

Fetch 事件的代理代码如下:

self.onfetch = function(event) {
  const { url } = event.request;
  // 跳过一般的请求
  if (!isDownloadUrl(url)) return event.respondWith(fetch(event.request));

  const headers = new Headers();
  headers.append('Content-Type', 'application/octet-stream; charset=UTF-8');

  // 获取 URL 中的 ID 数据
  // 相当于 Main 线程通过 URL 传递参数给 Service Worker,用于表示想要下载的具体数据
  const id = getDownloadFileID(url);
  const streamInfo = streamMapping[id];

  if (!streamInfo) {
    // 没有找到数据的情况,返回 404
    return event.respondWith(new Response('Not Found', {
      headers,
      status: 404,
      statusText: 'Not Found'
    }));
  }

  const { filename } = streamInfo;
  // Content-Disposition 中的 filename 必须是 US-ASCII
  // http://tools.ietf.org/html/rfc2183#section-2.3
  const asciiName = filename.replace(/[^\x20-\x7e\xa0-\xff]/g, '?');
  // 通过 filename*=UTF-8''xxx 这样的方式,可以让浏览器使用 UTF-8 的文件名
  const encodedName = encodeURIComponent(filename)
    .replace(/['()]/g, escape)
    .replace(/\*/g, "%2A");
  headers.append(
    'Content-Disposition',
    `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`
  );
  headers.append('Content-Length', `${streamInfo.filesize}`);
  headers.append('X-Content-Type-Options', 'nosniff');
  // 将 Service Worker 中的 stream 作为 Response 返回
  // 只要 Stream 没有完结,浏览器的下载行为就会继续,直到 Stream 停止
  return event.respondWith(new Response(streamInfo.stream, {
    headers
  }));
}

关于上面代码的两个延伸阅读:

  1. 虽然 Content-Disposition 默认只能写 ASCII 的文件名,但是 UTF-8 的文件名也是可以设置的。关于 filename*=UTF-8''xxx 这种设置方案,在 StackOverflow 上有相关讨论
  2. X-COntent-Type-Options 设置为 nosniff 可以阻止浏览器的 MIME 类型嗅探,更多讨论可以参考 MDN

创建和使用 Stream 的代码如下:

function getStream() {
  return new Promise((resolve) => {
    const stream = new ReadableStream({
      start: function(controller) {
        resolve(stream);
      },
      pull: function(controller) {
        return new Promise((resolve, reject) => {
          // 当从 Stream 获取数据的时候,返回一个 Promise
          // 并在 onUpdate 赋值,等待 Main 线程的数据
          // 当 Main 线程传递新数据之后,调用这里的 onUpdate 函数,将 data 传入
          // 接下来通过 FileReader 读取数据,转化成 Uint8Array,放入 Stream 中
          // 在清除 onUpdate 函数,等待下一次 Pull
          streamInfo.onUpdate = (data, callback) => {
            const reader = new FileReader();
            reader.onload = (e) => {
              const { target } = e;
              if (streamInfo.stream) {
                controller.enqueue(new Uint8Array(target.result));
                streamInfo.onUpdate = null;
                resolve();
                callback();
              } else {
                reject();
              }
            };
            reader.readAsArrayBuffer(data);
          };
        });
      }
    });
  });
}

从 Main 获取数据并更新给 Stream 的代码如下:

self.onmessage = function (event) {
  const { data, ports } = event;
  const [portA = null, portB = null] = ports;
  const promise = new Promise((resolve) => {
    // 根据消息类型,选择创建一个新的 stream 或是往一个已经创建的 stream 中写入数据
    if (data.type === 'create') {
      getStream().then(stream => {
        streamInfo[data.id] = {
          filesize: data.filesize,
          filename: data.filename,
          stream
        };
        resolve();
      });
    } else if (data.type === 'insert') {
      portB.onmessage = (msg) => {
        const { chunk } = msg.data;
        if (streamInfo[data.id].onUpdate) {
          streamInfo[data.id].onUpdate(chunk, resolve);
        } else {
          // 等待 onUpdate API 创建...
        }
      }
    }
  });
  event.waitUntil(promise);
}

MyAirBridge 网站使用了类似上面提到的技术来下载中的文件内容。Service Worker 的代码参考这里