Skip to content

Commit

Permalink
decouple AbortController (and friends) from Node.js internals
Browse files Browse the repository at this point in the history
  • Loading branch information
shirakaba committed Mar 19, 2024
1 parent 1d4d24d commit 94d845a
Show file tree
Hide file tree
Showing 11 changed files with 224 additions and 59 deletions.
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
# DOM Events implementation for WinterCG

A polyfill for the [DOM Events](https://dom.spec.whatwg.org/#introduction-to-dom-events) APIs:
A polyfill for [DOM Events](https://dom.spec.whatwg.org/#introduction-to-dom-events) and related APIs:

- [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
- [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal)
- [CustomEvent](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent)
- [DOMException](https://developer.mozilla.org/en-US/docs/Web/API/DOMException)
- [Event](https://developer.mozilla.org/en-US/docs/Web/API/Event)
- [EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget)

Expand Down Expand Up @@ -130,13 +133,16 @@ Below, I'll describe for each bundler how to integrate this package into your bu

#### Webpack 5

This configuration ensures that `CustomEvent`, `Event`, and `EventTarget` are available from global scope:
This configuration ensures that all the implemented APIs are available from global scope:

```js
const webpackConfig = {
plugins: [
new webpack.ProvidePlugin({
AbortController: ['dom-events-wintercg', 'AbortController'],
AbortSignal: ['dom-events-wintercg', 'AbortSignal'],
CustomEvent: ['dom-events-wintercg', 'CustomEvent'],
DOMException: ['dom-events-wintercg', 'DOMException'],
Event: ['dom-events-wintercg', 'Event'],
EventTarget: ['dom-events-wintercg', 'EventTarget'],
}),
Expand All @@ -155,7 +161,10 @@ Additionally, you can polyfill _some of_ the Node.js [events](https://nodejs.org
+ },
plugins: [
new webpack.ProvidePlugin({
AbortController: ['dom-events-wintercg', 'AbortController'],
AbortSignal: ['dom-events-wintercg', 'AbortSignal'],
CustomEvent: ['dom-events-wintercg', 'CustomEvent'],
DOMException: ['dom-events-wintercg', 'DOMException'],
Event: ['dom-events-wintercg', 'Event'],
EventTarget: ['dom-events-wintercg', 'EventTarget'],
}),
Expand All @@ -175,6 +184,17 @@ Your JS engine or runtime must support the following APIs:
- [WeakRef](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakRef)
- Basic [ESM](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) (`import` and `export`)

There are also some optional requirements for feature-completeness:

### [AbortSignal.timeout()](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static)

Requires:

- [setTimeout](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout)
- [clearTimeout](https://developer.mozilla.org/en-US/docs/Web/API/clearTimeout)

If missing, `AbortSignal.timeout()` will throw an Error with code `ERR_METHOD_NOT_IMPLEMENTED` if called.

## Differences from browser EventTarget

Beyond the differences explained in the Node.js [SDK docs](https://nodejs.org/api/events.html#nodejs-eventtarget-vs-dom-eventtarget), see this excellent article from NearForm about how they first [brought EventTarget to Node.js](https://www.nearform.com/insights/node-js-and-the-struggles-of-being-an-eventtarget/), which covers some of the compromises they had to make in the implementation. In particular, there is no concept of bubbling or capturing, and `event.preventDefault()` is a bit useless, as it never has a "default action" to prevent.
Expand Down
86 changes: 37 additions & 49 deletions src/abort-controller.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
'use strict';

// Modeled very closely on the AbortController implementation
// in https://github.com/mysticatea/abort-controller (MIT license)

const {
import {
ObjectAssign,
ObjectDefineProperties,
ObjectSetPrototypeOf,
Expand All @@ -14,9 +12,9 @@ const {
Symbol,
SymbolToStringTag,
WeakRef,
} = primordials;
} from './primordials.js';

const {
import {
defineEventHandler,
EventTarget,
Event,
Expand All @@ -25,57 +23,52 @@ const {
kRemoveListener,
kResistStopPropagation,
kWeakHandler,
} = require('internal/event_target');
const {
} from './event-target.js';
import {
createDeferredPromise,
customInspectSymbol,
kEmptyObject,
kEnumerableProperty,
} = require('internal/util');
const { inspect } = require('internal/util/inspect');
const {
codes: { ERR_ILLEGAL_CONSTRUCTOR, ERR_INVALID_ARG_TYPE, ERR_INVALID_THIS },
} = require('internal/errors');

const {
} from './util.js';
import { inspect } from './inspect.js';
import { assert, codes } from './errors.js';
import {
validateAbortSignal,
validateAbortSignalArray,
validateObject,
validateUint32,
kValidateObjectAllowArray,
kValidateObjectAllowFunction,
} = require('internal/validators');

const { DOMException } = internalBinding('messaging');

const { clearTimeout, setTimeout } = require('timers');
const assert = require('internal/assert');
} from './validators.js';
import { DOMException } from './dom-exception.js';

const {
ERR_ILLEGAL_CONSTRUCTOR,
ERR_INVALID_ARG_TYPE,
ERR_INVALID_THIS,
ERR_METHOD_NOT_IMPLEMENTED,
} = codes;

import {
kDeserialize,
kTransfer,
kTransferList,
} = require('internal/worker/js_transferable');

let _MessageChannel;
let markTransferMode;

// Loading the MessageChannel and markTransferable have to be done lazily
// because otherwise we'll end up with a require cycle that ends up with
// an incomplete initialization of abort_controller.
markTransferMode,
} from './js-transferable.js';
import { MessageChannel as NoOpMessageChannel } from './message-channel.js';

function lazyMessageChannel() {
_MessageChannel ??= require('internal/worker/io').MessageChannel;
return new _MessageChannel();
const MessageChannel = globalThis.MessageChannel ?? NoOpMessageChannel;
return new MessageChannel();
}

function lazyMarkTransferMode(obj, cloneable, transferable) {
markTransferMode ??=
require('internal/worker/js_transferable').markTransferMode;
markTransferMode(obj, cloneable, transferable);
}

const clearTimeoutRegistry = new SafeFinalizationRegistry(clearTimeout);
const clearTimeoutRegistry = new SafeFinalizationRegistry(
globalThis.clearTimeout,
);
const gcPersistentSignals = new SafeSet();

const kAborted = Symbol('kAborted');
Expand Down Expand Up @@ -110,7 +103,7 @@ function validateThisAbortSignal(obj) {
// the created timer object. Separately, we add the signal to a
// FinalizerRegistry that will clear the timeout when the signal is gc'd.
function setWeakAbortSignalTimeout(weakRef, delay) {
const timeout = setTimeout(() => {
const timeout = globalThis.setTimeout(() => {
const signal = weakRef.deref();
if (signal !== undefined) {
gcPersistentSignals.delete(signal);
Expand All @@ -127,7 +120,7 @@ function setWeakAbortSignalTimeout(weakRef, delay) {
return timeout;
}

class AbortSignal extends EventTarget {
export class AbortSignal extends EventTarget {
constructor() {
throw new ERR_ILLEGAL_CONSTRUCTOR();
}
Expand Down Expand Up @@ -181,6 +174,10 @@ class AbortSignal extends EventTarget {
* @returns {AbortSignal}
*/
static timeout(delay) {
if (!globalThis.setTimeout || !globalThis.clearTimeout) {
throw new ERR_METHOD_NOT_IMPLEMENTED('timeout()');
}

validateUint32(delay, 'delay', false);
const signal = createAbortSignal();
signal[kTimeout] = true;
Expand Down Expand Up @@ -324,7 +321,7 @@ class AbortSignal extends EventTarget {
}
}

function ClonedAbortSignal() {
export function ClonedAbortSignal() {
return createAbortSignal({ transferable: true });
}
ClonedAbortSignal.prototype[kDeserialize] = () => {};
Expand Down Expand Up @@ -384,7 +381,7 @@ function abortSignal(signal, reason) {
});
}

class AbortController {
export class AbortController {
#signal;

/**
Expand Down Expand Up @@ -425,7 +422,7 @@ class AbortController {
* @param {AbortSignal} signal
* @returns {AbortSignal}
*/
function transferableAbortSignal(signal) {
export function transferableAbortSignal(signal) {
if (signal?.[kAborted] === undefined)
throw new ERR_INVALID_ARG_TYPE('signal', 'AbortSignal', signal);
lazyMarkTransferMode(signal, false, true);
Expand All @@ -435,7 +432,7 @@ function transferableAbortSignal(signal) {
/**
* Creates an AbortController with a transferable AbortSignal
*/
function transferableAbortController() {
export function transferableAbortController() {
return AbortController[kMakeTransferable]();
}

Expand All @@ -444,7 +441,7 @@ function transferableAbortController() {
* @param {any} resource
* @returns {Promise<void>}
*/
async function aborted(signal, resource) {
export async function aborted(signal, resource) {
if (signal === undefined) {
throw new ERR_INVALID_ARG_TYPE('signal', 'AbortSignal', signal);
}
Expand Down Expand Up @@ -478,12 +475,3 @@ ObjectDefineProperty(AbortController.prototype, SymbolToStringTag, {
configurable: true,
value: 'AbortController',
});

module.exports = {
AbortController,
AbortSignal,
ClonedAbortSignal,
aborted,
transferableAbortSignal,
transferableAbortController,
};
10 changes: 3 additions & 7 deletions src/dom-exception.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
'use strict';

const {
import {
ErrorCaptureStackTrace,
ErrorPrototype,
ObjectDefineProperties,
Expand All @@ -11,7 +9,7 @@ const {
SafeSet,
SymbolToStringTag,
TypeError,
} = primordials;
} from './primordials.js';

function throwInvalidThisError(Base, type) {
const err = new Base();
Expand Down Expand Up @@ -48,7 +46,7 @@ const disusedNamesSet = new SafeSet()
.add('NoDataAllowedError')
.add('ValidationError');

class DOMException {
export class DOMException {
constructor(message = '', options = 'Error') {
ErrorCaptureStackTrace(this);

Expand Down Expand Up @@ -153,5 +151,3 @@ for (const { 0: name, 1: codeName, 2: value } of [
ObjectDefineProperty(DOMException.prototype, codeName, desc);
nameToCodeMap.set(name, value);
}

exports.DOMException = DOMException;
4 changes: 4 additions & 0 deletions src/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ export function hideStackFrames(fn) {
return fn;
}

E('ERR_ILLEGAL_CONSTRUCTOR', 'Illegal constructor', TypeError);

E(
'ERR_INVALID_ARG_TYPE',
(name, expected, actual) => {
Expand Down Expand Up @@ -275,6 +277,8 @@ E(

E('ERR_INVALID_THIS', 'Value of "this" must be of type %s', TypeError);

E('ERR_METHOD_NOT_IMPLEMENTED', 'The %s method is not implemented', Error);

E(
'ERR_MISSING_ARGS',
(...args) => {
Expand Down
10 changes: 10 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
export {
AbortController,
aborted,
AbortSignal,
ClonedAbortSignal,
transferableAbortController,
transferableAbortSignal,
} from './abort-controller.js';
export { DOMException } from './dom-exception.js';
export {
CustomEvent,
defineEventHandler,
Expand All @@ -6,4 +15,5 @@ export {
kWeakHandler,
} from './event-target.js';
export { setMaxListeners } from './events.js';
export { MessageChannel } from './message-channel.js';
export { polyfill } from './polyfill.js';
56 changes: 56 additions & 0 deletions src/js-transferable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
export const kDeserialize = Symbol('messaging_deserialize_symbol');
export const kTransfer = Symbol('messaging_transfer_symbol');
export const kTransferList = Symbol('messaging_transfer_list_symbol');

export const kDisallowCloneAndTransfer = Symbol('kDisallowCloneAndTransfer');
export const kCloneable = Symbol('kCloneable');
export const kTransferable = Symbol('kTransferable');
export const transfer_mode_private_symbol = Symbol('node:transfer_mode');

/**
* Mark an object as being transferable or customized cloneable in
* `.postMessage()`.
* This should only applied to host objects like Web API interfaces, Node.js'
* built-in objects.
* Objects marked as cloneable and transferable should implement the method
* `@@kClone` and `@@kTransfer` respectively. Method `@@kDeserialize` is
* required to deserialize the data to a new instance.
*
* Example implementation of a cloneable interface (assuming its located in
* `internal/my_interface.js`):
*
* ```
* class MyInterface {
* constructor(...args) {
* markTransferMode(this, true);
* this.args = args;
* }
* [kDeserialize](data) {
* this.args = data.args;
* }
* [kClone]() {
* return {
* data: { args: this.args },
* deserializeInfo: 'internal/my_interface:MyInterface',
* }
* }
* }
*
* module.exports = {
* MyInterface,
* };
* ```
* @param {object} obj Host objects that can be either cloned or transferred.
* @param {boolean} [cloneable] if the object can be cloned and `@@kClone` is
* implemented.
* @param {boolean} [transferable] if the object can be transferred and
* `@@kTransfer` is implemented.
*/
export function markTransferMode(obj, cloneable = false, transferable = false) {
if ((typeof obj !== 'object' && typeof obj !== 'function') || obj === null)
return; // This object is a primitive and therefore already untransferable.
let mode = kDisallowCloneAndTransfer;
if (cloneable) mode |= kCloneable;
if (transferable) mode |= kTransferable;
obj[transfer_mode_private_symbol] = mode;
}
Loading

0 comments on commit 94d845a

Please sign in to comment.