跨域 Service Worker - 使用外部提取进行实验

杰夫·波斯尼克
Jeff Posnick

背景

Service Worker 使 Web 开发者能够响应其 Web 应用发出的网络请求,使其即使在离线状态下也能继续工作、对抗 lie-fi,以及实现复杂的缓存交互,例如 stale-while-revalidate。但一直以来,Service Worker 都与特定源相关联 - 作为 Web 应用的所有者,您负责编写和部署 Service Worker 以拦截 Web 应用发出的所有网络请求。在该模型中,每个 Service Worker 甚至负责处理跨域请求,例如向第三方 API 或网络字体发出的请求。

如果第三方提供商(API、网页字体或其他常用服务)有能力部署自己的 Service Worker(该 Service Worker 能够处理其他源向其源发出的请求),该怎么办?提供程序可以实现自己的自定义网络逻辑,并利用单个权威的缓存实例来存储其响应。现在,得益于外部提取,这种类型的第三方 Service Worker 部署已成为现实。

对于任何通过浏览器通过 HTTPS 请求访问的 Service 提供器,部署一个实现外部提取的 Service Worker 都很有意义;只需想想您可以提供独立于网络版本的服务的情况,在该场景中,浏览器可以利用通用资源缓存。可能从中受益的服务包括但不限于:

  • 具有 RESTful 接口的 API 提供商
  • 网页字体提供程序
  • 分析服务提供商
  • 图片托管服务提供商
  • 常规内容分发网络

例如,假设您是一个分析服务提供商。通过部署外部提取 Service Worker,您可以确保用户离线时对服务的所有失败请求都会加入队列,并在连接返回时重放。尽管服务的客户端可以通过第一方 Service Worker 实现类似行为,但要求每个客户端为您的服务编写定制逻辑不如依赖于您部署的共享外部提取 Service Worker 的可扩展性。

前提条件

源试用令牌

外部提取仍被视为实验性功能。为了避免在浏览器供应商全面指定及同意此设计之前过早烘焙出此设计,我们已在 Chrome 54 中以源试用的形式实施了此设计。只要外部提取仍处在实验阶段,为了在您托管的服务中使用这项新功能,您需要请求一个范围限定为服务特定源的令牌。对于您希望通过外部提取处理的资源的所有跨源请求,以及针对 Service Worker JavaScript 资源的响应中,应以 HTTP 响应标头的形式包含该令牌:

Origin-Trial: token_obtained_from_signup

试用将于 2017 年 3 月结束。届时,我们预计已找出可稳定该功能所需的任何更改,并(希望如此)默认启用该功能。如果届时默认启用外部提取功能,则与现有源试用令牌关联的功能将停止运行。

在注册官方源试用令牌之前,为方便进行外部提取,您可以前往 chrome://flags/#enable-experimental-web-platform-features 并启用“实验性 Web 平台功能”标记,绕过 Chrome 中针对本地计算机的这一要求。请注意,您在本地实验中要使用的每一个 Chrome 实例都需要执行此操作;而对于源试用令牌,该功能可供您的所有 Chrome 用户使用。

HTTPS

与所有 Service Worker 部署一样,您用于提供资源和 Service Worker 脚本的 Web 服务器需要通过 HTTPS 访问。此外,外部提取拦截仅适用于来自在安全源上托管的页面的请求,因此您服务的客户端需要使用 HTTPS 才能利用您的外部提取实现。

使用外部提取

了解了这些前提条件后,接下来我们将深入了解启动并运行外部提取 Service Worker 所需的技术细节。

注册 Service Worker

您可能遇到的第一个挑战是如何注册 Service Worker。如果您以前使用过 Service Worker,则可能熟悉以下内容:

// You can't do this!
if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('service-worker.js');
}

这种用于第一方 Service Worker 注册的 JavaScript 代码在 Web 应用的上下文中是合理的,因为用户导航到您控制的网址会触发此操作。但这并不是注册第三方 Service Worker 的可行方法,因为浏览器与服务器之间唯一的交互就是请求特定的子资源,而不是完整的导航。如果浏览器请求来自您维护的 CDN 服务器的图片,您不能将这段 JavaScript 代码添加到响应的前面,并期望系统能够运行此代码段。需要在正常的 JavaScript 执行上下文之外使用另一种 Service Worker 注册方法。

解决方案会采用 HTTP 标头的形式,您的服务器可在任何响应中加入该标头:

Link: </service-worker.js>; rel="serviceworker"; scope="/"

让我们将此示例标头拆分为多个组件,每个组件都由 ; 字符分隔。

  • </service-worker.js> 是必需的,用于指定 Service Worker 文件的路径(将 /service-worker.js 替换为脚本的相应路径)。它直接对应于 scriptURL 字符串,该字符串会作为第一个参数传递给 navigator.serviceWorker.register()。根据 Link 标头规范的要求,该值需要用 <> 个字符括起来;如果提供的是相对网址而不是绝对网址,系统会将其解读为相对于响应位置
  • rel="serviceworker" 也是必需项,且无需进行任何自定义即可添加。
  • scope=/ 是一个可选的范围声明,相当于可作为第二个参数传入 navigator.serviceWorker.register()options.scope 字符串。对于许多用例,您可以使用默认范围。因此,除非您确定需要用到它,否则可省略此列。Link 标头注册同样适用于允许的最大范围限制,以及通过 Service-Worker-Allowed 标头放宽这些限制的功能。

与“传统”Service Worker 注册一样,使用 Link 标头会安装一个 Service Worker,该 Service Worker 将用于针对已注册的作用域发出的下一个请求。包含特殊标头的响应正文将按原样使用,可立即用于页面,无需等待外部 Service Worker 完成安装。

请注意,外部提取当前作为源试用实现,因此除了链接响应标头之外,您还需要添加有效的 Origin-Trial 标头。为了注册外部提取 Service Worker,至少要添加的响应标头集为

Link: </service-worker.js>; rel="serviceworker"
Origin-Trial: token_obtained_from_signup

调试注册

在开发过程中,您可能需要确认您的外部提取 Service Worker 是否已正确安装并在处理请求。您可以在 Chrome 的开发者工具中检查几项内容,以确认一切按预期运行。

发送的响应标头是否正确?

为了注册外部提取 Service Worker,您需要在响应对域上托管的资源的响应上设置 Link 标头,如本博文前面所述。在源试用期间,如果您未设置 chrome://flags/#enable-experimental-web-platform-features,则还需要设置 Origin-Trial 响应标头。您可以查看开发者工具“网络”面板中的条目,以确认您的网络服务器是否设置了这些标头:

“Network”面板中显示的标题。

外部提取 Service Worker 是否已正确注册?

您还可以在开发者工具的 Application 面板中查看 Service Worker 的完整列表,以确认底层 Service Worker 注册,包括其作用域。请务必选择“Show all”(全部显示)选项,因为默认情况下,您只能看到当前源站的 Service Worker。

“Applications”面板中的外部提取 Service Worker。

install 事件处理脚本

现在,您已经注册了第三方 Service Worker,它将有机会像其他 Service Worker 一样响应 installactivate 事件。它可以充分利用这些事件,例如,在 install 事件期间使用所需资源填充缓存,或在 activate 事件中清除过时的缓存。

除了普通的 install 事件缓存 activity 之外,还需要在第三方 Service Worker 的 install 事件处理脚本中执行一个额外的步骤。您的代码需要调用 registerForeignFetch(),如以下示例所示:

self.addEventListener('install', event => {
    event.registerForeignFetch({
    scopes: [self.registration.scope], // or some sub-scope
    origins: ['*'] // or ['https://example.com']
    });
});

有两个配置选项,两者都必需:

  • scopes 接受包含一个或多个字符串的数组,其中每个字符串都表示会触发 foreignfetch 事件的请求范围。但是,您可能会想:我已经在 Service Worker 注册期间定义了一个作用域!是的,而且整个范围仍然相关 - 您在此处指定的每个范围必须等于 Service Worker 整体范围的子范围,或者是整个范围的一部分。通过此处的额外范围限制,您可以部署一个通用的 Service Worker,以同时处理第一方 fetch 事件(针对从您自己的网站发出的请求)和第三方 foreignfetch 事件(针对来自其他网域的请求),并明确指出只有更大范围的一个子集可以触发 foreignfetch。实际上,如果您要部署一个专门处理第三方 foreignfetch 事件的 Service Worker,您只需要使用一个与 Service Worker 的整体范围相等的明确作用域。以上示例将使用值 self.registration.scope 执行此操作。
  • origins 还接受包含一个或多个字符串的数组,可让您将 foreignfetch 处理程序限制为仅响应来自特定网域的请求。例如,如果您明确允许“https://example.com”,那么从托管在 https://example.com/path/to/page.html 上的网页发出的请求将会触发您的外部提取处理程序,但从 https://random-domain.com/path/to/page.html 发出的请求不会触发您的处理程序。除非您有特定原因只针对一部分远程源触发外部提取逻辑,否则您只需将 '*' 指定为数组中的唯一值,即可允许所有源站。

externalfetch 事件处理脚本

现在,您已经安装了第三方 Service Worker,并通过 registerForeignFetch() 对其进行了配置。接下来,它将有机会拦截对您的服务器的跨源子资源请求,该请求处于外部提取范围内。

在传统的第一方 Service Worker 中,每个请求都会触发一个 fetch 事件,您的 Service Worker 有机会响应该事件。我们的第三方 Service Worker 有机会处理名为 foreignfetch 且略有不同的事件。从概念上讲,这两个事件非常相似,它们可让您检查传入的请求,并选择性地通过 respondWith() 对其提供响应:

self.addEventListener('foreignfetch', event => {
    // Assume that requestLogic() is a custom function that takes
    // a Request and returns a Promise which resolves with a Response.
    event.respondWith(
    requestLogic(event.request).then(response => {
        return {
        response: response,
        // Omit to origin to return an opaque response.
        // With this set, the client will receive a CORS response.
        origin: event.origin,
        // Omit headers unless you need additional header filtering.
        // With this set, only Content-Type will be exposed.
        headers: ['Content-Type']
        };
    })
    );
});

尽管在概念上相似,但在对 ForeignFetchEvent 调用 respondWith() 时,实际存在一些差异。您不能像使用 FetchEvent 那样只将 Response(或以 Response 进行解析的 Promise)提供给 respondWith(),而是需要将使用具有特定属性的对象进行解析的 Promise 传递给 ForeignFetchEventrespondWith()

  • response 是必需的,并且必须设置为 Response 对象,该对象会返回给发出请求的客户端。如果您提供的不是有效的 Response,客户端的请求将因网络连接错误而终止。与在 fetch 事件处理脚本内调用 respondWith() 不同,您必须在此处提供 Response,而不是使用 Response 解析的 Promise您可以通过 promise 链构建响应,并将该链作为参数传递给 foreignfetchrespondWith(),但该链必须使用包含 response 属性设置为 Response 对象的对象进行解析。您可以在上面的代码示例中看到相关演示。
  • origin 是可选的,用于确定返回的响应是否不透明。如果留空,响应将是不透明的,并且客户端对响应的正文和标头将具有有限的访问权限。如果请求是使用 mode: 'cors' 发出的,则返回不透明响应将被视为错误。但是,如果您指定的字符串值等于远程客户端的来源(可通过 event.origin 获取),即表示您明确选择向客户端提供启用了 CORS 的响应。
  • headers 也是可选的,仅在您同时还指定 origin 并返回 CORS 响应时才有用。默认情况下,只有 CORS 安全名单中的响应标头列表中的标头会包含在您的响应中。如果您需要进一步过滤返回的内容,标头将获取一个或多个标头名称的列表,并将其用作要在响应中公开的标头的许可名单。这样,您就可以选择启用 CORS,同时仍可防止将可能敏感的响应标头直接提供给远程客户端。

请务必注意,当 foreignfetch 处理程序运行时,它可以访问托管 Service Worker 的来源的所有凭据和环境授权。作为部署启用外部提取的 Service Worker 的开发者,您有责任确保不会泄露任何无法通过这些凭据获取的特权响应数据。要求选择启用 CORS 响应是限制意外泄露的一个步骤,但作为开发者,您可以通过以下方式在 foreignfetch 处理程序中明确发出 fetch() 请求,不使用隐式凭据

self.addEventListener('foreignfetch', event => {
    // The new Request will have credentials omitted by default.
    const noCredentialsRequest = new Request(event.request.url);
    event.respondWith(
    // Replace with your own request logic as appropriate.
    fetch(noCredentialsRequest)
        .catch(() => caches.match(noCredentialsRequest))
        .then(response => ({response}))
    );
});

客户端注意事项

还有一些其他注意事项会影响外部提取 Service Worker 处理来自服务客户端的请求的方式。

拥有自己的第一方 Service Worker 的客户端

您的服务的一些客户端可能已经拥有自己的第一方 Service Worker,以处理源自其 Web 应用的请求。这对您的第三方外部提取 Service Worker 意味着什么?

第一方 Service Worker 中的 fetch 处理程序将有机会响应 Web 应用发出的所有请求,即使第三方 Service Worker 启用了 foreignfetch 且作用域涵盖请求,也是如此。但是,具有第一方 Service Worker 的客户仍然可以利用您的外部提取 Service Worker!

在第一方 Service Worker 内,使用 fetch() 检索跨源资源将触发相应的外部提取 Service Worker。这意味着,如下代码可以利用 foreignfetch 处理程序:

// Inside a client's first-party service-worker.js:
self.addEventListener('fetch', event => {
    // If event.request is under your foreign fetch service worker's
    // scope, this will trigger your foreignfetch handler.
    event.respondWith(fetch(event.request));
});

同样,如果存在第一方提取处理程序,但它们在处理跨源资源的请求时未调用 event.respondWith(),则请求将自动“进入”您的 foreignfetch 处理程序:

// Inside a client's first-party service-worker.js:
self.addEventListener('fetch', event => {
    if (event.request.mode === 'same-origin') {
    event.respondWith(localRequestLogic(event.request));
    }

    // Since event.respondWith() isn't called for cross-origin requests,
    // any foreignfetch handlers scoped to the request will get a chance
    // to provide a response.
});

如果第一方 fetch 处理程序调用 event.respondWith(),但不使用 fetch() 请求外部提取作用域内的资源,则外部提取 Service Worker 将没有机会处理该请求。

没有自己的 Service Worker 的客户端

向第三方服务发出请求的所有客户端在服务部署外部提取 Service Worker 时都会受益,即使这些客户端还没有使用自己的 Service Worker。客户端只需使用支持外部提取 Service Worker 的浏览器,即可选择使用外部提取 Service Worker,无需执行任何特定操作。这意味着,通过部署外部提取 Service Worker,您的自定义请求逻辑和共享缓存将立即受益于您的许多服务的客户端,而无需他们采取进一步措施。

总结:客户端在哪里寻求响应

基于上述信息,我们可以构建一个来源层次结构,客户端将使用此层次结构来查找跨源请求的响应。

  1. 第一方 Service Worker 的 fetch 处理程序(如果存在)
  2. 第三方 Service Worker 的 foreignfetch 处理程序(如果存在,且仅适用于跨源请求)
  3. 浏览器的 HTTP 缓存(如果存在新鲜响应)
  4. 广告网络

浏览器从顶部开始,然后根据 Service Worker 的实现继续往下浏览,直到找到响应的来源。

了解详情

随时掌握最新动态

Chrome 针对国外抓取源试用的实现可能会发生变化,具体取决于开发者的反馈。我们会通过内嵌更改及时更新此帖子,并会及时记录以下具体更改。我们还会通过 @chromiumdev Twitter 帐号分享有关重大更改的信息。