Skip to content

Commit

Permalink
Experimental service worker support (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
PaperStrike authored Sep 19, 2021
1 parent 45e563c commit d165b5d
Show file tree
Hide file tree
Showing 8 changed files with 441 additions and 10 deletions.
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
56 changes: 48 additions & 8 deletions README.zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`](<https://github.com/node-fetch/node-fetch>)[`whatwg-fetch`](<https://github.com/github/fetch>)[`cross-fetch`](<https://github.com/lquixada/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
Expand Down Expand Up @@ -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` 配置。
Expand Down
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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 .",
Expand Down
99 changes: 99 additions & 0 deletions src/lib/sw/Client.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>): Promise<Response> => {
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<Response>((resolve) => {
this.fetchResolveList.push(resolve);
});
};

onMessage = (event: MessageEvent<RequestMessage | ResponseMessage>): void => {
if (event.data && 'request' in event.data) {
// eslint-disable-next-line no-void
void this.onRequestMessage(event as MessageEvent<RequestMessage>);
}
if (event.data && 'response' in event.data) {
// eslint-disable-next-line no-void
void this.onResponseMessage(event as MessageEvent<ResponseMessage>);
}
};

/**
* For messaged requests, send to the receiver.
*/
onRequestMessage = async (
event: MessageEvent<RequestMessage>,
): Promise<void> => {
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<ResponseMessage>,
): Promise<void> => {
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;
}
}
13 changes: 13 additions & 0 deletions src/lib/sw/Message.ts
Original file line number Diff line number Diff line change
@@ -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;
}
104 changes: 104 additions & 0 deletions src/lib/sw/Worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/// <reference lib="WebWorker" />
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<Response>((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<ExtendableMessageEvent, 'data'> & { data: RequestMessage },
): Promise<void> => {
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<ExtendableMessageEvent, 'data'> & { data: ResponseMessage },
): Promise<void> => {
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;
}
}
Loading

0 comments on commit d165b5d

Please sign in to comment.