Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/js/builtins.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*/
Comment thread
robobun marked this conversation as resolved.
$kIsNodeStyleListener?: boolean;
}

declare class OutOfMemoryError {
Expand Down
1 change: 1 addition & 0 deletions src/js/builtins/BunBuiltinNames.h
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ using namespace JSC;
macro(isUntransferable) \
macro(join) \
macro(json) \
macro(kIsNodeStyleListener) \
macro(kResistStopPropagation) \
macro(key) \
macro(lazy) \
Expand Down
267 changes: 76 additions & 191 deletions src/js/node/worker_threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,189 +101,7 @@
// after their Worker exits
let urlRevokeRegistry: FinalizationRegistry<string> | 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.
Expand Down Expand Up @@ -814,15 +632,82 @@
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,
});
// 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).
Comment thread
robobun marked this conversation as resolved.
const byType = new SafeMap();
function track(type: string, listener: any, registered: any) {
let set = byType.get(type);
if (!set) byType.set(type, (set = new SafeMap()));
set.set(listener, registered);
}
function on(this: any, type: string, listener: any) {
self.addEventListener(type, listener, { $kIsNodeStyleListener: true } as AddEventListenerOptions);
track(type, listener, listener);
return this;
}
Comment thread
robobun marked this conversation as resolved.
function once(this: any, type: string, listener: any) {
const wrapper = (arg: any) => {
byType.get(type)?.delete(listener);
listener(arg);
};
self.addEventListener(type, wrapper, { once: true, $kIsNodeStyleListener: true } as AddEventListenerOptions);
track(type, listener, wrapper);
return this;
Comment thread
robobun marked this conversation as resolved.
}

Check warning on line 659 in src/js/node/worker_threads.ts

View check run for this annotation

Claude / Claude Code Review

fakeParentPort once() wrapper breaks same-fn dedup; track() overwrites unconditionally

`fakeParentPort()`'s `once()` (from d2869b4) creates a fresh `wrapper` closure per call, so native EventTarget dedup (keyed on callback identity) no longer applies: `parentPort.once('message', fn); parentPort.once('message', fn)` registers two listeners and `fn` fires twice. `track()` also does `set.set(listener, registered)` unconditionally, so the second call overwrites `fn→wrapper1` with `fn→wrapper2` and `off(fn)` can no longer remove `wrapper1`; same for `on(fn)` followed by `once(fn)`. The
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
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 Array.from(byType.keys())) clear(t);
Comment thread
robobun marked this conversation as resolved.
Outdated
else clear(type!);
Comment thread
robobun marked this conversation as resolved.
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;
}
Expand Down
5 changes: 5 additions & 0 deletions src/jsc/bindings/webcore/AddEventListenerOptions.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
bool isNodeStyleListener { false };
};

} // namespace WebCore
4 changes: 2 additions & 2 deletions src/jsc/bindings/webcore/EventFactory.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ JSC::JSValue toJSNewlyCreated(JSC::JSGlobalObject*, JSDOMGlobalObject* globalObj
}
// case CompositionEventInterfaceType:
// return createWrapper<CompositionEvent>(globalObject, WTF::move(impl));
// case CustomEventInterfaceType:
// return createWrapper<CustomEvent>(globalObject, WTF::move(impl));
case CustomEventInterfaceType:
return createWrapper<CustomEvent>(globalObject, WTF::move(impl));
// #if ENABLE(DEVICE_ORIENTATION)
// case DeviceMotionEventInterfaceType:
// return createWrapper<DeviceMotionEvent>(globalObject, WTF::move(impl));
Expand Down
4 changes: 2 additions & 2 deletions src/jsc/bindings/webcore/EventHeaders.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// #if ENABLE(DEVICE_ORIENTATION)
// #include "DeviceMotionEvent.h"
// #include "JSDeviceMotionEvent.h"
Expand Down
3 changes: 3 additions & 0 deletions src/jsc/bindings/webcore/EventListener.h
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ class EventListener : public RefCounted<EventListener>, public CanMakeWeakPtr<Ev
virtual ~EventListener() = default;
virtual bool operator==(const EventListener&) const = 0;
virtual void handleEvent(ScriptExecutionContext&, Event&) = 0;
// For listeners registered with kIsNodeStyleListener; JSEventListener
// overrides this to pass the event's carried value (.data/.detail/.error).
Comment thread
robobun marked this conversation as resolved.
virtual void handleEventNodeStyle(ScriptExecutionContext& ctx, Event& event) { handleEvent(ctx, event); }

virtual void visitJSFunction(JSC::AbstractSlotVisitor&) {}
virtual void visitJSFunction(JSC::SlotVisitor&) {}
Expand Down
17 changes: 17 additions & 0 deletions src/jsc/bindings/webcore/EventListenerMap.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,23 @@ bool EventListenerMap::remove(const AtomString& eventType, EventListener& listen
return false;
}

bool EventListenerMap::removeAll(const AtomString& eventType)
{
releaseAssertOrSetThreadUID();
Locker locker { m_lock };

for (unsigned i = 0; i < m_entries.size(); ++i) {
if (m_entries[i].first == eventType) {
for (auto& listener : m_entries[i].second)
listener->markAsRemoved();
m_entries.removeAt(i);
return true;
}
}

return false;
}

EventListenerVector* EventListenerMap::find(const AtomString& eventType)
{
for (auto& entry : m_entries) {
Expand Down
1 change: 1 addition & 0 deletions src/jsc/bindings/webcore/EventListenerMap.h
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class EventListenerMap {
void replace(const AtomString& eventType, EventListener& oldListener, Ref<EventListener>&& newListener, const RegisteredEventListener::Options&);
RegisteredEventListener* add(const AtomString& eventType, Ref<EventListener>&&, 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<EventListenerMap*>(this)->find(eventType); }
Vector<AtomString> eventTypes() const;
Expand Down
Loading
Loading