Skip to content

Commit 2d8aa74

Browse files
avivkellerpanva
authored andcommitted
worker: add support for Web Workers
Signed-off-by: Aviv Keller <me@aviv.sh>
1 parent f85f351 commit 2d8aa74

29 files changed

Lines changed: 1431 additions & 23 deletions

benchmark/misc/startup-core.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ function main({ n, script, mode }) {
6464
const warmup = 3;
6565
const state = { n, finished: -warmup };
6666
if (mode === 'worker') {
67+
// eslint-disable-next-line no-global-assign
6768
Worker = require('worker_threads').Worker;
6869
spawnWorker(script, bench, state);
6970
} else {

doc/api/cli.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1556,6 +1556,14 @@ changes:
15561556

15571557
Enable experimental WebAssembly System Interface (WASI) support.
15581558

1559+
### `--experimental-web-worker`
1560+
1561+
<!-- YAML
1562+
added: REPLACEME
1563+
-->
1564+
1565+
Enable experimental support for the Web Worker API.
1566+
15591567
### `--experimental-worker-inspection`
15601568

15611569
<!-- YAML
@@ -3899,6 +3907,7 @@ one is included in the list below.
38993907
* `--experimental-vfs`
39003908
* `--experimental-vm-modules`
39013909
* `--experimental-wasi-unstable-preview1`
3910+
* `--experimental-web-worker`
39023911
* `--force-context-aware`
39033912
* `--force-fips`
39043913
* `--force-node-api-uncaught-exceptions-policy`

doc/api/globals.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,6 +1310,105 @@ changes:
13101310
A browser-compatible implementation of {WebSocket}. Disable this API
13111311
with the [`--no-experimental-websocket`][] CLI flag.
13121312

1313+
## Class: `Worker`
1314+
1315+
<!-- YAML
1316+
added: REPLACEME
1317+
-->
1318+
1319+
> Stability: 1 - Experimental. Enable this API with the
1320+
> [`--experimental-web-worker`][] CLI flag.
1321+
1322+
A mostly browser-compatible implementation of Web Workers of the [HTML Standard][],
1323+
implemented on top of [`node:worker_threads`][]. Threads created with it
1324+
are given the {DedicatedWorkerGlobalScope} API (`self`,
1325+
`name`, `location`, `navigator`, `postMessage()`, `close()`, and
1326+
`importScripts()`), in addition to the usual Node.js globals, such as `process`.
1327+
1328+
```js
1329+
// worker.js
1330+
addEventListener('message', (event) => {
1331+
postMessage(`${event.data} from ${name}!`);
1332+
});
1333+
```
1334+
1335+
```js
1336+
// main.js
1337+
const worker = new Worker('./worker.js', { name: 'greeter' });
1338+
1339+
worker.addEventListener('message', (event) => {
1340+
console.log(event.data); // Prints: Hello from greeter!
1341+
worker.terminate();
1342+
});
1343+
1344+
worker.postMessage('Hello');
1345+
```
1346+
1347+
Because their lifetime and sharing model depend on origins and
1348+
browsing contexts, Node.js does not currently implement `SharedWorker`.
1349+
1350+
### Loading worker scripts
1351+
1352+
Worker scripts are read synchronously from the local file system or from
1353+
memory rather than fetched over the network, which changes which URLs are
1354+
accepted and how failures are reported:
1355+
1356+
* `new Worker()` and `importScripts()` accept only `file:`, `data:`, and
1357+
`blob:` URLs. Any other scheme makes `new Worker()` throw a
1358+
`NotSupportedError` and `importScripts()` throw a `NetworkError`.
1359+
* A script that cannot be read makes `importScripts()` throw a `NetworkError`;
1360+
for `new Worker()` it fires an `error` event at the `Worker` object.
1361+
* Redirects, the `nosniff` check, and HTTP MIME type validation do not apply.
1362+
MIME types are validated only for `data:` and `blob:` URLs. The
1363+
`credentials` option is validated for API compatibility but has no effect,
1364+
since no network request is made.
1365+
* On the main thread, relative script URLs are resolved against the current
1366+
working directory, because there is no document base URL. Within a worker
1367+
they are resolved against the worker's own URL (as is done in the spec).
1368+
* For `blob:` URLs, the script must be held in memory, so blobs backed by a file,
1369+
such as those returned by [`fs.openAsBlob()`][], cannot be used.
1370+
1371+
### Differences from the HTML Standard
1372+
1373+
Besides script loading, mentioned above:
1374+
1375+
* Node.js has no origin model, so same-origin and cross-origin distinctions do
1376+
not exist and `location.origin` is `'null'` for every supported scheme.
1377+
* `close()` terminates the worker immediately instead of following the
1378+
specification's "closing flag" algorithm, so code remaining in the current
1379+
task after `close()` is not executed.
1380+
* The worker global is the normal Node.js global object with
1381+
`DedicatedWorkerGlobalScope` inserted into its prototype chain, rather than
1382+
a fresh global created from the interface. Node.js globals such as
1383+
`process`, `Buffer`, and `require()` remain available to worker scripts.
1384+
* `ErrorEvent`s dispatched at `Worker` instances include `message` and
1385+
`error`, but `filename`, `lineno`, and `colno` are always `''`, `0`, and
1386+
`0`. An uncaught exception terminates the worker thread, and an unhandled
1387+
`error` event is not propagated further: it neither reaches the parent's
1388+
global scope nor affects the exit code of the process.
1389+
* The following {WorkerGlobalScope} events are never dispatched, although
1390+
their handler properties exist: `languagechange`, `online`, and `offline`,
1391+
since these concepts do not exist in Node.js; `rejectionhandled` and
1392+
`unhandledrejection`, since Node.js exposes the equivalent does not
1393+
implement the `PromiseRejectionEvent` interface or the per-rejection
1394+
`preventDefault()` behavior required by the HTML Standard.
1395+
1396+
### Web Workers and `node:worker_threads`
1397+
1398+
Every Web Worker is backed by a [`node:worker_threads`][] {Worker}, so the
1399+
two APIs share their threading, structured clone, and transfer semantics.
1400+
Inside a worker, \[`worker_threads.parentPort`]\[] is the port behind
1401+
`self.postMessage()` and the worker's `message` events, `isMainThread` is
1402+
`false`, and `workerData` is `undefined`.
1403+
1404+
As a rule of thumb, use [`node:worker_threads`][] directly when a program
1405+
needs `workerData`, a custom `env` or `execArgv`, resource limits, stdio
1406+
redirection, the `'online'` and `'exit'` events, or `worker.threadId`;
1407+
`Worker` accepts only the `name`, `type`, and `credentials` options and,
1408+
per the specification, its `terminate()` returns `undefined`, rather than
1409+
a promise. Threads started through [`node:worker_threads`][] are ordinary
1410+
Node.js threads and do not get the worker global scope APIs.
1411+
13131412
## Class: `WritableStream`
13141413

13151414
<!-- YAML
@@ -1355,10 +1454,12 @@ A browser-compatible implementation of [`WritableStreamDefaultWriter`][].
13551454
[CommonJS module]: modules.md
13561455
[CommonJS modules]: modules.md
13571456
[ECMAScript module]: esm.md
1457+
[HTML Standard]: https://html.spec.whatwg.org/multipage/workers.html
13581458
[Navigator API]: https://html.spec.whatwg.org/multipage/system-state.html#the-navigator-object
13591459
[RFC 5646]: https://www.rfc-editor.org/rfc/rfc5646.txt
13601460
[Web Crypto API]: webcrypto.md
13611461
[`--experimental-eventsource`]: cli.md#--experimental-eventsource
1462+
[`--experimental-web-worker`]: cli.md#--experimental-web-worker
13621463
[`--localstorage-file`]: cli.md#--localstorage-filefile
13631464
[`--no-experimental-global-navigator`]: cli.md#--no-experimental-global-navigator
13641465
[`--no-experimental-websocket`]: cli.md#--no-experimental-websocket
@@ -1410,9 +1511,11 @@ A browser-compatible implementation of [`WritableStreamDefaultWriter`][].
14101511
[`console`]: console.md
14111512
[`exports`]: modules.md#exports
14121513
[`fetch()`]: https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch
1514+
[`fs.openAsBlob()`]: fs.md#fsopenasblobpath-options
14131515
[`globalThis`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/globalThis
14141516
[`localStorage`]: https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage
14151517
[`module`]: modules.md#module
1518+
[`node:worker_threads`]: worker_threads.md
14161519
[`perf_hooks.performance`]: perf_hooks.md#perf_hooksperformance
14171520
[`process.nextTick()`]: process.md#processnexttickcallback-args
14181521
[`process` object]: process.md#process

doc/node.1

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,9 @@ Enable experimental ES Module support in the \fBnode:vm\fR module.
831831
.It Fl -experimental-wasi-unstable-preview1
832832
Enable experimental WebAssembly System Interface (WASI) support.
833833
.
834+
.It Fl -experimental-web-worker
835+
Enable experimental support for the Web Worker API.
836+
.
834837
.It Fl -experimental-worker-inspection
835838
Enable experimental support for the worker inspection with Chrome DevTools.
836839
.
@@ -2024,6 +2027,8 @@ one is included in the list below.
20242027
.It
20252028
\fB--experimental-wasi-unstable-preview1\fR
20262029
.It
2030+
\fB--experimental-web-worker\fR
2031+
.It
20272032
\fB--force-context-aware\fR
20282033
.It
20292034
\fB--force-fips\fR

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export default [
151151
WritableStreamDefaultWriter: 'readonly',
152152
WritableStreamDefaultController: 'readonly',
153153
WebSocket: 'readonly',
154+
Worker: 'readonly',
154155
},
155156
},
156157
},

lib/internal/blob.js

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const {
3030
} = internalBinding('buffer');
3131

3232
const {
33-
TextDecoder,
33+
getUtf8Decoder,
3434
TextEncoder,
3535
} = require('internal/encoding');
3636
const { URL } = require('internal/url');
@@ -88,7 +88,6 @@ let ReadableStream;
8888
let TextDecoderStream;
8989

9090
const enc = new TextEncoder();
91-
let dec;
9291

9392
// Yes, lazy loading is annoying but because of circular
9493
// references between the url, internal/blob, and buffer
@@ -312,7 +311,7 @@ class Blob {
312311
if (!isBlob(this))
313312
return PromiseReject(new ERR_INVALID_THIS('Blob'));
314313

315-
dec ??= new TextDecoder();
314+
const dec = getUtf8Decoder();
316315

317316
return PromisePrototypeThen(
318317
arrayBuffer(this),
@@ -466,6 +465,43 @@ function arrayBuffer(blob) {
466465
return promise;
467466
}
468467

468+
/**
469+
* Read a blob's data synchronously. This is only possible when every part
470+
* of the blob is memory-resident, in which case the reader's pull callbacks
471+
* are invoked synchronously; otherwise (e.g. for file-backed blobs)
472+
* undefined is returned.
473+
* @param {Blob} blob
474+
* @returns {ArrayBuffer|undefined}
475+
*/
476+
function getBlobDataSync(blob) {
477+
const reader = blob[kHandle].getReader();
478+
const buffers = [];
479+
let result;
480+
let ended = false;
481+
while (!ended) {
482+
let sync = false;
483+
reader.pull((status, buffer) => {
484+
sync = true;
485+
if (status === 0) {
486+
// EOS; buffer should be undefined here.
487+
result = concat(buffers);
488+
ended = true;
489+
return;
490+
} else if (status < 0) {
491+
ended = true;
492+
return;
493+
}
494+
if (buffer !== undefined)
495+
ArrayPrototypePush(buffers, buffer);
496+
});
497+
if (!sync) {
498+
// The data is not available synchronously.
499+
break;
500+
}
501+
}
502+
return result;
503+
}
504+
469505
function createBlobReaderStream(reader) {
470506
return new lazyReadableStream({
471507
type: 'bytes',
@@ -644,8 +680,10 @@ module.exports = {
644680
createBlobFromFilePath,
645681
createBlobReaderIterable,
646682
createBlobReaderStream,
683+
getBlobDataSync,
647684
isBlob,
648685
kHandle,
686+
kType,
649687
resolveObjectURL,
650688
TransferableBlob,
651689
};

lib/internal/bootstrap/web/exposed-window-or-worker.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ exposeLazyInterfaces(globalThis, 'internal/worker/io', ['BroadcastChannel']);
5151
exposeLazyInterfaces(globalThis, 'internal/worker/io', [
5252
'MessageChannel', 'MessagePort',
5353
]);
54+
// https://html.spec.whatwg.org/multipage/workers.html#dedicated-workers-and-the-worker-interface
55+
exposeLazyInterfaces(globalThis, 'internal/webworker', ['Worker']);
5456
// https://www.w3.org/TR/FileAPI/#dfn-Blob
5557
exposeLazyInterfaces(globalThis, 'internal/blob', ['Blob']);
5658
// https://www.w3.org/TR/FileAPI/#dfn-file

lib/internal/encoding.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,8 +614,17 @@ ObjectDefineProperties(TextDecoder.prototype, {
614614
},
615615
});
616616

617+
// A lazily created TextDecoder for the common case of decoding UTF-8 with
618+
// the default options, shared between internal modules.
619+
let utf8Decoder;
620+
function getUtf8Decoder() {
621+
utf8Decoder ??= new TextDecoder();
622+
return utf8Decoder;
623+
}
624+
617625
module.exports = {
618626
getEncodingFromLabel,
627+
getUtf8Decoder,
619628
TextDecoder,
620629
TextEncoder,
621630
};

lib/internal/event_target.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -722,7 +722,10 @@ class EventTarget {
722722
return;
723723

724724
type = webidl.converters.DOMString(type);
725-
const capture = options?.capture === true;
725+
// Flatten the options argument like addEventListener does.
726+
// Refs: https://dom.spec.whatwg.org/#concept-flatten-options
727+
const capture = typeof options === 'boolean' ?
728+
options : options?.capture === true;
726729

727730
if (this[kEvents] === undefined)
728731
return;
@@ -1163,6 +1166,13 @@ function defineEventHandler(emitter, name, event = name) {
11631166

11641167
function set(value) {
11651168
validateThisInternalField(this, kHandlers, 'EventTarget');
1169+
// Event handler IDL attributes are [LegacyTreatNonObjectAsNull]: values
1170+
// that are neither callable nor objects deactivate the handler.
1171+
// Refs: https://html.spec.whatwg.org/multipage/webappapis.html#event-handler-idl-attributes
1172+
if (typeof value !== 'function' &&
1173+
(typeof value !== 'object' || value === null)) {
1174+
value = null;
1175+
}
11661176
if (this[kHandlers] === undefined)
11671177
this[kHandlers] = new SafeMap();
11681178
let wrappedHandler = this[kHandlers].get(event);

lib/internal/main/worker_thread.js

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ port.on('message', (message) => {
100100
hasStdin,
101101
publicPort,
102102
workerData,
103+
webWorkerData,
103104
mainThreadPort,
104105
} = message;
105106

@@ -116,6 +117,11 @@ port.on('message', (message) => {
116117
require('internal/worker').assignEnvironmentData(environmentData);
117118
setupMainThreadPort(mainThreadPort);
118119

120+
if (webWorkerData !== undefined) {
121+
require('internal/webworker')
122+
.installDedicatedWorkerGlobalScope(webWorkerData.url, webWorkerData);
123+
}
124+
119125
// The counter is only passed to the workers created by the main thread,
120126
// not to workers created by other workers.
121127
let cachedCwd = '';
@@ -155,7 +161,12 @@ port.on('message', (message) => {
155161
break;
156162
}
157163

158-
case 'classic': if (getOptionValue('--input-type') !== 'module') {
164+
case 'classic': if (webWorkerData?.source !== undefined) {
165+
// The source of a web Worker script loaded from a blob: or data:
166+
// URL.
167+
runWebWorkerScript(webWorkerData.source, webWorkerData);
168+
break;
169+
} else if (getOptionValue('--input-type') !== 'module') {
159170
const name = '[worker eval]';
160171
// This is necessary for CJS module compilation.
161172
// TODO: pass this with something really internal.
@@ -215,6 +226,13 @@ port.on('message', (message) => {
215226
}
216227

217228
default: {
229+
if (webWorkerData !== undefined) {
230+
// A web Worker script loaded from a file: URL. Its type decides
231+
// how the source is run, regardless of the file extension.
232+
const source = require('fs').readFileSync(filename, 'utf8');
233+
runWebWorkerScript(source, webWorkerData);
234+
break;
235+
}
218236
// script filename
219237
// runMain here might be monkey-patched by users in --require.
220238
// XXX: the monkey-patchability here should probably be deprecated.
@@ -289,6 +307,21 @@ function workerOnGlobalUncaughtException(error, fromPromise) {
289307
process.exit();
290308
}
291309

310+
function runWebWorkerScript(source, webWorkerData) {
311+
const webworker = require('internal/webworker');
312+
if (webWorkerData.type === 'module') {
313+
PromisePrototypeThen(
314+
webworker.runModuleScriptSource(source, webWorkerData.url),
315+
undefined,
316+
(error) => workerOnGlobalUncaughtException(error, true),
317+
);
318+
} else {
319+
// Unlike a worker eval, "run a classic script" evaluates the source in
320+
// the worker's global scope.
321+
webworker.runClassicScriptSource(source, webWorkerData.url);
322+
}
323+
}
324+
292325
// Patch the global uncaught exception handler so it gets picked up by
293326
// node::errors::TriggerUncaughtException().
294327
process._fatalException = workerOnGlobalUncaughtException;

0 commit comments

Comments
 (0)