You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/arts/bus
dev fbacd1133d
Remove frontend art
5 months ago
..
svelte eidos: pilot wrapper pattern + doctrinal API conventions 5 months ago
test eidos: pilot wrapper pattern + doctrinal API conventions 5 months ago
README.md Remove frontend art 5 months ago
active-bus.svelte.ts eidos: pilot wrapper pattern + doctrinal API conventions 5 months ago
engine-bus.ts eidos: pilot wrapper pattern + doctrinal API conventions 5 months ago
index.ts eidos: pilot wrapper pattern + doctrinal API conventions 5 months ago
silent-bus.ts eidos: pilot wrapper pattern + doctrinal API conventions 5 months ago
types.ts eidos: pilot wrapper pattern + doctrinal API conventions 5 months ago

README.md

bus

Status 2026-05-14: layer split into libs/bus (pure contracts) and arts/bus (engine implementation) is done. Modules consume the bus through interfaces in $libs/bus; the implementation in $bus is reserved for the composition root (active-app) and tests. subscribe(), publishCausedBy(), the re-entrancy guard, the Svelte adapter, the active wrapper, cloneability checks and app-event runtime/payload guards are implemented. The only deliberate deferrals are listed in Deferred work.

bus is the framework's mechanical event bus. It transports facts through typed envelopes with deterministic order, an explicit error policy, and observability hooks. It does not know about session, auth, cache, perm, connection, users, tenants, permissions, or any business rule.

Applications never instantiate one bus per module. active-app creates a single App.bus per render scope and injects it. Artifacts that need to publish or listen receive that bus, or the smaller EventPublisher interface, from the composition root.

Cross-artifact coordination is built on top of the bus with this flow:

artifact module events  →  active-app/orca translators  →  app events  →  consumer reactions

Position

bus is catalog-agnostic at runtime. EngineBus<TEvents> can be typed for a composition surface such as ActiveAppBusEvents, but there is no global event registry inside the bus engine. Type safety per module comes from each owner declaring constants, payload shapes, and typed publishX / onX helpers.

The framework distinguishes two layers of events that share a single App.bus instance:

  • Module events (SESSION_EVENT_*, AUTH_EVENT_*, CACHE_EVENT_*, …) — internal facts emitted by the artifact that owns them. They can iterate; they are not part of the public contract.
  • App events (APP_EVENT_*) — the stable public contract. Universal facts that consumers and external plugins listen to. Renaming or removing one is a breaking change.

Translators in arts/active-app/presets/* or service-specific bridges map module events into app events. Consumers subscribe only to app events.

DDD reference

The model is Domain Events + Integration Events with an Anti-Corruption Layer:

Active term DDD term
Module events (SESSION_EVENT_*, …) Domain events (private, iterable)
App events (APP_EVENT_*) Integration events (public, stable)
active-app/presets/* or service bridges Anti-corruption layer + event mapper
Per-consumer auto-reactions Stateless process managers

If you have a DDD background the model maps 1:1.

Layer split

arts/bus/                           ← engine, framework-agnostic
  types.ts                            EngineBus, BusEnvelope, BusListener,
                                      BusPublishOptions, BusPublishResult,
                                      EventPublisher, BusAnyListener
  consts.ts                           BUS_*, listener error modes, diagnostics
  engine-bus.ts                       createEngineBus()
                                      no Svelte imports
  errors.ts / matching.ts / diagnostics.ts
  test/

arts/bus/svelte/                    ← Svelte adapter
  index.ts                            createSvelteEngineBus()
                                      wraps listener invocation in untrack

arts/bus/active-bus.svelte.ts       ← reactive wrappers (minimal surface)
  createBusRecent()                   { lastEvent, count, clear, dispose }

arts/active-app/events.ts           ← APP_EVENT_* constants + payloads
                                      + APP_EVENT_RUNTIMES metadata

arts/bus/svelte/context.svelte.ts   ← getBus / setBus via createContext

arts/<module>/consts.ts             ← module event constants
arts/<module>/types.ts               ← module event payload types
arts/<module>/bus-helpers.ts         ← typed publish/subscribe helpers
                                       (the only path to call-site type safety)

arts/active-app/presets/            ← translators/reactions (anti-corruption layer)
  identity-translator.ts              module events → app events
  permissions-translator.ts
  tenant-translator.ts
  connectivity-translator.ts
  dispose-translator.ts

Hard rule: arts/bus/engine-bus.ts does not import from svelte. Svelte-specific behaviour (untrack, runes wrappers, $effect.root) lives in arts/bus/svelte/ and arts/bus/active-bus.svelte.ts. The engine must be testable without DOM or Svelte runtime.

The EngineBus contract

export interface EngineBus {
    publish<TType extends string, TPayload>(
        type: TType,
        payload: TPayload,
        options?: BusPublishOptions
    ): BusPublishResult<TType, TPayload>;

    publishAsync<TType extends string, TPayload>(
        type: TType,
        payload: TPayload,
        options?: BusPublishOptions
    ): Promise<BusPublishResult<TType, TPayload>>;

    /**
     * Publish with `causationId` automatically set to `parent.id`. Use
     * inside translators to keep the causation chain populated.
     */
    publishCausedBy<TType extends string, TPayload>(
        parent: BusEnvelope,
        type: TType,
        payload: TPayload,
        options?: BusPublishOptions
    ): BusPublishResult<TType, TPayload>;

    on<TType extends string, TPayload>(
        type: TType,
        listener: BusListener<TPayload>,
        options?: BusListenOptions
    ): BusSubscription;

    /**
     * Sugar for `$effect`: returns the unsubscribe function directly so a
     * Svelte component writes `$effect(() => Bus.subscribe(type, fn))`.
     */
    subscribe<TType extends string, TPayload>(
        type: TType,
        listener: BusListener<TPayload>,
        options?: BusListenOptions
    ): () => void;

    onAny(listener: BusAnyListener<TEvents>, options?: BusListenOptions): BusSubscription;

    once<TType extends string, TPayload>(
        type: TType,
        listener: BusListener<TPayload>,
        options?: BusListenOptions
    ): BusSubscription;

    listenerCount(type?: string): number;
    _clearForTesting(type?: string): void;
    dispose(): void;
}

export interface EventPublisher {
    publish<TType extends string, TPayload>(
        type: TType,
        payload: TPayload,
        options?: BusPublishOptions
    ): BusPublishResult<TType, TPayload>;
}

Envelope

export interface BusEnvelope<TType extends string = string, TPayload = unknown> {
    readonly id: string;
    readonly type: TType;
    readonly payload: TPayload;
    readonly at: number;                      // epoch ms
    readonly source: string;
    readonly correlationId?: string;
    readonly causationId?: string;
    readonly context?: Readonly<Record<string, unknown>>;
    readonly tags?: readonly string[];
}

The current envelope is intentionally small and stable for the runtime: identity, type, source, timestamp, payload and correlation/causation metadata. It is CloudEvents-inspired, not a CloudEvents JSON object. If an outbox or OpenTelemetry exporter needs CloudEvents later, that adapter can map at to time and add specversion, datacontenttype and subject without forcing the in-memory runtime to carry those fields on every publish.

Engine options + invokeListener hook

export interface EngineBusOptions {
    readonly logger?: Logger;
    readonly clock?: BusClock;
    readonly idFactory?: () => string;
    readonly maxListenersPerEvent?: number;       // default 32
    readonly maxReentrancyDepth?: number;         // default 32
    readonly listenerErrorMode?: BusListenerErrorMode;

    /**
     * Called for every listener invocation. The Svelte adapter wraps with
     * `untrack`. Default: identity.
     */
    readonly invokeListener?: (fn: () => void | Promise<void>) => void | Promise<void>;
}

The engine never imports from svelte. The invokeListener hook is the seam the Svelte adapter uses to wrap calls in untrack (see Svelte/SvelteKit Runtime Contract).

Listener failure shape

BusListenerFailure carries enough context for distributed debugging:

export interface BusListenerFailure {
    readonly listenerId?: string;
    readonly type: string;
    readonly envelopeId: string;
    readonly correlationId?: string;
    readonly causationId?: string;
    readonly error: unknown;
}

BusAnyListener for onAny

onAny accepts a union of every event in the current bus map:

export type BusAnyListener<TEvents extends BusEventMap> = (
    event: BusEventUnion<TEvents>,
    context: BusListenerContext
) => void | Promise<void>;

The convention remains conservative: onAny is for diagnostics, devtools, event capture and tests. Business side effects should subscribe to the exact APP_EVENT_* they need.

Re-entrancy

Bus.publish() from inside a listener uses DFS (the new envelope runs immediately, recursively). maxReentrancyDepth (default 32) prevents accidental loops; exceeding it throws BusReentrancyLimitError. This matches Node's EventEmitter semantics and is the least surprising option.

Bus.publishAsync() from inside a listener is microtask-scheduled and does not increment the synchronous depth counter.

Why no <TEvents> generic

A single global typed map would force every event the runtime might emit to be declared in one place. That locks the framework to a closed registry, blocks third-party plugins from adding their own events, and conflates the bus (transport) with the catalog (which events exist). Active therefore types each composition surface (ActiveAppBusEvents, module test maps, plugin maps) locally while the engine stays generic.

Two-layer event model

Module events (internal, iterable)

Each module owns and emits its own facts. Constants live in the module's consts.ts, payloads in types.ts, helpers in bus-helpers.ts.

// arts/session/consts.ts
export const SESSION_EVENT_CHANGED = 'session.changed';
export const SESSION_EVENT_IDENTITY_CHANGED = 'session.identity.changed';
export const SESSION_EVENT_REVOKED = 'session.revoked';
export const SESSION_EVENT_EXPIRED = 'session.expired';
export const SESSION_EVENT_REFRESHED = 'session.refreshed';

// arts/session/types.ts
export interface SessLifecyclePayload {
    readonly event: SessionEvent;
    readonly generation: number;
    readonly identity: {
        readonly from: SessionIdentityState;
        readonly to: SessionIdentityState;
    };
}

// arts/session/bus-helpers.ts
import type { BusPublishOptions, EventPublisher } from '$libs/bus';

// One publisher fans out to every event the lifecycle implies.
// `session.changed` is always emitted; `session.identity.changed` only when
// identity actually transitions; `session.revoked` / `session.expired` /
// `session.refreshed` only for the matching lifecycle event.
export function publishSessLifecycleEvent(
    bus: SessEventPublisher,
    payload: SessLifecyclePayload,
    options: BusPublishOptions = {}
): void {
    if (payload.event === SESSION_EVENT_LIFECYCLE_INITIAL) return;
    const opts = { source: SESSION_MODULE, ...options };

    bus.publish(SESSION_EVENT_CHANGED, payload, opts);
    if (payload.identity.from !== payload.identity.to) {
        bus.publish(SESSION_EVENT_IDENTITY_CHANGED, payload, opts);
    }
    if (payload.event === SESSION_EVENT_LIFECYCLE_REVOKED) {
        bus.publish(SESSION_EVENT_REVOKED, payload, opts);
    }
    if (payload.event === SESSION_EVENT_LIFECYCLE_EXPIRED) {
        bus.publish(SESSION_EVENT_EXPIRED, payload, opts);
    }
    if (payload.event === SESSION_EVENT_LIFECYCLE_REFRESHED) {
        bus.publish(SESSION_EVENT_REFRESHED, payload, opts);
    }
}

// Subscriber: one event, one listener. `onSessChanged` covers every
// lifecycle transition; subscribe to `SESSION_EVENT_IDENTITY_CHANGED` /
// `SESSION_EVENT_REVOKED` / etc directly when you only care about a slice.
export function onSessChanged(
    bus: EngineBus<SessEventMap>,
    listener: (event: SessChangedEnvelope) => void | Promise<void>
): BusSubscription {
    return bus.on(SESSION_EVENT_CHANGED, (event) => listener(event as SessChangedEnvelope));
}

Module events may change between minors (rename, payload addition, deprecation). Only translators consume them.

App-owned events

The only event App publishes is APP_EVENT_DISPOSE_STARTING, fired at the start of App.dispose() via publishAppDisposeStarting() in arts/active-app/events.ts.

Cross-module reactions (cache.clear on identity change, perm.invalidate on revoke, connections.reauth on identity change) are not bus re-publications: they live as orca actions registered through the presets in arts/active-app/presets/. Modules publish their own typed events (SESSION_EVENT_* etc.) directly on App.bus; orca subscribes and runs the registered actions.

App.bus is always-present per render scope

active-app creates the bus automatically; it is not a factory and never optional. The bus is per request on server, per root on client — never a module singleton (see Rule 1 below).

// arts/active-app/active-app.svelte.ts (sketch)
import { createSvelteEngineBus } from '$bus/svelte';

const Bus = createSvelteEngineBus({
    logger: Logger,
    clock: Timers.clock
});

const App = { Logger, Lang, Format, Dom, Storage, Http, Timers, Bus, Cache };

Modules accept EngineBus or EventPublisher from $bus and use whatever App hands them. They must not import another artifact just to observe its private events.

Svelte/SvelteKit Runtime Contract

These rules are runtime contract, not recommendations. Each rule has a mandatory test (see Mandatory tests).

Rule 1 — No mutable bus singleton

// FORBIDDEN
export const Bus = createEngineBus();

SvelteKit servers are long-lived processes shared by every concurrent request. A module-scoped Bus is shared across requests and leaks listeners and events between users.

The bus must be instantiated per request on server, per root on client:

Server request:  hooks.server.ts → event.locals.bus = createEngineBus(...)
Client app:      root component → const bus = createSvelteEngineBus(...);
                                  setBus(bus)

Server bus and client bus are independent instances. They do not share state. Only serializable data crosses the boundary (session snapshot, identity, tenant) via event.locals and SvelteKit's load-data flow. Never serialize the bus itself.

Rule 2 — Inject by context, not by import

Inside Svelte components, App.bus is consumed via context. The helper lives in arts/bus/svelte/context.svelte.ts and is re-exported from $bus:

// arts/bus/svelte/context.svelte.ts (excerpt)
import { getContext, setContext } from 'svelte';
import { BusNoContextError } from '$libs/bus';
import type { EngineBus } from '../types';

const BUS_CONTEXT = Symbol('arts.bus.context');

export function setBus(bus) { setContext(BUS_CONTEXT, bus); return bus; }
export function getBus() {
    const bus = getContext(BUS_CONTEXT);
    if (!bus) throw new BusNoContextError();
    return bus;
}

Root component sets it; descendants read it:

<!-- src/routes/+layout.svelte -->
<script lang="ts">
    import { setBus } from '$bus';
    setBus(App.bus);
</script>

Factory injection (passing a bus argument explicitly to a service or test runtime) remains the override path for tests and non-Svelte contexts.

Rule 3 — $effect is the canonical subscription pattern

Inside components, use Bus.subscribe() (returns the unsubscribe function directly) inside $effect:

<script lang="ts">
    import { SESSION_EVENT_IDENTITY_CHANGED } from '$session';
    const Bus = getBus();

    $effect(() =>
        Bus.subscribe(SESSION_EVENT_IDENTITY_CHANGED, (event) => {
            // react
        })
    );
</script>

$effect cleanup runs on unmount and on re-execution. subscribe() returns the teardown directly so the effect can return it without an intermediate variable. $effect does not run during SSR, so this pattern is safe.

For services, plugins, or scripts top-level (not inside a component), use Bus.on() and call sub.unsubscribe() manually or pass an AbortSignal.

Rule 4 — Listeners run inside untrack (Svelte adapter)

Reading $state from a listener invocation creates a reactive dependency against whatever $effect or $derived is active at publish time. That is rarely intended. The Svelte adapter wraps every listener invocation in untrack:

// arts/bus/svelte/index.ts
import { untrack } from 'svelte';
import { createEngineBus, type EngineBusOptions } from '$bus';

export function createSvelteEngineBus(options: EngineBusOptions = {}) {
    return createEngineBus({
        ...options,
        invokeListener: (fn) => untrack(fn)
    });
}

The pure engine never imports from svelte. The adapter does. Listeners that genuinely want reactive reads opt in explicitly.

Rule 5 — Payloads must be plain serializable data

Bus payloads must be cloneable. In DEV the bus runs structuredClone(payload) and throws BusInvalidPayloadError on failure. This catches:

  • functions, class instances without Symbol.cloneable;
  • DOM nodes;
  • non-cloneable types (sockets, file handles).

Reactive $state proxies are technically cloneable but must not be published as-is: listeners reading from the published value would create transitive dependencies. Callers wrap reactive state with $state.snapshot(...) before publish:

publishSessLifecycleEvent(Bus, $state.snapshot(payload));

Rule 6 — Bus per render scope, disposed on tear-down

component unmount  →  $effect cleanups run, listeners unsubscribe
App.dispose()      →  factories disposed → translators torn down
                       → Bus.dispose() → roots disposed

$effect.root is not used inside the engine. It may be used inside active-bus.svelte.ts for reactive wrappers that outlive a single component's lifecycle.

Rule 7 — Server / client / both-runtime events

Each APP_EVENT_* declares where it is allowed to fire:

// arts/active-app/events.ts
export const APP_EVENT_RUNTIMES: Readonly<Record<string, AppEventRuntime>> = {
    [APP_EVENT_DISPOSE_STARTING]: APP_EVENT_RUNTIME_BOTH
};

export type AppEventRuntime =
    | typeof APP_EVENT_RUNTIME_BOTH
    | typeof APP_EVENT_RUNTIME_CLIENT;

export function assertEventCanFire(type: string, where: 'server' | 'client'): void {
    const runtime = APP_EVENT_RUNTIMES[type];
    if (runtime && runtime !== 'both' && runtime !== where) {
        throw new AappInvalidEventRuntimeError(type, runtime, where);
    }
}

The app-event helpers call assertEventCanFire before publishing. The bus itself stays mechanical and artifact-agnostic; it does not know what an app identity, tenant or connectivity event means. Wrong-side publishes through publishApp* helpers (for example connectivity on the server) throw early instead of silently misbehaving. Constants stay as strings; metadata lives in a parallel table.

Mandatory tests

Eight tests are required for the v0.1 gate:

Test What it verifies
SSR isolation A listener registered against request A's bus never sees request B's events.
No singleton Importing the bus module twice yields no shared mutable state.
Context isolation Two rendered app roots have different bus instances via getBus().
Svelte cleanup $effect-registered listener is unsubscribed when the component unmounts.
Untrack Publishing during a $effect run does not create accidental reactive deps.
Payload safety DEV-mode structuredClone check rejects non-cloneable payloads.
Runtime guard Publishing a client-only event on the server throws in DEV.
Flush test flushSync(() => Bus.publish(...)) makes DOM updates observable synchronously.

Cross-module reactions live in orca, not on the bus

Earlier drafts of this art proposed a "translator" layer that turned module events (SESSION_EVENT_*) into app events (APP_EVENT_*) and a per-consumer autoInvalidateOn / autoReauthOn config that decided who reacted to what. The big-bang refactor (commits 64ab1f0, c6de4a1) removed that machinery entirely.

The model now is:

module emits SESSION_EVENT_IDENTITY_CHANGED on App.bus
        -> orca picks up the event (it subscribed lazily on first
                                    action registration)
        -> orca runs every action registered for that event
        -> actions call App.cache.clear(), App.perm.invalidate(), etc.

Reactions are declared as orca actions — registered through the presets in arts/active-app/presets/ and wired en bloc by applyStandardOrca(App):

import { createActiveApp, applyStandardOrca } from '$active-app';

const App = createActiveApp({
    services: {
        cache: defineActiveCache({}),
        perm: defineActivePerm({ endpoint: '/api/perm' }),
        session: defineActiveSession({ ... })
    }
});

applyStandardOrca(App);
// → SESSION_EVENT_IDENTITY_CHANGED runs cache.clear() + perm.invalidate()
// → SESSION_EVENT_REVOKED runs cache.clear()

Cherry-pick when the standard set is too aggressive:

import {
    applyCacheClearOnIdentityChange,
    applyPermInvalidateOnIdentityChange
} from '$active-app';

applyCacheClearOnIdentityChange(App);
applyPermInvalidateOnIdentityChange(App);

The bus's job is to carry typed events between modules. It does not re-publish, classify, or react. The "consumer rules" reduce to: the consumer registers an orca action; the bus stays dumb.

The consequence for module owners writing a new art: publish your <MODULE>_EVENT_* events directly on App.bus and document them. If a later integration needs to react across modules, the integration ships as an orca preset, not as code inside your art.

Reactive wrappers (active-bus.svelte.ts)

Most components want a reactive view of the latest event of a given type. v0.1 ships a minimal active wrapper:

// arts/bus/active-bus.svelte.ts
export function createBusRecent<TPayload>(bus: EngineBus, type: string) {
    let lastEvent = $state<BusEnvelope<string, TPayload> | undefined>();
    let count = $state(0);

    const unsubscribe = bus.subscribe(type, (event) => {
        lastEvent = event as BusEnvelope<string, TPayload>;
        count += 1;
    });

    return {
        get lastEvent() { return lastEvent; },
        get count() { return count; },
        clear() { lastEvent = undefined; count = 0; },
        dispose: unsubscribe
    };
}

Surface stays minimal in v0.1: lastEvent, count, clear(), dispose. Anything richer waits until real usage patterns emerge.

App-owned event catalog

Only one event survived the big-bang refactor:

Constant Meaning
APP_EVENT_DISPOSE_STARTING App.dispose() entered, last chance to flush listeners

Everything else (identity changes, tenant switches, connectivity, cache invalidation, permission refresh) is now expressed as module events owned by their respective arts (SESSION_EVENT_*, etc.) and consumed through orca actions registered at the App level.

A bus with 50 app events is the same god-object problem at a different layer; resist promoting module events to app events unless the fact is genuinely cross-cutting.

Credential safety on the bus

Bus payloads are public contract. They end up in logs, devtools, audit trails, and (in the future) outbox / replay. Their payloads must never carry credentials.

Two protections:

  1. Typed payload restriction. Payload interfaces must not declare keys named token, secret, password, hash, authorization, credential (case-insensitive).

  2. Pre-publish guard. publishAppDisposeStarting() calls assertAppEventPayloadSafe(...) (in arts/active-app/events.ts) and throws AappUnsafeEventPayloadError when a payload key contains a sensitive part such as token, secret, password, hash, authorization, cookie, credential, csrf or header. Module-event publishers should apply the same hygiene — pass correlationId and let subscribers resolve sensitive context from App.session themselves.

What does not belong on the bus

  • Private module notifications: Connection.onState, Cache.on, Session.onChange stay where they are. Intra-module reactivity is not a cross-module fact.
  • Logs. Logger is its own pipeline. The bus may produce diagnostics through Logger, never the inverse.
  • High-frequency events (cursor position, scroll, keystrokes). Use regular DOM channels for those.
  • Perceptual signals (taxis sema). EngineSemantic is a different registry for a different purpose; do not unify.
  • Stor's internal entry-bus. Per-EngineStorage synchronization stays inside storage; it is not App.bus.

Testing patterns

import { flushSync } from 'svelte';
import { publishAppDisposeStarting } from '$active-app';

flushSync(() => {
    publishAppDisposeStarting(Bus, { cause: 'test' });
});
expect(screen.getByText('disposing')).toBeVisible();

flushSync forces pending Svelte updates to apply immediately. Without it, a publish followed by a DOM expectation is racy.

For strict tests, throw on listener errors:

const Bus = createEngineBus({
    listenerErrorMode: 'throw',
    logger: testLogger
});

For isolating a feature, disable translators:

createActiveApp({ orchestration: 'silent' });
// publish app events manually, assert reactions

API stability

Frozen for 0.1.x:

  • EngineBus, EventPublisher, BusEnvelope, BusPublishResult, BusSubscription, BusListener, BusListenerContext, BusListenerErrorMode, BusAnyListener, BusEventUnion.
  • Generic BUS_* constants (modes, defaults, BUS_EVENT_ALL, BusInvalidPayloadError, BusReentrancyLimitError).
  • All APP_EVENT_* constants and their payload types.
  • Sync-vs-async semantics, listener order, error handling contract.
  • Re-entrancy semantics (DFS + maxReentrancyDepth).

Free to iterate inside 0.1.x:

  • Module events (SESSION_EVENT_*, AUTH_EVENT_*, …) and their payloads.
  • Internal diagnostics constants.
  • Translator/reaction implementations under arts/active-app/presets/.

Bundle budget

arts/bus core (engine only): target ≤ 3 KB gzip.

Status: gate is not yet wired into scripts/bundle-smoke.mjs. v0.1 must close this gap before tagging — an unenforced budget is no budget.

The Svelte adapter (arts/bus/svelte/) and active-bus.svelte.ts are not counted against this budget; they are paid for only when used.

Current implementation status

Implemented:

  • EngineBus<TEvents> and EventPublisher<TEvents> interfaces in $libs/bus.
  • publish, publishAsync, publishCausedBy, on, once, onAny, subscribe, listener counts, test cleanup and idempotent dispose.
  • Deterministic listener order; once is removed before invocation; onAny receives the envelope after exact listeners.
  • Per-publish listener error override through BusPublishOptions.listenerErrorMode.
  • Re-entrancy DFS with maxReentrancyDepth and BusReentrancyLimitError.
  • DEV cloneability guard with BusInvalidPayloadError.
  • Svelte adapter createSvelteEngineBus() via invokeListener + untrack.
  • Minimal active wrapper createBusRecent().
  • arts/active-app/events.ts app-event constants, runtime metadata, publishApp* helpers, onApp* helpers and unsafe-payload guard.
  • App-level session translator and dispose-starting publisher.
  • Per-consumer reactions for Cache, Perms and Connections.

Deferred work

Out of scope for the current cut:

  • priority numeric on listeners — use named phases in v1 if needed.
  • AddEventCascade API — sugar over on(A, () => publish(B)).
  • Standard Schema validation per app event — TS types + credential lint cover v0.1.
  • Per-listener timeout in publishAsync — current behaviour: waits for all. Add timeout in v0.2.
  • Replay, outbox, bounded queue, distributed bus, AsyncAPI catalog.
  • Active wrapper beyond createBusRecent (history, derived stores).
  • Server↔client bus serialization. Forbidden.

Naming convention reminder

  • BUS_* — generic bus constants (live in arts/bus).
  • APP_EVENT_* — public app event names (live in arts/active-app/events.ts).
  • <MOD>_EVENT_* (SESSION_EVENT_*, AUTH_EVENT_*, CACHE_EVENT_*, PERM_EVENT_*, CONNECTION_EVENT_*) — module event names, live in their owning artifact's consts.ts.
  • Diagnostic constants are full-prefixed strings, never bare names: 'bus.event.published', not 'event_published'.
  • Helpers follow publish<Domain><Verb> / on<Domain><Verb>.

Invariants

  • arts/bus does not import other artifacts.
  • arts/bus does not export domain event constants. Domain events belong to their owning artifact; APP_EVENT_* belongs to arts/active-app.
  • Events describe what happened; they never command another module to do something.
  • Automatic side-effects are opt-in per consumer.
  • App events are public contract; they never carry credentials.
  • The bus is per request on server, per root on client. Never a module singleton.

Powered by TurnKey Linux.