From d165b5d4dcbcedf65ae0e9d393d1013e512ecee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=8E?= <1395348685z@gmail.com> Date: Mon, 20 Sep 2021 00:35:53 +0800 Subject: [PATCH] Experimental service worker support (#2) --- README.md | 42 ++++++++++++++- README.zh-Hans.md | 56 +++++++++++++++++--- package.json | 10 +++- src/lib/sw/Client.ts | 99 ++++++++++++++++++++++++++++++++++++ src/lib/sw/Message.ts | 13 +++++ src/lib/sw/Worker.ts | 104 ++++++++++++++++++++++++++++++++++++++ src/lib/sw/toCloneable.ts | 95 ++++++++++++++++++++++++++++++++++ src/sw.ts | 32 ++++++++++++ 8 files changed, 441 insertions(+), 10 deletions(-) create mode 100644 src/lib/sw/Client.ts create mode 100644 src/lib/sw/Message.ts create mode 100644 src/lib/sw/Worker.ts create mode 100644 src/lib/sw/toCloneable.ts create mode 100644 src/sw.ts diff --git a/README.md b/README.md index b34b158..f5c2e5f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ [![Build Status](https://github.com/PaperStrike/onfetch/actions/workflows/test.yml/badge.svg)](https://github.com/PaperStrike/onfetch/actions/workflows/test.yml) [![npm Package](https://img.shields.io/npm/v/onfetch?logo=npm "onfetch")](https://www.npmjs.com/package/onfetch) -Mock [`fetch()`][mdn-fetch-func] with native [`Request`][mdn-request-api] / [`Response`][mdn-response-api] API. +Mock [`fetch()`][mdn-fetch-func] with native [`Request`][mdn-request-api] / [`Response`][mdn-response-api] API. Optionally, mock All with [Service Worker](#service-worker). Works with [`node-fetch`](https://github.com/node-fetch/node-fetch), [`whatwg-fetch`](https://github.com/github/fetch), [`cross-fetch`](https://github.com/lquixada/cross-fetch), whatever, and mainly, modern browsers. @@ -25,6 +25,7 @@ Works with [`node-fetch`](https://github.com/node-fetch/node-fetch), [`whatwg-fe [Delay](#delay), [Redirect](#redirect), [Times](#times), +[Service Worker](#service-worker), [Options](#options), [Q&A][q-a], or @@ -282,6 +283,45 @@ Sugar for `rule.times(3)`. For `rule.times(Infinity)`. +## Service Worker +[mdn-service-worker-api]: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API +[mdn-xml-http-request-api]: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest + +[Service Worker API][mdn-service-worker-api] only works in browsers. + +With the help of [Service Worker API][mdn-service-worker-api], you can now mock **all** the resources your page requires, including those don't go with [XMLHttpRequest][mdn-xml-http-request-api] nor [`fetch`][mdn-fetch-func] (e.g., CSS files). + +```js +// In the main browsing context. +import onfetch from 'onfetch/sw'; + +onfetch('/script.js').reply('console.log(\'mocked!\')'); +const script = document.createElement('script'); +script.src = '/script.js'; + +// Logs 'mocked!' +document.head.append(script); +``` + +To enable this feature, import `onfetch/sw` in you service worker. + +```js +// In the service worker. +import 'onfetch/sw'; +``` + +Optionally, store a reference of `worker` to disable it at some point. + +```js +import { worker as onfetchWorker } from 'onfetch/sw'; + +self.addEventListener('message', ({ data }) => { + if (data && 'example' in data) onfetchWorker.deactivate(); +}); +``` + +You may have noticed that we use `onfetch/sw` both in the page and in the worker. Yes, `onfetch/sw` detects the context itself and runs different necessary code sets. + ## Options Configurable via `onfetch.config`. diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 8495581..dd4c4cd 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -14,21 +14,22 @@ [![CI 状态](https://github.com/PaperStrike/onfetch/actions/workflows/test.yml/badge.svg)](https://github.com/PaperStrike/onfetch/actions/workflows/test.yml) [![npm 包](https://img.shields.io/npm/v/onfetch?logo=npm "onfetch")](https://www.npmjs.com/package/onfetch) -配合原生 [`Request`][mdn-request-api] / [`Response`][mdn-response-api] API 模拟 [`fetch()`][mdn-fetch-func] 请求响应。 +配合原生 [`Request`][mdn-request-api] / [`Response`][mdn-response-api] API 模拟 [`fetch()`][mdn-fetch-func] 请求响应。可选地,配合 [Service Worker](#service-worker) 模拟**所有**请求响应。 支持主流现代浏览器,兼容 [`node-fetch`]()、[`whatwg-fetch`]()、[`cross-fetch`]() 等 Polyfill 库。 --- 🐿️ 跳转到 -[回调](#回调), -[延时](#延时), -[重定向](#重定向), -[次数](#次数), -[选项](#选项), -[Q&A][q-a], +[回调](#回调), +[延时](#延时), +[重定向](#重定向), +[次数](#次数), +[Service Worker](#service-worker), +[选项](#选项), +[Q&A][q-a], 或 -[Contributing Guide][contributing]. +[Contributing Guide][contributing]。 ## 概述 [mdn-headers-api]: https://developer.mozilla.org/en-US/docs/Web/API/Headers @@ -281,6 +282,45 @@ fetch('/foo'); // 回落到默认规则 `defaultRule` `rule.times(Infinity)`。 +## Service Worker +[mdn-service-worker-api]: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API +[mdn-xml-http-request-api]: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest + +[Service Worker API][mdn-service-worker-api] 只适用于浏览器。 + +配合 [Service Worker API][mdn-service-worker-api],你将可以拦截模拟包括 CSS 文件这类不走 [XMLHttpRequest][mdn-xml-http-request-api]、也不走 [`fetch`][mdn-fetch-func] 的,页面所发送的**所有请求资源**。 + +```js +// 在页面脚本中 +import onfetch from 'onfetch/sw'; + +onfetch('/script.js').reply('console.log(\'mocked!\')'); +const script = document.createElement('script'); +script.src = '/script.js'; + +// 输出 'mocked!' +document.head.append(script); +``` + +要使用这个特性,在你的 Service Worker 脚本中引入 `onfetch/sw`。 + +```js +// 在 service worker 中 +import 'onfetch/sw'; +``` + +可选地,储存一个对 `worker` 的引用以在某时刻暂停。 + +```js +import { worker as onfetchWorker } from 'onfetch/sw'; + +self.addEventListener('message', ({ data }) => { + if (data && 'example' in data) onfetchWorker.deactivate(); +}); +``` + +你大概已经注意到主页面和 Service Worker 中我们使用的都是 `onfetch/sw`。 没错,`onfetch/sw` 会自动检测调用环境运行不同所需代码。 + ## 选项 可通过 `onfetch.config` 配置。 diff --git a/package.json b/package.json index 4009216..086ff83 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "onfetch", "version": "0.2.1", - "description": "Mock fetch() with native Request / Response API", + "description": "Mock fetch() with native Request / Response API. Optionally, mock All with Service Worker", "types": "./build/index.d.ts", "main": "./build/index.js", "type": "module", @@ -12,9 +12,17 @@ }, "exports": { ".": "./build/index.js", + "./sw": "./build/sw.js", "./core": "./build/core.js", "./package.json": "./package.json" }, + "typesVersions": { + "*": { + ".": ["./build/index.d.ts"], + "sw": ["./build/sw.d.ts"], + "core": ["./build/core.d.ts"] + } + }, "scripts": { "build": "tsc", "lint": "eslint .", diff --git a/src/lib/sw/Client.ts b/src/lib/sw/Client.ts new file mode 100644 index 0000000..d76dbd1 --- /dev/null +++ b/src/lib/sw/Client.ts @@ -0,0 +1,99 @@ +import toCloneable from './toCloneable'; +import { RequestMessage, ResponseMessage } from './Message'; + +/** + * A fetch receiver + * - For received requests, message to service worker. + * - For messaged requests, send to the receiver. <- Intercept here. + */ +export default class Client { + workerContainer: ServiceWorkerContainer; + + constructor(workerContainer: ServiceWorkerContainer) { + this.workerContainer = workerContainer; + } + + fetchResolveList: (((res: Response) => void) | null)[] = []; + + /** + * For received requests, message to the service worker. + */ + fetch = async (...args: Parameters): Promise => { + const request = new Request(...args); + const { controller } = this.workerContainer; + if (!controller) { + throw new Error('Server not ready'); + } + controller.postMessage({ + request: await toCloneable(request), + id: this.fetchResolveList.length, + }); + return new Promise((resolve) => { + this.fetchResolveList.push(resolve); + }); + }; + + onMessage = (event: MessageEvent): void => { + if (event.data && 'request' in event.data) { + // eslint-disable-next-line no-void + void this.onRequestMessage(event as MessageEvent); + } + if (event.data && 'response' in event.data) { + // eslint-disable-next-line no-void + void this.onResponseMessage(event as MessageEvent); + } + }; + + /** + * For messaged requests, send to the receiver. + */ + onRequestMessage = async ( + event: MessageEvent, + ): Promise => { + const { data: { request, id } } = event; + const response = await this.fetch(request.url, request); + const { controller } = this.workerContainer; + if (!controller) { + throw new Error('Server not ready'); + } + controller.postMessage({ + response: await toCloneable(response), + id, + }); + }; + + /** + * For messaged responses, treat as a previous received request's response. + */ + onResponseMessage = async ( + event: MessageEvent, + ): Promise => { + const { data: { response, id } } = event; + const resolve = this.fetchResolveList[id]; + if (!resolve) return; + resolve(new Response(response.body, response)); + this.fetchResolveList[id] = null; + }; + + private addedListeners = false; + + isActive(): boolean { + return this.addedListeners; + } + + /** + * Stop receiving worker messages. + */ + deactivate(): void { + this.workerContainer.removeEventListener('message', this.onMessage); + this.addedListeners = false; + } + + /** + * Start receiving worker messages. + */ + activate(): void { + this.workerContainer.addEventListener('message', this.onMessage); + this.addedListeners = true; + } +} diff --git a/src/lib/sw/Message.ts b/src/lib/sw/Message.ts new file mode 100644 index 0000000..870c9e9 --- /dev/null +++ b/src/lib/sw/Message.ts @@ -0,0 +1,13 @@ +import { CloneableRequest, CloneableResponse } from './toCloneable'; + +export interface Message { + id: number; +} + +export interface RequestMessage extends Message { + request: CloneableRequest; +} + +export interface ResponseMessage extends Message { + response: CloneableResponse; +} diff --git a/src/lib/sw/Worker.ts b/src/lib/sw/Worker.ts new file mode 100644 index 0000000..bedf799 --- /dev/null +++ b/src/lib/sw/Worker.ts @@ -0,0 +1,104 @@ +/// +import toCloneable from './toCloneable'; +import { RequestMessage, ResponseMessage } from './Message'; + +/** + * A fetch forwarder + * - For captured requests, message back to client. + * - For messaged requests, message back the real response. + */ +export default class Worker { + scope: ServiceWorkerGlobalScope; + + constructor(scope: ServiceWorkerGlobalScope) { + this.scope = scope; + } + + onFetchResolveList: (((res: Response) => void) | null)[] = []; + + /** + * For captured requests, message back to the client. + */ + onFetch = (event: FetchEvent): void => { + const respondPromise = (async () => { + const client = await this.scope.clients.get(event.clientId); + if (!client) { + throw new Error('No client matched'); + } + client.postMessage({ + request: await toCloneable(event.request), + id: this.onFetchResolveList.length, + }); + return new Promise((resolve) => { + this.onFetchResolveList.push(resolve); + }); + })(); + event.respondWith(respondPromise); + }; + + onMessage = (event: ExtendableMessageEvent): void => { + if (event.data && 'request' in event.data) { + // eslint-disable-next-line no-void + void this.onRequestMessage(event); + } + if (event.data && 'response' in event.data) { + // eslint-disable-next-line no-void + void this.onResponseMessage(event); + } + }; + + /** + * For messaged requests, respond with real responses. + */ + onRequestMessage = async ( + event: Omit & { data: RequestMessage }, + ): Promise => { + const { source, data: { request, id } } = event; + if (!source) { + throw new Error('Request came from unknown source'); + } + source.postMessage({ + response: await toCloneable(await this.scope.fetch(request.url, request)), + id, + }); + }; + + /** + * For messaged responses, treat as a previous captured request's response. + */ + onResponseMessage = async ( + event: Omit & { data: ResponseMessage }, + ): Promise => { + const { data: { response, id } } = event; + const resolve = this.onFetchResolveList[id]; + if (!resolve) { + throw new Error('Response came for unknown request'); + } + resolve(new Response(response.body, response)); + this.onFetchResolveList[id] = null; + }; + + private addedListeners = false; + + isActive(): boolean { + return this.addedListeners; + } + + /** + * Stop capturing requests and receiving messages. + */ + deactivate(): void { + this.scope.removeEventListener('fetch', this.onFetch); + this.scope.removeEventListener('message', this.onMessage); + this.addedListeners = false; + } + + /** + * Start capturing requests and receiving messages. + */ + activate(): void { + this.scope.addEventListener('fetch', this.onFetch); + this.scope.addEventListener('message', this.onMessage); + this.addedListeners = true; + } +} diff --git a/src/lib/sw/toCloneable.ts b/src/lib/sw/toCloneable.ts new file mode 100644 index 0000000..a7d7f8e --- /dev/null +++ b/src/lib/sw/toCloneable.ts @@ -0,0 +1,95 @@ +export type CloneableRequest = { + body: ArrayBuffer | null; + cache: RequestCache; + credentials: RequestCredentials; + destination: RequestDestination; + headers: [string, string][]; + integrity: string; + keepalive: boolean; + method: string; + mode: RequestMode; + redirect: RequestRedirect; + referrer: string; + referrerPolicy: ReferrerPolicy; + url: string; +}; + +export type CloneableResponse = { + body: ArrayBuffer | null; + headers: [string, string][]; + ok: boolean; + redirected: boolean; + status: number; + statusText: string; + type: ResponseType; + url: string; +}; + +/** + * Construct a cloneable object for a given Request or Response. + * @see [The structured clone algorithm - Web APIs | MDN]{@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm} + */ +async function toCloneable(r: Request): Promise; +async function toCloneable(r: Response): Promise; +async function toCloneable( + requestOrResponse: Request | Response, +): Promise { + const arrayBuffer = await requestOrResponse.clone().arrayBuffer(); + const body = arrayBuffer.byteLength > 0 ? arrayBuffer : null; + + const headers = [...requestOrResponse.headers]; + + // To cloneable request. + if (requestOrResponse instanceof Request) { + const { + cache, + credentials, + destination, + integrity, + keepalive, + method, + mode, + redirect, + referrer, + referrerPolicy, + url, + } = requestOrResponse; + return { + body, + cache, + credentials, + destination, + headers, + integrity, + keepalive, + method, + mode, + redirect, + referrer, + referrerPolicy, + url, + }; + } + + // To cloneable response. + const { + ok, + redirected, + status, + statusText, + type, + url, + } = requestOrResponse; + return { + body, + headers, + ok, + redirected, + status, + statusText, + type, + url, + }; +} + +export default toCloneable; diff --git a/src/sw.ts b/src/sw.ts new file mode 100644 index 0000000..00b1a5f --- /dev/null +++ b/src/sw.ts @@ -0,0 +1,32 @@ +/// +import mockFetchOn from './core'; +import Client from './lib/sw/Client'; +import Worker from './lib/sw/Worker'; + +export * from './core'; +export { mockFetchOn, Client, Worker }; + +declare const globalThis: ServiceWorkerGlobalScope | typeof window; + +const isInServiceWorker = ( + typeof ServiceWorkerGlobalScope !== 'undefined' && globalThis instanceof ServiceWorkerGlobalScope +); + +export const client = isInServiceWorker ? null : new Client(window.navigator.serviceWorker); +client?.activate(); + +export const worker = isInServiceWorker ? new Worker(globalThis) : null; +worker?.activate(); + +/** + * The fetch mocker. + * Service worker can mock its fetch, too. + */ +const onfetch = mockFetchOn(client || globalThis); + +// Activate client mock by default. +if (!isInServiceWorker) { + onfetch.activate(); +} + +export default onfetch;