diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 7cc91f81fc46..eacfcb73cc8a 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -475,6 +475,12 @@ declare interface AddEventListenerOptions { * internal `kResistStopPropagation`. */ $kResistStopPropagation?: boolean; + /** + * Private symbol read by the native EventTarget. A listener registered with it is + * invoked with the event's underlying value (data / detail / error) instead of the + * Event wrapper. Mirrors Node.js's internal `kIsNodeStyleListener`. + */ + $kIsNodeStyleListener?: boolean; } declare class OutOfMemoryError { diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index b6495c2eea2b..cf86a6694a6b 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -119,6 +119,7 @@ using namespace JSC; macro(isUntransferable) \ macro(join) \ macro(json) \ + macro(kIsNodeStyleListener) \ macro(kResistStopPropagation) \ macro(key) \ macro(lazy) \ diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 921df1a621d2..367ef91a0c9b 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -101,189 +101,7 @@ type NodeWorkerOptions = import("node:worker_threads").WorkerOptions; // after their Worker exits let urlRevokeRegistry: FinalizationRegistry | undefined = undefined; -function injectFakeEmitter(Class) { - // Per-instance registry mapping each event to (user listener -> wrapper), so - // listenerCount/eventNames/removeAllListeners work over EventTarget's opaque - // internal map and off() can find the wrapper a given listener registered. - // SafeMap: its prototype is a frozen, null-proto snapshot of Map.prototype, so - // .get/.set/.size/.values()/iteration all bypass a user-replaced Map.prototype. - // (It has no @get/@set private names, so the $-intrinsics don't apply to it.) - // Keyed by a module-local symbol, not a WeakMap — WeakMap has neither defence. - const kListenerRegistry = Symbol("listenerRegistry"); - function registryFor(target, create) { - let map = target[kListenerRegistry]; - if (!map && create) target[kListenerRegistry] = map = new SafeMap(); - return map; - } - - function messageEventHandler(event: MessageEvent) { - return event.data; - } - - function errorEventHandler(event: ErrorEvent) { - return event.error; - } - - function customEventHandler(event) { - return event.detail; - } - - function wrapped(run, listener) { - return function (event) { - return listener(run(event)); - }; - } - - function functionForEventType(event, listener) { - switch (event) { - case "error": - case "messageerror": { - return wrapped(errorEventHandler, listener); - } - - case "message": { - return wrapped(messageEventHandler, listener); - } - - default: { - return wrapped(customEventHandler, listener); - } - } - } - - function EventClass(eventName) { - if (eventName === "error" || eventName === "messageerror") { - return ErrorEvent; - } - - return MessageEvent; - } - - // EventTarget dedupes on (type, callback), so in node the FIRST registration of - // a listener wins outright -- including its once-ness -- and later adds of the - // same function are no-ops. Keying wrappers per listener reproduces that. - function register(target, event, listener, wrapper, options) { - const map = registryFor(target, true)!; - let byListener = map.get(event); - if (!byListener) map.set(event, (byListener = new SafeMap())); - if (byListener.has(listener)) return false; - target.addEventListener(event, wrapper, options); - byListener.set(listener, wrapper); - return true; - } - - function on(event, listener) { - register(this, event, listener, functionForEventType(event, listener), undefined); - return this; - } - - function off(event, listener) { - if (listener) { - const byListener = registryFor(this, false)?.get(event); - const wrapper = byListener?.get(listener) ?? listener; - this.removeEventListener(event, wrapper); - byListener?.delete(listener); - } else { - this.removeEventListener(event); - } - return this; - } - - function once(event, listener) { - const wrapper = functionForEventType(event, listener); - const target = this; - // EventTarget drops a {once:true} listener natively, without telling the - // registry — so purge it here or listenerCount()/eventNames() keep counting - // a listener that already fired. - function onceWrapper(ev) { - registryFor(target, false)?.get(event)?.delete(listener); - return wrapper(ev); - } - register(this, event, listener, onceWrapper, { once: true }); - return this; - } - - function emit(event, ...args) { - switch (event) { - case "error": - case "messageerror": - case "message": - this.dispatchEvent(new (EventClass(event))(event, ...args)); - break; - default: - // Non-standard events surface as CustomEvent (detail = first arg) to - // addEventListener and as the raw argument to .on(), matching node. - this.dispatchEvent(new CustomEvent(event, { detail: args[0] })); - break; - } - return this; - } - - const kMaxListeners = Symbol("kMaxListeners"); - function setMaxListeners(n) { - this[kMaxListeners] = n; - return this; - } - function getMaxListeners() { - return this[kMaxListeners] ?? 10; - } - function listenerCount(type) { - return registryFor(this, false)?.get(type)?.size ?? 0; - } - function eventNames() { - const map = registryFor(this, false); - if (!map) return []; - const out: string[] = []; - for (const [k, v] of map) if (v.size > 0) out.push(k); - return out; - } - function removeAllListeners(type) { - const map = registryFor(this, false); - if (!map) return this; - const removeType = t => { - const byListener = map.get(t); - if (byListener) { - for (const w of byListener.values()) this.removeEventListener(t, w); - map.delete(t); - } - }; - if (arguments.length === 0) { - // removeType only deletes `t`, and a Map iterator tolerates deleting the - // entry it just yielded — so no snapshot copy is needed here. - for (const t of map.keys()) removeType(t); - } else { - removeType(type); - } - return this; - } - - // node inherits these from NodeEventTarget.prototype (a curated subset of - // EventEmitter, not EventEmitter itself); use an intermediate prototype so - // Object.getOwnPropertyNames(MessagePort.prototype) matches node. - const proto = Class.prototype; - const inherited = Object.create(Object.getPrototypeOf(proto)); - const emitterMethods: [string, Function][] = [ - ["on", on], - ["off", off], - ["once", once], - ["emit", emit], - ["addListener", on], - ["removeListener", off], - ["listenerCount", listenerCount], - ["eventNames", eventNames], - ["removeAllListeners", removeAllListeners], - ["setMaxListeners", setMaxListeners], - ["getMaxListeners", getMaxListeners], - ]; - for (const [methodName, value] of emitterMethods) { - Object.defineProperty(inherited, methodName, { value, writable: true, enumerable: false, configurable: true }); - } - Object.setPrototypeOf(proto, inherited); -} - const _MessagePort = globalThis.MessagePort; -injectFakeEmitter(_MessagePort); - const MessagePort = _MessagePort; // node's close(cb) registers cb as a one-time "close" listener before the native close. @@ -810,19 +628,93 @@ function fakeParentPort() { value: self.addEventListener.bind(self), }); + // MessagePort.prototype.on/etc. require a real MessagePort receiver; shadow + // them so calls on this stand-in forward to the global scope. A local map + // tracks which listeners were added via parentPort so listenerCount/ + // eventNames/removeAllListeners see only those (self may carry internal + // listeners the user must not touch). + const byType = new SafeMap(); Object.defineProperty(fake, "removeEventListener", { - value: self.removeEventListener.bind(self), - }); - - Object.defineProperty(fake, "removeListener", { - value: self.removeEventListener.bind(self), - enumerable: false, - }); - - Object.defineProperty(fake, "addListener", { - value: self.addEventListener.bind(self), - enumerable: false, + value(type: string, listener: any, options?: any) { + const set = byType.get(type); + self.removeEventListener(type, set?.get(listener) ?? listener, options); + set?.delete(listener); + }, }); + function track(type: string, listener: any, registered: any) { + let set = byType.get(type); + if (!set) byType.set(type, (set = new SafeMap())); + // First registration of a given listener wins (including its once-ness), + // matching node's NodeEventTarget dedup. + if (set.has(listener)) return false; + set.set(listener, registered); + return true; + } + function on(this: any, type: string, listener: any) { + if (track(type, listener, listener)) + self.addEventListener(type, listener, { $kIsNodeStyleListener: true } as AddEventListenerOptions); + return this; + } + function once(this: any, type: string, listener: any) { + const wrapper = (arg: any) => { + byType.get(type)?.delete(listener); + return listener(arg); + }; + if (track(type, listener, wrapper)) + self.addEventListener(type, wrapper, { once: true, $kIsNodeStyleListener: true } as AddEventListenerOptions); + return this; + } + function off(this: any, type: string, listener: any) { + const set = byType.get(type); + self.removeEventListener(type, set?.get(listener) ?? listener); + set?.delete(listener); + return this; + } + function listenerCount(type: string) { + return byType.get(type)?.size ?? 0; + } + function eventNames() { + const out: string[] = []; + for (const [k, v] of byType) if (v.size > 0) out.push(k); + return out; + } + function removeAllListeners(this: any, type?: string) { + const clear = (t: string) => { + const set = byType.get(t); + if (set) { + for (const registered of set.values()) self.removeEventListener(t, registered); + byType.delete(t); + } + }; + if (arguments.length === 0) for (const t of byType.keys()) clear(t); + else clear(type!); + return this; + } + function emit(type: string, arg?: any) { + const had = (byType.get(type)?.size ?? 0) > 0; + const ev = + type === "message" || type === "messageerror" + ? new MessageEvent(type, { data: arg }) + : new CustomEvent(type, { detail: arg }); + self.dispatchEvent(ev); + return had; + } + let maxListeners = 10; + for (const [name, fn] of [ + ["on", on], + ["addListener", on], + ["once", once], + ["off", off], + ["removeListener", off], + ["listenerCount", listenerCount], + ["eventNames", eventNames], + ["removeAllListeners", removeAllListeners], + ["emit", emit], + ["getMaxListeners", () => maxListeners], + ["setMaxListeners", n => ((maxListeners = n), fake)], + ] as const) { + Object.defineProperty(fake, name, { value: fn, enumerable: false, configurable: true, writable: true }); + } return fake; } diff --git a/src/jsc/bindings/webcore/AddEventListenerOptions.h b/src/jsc/bindings/webcore/AddEventListenerOptions.h index 377792966ecf..d3335b98c68e 100644 --- a/src/jsc/bindings/webcore/AddEventListenerOptions.h +++ b/src/jsc/bindings/webcore/AddEventListenerOptions.h @@ -48,6 +48,11 @@ struct AddEventListenerOptions : EventListenerOptions { // modules, mirroring Node.js's kResistStopPropagation: a listener registered // with it still runs after another listener called stopImmediatePropagation(). bool resistStopPropagation { false }; + + // Not part of the DOM standard. Mirrors Node.js's kIsNodeStyleListener: when + // set, the listener is invoked with the event's underlying value (data/detail/ + // error) instead of the Event wrapper. MessagePort.prototype.on/.once set it. + bool isNodeStyleListener { false }; }; } // namespace WebCore diff --git a/src/jsc/bindings/webcore/EventFactory.cpp b/src/jsc/bindings/webcore/EventFactory.cpp index 062a6aac8e82..56d6777702a9 100644 --- a/src/jsc/bindings/webcore/EventFactory.cpp +++ b/src/jsc/bindings/webcore/EventFactory.cpp @@ -88,8 +88,8 @@ JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject*, JSDOMGlobalObject* globalObj } // case CompositionEventInterfaceType: // return createWrapper(globalObject, WTF::move(impl)); - // case CustomEventInterfaceType: - // return createWrapper(globalObject, WTF::move(impl)); + case CustomEventInterfaceType: + return createWrapper(globalObject, WTF::move(impl)); // #if ENABLE(DEVICE_ORIENTATION) // case DeviceMotionEventInterfaceType: // return createWrapper(globalObject, WTF::move(impl)); diff --git a/src/jsc/bindings/webcore/EventHeaders.h b/src/jsc/bindings/webcore/EventHeaders.h index 6692b9234a80..6f2561297e01 100644 --- a/src/jsc/bindings/webcore/EventHeaders.h +++ b/src/jsc/bindings/webcore/EventHeaders.h @@ -78,8 +78,8 @@ #include "JSCloseEvent.h" // #include "CompositionEvent.h" // #include "JSCompositionEvent.h" -// #include "CustomEvent.h" -// #include "JSCustomEvent.h" +#include "CustomEvent.h" +#include "JSCustomEvent.h" // #if ENABLE(DEVICE_ORIENTATION) // #include "DeviceMotionEvent.h" // #include "JSDeviceMotionEvent.h" diff --git a/src/jsc/bindings/webcore/EventListener.h b/src/jsc/bindings/webcore/EventListener.h index c164c0f170f1..c3a8372b9922 100644 --- a/src/jsc/bindings/webcore/EventListener.h +++ b/src/jsc/bindings/webcore/EventListener.h @@ -52,6 +52,9 @@ class EventListener : public RefCounted, public CanMakeWeakPtrmarkAsRemoved(); + m_entries.removeAt(i); + return true; + } + } + + return false; +} + EventListenerVector* EventListenerMap::find(const AtomString& eventType) { for (auto& entry : m_entries) { diff --git a/src/jsc/bindings/webcore/EventListenerMap.h b/src/jsc/bindings/webcore/EventListenerMap.h index af32801205e6..7ba52bb9c3ff 100644 --- a/src/jsc/bindings/webcore/EventListenerMap.h +++ b/src/jsc/bindings/webcore/EventListenerMap.h @@ -61,6 +61,7 @@ class EventListenerMap { void replace(const AtomString& eventType, EventListener& oldListener, Ref&& newListener, const RegisteredEventListener::Options&); RegisteredEventListener* add(const AtomString& eventType, Ref&&, const RegisteredEventListener::Options&); bool remove(const AtomString& eventType, EventListener&, bool useCapture); + bool removeAll(const AtomString& eventType); WEBCORE_EXPORT EventListenerVector* find(const AtomString& eventType); const EventListenerVector* find(const AtomString& eventType) const { return const_cast(this)->find(eventType); } Vector eventTypes() const; diff --git a/src/jsc/bindings/webcore/EventTarget.cpp b/src/jsc/bindings/webcore/EventTarget.cpp index e3d74425215e..9c13850c22ab 100644 --- a/src/jsc/bindings/webcore/EventTarget.cpp +++ b/src/jsc/bindings/webcore/EventTarget.cpp @@ -103,7 +103,7 @@ bool EventTarget::addEventListener(const AtomString& eventType, Refcallback().handleEvent(context, event); + if (registeredListener->isNodeStyleListener()) [[unlikely]] + registeredListener->callback().handleEventNodeStyle(context, event); + else + registeredListener->callback().handleEvent(context, event); // InspectorInstrumentation::didHandleEvent(context, event, *registeredListener); if (registeredListener->isPassive()) @@ -366,6 +369,18 @@ const EventListenerVector& EventTarget::eventListeners(const AtomString& eventTy return listenerVector ? *listenerVector : emptyVector.get(); } +void EventTarget::removeAllEventListenersForType(const AtomString& eventType) +{ + auto* data = eventTargetData(); + if (!data) + return; + if (data->eventListenerMap.removeAll(eventType)) { + if (this->onDidChangeListener) [[unlikely]] + this->onDidChangeListener(*this, eventType, OnDidChangeListenerKind::Clear); + eventListenersDidChange(); + } +} + void EventTarget::removeAllEventListeners() { // auto& threadData = threadGlobalData(); diff --git a/src/jsc/bindings/webcore/EventTarget.h b/src/jsc/bindings/webcore/EventTarget.h index 52b34ad09b54..4eacc61cf37e 100644 --- a/src/jsc/bindings/webcore/EventTarget.h +++ b/src/jsc/bindings/webcore/EventTarget.h @@ -114,6 +114,7 @@ class EventTarget : public ScriptWrappable, public CanMakeWeakPtrWithBitField AddEventListenerOptions convertDictionary(JS result.resistStopPropagation = convert(lexicalGlobalObject, resistStopPropagationValue); RETURN_IF_EXCEPTION(throwScope, {}); } + JSValue isNodeStyleListenerValue = object->get(&lexicalGlobalObject, builtinNames(vm).kIsNodeStyleListenerPrivateName()); + RETURN_IF_EXCEPTION(throwScope, {}); + if (!isNodeStyleListenerValue.isUndefined()) { + result.isNodeStyleListener = convert(lexicalGlobalObject, isNodeStyleListenerValue); + RETURN_IF_EXCEPTION(throwScope, {}); + } } return result; } diff --git a/src/jsc/bindings/webcore/JSEventListener.cpp b/src/jsc/bindings/webcore/JSEventListener.cpp index e1d7f4eb3eb7..86d392babfd8 100644 --- a/src/jsc/bindings/webcore/JSEventListener.cpp +++ b/src/jsc/bindings/webcore/JSEventListener.cpp @@ -32,6 +32,9 @@ // #include "JSDocument.h" #include "JSEvent.h" #include "JSEventTarget.h" +#include "JSMessageEvent.h" +#include "CustomEvent.h" +#include "ErrorEvent.h" #include "WebCoreJSClientData.h" // #include "JSExecState.h" // #include "JSExecStateInstrumentation.h" @@ -43,6 +46,7 @@ #include #include #include +#include namespace WebCore { using namespace JSC; @@ -141,6 +145,40 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionEmitUncaughtExceptionNextTick, (JSC::JSGlobal return JSC::JSValue::encode(JSC::jsUndefined()); } +// The value a kIsNodeStyleListener listener receives instead of the Event wrapper +// (MessageEvent → .data, CustomEvent → .detail, ErrorEvent → .error). +static JSC::JSValue nodeStyleArgumentForEvent(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, Event& event) +{ + switch (event.eventInterface()) { + case MessageEventInterfaceType: { + auto& me = static_cast(event); + // Reuse the deserialized payload cached by MessagePort dispatch so + // .on listeners and event.data readers see the same value by identity. + if (auto cached = me.cachedData().getValue({})) + return cached; + if (std::holds_alternative(me.data())) + return me.jsData().getValue(JSC::jsNull()); + // Still serialized: read through the wrapper so the result is cached. + JSValue jsEvent = toJS(lexicalGlobalObject, globalObject, &event); + if (auto* wrapper = dynamicDowncast(jsEvent.getObject())) + return wrapper->data(*lexicalGlobalObject); + return JSC::jsNull(); + } + case CustomEventInterfaceType: + return static_cast(event).detail().getValue(JSC::jsNull()); + case ErrorEventInterfaceType: + return static_cast(event).error(*lexicalGlobalObject); + default: + return JSC::jsUndefined(); + } +} + +void JSEventListener::handleEventNodeStyle(ScriptExecutionContext& scriptExecutionContext, Event& event) +{ + SetForScope nodeStyle { m_invokeAsNodeStyle, true }; + handleEvent(scriptExecutionContext, event); +} + void JSEventListener::handleEvent(ScriptExecutionContext& scriptExecutionContext, Event& event) { if (scriptExecutionContext.isJSExecutionForbidden()) @@ -222,7 +260,7 @@ void JSEventListener::handleEvent(ScriptExecutionContext& scriptExecutionContext Ref protectedThis(*this); MarkedArgumentBuffer args; - args.append(toJS(lexicalGlobalObject, globalObject, &event)); + args.append(m_invokeAsNodeStyle ? nodeStyleArgumentForEvent(lexicalGlobalObject, globalObject, event) : toJS(lexicalGlobalObject, globalObject, &event)); ASSERT(!args.hasOverflowed()); // JSExecState::instrumentFunction(&scriptExecutionContext, callData); diff --git a/src/jsc/bindings/webcore/JSEventListener.h b/src/jsc/bindings/webcore/JSEventListener.h index fb8da1bb6296..98dffe4a4198 100644 --- a/src/jsc/bindings/webcore/JSEventListener.h +++ b/src/jsc/bindings/webcore/JSEventListener.h @@ -78,6 +78,7 @@ class JSEventListener : public EventListener { JSEventListener(JSC::JSObject* function, JSC::JSObject* wrapper, bool isAttribute, CreatedFromMarkup, DOMWrapperWorld&); void handleEvent(ScriptExecutionContext&, Event&) override; + void handleEventNodeStyle(ScriptExecutionContext&, Event&) final; void setWrapperWhenInitializingJSFunction(JSC::VM&, JSC::JSObject* wrapper) const { m_wrapper = JSC::Weak(wrapper); } private: @@ -85,6 +86,8 @@ class JSEventListener : public EventListener { bool m_wasCreatedFromMarkup : 1; mutable bool m_isInitialized : 1; + // Transient: set by handleEventNodeStyle around one handleEvent call. + bool m_invokeAsNodeStyle { false }; mutable JSC::Weak m_jsFunction; mutable JSC::Weak m_wrapper; diff --git a/src/jsc/bindings/webcore/JSMessagePort.cpp b/src/jsc/bindings/webcore/JSMessagePort.cpp index 6bc8c19950ed..94f16ef892ea 100644 --- a/src/jsc/bindings/webcore/JSMessagePort.cpp +++ b/src/jsc/bindings/webcore/JSMessagePort.cpp @@ -22,6 +22,8 @@ #include "JSMessagePort.h" #include "ActiveDOMObject.h" +#include "AddEventListenerOptions.h" +#include "CustomEvent.h" #include "EventNames.h" #include "ExtendedDOMClientIsoSubspaces.h" #include "ExtendedDOMIsoSubspaces.h" @@ -32,14 +34,18 @@ #include "JSDOMConvertAny.h" #include "JSDOMConvertBase.h" #include "JSDOMConvertDictionary.h" +#include "JSDOMConvertEventListener.h" +#include "JSDOMConvertNullable.h" #include "JSDOMConvertObject.h" #include "JSDOMConvertSequences.h" +#include "JSDOMConvertStrings.h" #include "JSDOMExceptionHandling.h" #include "JSDOMGlobalObjectInlines.h" #include "JSDOMOperation.h" #include "JSDOMWrapperCache.h" #include "JSEventListener.h" #include "JSStructuredSerializeOptions.h" +#include "MessageEvent.h" #include "ScriptExecutionContext.h" #include "WebCoreJSClientData.h" // #include "WebCoreOpaqueRootInlines.h" @@ -65,6 +71,17 @@ static JSC_DECLARE_HOST_FUNCTION(jsMessagePortPrototypeFunction_close); static JSC_DECLARE_HOST_FUNCTION(jsMessagePortPrototypeFunction_ref); static JSC_DECLARE_HOST_FUNCTION(jsMessagePortPrototypeFunction_unref); static JSC_DECLARE_HOST_FUNCTION(jsMessagePortPrototypeFunction_hasRef); +// NodeEventTarget (node lib/internal/event_target.js): an EventEmitter subset +// that reads and writes the same listener list as addEventListener. +static JSC_DECLARE_HOST_FUNCTION(jsMessagePortPrototypeFunction_on); +static JSC_DECLARE_HOST_FUNCTION(jsMessagePortPrototypeFunction_once); +static JSC_DECLARE_HOST_FUNCTION(jsMessagePortPrototypeFunction_off); +static JSC_DECLARE_HOST_FUNCTION(jsMessagePortPrototypeFunction_emit); +static JSC_DECLARE_HOST_FUNCTION(jsMessagePortPrototypeFunction_listenerCount); +static JSC_DECLARE_HOST_FUNCTION(jsMessagePortPrototypeFunction_eventNames); +static JSC_DECLARE_HOST_FUNCTION(jsMessagePortPrototypeFunction_removeAllListeners); +static JSC_DECLARE_HOST_FUNCTION(jsMessagePortPrototypeFunction_setMaxListeners); +static JSC_DECLARE_HOST_FUNCTION(jsMessagePortPrototypeFunction_getMaxListeners); // Attributes @@ -138,6 +155,21 @@ static const HashTableValue JSMessagePortPrototypeTableValues[] = { { "hasRef"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMessagePortPrototypeFunction_hasRef, 0 } }, }; +// On an intermediate prototype so getOwnPropertyNames(MessagePort.prototype) matches node. +static const HashTableValue JSMessagePortNodeEventTargetTableValues[] = { + { "on"_s, static_cast(JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMessagePortPrototypeFunction_on, 2 } }, + { "addListener"_s, static_cast(JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMessagePortPrototypeFunction_on, 2 } }, + { "once"_s, static_cast(JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMessagePortPrototypeFunction_once, 2 } }, + { "off"_s, static_cast(JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMessagePortPrototypeFunction_off, 2 } }, + { "removeListener"_s, static_cast(JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMessagePortPrototypeFunction_off, 2 } }, + { "emit"_s, static_cast(JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMessagePortPrototypeFunction_emit, 1 } }, + { "listenerCount"_s, static_cast(JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMessagePortPrototypeFunction_listenerCount, 1 } }, + { "eventNames"_s, static_cast(JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMessagePortPrototypeFunction_eventNames, 0 } }, + { "removeAllListeners"_s, static_cast(JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMessagePortPrototypeFunction_removeAllListeners, 0 } }, + { "setMaxListeners"_s, static_cast(JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMessagePortPrototypeFunction_setMaxListeners, 1 } }, + { "getMaxListeners"_s, static_cast(JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMessagePortPrototypeFunction_getMaxListeners, 0 } }, +}; + const ClassInfo JSMessagePortPrototype::s_info = { "MessagePort"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSMessagePortPrototype) }; void JSMessagePortPrototype::finishCreation(VM& vm) @@ -158,7 +190,13 @@ JSMessagePort::JSMessagePort(Structure* structure, JSDOMGlobalObject& globalObje JSObject* JSMessagePort::createPrototype(VM& vm, JSDOMGlobalObject& globalObject) { - auto* structure = JSMessagePortPrototype::createStructure(vm, &globalObject, JSEventTarget::prototype(vm, globalObject)); + auto* eventTargetPrototype = JSEventTarget::prototype(vm, globalObject); + auto* nodeEventTargetStructure = JSC::JSFinalObject::createStructure(vm, &globalObject, eventTargetPrototype, 0); + nodeEventTargetStructure->setMayBePrototype(true); + auto* nodeEventTargetPrototype = JSC::JSFinalObject::create(vm, nodeEventTargetStructure); + reifyStaticProperties(vm, JSMessagePort::info(), JSMessagePortNodeEventTargetTableValues, *nodeEventTargetPrototype); + + auto* structure = JSMessagePortPrototype::createStructure(vm, &globalObject, nodeEventTargetPrototype); structure->setMayBePrototype(true); return JSMessagePortPrototype::create(vm, &globalObject, structure); } @@ -402,6 +440,183 @@ JSC_DEFINE_HOST_FUNCTION(jsMessagePortPrototypeFunction_hasRef, (JSGlobalObject return IDLOperation::call(*lexicalGlobalObject, *callFrame, "hasRef"); } +// NodeEventTarget ------------------------------------------------------------ + +static inline JSC::EncodedJSValue messagePortNodeAddListener(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis, bool once) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = castedThis->wrapped(); + EnsureStillAliveScope argument0 = callFrame->argument(0); + auto type = convert>(*lexicalGlobalObject, argument0.value()); + RETURN_IF_EXCEPTION(throwScope, {}); + EnsureStillAliveScope argument1 = callFrame->argument(1); + auto listener = convert>>(*lexicalGlobalObject, argument1.value(), *castedThis, [once](JSC::JSGlobalObject& lexicalGlobalObject, JSC::ThrowScope& scope) { throwArgumentMustBeObjectError(lexicalGlobalObject, scope, 1, "listener"_s, "MessagePort"_s, once ? "once"_s : "on"_s); }); + RETURN_IF_EXCEPTION(throwScope, {}); + if (listener) { + AddEventListenerOptions options; + options.once = once; + options.isNodeStyleListener = true; + static_cast(impl).addEventListener(WTF::move(type), listener.releaseNonNull(), options); + vm.writeBarrier(&static_cast(*castedThis), argument1.value()); + } + return JSValue::encode(castedThis); +} + +static inline JSC::EncodedJSValue jsMessagePortPrototypeFunction_onBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + return messagePortNodeAddListener(lexicalGlobalObject, callFrame, castedThis, false); +} + +JSC_DEFINE_HOST_FUNCTION(jsMessagePortPrototypeFunction_on, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "on"); +} + +static inline JSC::EncodedJSValue jsMessagePortPrototypeFunction_onceBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + return messagePortNodeAddListener(lexicalGlobalObject, callFrame, castedThis, true); +} + +JSC_DEFINE_HOST_FUNCTION(jsMessagePortPrototypeFunction_once, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "once"); +} + +static inline JSC::EncodedJSValue jsMessagePortPrototypeFunction_offBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = castedThis->wrapped(); + EnsureStillAliveScope argument0 = callFrame->argument(0); + auto type = convert>(*lexicalGlobalObject, argument0.value()); + RETURN_IF_EXCEPTION(throwScope, {}); + EnsureStillAliveScope argument1 = callFrame->argument(1); + auto listener = convert>>(*lexicalGlobalObject, argument1.value(), *castedThis, [](JSC::JSGlobalObject& lexicalGlobalObject, JSC::ThrowScope& scope) { throwArgumentMustBeObjectError(lexicalGlobalObject, scope, 1, "listener"_s, "MessagePort"_s, "off"_s); }); + RETURN_IF_EXCEPTION(throwScope, {}); + if (listener) + static_cast(impl).removeEventListener(WTF::move(type), *listener, {}); + return JSValue::encode(castedThis); +} + +JSC_DEFINE_HOST_FUNCTION(jsMessagePortPrototypeFunction_off, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "off"); +} + +static inline JSC::EncodedJSValue jsMessagePortPrototypeFunction_emitBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = castedThis->wrapped(); + EnsureStillAliveScope argument0 = callFrame->argument(0); + auto type = convert>(*lexicalGlobalObject, argument0.value()); + RETURN_IF_EXCEPTION(throwScope, {}); + bool had = impl.hasEventListeners(type); + if (had) { + EnsureStillAliveScope argument1 = callFrame->argument(1); + JSValue arg = argument1.value(); + // node's MessagePort[kCreateEvent]: MessageEvent for message/messageerror, + // CustomEvent otherwise; node-style listeners recover arg at invoke time. + if (type == eventNames().messageEvent || type == eventNames().messageerrorEvent) { + MessageEvent::Init init; + init.data = arg; + auto event = MessageEvent::create(type, WTF::move(init)); + impl.dispatchEvent(event.get()); + } else { + CustomEvent::Init init; + init.detail = arg; + auto event = CustomEvent::create(type, init); + impl.dispatchEvent(event.get()); + } + } + return JSValue::encode(jsBoolean(had)); +} + +JSC_DEFINE_HOST_FUNCTION(jsMessagePortPrototypeFunction_emit, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "emit"); +} + +static inline JSC::EncodedJSValue jsMessagePortPrototypeFunction_listenerCountBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + EnsureStillAliveScope argument0 = callFrame->argument(0); + auto type = convert>(*lexicalGlobalObject, argument0.value()); + RETURN_IF_EXCEPTION(throwScope, {}); + return JSValue::encode(jsNumber(castedThis->wrapped().eventListeners(type).size())); +} + +JSC_DEFINE_HOST_FUNCTION(jsMessagePortPrototypeFunction_listenerCount, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "listenerCount"); +} + +static inline JSC::EncodedJSValue jsMessagePortPrototypeFunction_eventNamesBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + UNUSED_PARAM(callFrame); + auto types = castedThis->wrapped().eventTypes(); + MarkedArgumentBuffer values; + for (auto& t : types) + values.append(jsString(vm, t.string())); + return JSValue::encode(constructArray(lexicalGlobalObject, static_cast(nullptr), values)); +} + +JSC_DEFINE_HOST_FUNCTION(jsMessagePortPrototypeFunction_eventNames, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "eventNames"); +} + +static inline JSC::EncodedJSValue jsMessagePortPrototypeFunction_removeAllListenersBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = castedThis->wrapped(); + EnsureStillAliveScope argument0 = callFrame->argument(0); + if (argument0.value().isUndefinedOrNull()) { + impl.removeAllEventListeners(); + } else { + auto type = convert>(*lexicalGlobalObject, argument0.value()); + RETURN_IF_EXCEPTION(throwScope, {}); + impl.removeAllEventListenersForType(type); + } + return JSValue::encode(castedThis); +} + +JSC_DEFINE_HOST_FUNCTION(jsMessagePortPrototypeFunction_removeAllListeners, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "removeAllListeners"); +} + +static inline JSC::EncodedJSValue jsMessagePortPrototypeFunction_setMaxListenersBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto n = callFrame->argument(0).toUInt32(lexicalGlobalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + castedThis->wrapped().setNodeMaxListeners(n); + return JSValue::encode(castedThis); +} + +JSC_DEFINE_HOST_FUNCTION(jsMessagePortPrototypeFunction_setMaxListeners, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "setMaxListeners"); +} + +static inline JSC::EncodedJSValue jsMessagePortPrototypeFunction_getMaxListenersBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + UNUSED_PARAM(lexicalGlobalObject); + UNUSED_PARAM(callFrame); + return JSValue::encode(jsNumber(castedThis->wrapped().nodeMaxListeners())); +} + +JSC_DEFINE_HOST_FUNCTION(jsMessagePortPrototypeFunction_getMaxListeners, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "getMaxListeners"); +} + JSC::GCClient::IsoSubspace* JSMessagePort::subspaceForImpl(JSC::VM& vm) { return WebCore::subspaceForImpl( diff --git a/src/jsc/bindings/webcore/MessagePort.cpp b/src/jsc/bindings/webcore/MessagePort.cpp index a59c83b8c29b..7e35ff165afd 100644 --- a/src/jsc/bindings/webcore/MessagePort.cpp +++ b/src/jsc/bindings/webcore/MessagePort.cpp @@ -493,10 +493,17 @@ void MessagePort::updateListenerEventLoopRef() void MessagePort::onDidChangeListenerImpl(EventTarget& self, const AtomString& eventType, OnDidChangeListenerKind kind) { + auto& port = static_cast(self); + // removeAllListeners() bypasses the removeEventListener override, so the + // m_has*EventListener flags are reconciled here for Clear. + if (eventType == eventNames().closeEvent) { + if (kind == Clear) + port.m_hasCloseEventListener.store(false, std::memory_order_release); + return; + } if (eventType != eventNames().messageEvent) return; - auto& port = static_cast(self); switch (kind) { case Add: port.m_messageEventCount++; @@ -507,6 +514,7 @@ void MessagePort::onDidChangeListenerImpl(EventTarget& self, const AtomString& e break; case Clear: port.m_messageEventCount = 0; + port.m_hasMessageEventListener = false; break; } port.updateListenerEventLoopRef(); diff --git a/src/jsc/bindings/webcore/MessagePort.h b/src/jsc/bindings/webcore/MessagePort.h index 86d047d320b0..9ddd36b4d1cb 100644 --- a/src/jsc/bindings/webcore/MessagePort.h +++ b/src/jsc/bindings/webcore/MessagePort.h @@ -115,6 +115,9 @@ class MessagePort final : public ContextDestructionObserver, public EventTarget, // Report the actual loop-ref state (matches Node's uv_has_ref), not the intent flag. bool jsHasRef() { return m_hasRef || m_listenerLoopRefActive; } + uint32_t nodeMaxListeners() const { return m_nodeMaxListeners; } + void setNodeMaxListeners(uint32_t n) { m_nodeMaxListeners = n; } + private: MessagePort(ScriptExecutionContext&, Ref&&, uint8_t side); @@ -165,6 +168,7 @@ class MessagePort final : public ContextDestructionObserver, public EventTarget, bool m_listenerLoopRefActive { false }; uint32_t m_messageEventCount { 0 }; + uint32_t m_nodeMaxListeners { 10 }; static void onDidChangeListenerImpl(EventTarget& self, const AtomString& eventType, OnDidChangeListenerKind kind); // Reconciles the listener event-loop ref with (m_isRefd && m_messageEventCount > 0). void updateListenerEventLoopRef(); diff --git a/src/jsc/bindings/webcore/RegisteredEventListener.h b/src/jsc/bindings/webcore/RegisteredEventListener.h index 6b7d847595e9..6dee20b6a17d 100644 --- a/src/jsc/bindings/webcore/RegisteredEventListener.h +++ b/src/jsc/bindings/webcore/RegisteredEventListener.h @@ -36,11 +36,12 @@ class WeakPtrImplWithEventTargetData; class RegisteredEventListener : public RefCounted { public: struct Options { - Options(bool capture = false, bool passive = false, bool once = false, bool resistStopPropagation = false) + Options(bool capture = false, bool passive = false, bool once = false, bool resistStopPropagation = false, bool isNodeStyleListener = false) : capture(capture) , passive(passive) , once(once) , resistStopPropagation(resistStopPropagation) + , isNodeStyleListener(isNodeStyleListener) { } @@ -48,6 +49,7 @@ class RegisteredEventListener : public RefCounted { bool passive; bool once; bool resistStopPropagation; + bool isNodeStyleListener; }; static Ref create(Ref&& listener, const Options& options) @@ -63,6 +65,7 @@ class RegisteredEventListener : public RefCounted { bool isOnce() const { return m_isOnce; } bool wasRemoved() const { return m_wasRemoved; } bool resistsStopPropagation() const { return m_resistStopPropagation; } + bool isNodeStyleListener() const { return m_isNodeStyleListener; } void markAsRemoved(); @@ -81,6 +84,7 @@ class RegisteredEventListener : public RefCounted { , m_isOnce(options.once) , m_wasRemoved(false) , m_resistStopPropagation(options.resistStopPropagation) + , m_isNodeStyleListener(options.isNodeStyleListener) , m_callback(WTF::move(listener)) { } @@ -90,6 +94,7 @@ class RegisteredEventListener : public RefCounted { bool m_isOnce : 1; bool m_wasRemoved : 1; bool m_resistStopPropagation : 1; + bool m_isNodeStyleListener : 1; uint32_t m_abortAlgorithmIdentifier { 0 }; Ref m_callback; WeakPtr m_abortSignal; diff --git a/test/js/node/worker_threads/message-port-node-event-target.test.ts b/test/js/node/worker_threads/message-port-node-event-target.test.ts new file mode 100644 index 000000000000..c5f0fdcd9948 --- /dev/null +++ b/test/js/node/worker_threads/message-port-node-event-target.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; +import { getEventListeners, once } from "node:events"; +import { Worker } from "node:worker_threads"; + +// MessagePort is a NodeEventTarget in node: .on/.addListener/.once share the +// same listener list as addEventListener, so listenerCount/eventNames/ +// removeAllListeners/getEventListeners see both, cross-remove works, and +// emit() returns a boolean while passing the raw argument by identity. +describe("MessagePort NodeEventTarget", () => { + test("surface exists without importing node:worker_threads", async () => { + // Only this row needs a fresh process so nothing in this file can + // retroactively install the surface by importing worker_threads. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + if (require.cache["node:worker_threads"]) throw new Error("worker_threads preloaded"); + const { port1 } = new MessageChannel(); + const names = ["on","off","once","emit","addListener","removeListener","listenerCount","eventNames","removeAllListeners","setMaxListeners","getMaxListeners"]; + for (const n of names) { + if (typeof port1[n] !== "function") throw new Error("missing " + n + ": " + typeof port1[n]); + } + port1.close(); + process.stdout.write("OK"); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("OK"); + expect(exitCode).toBe(0); + }); + + test("same fn via .on + addEventListener is one listener, invoked once", async () => { + const { port1, port2 } = new MessageChannel(); + try { + let n = 0; + const fn = () => n++; + port1.on("message", fn); + port1.addEventListener("message", fn); + port2.postMessage("x"); + await once(port1, "message"); + expect(n).toBe(1); + } finally { + port1.close(); + } + }); + + test("listenerCount counts both .on and addEventListener listeners", () => { + const { port1 } = new MessageChannel(); + try { + port1.on("message", () => {}); + port1.addEventListener("message", () => {}); + expect(port1.listenerCount("message")).toBe(2); + } finally { + port1.close(); + } + }); + + test("eventNames reports types registered via addEventListener", () => { + const { port1 } = new MessageChannel(); + try { + port1.addEventListener("foo", () => {}); + expect(port1.eventNames()).toEqual(["foo"]); + } finally { + port1.close(); + } + }); + + test("removeEventListener removes a listener added via .on", () => { + const { port1 } = new MessageChannel(); + try { + const fn = () => {}; + port1.on("message", fn); + port1.removeEventListener("message", fn); + expect(port1.listenerCount("message")).toBe(0); + } finally { + port1.close(); + } + }); + + test("removeAllListeners clears addEventListener listeners too", async () => { + const { port1, port2 } = new MessageChannel(); + try { + let n = 0; + port1.addEventListener("message", () => n++); + port1.on("message", () => n++); + port1.removeAllListeners("message"); + port1.start(); + port2.postMessage("x"); + await new Promise(r => setImmediate(() => r())); + expect(n).toBe(0); + } finally { + port1.close(); + } + }); + + test("emit passes the argument by identity to .on listeners", () => { + const { port1 } = new MessageChannel(); + try { + const payload = { a: 1 }; + let got; + port1.on("message", v => (got = v)); + port1.emit("message", payload); + expect(got).toBe(payload); + } finally { + port1.close(); + } + }); + + test("emit returns a boolean (true when listeners, false otherwise)", () => { + const { port1 } = new MessageChannel(); + try { + expect(port1.emit("nope", 1)).toBe(false); + port1.on("hit", () => {}); + expect(port1.emit("hit", 1)).toBe(true); + } finally { + port1.close(); + } + }); + + test("emit with a primitive payload does not throw", () => { + const { port1 } = new MessageChannel(); + try { + let got; + port1.on("message", v => (got = v)); + port1.emit("message", 1); + expect(got).toBe(1); + } finally { + port1.close(); + } + }); + + test("emit('error', err) passes the error by identity", () => { + const { port1 } = new MessageChannel(); + try { + const err = new Error("boom"); + let got; + port1.on("error", e => (got = e)); + port1.emit("error", err); + expect(got).toBe(err); + } finally { + port1.close(); + } + }); + + test(".once dedupes against .on for the same fn", () => { + const { port1 } = new MessageChannel(); + try { + const fn = () => {}; + port1.on("foo", fn); + port1.once("foo", fn); + expect(port1.listenerCount("foo")).toBe(1); + } finally { + port1.close(); + } + }); + + test("events.getEventListeners returns the user fn, not a wrapper", () => { + const { port1 } = new MessageChannel(); + try { + const fn = () => {}; + port1.on("message", fn); + const list = getEventListeners(port1, "message"); + expect(list.length).toBe(1); + expect(list[0]).toBe(fn); + } finally { + port1.close(); + } + }); + + test("addEventListener listeners still receive an Event; .on listeners get the payload", async () => { + const { port1, port2 } = new MessageChannel(); + try { + let a, b; + port1.on("message", v => (a = v)); + port1.addEventListener("message", ev => (b = ev)); + port2.postMessage(42); + await once(port1, "message"); + expect(a).toBe(42); + expect(b).toBeInstanceOf(MessageEvent); + expect((b as MessageEvent).data).toBe(42); + } finally { + port1.close(); + } + }); + + test("emit with an addEventListener listener delivers an Event with matching data/detail", () => { + const { port1 } = new MessageChannel(); + try { + let msg, custom; + port1.addEventListener("message", ev => (msg = (ev as MessageEvent).data)); + port1.addEventListener("foo", ev => (custom = (ev as CustomEvent).detail)); + port1.emit("message", 7); + port1.emit("foo", 8); + expect(msg).toBe(7); + expect(custom).toBe(8); + } finally { + port1.close(); + } + }); + + test(".off removes a listener added via addEventListener", () => { + const { port1 } = new MessageChannel(); + try { + const fn = () => {}; + port1.addEventListener("message", fn); + port1.off("message", fn); + expect(port1.listenerCount("message")).toBe(0); + } finally { + port1.close(); + } + }); + + test("removeAllListeners('message') re-buffers instead of dropping", async () => { + const { port1, port2 } = new MessageChannel(); + try { + port1.on("message", () => { + throw new Error("should not fire"); + }); + port1.removeAllListeners("message"); + port2.postMessage("x"); + await new Promise(r => setImmediate(() => r())); + const got = await once(port1, "message"); + expect(got[0]).toBe("x"); + } finally { + port1.close(); + } + }); + + test("parentPort.listenerCount / eventNames / removeAllListeners work in a worker", async () => { + await using w = new Worker( + "const { parentPort } = require('node:worker_threads');" + + "const fn = () => {};" + + "parentPort.on('message', fn);" + + "const lc = parentPort.listenerCount('message');" + + "const en = parentPort.eventNames().includes('message');" + + "parentPort.removeAllListeners('message');" + + "const after = parentPort.listenerCount('message');" + + "const ml = parentPort.getMaxListeners();" + + "parentPort.postMessage([lc, en, after, ml]);", + { eval: true }, + ); + const [got] = await once(w, "message"); + expect(got).toEqual([1, true, 0, 10]); + }); +});