diff --git a/.changeset/healthy-mangos-wait.md b/.changeset/healthy-mangos-wait.md new file mode 100644 index 000000000..5317c2847 --- /dev/null +++ b/.changeset/healthy-mangos-wait.md @@ -0,0 +1,5 @@ +--- +'wmr': minor +--- + +Add built-in support for [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) based on browser APIs. Example on how to use Web Workers with WMR: https://wmr.dev/docs/web-workers diff --git a/docs/public/content/_config.yml b/docs/public/content/_config.yml index f3de9480a..177fb1dac 100644 --- a/docs/public/content/_config.yml +++ b/docs/public/content/_config.yml @@ -8,5 +8,6 @@ collections: - configuration - plugins - prerendering + - web-workers - { heading: 'API' } - plugin-api diff --git a/docs/public/content/docs/web-workers.md b/docs/public/content/docs/web-workers.md new file mode 100644 index 000000000..dc4aa592c --- /dev/null +++ b/docs/public/content/docs/web-workers.md @@ -0,0 +1,42 @@ +--- +nav: Web Workers +title: 'Web Workers' +--- + +[Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) are a way to do threading in JavaScript. It is a simple mean to run work in the background to keep the main thread responsive for UI work. + +To use web workers with WMR you can use the [Web Workers API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers#web_workers_api) directly. + +```js +// index.js +const worker = new Worker(new URL('./my.worker.js', import.meta.url)); + +// Subscribe to messages coming from the worker +worker.addEventListener('message', e => console.log(e.data)); + +// Ping worker +worker.postMessage("Let's go"); +``` + +WMR relies on the filename to detect workers. That's why it must have the `.worker` suffix in the filename. + +```js +// my.worker.js +addEventListener('message', () => { + // Always answer with "hello" + postMessage('hello'); +}); +``` + +> We highly recommend using [comlink](https://github.com/GoogleChromeLabs/comlink) for working with web workers. It abstracts away the manual message passing that's required to communicate workers. + +## ESM Support in Web Workers + +Support for module mode so that you can use `import` and `export` statements can be turned on by passing `{ format: 'module' }` to the `Worker` constructor. + +```js +const workerUrl = new URL('./my.worker.js', import.meta.url); +const worker = new Worker(workerUrl, { type: 'module' }); +``` + +> Be cautious: ESM is not yet supported in every mainstream browser. diff --git a/docs/public/content/index.md b/docs/public/content/index.md index 886c45f31..950affcf4 100644 --- a/docs/public/content/index.md +++ b/docs/public/content/index.md @@ -18,6 +18,7 @@ All the features you'd expect and more, from development to production: 🗜   Highly optimized Rollup-based production output (`wmr build`)
📑   Crawls and pre-renders your app's pages to static HTML at build time
🏎   Built-in HTTP2 in dev and prod (`wmr serve --http2`)
+👷   Built-in support for web workers 🔧   Supports [Rollup plugins](/docs/plugins), even in development where Rollup isn't used diff --git a/packages/wmr/src/lib/plugins.js b/packages/wmr/src/lib/plugins.js index acf6eea79..474749576 100644 --- a/packages/wmr/src/lib/plugins.js +++ b/packages/wmr/src/lib/plugins.js @@ -26,13 +26,26 @@ import { acornDefaultPlugins } from './acorn-default-plugins.js'; import { prefreshPlugin } from '../plugins/preact/prefresh.js'; import { absolutePathPlugin } from '../plugins/absolute-path-plugin.js'; import { lessPlugin } from '../plugins/less-plugin.js'; +import { workerPlugin } from '../plugins/worker-plugin.js'; /** - * @param {import("wmr").Options} options + * @param {import("wmr").Options & { runtimeEnv: "default" | "worker"}} options * @returns {import("wmr").Plugin[]} */ export function getPlugins(options) { - const { plugins, publicPath, alias, root, env, minify, mode, sourcemap, features, visualize } = options; + const { + plugins, + publicPath, + alias, + root, + env, + minify, + mode, + runtimeEnv = 'default', + sourcemap, + features, + visualize + } = options; // Plugins are pre-sorted let split = plugins.findIndex(p => p.enforce === 'post'); @@ -41,6 +54,8 @@ export function getPlugins(options) { const production = mode === 'build'; const mergedAssets = new Set(); + const isWorker = runtimeEnv === 'worker'; + return [ acornDefaultPlugins(), ...plugins.slice(0, split), @@ -74,13 +89,15 @@ export function getPlugins(options) { env, NODE_ENV: production ? 'production' : 'development' }), + // Nested workers are not supported at the moment + !isWorker && workerPlugin(options), htmPlugin({ production, sourcemap: options.sourcemap }), wmrPlugin({ hot: !production, sourcemap: options.sourcemap }), fastCjsPlugin({ // Only transpile CommonJS in node_modules and explicit .cjs files: include: /(^npm\/|[/\\]node_modules[/\\]|\.cjs$)/ }), - production && npmPlugin({ external: false }), + (production || isWorker) && npmPlugin({ external: false }), resolveExtensionsPlugin({ extensions: ['.ts', '.tsx', '.js', '.cjs'], index: true diff --git a/packages/wmr/src/plugins/wmr/client.js b/packages/wmr/src/plugins/wmr/client.js index 6483801b7..58d6a89ac 100644 --- a/packages/wmr/src/plugins/wmr/client.js +++ b/packages/wmr/src/plugins/wmr/client.js @@ -46,7 +46,9 @@ function handleMessage(e) { const data = JSON.parse(e.data); switch (data.type) { case 'reload': - location.reload(); + if (HAS_DOM) { + location.reload(); + } break; case 'update': if (errorOverlay) { @@ -64,7 +66,10 @@ function handleMessage(e) { return; } } else if (url.replace(URL_SUFFIX, '') === resolve(location.pathname).replace(URL_SUFFIX, '')) { - return location.reload(); + if (HAS_DOM) { + location.reload(); + } + return; } else { if (!HAS_DOM) return; for (const el of document.querySelectorAll('[src],[href]')) { diff --git a/packages/wmr/src/plugins/wmr/plugin.js b/packages/wmr/src/plugins/wmr/plugin.js index d57cfbb65..2c2238709 100644 --- a/packages/wmr/src/plugins/wmr/plugin.js +++ b/packages/wmr/src/plugins/wmr/plugin.js @@ -43,7 +43,7 @@ export default function wmrPlugin({ hot = true, sourcemap } = {}) { }, transform(code, id) { const ch = id[0]; - if (ch === '\0' || !/\.[tj]sx?$/.test(id)) return; + if (ch === '\0' || !/\.[tj]sx?$/.test(id) || /\.worker\.[tj]sx?$/.test(id)) return; let hasHot = /(import\.meta\.hot|\$IMPORT_META_HOT\$)/.test(code); let before = ''; let after = ''; diff --git a/packages/wmr/src/plugins/worker-plugin.js b/packages/wmr/src/plugins/worker-plugin.js new file mode 100644 index 000000000..bdd2d46fa --- /dev/null +++ b/packages/wmr/src/plugins/worker-plugin.js @@ -0,0 +1,136 @@ +import MagicString from 'magic-string'; +import * as rollup from 'rollup'; +import path from 'path'; +import { getPlugins } from '../lib/plugins.js'; +import * as kl from 'kolorist'; + +/** + * @param {import("wmr").Options} options + * @returns {import('rollup').Plugin} + */ +export function workerPlugin(options) { + const plugins = getPlugins({ ...options, runtimeEnv: 'worker' }); + + /** @type {Map} */ + const moduleWorkers = new Map(); + let didWarnESM = false; + + return { + name: 'worker', + async transform(code, id) { + // Transpile worker file if we're dealing with a worker + if (/\.worker\.(?:[tj]sx?|mjs)$/.test(id)) { + const resolved = await this.resolve(id); + const resolvedId = resolved ? resolved.id : id; + + if (moduleWorkers.has(resolvedId)) { + if (!didWarnESM) { + const relativeId = path.relative(options.root, resolvedId); + this.warn( + kl.yellow( + `Warning: Module workers are not widely supported yet. Use at your own risk. This warning occurs, because file ` + ) + + kl.cyan(relativeId) + + kl.yellow(` was loaded as a Web Worker with type "module"`) + ); + didWarnESM = true; + } + + // ..but not in module mode + return; + } + + // TODO: Add support for HMR inside a worker. + + // Firefox doesn't support modules inside web workers. They're + // the only main browser left to implement that feature. Until + // that's resolved we need to pre-bundle the worker code as a + // single script with no dependencies. Once they support that + // we can drop the bundling part and have nested workers work + // out of the box. + // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1247687 + const bundle = await rollup.rollup({ + input: id, + plugins: [ + { + name: 'worker-meta', + resolveImportMeta(property) { + // `import.meta.url` is only available in ESM environments + if (property === 'url') { + return 'location.href'; + } + } + }, + ...plugins + ], + // Inline all dependencies + external: () => false + }); + + const res = await bundle.generate({ + format: 'iife', + + sourcemap: options.sourcemap, + inlineDynamicImports: true + }); + + await bundle.close(); + + return { + code: res.output[0].code, + map: res.output[0].map || null + }; + } + // Check if a worker is referenced anywhere in the file + else if (/\.(?:[tj]sx?|mjs|cjs)$/.test(id)) { + const WORKER_REG = /new URL\(\s*['"]([\w.-/:~]+)['"],\s*import\.meta\.url\s*\)(,\s*{.*?["']module["'].*?})?/gm; + + if (WORKER_REG.test(code)) { + const s = new MagicString(code, { + filename: id, + // @ts-ignore + indentExclusionRanges: undefined + }); + + let match; + WORKER_REG.lastIndex = 0; + while ((match = WORKER_REG.exec(code))) { + const spec = match[1]; + + // Worker URLs must be relative to properly work with chunks + if (/^https?:/.test(spec) || !/^\.\.?\//.test(spec)) { + throw new Error(`Worker import specifier must be relative. Got "${spec}" instead.`); + } + + const ref = this.emitFile({ + type: 'chunk', + id: spec + }); + + const resolved = await this.resolve(spec, id); + const resolvedId = resolved ? resolved.id : spec; + + let usageCount = moduleWorkers.get(resolvedId) || 0; + if (match[2]) { + moduleWorkers.set(resolvedId, usageCount + 1); + } else if (usageCount === 0) { + moduleWorkers.delete(resolvedId); + } + + const start = match.index + match[0].indexOf(spec); + // Account for quoting characters and force URL to be + // relative. + s.overwrite(start - 1, start + spec.length + 1, `'.' + import.meta.ROLLUP_FILE_URL_${ref}`); + } + + return { + code: s.toString(), + map: options.sourcemap + ? s.generateMap({ source: id, file: path.posix.basename(id), includeContent: true }) + : null + }; + } + } + } + }; +} diff --git a/packages/wmr/test/fixtures/worker-esm/dep-a.js b/packages/wmr/test/fixtures/worker-esm/dep-a.js new file mode 100644 index 000000000..8fd3dc4bd --- /dev/null +++ b/packages/wmr/test/fixtures/worker-esm/dep-a.js @@ -0,0 +1 @@ +export { value } from './dep-b'; diff --git a/packages/wmr/test/fixtures/worker-esm/dep-b.js b/packages/wmr/test/fixtures/worker-esm/dep-b.js new file mode 100644 index 000000000..2c47d2b98 --- /dev/null +++ b/packages/wmr/test/fixtures/worker-esm/dep-b.js @@ -0,0 +1 @@ +export const value = 'it works'; diff --git a/packages/wmr/test/fixtures/worker-esm/entry-1.js b/packages/wmr/test/fixtures/worker-esm/entry-1.js new file mode 100644 index 000000000..623084c3e --- /dev/null +++ b/packages/wmr/test/fixtures/worker-esm/entry-1.js @@ -0,0 +1 @@ +export { value } from './dep-a'; diff --git a/packages/wmr/test/fixtures/worker-esm/foo.worker.js b/packages/wmr/test/fixtures/worker-esm/foo.worker.js new file mode 100644 index 000000000..adbad6b49 --- /dev/null +++ b/packages/wmr/test/fixtures/worker-esm/foo.worker.js @@ -0,0 +1,5 @@ +import { value } from './dep-b'; + +addEventListener('message', () => { + postMessage(value); +}); diff --git a/packages/wmr/test/fixtures/worker-esm/index.html b/packages/wmr/test/fixtures/worker-esm/index.html new file mode 100644 index 000000000..e3f64f80c --- /dev/null +++ b/packages/wmr/test/fixtures/worker-esm/index.html @@ -0,0 +1,3 @@ +

it doesn't work

+

it doesn't work

+ diff --git a/packages/wmr/test/fixtures/worker-esm/index.js b/packages/wmr/test/fixtures/worker-esm/index.js new file mode 100644 index 000000000..a76961b3f --- /dev/null +++ b/packages/wmr/test/fixtures/worker-esm/index.js @@ -0,0 +1,11 @@ +import { value as value2 } from './entry-1'; + +document.querySelector('h2').textContent = value2; + +const worker = new Worker(new URL('./foo.worker.js', import.meta.url), { type: 'module' }); + +worker.addEventListener('message', e => { + document.querySelector('h1').textContent = e.data; +}); + +worker.postMessage('hello'); diff --git a/packages/wmr/test/fixtures/worker-multi/bar.worker.js b/packages/wmr/test/fixtures/worker-multi/bar.worker.js new file mode 100644 index 000000000..bd05ed848 --- /dev/null +++ b/packages/wmr/test/fixtures/worker-multi/bar.worker.js @@ -0,0 +1,3 @@ +addEventListener('message', () => { + postMessage('it works'); +}); diff --git a/packages/wmr/test/fixtures/worker-multi/foo.worker.js b/packages/wmr/test/fixtures/worker-multi/foo.worker.js new file mode 100644 index 000000000..bd05ed848 --- /dev/null +++ b/packages/wmr/test/fixtures/worker-multi/foo.worker.js @@ -0,0 +1,3 @@ +addEventListener('message', () => { + postMessage('it works'); +}); diff --git a/packages/wmr/test/fixtures/worker-multi/index.html b/packages/wmr/test/fixtures/worker-multi/index.html new file mode 100644 index 000000000..e3f64f80c --- /dev/null +++ b/packages/wmr/test/fixtures/worker-multi/index.html @@ -0,0 +1,3 @@ +

it doesn't work

+

it doesn't work

+ diff --git a/packages/wmr/test/fixtures/worker-multi/index.js b/packages/wmr/test/fixtures/worker-multi/index.js new file mode 100644 index 000000000..b1f2d95fc --- /dev/null +++ b/packages/wmr/test/fixtures/worker-multi/index.js @@ -0,0 +1,12 @@ +const foo = new Worker(new URL('./foo.worker.js', import.meta.url)); +const bar = new Worker(new URL('./foo.worker.js', import.meta.url)); + +foo.addEventListener('message', e => { + document.querySelector('h1').textContent = e.data; +}); +bar.addEventListener('message', e => { + document.querySelector('h2').textContent = e.data; +}); + +foo.postMessage('hello'); +bar.postMessage('hello'); diff --git a/packages/wmr/test/fixtures/worker-relative/foo/foo.worker.js b/packages/wmr/test/fixtures/worker-relative/foo/foo.worker.js new file mode 100644 index 000000000..bd05ed848 --- /dev/null +++ b/packages/wmr/test/fixtures/worker-relative/foo/foo.worker.js @@ -0,0 +1,3 @@ +addEventListener('message', () => { + postMessage('it works'); +}); diff --git a/packages/wmr/test/fixtures/worker-relative/foo/index.js b/packages/wmr/test/fixtures/worker-relative/foo/index.js new file mode 100644 index 000000000..5bb0e2fdd --- /dev/null +++ b/packages/wmr/test/fixtures/worker-relative/foo/index.js @@ -0,0 +1,7 @@ +const worker = new Worker(new URL('./foo.worker.js', import.meta.url)); + +worker.addEventListener('message', e => { + document.querySelector('h1').textContent = e.data; +}); + +worker.postMessage('hello'); diff --git a/packages/wmr/test/fixtures/worker-relative/index.html b/packages/wmr/test/fixtures/worker-relative/index.html new file mode 100644 index 000000000..9cfb45a0e --- /dev/null +++ b/packages/wmr/test/fixtures/worker-relative/index.html @@ -0,0 +1,2 @@ +

it doesn't work

+ diff --git a/packages/wmr/test/fixtures/worker-relative/index.js b/packages/wmr/test/fixtures/worker-relative/index.js new file mode 100644 index 000000000..76a6714be --- /dev/null +++ b/packages/wmr/test/fixtures/worker-relative/index.js @@ -0,0 +1 @@ +import './foo/index.js'; diff --git a/packages/wmr/test/fixtures/worker/dep-a.js b/packages/wmr/test/fixtures/worker/dep-a.js new file mode 100644 index 000000000..8fd3dc4bd --- /dev/null +++ b/packages/wmr/test/fixtures/worker/dep-a.js @@ -0,0 +1 @@ +export { value } from './dep-b'; diff --git a/packages/wmr/test/fixtures/worker/dep-b.js b/packages/wmr/test/fixtures/worker/dep-b.js new file mode 100644 index 000000000..2c47d2b98 --- /dev/null +++ b/packages/wmr/test/fixtures/worker/dep-b.js @@ -0,0 +1 @@ +export const value = 'it works'; diff --git a/packages/wmr/test/fixtures/worker/entry-1.js b/packages/wmr/test/fixtures/worker/entry-1.js new file mode 100644 index 000000000..623084c3e --- /dev/null +++ b/packages/wmr/test/fixtures/worker/entry-1.js @@ -0,0 +1 @@ +export { value } from './dep-a'; diff --git a/packages/wmr/test/fixtures/worker/foo.worker.js b/packages/wmr/test/fixtures/worker/foo.worker.js new file mode 100644 index 000000000..adbad6b49 --- /dev/null +++ b/packages/wmr/test/fixtures/worker/foo.worker.js @@ -0,0 +1,5 @@ +import { value } from './dep-b'; + +addEventListener('message', () => { + postMessage(value); +}); diff --git a/packages/wmr/test/fixtures/worker/index.html b/packages/wmr/test/fixtures/worker/index.html new file mode 100644 index 000000000..e3f64f80c --- /dev/null +++ b/packages/wmr/test/fixtures/worker/index.html @@ -0,0 +1,3 @@ +

it doesn't work

+

it doesn't work

+ diff --git a/packages/wmr/test/fixtures/worker/index.js b/packages/wmr/test/fixtures/worker/index.js new file mode 100644 index 000000000..db614a958 --- /dev/null +++ b/packages/wmr/test/fixtures/worker/index.js @@ -0,0 +1,11 @@ +import { value as value2 } from './entry-1'; + +document.querySelector('h2').textContent = value2; + +const worker = new Worker(new URL('./foo.worker.js', import.meta.url)); + +worker.addEventListener('message', e => { + document.querySelector('h1').textContent = e.data; +}); + +worker.postMessage('hello'); diff --git a/packages/wmr/test/worker.test.js b/packages/wmr/test/worker.test.js new file mode 100644 index 000000000..c13fdd2c9 --- /dev/null +++ b/packages/wmr/test/worker.test.js @@ -0,0 +1,171 @@ +import path from 'path'; +import { + getOutput, + loadFixture, + runWmr, + runWmrFast, + serveStatic, + setupTest, + teardown, + waitForMessage, + waitForPass, + withLog +} from './test-helpers.js'; + +jest.setTimeout(30000); + +describe('Workers', () => { + describe('development', () => { + /** @type {TestEnv} */ + let env; + /** @type {WmrInstance} */ + let instance; + + beforeEach(async () => { + env = await setupTest(); + }); + + afterEach(async () => { + await teardown(env); + instance.close(); + }); + + it('should load worker', async () => { + await loadFixture('worker', env); + instance = await runWmrFast(env.tmp.path); + await getOutput(env, instance); + + await withLog(instance.output, async () => { + await waitForPass(async () => { + const h1 = await env.page.evaluate('document.querySelector("h1").textContent'); + const h2 = await env.page.evaluate('document.querySelector("h2").textContent'); + + expect(h1).toMatch('it works'); + expect(h2).toMatch('it works'); + }); + }); + }); + + it('should load multiple workers', async () => { + await loadFixture('worker-multi', env); + instance = await runWmrFast(env.tmp.path); + await getOutput(env, instance); + + await withLog(instance.output, async () => { + await waitForPass(async () => { + const h1 = await env.page.evaluate('document.querySelector("h1").textContent'); + const h2 = await env.page.evaluate('document.querySelector("h2").textContent'); + + expect(h1).toMatch('it works'); + expect(h2).toMatch('it works'); + }); + }); + }); + + it('should support ESM workers', async () => { + await loadFixture('worker-esm', env); + instance = await runWmrFast(env.tmp.path); + await getOutput(env, instance); + + await withLog(instance.output, async () => { + await waitForPass(async () => { + const h1 = await env.page.evaluate('document.querySelector("h1").textContent'); + const h2 = await env.page.evaluate('document.querySelector("h2").textContent'); + + expect(h1).toMatch('it works'); + expect(h2).toMatch('it works'); + }); + + await waitForMessage(instance.output, /Module workers are not widely supported/); + }); + }); + }); + + describe('production', () => { + /** @type {TestEnv} */ + let env; + /** @type {WmrInstance} */ + let instance; + /** @type {(()=>void)[]} */ + let cleanup = []; + + beforeEach(async () => { + env = await setupTest(); + }); + + afterEach(async () => { + await teardown(env); + instance.close(); + await Promise.all(cleanup.map(c => Promise.resolve().then(c))); + cleanup.length = 0; + }); + + it('should load worker', async () => { + await loadFixture('worker', env); + instance = await runWmr(env.tmp.path, 'build'); + + expect(await instance.done).toEqual(0); + const { address, stop } = serveStatic(path.join(env.tmp.path, 'dist')); + cleanup.push(stop); + await env.page.goto(address, { + waitUntil: ['networkidle0', 'load'] + }); + + await withLog(instance.output, async () => { + await waitForPass(async () => { + const h1 = await env.page.evaluate('document.querySelector("h1").textContent'); + const h2 = await env.page.evaluate('document.querySelector("h2").textContent'); + + expect(h1).toMatch('it works'); + expect(h2).toMatch('it works'); + }); + }); + }); + + it('should load multiple workers', async () => { + await loadFixture('worker-multi', env); + instance = await runWmr(env.tmp.path, 'build'); + + expect(await instance.done).toEqual(0); + const { address, stop } = serveStatic(path.join(env.tmp.path, 'dist')); + cleanup.push(stop); + await env.page.goto(address, { + waitUntil: ['networkidle0', 'load'] + }); + + await withLog(instance.output, async () => { + await waitForPass(async () => { + const h1 = await env.page.evaluate('document.querySelector("h1").textContent'); + const h2 = await env.page.evaluate('document.querySelector("h2").textContent'); + + expect(h1).toMatch('it works'); + expect(h2).toMatch('it works'); + }); + }); + }); + + it('should load ESM workers', async () => { + await loadFixture('worker-esm', env); + instance = await runWmr(env.tmp.path, 'build'); + + expect(await instance.done).toEqual(0); + const { address, stop } = serveStatic(path.join(env.tmp.path, 'dist')); + cleanup.push(stop); + await env.page.goto(address, { + waitUntil: ['networkidle0', 'load'] + }); + + await withLog(instance.output, async () => { + await waitForPass(async () => { + const h1 = await env.page.evaluate('document.querySelector("h1").textContent'); + const h2 = await env.page.evaluate('document.querySelector("h2").textContent'); + + expect(h1).toMatch('it works'); + expect(h2).toMatch('it works'); + }); + + await waitForMessage(instance.output, /Module workers are not widely supported/); + }); + }); + }); +});