From 5c059cfe245a5170f41b380ab7d553b5a3be845e Mon Sep 17 00:00:00 2001 From: John Wright Date: Fri, 4 Sep 2026 17:24:24 +0100 Subject: [PATCH 01/18] docs: agents --- .github/prompts/new-package.prompt.md | 42 +++++++++++++++++++++++++++ AGENTS.md | 34 ++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 .github/prompts/new-package.prompt.md create mode 100644 AGENTS.md diff --git a/.github/prompts/new-package.prompt.md b/.github/prompts/new-package.prompt.md new file mode 100644 index 000000000..2f0000aa9 --- /dev/null +++ b/.github/prompts/new-package.prompt.md @@ -0,0 +1,42 @@ +--- +description: "Scaffold a new @plugola/* workspace package with the repo's standard structure (package.json, tsconfigs, src, test, README, LICENSE) and wire it into the root tsconfig and release-please config. Use when adding a new package to the monorepo." +name: 'New Plugola Package' +argument-hint: ' [one-line description]' +agent: 'agent' +--- + +Scaffold a new `@plugola/*` package in this monorepo. Follow the conventions in [AGENTS.md](../../AGENTS.md). + +## Inputs + +- **Package name**: the unscoped name (e.g. `state-machine` → published as `@plugola/state-machine`). If not provided in the arguments, ask for it. +- **Description**: a one-line summary for `package.json` and the README. If not provided, ask. +- **Main entry file**: default `src/index.ts`. Ask only if the package's primary export should be a named file (some packages use e.g. `src/Store.ts` and point `exports` at `./dist/Store.js`). + +Model the new package on the smallest existing one, [packages/store](../../packages/store) — read its files first to match structure and current dependency versions exactly. + +## Steps + +1. Create `packages//` with these files: + - `package.json` — `"name": "@plugola/"`, `"version": "0.0.0"`, `"type": "module"`, `"sideEffects": false`, `exports` pointing at the built `./dist/.js`. Copy the `scripts` block (`build`, `clean`, `start`, `test`) and `devDependencies` verbatim from an existing package so versions stay in sync. Keep `dependencies` to `tslib` unless the user needs more. + - `tsconfig.json` — `extends: "../../tsconfig.json"`, `compilerOptions: { "outDir": "dist", "rootDir": "./src" }`, `include: ["src"]`. + - `test/tsconfig.json` — `extends: "../tsconfig.json"`, `compilerOptions: { "noEmit": true, "rootDir": "../" }`, `include: ["../src", "."]`. + - `src/.ts` — a minimal starting export (default export for a main class, using `#private` fields; named exports otherwise). + - `test/.test.ts` — a vitest spec importing from `../src/.js` (note the `.js` extension) with `import { test, expect } from 'vitest'` and one placeholder assertion. + - `README.md` — `# @plugola/`, the description as a `>` blockquote, and a `## Usage` heading. + - `LICENSE` — copy verbatim from [packages/store/LICENSE](../../packages/store/LICENSE). + +2. Wire it into the build: add `{ "path": "packages/" }` to `references` in the root [tsconfig.json](../../tsconfig.json), keeping the list alphabetically ordered. + +3. Register it for releases: add `"packages/": {}` to the `packages` map in [release-please-config.json](../../release-please-config.json), keeping alphabetical order. + +4. Verify: run `npm install` (to link the workspace), then `npm run build` and `npm test --workspace @plugola/`. Fix any errors before finishing. + +## Conventions to honor + +- **ESM only**: relative imports MUST include the `.js` extension, even in `.ts` files. +- **Prettier**: no semicolons, single quotes — match the surrounding code exactly. +- **Strict TS**: no unused locals/parameters, or the build fails. +- Do NOT hand-write a `CHANGELOG.md` or invent a real version — release-please manages versions and changelogs. + +When done, print the list of created/modified files and the exact command to run the new package's tests. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..6f4375aa5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Plugola + +TypeScript monorepo of small, independently published `@plugola/*` packages built around a plugin system (message bus, plugin manager, store, streams, logger, etc.). See the [README](README.md) and each package's own `README.md`. + +## Layout + +- npm workspaces: every package lives in `packages/*` and is published independently. +- Each package: `src/` (source), `test/` (vitest specs), `dist/` (build output, generated), its own `package.json`, `tsconfig.json`, and `CHANGELOG.md`. +- Cross-package deps use published versions (e.g. `@plugola/plugin-manager` depends on `@plugola/graph`), wired via TypeScript project references in the root [tsconfig.json](tsconfig.json). + +## Commands (run from repo root) + +- `npm ci` — install. +- `npm run build` — `tsc --build` across all project references. Run before tests; packages import each other's compiled `dist/`. +- `npm test` — runs `npm test` in every workspace (each is `vitest --run`). +- `npm start` — `tsc --build --watch`. +- Single package: `npm test --workspace @plugola/` or `cd packages/ && npx vitest`. + +## Conventions + +- **ESM only**: every package is `"type": "module"` with `module`/`moduleResolution: nodenext`. Relative imports MUST include the `.js` extension, even from `.ts` files (e.g. `import Graph from '../src/Graph.js'`). +- **TypeScript is strict**, plus `noUnusedLocals`, `noUnusedParameters`, `noImplicitOverride`, `noImplicitReturns`. Clean up unused code or the build fails. +- **Main class per package** is usually a `default export`; use `#private` class fields for internals. +- **Prettier** (see [prettier.config.js](prettier.config.js)): no semicolons, single quotes. `lint-staged` formats on commit. +- **Tests**: vitest with `import { test, expect, beforeEach } from 'vitest'`. Inline snapshots (`toMatchInlineSnapshot`) are used heavily — regenerate with `npx vitest -u`. + +## Commits & releases + +- **Conventional Commits** are enforced by commitlint + husky on commit (`feat:`, `fix:`, `chore:`, etc.). Non-conforming messages are rejected. +- Releases are automated via release-please ([release-please-config.json](release-please-config.json)); packages are versioned and tagged independently. Do not hand-edit `CHANGELOG.md` or bump versions manually. + +## CI + +PRs run `npm ci && npm run build && npm test` on Node 24 and 26 ([pull-request.yml](.github/workflows/pull-request.yml)). Ensure a clean build and passing tests on a current Node version before pushing. From 26bd49b8c4751c49b62c020fea9d4ccdb2b51eeb Mon Sep 17 00:00:00 2001 From: John Wright Date: Sat, 12 Sep 2026 09:10:37 +0100 Subject: [PATCH 02/18] feat: starting a new bus structure --- packages/message-bus/package.json | 1 + packages/message-bus/src/dmg-bus/Broker.ts | 266 +++ .../message-bus/src/dmg-bus/Broker/Broker.ts | 35 + .../src/dmg-bus/Broker/EventHandler.ts | 17 + .../message-bus/src/dmg-bus/Broker/Factory.ts | 36 + .../message-bus/src/dmg-bus/Broker/Handler.ts | 21 + .../src/dmg-bus/Broker/InboundBroker.ts | 53 + .../src/dmg-bus/Broker/InvocationHandler.ts | 27 + .../src/dmg-bus/Broker/OutboundBroker.ts | 223 +++ packages/message-bus/src/dmg-bus/Bus.ts | 100 ++ .../src/dmg-bus/DiscriminatedQueue.ts | 32 + packages/message-bus/src/dmg-bus/Event.ts | 21 + .../message-bus/src/dmg-bus/EventListener.ts | 18 + packages/message-bus/src/dmg-bus/Filter.ts | 20 + packages/message-bus/src/dmg-bus/Function.ts | 17 + .../message-bus/src/dmg-bus/PluginBroker.ts | 114 ++ packages/message-bus/src/dmg-bus/Queue.ts | 29 + .../message-bus/src/dmg-bus/broker.test.ts | 161 ++ packages/message-bus/src/dmg-bus/mung.ts | 12 + packages/message-bus/src/dmg-bus/polyfill.ts | 4 + .../message-bus/src/dmg-bus/queue.test.ts | 23 + .../src/dmg-bus/queuedMethods.test.ts | 36 + .../message-bus/src/dmg-bus/queuedMethods.ts | 16 + packages/message-bus/vitest.config.ts | 7 + yarn.lock | 1422 +++++++++++++++++ 25 files changed, 2711 insertions(+) create mode 100644 packages/message-bus/src/dmg-bus/Broker.ts create mode 100644 packages/message-bus/src/dmg-bus/Broker/Broker.ts create mode 100644 packages/message-bus/src/dmg-bus/Broker/EventHandler.ts create mode 100644 packages/message-bus/src/dmg-bus/Broker/Factory.ts create mode 100644 packages/message-bus/src/dmg-bus/Broker/Handler.ts create mode 100644 packages/message-bus/src/dmg-bus/Broker/InboundBroker.ts create mode 100644 packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts create mode 100644 packages/message-bus/src/dmg-bus/Broker/OutboundBroker.ts create mode 100644 packages/message-bus/src/dmg-bus/Bus.ts create mode 100644 packages/message-bus/src/dmg-bus/DiscriminatedQueue.ts create mode 100644 packages/message-bus/src/dmg-bus/Event.ts create mode 100644 packages/message-bus/src/dmg-bus/EventListener.ts create mode 100644 packages/message-bus/src/dmg-bus/Filter.ts create mode 100644 packages/message-bus/src/dmg-bus/Function.ts create mode 100644 packages/message-bus/src/dmg-bus/PluginBroker.ts create mode 100644 packages/message-bus/src/dmg-bus/Queue.ts create mode 100644 packages/message-bus/src/dmg-bus/broker.test.ts create mode 100644 packages/message-bus/src/dmg-bus/mung.ts create mode 100644 packages/message-bus/src/dmg-bus/polyfill.ts create mode 100644 packages/message-bus/src/dmg-bus/queue.test.ts create mode 100644 packages/message-bus/src/dmg-bus/queuedMethods.test.ts create mode 100644 packages/message-bus/src/dmg-bus/queuedMethods.ts create mode 100644 packages/message-bus/vitest.config.ts create mode 100644 yarn.lock diff --git a/packages/message-bus/package.json b/packages/message-bus/package.json index d9e1fb2ba..e097efdd9 100644 --- a/packages/message-bus/package.json +++ b/packages/message-bus/package.json @@ -35,6 +35,7 @@ "@johngw/async-iterator": "^4.0.1", "@johngw/stream": "^4.2.0", "@plugola/invoke": "^2.0.3", + "esbuild-decorators": "^1.0.0", "ts-toolbelt": "^9.6.0", "tslib": "^2.8.1" } diff --git a/packages/message-bus/src/dmg-bus/Broker.ts b/packages/message-bus/src/dmg-bus/Broker.ts new file mode 100644 index 000000000..3d22ba553 --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Broker.ts @@ -0,0 +1,266 @@ +import { Bus } from './Bus.js' +import { DiscriminatedQueue } from './DiscriminatedQueue.js' +import { Invocation, InvocationClass, Event, EventClass } from './Event.js' +import { InvocationListener, EventListener } from './EventListener.js' +import { Filter, FilterEntries, FilterPredicate } from './Filter.js' +import { withCounter } from './Function.js' +import { PluginBroker } from './PluginBroker.js' +import { queueMethod } from './queuedMethods.js' + +abstract class Handler { + readonly #filterEntries: FilterEntries + + constructor(filter: Filter) { + this.#filterEntries = Object.entries(filter) as FilterEntries + } + + protected filter(event: Event) { + return ( + !this.#filterEntries.length || + this.#filterEntries.some(([key, value]) => + typeof value === 'function' + ? (value as FilterPredicate)(event) + : value === event[key], + ) + ) + } +} + +class EventHandler extends Handler { + readonly #listener: EventListener + + constructor(filter: Filter, listener: EventListener) { + super(filter) + this.#listener = listener + } + + handle(event: Event) { + if (this.filter(event)) this.#listener(event) + } +} + +class InvocationHandler extends Handler { + readonly #listener: InvocationListener> + + constructor( + filter: Filter, + listener: InvocationListener>, + ) { + super(filter) + this.#listener = listener + } + + handle( + event: Invocation, + context: { + send: (value: unknown) => void + finish: () => void + signal?: AbortSignal + }, + ) { + if (this.filter(event)) this.#listener(event, context) + } +} + +export class Broker implements ListenableBroker { + readonly eventHandlers = new Map>() + + readonly invokeHandlers = new Map< + InvocationClass, + Set + >() + + readonly queue = new DiscriminatedQueue({}) + + constructor( + public readonly bus: Bus, + public readonly name: string, + public readonly abortSignal: AbortSignal, + ) { + abortSignal.addEventListener('abort', () => { + this.eventHandlers.clear() + this.invokeHandlers.clear() + }) + } + + emit = queueMethod(this.queue, (event: Event) => { + for (const eventHandler of this.eventHandlers.get( + event.constructor as EventClass, + ) ?? []) + eventHandler.handle(event) + }) + + invoke( + event: Invocation, + context: { + finish: () => void + send: (value: any) => void + signal?: AbortSignal + }, + ) { + if (!this.queue.running) return context.finish() + + const invokeHandlers = this.invokeHandlers.get( + event.constructor as InvocationClass, + ) + + if (!invokeHandlers?.size) return context.finish() + + const finish = withCounter((counter) => { + if (counter >= invokeHandlers.size) context.finish() + }) + + for (const invokeHandler of invokeHandlers) + invokeHandler.handle(event, { ...context, finish }) + } + + start() { + this.queue.start() + } + + stop() { + this.queue.stop() + } + + on( + eventClass: E, + eventListener: EventListener, + ): () => void + + on( + eventClass: E, + filter: Filter, + eventListener: EventListener, + ): () => void + + on( + eventClass: E, + filterOrEventListener: Filter | EventListener, + eventListener?: EventListener, + ): () => void { + const filter = (eventListener ? filterOrEventListener : {}) as Filter + eventListener ??= filterOrEventListener as EventListener + const eventHandlers = this.eventHandlers.getOrInsert(eventClass, new Set()) + const eventHandler = new EventHandler(filter, eventListener) + eventHandlers.add(eventHandler) + const unregister = this.bus.on(this, eventClass) + return () => { + const eventHandlers = this.eventHandlers.get(eventClass) + eventHandlers?.delete(eventHandler) + if (!eventHandlers?.size) unregister() + } + } + + once( + eventClass: E, + eventListener: EventListener, + ): () => void + + once( + eventClass: E, + filter: Filter, + eventListener: EventListener, + ): () => void + + once( + eventClass: E, + filterOrEventListener: Filter | EventListener, + eventListener?: EventListener, + ): () => void { + const filter = (eventListener ? filterOrEventListener : {}) as Filter + eventListener ??= filterOrEventListener as EventListener + const off = this.on(eventClass, filter, (event) => { + eventListener(event) + off() + }) + return off + } + + until( + eventClass: E, + filter: Filter = {}, + ): Promise> { + return new Promise((resolve, reject) => { + const onAbort = () => reject(this.abortSignal.reason) + if (this.abortSignal.aborted) return onAbort() + this.abortSignal.addEventListener('abort', onAbort) + this.once(eventClass, filter, (event) => { + this.abortSignal.removeEventListener('abort', onAbort) + resolve(event) + }) + }) + } + + register>( + eventClass: E, + listener: InvocationListener, + ): () => void + + register>( + eventClass: E, + filter: Filter, + listener: InvocationListener, + ): () => void + + register>( + eventClass: E, + filterOrListener: Filter | InvocationListener, + listener?: InvocationListener, + ): () => void { + const filter = (listener ? filterOrListener : {}) as Filter + listener ??= filterOrListener as InvocationListener + const handlers = this.invokeHandlers.getOrInsert(eventClass, new Set()) + const handler = new InvocationHandler(filter, listener) + handlers.add(handler) + const unregister = this.bus.register(this, eventClass) + return () => { + const handlers = this.invokeHandlers.get(eventClass) + handlers?.delete(handler) + if (!handlers?.size) unregister() + } + } + + pluginBroker() { + return new PluginBroker(this) + } +} + +export interface ListenableBroker { + on( + eventClass: E, + eventListener: EventListener, + ): () => void + + on( + eventClass: E, + filter: Filter, + eventListener: EventListener, + ): () => void + + once( + eventClass: E, + eventListener: EventListener, + ): () => void + + once( + eventClass: E, + filter: Filter, + eventListener: EventListener, + ): () => void + + until( + eventClass: E, + filter?: Filter, + ): Promise> + + register>( + eventClass: E, + listener: InvocationListener, + ): () => void + + register>( + eventClass: E, + filter: Filter, + listener: InvocationListener, + ): () => void +} diff --git a/packages/message-bus/src/dmg-bus/Broker/Broker.ts b/packages/message-bus/src/dmg-bus/Broker/Broker.ts new file mode 100644 index 000000000..6a256ee6a --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Broker/Broker.ts @@ -0,0 +1,35 @@ +import { Bus } from '../Bus.js' +import { DiscriminatedQueue } from '../DiscriminatedQueue.js' +import { EventClass, InvocationClass } from '../Event.js' +import { EventHandler } from './EventHandler.js' +import { InboundBroker } from './InboundBroker.js' +import { InvocationHandler } from './InvocationHandler.js' +import { OutboundBroker } from './OutboundBroker.js' + +export class Broker { + readonly eventHandlers = new Map>() + + readonly invokeHandlers = new Map< + InvocationClass, + Set + >() + + readonly queue = new DiscriminatedQueue({}) + + readonly inbound: InboundBroker + + readonly outbound: OutboundBroker + + constructor( + readonly bus: Bus, + readonly name: string, + readonly abortSignal: AbortSignal, + ) { + this.inbound = new InboundBroker(this) + this.outbound = new OutboundBroker(this) + this.abortSignal.addEventListener('abort', () => { + this.eventHandlers.clear() + this.invokeHandlers.clear() + }) + } +} diff --git a/packages/message-bus/src/dmg-bus/Broker/EventHandler.ts b/packages/message-bus/src/dmg-bus/Broker/EventHandler.ts new file mode 100644 index 000000000..c107bbe08 --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Broker/EventHandler.ts @@ -0,0 +1,17 @@ +import { Event, EventClass } from '../Event.js' +import { EventListener } from '../EventListener.js' +import { Filter } from '../Filter.js' +import { Handler } from './Handler.js' + +export class EventHandler extends Handler { + readonly #listener: EventListener + + constructor(filter: Filter, listener: EventListener) { + super(filter) + this.#listener = listener + } + + handle(event: Event) { + if (this.filter(event)) this.#listener(event) + } +} diff --git a/packages/message-bus/src/dmg-bus/Broker/Factory.ts b/packages/message-bus/src/dmg-bus/Broker/Factory.ts new file mode 100644 index 000000000..26cf3ec90 --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Broker/Factory.ts @@ -0,0 +1,36 @@ +import type { Bus } from '../Bus.js' +import { DiscriminatedQueue } from '../DiscriminatedQueue.js' +import { EventClass, InvocationClass } from '../Event.js' +import { EventHandler } from './EventHandler.js' +import { InboundBroker } from './InboundBroker.js' +import { InvocationHandler } from './InvocationHandler.js' +import { OutboundBroker } from './OutboundBroker.js' + +export function brokerFactory(name: string, bus: Bus) { + const abortController = new AbortController() + const eventHandlers = new Map>() + const invocationHandlers = new Map< + InvocationClass, + Set + >() + const queue = new DiscriminatedQueue({}) + return { + abortController, + inboundBroker: new InboundBroker( + bus, + name, + abortController.signal, + eventHandlers, + invocationHandlers, + queue, + ), + outboundBroker: new OutboundBroker( + bus, + name, + abortController.signal, + eventHandlers, + invocationHandlers, + queue, + ), + } +} diff --git a/packages/message-bus/src/dmg-bus/Broker/Handler.ts b/packages/message-bus/src/dmg-bus/Broker/Handler.ts new file mode 100644 index 000000000..6d6b4e8ca --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Broker/Handler.ts @@ -0,0 +1,21 @@ +import { Event, EventClass } from '../Event.js' +import { Filter, FilterEntries, FilterPredicate } from '../Filter.js' + +export abstract class Handler { + readonly #filterEntries: FilterEntries + + constructor(filter: Filter) { + this.#filterEntries = Object.entries(filter) as FilterEntries + } + + protected filter(event: Event) { + return ( + !this.#filterEntries.length || + this.#filterEntries.some(([key, value]) => + typeof value === 'function' + ? (value as FilterPredicate)(event) + : value === event[key], + ) + ) + } +} diff --git a/packages/message-bus/src/dmg-bus/Broker/InboundBroker.ts b/packages/message-bus/src/dmg-bus/Broker/InboundBroker.ts new file mode 100644 index 000000000..1bdbe724f --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Broker/InboundBroker.ts @@ -0,0 +1,53 @@ +import { Event, EventClass, Invocation, InvocationClass } from '../Event.js' +import { withCounter } from '../Function.js' +import { queueMethod } from '../queuedMethods.js' +import { Broker } from './Broker.js' + +export class InboundBroker { + readonly #broker: Broker + + readonly emit: (event: Event) => void + + constructor(broker: Broker) { + this.#broker = broker + + this.emit = queueMethod(this.#broker.queue, (event: Event) => { + for (const eventHandler of this.#broker.eventHandlers.get( + event.constructor as EventClass, + ) ?? []) + eventHandler.handle(event) + }) + } + + invoke( + event: Invocation, + context: { + finish: () => void + send: (value: any) => void + signal?: AbortSignal + }, + ) { + if (!this.#broker.queue.running) return context.finish() + + const invokeHandlers = this.#broker.invokeHandlers.get( + event.constructor as InvocationClass, + ) + + if (!invokeHandlers?.size) return context.finish() + + const finish = withCounter((counter) => { + if (counter >= invokeHandlers.size) context.finish() + }) + + for (const invokeHandler of invokeHandlers) + invokeHandler.handle(event, { ...context, finish }) + } + + start() { + this.#broker.queue.start() + } + + stop() { + this.#broker.queue.stop() + } +} diff --git a/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts b/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts new file mode 100644 index 000000000..85b235e88 --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts @@ -0,0 +1,27 @@ +import { EventClass, Invocation, InvocationClass } from '../Event.js' +import { InvocationListener } from '../EventListener.js' +import { Filter } from '../Filter.js' +import { Handler } from './Handler.js' + +export class InvocationHandler extends Handler { + readonly #listener: InvocationListener> + + constructor( + filter: Filter, + listener: InvocationListener>, + ) { + super(filter) + this.#listener = listener + } + + handle( + event: Invocation, + context: { + send: (value: unknown) => void + finish: () => void + signal?: AbortSignal + }, + ) { + if (this.filter(event)) this.#listener(event, context) + } +} diff --git a/packages/message-bus/src/dmg-bus/Broker/OutboundBroker.ts b/packages/message-bus/src/dmg-bus/Broker/OutboundBroker.ts new file mode 100644 index 000000000..edda2921b --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Broker/OutboundBroker.ts @@ -0,0 +1,223 @@ +import { + Event, + EventClass, + Invocation, + InvocationClass, + InvocationType, +} from '../Event.js' +import { + EventListener, + InvocationListener, + InvocationListenerContext, +} from '../EventListener.js' +import { Filter } from '../Filter.js' +import { queueMethod } from '../queuedMethods.js' +import { Broker } from './Broker.js' +import { EventHandler } from './EventHandler.js' +import { InvocationHandler } from './InvocationHandler.js' + +export class OutboundBroker { + readonly #broker: Broker + + readonly #invoke: >( + event: InstanceType, + params: InvocationListenerContext, + ) => void + + readonly emit: (event: Event) => void + + constructor(broker: Broker) { + this.#broker = broker + + this.#invoke = queueMethod( + broker.queue, + ( + event: Invocation, + context: { + send(value: any): void + finish(): void + signal?: AbortSignal + }, + ) => { + this.#broker.bus.emit(event) + this.#broker.bus.invoke(event, context) + }, + ) + + this.emit = queueMethod(broker.queue, (event: Event) => { + this.#broker.bus.emit(event) + }) + } + + start(name: string) { + this.#broker.bus.start(name) + } + + stop(name: string) { + this.#broker.bus.stop(name) + } + + abort(name: string, reason?: any) { + this.#broker.bus.abort(name, reason) + } + + on( + eventClass: E, + eventListener: EventListener, + ): () => void + + on( + eventClass: E, + filter: Filter, + eventListener: EventListener, + ): () => void + + on( + eventClass: E, + filterOrEventListener: Filter | EventListener, + eventListener?: EventListener, + ): () => void { + const filter = (eventListener ? filterOrEventListener : {}) as Filter + eventListener ??= filterOrEventListener as EventListener + const eventHandlers = this.#broker.eventHandlers.getOrInsert( + eventClass, + new Set(), + ) + const eventHandler = new EventHandler(filter, eventListener) + eventHandlers.add(eventHandler) + const unregister = this.#broker.bus.on(this, eventClass) + return () => { + const eventHandlers = this.#broker.eventHandlers.get(eventClass) + eventHandlers?.delete(eventHandler) + if (!eventHandlers?.size) unregister() + } + } + + once( + eventClass: E, + eventListener: EventListener, + ): () => void + + once( + eventClass: E, + filter: Filter, + eventListener: EventListener, + ): () => void + + once( + eventClass: E, + filterOrEventListener: Filter | EventListener, + eventListener?: EventListener, + ): () => void { + const filter = (eventListener ? filterOrEventListener : {}) as Filter + eventListener ??= filterOrEventListener as EventListener + const off = this.on(eventClass, filter, (event) => { + eventListener(event) + off() + }) + return off + } + + until( + eventClass: E, + filter: Filter = {}, + ): Promise> { + return new Promise((resolve, reject) => { + const onAbort = () => reject(this.#broker.abortSignal.reason) + if (this.#broker.abortSignal.aborted) return onAbort() + this.#broker.abortSignal.addEventListener('abort', onAbort) + this.once(eventClass, filter, (event) => { + this.#broker.abortSignal.removeEventListener('abort', onAbort) + resolve(event) + }) + }) + } + + register>( + eventClass: E, + listener: InvocationListener, + ): () => void + + register>( + eventClass: E, + filter: Filter, + listener: InvocationListener, + ): () => void + + register>( + eventClass: E, + filterOrListener: Filter | InvocationListener, + listener?: InvocationListener, + ): () => void { + const filter = (listener ? filterOrListener : {}) as Filter + listener ??= filterOrListener as InvocationListener + const handlers = this.#broker.invokeHandlers.getOrInsert( + eventClass, + new Set(), + ) + const handler = new InvocationHandler(filter, listener) + handlers.add(handler) + const unregister = this.#broker.bus.register(this, eventClass) + return () => { + const handlers = this.#broker.invokeHandlers.get(eventClass) + handlers?.delete(handler) + if (!handlers?.size) unregister() + } + } + + invoke>( + event: E, + { + signal, + }: { + signal?: AbortSignal + } = {}, + ): { + collect(): Promise[]> + iterate(): AsyncIterable, undefined> + promise(): Promise + } { + type T = InvocationType + + const readableStream = new ReadableStream({ + start: (controller) => { + const abort = () => controller.error(this.#broker.abortSignal.reason) + const close = () => controller.close() + + if (this.#broker.abortSignal.aborted) return abort() + if (signal?.aborted) return close() + + this.#broker.abortSignal.addEventListener('abort', abort) + signal?.addEventListener('abort', close) + + this.#invoke(event, { + send: (value: T) => controller.enqueue(value), + finish: () => { + this.#broker.abortSignal.removeEventListener('abort', abort) + signal?.removeEventListener('abort', close) + controller.close() + }, + signal, + }) + }, + }) + + return { + async collect() { + const items: T[] = [] + for await (const item of readableStream) items.push(item) + return items + }, + + iterate: () => readableStream.values(), + + promise: async (concurrency = 10) => { + const reader = readableStream.getReader() + let item = await reader.read() + for (let i = 0; i < concurrency && !item.done; i++) { + while ((item = await reader.read()) && !item.done) {} + } + }, + } + } +} diff --git a/packages/message-bus/src/dmg-bus/Bus.ts b/packages/message-bus/src/dmg-bus/Bus.ts new file mode 100644 index 000000000..f2e1e8e3b --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Bus.ts @@ -0,0 +1,100 @@ +import { brokerFactory } from './Broker/Factory.js' +import { InboundBroker } from './Broker/InboundBroker.js' +import { OutboundBroker } from './Broker/OutboundBroker.js' +import { Event, EventClass, Invocation, InvocationClass } from './Event.js' +import { withCounter } from './Function.js' + +export class Bus { + #brokers = new Map< + string, + { broker: InboundBroker; abortController: AbortController } + >() + + #eventBrokers = new Map>() + + #invokeBrokers = new Map, Set>() + + broker(name: string) { + if (this.#brokers.has(name)) + throw new Error(`Broker "${name}" has already been registered`) + + const { abortController, inboundBroker, outboundBroker } = brokerFactory( + name, + this, + ) + + this.#brokers.set(name, { broker: inboundBroker, abortController }) + + abortController.signal.addEventListener('abort', () => { + this.#brokers.delete(name) + for (const brokerNames of this.#eventBrokers.values()) { + brokerNames.delete(name) + } + }) + + return outboundBroker + } + + on(broker: OutboundBroker, eventClass: E): () => void { + const eventBrokers = this.#eventBrokers.getOrInsert(eventClass, new Set()) + eventBrokers.add(broker.name) + return () => { + this.#eventBrokers.get(eventClass)?.delete(broker.name) + } + } + + emit(event: E) { + const eventBrokers = this.#eventBrokers.get(event.constructor as EventClass) + if (!eventBrokers?.size) return + for (const name of eventBrokers) this.#brokers.get(name)?.broker.emit(event) + } + + register( + broker: OutboundBroker, + eventClass: InvocationClass, + ): () => void { + const invokeBrokers = this.#invokeBrokers.getOrInsert(eventClass, new Set()) + invokeBrokers.add(broker.name) + return () => { + this.#invokeBrokers.get(eventClass)?.delete(broker.name) + } + } + + invoke( + event: Invocation, + context: { + send: (item: T) => void + finish: () => void + signal?: AbortSignal + }, + ) { + const invokeBrokers = this.#invokeBrokers.get( + event.constructor as InvocationClass, + ) + + if (!invokeBrokers?.size) return context.finish() + + const finish = withCounter((counter) => { + if (counter >= invokeBrokers.size) context.finish() + }) + + for (const name of invokeBrokers) + this.#brokers.get(name)?.broker.invoke(event, { ...context, finish }) + } + + abort(name: string, reason?: Error) { + this.#brokers.get(name)?.abortController.abort(reason) + } + + start(name?: string) { + if (name === undefined) + for (const { broker } of this.#brokers.values()) broker.start() + else this.#brokers.get(name)?.broker.start() + } + + stop(name?: string) { + if (name === undefined) + for (const { broker } of this.#brokers.values()) broker.stop() + else this.#brokers.get(name)?.broker.stop() + } +} diff --git a/packages/message-bus/src/dmg-bus/DiscriminatedQueue.ts b/packages/message-bus/src/dmg-bus/DiscriminatedQueue.ts new file mode 100644 index 000000000..1794fe25d --- /dev/null +++ b/packages/message-bus/src/dmg-bus/DiscriminatedQueue.ts @@ -0,0 +1,32 @@ +import { Queue } from './Queue.js' + +export class DiscriminatedQueue< + T extends Record, +> extends Queue> { + #execRecord: DiscriminatedQueueExecRecord + + constructor(execRecord: DiscriminatedQueueExecRecord) { + super((item) => { + execRecord[item.type](item) + }) + this.#execRecord = execRecord + } + + addExec( + type: K, + exec: (item: T) => void, + ): DiscriminatedQueue { + type NewQueue = DiscriminatedQueue + const newQueue = this as unknown as NewQueue + newQueue.#execRecord[type] = exec + return newQueue + } +} + +type DiscriminatedQueueItem> = { + [K in keyof T]: { type: K } & T[K] +}[keyof T] + +type DiscriminatedQueueExecRecord> = { + [K in keyof T]: (item: T[K]) => void +} diff --git a/packages/message-bus/src/dmg-bus/Event.ts b/packages/message-bus/src/dmg-bus/Event.ts new file mode 100644 index 000000000..3dc142ad5 --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Event.ts @@ -0,0 +1,21 @@ +export interface Event { + readonly $name: string +} + +export type EventClass = abstract new ( + ...args: any[] +) => E + +export abstract class Invocation implements Event { + abstract $name: string + declare $invocationType: T +} + +export type InvocationClass = abstract new (...args: any) => Invocation + +export type InvocationType | InvocationClass> = + E extends Invocation + ? V + : E extends InvocationClass + ? V + : never diff --git a/packages/message-bus/src/dmg-bus/EventListener.ts b/packages/message-bus/src/dmg-bus/EventListener.ts new file mode 100644 index 000000000..942c71e03 --- /dev/null +++ b/packages/message-bus/src/dmg-bus/EventListener.ts @@ -0,0 +1,18 @@ +import { EventClass, InvocationClass, InvocationType } from './Event.js' + +export interface EventListener { + (event: InstanceType): void | Promise +} + +export interface InvocationListener> { + ( + event: InstanceType, + context: InvocationListenerContext, + ): void | Promise +} + +export interface InvocationListenerContext> { + send: (value: InvocationType) => unknown + finish: () => unknown + signal?: AbortSignal +} diff --git a/packages/message-bus/src/dmg-bus/Filter.ts b/packages/message-bus/src/dmg-bus/Filter.ts new file mode 100644 index 000000000..1996834d5 --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Filter.ts @@ -0,0 +1,20 @@ +import { EventClass } from './Event.js' + +export type Filter = { + [K in keyof InstanceType]?: FilterValue +} + +export type FilterEntry = { + [K in keyof InstanceType]: [K, FilterValue] +}[keyof InstanceType] + +export type FilterEntries = FilterEntry[] + +export type FilterValue< + E extends EventClass, + K extends keyof InstanceType, +> = InstanceType[K] | FilterPredicate + +export interface FilterPredicate { + (event: InstanceType): boolean +} diff --git a/packages/message-bus/src/dmg-bus/Function.ts b/packages/message-bus/src/dmg-bus/Function.ts new file mode 100644 index 000000000..533fdbfff --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Function.ts @@ -0,0 +1,17 @@ +export function withCounter( + start: number, + fn: (counter: number, ...args: Args) => R, +): (...args: Args) => R + +export function withCounter( + fn: (counter: number, ...args: Args) => R, +): (...args: Args) => R + +export function withCounter( + startOrFn: number | ((counter: number, ...args: Args) => R), + fn?: (counter: number, ...args: Args) => R, +): (...args: Args) => R { + let counter = (fn ? startOrFn : 1) as number + fn ??= startOrFn as (counter: number, ...args: Args) => R + return (...args) => fn(counter++, ...args) +} diff --git a/packages/message-bus/src/dmg-bus/PluginBroker.ts b/packages/message-bus/src/dmg-bus/PluginBroker.ts new file mode 100644 index 000000000..f7671c38b --- /dev/null +++ b/packages/message-bus/src/dmg-bus/PluginBroker.ts @@ -0,0 +1,114 @@ +import { Broker, ListenableBroker } from './Broker.js' +import { Event, Invocation, InvocationType } from './Event.js' +import { queueMethod } from './queuedMethods.js' + +export class PluginBroker implements ListenableBroker { + #broker: Broker + #invoke: Broker['invoke'] + + emit: Broker['emit'] + on: ListenableBroker['on'] + once: ListenableBroker['once'] + until: ListenableBroker['until'] + register: ListenableBroker['register'] + + get abortSignal() { + return this.#broker.abortSignal + } + + constructor(broker: Broker) { + this.#broker = broker + + this.#invoke = queueMethod( + this.#broker.queue, + ( + event: Invocation, + context: { + send(value: any): void + finish(): void + signal?: AbortSignal + }, + ) => { + broker.bus.emit(event) + broker.bus.invoke(event, context) + }, + ) + + this.on = broker.on.bind(broker) + this.once = broker.once.bind(broker) + this.until = broker.until.bind(broker) + this.register = broker.register.bind(broker) + + this.emit = queueMethod(this.#broker.queue, (event: Event) => { + broker.bus.emit(event) + }) + } + + start(name: string) { + this.#broker.bus.start(name) + } + + stop(name: string) { + this.#broker.bus.stop(name) + } + + abort(name: string, reason?: any) { + this.#broker.bus.abort(name, reason) + } + + invoke>( + event: E, + { + signal, + }: { + signal?: AbortSignal + } = {}, + ): { + collect(): Promise[]> + iterate(): AsyncIterable, undefined> + promise(): Promise + } { + type T = InvocationType + + const readableStream = new ReadableStream({ + start: (controller) => { + const abort = () => controller.error(this.abortSignal.reason) + const close = () => controller.close() + + if (this.abortSignal.aborted) return abort() + if (signal?.aborted) return close() + + this.abortSignal.addEventListener('abort', abort) + signal?.addEventListener('abort', close) + + this.#invoke(event, { + send: (value: T) => controller.enqueue(value), + finish: () => { + this.abortSignal.removeEventListener('abort', abort) + signal?.removeEventListener('abort', close) + controller.close() + }, + signal, + }) + }, + }) + + return { + async collect() { + const items: T[] = [] + for await (const item of readableStream) items.push(item) + return items + }, + + iterate: () => readableStream.values(), + + promise: async (concurrency = 10) => { + const reader = readableStream.getReader() + let item = await reader.read() + for (let i = 0; i < concurrency && !item.done; i++) { + while ((item = await reader.read()) && !item.done) {} + } + }, + } + } +} diff --git a/packages/message-bus/src/dmg-bus/Queue.ts b/packages/message-bus/src/dmg-bus/Queue.ts new file mode 100644 index 000000000..90ed166a3 --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Queue.ts @@ -0,0 +1,29 @@ +export class Queue { + #exec: (item: T) => void + #items: T[] + + constructor(exec: (item: T) => void, items: T[] = []) { + this.#exec = exec + this.#items = items + } + + #running = false + get running() { + return this.#running + } + + push(item: T) { + if (this.#running) this.#exec(item) + else this.#items.push(item) + } + + start() { + this.#running = true + let item: T | undefined + while (this.#running && (item = this.#items.shift())) this.push(item) + } + + stop() { + this.#running = false + } +} diff --git a/packages/message-bus/src/dmg-bus/broker.test.ts b/packages/message-bus/src/dmg-bus/broker.test.ts new file mode 100644 index 000000000..d0347248a --- /dev/null +++ b/packages/message-bus/src/dmg-bus/broker.test.ts @@ -0,0 +1,161 @@ +import './polyfill.js' +import { setTimeout } from 'node:timers/promises' +import { beforeEach, describe, expect, Mock, test, vi } from 'vitest' +import { PluginBroker } from './PluginBroker.js' +import { Bus } from './Bus.js' +import { Event, Invocation } from './Event.js' +import { InvocationListenerParams } from './EventListener.js' + +let brokerA: PluginBroker +let brokerB: PluginBroker + +beforeEach(() => { + const bus = new Bus() + brokerA = bus.broker('a').pluginBroker() + brokerB = bus.broker('b').pluginBroker() + bus.start() +}) + +test('emit', () => { + const spy = vi.fn() + const event = new TestEvent('bar') + brokerA.on(TestEvent, spy) + brokerB.emit(event) + expect(spy).toHaveBeenCalledWith(event) +}) + +test('once', () => { + const spy = vi.fn() + const event = new TestEvent('bar') + brokerA.once(TestEvent, spy) + brokerA.emit(event) + brokerB.emit(event) + expect(spy).toHaveBeenCalledTimes(1) + expect(spy.mock.calls[0][0]).toBe(event) +}) + +test('twice', () => { + const spy = vi.fn() + const event = new TestEvent('bar') + brokerA.on(TestEvent, spy) + brokerA.emit(event) + brokerB.emit(event) + expect(spy).toHaveBeenCalledTimes(2) +}) + +test('until', async () => { + const event = new TestEvent('bar') + const promise = brokerA.until(TestEvent) + brokerA.emit(event) + expect(await promise).toEqual(event) +}) + +test('stop', () => { + const spy = vi.fn() + brokerB.stop('a') + brokerA.on(TestEvent, spy) + brokerB.emit(new TestEvent('foo')) + expect(spy).not.toHaveBeenCalled() + brokerB.start('a') + expect(spy).toHaveBeenCalled() +}) + +test('abort', () => { + const spy = vi.fn() + brokerA.abortSignal.addEventListener('abort', spy) + brokerB.abort('a') + expect(spy).toHaveBeenCalled() +}) + +describe('invoke', () => { + let spy: Mock< + ( + event: TestInvocation, + { send, finish }: InvocationListenerParams, + ) => void + > + + beforeEach(() => { + spy = vi.fn((event, { send, finish }) => { + send(`one ${event.foo}`) + send(`two ${event.foo}`) + finish() + }) + + brokerA.register(TestInvocation, spy) + }) + + test('collect', () => + expect( + brokerB.invoke(new TestInvocation('thing')).collect(), + ).resolves.toEqual(['one thing', 'two thing'])) + + test('iterate', async () => { + let result: string[] = [] + for await (const item of brokerB + .invoke(new TestInvocation('thing')) + .iterate()) { + result.push(item) + } + expect(result).toEqual(['one thing', 'two thing']) + }) + + test('promise', async () => { + await brokerB.invoke(new TestInvocation('thing')).promise() + expect(spy).toHaveBeenCalled() + }) + + test('it emits the event as well', () => { + const onSpy = vi.fn() + const event = new TestInvocation('bar') + brokerA.on(TestInvocation, onSpy) + brokerB.invoke(event) + expect(onSpy).toHaveBeenCalledWith(event) + }) + + test('multi registers', async () => { + brokerA.register(TestInvocation, (_, { finish, send }) => { + send('foo') + finish() + }) + + brokerB.register(TestInvocation, (_, { finish, send }) => { + send('bar') + finish() + }) + + expect(await brokerB.invoke(new TestInvocation('foo')).collect()).toEqual([ + 'one foo', + 'two foo', + 'foo', + 'bar', + ]) + }) + + test('timeouts', async () => { + brokerA.register(TestInvocation, async (event, { finish, send }) => { + send(`hello ${event.foo}`) + await setTimeout(1_000) + send(`hello again ${event.foo}`) + finish() + }) + + expect( + await brokerB + .invoke(new TestInvocation('foo'), { signal: AbortSignal.timeout(10) }) + .collect(), + ).toEqual(['one foo', 'two foo', 'hello foo']) + }) +}) + +class TestEvent implements Event { + $name = 'test' + constructor(readonly foo: string) {} +} + +class TestInvocation extends Invocation { + $name = 'test invocation' + constructor(readonly foo: string) { + super() + } +} diff --git a/packages/message-bus/src/dmg-bus/mung.ts b/packages/message-bus/src/dmg-bus/mung.ts new file mode 100644 index 000000000..00c1e6f59 --- /dev/null +++ b/packages/message-bus/src/dmg-bus/mung.ts @@ -0,0 +1,12 @@ +class AuctionEvent extends CallbackEvent {} + +broker.register(AuctionEvent, (event) => { + event.send(1) + event.send(2) + event.send(3) + event.finish() +}) + +const { promise } = broker.publish(new PromiseEvent(10_000)) + +broker.publish(new AuctionEvent()) diff --git a/packages/message-bus/src/dmg-bus/polyfill.ts b/packages/message-bus/src/dmg-bus/polyfill.ts new file mode 100644 index 000000000..6086c7eca --- /dev/null +++ b/packages/message-bus/src/dmg-bus/polyfill.ts @@ -0,0 +1,4 @@ +Map.prototype.getOrInsert ??= function (key, defaultValue) { + if (!this.has(key)) this.set(key, defaultValue) + return this.get(key)! +} diff --git a/packages/message-bus/src/dmg-bus/queue.test.ts b/packages/message-bus/src/dmg-bus/queue.test.ts new file mode 100644 index 000000000..04efa0edc --- /dev/null +++ b/packages/message-bus/src/dmg-bus/queue.test.ts @@ -0,0 +1,23 @@ +import { expect, test, vi } from 'vitest' +import { DiscriminatedQueue } from './DiscriminatedQueue.js' + +test('discriminated queue', () => { + const fooSpy = vi.fn((_item: { name: string }) => {}) + const barSpy = vi.fn((_item: { num: number }) => {}) + + const queue = new DiscriminatedQueue({ + foo: fooSpy, + bar: barSpy, + }) + + queue.push({ type: 'foo', name: 'My name' }) + queue.push({ type: 'bar', num: 123 }) + + expect(fooSpy).not.toHaveBeenCalled() + expect(barSpy).not.toHaveBeenCalled() + + queue.start() + + expect(fooSpy).toHaveBeenCalledWith({ type: 'foo', name: 'My name' }) + expect(barSpy).toHaveBeenCalledWith({ type: 'bar', num: 123 }) +}) diff --git a/packages/message-bus/src/dmg-bus/queuedMethods.test.ts b/packages/message-bus/src/dmg-bus/queuedMethods.test.ts new file mode 100644 index 000000000..dcab51fbd --- /dev/null +++ b/packages/message-bus/src/dmg-bus/queuedMethods.test.ts @@ -0,0 +1,36 @@ +import { expect, test, vi } from 'vitest' +import { queuedMethods } from './queuedMethods.js' +import { Queue } from './Queue.js' + +const Test = queuedMethods( + ['foo'], + class Test { + $queue!: Queue + + foo(spy: () => void) { + spy() + } + + bar(spy: () => void) { + spy() + } + }, +) + +test('doesnt affect bar', () => { + const t = new Test() + const spy = vi.fn() + t.bar(spy) + expect(spy).toHaveBeenCalled() +}) + +test('queues configured methods', () => { + const t = new Test() + const spy = vi.fn() + + t.foo(spy) + expect(spy).not.toHaveBeenCalled() + + t.$queue.start() + expect(spy).toHaveBeenCalled() +}) diff --git a/packages/message-bus/src/dmg-bus/queuedMethods.ts b/packages/message-bus/src/dmg-bus/queuedMethods.ts new file mode 100644 index 000000000..8860c7edd --- /dev/null +++ b/packages/message-bus/src/dmg-bus/queuedMethods.ts @@ -0,0 +1,16 @@ +import { DiscriminatedQueue } from './DiscriminatedQueue.js' + +export function queueMethod( + queue: DiscriminatedQueue, + method: (...args: Args) => unknown, +): (...args: Args) => void { + const type = Symbol() + + queue.addExec(type, (item: { args: Args }) => { + method(...item.args) + }) + + return (...args) => { + queue.push({ type, args }) + } +} diff --git a/packages/message-bus/vitest.config.ts b/packages/message-bus/vitest.config.ts new file mode 100644 index 000000000..acbba3ef1 --- /dev/null +++ b/packages/message-bus/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + oxc: { + target: 'es2020', + }, +}) diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 000000000..6fae6ca7e --- /dev/null +++ b/yarn.lock @@ -0,0 +1,1422 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.0.0": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" + integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== + dependencies: + "@babel/helper-validator-identifier" "^7.29.7" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + +"@commitlint/cli@21.0.2": + version "21.0.2" + resolved "https://registry.yarnpkg.com/@commitlint/cli/-/cli-21.0.2.tgz#34f006f2ffa77ade13a6bfe97296290f99aaddeb" + integrity sha512-YMmfLbqBg+ZRvvmPhc+cilSQFrh/AgzVgCT1U/OifmUZEwPbvCtA8rN//YNaF9d5eoZphxVMGYtmwA2QgQORgg== + dependencies: + "@commitlint/format" "^21.0.1" + "@commitlint/lint" "^21.0.2" + "@commitlint/load" "^21.0.2" + "@commitlint/read" "^21.0.2" + "@commitlint/types" "^21.0.1" + tinyexec "^1.0.0" + yargs "^18.0.0" + +"@commitlint/config-conventional@21.0.2": + version "21.0.2" + resolved "https://registry.yarnpkg.com/@commitlint/config-conventional/-/config-conventional-21.0.2.tgz#10858dd7f499edd6336a748247d005b81efd5b54" + integrity sha512-P/ZRhryQmkj0Z0dY9FOoRwe3xkwJyyAdtXwt01NT2kuZttcG2CNYp1q5Ci3u+nDT2jcbJRw2kt13Czl1qKNPfg== + dependencies: + "@commitlint/types" "^21.0.1" + conventional-changelog-conventionalcommits "^9.2.0" + +"@commitlint/config-validator@^21.2.0": + version "21.2.0" + resolved "https://registry.yarnpkg.com/@commitlint/config-validator/-/config-validator-21.2.0.tgz#9597a3195ec5371594868f1eca14a3b3e6ffdc36" + integrity sha512-t7AzNHAKeIdo/3NRGwzpufKHsKkPHmFs/56N2Fnsh0/r0rGtnQzTxk6vnFgjaGr4hdSQKNB50/KAhR9Yk4LJKA== + dependencies: + "@commitlint/types" "^21.2.0" + ajv "^8.11.0" + +"@commitlint/ensure@^21.2.0": + version "21.2.0" + resolved "https://registry.yarnpkg.com/@commitlint/ensure/-/ensure-21.2.0.tgz#6a46807a290bc95ac3cc001fd4068e538977627b" + integrity sha512-76IF9vDNS13lAzEEik9eKwzt8f9hYhWiwVXZ2AnyLCz5/f511FsEQ3pw1X3/zSQpdRLQU7i5qDMVKyXi1GWjSg== + dependencies: + "@commitlint/types" "^21.2.0" + es-toolkit "^1.46.0" + +"@commitlint/execute-rule@^21.0.1": + version "21.0.1" + resolved "https://registry.yarnpkg.com/@commitlint/execute-rule/-/execute-rule-21.0.1.tgz#3ebeee4d645527edfc3cf583ecbad92a4c28eddb" + integrity sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA== + +"@commitlint/format@^21.0.1": + version "21.2.2" + resolved "https://registry.yarnpkg.com/@commitlint/format/-/format-21.2.2.tgz#e08d38f153d44615aed53123dfdab1abaab82ee0" + integrity sha512-v6fvxZSc/AvVMROlr3H34+1766bZSYApRUSCAMjWamStPjKMvZ8GdvVA5YW/VQNgbFTmcMz6OYmSTJEvIjPrfA== + dependencies: + "@commitlint/types" "^21.2.0" + picocolors "^1.1.1" + +"@commitlint/is-ignored@^21.2.2": + version "21.2.2" + resolved "https://registry.yarnpkg.com/@commitlint/is-ignored/-/is-ignored-21.2.2.tgz#6c44ae438aa415158d2c58d1cedcd9ac3d6a716a" + integrity sha512-9UoKNgfFE3LU7FrzierCvk3CdDfMDeVGC86qZiT/n0TIjfq/dmZ9MHuXd45OTNRa26ZanmJRxEtmiXk/lEJihg== + dependencies: + "@commitlint/types" "^21.2.0" + semver "^7.6.0" + +"@commitlint/lint@^21.0.2": + version "21.2.2" + resolved "https://registry.yarnpkg.com/@commitlint/lint/-/lint-21.2.2.tgz#24492822825e6e430b24f205447233b41183fadd" + integrity sha512-Fy8JxEBzdmsYWFude/61GxXu5O+wEymwiRK2z9GL9R8mCsXphCoGxAFc5iHn5mjlfcSrhiiONE+ksf4KOjnaPg== + dependencies: + "@commitlint/is-ignored" "^21.2.2" + "@commitlint/parse" "^21.2.2" + "@commitlint/rules" "^21.2.2" + "@commitlint/types" "^21.2.0" + +"@commitlint/load@^21.0.2": + version "21.2.2" + resolved "https://registry.yarnpkg.com/@commitlint/load/-/load-21.2.2.tgz#882128f6412ae123eb507b0a9e32a3c99ff3981d" + integrity sha512-0Tt6wDPX167cjKC5D4zhm0+20wJJG+TN/TKovMOspfSe78rOnKX+MNzlVNiu6HyQPZChPJ8QBH31MVt6Bb8fCg== + dependencies: + "@commitlint/config-validator" "^21.2.0" + "@commitlint/execute-rule" "^21.0.1" + "@commitlint/resolve-extends" "^21.2.2" + "@commitlint/types" "^21.2.0" + cosmiconfig "^9.0.1" + cosmiconfig-typescript-loader "^6.1.0" + es-toolkit "^1.46.0" + is-plain-obj "^4.1.0" + picocolors "^1.1.1" + +"@commitlint/message@^21.2.0": + version "21.2.0" + resolved "https://registry.yarnpkg.com/@commitlint/message/-/message-21.2.0.tgz#72cbca26f7b1b37da5c1e40644c673efbb0e193e" + integrity sha512-YxGoiXD/HXNXLJPrQwE5poXa+XH0CBEm+mdvbHQP0g6MV/dmJyUFCzPNzZbxL93GvZ70TmtTK0Z0/IBpAqHv8g== + +"@commitlint/parse@^21.2.2": + version "21.2.2" + resolved "https://registry.yarnpkg.com/@commitlint/parse/-/parse-21.2.2.tgz#1ed7757d808bf18bfac4d881f9ad97a540723b30" + integrity sha512-MEkobPfvRp+z06Wro8HMG1BDGHzZmj82A1LH1nWeG3ipHpg/x4m6v3wEDvMBIKjRFUnfR3nBeFs3MVCr7UdAmg== + dependencies: + "@commitlint/types" "^21.2.0" + conventional-changelog-angular "^9.0.0" + conventional-commits-parser "^7.0.0" + +"@commitlint/read@^21.0.2": + version "21.2.1" + resolved "https://registry.yarnpkg.com/@commitlint/read/-/read-21.2.1.tgz#a39f19f73b45a604619831fdcbf93fa022feb870" + integrity sha512-hUW7EJQnNTL0vPOmVMNK4CrnrNBN0nN+JJHReFkdHO5y4iyHeEmTBwuC15OCqUTjxWo7idnH1LftfpWVIaPWIA== + dependencies: + "@commitlint/top-level" "^21.2.0" + "@commitlint/types" "^21.2.0" + "@conventional-changelog/git-client" "^3.0.0" + tinyexec "^1.0.0" + +"@commitlint/resolve-extends@^21.2.2": + version "21.2.2" + resolved "https://registry.yarnpkg.com/@commitlint/resolve-extends/-/resolve-extends-21.2.2.tgz#f867d0cea131f2c2bd64e6d079a443b0e5c6e851" + integrity sha512-RPkJ/IFi7sMUUVbZLqwWFtWw/zRDcfFsmrPSiTMrt5wb7AdxOr86EGQFvmGzef5QKV5IPBWWCujqVTw1RWX44A== + dependencies: + "@commitlint/config-validator" "^21.2.0" + "@commitlint/types" "^21.2.0" + es-toolkit "^1.46.0" + global-directory "^5.0.0" + resolve-from "^5.0.0" + +"@commitlint/rules@^21.2.2": + version "21.2.2" + resolved "https://registry.yarnpkg.com/@commitlint/rules/-/rules-21.2.2.tgz#11282e8645ac1bbac948dc9a53e33b50e6ac5ef2" + integrity sha512-eplQzyYkBjYB1HyyRj8hkcK11Y9DU9nuBz7uOKEd6NpE9NGDytLFCAnlRE+OoiK/5sHEJsaz2RGhuWBvYzIbNA== + dependencies: + "@commitlint/ensure" "^21.2.0" + "@commitlint/message" "^21.2.0" + "@commitlint/to-lines" "^21.0.1" + "@commitlint/types" "^21.2.0" + +"@commitlint/to-lines@^21.0.1": + version "21.0.1" + resolved "https://registry.yarnpkg.com/@commitlint/to-lines/-/to-lines-21.0.1.tgz#9a76fcc634f2be2b5effcf67a2d69fcb4bdec9f8" + integrity sha512-bd1BFII7p1EQZre9Kaj+kKaMFP3cFCdt21K7DItVux9XP5WjLgJ0/Uy1pJJh9aPwVJ6SKg62PxqlZaHI8hQAXw== + +"@commitlint/top-level@^21.2.0": + version "21.2.0" + resolved "https://registry.yarnpkg.com/@commitlint/top-level/-/top-level-21.2.0.tgz#bf7dadeaf5a51771bd0c44cc8bcf23ac66715b9e" + integrity sha512-Y5gmQ+KxzqCrBFJfLvFEPvvwD3LDiNZoTT2yeFBm96M8qhmqSzQc5DvX3rheAaAMjyIvMXOCLS/mWfdpONsjyQ== + dependencies: + escalade "^3.2.0" + +"@commitlint/types@^21.0.1", "@commitlint/types@^21.2.0": + version "21.2.0" + resolved "https://registry.yarnpkg.com/@commitlint/types/-/types-21.2.0.tgz#da93897b416f788323f639cce6045310aeb03096" + integrity sha512-7zVFCDB2reMvJH5dmbKnOQPjZEvjdJTH8jc0U/PIPU1r3/+vf5pD1HlfitV2MWsWXrvu7u39iY1lyLUPOaN0Gw== + dependencies: + conventional-commits-parser "^7.0.0" + picocolors "^1.1.1" + +"@conventional-changelog/git-client@^3.0.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@conventional-changelog/git-client/-/git-client-3.1.2.tgz#2298db5ba59a2f8826300bc7ad477169f76f15aa" + integrity sha512-jZqwnJwf7nboIlAcw/mkOjVa6DexCcUOgT2oOQgkoi3z9vR8tGFkcMy2BFcYwjhL9sYcDDXkRQDayiDieCoW7A== + dependencies: + "@simple-libs/child-process-utils" "^2.0.0" + "@simple-libs/stream-utils" "^2.0.0" + semver "^7.5.2" + +"@conventional-changelog/template@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@conventional-changelog/template/-/template-1.4.0.tgz#30a8680f2ab8d3a60a08e86bebb33fe8f0f8f100" + integrity sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg== + +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + +"@johngw/async-iterator@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@johngw/async-iterator/-/async-iterator-4.0.1.tgz#9f570ccb9f4c9f3347a79b6e03e3be52761eb9b9" + integrity sha512-F3onZvq3PhkNJAGcTiL6Sn/eMkoRRzWKuugVMjf+ajhFxMv+H+812HWJfVJ0t6nOy4jATVh28jIXcH+3CRd7Xg== + dependencies: + "@johngw/async" "5.0.1" + "@johngw/object" "1.2.3" + tslib "2.5.2" + +"@johngw/async@5.0.1", "@johngw/async@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@johngw/async/-/async-5.0.1.tgz#fa451aa574ab4f48b77eb4ac0c932e40f2fd14b4" + integrity sha512-aHA7Y+eTeY4JiPXYftN+KnV0Sv5n3kmnA4qcAMTxuNDALr7ymLgC+6wxbxib38JrK6KkDec5fnfx2B/73Y8TgA== + dependencies: + "@johngw/object" "1.2.3" + tslib "2.5.2" + +"@johngw/map@^1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@johngw/map/-/map-1.3.2.tgz#5c1164fceed6ed5a2a099c6806c9144bde48e02c" + integrity sha512-woZ3vKewyPBxrcto157rbejVDgnoFyGYCfML5UPHbdv4JNMkeShh5zHaA4Soyxpb3Ij0Kqfmrd/pZktRYZ2BIQ== + dependencies: + tslib "2.5.2" + +"@johngw/object@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@johngw/object/-/object-1.2.3.tgz#5ec5f7e05b0783729c534e74b99e88caaa6538e4" + integrity sha512-Uovq+lLDx6+8Lx1hwWkpIKJ1Q+ZrUg+ZsZJMKTJRv3eTiX5k0Sctq+zAjNMDMlObRZzoEWlAGRSiQ3yTtlNesw== + dependencies: + tslib "2.5.2" + +"@johngw/outerface@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@johngw/outerface/-/outerface-2.0.3.tgz#a4ff44895f509de94ada4d0b60a499e19e9395cf" + integrity sha512-WBRAU5dOs8TpJp+T2DdLfKHQR4phNI5cJaMkbIj7Y4LXKVqKD/zrbyYP6JBEXVl18X0qoHmnu5xlNnAw2/A/ew== + dependencies: + tslib "^2.8.1" + +"@johngw/stream-common@^2.1.1", "@johngw/stream-common@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@johngw/stream-common/-/stream-common-2.3.1.tgz#24526ee624df5bc8ffee50e9858f1dfa5a8f048f" + integrity sha512-QQf12kHlzjlTWmg0Q4N360up5HEQRHn0R/882o/gtmRcKX0n9RDrFy6Dr418vPBJgD2gT3IvYTM5T+R+4GPF2w== + dependencies: + "@types/node" "^25.0.0" + assert-never "1.4.0" + ts-toolbelt "9.6.0" + tslib "2.8.1" + +"@johngw/stream-test@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@johngw/stream-test/-/stream-test-2.2.0.tgz#28d4587b94bba1ded523d816421366ed1e1a7991" + integrity sha512-ACNgebOeaFYRLI31VHwzZoWJhuKFGBilzo14rV44QPDfo5+X5bMUmE/BzVoDL97ZUoWHJbOZjY/tfdbzl5iSDQ== + dependencies: + "@johngw/stream-common" "^2.1.1" + "@johngw/timeline" "^5.0.0" + "@sinonjs/fake-timers" "^15.4.0" + "@types/node" "^25.0.0" + assert-never "^1.4.0" + js-yaml "^4.1.1" + tslib "^2.8.1" + +"@johngw/stream@^4.2.0": + version "4.4.1" + resolved "https://registry.yarnpkg.com/@johngw/stream/-/stream-4.4.1.tgz#21de6c8dbbfea48ed3b9e45b97007d4f203d1f6d" + integrity sha512-cohmqSHNbjLAqSSV0eosXcLJZ9ZYZPdX3lpPOxkDlXFq/VbiHhyvUTmW5+jAHUkxGVm+Azyd9pOjNp91QT58vQ== + dependencies: + "@johngw/stream-common" "^2.3.1" + "@types/node" "^25.0.0" + assert-never "1.4.0" + ts-toolbelt "9.6.0" + tslib "2.8.1" + +"@johngw/timeline@^5.0.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@johngw/timeline/-/timeline-5.2.0.tgz#dc67f1cd179404afcb5084d81bcda00e8cc3a3b3" + integrity sha512-5lpSVvezfFNoUDkyyntYRGkWK+2w7+3FKSlmDbm9xtyPeslfV2QVGTBxuoIWzYKp2XZJsRuWrmLXOt3LYZPfzQ== + dependencies: + "@johngw/outerface" "^2.0.2" + js-yaml "^4.2.0" + tslib "^2.8.1" + +"@jridgewell/resolve-uri@^3.0.3": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.5.5": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz#f4c663e862f06dc98ca4d453862c46902789a18d" + integrity sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw== + +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@oxc-project/types@=0.149.0": + version "0.149.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.149.0.tgz#328c1d19403980199c869676871db9ed46932ff8" + integrity sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA== + +"@rolldown/binding-android-arm-eabi@1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz#55f0a8e97eb87a0873ea1466d2b4af9510739589" + integrity sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw== + +"@rolldown/binding-android-arm64@1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz#3b783ee41120b2fefac41bd8e6d711f4ce7f82a7" + integrity sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ== + +"@rolldown/binding-darwin-arm64@1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz#56c27646e8faae70aa56065a3b686b1d7de9d8b4" + integrity sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA== + +"@rolldown/binding-darwin-x64@1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz#3b9da1032c897ce191cb23601dbf9887ce6895ae" + integrity sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA== + +"@rolldown/binding-freebsd-x64@1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz#43d4257704b15204aa42059e36a638b63c0e402d" + integrity sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ== + +"@rolldown/binding-linux-arm-gnueabihf@1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz#d95b9fe9f04f2278cb2eef98e2cf20199c3fe5d0" + integrity sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q== + +"@rolldown/binding-linux-arm64-gnu@1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz#07f33840b650b6a3f5954ccfc43b13629374182a" + integrity sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew== + +"@rolldown/binding-linux-arm64-musl@1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz#357922929ec19c05f6d8eabd0899a053bab1a08b" + integrity sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA== + +"@rolldown/binding-linux-ppc64-gnu@1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz#0bc7b29262d3022d8cf57876d70d819387bd3dc9" + integrity sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA== + +"@rolldown/binding-linux-s390x-gnu@1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz#3a3d653becc48975025b8922056822662b7d4414" + integrity sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ== + +"@rolldown/binding-linux-x64-gnu@1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz#c14ec9af7dbc2b1e459b36e292d57b27565f6737" + integrity sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g== + +"@rolldown/binding-linux-x64-musl@1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz#40c2888d1ac87d9e39c6fc6799c060510e7a4d69" + integrity sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA== + +"@rolldown/binding-openharmony-arm64@1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz#3e021f4e77283ca111c709d3ed6775419f471056" + integrity sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg== + +"@rolldown/binding-win32-arm64-msvc@1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz#32b6c6a335ad14f97025bb2bc90135222405bb76" + integrity sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A== + +"@rolldown/binding-win32-x64-msvc@1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz#df4e965896c61411b96566abf345f46141f498c4" + integrity sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng== + +"@rolldown/pluginutils@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz#e3fcee093fbb5ce765e1ad088ff4de2889f6f9be" + integrity sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw== + +"@simple-libs/child-process-utils@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz#90ea8a9008ab622cc50cf94ed3469ce5144e9eb6" + integrity sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A== + dependencies: + "@simple-libs/stream-utils" "^2.0.0" + +"@simple-libs/stream-utils@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@simple-libs/stream-utils/-/stream-utils-2.0.0.tgz#758d2a0876b4d672dac1eae212cdbab452670d3c" + integrity sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ== + +"@sinonjs/commons@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^15.4.0": + version "15.4.0" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz#5d40c151a9e66075fe4520bec40bccfe54931962" + integrity sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA== + dependencies: + "@sinonjs/commons" "^3.0.1" + +"@standard-schema/spec@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + +"@tsconfig/node10@^1.0.7": + version "1.0.13" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.13.tgz#56105a9a8c786e8f15e35746879cf2d52275485b" + integrity sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" + integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== + +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + +"@types/estree@^1.0.0": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" + integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== + +"@types/lodash.set@4.3.9": + version "4.3.9" + resolved "https://registry.yarnpkg.com/@types/lodash.set/-/lodash.set-4.3.9.tgz#55d95bce407b42c6655f29b2d0811fd428e698f0" + integrity sha512-KOxyNkZpbaggVmqbpr82N2tDVTx05/3/j0f50Es1prxrWB0XYf9p3QNxqcbWb7P1Q9wlvsUSlCFnwlPCIJ46PQ== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.17.25" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.25.tgz#69765ac7bcddb0eb072961cf292524a8f5b3c2c0" + integrity sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ== + +"@types/node@25.9.2": + version "25.9.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.2.tgz#fc8958e757994b71fee516f9634bdb03d1b19e9f" + integrity sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw== + dependencies: + undici-types ">=7.24.0 <7.24.7" + +"@types/node@^25.0.0": + version "25.9.6" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.6.tgz#47865018f9a5635f161ec69c041385edb15d78fe" + integrity sha512-JR6Q/PV5DKFvjrGFVqQJdeG0qvsqQQLDa3TzFrqVwhqRXqwNaxPo2KYCtCQXpOdIulCouKwT7a6in9nFthBAzw== + dependencies: + undici-types ">=7.24.0 <7.24.7" + +"@vitest/expect@4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.8.tgz#45154f1f8559f55c5281eb0dcb1ac37b581a87d8" + integrity sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ== + dependencies: + "@standard-schema/spec" "^1.1.0" + "@types/chai" "^5.2.2" + "@vitest/spy" "4.1.8" + "@vitest/utils" "4.1.8" + chai "^6.2.2" + tinyrainbow "^3.1.0" + +"@vitest/mocker@4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.8.tgz#d006bfc5894a1af51e74deddef2535d6bd436b16" + integrity sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw== + dependencies: + "@vitest/spy" "4.1.8" + estree-walker "^3.0.3" + magic-string "^0.30.21" + +"@vitest/pretty-format@4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.8.tgz#d9d2e248b900d7ad9556c4374fcdf1871c615193" + integrity sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA== + dependencies: + tinyrainbow "^3.1.0" + +"@vitest/runner@4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.8.tgz#4631808f3996359b74ccc3ca262990e14c295d50" + integrity sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg== + dependencies: + "@vitest/utils" "4.1.8" + pathe "^2.0.3" + +"@vitest/snapshot@4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.8.tgz#37470135d64ea11bb2a839b1c6b7f5de7018f6ee" + integrity sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ== + dependencies: + "@vitest/pretty-format" "4.1.8" + "@vitest/utils" "4.1.8" + magic-string "^0.30.21" + pathe "^2.0.3" + +"@vitest/spy@4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.8.tgz#3abfe9301d25c39f808dcaa9f10fec0dd370e564" + integrity sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA== + +"@vitest/utils@4.1.8": + version "4.1.8" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.8.tgz#099ea5255cec08735410cf707edaba2c158c5ad9" + integrity sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg== + dependencies: + "@vitest/pretty-format" "4.1.8" + convert-source-map "^2.0.0" + tinyrainbow "^3.1.0" + +acorn-walk@^8.1.1: + version "8.3.5" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.5.tgz#8a6b8ca8fc5b34685af15dabb44118663c296496" + integrity sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw== + dependencies: + acorn "^8.11.0" + +acorn@^8.11.0, acorn@^8.4.1: + version "8.18.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940" + integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ== + +ajv@^8.11.0: + version "8.20.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9" + integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + +ansi-escapes@^7.0.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz#5395bb74b2150a4a1d6e3c2565f4aeca78d28627" + integrity sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg== + dependencies: + environment "^1.0.0" + +ansi-regex@^6.2.2: + version "6.3.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.3.0.tgz#247c8e7b70a1a43b10ce14c0226fcbf58e8815d5" + integrity sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ== + +ansi-styles@^6.2.1, ansi-styles@^6.2.3: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +argue-cli@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/argue-cli/-/argue-cli-3.2.0.tgz#15b9ff31e0ce316c9fd1ff1761188fa81995f1c5" + integrity sha512-VipTB0gXgGIFO2Rg9yEVN5wLt2AurJcZqDbgmYSwPwsykhLrQhs240/bfceev4w68lI8JshoOJIk9uW7tFzROw== + +array-ify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/array-ify/-/array-ify-1.0.0.tgz#9e528762b4a9066ad163a6962a364418e9626ece" + integrity sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng== + +assert-never@1.4.0, assert-never@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/assert-never/-/assert-never-1.4.0.tgz#b0d4988628c87f35eb94716cc54422a63927e175" + integrity sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA== + +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + +balanced-match@^4.0.2: + version "4.0.4" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + +brace-expansion@^5.0.8: + version "5.0.9" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf" + integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg== + dependencies: + balanced-match "^4.0.2" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +chai@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" + integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== + +cli-cursor@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-5.0.0.tgz#24a4831ecf5a6b01ddeb32fb71a4b2088b0dce38" + integrity sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw== + dependencies: + restore-cursor "^5.0.0" + +cli-truncate@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-5.2.0.tgz#c8e72aaca8339c773d128c36e0a17c6315b694eb" + integrity sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw== + dependencies: + slice-ansi "^8.0.0" + string-width "^8.2.0" + +cliui@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-9.0.1.tgz#6f7890f386f6f1f79953adc1f78dec46fcc2d291" + integrity sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w== + dependencies: + string-width "^7.2.0" + strip-ansi "^7.1.0" + wrap-ansi "^9.0.0" + +compare-func@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/compare-func/-/compare-func-2.0.0.tgz#fb65e75edbddfd2e568554e8b5b05fff7a51fcb3" + integrity sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA== + dependencies: + array-ify "^1.0.0" + dot-prop "^5.1.0" + +conventional-changelog-angular@^9.0.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/conventional-changelog-angular/-/conventional-changelog-angular-9.4.0.tgz#ec51393e10b5c4c99c9e79f21d6d293b26cd1314" + integrity sha512-HdxRxuS8bBXVIuo4V82gvSwAXT0vYQUizrjs/izmPg5JdDstr8v8I5hduGL3iQbG+o310dUDxC4+LetuS5hu9w== + dependencies: + "@conventional-changelog/template" "^1.4.0" + +conventional-changelog-conventionalcommits@^9.2.0: + version "9.3.1" + resolved "https://registry.yarnpkg.com/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz#14f2dd65ccc5de09322a7eb0159f3e0259d7399c" + integrity sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw== + dependencies: + compare-func "^2.0.0" + +conventional-commits-parser@^7.0.0: + version "7.1.2" + resolved "https://registry.yarnpkg.com/conventional-commits-parser/-/conventional-commits-parser-7.1.2.tgz#4dca0779b338bab96fe994e864dfedf3c1908b03" + integrity sha512-O+x4N2yH+ijvqWlIyTHsXTAP+algNWgGbjY2duCe8w2vUMvUB95cLRslCPfTMQyLAKlet3bhZTdu6ozn4M+QJQ== + dependencies: + "@simple-libs/stream-utils" "^2.0.0" + argue-cli "^3.1.0" + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +cosmiconfig-typescript-loader@^6.1.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz#a5ee3ec3da5a976c0c233fd0e0b5ca14b58b0327" + integrity sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA== + dependencies: + jiti "2.6.1" + +cosmiconfig@^9.0.1: + version "9.0.2" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-9.0.2.tgz#9e5615163becf6a82211fb33d2f68947c25d0c5e" + integrity sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg== + dependencies: + env-paths "^2.2.1" + import-fresh "^3.3.0" + js-yaml "^4.1.0" + parse-json "^5.2.0" + +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + +detect-libc@^2.0.3: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + +diff@^4.0.1: + version "4.0.4" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.4.tgz#7a6dbfda325f25f07517e9b518f897c08332e07d" + integrity sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ== + +dot-prop@^5.1.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" + integrity sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q== + dependencies: + is-obj "^2.0.0" + +emoji-regex@^10.3.0: + version "10.6.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d" + integrity sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A== + +env-paths@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" + integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== + +environment@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/environment/-/environment-1.1.0.tgz#8e86c66b180f363c7ab311787e0259665f45a9f1" + integrity sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q== + +error-ex@^1.3.1: + version "1.3.4" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.4.tgz#b3a8d8bb6f92eecc1629e3e27d3c8607a8a32414" + integrity sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ== + dependencies: + is-arrayish "^0.2.1" + +es-module-lexer@^2.0.0: + version "2.3.2" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.3.2.tgz#311fa4f40168c1975c505477c51b23234d41ad55" + integrity sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw== + +es-toolkit@^1.46.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.52.0.tgz#71eaf1a8b18834ef77637eccbb885ba4c03cd6dd" + integrity sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA== + +esbuild-decorators@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/esbuild-decorators/-/esbuild-decorators-1.0.0.tgz#ead1141507455b45a393f56b42197c5d83443a44" + integrity sha512-D8Z0tiurpW3FgI8xGToi59v8mHrGWONvLTUOP6hSvHgKNHP1627TW+GfsMKNLLiiNcG7rzIuggeThJYcD1ob1g== + +escalade@^3.1.1, escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + +eventemitter3@^5.0.4: + version "5.0.4" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" + integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== + +expect-type@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" + integrity sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA== + +fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-uri@^3.0.1: + version "3.1.7" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.7.tgz#743157d957f3cbb4c65310e033dc2ad4ad7dc60a" + integrity sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg== + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-east-asian-width@^1.0.0, get-east-asian-width@^1.3.1, get-east-asian-width@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz#216900f91df11a8b2c198c3e1d93d6c035a776b9" + integrity sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA== + +glob@^13.0.3: + version "13.0.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.6.tgz#078666566a425147ccacfbd2e332deb66a2be71d" + integrity sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw== + dependencies: + minimatch "^10.2.2" + minipass "^7.1.3" + path-scurry "^2.0.2" + +global-directory@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/global-directory/-/global-directory-5.0.0.tgz#0f66a94212acd0f81ee838d0a991e88d1c2836cf" + integrity sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w== + dependencies: + ini "6.0.0" + +husky@9.1.7: + version "9.1.7" + resolved "https://registry.yarnpkg.com/husky/-/husky-9.1.7.tgz#d46a38035d101b46a70456a850ff4201344c0b2d" + integrity sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA== + +import-fresh@^3.3.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +ini@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/ini/-/ini-6.0.0.tgz#efc7642b276f6a37d22fdf56ef50889d7146bf30" + integrity sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ== + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + +is-fullwidth-code-point@^5.0.0, is-fullwidth-code-point@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz#046b2a6d4f6b156b2233d3207d4b5a9783999b98" + integrity sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ== + dependencies: + get-east-asian-width "^1.3.1" + +is-obj@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" + integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== + +is-plain-obj@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" + integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== + +jiti@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.6.1.tgz#178ef2fc9a1a594248c20627cd820187a4d78d92" + integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0: + version "4.3.2" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.2.tgz#8e44fb14a2643c59726bb15787b5f1512cb3d3fb" + integrity sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA== + dependencies: + argparse "^2.0.1" + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +lightningcss-android-arm64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz#9a6841f88ae50fc83502903892b41af41bc2b907" + integrity sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg== + +lightningcss-darwin-arm64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz#c0f2c31c0bfd19fa4dd3f18e957a1f1a152097d6" + integrity sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg== + +lightningcss-darwin-x64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz#cb0705965acb538c6683949ce6925fb3cdf7c361" + integrity sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ== + +lightningcss-freebsd-x64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz#763538828b26bab2680dadafcc84ee78b0eb502b" + integrity sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg== + +lightningcss-linux-arm-gnueabihf@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz#6862e3176a331aedbdec1ed352b4d7d0dd0784de" + integrity sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ== + +lightningcss-linux-arm64-gnu@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz#c6a3a2ed15141daf6bdc2628930f8e39bdf473aa" + integrity sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg== + +lightningcss-linux-arm64-musl@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz#7fa1334971fc82845f9827df6ef8a0b20914bac6" + integrity sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ== + +lightningcss-linux-x64-gnu@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz#8b927862ea8c2bbc6831a46509244b50d9936e55" + integrity sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg== + +lightningcss-linux-x64-musl@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz#0c525bb077dfd94404c059cfe42dad797e96aeaf" + integrity sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw== + +lightningcss-win32-arm64-msvc@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz#850ee1103dac989cfab50e3ac22d1a69e394e63d" + integrity sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA== + +lightningcss-win32-x64-msvc@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz#e343ae152eed3609dc6e11949d1a3bf39a1c946f" + integrity sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA== + +lightningcss@^1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.33.0.tgz#c08867d71a79385c6e190214fd72fef3e5f95f0b" + integrity sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA== + dependencies: + detect-libc "^2.0.3" + optionalDependencies: + lightningcss-android-arm64 "1.33.0" + lightningcss-darwin-arm64 "1.33.0" + lightningcss-darwin-x64 "1.33.0" + lightningcss-freebsd-x64 "1.33.0" + lightningcss-linux-arm-gnueabihf "1.33.0" + lightningcss-linux-arm64-gnu "1.33.0" + lightningcss-linux-arm64-musl "1.33.0" + lightningcss-linux-x64-gnu "1.33.0" + lightningcss-linux-x64-musl "1.33.0" + lightningcss-win32-arm64-msvc "1.33.0" + lightningcss-win32-x64-msvc "1.33.0" + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +lint-staged@17.0.7: + version "17.0.7" + resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-17.0.7.tgz#2ed5ffb49d283425778125386278bb4d7ce24d22" + integrity sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA== + dependencies: + listr2 "^10.2.1" + picomatch "^4.0.4" + string-argv "^0.3.2" + tinyexec "^1.2.4" + optionalDependencies: + yaml "^2.9.0" + +listr2@^10.2.1: + version "10.2.2" + resolved "https://registry.yarnpkg.com/listr2/-/listr2-10.2.2.tgz#01d987a6e3fb03b17612968617366f28fdeaf4d3" + integrity sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw== + dependencies: + cli-truncate "^5.2.0" + eventemitter3 "^5.0.4" + log-update "^6.1.0" + rfdc "^1.4.1" + wrap-ansi "^10.0.0" + +lodash.set@4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23" + integrity sha512-4hNPN5jlm/N/HLMCO43v8BXKq9Z7QdAGc/VGrRD61w8gN9g/6jF9A4L1pbUgBLCffi0w9VsXfTOij5x8iTyFvg== + +log-update@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/log-update/-/log-update-6.1.0.tgz#1a04ff38166f94647ae1af562f4bd6a15b1b7cd4" + integrity sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w== + dependencies: + ansi-escapes "^7.0.0" + cli-cursor "^5.0.0" + slice-ansi "^7.1.0" + strip-ansi "^7.1.0" + wrap-ansi "^9.0.0" + +lru-cache@^11.0.0: + version "11.5.2" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.5.2.tgz#00e16665c90c620fba14a3c368732a976493f760" + integrity sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g== + +magic-string@^0.30.21: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +mimic-function@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/mimic-function/-/mimic-function-5.0.1.tgz#acbe2b3349f99b9deaca7fb70e48b83e94e67076" + integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA== + +minimatch@^10.2.2: + version "10.2.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef" + integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A== + dependencies: + brace-expansion "^5.0.8" + +minipass@^7.1.2, minipass@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" + integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== + +nanoid@^3.3.18: + version "3.3.18" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== + +obug@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/obug/-/obug-2.2.1.tgz#9c453efeeda822050eb9774242e969c784d64d78" + integrity sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q== + +onetime@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-7.0.0.tgz#9f16c92d8c9ef5120e3acd9dd9957cceecc1ab60" + integrity sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ== + dependencies: + mimic-function "^5.0.0" + +package-json-from-dist@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-json@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +path-scurry@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.2.tgz#6be0d0ee02a10d9e0de7a98bae65e182c9061f85" + integrity sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg== + dependencies: + lru-cache "^11.0.0" + minipass "^7.1.2" + +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^4.0.3, picomatch@^4.0.4, picomatch@^4.0.5: + version "4.0.7" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f" + integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA== + +postcss@^8.5.26: + version "8.5.28" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.28.tgz#da4563a99a06e62d6c1cd1acae363224bcaed6e9" + integrity sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A== + dependencies: + nanoid "^3.3.18" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +prettier@3.8.3: + version "3.8.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.3.tgz#560f2de55bf01b4c0503bc629d5df99b9a1d09b0" + integrity sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw== + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +restore-cursor@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-5.1.0.tgz#0766d95699efacb14150993f55baf0953ea1ebe7" + integrity sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA== + dependencies: + onetime "^7.0.0" + signal-exit "^4.1.0" + +rfdc@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" + integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== + +rimraf@6.1.3: + version "6.1.3" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-6.1.3.tgz#afbee236b3bd2be331d4e7ce4493bac1718981af" + integrity sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA== + dependencies: + glob "^13.0.3" + package-json-from-dist "^1.0.1" + +rolldown@~1.2.4: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.2.8.tgz#3a18ad3c74809f05abe9130229b2b8b4249e9e21" + integrity sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ== + dependencies: + "@oxc-project/types" "=0.149.0" + "@rolldown/pluginutils" "^1.0.0" + optionalDependencies: + "@rolldown/binding-android-arm-eabi" "1.2.8" + "@rolldown/binding-android-arm64" "1.2.8" + "@rolldown/binding-darwin-arm64" "1.2.8" + "@rolldown/binding-darwin-x64" "1.2.8" + "@rolldown/binding-freebsd-x64" "1.2.8" + "@rolldown/binding-linux-arm-gnueabihf" "1.2.8" + "@rolldown/binding-linux-arm64-gnu" "1.2.8" + "@rolldown/binding-linux-arm64-musl" "1.2.8" + "@rolldown/binding-linux-ppc64-gnu" "1.2.8" + "@rolldown/binding-linux-s390x-gnu" "1.2.8" + "@rolldown/binding-linux-x64-gnu" "1.2.8" + "@rolldown/binding-linux-x64-musl" "1.2.8" + "@rolldown/binding-openharmony-arm64" "1.2.8" + "@rolldown/binding-win32-arm64-msvc" "1.2.8" + "@rolldown/binding-win32-x64-msvc" "1.2.8" + +semver@^7.5.2, semver@^7.6.0: + version "7.8.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" + integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== + +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + +signal-exit@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +slice-ansi@^7.1.0: + version "7.1.2" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-7.1.2.tgz#adf7be70aa6d72162d907cd0e6d5c11f507b5403" + integrity sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w== + dependencies: + ansi-styles "^6.2.1" + is-fullwidth-code-point "^5.0.0" + +slice-ansi@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-8.0.0.tgz#22d0b66d18bc5c57f488bfcf36cbde3bef731537" + integrity sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg== + dependencies: + ansi-styles "^6.2.3" + is-fullwidth-code-point "^5.1.0" + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +std-env@^4.0.0-rc.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-4.2.0.tgz#8ebe0ec60485668ab47227b312f4254cdf80c9d3" + integrity sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw== + +string-argv@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" + integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== + +string-width@^7.0.0, string-width@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc" + integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== + dependencies: + emoji-regex "^10.3.0" + get-east-asian-width "^1.0.0" + strip-ansi "^7.1.0" + +string-width@^8.2.0, string-width@^8.2.1: + version "8.2.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-8.2.2.tgz#7310516493df575742fe98af6fae87d85d5ed0ac" + integrity sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg== + dependencies: + get-east-asian-width "^1.5.0" + strip-ansi "^7.1.2" + +strip-ansi@^7.1.0, strip-ansi@^7.1.2: + version "7.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" + integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== + dependencies: + ansi-regex "^6.2.2" + +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^1.0.0, tinyexec@^1.0.2, tinyexec@^1.2.4: + version "1.3.1" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.3.1.tgz#16a2e3c6e23fafce72640e678e651db36145b9c6" + integrity sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA== + +tinyglobby@^0.2.15, tinyglobby@^0.2.17: + version "0.2.17" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631" + integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + +tinyrainbow@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.1.1.tgz#c0168387d3d8d70b6b3c2c0936de5fee738cea20" + integrity sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw== + +ts-node@10.9.2: + version "10.9.2" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" + integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + +ts-toolbelt@9.6.0, ts-toolbelt@^9.6.0: + version "9.6.0" + resolved "https://registry.yarnpkg.com/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz#50a25426cfed500d4a09bd1b3afb6f28879edfd5" + integrity sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w== + +tslib@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.2.tgz#1b6f07185c881557b0ffa84b111a0106989e8338" + integrity sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA== + +tslib@2.8.1, tslib@^2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +type-detect@4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +typescript@6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21" + integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== + +"undici-types@>=7.24.0 <7.24.7": + version "7.24.6" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.24.6.tgz#61275b485d7fd4e9d269c7cf04ec2873c9cc0f91" + integrity sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg== + +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + +"vite@^6.0.0 || ^7.0.0 || ^8.0.0": + version "8.2.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-8.2.2.tgz#399aefad3656145145be110d137a07ea5bb55014" + integrity sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q== + dependencies: + lightningcss "^1.33.0" + picomatch "^4.0.5" + postcss "^8.5.26" + rolldown "~1.2.4" + tinyglobby "^0.2.17" + optionalDependencies: + fsevents "~2.3.3" + +vitest@4.1.8: + version "4.1.8" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.8.tgz#9fed17277bf7350497e54338898a7afd46dfd509" + integrity sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig== + dependencies: + "@vitest/expect" "4.1.8" + "@vitest/mocker" "4.1.8" + "@vitest/pretty-format" "4.1.8" + "@vitest/runner" "4.1.8" + "@vitest/snapshot" "4.1.8" + "@vitest/spy" "4.1.8" + "@vitest/utils" "4.1.8" + es-module-lexer "^2.0.0" + expect-type "^1.3.0" + magic-string "^0.30.21" + obug "^2.1.1" + pathe "^2.0.3" + picomatch "^4.0.3" + std-env "^4.0.0-rc.1" + tinybench "^2.9.0" + tinyexec "^1.0.2" + tinyglobby "^0.2.15" + tinyrainbow "^3.1.0" + vite "^6.0.0 || ^7.0.0 || ^8.0.0" + why-is-node-running "^2.3.0" + +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + +wrap-ansi@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-10.0.1.tgz#59b821b7987d36a87a367bc9517284ff974086ea" + integrity sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q== + dependencies: + ansi-styles "^6.2.3" + string-width "^8.2.0" + +wrap-ansi@^9.0.0: + version "9.0.2" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98" + integrity sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww== + dependencies: + ansi-styles "^6.2.1" + string-width "^7.0.0" + strip-ansi "^7.1.0" + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yaml@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4" + integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== + +yargs-parser@^22.0.0: + version "22.0.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8" + integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw== + +yargs@^18.0.0: + version "18.1.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-18.1.0.tgz#cd7e98c703ef51695bbbf062ed58f28e94291b56" + integrity sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg== + dependencies: + cliui "^9.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + string-width "^8.2.1" + y18n "^5.0.5" + yargs-parser "^22.0.0" + +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== From b79bf0c5e653fb13c1e13890b340ac6fc8e3ecd8 Mon Sep 17 00:00:00 2001 From: John Wright Date: Tue, 15 Sep 2026 10:21:01 +0100 Subject: [PATCH 03/18] chore: npm i --- package-lock.json | 499 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 498 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 26bb4b156..2778d8627 100644 --- a/package-lock.json +++ b/package-lock.json @@ -375,6 +375,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@johngw/async": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@johngw/async/-/async-5.0.1.tgz", @@ -1598,6 +2040,61 @@ "benchmarks" ] }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/esbuild-decorators": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/esbuild-decorators/-/esbuild-decorators-1.0.0.tgz", + "integrity": "sha512-D8Z0tiurpW3FgI8xGToi59v8mHrGWONvLTUOP6hSvHgKNHP1627TW+GfsMKNLLiiNcG7rzIuggeThJYcD1ob1g==", + "license": "MIT", + "engines": { + "node": "^14.13 || >=16 || >=18" + }, + "peerDependencies": { + "esbuild": ">=0.14.0", + "typescript": ">=4.7.0" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2906,7 +3403,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -3271,6 +3767,7 @@ "@johngw/async-iterator": "^4.0.1", "@johngw/stream": "^4.2.0", "@plugola/invoke": "^2.0.3", + "esbuild-decorators": "^1.0.0", "ts-toolbelt": "^9.6.0", "tslib": "^2.8.1" }, From 78de8849a3a7264a33078c70470dac7a7a826f69 Mon Sep 17 00:00:00 2001 From: John Wright Date: Tue, 15 Sep 2026 10:21:23 +0100 Subject: [PATCH 04/18] refactor: split broker api --- packages/message-bus/src/dmg-bus/Broker.ts | 266 ------------------ .../message-bus/src/dmg-bus/Broker/Broker.ts | 35 --- .../src/dmg-bus/Broker/BrokerModel.ts | 27 ++ .../src/dmg-bus/Broker/BusBroker.ts | 69 +++++ .../src/dmg-bus/Broker/EventHandler.ts | 6 +- .../message-bus/src/dmg-bus/Broker/Factory.ts | 45 +-- .../message-bus/src/dmg-bus/Broker/Handler.ts | 4 +- .../src/dmg-bus/Broker/InboundBroker.ts | 53 ---- .../src/dmg-bus/Broker/InvocationHandler.ts | 6 +- .../{OutboundBroker.ts => PluginBroker.ts} | 76 ++--- packages/message-bus/src/dmg-bus/Bus.ts | 51 ++-- packages/message-bus/src/dmg-bus/Filter.ts | 2 +- .../message-bus/src/dmg-bus/PluginBroker.ts | 114 -------- .../dmg-bus/{ => Queue}/DiscriminatedQueue.ts | 0 .../src/dmg-bus/Queue/MethodQueue.ts | 23 ++ .../src/dmg-bus/{ => Queue}/Queue.ts | 0 .../src/dmg-bus/{ => Queue}/queue.test.ts | 0 .../message-bus/src/dmg-bus/broker.test.ts | 11 +- .../src/dmg-bus/{ => lang}/Function.ts | 0 packages/message-bus/src/dmg-bus/lang/Map.ts | 4 + packages/message-bus/src/dmg-bus/mung.ts | 12 - packages/message-bus/src/dmg-bus/polyfill.ts | 4 - .../src/dmg-bus/queuedMethods.test.ts | 36 --- .../message-bus/src/dmg-bus/queuedMethods.ts | 16 -- 24 files changed, 221 insertions(+), 639 deletions(-) delete mode 100644 packages/message-bus/src/dmg-bus/Broker.ts delete mode 100644 packages/message-bus/src/dmg-bus/Broker/Broker.ts create mode 100644 packages/message-bus/src/dmg-bus/Broker/BrokerModel.ts create mode 100644 packages/message-bus/src/dmg-bus/Broker/BusBroker.ts delete mode 100644 packages/message-bus/src/dmg-bus/Broker/InboundBroker.ts rename packages/message-bus/src/dmg-bus/Broker/{OutboundBroker.ts => PluginBroker.ts} (73%) delete mode 100644 packages/message-bus/src/dmg-bus/PluginBroker.ts rename packages/message-bus/src/dmg-bus/{ => Queue}/DiscriminatedQueue.ts (100%) create mode 100644 packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts rename packages/message-bus/src/dmg-bus/{ => Queue}/Queue.ts (100%) rename packages/message-bus/src/dmg-bus/{ => Queue}/queue.test.ts (100%) rename packages/message-bus/src/dmg-bus/{ => lang}/Function.ts (100%) create mode 100644 packages/message-bus/src/dmg-bus/lang/Map.ts delete mode 100644 packages/message-bus/src/dmg-bus/mung.ts delete mode 100644 packages/message-bus/src/dmg-bus/polyfill.ts delete mode 100644 packages/message-bus/src/dmg-bus/queuedMethods.test.ts delete mode 100644 packages/message-bus/src/dmg-bus/queuedMethods.ts diff --git a/packages/message-bus/src/dmg-bus/Broker.ts b/packages/message-bus/src/dmg-bus/Broker.ts deleted file mode 100644 index 3d22ba553..000000000 --- a/packages/message-bus/src/dmg-bus/Broker.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { Bus } from './Bus.js' -import { DiscriminatedQueue } from './DiscriminatedQueue.js' -import { Invocation, InvocationClass, Event, EventClass } from './Event.js' -import { InvocationListener, EventListener } from './EventListener.js' -import { Filter, FilterEntries, FilterPredicate } from './Filter.js' -import { withCounter } from './Function.js' -import { PluginBroker } from './PluginBroker.js' -import { queueMethod } from './queuedMethods.js' - -abstract class Handler { - readonly #filterEntries: FilterEntries - - constructor(filter: Filter) { - this.#filterEntries = Object.entries(filter) as FilterEntries - } - - protected filter(event: Event) { - return ( - !this.#filterEntries.length || - this.#filterEntries.some(([key, value]) => - typeof value === 'function' - ? (value as FilterPredicate)(event) - : value === event[key], - ) - ) - } -} - -class EventHandler extends Handler { - readonly #listener: EventListener - - constructor(filter: Filter, listener: EventListener) { - super(filter) - this.#listener = listener - } - - handle(event: Event) { - if (this.filter(event)) this.#listener(event) - } -} - -class InvocationHandler extends Handler { - readonly #listener: InvocationListener> - - constructor( - filter: Filter, - listener: InvocationListener>, - ) { - super(filter) - this.#listener = listener - } - - handle( - event: Invocation, - context: { - send: (value: unknown) => void - finish: () => void - signal?: AbortSignal - }, - ) { - if (this.filter(event)) this.#listener(event, context) - } -} - -export class Broker implements ListenableBroker { - readonly eventHandlers = new Map>() - - readonly invokeHandlers = new Map< - InvocationClass, - Set - >() - - readonly queue = new DiscriminatedQueue({}) - - constructor( - public readonly bus: Bus, - public readonly name: string, - public readonly abortSignal: AbortSignal, - ) { - abortSignal.addEventListener('abort', () => { - this.eventHandlers.clear() - this.invokeHandlers.clear() - }) - } - - emit = queueMethod(this.queue, (event: Event) => { - for (const eventHandler of this.eventHandlers.get( - event.constructor as EventClass, - ) ?? []) - eventHandler.handle(event) - }) - - invoke( - event: Invocation, - context: { - finish: () => void - send: (value: any) => void - signal?: AbortSignal - }, - ) { - if (!this.queue.running) return context.finish() - - const invokeHandlers = this.invokeHandlers.get( - event.constructor as InvocationClass, - ) - - if (!invokeHandlers?.size) return context.finish() - - const finish = withCounter((counter) => { - if (counter >= invokeHandlers.size) context.finish() - }) - - for (const invokeHandler of invokeHandlers) - invokeHandler.handle(event, { ...context, finish }) - } - - start() { - this.queue.start() - } - - stop() { - this.queue.stop() - } - - on( - eventClass: E, - eventListener: EventListener, - ): () => void - - on( - eventClass: E, - filter: Filter, - eventListener: EventListener, - ): () => void - - on( - eventClass: E, - filterOrEventListener: Filter | EventListener, - eventListener?: EventListener, - ): () => void { - const filter = (eventListener ? filterOrEventListener : {}) as Filter - eventListener ??= filterOrEventListener as EventListener - const eventHandlers = this.eventHandlers.getOrInsert(eventClass, new Set()) - const eventHandler = new EventHandler(filter, eventListener) - eventHandlers.add(eventHandler) - const unregister = this.bus.on(this, eventClass) - return () => { - const eventHandlers = this.eventHandlers.get(eventClass) - eventHandlers?.delete(eventHandler) - if (!eventHandlers?.size) unregister() - } - } - - once( - eventClass: E, - eventListener: EventListener, - ): () => void - - once( - eventClass: E, - filter: Filter, - eventListener: EventListener, - ): () => void - - once( - eventClass: E, - filterOrEventListener: Filter | EventListener, - eventListener?: EventListener, - ): () => void { - const filter = (eventListener ? filterOrEventListener : {}) as Filter - eventListener ??= filterOrEventListener as EventListener - const off = this.on(eventClass, filter, (event) => { - eventListener(event) - off() - }) - return off - } - - until( - eventClass: E, - filter: Filter = {}, - ): Promise> { - return new Promise((resolve, reject) => { - const onAbort = () => reject(this.abortSignal.reason) - if (this.abortSignal.aborted) return onAbort() - this.abortSignal.addEventListener('abort', onAbort) - this.once(eventClass, filter, (event) => { - this.abortSignal.removeEventListener('abort', onAbort) - resolve(event) - }) - }) - } - - register>( - eventClass: E, - listener: InvocationListener, - ): () => void - - register>( - eventClass: E, - filter: Filter, - listener: InvocationListener, - ): () => void - - register>( - eventClass: E, - filterOrListener: Filter | InvocationListener, - listener?: InvocationListener, - ): () => void { - const filter = (listener ? filterOrListener : {}) as Filter - listener ??= filterOrListener as InvocationListener - const handlers = this.invokeHandlers.getOrInsert(eventClass, new Set()) - const handler = new InvocationHandler(filter, listener) - handlers.add(handler) - const unregister = this.bus.register(this, eventClass) - return () => { - const handlers = this.invokeHandlers.get(eventClass) - handlers?.delete(handler) - if (!handlers?.size) unregister() - } - } - - pluginBroker() { - return new PluginBroker(this) - } -} - -export interface ListenableBroker { - on( - eventClass: E, - eventListener: EventListener, - ): () => void - - on( - eventClass: E, - filter: Filter, - eventListener: EventListener, - ): () => void - - once( - eventClass: E, - eventListener: EventListener, - ): () => void - - once( - eventClass: E, - filter: Filter, - eventListener: EventListener, - ): () => void - - until( - eventClass: E, - filter?: Filter, - ): Promise> - - register>( - eventClass: E, - listener: InvocationListener, - ): () => void - - register>( - eventClass: E, - filter: Filter, - listener: InvocationListener, - ): () => void -} diff --git a/packages/message-bus/src/dmg-bus/Broker/Broker.ts b/packages/message-bus/src/dmg-bus/Broker/Broker.ts deleted file mode 100644 index 6a256ee6a..000000000 --- a/packages/message-bus/src/dmg-bus/Broker/Broker.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Bus } from '../Bus.js' -import { DiscriminatedQueue } from '../DiscriminatedQueue.js' -import { EventClass, InvocationClass } from '../Event.js' -import { EventHandler } from './EventHandler.js' -import { InboundBroker } from './InboundBroker.js' -import { InvocationHandler } from './InvocationHandler.js' -import { OutboundBroker } from './OutboundBroker.js' - -export class Broker { - readonly eventHandlers = new Map>() - - readonly invokeHandlers = new Map< - InvocationClass, - Set - >() - - readonly queue = new DiscriminatedQueue({}) - - readonly inbound: InboundBroker - - readonly outbound: OutboundBroker - - constructor( - readonly bus: Bus, - readonly name: string, - readonly abortSignal: AbortSignal, - ) { - this.inbound = new InboundBroker(this) - this.outbound = new OutboundBroker(this) - this.abortSignal.addEventListener('abort', () => { - this.eventHandlers.clear() - this.invokeHandlers.clear() - }) - } -} diff --git a/packages/message-bus/src/dmg-bus/Broker/BrokerModel.ts b/packages/message-bus/src/dmg-bus/Broker/BrokerModel.ts new file mode 100644 index 000000000..0a83336f6 --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Broker/BrokerModel.ts @@ -0,0 +1,27 @@ +import type { Bus } from '../Bus.js' +import type { EventClass, InvocationClass } from '../Event.js' +import type { EventHandler } from './EventHandler.js' +import type { InvocationHandler } from './InvocationHandler.js' +import { MethodQueue } from '../Queue/MethodQueue.js' + +export class BrokerModel { + readonly eventHandlers = new Map>() + + readonly invokeHandlers = new Map< + InvocationClass, + Set + >() + + readonly queue = new MethodQueue() + + constructor( + readonly bus: Bus, + readonly name: string, + readonly abortSignal: AbortSignal, + ) { + abortSignal.addEventListener('abort', () => { + this.eventHandlers.clear() + this.invokeHandlers.clear() + }) + } +} diff --git a/packages/message-bus/src/dmg-bus/Broker/BusBroker.ts b/packages/message-bus/src/dmg-bus/Broker/BusBroker.ts new file mode 100644 index 000000000..08c4a274a --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Broker/BusBroker.ts @@ -0,0 +1,69 @@ +import type { + Event, + EventClass, + Invocation, + InvocationClass, +} from '../Event.js' +import { withCounter } from '../lang/Function.js' +import type { BrokerModel } from './BrokerModel.js' + +export class BusBroker { + readonly #model: BrokerModel + + readonly #abortController: AbortController + + readonly emit: (event: Event) => void + + constructor(model: BrokerModel, abortController: AbortController) { + this.#abortController = abortController + + this.#model = model + + this.emit = model.queue.queueMethod((event: Event) => { + for (const eventHandler of this.#model.eventHandlers.get( + event.constructor as EventClass, + ) ?? []) + eventHandler.handle(event) + }) + } + + get name() { + return this.#model.name + } + + invoke( + event: Invocation, + context: { + finish: () => void + send: (value: any) => void + signal?: AbortSignal + }, + ) { + if (!this.#model.queue.running) return context.finish() + + const invokeHandlers = this.#model.invokeHandlers.get( + event.constructor as InvocationClass, + ) + + if (!invokeHandlers?.size) return context.finish() + + const finish = withCounter((counter) => { + if (counter >= invokeHandlers.size) context.finish() + }) + + for (const invokeHandler of invokeHandlers) + invokeHandler.handle(event, { ...context, finish }) + } + + start() { + this.#model.queue.start() + } + + stop() { + this.#model.queue.stop() + } + + abort(reason?: any) { + this.#abortController.abort(reason) + } +} diff --git a/packages/message-bus/src/dmg-bus/Broker/EventHandler.ts b/packages/message-bus/src/dmg-bus/Broker/EventHandler.ts index c107bbe08..8d8da7e10 100644 --- a/packages/message-bus/src/dmg-bus/Broker/EventHandler.ts +++ b/packages/message-bus/src/dmg-bus/Broker/EventHandler.ts @@ -1,6 +1,6 @@ -import { Event, EventClass } from '../Event.js' -import { EventListener } from '../EventListener.js' -import { Filter } from '../Filter.js' +import type { Event, EventClass } from '../Event.js' +import type { EventListener } from '../EventListener.js' +import type { Filter } from '../Filter.js' import { Handler } from './Handler.js' export class EventHandler extends Handler { diff --git a/packages/message-bus/src/dmg-bus/Broker/Factory.ts b/packages/message-bus/src/dmg-bus/Broker/Factory.ts index 26cf3ec90..31442bd79 100644 --- a/packages/message-bus/src/dmg-bus/Broker/Factory.ts +++ b/packages/message-bus/src/dmg-bus/Broker/Factory.ts @@ -1,36 +1,21 @@ import type { Bus } from '../Bus.js' -import { DiscriminatedQueue } from '../DiscriminatedQueue.js' -import { EventClass, InvocationClass } from '../Event.js' -import { EventHandler } from './EventHandler.js' -import { InboundBroker } from './InboundBroker.js' -import { InvocationHandler } from './InvocationHandler.js' -import { OutboundBroker } from './OutboundBroker.js' +import { BrokerModel } from './BrokerModel.js' +import { BusBroker } from './BusBroker.js' +import { PluginBroker } from './PluginBroker.js' -export function brokerFactory(name: string, bus: Bus) { +export function brokerFactory( + bus: Bus, + name: string, +): { + abortSignal: AbortSignal + busBroker: BusBroker + pluginBroker: PluginBroker +} { const abortController = new AbortController() - const eventHandlers = new Map>() - const invocationHandlers = new Map< - InvocationClass, - Set - >() - const queue = new DiscriminatedQueue({}) + const model = new BrokerModel(bus, name, abortController.signal) return { - abortController, - inboundBroker: new InboundBroker( - bus, - name, - abortController.signal, - eventHandlers, - invocationHandlers, - queue, - ), - outboundBroker: new OutboundBroker( - bus, - name, - abortController.signal, - eventHandlers, - invocationHandlers, - queue, - ), + abortSignal: abortController.signal, + busBroker: new BusBroker(model, abortController), + pluginBroker: new PluginBroker(model, abortController.signal), } } diff --git a/packages/message-bus/src/dmg-bus/Broker/Handler.ts b/packages/message-bus/src/dmg-bus/Broker/Handler.ts index 6d6b4e8ca..8846c5849 100644 --- a/packages/message-bus/src/dmg-bus/Broker/Handler.ts +++ b/packages/message-bus/src/dmg-bus/Broker/Handler.ts @@ -1,5 +1,5 @@ -import { Event, EventClass } from '../Event.js' -import { Filter, FilterEntries, FilterPredicate } from '../Filter.js' +import type { Event, EventClass } from '../Event.js' +import type { Filter, FilterEntries, FilterPredicate } from '../Filter.js' export abstract class Handler { readonly #filterEntries: FilterEntries diff --git a/packages/message-bus/src/dmg-bus/Broker/InboundBroker.ts b/packages/message-bus/src/dmg-bus/Broker/InboundBroker.ts deleted file mode 100644 index 1bdbe724f..000000000 --- a/packages/message-bus/src/dmg-bus/Broker/InboundBroker.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Event, EventClass, Invocation, InvocationClass } from '../Event.js' -import { withCounter } from '../Function.js' -import { queueMethod } from '../queuedMethods.js' -import { Broker } from './Broker.js' - -export class InboundBroker { - readonly #broker: Broker - - readonly emit: (event: Event) => void - - constructor(broker: Broker) { - this.#broker = broker - - this.emit = queueMethod(this.#broker.queue, (event: Event) => { - for (const eventHandler of this.#broker.eventHandlers.get( - event.constructor as EventClass, - ) ?? []) - eventHandler.handle(event) - }) - } - - invoke( - event: Invocation, - context: { - finish: () => void - send: (value: any) => void - signal?: AbortSignal - }, - ) { - if (!this.#broker.queue.running) return context.finish() - - const invokeHandlers = this.#broker.invokeHandlers.get( - event.constructor as InvocationClass, - ) - - if (!invokeHandlers?.size) return context.finish() - - const finish = withCounter((counter) => { - if (counter >= invokeHandlers.size) context.finish() - }) - - for (const invokeHandler of invokeHandlers) - invokeHandler.handle(event, { ...context, finish }) - } - - start() { - this.#broker.queue.start() - } - - stop() { - this.#broker.queue.stop() - } -} diff --git a/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts b/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts index 85b235e88..bde22d0b5 100644 --- a/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts +++ b/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts @@ -1,6 +1,6 @@ -import { EventClass, Invocation, InvocationClass } from '../Event.js' -import { InvocationListener } from '../EventListener.js' -import { Filter } from '../Filter.js' +import type { EventClass, Invocation, InvocationClass } from '../Event.js' +import type { InvocationListener } from '../EventListener.js' +import type { Filter } from '../Filter.js' import { Handler } from './Handler.js' export class InvocationHandler extends Handler { diff --git a/packages/message-bus/src/dmg-bus/Broker/OutboundBroker.ts b/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts similarity index 73% rename from packages/message-bus/src/dmg-bus/Broker/OutboundBroker.ts rename to packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts index edda2921b..63d69905a 100644 --- a/packages/message-bus/src/dmg-bus/Broker/OutboundBroker.ts +++ b/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts @@ -1,4 +1,4 @@ -import { +import type { Event, EventClass, Invocation, @@ -10,14 +10,14 @@ import { InvocationListener, InvocationListenerContext, } from '../EventListener.js' -import { Filter } from '../Filter.js' -import { queueMethod } from '../queuedMethods.js' -import { Broker } from './Broker.js' +import type { Filter } from '../Filter.js' +import { getOrInsert } from '../lang/Map.js' +import type { BrokerModel } from './BrokerModel.js' import { EventHandler } from './EventHandler.js' import { InvocationHandler } from './InvocationHandler.js' -export class OutboundBroker { - readonly #broker: Broker +export class PluginBroker { + readonly #model: BrokerModel readonly #invoke: >( event: InstanceType, @@ -26,11 +26,17 @@ export class OutboundBroker { readonly emit: (event: Event) => void - constructor(broker: Broker) { - this.#broker = broker + get name() { + return this.#model.name + } + + constructor( + model: BrokerModel, + readonly abortSignal: AbortSignal, + ) { + this.#model = model - this.#invoke = queueMethod( - broker.queue, + this.#invoke = model.queue.queueMethod( ( event: Invocation, context: { @@ -39,26 +45,26 @@ export class OutboundBroker { signal?: AbortSignal }, ) => { - this.#broker.bus.emit(event) - this.#broker.bus.invoke(event, context) + this.#model.bus.emit(event) + this.#model.bus.invoke(event, context) }, ) - this.emit = queueMethod(broker.queue, (event: Event) => { - this.#broker.bus.emit(event) + this.emit = model.queue.queueMethod((event: Event) => { + this.#model.bus.emit(event) }) } start(name: string) { - this.#broker.bus.start(name) + this.#model.bus.start(name) } stop(name: string) { - this.#broker.bus.stop(name) + this.#model.bus.stop(name) } abort(name: string, reason?: any) { - this.#broker.bus.abort(name, reason) + this.#model.bus.abort(name, reason) } on( @@ -79,15 +85,18 @@ export class OutboundBroker { ): () => void { const filter = (eventListener ? filterOrEventListener : {}) as Filter eventListener ??= filterOrEventListener as EventListener - const eventHandlers = this.#broker.eventHandlers.getOrInsert( + + const eventHandlers = getOrInsert( + this.#model.eventHandlers, eventClass, new Set(), ) const eventHandler = new EventHandler(filter, eventListener) eventHandlers.add(eventHandler) - const unregister = this.#broker.bus.on(this, eventClass) + + const unregister = this.#model.bus.on(this, eventClass) return () => { - const eventHandlers = this.#broker.eventHandlers.get(eventClass) + const eventHandlers = this.#model.eventHandlers.get(eventClass) eventHandlers?.delete(eventHandler) if (!eventHandlers?.size) unregister() } @@ -123,11 +132,11 @@ export class OutboundBroker { filter: Filter = {}, ): Promise> { return new Promise((resolve, reject) => { - const onAbort = () => reject(this.#broker.abortSignal.reason) - if (this.#broker.abortSignal.aborted) return onAbort() - this.#broker.abortSignal.addEventListener('abort', onAbort) + const onAbort = () => reject(this.abortSignal.reason) + if (this.abortSignal.aborted) return onAbort() + this.abortSignal.addEventListener('abort', onAbort) this.once(eventClass, filter, (event) => { - this.#broker.abortSignal.removeEventListener('abort', onAbort) + this.abortSignal.removeEventListener('abort', onAbort) resolve(event) }) }) @@ -151,15 +160,18 @@ export class OutboundBroker { ): () => void { const filter = (listener ? filterOrListener : {}) as Filter listener ??= filterOrListener as InvocationListener - const handlers = this.#broker.invokeHandlers.getOrInsert( + + const handlers = getOrInsert( + this.#model.invokeHandlers, eventClass, new Set(), ) const handler = new InvocationHandler(filter, listener) handlers.add(handler) - const unregister = this.#broker.bus.register(this, eventClass) + + const unregister = this.#model.bus.register(this, eventClass) return () => { - const handlers = this.#broker.invokeHandlers.get(eventClass) + const handlers = this.#model.invokeHandlers.get(eventClass) handlers?.delete(handler) if (!handlers?.size) unregister() } @@ -181,19 +193,19 @@ export class OutboundBroker { const readableStream = new ReadableStream({ start: (controller) => { - const abort = () => controller.error(this.#broker.abortSignal.reason) + const abort = () => controller.error(this.abortSignal.reason) const close = () => controller.close() - if (this.#broker.abortSignal.aborted) return abort() + if (this.abortSignal.aborted) return abort() if (signal?.aborted) return close() - this.#broker.abortSignal.addEventListener('abort', abort) + this.abortSignal.addEventListener('abort', abort) signal?.addEventListener('abort', close) this.#invoke(event, { - send: (value: T) => controller.enqueue(value), + send: (value: unknown) => controller.enqueue(value as T), finish: () => { - this.#broker.abortSignal.removeEventListener('abort', abort) + this.abortSignal.removeEventListener('abort', abort) signal?.removeEventListener('abort', close) controller.close() }, diff --git a/packages/message-bus/src/dmg-bus/Bus.ts b/packages/message-bus/src/dmg-bus/Bus.ts index f2e1e8e3b..19d7aebe1 100644 --- a/packages/message-bus/src/dmg-bus/Bus.ts +++ b/packages/message-bus/src/dmg-bus/Bus.ts @@ -1,14 +1,12 @@ +import type { BusBroker } from './Broker/BusBroker.js' import { brokerFactory } from './Broker/Factory.js' -import { InboundBroker } from './Broker/InboundBroker.js' -import { OutboundBroker } from './Broker/OutboundBroker.js' -import { Event, EventClass, Invocation, InvocationClass } from './Event.js' -import { withCounter } from './Function.js' +import type { PluginBroker } from './Broker/PluginBroker.js' +import type { Event, EventClass, Invocation, InvocationClass } from './Event.js' +import { withCounter } from './lang/Function.js' +import { getOrInsert } from './lang/Map.js' export class Bus { - #brokers = new Map< - string, - { broker: InboundBroker; abortController: AbortController } - >() + #brokers = new Map() #eventBrokers = new Map>() @@ -18,25 +16,22 @@ export class Bus { if (this.#brokers.has(name)) throw new Error(`Broker "${name}" has already been registered`) - const { abortController, inboundBroker, outboundBroker } = brokerFactory( - name, - this, - ) + const { abortSignal, busBroker, pluginBroker } = brokerFactory(this, name) - this.#brokers.set(name, { broker: inboundBroker, abortController }) + this.#brokers.set(name, busBroker) - abortController.signal.addEventListener('abort', () => { + abortSignal.addEventListener('abort', () => { this.#brokers.delete(name) for (const brokerNames of this.#eventBrokers.values()) { brokerNames.delete(name) } }) - return outboundBroker + return pluginBroker } - on(broker: OutboundBroker, eventClass: E): () => void { - const eventBrokers = this.#eventBrokers.getOrInsert(eventClass, new Set()) + on(broker: PluginBroker, eventClass: E): () => void { + const eventBrokers = getOrInsert(this.#eventBrokers, eventClass, new Set()) eventBrokers.add(broker.name) return () => { this.#eventBrokers.get(eventClass)?.delete(broker.name) @@ -46,14 +41,18 @@ export class Bus { emit(event: E) { const eventBrokers = this.#eventBrokers.get(event.constructor as EventClass) if (!eventBrokers?.size) return - for (const name of eventBrokers) this.#brokers.get(name)?.broker.emit(event) + for (const name of eventBrokers) this.#brokers.get(name)?.emit(event) } register( - broker: OutboundBroker, + broker: PluginBroker, eventClass: InvocationClass, ): () => void { - const invokeBrokers = this.#invokeBrokers.getOrInsert(eventClass, new Set()) + const invokeBrokers = getOrInsert( + this.#invokeBrokers, + eventClass, + new Set(), + ) invokeBrokers.add(broker.name) return () => { this.#invokeBrokers.get(eventClass)?.delete(broker.name) @@ -79,22 +78,22 @@ export class Bus { }) for (const name of invokeBrokers) - this.#brokers.get(name)?.broker.invoke(event, { ...context, finish }) + this.#brokers.get(name)?.invoke(event, { ...context, finish }) } abort(name: string, reason?: Error) { - this.#brokers.get(name)?.abortController.abort(reason) + this.#brokers.get(name)?.abort(reason) } start(name?: string) { if (name === undefined) - for (const { broker } of this.#brokers.values()) broker.start() - else this.#brokers.get(name)?.broker.start() + for (const broker of this.#brokers.values()) broker.start() + else this.#brokers.get(name)?.start() } stop(name?: string) { if (name === undefined) - for (const { broker } of this.#brokers.values()) broker.stop() - else this.#brokers.get(name)?.broker.stop() + for (const broker of this.#brokers.values()) broker.stop() + else this.#brokers.get(name)?.stop() } } diff --git a/packages/message-bus/src/dmg-bus/Filter.ts b/packages/message-bus/src/dmg-bus/Filter.ts index 1996834d5..368ff02a3 100644 --- a/packages/message-bus/src/dmg-bus/Filter.ts +++ b/packages/message-bus/src/dmg-bus/Filter.ts @@ -1,4 +1,4 @@ -import { EventClass } from './Event.js' +import type { EventClass } from './Event.js' export type Filter = { [K in keyof InstanceType]?: FilterValue diff --git a/packages/message-bus/src/dmg-bus/PluginBroker.ts b/packages/message-bus/src/dmg-bus/PluginBroker.ts deleted file mode 100644 index f7671c38b..000000000 --- a/packages/message-bus/src/dmg-bus/PluginBroker.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Broker, ListenableBroker } from './Broker.js' -import { Event, Invocation, InvocationType } from './Event.js' -import { queueMethod } from './queuedMethods.js' - -export class PluginBroker implements ListenableBroker { - #broker: Broker - #invoke: Broker['invoke'] - - emit: Broker['emit'] - on: ListenableBroker['on'] - once: ListenableBroker['once'] - until: ListenableBroker['until'] - register: ListenableBroker['register'] - - get abortSignal() { - return this.#broker.abortSignal - } - - constructor(broker: Broker) { - this.#broker = broker - - this.#invoke = queueMethod( - this.#broker.queue, - ( - event: Invocation, - context: { - send(value: any): void - finish(): void - signal?: AbortSignal - }, - ) => { - broker.bus.emit(event) - broker.bus.invoke(event, context) - }, - ) - - this.on = broker.on.bind(broker) - this.once = broker.once.bind(broker) - this.until = broker.until.bind(broker) - this.register = broker.register.bind(broker) - - this.emit = queueMethod(this.#broker.queue, (event: Event) => { - broker.bus.emit(event) - }) - } - - start(name: string) { - this.#broker.bus.start(name) - } - - stop(name: string) { - this.#broker.bus.stop(name) - } - - abort(name: string, reason?: any) { - this.#broker.bus.abort(name, reason) - } - - invoke>( - event: E, - { - signal, - }: { - signal?: AbortSignal - } = {}, - ): { - collect(): Promise[]> - iterate(): AsyncIterable, undefined> - promise(): Promise - } { - type T = InvocationType - - const readableStream = new ReadableStream({ - start: (controller) => { - const abort = () => controller.error(this.abortSignal.reason) - const close = () => controller.close() - - if (this.abortSignal.aborted) return abort() - if (signal?.aborted) return close() - - this.abortSignal.addEventListener('abort', abort) - signal?.addEventListener('abort', close) - - this.#invoke(event, { - send: (value: T) => controller.enqueue(value), - finish: () => { - this.abortSignal.removeEventListener('abort', abort) - signal?.removeEventListener('abort', close) - controller.close() - }, - signal, - }) - }, - }) - - return { - async collect() { - const items: T[] = [] - for await (const item of readableStream) items.push(item) - return items - }, - - iterate: () => readableStream.values(), - - promise: async (concurrency = 10) => { - const reader = readableStream.getReader() - let item = await reader.read() - for (let i = 0; i < concurrency && !item.done; i++) { - while ((item = await reader.read()) && !item.done) {} - } - }, - } - } -} diff --git a/packages/message-bus/src/dmg-bus/DiscriminatedQueue.ts b/packages/message-bus/src/dmg-bus/Queue/DiscriminatedQueue.ts similarity index 100% rename from packages/message-bus/src/dmg-bus/DiscriminatedQueue.ts rename to packages/message-bus/src/dmg-bus/Queue/DiscriminatedQueue.ts diff --git a/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts b/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts new file mode 100644 index 000000000..266bc5ec9 --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts @@ -0,0 +1,23 @@ +import { DiscriminatedQueue } from './Queue/DiscriminatedQueue.js' + +export class MethodQueue extends DiscriminatedQueue< + Record +> { + constructor() { + super({}) + } + + queueMethod( + method: (...args: Args) => unknown, + ): (...args: Args) => void { + const type = Symbol() + + this.addExec(type, (item: { args: Args }) => { + method(...item.args) + }) + + return (...args) => { + this.push({ type, args }) + } + } +} diff --git a/packages/message-bus/src/dmg-bus/Queue.ts b/packages/message-bus/src/dmg-bus/Queue/Queue.ts similarity index 100% rename from packages/message-bus/src/dmg-bus/Queue.ts rename to packages/message-bus/src/dmg-bus/Queue/Queue.ts diff --git a/packages/message-bus/src/dmg-bus/queue.test.ts b/packages/message-bus/src/dmg-bus/Queue/queue.test.ts similarity index 100% rename from packages/message-bus/src/dmg-bus/queue.test.ts rename to packages/message-bus/src/dmg-bus/Queue/queue.test.ts diff --git a/packages/message-bus/src/dmg-bus/broker.test.ts b/packages/message-bus/src/dmg-bus/broker.test.ts index d0347248a..1302c7663 100644 --- a/packages/message-bus/src/dmg-bus/broker.test.ts +++ b/packages/message-bus/src/dmg-bus/broker.test.ts @@ -1,18 +1,17 @@ -import './polyfill.js' import { setTimeout } from 'node:timers/promises' import { beforeEach, describe, expect, Mock, test, vi } from 'vitest' -import { PluginBroker } from './PluginBroker.js' import { Bus } from './Bus.js' import { Event, Invocation } from './Event.js' -import { InvocationListenerParams } from './EventListener.js' +import type { InvocationListenerContext } from './EventListener.js' +import type { PluginBroker } from './Broker/PluginBroker.js' let brokerA: PluginBroker let brokerB: PluginBroker beforeEach(() => { const bus = new Bus() - brokerA = bus.broker('a').pluginBroker() - brokerB = bus.broker('b').pluginBroker() + brokerA = bus.broker('a') + brokerB = bus.broker('b') bus.start() }) @@ -71,7 +70,7 @@ describe('invoke', () => { let spy: Mock< ( event: TestInvocation, - { send, finish }: InvocationListenerParams, + { send, finish }: InvocationListenerContext, ) => void > diff --git a/packages/message-bus/src/dmg-bus/Function.ts b/packages/message-bus/src/dmg-bus/lang/Function.ts similarity index 100% rename from packages/message-bus/src/dmg-bus/Function.ts rename to packages/message-bus/src/dmg-bus/lang/Function.ts diff --git a/packages/message-bus/src/dmg-bus/lang/Map.ts b/packages/message-bus/src/dmg-bus/lang/Map.ts new file mode 100644 index 000000000..e488057de --- /dev/null +++ b/packages/message-bus/src/dmg-bus/lang/Map.ts @@ -0,0 +1,4 @@ +export function getOrInsert(map: Map, key: K, defaultValue: V) { + if (!map.has(key)) map.set(key, defaultValue) + return map.get(key)! +} diff --git a/packages/message-bus/src/dmg-bus/mung.ts b/packages/message-bus/src/dmg-bus/mung.ts deleted file mode 100644 index 00c1e6f59..000000000 --- a/packages/message-bus/src/dmg-bus/mung.ts +++ /dev/null @@ -1,12 +0,0 @@ -class AuctionEvent extends CallbackEvent {} - -broker.register(AuctionEvent, (event) => { - event.send(1) - event.send(2) - event.send(3) - event.finish() -}) - -const { promise } = broker.publish(new PromiseEvent(10_000)) - -broker.publish(new AuctionEvent()) diff --git a/packages/message-bus/src/dmg-bus/polyfill.ts b/packages/message-bus/src/dmg-bus/polyfill.ts deleted file mode 100644 index 6086c7eca..000000000 --- a/packages/message-bus/src/dmg-bus/polyfill.ts +++ /dev/null @@ -1,4 +0,0 @@ -Map.prototype.getOrInsert ??= function (key, defaultValue) { - if (!this.has(key)) this.set(key, defaultValue) - return this.get(key)! -} diff --git a/packages/message-bus/src/dmg-bus/queuedMethods.test.ts b/packages/message-bus/src/dmg-bus/queuedMethods.test.ts deleted file mode 100644 index dcab51fbd..000000000 --- a/packages/message-bus/src/dmg-bus/queuedMethods.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { expect, test, vi } from 'vitest' -import { queuedMethods } from './queuedMethods.js' -import { Queue } from './Queue.js' - -const Test = queuedMethods( - ['foo'], - class Test { - $queue!: Queue - - foo(spy: () => void) { - spy() - } - - bar(spy: () => void) { - spy() - } - }, -) - -test('doesnt affect bar', () => { - const t = new Test() - const spy = vi.fn() - t.bar(spy) - expect(spy).toHaveBeenCalled() -}) - -test('queues configured methods', () => { - const t = new Test() - const spy = vi.fn() - - t.foo(spy) - expect(spy).not.toHaveBeenCalled() - - t.$queue.start() - expect(spy).toHaveBeenCalled() -}) diff --git a/packages/message-bus/src/dmg-bus/queuedMethods.ts b/packages/message-bus/src/dmg-bus/queuedMethods.ts deleted file mode 100644 index 8860c7edd..000000000 --- a/packages/message-bus/src/dmg-bus/queuedMethods.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { DiscriminatedQueue } from './DiscriminatedQueue.js' - -export function queueMethod( - queue: DiscriminatedQueue, - method: (...args: Args) => unknown, -): (...args: Args) => void { - const type = Symbol() - - queue.addExec(type, (item: { args: Args }) => { - method(...item.args) - }) - - return (...args) => { - queue.push({ type, args }) - } -} From d98413118435cea49363bba4ee85985d60980929 Mon Sep 17 00:00:00 2001 From: John Wright Date: Tue, 15 Sep 2026 10:22:02 +0100 Subject: [PATCH 05/18] fix: correct import location --- packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts b/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts index 266bc5ec9..520be13ff 100644 --- a/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts +++ b/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts @@ -1,4 +1,4 @@ -import { DiscriminatedQueue } from './Queue/DiscriminatedQueue.js' +import { DiscriminatedQueue } from './DiscriminatedQueue.js' export class MethodQueue extends DiscriminatedQueue< Record From 808fd81d6b26c8789c8307caf8aff629494c7838 Mon Sep 17 00:00:00 2001 From: John Wright Date: Tue, 15 Sep 2026 16:57:32 +0100 Subject: [PATCH 06/18] refactor: immutable queue --- .../src/dmg-bus/{ => Broker}/broker.test.ts | 8 ++--- .../src/dmg-bus/Queue/DiscriminatedQueue.ts | 19 ++++++++---- .../src/dmg-bus/Queue/MethodQueue.ts | 29 ++++++++++++------- .../message-bus/src/dmg-bus/Queue/Queue.ts | 13 +++++---- .../message-bus/src/dmg-bus/Queue/Runnable.ts | 5 ++++ 5 files changed, 49 insertions(+), 25 deletions(-) rename packages/message-bus/src/dmg-bus/{ => Broker}/broker.test.ts (94%) create mode 100644 packages/message-bus/src/dmg-bus/Queue/Runnable.ts diff --git a/packages/message-bus/src/dmg-bus/broker.test.ts b/packages/message-bus/src/dmg-bus/Broker/broker.test.ts similarity index 94% rename from packages/message-bus/src/dmg-bus/broker.test.ts rename to packages/message-bus/src/dmg-bus/Broker/broker.test.ts index 1302c7663..0b35c25ad 100644 --- a/packages/message-bus/src/dmg-bus/broker.test.ts +++ b/packages/message-bus/src/dmg-bus/Broker/broker.test.ts @@ -1,9 +1,9 @@ import { setTimeout } from 'node:timers/promises' import { beforeEach, describe, expect, Mock, test, vi } from 'vitest' -import { Bus } from './Bus.js' -import { Event, Invocation } from './Event.js' -import type { InvocationListenerContext } from './EventListener.js' -import type { PluginBroker } from './Broker/PluginBroker.js' +import { Bus } from '../Bus.js' +import { Event, Invocation } from '../Event.js' +import type { InvocationListenerContext } from '../EventListener.js' +import type { PluginBroker } from './PluginBroker.js' let brokerA: PluginBroker let brokerB: PluginBroker diff --git a/packages/message-bus/src/dmg-bus/Queue/DiscriminatedQueue.ts b/packages/message-bus/src/dmg-bus/Queue/DiscriminatedQueue.ts index 1794fe25d..486893a4e 100644 --- a/packages/message-bus/src/dmg-bus/Queue/DiscriminatedQueue.ts +++ b/packages/message-bus/src/dmg-bus/Queue/DiscriminatedQueue.ts @@ -5,10 +5,13 @@ export class DiscriminatedQueue< > extends Queue> { #execRecord: DiscriminatedQueueExecRecord - constructor(execRecord: DiscriminatedQueueExecRecord) { + constructor( + execRecord: DiscriminatedQueueExecRecord, + items?: DiscriminatedQueueItem[], + ) { super((item) => { execRecord[item.type](item) - }) + }, items) this.#execRecord = execRecord } @@ -16,10 +19,14 @@ export class DiscriminatedQueue< type: K, exec: (item: T) => void, ): DiscriminatedQueue { - type NewQueue = DiscriminatedQueue - const newQueue = this as unknown as NewQueue - newQueue.#execRecord[type] = exec - return newQueue + type NewT = T & { [P in K]: T } + return new DiscriminatedQueue( + { + ...this.#execRecord, + [type]: exec, + }, + this.items as unknown as DiscriminatedQueueItem[], + ) } } diff --git a/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts b/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts index 520be13ff..cc3a24524 100644 --- a/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts +++ b/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts @@ -1,23 +1,32 @@ import { DiscriminatedQueue } from './DiscriminatedQueue.js' +import type { Runnable } from './Runnable.js' -export class MethodQueue extends DiscriminatedQueue< - Record -> { - constructor() { - super({}) - } +export class MethodQueue implements Runnable { + #queue = new DiscriminatedQueue>({}) queueMethod( method: (...args: Args) => unknown, ): (...args: Args) => void { const type = Symbol() - this.addExec(type, (item: { args: Args }) => { - method(...item.args) - }) + this.#queue = this.#queue.addExec(type, (item: { args: unknown[] }) => { + method(...(item.args as Args)) + }) as any return (...args) => { - this.push({ type, args }) + this.#queue.push({ type, args }) } } + + start() { + this.#queue.start() + } + + stop() { + this.#queue.stop() + } + + get running() { + return this.#queue.running + } } diff --git a/packages/message-bus/src/dmg-bus/Queue/Queue.ts b/packages/message-bus/src/dmg-bus/Queue/Queue.ts index 90ed166a3..ed2084208 100644 --- a/packages/message-bus/src/dmg-bus/Queue/Queue.ts +++ b/packages/message-bus/src/dmg-bus/Queue/Queue.ts @@ -1,10 +1,12 @@ -export class Queue { +import type { Runnable } from './Runnable.js' + +export class Queue implements Runnable { #exec: (item: T) => void - #items: T[] + protected readonly items: T[] constructor(exec: (item: T) => void, items: T[] = []) { this.#exec = exec - this.#items = items + this.items = items } #running = false @@ -14,13 +16,14 @@ export class Queue { push(item: T) { if (this.#running) this.#exec(item) - else this.#items.push(item) + else this.items.push(item) } start() { + if (this.#running) return this.#running = true let item: T | undefined - while (this.#running && (item = this.#items.shift())) this.push(item) + while (this.#running && (item = this.items.shift())) this.push(item) } stop() { diff --git a/packages/message-bus/src/dmg-bus/Queue/Runnable.ts b/packages/message-bus/src/dmg-bus/Queue/Runnable.ts new file mode 100644 index 000000000..253c2a252 --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Queue/Runnable.ts @@ -0,0 +1,5 @@ +export interface Runnable { + start(): void + stop(): void + running: boolean +} From 377df45651dff4e0e711a84230cf16ee870a7f82 Mon Sep 17 00:00:00 2001 From: John Wright Date: Wed, 16 Sep 2026 09:36:58 +0100 Subject: [PATCH 07/18] refactor: merge BusBroker and BrokerModel in to Broker --- .../message-bus/src/dmg-bus/Broker/Broker.ts | 97 ++++++++++++++++ .../src/dmg-bus/Broker/BrokerModel.ts | 27 ----- .../src/dmg-bus/Broker/BusBroker.ts | 69 ------------ .../message-bus/src/dmg-bus/Broker/Factory.ts | 21 ---- .../src/dmg-bus/Broker/PluginBroker.ts | 105 ++++++++++++------ .../src/dmg-bus/Broker/broker.test.ts | 17 +-- packages/message-bus/src/dmg-bus/Bus.ts | 21 ++-- .../message-bus/src/dmg-bus/lang/Error.ts | 3 + .../message-bus/src/dmg-bus/lang/Signal.ts | 24 ++++ 9 files changed, 215 insertions(+), 169 deletions(-) create mode 100644 packages/message-bus/src/dmg-bus/Broker/Broker.ts delete mode 100644 packages/message-bus/src/dmg-bus/Broker/BrokerModel.ts delete mode 100644 packages/message-bus/src/dmg-bus/Broker/BusBroker.ts delete mode 100644 packages/message-bus/src/dmg-bus/Broker/Factory.ts create mode 100644 packages/message-bus/src/dmg-bus/lang/Error.ts create mode 100644 packages/message-bus/src/dmg-bus/lang/Signal.ts diff --git a/packages/message-bus/src/dmg-bus/Broker/Broker.ts b/packages/message-bus/src/dmg-bus/Broker/Broker.ts new file mode 100644 index 000000000..4d237cc97 --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Broker/Broker.ts @@ -0,0 +1,97 @@ +import type { Bus } from '../Bus.js' +import type { + Event, + EventClass, + Invocation, + InvocationClass, +} from '../Event.js' +import { withCounter } from '../lang/Function.js' +import { MethodQueue } from '../Queue/MethodQueue.js' +import type { EventHandler } from './EventHandler.js' +import type { InvocationHandler } from './InvocationHandler.js' + +/** + * The full broker. Only the Bus and internal machinery ever hold one; plugins + * receive a PluginBroker facade instead. Owns the handler registries + queue, + * and implements the *inbound* pipe: the bus delivering events/invocations + * into this broker's local handlers. Note `emit`/`invoke`/`start`/`stop`/`abort` + * here are the bus-driven direction — the identically named PluginBroker + * methods are the opposite (plugin -> bus) direction, which is exactly why the + * two can't collapse into one object. + */ +export class Broker { + readonly eventHandlers = new Map>() + + readonly invokeHandlers = new Map< + InvocationClass, + Set + >() + + readonly queue = new MethodQueue() + + readonly emit: (event: Event) => void + + readonly #abortController = new AbortController() + + constructor( + readonly bus: Bus, + readonly name: string, + ) { + this.abortSignal.addEventListener('abort', () => { + this.eventHandlers.clear() + this.invokeHandlers.clear() + }) + + this.emit = this.queue.queueMethod((event: Event) => { + for (const handler of this.eventHandlers.get( + event.constructor as EventClass, + ) ?? []) + handler.handle(event) + }) + } + + get abortSignal(): AbortSignal { + return this.#abortController.signal + } + + onAbort(fn: (reason: any) => any) { + this.abortSignal.addEventListener('abort', fn) + return () => this.abortSignal.removeEventListener('abort', fn) + } + + invoke( + event: Invocation, + context: { + finish: () => void + send: (value: any) => void + signal?: AbortSignal + }, + ) { + if (!this.queue.running) return context.finish() + + const invokeHandlers = this.invokeHandlers.get( + event.constructor as InvocationClass, + ) + + if (!invokeHandlers?.size) return context.finish() + + const finish = withCounter((counter) => { + if (counter >= invokeHandlers.size) context.finish() + }) + + for (const handler of invokeHandlers) + handler.handle(event, { ...context, finish }) + } + + start() { + this.queue.start() + } + + stop() { + this.queue.stop() + } + + abort(reason?: unknown) { + this.#abortController.abort(reason) + } +} diff --git a/packages/message-bus/src/dmg-bus/Broker/BrokerModel.ts b/packages/message-bus/src/dmg-bus/Broker/BrokerModel.ts deleted file mode 100644 index 0a83336f6..000000000 --- a/packages/message-bus/src/dmg-bus/Broker/BrokerModel.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Bus } from '../Bus.js' -import type { EventClass, InvocationClass } from '../Event.js' -import type { EventHandler } from './EventHandler.js' -import type { InvocationHandler } from './InvocationHandler.js' -import { MethodQueue } from '../Queue/MethodQueue.js' - -export class BrokerModel { - readonly eventHandlers = new Map>() - - readonly invokeHandlers = new Map< - InvocationClass, - Set - >() - - readonly queue = new MethodQueue() - - constructor( - readonly bus: Bus, - readonly name: string, - readonly abortSignal: AbortSignal, - ) { - abortSignal.addEventListener('abort', () => { - this.eventHandlers.clear() - this.invokeHandlers.clear() - }) - } -} diff --git a/packages/message-bus/src/dmg-bus/Broker/BusBroker.ts b/packages/message-bus/src/dmg-bus/Broker/BusBroker.ts deleted file mode 100644 index 08c4a274a..000000000 --- a/packages/message-bus/src/dmg-bus/Broker/BusBroker.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { - Event, - EventClass, - Invocation, - InvocationClass, -} from '../Event.js' -import { withCounter } from '../lang/Function.js' -import type { BrokerModel } from './BrokerModel.js' - -export class BusBroker { - readonly #model: BrokerModel - - readonly #abortController: AbortController - - readonly emit: (event: Event) => void - - constructor(model: BrokerModel, abortController: AbortController) { - this.#abortController = abortController - - this.#model = model - - this.emit = model.queue.queueMethod((event: Event) => { - for (const eventHandler of this.#model.eventHandlers.get( - event.constructor as EventClass, - ) ?? []) - eventHandler.handle(event) - }) - } - - get name() { - return this.#model.name - } - - invoke( - event: Invocation, - context: { - finish: () => void - send: (value: any) => void - signal?: AbortSignal - }, - ) { - if (!this.#model.queue.running) return context.finish() - - const invokeHandlers = this.#model.invokeHandlers.get( - event.constructor as InvocationClass, - ) - - if (!invokeHandlers?.size) return context.finish() - - const finish = withCounter((counter) => { - if (counter >= invokeHandlers.size) context.finish() - }) - - for (const invokeHandler of invokeHandlers) - invokeHandler.handle(event, { ...context, finish }) - } - - start() { - this.#model.queue.start() - } - - stop() { - this.#model.queue.stop() - } - - abort(reason?: any) { - this.#abortController.abort(reason) - } -} diff --git a/packages/message-bus/src/dmg-bus/Broker/Factory.ts b/packages/message-bus/src/dmg-bus/Broker/Factory.ts deleted file mode 100644 index 31442bd79..000000000 --- a/packages/message-bus/src/dmg-bus/Broker/Factory.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Bus } from '../Bus.js' -import { BrokerModel } from './BrokerModel.js' -import { BusBroker } from './BusBroker.js' -import { PluginBroker } from './PluginBroker.js' - -export function brokerFactory( - bus: Bus, - name: string, -): { - abortSignal: AbortSignal - busBroker: BusBroker - pluginBroker: PluginBroker -} { - const abortController = new AbortController() - const model = new BrokerModel(bus, name, abortController.signal) - return { - abortSignal: abortController.signal, - busBroker: new BusBroker(model, abortController), - pluginBroker: new PluginBroker(model, abortController.signal), - } -} diff --git a/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts b/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts index 63d69905a..ede635716 100644 --- a/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts +++ b/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts @@ -12,12 +12,12 @@ import { } from '../EventListener.js' import type { Filter } from '../Filter.js' import { getOrInsert } from '../lang/Map.js' -import type { BrokerModel } from './BrokerModel.js' +import type { Broker } from './Broker.js' import { EventHandler } from './EventHandler.js' import { InvocationHandler } from './InvocationHandler.js' export class PluginBroker { - readonly #model: BrokerModel + readonly #broker: Broker readonly #invoke: >( event: InstanceType, @@ -27,16 +27,30 @@ export class PluginBroker { readonly emit: (event: Event) => void get name() { - return this.#model.name + return this.#broker.name } - constructor( - model: BrokerModel, - readonly abortSignal: AbortSignal, - ) { - this.#model = model + get abortSignal(): AbortSignal { + return this.#broker.abortSignal + } + + get aborted() { + return this.abortSignal.aborted + } + + get abortReason() { + return this.abortSignal.reason + } - this.#invoke = model.queue.queueMethod( + onAbort(fn: (reason: any) => any) { + this.abortSignal.addEventListener('abort', fn) + return () => this.abortSignal.removeEventListener('abort', fn) + } + + constructor(broker: Broker) { + this.#broker = broker + + this.#invoke = broker.queue.queueMethod( ( event: Invocation, context: { @@ -45,26 +59,26 @@ export class PluginBroker { signal?: AbortSignal }, ) => { - this.#model.bus.emit(event) - this.#model.bus.invoke(event, context) + this.#broker.bus.emit(event) + this.#broker.bus.invoke(event, context) }, ) - this.emit = model.queue.queueMethod((event: Event) => { - this.#model.bus.emit(event) + this.emit = broker.queue.queueMethod((event: Event) => { + this.#broker.bus.emit(event) }) } start(name: string) { - this.#model.bus.start(name) + this.#broker.bus.start(name) } stop(name: string) { - this.#model.bus.stop(name) + this.#broker.bus.stop(name) } abort(name: string, reason?: any) { - this.#model.bus.abort(name, reason) + this.#broker.bus.abort(name, reason) } on( @@ -87,16 +101,16 @@ export class PluginBroker { eventListener ??= filterOrEventListener as EventListener const eventHandlers = getOrInsert( - this.#model.eventHandlers, + this.#broker.eventHandlers, eventClass, new Set(), ) const eventHandler = new EventHandler(filter, eventListener) eventHandlers.add(eventHandler) - const unregister = this.#model.bus.on(this, eventClass) + const unregister = this.#broker.bus.on(this, eventClass) return () => { - const eventHandlers = this.#model.eventHandlers.get(eventClass) + const eventHandlers = this.#broker.eventHandlers.get(eventClass) eventHandlers?.delete(eventHandler) if (!eventHandlers?.size) unregister() } @@ -162,16 +176,16 @@ export class PluginBroker { listener ??= filterOrListener as InvocationListener const handlers = getOrInsert( - this.#model.invokeHandlers, + this.#broker.invokeHandlers, eventClass, new Set(), ) const handler = new InvocationHandler(filter, listener) handlers.add(handler) - const unregister = this.#model.bus.register(this, eventClass) + const unregister = this.#broker.bus.register(this, eventClass) return () => { - const handlers = this.#model.invokeHandlers.get(eventClass) + const handlers = this.#broker.invokeHandlers.get(eventClass) handlers?.delete(handler) if (!handlers?.size) unregister() } @@ -191,27 +205,50 @@ export class PluginBroker { } { type T = InvocationType + const self = this + const producer = new AbortController() + let settled = false + let teardown = (_reason?: any) => {} + const readableStream = new ReadableStream({ start: (controller) => { - const abort = () => controller.error(this.abortSignal.reason) - const close = () => controller.close() + const offAbort = this.onAbort(abort) + signal?.addEventListener('abort', close) - if (this.abortSignal.aborted) return abort() - if (signal?.aborted) return close() + teardown = (reason?: any) => { + if (settled) return + settled = true + offAbort() + signal?.removeEventListener('abort', close) + producer.abort(reason) + } - this.abortSignal.addEventListener('abort', abort) - signal?.addEventListener('abort', close) + if (this.aborted) return abort() + if (signal?.aborted) return close() this.#invoke(event, { - send: (value: unknown) => controller.enqueue(value as T), - finish: () => { - this.abortSignal.removeEventListener('abort', abort) - signal?.removeEventListener('abort', close) - controller.close() + send: (value: unknown) => { + if (settled) return + controller.enqueue(value as T) }, - signal, + finish: close, + signal: producer.signal, }) + + function close() { + if (settled) return + teardown(signal?.reason) + controller.close() + } + + function abort() { + if (settled) return + teardown(self.abortReason) + controller.error(self.abortReason) + } }, + + cancel: (reason) => teardown(reason), }) return { diff --git a/packages/message-bus/src/dmg-bus/Broker/broker.test.ts b/packages/message-bus/src/dmg-bus/Broker/broker.test.ts index 0b35c25ad..d6dd7b7f5 100644 --- a/packages/message-bus/src/dmg-bus/Broker/broker.test.ts +++ b/packages/message-bus/src/dmg-bus/Broker/broker.test.ts @@ -1,4 +1,4 @@ -import { setTimeout } from 'node:timers/promises' +import { timeout } from '../lang/Signal.js' import { beforeEach, describe, expect, Mock, test, vi } from 'vitest' import { Bus } from '../Bus.js' import { Event, Invocation } from '../Event.js' @@ -132,12 +132,15 @@ describe('invoke', () => { }) test('timeouts', async () => { - brokerA.register(TestInvocation, async (event, { finish, send }) => { - send(`hello ${event.foo}`) - await setTimeout(1_000) - send(`hello again ${event.foo}`) - finish() - }) + brokerA.register( + TestInvocation, + async (event, { finish, send, signal }) => { + send(`hello ${event.foo}`) + await timeout(1_000, signal) + send(`hello again ${event.foo}`) + finish() + }, + ) expect( await brokerB diff --git a/packages/message-bus/src/dmg-bus/Bus.ts b/packages/message-bus/src/dmg-bus/Bus.ts index 19d7aebe1..64ac55c2c 100644 --- a/packages/message-bus/src/dmg-bus/Bus.ts +++ b/packages/message-bus/src/dmg-bus/Bus.ts @@ -1,12 +1,11 @@ -import type { BusBroker } from './Broker/BusBroker.js' -import { brokerFactory } from './Broker/Factory.js' -import type { PluginBroker } from './Broker/PluginBroker.js' +import { Broker } from './Broker/Broker.js' +import { PluginBroker } from './Broker/PluginBroker.js' import type { Event, EventClass, Invocation, InvocationClass } from './Event.js' import { withCounter } from './lang/Function.js' import { getOrInsert } from './lang/Map.js' export class Bus { - #brokers = new Map() + #brokers = new Map() #eventBrokers = new Map>() @@ -16,18 +15,18 @@ export class Bus { if (this.#brokers.has(name)) throw new Error(`Broker "${name}" has already been registered`) - const { abortSignal, busBroker, pluginBroker } = brokerFactory(this, name) + const broker = new Broker(this, name) + this.#brokers.set(name, broker) - this.#brokers.set(name, busBroker) - - abortSignal.addEventListener('abort', () => { + broker.onAbort(() => { this.#brokers.delete(name) - for (const brokerNames of this.#eventBrokers.values()) { + for (const brokerNames of this.#eventBrokers.values()) + brokerNames.delete(name) + for (const brokerNames of this.#invokeBrokers.values()) brokerNames.delete(name) - } }) - return pluginBroker + return new PluginBroker(broker) } on(broker: PluginBroker, eventClass: E): () => void { diff --git a/packages/message-bus/src/dmg-bus/lang/Error.ts b/packages/message-bus/src/dmg-bus/lang/Error.ts new file mode 100644 index 000000000..c8249d6b3 --- /dev/null +++ b/packages/message-bus/src/dmg-bus/lang/Error.ts @@ -0,0 +1,3 @@ +export function isAbortError(error: unknown): error is Error { + return error instanceof Error && error.name === 'AbortError' +} diff --git a/packages/message-bus/src/dmg-bus/lang/Signal.ts b/packages/message-bus/src/dmg-bus/lang/Signal.ts new file mode 100644 index 000000000..42ddc86c7 --- /dev/null +++ b/packages/message-bus/src/dmg-bus/lang/Signal.ts @@ -0,0 +1,24 @@ +export const CANCELLED = Symbol.for('dmg-bus/cancelled') + +export function timeout( + ms: number, + signal: AbortSignal | undefined, + value: T, +): Promise + +export function timeout( + ms: number, + signal?: AbortSignal, +): Promise + +export function timeout( + ms: number, + signal?: AbortSignal, + value?: T, +): Promise { + return new Promise((resolve) => { + if (signal?.aborted) return resolve(CANCELLED) + signal?.addEventListener('abort', () => resolve(CANCELLED)) + setTimeout(() => resolve(value), ms) + }) +} From 51521efaa1ec97cf98f0d502e233a6808bf25ff8 Mon Sep 17 00:00:00 2001 From: John Wright Date: Wed, 16 Sep 2026 12:34:46 +0100 Subject: [PATCH 08/18] feat(dmg-bus): interception --- .../message-bus/src/dmg-bus/Broker/Broker.ts | 45 +++++++++++--- .../src/dmg-bus/Broker/InterceptionHandler.ts | 25 ++++++++ .../src/dmg-bus/Broker/InvocationHandler.ts | 11 ++-- .../src/dmg-bus/Broker/PluginBroker.ts | 62 ++++++++++++++----- .../src/dmg-bus/Broker/broker.test.ts | 42 +++++++++++-- packages/message-bus/src/dmg-bus/Bus.ts | 42 +++++++++++-- .../message-bus/src/dmg-bus/EventListener.ts | 16 ++++- .../src/dmg-bus/Queue/MethodQueue.ts | 10 +-- 8 files changed, 203 insertions(+), 50 deletions(-) create mode 100644 packages/message-bus/src/dmg-bus/Broker/InterceptionHandler.ts diff --git a/packages/message-bus/src/dmg-bus/Broker/Broker.ts b/packages/message-bus/src/dmg-bus/Broker/Broker.ts index 4d237cc97..f0713589b 100644 --- a/packages/message-bus/src/dmg-bus/Broker/Broker.ts +++ b/packages/message-bus/src/dmg-bus/Broker/Broker.ts @@ -5,9 +5,11 @@ import type { Invocation, InvocationClass, } from '../Event.js' +import { CANCEL, InvocationListenerContext } from '../EventListener.js' import { withCounter } from '../lang/Function.js' import { MethodQueue } from '../Queue/MethodQueue.js' import type { EventHandler } from './EventHandler.js' +import type { InterceptionHandler } from './InterceptionHandler.js' import type { InvocationHandler } from './InvocationHandler.js' /** @@ -27,6 +29,11 @@ export class Broker { Set >() + readonly interceptionHandlers = new Map< + EventClass, + Set + >() + readonly queue = new MethodQueue() readonly emit: (event: Event) => void @@ -59,13 +66,29 @@ export class Broker { return () => this.abortSignal.removeEventListener('abort', fn) } + async intercept>( + event: InstanceType, + ): Promise | typeof CANCEL> { + if (!this.queue.running) return event + + const interceptionHandlers = this.interceptionHandlers.get( + event.constructor as EventClass, + ) + + if (!interceptionHandlers?.size) return event + + for (const interceptionHandler of interceptionHandlers) { + const result = await interceptionHandler.handle(event) + if (result === CANCEL) return CANCEL + else if (result) event = result as InstanceType + } + + return event + } + invoke( event: Invocation, - context: { - finish: () => void - send: (value: any) => void - signal?: AbortSignal - }, + context: InvocationListenerContext>, ) { if (!this.queue.running) return context.finish() @@ -75,12 +98,14 @@ export class Broker { if (!invokeHandlers?.size) return context.finish() - const finish = withCounter((counter) => { - if (counter >= invokeHandlers.size) context.finish() - }) + const handlerContext = { + ...context, + finish: withCounter((counter) => { + if (counter >= invokeHandlers.size) context.finish() + }), + } as InvocationListenerContext> - for (const handler of invokeHandlers) - handler.handle(event, { ...context, finish }) + for (const handler of invokeHandlers) handler.handle(event, handlerContext) } start() { diff --git a/packages/message-bus/src/dmg-bus/Broker/InterceptionHandler.ts b/packages/message-bus/src/dmg-bus/Broker/InterceptionHandler.ts new file mode 100644 index 000000000..255401544 --- /dev/null +++ b/packages/message-bus/src/dmg-bus/Broker/InterceptionHandler.ts @@ -0,0 +1,25 @@ +import type { Event, EventClass, InvocationClass } from '../Event.js' +import type { + InterceptionListener, + InterceptionResult, +} from '../EventListener.js' +import { Filter } from '../Filter.js' +import { Handler } from './Handler.js' + +export class InterceptionHandler extends Handler { + readonly #listener: InterceptionListener< + EventClass | InvocationClass + > + + constructor( + filter: Filter, + listener: InterceptionListener>, + ) { + super(filter) + this.#listener = listener + } + + handle(event: Event): InterceptionResult { + if (this.filter(event)) return this.#listener(event as any) + } +} diff --git a/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts b/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts index bde22d0b5..4bf398e52 100644 --- a/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts +++ b/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts @@ -1,5 +1,8 @@ import type { EventClass, Invocation, InvocationClass } from '../Event.js' -import type { InvocationListener } from '../EventListener.js' +import type { + InvocationListener, + InvocationListenerContext, +} from '../EventListener.js' import type { Filter } from '../Filter.js' import { Handler } from './Handler.js' @@ -16,11 +19,7 @@ export class InvocationHandler extends Handler { handle( event: Invocation, - context: { - send: (value: unknown) => void - finish: () => void - signal?: AbortSignal - }, + context: InvocationListenerContext>, ) { if (this.filter(event)) this.#listener(event, context) } diff --git a/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts b/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts index ede635716..03fa872a5 100644 --- a/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts +++ b/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts @@ -6,7 +6,9 @@ import type { InvocationType, } from '../Event.js' import { + CANCEL, EventListener, + InterceptionListener, InvocationListener, InvocationListenerContext, } from '../EventListener.js' @@ -14,6 +16,7 @@ import type { Filter } from '../Filter.js' import { getOrInsert } from '../lang/Map.js' import type { Broker } from './Broker.js' import { EventHandler } from './EventHandler.js' +import { InterceptionHandler } from './InterceptionHandler.js' import { InvocationHandler } from './InvocationHandler.js' export class PluginBroker { @@ -24,7 +27,7 @@ export class PluginBroker { params: InvocationListenerContext, ) => void - readonly emit: (event: Event) => void + readonly emit: (event: E) => Promise get name() { return this.#broker.name @@ -51,7 +54,7 @@ export class PluginBroker { this.#broker = broker this.#invoke = broker.queue.queueMethod( - ( + async ( event: Invocation, context: { send(value: any): void @@ -59,14 +62,15 @@ export class PluginBroker { signal?: AbortSignal }, ) => { - this.#broker.bus.emit(event) - this.#broker.bus.invoke(event, context) + const result = await this.#broker.bus.emit(event) + if (result === CANCEL) return + this.#broker.bus.invoke(result, context) }, ) - this.emit = broker.queue.queueMethod((event: Event) => { - this.#broker.bus.emit(event) - }) + this.emit = broker.queue.queueMethod((event: E) => + this.#broker.bus.emit(event), + ) } start(name: string) { @@ -191,6 +195,41 @@ export class PluginBroker { } } + intercept( + eventClass: E, + listener: InterceptionListener, + ): () => void + + intercept( + eventClass: E, + filter: Filter, + listener: InterceptionListener, + ): () => void + + intercept>( + eventClass: E, + filterOrListener: Filter | InterceptionListener, + listener?: InterceptionListener, + ): () => void { + const filter = (listener ? filterOrListener : {}) as Filter + listener ??= filterOrListener as InterceptionListener + + const handlers = getOrInsert( + this.#broker.interceptionHandlers, + eventClass, + new Set(), + ) + const handler = new InterceptionHandler(filter, listener) + handlers.add(handler) + + const unregister = this.#broker.bus.intercept(this, eventClass) + return () => { + const handlers = this.#broker.interceptionHandlers.get(eventClass) + handlers?.delete(handler) + if (!handlers?.size) unregister() + } + } + invoke>( event: E, { @@ -201,7 +240,6 @@ export class PluginBroker { ): { collect(): Promise[]> iterate(): AsyncIterable, undefined> - promise(): Promise } { type T = InvocationType @@ -259,14 +297,6 @@ export class PluginBroker { }, iterate: () => readableStream.values(), - - promise: async (concurrency = 10) => { - const reader = readableStream.getReader() - let item = await reader.read() - for (let i = 0; i < concurrency && !item.done; i++) { - while ((item = await reader.read()) && !item.done) {} - } - }, } } } diff --git a/packages/message-bus/src/dmg-bus/Broker/broker.test.ts b/packages/message-bus/src/dmg-bus/Broker/broker.test.ts index d6dd7b7f5..e0c63fe96 100644 --- a/packages/message-bus/src/dmg-bus/Broker/broker.test.ts +++ b/packages/message-bus/src/dmg-bus/Broker/broker.test.ts @@ -2,7 +2,7 @@ import { timeout } from '../lang/Signal.js' import { beforeEach, describe, expect, Mock, test, vi } from 'vitest' import { Bus } from '../Bus.js' import { Event, Invocation } from '../Event.js' -import type { InvocationListenerContext } from '../EventListener.js' +import { CANCEL, type InvocationListenerContext } from '../EventListener.js' import type { PluginBroker } from './PluginBroker.js' let brokerA: PluginBroker @@ -99,11 +99,6 @@ describe('invoke', () => { expect(result).toEqual(['one thing', 'two thing']) }) - test('promise', async () => { - await brokerB.invoke(new TestInvocation('thing')).promise() - expect(spy).toHaveBeenCalled() - }) - test('it emits the event as well', () => { const onSpy = vi.fn() const event = new TestInvocation('bar') @@ -150,6 +145,41 @@ describe('invoke', () => { }) }) +describe('intercept', () => { + test('cancelling', async () => { + const spy = vi.fn() + brokerA.on(TestEvent, spy) + brokerB.intercept(TestEvent, () => CANCEL) + await brokerB.emit(new TestEvent('foo')) + expect(spy).not.toHaveBeenCalled() + }) + + test('changing the event', async () => { + const spy = vi.fn() + brokerA.on(TestEvent, spy) + brokerB.intercept( + TestEvent, + (event) => new TestEvent(`Intercepted ${event.foo}`), + ) + await brokerB.emit(new TestEvent('foo')) + expect(spy).toHaveBeenCalledWith(new TestEvent('Intercepted foo')) + }) + + test('changing invocations', async () => { + const spy = vi.fn() + brokerA.register(TestInvocation, (event, { finish }) => { + spy(event) + finish() + }) + brokerB.intercept( + TestInvocation, + (event) => new TestInvocation(`Intercepted ${event.foo}`), + ) + await brokerB.invoke(new TestInvocation('foo')).collect() + expect(spy).toHaveBeenCalledWith(new TestInvocation('Intercepted foo')) + }) +}) + class TestEvent implements Event { $name = 'test' constructor(readonly foo: string) {} diff --git a/packages/message-bus/src/dmg-bus/Bus.ts b/packages/message-bus/src/dmg-bus/Bus.ts index 64ac55c2c..aad8ac55b 100644 --- a/packages/message-bus/src/dmg-bus/Bus.ts +++ b/packages/message-bus/src/dmg-bus/Bus.ts @@ -1,6 +1,7 @@ import { Broker } from './Broker/Broker.js' import { PluginBroker } from './Broker/PluginBroker.js' import type { Event, EventClass, Invocation, InvocationClass } from './Event.js' +import { CANCEL } from './EventListener.js' import { withCounter } from './lang/Function.js' import { getOrInsert } from './lang/Map.js' @@ -11,6 +12,8 @@ export class Bus { #invokeBrokers = new Map, Set>() + #interceptBrokers = new Map>() + broker(name: string) { if (this.#brokers.has(name)) throw new Error(`Broker "${name}" has already been registered`) @@ -37,10 +40,22 @@ export class Bus { } } - emit(event: E) { + async emit(event: E): Promise { + const interceptorBrokers = + this.#interceptBrokers.get(event.constructor as EventClass) ?? [] + + for (const name of interceptorBrokers) { + const result = await this.#brokers.get(name)?.intercept(event) + if (result === CANCEL) return CANCEL + else if (result) event = result as E + } + const eventBrokers = this.#eventBrokers.get(event.constructor as EventClass) - if (!eventBrokers?.size) return + if (!eventBrokers?.size) return event + for (const name of eventBrokers) this.#brokers.get(name)?.emit(event) + + return event } register( @@ -58,6 +73,18 @@ export class Bus { } } + intercept(broker: PluginBroker, eventClass: EventClass): () => void { + const interceptBrokers = getOrInsert( + this.#interceptBrokers, + eventClass, + new Set(), + ) + interceptBrokers.add(broker.name) + return () => { + this.#interceptBrokers.get(eventClass)?.delete(broker.name) + } + } + invoke( event: Invocation, context: { @@ -72,12 +99,15 @@ export class Bus { if (!invokeBrokers?.size) return context.finish() - const finish = withCounter((counter) => { - if (counter >= invokeBrokers.size) context.finish() - }) + const brokerContext = { + ...context, + finish: withCounter((counter) => { + if (counter >= invokeBrokers.size) context.finish() + }), + } for (const name of invokeBrokers) - this.#brokers.get(name)?.invoke(event, { ...context, finish }) + this.#brokers.get(name)?.invoke(event, brokerContext) } abort(name: string, reason?: Error) { diff --git a/packages/message-bus/src/dmg-bus/EventListener.ts b/packages/message-bus/src/dmg-bus/EventListener.ts index 942c71e03..32a1fe5e8 100644 --- a/packages/message-bus/src/dmg-bus/EventListener.ts +++ b/packages/message-bus/src/dmg-bus/EventListener.ts @@ -12,7 +12,19 @@ export interface InvocationListener> { } export interface InvocationListenerContext> { - send: (value: InvocationType) => unknown - finish: () => unknown + send: (value: InvocationType) => void + finish: () => void signal?: AbortSignal } + +export const CANCEL = Symbol.for('dmg-bus/cancel') + +export type InterceptionResult = + | void + | InstanceType + | typeof CANCEL + | Promise> + +export interface InterceptionListener { + (event: InstanceType): InterceptionResult +} diff --git a/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts b/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts index cc3a24524..a1666e6d1 100644 --- a/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts +++ b/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts @@ -4,17 +4,19 @@ import type { Runnable } from './Runnable.js' export class MethodQueue implements Runnable { #queue = new DiscriminatedQueue>({}) - queueMethod( - method: (...args: Args) => unknown, - ): (...args: Args) => void { + queueMethod( + method: (...args: Args) => R, + ): (...args: Args) => Promise> { const type = Symbol() + const { promise, resolve } = Promise.withResolvers>() this.#queue = this.#queue.addExec(type, (item: { args: unknown[] }) => { - method(...(item.args as Args)) + resolve(method(...(item.args as Args)) as Awaited) }) as any return (...args) => { this.#queue.push({ type, args }) + return promise } } From 27ee014f31b7559b1cf69b1e7381299b20910296 Mon Sep 17 00:00:00 2001 From: John Wright Date: Wed, 16 Sep 2026 13:52:06 +0100 Subject: [PATCH 09/18] refactor: abstract adding event listeners --- .../message-bus/src/dmg-bus/Broker/Broker.ts | 2 +- .../src/dmg-bus/Broker/EventHandler.ts | 2 +- .../message-bus/src/dmg-bus/Broker/Handler.ts | 2 + .../src/dmg-bus/Broker/InterceptionHandler.ts | 2 +- .../src/dmg-bus/Broker/InvocationHandler.ts | 2 +- .../src/dmg-bus/Broker/PluginBroker.ts | 85 ++++++++++--------- packages/message-bus/src/dmg-bus/Bus.ts | 11 ++- 7 files changed, 56 insertions(+), 50 deletions(-) diff --git a/packages/message-bus/src/dmg-bus/Broker/Broker.ts b/packages/message-bus/src/dmg-bus/Broker/Broker.ts index f0713589b..c78477619 100644 --- a/packages/message-bus/src/dmg-bus/Broker/Broker.ts +++ b/packages/message-bus/src/dmg-bus/Broker/Broker.ts @@ -5,7 +5,7 @@ import type { Invocation, InvocationClass, } from '../Event.js' -import { CANCEL, InvocationListenerContext } from '../EventListener.js' +import { CANCEL, type InvocationListenerContext } from '../EventListener.js' import { withCounter } from '../lang/Function.js' import { MethodQueue } from '../Queue/MethodQueue.js' import type { EventHandler } from './EventHandler.js' diff --git a/packages/message-bus/src/dmg-bus/Broker/EventHandler.ts b/packages/message-bus/src/dmg-bus/Broker/EventHandler.ts index 8d8da7e10..30d081788 100644 --- a/packages/message-bus/src/dmg-bus/Broker/EventHandler.ts +++ b/packages/message-bus/src/dmg-bus/Broker/EventHandler.ts @@ -11,7 +11,7 @@ export class EventHandler extends Handler { this.#listener = listener } - handle(event: Event) { + override handle(event: Event) { if (this.filter(event)) this.#listener(event) } } diff --git a/packages/message-bus/src/dmg-bus/Broker/Handler.ts b/packages/message-bus/src/dmg-bus/Broker/Handler.ts index 8846c5849..913572e7f 100644 --- a/packages/message-bus/src/dmg-bus/Broker/Handler.ts +++ b/packages/message-bus/src/dmg-bus/Broker/Handler.ts @@ -8,6 +8,8 @@ export abstract class Handler { this.#filterEntries = Object.entries(filter) as FilterEntries } + abstract handle(...args: unknown[]): unknown + protected filter(event: Event) { return ( !this.#filterEntries.length || diff --git a/packages/message-bus/src/dmg-bus/Broker/InterceptionHandler.ts b/packages/message-bus/src/dmg-bus/Broker/InterceptionHandler.ts index 255401544..c00017e25 100644 --- a/packages/message-bus/src/dmg-bus/Broker/InterceptionHandler.ts +++ b/packages/message-bus/src/dmg-bus/Broker/InterceptionHandler.ts @@ -19,7 +19,7 @@ export class InterceptionHandler extends Handler { this.#listener = listener } - handle(event: Event): InterceptionResult { + override handle(event: Event): InterceptionResult { if (this.filter(event)) return this.#listener(event as any) } } diff --git a/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts b/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts index 4bf398e52..ac6c7ad74 100644 --- a/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts +++ b/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts @@ -17,7 +17,7 @@ export class InvocationHandler extends Handler { this.#listener = listener } - handle( + override handle( event: Invocation, context: InvocationListenerContext>, ) { diff --git a/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts b/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts index 03fa872a5..9cc4021f0 100644 --- a/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts +++ b/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts @@ -16,6 +16,7 @@ import type { Filter } from '../Filter.js' import { getOrInsert } from '../lang/Map.js' import type { Broker } from './Broker.js' import { EventHandler } from './EventHandler.js' +import type { Handler } from './Handler.js' import { InterceptionHandler } from './InterceptionHandler.js' import { InvocationHandler } from './InvocationHandler.js' @@ -101,23 +102,14 @@ export class PluginBroker { filterOrEventListener: Filter | EventListener, eventListener?: EventListener, ): () => void { - const filter = (eventListener ? filterOrEventListener : {}) as Filter - eventListener ??= filterOrEventListener as EventListener - - const eventHandlers = getOrInsert( + return this.#addListener( + (filter, listener) => new EventHandler(filter, listener), this.#broker.eventHandlers, + this.#broker.bus.on, eventClass, - new Set(), + filterOrEventListener, + eventListener, ) - const eventHandler = new EventHandler(filter, eventListener) - eventHandlers.add(eventHandler) - - const unregister = this.#broker.bus.on(this, eventClass) - return () => { - const eventHandlers = this.#broker.eventHandlers.get(eventClass) - eventHandlers?.delete(eventHandler) - if (!eventHandlers?.size) unregister() - } } once( @@ -176,23 +168,14 @@ export class PluginBroker { filterOrListener: Filter | InvocationListener, listener?: InvocationListener, ): () => void { - const filter = (listener ? filterOrListener : {}) as Filter - listener ??= filterOrListener as InvocationListener - - const handlers = getOrInsert( + return this.#addListener( + (filter, listener) => new InvocationHandler(filter, listener), this.#broker.invokeHandlers, + this.#broker.bus.register as any, eventClass, - new Set(), + filterOrListener, + listener, ) - const handler = new InvocationHandler(filter, listener) - handlers.add(handler) - - const unregister = this.#broker.bus.register(this, eventClass) - return () => { - const handlers = this.#broker.invokeHandlers.get(eventClass) - handlers?.delete(handler) - if (!handlers?.size) unregister() - } } intercept( @@ -211,23 +194,14 @@ export class PluginBroker { filterOrListener: Filter | InterceptionListener, listener?: InterceptionListener, ): () => void { - const filter = (listener ? filterOrListener : {}) as Filter - listener ??= filterOrListener as InterceptionListener - - const handlers = getOrInsert( + return this.#addListener( + (filter, listener) => new InterceptionHandler(filter, listener), this.#broker.interceptionHandlers, + this.#broker.bus.intercept, eventClass, - new Set(), + filterOrListener, + listener, ) - const handler = new InterceptionHandler(filter, listener) - handlers.add(handler) - - const unregister = this.#broker.bus.intercept(this, eventClass) - return () => { - const handlers = this.#broker.interceptionHandlers.get(eventClass) - handlers?.delete(handler) - if (!handlers?.size) unregister() - } } invoke>( @@ -299,4 +273,31 @@ export class PluginBroker { iterate: () => readableStream.values(), } } + + #addListener< + H extends Handler, + E extends EventClass, + F extends (...args: any[]) => any, + >( + createHandler: (filter: Filter, eventListener: F) => H, + registry: Map>, + subscribe: (broker: this, eventClass: EventClass) => () => void, + eventClass: EventClass, + filterOrEventListener: Filter | F, + eventListener?: F, + ) { + const filter = (eventListener ? filterOrEventListener : {}) as Filter + eventListener ??= filterOrEventListener as F + + const handlers = getOrInsert(registry, eventClass, new Set()) + const handler = createHandler(filter, eventListener) + handlers.add(handler) + + const unregister = subscribe(this, eventClass) + return () => { + const handlers = registry.get(eventClass) + handlers?.delete(handler) + if (!handlers?.size) unregister() + } + } } diff --git a/packages/message-bus/src/dmg-bus/Bus.ts b/packages/message-bus/src/dmg-bus/Bus.ts index aad8ac55b..ccc896513 100644 --- a/packages/message-bus/src/dmg-bus/Bus.ts +++ b/packages/message-bus/src/dmg-bus/Bus.ts @@ -32,7 +32,10 @@ export class Bus { return new PluginBroker(broker) } - on(broker: PluginBroker, eventClass: E): () => void { + on = ( + broker: PluginBroker, + eventClass: E, + ): (() => void) => { const eventBrokers = getOrInsert(this.#eventBrokers, eventClass, new Set()) eventBrokers.add(broker.name) return () => { @@ -58,10 +61,10 @@ export class Bus { return event } - register( + register = ( broker: PluginBroker, eventClass: InvocationClass, - ): () => void { + ): (() => void) => { const invokeBrokers = getOrInsert( this.#invokeBrokers, eventClass, @@ -73,7 +76,7 @@ export class Bus { } } - intercept(broker: PluginBroker, eventClass: EventClass): () => void { + intercept = (broker: PluginBroker, eventClass: EventClass): (() => void) => { const interceptBrokers = getOrInsert( this.#interceptBrokers, eventClass, From 0a067480f81cf465cee6a97249a0dd1984f3e128 Mon Sep 17 00:00:00 2001 From: John Wright Date: Thu, 17 Sep 2026 08:39:38 +0100 Subject: [PATCH 10/18] refactor: simplify handler class We only need the one. --- .../message-bus/src/dmg-bus/Broker/Broker.ts | 15 ++-- .../src/dmg-bus/Broker/EventHandler.ts | 17 ---- .../message-bus/src/dmg-bus/Broker/Handler.ts | 15 +++- .../src/dmg-bus/Broker/InterceptionHandler.ts | 25 ------ .../src/dmg-bus/Broker/InvocationHandler.ts | 26 ------ .../src/dmg-bus/Broker/PluginBroker.ts | 69 ++++++-------- packages/message-bus/src/dmg-bus/Bus.ts | 89 ++++++++++--------- .../src/dmg-bus/Queue/MethodQueue.ts | 4 +- .../message-bus/src/dmg-bus/lang/Error.ts | 3 - 9 files changed, 97 insertions(+), 166 deletions(-) delete mode 100644 packages/message-bus/src/dmg-bus/Broker/EventHandler.ts delete mode 100644 packages/message-bus/src/dmg-bus/Broker/InterceptionHandler.ts delete mode 100644 packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts delete mode 100644 packages/message-bus/src/dmg-bus/lang/Error.ts diff --git a/packages/message-bus/src/dmg-bus/Broker/Broker.ts b/packages/message-bus/src/dmg-bus/Broker/Broker.ts index c78477619..6ff4bb60e 100644 --- a/packages/message-bus/src/dmg-bus/Broker/Broker.ts +++ b/packages/message-bus/src/dmg-bus/Broker/Broker.ts @@ -8,9 +8,8 @@ import type { import { CANCEL, type InvocationListenerContext } from '../EventListener.js' import { withCounter } from '../lang/Function.js' import { MethodQueue } from '../Queue/MethodQueue.js' -import type { EventHandler } from './EventHandler.js' -import type { InterceptionHandler } from './InterceptionHandler.js' -import type { InvocationHandler } from './InvocationHandler.js' +import { Handler } from './Handler.js' +import { PluginBroker } from './PluginBroker.js' /** * The full broker. Only the Bus and internal machinery ever hold one; plugins @@ -22,16 +21,16 @@ import type { InvocationHandler } from './InvocationHandler.js' * two can't collapse into one object. */ export class Broker { - readonly eventHandlers = new Map>() + readonly eventHandlers = new Map>>() readonly invokeHandlers = new Map< InvocationClass, - Set + Set>> >() readonly interceptionHandlers = new Map< EventClass, - Set + Set> >() readonly queue = new MethodQueue() @@ -119,4 +118,8 @@ export class Broker { abort(reason?: unknown) { this.#abortController.abort(reason) } + + createPluginFacade() { + return new PluginBroker(this) + } } diff --git a/packages/message-bus/src/dmg-bus/Broker/EventHandler.ts b/packages/message-bus/src/dmg-bus/Broker/EventHandler.ts deleted file mode 100644 index 30d081788..000000000 --- a/packages/message-bus/src/dmg-bus/Broker/EventHandler.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Event, EventClass } from '../Event.js' -import type { EventListener } from '../EventListener.js' -import type { Filter } from '../Filter.js' -import { Handler } from './Handler.js' - -export class EventHandler extends Handler { - readonly #listener: EventListener - - constructor(filter: Filter, listener: EventListener) { - super(filter) - this.#listener = listener - } - - override handle(event: Event) { - if (this.filter(event)) this.#listener(event) - } -} diff --git a/packages/message-bus/src/dmg-bus/Broker/Handler.ts b/packages/message-bus/src/dmg-bus/Broker/Handler.ts index 913572e7f..1b58d87d3 100644 --- a/packages/message-bus/src/dmg-bus/Broker/Handler.ts +++ b/packages/message-bus/src/dmg-bus/Broker/Handler.ts @@ -1,16 +1,23 @@ import type { Event, EventClass } from '../Event.js' import type { Filter, FilterEntries, FilterPredicate } from '../Filter.js' -export abstract class Handler { +export class Handler { readonly #filterEntries: FilterEntries + readonly #listener: (event: E, ...args: unknown[]) => R - constructor(filter: Filter) { + constructor( + filter: Filter, + listener: (event: E, ...args: unknown[]) => R, + ) { this.#filterEntries = Object.entries(filter) as FilterEntries + this.#listener = listener } - abstract handle(...args: unknown[]): unknown + handle(event: E, ...args: unknown[]): R | void { + if (this.#filter(event)) return this.#listener(event, ...args) + } - protected filter(event: Event) { + #filter(event: E) { return ( !this.#filterEntries.length || this.#filterEntries.some(([key, value]) => diff --git a/packages/message-bus/src/dmg-bus/Broker/InterceptionHandler.ts b/packages/message-bus/src/dmg-bus/Broker/InterceptionHandler.ts deleted file mode 100644 index c00017e25..000000000 --- a/packages/message-bus/src/dmg-bus/Broker/InterceptionHandler.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Event, EventClass, InvocationClass } from '../Event.js' -import type { - InterceptionListener, - InterceptionResult, -} from '../EventListener.js' -import { Filter } from '../Filter.js' -import { Handler } from './Handler.js' - -export class InterceptionHandler extends Handler { - readonly #listener: InterceptionListener< - EventClass | InvocationClass - > - - constructor( - filter: Filter, - listener: InterceptionListener>, - ) { - super(filter) - this.#listener = listener - } - - override handle(event: Event): InterceptionResult { - if (this.filter(event)) return this.#listener(event as any) - } -} diff --git a/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts b/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts deleted file mode 100644 index ac6c7ad74..000000000 --- a/packages/message-bus/src/dmg-bus/Broker/InvocationHandler.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { EventClass, Invocation, InvocationClass } from '../Event.js' -import type { - InvocationListener, - InvocationListenerContext, -} from '../EventListener.js' -import type { Filter } from '../Filter.js' -import { Handler } from './Handler.js' - -export class InvocationHandler extends Handler { - readonly #listener: InvocationListener> - - constructor( - filter: Filter, - listener: InvocationListener>, - ) { - super(filter) - this.#listener = listener - } - - override handle( - event: Invocation, - context: InvocationListenerContext>, - ) { - if (this.filter(event)) this.#listener(event, context) - } -} diff --git a/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts b/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts index 9cc4021f0..73ecf86a7 100644 --- a/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts +++ b/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts @@ -15,10 +15,7 @@ import { import type { Filter } from '../Filter.js' import { getOrInsert } from '../lang/Map.js' import type { Broker } from './Broker.js' -import { EventHandler } from './EventHandler.js' -import type { Handler } from './Handler.js' -import { InterceptionHandler } from './InterceptionHandler.js' -import { InvocationHandler } from './InvocationHandler.js' +import { Handler } from './Handler.js' export class PluginBroker { readonly #broker: Broker @@ -30,27 +27,6 @@ export class PluginBroker { readonly emit: (event: E) => Promise - get name() { - return this.#broker.name - } - - get abortSignal(): AbortSignal { - return this.#broker.abortSignal - } - - get aborted() { - return this.abortSignal.aborted - } - - get abortReason() { - return this.abortSignal.reason - } - - onAbort(fn: (reason: any) => any) { - this.abortSignal.addEventListener('abort', fn) - return () => this.abortSignal.removeEventListener('abort', fn) - } - constructor(broker: Broker) { this.#broker = broker @@ -74,6 +50,27 @@ export class PluginBroker { ) } + get name() { + return this.#broker.name + } + + get abortSignal(): AbortSignal { + return this.#broker.abortSignal + } + + get aborted() { + return this.abortSignal.aborted + } + + get abortReason() { + return this.abortSignal.reason + } + + onAbort(fn: (reason: any) => any) { + this.abortSignal.addEventListener('abort', fn, { once: true }) + return () => this.abortSignal.removeEventListener('abort', fn) + } + start(name: string) { this.#broker.bus.start(name) } @@ -103,7 +100,6 @@ export class PluginBroker { eventListener?: EventListener, ): () => void { return this.#addListener( - (filter, listener) => new EventHandler(filter, listener), this.#broker.eventHandlers, this.#broker.bus.on, eventClass, @@ -169,9 +165,8 @@ export class PluginBroker { listener?: InvocationListener, ): () => void { return this.#addListener( - (filter, listener) => new InvocationHandler(filter, listener), this.#broker.invokeHandlers, - this.#broker.bus.register as any, + this.#broker.bus.register, eventClass, filterOrListener, listener, @@ -195,7 +190,6 @@ export class PluginBroker { listener?: InterceptionListener, ): () => void { return this.#addListener( - (filter, listener) => new InterceptionHandler(filter, listener), this.#broker.interceptionHandlers, this.#broker.bus.intercept, eventClass, @@ -274,23 +268,18 @@ export class PluginBroker { } } - #addListener< - H extends Handler, - E extends EventClass, - F extends (...args: any[]) => any, - >( - createHandler: (filter: Filter, eventListener: F) => H, - registry: Map>, - subscribe: (broker: this, eventClass: EventClass) => () => void, - eventClass: EventClass, - filterOrEventListener: Filter | F, + #addListener any>( + registry: Map, any>>>, + subscribe: (broker: this, eventClass: E) => () => void, + eventClass: E, + filterOrEventListener: Filter | F, eventListener?: F, ) { const filter = (eventListener ? filterOrEventListener : {}) as Filter eventListener ??= filterOrEventListener as F const handlers = getOrInsert(registry, eventClass, new Set()) - const handler = createHandler(filter, eventListener) + const handler = new Handler(filter, eventListener) handlers.add(handler) const unregister = subscribe(this, eventClass) diff --git a/packages/message-bus/src/dmg-bus/Bus.ts b/packages/message-bus/src/dmg-bus/Bus.ts index ccc896513..7a1631ca2 100644 --- a/packages/message-bus/src/dmg-bus/Bus.ts +++ b/packages/message-bus/src/dmg-bus/Bus.ts @@ -6,33 +6,15 @@ import { withCounter } from './lang/Function.js' import { getOrInsert } from './lang/Map.js' export class Bus { - #brokers = new Map() + readonly #brokers = new Map() - #eventBrokers = new Map>() + readonly #eventBrokers = new Map>() - #invokeBrokers = new Map, Set>() + readonly #invokeBrokers = new Map, Set>() - #interceptBrokers = new Map>() + readonly #interceptBrokers = new Map>() - broker(name: string) { - if (this.#brokers.has(name)) - throw new Error(`Broker "${name}" has already been registered`) - - const broker = new Broker(this, name) - this.#brokers.set(name, broker) - - broker.onAbort(() => { - this.#brokers.delete(name) - for (const brokerNames of this.#eventBrokers.values()) - brokerNames.delete(name) - for (const brokerNames of this.#invokeBrokers.values()) - brokerNames.delete(name) - }) - - return new PluginBroker(broker) - } - - on = ( + readonly on = ( broker: PluginBroker, eventClass: E, ): (() => void) => { @@ -43,25 +25,7 @@ export class Bus { } } - async emit(event: E): Promise { - const interceptorBrokers = - this.#interceptBrokers.get(event.constructor as EventClass) ?? [] - - for (const name of interceptorBrokers) { - const result = await this.#brokers.get(name)?.intercept(event) - if (result === CANCEL) return CANCEL - else if (result) event = result as E - } - - const eventBrokers = this.#eventBrokers.get(event.constructor as EventClass) - if (!eventBrokers?.size) return event - - for (const name of eventBrokers) this.#brokers.get(name)?.emit(event) - - return event - } - - register = ( + readonly register = ( broker: PluginBroker, eventClass: InvocationClass, ): (() => void) => { @@ -76,7 +40,10 @@ export class Bus { } } - intercept = (broker: PluginBroker, eventClass: EventClass): (() => void) => { + readonly intercept = ( + broker: PluginBroker, + eventClass: EventClass, + ): (() => void) => { const interceptBrokers = getOrInsert( this.#interceptBrokers, eventClass, @@ -88,6 +55,42 @@ export class Bus { } } + broker(name: string) { + if (this.#brokers.has(name)) + throw new Error(`Broker "${name}" has already been registered`) + + const broker = new Broker(this, name) + this.#brokers.set(name, broker) + + broker.onAbort(() => { + this.#brokers.delete(name) + for (const brokerNames of this.#eventBrokers.values()) + brokerNames.delete(name) + for (const brokerNames of this.#invokeBrokers.values()) + brokerNames.delete(name) + }) + + return broker.createPluginFacade() + } + + async emit(event: E): Promise { + const interceptorBrokers = + this.#interceptBrokers.get(event.constructor as EventClass) ?? [] + + for (const name of interceptorBrokers) { + const result = await this.#brokers.get(name)?.intercept(event) + if (result === CANCEL) return CANCEL + else if (result) event = result as E + } + + const eventBrokers = this.#eventBrokers.get(event.constructor as EventClass) + if (!eventBrokers?.size) return event + + for (const name of eventBrokers) this.#brokers.get(name)?.emit(event) + + return event + } + invoke( event: Invocation, context: { diff --git a/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts b/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts index a1666e6d1..d8c855a05 100644 --- a/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts +++ b/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts @@ -10,8 +10,8 @@ export class MethodQueue implements Runnable { const type = Symbol() const { promise, resolve } = Promise.withResolvers>() - this.#queue = this.#queue.addExec(type, (item: { args: unknown[] }) => { - resolve(method(...(item.args as Args)) as Awaited) + this.#queue = this.#queue.addExec(type, (item: { args: Args }) => { + resolve(method(...item.args) as Awaited) }) as any return (...args) => { diff --git a/packages/message-bus/src/dmg-bus/lang/Error.ts b/packages/message-bus/src/dmg-bus/lang/Error.ts deleted file mode 100644 index c8249d6b3..000000000 --- a/packages/message-bus/src/dmg-bus/lang/Error.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function isAbortError(error: unknown): error is Error { - return error instanceof Error && error.name === 'AbortError' -} From d594f8f186db41a1f77c4d0936a3fb44d0d73a34 Mon Sep 17 00:00:00 2001 From: John Wright Date: Thu, 17 Sep 2026 08:43:37 +0100 Subject: [PATCH 11/18] refactor: simplify types --- packages/message-bus/src/dmg-bus/Broker/Broker.ts | 12 +++--------- packages/message-bus/src/dmg-bus/Broker/Handler.ts | 8 ++++---- .../message-bus/src/dmg-bus/Broker/PluginBroker.ts | 2 +- packages/message-bus/src/dmg-bus/Event.ts | 6 ++++-- 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/packages/message-bus/src/dmg-bus/Broker/Broker.ts b/packages/message-bus/src/dmg-bus/Broker/Broker.ts index 6ff4bb60e..b09cd3af1 100644 --- a/packages/message-bus/src/dmg-bus/Broker/Broker.ts +++ b/packages/message-bus/src/dmg-bus/Broker/Broker.ts @@ -23,15 +23,9 @@ import { PluginBroker } from './PluginBroker.js' export class Broker { readonly eventHandlers = new Map>>() - readonly invokeHandlers = new Map< - InvocationClass, - Set>> - >() + readonly invokeHandlers = new Map>>() - readonly interceptionHandlers = new Map< - EventClass, - Set> - >() + readonly interceptionHandlers = new Map>>() readonly queue = new MethodQueue() @@ -65,7 +59,7 @@ export class Broker { return () => this.abortSignal.removeEventListener('abort', fn) } - async intercept>( + async intercept( event: InstanceType, ): Promise | typeof CANCEL> { if (!this.queue.running) return event diff --git a/packages/message-bus/src/dmg-bus/Broker/Handler.ts b/packages/message-bus/src/dmg-bus/Broker/Handler.ts index 1b58d87d3..eadd158d8 100644 --- a/packages/message-bus/src/dmg-bus/Broker/Handler.ts +++ b/packages/message-bus/src/dmg-bus/Broker/Handler.ts @@ -1,19 +1,19 @@ import type { Event, EventClass } from '../Event.js' import type { Filter, FilterEntries, FilterPredicate } from '../Filter.js' -export class Handler { +export class Handler { readonly #filterEntries: FilterEntries - readonly #listener: (event: E, ...args: unknown[]) => R + readonly #listener: (event: E, ...args: unknown[]) => any constructor( filter: Filter, - listener: (event: E, ...args: unknown[]) => R, + listener: (event: E, ...args: unknown[]) => any, ) { this.#filterEntries = Object.entries(filter) as FilterEntries this.#listener = listener } - handle(event: E, ...args: unknown[]): R | void { + handle(event: E, ...args: unknown[]): any | void { if (this.#filter(event)) return this.#listener(event, ...args) } diff --git a/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts b/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts index 73ecf86a7..05393fbed 100644 --- a/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts +++ b/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts @@ -269,7 +269,7 @@ export class PluginBroker { } #addListener any>( - registry: Map, any>>>, + registry: Map>>>, subscribe: (broker: this, eventClass: E) => () => void, eventClass: E, filterOrEventListener: Filter | F, diff --git a/packages/message-bus/src/dmg-bus/Event.ts b/packages/message-bus/src/dmg-bus/Event.ts index 3dc142ad5..e00d52022 100644 --- a/packages/message-bus/src/dmg-bus/Event.ts +++ b/packages/message-bus/src/dmg-bus/Event.ts @@ -6,12 +6,14 @@ export type EventClass = abstract new ( ...args: any[] ) => E -export abstract class Invocation implements Event { +export abstract class Invocation implements Event { abstract $name: string declare $invocationType: T } -export type InvocationClass = abstract new (...args: any) => Invocation +export type InvocationClass = abstract new ( + ...args: any +) => Invocation export type InvocationType | InvocationClass> = E extends Invocation From f4ff39f24d38061bc2c35273d1546d82a527a75b Mon Sep 17 00:00:00 2001 From: John Wright Date: Thu, 17 Sep 2026 09:48:30 +0100 Subject: [PATCH 12/18] feat(message-bus)!: rewrite around class-based events and per-broker queues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the MessageBus API (string event names, tuple args, generators and RPC-style invokables) with a class-based bus. Events are plain class instances routed by constructor identity, so EventDescriptions are no longer needed. The invokable and generator APIs collapse into a single streaming register/invoke pair, and the RPC-style request/response model is removed. Each broker owns its own queue, so brokers can be paused and resumed independently. Also fixes flaws found while reviewing the rewrite, each covered by tests: - filters now AND their keys instead of OR - each emit() call resolves to its own result rather than a shared promise - invoke completion is the settling of each handler, not a finish() count, so a filtered-out or negligent handler can no longer hang or truncate the stream - invoke gains an error channel: fail-loud by default, isolate with { onError } - interceptor routing is cleaned up when a broker aborts - queueMethod no longer resets a running queue BREAKING CHANGE: the entire public API of @plugola/message-bus has changed. - `MessageBus` is replaced by `Bus`; `bus.broker(name)` returns a `PluginBroker`. - Events are classes implementing `Event` (or extending `Invocation`) and are subscribed to by class, not string name. `EventDescription`s are removed. - Invokables and generators are unified into a streaming API: register a handler with `broker.register(Invocation, (event, { send, signal }) => ...)` and call `broker.invoke(new Invocation()).collect()` / `.iterate()`. There is no `finish()` — a handler completes when it returns or its promise settles. The RPC-style request/response model is removed. - Invocation errors are fail-loud by default; pass `{ onError }` to `invoke()` to isolate handlers and keep the stream running. - Lifecycle methods are renamed: `start`/`stop` are now `resume`/`pause` on both `Bus` and `PluginBroker`. - `CancelEvent` is renamed to `CANCEL`. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/message-bus/README.md | 222 +++++-- packages/message-bus/src/AbortController.ts | 25 - packages/message-bus/src/Broker.ts | 237 ------- .../src/{dmg-bus => }/Broker/Broker.ts | 46 +- .../src/{dmg-bus => }/Broker/Handler.ts | 11 +- .../src/{dmg-bus => }/Broker/PluginBroker.ts | 174 ++++- packages/message-bus/src/Bus.ts | 208 ++++++ packages/message-bus/src/Event.ts | 64 ++ packages/message-bus/src/EventListener.ts | 81 +++ packages/message-bus/src/Filter.ts | 36 + packages/message-bus/src/MessageBus.ts | 617 ------------------ packages/message-bus/src/MessageBusError.ts | 11 - .../{dmg-bus => }/Queue/DiscriminatedQueue.ts | 22 +- packages/message-bus/src/Queue/MethodQueue.ts | 46 ++ .../src/{dmg-bus => }/Queue/Queue.ts | 0 .../src/{dmg-bus => }/Queue/Runnable.ts | 0 .../message-bus/src/SubscriptionDisposer.ts | 15 - packages/message-bus/src/array.ts | 75 --- packages/message-bus/src/dmg-bus/Bus.ts | 134 ---- packages/message-bus/src/dmg-bus/Event.ts | 23 - .../message-bus/src/dmg-bus/EventListener.ts | 30 - packages/message-bus/src/dmg-bus/Filter.ts | 20 - .../src/dmg-bus/Queue/MethodQueue.ts | 34 - .../src/dmg-bus/Queue/queue.test.ts | 23 - .../message-bus/src/dmg-bus/lang/Function.ts | 17 - .../message-bus/src/dmg-bus/lang/Signal.ts | 24 - .../src/errors/InvokableNotRegisteredError.ts | 23 - packages/message-bus/src/errors/index.ts | 1 - packages/message-bus/src/index.ts | 15 +- .../message-bus/src/{dmg-bus => }/lang/Map.ts | 0 packages/message-bus/src/matcher.ts | 86 --- packages/message-bus/src/object.ts | 12 - packages/message-bus/src/symbols.ts | 1 - packages/message-bus/src/types/MessageBus.ts | 41 -- packages/message-bus/src/types/events.ts | 131 ---- packages/message-bus/src/types/generators.ts | 47 -- packages/message-bus/src/types/invokables.ts | 82 --- packages/message-bus/src/types/streams.ts | 60 -- packages/message-bus/src/types/util.ts | 7 - packages/message-bus/test/MessageBus.test.ts | 545 ---------------- .../message-bus/test/broker-lifecycle.test.ts | 275 ++++++++ .../dmg-bus/Broker => test}/broker.test.ts | 48 +- packages/message-bus/test/filter.test.ts | 74 +++ packages/message-bus/test/queue.test.ts | 97 +++ packages/message-bus/tsconfig.json | 3 +- 45 files changed, 1266 insertions(+), 2477 deletions(-) delete mode 100644 packages/message-bus/src/AbortController.ts delete mode 100644 packages/message-bus/src/Broker.ts rename packages/message-bus/src/{dmg-bus => }/Broker/Broker.ts (70%) rename packages/message-bus/src/{dmg-bus => }/Broker/Handler.ts (73%) rename packages/message-bus/src/{dmg-bus => }/Broker/PluginBroker.ts (52%) create mode 100644 packages/message-bus/src/Bus.ts create mode 100644 packages/message-bus/src/Event.ts create mode 100644 packages/message-bus/src/EventListener.ts create mode 100644 packages/message-bus/src/Filter.ts delete mode 100644 packages/message-bus/src/MessageBus.ts delete mode 100644 packages/message-bus/src/MessageBusError.ts rename packages/message-bus/src/{dmg-bus => }/Queue/DiscriminatedQueue.ts (53%) create mode 100644 packages/message-bus/src/Queue/MethodQueue.ts rename packages/message-bus/src/{dmg-bus => }/Queue/Queue.ts (100%) rename packages/message-bus/src/{dmg-bus => }/Queue/Runnable.ts (100%) delete mode 100644 packages/message-bus/src/SubscriptionDisposer.ts delete mode 100644 packages/message-bus/src/array.ts delete mode 100644 packages/message-bus/src/dmg-bus/Bus.ts delete mode 100644 packages/message-bus/src/dmg-bus/Event.ts delete mode 100644 packages/message-bus/src/dmg-bus/EventListener.ts delete mode 100644 packages/message-bus/src/dmg-bus/Filter.ts delete mode 100644 packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts delete mode 100644 packages/message-bus/src/dmg-bus/Queue/queue.test.ts delete mode 100644 packages/message-bus/src/dmg-bus/lang/Function.ts delete mode 100644 packages/message-bus/src/dmg-bus/lang/Signal.ts delete mode 100644 packages/message-bus/src/errors/InvokableNotRegisteredError.ts delete mode 100644 packages/message-bus/src/errors/index.ts rename packages/message-bus/src/{dmg-bus => }/lang/Map.ts (100%) delete mode 100644 packages/message-bus/src/matcher.ts delete mode 100644 packages/message-bus/src/object.ts delete mode 100644 packages/message-bus/src/symbols.ts delete mode 100644 packages/message-bus/src/types/MessageBus.ts delete mode 100644 packages/message-bus/src/types/events.ts delete mode 100644 packages/message-bus/src/types/generators.ts delete mode 100644 packages/message-bus/src/types/invokables.ts delete mode 100644 packages/message-bus/src/types/streams.ts delete mode 100644 packages/message-bus/src/types/util.ts delete mode 100644 packages/message-bus/test/MessageBus.test.ts create mode 100644 packages/message-bus/test/broker-lifecycle.test.ts rename packages/message-bus/{src/dmg-bus/Broker => test}/broker.test.ts (80%) create mode 100644 packages/message-bus/test/filter.test.ts create mode 100644 packages/message-bus/test/queue.test.ts diff --git a/packages/message-bus/README.md b/packages/message-bus/README.md index b0b9bcefc..4741b52c0 100644 --- a/packages/message-bus/README.md +++ b/packages/message-bus/README.md @@ -1,122 +1,210 @@ # @plugola/message-bus -Plugola event management +A small, class-based message bus for plugin systems. -## Examples +Participants each get a **broker**. Brokers publish and subscribe to **events** +(plain class instances), transform them in flight with **interceptors**, and use +the **invoke** pattern to ask other brokers for a stream of values. Each broker +owns its own queue, so any broker can be paused and resumed independently. -### Simple Event Handling +## Concepts at a glance + +- **`Bus`** — the hub the host owns. It hands out brokers and controls their + lifecycle (`start` / `stop` / `abort`). +- **`PluginBroker`** — what `bus.broker(name)` returns; the object a participant + actually uses. +- **Event** — any class instance with a `$name`. Routing is by _class_, not by + `$name` (which is just a label for logging). +- **Invocation** — an event that also collects a stream of values back from its + handlers. + +## Getting started ```typescript -const messageBus = new MessageBus< - { - foo: [string] - bar: [number, number] - }, - {}, - {} ->() +import { Bus, Event } from '@plugola/message-bus' -messageBus.start() +class Greeting implements Event { + readonly $name = 'greeting' + constructor(readonly text: string) {} +} -const broker = messageBus.broker('broker name') +const bus = new Bus() +const alice = bus.broker('alice') +const bob = bus.broker('bob') -broker.on('foo', (str) => { - console.info(str) +alice.on(Greeting, (event) => { + console.info(`alice heard: ${event.text}`) }) -broker.on('bar', (num1, num2) => { - console.info(num1 + num2) -}) +bus.resume() + +bob.emit(new Greeting('hello world')) +``` + +Every subscription returns a disposer: -broker.emit('foo', 'hello world') -broker.emit('bar', 1, 2) +```typescript +const off = alice.on(Greeting, handler) +off() // unsubscribe ``` -### Queued Events +## Paused until resumed -Events are queued until, you call `messageBus.start()`. +Brokers begin paused, so nothing is delivered until the bus is resumed — it's +safe to wire up subscriptions first. Messages emitted while a broker is paused +are buffered and replayed when it resumes. ```typescript -const messageBus = new MessageBus<{ foo: [string] }, {}, {}>() -const broker = messageBus.broker('my broker') +const bus = new Bus() +const broker = bus.broker('my-broker') -broker.on('foo', () => - console.info("I'll log once messageBus.start() is called") -) -broker.emit('foo') +broker.on(Greeting, () => console.info("I'll run once the bus is resumed")) +broker.emit(new Greeting('hi')) -messageBus.start() +bus.resume() ``` -### Subcription Specificity +## Filtering subscriptions -You can narrow down you subsciption by the arguments emitted. +Pass a filter to narrow a subscription to the events you care about. A filter is +a partial map of the event's properties; each value is either an expected value +(compared with `===`) or a predicate. **All** listed keys must match. ```typescript -const messageBus = new MessageBus<{ foo: [string, string] }, {}, {}>() -const broker = messageBus.broker('my broker') - -messageBus.start() +class Order implements Event { + readonly $name = 'order' + constructor( + readonly status: 'pending' | 'paid', + readonly total: number, + ) {} +} -broker.on('foo', 'bar', 'bazzle', () => { - console.info('I only care when foo is emitted with "bar" and "bazzle"') +broker.on(Order, { status: 'paid', total: (o) => o.total > 100 }, (order) => { + console.info('big paid order', order) }) -broker.emit('foo', 'rab', 'elzzab') // no subscriptions registered for this event -broker.emit('foo', 'bar', 'bazzle') +broker.emit(new Order('pending', 500)) // ignored — status doesn't match +broker.emit(new Order('paid', 50)) // ignored — total predicate fails +broker.emit(new Order('paid', 500)) // delivered ``` -### Event Interception +Filters work with `on`, `once`, `until`, `register`, and `intercept`. -You can intercept messages to modify their arguments. +## Waiting for an event + +`until` resolves with the next matching event, and rejects if the broker is +aborted while waiting. ```typescript -const messageBus = new MessageBus<{ foo: [string] }, {}, {}>() -const broker = messageBus.broker('my broker') +const order = await broker.until(Order, { status: 'paid' }) +``` -messageBus.start() +## Interception -broker.intercept('foo', (str) => [str.reverse()]) +Interceptors run before listeners and can transform or cancel an event. Return a +new event to replace it, `CANCEL` to drop it, or nothing to leave it unchanged. +Interceptors across brokers form a chain, each seeing the previous one's result. -broker.on('foo', console.info) // rab +```typescript +broker.intercept(Greeting, (event) => new Greeting(event.text.toUpperCase())) -broker.emit('foo', 'bar') +broker.on(Greeting, (e) => console.info(e.text)) // "HELLO" + +await broker.emit(new Greeting('hello')) ``` -You can also cancel the event completely. +Cancelling stops the event completely: ```typescript -import { CancelEvent } from '@plugpola/message-bus' +import { CANCEL } from '@plugola/message-bus' -broker.intercept('foo', () => CancelEvent) +broker.intercept(Greeting, () => CANCEL) -broker.emit('foo') // no subscriptions will get called +const result = await broker.emit(new Greeting('hello')) +// result === CANCEL, and no listeners were called ``` -### Event Generators +## Invocations (request / stream) -You can register any number of "generators" to an event. +An `Invocation` is an event whose registered handlers stream values back. Any +number of brokers can register for the same invocation; the caller collects the +values from all of them. ```typescript -const messageBus = new MessageBus< - {}, - { foo: { args: [string]; yield: [string] } }, - {} ->() +import { Bus, Invocation } from '@plugola/message-bus' -const broker = messageBus.broker('my broker') +class ListFiles extends Invocation { + readonly $name = 'list-files' + constructor(readonly dir: string) { + super() + } +} -messageBus.start() +const bus = new Bus() +const fs = bus.broker('fs') +const app = bus.broker('app') +bus.resume() -broker.generator('foo', async function* (thing) { - yield `${thing} is a thing` +fs.register(ListFiles, async (event, { send, signal }) => { + for (const file of await readdir(event.dir, { signal })) send(file) }) -broker.generator('foo', 'bar', async function* (thing) { - yield 'Bars are great' -}) +// collect everything into an array… +const files = await app.invoke(new ListFiles('/tmp')).collect() -for await (const item of broker.iterate('foo', 'bar')) { - // item = "bar is a thing" | "Bars are great" +// …or consume as a stream +for await (const file of app.invoke(new ListFiles('/tmp')).iterate()) { + console.info(file) } ``` + +A handler is done when its function returns (or its returned promise settles) — +there is no `finish()` to call. The invocation completes once every handler +across every broker has settled. + +Because an invocation is emitted as an event before it is invoked, it can also be +filtered and intercepted like any other event. + +### Cancelling and errors + +Pass a `signal` to cancel; it is forwarded to handlers so they can abort +in-flight work. By default an unobserved handler error is fail-loud — it rejects +the stream. Pass `onError` to isolate handlers instead: errors are reported there +and the stream still completes with the healthy handlers' values. + +```typescript +const files = await app + .invoke(new ListFiles('/tmp'), { + signal: AbortSignal.timeout(1_000), + onError: (error) => console.warn('a file source failed', error), + }) + .collect() +``` + +## Pausing and lifecycle + +Each broker has its own queue, so brokers pause independently. Lifecycle methods +address brokers by name, so one participant can control another's. + +```typescript +broker.pause('worker') // buffer the worker's messages +broker.resume('worker') // resume and drain them + +broker.abort('worker') // permanent teardown; frees the name for reuse +``` + +The host can also drive every broker at once through the bus: + +```typescript +bus.pause() // pause all +bus.resume() // resume all +``` + +A broker exposes its abort state for cleanup: + +```typescript +broker.onAbort((reason) => cleanup(reason)) +broker.aborted // boolean +broker.abortReason // the reason, if any +broker.abortSignal // the underlying AbortSignal +``` diff --git a/packages/message-bus/src/AbortController.ts b/packages/message-bus/src/AbortController.ts deleted file mode 100644 index 1187b7526..000000000 --- a/packages/message-bus/src/AbortController.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Creates a new AbortController and pipes an abort - * signal into it. - */ -export function fromSignal(abortSignal: AbortSignal) { - const abortController = new AbortController() - - if (abortSignal.aborted) { - abortController.abort() - } else { - abortSignal.addEventListener('abort', () => abortController.abort(), { - once: true, - }) - } - - return abortController -} - -/** - * Monitor multiple AbortSignals using the same interface as - * a single AbortSignal - */ -export function anySignal(...abortSignals: Array) { - return AbortSignal.any(abortSignals.filter((x) => !!x)) -} diff --git a/packages/message-bus/src/Broker.ts b/packages/message-bus/src/Broker.ts deleted file mode 100644 index c80ac6dda..000000000 --- a/packages/message-bus/src/Broker.ts +++ /dev/null @@ -1,237 +0,0 @@ -import type MessageBus from './MessageBus.js' -import SubscriptionDisposer from './SubscriptionDisposer.js' -import type { - EventInterceptorArgs, - SubscriberArgs, - UntilArgs, - UntilRtn, -} from './types/events.js' -import type { EventGeneratorArgs } from './types/generators.js' -import type { - InvokerInterceptorArgs, - MatchableInvokerRegistrationArgs, -} from './types/invokables.js' -import { - ErrorHandler, - MessageBusContext, - Unsubscriber, -} from './types/MessageBus.js' -import { StreamReaderArgs } from './types/streams.js' - -export default class Broker<$ extends MessageBusContext = MessageBusContext> { - constructor( - messageBus: MessageBus<$>, - id: string, - abortController: AbortController, - ) { - this.#abortController = abortController - this.#id = id - this.#messageBus = messageBus - this.onAbort(() => this.#disposer.dispose()) - } - - readonly #abortController: AbortController - get abortController() { - return this.#abortController - } - - get aborted() { - return this.abortSignal.aborted - } - - get abortSignal(): AbortSignal { - return this.#abortController.signal - } - - readonly #disposer = new SubscriptionDisposer() - get disposer() { - return this.#disposer - } - - readonly #id: string - get id() { - return this.#id - } - - readonly #messageBus: MessageBus<$> - get messageBus() { - return this.#messageBus - } - - readonly onAbort = (fn: () => any) => { - this.abortSignal.addEventListener('abort', fn) - } - - abort() { - this.#abortController.abort() - } - - onError(errorHandler: ErrorHandler) { - return this.#messageBus.onError((error) => { - if (error.brokerId === this.id) { - errorHandler(error) - } - }) - } - - emit( - eventName: EventName, - ...args: $['events'][EventName] - ): void | Promise { - return this.#messageBus.emit(this, eventName, args) - } - - emitSignal( - eventName: EventName, - signal: AbortSignal, - ...args: $['events'][EventName] - ): void | Promise { - return this.#messageBus.emit(this, eventName, args, signal) - } - - interceptEvent( - eventName: EventName, - ...args: EventInterceptorArgs<$['events'][EventName]> - ): Unsubscriber { - return this.#messageBus.interceptEvent(this as any, eventName, args) - } - - on( - eventName: EventName, - ...args: SubscriberArgs<$['events'][EventName]> - ): Unsubscriber { - return this.#messageBus.on(this, eventName, args) - } - - once( - eventName: EventName, - ...args: SubscriberArgs<$['events'][EventName]> - ): Unsubscriber { - return this.#messageBus.once(this, eventName, args) - } - - hasSubscriber(eventName: keyof $['events']) { - return this.#messageBus.hasSubscriber(eventName) - } - - async until< - EventName extends keyof $['events'], - Args extends UntilArgs<$['events'][EventName]>, - >( - eventName: EventName, - ...args: Args - ): Promise> { - return this.#messageBus.until(this, eventName, args) as any - } - - async untilSignal< - EventName extends keyof $['events'], - Args extends UntilArgs<$['events'][EventName]>, - >( - eventName: EventName, - abortSignal: AbortSignal, - ...args: Args - ): Promise> { - return this.#messageBus.until(this, eventName, args, abortSignal) as any - } - - generator( - eventName: EventName, - ...args: EventGeneratorArgs< - $['generators'][EventName]['args'], - $['generators'][EventName]['yield'] - > - ): Unsubscriber { - return this.#messageBus.generator(this, eventName, args) - } - - iterate( - eventName: EventName, - ...args: $['generators'][EventName]['args'] - ): AsyncIterable<$['generators'][EventName]['yield']> { - return this.#messageBus.iterate(this, eventName, args) - } - - iterateSignal( - eventName: EventName, - abortSignal: AbortSignal, - ...args: $['generators'][EventName]['args'] - ): AsyncIterable<$['generators'][EventName]['yield']> { - return this.#messageBus.iterate(this, eventName, args, abortSignal) - } - - iterateWithin( - within: number, - eventName: EventName, - ...args: $['generators'][EventName]['args'] - ): AsyncIterable<$['generators'][EventName]['yield']> { - return this.#messageBus.iterateWithin(this, within, eventName, args) - } - - accumulate( - eventName: EventName, - ...args: $['generators'][EventName]['args'] - ): Promise<$['generators'][EventName]['yield'][]> { - return this.#messageBus.accumulate(this, eventName, args) - } - - accumulateWithin( - within: number, - eventName: EventName, - ...args: $['generators'][EventName]['args'] - ): Promise<$['generators'][EventName]['yield'][]> { - return this.#messageBus.accumulateWithin(this, within, eventName, args) - } - - register( - invokableName: InvokableName, - ...args: MatchableInvokerRegistrationArgs< - $['invokables'][InvokableName]['args'], - $['invokables'][InvokableName]['return'] - > - ): Unsubscriber { - return this.#messageBus.register(this, invokableName, args) - } - - invoke( - invokableName: InvokableName, - ...args: $['invokables'][InvokableName]['args'] - ): Promise<$['invokables'][InvokableName]['return']> { - return this.#messageBus.invoke(this, invokableName, args) - } - - invokeSignal( - invokableName: InvokableName, - abortSignal: AbortSignal, - ...args: $['invokables'][InvokableName]['args'] - ): Promise<$['invokables'][InvokableName]['return']> { - return this.#messageBus.invoke(this, invokableName, args, abortSignal) - } - - interceptInvoker( - invokableName: InvokableName, - ...args: InvokerInterceptorArgs< - $['invokables'][InvokableName]['args'], - $['invokables'][InvokableName]['return'] - > - ): Unsubscriber { - return this.#messageBus.interceptInvoker(this, invokableName, args) - } - - reader( - streamName: StreamName, - ...args: StreamReaderArgs< - $['streams'][StreamName]['args'], - $['streams'][StreamName]['item'] - > - ): Unsubscriber { - return this.#messageBus.reader(this, streamName, args) - } - - stream( - streamName: StreamName, - ...args: $['streams'][StreamName]['args'] - ): ReadableStream<$['streams'][StreamName]['item']> { - return this.#messageBus.stream(this, streamName, args) - } -} diff --git a/packages/message-bus/src/dmg-bus/Broker/Broker.ts b/packages/message-bus/src/Broker/Broker.ts similarity index 70% rename from packages/message-bus/src/dmg-bus/Broker/Broker.ts rename to packages/message-bus/src/Broker/Broker.ts index b09cd3af1..9e3445e95 100644 --- a/packages/message-bus/src/dmg-bus/Broker/Broker.ts +++ b/packages/message-bus/src/Broker/Broker.ts @@ -5,8 +5,11 @@ import type { Invocation, InvocationClass, } from '../Event.js' -import { CANCEL, type InvocationListenerContext } from '../EventListener.js' -import { withCounter } from '../lang/Function.js' +import { + CANCEL, + type InvocationErrorHandler, + type InvocationListenerContext, +} from '../EventListener.js' import { MethodQueue } from '../Queue/MethodQueue.js' import { Handler } from './Handler.js' import { PluginBroker } from './PluginBroker.js' @@ -15,7 +18,7 @@ import { PluginBroker } from './PluginBroker.js' * The full broker. Only the Bus and internal machinery ever hold one; plugins * receive a PluginBroker facade instead. Owns the handler registries + queue, * and implements the *inbound* pipe: the bus delivering events/invocations - * into this broker's local handlers. Note `emit`/`invoke`/`start`/`stop`/`abort` + * into this broker's local handlers. Note `emit`/`invoke`/`pause`/`resume`/`abort` * here are the bus-driven direction — the identically named PluginBroker * methods are the opposite (plugin -> bus) direction, which is exactly why the * two can't collapse into one object. @@ -79,33 +82,44 @@ export class Broker { return event } - invoke( + /** + * Runs every local handler for the invocation and resolves once they have all + * settled. Completion is derived from the handlers' own return values: a + * filtered-out handler resolves immediately (its `handle` returns `undefined`), + * so there's no count to keep in sync and nothing to hang on. The handler set + * is snapshotted, so (un)registering during an in-flight invocation can't move + * the target. Handlers are isolated: one that throws is routed to + * `reportError` and neither stops its siblings nor prevents completion. + */ + async invoke( event: Invocation, context: InvocationListenerContext>, - ) { - if (!this.queue.running) return context.finish() + reportError: InvocationErrorHandler, + ): Promise { + if (!this.queue.running) return const invokeHandlers = this.invokeHandlers.get( event.constructor as InvocationClass, ) - if (!invokeHandlers?.size) return context.finish() + if (!invokeHandlers?.size) return - const handlerContext = { - ...context, - finish: withCounter((counter) => { - if (counter >= invokeHandlers.size) context.finish() + await Promise.all( + Array.from(invokeHandlers, async (handler) => { + try { + await handler.handle(event, context) + } catch (error) { + reportError(error) + } }), - } as InvocationListenerContext> - - for (const handler of invokeHandlers) handler.handle(event, handlerContext) + ) } - start() { + resume() { this.queue.start() } - stop() { + pause() { this.queue.stop() } diff --git a/packages/message-bus/src/dmg-bus/Broker/Handler.ts b/packages/message-bus/src/Broker/Handler.ts similarity index 73% rename from packages/message-bus/src/dmg-bus/Broker/Handler.ts rename to packages/message-bus/src/Broker/Handler.ts index eadd158d8..14d19ccfa 100644 --- a/packages/message-bus/src/dmg-bus/Broker/Handler.ts +++ b/packages/message-bus/src/Broker/Handler.ts @@ -18,13 +18,10 @@ export class Handler { } #filter(event: E) { - return ( - !this.#filterEntries.length || - this.#filterEntries.some(([key, value]) => - typeof value === 'function' - ? (value as FilterPredicate)(event) - : value === event[key], - ) + return this.#filterEntries.every(([key, value]) => + typeof value === 'function' + ? (value as FilterPredicate)(event) + : value === event[key], ) } } diff --git a/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts b/packages/message-bus/src/Broker/PluginBroker.ts similarity index 52% rename from packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts rename to packages/message-bus/src/Broker/PluginBroker.ts index 05393fbed..cd16000e9 100644 --- a/packages/message-bus/src/dmg-bus/Broker/PluginBroker.ts +++ b/packages/message-bus/src/Broker/PluginBroker.ts @@ -9,6 +9,7 @@ import { CANCEL, EventListener, InterceptionListener, + InvocationErrorHandler, InvocationListener, InvocationListenerContext, } from '../EventListener.js' @@ -17,14 +18,33 @@ import { getOrInsert } from '../lang/Map.js' import type { Broker } from './Broker.js' import { Handler } from './Handler.js' +/** + * A participant's handle on the {@link Bus} — what `bus.broker(name)` returns. + * Everything a plugin does flows through here: subscribing to events + * ({@link PluginBroker.on}, {@link PluginBroker.once}, {@link PluginBroker.until}), + * publishing them ({@link PluginBroker.emit}), transforming them in flight + * ({@link PluginBroker.intercept}), and the request/stream pattern of + * {@link PluginBroker.register} + {@link PluginBroker.invoke}. + * + * Outbound calls (`emit`, `invoke`) are queued through this broker's own queue, + * so while the broker is paused they buffer and replay when it resumes. Every + * subscription returns a disposer that removes it. + */ export class PluginBroker { readonly #broker: Broker readonly #invoke: >( event: InstanceType, params: InvocationListenerContext, - ) => void - + reportError: InvocationErrorHandler, + ) => Promise + + /** + * Publishes an event to the whole bus. Runs any interceptors first, then + * delivers to every broker subscribed to the event's class. Resolves to the + * event as it stood after interception, or {@link CANCEL} if it was cancelled. + * Queued through this broker, so it waits while the broker is paused. + */ readonly emit: (event: E) => Promise constructor(broker: Broker) { @@ -35,13 +55,13 @@ export class PluginBroker { event: Invocation, context: { send(value: any): void - finish(): void - signal?: AbortSignal + signal: AbortSignal }, + reportError: InvocationErrorHandler, ) => { const result = await this.#broker.bus.emit(event) if (result === CANCEL) return - this.#broker.bus.invoke(result, context) + await this.#broker.bus.invoke(result, context, reportError) }, ) @@ -50,44 +70,78 @@ export class PluginBroker { ) } + /** This broker's unique name on the bus. */ get name() { return this.#broker.name } + /** The signal that fires when this broker is aborted; useful for teardown. */ get abortSignal(): AbortSignal { return this.#broker.abortSignal } + /** Whether this broker has been aborted. */ get aborted() { return this.abortSignal.aborted } + /** The reason passed to the abort that tore this broker down, if any. */ get abortReason() { return this.abortSignal.reason } + /** + * Runs `fn` once, when this broker is aborted (immediately if it already has). + * Returns a disposer that removes the listener. + */ onAbort(fn: (reason: any) => any) { this.abortSignal.addEventListener('abort', fn, { once: true }) return () => this.abortSignal.removeEventListener('abort', fn) } - start(name: string) { - this.#broker.bus.start(name) + /** + * Resumes the named broker, draining its buffered messages. Brokers are + * addressed by name so one participant can control another's lifecycle. + */ + resume(name: string) { + this.#broker.bus.resume(name) } - stop(name: string) { - this.#broker.bus.stop(name) + /** + * Pauses the named broker: its queue stops draining and inbound messages + * buffer until {@link PluginBroker.resume}. Reversible. + */ + pause(name: string) { + this.#broker.bus.pause(name) } + /** + * Permanently aborts the named broker — clears its handlers and frees its + * name. Not reversible; use {@link PluginBroker.pause} to merely pause. + */ abort(name: string, reason?: any) { this.#broker.bus.abort(name, reason) } + /** + * Subscribes `eventListener` to every event of `eventClass`. + * + * @returns a disposer that removes the subscription. + * @example + * ```ts + * const off = broker.on(UserLoggedIn, (e) => console.log(e.userId)) + * off() // unsubscribe + * ``` + */ on( eventClass: E, eventListener: EventListener, ): () => void + /** + * Subscribes only to events matching `filter` (all keys must match — see + * {@link Filter}). + */ on( eventClass: E, filter: Filter, @@ -108,11 +162,18 @@ export class PluginBroker { ) } + /** + * Like {@link PluginBroker.on}, but the listener fires at most once and then + * unsubscribes itself. + * + * @returns a disposer, in case you want to cancel before it ever fires. + */ once( eventClass: E, eventListener: EventListener, ): () => void + /** Fires once for the first event matching `filter`, then unsubscribes. */ once( eventClass: E, filter: Filter, @@ -133,6 +194,15 @@ export class PluginBroker { return off } + /** + * Resolves with the next event of `eventClass` (optionally matching `filter`). + * Rejects with the abort reason if this broker is aborted while waiting. + * + * @example + * ```ts + * const ready = await broker.until(AppReady) + * ``` + */ until( eventClass: E, filter: Filter = {}, @@ -148,11 +218,27 @@ export class PluginBroker { }) } + /** + * Registers a handler that streams values back for an {@link Invocation}. Many + * brokers can register for the same invocation; a caller's + * {@link PluginBroker.invoke} collects the values from all of them. The + * invocation completes for this handler when the function returns (or its + * promise settles). + * + * @returns a disposer that removes the handler. + * @example + * ```ts + * broker.register(ListFiles, async (event, { send, signal }) => { + * for (const f of await readdir(event.dir, { signal })) send(f) + * }) + * ``` + */ register>( eventClass: E, listener: InvocationListener, ): () => void + /** Registers a handler only for invocations matching `filter`. */ register>( eventClass: E, filter: Filter, @@ -173,11 +259,22 @@ export class PluginBroker { ) } + /** + * Intercepts events of `eventClass` before their listeners run. The listener + * may return a replacement event, {@link CANCEL} to drop it, or nothing to + * leave it unchanged (see {@link InterceptionResult}). Interceptors across + * brokers form a chain, each seeing the previous one's result. Because an + * {@link Invocation} is emitted before it is invoked, intercepting its class + * transforms or cancels invocations too. + * + * @returns a disposer that removes the interceptor. + */ intercept( eventClass: E, listener: InterceptionListener, ): () => void + /** Intercepts only events matching `filter`. */ intercept( eventClass: E, filter: Filter, @@ -198,12 +295,36 @@ export class PluginBroker { ) } + /** + * Emits an invocation and streams back the values every registered handler + * `send`s. Nothing runs until you consume the result, via either + * `collect()` (a promise of all values) or `iterate()` (an async iterable that + * yields them as they arrive). The stream completes once every handler across + * every broker has settled. + * + * Errors are fail-loud by default: an unobserved handler error rejects the + * stream. Pass `onError` to isolate handlers instead — errors are reported + * there and the stream still completes with the healthy handlers' values. Pass + * `signal` to cancel; the same signal is forwarded to handlers so they can + * abort in-flight work. + * + * @example + * ```ts + * const files = await broker.invoke(new ListFiles('/tmp')).collect() + * + * for await (const file of broker.invoke(new ListFiles('/tmp')).iterate()) { + * console.log(file) + * } + * ``` + */ invoke>( event: E, { signal, + onError, }: { signal?: AbortSignal + onError?: InvocationErrorHandler } = {}, ): { collect(): Promise[]> @@ -216,6 +337,15 @@ export class PluginBroker { let settled = false let teardown = (_reason?: any) => {} + // Without an onError observer, an unobserved handler error rethrows and so + // surfaces on the stream (fail-loud). Supplying onError isolates handlers: + // errors are reported there and the stream still completes. + const reportError: InvocationErrorHandler = + onError ?? + ((error) => { + throw error + }) + const readableStream = new ReadableStream({ start: (controller) => { const offAbort = this.onAbort(abort) @@ -232,14 +362,20 @@ export class PluginBroker { if (this.aborted) return abort() if (signal?.aborted) return close() - this.#invoke(event, { - send: (value: unknown) => { - if (settled) return - controller.enqueue(value as T) + // Completion is the settling of #invoke's promise: it resolves once + // every handler across every broker has returned, and rejects if one + // of them threw. + this.#invoke( + event, + { + send: (value: unknown) => { + if (settled) return + controller.enqueue(value as T) + }, + signal: producer.signal, }, - finish: close, - signal: producer.signal, - }) + reportError, + ).then(close, fail) function close() { if (settled) return @@ -252,6 +388,12 @@ export class PluginBroker { teardown(self.abortReason) controller.error(self.abortReason) } + + function fail(error: unknown) { + if (settled) return + teardown(error) + controller.error(error) + } }, cancel: (reason) => teardown(reason), diff --git a/packages/message-bus/src/Bus.ts b/packages/message-bus/src/Bus.ts new file mode 100644 index 000000000..3c2dd2312 --- /dev/null +++ b/packages/message-bus/src/Bus.ts @@ -0,0 +1,208 @@ +import { Broker } from './Broker/Broker.js' +import { PluginBroker } from './Broker/PluginBroker.js' +import type { Event, EventClass, Invocation, InvocationClass } from './Event.js' +import { + CANCEL, + type InvocationErrorHandler, + type InvocationListenerContext, +} from './EventListener.js' +import { getOrInsert } from './lang/Map.js' + +/** + * The message bus: the shared hub the host owns. It hands out a + * {@link PluginBroker} per named participant, routes events / invocations / + * interceptions between them, and controls their lifecycle. + * + * Each broker has its own queue, so brokers can be paused and resumed + * independently. Brokers begin paused: nothing is delivered until the bus (or + * the individual broker) is resumed, so wiring up subscriptions before + * {@link Bus.resume} is safe. + * + * @example + * ```ts + * const bus = new Bus() + * const a = bus.broker('a') + * const b = bus.broker('b') + * a.on(Ping, () => console.log('pong')) + * bus.resume() + * b.emit(new Ping()) + * ``` + */ +export class Bus { + readonly #brokers = new Map() + + readonly #eventBrokers = new Map>() + + readonly #invokeBrokers = new Map, Set>() + + readonly #interceptBrokers = new Map>() + + /** + * Records that a broker is interested in an event class so {@link Bus.emit} + * knows to route to it. Called by the broker facade when a listener is added; + * returns a disposer that drops the routing entry. + * @internal + */ + readonly on = ( + broker: PluginBroker, + eventClass: E, + ): (() => void) => { + const eventBrokers = getOrInsert(this.#eventBrokers, eventClass, new Set()) + eventBrokers.add(broker.name) + return () => { + this.#eventBrokers.get(eventClass)?.delete(broker.name) + } + } + + /** + * Records that a broker registered a handler for an invocation class so + * {@link Bus.invoke} routes to it. Returns a disposer. + * @internal + */ + readonly register = ( + broker: PluginBroker, + eventClass: InvocationClass, + ): (() => void) => { + const invokeBrokers = getOrInsert( + this.#invokeBrokers, + eventClass, + new Set(), + ) + invokeBrokers.add(broker.name) + return () => { + this.#invokeBrokers.get(eventClass)?.delete(broker.name) + } + } + + /** + * Records that a broker wants to intercept an event class so {@link Bus.emit} + * runs it through that broker before delivery. Returns a disposer. + * @internal + */ + readonly intercept = ( + broker: PluginBroker, + eventClass: EventClass, + ): (() => void) => { + const interceptBrokers = getOrInsert( + this.#interceptBrokers, + eventClass, + new Set(), + ) + interceptBrokers.add(broker.name) + return () => { + this.#interceptBrokers.get(eventClass)?.delete(broker.name) + } + } + + /** + * Creates a broker under `name` and returns its {@link PluginBroker} facade — + * the object a participant uses to subscribe, emit, and invoke. Names are + * unique for the bus's lifetime; a name frees up again once its broker is + * aborted. + * + * @throws if a broker with `name` is already registered. + */ + broker(name: string) { + if (this.#brokers.has(name)) + throw new Error(`Broker "${name}" has already been registered`) + + const broker = new Broker(this, name) + this.#brokers.set(name, broker) + + broker.onAbort(() => { + this.#brokers.delete(name) + for (const routes of [ + this.#eventBrokers, + this.#invokeBrokers, + this.#interceptBrokers, + ]) + for (const brokerNames of routes.values()) brokerNames.delete(name) + }) + + return broker.createPluginFacade() + } + + /** + * Runs the event through every registered interceptor (in chain order) and + * then delivers the final event to every subscribed broker. Resolves to the + * event as it stood after interception, or {@link CANCEL} if an interceptor + * cancelled it. Prefer `broker.emit`, which queues through the sender's broker; + * this is the bus-level primitive it calls. + * @internal + */ + async emit(event: E): Promise { + const interceptorBrokers = + this.#interceptBrokers.get(event.constructor as EventClass) ?? [] + + for (const name of interceptorBrokers) { + const result = await this.#brokers.get(name)?.intercept(event) + if (result === CANCEL) return CANCEL + else if (result) event = result as E + } + + const eventBrokers = this.#eventBrokers.get(event.constructor as EventClass) + if (!eventBrokers?.size) return event + + for (const name of eventBrokers) this.#brokers.get(name)?.emit(event) + + return event + } + + /** + * Fans an invocation out to every broker that registered for it and resolves + * once they have all settled. The broker set is snapshotted so a broker + * (un)registering mid-flight can't move the target; a broker that has since + * been removed simply contributes a resolved `undefined`. Prefer + * `broker.invoke`, which wraps this in a stream; this is the bus-level + * primitive it calls. + * @internal + */ + async invoke( + event: Invocation, + context: InvocationListenerContext>, + reportError: InvocationErrorHandler, + ): Promise { + const invokeBrokers = this.#invokeBrokers.get( + event.constructor as InvocationClass, + ) + + if (!invokeBrokers?.size) return + + await Promise.all( + Array.from(invokeBrokers, (name) => + this.#brokers.get(name)?.invoke(event, context, reportError), + ), + ) + } + + /** + * Permanently tears down the named broker: fires its abort signal, clears its + * handlers, and removes it from all routing so its name can be reused. Unlike + * {@link Bus.pause}, this cannot be undone. + */ + abort(name: string, reason?: Error) { + this.#brokers.get(name)?.abort(reason) + } + + /** + * Resumes brokers so queued and future messages are delivered. Brokers begin + * paused, so this is also how you first bring the bus to life. With a `name`, + * resumes just that broker; with no argument, resumes every broker on the bus. + */ + resume(name?: string) { + if (name === undefined) + for (const broker of this.#brokers.values()) broker.resume() + else this.#brokers.get(name)?.resume() + } + + /** + * Pauses brokers: their queues stop draining and inbound messages buffer until + * resumed. With a `name`, pauses just that broker; with no argument, pauses + * every broker on the bus. Reversible via {@link Bus.resume}. + */ + pause(name?: string) { + if (name === undefined) + for (const broker of this.#brokers.values()) broker.pause() + else this.#brokers.get(name)?.pause() + } +} diff --git a/packages/message-bus/src/Event.ts b/packages/message-bus/src/Event.ts new file mode 100644 index 000000000..5515f986d --- /dev/null +++ b/packages/message-bus/src/Event.ts @@ -0,0 +1,64 @@ +/** + * The shape every message on the bus shares. Messages are plain class instances + * — listeners subscribe to an event's *class* (its constructor), and the bus + * routes by that class identity, so two events of the same class are delivered + * to the same listeners. `$name` is a human-readable label for logging and + * debugging; it plays no part in routing. + * + * @example + * ```ts + * class UserLoggedIn implements Event { + * readonly $name = 'user-logged-in' + * constructor(readonly userId: string) {} + * } + * ``` + */ +export interface Event { + readonly $name: string +} + +/** + * The constructor type of an {@link Event}. This is what you pass to + * `broker.on`, `broker.intercept`, etc. — the class itself, not an instance. + */ +export type EventClass = abstract new ( + ...args: any[] +) => E + +/** + * An {@link Event} that also asks registered handlers to stream values back. + * Subclass it and fix the value type through `T`; emit it with `broker.invoke` + * and collect the `T`s that handlers `send`. Because an invocation is also an + * event, it is first emitted (and can be intercepted) before it is invoked. + * + * @typeParam T - the type of each value handlers stream back. + * + * @example + * ```ts + * class ListFiles extends Invocation { + * readonly $name = 'list-files' + * constructor(readonly dir: string) { super() } + * } + * ``` + */ +export abstract class Invocation implements Event { + abstract $name: string + /** Phantom field carrying `T` for inference; never assigned at runtime. */ + declare $invocationType: T +} + +/** The constructor type of an {@link Invocation}. */ +export type InvocationClass = abstract new ( + ...args: any +) => Invocation + +/** + * Extracts the streamed value type `T` from an {@link Invocation} instance or + * its class — e.g. `InvocationType` is `string`. + */ +export type InvocationType | InvocationClass> = + E extends Invocation + ? V + : E extends InvocationClass + ? V + : never diff --git a/packages/message-bus/src/EventListener.ts b/packages/message-bus/src/EventListener.ts new file mode 100644 index 000000000..66bcd4514 --- /dev/null +++ b/packages/message-bus/src/EventListener.ts @@ -0,0 +1,81 @@ +import { EventClass, InvocationClass, InvocationType } from './Event.js' + +/** + * Handles an emitted event. Registered with `broker.on` / `broker.once`. A + * returned promise is not awaited by the emitter — events are fire-and-forget. + */ +export interface EventListener { + (event: InstanceType): void | Promise +} + +/** + * Handles an invocation, streaming results back through `context.send`. + * Registered with `broker.register`. The invocation completes for this handler + * when the function returns (or its returned promise settles) — see + * {@link InvocationListenerContext}. + * + * @example + * ```ts + * broker.register(ListFiles, async (event, { send, signal }) => { + * for (const file of await readdir(event.dir, { signal })) send(file) + * }) + * ``` + */ +export interface InvocationListener> { + ( + event: InstanceType, + context: InvocationListenerContext, + ): void | Promise +} + +/** + * The context handed to an invocation listener. A listener streams results + * through `send`; the invocation is considered complete for that listener when + * the function it registered returns (or its returned promise settles). There + * is deliberately no `finish` — completion is implicit, so a listener can never + * forget to call it, and one that is filtered out or throws can't wedge the + * stream. `signal` aborts when the consumer aborts, the broker aborts, or the + * stream is otherwise torn down, so long-running work can bail early. + */ +export interface InvocationListenerContext> { + send: (value: InvocationType) => void + signal: AbortSignal +} + +/** + * Receives an error thrown (or rejected) by an individual invocation handler. + * Handlers are isolated: reporting an error here lets the other handlers keep + * streaming and the invocation still complete. The default sink rethrows, so an + * unobserved handler error surfaces on the stream instead of being swallowed; + * pass your own to `invoke({ onError })` to observe errors without failing it. + */ +export type InvocationErrorHandler = (error: unknown) => void + +/** + * Returned by an interceptor to stop an event dead: no further interceptors run + * and no listeners are notified. Also the resolved value of `broker.emit` when + * an interceptor cancelled the event. + */ +export const CANCEL = Symbol.for('dmg-bus/cancel') + +/** + * What an {@link InterceptionListener} may return: + * - `void`/`undefined` — leave the event unchanged; + * - a new event instance — replace it for downstream interceptors and listeners; + * - {@link CANCEL} — drop the event entirely; + * - a promise of any of the above. + */ +export type InterceptionResult = + | void + | InstanceType + | typeof CANCEL + | Promise> + +/** + * Inspects an event before listeners see it and may transform or cancel it. + * Registered with `broker.intercept`. Interceptors across brokers run in a + * chain, each receiving the previous one's (possibly replaced) event. + */ +export interface InterceptionListener { + (event: InstanceType): InterceptionResult +} diff --git a/packages/message-bus/src/Filter.ts b/packages/message-bus/src/Filter.ts new file mode 100644 index 000000000..120e419b7 --- /dev/null +++ b/packages/message-bus/src/Filter.ts @@ -0,0 +1,36 @@ +import type { EventClass } from './Event.js' + +/** + * Narrows a subscription to only the events you care about. A filter is a + * partial map of an event's properties, where each value is either an expected + * value (matched with `===`) or a predicate. **All** listed keys must match + * (logical AND); an empty filter matches every event. + * + * @example + * ```ts + * // fires only when both hold + * broker.on(Order, { status: 'paid', total: (o) => o.total > 100 }, listener) + * ``` + */ +export type Filter = { + [K in keyof InstanceType]?: FilterValue +} + +/** A single `[key, value-or-predicate]` pair of a {@link Filter}. */ +export type FilterEntry = { + [K in keyof InstanceType]: [K, FilterValue] +}[keyof InstanceType] + +/** A {@link Filter} flattened to its `[key, value]` entries. */ +export type FilterEntries = FilterEntry[] + +/** An accepted filter value for property `K`: the value itself, or a predicate. */ +export type FilterValue< + E extends EventClass, + K extends keyof InstanceType, +> = InstanceType[K] | FilterPredicate + +/** A predicate filter: gets the whole event, returns whether it matches. */ +export interface FilterPredicate { + (event: InstanceType): boolean +} diff --git a/packages/message-bus/src/MessageBus.ts b/packages/message-bus/src/MessageBus.ts deleted file mode 100644 index b409561df..000000000 --- a/packages/message-bus/src/MessageBus.ts +++ /dev/null @@ -1,617 +0,0 @@ -import { AbortError } from '@johngw/async' -import { - accumulate, - combineIterators, - iteratorRace, -} from '@johngw/async-iterator' -import { filterMap, init, last, removeItem, replaceLastItem } from './array.js' -import Broker from './Broker.js' -import MessageBusError from './MessageBusError.js' -import { amend } from './object.js' -import { CancelEvent } from './symbols.js' -import { - EventInterceptorArgs, - EventInterceptors, - SubscriberArgs, - SubscriberFn, - Subscribers, - UntilArgs, - UntilRtn, -} from './types/events.js' -import { EventGeneratorArgs, EventGenerators } from './types/generators.js' -import { - InvokerInterceptorArgs, - InvokerInterceptors, - Invokers, - MatchableInvokerRegistrationArgs, -} from './types/invokables.js' -import { Stringable, UnpackResolvableValue } from './types/util.js' -import { - AddAbortSignal, - ErrorHandler, - MessageBusContext, - Unsubscriber, -} from './types/MessageBus.js' -import { anySignal, fromSignal } from './AbortController.js' -import { InvokableNotRegisteredError } from './errors/InvokableNotRegisteredError.js' -import { InvokerFn } from '@plugola/invoke' -import { match } from './matcher.js' -import { StreamReader, StreamReaderArgs, Streams } from './types/streams.js' -import { WritableReadablePair } from '@johngw/stream/transformers/WritableReadablePair' -import { mergeUnderlyingSource } from '@johngw/stream' - -export default class MessageBus< - $ extends MessageBusContext = MessageBusContext, -> { - #errorHandlers: ErrorHandler[] = [] - #eventInterceptors: EventInterceptors<$> = {} - #eventGenerators: EventGenerators<$> = {} - #invokers: Invokers<$> = {} - #invokerInterceptors: InvokerInterceptors<$> = {} - #queued: Array<() => unknown> = [] - #started = false - #streams: Streams<$> = {} - #subscribers: Subscribers<$> = {} - - onError(errorHandler: ErrorHandler) { - this.#errorHandlers.push(errorHandler) - return () => { - this.#errorHandlers = removeItem(errorHandler, this.#errorHandlers) - } - } - - #reportError(brokerId: string, eventName: Stringable, error: Error) { - for (const errorHandler of this.#errorHandlers) - errorHandler(new MessageBusError(brokerId, eventName, error)) - } - - broker(id: string, abort?: AbortSignal | AbortController) { - const abortController = !abort - ? new AbortController() - : abort instanceof AbortController - ? abort - : fromSignal(abort) - - return new Broker<$>(this, id, abortController) - } - - async start() { - this.#started = true - return Promise.all(this.#queued.map((handle) => handle())) - } - - emit( - broker: Broker<$>, - eventName: EventName, - args: $['events'][EventName], - abortSignal?: AbortSignal, - ): void | Promise { - const handle = () => { - let result: void | Promise = undefined - - try { - const interception = this.#callEventInterceptors(eventName, args) - result = interception - ? interception.then((moddedArgs) => { - if (moddedArgs !== CancelEvent) { - this.#callSubscribers(eventName, moddedArgs, abortSignal) - } - }) - : this.#callSubscribers(eventName, args, abortSignal) - } catch (error: any) { - this.#reportError(broker.id, eventName, error) - } - - return result instanceof Promise - ? result.catch((error) => - this.#reportError(broker.id, eventName, error), - ) - : result - } - - return this.#started - ? handle() - : this.#queue(broker, handle).catch((error) => - this.#reportError(broker.id, eventName, error), - ) - } - - interceptEvent( - broker: Broker<$>, - eventName: EventName, - args: EventInterceptorArgs<$['events'][EventName]>, - ): Unsubscriber { - const interceptor = { - broker, - args: init(args), - fn: last(args), - } as any - - this.#eventInterceptors = amend( - this.#eventInterceptors, - eventName, - (interceptors = []) => [...interceptors!, interceptor], - ) - - return () => { - this.#eventInterceptors[eventName] = removeItem( - interceptor, - this.#eventInterceptors[eventName]!, - ) - } - } - - interceptInvoker( - broker: Broker<$>, - invokableName: InvokableName, - args: InvokerInterceptorArgs< - $['invokables'][InvokableName]['args'], - $['invokables'][InvokableName]['return'] - >, - ): Unsubscriber { - const interceptor = { - broker, - args: init(args), - fn: last(args), - } as any - - this.#invokerInterceptors = amend( - this.#invokerInterceptors, - invokableName, - (interceptors = []) => [...interceptors!, interceptor], - ) - - return () => { - this.#invokerInterceptors[invokableName] = removeItem( - interceptor, - this.#invokerInterceptors[invokableName]!, - ) - } - } - - on( - broker: Broker<$>, - eventName: EventName, - args: SubscriberArgs<$['events'][EventName]>, - ): Unsubscriber { - if (broker.aborted) return () => {} - - const subscriber = { - broker, - args: init(args), - fn: last(args), - } as any - - this.#subscribers = amend( - this.#subscribers, - eventName, - (subscribers = []) => [...subscribers!, subscriber], - ) - - const cancel = () => { - this.#subscribers[eventName] = removeItem( - subscriber, - this.#subscribers[eventName]!, - ) - } - - broker.onAbort(cancel) - - return cancel - } - - once( - broker: Broker<$>, - eventName: EventName, - args: SubscriberArgs<$['events'][EventName]>, - ): Unsubscriber { - const fn = last(args) as SubscriberFn<$['events'][EventName]> - const onceFn: SubscriberFn<$['events'][EventName]> = (...args) => { - cancel() - return fn(...args) - } - const cancel = this.on( - broker, - eventName, - replaceLastItem(args, onceFn) as SubscriberArgs<$['events'][EventName]>, - ) - return cancel - } - - async until< - EventName extends keyof $['events'], - Args extends UntilArgs<$['events'][EventName]>, - >( - broker: Broker<$>, - eventName: EventName, - args: Args, - abortSignal?: AbortSignal, - ): Promise> { - return new Promise>( - (resolve, reject) => { - const abortSignalComposite = anySignal(abortSignal, broker.abortSignal) - - if (abortSignalComposite.aborted) return reject(new AbortError()) - - const subscriberArgs = [ - ...args, - (...args: any) => resolve(args), - ] as SubscriberArgs<$['events'][EventName]> - - this.once(broker, eventName, subscriberArgs) - - abortSignalComposite.addEventListener('abort', () => { - reject(new AbortError()) - }) - }, - ) - } - - hasSubscriber(eventName: keyof $['events']) { - return !!this.#subscribers[eventName]?.length - } - - generator( - broker: Broker<$>, - eventName: EventName, - args: EventGeneratorArgs< - $['generators'][EventName]['args'], - $['generators'][EventName]['yield'] - >, - ): Unsubscriber { - if (broker.aborted) return () => {} - - const iterator = { - broker, - args: init(args), - fn: last(args), - } as any - - this.#eventGenerators = amend( - this.#eventGenerators, - eventName, - (iterators = []) => [...iterators!, iterator], - ) - - const cancel = () => { - this.#eventGenerators[eventName] = removeItem( - iterator, - this.#eventGenerators[eventName]!, - ) - } - - broker.onAbort(cancel) - - return cancel - } - - async *iterate( - broker: Broker<$>, - eventName: EventName, - args: $['generators'][EventName]['args'], - abortSignal?: AbortSignal, - ): AsyncIterable<$['generators'][EventName]['yield']> { - if (!this.#started) await this.#queue(broker, () => {}) - - yield* combineIterators( - ...(this.#eventGenerators[eventName] || [])! - .filter((iterator) => this.#argumentIndex(iterator.args, args) !== -1) - .map((iterator) => - iterator.fn( - ...args.slice(this.#argumentIndex(iterator.args, args)), - anySignal(abortSignal, iterator.broker.abortSignal), - ), - ), - ) - } - - iterateWithin( - broker: Broker<$>, - within: number, - eventName: EventName, - args: $['generators'][EventName]['args'], - abortSignal?: AbortSignal, - ): AsyncIterable<$['generators'][EventName]['yield']> { - return iteratorRace( - this.iterate(broker, eventName, args, abortSignal), - within, - anySignal(abortSignal, broker.abortSignal), - ) - } - - async accumulate( - broker: Broker<$>, - eventName: EventName, - args: $['generators'][EventName]['args'], - abortSignal?: AbortSignal, - ) { - return accumulate(this.iterate(broker, eventName, args, abortSignal)) - } - - async accumulateWithin( - broker: Broker<$>, - within: number, - eventName: EventName, - args: $['generators'][EventName]['args'], - abortSignal?: AbortSignal, - ) { - return accumulate( - this.iterateWithin(broker, within, eventName, args, abortSignal), - ) - } - - register( - broker: Broker<$>, - invokableName: InvokableName, - allArgs: MatchableInvokerRegistrationArgs< - $['invokables'][InvokableName]['args'], - $['invokables'][InvokableName]['return'] - >, - ): Unsubscriber { - if (broker.aborted) return () => {} - - const args = init(allArgs) as $['invokables'][InvokableName]['args'] - const fn = last(allArgs) as InvokerFn< - $['invokables'][InvokableName]['args'], - $['invokables'][InvokableName]['return'] - > - const invokers = this.#invokers[invokableName] || [] - const registeredInvoker = invokers.find( - (invoker) => this.#argumentIndex(invoker.args, args) !== -1, - ) - - if (registeredInvoker) - throw new Error( - `An invoker has already been registered that matches ${invokableName.toString()} with args: ${args.join( - ', ', - )}.`, - ) - - const subscriber = { - broker, - args, - fn, - } - - this.#invokers[invokableName] = [ - ...invokers, - subscriber, - ] as unknown as Invokers<$>[InvokableName] - - const cancel = () => { - this.#invokers[invokableName] = removeItem( - subscriber, - this.#invokers[invokableName] as any, - ) as unknown as Invokers<$>[InvokableName] - } - - broker.onAbort(() => setTimeout(cancel, 0)) - - return cancel - } - - async invoke( - broker: Broker<$>, - invokableName: InvokableName, - args: $['invokables'][InvokableName]['args'], - abortSignal?: AbortSignal, - ): Promise<$['invokables'][InvokableName]['return']> { - const handle = async () => - new Promise((resolve, reject) => { - const abortSignalComposite = anySignal(abortSignal, broker.abortSignal) - if (abortSignalComposite.aborted) return reject(new AbortError()) - abortSignalComposite.addEventListener('abort', () => - reject(new AbortError()), - ) - - resolve(this.#invokeChain(invokableName, args, abortSignalComposite)) - }) - - return this.#started ? handle() : this.#queue(broker, handle) - } - - reader( - broker: Broker<$>, - streamName: StreamName, - allArgs: StreamReaderArgs< - $['streams'][StreamName]['args'], - $['streams'][StreamName]['item'] - >, - ): Unsubscriber { - type $StreamReader = StreamReader<$, StreamName> - - const streamer: $StreamReader = { - broker, - args: init(allArgs) as $StreamReader['args'], - fn: last(allArgs) as $StreamReader['fn'], - } - - this.#streams[streamName] ??= [] - - this.#streams[streamName].push(streamer) - - const cancel = () => { - this.#streams[streamName] = removeItem( - streamer, - this.#streams[streamName]!, - ) - } - - broker.onAbort(cancel) - - return cancel - } - - stream( - broker: Broker<$>, - streamName: StreamName, - args: $['streams'][StreamName]['args'], - abortSignal?: AbortSignal, - ): ReadableStream<$['streams'][StreamName]['item']> { - type Item = $['streams'][StreamName]['item'] - - const streamers = this.#streams[streamName] ?? [] - - const abortSignalComposite = anySignal(abortSignal, broker.abortSignal) - - const streams = () => - filterMap( - streamers, - (streamer) => this.#argumentIndex(streamer.args, args), - (_streamer, argumentIndex) => argumentIndex !== -1, - (streamer, argumentIndex) => - new ReadableStream( - streamer.fn( - ...([ - ...streamer.args.slice(0, argumentIndex), - ...args.slice(argumentIndex), - abortSignalComposite, - ] as AddAbortSignal<$['streams'][StreamName]['args']>), - ), - ), - ) - - return new ReadableStream({ - start: async (controller) => { - if (!this.#started) await this.#queue(broker, () => {}) - const abort = () => controller.error(abortSignalComposite.reason) - if (abortSignalComposite.aborted) return abort() - abortSignalComposite.addEventListener('abort', abort) - }, - }).pipeThrough( - new WritableReadablePair({}, mergeUnderlyingSource(streams)), - ) - } - - #callEventInterceptors( - eventName: EventName, - args: $['events'][EventName], - ): void | Promise<$['events'][EventName] | typeof CancelEvent> { - const eventInterceptors = (this.#eventInterceptors[eventName] || [])! - - if (!eventInterceptors.length) return - - return (async () => { - let moddedArgs: $['events'][EventName] | typeof CancelEvent = args - - for (const interceptor of eventInterceptors) { - const index = this.#argumentIndex(interceptor.args, moddedArgs) - - if (index === -1) continue - - const newArgs = await interceptor.fn(...moddedArgs.slice(index)) - - if (newArgs === CancelEvent) return CancelEvent - else if (newArgs) - moddedArgs = [ - ...moddedArgs.slice(0, index), - ...newArgs, - ] as $['events'][EventName] - } - - return moddedArgs - })() - } - - async #invokeChain( - invokableName: InvokableName, - args: $['invokables'][InvokableName]['args'], - signal: AbortSignal, - ): Promise<$['invokables'][InvokableName]['return']> { - const invokerInterceptors = this.#invokerInterceptors[invokableName] || [] - - const invokeChain = async ( - index: number, - args: $['invokables'][InvokableName]['args'], - ): Promise<$['invokables'][InvokableName]['return']> => { - const interceptor = invokerInterceptors[index] - if (!interceptor) return this.#invoke(invokableName, args, signal) - const argIndex = this.#argumentIndex(interceptor.args, args) - return argIndex === -1 - ? invokeChain(index + 1, args) - : interceptor.fn( - (...nextArgs) => invokeChain(index + 1, nextArgs), - ...args.slice(argIndex), - ) - } - - return invokeChain(0, args) - } - - #callSubscribers( - eventName: EventName, - args: $['events'][EventName], - abortSignal?: AbortSignal, - ): void | Promise { - const subscribers = (this.#subscribers[eventName] || [])! - const promises: Promise[] = [] - - for (const subscriber of subscribers) { - const index = this.#argumentIndex(subscriber.args, args) - - const subscriberAbortSignal = anySignal( - abortSignal, - subscriber.broker.abortSignal, - ) - - if (index >= 0) { - const promise = subscriber.fn( - ...args.slice(index), - subscriberAbortSignal, - ) - if (promise) { - promises.push(promise) - } - } - } - - if (promises.length) { - return Promise.all(promises).then(() => {}) - } - } - - async #invoke( - invokableName: InvokableName, - args: $['invokables'][InvokableName]['args'], - abortSignal: AbortSignal, - ): Promise<$['invokables'][InvokableName]['return']> { - const invokers = this.#invokers[invokableName] - const invoker = - invokers && - invokers.find((invoker) => this.#argumentIndex(invoker.args, args) !== -1) - - if (!invoker) { - throw new InvokableNotRegisteredError(this, invokableName.toString()) - } - - return invoker.fn( - ...(args.slice( - this.#argumentIndex(invoker.args, args), - ) as $['invokables'][InvokableName]['args']), - abortSignal, - ) - } - - #argumentIndex(args1: ArrayLike, args2: ArrayLike) { - if (!args1.length) return 0 - else if (args1.length > args2.length) return -1 - - let i = 0 - for (; i < args1.length; i++) if (!match(args1[i], args2[i])) return -1 - return i - } - - async #queue(broker: Broker<$>, handler: () => T) { - return new Promise>((resolve, reject) => { - if (broker.aborted) return reject(new AbortError()) - - const fn = () => resolve(handler() as UnpackResolvableValue) - this.#queued.push(fn) - - broker.onAbort(() => { - this.#queued = removeItem(fn, this.#queued) - reject(new AbortError()) - }) - }) - } -} diff --git a/packages/message-bus/src/MessageBusError.ts b/packages/message-bus/src/MessageBusError.ts deleted file mode 100644 index c2c32d171..000000000 --- a/packages/message-bus/src/MessageBusError.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Stringable } from './types/util.js' - -export default class MessageBusError extends Error { - constructor( - public readonly brokerId: string, - public readonly eventName: Stringable, - public readonly originalError: Error - ) { - super(`${brokerId}[${eventName}]: ${originalError.message}`) - } -} diff --git a/packages/message-bus/src/dmg-bus/Queue/DiscriminatedQueue.ts b/packages/message-bus/src/Queue/DiscriminatedQueue.ts similarity index 53% rename from packages/message-bus/src/dmg-bus/Queue/DiscriminatedQueue.ts rename to packages/message-bus/src/Queue/DiscriminatedQueue.ts index 486893a4e..8ad000507 100644 --- a/packages/message-bus/src/dmg-bus/Queue/DiscriminatedQueue.ts +++ b/packages/message-bus/src/Queue/DiscriminatedQueue.ts @@ -15,18 +15,18 @@ export class DiscriminatedQueue< this.#execRecord = execRecord } - addExec( + /** + * Registers an executor for a new discriminant, mutating this queue in place + * and returning it (widened) rather than allocating a replacement. Mutating is + * deliberate: a fresh instance would start out stopped and silently drop the + * running state, so a discriminant added after start() would never run. + */ + addExec( type: K, - exec: (item: T) => void, - ): DiscriminatedQueue { - type NewT = T & { [P in K]: T } - return new DiscriminatedQueue( - { - ...this.#execRecord, - [type]: exec, - }, - this.items as unknown as DiscriminatedQueueItem[], - ) + exec: (item: U) => void, + ): DiscriminatedQueue { + ;(this.#execRecord as Record void>)[type] = exec + return this as unknown as DiscriminatedQueue } } diff --git a/packages/message-bus/src/Queue/MethodQueue.ts b/packages/message-bus/src/Queue/MethodQueue.ts new file mode 100644 index 000000000..6a9bb17d2 --- /dev/null +++ b/packages/message-bus/src/Queue/MethodQueue.ts @@ -0,0 +1,46 @@ +import { DiscriminatedQueue } from './DiscriminatedQueue.js' +import type { Runnable } from './Runnable.js' + +export class MethodQueue implements Runnable { + #queue = new DiscriminatedQueue>({}) + + queueMethod( + method: (...args: Args) => R, + ): (...args: Args) => Promise> { + const type = Symbol() + + this.#queue = this.#queue.addExec(type, (item: QueuedCall) => { + try { + item.resolve(method(...item.args) as Awaited) + } catch (error) { + item.reject(error) + } + }) as any + + return (...args) => { + const { promise, resolve, reject } = Promise.withResolvers>() + this.#queue.push({ type, args, resolve, reject } as QueuedCall & { + type: symbol + }) + return promise + } + } + + start() { + this.#queue.start() + } + + stop() { + this.#queue.stop() + } + + get running() { + return this.#queue.running + } +} + +interface QueuedCall { + args: Args + resolve: (value: Awaited) => void + reject: (reason: unknown) => void +} diff --git a/packages/message-bus/src/dmg-bus/Queue/Queue.ts b/packages/message-bus/src/Queue/Queue.ts similarity index 100% rename from packages/message-bus/src/dmg-bus/Queue/Queue.ts rename to packages/message-bus/src/Queue/Queue.ts diff --git a/packages/message-bus/src/dmg-bus/Queue/Runnable.ts b/packages/message-bus/src/Queue/Runnable.ts similarity index 100% rename from packages/message-bus/src/dmg-bus/Queue/Runnable.ts rename to packages/message-bus/src/Queue/Runnable.ts diff --git a/packages/message-bus/src/SubscriptionDisposer.ts b/packages/message-bus/src/SubscriptionDisposer.ts deleted file mode 100644 index 0d0d2b9a9..000000000 --- a/packages/message-bus/src/SubscriptionDisposer.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Unsubscriber } from './types/MessageBus.js' - -export default class SubscriptionDisposer { - #unsubscribers = new Set() - - add(unsubscriber: Unsubscriber) { - this.#unsubscribers.add(unsubscriber) - } - - dispose() { - for (const unsubscribe of this.#unsubscribers) { - unsubscribe() - } - } -} diff --git a/packages/message-bus/src/array.ts b/packages/message-bus/src/array.ts deleted file mode 100644 index 99502f020..000000000 --- a/packages/message-bus/src/array.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { L } from 'ts-toolbelt' - -export function init(array: T[]) { - return array.slice(0, -1) -} - -export function last(array: T): L.Last { - return array[array.length - 1] -} - -export function removeItem(item: T, array: T[]) { - const index = array.indexOf(item) - return index === -1 ? array : removeIndex(index, array) -} - -export function removeIndex(index: number, array: T[]) { - return [...array.slice(0, index), ...array.slice(index + 1)] -} - -export function replaceLastItem(array: never[], item: unknown): never[] -export function replaceLastItem( - array: Ts, - item: T, -): L.Append, T> -export function replaceLastItem(array: Ts, item: T) { - return array.length === 0 ? array : [...init(array), item] -} - -export function filterMap( - array: I[], - context: ContextCreator, - filter: Filterer, - map: Mapper, -): O[] -export function filterMap( - array: I[], - filter: Filterer, - map: Mapper, -): O[] -export function filterMap( - array: I[], - contextOrFilter: ContextCreator | Filterer, - filterOrMap: Filterer | Mapper, - maybeMap?: Mapper, -): O[] { - let context: undefined | ContextCreator - let filter: Filterer - let map: Mapper - - if (maybeMap) { - context = contextOrFilter as ContextCreator - filter = filterOrMap as Filterer - map = maybeMap as Mapper - } else { - filter = contextOrFilter as Filterer - map = filterOrMap as Mapper - } - - const output: O[] = [] - for (const item of array) { - const c = context?.(item) - if (filter(item, c as any)) output.push(map(item, c as any)) - } - return output -} - -type Filterer = C extends never - ? (input: I) => boolean - : (input: I, context: C) => boolean - -type Mapper = C extends never - ? (input: I) => O - : (input: I, context: C) => O - -type ContextCreator = (input: I) => C diff --git a/packages/message-bus/src/dmg-bus/Bus.ts b/packages/message-bus/src/dmg-bus/Bus.ts deleted file mode 100644 index 7a1631ca2..000000000 --- a/packages/message-bus/src/dmg-bus/Bus.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Broker } from './Broker/Broker.js' -import { PluginBroker } from './Broker/PluginBroker.js' -import type { Event, EventClass, Invocation, InvocationClass } from './Event.js' -import { CANCEL } from './EventListener.js' -import { withCounter } from './lang/Function.js' -import { getOrInsert } from './lang/Map.js' - -export class Bus { - readonly #brokers = new Map() - - readonly #eventBrokers = new Map>() - - readonly #invokeBrokers = new Map, Set>() - - readonly #interceptBrokers = new Map>() - - readonly on = ( - broker: PluginBroker, - eventClass: E, - ): (() => void) => { - const eventBrokers = getOrInsert(this.#eventBrokers, eventClass, new Set()) - eventBrokers.add(broker.name) - return () => { - this.#eventBrokers.get(eventClass)?.delete(broker.name) - } - } - - readonly register = ( - broker: PluginBroker, - eventClass: InvocationClass, - ): (() => void) => { - const invokeBrokers = getOrInsert( - this.#invokeBrokers, - eventClass, - new Set(), - ) - invokeBrokers.add(broker.name) - return () => { - this.#invokeBrokers.get(eventClass)?.delete(broker.name) - } - } - - readonly intercept = ( - broker: PluginBroker, - eventClass: EventClass, - ): (() => void) => { - const interceptBrokers = getOrInsert( - this.#interceptBrokers, - eventClass, - new Set(), - ) - interceptBrokers.add(broker.name) - return () => { - this.#interceptBrokers.get(eventClass)?.delete(broker.name) - } - } - - broker(name: string) { - if (this.#brokers.has(name)) - throw new Error(`Broker "${name}" has already been registered`) - - const broker = new Broker(this, name) - this.#brokers.set(name, broker) - - broker.onAbort(() => { - this.#brokers.delete(name) - for (const brokerNames of this.#eventBrokers.values()) - brokerNames.delete(name) - for (const brokerNames of this.#invokeBrokers.values()) - brokerNames.delete(name) - }) - - return broker.createPluginFacade() - } - - async emit(event: E): Promise { - const interceptorBrokers = - this.#interceptBrokers.get(event.constructor as EventClass) ?? [] - - for (const name of interceptorBrokers) { - const result = await this.#brokers.get(name)?.intercept(event) - if (result === CANCEL) return CANCEL - else if (result) event = result as E - } - - const eventBrokers = this.#eventBrokers.get(event.constructor as EventClass) - if (!eventBrokers?.size) return event - - for (const name of eventBrokers) this.#brokers.get(name)?.emit(event) - - return event - } - - invoke( - event: Invocation, - context: { - send: (item: T) => void - finish: () => void - signal?: AbortSignal - }, - ) { - const invokeBrokers = this.#invokeBrokers.get( - event.constructor as InvocationClass, - ) - - if (!invokeBrokers?.size) return context.finish() - - const brokerContext = { - ...context, - finish: withCounter((counter) => { - if (counter >= invokeBrokers.size) context.finish() - }), - } - - for (const name of invokeBrokers) - this.#brokers.get(name)?.invoke(event, brokerContext) - } - - abort(name: string, reason?: Error) { - this.#brokers.get(name)?.abort(reason) - } - - start(name?: string) { - if (name === undefined) - for (const broker of this.#brokers.values()) broker.start() - else this.#brokers.get(name)?.start() - } - - stop(name?: string) { - if (name === undefined) - for (const broker of this.#brokers.values()) broker.stop() - else this.#brokers.get(name)?.stop() - } -} diff --git a/packages/message-bus/src/dmg-bus/Event.ts b/packages/message-bus/src/dmg-bus/Event.ts deleted file mode 100644 index e00d52022..000000000 --- a/packages/message-bus/src/dmg-bus/Event.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface Event { - readonly $name: string -} - -export type EventClass = abstract new ( - ...args: any[] -) => E - -export abstract class Invocation implements Event { - abstract $name: string - declare $invocationType: T -} - -export type InvocationClass = abstract new ( - ...args: any -) => Invocation - -export type InvocationType | InvocationClass> = - E extends Invocation - ? V - : E extends InvocationClass - ? V - : never diff --git a/packages/message-bus/src/dmg-bus/EventListener.ts b/packages/message-bus/src/dmg-bus/EventListener.ts deleted file mode 100644 index 32a1fe5e8..000000000 --- a/packages/message-bus/src/dmg-bus/EventListener.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { EventClass, InvocationClass, InvocationType } from './Event.js' - -export interface EventListener { - (event: InstanceType): void | Promise -} - -export interface InvocationListener> { - ( - event: InstanceType, - context: InvocationListenerContext, - ): void | Promise -} - -export interface InvocationListenerContext> { - send: (value: InvocationType) => void - finish: () => void - signal?: AbortSignal -} - -export const CANCEL = Symbol.for('dmg-bus/cancel') - -export type InterceptionResult = - | void - | InstanceType - | typeof CANCEL - | Promise> - -export interface InterceptionListener { - (event: InstanceType): InterceptionResult -} diff --git a/packages/message-bus/src/dmg-bus/Filter.ts b/packages/message-bus/src/dmg-bus/Filter.ts deleted file mode 100644 index 368ff02a3..000000000 --- a/packages/message-bus/src/dmg-bus/Filter.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { EventClass } from './Event.js' - -export type Filter = { - [K in keyof InstanceType]?: FilterValue -} - -export type FilterEntry = { - [K in keyof InstanceType]: [K, FilterValue] -}[keyof InstanceType] - -export type FilterEntries = FilterEntry[] - -export type FilterValue< - E extends EventClass, - K extends keyof InstanceType, -> = InstanceType[K] | FilterPredicate - -export interface FilterPredicate { - (event: InstanceType): boolean -} diff --git a/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts b/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts deleted file mode 100644 index d8c855a05..000000000 --- a/packages/message-bus/src/dmg-bus/Queue/MethodQueue.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { DiscriminatedQueue } from './DiscriminatedQueue.js' -import type { Runnable } from './Runnable.js' - -export class MethodQueue implements Runnable { - #queue = new DiscriminatedQueue>({}) - - queueMethod( - method: (...args: Args) => R, - ): (...args: Args) => Promise> { - const type = Symbol() - const { promise, resolve } = Promise.withResolvers>() - - this.#queue = this.#queue.addExec(type, (item: { args: Args }) => { - resolve(method(...item.args) as Awaited) - }) as any - - return (...args) => { - this.#queue.push({ type, args }) - return promise - } - } - - start() { - this.#queue.start() - } - - stop() { - this.#queue.stop() - } - - get running() { - return this.#queue.running - } -} diff --git a/packages/message-bus/src/dmg-bus/Queue/queue.test.ts b/packages/message-bus/src/dmg-bus/Queue/queue.test.ts deleted file mode 100644 index 04efa0edc..000000000 --- a/packages/message-bus/src/dmg-bus/Queue/queue.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect, test, vi } from 'vitest' -import { DiscriminatedQueue } from './DiscriminatedQueue.js' - -test('discriminated queue', () => { - const fooSpy = vi.fn((_item: { name: string }) => {}) - const barSpy = vi.fn((_item: { num: number }) => {}) - - const queue = new DiscriminatedQueue({ - foo: fooSpy, - bar: barSpy, - }) - - queue.push({ type: 'foo', name: 'My name' }) - queue.push({ type: 'bar', num: 123 }) - - expect(fooSpy).not.toHaveBeenCalled() - expect(barSpy).not.toHaveBeenCalled() - - queue.start() - - expect(fooSpy).toHaveBeenCalledWith({ type: 'foo', name: 'My name' }) - expect(barSpy).toHaveBeenCalledWith({ type: 'bar', num: 123 }) -}) diff --git a/packages/message-bus/src/dmg-bus/lang/Function.ts b/packages/message-bus/src/dmg-bus/lang/Function.ts deleted file mode 100644 index 533fdbfff..000000000 --- a/packages/message-bus/src/dmg-bus/lang/Function.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function withCounter( - start: number, - fn: (counter: number, ...args: Args) => R, -): (...args: Args) => R - -export function withCounter( - fn: (counter: number, ...args: Args) => R, -): (...args: Args) => R - -export function withCounter( - startOrFn: number | ((counter: number, ...args: Args) => R), - fn?: (counter: number, ...args: Args) => R, -): (...args: Args) => R { - let counter = (fn ? startOrFn : 1) as number - fn ??= startOrFn as (counter: number, ...args: Args) => R - return (...args) => fn(counter++, ...args) -} diff --git a/packages/message-bus/src/dmg-bus/lang/Signal.ts b/packages/message-bus/src/dmg-bus/lang/Signal.ts deleted file mode 100644 index 42ddc86c7..000000000 --- a/packages/message-bus/src/dmg-bus/lang/Signal.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const CANCELLED = Symbol.for('dmg-bus/cancelled') - -export function timeout( - ms: number, - signal: AbortSignal | undefined, - value: T, -): Promise - -export function timeout( - ms: number, - signal?: AbortSignal, -): Promise - -export function timeout( - ms: number, - signal?: AbortSignal, - value?: T, -): Promise { - return new Promise((resolve) => { - if (signal?.aborted) return resolve(CANCELLED) - signal?.addEventListener('abort', () => resolve(CANCELLED)) - setTimeout(() => resolve(value), ms) - }) -} diff --git a/packages/message-bus/src/errors/InvokableNotRegisteredError.ts b/packages/message-bus/src/errors/InvokableNotRegisteredError.ts deleted file mode 100644 index 07635f16e..000000000 --- a/packages/message-bus/src/errors/InvokableNotRegisteredError.ts +++ /dev/null @@ -1,23 +0,0 @@ -import MessageBus from '../MessageBus.js' -import { MessageBusContext } from '../types/MessageBus.js' - -export class InvokableNotRegisteredError< - $ extends MessageBusContext = MessageBusContext, -> extends Error { - #messageBus: MessageBus<$> - #invokableName: string - - constructor(messageBus: MessageBus<$>, invokableName: string) { - super(`Cannot find matching invoker for "${invokableName}".`) - this.#messageBus = messageBus - this.#invokableName = invokableName - } - - get messageBus() { - return this.#messageBus - } - - get invokableName() { - return this.#invokableName - } -} diff --git a/packages/message-bus/src/errors/index.ts b/packages/message-bus/src/errors/index.ts deleted file mode 100644 index 682b0e3c7..000000000 --- a/packages/message-bus/src/errors/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './InvokableNotRegisteredError.js' diff --git a/packages/message-bus/src/index.ts b/packages/message-bus/src/index.ts index b5433b828..4c9427a3f 100644 --- a/packages/message-bus/src/index.ts +++ b/packages/message-bus/src/index.ts @@ -1,10 +1,5 @@ -export { AbortError } from '@johngw/async' -export { default as Broker } from './Broker.js' -export { CancelEvent } from './symbols.js' -export { default as MessageBus } from './MessageBus.js' -export * from './matcher.js' -export * from './types/MessageBus.js' -export * from './types/events.js' -export * from './types/generators.js' -export * from './types/invokables.js' -export * from './errors/index.js' +export { PluginBroker } from './Broker/PluginBroker.js' +export { Bus } from './Bus.js' +export * from './EventListener.js' +export * from './Event.js' +export * from './Filter.js' diff --git a/packages/message-bus/src/dmg-bus/lang/Map.ts b/packages/message-bus/src/lang/Map.ts similarity index 100% rename from packages/message-bus/src/dmg-bus/lang/Map.ts rename to packages/message-bus/src/lang/Map.ts diff --git a/packages/message-bus/src/matcher.ts b/packages/message-bus/src/matcher.ts deleted file mode 100644 index 10fef21ca..000000000 --- a/packages/message-bus/src/matcher.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * A {@link Matcher} stands in for a literal argument in a filter prefix. When - * one sits in a registered argument position, the bus runs its match method - * against the emitted value instead of comparing for strict equality. - * - * The contract is a single symbol-keyed method, so anything — a plain object, a - * class instance, a larger value that wants to double as a matcher — becomes a - * matcher simply by implementing it. - * - * `T` is contravariant (it only appears in the match parameter), so a narrow - * matcher such as `Matcher<{ bar: number }>` is accepted wherever a wider - * object that structurally includes it is expected. - */ -export const MatcherSymbol = Symbol.for('@plugola/message-bus matcher') - -export interface Matcher { - [MatcherSymbol](value: T): boolean -} - -export function isMatcher(value: unknown): value is Matcher { - return ( - typeof value === 'object' && - value !== null && - typeof (value as Partial)[MatcherSymbol] === 'function' - ) -} - -/** - * Compare a registered filter argument against an emitted value: run the - * predicate if it's a {@link Matcher}, otherwise compare for strict equality. - */ -export function match(matcher: unknown, value: unknown): boolean { - return isMatcher(matcher) ? matcher[MatcherSymbol](value) : matcher === value -} - -/** - * Widen a filter-prefix tuple so a {@link Matcher} is accepted in place of any - * literal argument. - * - * @example - * type A = Matchable<[string, { bar: number }]> - * // [string | Matcher, { bar: number } | Matcher<{ bar: number }>] - */ -export type Matchable = { - [K in keyof A]: A[K] | Matcher -} - -/** - * Match an argument against any boolean test. - * - * @example - * broker.on('foo', predicate((n: number) => n > 2), () => {}) - */ -export function predicate(match: (value: T) => boolean): Matcher { - return { [MatcherSymbol]: match } -} - -/** - * Match an object argument that deeply contains the given subset. Nested - * objects are compared recursively as partials; every other value is compared - * with strict equality. - * - * @example - * broker.on('foo', objectWith({ bar: 2 }), () => {}) - * // fires for emit('foo', { bar: 2, baz: 9 }) - */ -export function objectWith(subset: S): Matcher { - return { [MatcherSymbol]: (value) => objectContains(value, subset) } -} - -function objectContains(value: unknown, subset: object): boolean { - if (typeof value !== 'object' || value === null) return false - - for (const key of Reflect.ownKeys(subset)) { - const expected = (subset as Record)[key] - const actual = (value as Record)[key] - - if (expected !== null && typeof expected === 'object') { - if (!objectContains(actual, expected)) return false - } else if (actual !== expected) { - return false - } - } - - return true -} diff --git a/packages/message-bus/src/object.ts b/packages/message-bus/src/object.ts deleted file mode 100644 index 99732aedf..000000000 --- a/packages/message-bus/src/object.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { O } from 'ts-toolbelt' - -export function amend< - O extends Record, - K extends keyof O, - V extends O[K] ->(obj: O, key: K, fn: (value: O[K]) => V): O.Replace { - return { - ...obj, - [key]: fn(obj[key]), - } as any -} diff --git a/packages/message-bus/src/symbols.ts b/packages/message-bus/src/symbols.ts deleted file mode 100644 index badaaf38f..000000000 --- a/packages/message-bus/src/symbols.ts +++ /dev/null @@ -1 +0,0 @@ -export const CancelEvent = Symbol.for('cancel event') diff --git a/packages/message-bus/src/types/MessageBus.ts b/packages/message-bus/src/types/MessageBus.ts deleted file mode 100644 index 05fd2718f..000000000 --- a/packages/message-bus/src/types/MessageBus.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { L } from 'ts-toolbelt' -import type { InvokablesDict } from '@plugola/invoke' -import type Broker from '../Broker.js' -import type MessageBus from '../MessageBus.js' -import type MessageBusError from '../MessageBusError.js' -import type { EventsT } from './events.js' -import type { EventGeneratorsT } from './generators.js' -import type { StreamsDict } from './streams.js' - -export interface Unsubscriber { - (): void -} - -export type AddAbortSignal = L.Append - -/** - * The single "bag" of type information a {@link MessageBus} (and its - * {@link Broker}) is configured with. Adding a new feature means adding a - * property here rather than appending a positional generic to every reference. - */ -export interface MessageBusContext { - events: EventsT - generators: EventGeneratorsT - invokables: InvokablesDict - streams: StreamsDict -} - -/** - * Build a {@link MessageBusContext} from a partial one, defaulting any slot the - * consumer doesn't specify. - * - * @example - * type Ctx = CreateMessageBusContext<{ events: { foo: [string] } }> - * let bus: MessageBus - */ -export type CreateMessageBusContext = {}> = - Omit & C - -export interface ErrorHandler { - (error: MessageBusError): any -} diff --git a/packages/message-bus/src/types/events.ts b/packages/message-bus/src/types/events.ts deleted file mode 100644 index 187650dea..000000000 --- a/packages/message-bus/src/types/events.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { L } from 'ts-toolbelt' -import type { Matcher, Matchable } from '../matcher.js' -import type Broker from '../Broker.js' -import type { CancelEvent } from '../symbols.js' -import type { AddAbortSignal, MessageBusContext } from './MessageBus.js' - -export type EventsT = Record - -export type CreateEvents = T - -export type SubscriberFn = ( - ...args: AddAbortSignal -) => void | Promise - -/** - * Create an `.on` argument union from a list of types (value of `EventsT`). - * - * @example - * type Args = SubscriberArgs<[string, number]> - * // [string, number, SubscriberFn<[]>] - * // | [string, SubscriberFn<[number]>] - * // | [SubscriberFn<[string, number]>] - */ -export type SubscriberArgs = _SubscriberArgs< - A, - [], - [SubscriberFn] -> - -type _SubscriberArgs< - A extends unknown[], - B extends unknown[], - Acc extends unknown[], -> = - L.Length extends 0 - ? Acc - : _SubscriberArgs< - L.Pop, - L.Prepend>, - L.Append, SubscriberFn> | Acc - > - -/** - * Create an `.until` argument union from a list of types. - * - * @example - * type Args = UntilArgs<[string, number]> - * // never[] - * // | [string] - * // | [string, number] - */ -export type UntilArgs = _UntilArgs> - -type _UntilArgs = - L.Length extends 0 ? Acc : _UntilArgs, Matchable> | Acc> - -/** - * The return value of `.until` from given arguments and - * a list of all posible types. - * - * @example - * type Rtn = UntilRtn<[string, number, string], []> - * // [string, number, string] - * type Rtn = UntilRtn<[string, number, string], [string]> - * // [number, string] - * type Rtn = UntilRtn<[string, number, string], [string, number]> - * // [string] - * type Rtn = UntilRtn<[string, number, string], [string, number, string]> - * // [] - */ -export type UntilRtn = - L.Length extends 0 - ? T - : L.Head extends Matcher - ? UntilRtn, L.Tail> - : L.Head extends L.Head - ? UntilRtn, L.Tail> - : never - -export type Subscribers<$ extends MessageBusContext> = Partial<{ - [EventName in keyof $['events']]: Subscriber<$>[] -}> - -export interface Subscriber<$ extends MessageBusContext> { - broker: Broker<$> - args: unknown[] - fn: SubscriberFn -} - -export type EventInterceptorFn< - Args extends unknown[], - NewArgs extends unknown[], -> = ( - ...args: Args -) => - | typeof CancelEvent - | void - | NewArgs - | Promise - -export type EventInterceptorArgs = _EventInterceptorArgs< - A, - [], - A, - [EventInterceptorFn] -> - -type _EventInterceptorArgs< - A extends unknown[], - B extends unknown[], - C extends unknown[], - Acc extends unknown[], -> = - L.Length extends 0 - ? Acc - : _EventInterceptorArgs< - L.Pop, - L.Prepend>, - C, - Acc | [...Matchable, EventInterceptorFn] - > - -export type EventInterceptors<$ extends MessageBusContext> = Partial<{ - [EventName in keyof $['events']]: EventInterceptor<$>[] -}> - -export interface EventInterceptor<$ extends MessageBusContext> { - broker: Broker<$> - args: unknown[] - fn: EventInterceptorFn -} diff --git a/packages/message-bus/src/types/generators.ts b/packages/message-bus/src/types/generators.ts deleted file mode 100644 index e9f9c6e5c..000000000 --- a/packages/message-bus/src/types/generators.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { L } from 'ts-toolbelt' -import type { Matchable } from '../matcher.js' -import type Broker from '../Broker.js' -import type { AddAbortSignal, MessageBusContext } from './MessageBus.js' - -export type EventGeneratorsT = Record< - string, - { args: unknown[]; yield: unknown } -> - -export type CreateEventGenerators = T - -export type EventGeneratorFn = ( - ...args: AddAbortSignal -) => AsyncIterable - -export interface EventGenerator<$ extends MessageBusContext> { - broker: Broker<$> - args: unknown[] - fn: EventGeneratorFn -} - -export type EventGeneratorArgs = _EventGeneratorArgs< - A, - R, - [], - [EventGeneratorFn] -> - -export type _EventGeneratorArgs< - A extends unknown[], - R, - B extends unknown[], - Acc extends unknown[], -> = - L.Length extends 0 - ? Acc - : _EventGeneratorArgs< - L.Pop, - R, - L.Prepend>, - Acc | L.Append, EventGeneratorFn> - > - -export type EventGenerators<$ extends MessageBusContext> = Partial<{ - [EventName in keyof $['generators']]: EventGenerator<$>[] -}> diff --git a/packages/message-bus/src/types/invokables.ts b/packages/message-bus/src/types/invokables.ts deleted file mode 100644 index 6c918af1e..000000000 --- a/packages/message-bus/src/types/invokables.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { InvokerFn, InvokerRegistrationArgs } from '@plugola/invoke' -import type Broker from '../Broker.js' -import type { L } from 'ts-toolbelt' -import type { Matchable } from '../matcher.js' -import type { MessageBusContext } from './MessageBus.js' - -export { - CreateInvokablesDict, - InvokablesDict, - InvokerFn, - InvokerRegistrationArgs, -} from '@plugola/invoke' - -/** - * {@link InvokerRegistrationArgs} with matchers allowed in every filter-prefix - * position. Invoke owns the base type and stays matcher-agnostic; we widen each - * union member's prefix here, at the message-bus boundary. - */ -export type MatchableInvokerRegistrationArgs< - A extends unknown[], - Return, -> = WrapPrefix> - -type WrapPrefix = U extends [ - ...infer Pre extends unknown[], - infer Fn, -] - ? [...Matchable
, Fn]
-  : U
-
-export interface Invoker<
-  $ extends MessageBusContext,
-  Args extends unknown[],
-  Return,
-> {
-  broker: Broker<$>
-  args: Args
-  fn: InvokerFn
-}
-
-export type Invokers<$ extends MessageBusContext> = Partial<{
-  [InvokableName in keyof $['invokables']]: Invoker<
-    $,
-    $['invokables'][InvokableName]['args'],
-    $['invokables'][InvokableName]['return']
-  >[]
-}>
-
-export type InvokerInterceptorFn = (
-  next: (...args: Args) => Promise,
-  ...args: Args
-) => Return | Promise
-
-export type InvokerInterceptorArgs<
-  A extends unknown[],
-  Return,
-> = _InvokerInterceptorArgs]>
-
-export type _InvokerInterceptorArgs<
-  A extends unknown[],
-  Return,
-  B extends unknown[],
-  Acc extends unknown[],
-> =
-  L.Length extends 0
-    ? Acc
-    : _InvokerInterceptorArgs<
-        L.Pop,
-        Return,
-        B,
-        Acc | L.Append, InvokerInterceptorFn>
-      >
-
-export type InvokerInterceptors<$ extends MessageBusContext> = Partial<{
-  [InvokableName in keyof $['invokables']]: InvokerInterceptor<$>[]
-}>
-
-export interface InvokerInterceptor<$ extends MessageBusContext> {
-  broker: Broker<$>
-  args: unknown[]
-  fn: InvokerInterceptorFn
-}
diff --git a/packages/message-bus/src/types/streams.ts b/packages/message-bus/src/types/streams.ts
deleted file mode 100644
index b3fed29dd..000000000
--- a/packages/message-bus/src/types/streams.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { L } from 'ts-toolbelt'
-import type { Matchable } from '../matcher.js'
-import Broker from '../Broker.js'
-import { AddAbortSignal, MessageBusContext } from './MessageBus.js'
-import { UnderlyingDefaultSource } from 'node:stream/web'
-
-export type StreamsDict = Record
-
-export type CreateStreamsDict = T
-
-export type ReaderFn = (
-  ...args: AddAbortSignal
-) => UnderlyingDefaultSource
-
-export interface StreamReader<
-  $ extends MessageBusContext,
-  StreamName extends keyof $['streams'],
-> {
-  broker: Broker<$>
-  args: $['streams'][StreamName]['args']
-  fn: ReaderFn<
-    $['streams'][StreamName]['args'],
-    $['streams'][StreamName]['item']
-  >
-}
-
-export type Streams<$ extends MessageBusContext> = Partial<{
-  [StreamName in keyof $['streams']]: StreamReader<$, StreamName>[]
-}>
-
-/**
- * Create a `.reader` argument union from a list of arguments and a return type.
- *
- * @example
- * type Args = StreamReaderArgs<[string, number], string>
- * // | [string, number, ReaderFn<[], string>]
- * // | [string, ReaderFn<[number], string>]
- * // | [ReaderFn<[string, number], string>]
- */
-export type StreamReaderArgs = _StreamReaderArgs<
-  A,
-  Item,
-  [],
-  [ReaderFn]
->
-
-type _StreamReaderArgs<
-  A extends unknown[],
-  Item,
-  B extends unknown[],
-  Acc extends unknown[],
-> =
-  L.Length extends 0
-    ? Acc
-    : _StreamReaderArgs<
-        L.Pop,
-        Item,
-        L.Prepend>,
-        L.Append, ReaderFn> | Acc
-      >
diff --git a/packages/message-bus/src/types/util.ts b/packages/message-bus/src/types/util.ts
deleted file mode 100644
index a11a41f3b..000000000
--- a/packages/message-bus/src/types/util.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export type UnpackResolvableValue = T extends Promise
-  ? UnpackResolvableValue
-  : T
-
-export interface Stringable {
-  toString(): string
-}
diff --git a/packages/message-bus/test/MessageBus.test.ts b/packages/message-bus/test/MessageBus.test.ts
deleted file mode 100644
index 4d4459cfb..000000000
--- a/packages/message-bus/test/MessageBus.test.ts
+++ /dev/null
@@ -1,545 +0,0 @@
-import { beforeEach, describe, expect, Mock, test, vi } from 'vitest'
-import { setImmediate } from 'node:timers/promises'
-import Broker from '../src/Broker.js'
-import MessageBus from '../src/MessageBus.js'
-import { CancelEvent } from '../src/symbols.js'
-import { AbortError, timeout } from '@johngw/async'
-import MessageBusError from '../src/MessageBusError.js'
-import { CreateEvents } from '../src/types/events.js'
-import { CreateMessageBusContext } from '../src/types/MessageBus.js'
-import { CreateInvokablesDict } from '../src/types/invokables.js'
-import { CreateEventGenerators } from '../src/types/generators.js'
-import { write } from '@johngw/stream'
-import { objectWith, predicate } from '../src/matcher.js'
-
-describe('events', () => {
-  type Events = CreateEvents<{ foo: []; bar: [string]; mung: [string, number] }>
-  let messageBus: MessageBus>
-  let broker: Broker>
-  let foo: Mock<() => void>
-  let bar: Mock<(x: string) => void>
-
-  beforeEach(() => {
-    messageBus = new MessageBus()
-    broker = messageBus.broker('test')
-    foo = vi.fn()
-    bar = vi.fn()
-    broker.on('foo', foo)
-    broker.on('bar', bar)
-  })
-
-  test('events', () => {
-    messageBus.start()
-    broker.emit('foo')
-    broker.emit('bar', 'hello world')
-    expect(foo).toHaveBeenCalled()
-    expect(bar).toHaveBeenCalledWith('hello world', expect.any(AbortSignal))
-  })
-
-  test('queued events', () => {
-    broker.emit('foo')
-    broker.emit('bar', 'hello world')
-
-    expect(foo).not.toHaveBeenCalled()
-    expect(bar).not.toHaveBeenCalled()
-
-    messageBus.start()
-    expect(foo).toHaveBeenCalled()
-    expect(bar).toHaveBeenCalledWith('hello world', expect.any(AbortSignal))
-  })
-
-  test('can wait for all asynchronous listeners', async () => {
-    const fn = vi.fn()
-    messageBus.start()
-    broker.on('foo', async () => {
-      await timeout()
-      fn()
-    })
-    await broker.emit('foo')
-    expect(fn).toHaveBeenCalled()
-  })
-
-  test('once listeners', () => {
-    const fn = vi.fn()
-    messageBus.start()
-    broker.once('foo', fn)
-    broker.emit('foo')
-    broker.emit('foo')
-    expect(fn).toHaveBeenCalledTimes(1)
-  })
-
-  test('until listeners', async () => {
-    messageBus.start()
-    const result = broker.until('bar')
-    broker.emit('bar', 'hello')
-    expect(await result).toEqual(['hello', expect.any(AbortSignal)])
-  })
-
-  test('partial until listeners', async () => {
-    const fn = vi.fn()
-    messageBus.start()
-    broker.until('bar', 'hello').then(fn)
-    broker.emit('bar', 'no')
-    await setImmediate()
-    expect(fn).not.toHaveBeenCalled()
-    broker.emit('bar', 'hello')
-    await setImmediate()
-    expect(fn).toHaveBeenCalledWith([expect.any(AbortSignal)])
-  })
-
-  test('intercepting events', async () => {
-    messageBus.start()
-    broker.interceptEvent('bar', (x) => [x + '1'])
-    await broker.emit('bar', 'hello')
-    expect(bar).toHaveBeenCalledWith('hello1', expect.any(AbortSignal))
-  })
-
-  test('cancelling events with interception', async () => {
-    messageBus.start()
-    broker.interceptEvent(
-      'foo',
-      async (): Promise => CancelEvent,
-    )
-    await broker.emit('foo')
-    expect(foo).not.toHaveBeenCalled()
-  })
-
-  test('partial subscribers', () => {
-    const fn = vi.fn()
-    messageBus.start()
-    broker.on('mung', 'face', fn)
-    broker.emit('mung', 'mung', 1)
-    broker.emit('mung', 'face', 2)
-    expect(fn).toHaveBeenCalledTimes(1)
-    expect(fn).toHaveBeenCalledWith(2, expect.any(AbortSignal))
-  })
-
-  test('aborting removes subscribers', () => {
-    messageBus.start()
-    broker.abort()
-    broker.emit('foo')
-    expect(foo).not.toHaveBeenCalled()
-  })
-
-  test('aborting cancels queued emits', async () => {
-    const onAbort = vi.fn()
-    broker.emit('foo')
-    broker.abort()
-    messageBus.onError(onAbort)
-    await messageBus.start()
-    expect(foo).not.toHaveBeenCalled()
-    expect(onAbort).toHaveBeenCalled()
-    expect(onAbort.mock.calls[0][0].message).toBe(
-      'test[foo]: Async operation was aborted',
-    )
-  })
-})
-
-describe('iterators', () => {
-  type Iterables = CreateEventGenerators<{
-    foo: { args: []; yield: string }
-    bar: { args: [string]; yield: string }
-  }>
-  let messageBus: MessageBus>
-  let broker: Broker>
-
-  beforeEach(() => {
-    messageBus = new MessageBus()
-    broker = messageBus.broker('test')
-  })
-
-  test('yielding', async () => {
-    const results = []
-
-    broker.generator('foo', async function* () {
-      yield 'hello'
-      yield 'world'
-    })
-
-    broker.generator('foo', async function* () {
-      yield 'moo'
-      yield 'car'
-    })
-
-    messageBus.start()
-    for await (const result of broker.iterate('foo')) {
-      results.push(result)
-    }
-
-    expect(results).toEqual(['hello', 'world', 'moo', 'car'])
-  })
-
-  test('partial subscribers', async () => {
-    const results = []
-
-    broker.generator('bar', async function* (str) {
-      yield str
-    })
-
-    broker.generator('bar', 'mung', async function* () {
-      yield 'face'
-    })
-
-    broker.generator('bar', 'shouldIgnore', async function* () {
-      yield 'ERROR'
-    })
-
-    messageBus.start()
-    for await (const result of broker.iterate('bar', 'mung')) {
-      results.push(result)
-    }
-
-    expect(results).toEqual(['mung', 'face'])
-  })
-})
-
-describe('invokables', () => {
-  type Invokables = CreateInvokablesDict<{
-    foo: { args: []; return: string }
-    bar: { args: [string]; return: string }
-    afoo: { args: [string]; return: string }
-    never: { args: []; return: Promise }
-  }>
-  let messageBus: MessageBus<
-    CreateMessageBusContext<{ invokables: Invokables }>
-  >
-  let broker: Broker>
-  let foo: Mock<() => string>
-  let bar: Mock<(x: string) => string>
-
-  beforeEach(() => {
-    messageBus = new MessageBus()
-    broker = messageBus.broker('test')
-    foo = vi.fn(() => 'foo')
-    bar = vi.fn((x: string) => x + '1')
-    broker.register('foo', foo)
-    broker.register('bar', bar)
-    broker.register(
-      'never',
-      (abortSignal) =>
-        new Promise((_, reject) => {
-          abortSignal.onabort = () => reject(new AbortError())
-        }),
-    )
-  })
-
-  test('returning values', async () => {
-    messageBus.start()
-    expect(await broker.invoke('foo')).toEqual('foo')
-    expect(await broker.invoke('bar', 'hello')).toEqual('hello1')
-  })
-
-  test('queued messages', async () => {
-    const promise = broker.invoke('foo')
-    messageBus.start()
-    expect(await promise).toBe('foo')
-  })
-
-  test('invoking unregistered', async () => {
-    messageBus.start()
-    try {
-      // @ts-ignore
-      await broker.invoke('not register')
-    } catch (error) {
-      expect(error).toHaveProperty(
-        'message',
-        'Cannot find matching invoker for "not register".',
-      )
-      return
-    }
-    throw new Error('Invoking an unregistered endpoint should error')
-  })
-
-  test('registering more than once', () => {
-    expect(() => {
-      broker.register('foo', () => 'foo')
-    }).toThrowError()
-
-    broker.register('afoo', 'foo', () => 'foo')
-    broker.register('afoo', 'mung', () => 'face')
-
-    expect(() => {
-      broker.register('afoo', 'foo', () => 'foo')
-    }).toThrowError()
-  })
-
-  test('intercept invokers', async () => {
-    messageBus.start()
-    broker.interceptInvoker('bar', (next, x) => next(x + '1'))
-    expect(await broker.invoke('bar', 'hello')).toEqual('hello11')
-  })
-
-  test('intercepting with indexed parameters', async () => {
-    messageBus.start()
-    const match = 'hello'
-    broker.interceptInvoker('bar', match, async (next) => {
-      const result = await next(match)
-      return result + ' foo'
-    })
-    expect(await broker.invoke('bar', 'no intercept')).toEqual('no intercept1')
-    expect(await broker.invoke('bar', 'hello')).toEqual('hello1 foo')
-  })
-
-  test('aborting will throw AbortError', async () => {
-    messageBus.start()
-    const result = broker.invoke('never')
-    broker.abort()
-    await expect(result).rejects.toThrow('Async operation was aborted')
-  })
-})
-
-describe('error handling', () => {
-  type Events = { foo: []; bar: [string] }
-  let messageBus: MessageBus>
-  let broker: Broker>
-
-  beforeEach(() => {
-    messageBus = new MessageBus()
-    broker = messageBus.broker('test')
-  })
-
-  test('immediately throing errors inside subscribers', () =>
-    new Promise((resolve) => {
-      messageBus.start()
-      messageBus.onError((error) => {
-        expect(error).toBeInstanceOf(MessageBusError)
-        expect(error.message).toBe('test[foo]: Foo errored')
-        resolve()
-      })
-      broker.on('foo', () => {
-        throw new Error('Foo errored')
-      })
-      broker.emit('foo')
-    }))
-
-  test('queuing errors inside subscribers', () =>
-    new Promise((resolve) => {
-      messageBus.onError((error) => {
-        expect(error).toBeInstanceOf(MessageBusError)
-        expect(error.message).toBe('test[foo]: Foo errored')
-        resolve()
-      })
-      broker.on('foo', () => {
-        throw new Error('Foo errored')
-      })
-      broker.emit('foo')
-      messageBus.start()
-    }))
-})
-
-describe('streams', () => {
-  type Streamables = { foo: { args: [number]; item: string } }
-  let messageBus: MessageBus>
-  let broker: Broker>
-
-  beforeEach(() => {
-    messageBus = new MessageBus()
-    messageBus.start()
-    broker = messageBus.broker('test')
-  })
-
-  test('single reader', async () => {
-    const fn = vi.fn()
-
-    broker.reader('foo', (x) => ({
-      start(controller) {
-        controller.enqueue(x.toString())
-        controller.close()
-      },
-    }))
-
-    await broker.stream('foo', 100).pipeTo(write(fn))
-
-    expect(fn).toHaveBeenCalledTimes(1)
-    expect(fn.mock.calls[0][0]).toBe('100')
-  })
-
-  test('multiple readers', async () => {
-    const fn = vi.fn()
-
-    broker.reader('foo', (x) => ({
-      start(controller) {
-        controller.enqueue(x.toString())
-        controller.close()
-      },
-    }))
-
-    broker.reader('foo', (x) => ({
-      start(controller) {
-        controller.enqueue(x.toString())
-        controller.close()
-      },
-    }))
-
-    await broker.stream('foo', 100).pipeTo(write(fn))
-
-    expect(fn).toHaveBeenCalledTimes(2)
-    expect(fn.mock.calls[0][0]).toBe('100')
-    expect(fn.mock.calls[1][0]).toBe('100')
-  })
-
-  test('aborting', async () => {
-    broker.reader('foo', (x) => ({
-      start(controller) {
-        controller.enqueue(x.toString())
-        controller.close()
-      },
-    }))
-
-    const promise = broker.stream('foo', 100).pipeTo(write())
-
-    broker.abort()
-
-    await expect(promise).rejects.toThrow()
-  })
-
-  test('specification filtering', async () => {
-    const fn = vi.fn()
-
-    broker.reader('foo', 10, () => ({
-      start(controller) {
-        controller.enqueue('10')
-        controller.close()
-      },
-    }))
-
-    broker.reader('foo', (x) => ({
-      start(controller) {
-        controller.enqueue(x.toString())
-        controller.close()
-      },
-    }))
-
-    await broker.stream('foo', 100).pipeTo(write(fn))
-
-    expect(fn).toHaveBeenCalledTimes(1)
-    expect(fn.mock.calls[0][0]).toBe('100')
-  })
-
-  test('specification applying', async () => {
-    const fn = vi.fn()
-
-    broker.reader('foo', 10, () => ({
-      start(controller) {
-        controller.enqueue('10')
-        controller.close()
-      },
-    }))
-
-    broker.reader('foo', (x) => ({
-      start(controller) {
-        controller.enqueue(x.toString())
-        controller.close()
-      },
-    }))
-
-    await broker.stream('foo', 10).pipeTo(write(fn))
-
-    expect(fn).toHaveBeenCalledTimes(2)
-    expect(fn.mock.calls[0][0]).toBe('10')
-    expect(fn.mock.calls[1][0]).toBe('10')
-  })
-})
-
-describe('matchers', () => {
-  type $ = CreateMessageBusContext<{
-    events: {
-      foo: [{ bar: number; baz?: string }]
-      keyed: [{ id: number }, string]
-      nested: [{ meta: { tag: string } }]
-      num: [number]
-    }
-    invokables: {
-      handle: { args: [{ id: number }]; return: string }
-    }
-    streams: { feed: { args: [{ kind: string }]; item: string } }
-    generators: {
-      gen: { args: [{ tag: string }]; yield: string }
-    }
-  }>
-
-  let messageBus: MessageBus<$>
-  let broker: Broker<$>
-
-  beforeEach(() => {
-    messageBus = new MessageBus()
-    broker = messageBus.broker('test')
-    messageBus.start()
-  })
-
-  test('objectWith fires only on matching objects', () => {
-    const fn = vi.fn()
-    broker.on('foo', objectWith({ bar: 2 }), fn)
-    broker.emit('foo', { bar: 1 })
-    broker.emit('foo', { bar: 2 })
-    broker.emit('foo', { bar: 2, baz: 'x' })
-    expect(fn).toHaveBeenCalledTimes(2)
-  })
-
-  test('the matched argument is consumed', () => {
-    const fn = vi.fn()
-    broker.on('keyed', objectWith({ id: 1 }), fn)
-    broker.emit('keyed', { id: 1 }, 'hello')
-    expect(fn).toHaveBeenCalledWith('hello', expect.any(AbortSignal))
-  })
-
-  test('objectWith matches nested objects deeply', () => {
-    const fn = vi.fn()
-    broker.on('nested', objectWith({ meta: { tag: 'a' } }), fn)
-    broker.emit('nested', { meta: { tag: 'b' } })
-    broker.emit('nested', { meta: { tag: 'a' } })
-    expect(fn).toHaveBeenCalledTimes(1)
-  })
-
-  test('predicate matches arbitrary values', () => {
-    const fn = vi.fn()
-    broker.on(
-      'num',
-      predicate((n: number) => n > 2),
-      fn,
-    )
-    broker.emit('num', 1)
-    broker.emit('num', 3)
-    expect(fn).toHaveBeenCalledTimes(1)
-    expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal))
-  })
-
-  test('matchers filter invoker registrations', async () => {
-    broker.register('handle', objectWith({ id: 1 }), () => 'one')
-    broker.register('handle', () => 'other')
-    expect(await broker.invoke('handle', { id: 1 })).toBe('one')
-    expect(await broker.invoke('handle', { id: 2 })).toBe('other')
-  })
-
-  test('matchers filter stream readers', async () => {
-    const fn = vi.fn()
-    broker.reader('feed', objectWith({ kind: 'a' }), () => ({
-      start(controller) {
-        controller.enqueue('matched')
-        controller.close()
-      },
-    }))
-    broker.reader('feed', objectWith({ kind: 'b' }), () => ({
-      start(controller) {
-        controller.enqueue('ERROR')
-        controller.close()
-      },
-    }))
-    await broker.stream('feed', { kind: 'a' }).pipeTo(write(fn))
-    expect(fn).toHaveBeenCalledTimes(1)
-    expect(fn.mock.calls[0][0]).toBe('matched')
-  })
-
-  test('matchers filter generators', async () => {
-    const results: string[] = []
-    broker.generator('gen', objectWith({ tag: 'a' }), async function* () {
-      yield 'matched'
-    })
-    broker.generator('gen', objectWith({ tag: 'b' }), async function* () {
-      yield 'ERROR'
-    })
-    for await (const result of broker.iterate('gen', { tag: 'a' }))
-      results.push(result)
-    expect(results).toEqual(['matched'])
-  })
-})
diff --git a/packages/message-bus/test/broker-lifecycle.test.ts b/packages/message-bus/test/broker-lifecycle.test.ts
new file mode 100644
index 000000000..41cf75267
--- /dev/null
+++ b/packages/message-bus/test/broker-lifecycle.test.ts
@@ -0,0 +1,275 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+import { Bus } from '../src/Bus.js'
+import { Event, Invocation } from '../src/Event.js'
+import { CANCEL } from '../src/EventListener.js'
+import type { PluginBroker } from '../src/Broker/PluginBroker.js'
+
+let bus: Bus
+let brokerA: PluginBroker
+let brokerB: PluginBroker
+
+beforeEach(() => {
+  bus = new Bus()
+  brokerA = bus.broker('a')
+  brokerB = bus.broker('b')
+  bus.resume()
+})
+
+describe('unsubscribe', () => {
+  test('on() returns a disposer that stops delivery', () => {
+    const spy = vi.fn()
+    const off = brokerA.on(TestEvent, spy)
+    brokerB.emit(new TestEvent('one'))
+    off()
+    brokerB.emit(new TestEvent('two'))
+    expect(spy).toHaveBeenCalledTimes(1)
+  })
+
+  test('a disposed listener does not resubscribe on a later emit', () => {
+    const spy = vi.fn()
+    const off = brokerA.on(TestEvent, spy)
+    off()
+    brokerB.emit(new TestEvent('x'))
+    brokerB.emit(new TestEvent('y'))
+    expect(spy).not.toHaveBeenCalled()
+  })
+
+  test('intercept() returns a disposer', async () => {
+    const spy = vi.fn((e: TestEvent) => new TestEvent(`i ${e.foo}`))
+    const off = brokerA.intercept(TestEvent, spy)
+    off()
+    const onSpy = vi.fn()
+    brokerB.on(TestEvent, onSpy)
+    await brokerB.emit(new TestEvent('foo'))
+    expect(spy).not.toHaveBeenCalled()
+    expect(onSpy).toHaveBeenCalledWith(new TestEvent('foo'))
+  })
+})
+
+describe('abort', () => {
+  test('clears the aborted broker’s event handlers', () => {
+    const spy = vi.fn()
+    brokerA.on(TestEvent, spy)
+    brokerB.abort('a')
+    brokerB.emit(new TestEvent('foo'))
+    expect(spy).not.toHaveBeenCalled()
+  })
+
+  test('removes the broker from the bus so its name can be reused', () => {
+    brokerA.abort('a')
+    expect(() => bus.broker('a')).not.toThrow()
+  })
+
+  test('onAbort fires once with the reason', () => {
+    const spy = vi.fn()
+    brokerA.onAbort(spy)
+    const reason = new Error('bye')
+    brokerB.abort('a', reason)
+    expect(brokerA.aborted).toBe(true)
+    expect(brokerA.abortReason).toBe(reason)
+    expect(spy).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe('registering the same broker name twice', () => {
+  test('throws', () => {
+    expect(() => bus.broker('a')).toThrow(/already been registered/)
+  })
+})
+
+describe('pause / resume', () => {
+  test('events emitted while a broker is paused are buffered then replayed', () => {
+    const spy = vi.fn()
+    brokerB.pause('a')
+    brokerA.on(TestEvent, spy)
+    brokerB.emit(new TestEvent('foo'))
+    expect(spy).not.toHaveBeenCalled()
+    brokerB.resume('a')
+    expect(spy).toHaveBeenCalledTimes(1)
+  })
+
+  test('a paused broker does not intercept', async () => {
+    const spy = vi.fn((e: TestEvent) => new TestEvent(`i ${e.foo}`))
+    brokerA.intercept(TestEvent, spy)
+    brokerB.pause('a')
+    const onSpy = vi.fn()
+    brokerB.on(TestEvent, onSpy)
+    await brokerB.emit(new TestEvent('foo'))
+    expect(spy).not.toHaveBeenCalled()
+    expect(onSpy).toHaveBeenCalledWith(new TestEvent('foo'))
+  })
+})
+
+describe('invoke completion', () => {
+  test('collect resolves to [] when there are no registrants', async () => {
+    expect(await brokerB.invoke(new TestInvocation('x')).collect()).toEqual([])
+  })
+
+  test('collect resolves to [] when the only registrant is paused', async () => {
+    brokerA.register(TestInvocation, (_e, { send }) => {
+      send('nope')
+    })
+    brokerB.pause('a')
+    expect(await brokerB.invoke(new TestInvocation('x')).collect()).toEqual([])
+  })
+
+  test('a filtered-out handler does not hang the stream', async () => {
+    // Regression: completion is per-handler settling, so a handler whose filter
+    // rejects the event completes immediately instead of deadlocking collect().
+    brokerA.register(TestInvocation, { foo: 'never' }, (_e, { send }) => {
+      send('nope')
+    })
+    expect(
+      await brokerB.invoke(new TestInvocation('actual')).collect(),
+    ).toEqual([])
+  })
+
+  test('completes when a handler returns without any explicit signal', async () => {
+    brokerA.register(TestInvocation, (_e, { send }) => {
+      send('a')
+      send('b')
+    })
+    expect(await brokerB.invoke(new TestInvocation('x')).collect()).toEqual([
+      'a',
+      'b',
+    ])
+  })
+
+  test('waits for an async handler to settle before completing', async () => {
+    brokerA.register(TestInvocation, async (_e, { send }) => {
+      send('first')
+      await Promise.resolve()
+      await Promise.resolve()
+      send('second')
+    })
+    expect(await brokerB.invoke(new TestInvocation('x')).collect()).toEqual([
+      'first',
+      'second',
+    ])
+  })
+
+  test('a throwing handler rejects the stream when no onError is given', async () => {
+    brokerA.register(TestInvocation, () => {
+      throw new Error('boom')
+    })
+    await expect(
+      brokerB.invoke(new TestInvocation('x')).collect(),
+    ).rejects.toThrow('boom')
+  })
+
+  test('onError isolates a throwing handler and the stream still completes', async () => {
+    brokerA.register(TestInvocation, () => {
+      throw new Error('boom')
+    })
+    brokerB.register(TestInvocation, (_e, { send }) => send('ok'))
+
+    const onError = vi.fn()
+    const items = await brokerB
+      .invoke(new TestInvocation('x'), { onError })
+      .collect()
+
+    expect(items).toEqual(['ok'])
+    expect(onError).toHaveBeenCalledTimes(1)
+    expect(onError.mock.calls[0][0]).toBeInstanceOf(Error)
+    expect((onError.mock.calls[0][0] as Error).message).toBe('boom')
+  })
+
+  test('onError also catches a rejected async handler', async () => {
+    brokerA.register(TestInvocation, async () => {
+      throw new Error('async boom')
+    })
+
+    const onError = vi.fn()
+    const items = await brokerA
+      .invoke(new TestInvocation('x'), { onError })
+      .collect()
+
+    expect(items).toEqual([])
+    expect((onError.mock.calls[0][0] as Error).message).toBe('async boom')
+  })
+
+  test('onError receives an error from every failing handler across brokers', async () => {
+    brokerA.register(TestInvocation, () => {
+      throw new Error('a')
+    })
+    brokerB.register(TestInvocation, () => {
+      throw new Error('b')
+    })
+
+    const onError = vi.fn()
+    await brokerA.invoke(new TestInvocation('x'), { onError }).collect()
+
+    const messages = onError.mock.calls.map((c) => (c[0] as Error).message)
+    expect(messages.sort()).toEqual(['a', 'b'])
+  })
+
+  test('completes across multiple brokers each streaming', async () => {
+    brokerA.register(TestInvocation, (_e, { send }) => send('a'))
+    brokerB.register(TestInvocation, async (_e, { send }) => {
+      await Promise.resolve()
+      send('b')
+    })
+    const items = await brokerA.invoke(new TestInvocation('x')).collect()
+    expect(items.sort()).toEqual(['a', 'b'])
+  })
+})
+
+describe('multiple interceptors', () => {
+  test('are applied in a chain', async () => {
+    brokerA.intercept(TestEvent, (e) => new TestEvent(`${e.foo}-a`))
+    brokerB.intercept(TestEvent, (e) => new TestEvent(`${e.foo}-b`))
+    const spy = vi.fn()
+    brokerA.on(TestEvent, spy)
+    await brokerB.emit(new TestEvent('start'))
+    const received = spy.mock.calls[0]?.[0] as TestEvent
+    expect(received.foo).toMatch(/^start-/)
+  })
+
+  test('a later interceptor can cancel after an earlier one transformed', async () => {
+    brokerA.intercept(TestEvent, (e) => new TestEvent(`${e.foo}-a`))
+    brokerB.intercept(TestEvent, () => CANCEL)
+    const spy = vi.fn()
+    brokerA.on(TestEvent, spy)
+    const result = await brokerB.emit(new TestEvent('start'))
+    expect(result).toBe(CANCEL)
+    expect(spy).not.toHaveBeenCalled()
+  })
+})
+
+describe('emit return value', () => {
+  test('each call resolves to its own (possibly intercepted) event', async () => {
+    brokerA.intercept(TestEvent, (e) => new TestEvent(`i ${e.foo}`))
+    const first = (await brokerB.emit(new TestEvent('one'))) as TestEvent
+    const second = (await brokerB.emit(new TestEvent('two'))) as TestEvent
+    expect(first.foo).toBe('i one')
+    expect(second.foo).toBe('i two')
+  })
+
+  test('a cancelled emit resolves to CANCEL without leaking to later emits', async () => {
+    brokerA.intercept(TestEvent, { foo: 'kill' }, () => CANCEL)
+    expect(await brokerB.emit(new TestEvent('kill'))).toBe(CANCEL)
+    const after = (await brokerB.emit(new TestEvent('ok'))) as TestEvent
+    expect(after.foo).toBe('ok')
+  })
+})
+
+describe('until', () => {
+  test('rejects when the broker aborts before the event arrives', async () => {
+    const promise = brokerA.until(TestEvent)
+    const reason = new Error('gone')
+    brokerB.abort('a', reason)
+    await expect(promise).rejects.toBe(reason)
+  })
+})
+
+class TestEvent implements Event {
+  $name = 'test'
+  constructor(readonly foo: string) {}
+}
+
+class TestInvocation extends Invocation {
+  $name = 'test invocation'
+  constructor(readonly foo: string) {
+    super()
+  }
+}
diff --git a/packages/message-bus/src/dmg-bus/Broker/broker.test.ts b/packages/message-bus/test/broker.test.ts
similarity index 80%
rename from packages/message-bus/src/dmg-bus/Broker/broker.test.ts
rename to packages/message-bus/test/broker.test.ts
index e0c63fe96..58705e3d2 100644
--- a/packages/message-bus/src/dmg-bus/Broker/broker.test.ts
+++ b/packages/message-bus/test/broker.test.ts
@@ -1,9 +1,9 @@
-import { timeout } from '../lang/Signal.js'
+import { setTimeout } from 'node:timers/promises'
 import { beforeEach, describe, expect, Mock, test, vi } from 'vitest'
-import { Bus } from '../Bus.js'
-import { Event, Invocation } from '../Event.js'
-import { CANCEL, type InvocationListenerContext } from '../EventListener.js'
-import type { PluginBroker } from './PluginBroker.js'
+import { Bus } from '../src/Bus.js'
+import { Event, Invocation } from '../src/Event.js'
+import { CANCEL, type InvocationListenerContext } from '../src/EventListener.js'
+import type { PluginBroker } from '../src/Broker/PluginBroker.js'
 
 let brokerA: PluginBroker
 let brokerB: PluginBroker
@@ -12,7 +12,7 @@ beforeEach(() => {
   const bus = new Bus()
   brokerA = bus.broker('a')
   brokerB = bus.broker('b')
-  bus.start()
+  bus.resume()
 })
 
 test('emit', () => {
@@ -49,13 +49,13 @@ test('until', async () => {
   expect(await promise).toEqual(event)
 })
 
-test('stop', () => {
+test('pause', () => {
   const spy = vi.fn()
-  brokerB.stop('a')
+  brokerB.pause('a')
   brokerA.on(TestEvent, spy)
   brokerB.emit(new TestEvent('foo'))
   expect(spy).not.toHaveBeenCalled()
-  brokerB.start('a')
+  brokerB.resume('a')
   expect(spy).toHaveBeenCalled()
 })
 
@@ -70,15 +70,14 @@ describe('invoke', () => {
   let spy: Mock<
     (
       event: TestInvocation,
-      { send, finish }: InvocationListenerContext,
+      context: InvocationListenerContext,
     ) => void
   >
 
   beforeEach(() => {
-    spy = vi.fn((event, { send, finish }) => {
+    spy = vi.fn((event, { send }) => {
       send(`one ${event.foo}`)
       send(`two ${event.foo}`)
-      finish()
     })
 
     brokerA.register(TestInvocation, spy)
@@ -108,14 +107,12 @@ describe('invoke', () => {
   })
 
   test('multi registers', async () => {
-    brokerA.register(TestInvocation, (_, { finish, send }) => {
+    brokerA.register(TestInvocation, (_, { send }) => {
       send('foo')
-      finish()
     })
 
-    brokerB.register(TestInvocation, (_, { finish, send }) => {
+    brokerB.register(TestInvocation, (_, { send }) => {
       send('bar')
-      finish()
     })
 
     expect(await brokerB.invoke(new TestInvocation('foo')).collect()).toEqual([
@@ -127,15 +124,13 @@ describe('invoke', () => {
   })
 
   test('timeouts', async () => {
-    brokerA.register(
-      TestInvocation,
-      async (event, { finish, send, signal }) => {
-        send(`hello ${event.foo}`)
-        await timeout(1_000, signal)
-        send(`hello again ${event.foo}`)
-        finish()
-      },
-    )
+    brokerA.register(TestInvocation, async (event, { send, signal }) => {
+      send(`hello ${event.foo}`)
+      try {
+        await setTimeout(1_000, null, { signal })
+      } catch (error) {}
+      send(`hello again ${event.foo}`)
+    })
 
     expect(
       await brokerB
@@ -167,9 +162,8 @@ describe('intercept', () => {
 
   test('changing invocations', async () => {
     const spy = vi.fn()
-    brokerA.register(TestInvocation, (event, { finish }) => {
+    brokerA.register(TestInvocation, (event) => {
       spy(event)
-      finish()
     })
     brokerB.intercept(
       TestInvocation,
diff --git a/packages/message-bus/test/filter.test.ts b/packages/message-bus/test/filter.test.ts
new file mode 100644
index 000000000..43bbd8ae1
--- /dev/null
+++ b/packages/message-bus/test/filter.test.ts
@@ -0,0 +1,74 @@
+import { beforeEach, expect, test, vi } from 'vitest'
+import { Bus } from '../src/Bus.js'
+import { Event } from '../src/Event.js'
+import type { PluginBroker } from '../src/Broker/PluginBroker.js'
+
+let brokerA: PluginBroker
+let brokerB: PluginBroker
+
+beforeEach(() => {
+  const bus = new Bus()
+  brokerA = bus.broker('a')
+  brokerB = bus.broker('b')
+  bus.resume()
+})
+
+test('an empty filter matches every event', () => {
+  const spy = vi.fn()
+  brokerA.on(TestEvent, {}, spy)
+  brokerB.emit(new TestEvent('anything'))
+  expect(spy).toHaveBeenCalledTimes(1)
+})
+
+test('a value filter matches on equality', () => {
+  const spy = vi.fn()
+  brokerA.on(TestEvent, { foo: 'yes' }, spy)
+  brokerB.emit(new TestEvent('no'))
+  expect(spy).not.toHaveBeenCalled()
+  brokerB.emit(new TestEvent('yes'))
+  expect(spy).toHaveBeenCalledTimes(1)
+})
+
+test('a predicate filter matches on the returned boolean', () => {
+  const spy = vi.fn()
+  brokerA.on(TestEvent, { foo: (e) => e.foo.startsWith('a') }, spy)
+  brokerB.emit(new TestEvent('bee'))
+  expect(spy).not.toHaveBeenCalled()
+  brokerB.emit(new TestEvent('ant'))
+  expect(spy).toHaveBeenCalledTimes(1)
+})
+
+test('a multi-key filter requires every key to match (AND, not OR)', () => {
+  const spy = vi.fn()
+  brokerA.on(TestEvent, { foo: 'match', bar: 'match' }, spy)
+
+  // only foo matches
+  brokerB.emit(new TestEvent('match', 'other'))
+  // only bar matches
+  brokerB.emit(new TestEvent('other', 'match'))
+  expect(spy).not.toHaveBeenCalled()
+
+  // both match
+  brokerB.emit(new TestEvent('match', 'match'))
+  expect(spy).toHaveBeenCalledTimes(1)
+})
+
+test('a multi-key filter mixes value and predicate keys with AND', () => {
+  const spy = vi.fn()
+  brokerA.on(TestEvent, { foo: 'match', bar: (e) => e.bar.length > 2 }, spy)
+
+  brokerB.emit(new TestEvent('match', 'no')) // predicate fails
+  brokerB.emit(new TestEvent('nope', 'yesss')) // value fails
+  expect(spy).not.toHaveBeenCalled()
+
+  brokerB.emit(new TestEvent('match', 'yesss'))
+  expect(spy).toHaveBeenCalledTimes(1)
+})
+
+class TestEvent implements Event {
+  $name = 'test'
+  constructor(
+    readonly foo: string,
+    readonly bar: string = 'bar',
+  ) {}
+}
diff --git a/packages/message-bus/test/queue.test.ts b/packages/message-bus/test/queue.test.ts
new file mode 100644
index 000000000..f593dc8ca
--- /dev/null
+++ b/packages/message-bus/test/queue.test.ts
@@ -0,0 +1,97 @@
+import { expect, test, vi } from 'vitest'
+import { DiscriminatedQueue } from '../src/Queue/DiscriminatedQueue.js'
+import { MethodQueue } from '../src/Queue/MethodQueue.js'
+import { Queue } from '../src/Queue/Queue.js'
+
+test('discriminated queue', () => {
+  const fooSpy = vi.fn((_item: { name: string }) => {})
+  const barSpy = vi.fn((_item: { num: number }) => {})
+
+  const queue = new DiscriminatedQueue({
+    foo: fooSpy,
+    bar: barSpy,
+  })
+
+  queue.push({ type: 'foo', name: 'My name' })
+  queue.push({ type: 'bar', num: 123 })
+
+  expect(fooSpy).not.toHaveBeenCalled()
+  expect(barSpy).not.toHaveBeenCalled()
+
+  queue.start()
+
+  expect(fooSpy).toHaveBeenCalledWith({ type: 'foo', name: 'My name' })
+  expect(barSpy).toHaveBeenCalledWith({ type: 'bar', num: 123 })
+})
+
+test('Queue buffers while stopped and drains in FIFO order on start', () => {
+  const seen: number[] = []
+  const queue = new Queue((n) => seen.push(n))
+  queue.push(1)
+  queue.push(2)
+  queue.push(3)
+  expect(seen).toEqual([])
+  queue.start()
+  expect(seen).toEqual([1, 2, 3])
+})
+
+test('Queue executes immediately once running', () => {
+  const seen: number[] = []
+  const queue = new Queue((n) => seen.push(n))
+  queue.start()
+  queue.push(1)
+  expect(seen).toEqual([1])
+})
+
+test('Queue stops draining when stopped mid-flight', () => {
+  const seen: number[] = []
+  const queue = new Queue((n) => {
+    seen.push(n)
+    if (n === 2) queue.stop()
+  })
+  queue.push(1)
+  queue.push(2)
+  queue.push(3)
+  queue.start()
+  expect(seen).toEqual([1, 2])
+})
+
+test('MethodQueue defers a call until started, then runs it', () => {
+  const queue = new MethodQueue()
+  const spy = vi.fn((a: number, b: number) => a + b)
+  const call = queue.queueMethod(spy)
+  call(2, 3)
+  expect(spy).not.toHaveBeenCalled()
+  queue.start()
+  expect(spy).toHaveBeenCalledWith(2, 3)
+})
+
+test('MethodQueue preserves running state when a method is queued after start', () => {
+  const queue = new MethodQueue()
+  queue.start()
+  expect(queue.running).toBe(true)
+  queue.queueMethod(() => {})
+  expect(queue.running).toBe(true)
+})
+
+test('MethodQueue runs a method queued after start immediately', () => {
+  const queue = new MethodQueue()
+  queue.start()
+  const spy = vi.fn()
+  const call = queue.queueMethod(spy)
+  call('hi')
+  expect(spy).toHaveBeenCalledWith('hi')
+})
+
+test('MethodQueue methods queued before and after start share running state', () => {
+  const queue = new MethodQueue()
+  const early = vi.fn()
+  const callEarly = queue.queueMethod(early)
+  queue.start()
+  const late = vi.fn()
+  const callLate = queue.queueMethod(late)
+  callEarly('a')
+  callLate('b')
+  expect(early).toHaveBeenCalledWith('a')
+  expect(late).toHaveBeenCalledWith('b')
+})
diff --git a/packages/message-bus/tsconfig.json b/packages/message-bus/tsconfig.json
index f61bf9eaa..c38ab81fb 100644
--- a/packages/message-bus/tsconfig.json
+++ b/packages/message-bus/tsconfig.json
@@ -6,6 +6,5 @@
     "lib": ["ESNext"],
     "outDir": "dist"
   },
-  "include": ["src"],
-  "references": [{ "path": "../invoke" }]
+  "include": ["src"]
 }

From 344cc71eb657911471ea26df10499ea1a6f9b214 Mon Sep 17 00:00:00 2001
From: John Wright 
Date: Thu, 17 Sep 2026 17:29:03 +0100
Subject: [PATCH 13/18] feat(message-bus): externally abortable brokers

Optionally pass an abort signal in to broker construction.
---
 packages/message-bus/src/Broker/Broker.ts | 15 +++++++++------
 packages/message-bus/src/Bus.ts           |  4 ++--
 2 files changed, 11 insertions(+), 8 deletions(-)

diff --git a/packages/message-bus/src/Broker/Broker.ts b/packages/message-bus/src/Broker/Broker.ts
index 9e3445e95..82a529d53 100644
--- a/packages/message-bus/src/Broker/Broker.ts
+++ b/packages/message-bus/src/Broker/Broker.ts
@@ -36,11 +36,18 @@ export class Broker {
 
   readonly #abortController = new AbortController()
 
+  readonly abortSignal: AbortSignal
+
   constructor(
     readonly bus: Bus,
     readonly name: string,
+    abortSignal?: AbortSignal,
   ) {
-    this.abortSignal.addEventListener('abort', () => {
+    this.abortSignal = abortSignal
+      ? AbortSignal.any([abortSignal, this.#abortController.signal])
+      : this.#abortController.signal
+
+    this.onAbort(() => {
       this.eventHandlers.clear()
       this.invokeHandlers.clear()
     })
@@ -53,12 +60,8 @@ export class Broker {
     })
   }
 
-  get abortSignal(): AbortSignal {
-    return this.#abortController.signal
-  }
-
   onAbort(fn: (reason: any) => any) {
-    this.abortSignal.addEventListener('abort', fn)
+    this.abortSignal.addEventListener('abort', fn, { once: true })
     return () => this.abortSignal.removeEventListener('abort', fn)
   }
 
diff --git a/packages/message-bus/src/Bus.ts b/packages/message-bus/src/Bus.ts
index 3c2dd2312..35a7acba6 100644
--- a/packages/message-bus/src/Bus.ts
+++ b/packages/message-bus/src/Bus.ts
@@ -102,11 +102,11 @@ export class Bus {
    *
    * @throws if a broker with `name` is already registered.
    */
-  broker(name: string) {
+  broker(name: string, abortSignal?: AbortSignal) {
     if (this.#brokers.has(name))
       throw new Error(`Broker "${name}" has already been registered`)
 
-    const broker = new Broker(this, name)
+    const broker = new Broker(this, name, abortSignal)
     this.#brokers.set(name, broker)
 
     broker.onAbort(() => {

From cc6c3abf17d7c113e8e4abc2069cb269cc825234 Mon Sep 17 00:00:00 2001
From: John Wright 
Date: Fri, 18 Sep 2026 13:35:34 +0100
Subject: [PATCH 14/18] refactor: separate streaming logic from broker

For a cleaner coding style.
---
 .../message-bus/src/Broker/PluginBroker.ts    | 160 ++++++++++--------
 1 file changed, 87 insertions(+), 73 deletions(-)

diff --git a/packages/message-bus/src/Broker/PluginBroker.ts b/packages/message-bus/src/Broker/PluginBroker.ts
index cd16000e9..31fbd80a1 100644
--- a/packages/message-bus/src/Broker/PluginBroker.ts
+++ b/packages/message-bus/src/Broker/PluginBroker.ts
@@ -1,3 +1,4 @@
+import { UnderlyingDefaultSource } from 'node:stream/web'
 import type {
   Event,
   EventClass,
@@ -7,11 +8,11 @@ import type {
 } from '../Event.js'
 import {
   CANCEL,
-  EventListener,
-  InterceptionListener,
-  InvocationErrorHandler,
-  InvocationListener,
-  InvocationListenerContext,
+  type EventListener,
+  type InterceptionListener,
+  type InvocationErrorHandler,
+  type InvocationListener,
+  type InvocationListenerContext,
 } from '../EventListener.js'
 import type { Filter } from '../Filter.js'
 import { getOrInsert } from '../lang/Map.js'
@@ -321,7 +322,9 @@ export class PluginBroker {
     event: E,
     {
       signal,
-      onError,
+      onError = (error) => {
+        throw error
+      },
     }: {
       signal?: AbortSignal
       onError?: InvocationErrorHandler
@@ -332,75 +335,12 @@ export class PluginBroker {
   } {
     type T = InvocationType
 
-    const self = this
-    const producer = new AbortController()
-    let settled = false
-    let teardown = (_reason?: any) => {}
-
-    // Without an onError observer, an unobserved handler error rethrows and so
-    // surfaces on the stream (fail-loud). Supplying onError isolates handlers:
-    // errors are reported there and the stream still completes.
-    const reportError: InvocationErrorHandler =
-      onError ??
-      ((error) => {
-        throw error
-      })
-
-    const readableStream = new ReadableStream({
-      start: (controller) => {
-        const offAbort = this.onAbort(abort)
-        signal?.addEventListener('abort', close)
-
-        teardown = (reason?: any) => {
-          if (settled) return
-          settled = true
-          offAbort()
-          signal?.removeEventListener('abort', close)
-          producer.abort(reason)
-        }
-
-        if (this.aborted) return abort()
-        if (signal?.aborted) return close()
-
-        // Completion is the settling of #invoke's promise: it resolves once
-        // every handler across every broker has returned, and rejects if one
-        // of them threw.
-        this.#invoke(
-          event,
-          {
-            send: (value: unknown) => {
-              if (settled) return
-              controller.enqueue(value as T)
-            },
-            signal: producer.signal,
-          },
-          reportError,
-        ).then(close, fail)
-
-        function close() {
-          if (settled) return
-          teardown(signal?.reason)
-          controller.close()
-        }
-
-        function abort() {
-          if (settled) return
-          teardown(self.abortReason)
-          controller.error(self.abortReason)
-        }
-
-        function fail(error: unknown) {
-          if (settled) return
-          teardown(error)
-          controller.error(error)
-        }
-      },
-
-      cancel: (reason) => teardown(reason),
-    })
+    const readableStream = new ReadableStream(
+      new InvocationSource(this, event, this.#invoke, onError, signal),
+    )
 
     return {
-      async collect() {
+      collect: async () => {
         const items: T[] = []
         for await (const item of readableStream) items.push(item)
         return items
@@ -432,3 +372,77 @@ export class PluginBroker {
     }
   }
 }
+
+class InvocationSource<
+  E extends Invocation,
+> implements UnderlyingDefaultSource> {
+  #producer = new AbortController()
+  #settled = false
+  #teardown = (_reason: any) => {}
+
+  constructor(
+    private readonly pluginBroker: PluginBroker,
+    private readonly event: E,
+    private readonly invoke: (
+      event: E,
+      params: InvocationListenerContext,
+      reportError: InvocationErrorHandler,
+    ) => Promise,
+    private readonly onError: InvocationErrorHandler,
+    private readonly signal?: AbortSignal,
+  ) {}
+
+  start(controller: ReadableStreamDefaultController>) {
+    const offAbort = this.pluginBroker.onAbort(() => this.#abort(controller))
+    const close = () => this.#close(controller)
+    this.signal?.addEventListener('abort', close, { once: true })
+
+    this.#teardown = (reason: any) => {
+      if (this.#settled) return
+      this.#settled = true
+      offAbort()
+      this.signal?.removeEventListener('abort', close)
+      this.#producer.abort(reason)
+    }
+
+    if (this.pluginBroker.aborted) return this.#abort(controller)
+    if (this.signal?.aborted) return this.#close(controller)
+
+    // Completion is the settling of #invoke's promise: it resolves once
+    // every handler across every broker has returned, and rejects if one
+    // of them threw.
+    this.invoke(
+      this.event,
+      {
+        send: (value: unknown) => {
+          if (this.#settled) return
+          controller.enqueue(value as InvocationType)
+        },
+        signal: this.#producer.signal,
+      },
+      this.onError,
+    ).then(
+      () => this.#close(controller),
+      (reason: any) => this.#abort(controller, reason),
+    )
+  }
+
+  cancel(reason: any) {
+    this.#teardown(reason)
+  }
+
+  #close(controller: ReadableStreamDefaultController>) {
+    if (this.#settled) return
+    this.#teardown(this.signal?.reason)
+    controller.close()
+  }
+
+  #abort(
+    controller: ReadableStreamDefaultController>,
+    reason = this.pluginBroker.abortReason,
+  ) {
+    if (this.#settled) return
+    this.#teardown(reason)
+    controller.error(reason)
+  }
+}

From e3b54f0d1f15871a0926254fe5a6e0eaeec90e0b Mon Sep 17 00:00:00 2001
From: John Wright 
Date: Fri, 18 Sep 2026 15:12:10 +0100
Subject: [PATCH 15/18] refactor: abstract abort handling

---
 packages/message-bus/src/Broker/Broker.ts     |  8 ++-
 .../message-bus/src/Broker/PluginBroker.ts    | 51 +++++++++----------
 packages/message-bus/src/lang/AbortSignal.ts  | 15 ++++++
 .../message-bus/test/broker-lifecycle.test.ts |  4 +-
 4 files changed, 46 insertions(+), 32 deletions(-)
 create mode 100644 packages/message-bus/src/lang/AbortSignal.ts

diff --git a/packages/message-bus/src/Broker/Broker.ts b/packages/message-bus/src/Broker/Broker.ts
index 82a529d53..1a3583153 100644
--- a/packages/message-bus/src/Broker/Broker.ts
+++ b/packages/message-bus/src/Broker/Broker.ts
@@ -10,6 +10,7 @@ import {
   type InvocationErrorHandler,
   type InvocationListenerContext,
 } from '../EventListener.js'
+import { onAbort } from '../lang/AbortSignal.js'
 import { MethodQueue } from '../Queue/MethodQueue.js'
 import { Handler } from './Handler.js'
 import { PluginBroker } from './PluginBroker.js'
@@ -61,8 +62,11 @@ export class Broker {
   }
 
   onAbort(fn: (reason: any) => any) {
-    this.abortSignal.addEventListener('abort', fn, { once: true })
-    return () => this.abortSignal.removeEventListener('abort', fn)
+    return onAbort(fn, this.abortSignal)
+  }
+
+  get aborted() {
+    return this.abortSignal.aborted
   }
 
   async intercept(
diff --git a/packages/message-bus/src/Broker/PluginBroker.ts b/packages/message-bus/src/Broker/PluginBroker.ts
index 31fbd80a1..86f039587 100644
--- a/packages/message-bus/src/Broker/PluginBroker.ts
+++ b/packages/message-bus/src/Broker/PluginBroker.ts
@@ -18,6 +18,7 @@ import type { Filter } from '../Filter.js'
 import { getOrInsert } from '../lang/Map.js'
 import type { Broker } from './Broker.js'
 import { Handler } from './Handler.js'
+import { onAbort } from '../lang/AbortSignal.js'
 
 /**
  * A participant's handle on the {@link Bus} — what `bus.broker(name)` returns.
@@ -81,23 +82,12 @@ export class PluginBroker {
     return this.#broker.abortSignal
   }
 
-  /** Whether this broker has been aborted. */
-  get aborted() {
-    return this.abortSignal.aborted
-  }
-
-  /** The reason passed to the abort that tore this broker down, if any. */
-  get abortReason() {
-    return this.abortSignal.reason
-  }
-
   /**
    * Runs `fn` once, when this broker is aborted (immediately if it already has).
    * Returns a disposer that removes the listener.
    */
   onAbort(fn: (reason: any) => any) {
-    this.abortSignal.addEventListener('abort', fn, { once: true })
-    return () => this.abortSignal.removeEventListener('abort', fn)
+    return onAbort(fn, this.abortSignal)
   }
 
   /**
@@ -209,13 +199,12 @@ export class PluginBroker {
     filter: Filter = {},
   ): Promise> {
     return new Promise((resolve, reject) => {
-      const onAbort = () => reject(this.abortSignal.reason)
-      if (this.abortSignal.aborted) return onAbort()
-      this.abortSignal.addEventListener('abort', onAbort)
-      this.once(eventClass, filter, (event) => {
-        this.abortSignal.removeEventListener('abort', onAbort)
-        resolve(event)
-      })
+      const { aborted, off } = onAbort(reject, this.abortSignal)
+      if (!aborted)
+        this.once(eventClass, filter, (event) => {
+          off()
+          resolve(event)
+        })
     })
   }
 
@@ -376,7 +365,7 @@ export class PluginBroker {
 class InvocationSource<
   E extends Invocation,
 > implements UnderlyingDefaultSource> {
-  #producer = new AbortController()
+  readonly #producer = new AbortController()
   #settled = false
   #teardown = (_reason: any) => {}
 
@@ -393,20 +382,26 @@ class InvocationSource<
   ) {}
 
   start(controller: ReadableStreamDefaultController>) {
-    const offAbort = this.pluginBroker.onAbort(() => this.#abort(controller))
-    const close = () => this.#close(controller)
-    this.signal?.addEventListener('abort', close, { once: true })
+    let aborted = false
+    let offAbort = () => {}
+    let offBrokerAbort = () => {}
 
     this.#teardown = (reason: any) => {
       if (this.#settled) return
       this.#settled = true
       offAbort()
-      this.signal?.removeEventListener('abort', close)
+      offBrokerAbort()
       this.#producer.abort(reason)
     }
-
-    if (this.pluginBroker.aborted) return this.#abort(controller)
-    if (this.signal?.aborted) return this.#close(controller)
+    ;({ aborted, off: offBrokerAbort } = this.pluginBroker.onAbort(() =>
+      this.#abort(controller),
+    ))
+    if (aborted) return
+    ;({ aborted, off: offAbort } = onAbort(
+      () => this.#close(controller),
+      this.signal,
+    ))
+    if (aborted) return
 
     // Completion is the settling of #invoke's promise: it resolves once
     // every handler across every broker has returned, and rejects if one
@@ -439,7 +434,7 @@ class InvocationSource<
 
   #abort(
     controller: ReadableStreamDefaultController>,
-    reason = this.pluginBroker.abortReason,
+    reason = this.pluginBroker.abortSignal.reason,
   ) {
     if (this.#settled) return
     this.#teardown(reason)
diff --git a/packages/message-bus/src/lang/AbortSignal.ts b/packages/message-bus/src/lang/AbortSignal.ts
new file mode 100644
index 000000000..9b306e339
--- /dev/null
+++ b/packages/message-bus/src/lang/AbortSignal.ts
@@ -0,0 +1,15 @@
+export function onAbort(
+  fn: (reason: any) => any,
+  signal?: AbortSignal,
+): { aborted: boolean; off(): void } {
+  const $fn = () => fn(signal?.reason)
+  const aborted = !!signal?.aborted
+
+  if (aborted) {
+    $fn()
+    return { aborted, off: () => {} }
+  }
+
+  signal?.addEventListener('abort', $fn, { once: true })
+  return { aborted, off: () => signal?.removeEventListener('abort', $fn) }
+}
diff --git a/packages/message-bus/test/broker-lifecycle.test.ts b/packages/message-bus/test/broker-lifecycle.test.ts
index 41cf75267..d465c460f 100644
--- a/packages/message-bus/test/broker-lifecycle.test.ts
+++ b/packages/message-bus/test/broker-lifecycle.test.ts
@@ -65,8 +65,8 @@ describe('abort', () => {
     brokerA.onAbort(spy)
     const reason = new Error('bye')
     brokerB.abort('a', reason)
-    expect(brokerA.aborted).toBe(true)
-    expect(brokerA.abortReason).toBe(reason)
+    expect(brokerA.abortSignal.aborted).toBe(true)
+    expect(brokerA.abortSignal.reason).toBe(reason)
     expect(spy).toHaveBeenCalledTimes(1)
   })
 })

From afe80cdfc5399a6d16c5f4b572fc3b3251e04073 Mon Sep 17 00:00:00 2001
From: John Wright 
Date: Fri, 18 Sep 2026 17:53:34 +0100
Subject: [PATCH 16/18] refactor(message-bus)!: rename around Enterprise
 Integration Patterns

Realign the package's vocabulary with the EIP glossary:

- Bus            -> MessageBus
- PluginBroker   -> MessageGateway   (a participant's endpoint facade)
- Broker         -> MessageDispatcher (drives local performers)
- Event          -> Message
- Invocation     -> CommandMessage
- EventListener        -> Subscriber
- InvocationListener   -> Responder
- InterceptionListener -> Interceptor
- InvocationType -> ResponseType; $invocationType -> $responseType
- bus.broker(name) -> bus.gateway(name)

The dispatcher's inbound methods now say what they do rather than
mirroring the gateway's outbound names: emit -> dispatch, invoke ->
dispatchCommand, intercept -> runInterceptors.

Group modules by concept: Message/ (Message, CommandMessage), Roles/
(Subscriber, Responder, Interceptor), Gateway/ (MessageGateway,
MessageDispatcher, Handler). Document the EIP pattern each concept
maps to (Message Filter, Selective Consumer, Pipes-and-Filters).

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 packages/message-bus/src/Broker/Broker.ts     | 140 ------
 packages/message-bus/src/Broker/Handler.ts    |  27 --
 .../message-bus/src/Broker/PluginBroker.ts    | 443 -----------------
 packages/message-bus/src/Bus.ts               | 208 --------
 packages/message-bus/src/Event.ts             |  64 ---
 packages/message-bus/src/EventListener.ts     |  81 ----
 packages/message-bus/src/Filter.ts            |  38 +-
 packages/message-bus/src/Gateway/Handler.ts   |  27 ++
 .../src/Gateway/MessageDispatcher.ts          | 143 ++++++
 .../message-bus/src/Gateway/MessageGateway.ts | 444 ++++++++++++++++++
 .../message-bus/src/Message/CommandMessage.ts |  41 ++
 packages/message-bus/src/Message/Message.ts   |  26 +
 packages/message-bus/src/MessageBus.ts        | 214 +++++++++
 packages/message-bus/src/Roles/Interceptor.ts |  35 ++
 packages/message-bus/src/Roles/Responder.ts   |  41 ++
 packages/message-bus/src/Roles/Subscriber.ts  |   9 +
 packages/message-bus/src/index.ts             |  11 +-
 .../message-bus/test/broker-lifecycle.test.ts | 275 -----------
 packages/message-bus/test/broker.test.ts      | 187 --------
 packages/message-bus/test/filter.test.ts      |  52 +-
 .../test/gateway-lifecycle.test.ts            | 277 +++++++++++
 packages/message-bus/test/gateway.test.ts     | 189 ++++++++
 22 files changed, 1500 insertions(+), 1472 deletions(-)
 delete mode 100644 packages/message-bus/src/Broker/Broker.ts
 delete mode 100644 packages/message-bus/src/Broker/Handler.ts
 delete mode 100644 packages/message-bus/src/Broker/PluginBroker.ts
 delete mode 100644 packages/message-bus/src/Bus.ts
 delete mode 100644 packages/message-bus/src/Event.ts
 delete mode 100644 packages/message-bus/src/EventListener.ts
 create mode 100644 packages/message-bus/src/Gateway/Handler.ts
 create mode 100644 packages/message-bus/src/Gateway/MessageDispatcher.ts
 create mode 100644 packages/message-bus/src/Gateway/MessageGateway.ts
 create mode 100644 packages/message-bus/src/Message/CommandMessage.ts
 create mode 100644 packages/message-bus/src/Message/Message.ts
 create mode 100644 packages/message-bus/src/MessageBus.ts
 create mode 100644 packages/message-bus/src/Roles/Interceptor.ts
 create mode 100644 packages/message-bus/src/Roles/Responder.ts
 create mode 100644 packages/message-bus/src/Roles/Subscriber.ts
 delete mode 100644 packages/message-bus/test/broker-lifecycle.test.ts
 delete mode 100644 packages/message-bus/test/broker.test.ts
 create mode 100644 packages/message-bus/test/gateway-lifecycle.test.ts
 create mode 100644 packages/message-bus/test/gateway.test.ts

diff --git a/packages/message-bus/src/Broker/Broker.ts b/packages/message-bus/src/Broker/Broker.ts
deleted file mode 100644
index 1a3583153..000000000
--- a/packages/message-bus/src/Broker/Broker.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import type { Bus } from '../Bus.js'
-import type {
-  Event,
-  EventClass,
-  Invocation,
-  InvocationClass,
-} from '../Event.js'
-import {
-  CANCEL,
-  type InvocationErrorHandler,
-  type InvocationListenerContext,
-} from '../EventListener.js'
-import { onAbort } from '../lang/AbortSignal.js'
-import { MethodQueue } from '../Queue/MethodQueue.js'
-import { Handler } from './Handler.js'
-import { PluginBroker } from './PluginBroker.js'
-
-/**
- * The full broker. Only the Bus and internal machinery ever hold one; plugins
- * receive a PluginBroker facade instead. Owns the handler registries + queue,
- * and implements the *inbound* pipe: the bus delivering events/invocations
- * into this broker's local handlers. Note `emit`/`invoke`/`pause`/`resume`/`abort`
- * here are the bus-driven direction — the identically named PluginBroker
- * methods are the opposite (plugin -> bus) direction, which is exactly why the
- * two can't collapse into one object.
- */
-export class Broker {
-  readonly eventHandlers = new Map>>()
-
-  readonly invokeHandlers = new Map>>()
-
-  readonly interceptionHandlers = new Map>>()
-
-  readonly queue = new MethodQueue()
-
-  readonly emit: (event: Event) => void
-
-  readonly #abortController = new AbortController()
-
-  readonly abortSignal: AbortSignal
-
-  constructor(
-    readonly bus: Bus,
-    readonly name: string,
-    abortSignal?: AbortSignal,
-  ) {
-    this.abortSignal = abortSignal
-      ? AbortSignal.any([abortSignal, this.#abortController.signal])
-      : this.#abortController.signal
-
-    this.onAbort(() => {
-      this.eventHandlers.clear()
-      this.invokeHandlers.clear()
-    })
-
-    this.emit = this.queue.queueMethod((event: Event) => {
-      for (const handler of this.eventHandlers.get(
-        event.constructor as EventClass,
-      ) ?? [])
-        handler.handle(event)
-    })
-  }
-
-  onAbort(fn: (reason: any) => any) {
-    return onAbort(fn, this.abortSignal)
-  }
-
-  get aborted() {
-    return this.abortSignal.aborted
-  }
-
-  async intercept(
-    event: InstanceType,
-  ): Promise | typeof CANCEL> {
-    if (!this.queue.running) return event
-
-    const interceptionHandlers = this.interceptionHandlers.get(
-      event.constructor as EventClass,
-    )
-
-    if (!interceptionHandlers?.size) return event
-
-    for (const interceptionHandler of interceptionHandlers) {
-      const result = await interceptionHandler.handle(event)
-      if (result === CANCEL) return CANCEL
-      else if (result) event = result as InstanceType
-    }
-
-    return event
-  }
-
-  /**
-   * Runs every local handler for the invocation and resolves once they have all
-   * settled. Completion is derived from the handlers' own return values: a
-   * filtered-out handler resolves immediately (its `handle` returns `undefined`),
-   * so there's no count to keep in sync and nothing to hang on. The handler set
-   * is snapshotted, so (un)registering during an in-flight invocation can't move
-   * the target. Handlers are isolated: one that throws is routed to
-   * `reportError` and neither stops its siblings nor prevents completion.
-   */
-  async invoke(
-    event: Invocation,
-    context: InvocationListenerContext>,
-    reportError: InvocationErrorHandler,
-  ): Promise {
-    if (!this.queue.running) return
-
-    const invokeHandlers = this.invokeHandlers.get(
-      event.constructor as InvocationClass,
-    )
-
-    if (!invokeHandlers?.size) return
-
-    await Promise.all(
-      Array.from(invokeHandlers, async (handler) => {
-        try {
-          await handler.handle(event, context)
-        } catch (error) {
-          reportError(error)
-        }
-      }),
-    )
-  }
-
-  resume() {
-    this.queue.start()
-  }
-
-  pause() {
-    this.queue.stop()
-  }
-
-  abort(reason?: unknown) {
-    this.#abortController.abort(reason)
-  }
-
-  createPluginFacade() {
-    return new PluginBroker(this)
-  }
-}
diff --git a/packages/message-bus/src/Broker/Handler.ts b/packages/message-bus/src/Broker/Handler.ts
deleted file mode 100644
index 14d19ccfa..000000000
--- a/packages/message-bus/src/Broker/Handler.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import type { Event, EventClass } from '../Event.js'
-import type { Filter, FilterEntries, FilterPredicate } from '../Filter.js'
-
-export class Handler {
-  readonly #filterEntries: FilterEntries
-  readonly #listener: (event: E, ...args: unknown[]) => any
-
-  constructor(
-    filter: Filter,
-    listener: (event: E, ...args: unknown[]) => any,
-  ) {
-    this.#filterEntries = Object.entries(filter) as FilterEntries
-    this.#listener = listener
-  }
-
-  handle(event: E, ...args: unknown[]): any | void {
-    if (this.#filter(event)) return this.#listener(event, ...args)
-  }
-
-  #filter(event: E) {
-    return this.#filterEntries.every(([key, value]) =>
-      typeof value === 'function'
-        ? (value as FilterPredicate)(event)
-        : value === event[key],
-    )
-  }
-}
diff --git a/packages/message-bus/src/Broker/PluginBroker.ts b/packages/message-bus/src/Broker/PluginBroker.ts
deleted file mode 100644
index 86f039587..000000000
--- a/packages/message-bus/src/Broker/PluginBroker.ts
+++ /dev/null
@@ -1,443 +0,0 @@
-import { UnderlyingDefaultSource } from 'node:stream/web'
-import type {
-  Event,
-  EventClass,
-  Invocation,
-  InvocationClass,
-  InvocationType,
-} from '../Event.js'
-import {
-  CANCEL,
-  type EventListener,
-  type InterceptionListener,
-  type InvocationErrorHandler,
-  type InvocationListener,
-  type InvocationListenerContext,
-} from '../EventListener.js'
-import type { Filter } from '../Filter.js'
-import { getOrInsert } from '../lang/Map.js'
-import type { Broker } from './Broker.js'
-import { Handler } from './Handler.js'
-import { onAbort } from '../lang/AbortSignal.js'
-
-/**
- * A participant's handle on the {@link Bus} — what `bus.broker(name)` returns.
- * Everything a plugin does flows through here: subscribing to events
- * ({@link PluginBroker.on}, {@link PluginBroker.once}, {@link PluginBroker.until}),
- * publishing them ({@link PluginBroker.emit}), transforming them in flight
- * ({@link PluginBroker.intercept}), and the request/stream pattern of
- * {@link PluginBroker.register} + {@link PluginBroker.invoke}.
- *
- * Outbound calls (`emit`, `invoke`) are queued through this broker's own queue,
- * so while the broker is paused they buffer and replay when it resumes. Every
- * subscription returns a disposer that removes it.
- */
-export class PluginBroker {
-  readonly #broker: Broker
-
-  readonly #invoke: >(
-    event: InstanceType,
-    params: InvocationListenerContext,
-    reportError: InvocationErrorHandler,
-  ) => Promise
-
-  /**
-   * Publishes an event to the whole bus. Runs any interceptors first, then
-   * delivers to every broker subscribed to the event's class. Resolves to the
-   * event as it stood after interception, or {@link CANCEL} if it was cancelled.
-   * Queued through this broker, so it waits while the broker is paused.
-   */
-  readonly emit: (event: E) => Promise
-
-  constructor(broker: Broker) {
-    this.#broker = broker
-
-    this.#invoke = broker.queue.queueMethod(
-      async (
-        event: Invocation,
-        context: {
-          send(value: any): void
-          signal: AbortSignal
-        },
-        reportError: InvocationErrorHandler,
-      ) => {
-        const result = await this.#broker.bus.emit(event)
-        if (result === CANCEL) return
-        await this.#broker.bus.invoke(result, context, reportError)
-      },
-    )
-
-    this.emit = broker.queue.queueMethod((event: E) =>
-      this.#broker.bus.emit(event),
-    )
-  }
-
-  /** This broker's unique name on the bus. */
-  get name() {
-    return this.#broker.name
-  }
-
-  /** The signal that fires when this broker is aborted; useful for teardown. */
-  get abortSignal(): AbortSignal {
-    return this.#broker.abortSignal
-  }
-
-  /**
-   * Runs `fn` once, when this broker is aborted (immediately if it already has).
-   * Returns a disposer that removes the listener.
-   */
-  onAbort(fn: (reason: any) => any) {
-    return onAbort(fn, this.abortSignal)
-  }
-
-  /**
-   * Resumes the named broker, draining its buffered messages. Brokers are
-   * addressed by name so one participant can control another's lifecycle.
-   */
-  resume(name: string) {
-    this.#broker.bus.resume(name)
-  }
-
-  /**
-   * Pauses the named broker: its queue stops draining and inbound messages
-   * buffer until {@link PluginBroker.resume}. Reversible.
-   */
-  pause(name: string) {
-    this.#broker.bus.pause(name)
-  }
-
-  /**
-   * Permanently aborts the named broker — clears its handlers and frees its
-   * name. Not reversible; use {@link PluginBroker.pause} to merely pause.
-   */
-  abort(name: string, reason?: any) {
-    this.#broker.bus.abort(name, reason)
-  }
-
-  /**
-   * Subscribes `eventListener` to every event of `eventClass`.
-   *
-   * @returns a disposer that removes the subscription.
-   * @example
-   * ```ts
-   * const off = broker.on(UserLoggedIn, (e) => console.log(e.userId))
-   * off() // unsubscribe
-   * ```
-   */
-  on(
-    eventClass: E,
-    eventListener: EventListener,
-  ): () => void
-
-  /**
-   * Subscribes only to events matching `filter` (all keys must match — see
-   * {@link Filter}).
-   */
-  on(
-    eventClass: E,
-    filter: Filter,
-    eventListener: EventListener,
-  ): () => void
-
-  on(
-    eventClass: E,
-    filterOrEventListener: Filter | EventListener,
-    eventListener?: EventListener,
-  ): () => void {
-    return this.#addListener(
-      this.#broker.eventHandlers,
-      this.#broker.bus.on,
-      eventClass,
-      filterOrEventListener,
-      eventListener,
-    )
-  }
-
-  /**
-   * Like {@link PluginBroker.on}, but the listener fires at most once and then
-   * unsubscribes itself.
-   *
-   * @returns a disposer, in case you want to cancel before it ever fires.
-   */
-  once(
-    eventClass: E,
-    eventListener: EventListener,
-  ): () => void
-
-  /** Fires once for the first event matching `filter`, then unsubscribes. */
-  once(
-    eventClass: E,
-    filter: Filter,
-    eventListener: EventListener,
-  ): () => void
-
-  once(
-    eventClass: E,
-    filterOrEventListener: Filter | EventListener,
-    eventListener?: EventListener,
-  ): () => void {
-    const filter = (eventListener ? filterOrEventListener : {}) as Filter
-    eventListener ??= filterOrEventListener as EventListener
-    const off = this.on(eventClass, filter, (event) => {
-      eventListener(event)
-      off()
-    })
-    return off
-  }
-
-  /**
-   * Resolves with the next event of `eventClass` (optionally matching `filter`).
-   * Rejects with the abort reason if this broker is aborted while waiting.
-   *
-   * @example
-   * ```ts
-   * const ready = await broker.until(AppReady)
-   * ```
-   */
-  until(
-    eventClass: E,
-    filter: Filter = {},
-  ): Promise> {
-    return new Promise((resolve, reject) => {
-      const { aborted, off } = onAbort(reject, this.abortSignal)
-      if (!aborted)
-        this.once(eventClass, filter, (event) => {
-          off()
-          resolve(event)
-        })
-    })
-  }
-
-  /**
-   * Registers a handler that streams values back for an {@link Invocation}. Many
-   * brokers can register for the same invocation; a caller's
-   * {@link PluginBroker.invoke} collects the values from all of them. The
-   * invocation completes for this handler when the function returns (or its
-   * promise settles).
-   *
-   * @returns a disposer that removes the handler.
-   * @example
-   * ```ts
-   * broker.register(ListFiles, async (event, { send, signal }) => {
-   *   for (const f of await readdir(event.dir, { signal })) send(f)
-   * })
-   * ```
-   */
-  register>(
-    eventClass: E,
-    listener: InvocationListener,
-  ): () => void
-
-  /** Registers a handler only for invocations matching `filter`. */
-  register>(
-    eventClass: E,
-    filter: Filter,
-    listener: InvocationListener,
-  ): () => void
-
-  register>(
-    eventClass: E,
-    filterOrListener: Filter | InvocationListener,
-    listener?: InvocationListener,
-  ): () => void {
-    return this.#addListener(
-      this.#broker.invokeHandlers,
-      this.#broker.bus.register,
-      eventClass,
-      filterOrListener,
-      listener,
-    )
-  }
-
-  /**
-   * Intercepts events of `eventClass` before their listeners run. The listener
-   * may return a replacement event, {@link CANCEL} to drop it, or nothing to
-   * leave it unchanged (see {@link InterceptionResult}). Interceptors across
-   * brokers form a chain, each seeing the previous one's result. Because an
-   * {@link Invocation} is emitted before it is invoked, intercepting its class
-   * transforms or cancels invocations too.
-   *
-   * @returns a disposer that removes the interceptor.
-   */
-  intercept(
-    eventClass: E,
-    listener: InterceptionListener,
-  ): () => void
-
-  /** Intercepts only events matching `filter`. */
-  intercept(
-    eventClass: E,
-    filter: Filter,
-    listener: InterceptionListener,
-  ): () => void
-
-  intercept>(
-    eventClass: E,
-    filterOrListener: Filter | InterceptionListener,
-    listener?: InterceptionListener,
-  ): () => void {
-    return this.#addListener(
-      this.#broker.interceptionHandlers,
-      this.#broker.bus.intercept,
-      eventClass,
-      filterOrListener,
-      listener,
-    )
-  }
-
-  /**
-   * Emits an invocation and streams back the values every registered handler
-   * `send`s. Nothing runs until you consume the result, via either
-   * `collect()` (a promise of all values) or `iterate()` (an async iterable that
-   * yields them as they arrive). The stream completes once every handler across
-   * every broker has settled.
-   *
-   * Errors are fail-loud by default: an unobserved handler error rejects the
-   * stream. Pass `onError` to isolate handlers instead — errors are reported
-   * there and the stream still completes with the healthy handlers' values. Pass
-   * `signal` to cancel; the same signal is forwarded to handlers so they can
-   * abort in-flight work.
-   *
-   * @example
-   * ```ts
-   * const files = await broker.invoke(new ListFiles('/tmp')).collect()
-   *
-   * for await (const file of broker.invoke(new ListFiles('/tmp')).iterate()) {
-   *   console.log(file)
-   * }
-   * ```
-   */
-  invoke>(
-    event: E,
-    {
-      signal,
-      onError = (error) => {
-        throw error
-      },
-    }: {
-      signal?: AbortSignal
-      onError?: InvocationErrorHandler
-    } = {},
-  ): {
-    collect(): Promise[]>
-    iterate(): AsyncIterable, undefined>
-  } {
-    type T = InvocationType
-
-    const readableStream = new ReadableStream(
-      new InvocationSource(this, event, this.#invoke, onError, signal),
-    )
-
-    return {
-      collect: async () => {
-        const items: T[] = []
-        for await (const item of readableStream) items.push(item)
-        return items
-      },
-
-      iterate: () => readableStream.values(),
-    }
-  }
-
-  #addListener any>(
-    registry: Map>>>,
-    subscribe: (broker: this, eventClass: E) => () => void,
-    eventClass: E,
-    filterOrEventListener: Filter | F,
-    eventListener?: F,
-  ) {
-    const filter = (eventListener ? filterOrEventListener : {}) as Filter
-    eventListener ??= filterOrEventListener as F
-
-    const handlers = getOrInsert(registry, eventClass, new Set())
-    const handler = new Handler(filter, eventListener)
-    handlers.add(handler)
-
-    const unregister = subscribe(this, eventClass)
-    return () => {
-      const handlers = registry.get(eventClass)
-      handlers?.delete(handler)
-      if (!handlers?.size) unregister()
-    }
-  }
-}
-
-class InvocationSource<
-  E extends Invocation,
-> implements UnderlyingDefaultSource> {
-  readonly #producer = new AbortController()
-  #settled = false
-  #teardown = (_reason: any) => {}
-
-  constructor(
-    private readonly pluginBroker: PluginBroker,
-    private readonly event: E,
-    private readonly invoke: (
-      event: E,
-      params: InvocationListenerContext,
-      reportError: InvocationErrorHandler,
-    ) => Promise,
-    private readonly onError: InvocationErrorHandler,
-    private readonly signal?: AbortSignal,
-  ) {}
-
-  start(controller: ReadableStreamDefaultController>) {
-    let aborted = false
-    let offAbort = () => {}
-    let offBrokerAbort = () => {}
-
-    this.#teardown = (reason: any) => {
-      if (this.#settled) return
-      this.#settled = true
-      offAbort()
-      offBrokerAbort()
-      this.#producer.abort(reason)
-    }
-    ;({ aborted, off: offBrokerAbort } = this.pluginBroker.onAbort(() =>
-      this.#abort(controller),
-    ))
-    if (aborted) return
-    ;({ aborted, off: offAbort } = onAbort(
-      () => this.#close(controller),
-      this.signal,
-    ))
-    if (aborted) return
-
-    // Completion is the settling of #invoke's promise: it resolves once
-    // every handler across every broker has returned, and rejects if one
-    // of them threw.
-    this.invoke(
-      this.event,
-      {
-        send: (value: unknown) => {
-          if (this.#settled) return
-          controller.enqueue(value as InvocationType)
-        },
-        signal: this.#producer.signal,
-      },
-      this.onError,
-    ).then(
-      () => this.#close(controller),
-      (reason: any) => this.#abort(controller, reason),
-    )
-  }
-
-  cancel(reason: any) {
-    this.#teardown(reason)
-  }
-
-  #close(controller: ReadableStreamDefaultController>) {
-    if (this.#settled) return
-    this.#teardown(this.signal?.reason)
-    controller.close()
-  }
-
-  #abort(
-    controller: ReadableStreamDefaultController>,
-    reason = this.pluginBroker.abortSignal.reason,
-  ) {
-    if (this.#settled) return
-    this.#teardown(reason)
-    controller.error(reason)
-  }
-}
diff --git a/packages/message-bus/src/Bus.ts b/packages/message-bus/src/Bus.ts
deleted file mode 100644
index 35a7acba6..000000000
--- a/packages/message-bus/src/Bus.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-import { Broker } from './Broker/Broker.js'
-import { PluginBroker } from './Broker/PluginBroker.js'
-import type { Event, EventClass, Invocation, InvocationClass } from './Event.js'
-import {
-  CANCEL,
-  type InvocationErrorHandler,
-  type InvocationListenerContext,
-} from './EventListener.js'
-import { getOrInsert } from './lang/Map.js'
-
-/**
- * The message bus: the shared hub the host owns. It hands out a
- * {@link PluginBroker} per named participant, routes events / invocations /
- * interceptions between them, and controls their lifecycle.
- *
- * Each broker has its own queue, so brokers can be paused and resumed
- * independently. Brokers begin paused: nothing is delivered until the bus (or
- * the individual broker) is resumed, so wiring up subscriptions before
- * {@link Bus.resume} is safe.
- *
- * @example
- * ```ts
- * const bus = new Bus()
- * const a = bus.broker('a')
- * const b = bus.broker('b')
- * a.on(Ping, () => console.log('pong'))
- * bus.resume()
- * b.emit(new Ping())
- * ```
- */
-export class Bus {
-  readonly #brokers = new Map()
-
-  readonly #eventBrokers = new Map>()
-
-  readonly #invokeBrokers = new Map, Set>()
-
-  readonly #interceptBrokers = new Map>()
-
-  /**
-   * Records that a broker is interested in an event class so {@link Bus.emit}
-   * knows to route to it. Called by the broker facade when a listener is added;
-   * returns a disposer that drops the routing entry.
-   * @internal
-   */
-  readonly on = (
-    broker: PluginBroker,
-    eventClass: E,
-  ): (() => void) => {
-    const eventBrokers = getOrInsert(this.#eventBrokers, eventClass, new Set())
-    eventBrokers.add(broker.name)
-    return () => {
-      this.#eventBrokers.get(eventClass)?.delete(broker.name)
-    }
-  }
-
-  /**
-   * Records that a broker registered a handler for an invocation class so
-   * {@link Bus.invoke} routes to it. Returns a disposer.
-   * @internal
-   */
-  readonly register = (
-    broker: PluginBroker,
-    eventClass: InvocationClass,
-  ): (() => void) => {
-    const invokeBrokers = getOrInsert(
-      this.#invokeBrokers,
-      eventClass,
-      new Set(),
-    )
-    invokeBrokers.add(broker.name)
-    return () => {
-      this.#invokeBrokers.get(eventClass)?.delete(broker.name)
-    }
-  }
-
-  /**
-   * Records that a broker wants to intercept an event class so {@link Bus.emit}
-   * runs it through that broker before delivery. Returns a disposer.
-   * @internal
-   */
-  readonly intercept = (
-    broker: PluginBroker,
-    eventClass: EventClass,
-  ): (() => void) => {
-    const interceptBrokers = getOrInsert(
-      this.#interceptBrokers,
-      eventClass,
-      new Set(),
-    )
-    interceptBrokers.add(broker.name)
-    return () => {
-      this.#interceptBrokers.get(eventClass)?.delete(broker.name)
-    }
-  }
-
-  /**
-   * Creates a broker under `name` and returns its {@link PluginBroker} facade —
-   * the object a participant uses to subscribe, emit, and invoke. Names are
-   * unique for the bus's lifetime; a name frees up again once its broker is
-   * aborted.
-   *
-   * @throws if a broker with `name` is already registered.
-   */
-  broker(name: string, abortSignal?: AbortSignal) {
-    if (this.#brokers.has(name))
-      throw new Error(`Broker "${name}" has already been registered`)
-
-    const broker = new Broker(this, name, abortSignal)
-    this.#brokers.set(name, broker)
-
-    broker.onAbort(() => {
-      this.#brokers.delete(name)
-      for (const routes of [
-        this.#eventBrokers,
-        this.#invokeBrokers,
-        this.#interceptBrokers,
-      ])
-        for (const brokerNames of routes.values()) brokerNames.delete(name)
-    })
-
-    return broker.createPluginFacade()
-  }
-
-  /**
-   * Runs the event through every registered interceptor (in chain order) and
-   * then delivers the final event to every subscribed broker. Resolves to the
-   * event as it stood after interception, or {@link CANCEL} if an interceptor
-   * cancelled it. Prefer `broker.emit`, which queues through the sender's broker;
-   * this is the bus-level primitive it calls.
-   * @internal
-   */
-  async emit(event: E): Promise {
-    const interceptorBrokers =
-      this.#interceptBrokers.get(event.constructor as EventClass) ?? []
-
-    for (const name of interceptorBrokers) {
-      const result = await this.#brokers.get(name)?.intercept(event)
-      if (result === CANCEL) return CANCEL
-      else if (result) event = result as E
-    }
-
-    const eventBrokers = this.#eventBrokers.get(event.constructor as EventClass)
-    if (!eventBrokers?.size) return event
-
-    for (const name of eventBrokers) this.#brokers.get(name)?.emit(event)
-
-    return event
-  }
-
-  /**
-   * Fans an invocation out to every broker that registered for it and resolves
-   * once they have all settled. The broker set is snapshotted so a broker
-   * (un)registering mid-flight can't move the target; a broker that has since
-   * been removed simply contributes a resolved `undefined`. Prefer
-   * `broker.invoke`, which wraps this in a stream; this is the bus-level
-   * primitive it calls.
-   * @internal
-   */
-  async invoke(
-    event: Invocation,
-    context: InvocationListenerContext>,
-    reportError: InvocationErrorHandler,
-  ): Promise {
-    const invokeBrokers = this.#invokeBrokers.get(
-      event.constructor as InvocationClass,
-    )
-
-    if (!invokeBrokers?.size) return
-
-    await Promise.all(
-      Array.from(invokeBrokers, (name) =>
-        this.#brokers.get(name)?.invoke(event, context, reportError),
-      ),
-    )
-  }
-
-  /**
-   * Permanently tears down the named broker: fires its abort signal, clears its
-   * handlers, and removes it from all routing so its name can be reused. Unlike
-   * {@link Bus.pause}, this cannot be undone.
-   */
-  abort(name: string, reason?: Error) {
-    this.#brokers.get(name)?.abort(reason)
-  }
-
-  /**
-   * Resumes brokers so queued and future messages are delivered. Brokers begin
-   * paused, so this is also how you first bring the bus to life. With a `name`,
-   * resumes just that broker; with no argument, resumes every broker on the bus.
-   */
-  resume(name?: string) {
-    if (name === undefined)
-      for (const broker of this.#brokers.values()) broker.resume()
-    else this.#brokers.get(name)?.resume()
-  }
-
-  /**
-   * Pauses brokers: their queues stop draining and inbound messages buffer until
-   * resumed. With a `name`, pauses just that broker; with no argument, pauses
-   * every broker on the bus. Reversible via {@link Bus.resume}.
-   */
-  pause(name?: string) {
-    if (name === undefined)
-      for (const broker of this.#brokers.values()) broker.pause()
-    else this.#brokers.get(name)?.pause()
-  }
-}
diff --git a/packages/message-bus/src/Event.ts b/packages/message-bus/src/Event.ts
deleted file mode 100644
index 5515f986d..000000000
--- a/packages/message-bus/src/Event.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-/**
- * The shape every message on the bus shares. Messages are plain class instances
- * — listeners subscribe to an event's *class* (its constructor), and the bus
- * routes by that class identity, so two events of the same class are delivered
- * to the same listeners. `$name` is a human-readable label for logging and
- * debugging; it plays no part in routing.
- *
- * @example
- * ```ts
- * class UserLoggedIn implements Event {
- *   readonly $name = 'user-logged-in'
- *   constructor(readonly userId: string) {}
- * }
- * ```
- */
-export interface Event {
-  readonly $name: string
-}
-
-/**
- * The constructor type of an {@link Event}. This is what you pass to
- * `broker.on`, `broker.intercept`, etc. — the class itself, not an instance.
- */
-export type EventClass = abstract new (
-  ...args: any[]
-) => E
-
-/**
- * An {@link Event} that also asks registered handlers to stream values back.
- * Subclass it and fix the value type through `T`; emit it with `broker.invoke`
- * and collect the `T`s that handlers `send`. Because an invocation is also an
- * event, it is first emitted (and can be intercepted) before it is invoked.
- *
- * @typeParam T - the type of each value handlers stream back.
- *
- * @example
- * ```ts
- * class ListFiles extends Invocation {
- *   readonly $name = 'list-files'
- *   constructor(readonly dir: string) { super() }
- * }
- * ```
- */
-export abstract class Invocation implements Event {
-  abstract $name: string
-  /** Phantom field carrying `T` for inference; never assigned at runtime. */
-  declare $invocationType: T
-}
-
-/** The constructor type of an {@link Invocation}. */
-export type InvocationClass = abstract new (
-  ...args: any
-) => Invocation
-
-/**
- * Extracts the streamed value type `T` from an {@link Invocation} instance or
- * its class — e.g. `InvocationType` is `string`.
- */
-export type InvocationType | InvocationClass> =
-  E extends Invocation
-    ? V
-    : E extends InvocationClass
-      ? V
-      : never
diff --git a/packages/message-bus/src/EventListener.ts b/packages/message-bus/src/EventListener.ts
deleted file mode 100644
index 66bcd4514..000000000
--- a/packages/message-bus/src/EventListener.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { EventClass, InvocationClass, InvocationType } from './Event.js'
-
-/**
- * Handles an emitted event. Registered with `broker.on` / `broker.once`. A
- * returned promise is not awaited by the emitter — events are fire-and-forget.
- */
-export interface EventListener {
-  (event: InstanceType): void | Promise
-}
-
-/**
- * Handles an invocation, streaming results back through `context.send`.
- * Registered with `broker.register`. The invocation completes for this handler
- * when the function returns (or its returned promise settles) — see
- * {@link InvocationListenerContext}.
- *
- * @example
- * ```ts
- * broker.register(ListFiles, async (event, { send, signal }) => {
- *   for (const file of await readdir(event.dir, { signal })) send(file)
- * })
- * ```
- */
-export interface InvocationListener> {
-  (
-    event: InstanceType,
-    context: InvocationListenerContext,
-  ): void | Promise
-}
-
-/**
- * The context handed to an invocation listener. A listener streams results
- * through `send`; the invocation is considered complete for that listener when
- * the function it registered returns (or its returned promise settles). There
- * is deliberately no `finish` — completion is implicit, so a listener can never
- * forget to call it, and one that is filtered out or throws can't wedge the
- * stream. `signal` aborts when the consumer aborts, the broker aborts, or the
- * stream is otherwise torn down, so long-running work can bail early.
- */
-export interface InvocationListenerContext> {
-  send: (value: InvocationType) => void
-  signal: AbortSignal
-}
-
-/**
- * Receives an error thrown (or rejected) by an individual invocation handler.
- * Handlers are isolated: reporting an error here lets the other handlers keep
- * streaming and the invocation still complete. The default sink rethrows, so an
- * unobserved handler error surfaces on the stream instead of being swallowed;
- * pass your own to `invoke({ onError })` to observe errors without failing it.
- */
-export type InvocationErrorHandler = (error: unknown) => void
-
-/**
- * Returned by an interceptor to stop an event dead: no further interceptors run
- * and no listeners are notified. Also the resolved value of `broker.emit` when
- * an interceptor cancelled the event.
- */
-export const CANCEL = Symbol.for('dmg-bus/cancel')
-
-/**
- * What an {@link InterceptionListener} may return:
- * - `void`/`undefined` — leave the event unchanged;
- * - a new event instance — replace it for downstream interceptors and listeners;
- * - {@link CANCEL} — drop the event entirely;
- * - a promise of any of the above.
- */
-export type InterceptionResult =
-  | void
-  | InstanceType
-  | typeof CANCEL
-  | Promise>
-
-/**
- * Inspects an event before listeners see it and may transform or cancel it.
- * Registered with `broker.intercept`. Interceptors across brokers run in a
- * chain, each receiving the previous one's (possibly replaced) event.
- */
-export interface InterceptionListener {
-  (event: InstanceType): InterceptionResult
-}
diff --git a/packages/message-bus/src/Filter.ts b/packages/message-bus/src/Filter.ts
index 120e419b7..2d849bcc2 100644
--- a/packages/message-bus/src/Filter.ts
+++ b/packages/message-bus/src/Filter.ts
@@ -1,36 +1,40 @@
-import type { EventClass } from './Event.js'
+import type { MessageClass } from './Message/Message.js'
 
 /**
- * Narrows a subscription to only the events you care about. A filter is a
- * partial map of an event's properties, where each value is either an expected
+ * Narrows a subscription to only the messages you care about. A filter is a
+ * partial map of a message's properties, where each value is either an expected
  * value (matched with `===`) or a predicate. **All** listed keys must match
- * (logical AND); an empty filter matches every event.
+ * (logical AND); an empty filter matches every message.
+ *
+ * This is EIP's *Message Filter* (whole-message pass-or-discard) — not the
+ * similarly named *Content Filter*, which strips fields from within a message.
+ * A {@link Handler} carrying one is a *Selective Consumer*.
  *
  * @example
  * ```ts
  * // fires only when both hold
- * broker.on(Order, { status: 'paid', total: (o) => o.total > 100 }, listener)
+ * gateway.on(Order, { status: 'paid', total: (o) => o.total > 100 }, subscriber)
  * ```
  */
-export type Filter = {
-  [K in keyof InstanceType]?: FilterValue
+export type Filter = {
+  [K in keyof InstanceType]?: FilterValue
 }
 
 /** A single `[key, value-or-predicate]` pair of a {@link Filter}. */
-export type FilterEntry = {
-  [K in keyof InstanceType]: [K, FilterValue]
-}[keyof InstanceType]
+export type FilterEntry = {
+  [K in keyof InstanceType]: [K, FilterValue]
+}[keyof InstanceType]
 
 /** A {@link Filter} flattened to its `[key, value]` entries. */
-export type FilterEntries = FilterEntry[]
+export type FilterEntries = FilterEntry[]
 
 /** An accepted filter value for property `K`: the value itself, or a predicate. */
 export type FilterValue<
-  E extends EventClass,
-  K extends keyof InstanceType,
-> = InstanceType[K] | FilterPredicate
+  M extends MessageClass,
+  K extends keyof InstanceType,
+> = InstanceType[K] | FilterPredicate
 
-/** A predicate filter: gets the whole event, returns whether it matches. */
-export interface FilterPredicate {
-  (event: InstanceType): boolean
+/** A predicate filter: gets the whole message, returns whether it matches. */
+export interface FilterPredicate {
+  (message: InstanceType): boolean
 }
diff --git a/packages/message-bus/src/Gateway/Handler.ts b/packages/message-bus/src/Gateway/Handler.ts
new file mode 100644
index 000000000..417997ef1
--- /dev/null
+++ b/packages/message-bus/src/Gateway/Handler.ts
@@ -0,0 +1,27 @@
+import type { Message, MessageClass } from '../Message/Message.js'
+import type { Filter, FilterEntries, FilterPredicate } from '../Filter.js'
+
+export class Handler {
+  readonly #filterEntries: FilterEntries
+  readonly #callback: (message: M, ...args: unknown[]) => any
+
+  constructor(
+    filter: Filter,
+    callback: (message: M, ...args: unknown[]) => any,
+  ) {
+    this.#filterEntries = Object.entries(filter) as FilterEntries
+    this.#callback = callback
+  }
+
+  handle(message: M, ...args: unknown[]): any | void {
+    if (this.#filter(message)) return this.#callback(message, ...args)
+  }
+
+  #filter(message: M) {
+    return this.#filterEntries.every(([key, value]) =>
+      typeof value === 'function'
+        ? (value as FilterPredicate)(message)
+        : value === message[key],
+    )
+  }
+}
diff --git a/packages/message-bus/src/Gateway/MessageDispatcher.ts b/packages/message-bus/src/Gateway/MessageDispatcher.ts
new file mode 100644
index 000000000..aa36a465a
--- /dev/null
+++ b/packages/message-bus/src/Gateway/MessageDispatcher.ts
@@ -0,0 +1,143 @@
+import type { MessageBus } from '../MessageBus.js'
+import type { Message, MessageClass } from '../Message/Message.js'
+import type {
+  CommandMessage,
+  CommandMessageClass,
+} from '../Message/CommandMessage.js'
+import { CANCEL } from '../Roles/Interceptor.js'
+import type {
+  ResponderContext,
+  ResponderErrorHandler,
+} from '../Roles/Responder.js'
+import { onAbort } from '../lang/AbortSignal.js'
+import { MethodQueue } from '../Queue/MethodQueue.js'
+import { Handler } from './Handler.js'
+import { MessageGateway } from './MessageGateway.js'
+
+/**
+ * The inbound half of a participant: the bus delivers messages/commands into
+ * this dispatcher, which drives the participant's local performers —
+ * {@link MessageDispatcher.dispatch} runs its subscribers,
+ * {@link MessageDispatcher.dispatchCommand} runs its responders, and
+ * {@link MessageDispatcher.runInterceptors} runs its interceptor chain. Only the
+ * MessageBus and internal machinery ever hold one; participants receive a
+ * {@link MessageGateway} facade (the outbound half) instead. Owns the performer
+ * registries and the queue that lets the participant be paused and resumed.
+ */
+export class MessageDispatcher {
+  readonly subscribers = new Map>>()
+
+  readonly responders = new Map<
+    CommandMessageClass,
+    Set>
+  >()
+
+  readonly interceptors = new Map>>()
+
+  readonly queue = new MethodQueue()
+
+  readonly dispatch: (message: Message) => void
+
+  readonly #abortController = new AbortController()
+
+  readonly abortSignal: AbortSignal
+
+  constructor(
+    readonly bus: MessageBus,
+    readonly name: string,
+    abortSignal?: AbortSignal,
+  ) {
+    this.abortSignal = abortSignal
+      ? AbortSignal.any([abortSignal, this.#abortController.signal])
+      : this.#abortController.signal
+
+    this.onAbort(() => {
+      this.subscribers.clear()
+      this.responders.clear()
+    })
+
+    this.dispatch = this.queue.queueMethod((message: Message) => {
+      for (const handler of this.subscribers.get(
+        message.constructor as MessageClass,
+      ) ?? [])
+        handler.handle(message)
+    })
+  }
+
+  onAbort(fn: (reason: any) => any) {
+    return onAbort(fn, this.abortSignal)
+  }
+
+  get aborted() {
+    return this.abortSignal.aborted
+  }
+
+  async runInterceptors(
+    message: InstanceType,
+  ): Promise | typeof CANCEL> {
+    if (!this.queue.running) return message
+
+    const interceptors = this.interceptors.get(
+      message.constructor as MessageClass,
+    )
+
+    if (!interceptors?.size) return message
+
+    for (const interceptor of interceptors) {
+      const result = await interceptor.handle(message)
+      if (result === CANCEL) return CANCEL
+      else if (result) message = result as InstanceType
+    }
+
+    return message
+  }
+
+  /**
+   * Runs every local responder for the command and resolves once they have all
+   * settled. Completion is derived from the responders' own return values: a
+   * filtered-out responder resolves immediately (its `handle` returns
+   * `undefined`), so there's no count to keep in sync and nothing to hang on. The
+   * responder set is snapshotted, so (un)registering during an in-flight command
+   * can't move the target. Responders are isolated: one that throws is routed to
+   * `reportError` and neither stops its siblings nor prevents completion.
+   */
+  async dispatchCommand(
+    command: CommandMessage,
+    context: ResponderContext>,
+    reportError: ResponderErrorHandler,
+  ): Promise {
+    if (!this.queue.running) return
+
+    const responders = this.responders.get(
+      command.constructor as CommandMessageClass,
+    )
+
+    if (!responders?.size) return
+
+    await Promise.all(
+      Array.from(responders, async (handler) => {
+        try {
+          await handler.handle(command, context)
+        } catch (error) {
+          reportError(error)
+        }
+      }),
+    )
+  }
+
+  resume() {
+    this.queue.start()
+  }
+
+  pause() {
+    this.queue.stop()
+  }
+
+  abort(reason?: unknown) {
+    this.#abortController.abort(reason)
+  }
+
+  createGateway() {
+    return new MessageGateway(this)
+  }
+}
diff --git a/packages/message-bus/src/Gateway/MessageGateway.ts b/packages/message-bus/src/Gateway/MessageGateway.ts
new file mode 100644
index 000000000..1e12bf8b5
--- /dev/null
+++ b/packages/message-bus/src/Gateway/MessageGateway.ts
@@ -0,0 +1,444 @@
+import { UnderlyingDefaultSource } from 'node:stream/web'
+import type { Message, MessageClass } from '../Message/Message.js'
+import type {
+  CommandMessage,
+  CommandMessageClass,
+  ResponseType,
+} from '../Message/CommandMessage.js'
+import { CANCEL } from '../Roles/Interceptor.js'
+import type { Interceptor } from '../Roles/Interceptor.js'
+import type { Subscriber } from '../Roles/Subscriber.js'
+import type {
+  Responder,
+  ResponderContext,
+  ResponderErrorHandler,
+} from '../Roles/Responder.js'
+import type { Filter } from '../Filter.js'
+import { getOrInsert } from '../lang/Map.js'
+import type { MessageDispatcher } from './MessageDispatcher.js'
+import { Handler } from './Handler.js'
+import { onAbort } from '../lang/AbortSignal.js'
+
+/**
+ * A participant's handle on the {@link MessageBus} — what `bus.gateway(name)`
+ * returns. Everything a participant does flows through here: subscribing to
+ * messages ({@link MessageGateway.on}, {@link MessageGateway.once},
+ * {@link MessageGateway.until}), publishing them ({@link MessageGateway.emit}),
+ * transforming them in flight ({@link MessageGateway.intercept}), and the
+ * request/stream pattern of {@link MessageGateway.register} +
+ * {@link MessageGateway.invoke}.
+ *
+ * Outbound calls (`emit`, `invoke`) are queued through this participant's own
+ * queue, so while it is paused they buffer and replay when it resumes. Every
+ * subscription returns a disposer that removes it.
+ */
+export class MessageGateway {
+  readonly #dispatcher: MessageDispatcher
+
+  readonly #invoke: >(
+    command: InstanceType,
+    params: ResponderContext,
+    reportError: ResponderErrorHandler,
+  ) => Promise
+
+  /**
+   * Publishes a message to the whole bus. Runs any interceptors first, then
+   * delivers to every participant subscribed to the message's class. Resolves to
+   * the message as it stood after interception, or {@link CANCEL} if it was
+   * cancelled. Queued through this participant, so it waits while it is paused.
+   */
+  readonly emit: (message: M) => Promise
+
+  constructor(dispatcher: MessageDispatcher) {
+    this.#dispatcher = dispatcher
+
+    this.#invoke = dispatcher.queue.queueMethod(
+      async (
+        command: CommandMessage,
+        context: {
+          send(value: any): void
+          signal: AbortSignal
+        },
+        reportError: ResponderErrorHandler,
+      ) => {
+        const result = await this.#dispatcher.bus.emit(command)
+        if (result === CANCEL) return
+        await this.#dispatcher.bus.invoke(result, context, reportError)
+      },
+    )
+
+    this.emit = dispatcher.queue.queueMethod((message: M) =>
+      this.#dispatcher.bus.emit(message),
+    )
+  }
+
+  /** This participant's unique name on the bus. */
+  get name() {
+    return this.#dispatcher.name
+  }
+
+  /** The signal that fires when this participant is aborted; useful for teardown. */
+  get abortSignal(): AbortSignal {
+    return this.#dispatcher.abortSignal
+  }
+
+  /**
+   * Runs `fn` once, when this participant is aborted (immediately if it already
+   * has). Returns a disposer that removes the listener.
+   */
+  onAbort(fn: (reason: any) => any) {
+    return onAbort(fn, this.abortSignal)
+  }
+
+  /**
+   * Resumes the named participant, draining its buffered messages. Participants
+   * are addressed by name so one can control another's lifecycle.
+   */
+  resume(name: string) {
+    this.#dispatcher.bus.resume(name)
+  }
+
+  /**
+   * Pauses the named participant: its queue stops draining and inbound messages
+   * buffer until {@link MessageGateway.resume}. Reversible.
+   */
+  pause(name: string) {
+    this.#dispatcher.bus.pause(name)
+  }
+
+  /**
+   * Permanently aborts the named participant — clears its handlers and frees its
+   * name. Not reversible; use {@link MessageGateway.pause} to merely pause.
+   */
+  abort(name: string, reason?: any) {
+    this.#dispatcher.bus.abort(name, reason)
+  }
+
+  /**
+   * Subscribes `subscriber` to every message of `messageClass`.
+   *
+   * @returns a disposer that removes the subscription.
+   * @example
+   * ```ts
+   * const off = gateway.on(UserLoggedIn, (m) => console.log(m.userId))
+   * off() // unsubscribe
+   * ```
+   */
+  on(
+    messageClass: M,
+    subscriber: Subscriber,
+  ): () => void
+
+  /**
+   * Subscribes only to messages matching `filter` (all keys must match — see
+   * {@link Filter}).
+   */
+  on(
+    messageClass: M,
+    filter: Filter,
+    subscriber: Subscriber,
+  ): () => void
+
+  on(
+    messageClass: M,
+    filterOrSubscriber: Filter | Subscriber,
+    subscriber?: Subscriber,
+  ): () => void {
+    return this.#addListener(
+      this.#dispatcher.subscribers,
+      this.#dispatcher.bus.on,
+      messageClass,
+      filterOrSubscriber,
+      subscriber,
+    )
+  }
+
+  /**
+   * Like {@link MessageGateway.on}, but the subscriber fires at most once and
+   * then unsubscribes itself.
+   *
+   * @returns a disposer, in case you want to cancel before it ever fires.
+   */
+  once(
+    messageClass: M,
+    subscriber: Subscriber,
+  ): () => void
+
+  /** Fires once for the first message matching `filter`, then unsubscribes. */
+  once(
+    messageClass: M,
+    filter: Filter,
+    subscriber: Subscriber,
+  ): () => void
+
+  once(
+    messageClass: M,
+    filterOrSubscriber: Filter | Subscriber,
+    subscriber?: Subscriber,
+  ): () => void {
+    const filter = (subscriber ? filterOrSubscriber : {}) as Filter
+    subscriber ??= filterOrSubscriber as Subscriber
+    const off = this.on(messageClass, filter, (message) => {
+      subscriber(message)
+      off()
+    })
+    return off
+  }
+
+  /**
+   * Resolves with the next message of `messageClass` (optionally matching
+   * `filter`). Rejects with the abort reason if this participant is aborted while
+   * waiting.
+   *
+   * @example
+   * ```ts
+   * const ready = await gateway.until(AppReady)
+   * ```
+   */
+  until(
+    messageClass: M,
+    filter: Filter = {},
+  ): Promise> {
+    return new Promise((resolve, reject) => {
+      const { aborted, off } = onAbort(reject, this.abortSignal)
+      if (!aborted)
+        this.once(messageClass, filter, (message) => {
+          off()
+          resolve(message)
+        })
+    })
+  }
+
+  /**
+   * Registers a responder that streams values back for a {@link CommandMessage}.
+   * Many participants can register for the same command; a caller's
+   * {@link MessageGateway.invoke} collects the values from all of them (a
+   * scatter-gather). The command completes for this responder when the function
+   * returns (or its promise settles).
+   *
+   * @returns a disposer that removes the responder.
+   * @example
+   * ```ts
+   * gateway.register(ListFiles, async (command, { send, signal }) => {
+   *   for (const f of await readdir(command.dir, { signal })) send(f)
+   * })
+   * ```
+   */
+  register>(
+    commandClass: E,
+    responder: Responder,
+  ): () => void
+
+  /** Registers a responder only for commands matching `filter`. */
+  register>(
+    commandClass: E,
+    filter: Filter,
+    responder: Responder,
+  ): () => void
+
+  register>(
+    commandClass: E,
+    filterOrResponder: Filter | Responder,
+    responder?: Responder,
+  ): () => void {
+    return this.#addListener(
+      this.#dispatcher.responders,
+      this.#dispatcher.bus.register,
+      commandClass,
+      filterOrResponder,
+      responder,
+    )
+  }
+
+  /**
+   * Intercepts messages of `messageClass` before their subscribers run. The
+   * interceptor may return a replacement message, {@link CANCEL} to drop it, or
+   * nothing to leave it unchanged (see {@link InterceptorResult}). Interceptors
+   * across participants form a chain, each seeing the previous one's result.
+   * Because a {@link CommandMessage} is emitted before it is invoked,
+   * intercepting its class transforms or cancels commands too.
+   *
+   * @returns a disposer that removes the interceptor.
+   */
+  intercept(
+    messageClass: M,
+    interceptor: Interceptor,
+  ): () => void
+
+  /** Intercepts only messages matching `filter`. */
+  intercept(
+    messageClass: M,
+    filter: Filter,
+    interceptor: Interceptor,
+  ): () => void
+
+  intercept>(
+    messageClass: M,
+    filterOrInterceptor: Filter | Interceptor,
+    interceptor?: Interceptor,
+  ): () => void {
+    return this.#addListener(
+      this.#dispatcher.interceptors,
+      this.#dispatcher.bus.intercept,
+      messageClass,
+      filterOrInterceptor,
+      interceptor,
+    )
+  }
+
+  /**
+   * Emits a command and streams back the values every registered responder
+   * `send`s (a scatter-gather). Nothing runs until you consume the result, via
+   * either `collect()` (a promise of all values) or `iterate()` (an async
+   * iterable that yields them as they arrive). The stream completes once every
+   * responder across every participant has settled.
+   *
+   * Errors are fail-loud by default: an unobserved responder error rejects the
+   * stream. Pass `onError` to isolate responders instead — errors are reported
+   * there and the stream still completes with the healthy responders' values.
+   * Pass `signal` to cancel; the same signal is forwarded to responders so they
+   * can abort in-flight work.
+   *
+   * @example
+   * ```ts
+   * const files = await gateway.invoke(new ListFiles('/tmp')).collect()
+   *
+   * for await (const file of gateway.invoke(new ListFiles('/tmp')).iterate()) {
+   *   console.log(file)
+   * }
+   * ```
+   */
+  invoke>(
+    command: E,
+    {
+      signal,
+      onError = (error) => {
+        throw error
+      },
+    }: {
+      signal?: AbortSignal
+      onError?: ResponderErrorHandler
+    } = {},
+  ): {
+    collect(): Promise[]>
+    iterate(): AsyncIterable, undefined>
+  } {
+    type T = ResponseType
+
+    const readableStream = new ReadableStream(
+      new ResponseSource(this, command, this.#invoke, onError, signal),
+    )
+
+    return {
+      collect: async () => {
+        const items: T[] = []
+        for await (const item of readableStream) items.push(item)
+        return items
+      },
+
+      iterate: () => readableStream.values(),
+    }
+  }
+
+  #addListener any>(
+    registry: Map>>>,
+    subscribe: (gateway: this, messageClass: M) => () => void,
+    messageClass: M,
+    filterOrCallback: Filter | F,
+    callback?: F,
+  ) {
+    const filter = (callback ? filterOrCallback : {}) as Filter
+    callback ??= filterOrCallback as F
+
+    const handlers = getOrInsert(registry, messageClass, new Set())
+    const handler = new Handler(filter, callback)
+    handlers.add(handler)
+
+    const unregister = subscribe(this, messageClass)
+    return () => {
+      const handlers = registry.get(messageClass)
+      handlers?.delete(handler)
+      if (!handlers?.size) unregister()
+    }
+  }
+}
+
+class ResponseSource<
+  E extends CommandMessage,
+> implements UnderlyingDefaultSource> {
+  readonly #producer = new AbortController()
+  #settled = false
+  #teardown = (_reason: any) => {}
+
+  constructor(
+    private readonly gateway: MessageGateway,
+    private readonly command: E,
+    private readonly invoke: (
+      command: E,
+      params: ResponderContext,
+      reportError: ResponderErrorHandler,
+    ) => Promise,
+    private readonly onError: ResponderErrorHandler,
+    private readonly signal?: AbortSignal,
+  ) {}
+
+  start(controller: ReadableStreamDefaultController>) {
+    let aborted = false
+    let offAbort = () => {}
+    let offGatewayAbort = () => {}
+
+    this.#teardown = (reason: any) => {
+      if (this.#settled) return
+      this.#settled = true
+      offAbort()
+      offGatewayAbort()
+      this.#producer.abort(reason)
+    }
+    ;({ aborted, off: offGatewayAbort } = this.gateway.onAbort(() =>
+      this.#abort(controller),
+    ))
+    if (aborted) return
+    ;({ aborted, off: offAbort } = onAbort(
+      () => this.#close(controller),
+      this.signal,
+    ))
+    if (aborted) return
+
+    // Completion is the settling of #invoke's promise: it resolves once
+    // every responder across every participant has returned, and rejects if
+    // one of them threw.
+    this.invoke(
+      this.command,
+      {
+        send: (value: unknown) => {
+          if (this.#settled) return
+          controller.enqueue(value as ResponseType)
+        },
+        signal: this.#producer.signal,
+      },
+      this.onError,
+    ).then(
+      () => this.#close(controller),
+      (reason: any) => this.#abort(controller, reason),
+    )
+  }
+
+  cancel(reason: any) {
+    this.#teardown(reason)
+  }
+
+  #close(controller: ReadableStreamDefaultController>) {
+    if (this.#settled) return
+    this.#teardown(this.signal?.reason)
+    controller.close()
+  }
+
+  #abort(
+    controller: ReadableStreamDefaultController>,
+    reason = this.gateway.abortSignal.reason,
+  ) {
+    if (this.#settled) return
+    this.#teardown(reason)
+    controller.error(reason)
+  }
+}
diff --git a/packages/message-bus/src/Message/CommandMessage.ts b/packages/message-bus/src/Message/CommandMessage.ts
new file mode 100644
index 000000000..f767f9052
--- /dev/null
+++ b/packages/message-bus/src/Message/CommandMessage.ts
@@ -0,0 +1,41 @@
+import type { Message } from './Message.js'
+
+/**
+ * A {@link Message} that also asks registered responders to stream values back.
+ * Subclass it and fix the value type through `T`; send it with `gateway.invoke`
+ * and collect the `T`s that responders `send`. Because a command message is also
+ * a message, it is first emitted (and can be intercepted) before it is invoked.
+ *
+ * @typeParam T - the type of each value responders stream back.
+ *
+ * @example
+ * ```ts
+ * class ListFiles extends CommandMessage {
+ *   readonly $name = 'list-files'
+ *   constructor(readonly dir: string) { super() }
+ * }
+ * ```
+ */
+export abstract class CommandMessage implements Message {
+  abstract $name: string
+  /** Phantom field carrying `T` for inference; never assigned at runtime. */
+  declare $responseType: T
+}
+
+/** The constructor type of a {@link CommandMessage}. */
+export type CommandMessageClass = abstract new (
+  ...args: any
+) => CommandMessage
+
+/**
+ * Extracts the streamed value type `T` from a {@link CommandMessage} instance or
+ * its class — e.g. `ResponseType` is `string`.
+ */
+export type ResponseType<
+  E extends CommandMessage | CommandMessageClass,
+> =
+  E extends CommandMessage
+    ? V
+    : E extends CommandMessageClass
+      ? V
+      : never
diff --git a/packages/message-bus/src/Message/Message.ts b/packages/message-bus/src/Message/Message.ts
new file mode 100644
index 000000000..a4b7fc051
--- /dev/null
+++ b/packages/message-bus/src/Message/Message.ts
@@ -0,0 +1,26 @@
+/**
+ * The shape every message on the bus shares. Messages are plain class instances
+ * — subscribers subscribe to a message's *class* (its constructor), and the bus
+ * routes by that class identity, so two messages of the same class are delivered
+ * to the same subscribers. `$name` is a human-readable label for logging and
+ * debugging; it plays no part in routing.
+ *
+ * @example
+ * ```ts
+ * class UserLoggedIn implements Message {
+ *   readonly $name = 'user-logged-in'
+ *   constructor(readonly userId: string) {}
+ * }
+ * ```
+ */
+export interface Message {
+  readonly $name: string
+}
+
+/**
+ * The constructor type of a {@link Message}. This is what you pass to
+ * `gateway.on`, `gateway.intercept`, etc. — the class itself, not an instance.
+ */
+export type MessageClass = abstract new (
+  ...args: any[]
+) => M
diff --git a/packages/message-bus/src/MessageBus.ts b/packages/message-bus/src/MessageBus.ts
new file mode 100644
index 000000000..96a69201c
--- /dev/null
+++ b/packages/message-bus/src/MessageBus.ts
@@ -0,0 +1,214 @@
+import { MessageDispatcher } from './Gateway/MessageDispatcher.js'
+import { MessageGateway } from './Gateway/MessageGateway.js'
+import type { Message, MessageClass } from './Message/Message.js'
+import type {
+  CommandMessage,
+  CommandMessageClass,
+} from './Message/CommandMessage.js'
+import { CANCEL } from './Roles/Interceptor.js'
+import type {
+  ResponderContext,
+  ResponderErrorHandler,
+} from './Roles/Responder.js'
+import { getOrInsert } from './lang/Map.js'
+
+/**
+ * The message bus: the shared hub the host owns. It hands out a
+ * {@link MessageGateway} per named participant, routes messages / commands /
+ * interceptions between them, and controls their lifecycle.
+ *
+ * Each participant has its own queue, so participants can be paused and resumed
+ * independently. Participants begin paused: nothing is delivered until the bus
+ * (or the individual participant) is resumed, so wiring up subscriptions before
+ * {@link MessageBus.resume} is safe.
+ *
+ * @example
+ * ```ts
+ * const bus = new MessageBus()
+ * const a = bus.gateway('a')
+ * const b = bus.gateway('b')
+ * a.on(Ping, () => console.log('pong'))
+ * bus.resume()
+ * b.emit(new Ping())
+ * ```
+ */
+export class MessageBus {
+  readonly #dispatchers = new Map()
+
+  readonly #subscriberRoutes = new Map>()
+
+  readonly #responderRoutes = new Map<
+    CommandMessageClass,
+    Set
+  >()
+
+  readonly #interceptorRoutes = new Map>()
+
+  /**
+   * Records that a participant is interested in a message class so
+   * {@link MessageBus.emit} knows to route to it. Called by the gateway facade
+   * when a subscriber is added; returns a disposer that drops the routing entry.
+   * @internal
+   */
+  readonly on = (
+    gateway: MessageGateway,
+    messageClass: M,
+  ): (() => void) => {
+    const routes = getOrInsert(this.#subscriberRoutes, messageClass, new Set())
+    routes.add(gateway.name)
+    return () => {
+      this.#subscriberRoutes.get(messageClass)?.delete(gateway.name)
+    }
+  }
+
+  /**
+   * Records that a participant registered a responder for a command class so
+   * {@link MessageBus.invoke} routes to it. Returns a disposer.
+   * @internal
+   */
+  readonly register = (
+    gateway: MessageGateway,
+    commandClass: CommandMessageClass,
+  ): (() => void) => {
+    const routes = getOrInsert(this.#responderRoutes, commandClass, new Set())
+    routes.add(gateway.name)
+    return () => {
+      this.#responderRoutes.get(commandClass)?.delete(gateway.name)
+    }
+  }
+
+  /**
+   * Records that a participant wants to intercept a message class so
+   * {@link MessageBus.emit} runs it through that participant before delivery.
+   * Returns a disposer.
+   * @internal
+   */
+  readonly intercept = (
+    gateway: MessageGateway,
+    messageClass: MessageClass,
+  ): (() => void) => {
+    const routes = getOrInsert(this.#interceptorRoutes, messageClass, new Set())
+    routes.add(gateway.name)
+    return () => {
+      this.#interceptorRoutes.get(messageClass)?.delete(gateway.name)
+    }
+  }
+
+  /**
+   * Creates a participant under `name` and returns its {@link MessageGateway}
+   * facade — the object a participant uses to subscribe, emit, and invoke. Names
+   * are unique for the bus's lifetime; a name frees up again once its participant
+   * is aborted.
+   *
+   * @throws if a participant with `name` is already registered.
+   */
+  gateway(name: string, abortSignal?: AbortSignal) {
+    if (this.#dispatchers.has(name))
+      throw new Error(`Gateway "${name}" has already been registered`)
+
+    const dispatcher = new MessageDispatcher(this, name, abortSignal)
+    this.#dispatchers.set(name, dispatcher)
+
+    dispatcher.onAbort(() => {
+      this.#dispatchers.delete(name)
+      for (const routes of [
+        this.#subscriberRoutes,
+        this.#responderRoutes,
+        this.#interceptorRoutes,
+      ])
+        for (const names of routes.values()) names.delete(name)
+    })
+
+    return dispatcher.createGateway()
+  }
+
+  /**
+   * Runs the message through every registered interceptor (in chain order) and
+   * then delivers the final message to every subscribed participant. Resolves to
+   * the message as it stood after interception, or {@link CANCEL} if an
+   * interceptor cancelled it. Prefer `gateway.emit`, which queues through the
+   * sender's queue; this is the bus-level primitive it calls.
+   * @internal
+   */
+  async emit(message: M): Promise {
+    const interceptorNames =
+      this.#interceptorRoutes.get(message.constructor as MessageClass) ?? []
+
+    for (const name of interceptorNames) {
+      const result = await this.#dispatchers.get(name)?.runInterceptors(message)
+      if (result === CANCEL) return CANCEL
+      else if (result) message = result as M
+    }
+
+    const subscriberNames = this.#subscriberRoutes.get(
+      message.constructor as MessageClass,
+    )
+    if (!subscriberNames?.size) return message
+
+    for (const name of subscriberNames)
+      this.#dispatchers.get(name)?.dispatch(message)
+
+    return message
+  }
+
+  /**
+   * Fans a command out to every participant that registered for it and resolves
+   * once they have all settled. The participant set is snapshotted so a
+   * participant (un)registering mid-flight can't move the target; a participant
+   * that has since been removed simply contributes a resolved `undefined`. Prefer
+   * `gateway.invoke`, which wraps this in a stream; this is the bus-level
+   * primitive it calls.
+   * @internal
+   */
+  async invoke(
+    command: CommandMessage,
+    context: ResponderContext>,
+    reportError: ResponderErrorHandler,
+  ): Promise {
+    const responderNames = this.#responderRoutes.get(
+      command.constructor as CommandMessageClass,
+    )
+
+    if (!responderNames?.size) return
+
+    await Promise.all(
+      Array.from(responderNames, (name) =>
+        this.#dispatchers
+          .get(name)
+          ?.dispatchCommand(command, context, reportError),
+      ),
+    )
+  }
+
+  /**
+   * Permanently tears down the named participant: fires its abort signal, clears
+   * its handlers, and removes it from all routing so its name can be reused.
+   * Unlike {@link MessageBus.pause}, this cannot be undone.
+   */
+  abort(name: string, reason?: Error) {
+    this.#dispatchers.get(name)?.abort(reason)
+  }
+
+  /**
+   * Resumes participants so queued and future messages are delivered.
+   * Participants begin paused, so this is also how you first bring the bus to
+   * life. With a `name`, resumes just that participant; with no argument, resumes
+   * every participant on the bus.
+   */
+  resume(name?: string) {
+    if (name === undefined)
+      for (const dispatcher of this.#dispatchers.values()) dispatcher.resume()
+    else this.#dispatchers.get(name)?.resume()
+  }
+
+  /**
+   * Pauses participants: their queues stop draining and inbound messages buffer
+   * until resumed. With a `name`, pauses just that participant; with no argument,
+   * pauses every participant on the bus. Reversible via {@link MessageBus.resume}.
+   */
+  pause(name?: string) {
+    if (name === undefined)
+      for (const dispatcher of this.#dispatchers.values()) dispatcher.pause()
+    else this.#dispatchers.get(name)?.pause()
+  }
+}
diff --git a/packages/message-bus/src/Roles/Interceptor.ts b/packages/message-bus/src/Roles/Interceptor.ts
new file mode 100644
index 000000000..73dfb0cfb
--- /dev/null
+++ b/packages/message-bus/src/Roles/Interceptor.ts
@@ -0,0 +1,35 @@
+import { MessageClass } from '../Message/Message.js'
+
+/**
+ * Inspects a message before subscribers see it and may transform or cancel it.
+ * Registered with `gateway.intercept`. Interceptors across gateways run in a
+ * chain, each receiving the previous one's (possibly replaced) message.
+ *
+ * In EIP terms each interceptor is a *Message Translator* stage and the chain is
+ * *Pipes and Filters*, with a *Message Filter* veto via {@link CANCEL}. Adding
+ * data makes it a *Content Enricher*, stripping data a *Content Filter* — those
+ * are uses of an interceptor, not the whole of it.
+ */
+export interface Interceptor {
+  (message: InstanceType): InterceptorResult
+}
+
+/**
+ * What an {@link Interceptor} may return:
+ * - `void`/`undefined` — leave the message unchanged;
+ * - a new message instance — replace it for downstream interceptors and subscribers;
+ * - {@link CANCEL} — drop the message entirely;
+ * - a promise of any of the above.
+ */
+export type InterceptorResult =
+  | void
+  | InstanceType
+  | typeof CANCEL
+  | Promise>
+
+/**
+ * Returned by an interceptor to stop a message dead: no further interceptors run
+ * and no subscribers are notified. Also the resolved value of `gateway.emit`
+ * when an interceptor cancelled the message.
+ */
+export const CANCEL = Symbol.for('dmg-bus/cancel')
diff --git a/packages/message-bus/src/Roles/Responder.ts b/packages/message-bus/src/Roles/Responder.ts
new file mode 100644
index 000000000..b8190edbe
--- /dev/null
+++ b/packages/message-bus/src/Roles/Responder.ts
@@ -0,0 +1,41 @@
+import { CommandMessageClass, ResponseType } from '../Message/CommandMessage.js'
+
+/**
+ * Handles a command message, streaming results back through `context.send`.
+ * Registered with `gateway.register`. The command completes for this responder
+ * when the function returns (or its returned promise settles) — see
+ * {@link ResponderContext}.
+ *
+ * @example
+ * ```ts
+ * gateway.register(ListFiles, async (command, { send, signal }) => {
+ *   for (const file of await readdir(command.dir, { signal })) send(file)
+ * })
+ * ```
+ */
+export interface Responder> {
+  (command: InstanceType, context: ResponderContext): void | Promise
+}
+
+/**
+ * The context handed to a responder. A responder streams results through
+ * `send`; the command is considered complete for that responder when the
+ * function it registered returns (or its returned promise settles). There is
+ * deliberately no `finish` — completion is implicit, so a responder can never
+ * forget to call it, and one that is filtered out or throws can't wedge the
+ * stream. `signal` aborts when the consumer aborts, the gateway aborts, or the
+ * stream is otherwise torn down, so long-running work can bail early.
+ */
+export interface ResponderContext> {
+  send: (value: ResponseType) => void
+  signal: AbortSignal
+}
+
+/**
+ * Receives an error thrown (or rejected) by an individual responder. Responders
+ * are isolated: reporting an error here lets the other responders keep streaming
+ * and the command still complete. The default sink rethrows, so an unobserved
+ * responder error surfaces on the stream instead of being swallowed; pass your
+ * own to `invoke({ onError })` to observe errors without failing it.
+ */
+export type ResponderErrorHandler = (error: unknown) => void
diff --git a/packages/message-bus/src/Roles/Subscriber.ts b/packages/message-bus/src/Roles/Subscriber.ts
new file mode 100644
index 000000000..1e9266ebe
--- /dev/null
+++ b/packages/message-bus/src/Roles/Subscriber.ts
@@ -0,0 +1,9 @@
+import { MessageClass } from '../Message/Message.js'
+
+/**
+ * Handles an emitted message. Registered with `gateway.on` / `gateway.once`. A
+ * returned promise is not awaited by the emitter — messages are fire-and-forget.
+ */
+export interface Subscriber {
+  (message: InstanceType): void | Promise
+}
diff --git a/packages/message-bus/src/index.ts b/packages/message-bus/src/index.ts
index 4c9427a3f..bdecd440f 100644
--- a/packages/message-bus/src/index.ts
+++ b/packages/message-bus/src/index.ts
@@ -1,5 +1,8 @@
-export { PluginBroker } from './Broker/PluginBroker.js'
-export { Bus } from './Bus.js'
-export * from './EventListener.js'
-export * from './Event.js'
+export { MessageGateway } from './Gateway/MessageGateway.js'
+export { MessageBus } from './MessageBus.js'
+export * from './Roles/Subscriber.js'
+export * from './Roles/Responder.js'
+export * from './Roles/Interceptor.js'
+export * from './Message/Message.js'
+export * from './Message/CommandMessage.js'
 export * from './Filter.js'
diff --git a/packages/message-bus/test/broker-lifecycle.test.ts b/packages/message-bus/test/broker-lifecycle.test.ts
deleted file mode 100644
index d465c460f..000000000
--- a/packages/message-bus/test/broker-lifecycle.test.ts
+++ /dev/null
@@ -1,275 +0,0 @@
-import { beforeEach, describe, expect, test, vi } from 'vitest'
-import { Bus } from '../src/Bus.js'
-import { Event, Invocation } from '../src/Event.js'
-import { CANCEL } from '../src/EventListener.js'
-import type { PluginBroker } from '../src/Broker/PluginBroker.js'
-
-let bus: Bus
-let brokerA: PluginBroker
-let brokerB: PluginBroker
-
-beforeEach(() => {
-  bus = new Bus()
-  brokerA = bus.broker('a')
-  brokerB = bus.broker('b')
-  bus.resume()
-})
-
-describe('unsubscribe', () => {
-  test('on() returns a disposer that stops delivery', () => {
-    const spy = vi.fn()
-    const off = brokerA.on(TestEvent, spy)
-    brokerB.emit(new TestEvent('one'))
-    off()
-    brokerB.emit(new TestEvent('two'))
-    expect(spy).toHaveBeenCalledTimes(1)
-  })
-
-  test('a disposed listener does not resubscribe on a later emit', () => {
-    const spy = vi.fn()
-    const off = brokerA.on(TestEvent, spy)
-    off()
-    brokerB.emit(new TestEvent('x'))
-    brokerB.emit(new TestEvent('y'))
-    expect(spy).not.toHaveBeenCalled()
-  })
-
-  test('intercept() returns a disposer', async () => {
-    const spy = vi.fn((e: TestEvent) => new TestEvent(`i ${e.foo}`))
-    const off = brokerA.intercept(TestEvent, spy)
-    off()
-    const onSpy = vi.fn()
-    brokerB.on(TestEvent, onSpy)
-    await brokerB.emit(new TestEvent('foo'))
-    expect(spy).not.toHaveBeenCalled()
-    expect(onSpy).toHaveBeenCalledWith(new TestEvent('foo'))
-  })
-})
-
-describe('abort', () => {
-  test('clears the aborted broker’s event handlers', () => {
-    const spy = vi.fn()
-    brokerA.on(TestEvent, spy)
-    brokerB.abort('a')
-    brokerB.emit(new TestEvent('foo'))
-    expect(spy).not.toHaveBeenCalled()
-  })
-
-  test('removes the broker from the bus so its name can be reused', () => {
-    brokerA.abort('a')
-    expect(() => bus.broker('a')).not.toThrow()
-  })
-
-  test('onAbort fires once with the reason', () => {
-    const spy = vi.fn()
-    brokerA.onAbort(spy)
-    const reason = new Error('bye')
-    brokerB.abort('a', reason)
-    expect(brokerA.abortSignal.aborted).toBe(true)
-    expect(brokerA.abortSignal.reason).toBe(reason)
-    expect(spy).toHaveBeenCalledTimes(1)
-  })
-})
-
-describe('registering the same broker name twice', () => {
-  test('throws', () => {
-    expect(() => bus.broker('a')).toThrow(/already been registered/)
-  })
-})
-
-describe('pause / resume', () => {
-  test('events emitted while a broker is paused are buffered then replayed', () => {
-    const spy = vi.fn()
-    brokerB.pause('a')
-    brokerA.on(TestEvent, spy)
-    brokerB.emit(new TestEvent('foo'))
-    expect(spy).not.toHaveBeenCalled()
-    brokerB.resume('a')
-    expect(spy).toHaveBeenCalledTimes(1)
-  })
-
-  test('a paused broker does not intercept', async () => {
-    const spy = vi.fn((e: TestEvent) => new TestEvent(`i ${e.foo}`))
-    brokerA.intercept(TestEvent, spy)
-    brokerB.pause('a')
-    const onSpy = vi.fn()
-    brokerB.on(TestEvent, onSpy)
-    await brokerB.emit(new TestEvent('foo'))
-    expect(spy).not.toHaveBeenCalled()
-    expect(onSpy).toHaveBeenCalledWith(new TestEvent('foo'))
-  })
-})
-
-describe('invoke completion', () => {
-  test('collect resolves to [] when there are no registrants', async () => {
-    expect(await brokerB.invoke(new TestInvocation('x')).collect()).toEqual([])
-  })
-
-  test('collect resolves to [] when the only registrant is paused', async () => {
-    brokerA.register(TestInvocation, (_e, { send }) => {
-      send('nope')
-    })
-    brokerB.pause('a')
-    expect(await brokerB.invoke(new TestInvocation('x')).collect()).toEqual([])
-  })
-
-  test('a filtered-out handler does not hang the stream', async () => {
-    // Regression: completion is per-handler settling, so a handler whose filter
-    // rejects the event completes immediately instead of deadlocking collect().
-    brokerA.register(TestInvocation, { foo: 'never' }, (_e, { send }) => {
-      send('nope')
-    })
-    expect(
-      await brokerB.invoke(new TestInvocation('actual')).collect(),
-    ).toEqual([])
-  })
-
-  test('completes when a handler returns without any explicit signal', async () => {
-    brokerA.register(TestInvocation, (_e, { send }) => {
-      send('a')
-      send('b')
-    })
-    expect(await brokerB.invoke(new TestInvocation('x')).collect()).toEqual([
-      'a',
-      'b',
-    ])
-  })
-
-  test('waits for an async handler to settle before completing', async () => {
-    brokerA.register(TestInvocation, async (_e, { send }) => {
-      send('first')
-      await Promise.resolve()
-      await Promise.resolve()
-      send('second')
-    })
-    expect(await brokerB.invoke(new TestInvocation('x')).collect()).toEqual([
-      'first',
-      'second',
-    ])
-  })
-
-  test('a throwing handler rejects the stream when no onError is given', async () => {
-    brokerA.register(TestInvocation, () => {
-      throw new Error('boom')
-    })
-    await expect(
-      brokerB.invoke(new TestInvocation('x')).collect(),
-    ).rejects.toThrow('boom')
-  })
-
-  test('onError isolates a throwing handler and the stream still completes', async () => {
-    brokerA.register(TestInvocation, () => {
-      throw new Error('boom')
-    })
-    brokerB.register(TestInvocation, (_e, { send }) => send('ok'))
-
-    const onError = vi.fn()
-    const items = await brokerB
-      .invoke(new TestInvocation('x'), { onError })
-      .collect()
-
-    expect(items).toEqual(['ok'])
-    expect(onError).toHaveBeenCalledTimes(1)
-    expect(onError.mock.calls[0][0]).toBeInstanceOf(Error)
-    expect((onError.mock.calls[0][0] as Error).message).toBe('boom')
-  })
-
-  test('onError also catches a rejected async handler', async () => {
-    brokerA.register(TestInvocation, async () => {
-      throw new Error('async boom')
-    })
-
-    const onError = vi.fn()
-    const items = await brokerA
-      .invoke(new TestInvocation('x'), { onError })
-      .collect()
-
-    expect(items).toEqual([])
-    expect((onError.mock.calls[0][0] as Error).message).toBe('async boom')
-  })
-
-  test('onError receives an error from every failing handler across brokers', async () => {
-    brokerA.register(TestInvocation, () => {
-      throw new Error('a')
-    })
-    brokerB.register(TestInvocation, () => {
-      throw new Error('b')
-    })
-
-    const onError = vi.fn()
-    await brokerA.invoke(new TestInvocation('x'), { onError }).collect()
-
-    const messages = onError.mock.calls.map((c) => (c[0] as Error).message)
-    expect(messages.sort()).toEqual(['a', 'b'])
-  })
-
-  test('completes across multiple brokers each streaming', async () => {
-    brokerA.register(TestInvocation, (_e, { send }) => send('a'))
-    brokerB.register(TestInvocation, async (_e, { send }) => {
-      await Promise.resolve()
-      send('b')
-    })
-    const items = await brokerA.invoke(new TestInvocation('x')).collect()
-    expect(items.sort()).toEqual(['a', 'b'])
-  })
-})
-
-describe('multiple interceptors', () => {
-  test('are applied in a chain', async () => {
-    brokerA.intercept(TestEvent, (e) => new TestEvent(`${e.foo}-a`))
-    brokerB.intercept(TestEvent, (e) => new TestEvent(`${e.foo}-b`))
-    const spy = vi.fn()
-    brokerA.on(TestEvent, spy)
-    await brokerB.emit(new TestEvent('start'))
-    const received = spy.mock.calls[0]?.[0] as TestEvent
-    expect(received.foo).toMatch(/^start-/)
-  })
-
-  test('a later interceptor can cancel after an earlier one transformed', async () => {
-    brokerA.intercept(TestEvent, (e) => new TestEvent(`${e.foo}-a`))
-    brokerB.intercept(TestEvent, () => CANCEL)
-    const spy = vi.fn()
-    brokerA.on(TestEvent, spy)
-    const result = await brokerB.emit(new TestEvent('start'))
-    expect(result).toBe(CANCEL)
-    expect(spy).not.toHaveBeenCalled()
-  })
-})
-
-describe('emit return value', () => {
-  test('each call resolves to its own (possibly intercepted) event', async () => {
-    brokerA.intercept(TestEvent, (e) => new TestEvent(`i ${e.foo}`))
-    const first = (await brokerB.emit(new TestEvent('one'))) as TestEvent
-    const second = (await brokerB.emit(new TestEvent('two'))) as TestEvent
-    expect(first.foo).toBe('i one')
-    expect(second.foo).toBe('i two')
-  })
-
-  test('a cancelled emit resolves to CANCEL without leaking to later emits', async () => {
-    brokerA.intercept(TestEvent, { foo: 'kill' }, () => CANCEL)
-    expect(await brokerB.emit(new TestEvent('kill'))).toBe(CANCEL)
-    const after = (await brokerB.emit(new TestEvent('ok'))) as TestEvent
-    expect(after.foo).toBe('ok')
-  })
-})
-
-describe('until', () => {
-  test('rejects when the broker aborts before the event arrives', async () => {
-    const promise = brokerA.until(TestEvent)
-    const reason = new Error('gone')
-    brokerB.abort('a', reason)
-    await expect(promise).rejects.toBe(reason)
-  })
-})
-
-class TestEvent implements Event {
-  $name = 'test'
-  constructor(readonly foo: string) {}
-}
-
-class TestInvocation extends Invocation {
-  $name = 'test invocation'
-  constructor(readonly foo: string) {
-    super()
-  }
-}
diff --git a/packages/message-bus/test/broker.test.ts b/packages/message-bus/test/broker.test.ts
deleted file mode 100644
index 58705e3d2..000000000
--- a/packages/message-bus/test/broker.test.ts
+++ /dev/null
@@ -1,187 +0,0 @@
-import { setTimeout } from 'node:timers/promises'
-import { beforeEach, describe, expect, Mock, test, vi } from 'vitest'
-import { Bus } from '../src/Bus.js'
-import { Event, Invocation } from '../src/Event.js'
-import { CANCEL, type InvocationListenerContext } from '../src/EventListener.js'
-import type { PluginBroker } from '../src/Broker/PluginBroker.js'
-
-let brokerA: PluginBroker
-let brokerB: PluginBroker
-
-beforeEach(() => {
-  const bus = new Bus()
-  brokerA = bus.broker('a')
-  brokerB = bus.broker('b')
-  bus.resume()
-})
-
-test('emit', () => {
-  const spy = vi.fn()
-  const event = new TestEvent('bar')
-  brokerA.on(TestEvent, spy)
-  brokerB.emit(event)
-  expect(spy).toHaveBeenCalledWith(event)
-})
-
-test('once', () => {
-  const spy = vi.fn()
-  const event = new TestEvent('bar')
-  brokerA.once(TestEvent, spy)
-  brokerA.emit(event)
-  brokerB.emit(event)
-  expect(spy).toHaveBeenCalledTimes(1)
-  expect(spy.mock.calls[0][0]).toBe(event)
-})
-
-test('twice', () => {
-  const spy = vi.fn()
-  const event = new TestEvent('bar')
-  brokerA.on(TestEvent, spy)
-  brokerA.emit(event)
-  brokerB.emit(event)
-  expect(spy).toHaveBeenCalledTimes(2)
-})
-
-test('until', async () => {
-  const event = new TestEvent('bar')
-  const promise = brokerA.until(TestEvent)
-  brokerA.emit(event)
-  expect(await promise).toEqual(event)
-})
-
-test('pause', () => {
-  const spy = vi.fn()
-  brokerB.pause('a')
-  brokerA.on(TestEvent, spy)
-  brokerB.emit(new TestEvent('foo'))
-  expect(spy).not.toHaveBeenCalled()
-  brokerB.resume('a')
-  expect(spy).toHaveBeenCalled()
-})
-
-test('abort', () => {
-  const spy = vi.fn()
-  brokerA.abortSignal.addEventListener('abort', spy)
-  brokerB.abort('a')
-  expect(spy).toHaveBeenCalled()
-})
-
-describe('invoke', () => {
-  let spy: Mock<
-    (
-      event: TestInvocation,
-      context: InvocationListenerContext,
-    ) => void
-  >
-
-  beforeEach(() => {
-    spy = vi.fn((event, { send }) => {
-      send(`one ${event.foo}`)
-      send(`two ${event.foo}`)
-    })
-
-    brokerA.register(TestInvocation, spy)
-  })
-
-  test('collect', () =>
-    expect(
-      brokerB.invoke(new TestInvocation('thing')).collect(),
-    ).resolves.toEqual(['one thing', 'two thing']))
-
-  test('iterate', async () => {
-    let result: string[] = []
-    for await (const item of brokerB
-      .invoke(new TestInvocation('thing'))
-      .iterate()) {
-      result.push(item)
-    }
-    expect(result).toEqual(['one thing', 'two thing'])
-  })
-
-  test('it emits the event as well', () => {
-    const onSpy = vi.fn()
-    const event = new TestInvocation('bar')
-    brokerA.on(TestInvocation, onSpy)
-    brokerB.invoke(event)
-    expect(onSpy).toHaveBeenCalledWith(event)
-  })
-
-  test('multi registers', async () => {
-    brokerA.register(TestInvocation, (_, { send }) => {
-      send('foo')
-    })
-
-    brokerB.register(TestInvocation, (_, { send }) => {
-      send('bar')
-    })
-
-    expect(await brokerB.invoke(new TestInvocation('foo')).collect()).toEqual([
-      'one foo',
-      'two foo',
-      'foo',
-      'bar',
-    ])
-  })
-
-  test('timeouts', async () => {
-    brokerA.register(TestInvocation, async (event, { send, signal }) => {
-      send(`hello ${event.foo}`)
-      try {
-        await setTimeout(1_000, null, { signal })
-      } catch (error) {}
-      send(`hello again ${event.foo}`)
-    })
-
-    expect(
-      await brokerB
-        .invoke(new TestInvocation('foo'), { signal: AbortSignal.timeout(10) })
-        .collect(),
-    ).toEqual(['one foo', 'two foo', 'hello foo'])
-  })
-})
-
-describe('intercept', () => {
-  test('cancelling', async () => {
-    const spy = vi.fn()
-    brokerA.on(TestEvent, spy)
-    brokerB.intercept(TestEvent, () => CANCEL)
-    await brokerB.emit(new TestEvent('foo'))
-    expect(spy).not.toHaveBeenCalled()
-  })
-
-  test('changing the event', async () => {
-    const spy = vi.fn()
-    brokerA.on(TestEvent, spy)
-    brokerB.intercept(
-      TestEvent,
-      (event) => new TestEvent(`Intercepted ${event.foo}`),
-    )
-    await brokerB.emit(new TestEvent('foo'))
-    expect(spy).toHaveBeenCalledWith(new TestEvent('Intercepted foo'))
-  })
-
-  test('changing invocations', async () => {
-    const spy = vi.fn()
-    brokerA.register(TestInvocation, (event) => {
-      spy(event)
-    })
-    brokerB.intercept(
-      TestInvocation,
-      (event) => new TestInvocation(`Intercepted ${event.foo}`),
-    )
-    await brokerB.invoke(new TestInvocation('foo')).collect()
-    expect(spy).toHaveBeenCalledWith(new TestInvocation('Intercepted foo'))
-  })
-})
-
-class TestEvent implements Event {
-  $name = 'test'
-  constructor(readonly foo: string) {}
-}
-
-class TestInvocation extends Invocation {
-  $name = 'test invocation'
-  constructor(readonly foo: string) {
-    super()
-  }
-}
diff --git a/packages/message-bus/test/filter.test.ts b/packages/message-bus/test/filter.test.ts
index 43bbd8ae1..18521f8e8 100644
--- a/packages/message-bus/test/filter.test.ts
+++ b/packages/message-bus/test/filter.test.ts
@@ -1,71 +1,71 @@
 import { beforeEach, expect, test, vi } from 'vitest'
-import { Bus } from '../src/Bus.js'
-import { Event } from '../src/Event.js'
-import type { PluginBroker } from '../src/Broker/PluginBroker.js'
+import { MessageBus } from '../src/MessageBus.js'
+import { Message } from '../src/Message/Message.js'
+import type { MessageGateway } from '../src/Gateway/MessageGateway.js'
 
-let brokerA: PluginBroker
-let brokerB: PluginBroker
+let gatewayA: MessageGateway
+let gatewayB: MessageGateway
 
 beforeEach(() => {
-  const bus = new Bus()
-  brokerA = bus.broker('a')
-  brokerB = bus.broker('b')
+  const bus = new MessageBus()
+  gatewayA = bus.gateway('a')
+  gatewayB = bus.gateway('b')
   bus.resume()
 })
 
-test('an empty filter matches every event', () => {
+test('an empty filter matches every message', () => {
   const spy = vi.fn()
-  brokerA.on(TestEvent, {}, spy)
-  brokerB.emit(new TestEvent('anything'))
+  gatewayA.on(TestMessage, {}, spy)
+  gatewayB.emit(new TestMessage('anything'))
   expect(spy).toHaveBeenCalledTimes(1)
 })
 
 test('a value filter matches on equality', () => {
   const spy = vi.fn()
-  brokerA.on(TestEvent, { foo: 'yes' }, spy)
-  brokerB.emit(new TestEvent('no'))
+  gatewayA.on(TestMessage, { foo: 'yes' }, spy)
+  gatewayB.emit(new TestMessage('no'))
   expect(spy).not.toHaveBeenCalled()
-  brokerB.emit(new TestEvent('yes'))
+  gatewayB.emit(new TestMessage('yes'))
   expect(spy).toHaveBeenCalledTimes(1)
 })
 
 test('a predicate filter matches on the returned boolean', () => {
   const spy = vi.fn()
-  brokerA.on(TestEvent, { foo: (e) => e.foo.startsWith('a') }, spy)
-  brokerB.emit(new TestEvent('bee'))
+  gatewayA.on(TestMessage, { foo: (m) => m.foo.startsWith('a') }, spy)
+  gatewayB.emit(new TestMessage('bee'))
   expect(spy).not.toHaveBeenCalled()
-  brokerB.emit(new TestEvent('ant'))
+  gatewayB.emit(new TestMessage('ant'))
   expect(spy).toHaveBeenCalledTimes(1)
 })
 
 test('a multi-key filter requires every key to match (AND, not OR)', () => {
   const spy = vi.fn()
-  brokerA.on(TestEvent, { foo: 'match', bar: 'match' }, spy)
+  gatewayA.on(TestMessage, { foo: 'match', bar: 'match' }, spy)
 
   // only foo matches
-  brokerB.emit(new TestEvent('match', 'other'))
+  gatewayB.emit(new TestMessage('match', 'other'))
   // only bar matches
-  brokerB.emit(new TestEvent('other', 'match'))
+  gatewayB.emit(new TestMessage('other', 'match'))
   expect(spy).not.toHaveBeenCalled()
 
   // both match
-  brokerB.emit(new TestEvent('match', 'match'))
+  gatewayB.emit(new TestMessage('match', 'match'))
   expect(spy).toHaveBeenCalledTimes(1)
 })
 
 test('a multi-key filter mixes value and predicate keys with AND', () => {
   const spy = vi.fn()
-  brokerA.on(TestEvent, { foo: 'match', bar: (e) => e.bar.length > 2 }, spy)
+  gatewayA.on(TestMessage, { foo: 'match', bar: (m) => m.bar.length > 2 }, spy)
 
-  brokerB.emit(new TestEvent('match', 'no')) // predicate fails
-  brokerB.emit(new TestEvent('nope', 'yesss')) // value fails
+  gatewayB.emit(new TestMessage('match', 'no')) // predicate fails
+  gatewayB.emit(new TestMessage('nope', 'yesss')) // value fails
   expect(spy).not.toHaveBeenCalled()
 
-  brokerB.emit(new TestEvent('match', 'yesss'))
+  gatewayB.emit(new TestMessage('match', 'yesss'))
   expect(spy).toHaveBeenCalledTimes(1)
 })
 
-class TestEvent implements Event {
+class TestMessage implements Message {
   $name = 'test'
   constructor(
     readonly foo: string,
diff --git a/packages/message-bus/test/gateway-lifecycle.test.ts b/packages/message-bus/test/gateway-lifecycle.test.ts
new file mode 100644
index 000000000..47e2345f3
--- /dev/null
+++ b/packages/message-bus/test/gateway-lifecycle.test.ts
@@ -0,0 +1,277 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+import { MessageBus } from '../src/MessageBus.js'
+import { Message } from '../src/Message/Message.js'
+import { CommandMessage } from '../src/Message/CommandMessage.js'
+import { CANCEL } from '../src/Roles/Interceptor.js'
+import type { MessageGateway } from '../src/Gateway/MessageGateway.js'
+
+let bus: MessageBus
+let gatewayA: MessageGateway
+let gatewayB: MessageGateway
+
+beforeEach(() => {
+  bus = new MessageBus()
+  gatewayA = bus.gateway('a')
+  gatewayB = bus.gateway('b')
+  bus.resume()
+})
+
+describe('unsubscribe', () => {
+  test('on() returns a disposer that stops delivery', () => {
+    const spy = vi.fn()
+    const off = gatewayA.on(TestMessage, spy)
+    gatewayB.emit(new TestMessage('one'))
+    off()
+    gatewayB.emit(new TestMessage('two'))
+    expect(spy).toHaveBeenCalledTimes(1)
+  })
+
+  test('a disposed subscriber does not resubscribe on a later emit', () => {
+    const spy = vi.fn()
+    const off = gatewayA.on(TestMessage, spy)
+    off()
+    gatewayB.emit(new TestMessage('x'))
+    gatewayB.emit(new TestMessage('y'))
+    expect(spy).not.toHaveBeenCalled()
+  })
+
+  test('intercept() returns a disposer', async () => {
+    const spy = vi.fn((m: TestMessage) => new TestMessage(`i ${m.foo}`))
+    const off = gatewayA.intercept(TestMessage, spy)
+    off()
+    const onSpy = vi.fn()
+    gatewayB.on(TestMessage, onSpy)
+    await gatewayB.emit(new TestMessage('foo'))
+    expect(spy).not.toHaveBeenCalled()
+    expect(onSpy).toHaveBeenCalledWith(new TestMessage('foo'))
+  })
+})
+
+describe('abort', () => {
+  test('clears the aborted gateway’s subscribers', () => {
+    const spy = vi.fn()
+    gatewayA.on(TestMessage, spy)
+    gatewayB.abort('a')
+    gatewayB.emit(new TestMessage('foo'))
+    expect(spy).not.toHaveBeenCalled()
+  })
+
+  test('removes the gateway from the bus so its name can be reused', () => {
+    gatewayA.abort('a')
+    expect(() => bus.gateway('a')).not.toThrow()
+  })
+
+  test('onAbort fires once with the reason', () => {
+    const spy = vi.fn()
+    gatewayA.onAbort(spy)
+    const reason = new Error('bye')
+    gatewayB.abort('a', reason)
+    expect(gatewayA.abortSignal.aborted).toBe(true)
+    expect(gatewayA.abortSignal.reason).toBe(reason)
+    expect(spy).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe('registering the same gateway name twice', () => {
+  test('throws', () => {
+    expect(() => bus.gateway('a')).toThrow(/already been registered/)
+  })
+})
+
+describe('pause / resume', () => {
+  test('messages emitted while a gateway is paused are buffered then replayed', () => {
+    const spy = vi.fn()
+    gatewayB.pause('a')
+    gatewayA.on(TestMessage, spy)
+    gatewayB.emit(new TestMessage('foo'))
+    expect(spy).not.toHaveBeenCalled()
+    gatewayB.resume('a')
+    expect(spy).toHaveBeenCalledTimes(1)
+  })
+
+  test('a paused gateway does not intercept', async () => {
+    const spy = vi.fn((m: TestMessage) => new TestMessage(`i ${m.foo}`))
+    gatewayA.intercept(TestMessage, spy)
+    gatewayB.pause('a')
+    const onSpy = vi.fn()
+    gatewayB.on(TestMessage, onSpy)
+    await gatewayB.emit(new TestMessage('foo'))
+    expect(spy).not.toHaveBeenCalled()
+    expect(onSpy).toHaveBeenCalledWith(new TestMessage('foo'))
+  })
+})
+
+describe('invoke completion', () => {
+  test('collect resolves to [] when there are no registrants', async () => {
+    expect(await gatewayB.invoke(new TestCommand('x')).collect()).toEqual([])
+  })
+
+  test('collect resolves to [] when the only registrant is paused', async () => {
+    gatewayA.register(TestCommand, (_e, { send }) => {
+      send('nope')
+    })
+    gatewayB.pause('a')
+    expect(await gatewayB.invoke(new TestCommand('x')).collect()).toEqual([])
+  })
+
+  test('a filtered-out responder does not hang the stream', async () => {
+    // Regression: completion is per-responder settling, so a responder whose
+    // filter rejects the command completes immediately instead of deadlocking
+    // collect().
+    gatewayA.register(TestCommand, { foo: 'never' }, (_e, { send }) => {
+      send('nope')
+    })
+    expect(await gatewayB.invoke(new TestCommand('actual')).collect()).toEqual(
+      [],
+    )
+  })
+
+  test('completes when a responder returns without any explicit signal', async () => {
+    gatewayA.register(TestCommand, (_e, { send }) => {
+      send('a')
+      send('b')
+    })
+    expect(await gatewayB.invoke(new TestCommand('x')).collect()).toEqual([
+      'a',
+      'b',
+    ])
+  })
+
+  test('waits for an async responder to settle before completing', async () => {
+    gatewayA.register(TestCommand, async (_e, { send }) => {
+      send('first')
+      await Promise.resolve()
+      await Promise.resolve()
+      send('second')
+    })
+    expect(await gatewayB.invoke(new TestCommand('x')).collect()).toEqual([
+      'first',
+      'second',
+    ])
+  })
+
+  test('a throwing responder rejects the stream when no onError is given', async () => {
+    gatewayA.register(TestCommand, () => {
+      throw new Error('boom')
+    })
+    await expect(
+      gatewayB.invoke(new TestCommand('x')).collect(),
+    ).rejects.toThrow('boom')
+  })
+
+  test('onError isolates a throwing responder and the stream still completes', async () => {
+    gatewayA.register(TestCommand, () => {
+      throw new Error('boom')
+    })
+    gatewayB.register(TestCommand, (_e, { send }) => send('ok'))
+
+    const onError = vi.fn()
+    const items = await gatewayB
+      .invoke(new TestCommand('x'), { onError })
+      .collect()
+
+    expect(items).toEqual(['ok'])
+    expect(onError).toHaveBeenCalledTimes(1)
+    expect(onError.mock.calls[0][0]).toBeInstanceOf(Error)
+    expect((onError.mock.calls[0][0] as Error).message).toBe('boom')
+  })
+
+  test('onError also catches a rejected async responder', async () => {
+    gatewayA.register(TestCommand, async () => {
+      throw new Error('async boom')
+    })
+
+    const onError = vi.fn()
+    const items = await gatewayA
+      .invoke(new TestCommand('x'), { onError })
+      .collect()
+
+    expect(items).toEqual([])
+    expect((onError.mock.calls[0][0] as Error).message).toBe('async boom')
+  })
+
+  test('onError receives an error from every failing responder across gateways', async () => {
+    gatewayA.register(TestCommand, () => {
+      throw new Error('a')
+    })
+    gatewayB.register(TestCommand, () => {
+      throw new Error('b')
+    })
+
+    const onError = vi.fn()
+    await gatewayA.invoke(new TestCommand('x'), { onError }).collect()
+
+    const messages = onError.mock.calls.map((c) => (c[0] as Error).message)
+    expect(messages.sort()).toEqual(['a', 'b'])
+  })
+
+  test('completes across multiple gateways each streaming', async () => {
+    gatewayA.register(TestCommand, (_e, { send }) => send('a'))
+    gatewayB.register(TestCommand, async (_e, { send }) => {
+      await Promise.resolve()
+      send('b')
+    })
+    const items = await gatewayA.invoke(new TestCommand('x')).collect()
+    expect(items.sort()).toEqual(['a', 'b'])
+  })
+})
+
+describe('multiple interceptors', () => {
+  test('are applied in a chain', async () => {
+    gatewayA.intercept(TestMessage, (m) => new TestMessage(`${m.foo}-a`))
+    gatewayB.intercept(TestMessage, (m) => new TestMessage(`${m.foo}-b`))
+    const spy = vi.fn()
+    gatewayA.on(TestMessage, spy)
+    await gatewayB.emit(new TestMessage('start'))
+    const received = spy.mock.calls[0]?.[0] as TestMessage
+    expect(received.foo).toMatch(/^start-/)
+  })
+
+  test('a later interceptor can cancel after an earlier one transformed', async () => {
+    gatewayA.intercept(TestMessage, (m) => new TestMessage(`${m.foo}-a`))
+    gatewayB.intercept(TestMessage, () => CANCEL)
+    const spy = vi.fn()
+    gatewayA.on(TestMessage, spy)
+    const result = await gatewayB.emit(new TestMessage('start'))
+    expect(result).toBe(CANCEL)
+    expect(spy).not.toHaveBeenCalled()
+  })
+})
+
+describe('emit return value', () => {
+  test('each call resolves to its own (possibly intercepted) message', async () => {
+    gatewayA.intercept(TestMessage, (m) => new TestMessage(`i ${m.foo}`))
+    const first = (await gatewayB.emit(new TestMessage('one'))) as TestMessage
+    const second = (await gatewayB.emit(new TestMessage('two'))) as TestMessage
+    expect(first.foo).toBe('i one')
+    expect(second.foo).toBe('i two')
+  })
+
+  test('a cancelled emit resolves to CANCEL without leaking to later emits', async () => {
+    gatewayA.intercept(TestMessage, { foo: 'kill' }, () => CANCEL)
+    expect(await gatewayB.emit(new TestMessage('kill'))).toBe(CANCEL)
+    const after = (await gatewayB.emit(new TestMessage('ok'))) as TestMessage
+    expect(after.foo).toBe('ok')
+  })
+})
+
+describe('until', () => {
+  test('rejects when the gateway aborts before the message arrives', async () => {
+    const promise = gatewayA.until(TestMessage)
+    const reason = new Error('gone')
+    gatewayB.abort('a', reason)
+    await expect(promise).rejects.toBe(reason)
+  })
+})
+
+class TestMessage implements Message {
+  $name = 'test'
+  constructor(readonly foo: string) {}
+}
+
+class TestCommand extends CommandMessage {
+  $name = 'test command'
+  constructor(readonly foo: string) {
+    super()
+  }
+}
diff --git a/packages/message-bus/test/gateway.test.ts b/packages/message-bus/test/gateway.test.ts
new file mode 100644
index 000000000..91f6e3ea8
--- /dev/null
+++ b/packages/message-bus/test/gateway.test.ts
@@ -0,0 +1,189 @@
+import { setTimeout } from 'node:timers/promises'
+import { beforeEach, describe, expect, Mock, test, vi } from 'vitest'
+import { MessageBus } from '../src/MessageBus.js'
+import { Message } from '../src/Message/Message.js'
+import { CommandMessage } from '../src/Message/CommandMessage.js'
+import { CANCEL } from '../src/Roles/Interceptor.js'
+import type { ResponderContext } from '../src/Roles/Responder.js'
+import type { MessageGateway } from '../src/Gateway/MessageGateway.js'
+
+let gatewayA: MessageGateway
+let gatewayB: MessageGateway
+
+beforeEach(() => {
+  const bus = new MessageBus()
+  gatewayA = bus.gateway('a')
+  gatewayB = bus.gateway('b')
+  bus.resume()
+})
+
+test('emit', () => {
+  const spy = vi.fn()
+  const message = new TestMessage('bar')
+  gatewayA.on(TestMessage, spy)
+  gatewayB.emit(message)
+  expect(spy).toHaveBeenCalledWith(message)
+})
+
+test('once', () => {
+  const spy = vi.fn()
+  const message = new TestMessage('bar')
+  gatewayA.once(TestMessage, spy)
+  gatewayA.emit(message)
+  gatewayB.emit(message)
+  expect(spy).toHaveBeenCalledTimes(1)
+  expect(spy.mock.calls[0][0]).toBe(message)
+})
+
+test('twice', () => {
+  const spy = vi.fn()
+  const message = new TestMessage('bar')
+  gatewayA.on(TestMessage, spy)
+  gatewayA.emit(message)
+  gatewayB.emit(message)
+  expect(spy).toHaveBeenCalledTimes(2)
+})
+
+test('until', async () => {
+  const message = new TestMessage('bar')
+  const promise = gatewayA.until(TestMessage)
+  gatewayA.emit(message)
+  expect(await promise).toEqual(message)
+})
+
+test('pause', () => {
+  const spy = vi.fn()
+  gatewayB.pause('a')
+  gatewayA.on(TestMessage, spy)
+  gatewayB.emit(new TestMessage('foo'))
+  expect(spy).not.toHaveBeenCalled()
+  gatewayB.resume('a')
+  expect(spy).toHaveBeenCalled()
+})
+
+test('abort', () => {
+  const spy = vi.fn()
+  gatewayA.abortSignal.addEventListener('abort', spy)
+  gatewayB.abort('a')
+  expect(spy).toHaveBeenCalled()
+})
+
+describe('invoke', () => {
+  let spy: Mock<
+    (
+      command: TestCommand,
+      context: ResponderContext,
+    ) => void
+  >
+
+  beforeEach(() => {
+    spy = vi.fn((command, { send }) => {
+      send(`one ${command.foo}`)
+      send(`two ${command.foo}`)
+    })
+
+    gatewayA.register(TestCommand, spy)
+  })
+
+  test('collect', () =>
+    expect(
+      gatewayB.invoke(new TestCommand('thing')).collect(),
+    ).resolves.toEqual(['one thing', 'two thing']))
+
+  test('iterate', async () => {
+    let result: string[] = []
+    for await (const item of gatewayB
+      .invoke(new TestCommand('thing'))
+      .iterate()) {
+      result.push(item)
+    }
+    expect(result).toEqual(['one thing', 'two thing'])
+  })
+
+  test('it emits the message as well', () => {
+    const onSpy = vi.fn()
+    const command = new TestCommand('bar')
+    gatewayA.on(TestCommand, onSpy)
+    gatewayB.invoke(command)
+    expect(onSpy).toHaveBeenCalledWith(command)
+  })
+
+  test('multi registers', async () => {
+    gatewayA.register(TestCommand, (_, { send }) => {
+      send('foo')
+    })
+
+    gatewayB.register(TestCommand, (_, { send }) => {
+      send('bar')
+    })
+
+    expect(await gatewayB.invoke(new TestCommand('foo')).collect()).toEqual([
+      'one foo',
+      'two foo',
+      'foo',
+      'bar',
+    ])
+  })
+
+  test('timeouts', async () => {
+    gatewayA.register(TestCommand, async (command, { send, signal }) => {
+      send(`hello ${command.foo}`)
+      try {
+        await setTimeout(1_000, null, { signal })
+      } catch (error) {}
+      send(`hello again ${command.foo}`)
+    })
+
+    expect(
+      await gatewayB
+        .invoke(new TestCommand('foo'), { signal: AbortSignal.timeout(10) })
+        .collect(),
+    ).toEqual(['one foo', 'two foo', 'hello foo'])
+  })
+})
+
+describe('intercept', () => {
+  test('cancelling', async () => {
+    const spy = vi.fn()
+    gatewayA.on(TestMessage, spy)
+    gatewayB.intercept(TestMessage, () => CANCEL)
+    await gatewayB.emit(new TestMessage('foo'))
+    expect(spy).not.toHaveBeenCalled()
+  })
+
+  test('changing the message', async () => {
+    const spy = vi.fn()
+    gatewayA.on(TestMessage, spy)
+    gatewayB.intercept(
+      TestMessage,
+      (message) => new TestMessage(`Intercepted ${message.foo}`),
+    )
+    await gatewayB.emit(new TestMessage('foo'))
+    expect(spy).toHaveBeenCalledWith(new TestMessage('Intercepted foo'))
+  })
+
+  test('changing commands', async () => {
+    const spy = vi.fn()
+    gatewayA.register(TestCommand, (command) => {
+      spy(command)
+    })
+    gatewayB.intercept(
+      TestCommand,
+      (command) => new TestCommand(`Intercepted ${command.foo}`),
+    )
+    await gatewayB.invoke(new TestCommand('foo')).collect()
+    expect(spy).toHaveBeenCalledWith(new TestCommand('Intercepted foo'))
+  })
+})
+
+class TestMessage implements Message {
+  $name = 'test'
+  constructor(readonly foo: string) {}
+}
+
+class TestCommand extends CommandMessage {
+  $name = 'test command'
+  constructor(readonly foo: string) {
+    super()
+  }
+}

From 9d19a79adb8bb7b752a3f2e95c5bb4a108fb3f1a Mon Sep 17 00:00:00 2001
From: John Wright 
Date: Fri, 18 Sep 2026 18:19:33 +0100
Subject: [PATCH 17/18] feat(message-bus): enforce serializable message
 payloads

Constrain emit/invoke arguments to a JSON-safe Serializable type so a
message that holds a live reference (e.g. an HTMLElement) is a compile
error. Messages can sit in a paused queue and be delivered later, so a
payload must be a faithful snapshot rather than a reference that may be
detached or mutated by then.

The check is type-level only; $-prefixed metadata keys ($name,
$responseType) are exempt since the class wrapper is never serialized.

Add tsc-gated type tests (test:types) covering the mapping and the
emit/invoke call sites.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 packages/message-bus/package.json             |   3 +-
 .../message-bus/src/Gateway/MessageGateway.ts |  11 +-
 .../message-bus/src/Message/Serializable.ts   |  47 ++++++++
 packages/message-bus/src/index.ts             |   1 +
 .../message-bus/test/serializable.test-d.ts   | 109 ++++++++++++++++++
 packages/message-bus/test/tsconfig.json       |   2 +
 6 files changed, 170 insertions(+), 3 deletions(-)
 create mode 100644 packages/message-bus/src/Message/Serializable.ts
 create mode 100644 packages/message-bus/test/serializable.test-d.ts

diff --git a/packages/message-bus/package.json b/packages/message-bus/package.json
index e097efdd9..780fe9162 100644
--- a/packages/message-bus/package.json
+++ b/packages/message-bus/package.json
@@ -11,7 +11,8 @@
     "build": "tsc --build",
     "clean": "tsc --build --clean && rimraf dist",
     "start": "tsc --watch --preserveWatchOutput",
-    "test": "vitest --run"
+    "test": "vitest --run && npm run test:types",
+    "test:types": "tsc -p test/tsconfig.json"
   },
   "author": "",
   "license": "MIT",
diff --git a/packages/message-bus/src/Gateway/MessageGateway.ts b/packages/message-bus/src/Gateway/MessageGateway.ts
index 1e12bf8b5..183ebda50 100644
--- a/packages/message-bus/src/Gateway/MessageGateway.ts
+++ b/packages/message-bus/src/Gateway/MessageGateway.ts
@@ -13,6 +13,7 @@ import type {
   ResponderContext,
   ResponderErrorHandler,
 } from '../Roles/Responder.js'
+import type { Serializable } from '../Message/Serializable.js'
 import type { Filter } from '../Filter.js'
 import { getOrInsert } from '../lang/Map.js'
 import type { MessageDispatcher } from './MessageDispatcher.js'
@@ -46,8 +47,14 @@ export class MessageGateway {
    * delivers to every participant subscribed to the message's class. Resolves to
    * the message as it stood after interception, or {@link CANCEL} if it was
    * cancelled. Queued through this participant, so it waits while it is paused.
+   *
+   * The message's payload must be {@link Serializable}, so that a subscriber
+   * receiving it later (after the queue was paused) sees a faithful snapshot
+   * rather than a possibly-stale live reference.
    */
-  readonly emit: (message: M) => Promise
+  readonly emit: (
+    message: M & Serializable,
+  ) => Promise
 
   constructor(dispatcher: MessageDispatcher) {
     this.#dispatcher = dispatcher
@@ -309,7 +316,7 @@ export class MessageGateway {
    * ```
    */
   invoke>(
-    command: E,
+    command: E & Serializable,
     {
       signal,
       onError = (error) => {
diff --git a/packages/message-bus/src/Message/Serializable.ts b/packages/message-bus/src/Message/Serializable.ts
new file mode 100644
index 000000000..fbd29a2ea
--- /dev/null
+++ b/packages/message-bus/src/Message/Serializable.ts
@@ -0,0 +1,47 @@
+/**
+ * A compile-time guard that a message payload is JSON-safe — no functions, no
+ * class instances (DOM nodes, `Date`, `Map`, `Set`, …), no symbols or bigints:
+ * only strings, numbers, booleans, `null`, and arrays/plain objects of those.
+ *
+ * Messages can sit in a participant's queue while it is paused and are delivered
+ * later ({@link MethodQueue}). A payload that holds a live reference — an
+ * `HTMLElement`, say — is therefore no longer a faithful snapshot by the time a
+ * subscriber sees it: the node may have been detached or mutated, and the queue
+ * pins it alive in the meantime. Constraining payloads to JSON-safe data means a
+ * subscriber receives what was emitted, whenever it is emitted.
+ *
+ * This is a *type-level* rule only; nothing is validated or cloned at runtime.
+ * Because the bus routes by class identity, the message's own class wrapper is
+ * not serialized — so keys prefixed with `$` (the framework's metadata
+ * convention, e.g. {@link Message.$name} and {@link CommandMessage.$responseType})
+ * are exempt from the check. It is the payload fields that must be serializable.
+ *
+ * Applied to `emit`/`invoke` arguments, a non-serializable field surfaces as a
+ * type error at the call site pointing at the offending field.
+ *
+ * @example
+ * ```ts
+ * class Clicked implements Message {
+ *   readonly $name = 'clicked'
+ *   constructor(readonly el: HTMLElement) {} // ⛔ emit(new Clicked(...)) errors
+ * }
+ * ```
+ */
+export type Serializable = [T] extends [JsonPrimitive | undefined]
+  ? T
+  : [T] extends [(...args: never[]) => unknown]
+    ? NotSerializable<'a function will not survive queuing'>
+    : [T] extends [readonly unknown[]]
+      ? { [I in keyof T]: Serializable }
+      : [T] extends [object]
+        ? { [K in keyof T]: K extends `$${string}` ? T[K] : Serializable }
+        : NotSerializable<'value is not JSON-serializable'>
+
+/** The primitive leaves JSON can represent. */
+export type JsonPrimitive = string | number | boolean | null
+
+/**
+ * An unsatisfiable type whose key carries the reason into the compiler error, so
+ * a rejected field reports *why* rather than a bare `never`.
+ */
+type NotSerializable = { [K in Why]: never }
diff --git a/packages/message-bus/src/index.ts b/packages/message-bus/src/index.ts
index bdecd440f..b2616f02c 100644
--- a/packages/message-bus/src/index.ts
+++ b/packages/message-bus/src/index.ts
@@ -5,4 +5,5 @@ export * from './Roles/Responder.js'
 export * from './Roles/Interceptor.js'
 export * from './Message/Message.js'
 export * from './Message/CommandMessage.js'
+export * from './Message/Serializable.js'
 export * from './Filter.js'
diff --git a/packages/message-bus/test/serializable.test-d.ts b/packages/message-bus/test/serializable.test-d.ts
new file mode 100644
index 000000000..698e539cb
--- /dev/null
+++ b/packages/message-bus/test/serializable.test-d.ts
@@ -0,0 +1,109 @@
+// Type-level tests for the Serializable payload constraint. These are checked
+// with `tsc` (see the `test:types` script and tsconfig.test.json), not run:
+// vitest's `test`-glob excludes `*.test-d.ts`, and the exported `assert*`
+// functions below exist only so the compiler checks their bodies.
+
+import { expectTypeOf } from 'vitest'
+import type { MessageGateway } from '../src/Gateway/MessageGateway.js'
+import type { Message } from '../src/Message/Message.js'
+import { CommandMessage } from '../src/Message/CommandMessage.js'
+import type { Serializable } from '../src/Message/Serializable.js'
+
+// --- Serializable leaves JSON-safe payloads unchanged -------------------
+
+expectTypeOf>().toEqualTypeOf()
+expectTypeOf>().toEqualTypeOf()
+expectTypeOf>().toEqualTypeOf()
+expectTypeOf>().toEqualTypeOf()
+expectTypeOf>().toEqualTypeOf<
+  string | undefined
+>()
+expectTypeOf>().toEqualTypeOf()
+expectTypeOf>().toEqualTypeOf<[string, number]>()
+
+type Nested = { a: string; b: { c: number[]; d: boolean } }
+expectTypeOf>().toEqualTypeOf()
+
+// $-prefixed metadata keys ($name, $responseType, …) are exempt: a function
+// under such a key would otherwise be rejected.
+type WithMeta = { $name: 'x'; $responseType: () => void; foo: string }
+expectTypeOf>().toEqualTypeOf()
+
+// --- Serializable rejects non-JSON leaves -------------------------------
+
+expectTypeOf void>>().not.toEqualTypeOf<() => void>()
+expectTypeOf>().not.toEqualTypeOf()
+expectTypeOf>().not.toEqualTypeOf()
+expectTypeOf>().not.toEqualTypeOf()
+expectTypeOf>().not.toEqualTypeOf()
+
+// --- emit only accepts a serializable payload ------------------------------
+
+export function assertEmit(gateway: MessageGateway) {
+  gateway.emit(new UserLoggedIn('u1', ['admin'], { seen: true, count: 1 }))
+
+  // @ts-expect-error a Widget instance carries methods, so it is not serializable
+  gateway.emit(new Clicked(new Widget('root')))
+
+  // @ts-expect-error a function will not survive queuing
+  gateway.emit(new WithCallback(() => {}))
+
+  // @ts-expect-error the nested Date is not JSON-serializable
+  gateway.emit(new WithNested({ at: new Date() }))
+}
+
+// --- invoke only accepts a serializable command payload --------------------
+
+export function assertInvoke(gateway: MessageGateway) {
+  // The command's DOM-shaped *response* is exempt; only its payload is checked.
+  gateway.invoke(new GetWidget('root'))
+
+  // @ts-expect-error the Widget payload is not serializable
+  gateway.invoke(new RenderInto(new Widget('x')))
+}
+
+// --- fixtures --------------------------------------------------------------
+
+class UserLoggedIn implements Message {
+  readonly $name = 'user-logged-in'
+  constructor(
+    readonly userId: string,
+    readonly roles: string[],
+    readonly meta: { seen: boolean; count: number },
+  ) {}
+}
+
+class Clicked implements Message {
+  readonly $name = 'clicked'
+  constructor(readonly target: Widget) {}
+}
+
+class WithCallback implements Message {
+  readonly $name = 'with-callback'
+  constructor(readonly cb: () => void) {}
+}
+
+class WithNested implements Message {
+  readonly $name = 'with-nested'
+  constructor(readonly payload: { at: Date }) {}
+}
+
+class GetWidget extends CommandMessage {
+  readonly $name = 'get-widget'
+  constructor(readonly id: string) {
+    super()
+  }
+}
+
+class RenderInto extends CommandMessage {
+  readonly $name = 'render-into'
+  constructor(readonly target: Widget) {
+    super()
+  }
+}
+
+/** Stands in for a live, non-serializable reference (e.g. an `HTMLElement`). */
+class Widget {
+  constructor(readonly id: string) {}
+  render() {}
+}
diff --git a/packages/message-bus/test/tsconfig.json b/packages/message-bus/test/tsconfig.json
index bef502a25..b9c27b397 100644
--- a/packages/message-bus/test/tsconfig.json
+++ b/packages/message-bus/test/tsconfig.json
@@ -1,6 +1,8 @@
 {
   "extends": "../tsconfig.json",
   "compilerOptions": {
+    "composite": false,
+    "noEmit": true,
     "rootDir": "../"
   },
   "include": ["../src", "."]

From 0a567ce18aa38e1a9817b88963362ac7dbdd7a60 Mon Sep 17 00:00:00 2001
From: John Wright 
Date: Fri, 18 Sep 2026 20:06:50 +0100
Subject: [PATCH 18/18] refactor(message-bus): model bus members as
 Participants
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The gateway and dispatcher were coupled parent/child: the gateway reached
through `dispatcher.bus` to publish and into the dispatcher's registries to
wire up performers, while the dispatcher held `bus`/`name` purely for the
gateway's benefit.

Introduce a `Participant` that owns the shared kernel — name, queue, abort
lifecycle, and a `PerformerRegistry` — with two thin faces onto it:
`MessageGateway` (outbound) writes the registry and publishes on the bus;
`MessageDispatcher` (inbound) reads the registry and is gated on the queue.
Neither half owns the other, and the ownership graph now matches the runtime
flow (gateway.emit -> bus -> dispatcher). The dispatcher no longer holds the
bus at all.

Also rename `Handler` -> `SelectivePerformer` (`handle` -> `perform`) and the
`Gateway/` directory -> `Participant/`, aligning the whole folder with EIP
endpoint vocabulary (Messaging Gateway, Message Dispatcher, Selective
Consumer).

Note: on abort the registry now clears interceptors too, not just subscribers
and responders. This is unobservable — the bus drops the participant from all
routing and from its map at the same time — but is called out for review.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 packages/message-bus/src/Filter.ts            |   2 +-
 packages/message-bus/src/Gateway/Handler.ts   |  27 ----
 .../src/Gateway/MessageDispatcher.ts          | 143 ------------------
 packages/message-bus/src/MessageBus.ts        |  56 +++----
 .../src/Participant/MessageDispatcher.ts      |  93 ++++++++++++
 .../MessageGateway.ts                         |  96 ++++++------
 .../src/Participant/Participant.ts            |  70 +++++++++
 .../src/Participant/PerformerRegistry.ts      |  93 ++++++++++++
 .../src/Participant/SelectivePerformer.ts     |  33 ++++
 packages/message-bus/src/index.ts             |   2 +-
 packages/message-bus/test/filter.test.ts      |   2 +-
 .../test/gateway-lifecycle.test.ts            |   2 +-
 packages/message-bus/test/gateway.test.ts     |   2 +-
 .../message-bus/test/serializable.test-d.ts   |   2 +-
 14 files changed, 375 insertions(+), 248 deletions(-)
 delete mode 100644 packages/message-bus/src/Gateway/Handler.ts
 delete mode 100644 packages/message-bus/src/Gateway/MessageDispatcher.ts
 create mode 100644 packages/message-bus/src/Participant/MessageDispatcher.ts
 rename packages/message-bus/src/{Gateway => Participant}/MessageGateway.ts (85%)
 create mode 100644 packages/message-bus/src/Participant/Participant.ts
 create mode 100644 packages/message-bus/src/Participant/PerformerRegistry.ts
 create mode 100644 packages/message-bus/src/Participant/SelectivePerformer.ts

diff --git a/packages/message-bus/src/Filter.ts b/packages/message-bus/src/Filter.ts
index 2d849bcc2..7987003c3 100644
--- a/packages/message-bus/src/Filter.ts
+++ b/packages/message-bus/src/Filter.ts
@@ -8,7 +8,7 @@ import type { MessageClass } from './Message/Message.js'
  *
  * This is EIP's *Message Filter* (whole-message pass-or-discard) — not the
  * similarly named *Content Filter*, which strips fields from within a message.
- * A {@link Handler} carrying one is a *Selective Consumer*.
+ * A {@link SelectivePerformer} carrying one is a *Selective Consumer*.
  *
  * @example
  * ```ts
diff --git a/packages/message-bus/src/Gateway/Handler.ts b/packages/message-bus/src/Gateway/Handler.ts
deleted file mode 100644
index 417997ef1..000000000
--- a/packages/message-bus/src/Gateway/Handler.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import type { Message, MessageClass } from '../Message/Message.js'
-import type { Filter, FilterEntries, FilterPredicate } from '../Filter.js'
-
-export class Handler {
-  readonly #filterEntries: FilterEntries
-  readonly #callback: (message: M, ...args: unknown[]) => any
-
-  constructor(
-    filter: Filter,
-    callback: (message: M, ...args: unknown[]) => any,
-  ) {
-    this.#filterEntries = Object.entries(filter) as FilterEntries
-    this.#callback = callback
-  }
-
-  handle(message: M, ...args: unknown[]): any | void {
-    if (this.#filter(message)) return this.#callback(message, ...args)
-  }
-
-  #filter(message: M) {
-    return this.#filterEntries.every(([key, value]) =>
-      typeof value === 'function'
-        ? (value as FilterPredicate)(message)
-        : value === message[key],
-    )
-  }
-}
diff --git a/packages/message-bus/src/Gateway/MessageDispatcher.ts b/packages/message-bus/src/Gateway/MessageDispatcher.ts
deleted file mode 100644
index aa36a465a..000000000
--- a/packages/message-bus/src/Gateway/MessageDispatcher.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import type { MessageBus } from '../MessageBus.js'
-import type { Message, MessageClass } from '../Message/Message.js'
-import type {
-  CommandMessage,
-  CommandMessageClass,
-} from '../Message/CommandMessage.js'
-import { CANCEL } from '../Roles/Interceptor.js'
-import type {
-  ResponderContext,
-  ResponderErrorHandler,
-} from '../Roles/Responder.js'
-import { onAbort } from '../lang/AbortSignal.js'
-import { MethodQueue } from '../Queue/MethodQueue.js'
-import { Handler } from './Handler.js'
-import { MessageGateway } from './MessageGateway.js'
-
-/**
- * The inbound half of a participant: the bus delivers messages/commands into
- * this dispatcher, which drives the participant's local performers —
- * {@link MessageDispatcher.dispatch} runs its subscribers,
- * {@link MessageDispatcher.dispatchCommand} runs its responders, and
- * {@link MessageDispatcher.runInterceptors} runs its interceptor chain. Only the
- * MessageBus and internal machinery ever hold one; participants receive a
- * {@link MessageGateway} facade (the outbound half) instead. Owns the performer
- * registries and the queue that lets the participant be paused and resumed.
- */
-export class MessageDispatcher {
-  readonly subscribers = new Map>>()
-
-  readonly responders = new Map<
-    CommandMessageClass,
-    Set>
-  >()
-
-  readonly interceptors = new Map>>()
-
-  readonly queue = new MethodQueue()
-
-  readonly dispatch: (message: Message) => void
-
-  readonly #abortController = new AbortController()
-
-  readonly abortSignal: AbortSignal
-
-  constructor(
-    readonly bus: MessageBus,
-    readonly name: string,
-    abortSignal?: AbortSignal,
-  ) {
-    this.abortSignal = abortSignal
-      ? AbortSignal.any([abortSignal, this.#abortController.signal])
-      : this.#abortController.signal
-
-    this.onAbort(() => {
-      this.subscribers.clear()
-      this.responders.clear()
-    })
-
-    this.dispatch = this.queue.queueMethod((message: Message) => {
-      for (const handler of this.subscribers.get(
-        message.constructor as MessageClass,
-      ) ?? [])
-        handler.handle(message)
-    })
-  }
-
-  onAbort(fn: (reason: any) => any) {
-    return onAbort(fn, this.abortSignal)
-  }
-
-  get aborted() {
-    return this.abortSignal.aborted
-  }
-
-  async runInterceptors(
-    message: InstanceType,
-  ): Promise | typeof CANCEL> {
-    if (!this.queue.running) return message
-
-    const interceptors = this.interceptors.get(
-      message.constructor as MessageClass,
-    )
-
-    if (!interceptors?.size) return message
-
-    for (const interceptor of interceptors) {
-      const result = await interceptor.handle(message)
-      if (result === CANCEL) return CANCEL
-      else if (result) message = result as InstanceType
-    }
-
-    return message
-  }
-
-  /**
-   * Runs every local responder for the command and resolves once they have all
-   * settled. Completion is derived from the responders' own return values: a
-   * filtered-out responder resolves immediately (its `handle` returns
-   * `undefined`), so there's no count to keep in sync and nothing to hang on. The
-   * responder set is snapshotted, so (un)registering during an in-flight command
-   * can't move the target. Responders are isolated: one that throws is routed to
-   * `reportError` and neither stops its siblings nor prevents completion.
-   */
-  async dispatchCommand(
-    command: CommandMessage,
-    context: ResponderContext>,
-    reportError: ResponderErrorHandler,
-  ): Promise {
-    if (!this.queue.running) return
-
-    const responders = this.responders.get(
-      command.constructor as CommandMessageClass,
-    )
-
-    if (!responders?.size) return
-
-    await Promise.all(
-      Array.from(responders, async (handler) => {
-        try {
-          await handler.handle(command, context)
-        } catch (error) {
-          reportError(error)
-        }
-      }),
-    )
-  }
-
-  resume() {
-    this.queue.start()
-  }
-
-  pause() {
-    this.queue.stop()
-  }
-
-  abort(reason?: unknown) {
-    this.#abortController.abort(reason)
-  }
-
-  createGateway() {
-    return new MessageGateway(this)
-  }
-}
diff --git a/packages/message-bus/src/MessageBus.ts b/packages/message-bus/src/MessageBus.ts
index 96a69201c..7a24d2467 100644
--- a/packages/message-bus/src/MessageBus.ts
+++ b/packages/message-bus/src/MessageBus.ts
@@ -1,5 +1,4 @@
-import { MessageDispatcher } from './Gateway/MessageDispatcher.js'
-import { MessageGateway } from './Gateway/MessageGateway.js'
+import { Participant } from './Participant/Participant.js'
 import type { Message, MessageClass } from './Message/Message.js'
 import type {
   CommandMessage,
@@ -33,7 +32,7 @@ import { getOrInsert } from './lang/Map.js'
  * ```
  */
 export class MessageBus {
-  readonly #dispatchers = new Map()
+  readonly #participants = new Map()
 
   readonly #subscriberRoutes = new Map>()
 
@@ -51,13 +50,13 @@ export class MessageBus {
    * @internal
    */
   readonly on = (
-    gateway: MessageGateway,
+    name: string,
     messageClass: M,
   ): (() => void) => {
     const routes = getOrInsert(this.#subscriberRoutes, messageClass, new Set())
-    routes.add(gateway.name)
+    routes.add(name)
     return () => {
-      this.#subscriberRoutes.get(messageClass)?.delete(gateway.name)
+      this.#subscriberRoutes.get(messageClass)?.delete(name)
     }
   }
 
@@ -67,13 +66,13 @@ export class MessageBus {
    * @internal
    */
   readonly register = (
-    gateway: MessageGateway,
+    name: string,
     commandClass: CommandMessageClass,
   ): (() => void) => {
     const routes = getOrInsert(this.#responderRoutes, commandClass, new Set())
-    routes.add(gateway.name)
+    routes.add(name)
     return () => {
-      this.#responderRoutes.get(commandClass)?.delete(gateway.name)
+      this.#responderRoutes.get(commandClass)?.delete(name)
     }
   }
 
@@ -84,13 +83,13 @@ export class MessageBus {
    * @internal
    */
   readonly intercept = (
-    gateway: MessageGateway,
+    name: string,
     messageClass: MessageClass,
   ): (() => void) => {
     const routes = getOrInsert(this.#interceptorRoutes, messageClass, new Set())
-    routes.add(gateway.name)
+    routes.add(name)
     return () => {
-      this.#interceptorRoutes.get(messageClass)?.delete(gateway.name)
+      this.#interceptorRoutes.get(messageClass)?.delete(name)
     }
   }
 
@@ -103,14 +102,14 @@ export class MessageBus {
    * @throws if a participant with `name` is already registered.
    */
   gateway(name: string, abortSignal?: AbortSignal) {
-    if (this.#dispatchers.has(name))
+    if (this.#participants.has(name))
       throw new Error(`Gateway "${name}" has already been registered`)
 
-    const dispatcher = new MessageDispatcher(this, name, abortSignal)
-    this.#dispatchers.set(name, dispatcher)
+    const participant = new Participant(this, name, abortSignal)
+    this.#participants.set(name, participant)
 
-    dispatcher.onAbort(() => {
-      this.#dispatchers.delete(name)
+    participant.onAbort(() => {
+      this.#participants.delete(name)
       for (const routes of [
         this.#subscriberRoutes,
         this.#responderRoutes,
@@ -119,7 +118,7 @@ export class MessageBus {
         for (const names of routes.values()) names.delete(name)
     })
 
-    return dispatcher.createGateway()
+    return participant.gateway
   }
 
   /**
@@ -135,7 +134,9 @@ export class MessageBus {
       this.#interceptorRoutes.get(message.constructor as MessageClass) ?? []
 
     for (const name of interceptorNames) {
-      const result = await this.#dispatchers.get(name)?.runInterceptors(message)
+      const result = await this.#participants
+        .get(name)
+        ?.dispatcher.runInterceptors(message)
       if (result === CANCEL) return CANCEL
       else if (result) message = result as M
     }
@@ -146,7 +147,7 @@ export class MessageBus {
     if (!subscriberNames?.size) return message
 
     for (const name of subscriberNames)
-      this.#dispatchers.get(name)?.dispatch(message)
+      this.#participants.get(name)?.dispatcher.dispatch(message)
 
     return message
   }
@@ -173,9 +174,9 @@ export class MessageBus {
 
     await Promise.all(
       Array.from(responderNames, (name) =>
-        this.#dispatchers
+        this.#participants
           .get(name)
-          ?.dispatchCommand(command, context, reportError),
+          ?.dispatcher.dispatchCommand(command, context, reportError),
       ),
     )
   }
@@ -186,7 +187,7 @@ export class MessageBus {
    * Unlike {@link MessageBus.pause}, this cannot be undone.
    */
   abort(name: string, reason?: Error) {
-    this.#dispatchers.get(name)?.abort(reason)
+    this.#participants.get(name)?.abort(reason)
   }
 
   /**
@@ -197,8 +198,9 @@ export class MessageBus {
    */
   resume(name?: string) {
     if (name === undefined)
-      for (const dispatcher of this.#dispatchers.values()) dispatcher.resume()
-    else this.#dispatchers.get(name)?.resume()
+      for (const participant of this.#participants.values())
+        participant.resume()
+    else this.#participants.get(name)?.resume()
   }
 
   /**
@@ -208,7 +210,7 @@ export class MessageBus {
    */
   pause(name?: string) {
     if (name === undefined)
-      for (const dispatcher of this.#dispatchers.values()) dispatcher.pause()
-    else this.#dispatchers.get(name)?.pause()
+      for (const participant of this.#participants.values()) participant.pause()
+    else this.#participants.get(name)?.pause()
   }
 }
diff --git a/packages/message-bus/src/Participant/MessageDispatcher.ts b/packages/message-bus/src/Participant/MessageDispatcher.ts
new file mode 100644
index 000000000..d9be671ca
--- /dev/null
+++ b/packages/message-bus/src/Participant/MessageDispatcher.ts
@@ -0,0 +1,93 @@
+import type { Message, MessageClass } from '../Message/Message.js'
+import type {
+  CommandMessage,
+  CommandMessageClass,
+} from '../Message/CommandMessage.js'
+import { CANCEL } from '../Roles/Interceptor.js'
+import type {
+  ResponderContext,
+  ResponderErrorHandler,
+} from '../Roles/Responder.js'
+import type { Participant } from './Participant.js'
+import type { MessageGateway } from './MessageGateway.js'
+
+/**
+ * The inbound half of a {@link Participant}: the bus delivers messages/commands
+ * into this dispatcher, which drives the participant's local performers —
+ * {@link MessageDispatcher.dispatch} runs its subscribers,
+ * {@link MessageDispatcher.dispatchCommand} runs its responders, and
+ * {@link MessageDispatcher.runInterceptors} runs its interceptor chain. It reads
+ * the participant's {@link PerformerRegistry} (which the {@link MessageGateway}
+ * facade writes) and is gated on the participant's queue, so a paused
+ * participant delivers nothing. Only the MessageBus and internal machinery ever
+ * hold one; participants receive the gateway (the outbound half) instead.
+ */
+export class MessageDispatcher {
+  readonly dispatch: (message: Message) => void
+
+  readonly #participant: Participant
+
+  constructor(participant: Participant) {
+    this.#participant = participant
+
+    this.dispatch = participant.queue.queueMethod((message: Message) => {
+      for (const performer of participant.registry.subscribersFor(
+        message.constructor as MessageClass,
+      ) ?? [])
+        performer.perform(message)
+    })
+  }
+
+  async runInterceptors(
+    message: InstanceType,
+  ): Promise | typeof CANCEL> {
+    if (!this.#participant.queue.running) return message
+
+    const interceptors = this.#participant.registry.interceptorsFor(
+      message.constructor as MessageClass,
+    )
+
+    if (!interceptors?.size) return message
+
+    for (const interceptor of interceptors) {
+      const result = await interceptor.perform(message)
+      if (result === CANCEL) return CANCEL
+      else if (result) message = result as InstanceType
+    }
+
+    return message
+  }
+
+  /**
+   * Runs every local responder for the command and resolves once they have all
+   * settled. Completion is derived from the responders' own return values: a
+   * filtered-out responder resolves immediately (its `handle` returns
+   * `undefined`), so there's no count to keep in sync and nothing to hang on. The
+   * responder set is snapshotted, so (un)registering during an in-flight command
+   * can't move the target. Responders are isolated: one that throws is routed to
+   * `reportError` and neither stops its siblings nor prevents completion.
+   */
+  async dispatchCommand(
+    command: CommandMessage,
+    context: ResponderContext>,
+    reportError: ResponderErrorHandler,
+  ): Promise {
+    if (!this.#participant.queue.running) return
+
+    const responders = this.#participant.registry.respondersFor(
+      command.constructor as CommandMessageClass,
+    )
+
+    if (!responders?.size) return
+
+    await Promise.all(
+      Array.from(responders, async (performer) => {
+        try {
+          await performer.perform(command, context)
+        } catch (error) {
+          reportError(error)
+        }
+      }),
+    )
+  }
+}
diff --git a/packages/message-bus/src/Gateway/MessageGateway.ts b/packages/message-bus/src/Participant/MessageGateway.ts
similarity index 85%
rename from packages/message-bus/src/Gateway/MessageGateway.ts
rename to packages/message-bus/src/Participant/MessageGateway.ts
index 183ebda50..b64836d8f 100644
--- a/packages/message-bus/src/Gateway/MessageGateway.ts
+++ b/packages/message-bus/src/Participant/MessageGateway.ts
@@ -15,26 +15,29 @@ import type {
 } from '../Roles/Responder.js'
 import type { Serializable } from '../Message/Serializable.js'
 import type { Filter } from '../Filter.js'
-import { getOrInsert } from '../lang/Map.js'
-import type { MessageDispatcher } from './MessageDispatcher.js'
-import { Handler } from './Handler.js'
+import type { MessageBus } from '../MessageBus.js'
+import type { Participant } from './Participant.js'
 import { onAbort } from '../lang/AbortSignal.js'
 
 /**
- * A participant's handle on the {@link MessageBus} — what `bus.gateway(name)`
- * returns. Everything a participant does flows through here: subscribing to
- * messages ({@link MessageGateway.on}, {@link MessageGateway.once},
+ * The outbound half of a {@link Participant} — what `bus.gateway(name)` returns.
+ * Everything a participant does flows through here: subscribing to messages
+ * ({@link MessageGateway.on}, {@link MessageGateway.once},
  * {@link MessageGateway.until}), publishing them ({@link MessageGateway.emit}),
  * transforming them in flight ({@link MessageGateway.intercept}), and the
  * request/stream pattern of {@link MessageGateway.register} +
- * {@link MessageGateway.invoke}.
+ * {@link MessageGateway.invoke}. It writes the participant's
+ * {@link PerformerRegistry} (which the {@link MessageDispatcher} reads) and
+ * publishes onto the {@link MessageBus}.
  *
- * Outbound calls (`emit`, `invoke`) are queued through this participant's own
+ * Outbound calls (`emit`, `invoke`) are queued through the participant's own
  * queue, so while it is paused they buffer and replay when it resumes. Every
  * subscription returns a disposer that removes it.
  */
 export class MessageGateway {
-  readonly #dispatcher: MessageDispatcher
+  readonly #participant: Participant
+
+  readonly #bus: MessageBus
 
   readonly #invoke: >(
     command: InstanceType,
@@ -56,10 +59,11 @@ export class MessageGateway {
     message: M & Serializable,
   ) => Promise
 
-  constructor(dispatcher: MessageDispatcher) {
-    this.#dispatcher = dispatcher
+  constructor(participant: Participant, bus: MessageBus) {
+    this.#participant = participant
+    this.#bus = bus
 
-    this.#invoke = dispatcher.queue.queueMethod(
+    this.#invoke = participant.queue.queueMethod(
       async (
         command: CommandMessage,
         context: {
@@ -68,25 +72,25 @@ export class MessageGateway {
         },
         reportError: ResponderErrorHandler,
       ) => {
-        const result = await this.#dispatcher.bus.emit(command)
+        const result = await bus.emit(command)
         if (result === CANCEL) return
-        await this.#dispatcher.bus.invoke(result, context, reportError)
+        await bus.invoke(result, context, reportError)
       },
     )
 
-    this.emit = dispatcher.queue.queueMethod((message: M) =>
-      this.#dispatcher.bus.emit(message),
+    this.emit = participant.queue.queueMethod((message: M) =>
+      bus.emit(message),
     )
   }
 
   /** This participant's unique name on the bus. */
   get name() {
-    return this.#dispatcher.name
+    return this.#participant.name
   }
 
   /** The signal that fires when this participant is aborted; useful for teardown. */
   get abortSignal(): AbortSignal {
-    return this.#dispatcher.abortSignal
+    return this.#participant.abortSignal
   }
 
   /**
@@ -102,7 +106,7 @@ export class MessageGateway {
    * are addressed by name so one can control another's lifecycle.
    */
   resume(name: string) {
-    this.#dispatcher.bus.resume(name)
+    this.#bus.resume(name)
   }
 
   /**
@@ -110,7 +114,7 @@ export class MessageGateway {
    * buffer until {@link MessageGateway.resume}. Reversible.
    */
   pause(name: string) {
-    this.#dispatcher.bus.pause(name)
+    this.#bus.pause(name)
   }
 
   /**
@@ -118,7 +122,7 @@ export class MessageGateway {
    * name. Not reversible; use {@link MessageGateway.pause} to merely pause.
    */
   abort(name: string, reason?: any) {
-    this.#dispatcher.bus.abort(name, reason)
+    this.#bus.abort(name, reason)
   }
 
   /**
@@ -151,9 +155,9 @@ export class MessageGateway {
     filterOrSubscriber: Filter | Subscriber,
     subscriber?: Subscriber,
   ): () => void {
-    return this.#addListener(
-      this.#dispatcher.subscribers,
-      this.#dispatcher.bus.on,
+    return this.#addPerformer(
+      this.#participant.registry.addSubscriber,
+      this.#bus.on,
       messageClass,
       filterOrSubscriber,
       subscriber,
@@ -248,9 +252,9 @@ export class MessageGateway {
     filterOrResponder: Filter | Responder,
     responder?: Responder,
   ): () => void {
-    return this.#addListener(
-      this.#dispatcher.responders,
-      this.#dispatcher.bus.register,
+    return this.#addPerformer(
+      this.#participant.registry.addResponder,
+      this.#bus.register,
       commandClass,
       filterOrResponder,
       responder,
@@ -284,9 +288,9 @@ export class MessageGateway {
     filterOrInterceptor: Filter | Interceptor,
     interceptor?: Interceptor,
   ): () => void {
-    return this.#addListener(
-      this.#dispatcher.interceptors,
-      this.#dispatcher.bus.intercept,
+    return this.#addPerformer(
+      this.#participant.registry.addInterceptor,
+      this.#bus.intercept,
       messageClass,
       filterOrInterceptor,
       interceptor,
@@ -347,25 +351,27 @@ export class MessageGateway {
     }
   }
 
-  #addListener any>(
-    registry: Map>>>,
-    subscribe: (gateway: this, messageClass: M) => () => void,
-    messageClass: M,
-    filterOrCallback: Filter | F,
-    callback?: F,
+  #addPerformer<
+    Add extends (
+      messageClass: any,
+      filter: Filter,
+      callback: any,
+    ) => () => boolean,
+  >(
+    add: Add,
+    subscribe: (name: string, messageClass: any) => () => void,
+    messageClass: unknown,
+    filterOrCallback: unknown,
+    callback?: unknown,
   ) {
-    const filter = (callback ? filterOrCallback : {}) as Filter
-    callback ??= filterOrCallback as F
+    const filter = (callback ? filterOrCallback : {}) as Filter
+    callback ??= filterOrCallback
 
-    const handlers = getOrInsert(registry, messageClass, new Set())
-    const handler = new Handler(filter, callback)
-    handlers.add(handler)
+    const removePerformer = add(messageClass, filter, callback)
+    const unregister = subscribe(this.name, messageClass)
 
-    const unregister = subscribe(this, messageClass)
     return () => {
-      const handlers = registry.get(messageClass)
-      handlers?.delete(handler)
-      if (!handlers?.size) unregister()
+      if (removePerformer()) unregister()
     }
   }
 }
diff --git a/packages/message-bus/src/Participant/Participant.ts b/packages/message-bus/src/Participant/Participant.ts
new file mode 100644
index 000000000..e3da2cc97
--- /dev/null
+++ b/packages/message-bus/src/Participant/Participant.ts
@@ -0,0 +1,70 @@
+import type { MessageBus } from '../MessageBus.js'
+import { onAbort } from '../lang/AbortSignal.js'
+import { MethodQueue } from '../Queue/MethodQueue.js'
+import { PerformerRegistry } from './PerformerRegistry.js'
+import { MessageDispatcher } from './MessageDispatcher.js'
+import { MessageGateway } from './MessageGateway.js'
+
+/**
+ * One named endpoint on the {@link MessageBus}. A participant is the shared
+ * kernel of a bus member — its identity ({@link Participant.name}), its
+ * {@link PerformerRegistry wiring}, its {@link MethodQueue queue} (the single
+ * serialization point that lets it be paused and resumed), and its abort
+ * lifecycle — with two faces onto it:
+ *
+ * - {@link MessageGateway} (outbound): what the application holds; publishes and
+ *   registers performers.
+ * - {@link MessageDispatcher} (inbound): what the bus drives; delivers messages
+ *   to the registered performers.
+ *
+ * Both faces read and write the same kernel, so neither owns the other; the bus
+ * owns the participant and hands the application its gateway.
+ */
+export class Participant {
+  readonly registry = new PerformerRegistry()
+
+  readonly queue = new MethodQueue()
+
+  readonly abortSignal: AbortSignal
+
+  readonly dispatcher: MessageDispatcher
+
+  readonly gateway: MessageGateway
+
+  readonly #abortController = new AbortController()
+
+  constructor(
+    bus: MessageBus,
+    readonly name: string,
+    abortSignal?: AbortSignal,
+  ) {
+    this.abortSignal = abortSignal
+      ? AbortSignal.any([abortSignal, this.#abortController.signal])
+      : this.#abortController.signal
+
+    this.onAbort(() => this.registry.clear())
+
+    this.dispatcher = new MessageDispatcher(this)
+    this.gateway = new MessageGateway(this, bus)
+  }
+
+  onAbort(fn: (reason: any) => any) {
+    return onAbort(fn, this.abortSignal)
+  }
+
+  get aborted() {
+    return this.abortSignal.aborted
+  }
+
+  resume() {
+    this.queue.start()
+  }
+
+  pause() {
+    this.queue.stop()
+  }
+
+  abort(reason?: unknown) {
+    this.#abortController.abort(reason)
+  }
+}
diff --git a/packages/message-bus/src/Participant/PerformerRegistry.ts b/packages/message-bus/src/Participant/PerformerRegistry.ts
new file mode 100644
index 000000000..4a5fd74f6
--- /dev/null
+++ b/packages/message-bus/src/Participant/PerformerRegistry.ts
@@ -0,0 +1,93 @@
+import type { Message, MessageClass } from '../Message/Message.js'
+import type {
+  CommandMessage,
+  CommandMessageClass,
+} from '../Message/CommandMessage.js'
+import type { Subscriber } from '../Roles/Subscriber.js'
+import type { Interceptor } from '../Roles/Interceptor.js'
+import type { Responder } from '../Roles/Responder.js'
+import type { Filter } from '../Filter.js'
+import { getOrInsert } from '../lang/Map.js'
+import { SelectivePerformer } from './SelectivePerformer.js'
+
+/**
+ * A participant's local wiring: the set of {@link Subscriber}s,
+ * {@link Responder}s, and {@link Interceptor}s it has registered, each keyed by
+ * the message class it performs on. The {@link MessageGateway} writes to it (as
+ * the participant subscribes/registers/intercepts) and the
+ * {@link MessageDispatcher} reads from it (as the bus delivers), so it is the
+ * shared collaborator between the two halves rather than being owned by either.
+ *
+ * Each `add*` returns a disposer that removes just that handler and reports
+ * whether its message class now has no handlers left — the gateway uses that to
+ * decide when to drop the participant's bus-level route.
+ */
+export class PerformerRegistry {
+  readonly addSubscriber = (
+    messageClass: M,
+    filter: Filter,
+    subscriber: Subscriber,
+  ) => this.#add(this.#subscribers, messageClass, filter, subscriber)
+
+  readonly addResponder = >(
+    commandClass: E,
+    filter: Filter,
+    responder: Responder,
+  ) => this.#add(this.#responders, commandClass, filter, responder)
+
+  readonly addInterceptor = (
+    messageClass: M,
+    filter: Filter,
+    interceptor: Interceptor,
+  ) => this.#add(this.#interceptors, messageClass, filter, interceptor)
+
+  subscribersFor(messageClass: MessageClass) {
+    return this.#subscribers.get(messageClass)
+  }
+
+  respondersFor(commandClass: CommandMessageClass) {
+    return this.#responders.get(commandClass)
+  }
+
+  interceptorsFor(messageClass: MessageClass) {
+    return this.#interceptors.get(messageClass)
+  }
+
+  /** Drops every registered performer; used when the participant is aborted. */
+  clear() {
+    this.#subscribers.clear()
+    this.#responders.clear()
+    this.#interceptors.clear()
+  }
+
+  readonly #subscribers = new Map<
+    MessageClass,
+    Set>
+  >()
+
+  readonly #responders = new Map<
+    CommandMessageClass,
+    Set>
+  >()
+
+  readonly #interceptors = new Map<
+    MessageClass,
+    Set>
+  >()
+
+  #add(
+    registry: Map>>,
+    messageClass: unknown,
+    filter: Filter,
+    callback: (...args: any[]) => any,
+  ): () => boolean {
+    const performers = getOrInsert(registry, messageClass, new Set())
+    const performer = new SelectivePerformer(filter, callback)
+    performers.add(performer)
+
+    return () => {
+      performers.delete(performer)
+      return performers.size === 0
+    }
+  }
+}
diff --git a/packages/message-bus/src/Participant/SelectivePerformer.ts b/packages/message-bus/src/Participant/SelectivePerformer.ts
new file mode 100644
index 000000000..7bf6085c7
--- /dev/null
+++ b/packages/message-bus/src/Participant/SelectivePerformer.ts
@@ -0,0 +1,33 @@
+import type { Message, MessageClass } from '../Message/Message.js'
+import type { Filter, FilterEntries, FilterPredicate } from '../Filter.js'
+
+/**
+ * A performer (a {@link Subscriber}, {@link Responder}, or {@link Interceptor})
+ * paired with the {@link Filter} that decides which messages it acts on — a
+ * *Selective Consumer* in EIP terms. {@link SelectivePerformer.perform} runs the
+ * callback only when the message matches the filter.
+ */
+export class SelectivePerformer {
+  readonly #filterEntries: FilterEntries
+  readonly #performer: (message: M, ...args: unknown[]) => any
+
+  constructor(
+    filter: Filter,
+    performer: (message: M, ...args: unknown[]) => any,
+  ) {
+    this.#filterEntries = Object.entries(filter) as FilterEntries
+    this.#performer = performer
+  }
+
+  perform(message: M, ...args: unknown[]): any | void {
+    if (this.#filter(message)) return this.#performer(message, ...args)
+  }
+
+  #filter(message: M) {
+    return this.#filterEntries.every(([key, value]) =>
+      typeof value === 'function'
+        ? (value as FilterPredicate)(message)
+        : value === message[key],
+    )
+  }
+}
diff --git a/packages/message-bus/src/index.ts b/packages/message-bus/src/index.ts
index b2616f02c..747a6fd0a 100644
--- a/packages/message-bus/src/index.ts
+++ b/packages/message-bus/src/index.ts
@@ -1,4 +1,4 @@
-export { MessageGateway } from './Gateway/MessageGateway.js'
+export { MessageGateway } from './Participant/MessageGateway.js'
 export { MessageBus } from './MessageBus.js'
 export * from './Roles/Subscriber.js'
 export * from './Roles/Responder.js'
diff --git a/packages/message-bus/test/filter.test.ts b/packages/message-bus/test/filter.test.ts
index 18521f8e8..7a037b61c 100644
--- a/packages/message-bus/test/filter.test.ts
+++ b/packages/message-bus/test/filter.test.ts
@@ -1,7 +1,7 @@
 import { beforeEach, expect, test, vi } from 'vitest'
 import { MessageBus } from '../src/MessageBus.js'
 import { Message } from '../src/Message/Message.js'
-import type { MessageGateway } from '../src/Gateway/MessageGateway.js'
+import type { MessageGateway } from '../src/Participant/MessageGateway.js'
 
 let gatewayA: MessageGateway
 let gatewayB: MessageGateway
diff --git a/packages/message-bus/test/gateway-lifecycle.test.ts b/packages/message-bus/test/gateway-lifecycle.test.ts
index 47e2345f3..c00824035 100644
--- a/packages/message-bus/test/gateway-lifecycle.test.ts
+++ b/packages/message-bus/test/gateway-lifecycle.test.ts
@@ -3,7 +3,7 @@ import { MessageBus } from '../src/MessageBus.js'
 import { Message } from '../src/Message/Message.js'
 import { CommandMessage } from '../src/Message/CommandMessage.js'
 import { CANCEL } from '../src/Roles/Interceptor.js'
-import type { MessageGateway } from '../src/Gateway/MessageGateway.js'
+import type { MessageGateway } from '../src/Participant/MessageGateway.js'
 
 let bus: MessageBus
 let gatewayA: MessageGateway
diff --git a/packages/message-bus/test/gateway.test.ts b/packages/message-bus/test/gateway.test.ts
index 91f6e3ea8..a4fb09fae 100644
--- a/packages/message-bus/test/gateway.test.ts
+++ b/packages/message-bus/test/gateway.test.ts
@@ -5,7 +5,7 @@ import { Message } from '../src/Message/Message.js'
 import { CommandMessage } from '../src/Message/CommandMessage.js'
 import { CANCEL } from '../src/Roles/Interceptor.js'
 import type { ResponderContext } from '../src/Roles/Responder.js'
-import type { MessageGateway } from '../src/Gateway/MessageGateway.js'
+import type { MessageGateway } from '../src/Participant/MessageGateway.js'
 
 let gatewayA: MessageGateway
 let gatewayB: MessageGateway
diff --git a/packages/message-bus/test/serializable.test-d.ts b/packages/message-bus/test/serializable.test-d.ts
index 698e539cb..fa5268f16 100644
--- a/packages/message-bus/test/serializable.test-d.ts
+++ b/packages/message-bus/test/serializable.test-d.ts
@@ -4,7 +4,7 @@
 // functions below exist only so the compiler checks their bodies.
 
 import { expectTypeOf } from 'vitest'
-import type { MessageGateway } from '../src/Gateway/MessageGateway.js'
+import type { MessageGateway } from '../src/Participant/MessageGateway.js'
 import type { Message } from '../src/Message/Message.js'
 import { CommandMessage } from '../src/Message/CommandMessage.js'
 import type { Serializable } from '../src/Message/Serializable.js'