diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55aa8da --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +package-lock.json +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..e2ce682 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# jsdom-worker + +This is an experimental implementation of the Web Worker API (specifically Dedicated Worker) for JSDOM. + +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. + +It supports both "inline" _(created via Blob)_ and standard _(loaded via URL)_ workers. + +> **Hot Take:** this module likely works in the browser, where it could act as a simple inline worker "poorlyfill". + +## Why? + +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. + +## Installation + +`npm i jsdom-worker` + +## Example + +```js +import 'jsdom-global/register' +import 'jsdom-worker' + +let code = `onmessage = e => postMessage(e.data*2)` +let worker = new Worker(URL.createObjectURL(new Blob([code]))) +worker.onmessage = console.log +worker.postMessage(5) // 10 +``` + +## Usage with Jest + +For single tests, simply add `import 'jsdom-worker'` to your module. + +Otherwise, add it via the [setupFiles](https://facebook.github.io/jest/docs/en/configuration.html#setupfiles-array) Jest config option: + +```js +{ + "setupFiles": [ + "jsdom-worker" + ] +} +``` + +## License + +[MIT License](https://oss.ninja/mit/developit) diff --git a/package.json b/package.json new file mode 100644 index 0000000..df6d1b8 --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "jsdom-worker", + "version": "0.1.0", + "description": "Experimental Web Worker API implementation for JSDOM.", + "main": "dist/jsdom-inline-worker.js", + "module": "dist/jsdom-inline-worker.m.js", + "scripts": { + "prepare": "microbundle --external all", + "test": "eslint src test && npm run -s prepare && jest" + }, + "babel": { + "presets": [ + "env" + ] + }, + "keywords": [ + "jsdom", + "web worker" + ], + "eslintConfig": { + "extends": "eslint-config-developit" + }, + "author": "Jason Miller (http://jasonformat.com)", + "license": "MIT", + "files": [ + "dist" + ], + "devDependencies": { + "babel-jest": "^22.1.0", + "babel-preset-env": "^1.6.1", + "eslint": "^4.16.0", + "eslint-config-developit": "^1.1.1", + "jest": "^22.1.4", + "microbundle": "^0.4.3", + "node-fetch": "^1.7.3" + }, + "peerDependencies": { + "node-fetch": "*" + }, + "dependencies": { + "mitt": "^1.1.3", + "uuid-v4": "^0.1.0" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..379d564 --- /dev/null +++ b/src/index.js @@ -0,0 +1,86 @@ +import mitt from 'mitt'; +import uuid from 'uuid-v4'; +import fetch, { Response } from 'node-fetch'; + +if (!global.URL) global.URL = {}; +if (!global.URL.$$objects) { + global.URL.$$objects = new Map(); + global.URL.createObjectURL = blob => { + let id = uuid(); + global.URL.$$objects[id] = blob; + return `blob:http://localhost/${id}`; + }; + + let oldFetch = global.fetch || fetch; + global.fetch = function(url, opts) { + if (url.match(/^blob:/)) { + return new Promise( (resolve, reject) => { + let fr = new FileReader(); + fr.onload = () => { + let Res = global.Response || Response; + resolve(new Res(fr.result, { status: 200, statusText: 'OK' })); + }; + fr.onerror = () => { + reject(fr.error); + }; + let id = url.match(/[^/]+$/)[0]; + fr.readAsText(global.URL.$$objects[id]); + }); + } + return oldFetch.call(this, url, opts); + }; +} + +if (!global.document) { + global.document = {}; +} + +function Event(type) { this.type = type; } +if (!global.document.createEvent) { + global.document.createEvent = function(type) { + let Ctor = global[type] || Event; + return new Ctor(type); + }; +} + + +global.Worker = function Worker(url) { + let messageQueue = [], + inside = mitt(), + outside = mitt(), + scope = { + onmessage: null, + dispatchEvent: inside.emit, + addEventListener: inside.on, + removeEventListener: inside.off, + postMessage(data) { + outside.emit('message', { data }); + }, + fetch: global.fetch + }, + getScopeVar; + inside.on('message', e => { let f = getScopeVar('onmessage'); if (f) f.call(scope, e); }); + this.addEventListener = outside.on; + this.removeEventListener = outside.off; + this.dispatchEvent = outside.emit; + outside.on('message', e => { this.onmessage && this.onmessage(e); }); + this.postMessage = data => { + if (messageQueue!=null) messageQueue.push(data); + else inside.emit('message', { data }); + }; + this.terminate = () => { + throw Error('Not Supported'); + }; + global.fetch(url) + .then( r => r.text() ) + .then( code => { + let vars = 'var self=this,global=self'; + for (let k in scope) vars += `,${k}=self.${k}`; + // eval('(function() {'+vars+'\n'+code+'\n})').call(scope); + getScopeVar = eval('(function() {'+vars+'\n'+code+'\nreturn function(__){return eval(__)}})').call(scope); + let q = messageQueue; + messageQueue = null; + q.forEach(this.postMessage); + }) + .catch( e => { outside.emit('error', e); console.error(e); }); +}; diff --git a/test/index.test.js b/test/index.test.js new file mode 100644 index 0000000..a70938d --- /dev/null +++ b/test/index.test.js @@ -0,0 +1,14 @@ +import 'jsdom-worker'; + +const sleep = t => new Promise( r => { setTimeout(r, t); }); + +describe('jsdom-worker', () => { + it('should work', async () => { + let code = `onmessage = e => { postMessage(e.data*2) }`; + let worker = new Worker(URL.createObjectURL(new Blob([code]))); + worker.onmessage = jest.fn(); + worker.postMessage(5); + await sleep(10); + expect(worker.onmessage).toHaveBeenCalledWith({ data: 10 }); + }); +});