├── .editorconfig
├── .eslintrc.js
├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── README.md
├── package-lock.json
├── package.json
├── src
└── index.js
└── test
├── index.test.js
├── module.js
└── worker.js
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | end_of_line = lf
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 |
10 | [{*.json,.*rc,*.yml}]
11 | indent_style = space
12 | indent_size = 2
13 | insert_final_newline = false
14 |
15 | [*.md]
16 | trim_trailing_whitespace = false
17 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line
2 | module.exports = {
3 | env: {
4 | node: true,
5 | browser: true,
6 | es2021: true,
7 | },
8 | extends: ['eslint-config-developit', 'prettier', 'eslint:recommended'],
9 | ignorePatterns: ['**/dist/**'],
10 | };
11 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - '**'
7 | push:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | build:
13 | runs-on: ubuntu-latest
14 |
15 | strategy:
16 | matrix:
17 | node-version: [14.x, 16.x]
18 |
19 | steps:
20 | - uses: actions/checkout@v1
21 | - name: Use Node.js ${{ matrix.node-version }}
22 | uses: actions/setup-node@v1
23 | with:
24 | node-version: ${{ matrix.node-version }}
25 | - name: Cache node modules
26 | uses: actions/cache@v1
27 | env:
28 | cache-name: cache-node-modules
29 | with:
30 | path: ~/.npm
31 | # This uses the same name as the build-action so we can share the caches.
32 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
33 | restore-keys: |
34 | ${{ runner.os }}-build-${{ env.cache-name }}-
35 | ${{ runner.os }}-build-
36 | ${{ runner.os }}-
37 | - run: npm ci --ignore-scripts
38 | - name: npm build and test
39 | run: npm test
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .DS_Store
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | # jsdom-worker
6 |
7 | > _Lets you use Web Workers in Jest!_
8 |
9 | This is an experimental implementation of the Web Worker API (specifically Dedicated Worker) for JSDOM.
10 |
11 | It does not currently do any real threading, rather it implements the `Worker` interface but all work is done in the current thread. `jsdom-worker` runs wherever JSDOM runs, and does not require Node.
12 |
13 | It supports both "inline" _(created via Blob)_ and standard _(loaded via URL)_ workers.
14 |
15 | > **Hot Take:** this module likely works in the browser, where it could act as a simple inline worker "poorlyfill".
16 |
17 |
18 |
19 | ## Why?
20 |
21 | Jest uses a JSDOM environment by default, which means it doesn't support Workers. This means it is impossible to test code that requires both NodeJS functionality _and_ Web Workers. `jsdom-worker` implements enough of the Worker spec that it is now possible to do so.
22 |
23 | ## Installation
24 |
25 | `npm i jsdom-worker`
26 |
27 | ## Example
28 |
29 | ```js
30 | import 'jsdom-global/register';
31 | import 'jsdom-worker';
32 |
33 | let code = `onmessage = e => postMessage(e.data*2)`;
34 | let worker = new Worker(URL.createObjectURL(new Blob([code])));
35 | worker.onmessage = console.log;
36 | worker.postMessage(5); // 10
37 | ```
38 |
39 | ## Usage with Jest
40 |
41 | For single tests, simply add `import 'jsdom-worker'` to your module.
42 |
43 | Otherwise, add it via the [setupFiles](https://facebook.github.io/jest/docs/en/configuration.html#setupfiles-array) Jest config option:
44 |
45 | ```js
46 | {
47 | "setupFiles": [
48 | "jsdom-worker"
49 | ]
50 | }
51 | ```
52 |
53 | ## License
54 |
55 | [MIT License](https://oss.ninja/mit/developit)
56 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jsdom-worker",
3 | "version": "0.3.0",
4 | "description": "Experimental Web Worker API implementation for JSDOM.",
5 | "main": "./dist/jsdom-worker.js",
6 | "module": "./dist/jsdom-worker.mjs",
7 | "unpkg": "./dist/jsdom-worker.umd.js",
8 | "exports": {
9 | "import": "./dist/jsdom-worker.mjs",
10 | "default": "./dist/jsdom-worker.js"
11 | },
12 | "scripts": {
13 | "build": "microbundle --external all -f esm,cjs,umd",
14 | "test": "eslint '{src,test}/**/*.js' && npm run -s build && jest",
15 | "prepare": "npm run -s build && npm t",
16 | "release": "npm t && git commit -am \"$npm_package_version\" && git tag $npm_package_version && git push && git push --tags && npm publish"
17 | },
18 | "repository": "developit/jsdom-worker",
19 | "babel": {
20 | "presets": [
21 | [
22 | "@babel/preset-env",
23 | {
24 | "targets": {
25 | "node": "12"
26 | }
27 | }
28 | ]
29 | ]
30 | },
31 | "jest": {
32 | "testEnvironment": "jsdom"
33 | },
34 | "keywords": [
35 | "jsdom",
36 | "web worker"
37 | ],
38 | "author": "Jason Miller (http://jasonformat.com)",
39 | "license": "MIT",
40 | "files": [
41 | "dist"
42 | ],
43 | "prettier": {
44 | "useTabs": true,
45 | "arrowParens": "avoid",
46 | "singleQuote": true
47 | },
48 | "lint-staged": {
49 | "**/*.{js,jsx,ts,tsx,yml}": [
50 | "prettier --write"
51 | ]
52 | },
53 | "husky": {
54 | "hooks": {
55 | "pre-commit": "lint-staged"
56 | }
57 | },
58 | "devDependencies": {
59 | "@babel/preset-env": "^7.10.2",
60 | "@types/jest": "^28.1.8",
61 | "babel-jest": "^29.0.1",
62 | "eslint": "^7.2.0",
63 | "eslint-config-developit": "^1.1.1",
64 | "eslint-config-prettier": "^8.5.0",
65 | "husky": "^8.0.1",
66 | "jest": "^29.0.1",
67 | "jest-environment-jsdom": "^29.0.1",
68 | "lint-staged": "^13.0.3",
69 | "microbundle": "^0.15.1",
70 | "node-fetch": "^2.6.7",
71 | "prettier": "^2.7.1"
72 | },
73 | "peerDependencies": {
74 | "node-fetch": "*"
75 | },
76 | "dependencies": {
77 | "mitt": "^3.0.0",
78 | "uuid-v4": "^0.1.0"
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import mitt from 'mitt';
2 | import uuid from 'uuid-v4';
3 | import fetch, { Response } from 'node-fetch';
4 |
5 | // eslint-disable-next-line no-undef
6 |
7 | const self = /** @type {globalThis} */ (
8 | typeof global === 'object'
9 | ? global
10 | : typeof globalThis === 'object'
11 | ? globalThis
12 | : this
13 | );
14 |
15 | // @ts-ignore-next-line
16 | if (!self.URL) self.URL = {};
17 |
18 | // @ts-ignore
19 | let objects = /** @type {Map} */ (self.URL.$$objects);
20 |
21 | if (!objects) {
22 | objects = new Map();
23 | // @ts-ignore
24 | self.URL.$$objects = objects;
25 | self.URL.createObjectURL = blob => {
26 | let id = uuid();
27 | objects[id] = blob;
28 | return `blob:http://localhost/${id}`;
29 | };
30 | self.URL.revokeObjectURL = (url) => {
31 | let m = String(url).match(/^blob:http:\/\/localhost\/(.+)$/);
32 | if (m) delete objects[m[1]];
33 | };
34 | }
35 |
36 | if (!self.fetch || !('jsdomWorker' in self.fetch)) {
37 | let oldFetch = self.fetch || fetch;
38 | self.fetch = function (url, opts) {
39 | let _url = typeof url === 'object' ? url.url || url.href : url;
40 | if (_url.match(/^blob:/)) {
41 | return new Promise((resolve, reject) => {
42 | let fr = new FileReader();
43 | fr.onload = () => {
44 | let Res = self.Response || Response;
45 | resolve(new Res(fr.result, { status: 200, statusText: 'OK' }));
46 | };
47 | fr.onerror = () => {
48 | reject(fr.error);
49 | };
50 | let id = _url.match(/[^/]+$/)[0];
51 | fr.readAsText(objects[id]);
52 | });
53 | }
54 | return oldFetch.call(this, url, opts);
55 | };
56 | Object.defineProperty(self.fetch, 'jsdomWorker', {
57 | configurable: true,
58 | value: true,
59 | });
60 | }
61 |
62 | // @ts-ignore
63 | if (!self.document) self.document = {};
64 |
65 | function Event(type) {
66 | this.type = type;
67 | }
68 | Event.prototype.initEvent = Object;
69 | if (!self.document.createEvent) {
70 | self.document.createEvent = function (type) {
71 | let Ctor = global[type] || Event;
72 | return new Ctor(type);
73 | };
74 | }
75 |
76 | // @ts-ignore
77 | self.Worker = Worker;
78 |
79 | /**
80 | * @param {string | URL} url
81 | * @param {object} [options = {}]
82 | */
83 | function Worker(url, options) {
84 | let getScopeVar;
85 | /** @type {any[] | null} */
86 | let messageQueue = [];
87 | let inside = mitt();
88 | let outside = mitt();
89 | let terminated = false;
90 | let scope = {
91 | onmessage: null,
92 | dispatchEvent: inside.emit,
93 | addEventListener: inside.on,
94 | removeEventListener: inside.off,
95 | postMessage(data) {
96 | outside.emit('message', { data });
97 | },
98 | fetch: self.fetch,
99 | importScripts() {},
100 | };
101 | inside.on('message', e => {
102 | if (terminated) return;
103 | let f = scope.onmessage || getScopeVar('onmessage');
104 | if (f) f.call(scope, e);
105 | });
106 | this.addEventListener = outside.on;
107 | this.removeEventListener = outside.off;
108 | this.dispatchEvent = outside.emit;
109 | outside.on('message', e => {
110 | if (this.onmessage) this.onmessage(e);
111 | });
112 | this.onmessage = null;
113 | this.postMessage = data => {
114 | if (terminated) return;
115 | if (messageQueue != null) messageQueue.push(data);
116 | else inside.emit('message', { data });
117 | };
118 | this.terminate = () => {
119 | console.warn('Worker.prototype.terminate() not supported in jsdom-worker.');
120 | terminated = true;
121 | messageQueue = null;
122 | };
123 | self
124 | .fetch(url)
125 | .then(r => r.text())
126 | .then(code => {
127 | let vars = 'var self=this,global=self';
128 | for (let k in scope) vars += `,${k}=self.${k}`;
129 | getScopeVar = Function(
130 | vars +
131 | ';\n' +
132 | code +
133 | '\nreturn function(n){return n=="onmessage"?onmessage:null;}'
134 | ).call(scope);
135 | let q = messageQueue;
136 | messageQueue = null;
137 | if (q) q.forEach(this.postMessage);
138 | })
139 | .catch(e => {
140 | outside.emit('error', e);
141 | console.error(e);
142 | });
143 | }
144 |
--------------------------------------------------------------------------------
/test/index.test.js:
--------------------------------------------------------------------------------
1 | import 'jsdom-worker';
2 |
3 | import fs from 'fs';
4 | import path from 'path';
5 |
6 | const sleep = t =>
7 | new Promise(r => {
8 | setTimeout(r, t);
9 | });
10 |
11 | describe('jsdom-worker', () => {
12 | it('should work', async () => {
13 | let code = `onmessage = e => { postMessage(e.data*2) }`;
14 | let worker = new Worker(URL.createObjectURL(new Blob([code])));
15 | worker.onmessage = jest.fn();
16 | worker.postMessage(5);
17 | await sleep(10);
18 | expect(worker.onmessage).toHaveBeenCalledWith({ data: 10 });
19 | });
20 |
21 | it('should work with importScripts', async () => {
22 | const mod = fs.readFileSync(path.join(__dirname, './module.js'), 'utf-8');
23 | const code = fs.readFileSync(path.join(__dirname, './worker.js'), 'utf-8');
24 | const worker = new Worker(URL.createObjectURL(new Blob([mod + code])));
25 | worker.onmessage = jest.fn();
26 | worker.postMessage(0);
27 | await sleep(10);
28 | expect(worker.onmessage).toHaveBeenCalledWith({ data: 'test' });
29 | });
30 |
31 | it('should work with IIFE', async () => {
32 | const n = Math.random();
33 | const code = `(function(n){ onmessage = e => { postMessage(n) } })(${n})`;
34 | const worker = new Worker(URL.createObjectURL(new Blob([code])));
35 | worker.onmessage = jest.fn();
36 | worker.postMessage({ hi: 'bye' });
37 | await sleep(10);
38 | expect(worker.onmessage).toHaveBeenCalledWith({ data: n });
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/test/module.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | const importedModule = {
3 | string: 'test',
4 | };
5 |
--------------------------------------------------------------------------------
/test/worker.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | importScripts('module.js');
3 |
4 | onmessage = () => {
5 | postMessage(importedModule.string);
6 | };
7 |
--------------------------------------------------------------------------------