From 9387327a97a058bbb767c6f79cfb3f4c8a20bc41 Mon Sep 17 00:00:00 2001 From: Ross Wollman Date: Wed, 13 Jul 2022 16:42:46 -0700 Subject: [PATCH] docs: Service Worker examples and explanations (#15595) Resolves #15582. --- docs/src/service-workers.md | 409 ++++++++++++++++++++++++ tests/library/chromium/chromium.spec.ts | 166 ++++++++++ 2 files changed, 575 insertions(+) create mode 100644 docs/src/service-workers.md diff --git a/docs/src/service-workers.md b/docs/src/service-workers.md new file mode 100644 index 0000000000..c95a7e7a7b --- /dev/null +++ b/docs/src/service-workers.md @@ -0,0 +1,409 @@ +--- +id: service-workers +title: "Service Workers Guide" +--- + + +[Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) provide a browser-native method of handling requests made by a page with the native [Fetch API (`fetch`)](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) along with other network-requested assets (like scripts, css, and images). + +They can act as a **network proxy** between the page and the external network to perform caching logic or can provide users with an offline experience if the Service Worker adds a [FetchEvent](https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent#examples) listener. + +Many sites that use Service Workers simply use them as a transparent optimization technique. While users might notice a faster experience, the app's implementation is unaware of their existence. Running the app with or without Service Workers enabled appears functionally equivalent. + +**If your app uses Service Workers**, here's the scenarios that Playwright supports: + +1. Testing the page exactly like a user would experience it. This works out of the box in all supported browsers. +1. Test your page without a Service Worker. Set [`option: Browser.newContext.serviceWorkers`] to `'block'`. You can test your page as if no Service Worker was registered. +1. Listen for and route network traffic via Playwright, whether it comes from a Service Worker or not. In Firefox and WebKit, set [`option: Browser.newContext.serviceWorkers`] to `'block'` to avoid Service Worker network traffic entirely. In Chromium, either block Service Workers or use [`method: BrowserContext.route`]. +1. (Chromium-only) Test your Service Worker implementation itself. Use [`method: BrowserContext.serviceWorkers`] to get access to the Service Worker and evaluate there. + + +## Service Worker Fetch + +:::note +The next sections are only currently supported when using Playwright with Chrome/Chromium. In Firefox and WebKit, if a Service Worker has a FetchEvent handler, Playwright will **not** emit Network events for all network traffic. +::: + +### Accessing Service Workers and Waiting for Activation + +You can use [`method: BrowserContext.serviceWorkers`] to list the Service [Worker]s, or specifically watch for the Service [Worker] if you anticipate a page will trigger its [registration](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register): + +```js tab=js-ts +const [ serviceworker ] = await Promise.all([ + context.waitForEvent('serviceworker'), + page.goto('/example-with-a-service-worker.html'), +]); +``` + +```js tab=js-js +const [ serviceworker ] = await Promise.all([ + context.waitForEvent('serviceworker'), + page.goto('/example-with-a-service-worker.html'), +]); +``` + +```python async +async with context.expect_event("serviceworker") as event_info: + await page.goto('/example-with-a-service-worker.html') +serviceworker = await event_info.value +``` + +```python sync +with context.expect_event("serviceworker") as event_info: + page.goto('/example-with-a-service-worker.html') +serviceworker = event_info.value +``` + +```csharp +var waitForServiceWorkerTask = page.WaitForServiceWorkerAsync(); +await page.GotoAsync('/example-with-a-service-worker.html'); +var serviceworker = await waitForServiceWorkerTask; +``` + +```java +Worker serviceWorker = page.waitForRequest(() -> { + page.navigate('/example-with-a-service-worker.html'); +}); +``` + +[`event: BrowserContext.serviceWorker`] is fired ***before*** the Service Worker's main script has been evaluated, so ***before*** calling service[`method: Worker.evaluate`] you should wait on its activation. + +There are more iodiomatic methods of waiting for a Service Worker to be activated, but the following is an implementation agnostic method: + +```js tab=js-ts +await page.evaluate(async () => { + const registration = await window.navigator.serviceWorker.getRegistration(); + if (registration.active?.state === 'activated') + return; + await new Promise(res => window.navigator.serviceWorker.addEventListener('controllerchange', res)); +}); +``` + +```js tab=js-js +await page.evaluate(async () => { + const registration = await window.navigator.serviceWorker.getRegistration(); + if (registration.active?.state === 'activated') + return; + await new Promise(res => window.navigator.serviceWorker.addEventListener('controllerchange', res)); +}); +``` + +```python async +await page.evaluate("""async () => { + const registration = await window.navigator.serviceWorker.getRegistration(); + if (registration.active?.state === 'activated') + return; + await new Promise(res => window.navigator.serviceWorker.addEventListener('controllerchange', res)); +}""") +``` + +```python sync +page.evaluate("""async () => { + const registration = await window.navigator.serviceWorker.getRegistration(); + if (registration.active?.state === 'activated') + return; + await new Promise(res => window.navigator.serviceWorker.addEventListener('controllerchange', res)); +}""") +``` + +```csharp +await page.EvaluateAsync(@"async () => { + const registration = await window.navigator.serviceWorker.getRegistration(); + if (registration.active?.state === 'activated') + return; + await new Promise(res => window.navigator.serviceWorker.addEventListener('controllerchange', res)); +}"); +``` + +```java +page.evaluate( + "async () => {" + " const registration = await window.navigator.serviceWorker.getRegistration();" + + " if (registration.active?.state === 'activated')" + + " return;" + + " await new Promise(res => window.navigator.serviceWorker.addEventListener('controllerchange', res));" + + "}" +) +``` + +### Network Events and Routing + +Any network request made by the **Service Worker** will have: + +* [`event: BrowserContext.request`] and its correponding events ([`event: BrowserContext.requestFinished`] and [`event: BrowserContext.response`], or [`event: BrowserContext.requestFailed`]) +* [`method: BrowserContext.route`] will see the request +* [`method: Request.serviceWorker`] will be set to the Service [Worker] instance, and [`method: Request.frame`] will **throw** +* [`method: Response.fromServiceWorker`] will return `false` + +Additionally, any network request made by the **Page** (including its sub-[Frame]s) will have: + +* [`event: BrowserContext.request`] and its correponding events ([`event: BrowserContext.requestFinished`] and [`event: BrowserContext.response`], or [`event: BrowserContext.requestFailed`]) +* [`event: Page.request`] and its correponding events ([`event: Page.requestFinished`] and [`event: Page.response`], or [`event: Page.requestFailed`]) +* [`method: Page.route`] and [`method: Page.route`] will **not** see the request (if a Service Worker's fetch handler was registered) +* [`method: Request.serviceWorker`] will be set to `null`, and [`method: Request.frame`] will return the [Frame] +* [`method: Response.fromServiceWorker`] will return `true` (if a Service Worker's fetch handler was registered) + +Many Service Worker implementations simply execute the request from the page (possibly with some custom caching/offline logic omitted for simplicity): + +```js +// filename: transparent-service-worker.js +self.addEventListener("fetch", (event) => { + // actually make the request + const responsePromise = fetch(event.request); + // send it back to the page + event.respondWith(responsePromise); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(clients.claim()); +}); +``` + +If a page registers the above Service Worker: + +```html + + +``` + +On the first visit to the page via [`method: Page.goto`], the following Request/Response events would be emitted (along with the corresponding network lifecycle events): + +| Event | Owner | URL | Routed | [`method: Response.fromServiceWorker`] | +| - | - | - | - | - | +| [`event: BrowserContext.request`] | [Frame] | index.html | Yes | | +| [`event: Page.request`] | [Frame] | index.html | Yes | | +| [`event: BrowserContext.request`] | Service [Worker] | transparent-service-worker.js | Yes | | +| [`event: BrowserContext.request`] | Service [Worker] | data.json | Yes | | +| [`event: BrowserContext.request`] | [Frame] | data.json | | Yes | +| [`event: Page.request`] | [Frame] | data.json | | Yes | + + + +Since the example Service Worker just acts a basic transparent "proxy": + +* There's 2 [`event: BrowserContext.request`] events for `data.json`; one [Frame]-owned, the other Service [Worker]-owned. +* Only the Service [Worker]-owned request for the resource was routable via [`method: BrowserContext.route`]; the [Frame]-owned events for `data.json` are not routeable, as they would not have even had the possibility to hit the external network since the Service Worker has a fetch handler registered. + +:::caution +It's important to note: calling [`method: Request.frame`] or [`method: Response.frame`] will **throw** an exception, if called on a [Request]/[Response] that has a non-null [`method: Request.serviceWorker`]. +::: + + +#### Advanced Example + +When a Service Worker handles a page's request, the Service Worker can make 0 to n requests to the external network. The Service Worker might respond directly from a cache, generate a reponse in memory, rewrite the request, make two requests and then combine into 1, etc. + +Consider the code snippets below to understand Playwright's view into the Request/Responses and how it impacts routing in some of these cases. + + +```js +// filename: complex-service-worker.js +self.addEventListener("install", function (event) { + event.waitUntil( + caches.open("v1").then(function (cache) { + // 1. Pre-fetches and caches /addressbook.json + return cache.add("/addressbook.json"); + }) + ); +}); + +// Opt to handle FetchEvent's from the page +self.addEventListener("fetch", (event) => { + event.respondWith( + (async () => { + // 1. Try to first serve directly from caches + let response = await caches.match(event.request); + if (response) return response; + + // 2. Re-write request for /foo to /bar + if (event.request.url.endsWith("foo")) return fetch("./bar"); + + // 3. Prevent tracker.js from being retrieved, and returns a placeholder response + if (event.request.url.endsWith("tracker.js")) + return new Response('conosole.log("no trackers!")', { + status: 200, + headers: { "Content-Type": "text/javascript" }, + }); + + // 4. Otherwise, fallthrough, perform the fetch and respond + return fetch(event.request); + })() + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(clients.claim()); +}); +``` + +And a page that simply registers the Service Worker: + +```html + + +``` + +On the first visit to the page via [`method: Page.goto`], the following Request/Response events would be emitted: + +| Event | Owner | URL | Routed | [`method: Response.fromServiceWorker`] | +| - | - | - | - | - | +| [`event: BrowserContext.request`] | [Frame] | index.html | Yes | | +| [`event: Page.request`] | [Frame] | index.html | Yes | | +| [`event: BrowserContext.request`] | Service [Worker] | complex-service-worker.js | Yes | | +| [`event: BrowserContext.request`] | Service [Worker] | addressbook.json | Yes | | + +It's important to note that [`cache.add`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/add) caused the Service Worker to make a request (Service [Worker]-owned), even before `addressbook.json` was asked for in the page. + +Once the Service Worker is activated and handling FetchEvents, if the page makes the following requests: + +```js tab=js-ts +await page.evaluate(() => fetch('/addressbook.json')); +await page.evaluate(() => fetch('/foo')); +await page.evaluate(() => fetch('/tracker.js')); +await page.evaluate(() => fetch('/fallthrough.txt')); +``` + +```js tab=js-js +await page.evaluate(() => fetch('/addressbook.json')); +await page.evaluate(() => fetch('/foo')); +await page.evaluate(() => fetch('/tracker.js')); +await page.evaluate(() => fetch('/fallthrough.txt')); +``` + +```python async +await page.evaluate("fetch('/addressbook.json')") +await page.evaluate("fetch('/foo')") +await page.evaluate("fetch('/tracker.js')") +await page.evaluate("fetch('/fallthrough.txt')") +``` + +```python sync +page.evaluate("fetch('/addressbook.json')") +page.evaluate("fetch('/foo')") +page.evaluate("fetch('/tracker.js')") +page.evaluate("fetch('/fallthrough.txt')") +``` + +```csharp +await page.EvaluateAsync("fetch('/addressbook.json')"); +await page.EvaluateAsync("fetch('/foo')"); +await page.EvaluateAsync("fetch('/tracker.js')"); +await page.EvaluateAsync("fetch('/fallthrough.txt')"); +``` + +```java +page.evaluate("fetch('/addressbook.json')") +page.evaluate("fetch('/foo')") +page.evaluate("fetch('/tracker.js')") +page.evaluate("fetch('/fallthrough.txt')") +``` + +The following Request/Response events would be emitted: + +| Event | Owner | URL | Routed | [`method: Response.fromServiceWorker`] | +| - | - | - | - | - | +| [`event: BrowserContext.request`] | [Frame] | addressbook.json | | Yes | +| [`event: Page.request`] | [Frame] | addressbook.json | | Yes | +| [`event: BrowserContext.request`] | Service [Worker] | bar | Yes | | +| [`event: BrowserContext.request`] | [Frame] | foo | | Yes | +| [`event: Page.request`] | [Frame] | foo | | Yes | +| [`event: BrowserContext.request`] | [Frame] | tracker.js | | Yes | +| [`event: Page.request`] | [Frame] | tracker.js | | Yes | +| [`event: BrowserContext.request`] | Service [Worker] | fallthrough.txt | Yes | | +| [`event: BrowserContext.request`] | [Frame] | fallthrough.txt | | Yes | +| [`event: Page.request`] | [Frame] | fallthrough.txt | | Yes | + +It's important to note: + +* The page requested `/foo`, but the Service Worker requested `/bar`, so there are only [Frame]-owned events for `/foo`, but not `/bar`. +* Likewise, the Service Worker never hit the network for `tracker.js`, so ony [Frame]-owned events were emitted for that request. + +## Routing Service Worker Requests Only + +```js tab=js-ts +await context.route('**', async route => { + if (route.request().serviceWorker()) { + // NB: calling route.request().frame() here would THROW + return route.fulfill({ + contentType: 'text/plain', + status: 200, + body: 'from sw', + }); + } else { + return route.continue(); + } +}); +``` + +```js tab=js-js +await context.route('**', async route => { + if (route.request().serviceWorker()) { + // NB: calling route.request().frame() here would THROW + return route.fulfill({ + contentType: 'text/plain', + status: 200, + body: 'from sw', + }); + } else { + return route.continue(); + } +}); +``` + +```python async +async def handle(route: Route): + if route.request.service_worker: + # NB: calling route.request.frame here would THROW + await route.fulfill(content_type='text/plain', status=200, body='from sw'); + else: + await route.continue_() +await context.route('**', handle) +``` + +```python sync +def handle(route: Route): + if route.request.service_worker: + # NB: calling route.request.frame here would THROW + route.fulfill(content_type='text/plain', status=200, body='from sw'); + else: + route.continue_() +context.route('**', handle) +``` + +```csharp +await context.RouteAsync("**", async route => { + if (route.request().serviceWorker() != null) { + // NB: calling route.request.frame here would THROW + await route.FulfillAsync( + contentType: "text/plain", + status: 200, + body: "from sw" + ); + } else { + await route.Continue()Async(); + } +}); +``` + +```java +browserContext.route("**", route -> { + if (route.request()) { + // calling route.request().frame() here would THROW + route.fulfill(new Route.FulfillOptions() + .setStatus(200) + .setContentType("text/plain") + .setBody("from sw")); + } else { + route.resume(); + } +}); +``` + +## Known Limitations + +Requests for updated Service Worker main script code currently cannot be routed (https://github.com/microsoft/playwright/issues/14711). + diff --git a/tests/library/chromium/chromium.spec.ts b/tests/library/chromium/chromium.spec.ts index 27d0984bf4..929addf78d 100644 --- a/tests/library/chromium/chromium.spec.ts +++ b/tests/library/chromium/chromium.spec.ts @@ -233,6 +233,172 @@ test('should intercept only serviceworker request, not page', async ({ context, expect(response).toBe('from sw'); }); +test('should produce network events, routing, and annotations for Service Worker', async ({ page, context, server }) => { + server.setRoute('/index.html', (req, res) => { + res.write(` + + `); + res.end(); + }); + server.setRoute('/transparent-service-worker.js', (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/javascript' }); + res.write(` + self.addEventListener("fetch", (event) => { + // actually make the request + const responsePromise = fetch(event.request); + // send it back to the page + event.respondWith(responsePromise); + }); + + self.addEventListener("activate", (event) => { + event.waitUntil(clients.claim()); + }); + `); + res.end(); + }); + + const routed = []; + const formatRequest = async ([scope, r]: ['page' | 'context', any]) => `| ${(scope === 'page' ? '[`event: Page.request`]' : '[`event: BrowserContext.request`]').padEnd('[`event: BrowserContext.request`]'.length, ' ')} | ${r.serviceWorker() ? 'Service [Worker]' : '[Frame]'.padEnd('Service [Worker]'.length, ' ')} | ${r.url().split('/').pop().padEnd(30, ' ')} | ${(routed.includes(r) ? 'Yes' : '').padEnd('Routed'.length, ' ')} | ${((await r.response()).fromServiceWorker() ? 'Yes' : '').padEnd('[`method: Response.fromServiceWorker`]'.length, ' ')} |`; + await context.route('**', async route => { + routed.push(route.request()); + await route.continue(); + }); + await page.route('**', async route => { + routed.push(route.request()); + await route.continue(); + }); + const requests = []; + page.on('request', r => requests.push(['page', r])); + context.on('request', r => requests.push(['context', r])); + + const [ sw ] = await Promise.all([ + context.waitForEvent('serviceworker'), + page.goto(server.PREFIX + '/index.html'), + ]); + + await expect.poll(() => sw.evaluate(() => (self as any).registration.active?.state)).toBe('activated'); + + await page.evaluate(() => fetch('/data.json')); + + expect([ + '| Event | Owner | URL | Routed | [`method: Response.fromServiceWorker`] |', + ...await Promise.all(requests.map(formatRequest))]) + .toEqual([ + '| Event | Owner | URL | Routed | [`method: Response.fromServiceWorker`] |', + '| [`event: BrowserContext.request`] | [Frame] | index.html | Yes | |', + '| [`event: Page.request`] | [Frame] | index.html | Yes | |', + '| [`event: BrowserContext.request`] | Service [Worker] | transparent-service-worker.js | Yes | |', + '| [`event: BrowserContext.request`] | Service [Worker] | data.json | Yes | |', + '| [`event: BrowserContext.request`] | [Frame] | data.json | | Yes |', + '| [`event: Page.request`] | [Frame] | data.json | | Yes |', + ]); +}); + +test('should produce network events, routing, and annotations for Service Worker (advanced)', async ({ page, context, server }) => { + server.setRoute('/index.html', (req, res) => { + res.write(` + + `); + res.end(); + }); + server.setRoute('/complex-service-worker.js', (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/javascript' }); + res.write(` + self.addEventListener("install", function (event) { + event.waitUntil( + caches.open("v1").then(function (cache) { + // 1. Pre-fetches and caches /addressbook.json + return cache.add("/addressbook.json"); + }) + ); + }); + + // Opt to handle FetchEvent's from the page + self.addEventListener("fetch", (event) => { + event.respondWith( + (async () => { + // 1. Try to first serve directly from caches + let response = await caches.match(event.request); + if (response) return response; + + // 2. Re-write request for /foo to /bar + if (event.request.url.endsWith("foo")) return fetch("./bar"); + + // 3. Prevent tracker.js from being retrieved, and returns a placeholder response + if (event.request.url.endsWith("tracker.js")) + return new Response('conosole.log("no trackers!")', { + status: 200, + headers: { "Content-Type": "text/javascript" }, + }); + + // 4. Otherwise, fallthrough, perform the fetch and respond + return fetch(event.request); + })() + ); + }); + + self.addEventListener("activate", (event) => { + event.waitUntil(clients.claim()); + }); + `); + res.end(); + }); + server.setRoute('/addressbook.json', (req, res) => { + res.write('{}'); + res.end(); + }); + + const routed = []; + const formatRequest = async ([scope, r]: ['page' | 'context', any]) => `| ${(scope === 'page' ? '[`event: Page.request`]' : '[`event: BrowserContext.request`]').padEnd('[`event: BrowserContext.request`]'.length, ' ')} | ${r.serviceWorker() ? 'Service [Worker]' : '[Frame]'.padEnd('Service [Worker]'.length, ' ')} | ${r.url().split('/').pop().padEnd(30, ' ')} | ${(routed.includes(r) ? 'Yes' : '').padEnd('Routed'.length, ' ')} | ${((await r.response()).fromServiceWorker() ? 'Yes' : '').padEnd('[`method: Response.fromServiceWorker`]'.length, ' ')} |`; + await context.route('**', async route => { + routed.push(route.request()); + await route.continue(); + }); + await page.route('**', async route => { + routed.push(route.request()); + await route.continue(); + }); + const requests = []; + page.on('request', r => requests.push(['page', r])); + context.on('request', r => requests.push(['context', r])); + + const [ sw ] = await Promise.all([ + context.waitForEvent('serviceworker'), + page.goto(server.PREFIX + '/index.html'), + ]); + + await expect.poll(() => sw.evaluate(() => (self as any).registration.active?.state)).toBe('activated'); + + await page.evaluate(() => fetch('/addressbook.json')); + await page.evaluate(() => fetch('/foo')); + await page.evaluate(() => fetch('/tracker.js')); + await page.evaluate(() => fetch('/fallthrough.txt')); + + expect([ + '| Event | Owner | URL | Routed | [`method: Response.fromServiceWorker`] |', + ...await Promise.all(requests.map(formatRequest))]) + .toEqual([ + '| Event | Owner | URL | Routed | [`method: Response.fromServiceWorker`] |', + '| [`event: BrowserContext.request`] | [Frame] | index.html | Yes | |', + '| [`event: Page.request`] | [Frame] | index.html | Yes | |', + '| [`event: BrowserContext.request`] | Service [Worker] | complex-service-worker.js | Yes | |', + '| [`event: BrowserContext.request`] | Service [Worker] | addressbook.json | Yes | |', + '| [`event: BrowserContext.request`] | [Frame] | addressbook.json | | Yes |', + '| [`event: Page.request`] | [Frame] | addressbook.json | | Yes |', + '| [`event: BrowserContext.request`] | Service [Worker] | bar | Yes | |', + '| [`event: BrowserContext.request`] | [Frame] | foo | | Yes |', + '| [`event: Page.request`] | [Frame] | foo | | Yes |', + '| [`event: BrowserContext.request`] | [Frame] | tracker.js | | Yes |', + '| [`event: Page.request`] | [Frame] | tracker.js | | Yes |', + '| [`event: BrowserContext.request`] | Service [Worker] | fallthrough.txt | Yes | |', + '| [`event: BrowserContext.request`] | [Frame] | fallthrough.txt | | Yes |', + '| [`event: Page.request`] | [Frame] | fallthrough.txt | | Yes |' ]); +}); + test('should emit new service worker on update', async ({ context, page, server }) => { let version = 0; server.setRoute('/worker.js', (req, res) => {