diff --git a/benchmark/events/eventtarget.js b/benchmark/events/eventtarget.js new file mode 100644 index 00000000000000..7a7253aefb1347 --- /dev/null +++ b/benchmark/events/eventtarget.js @@ -0,0 +1,24 @@ +'use strict'; +const common = require('../common.js'); + +const bench = common.createBenchmark(main, { + n: [2e7], + listeners: [1, 5, 10] +}, { flags: ['--expose-internals'] }); + +function main({ n, listeners }) { + const { EventTarget, Event } = require('internal/event_target'); + const target = new EventTarget(); + + for (let n = 0; n < listeners; n++) + target.addEventListener('foo', () => {}); + + const event = new Event('foo'); + + bench.start(); + for (let i = 0; i < n; i++) { + target.dispatchEvent(event); + } + bench.end(n); + +} diff --git a/doc/api/errors.md b/doc/api/errors.md index 1cf926c84be4a2..6f717b382543a6 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -903,6 +903,11 @@ Encoding provided to `TextDecoder()` API was not one of the `--print` cannot be used with ESM input. + +### `ERR_EVENT_RECURSION` + +Thrown when an attempt is made to recursively dispatch an event on `EventTarget`. + ### `ERR_EXECUTION_ENVIRONMENT_NOT_AVAILABLE` diff --git a/doc/api/events.md b/doc/api/events.md index a30e2abb01353c..ed13d3f5e84757 100644 --- a/doc/api/events.md +++ b/doc/api/events.md @@ -935,6 +935,462 @@ if the `EventEmitter` emits `'error'`. It removes all listeners when exiting the loop. The `value` returned by each iteration is an array composed of the emitted event arguments. +## `EventTarget` and `Event` API + + +> Stability: 1 - Experimental + +The `EventTarget` and `Event` objects are a Node.js-specific implementation +of the [`EventTarget` Web API][] that are exposed by some Node.js core APIs. +Neither the `EventTarget` nor `Event` classes are currently available for end +user code to create. + +```js +const target = getEventTargetSomehow(); + +target.addEventListener('foo', (event) => { + console.log('foo event happened!'); +}); +``` + +### Node.js `EventTarget` vs. DOM `EventTarget` + +There are two key differences between the Node.js `EventTarget` and the +[`EventTarget` Web API][]: + +1. Whereas DOM `EventTarget` instances *may* be hierarchical, there is no + concept of hierarchy and event propagation in Node.js. That is, an event + dispatched to an `EventTarget` does not propagate through a hierarchy of + nested target objects that may each have their own set of handlers for the + event. +2. In the Node.js `EventTarget`, if an event listener is an async function + or returns a `Promise`, and the returned `Promise` rejects, the rejection + will be automatically captured and handled the same way as a listener that + throws synchronously (see [`EventTarget` Error Handling][] for details). + +### `NodeEventTarget` vs. `EventEmitter` + +The `NodeEventTarget` object implements a modified subset of the +`EventEmitter` API that allows it to closely *emulate* an `EventEmitter` in +certain situations. It is important to understand, however, that an +`NodeEventTarget` is *not* an instance of `EventEmitter` and cannot be used in +place of an `EventEmitter` in most cases. + +1. Unlike `EventEmitter`, any given `listener` can be registered at most once + per event `type`. Attempts to register a `listener` multiple times will be + ignored. +2. The `NodeEventTarget` does not emulate the full `EventEmitter` API. + Specifically the `prependListener()`, `prependOnceListener()`, + `rawListeners()`, `setMaxListeners()`, `getMaxListeners()`, and + `errorMonitor` APIs are not emulated. The `'newListener'` and + `'removeListener'` events will also not be emitted. +3. The `NodeEventTarget` does not implement any special default behavior + for events with type `'error'`. +3. The `NodeEventTarget` supports `EventListener` objects as well as + functions as handlers for all event types. + +### Event Listener + +Event listeners registered for an event `type` may either be JavaScript +functions or objects with a `handleEvent` property whose value is a function. + +In either case, the handler function will be invoked with the `event` argument +passed to the `eventTarget.dispatchEvent()` function. + +Async functions may be used as event listeners. If an async handler function +rejects, the rejection will be captured and be will handled as described in +[`EventTarget` Error Handling][]. + +An error thrown by one handler function will not prevent the other handlers +from being invoked. + +The return value of a handler function will be ignored. + +Handlers are always invoked in the order they were added. + +Handler functions may mutate the `event` object. + +```js +function handler1(event) { + console.log(event.type); // Prints 'foo' + event.a = 1; +} + +async function handler2(event) { + console.log(event.type); // Prints 'foo' + console.log(event.a); // Prints 1 +} + +const handler3 = { + handleEvent(event) { + console.log(event.type); // Prints 'foo' + } +}; + +const handler4 = { + async handleEvent(event) { + console.log(event.type); // Prints 'foo' + } +}; + +const target = getEventTargetSomehow(); + +target.addEventListener('foo', handler1); +target.addEventListener('foo', handler2); +target.addEventListener('foo', handler3); +target.addEventListener('foo', handler4, { once: true }); +``` + +### `EventTarget` Error Handling + +When a registered event listener throws (or returns a Promise that rejects), +by default the error will be forwarded to the `process.on('error')` event +on `process.nextTick()`. Throwing within an event listener will *not* stop +the other registered handlers from being invoked. + +The `EventTarget` does not implement any special default handling for +`'error'` type events. + +### Class: `Event` + + +The `Event` object is an adaptation of the [`Event` Web API][]. Instances +are created internally by Node.js. + +#### `event.bubbles` + + +* Type: {boolean} Always returns `false`. + +This is not used in Node.js and is provided purely for completeness. + +#### `event.cancelBubble()` + + +Alias for `event.stopPropagation()`. This is not used in Node.js and is +provided purely for completeness. + +#### `event.cancelable` + + +* Type: {boolean} True if the event was created with the `cancelable` option. + +#### `event.composed` + + +* Type: {boolean} Always returns `false`. + +This is not used in Node.js and is provided purely for completeness. + +#### `event.composedPath()` + + +Returns an array containing the current `EventTarget` as the only entry or +empty if the event is not currently being dispatched. This is not used in +Node.js and is provided purely for completeness. + +#### `event.currentTarget` + + +* Type: {EventTarget} The `EventTarget` currently dispatching the event. + +Alias for `event.target`. + +#### `event.defaultPrevented` + + +* Type: {boolean} + +Will be `true` if `cancelable` is `true` and `event.preventDefault()` has been +called. + +#### `event.eventPhase` + + +* Type: {number} Returns `0` while an event is not being dispatched, `2` while + it is being dispatched. + +This is not used in Node.js and is provided purely for completeness. + +#### `event.isTrusted` + + +* Type: {boolean} Always returns `false`. + +This is not used in Node.js and is provided purely for completeness. + +#### `event.preventDefault()` + + +Sets the `defaultPrevented` property to `true` if `cancelable` is `true`. + +#### `event.returnValue` + + +* Type: {boolean} True if the event has not been canceled. + +This is not used in Node.js and is provided purely for completeness. + +#### `event.srcElement` + + +* Type: {EventTarget} The `EventTarget` currently dispatching the event. + +Alias for `event.target`. + +#### `event.stopImmediatePropagation()` + + +Stops the invocation of event listeners after the current one completes. + +#### `event.stopPropagation()` + + +This is not used in Node.js and is provided purely for completeness. + +#### `event.target` + + +* Type: {EventTarget} The `EventTarget` currently dispatching the event. + +#### `event.timeStamp` + + +* Type: {number} + +The millisecond timestamp when the `Event` object was created. + +#### `event.type` + + +* Type: {string} + +The event type identifier. + +### Class: `EventTarget` + + +#### `eventTarget.addEventListener(type, listener[, options])` + + +* `type` {string} +* `listener` {Function|EventListener} +* `options` {Object} + * `once` {boolean} When `true`, the listener will be automatically removed + when it is first invoked. *Default*: `false` + * `passive` {boolean} When `true`, serves as a hint that the listener will + not call the `Event` object's `preventDefault()` method. *Default*: `false` + * `capture` {boolean} Not directly used by Node.js. Added for API + completeness. *Default*: `false` + +Adds a new handler for the `type` event. Any given `listener` will be added +only once per `type` and per `capture` option value. + +If the `once` option is `true`, the `listener` will be removed after the +next time a `type` event is dispatched. + +The `capture` option is not used by Node.js in any functional way other than +tracking registered event listeners per the `EventTarget` specification. +Specifically, the `capture` option is used as part of the key when registering +a `listener`. Any individual `listener` may be added once with +`capture = false`, and once with `capture = true`. + +```js +function handler(event) {} + +const target = getEventTargetSomehow(); +target.addEventListener('foo', handler, { capture: true }); // first +target.addEventListener('foo', handler, { capture: false }); // second + +// Removes the second instance of handler +target.removeEventListener('foo', handler); + +// Removes the first instance of handler +target.removeEventListener('foo', handler, { capture: true }); +``` + +#### `eventTarget.dispatchEvent(event)` + + +* `event` {Object|Event} + +Dispatches the `event` to the list of handlers for `event.type`. The `event` +may be an `Event` object or any object with a `type` property whose value is +a `string`. + +The registered event listeners will be synchronously invoked in the order they +were registered. + +#### `eventTarget.removeEventListener(type, listener)` + + +* `type` {string} +* `listener` {Function|EventListener} +* `options` {Object} + * `capture` {boolean} + +Removes the `listener` from the list of handlers for event `type`. + +### Class: `NodeEventTarget extends EventTarget` + + +The `NodeEventTarget` is a Node.js-specific extension to `EventTarget` +that emulates a subset of the `EventEmitter` API. + +#### `nodeEventTarget.addListener(type, listener[, options])` + + +* `type` {string} +* `listener` {Function|EventListener} +* `options` {Object} + * `once` {boolean} + +* Returns: {EventTarget} this + +Node.js-specific extension to the `EventTarget` class that emulates the +equivalent `EventEmitter` API. The only difference between `addListener()` and +`addEventListener()` is that `addListener()` will return a reference to the +`EventTarget`. + +#### `nodeEventTarget.eventNames()` + + +* Returns: {string[]} + +Node.js-specific extension to the `EventTarget` class that returns an array +of event `type` names for which event listeners are currently registered. + +#### `nodeEventTarget.listenerCount(type)` + + +* `type` {string} + +* Returns: {number} + +Node.js-specific extension to the `EventTarget` class that returns the number +of event listeners registered for the `type`. + +#### `nodeEventTarget.off(type, listener)` + + +* `type` {string} +* `listener` {Function|EventListener} + +* Returns: {EventTarget} this + +Node.js-speciic alias for `eventTarget.removeListener()`. + +#### `nodeEventTarget.on(type, listener[, options])` + + +* `type` {string} +* `listener` {Function|EventListener} +* `options` {Object} + * `once` {boolean} + +* Returns: {EventTarget} this + +Node.js-specific alias for `eventTarget.addListener()`. + +#### `nodeEventTarget.once(type, listener[, options])` + + +* `type` {string} +* `listener` {Function|EventListener} +* `options` {Object} + +* Returns: {EventTarget} this + +Node.js-specific extension to the `EventTarget` class that adds a `once` +listener for the given event `type`. This is equivalent to calling `on` +with the `once` option set to `true`. + +#### `nodeEventTarget.removeAllListeners([type])` + + +* `type` {string} + +Node.js-specific extension to the `EventTarget` class. If `type` is specified, +removes all registered listeners for `type`, otherwise removes all registered +listeners. + +#### `nodeEventTarget.removeListener(type, listener)` + + +* `type` {string} +* `listener` {Function|EventListener} + +* Returns: {EventTarget} this + +Node.js-specific extension to the `EventTarget` class that removes the +`listener` for the given `type`. The only difference between `removeListener()` +and `removeEventListener()` is that `removeListener()` will return a reference +to the `EventTarget`. + [WHATWG-EventTarget]: https://dom.spec.whatwg.org/#interface-eventtarget [`--trace-warnings`]: cli.html#cli_trace_warnings [`EventEmitter.defaultMaxListeners`]: #events_eventemitter_defaultmaxlisteners @@ -942,6 +1398,9 @@ composed of the emitted event arguments. [`emitter.listenerCount()`]: #events_emitter_listenercount_eventname [`emitter.removeListener()`]: #events_emitter_removelistener_eventname_listener [`emitter.setMaxListeners(n)`]: #events_emitter_setmaxlisteners_n +[`Event` Web API]: https://dom.spec.whatwg.org/#event +[`EventTarget` Error Handling]: #events_eventtarget_error_handling +[`EventTarget` Web API]: https://dom.spec.whatwg.org/#eventtarget [`fs.ReadStream`]: fs.html#fs_class_fs_readstream [`net.Server`]: net.html#net_class_net_server [`process.on('warning')`]: process.html#process_event_warning diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 56724ba53ed092..7eaf46b336b785 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -825,6 +825,7 @@ E('ERR_ENCODING_INVALID_ENCODED_DATA', function(encoding, ret) { E('ERR_ENCODING_NOT_SUPPORTED', 'The "%s" encoding is not supported', RangeError); E('ERR_EVAL_ESM_CANNOT_PRINT', '--print cannot be used with ESM input', Error); +E('ERR_EVENT_RECURSION', 'The event "%s" is already being dispatched', Error); E('ERR_FALSY_VALUE_REJECTION', function(reason) { this.reason = reason; return 'Promise was rejected with falsy value'; diff --git a/lib/internal/event_target.js b/lib/internal/event_target.js new file mode 100644 index 00000000000000..4cc561f95c0bba --- /dev/null +++ b/lib/internal/event_target.js @@ -0,0 +1,434 @@ +'use strict'; + +const { + ArrayFrom, + Error, + Map, + Object, + Set, + Symbol, + NumberIsNaN, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_EVENT_RECURSION, + ERR_OUT_OF_RANGE, + } +} = require('internal/errors'); + +const perf_hooks = require('perf_hooks'); +const { customInspectSymbol } = require('internal/util'); +const { inspect } = require('util'); + +const kEvents = Symbol('kEvents'); +const kStop = Symbol('kStop'); +const kTarget = Symbol('kTarget'); + +const kNewListener = Symbol('kNewListener'); +const kRemoveListener = Symbol('kRemoveListener'); + +class Event { + #type = undefined; + #defaultPrevented = false; + #cancelable = false; + #timestamp = perf_hooks.performance.now(); + + // Neither of these are currently used in the Node.js implementation + // of EventTarget because there is no concept of bubbling or + // composition. We preserve their values in Event but they are + // non-ops and do not carry any semantics in Node.js + #bubbles = false; + #composed = false; + + + constructor(type, options) { + if (options != null && typeof options !== 'object') + throw new ERR_INVALID_ARG_TYPE('options', 'object', options); + const { cancelable, bubbles, composed } = { ...options }; + this.#cancelable = !!cancelable; + this.#bubbles = !!bubbles; + this.#composed = !!composed; + this.#type = String(type); + } + + [customInspectSymbol](depth, options) { + const name = this.constructor.name; + if (depth < 0) + return name; + + const opts = Object.assign({}, options, { + dept: options.depth === null ? null : options.depth - 1 + }); + + return `${name} ${inspect({ + type: this.#type, + defaultPrevented: this.#defaultPrevented, + cancelable: this.#cancelable, + timeStamp: this.#timestamp, + }, opts)}`; + } + + stopImmediatePropagation() { + this[kStop] = true; + } + + preventDefault() { + this.#defaultPrevented = true; + } + + get target() { return this[kTarget]; } + get currentTarget() { return this[kTarget]; } + get srcElement() { return this[kTarget]; } + + get type() { return this.#type; } + + get cancelable() { return this.#cancelable; } + + get defaultPrevented() { return this.#cancelable && this.#defaultPrevented; } + + get timeStamp() { return this.#timestamp; } + + + // The following are non-op and unused properties/methods from Web API Event. + // These are not supported in Node.js and are provided purely for + // API completeness. + + composedPath() { return this[kTarget] ? [this[kTarget]] : []; } + get returnValue() { return !this.defaultPrevented; } + get bubbles() { return this.#bubbles; } + get composed() { return this.#composed; } + get isTrusted() { return false; } + get eventPhase() { + return this[kTarget] ? 2 : 0; // Equivalent to AT_TARGET or NONE + } + cancelBubble() { + // Non-op in Node.js. Alias for stopPropagation + } + stopPropagation() { + // Non-op in Node.js + } + + get [Symbol.toStringTag]() { return 'Event'; } +} + +// The listeners for an EventTarget are maintained as a linked list. +// Unfortunately, the way EventTarget is defined, listeners are accounted +// using the tuple [handler,capture], and even if we don't actually make +// use of capture or bubbling, in order to be spec compliant we have to +// take on the additional complexity of supporting it. Fortunately, using +// the linked list makes dispatching faster, even if adding/removing is +// slower. +class Listener { + next; + previous; + listener; + callback; + once; + capture; + passive; + + constructor(previous, listener, once, capture, passive) { + if (previous !== undefined) + previous.next = this; + this.previous = previous; + this.listener = listener; + this.once = once; + this.capture = capture; + this.passive = passive; + + this.callback = + typeof listener === 'function' ? + listener : + listener.handleEvent.bind(listener); + } + + same(listener, capture) { + return this.listener === listener && this.capture === capture; + } + + remove() { + if (this.previous !== undefined) + this.previous.next = this.next; + if (this.next !== undefined) + this.next.previous = this.previous; + } +} + +class EventTarget { + [kEvents] = new Map(); + #emitting = new Set(); + + [kNewListener](size, type, listener, once, capture, passive) {} + [kRemoveListener](size, type, listener, capture) {} + + addEventListener(type, listener, options = {}) { + validateListener(listener); + type = String(type); + + const { + once, + capture, + passive + } = validateEventListenerOptions(options); + + let root = this[kEvents].get(type); + + if (root === undefined) { + root = { size: 1, next: undefined }; + // This is the first handler in our linked list. + new Listener(root, listener, once, capture, passive); + this[kNewListener](root.size, type, listener, once, capture, passive); + this[kEvents].set(type, root); + return; + } + + let handler = root.next; + let previous; + + // We have to walk the linked list to see if we have a match + while (handler !== undefined && !handler.same(listener, capture)) { + previous = handler; + handler = handler.next; + } + + if (handler !== undefined) { // Duplicate! Ignore + return; + } + + new Listener(previous, listener, once, capture, passive); + root.size++; + this[kNewListener](root.size, type, listener, once, capture, passive); + } + + removeEventListener(type, listener, options = {}) { + validateListener(listener); + type = String(type); + const { capture } = validateEventListenerOptions(options); + const root = this[kEvents].get(type); + if (root === undefined || root.next === undefined) + return; + + let handler = root.next; + while (handler !== undefined) { + if (handler.same(listener, capture)) { + handler.remove(); + root.size--; + if (root.size === 0) + this[kEvents].delete(type); + this[kRemoveListener](root.size, type, listener, capture); + break; + } + handler = handler.next; + } + } + + dispatchEvent(event) { + if (!(event instanceof Event)) { + throw new ERR_INVALID_ARG_TYPE('event', 'Event', event); + } + + if (this.#emitting.has(event.type) || + event[kTarget] !== undefined) { + throw new ERR_EVENT_RECURSION(event.type); + } + + const root = this[kEvents].get(event.type); + if (root === undefined || root.next === undefined) + return true; + + event[kTarget] = this; + this.#emitting.add(event.type); + + let handler = root.next; + let next; + + while (handler !== undefined && + (handler.passive || event[kStop] !== true)) { + // Cache the next item in case this iteration removes the current one + next = handler.next; + + if (handler.once) { + handler.remove(); + root.size--; + } + + try { + const result = handler.callback.call(this, event); + if (result !== undefined && result !== null) + addCatch(this, result, event); + } catch (err) { + emitUnhandledRejectionOrErr(this, err, event); + } + + handler = next; + } + + this.#emitting.delete(event.type); + event[kTarget] = undefined; + + return event.defaultPrevented === true ? false : true; + } + + [customInspectSymbol](depth, options) { + const name = this.constructor.name; + if (depth < 0) + return name; + + const opts = Object.assign({}, options, { + dept: options.depth === null ? null : options.depth - 1 + }); + + return `${name} ${inspect({}, opts)}`; + } +} + +Object.defineProperties(EventTarget.prototype, { + addEventListener: { enumerable: true }, + removeEventListener: { enumerable: true }, + dispatchEvent: { enumerable: true } +}); + +class NodeEventTarget extends EventTarget { + static defaultMaxListeners = 10; + + #maxListeners = NodeEventTarget.defaultMaxListeners; + #maxListenersWarned = false; + + [kNewListener](size, type, listener, once, capture, passive) { + if (this.#maxListeners > 0 && + size > this.#maxListeners && + !this.#maxListenersWarned) { + this.#maxListenersWarned = true; + // No error code for this since it is a Warning + // eslint-disable-next-line no-restricted-syntax + const w = new Error('Possible EventTarget memory leak detected. ' + + `${size} ${type} listeners ` + + `added to ${inspect(this, { depth: -1 })}. Use ` + + 'setMaxListeners() to increase limit'); + w.name = 'MaxListenersExceededWarning'; + w.target = this; + w.type = type; + w.count = size; + process.emitWarning(w); + } + } + + setMaxListeners(n) { + if (typeof n !== 'number' || n < 0 || NumberIsNaN(n)) { + throw new ERR_OUT_OF_RANGE('n', 'a non-negative number', n); + } + this.#maxListeners = n; + return this; + } + + getMaxListeners() { + return this.#maxListeners; + } + + eventNames() { + return ArrayFrom(this[kEvents].keys()); + } + + listenerCount(type) { + const root = this[kEvents].get(String(type)); + return root !== undefined ? root.size : 0; + } + + off(type, listener, options) { + this.removeEventListener(type, listener, options); + return this; + } + + removeListener(type, listener, options) { + this.removeEventListener(type, listener, options); + return this; + } + + on(type, listener) { + this.addEventListener(type, listener); + return this; + } + + addListener(type, listener) { + this.addEventListener(type, listener); + return this; + } + + once(type, listener) { + this.addEventListener(type, listener, { once: true }); + return this; + } + + removeAllListeners(type) { + if (type !== undefined) { + this[kEvents].delete(String(type)); + } else { + this[kEvents].clear(); + } + } +} + +Object.defineProperties(NodeEventTarget.prototype, { + setMaxListeners: { enumerable: true }, + getMaxListeners: { enumerable: true }, + eventNames: { enumerable: true }, + listenerCount: { enumerable: true }, + off: { enumerable: true }, + removeListener: { enumerable: true }, + on: { enumerable: true }, + addListener: { enumerable: true }, + once: { enumerable: true }, + removeAllListeners: { enumerable: true }, +}); + +// EventTarget API + +function validateListener(listener) { + if (typeof listener === 'function' || + (listener != null && + typeof listener === 'object' && + typeof listener.handleEvent === 'function')) { + return; + } + throw new ERR_INVALID_ARG_TYPE('listener', 'EventListener', listener); +} + +function validateEventListenerOptions(options) { + if (options == null || typeof options !== 'object') + throw new ERR_INVALID_ARG_TYPE('options', 'object', options); + const { + once = false, + capture = false, + passive = false, + } = options; + return { + once: !!once, + capture: !!capture, + passive: !!passive, + }; +} + +function addCatch(that, promise, event) { + const then = promise.then; + if (typeof then === 'function') { + then.call(promise, undefined, function(err) { + // The callback is called with nextTick to avoid a follow-up + // rejection from this promise. + process.nextTick(emitUnhandledRejectionOrErr, that, err, event); + }); + } +} + +function emitUnhandledRejectionOrErr(that, err, event) { + process.emit('error', err, event); +} + +// EventEmitter-ish API: + +module.exports = { + Event, + EventTarget, + NodeEventTarget, +}; diff --git a/node.gyp b/node.gyp index 2c1a26c28cc79f..957ed7106cee61 100644 --- a/node.gyp +++ b/node.gyp @@ -129,6 +129,7 @@ 'lib/internal/encoding.js', 'lib/internal/errors.js', 'lib/internal/error-serdes.js', + 'lib/internal/event_target.js', 'lib/internal/fixed_queue.js', 'lib/internal/freelist.js', 'lib/internal/freeze_intrinsics.js', diff --git a/test/parallel/test-eventtarget.js b/test/parallel/test-eventtarget.js new file mode 100644 index 00000000000000..3b44714cfbe2dc --- /dev/null +++ b/test/parallel/test-eventtarget.js @@ -0,0 +1,386 @@ +// Flags: --expose-internals --no-warnings +'use strict'; + +const common = require('../common'); +const { + Event, + EventTarget, + NodeEventTarget, +} = require('internal/event_target'); + +const { + ok, + deepStrictEqual, + strictEqual, + throws, +} = require('assert'); + +const { once } = require('events'); + +// The globals are defined. +ok(Event); +ok(EventTarget); + +// First, test Event +{ + const ev = new Event('foo'); + strictEqual(ev.type, 'foo'); + strictEqual(ev.cancelable, false); + strictEqual(ev.defaultPrevented, false); + strictEqual(typeof ev.timeStamp, 'number'); + + deepStrictEqual(ev.composedPath(), []); + strictEqual(ev.returnValue, true); + strictEqual(ev.bubbles, false); + strictEqual(ev.composed, false); + strictEqual(ev.isTrusted, false); + strictEqual(ev.eventPhase, 0); + + // Not cancelable + ev.preventDefault(); + strictEqual(ev.defaultPrevented, false); +} + +{ + const ev = new Event('foo', { cancelable: true }); + strictEqual(ev.type, 'foo'); + strictEqual(ev.cancelable, true); + strictEqual(ev.defaultPrevented, false); + + ev.preventDefault(); + strictEqual(ev.defaultPrevented, true); +} + +{ + const eventTarget = new EventTarget(); + + const ev1 = common.mustCall(function(event) { + strictEqual(event.type, 'foo'); + strictEqual(this, eventTarget); + strictEqual(event.eventPhase, 2); + }, 2); + + const ev2 = { + handleEvent: common.mustCall(function(event) { + strictEqual(event.type, 'foo'); + strictEqual(this, ev2); + }) + }; + + eventTarget.addEventListener('foo', ev1); + eventTarget.addEventListener('foo', ev2, { once: true }); + ok(eventTarget.dispatchEvent(new Event('foo'))); + eventTarget.dispatchEvent(new Event('foo')); + + eventTarget.removeEventListener('foo', ev1); + eventTarget.dispatchEvent(new Event('foo')); +} + +{ + const eventTarget = new NodeEventTarget(); + strictEqual(eventTarget.listenerCount('foo'), 0); + deepStrictEqual(eventTarget.eventNames(), []); + + const ev1 = common.mustCall(function(event) { + strictEqual(event.type, 'foo'); + strictEqual(this, eventTarget); + }, 2); + + const ev2 = { + handleEvent: common.mustCall(function(event) { + strictEqual(event.type, 'foo'); + strictEqual(this, ev2); + }) + }; + + eventTarget.addEventListener('foo', ev1); + eventTarget.addEventListener('foo', ev2, { once: true }); + strictEqual(eventTarget.listenerCount('foo'), 2); + ok(eventTarget.dispatchEvent(new Event('foo'))); + strictEqual(eventTarget.listenerCount('foo'), 1); + eventTarget.dispatchEvent(new Event('foo')); + + eventTarget.removeEventListener('foo', ev1); + strictEqual(eventTarget.listenerCount('foo'), 0); + eventTarget.dispatchEvent(new Event('foo')); +} + + +{ + const eventTarget = new EventTarget(); + const event = new Event('foo', { cancelable: true }); + eventTarget.addEventListener('foo', (event) => event.preventDefault()); + ok(!eventTarget.dispatchEvent(event)); +} + +{ + const eventTarget = new NodeEventTarget(); + strictEqual(eventTarget.listenerCount('foo'), 0); + deepStrictEqual(eventTarget.eventNames(), []); + + const ev1 = common.mustCall((event) => { + strictEqual(event.type, 'foo'); + }, 2); + + const ev2 = { + handleEvent: common.mustCall((event) => { + strictEqual(event.type, 'foo'); + }) + }; + + strictEqual(eventTarget.on('foo', ev1), eventTarget); + strictEqual(eventTarget.once('foo', ev2, { once: true }), eventTarget); + strictEqual(eventTarget.listenerCount('foo'), 2); + eventTarget.dispatchEvent(new Event('foo')); + strictEqual(eventTarget.listenerCount('foo'), 1); + eventTarget.dispatchEvent(new Event('foo')); + + strictEqual(eventTarget.off('foo', ev1), eventTarget); + strictEqual(eventTarget.listenerCount('foo'), 0); + eventTarget.dispatchEvent(new Event('foo')); +} + +{ + const eventTarget = new NodeEventTarget(); + strictEqual(eventTarget.listenerCount('foo'), 0); + deepStrictEqual(eventTarget.eventNames(), []); + + const ev1 = common.mustCall((event) => { + strictEqual(event.type, 'foo'); + }, 2); + + const ev2 = { + handleEvent: common.mustCall((event) => { + strictEqual(event.type, 'foo'); + }) + }; + + eventTarget.addListener('foo', ev1); + eventTarget.once('foo', ev2, { once: true }); + strictEqual(eventTarget.listenerCount('foo'), 2); + eventTarget.dispatchEvent(new Event('foo')); + strictEqual(eventTarget.listenerCount('foo'), 1); + eventTarget.dispatchEvent(new Event('foo')); + + eventTarget.removeListener('foo', ev1); + strictEqual(eventTarget.listenerCount('foo'), 0); + eventTarget.dispatchEvent(new Event('foo')); +} + +{ + const eventTarget = new NodeEventTarget(); + strictEqual(eventTarget.listenerCount('foo'), 0); + deepStrictEqual(eventTarget.eventNames(), []); + + // Won't actually be called. + const ev1 = () => {}; + + // Won't actually be called. + const ev2 = { handleEvent() {} }; + + eventTarget.addListener('foo', ev1); + eventTarget.addEventListener('foo', ev1); + eventTarget.once('foo', ev2, { once: true }); + eventTarget.once('foo', ev2, { once: false }); + eventTarget.on('bar', ev1); + strictEqual(eventTarget.listenerCount('foo'), 2); + strictEqual(eventTarget.listenerCount('bar'), 1); + deepStrictEqual(eventTarget.eventNames(), ['foo', 'bar']); + eventTarget.removeAllListeners('foo'); + strictEqual(eventTarget.listenerCount('foo'), 0); + strictEqual(eventTarget.listenerCount('bar'), 1); + deepStrictEqual(eventTarget.eventNames(), ['bar']); + eventTarget.removeAllListeners(); + strictEqual(eventTarget.listenerCount('foo'), 0); + strictEqual(eventTarget.listenerCount('bar'), 0); + deepStrictEqual(eventTarget.eventNames(), []); +} + +{ + const uncaughtException = common.mustCall((err, event) => { + strictEqual(err.message, 'boom'); + strictEqual(event.type, 'foo'); + }, 4); + + // Whether or not the handler function is async or not, errors + // are routed to uncaughtException + process.on('error', uncaughtException); + + const eventTarget = new EventTarget(); + + const ev1 = async () => { throw new Error('boom'); }; + const ev2 = () => { throw new Error('boom'); }; + const ev3 = { handleEvent() { throw new Error('boom'); } }; + const ev4 = { async handleEvent() { throw new Error('boom'); } }; + + // Errors in a handler won't stop calling the others. + eventTarget.addEventListener('foo', ev1, { once: true }); + eventTarget.addEventListener('foo', ev2, { once: true }); + eventTarget.addEventListener('foo', ev3, { once: true }); + eventTarget.addEventListener('foo', ev4, { once: true }); + + eventTarget.dispatchEvent(new Event('foo')); +} + +{ + const eventTarget = new EventTarget(); + + // Once handler only invoked once + const ev = common.mustCall((event) => { + throws(() => eventTarget.dispatchEvent(new Event('foo')), { + code: 'ERR_EVENT_RECURSION' + }); + }); + + // Errors in a handler won't stop calling the others. + eventTarget.addEventListener('foo', ev); + + eventTarget.dispatchEvent(new Event('foo')); +} + +{ + // Coercion to string works + strictEqual((new Event(1)).type, '1'); + strictEqual((new Event(false)).type, 'false'); + strictEqual((new Event({})).type, String({})); + + const target = new EventTarget(); + + [ + 'foo', + {}, // No type event + undefined, + 1, + false + ].forEach((i) => { + throws(() => target.dispatchEvent(i), { + code: 'ERR_INVALID_ARG_TYPE' + }); + }); + + [ + 'foo', + 1, + {}, // No handleEvent function + false, + undefined + ].forEach((i) => { + throws(() => target.addEventListener('foo', i), { + code: 'ERR_INVALID_ARG_TYPE' + }); + }); + + [ + 'foo', + 1, + {}, // No handleEvent function + false, + undefined + ].forEach((i) => { + throws(() => target.removeEventListener('foo', i), { + code: 'ERR_INVALID_ARG_TYPE' + }); + }); +} + +{ + const target = new EventTarget(); + once(target, 'foo').then(common.mustCall()); + target.dispatchEvent(new Event('foo')); +} + +{ + const target = new NodeEventTarget(); + + process.on('warning', common.mustCall((warning) => { + ok(warning instanceof Error); + strictEqual(warning.name, 'MaxListenersExceededWarning'); + strictEqual(warning.target, target); + strictEqual(warning.count, 2); + strictEqual(warning.type, 'foo'); + ok(warning.message.includes( + '2 foo listeners added to NodeEventTarget')); + })); + + strictEqual(target.getMaxListeners(), NodeEventTarget.defaultMaxListeners); + target.setMaxListeners(1); + target.on('foo', () => {}); + target.on('foo', () => {}); +} + +{ + const target = new EventTarget(); + const event = new Event('foo'); + event.stopImmediatePropagation(); + target.addEventListener('foo', common.mustNotCall()); + target.dispatchEvent(event); +} + +{ + const target = new EventTarget(); + const event = new Event('foo'); + target.addEventListener('foo', common.mustCall((event) => { + event.stopImmediatePropagation(); + })); + target.addEventListener('foo', common.mustNotCall()); + target.dispatchEvent(event); +} + +{ + const target = new EventTarget(); + const event = new Event('foo'); + target.addEventListener('foo', common.mustCall((event) => { + event.stopImmediatePropagation(); + })); + target.addEventListener('foo', common.mustNotCall()); + target.dispatchEvent(event); +} + +{ + const target = new EventTarget(); + const event = new Event('foo'); + target.addEventListener('foo', common.mustCall((event) => { + strictEqual(event.target, target); + strictEqual(event.currentTarget, target); + strictEqual(event.srcElement, target); + })); + target.dispatchEvent(event); +} + +{ + const target1 = new EventTarget(); + const target2 = new EventTarget(); + const event = new Event('foo'); + target1.addEventListener('foo', common.mustCall((event) => { + throws(() => target2.dispatchEvent(event), { + code: 'ERR_EVENT_RECURSION' + }); + })); + target1.dispatchEvent(event); +} + +{ + const target = new EventTarget(); + const a = common.mustCall(() => target.removeEventListener('foo', a)); + const b = common.mustCall(2); + + target.addEventListener('foo', a); + target.addEventListener('foo', b); + + target.dispatchEvent(new Event('foo')); + target.dispatchEvent(new Event('foo')); +} + +{ + const target = new EventTarget(); + const a = common.mustCall(3); + + target.addEventListener('foo', a, { capture: true }); + target.addEventListener('foo', a, { capture: false }); + + target.dispatchEvent(new Event('foo')); + target.removeEventListener('foo', a, { capture: true }); + target.dispatchEvent(new Event('foo')); + target.removeEventListener('foo', a, { capture: false }); + target.dispatchEvent(new Event('foo')); +} diff --git a/tools/doc/type-parser.js b/tools/doc/type-parser.js index f35abb52f65e47..ec6c8e54ef8410 100644 --- a/tools/doc/type-parser.js +++ b/tools/doc/type-parser.js @@ -77,6 +77,9 @@ const customTypesMap = { 'import.meta': 'esm.html#esm_import_meta', 'EventEmitter': 'events.html#events_class_eventemitter', + 'EventTarget': 'events.html#events_class_eventtarget', + 'Event': 'events.html#events_class_event', + 'EventListener': 'events.html#events_event_listener', 'FileHandle': 'fs.html#fs_class_filehandle', 'fs.Dir': 'fs.html#fs_class_fs_dir', @@ -119,6 +122,9 @@ const customTypesMap = { 'net.Server': 'net.html#net_class_net_server', 'net.Socket': 'net.html#net_class_net_socket', + 'NodeEventTarget': + 'events.html#events_class_nodeeventtarget_extends_eventtarget', + 'os.constants.dlopen': 'os.html#os_dlopen_constants', 'Histogram': 'perf_hooks.html#perf_hooks_class_histogram',