diff --git a/README.md b/README.md index dd5f43c..75bdc52 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,15 @@ # DOM Events implementation for WinterCG -A polyfill for the [DOM Events APIs](https://dom.spec.whatwg.org/#introduction-to-dom-events), including [CustomEvent](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent), [Event](https://developer.mozilla.org/en-US/docs/Web/API/Event), and [EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget). +A polyfill for: + +- the [DOM Events APIs](https://dom.spec.whatwg.org/#introduction-to-dom-events): + - [CustomEvent](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent) + - [Event](https://developer.mozilla.org/en-US/docs/Web/API/Event) + - [EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) +- and some related APIs: + - [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) + - [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) + - [DOMException](https://developer.mozilla.org/en-US/docs/Web/API/DOMException) Implementation extracted from the Node.js codebase [as of 10th March 2024](https://github.com/nodejs/node/blob/575ced813988af00478aa1e6759487888c607238/lib/internal/event_target.js) (version `21.7.1`, I believe). @@ -171,6 +180,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. diff --git a/src/abort-controller.js b/src/abort-controller.js index e0de48a..806d2af 100644 --- a/src/abort-controller.js +++ b/src/abort-controller.js @@ -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, @@ -14,9 +12,9 @@ const { Symbol, SymbolToStringTag, WeakRef, -} = primordials; +} from './primordials.js'; -const { +import { defineEventHandler, EventTarget, Event, @@ -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'); @@ -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); @@ -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(); } @@ -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; @@ -324,7 +321,7 @@ class AbortSignal extends EventTarget { } } -function ClonedAbortSignal() { +export function ClonedAbortSignal() { return createAbortSignal({ transferable: true }); } ClonedAbortSignal.prototype[kDeserialize] = () => {}; @@ -384,7 +381,7 @@ function abortSignal(signal, reason) { }); } -class AbortController { +export class AbortController { #signal; /** @@ -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); @@ -435,7 +432,7 @@ function transferableAbortSignal(signal) { /** * Creates an AbortController with a transferable AbortSignal */ -function transferableAbortController() { +export function transferableAbortController() { return AbortController[kMakeTransferable](); } @@ -444,7 +441,7 @@ function transferableAbortController() { * @param {any} resource * @returns {Promise} */ -async function aborted(signal, resource) { +export async function aborted(signal, resource) { if (signal === undefined) { throw new ERR_INVALID_ARG_TYPE('signal', 'AbortSignal', signal); } @@ -478,12 +475,3 @@ ObjectDefineProperty(AbortController.prototype, SymbolToStringTag, { configurable: true, value: 'AbortController', }); - -module.exports = { - AbortController, - AbortSignal, - ClonedAbortSignal, - aborted, - transferableAbortSignal, - transferableAbortController, -}; diff --git a/src/dom-exception.js b/src/dom-exception.js index 951382c..b548b15 100644 --- a/src/dom-exception.js +++ b/src/dom-exception.js @@ -1,6 +1,4 @@ -'use strict'; - -const { +import { ErrorCaptureStackTrace, ErrorPrototype, ObjectDefineProperties, @@ -11,7 +9,7 @@ const { SafeSet, SymbolToStringTag, TypeError, -} = primordials; +} from './primordials.js'; function throwInvalidThisError(Base, type) { const err = new Base(); @@ -48,7 +46,7 @@ const disusedNamesSet = new SafeSet() .add('NoDataAllowedError') .add('ValidationError'); -class DOMException { +export class DOMException { constructor(message = '', options = 'Error') { ErrorCaptureStackTrace(this); @@ -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; diff --git a/src/errors.js b/src/errors.js index 4aa6a6b..08d7df9 100644 --- a/src/errors.js +++ b/src/errors.js @@ -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) => { @@ -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) => { diff --git a/src/index.js b/src/index.js index c3cbf66..5fc659f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,4 @@ +export { AbortController } from './abort-controller.js'; export { CustomEvent, defineEventHandler, diff --git a/src/js-transferable.js b/src/js-transferable.js new file mode 100644 index 0000000..8bac0fb --- /dev/null +++ b/src/js-transferable.js @@ -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; +} diff --git a/src/message-channel.js b/src/message-channel.js new file mode 100644 index 0000000..5089adb --- /dev/null +++ b/src/message-channel.js @@ -0,0 +1,26 @@ +import { EventTarget } from './event-target'; + +/** + * A dummy no-op MessagePort implementation. + */ +class MessagePort extends EventTarget { + onmessage = null; + onmessageerror = null; + close() {} + postMessage() {} + start() {} +} + +/** + * A dummy no-op MessageChannel implementation. + */ +export class MessageChannel { + port1 = { + port: new MessagePort(), + unref: () => this.port1.port, + }; + port2 = { + port: new MessagePort(), + unref: () => this.port2.port, + }; +} diff --git a/src/primordials.js b/src/primordials.js index 4692e40..ce689c3 100644 --- a/src/primordials.js +++ b/src/primordials.js @@ -14,6 +14,9 @@ export function ArrayIsArray(self) { } export const Boolean = globalThis.Boolean; export const Error = globalThis.Error; +/** No-op, because this API is unique to V8 */ +export const ErrorCaptureStackTrace = () => {}; +export const ErrorPrototype = globalThis.Error.prototype; export function FunctionPrototypeCall(fn, thisArgs, ...args) { return fn.call(thisArgs, ...args); } @@ -47,6 +50,11 @@ export function ObjectDefineProperty(self, name, prop) { export function ObjectGetOwnPropertyDescriptor(self, name) { return Object.getOwnPropertyDescriptor(self, name); } +export function ObjectSetPrototypeOf(target, proto) { + return Object.setPrototypeOf(target, proto); +} +export const Promise = globalThis.Promise; +export const PromiseResolve = globalThis.Promise.resolve; export const ReflectApply = globalThis.Reflect.apply; export const SafeFinalizationRegistry = globalThis.FinalizationRegistry; export const SafeMap = globalThis.Map; @@ -59,3 +67,4 @@ export const Symbol = globalThis.Symbol; export const SymbolFor = Symbol.for; export const SymbolToStringTag = Symbol.toStringTag; export const TypeError = globalThis.TypeError; +export const WeakRef = globalThis.WeakRef; diff --git a/src/util.js b/src/util.js index fe62c68..6acc3f7 100644 --- a/src/util.js +++ b/src/util.js @@ -1,4 +1,9 @@ -import { ObjectFreeze, SymbolFor, NumberIsNaN } from './primordials.js'; +import { + ObjectFreeze, + SymbolFor, + NumberIsNaN, + Promise, +} from './primordials.js'; export const customInspectSymbol = SymbolFor('nodejs.util.inspect.custom'); export const kEnumerableProperty = { __proto__: null }; @@ -7,6 +12,17 @@ ObjectFreeze(kEnumerableProperty); export const kEmptyObject = ObjectFreeze({ __proto__: null }); +export function createDeferredPromise() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + export function format(format, ...args) { // Simplified version of https://nodejs.org/api/util.html#utilformatformat-args return format.replace(/%([sdifj])/g, function (...[_unused, type]) { diff --git a/src/validators.js b/src/validators.js index d5e0c30..e0fd402 100644 --- a/src/validators.js +++ b/src/validators.js @@ -58,6 +58,26 @@ export const validateArray = hideStackFrames((value, name, minLength = 0) => { } }); +/** + * @callback validateAbortSignalArray + * @param {*} value + * @param {string} name + * @returns {asserts value is AbortSignal[]} + */ + +/** @type {validateAbortSignalArray} */ +export function validateAbortSignalArray(value, name) { + validateArray(value, name); + for (let i = 0; i < value.length; i++) { + const signal = value[i]; + const indexedName = `${name}[${i}]`; + if (signal == null) { + throw new ERR_INVALID_ARG_TYPE(indexedName, 'AbortSignal', signal); + } + validateAbortSignal(signal, indexedName); + } +} + /** * @callback validateAbortSignal * @param {*} signal