Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
9 changes: 5 additions & 4 deletions src/per_isolate/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ at context creation, before any user code. Gated by the
- `main.ts` installs the real stream globals (ReadableStream et al.) when
the `typescript_implemented_streams` compat flag is enabled, plus the
internal-testing `ReadableStreamDrainingReader` under
`expose_draining_reader`.
`expose_draining_reader`; and `Event`, `CustomEvent` and `EventTarget`
(`events/`) under `typescript_implemented_events`.
- Files are auto-discovered (`BUILD.bazel` and `tsconfig.json` both glob
`**/*.ts`). No registration needed for new files.
- Local convention: no copyright headers in this directory's bootstrap
Expand All @@ -46,8 +47,8 @@ at context creation, before any user code. Gated by the
The bootstrap runs for every worker regardless of compat date — both layouts
must work. JSG *methods* are always on the prototype.
- Internal cross-module surfaces are exported as a single namespace
object (`internalsForPipe`, `nativeStreamInternals`), never
re-exported to users.
object (`internalsForPipe`, `nativeStreamInternals`, `eventInternals`),
never re-exported to users.
- Internal class dispatch uses private-brand `in` checks, NEVER
`instanceof` (classes reachable from the global have user-reachable
`Symbol.hasInstance`).
Expand Down Expand Up @@ -220,4 +221,4 @@ to prevent accidental mutation by downstream bootstrap scripts.
- **NEVER** add a runtime require cycle between bootstrap scripts.

See also `webstreams/AGENTS.md` for streams-specific architecture and
anti-patterns.
anti-patterns, and `events/AGENTS.md` for the events classes.
63 changes: 63 additions & 0 deletions src/per_isolate/events/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# src/per_isolate/events/

TypeScript `Event`, `CustomEvent` and `EventTarget` (DOM §2), installed on
the global by `main.ts` under `typescript_implemented_events`. The C++
classes (`src/workerd/api/basics.{h,c++}`) stay registered: the runtime's
own events (`MessageEvent` and the rest) and targets (the global,
`AbortSignal`) are still C++, and the two implementations accept none of
each other's objects. File headers are the authoritative docs.

Parent directory conventions (primordials discipline, the JSG capture trap,
private-brand dispatch, no `instanceof`) apply here; see
`src/per_isolate/AGENTS.md`.

## FILE MAP

| File | Role |
| --- | --- |
| `common.ts` | WebIDL helpers (`toDictionary`, `notEnoughArguments`, `toStringTagProperty`, ...) and `requireAbortSignal`; leaf module |
| `event.ts` | `Event`, `CustomEvent`, and `eventInternals`: the frozen, null-prototype namespace of the spec steps `event-target.ts` performs on an event (`beginDispatch`, `enterInvoke`, `endDispatch`, ...); requires `common` |
| `event-target.ts` | `EventTarget`: listener storage, the options' conversion, dispatch, the `{signal}` abort algorithm; requires `common` and `event` |
| `events.ts` | Aggregator of the user-visible classes |

## INVARIANTS

- **The TS classes consult no C++ compat flag.** They hard-code the modern,
spec behavior at every compat date. The parity suites
(`src/tests/events/`) pin every place this differs from the C++ classes,
in their `AGENTS.md` ledgers; a behavior change here updates the ledger
row and its test.
- **Event state is private fields**, one `#flags` word for the spec's flags.
`event-target.ts` reaches it only through `eventInternals`, never the
public attributes, which user code can shadow.
- **`isTrusted` is an own, non-configurable accessor** defined in the
constructor ([LegacyUnforgeable]), with one getter shared by every
instance so their maps stay shared.
- **Listener records are null-prototype**; a type's list is created by the
first add and deleted once it empties. Removal marks the record
`removed` before splicing it, which is what a dispatch's clone of the
list checks.
- **Listeners are called through the captured `FunctionPrototypeCall`**,
and an object listener's `handleEvent` is read at every call. Results are
ignored.
- **Listener exceptions go to the global `reportError()` captured at load**
(not the property's current value), and never reach the
`dispatchEvent()` caller. A `reportError()` that itself throws (a
throwing `error` listener without `spec_compliant_dispatch_exceptions`)
is dropped.
- **The `{signal}` abort algorithm holds its record through a `WeakRef`**,
so a long-lived signal never retains a dead target or its listeners; the
record holds the registration handle, whose collection unregisters the
algorithm.
- **`requireAbortSignal` brand-checks through the C++ `aborted` getter.**
Under the instance-property layout (old compat dates) there is no getter,
and the check accepts any object whose `aborted` is a boolean
(event-target ledger #14).

## ANTI-PATTERNS

- **NEVER** read an event's public attributes, or call its public methods,
from `event-target.ts`; go through `eventInternals`.
- **NEVER** capture `globalThis.EventTarget` or `globalThis.Event` in
another bootstrap module: under the flag they are these classes, and
without it the C++ ones. `require('events/events')` for these.
101 changes: 101 additions & 0 deletions src/per_isolate/events/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
'use strict';

// Helpers shared by the events modules. Leaf module: requires nothing.
//
// TODO(cleanup): isObject, kNullProtoObject, kEnumerable,
// toStringTagProperty and toDictionary duplicate the WebIDL helpers in
// webstreams/common.ts (isObject is that file's requireObject check), and
// requireAbortSignal duplicates the signal check in webstreams/readable.ts's
// convertPipeOptions. Reconcile them into one shared module.

const { AbortSignalAbortedGet, ObjectFreeze, TypeError } = primordials;

// WebIDL's check that a value is an Object: true for functions too, false
// for null and every other primitive.
function isObject(value: unknown): value is object {
return (
(typeof value === 'object' && value !== null) || typeof value === 'function'
);
}

// What an omitted (or null) dictionary argument stands for. Null-prototype:
// WebIDL reads nothing for an omitted dictionary, so neither may we.
const kNullProtoObject: object = ObjectFreeze({ __proto__: null });

const kEnumerable: PropertyDescriptor = ObjectFreeze({
__proto__: kNullProtoObject,
enumerable: true,
});

function toStringTagProperty(tag: string): PropertyDescriptor {
return {
__proto__: kNullProtoObject,
value: tag,
writable: false,
enumerable: false,
configurable: true,
} as PropertyDescriptor;
}

// WebIDL conversion of an optional dictionary argument: undefined and null
// mean an empty dictionary, any other object (a function included) is read
// as the dictionary, and every other primitive is a TypeError.
function toDictionary(value: unknown, message: string): object {
if (value == null) return kNullProtoObject;
if (!isObject(value)) throw new TypeError(message);
return value;
}

// WebIDL's TypeError for a call with fewer arguments than the operation
// requires. `context` names the call, as in "Failed to execute
// 'dispatchEvent' on 'EventTarget'".
function notEnoughArguments(
context: string,
required: number,
present: number
): TypeError {
const noun = required === 1 ? 'argument' : 'arguments';
return new TypeError(
`${context}: ${required} ${noun} required, but only ${present} present.`
);
}

// WebIDL conversion of a value to an AbortSignal, returning whether it has
// aborted. Under the modern JSG layout the captured `aborted` getter throws
// for a receiver that is not an AbortSignal; under the instance-property
// layout (old compat dates) the capture is a plain read that cannot
// brand-check, so additionally require the boolean a genuine signal's own
// data property carries. Under old dates a forged {aborted: <boolean>}
// passes: utils.addAbortAlgorithm() rejects it when a listener is added, but
// a null listener, or a forgery claiming to have aborted, returns before
// that (event-target suite ledger #14).
function requireAbortSignal(value: unknown, message: string): boolean {
let aborted: unknown;
try {
aborted = AbortSignalAbortedGet(value);
} catch {
throw new TypeError(message);
}
if (typeof aborted !== 'boolean') throw new TypeError(message);
return aborted;
}

export type CommonHelpers = {
isObject: typeof isObject;
kNullProtoObject: typeof kNullProtoObject;
kEnumerable: typeof kEnumerable;
toStringTagProperty: typeof toStringTagProperty;
toDictionary: typeof toDictionary;
notEnoughArguments: typeof notEnoughArguments;
requireAbortSignal: typeof requireAbortSignal;
};

module.exports = ObjectFreeze({
isObject,
kNullProtoObject,
kEnumerable,
toStringTagProperty,
toDictionary,
notEnoughArguments,
requireAbortSignal,
});
Loading
Loading