Skip to content

Commit

Permalink
Merge pull request #827 from preactjs/worker
Browse files Browse the repository at this point in the history
  • Loading branch information
marvinhagemeister committed Sep 2, 2021
2 parents e5be87e + 5f89737 commit ab68790
Show file tree
Hide file tree
Showing 29 changed files with 462 additions and 6 deletions.
5 changes: 5 additions & 0 deletions .changeset/healthy-mangos-wait.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions docs/public/content/_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ collections:
- configuration
- plugins
- prerendering
- web-workers
- { heading: 'API' }
- plugin-api
42 changes: 42 additions & 0 deletions docs/public/content/docs/web-workers.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/public/content/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ All the features you'd expect and more, from development to production:
🗜 &nbsp; Highly optimized Rollup-based production output (`wmr build`)<br>
📑 &nbsp; Crawls and pre-renders your app's pages to static HTML at build time<br>
🏎 &nbsp; Built-in HTTP2 in dev and prod (`wmr serve --http2`)<br>
👷 &nbsp; Built-in support for web workers
🔧 &nbsp; Supports [Rollup plugins](/docs/plugins), even in development where Rollup isn't used

</div>
23 changes: 20 additions & 3 deletions packages/wmr/src/lib/plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions packages/wmr/src/plugins/wmr/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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]')) {
Expand Down
2 changes: 1 addition & 1 deletion packages/wmr/src/plugins/wmr/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down
136 changes: 136 additions & 0 deletions packages/wmr/src/plugins/worker-plugin.js
Original file line number Diff line number Diff line change
@@ -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<string, number>} */
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
};
}
}
}
};
}
1 change: 1 addition & 0 deletions packages/wmr/test/fixtures/worker-esm/dep-a.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { value } from './dep-b';
1 change: 1 addition & 0 deletions packages/wmr/test/fixtures/worker-esm/dep-b.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const value = 'it works';
1 change: 1 addition & 0 deletions packages/wmr/test/fixtures/worker-esm/entry-1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { value } from './dep-a';
5 changes: 5 additions & 0 deletions packages/wmr/test/fixtures/worker-esm/foo.worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { value } from './dep-b';

addEventListener('message', () => {
postMessage(value);
});
3 changes: 3 additions & 0 deletions packages/wmr/test/fixtures/worker-esm/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<h1>it doesn't work</h1>
<h2>it doesn't work</h2>
<script src="./index.js" type="module"></script>
11 changes: 11 additions & 0 deletions packages/wmr/test/fixtures/worker-esm/index.js
Original file line number Diff line number Diff line change
@@ -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');
3 changes: 3 additions & 0 deletions packages/wmr/test/fixtures/worker-multi/bar.worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
addEventListener('message', () => {
postMessage('it works');
});
3 changes: 3 additions & 0 deletions packages/wmr/test/fixtures/worker-multi/foo.worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
addEventListener('message', () => {
postMessage('it works');
});
3 changes: 3 additions & 0 deletions packages/wmr/test/fixtures/worker-multi/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<h1>it doesn't work</h1>
<h2>it doesn't work</h2>
<script src="./index.js" type="module"></script>
12 changes: 12 additions & 0 deletions packages/wmr/test/fixtures/worker-multi/index.js
Original file line number Diff line number Diff line change
@@ -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');
3 changes: 3 additions & 0 deletions packages/wmr/test/fixtures/worker-relative/foo/foo.worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
addEventListener('message', () => {
postMessage('it works');
});
7 changes: 7 additions & 0 deletions packages/wmr/test/fixtures/worker-relative/foo/index.js
Original file line number Diff line number Diff line change
@@ -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');
2 changes: 2 additions & 0 deletions packages/wmr/test/fixtures/worker-relative/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<h1>it doesn't work</h1>
<script src="./index.js" type="module"></script>
1 change: 1 addition & 0 deletions packages/wmr/test/fixtures/worker-relative/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './foo/index.js';
1 change: 1 addition & 0 deletions packages/wmr/test/fixtures/worker/dep-a.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { value } from './dep-b';
1 change: 1 addition & 0 deletions packages/wmr/test/fixtures/worker/dep-b.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const value = 'it works';
1 change: 1 addition & 0 deletions packages/wmr/test/fixtures/worker/entry-1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { value } from './dep-a';
5 changes: 5 additions & 0 deletions packages/wmr/test/fixtures/worker/foo.worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { value } from './dep-b';

addEventListener('message', () => {
postMessage(value);
});
3 changes: 3 additions & 0 deletions packages/wmr/test/fixtures/worker/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<h1>it doesn't work</h1>
<h2>it doesn't work</h2>
<script src="./index.js" type="module"></script>
11 changes: 11 additions & 0 deletions packages/wmr/test/fixtures/worker/index.js
Original file line number Diff line number Diff line change
@@ -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');
Loading

0 comments on commit ab68790

Please sign in to comment.