├── .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 | npm travis 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 | --------------------------------------------------------------------------------