eidos: pilot wrapper pattern + doctrinal API conventions

Toggle as the eidos pilot: subdirectory layout (recipe + Svelte wrapper +
types + index + README) replacing the flat CSS-only form. Pattern is
documented in eidos/components/README.md and the toggle README.

Shared types live in eidos/lib/types.ts. First export is `Size` (8 values
xxs..xxl + full); components narrow with `Extract<Size, ...>` per the
per-component-subset doctrine. No `Eidos` prefix on types — module path
already conveys the layer.

API doctrine:
  - soma stays compound (Toggle.Provider) for symmetry with multi-part
  - eidos exports both default + Provider so single-part components
    accept both `<Toggle>` (ergonomic) and `<Toggle.Provider>`
    (compound-style consumers)

SoundChannel eager-init fixes the autoplay race: AudioContext is created
+ resumed synchronously on the first user gesture (capture-phase
listener registered in the constructor), avoiding the previous race
where the first emit() scheduled the resume in a microtask outside the
gesture window.

Demo page (web/routes/toggle/+page.svelte) restructured so the live
preview renders ALWAYS above the tablist — Sema-tab Play buttons can
fire on the real toggle. Motion preview amplifies scale ×8 visually
only; doctrinal values stay in the <dl>.

Conventions 7-13 added to src/docs/sema-implementation-guide.md
covering: directory structure, wrapper composition, no Eidos prefix,
soma compound vs eidos flat, iconOnly sr-only body, sound eager-init,
docs-preview amplification.

CLAUDE.md gets a session hand-off block listing where things stand and
next concrete steps (migrate switch/collapsible/dialog/drawer/popover/
toast/avatar; wire topbar sound mute to masterGain; rename theme
tokens to drop the success/warning/danger fallback aliases).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 4546e448bd
commit f5a2a7fb49

@ -133,7 +133,63 @@ In-progress design notes for the kernel rewrite live in `src/uix/refactor_claude
and `src/uix/refactor_code.md`. Read them before making architectural changes
to soma / sema / morfo so you don't re-litigate decisions already taken.
The doctrinal API conventions (intent ↔ color resolution, per-component
subset, soma compound vs eidos flat, sound eager-init, etc.) live in
[`src/docs/sema-implementation-guide.md`](src/docs/sema-implementation-guide.md)
**Parte IV — Convenciones del API**. That section is authoritative for any
new component or migration. Read it before designing a wrapper, declaring
intents, or amplifying motion in a docs preview.
## Session hand-off — 2026-05-08
Where we left off:
- **Toggle is the eidos pilot** (`src/uix/eidos/components/toggle/`):
recipe + Svelte wrapper + types + index + README. The pattern is
documented in `src/uix/eidos/components/README.md`. Use it as the
template to migrate the next component.
- **Eidos has a shared lib** at `src/uix/eidos/lib/types.ts`. First
type is `Size` (`'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'full'`).
Components narrow with `Extract<Size, 'sm' | 'md' | 'lg'>` per the
per-component-subset doctrine.
- **No `Eidos` prefix on types**. The path
`$uix/eidos/components/{x}` already conveys layer. Public types are
`ToggleProps`, `ToggleVariant`, `ToggleSize`.
- **API doctrine**: soma stays compound (`Toggle.Provider`); eidos
exports both `default` and `Provider` for single-part components so
`<Toggle>` works for the ergonomic case while `<Toggle.Provider>`
stays available.
- **SoundChannel eager-init landed** in `src/uix/sema/chans/sound.ts`
to fix the autoplay race: the AudioContext is created + resumed
synchronously on the first user gesture (capture-phase listener on
`document`).
- **Demo page (`web/routes/toggle/+page.svelte`)** has the live
preview rendered ALWAYS above the tablist (so Sema-tab Play buttons
can fire on the real toggle) and the motion preview amplifies scale
×8 visually only — doctrinal values stay in the `<dl>`.
Next concrete steps:
1. Migrate the next eidos component from flat CSS to the
subdirectory wrapper pattern. Candidates in priority order:
`switch` → `collapsible` → `dialog` → `drawer` → `popover` →
`toast` → `avatar`. Each will need its own demo page under
`web/routes/{name}/+page.svelte` mirroring toggle's tab structure.
2. The docs site's topbar prefs (sound mute toggle, theme switcher)
are wired up but the sound mute does NOT yet drive
`SoundChannel.masterGain`. Wiring belongs on layout.svelte's `$effect`.
3. Theme global token rename — long-deferred. The 8 doctrinal tokens
(`primary, secondary, neutral, affirm, fulfill, risk, threat, loss`)
are referenced by the `data-color` recipes via fallback aliases
(`success → fulfill`, `warning → risk`, `danger → threat`). When
the theme files in `src/uix/eidos/themes/base/{light,dark}.css`
adopt the doctrinal names directly, drop the fallback aliases in
the per-component recipes.
4. `EidosToggleProps` was renamed; grep for any stale `Eidos*Props`
reference before adding new ones — there should be none.
Baseline `npm run check` shows 39 pre-existing errors in test files
unrelated to this work. Sema tests are green (53/53).
---

@ -0,0 +1,58 @@
/**
* Screenshot the toggle Sema tab with intent variations to verify
* the new visualizations.
*/
import { chromium } from 'playwright';
import { mkdir } from 'node:fs/promises';
const BASE = 'http://localhost:5177';
const OUT = 'g:/tmp/demos';
async function main() {
await mkdir(OUT, { recursive: true });
const b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 1280, height: 1100 } });
const p = await ctx.newPage();
const errs: string[] = [];
p.on('console', (m) => {
if (m.type() === 'error') errs.push(m.text());
});
p.on('pageerror', (e) => errs.push('pe: ' + e.message));
await p.goto(BASE + '/toggle', { waitUntil: 'networkidle' });
await p.waitForTimeout(500);
// Click toggle to flip pressed state (so the press intent matters)
await p.locator('[data-toggle]').first().click();
await p.waitForTimeout(300);
await p.screenshot({ path: OUT + '/toggle-live-after-click.png', fullPage: true });
// Switch to Sema tab
await p.locator('button.tab:has-text("Sema")').click();
await p.waitForTimeout(300);
await p.screenshot({ path: OUT + '/sema-neutral.png', fullPage: true });
// Pick affirm intent in the in-tab picker
await p.locator('.intent-chip:has-text("affirm")').click();
await p.waitForTimeout(300);
await p.screenshot({ path: OUT + '/sema-affirm.png', fullPage: true });
// Pick threat intent
await p.locator('.intent-chip:has-text("threat")').click();
await p.waitForTimeout(300);
await p.screenshot({ path: OUT + '/sema-threat.png', fullPage: true });
// Back to Live tab to confirm color reflects threat
await p.locator('button.tab:has-text("Live")').click();
await p.waitForTimeout(300);
await p.screenshot({ path: OUT + '/toggle-live-threat.png', fullPage: true });
console.log('errors:', errs.length);
for (const e of errs) console.log(' ' + e.slice(0, 200));
await b.close();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

@ -0,0 +1,56 @@
/**
* Verify the eidos toggle reflects intent + color in the on state.
* Sets pressed=true via the controls switch, then cycles intents.
*/
import { chromium } from 'playwright';
import { mkdir } from 'node:fs/promises';
const BASE = process.env.BASE_URL ?? 'http://localhost:5177';
const OUT = 'g:/tmp/demos';
async function main() {
await mkdir(OUT, { recursive: true });
const b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 1280, height: 1100 } });
const p = await ctx.newPage();
const errs: string[] = [];
p.on('console', (m) => {
if (m.type() === 'error') errs.push(m.text());
});
await p.goto(BASE + '/toggle', { waitUntil: 'networkidle' });
await p.waitForTimeout(300);
// Find the "pressed" control switch and click it (so the toggle is ON).
const pressedSwitch = p
.locator('.controls-grid .switch:has(span:text("pressed")) input')
.first();
await pressedSwitch.check();
await p.waitForTimeout(200);
// Enable checkMark + icon
await p.locator('.controls-grid .switch:has(span:text("checkMark")) input').check();
await p.locator('.controls-grid .switch:has(span:text("icon (slot)")) input').check();
await p.waitForTimeout(200);
// Cycle intents and capture each
for (const i of ['neutral', 'affirm', 'risk', 'threat'] as const) {
const select = p.locator('.controls-grid select').first();
await select.selectOption(i);
await p.waitForTimeout(200);
// Get just the toggle button DOM state
const dataColor = await p.locator('[data-toggle]').getAttribute('data-color');
const dataState = await p.locator('[data-toggle]').getAttribute('data-state');
console.log(`intent=${i} → data-color=${dataColor} data-state=${dataState}`);
await p.screenshot({ path: `${OUT}/toggle-on-${i}.png`, fullPage: false, clip: { x: 280, y: 380, width: 700, height: 200 } });
}
console.log('errors:', errs.length);
for (const e of errs) console.log(' ' + e.slice(0, 200));
await b.close();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

@ -0,0 +1,44 @@
/**
* Probe Toggle page across themes + interaction. Captures screenshots
* and console errors.
*/
import { chromium } from 'playwright';
import { mkdir } from 'node:fs/promises';
const BASE = process.env.BASE_URL ?? 'http://localhost:5177';
const OUT = 'g:/tmp/demos';
async function main() {
await mkdir(OUT, { recursive: true });
const b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } });
const p = await ctx.newPage();
const errs: string[] = [];
p.on('console', (m) => {
if (m.type() === 'error') errs.push(m.text());
});
p.on('pageerror', (e) => errs.push('pe: ' + e.message));
await p.goto(BASE + '/toggle', { waitUntil: 'networkidle' });
await p.waitForTimeout(500);
await p.screenshot({ path: OUT + '/toggle-light.png', fullPage: true });
await p.locator('[data-toggle]').first().click();
await p.waitForTimeout(150);
await p.screenshot({ path: OUT + '/toggle-press.png', fullPage: true });
await p.waitForTimeout(500);
// Switch to dark theme via the topbar
await p.locator('button[aria-label="Toggle theme"]').first().click();
await p.waitForTimeout(300);
await p.screenshot({ path: OUT + '/toggle-dark.png', fullPage: true });
console.log('errors:', errs.length);
for (const e of errs) console.log(' ' + e.slice(0, 200));
await b.close();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

@ -0,0 +1,92 @@
"""Rewrite soma/attrs imports.
For each soma file (excluding soma/attrs itself):
- find each `import { ... } from '<rel>/attrs'` or `'$soma/attrs'`
- split: morfo symbols → '$uix/morfo', dom symbols → '$libs/dom'
- emit one or two replacement imports
"""
import os
import re
import sys
MORFO_SYMBOLS = {
'createAttrs', 'AttrsOf', 'AttrsReturn',
'registerContract', 'getContract', 'assertContract',
}
DOM_SYMBOLS = {
'boolToStr', 'boolToEmptyStrOrUndef', 'boolToTrueOrUndef',
'getDataOpenClosed', 'getDataChecked', 'getAriaChecked',
}
IMPORT_RE = re.compile(
r"import\s+(type\s+)?\{([^}]+)\}\s+from\s+['\"](?:(?:\.\./)+attrs|\$soma/attrs)['\"];?",
re.MULTILINE,
)
def parse_names(block):
out = []
for raw in block.split(','):
item = raw.strip()
if not item:
continue
is_type = False
if item.startswith('type '):
is_type = True
item = item[5:].strip()
out.append((item, is_type))
return out
def render_import(items, source, has_type_import):
if not items:
return ''
type_marker = 'type ' if has_type_import else ''
parts = []
for name, is_type in items:
if has_type_import:
parts.append(name)
else:
parts.append(f'type {name}' if is_type else name)
body = ',\n\t'.join(parts)
return f"import {type_marker}{{\n\t{body}\n}} from '{source}';"
def process_match(m):
has_type_import = bool(m.group(1))
names = parse_names(m.group(2))
morfo_items = [(n, t) for (n, t) in names if n in MORFO_SYMBOLS]
dom_items = [(n, t) for (n, t) in names if n in DOM_SYMBOLS]
other_items = [(n, t) for (n, t) in names if n not in MORFO_SYMBOLS and n not in DOM_SYMBOLS]
if other_items:
print('UNKNOWN names:', other_items, file=sys.stderr)
return m.group(0)
out = []
if morfo_items:
out.append(render_import(morfo_items, '$uix/morfo', has_type_import))
if dom_items:
out.append(render_import(dom_items, '$libs/dom', has_type_import))
return '\n'.join(out)
SKIP_DIR = os.path.normpath('src/uix/soma/attrs')
changed = []
for root, dirs, files in os.walk('src/uix/soma'):
if os.path.normpath(root) == SKIP_DIR:
continue
for f in files:
if not f.endswith(('.ts', '.svelte', '.svelte.ts')):
continue
p = os.path.join(root, f)
s = open(p, 'r', encoding='utf-8').read()
new = IMPORT_RE.sub(process_match, s)
if new != s:
open(p, 'w', encoding='utf-8').write(new)
changed.append(p)
for f in changed:
print(f)
print(f'--- {len(changed)} files updated ---')

@ -0,0 +1,94 @@
"""One-shot rewrite of soma/reactive imports.
For each soma file (excluding soma/reactive itself):
- find each `import { ... } from '<rel>/reactive'` or `'$soma/reactive'`
- split the names: libs symbols → '$libs/reactive', provider symbols → '$soma/provider'
- emit one or two replacement imports
"""
import os
import re
import sys
LIBS_SYMBOLS = {
'state', 'readableActive', 'writableActive', 'autoReset',
'isState', 'isActive',
'ActiveSymbol', 'WritableSymbol',
'Active', 'State', 'Getter',
'ActiveProps', 'StateProps',
}
PROVIDER_SYMBOLS = {
'bindProps', 'OptsFromProps', 'WritableSpec', 'PropsConfigEntry',
}
IMPORT_RE = re.compile(
r"import\s+(type\s+)?\{([^}]+)\}\s+from\s+['\"](?:(?:\.\./)+reactive|\$soma/reactive)['\"];?",
re.MULTILINE,
)
def parse_names(block):
out = []
for raw in block.split(','):
item = raw.strip()
if not item:
continue
is_type = False
if item.startswith('type '):
is_type = True
item = item[5:].strip()
out.append((item, is_type))
return out
def render_import(items, source, has_type_import):
if not items:
return ''
type_marker = 'type ' if has_type_import else ''
parts = []
for name, is_type in items:
if has_type_import:
parts.append(name)
else:
parts.append(f'type {name}' if is_type else name)
body = ',\n\t'.join(parts)
return f"import {type_marker}{{\n\t{body}\n}} from '{source}';"
def process_match(m):
has_type_import = bool(m.group(1))
names = parse_names(m.group(2))
libs_items = [(n, t) for (n, t) in names if n in LIBS_SYMBOLS]
prov_items = [(n, t) for (n, t) in names if n in PROVIDER_SYMBOLS]
other_items = [(n, t) for (n, t) in names if n not in LIBS_SYMBOLS and n not in PROVIDER_SYMBOLS]
if other_items:
print('UNKNOWN names:', other_items, file=sys.stderr)
return m.group(0)
out = []
if libs_items:
out.append(render_import(libs_items, '$libs/reactive', has_type_import))
if prov_items:
out.append(render_import(prov_items, '$soma/provider', has_type_import))
return '\n'.join(out)
SKIP_DIR = os.path.normpath('src/uix/soma/reactive')
changed = []
for root, dirs, files in os.walk('src/uix/soma'):
if os.path.normpath(root) == SKIP_DIR:
continue
for f in files:
if not f.endswith(('.ts', '.svelte', '.svelte.ts')):
continue
p = os.path.join(root, f)
s = open(p, 'r', encoding='utf-8').read()
new = IMPORT_RE.sub(process_match, s)
if new != s:
open(p, 'w', encoding='utf-8').write(new)
changed.append(p)
for f in changed:
print(f)
print(f'--- {len(changed)} files updated ---')

@ -0,0 +1,288 @@
# arts — runtime artifacts
`src/arts/` contains the runtime building blocks of the application. Each
artifact is independent, has its own README, and follows two consistent
naming conventions:
- **`Engine*`** — public methods over private state (or no state at all).
Pure factory; the locale, logger or any volatile input is passed as
argument on every call. When an artifact has a true server-authoritative
counterpart (`perm`, `cach`), the engine lives under `src/svrs/`.
- **`Active*`** — an `Engine*` that exposes public reactive state. Lives in a
`.svelte.ts` file because it owns `$state`. Imports must target the file
directly, not the barrel, to keep the rest of the artifact runes-free.
## ActiveEngine Contract
Root active artifacts implement the shared `ActiveEngine<TSnapshot, TError>`
contract from `$libs/active`:
```ts
interface ActiveEngine<TSnapshot, TError> {
readonly loading: boolean;
readonly lastError: TError | null;
readonly disposed: boolean;
snapshot(): TSnapshot;
clearError(): void;
onChange(listener: (snapshot: TSnapshot) => void): () => void;
dispose(): void;
}
```
Conventions:
- Use direct getters (`Auth.current`, `Cache.loading`, `Perms.lastError`),
not a module-specific `.state` object.
- Use `loading`, never `pending`, for in-flight work.
- Use `onChange()` for snapshot subscriptions. Lower-level clients may expose
their own event buses, but active roots keep this name.
- `dispose()` is idempotent, clears owned listeners/entries/resources, and
subsequent public operations throw the artifact's `XxxDisposedError`.
- Root active artifacts that create entries (`ActiveCache.entry()`,
`ActiveConnections.connection()`, etc.) own those entries and dispose them
when the root is disposed.
## Logger And Diagnostics Contract
Every artifact that emits runtime information follows the same two-layer
contract:
```ts
import type { DiagnosticEvent, Diagnostics, Logger } from '$libs/logger';
```
- Public options use `logger?: Logger`. Do not create artifact-local logger
interfaces or narrowed aliases for individual modules.
- The root logger implementation is `EngineLogger` from `$logger`; it extends the
shared `Logger` contract from `$libs/logger`.
- Artifact code defines `<Artifact>Diagnostics` with
`create<Artifact>Diagnostics(logger?)` and emits catalogued events for
internal diagnostics.
- Diagnostic event names live in the artifact `consts.ts` as
`*_DIAGNOSTIC_EVENTS`. Messages live in `errors.ts` or `consts.ts`, never as
inline strings in runtime logic.
- `Diagnostics<TEvent>` always exposes `{ logger, emit(event) }`. The `logger`
property is the common `Logger`, so modules that need an ad-hoc `info` or
`error` still have the full logger without inventing a second interface.
- Level routing is controlled by the logger/transports via the existing
per-level enablement map, not by module-specific severity systems.
Typical shape:
```ts
export const HTTP_DIAGNOSTIC_EVENTS = {
REQUEST: 'http.request',
NETWORK_ERROR: 'http.network_error'
} as const;
export function createHttpDiagnostics(logger?: Logger): HttpDiagnostics {
return createCatalogDiagnostics({
logger,
defaultCategory: LOGGER_CATEGORY,
catalog: HTTP_DIAGNOSTIC_LOGS
});
}
```
This gives every module the same path to Sentry, Loki, Datadog, console,
test-capture transports or any future sink: inject one `Logger`, emit typed
diagnostic events, let `logr` route.
## Error Contract
Errors follow the same rule: strings are centralized, and public programmer
errors are typed.
- Error messages and error names live in `errors.ts` or `consts.ts`.
- Runtime code must not throw inline string/template errors outside tests or
vendored code.
- Public programmer errors use artifact-specific classes and guards:
`SessionDisposedError`, `ConnInvalidNameError`, `UnitsUnknownUnitError`, etc.
- Expected runtime failures should be returned as tagged data/results when the
artifact already has such a contract (`http`, `conn`, `perm`, `cach`).
- Validation failures are data (`SiumValidationError.issues`) and diagnostics
are emitted separately when a logger is injected.
## Map
| Artifact | Layer(s) | Purpose | Depends on |
| -------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| [`lang`](./lang/README.md) | `EngineLang`, `ActiveLang`, `ActiveMonoLang` | i18n: type-safe translations, BCP 47 resolution, plurals, refs, JSON round-trip | — |
| [`logr`](./logr/README.md) | `EngineLogger` | Structured logger: levels, transports, filters, vitals, dispose | — |
| [`timr`](./timr/README.md) | `EngineTimers`, `ActiveTimers` | Deterministic timer scheduler: clock injection, one-shots, intervals, cancellation, snapshots, backoff | `$libs/timers`, `$logger` (optional) |
| [`fmts`](./fmts/README.md) | `EngineFormat`, `ActiveFormat` | Localized formatting: numbers, currency, units, dates | `$logger` (currency) |
| [`adom`](./adom/README.md) | `ActiveDom` | Reactive DOM service: viewport, breakpoints, attribute writes, scroll lock | `$libs/dom`, `$reactive` |
| [`fend`](./fend/README.md) | `ActiveFrontend` | Frontend preferences: theme, mode, dir, density, applied via DOM attrs | `$adom` |
| [`sium`](./sium/README.md) | `EngineSium` | Validation contracts: schemas, issues, introspection, Standard Schema interop | `$lang` (optional), `$logger` (optional), `$libs/days`, `$libs/color` |
| [`stor`](./stor/README.md) | `EngineStorage`, `ActiveStorage` | Reactive sync key/value: pluggable adapters, version+migrate, TTL, validation, intra-tab + cross-tab sync, reactive keys | `$sium` (Standard Schema interop, optional) |
| [`http`](./http/README.md) | `EngineHttp` | HTTP client: tagged `HttpResult`, Standard Schema validation, retry, timeouts, hooks, SvelteKit `event.fetch` integration | `$libs/http`, `$libs/standard-schema` (type-only), `$logger` |
| [`sess`](./sess/README.md) | `EngineSession`, `ActiveSession` | Session lifecycle: adopt/revoke/refresh, auto-refresh, 401-rescue hook, SvelteKit SSR via `adoptServer` + cookie reader | `$storage`, `$timer`, `$http`, `$logger` (optional) |
| [`conn`](./conn/README.md) | `EngineConnections`, `ActiveConnections` | Realtime connection registry: transports, reconnect, heartbeat, request/reply, channels, session bridge | `$timer`, `$logger` (optional), `$session` bridge (optional) |
| [`auth`](./auth/README.md) | `ActiveAuth` (`EngineAuth` in `$svrs/auth`) | Authentication: password flows, CSRF, current session reflector, devices, logout, server-authoritative auth handlers | `$libs/auth`, `$http`, `$cache`, `$svrs/auth` |
| [`perm`](./perm/README.md) | `ActivePerms` (`EnginePerms` in `$svrs/perm`) | Authorization: policy runtime adapter, HTTP client/handlers, cache snapshot, `<Can />` guard | `$libs/perm`, `$libs/svrs`, `$http`, `$logger` (optional) |
| [`cach`](./cach/README.md) | `ActiveCache` (`EngineCache` in `$svrs/cache`) | Data cache: deterministic keys, policies, scopes, stale/revalidate, tags, memory/storage adapters | `$libs/cache`, `$storage` (adapter), `$logger` (optional) |
| [`aapp`](./aapp/README.md) | `ActiveApp` | App composition: wires Logger + Lang + Format + Frontend + Dom + Storage + Http + Timers + Cache; factories for Sess, Conn, Auth, Perm, Sium | every artifact above |
## Composition
Most apps consume the artifacts through `aapp`:
```ts
import { createActiveApp } from '$active-app';
const App = createActiveApp({
lang: { schema, defaultLocale: 'es', fallbackChain: ['en'] },
logger: { level: LogLevel.INFO, transports: [consoleTransport()] },
frontend: { theme: 'base' }
});
App.lang.t('common.ok');
App.format.currency.format(99.5);
App.lang.setLocale('es-MX'); // propagates to Lang, Format, Frontend
```
Every member of `App` is **always present**. When the caller does not
configure `lang`/`logger`/`formats`, App provides a structurally identical
adapter (mono lang, console logger, default-locale formats). Call sites stay
uniform: `App.lang.t(...)` and `App.format.currency.format(...)` work
whether or not i18n was configured.
`Sium`, `Session`, `Connections`, `Auth` and `Perms` are exposed as
**factories** because they are feature/page-scoped: App injects shared
services, but construction is explicit at the call site.
```ts
const App = createActiveApp({
services: {
sium: defineEngineSium({}),
connections: defineActiveConnections({}),
auth: defineActiveAuth({ initial: data.auth }),
perm: defineActivePerm({ endpoint: '/perm' })
}
});
applyStandardOrca(App);
```
See `aapp/README.md` for the full composition contract.
## Cross-artifact dependencies
```
lang logr
\ / | \
\ / | \
fmts http timr
\ | /|\
adom ─── fend \ | / | conn
\ \ \ | / | \
\ \ \ | / auth perm
──────────────── aapp ─ stor ─ cach
:
sium
```
- `lang` and `logr` are the dependency-free roots (`zero-dep` libraries).
- `fmts` consumes `logr` only inside `currency` (rate fetcher diagnostics).
- `sium` accepts `lang` and `logr` via injection; without them it falls back
to local message interpolation.
- `timr` is the deterministic scheduler consumed by `sess` and `conn`.
- `http` accepts `logr` via injection (auto-wired through `aapp`) and keeps
shared HTTP literals/types in `$libs/http`.
- `sess` consumes `stor` for persistence, `timr` for auto-refresh, and `http`
for 401-rescue integration.
- `conn` consumes `timr` for reconnect/heartbeat/ack timeouts and accepts the
App session bridge when composed through `aapp`.
- `auth` splits cleanly: `$svrs/auth` owns identity proof, CSRF and server
handlers; `$auth` owns the active client reflector. It feeds `sess`, `perm`
and `cach` through ports rather than owning their state.
- `perm` splits cleanly: `$svrs/perm` owns the authoritative engine/HTTP
handlers, while `$perm` owns the active UI reflector and `<Can />`.
- `cach` splits cleanly: `$svrs/cache` owns the imperative engine, while
`$cache` owns the active Svelte wrapper and can consume `stor` through its
storage adapter.
- `adom` depends only on the pure helpers in `libs/dom` and on `libs/reactive`.
- `fend` depends on `adom` for DOM attribute writes.
- `aapp` composes always-present roots and exposes factories for scoped
artifacts (`sium`, `sess`, `conn`, `auth`, `perm`).
## Shared types
| Module | Type | Used by |
| --------- | ------------------------------------------------------- | ------------------------------------------------------- |
| `$locale` | `LocaleSource` | `fmts.localeSource`, `fend.localeSource`, `aapp` wiring |
| `$lang` | `SupportedLocale` (`LangBase \| ${LangBase}-${string}`) | `lang`, consumers that want type-safe locales |
## Aliases
```js
// svelte.config.js
alias: {
$active-app: 'src/arts/aapp',
$adom: 'src/arts/adom',
$auth: 'src/arts/auth',
$cache: 'src/arts/cach',
$connection: 'src/arts/conn',
$frontend: 'src/arts/fend',
$format: 'src/arts/fmts',
$http: 'src/arts/http',
$lang: 'src/arts/lang',
$logger: 'src/arts/logr',
$perm: 'src/arts/perm',
$session: 'src/arts/sess',
$sium: 'src/arts/sium',
$storage: 'src/arts/stor',
$svrs: 'src/svrs',
$timer: 'src/arts/timr',
$libs: 'src/libs',
$locale: 'src/libs/locale',
$reactive: 'src/libs/reactive'
}
```
## Bundle policy
Every artifact is designed to tree-shake cleanly:
- `package.json` declares `"sideEffects": ["**/*.css", "**/*.svelte"]`, so any
`.ts` / `.svelte.ts` module that the bundler does not statically reach is
dropped from the production bundle.
- All barrels use **named re-exports** (`export { a, b } from './x'`) instead
of `export *`. This lets the bundler prove which symbols are reached from
a given import and drop the rest of the source module.
- `.svelte.ts` files defer module-level state (`viewport.svelte.ts`,
`body-scroll-lock.svelte.ts`) so importing the barrel does not allocate
Svelte runes runtime for unused features.
- External adapters (`$logger/adapters/*`) and dev helpers (`$active-app/testing`,
`$sium/_examples/`) live outside the main barrel. A consumer that does
not reference them never pays for them.
A consumer that builds `createActiveApp({ lang: { schema } })` and only
calls `App.lang.t(...)` should land roughly in the 40–50 KB minified range.
A consumer that wires every artifact (Sium + Storage cookies + Frontend
preferences + Web Vitals) lands in the ~120 KB range. The difference is
the per-feature surface, paid only when reached.
## Test pages
Each artifact ships an interactive page under `src/web/routes/test/<artifact>`:
- `/test/aapp` — full composition end-to-end
- `/test/ecosystem` — total integration demo: auth, sess, perm, cach, http, stor, sium, fmts, fend, adom, timr, conn, lang and logr in one app flow
- `/test/lang` — i18n with reactive locale switching, plurals, BCP 47
- `/test/logr` — log levels, transports, vitals, Sentry integration
- `/test/fmts` — numbers / currency / units / dates with shared locale
- `/test/fend` — theme, mode, dir, density applied to a target
- `/test/adom` — viewport, breakpoints, scroll lock, roving focus
- `/test/sium` — login / signup / profile schemas with translated issues
- `/test/stor` — adapters (memory / local / session / cookie), envelope versioning + migrate, raw mode, TTL, mergeDefaults, cross-tab sync
- `/test/http` — GET/POST with Sium validation, retry + Retry-After, timeout, cancellation, lifecycle hooks, tagged `HttpResult`
- `/test/sess` — session lifecycle: adopt/revoke/refresh with generation guard + dedup, auto-refresh, tagged `RevokeResult`, permission checks, event stream
- `/test/timr` — scheduler snapshots, intervals, cancellation and deterministic clocks
- `/test/conn` — websocket chat and connection/channel lifecycle
- `/test/auth` — server-authoritative auth surface: password flow, CSRF, devices, routes and security events
- `/test/perm` — authorization checks, `<Can />`, HTTP handlers and client cache
- `/test/cach` — cache policies, scopes, tags and active entries
Index at `/test`.

@ -0,0 +1,327 @@
# active-app
`arts/active-app` is the **composition layer** of the ecosystem. It builds the
fixed runtime core, composes opt-in services declared by the application, and
exposes the orchestration engine that wires them together.
```ts
import { createActiveApp } from '$active-app';
import {
defineActiveCache,
defineActiveLang,
defineActiveSession,
defineEngineHttp
} from '$active-app/services';
import { applyStandardOrca } from '$active-app/presets';
const App = createActiveApp({
logger: { level: LogLevel.INFO },
services: {
lang: defineActiveLang({ schema: appLang, defaultLocale: 'es' }),
http: defineEngineHttp({ baseUrl: '/api' }),
cache: defineActiveCache(),
session: defineActiveSession<MyUser>({
onRefresh,
onRevoke
})
}
});
applyStandardOrca(App);
```
## Two layers, three import paths
`active-app` is layered to keep bundles small and the contract obvious.
| Layer | Path | Loaded when |
| --- | --- | --- |
| **Core** | `$active-app` | Always — every app needs `createActiveApp`. |
| **Service factories** | `$active-app/services` | The app declares any service in `services: { … }`. |
| **Orchestration presets** | `$active-app/presets` | The app opts into standard reactions or cherry-picks them. |
Each layer is a separate barrel. An app that builds only the core never pulls
service factories or presets into its bundle.
## Core vs services
The composition has two layers:
- **Core** — `Logger`, `Bus`, `Timers`, `Orca`. Always built, never declared
as a service. Configurable via the `ActiveAppOptions` root.
- **Services** — opt-in pieces that the application declares in
`services: { … }`. If a service is not declared, it does not exist on
`App`, and TypeScript reports an error when consumers try to access it.
The legacy uppercase surface (`App.lang`, `App.cache`, `App.format`,
`App.frontend`, `App.dom`, `App.storage`, `App.http`) has been removed. Those
pieces are now opt-in services. There is no migration period; the project did
not have external consumers when the cut happened.
## What the core provides
```ts
interface ActiveAppCore {
readonly Logger: EngineLogger;
readonly Bus: EngineBus<ActiveAppBusEvents>;
readonly Timers: ActiveTimers;
readonly Orca: EngineOrca;
dispose(): void;
}
```
- `Logger` defaults to engine defaults (`level: WARN`, `consoleTransport()`).
Pass `{ level: NONE, transports: [] }` for silence.
- `Bus` and `Timers` are App-wide singletons. Services that need them
declare `'bus'` / `'timers'` in `coreDependencies`.
- `Orca` is always present, **inert until the application registers
actions**. Apps that don't use orchestration pay only for the engine's
empty maps. See the [orca README](../orca/README.md) for the
supported surface.
- `dispose()` publishes `APP_EVENT_DISPOSE_STARTING` first, then tears
every constructed service down in reverse order, then the core.
## How services work
A service is anything an `AppServiceFactory` produces. Factories live in
`arts/active-app/service-factories/` and are exported from
`$active-app/services`.
```ts
interface AppServiceFactory<TName, TCoreDeps, TServiceDeps, TInstance> {
readonly name: TName;
readonly coreDependencies: TCoreDeps;
readonly serviceDependencies?: TServiceDeps;
readonly initMode?: 'immediate' | 'lazy';
create(deps: { core: …; services: … }): TInstance;
dispose?(instance: TInstance): void;
}
```
The schema is just an object literal:
```ts
services: {
cache: defineActiveCache(),
session: defineActiveSession<MyUser>({ onRefresh, onRevoke })
}
```
The builder validates the schema, computes a topological order, builds
`immediate` services right away, and exposes `lazy` ones behind getters
that materialise on first access. Construction order is dependency-first;
disposal runs in reverse.
### Service init modes
| Mode | When the service is built |
| --- | --- |
| `lazy` (default) | First time `App.<name>` is read. |
| `immediate` | During `createActiveApp()`, after the core is up. |
`immediate` is for services with construction-time side effects (subscribing
to `BroadcastChannel`, hydrating from storage on boot, etc.). Everything else
is `lazy`.
### Service status
Every declared service has an observable status:
```ts
type ServiceStatus = 'absent' | 'present' | 'failed';
App.services; // Readonly<Record<string, ServiceStatus>>
```
Mostly used by devtools and tests; application code rarely reads it.
### Failure handling
If a factory's `create()` throws, the service status becomes `'failed'`.
Subsequent reads of `App.<name>` re-throw the original error wrapped in
`AappServiceConstructionFailedError`. The first read sees the same wrapped
error — the wrapping is cheap and uniform.
## Available services
| Factory | Slot | Notes |
| --- | --- | --- |
| `defineActiveLang(options)` | `lang` | Schema is required. |
| `defineActiveStorage(options)` | `storage` | Memory adapter by default. |
| `defineActiveDom(props)` | `dom` | Inert on the server. |
| `defineActiveFormat(options)` | `format` | Wires to `lang` automatically when both are declared. |
| `defineActiveFrontend(options)` | `frontend` | Wires to `dom` and `lang` automatically. |
| `defineActiveCache(options)` | `cache` | Passive — invalidation is driven by orca presets. |
| `defineActiveSession<TUser, …>(options)` | `session` | Publishes `SESSION_EVENT_*` on the bus. |
| `defineActivePerm(options)` | `perm` | Auto-invalidation is OFF; use orca preset. |
| `defineActiveAuth(options)` | `auth` | Requires an HTTP client in `options`. |
| `defineActiveConnections(options)` | `connections` | Identity tracking via orca preset. |
| `defineEngineHttp(options)` | `http` | Engine only — no Active wrapper. |
| `defineEngineSium(options)` | `sium` | Wires to `lang` automatically when declared. |
## Orchestration
`App.orca` is always present and inert. Reactions are not pre-wired — apps
register them explicitly through orca presets in `arts/active-app/presets/`.
```ts
import {
applyCacheClearOnRevoke,
applyCacheClearOnIdentityChange,
applyPermInvalidateOnIdentityChange,
applyStandardOrca
} from '$active-app/presets';
// Cherry-pick:
applyCacheClearOnRevoke(App);
applyPermInvalidateOnIdentityChange(App);
// Or all standard presets at once:
applyStandardOrca(App);
```
Each `apply*` returns a detach function for testing and hot-reload.
### Why presets live here, not inside arts
An art (`arts/cache`, `arts/perm`, …) does not know about `arts/session` or
`arts/orca`. That knowledge belongs to the composition layer. Putting presets
in `arts/active-app/` keeps the inter-art dependency graph clean: every art
depends only on `libs/` and on the core (`logger`, `bus`, `timers`, `orca`),
never on a sibling art.
## Bus context bridge
The Svelte-context helper `setBus` / `getBus` lives in `$bus`, not here.
The bus is the semantic owner of the propagation pattern; App is just a
consumer that calls `setBus(App.bus)` once near the layout root.
```svelte
<!-- app/+layout.svelte -->
<script lang="ts">
import { setBus } from '$bus';
import { App } from './app';
setBus(App.bus);
</script>
```
```svelte
<!-- somewhere deep in the tree -->
<script lang="ts">
import { getBus } from '$bus';
const Bus = getBus();
$effect(() => Bus.on('something', payload => …));
</script>
```
`getBus()` throws `BusNoContextError` (from `$libs/bus`) if no bus is in
scope — forgetting `setBus()` is a wiring bug, not a degraded mode.
## Events
Only one event is owned by `arts/active-app`:
```ts
export const APP_EVENT_DISPOSE_STARTING = 'app.dispose.starting';
```
It fires once at the start of `App.dispose()`, before any service teardown,
so subscribers can flush, persist or detach while their dependencies still
exist. Everything else used to be a republication of module-level events;
those republications have been removed in favour of orca presets that listen
to the canonical events directly.
`assertEventCanFire(type, where)` and `assertAppEventPayloadSafe(type,
payload)` are the safety nets used by typed publishers like
`publishAppDisposeStarting`. Both throw structured errors
(`AappInvalidEventRuntimeError`, `AappUnsafeEventPayloadError`) that
applications can catch.
## Errors
| Error | When it fires |
| --- | --- |
| `AappServiceNameMismatchError` | Schema key !== `factory.name`. |
| `AappServiceDependencyCycleError` | A cycle is detected in `serviceDependencies`. |
| `AappServiceConstructionFailedError` | A factory's `create()` throws. |
| `AappInvalidEventRuntimeError` | An `APP_EVENT_*` published in the wrong runtime. |
| `AappUnsafeEventPayloadError` | A sensitive key (`token`, `password`, `cookie`, …) is found in a payload. |
`getBus()` throws `BusNoContextError` (from `$libs/bus`) when no bus is
in Svelte context — that error belongs to `arts/bus/`, not `active-app/`.
All of them extend `CodeError` from `$libs/errs` and have type guards
(`isAappServiceNameMismatchError`, …).
## Filesystem layout
```
src/arts/active-app/
├── README.md ← this file
├── index.ts ← public entry point ($active-app)
├── consts.ts
├── errors.ts
├── events.ts ← APP_EVENT_DISPOSE_STARTING + safety helpers
├── services.ts ← AppServiceFactory contract
├── service-builder.ts ← topology, lazy proxies, dispose
├── active-app.svelte.ts ← createActiveApp()
├── service-factories/ ← $active-app/services
│ ├── index.ts
│ ├── cache.ts
│ ├── lang.ts
│ ├── storage.ts
│ ├── dom.ts
│ ├── format.ts
│ ├── frontend.ts
│ ├── http.ts
│ ├── session.ts
│ ├── auth.ts
│ ├── perm.ts
│ ├── connections.ts
│ └── sium.ts
├── presets/ ← $active-app/presets
│ ├── index.ts
│ ├── cache-clear-on-revoke.ts
│ ├── cache-clear-on-identity-change.ts
│ ├── perm-invalidate-on-identity-change.ts
│ └── standard.ts
└── test/
└── service-builder.test.ts
```
## Adding a new service
Three steps:
1. **Build the art** as a normal `arts/<name>/` module. The art does not
know about `App` or `services`; it exposes a pure `createActive<Name>`
or `createEngine<Name>` factory.
2. **Write the `define*` factory** in
`arts/active-app/service-factories/<name>.ts`. Declare which core deps
you read (`coreDependencies: ['logger', 'bus']`) and which sibling
services you optionally consume (`serviceDependencies: ['lang']`).
Export it from `service-factories/index.ts`.
3. **Optional — add presets** in `arts/active-app/presets/<name>-…ts`
for any reactions the standard composition wants to ship.
The service is then declarable from any application:
```ts
services: {
cart: defineActiveCart({ persistKey: 'cart' })
}
```
`App.cart` is now type-safe, lazy by default, and disposed in reverse order
when `App.dispose()` runs.
## Test
```bash
npx vitest run src/arts/active-app/test
```
The current suite covers the schema validation, topological ordering,
lazy/immediate construction, status reporting, dispose order, idempotence,
and core/service dependency injection.

@ -0,0 +1,189 @@
/**
* `createActiveApp()` — composed runtime root.
*
* Builds the five pieces of the core (`logger`, `bus`, `timers`,
* `orca`, `prefs`) and then defers everything else to the declarative
* service schema. The function itself is short on purpose — every
* art-specific knob has moved to its `defineActive*` /
* `defineEngine*` factory.
*
* Lowercase core surface — `App.logger` / `App.bus` / `App.timers` /
* `App.orca` / `App.prefs`. The previous PascalCase rule for core
* members existed for visual signalling and went against JS property
* convention. Removed.
*/
import type { EngineBus } from '$bus';
import { createSvelteEngineBus } from '$bus';
import { createEngineLogger } from '$logger/engine-logger';
import { createEngineOrca } from '$orca';
import {
createActivePrefs,
createPrefsStorageBridge,
NEUTRAL_PREFS_SCHEMA,
type ActivePrefs,
type PrefsStorageBridge
} from '$prefs';
import type { PrefsSchema } from '$libs/prefs';
import { createActiveTimers } from '$timer/active-timers.svelte';
import { APP_MODULE } from './consts.ts';
import { publishAppDisposeStarting } from './events.ts';
import { buildServiceBuilders } from './service-builder.ts';
import type { AppServiceSchema, CoreServices } from './services.ts';
import type {
ActiveApp,
ActiveAppBusEvents,
ActiveAppCore,
ActiveAppOptions,
ActiveAppPrefsOptions
} from './types.ts';
export function createActiveApp<
TSchema extends AppServiceSchema = AppServiceSchema,
TPrefsSchema extends PrefsSchema = PrefsSchema
>(options: ActiveAppOptions<TSchema, TPrefsSchema> = {}): ActiveApp<TSchema, TPrefsSchema> {
// ── Core ────────────────────────────────────────────────────────────
const logger = createEngineLogger(options.logger);
const timers = createActiveTimers({
...options.timers,
logger
});
const bus = createSvelteEngineBus<ActiveAppBusEvents>({
...options.bus,
logger,
clock: timers.clock
});
const orca = createEngineOrca({
...options.orca,
bus,
timers,
logger
});
const { engine: prefs, bridge: prefsBridge } = buildPrefs(options.prefs);
// ── Services ────────────────────────────────────────────────────────
const core = coreForBuilder(logger, bus, timers, orca, prefs);
const serviceBuilders = options.services
? buildServiceBuilders(options.services as AppServiceSchema, core)
: undefined;
let disposed = false;
const baseApp: ActiveAppCore<TPrefsSchema> = {
logger,
bus,
timers,
orca,
prefs: prefs as ActivePrefs<TPrefsSchema>,
dispose() {
if (disposed) return;
disposed = true;
// Announce dispose BEFORE tearing anything down so subscribers
// can still reach the bus and any service they depend on.
publishAppDisposeStarting(bus, { cause: APP_MODULE });
// Schema-declared services first (reverse construction order
// is handled by the builder).
serviceBuilders?.disposeAll();
// Prefs bridge tears down before the engine so a late storage
// op can't race a disposed engine.
prefsBridge?.dispose();
prefs.dispose();
// Remaining core last, in reverse build order.
orca.dispose();
bus.dispose();
timers.dispose();
logger.dispose();
}
};
const app = baseApp as ActiveApp<TSchema, TPrefsSchema>;
if (serviceBuilders !== undefined) {
for (const name of Object.keys(serviceBuilders.proxies)) {
Object.defineProperty(app, name, {
configurable: false,
enumerable: true,
get() {
return (serviceBuilders.proxies as Record<string, unknown>)[name];
}
});
}
Object.defineProperty(app, 'services', {
configurable: false,
enumerable: true,
get() {
return serviceBuilders.statusMap();
}
});
} else {
Object.defineProperty(app, 'services', {
configurable: false,
enumerable: true,
value: Object.freeze({})
});
}
return app;
}
/**
* Build the prefs engine and its optional storage bridge. When the
* caller omits `options.prefs` entirely, fall back to
* `NEUTRAL_PREFS_SCHEMA` so the core slot is always populated.
*/
function buildPrefs<S extends PrefsSchema>(
options: ActiveAppPrefsOptions<S> | undefined
): {
engine: ActivePrefs<S>;
bridge: PrefsStorageBridge | undefined;
} {
const schema =
(options?.schema as S | undefined) ?? (NEUTRAL_PREFS_SCHEMA as unknown as S);
const engine = createActivePrefs({
schema,
environment: options?.environment,
intent: options?.intent
});
let bridge: PrefsStorageBridge | undefined;
if (options?.storage !== undefined) {
bridge = createPrefsStorageBridge({
engine,
storage: options.storage,
onError: options.onStorageError
});
}
return { engine, bridge };
}
/**
* Adapt the App-level core to the generic `CoreServices` contract that
* service factories see.
*
* The downcast on `bus` is safe because:
* - `EngineBus<ActiveAppBusEvents>` is structurally a more precise
* instance of `EngineBus<BusEventMap>`.
* - Services that need to publish App-owned events do so through the
* dedicated typed publishers (`publishAppDisposeStarting`, …), not
* via raw `bus.publish(type, payload)` against an arbitrary string.
*/
function coreForBuilder(
logger: ActiveAppCore['logger'],
bus: ActiveAppCore['bus'],
timers: ActiveAppCore['timers'],
orca: ActiveAppCore['orca'],
prefs: ActiveAppCore['prefs']
): CoreServices {
return {
logger,
bus: bus as unknown as EngineBus,
timers,
orca,
prefs
};
}

@ -0,0 +1,41 @@
/**
* Constants for `arts/active-app`. Holds the module identifier, the
* safe-publish guards (sensitive key parts, runtime gates) and the
* canonical app-event names.
*/
export const APP_MODULE = 'app';
/**
* Substrings that identify a sensitive payload key. Any property whose
* lowercased name contains one of these is rejected by
* `assertAppEventPayloadSafe()`. The list is intentionally
* conservative — App-level events are facts, not transports for secrets.
*/
export const APP_EVENT_SENSITIVE_KEY_PARTS = [
'authorization',
'cookie',
'credential',
'csrf',
'hash',
'header',
'password',
'secret',
'token'
] as const;
/**
* Where a given app event is allowed to be published. `'both'` is the
* default for facts that exist in both server-rendered and browser-
* mounted contexts; `'client'` is reserved for browser-only signals;
* `'server'` for events that only make sense in the request lifecycle.
*/
export const APP_EVENT_RUNTIME_BOTH = 'both';
export const APP_EVENT_RUNTIME_CLIENT = 'client';
export const APP_EVENT_RUNTIME_SERVER = 'server';
export const APP_EVENT_RUNTIMES_VALUES = [
APP_EVENT_RUNTIME_BOTH,
APP_EVENT_RUNTIME_CLIENT,
APP_EVENT_RUNTIME_SERVER
] as const;

@ -0,0 +1,115 @@
/**
* App-owned events.
*
* The only event whose owner is `arts/active-app` itself is
* `APP_EVENT_DISPOSE_STARTING`. Everything else used to be a
* republication of module-level events (session, connection, etc.) —
* those republications have been removed. Apps that need to react to
* facts owned by other modules subscribe to those modules' events
* directly via `App.bus.on(SESSION_EVENT_*, …)` or, more commonly,
* register an orca action via a preset.
*/
import type { BusPublishOptions, EventPublisher, EventSubscriber } from '$libs/bus';
import {
APP_EVENT_RUNTIME_BOTH,
APP_EVENT_RUNTIME_CLIENT,
APP_EVENT_SENSITIVE_KEY_PARTS
} from './consts.ts';
import { AappInvalidEventRuntimeError, AappUnsafeEventPayloadError } from './errors.ts';
/**
* The single App-owned event. Fires once at the start of `App.dispose()`
* before any service teardown begins, so subscribers can flush, persist
* or detach before their dependencies disappear.
*/
export const APP_EVENT_DISPOSE_STARTING = 'app.dispose.starting';
/**
* Where each `APP_EVENT_*` is allowed to fire. `'both'` is the default;
* `'client'` is reserved for facts that only exist in the browser.
*/
export type AppEventRuntime = typeof APP_EVENT_RUNTIME_BOTH | typeof APP_EVENT_RUNTIME_CLIENT;
export const APP_EVENT_RUNTIMES: Readonly<Record<string, AppEventRuntime>> = {
[APP_EVENT_DISPOSE_STARTING]: APP_EVENT_RUNTIME_BOTH
};
/**
* Throws `AappInvalidEventRuntimeError` if `type` is an `APP_EVENT_*`
* declared as client-only and the current runtime is the server (or
* vice-versa). Module events are not registered in `APP_EVENT_RUNTIMES`
* and are accepted unconditionally.
*/
export function assertEventCanFire(
type: string,
where: typeof APP_EVENT_RUNTIME_CLIENT | 'server'
): void {
const allowed = APP_EVENT_RUNTIMES[type];
if (allowed === undefined) return;
if (allowed === APP_EVENT_RUNTIME_BOTH) return;
if (allowed === where) return;
throw new AappInvalidEventRuntimeError(type, allowed, where);
}
export function assertAppEventPayloadSafe(type: string, payload: unknown): void {
const unsafePath = findSensitivePayloadPath(payload);
if (unsafePath !== undefined) throw new AappUnsafeEventPayloadError(type, unsafePath);
}
function findSensitivePayloadPath(value: unknown, path = '$'): string | undefined {
if (value === null || typeof value !== 'object') return undefined;
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index += 1) {
const unsafe = findSensitivePayloadPath(value[index], `${path}[${index}]`);
if (unsafe !== undefined) return unsafe;
}
return undefined;
}
for (const [key, child] of Object.entries(value)) {
const childPath = `${path}.${key}`;
if (isSensitivePayloadKey(key)) return childPath;
const unsafe = findSensitivePayloadPath(child, childPath);
if (unsafe !== undefined) return unsafe;
}
return undefined;
}
function isSensitivePayloadKey(key: string): boolean {
const normalized = key.toLowerCase();
return APP_EVENT_SENSITIVE_KEY_PARTS.some((part) => normalized.includes(part));
}
export interface AppDisposeStartingPayload {
readonly cause: string;
}
export interface AppEventMap {
[APP_EVENT_DISPOSE_STARTING]: AppDisposeStartingPayload;
}
/**
* Read-only contract for App's event bus from the consumer side.
*/
export type AppEventBus = EventSubscriber<AppEventMap>;
function currentRuntime(): typeof APP_EVENT_RUNTIME_CLIENT | 'server' {
return typeof window === 'undefined' ? 'server' : APP_EVENT_RUNTIME_CLIENT;
}
export function publishAppDisposeStarting(
bus: EventPublisher<AppEventMap>,
payload: AppDisposeStartingPayload,
options?: BusPublishOptions
) {
assertEventCanFire(APP_EVENT_DISPOSE_STARTING, currentRuntime());
assertAppEventPayloadSafe(APP_EVENT_DISPOSE_STARTING, payload);
return bus.publish(APP_EVENT_DISPOSE_STARTING, payload, options);
}
export function onAppDisposeStarting(
bus: AppEventBus,
listener: (payload: AppDisposeStartingPayload) => void | Promise<void>
) {
return bus.on(APP_EVENT_DISPOSE_STARTING, (envelope) => listener(envelope.payload));
}

@ -0,0 +1,41 @@
import { ORCA_ON_ERROR_CONTINUE, ORCA_STAGE_MAIN, orcaError, orcaSuccess } from '$orca';
import { SESSION_EVENT_IDENTITY_CHANGED } from '$session';
import type { ActiveCache } from '$cache/types';
import type { ActiveAppCore } from '../types.ts';
const ACTION_ID = 'cache.clear-on-identity-change';
const TOKEN_CLEARED = 'cache:cleared-on-identity';
export interface CacheClearOnIdentityChangeApp extends Pick<ActiveAppCore, 'orca'> {
readonly cache: Pick<ActiveCache, 'clear'>;
}
/**
* Registers an orca action that clears the active cache when the
* session's actor identity changes.
*
* Replaces the legacy auto-invalidation that used to live inside
* `arts/cache`. Listens to `SESSION_EVENT_IDENTITY_CHANGED` (the
* canonical event from `arts/session`).
*
* Returns a detach function. Calling it unregisters the action; the
* engine then stops reacting to the event.
*/
export function applyCacheClearOnIdentityChange(
App: CacheClearOnIdentityChangeApp
): () => void {
return App.orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, {
id: ACTION_ID,
stage: ORCA_STAGE_MAIN,
provides: [TOKEN_CLEARED],
onError: ORCA_ON_ERROR_CONTINUE,
action: async () => {
try {
await App.cache.clear();
return orcaSuccess({ emits: [TOKEN_CLEARED] });
} catch (error) {
return orcaError(error);
}
}
});
}

@ -0,0 +1,43 @@
import { ORCA_ON_ERROR_CONTINUE, ORCA_STAGE_MAIN, orcaError, orcaSuccess } from '$orca';
import { SESSION_EVENT_REVOKED } from '$session';
import type { ActiveCache } from '$cache/types';
import type { ActiveAppCore } from '../types.ts';
const ACTION_ID = 'cache.clear-on-revoke';
const TOKEN_CLEARED = 'cache:cleared-on-revoke';
/**
* Shape this preset requires from `App`. We only need `Orca` from the
* core and a `cache` service that exposes `clear()`. Defining the
* dependency this narrowly makes the preset usable from any App that
* declares a compatible cache, regardless of what other services it
* has.
*/
export interface CacheClearOnRevokeApp extends Pick<ActiveAppCore, 'orca'> {
readonly cache: Pick<ActiveCache, 'clear'>;
}
/**
* Registers an orca action that clears the active cache when the
* session is revoked. Pairs with `applyCacheClearOnIdentityChange` for
* apps where revoke is independent of identity change (e.g. logout
* without login of another actor).
*
* Returns a detach function. Calling it unregisters the action.
*/
export function applyCacheClearOnRevoke(App: CacheClearOnRevokeApp): () => void {
return App.orca.onEvent(SESSION_EVENT_REVOKED, {
id: ACTION_ID,
stage: ORCA_STAGE_MAIN,
provides: [TOKEN_CLEARED],
onError: ORCA_ON_ERROR_CONTINUE,
action: async () => {
try {
await App.cache.clear();
return orcaSuccess({ emits: [TOKEN_CLEARED] });
} catch (error) {
return orcaError(error);
}
}
});
}

@ -0,0 +1,42 @@
import { ORCA_ON_ERROR_CONTINUE, ORCA_STAGE_MAIN, orcaError, orcaSuccess } from '$orca';
import { SESSION_EVENT_REVOKED } from '$session';
import type { ActiveConnections } from '$connection/types';
import type { ActiveAppCore } from '../types.ts';
const ACTION_ID = 'connections.close-on-revoke';
const TOKEN_CLOSED = 'connections:closed-on-revoke';
const CLOSE_REASON = 'session-revoked';
/**
* Shape this preset requires from `App`. Only `closeAll()` is needed.
*/
export interface ConnectionsCloseOnRevokeApp extends Pick<ActiveAppCore, 'orca'> {
readonly connections: Pick<ActiveConnections, 'closeAll'>;
}
/**
* Registers an orca action that closes every active connection when
* the session is revoked. Used together with
* `applyCacheClearOnRevoke` to ensure that a logout / forced sign-out
* leaves no live socket carrying the revoked identity's credentials.
*
* Returns a detach function. Calling it unregisters the action.
*/
export function applyConnectionsCloseOnRevoke(
App: ConnectionsCloseOnRevokeApp
): () => void {
return App.orca.onEvent(SESSION_EVENT_REVOKED, {
id: ACTION_ID,
stage: ORCA_STAGE_MAIN,
provides: [TOKEN_CLOSED],
onError: ORCA_ON_ERROR_CONTINUE,
action: async () => {
try {
App.connections.closeAll(CLOSE_REASON);
return orcaSuccess({ emits: [TOKEN_CLOSED] });
} catch (error) {
return orcaError(error);
}
}
});
}

@ -0,0 +1,47 @@
import { ORCA_ON_ERROR_CONTINUE, ORCA_STAGE_MAIN, orcaError, orcaSuccess } from '$orca';
import { SESSION_EVENT_IDENTITY_CHANGED } from '$session';
import type { ActiveConnections } from '$connection/types';
import type { ActiveAppCore } from '../types.ts';
const ACTION_ID = 'connections.reauth-on-identity-change';
const TOKEN_REAUTHENTICATED = 'connections:reauthenticated-on-identity';
/**
* Shape this preset requires from `App`. Only the
* `reauthenticateAll()` method is actually invoked, so apps can
* inject any compatible adapter — no need to expose the full
* `ActiveConnections` surface.
*/
export interface ConnectionsReauthOnIdentityChangeApp extends Pick<ActiveAppCore, 'orca'> {
readonly connections: Pick<ActiveConnections, 'reauthenticateAll'>;
}
/**
* Registers an orca action that asks every active connection to
* reauthenticate when the session's actor identity changes. Pairs with
* `applyCacheClearOnIdentityChange` and
* `applyPermInvalidateOnIdentityChange` to flush stale state from the
* previous user before any new request flies — the canonical motivator
* scenario for orca: "chat connected with the previous user's
* credentials" can no longer happen with this preset registered.
*
* Returns a detach function. Calling it unregisters the action.
*/
export function applyConnectionsReauthOnIdentityChange(
App: ConnectionsReauthOnIdentityChangeApp
): () => void {
return App.orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, {
id: ACTION_ID,
stage: ORCA_STAGE_MAIN,
provides: [TOKEN_REAUTHENTICATED],
onError: ORCA_ON_ERROR_CONTINUE,
action: async () => {
try {
await App.connections.reauthenticateAll();
return orcaSuccess({ emits: [TOKEN_REAUTHENTICATED] });
} catch (error) {
return orcaError(error);
}
}
});
}

@ -0,0 +1,43 @@
/**
* Orchestration presets for `arts/active-app`. Each `apply*` function
* registers one or more orca actions on `App.orca` that react to
* canonical lifecycle events (`SESSION_EVENT_*`, future
* `CONNECTION_EVENT_*`, etc.) and call the imperative API of the
* affected service.
*
* **Presets live here, not inside the arts.** An art (`arts/cache`,
* `arts/perm`, etc.) does not know about `arts/session` or
* `arts/orca` — that knowledge belongs to the composition layer
* (`arts/active-app`). Putting the presets here keeps the inter-art
* dependency inversion clean.
*
* Each preset is opt-in: applications register only the orchestration
* they need. `applyStandardOrca(App)` is a convenience aggregator that
* registers every preset whose required services are declared in `App`.
*/
export {
applyCacheClearOnIdentityChange,
type CacheClearOnIdentityChangeApp
} from './cache-clear-on-identity-change.ts';
export {
applyCacheClearOnRevoke,
type CacheClearOnRevokeApp
} from './cache-clear-on-revoke.ts';
export {
applyConnectionsCloseOnRevoke,
type ConnectionsCloseOnRevokeApp
} from './connections-close-on-revoke.ts';
export {
applyConnectionsReauthOnIdentityChange,
type ConnectionsReauthOnIdentityChangeApp
} from './connections-reauth-on-identity-change.ts';
export {
applyPermInvalidateOnIdentityChange,
type PermInvalidateOnIdentityChangeApp
} from './perm-invalidate-on-identity-change.ts';
export {
applySessionAutoRefresh,
type SessionAutoRefreshApp
} from './session-auto-refresh.ts';
export { applyStandardOrca, type StandardOrcaApp } from './standard.ts';

@ -0,0 +1,37 @@
import { ORCA_ON_ERROR_CONTINUE, ORCA_STAGE_MAIN, orcaError, orcaSuccess } from '$orca';
import { SESSION_EVENT_IDENTITY_CHANGED } from '$session';
import type { ActivePerms } from '$perm/types';
import type { ActiveAppCore } from '../types.ts';
const ACTION_ID = 'perm.invalidate-on-identity-change';
const TOKEN_INVALIDATED = 'perm:invalidated-on-identity';
export interface PermInvalidateOnIdentityChangeApp extends Pick<ActiveAppCore, 'orca'> {
readonly perm: Pick<ActivePerms, 'invalidate'>;
}
/**
* Registers an orca action that invalidates the local permissions cache
* when the session's actor identity changes.
*
* Replaces the legacy auto-invalidation that used to live inside
* `arts/perm`. Listens to `SESSION_EVENT_IDENTITY_CHANGED` directly.
*/
export function applyPermInvalidateOnIdentityChange(
App: PermInvalidateOnIdentityChangeApp
): () => void {
return App.orca.onEvent(SESSION_EVENT_IDENTITY_CHANGED, {
id: ACTION_ID,
stage: ORCA_STAGE_MAIN,
provides: [TOKEN_INVALIDATED],
onError: ORCA_ON_ERROR_CONTINUE,
action: () => {
try {
App.perm.invalidate();
return orcaSuccess({ emits: [TOKEN_INVALIDATED] });
} catch (error) {
return orcaError(error);
}
}
});
}

@ -0,0 +1,40 @@
import { withAutoRefresh } from '$session';
import type {
ActiveSession,
AutoRefreshCleanup,
AutoRefreshOptions
} from '$session/types';
import type { ActiveAppCore } from '../types.ts';
/**
* Shape this preset requires from `App`. Only the `session` slot is
* needed; the core's `Timers` is consumed automatically so that the
* refresh ticker runs through `App.timers` (one clock for the whole
* ecosystem) instead of the host `setInterval` fallback baked into
* `withAutoRefresh`.
*/
export interface SessionAutoRefreshApp<TUser, TCredential = undefined, TData = undefined>
extends Pick<ActiveAppCore, 'timers'> {
readonly session: ActiveSession<TUser, TCredential, TData>;
}
/**
* Wraps `App.session` with `withAutoRefresh` and pre-fills the
* `timers` and `now` injectors from the App's core. Apps that need
* deterministic refresh in tests / replay can still override
* `random` (or `now`, `timers`) through `options`.
*
* Returns the same cleanup function `withAutoRefresh` returns —
* idempotent, safe to call from a `disposeStarting` event handler
* or a Svelte teardown.
*/
export function applySessionAutoRefresh<TUser, TCredential = undefined, TData = undefined>(
App: SessionAutoRefreshApp<TUser, TCredential, TData>,
options: AutoRefreshOptions = {}
): AutoRefreshCleanup {
return withAutoRefresh(App.session, {
...options,
timers: options.timers ?? App.timers,
now: options.now ?? (() => App.timers.clock.now())
});
}

@ -0,0 +1,55 @@
import type { ActiveCache } from '$cache/types';
import type { ActiveConnections } from '$connection/types';
import type { ActivePerms } from '$perm/types';
import type { ActiveAppCore } from '../types.ts';
import { applyCacheClearOnIdentityChange } from './cache-clear-on-identity-change.ts';
import { applyCacheClearOnRevoke } from './cache-clear-on-revoke.ts';
import { applyConnectionsCloseOnRevoke } from './connections-close-on-revoke.ts';
import { applyConnectionsReauthOnIdentityChange } from './connections-reauth-on-identity-change.ts';
import { applyPermInvalidateOnIdentityChange } from './perm-invalidate-on-identity-change.ts';
/**
* Optional shape passed to `applyStandardOrca`. Whatever services the
* application declares are picked up automatically; missing services
* are skipped. The shape is intentionally permissive — `Partial<>`
* pieces — so an App that only declares `cache` (without `perm`) gets
* cache-related presets and nothing else.
*/
export interface StandardOrcaApp extends Pick<ActiveAppCore, 'orca'> {
readonly cache?: Pick<ActiveCache, 'clear'>;
readonly perm?: Pick<ActivePerms, 'invalidate'>;
readonly connections?: Pick<ActiveConnections, 'reauthenticateAll' | 'closeAll'>;
}
/**
* Registers every standard preset whose required services are declared
* on `App`. Apps that want a tailored set of reactions can cherry-pick
* individual `apply*` functions instead.
*
* Returns a single detach function that unregisters everything in
* reverse order — convenient for tests and hot reloading.
*/
export function applyStandardOrca(App: StandardOrcaApp): () => void {
const detachers: Array<() => void> = [];
if (App.cache !== undefined) {
const cacheApp = App as StandardOrcaApp & { cache: NonNullable<StandardOrcaApp['cache']> };
detachers.push(applyCacheClearOnIdentityChange(cacheApp));
detachers.push(applyCacheClearOnRevoke(cacheApp));
}
if (App.perm !== undefined) {
const permApp = App as StandardOrcaApp & { perm: NonNullable<StandardOrcaApp['perm']> };
detachers.push(applyPermInvalidateOnIdentityChange(permApp));
}
if (App.connections !== undefined) {
const connApp = App as StandardOrcaApp & {
connections: NonNullable<StandardOrcaApp['connections']>;
};
detachers.push(applyConnectionsReauthOnIdentityChange(connApp));
detachers.push(applyConnectionsCloseOnRevoke(connApp));
}
return () => {
for (let i = detachers.length - 1; i >= 0; i--) detachers[i]();
};
}

@ -0,0 +1,224 @@
/**
* Runtime that turns an `AppServiceSchema` into a set of getters on the
* `App` object plus a `disposeAll()` that tears them down in reverse
* order.
*
* Responsibilities:
* - Validate the schema (key === factory.name).
* - Compute the topological order of service construction; detect
* cycles statically.
* - Build `immediate` services right away, in topological order.
* - Expose getters for every declared service. Reading
* `App.<serviceName>` triggers `lazy` construction the first time.
* - Track per-service `ServiceStatus` for introspection.
* - Dispose services in reverse construction order; errors during
* dispose are swallowed (to mirror the engine convention used
* elsewhere in the ecosystem).
*
* Errors during construction propagate to the caller. The status of the
* failing service is `'failed'` and stays that way; subsequent reads
* re-throw the original error wrapped in
* `AappServiceConstructionFailedError`.
*/
import { untrack } from 'svelte';
import {
AappServiceConstructionFailedError,
AappServiceDependencyCycleError,
AappServiceNameMismatchError
} from './errors.ts';
import type {
AppServiceFactory,
AppServiceSchema,
CoreServices,
ServiceStatus
} from './services.ts';
export interface ServiceBuilders {
/**
* Object with one accessor per declared service. Reading triggers
* lazy construction. The keys are exactly `Object.keys(schema)`.
*/
readonly proxies: Readonly<Record<string, unknown>>;
/**
* Snapshot of `{ [name]: ServiceStatus }`. Re-reading is cheap.
*/
statusMap(): Readonly<Record<string, ServiceStatus>>;
/**
* Tear down every constructed service in reverse construction order.
* Idempotent.
*/
disposeAll(): void;
}
export function buildServiceBuilders(
schema: AppServiceSchema,
core: CoreServices
): ServiceBuilders {
validateSchema(schema);
const order = topologicalOrder(schema);
const instances = new Map<string, unknown>();
const status = new Map<string, ServiceStatus>();
const failures = new Map<string, unknown>();
// Records construction sequence so dispose can run in reverse.
const constructionLog: string[] = [];
for (const name of order) status.set(name, 'absent');
// Build immediate services in topological order, before exposing the
// proxies. If any of them throws, subsequent immediates are skipped
// and the error propagates.
for (const name of order) {
const factory = schema[name];
if (factory.initMode === 'immediate') {
construct(name);
}
}
function construct(name: string): unknown {
const cached = instances.get(name);
if (cached !== undefined || status.get(name) === 'present') return cached;
// If a previous attempt failed, re-throw the original failure.
if (status.get(name) === 'failed') {
throw new AappServiceConstructionFailedError(name, { cause: failures.get(name) });
}
const factory = schema[name];
const coreSubset = pickCore(core, factory.coreDependencies);
const serviceSubset: Record<string, unknown> = {};
for (const dep of factory.serviceDependencies ?? []) {
if (schema[dep] !== undefined) {
serviceSubset[dep] = construct(dep);
}
// Missing dependency stays undefined; the factory chooses how to
// react.
}
try {
// Lazy services may be constructed inside a `$derived` or
// template expression. If the factory subscribes to a $state-
// backed listener set during `create()` (e.g. format wiring
// `lang.onLocaleChange`), Svelte rejects the mutation. Run
// construction inside `untrack` so subscriptions wired here
// do not propagate into the outer reactive scope.
const instance = untrack(() =>
factory.create({
core: coreSubset,
services: serviceSubset
})
);
instances.set(name, instance);
status.set(name, 'present');
constructionLog.push(name);
return instance;
} catch (cause) {
status.set(name, 'failed');
failures.set(name, cause);
throw new AappServiceConstructionFailedError(name, { cause });
}
}
const proxies: Record<string, unknown> = {};
for (const name of Object.keys(schema)) {
Object.defineProperty(proxies, name, {
configurable: false,
enumerable: true,
get() {
return construct(name);
}
});
}
let disposed = false;
function disposeAll(): void {
if (disposed) return;
disposed = true;
for (let i = constructionLog.length - 1; i >= 0; i--) {
const name = constructionLog[i];
const factory = schema[name];
const instance = instances.get(name);
if (instance === undefined) continue;
try {
factory.dispose?.(instance);
} catch {
// dispose errors are swallowed by ecosystem convention
}
}
instances.clear();
}
return {
proxies,
statusMap() {
return Object.fromEntries(status);
},
disposeAll
};
}
// ── Helpers ────────────────────────────────────────────────────────────
function pickCore<K extends keyof CoreServices>(
core: CoreServices,
keys: readonly K[]
): Pick<CoreServices, K> {
const result = {} as Pick<CoreServices, K>;
for (const k of keys) result[k] = core[k];
return result;
}
function validateSchema(schema: AppServiceSchema): void {
for (const [key, factory] of Object.entries(schema)) {
if (factory.name !== key) {
throw new AappServiceNameMismatchError(key, factory.name);
}
}
}
/**
* Depth-first topological sort with cycle detection. Returns names in
* construction order (dependencies before dependents). Throws
* `AappServiceDependencyCycleError` if a cycle is detected, with the
* cycle path captured for debugging.
*/
function topologicalOrder(schema: AppServiceSchema): string[] {
const visited = new Set<string>();
const visiting = new Set<string>();
const stack: string[] = [];
const order: string[] = [];
function visit(name: string): void {
if (visited.has(name)) return;
if (visiting.has(name)) {
const cycleStart = stack.indexOf(name);
const cycle = [...stack.slice(cycleStart), name];
throw new AappServiceDependencyCycleError(cycle);
}
visiting.add(name);
stack.push(name);
const factory = schema[name];
if (factory) {
for (const dep of factory.serviceDependencies ?? []) {
if (schema[dep] !== undefined) visit(dep);
}
}
stack.pop();
visiting.delete(name);
visited.add(name);
order.push(name);
}
for (const name of Object.keys(schema)) visit(name);
return order;
}
/** Exposed for tests only. */
export const _internalsForTesting = {
topologicalOrder,
validateSchema
};
export type AppServiceFactoryAny = AppServiceFactory;

@ -0,0 +1,33 @@
import { createActiveAuth } from '$auth/active-auth.svelte';
import type { ActiveAuth, ActiveAuthOptions } from '$auth/types';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineActiveAuth(options)` produces a service factory for the `auth`
* slot.
*
* Auth requires an `http` client supplied through `options` (the auth
* client only reflects server decisions — it cannot run without a
* backend). It receives `logger` from the core.
*
* Auto-orchestration with `cache` (invalidating cache on revoke etc.)
* is NOT wired here — that lives in `arts/active-app/presets/`.
*/
export function defineActiveAuth(
options: Omit<ActiveAuthOptions, 'logger'>
): AppServiceFactory<'auth', readonly ['logger'], readonly [], ActiveAuth> {
return {
name: 'auth',
coreDependencies: ['logger'],
initMode: 'lazy',
create({ core }): ActiveAuth {
return createActiveAuth({
...options,
logger: core.logger
});
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,34 @@
import { createActiveCache } from '$cache/active-cache.svelte';
import type { ActiveCache, ActiveCacheOptions } from '$cache/types';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineActiveCache(options)` produces a service factory for the
* `cache` slot.
*
* The cache art is a passive runtime: invalidation is driven from the
* outside via orca presets (e.g. `applyCacheClearOnIdentityChange` in
* `arts/active-app/presets/`). The factory wires `logger` and
* `clock` from the core — TTL evaluation and any other now-based
* math then flow through `App.timers.clock`, the same time source
* the rest of the ecosystem uses.
*/
export function defineActiveCache(
options: Omit<ActiveCacheOptions, 'logger'> = {}
): AppServiceFactory<'cache', readonly ['logger', 'timers'], readonly [], ActiveCache> {
return {
name: 'cache',
coreDependencies: ['logger', 'timers'],
initMode: 'lazy',
create({ core }): ActiveCache {
return createActiveCache({
...options,
logger: core.logger,
clock: options.clock ?? { now: () => core.timers.clock.now() }
});
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,40 @@
import { createActiveConnections } from '$connection/active-connections.svelte';
import type {
ActiveConnections,
ActiveConnectionsOptions
} from '$connection/types';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineActiveConnections(options)` produces a service factory for the
* `connections` slot.
*
* Requires `logger` and `timers` from the core. Identity tracking
* (formerly via the `bus-session-source` shim) is now expected to come
* from an orca preset, or from the application passing `session` in
* options manually.
*/
export function defineActiveConnections(
options: Omit<ActiveConnectionsOptions, 'logger' | 'timers'> = {}
): AppServiceFactory<
'connections',
readonly ['logger', 'timers'],
readonly [],
ActiveConnections
> {
return {
name: 'connections',
coreDependencies: ['logger', 'timers'],
initMode: 'lazy',
create({ core }): ActiveConnections {
return createActiveConnections({
...options,
logger: core.logger,
timers: core.timers
});
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,24 @@
import { createActiveDom } from '$adom/active-dom.svelte';
import type { ActiveDom, ActiveDomProps } from '$adom/active-dom.svelte';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineActiveDom(props)` produces a service factory for the `dom`
* slot. `arts/adom` reads `window` directly when present; on the server
* it stays inert.
*/
export function defineActiveDom(
props: ActiveDomProps = {}
): AppServiceFactory<'dom', readonly [], readonly [], ActiveDom> {
return {
name: 'dom',
coreDependencies: [],
initMode: 'lazy',
create(): ActiveDom {
return createActiveDom(props);
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,54 @@
import { createActiveFormat } from '$format/active-formats.svelte';
import type { ActiveFormat, ActiveFormatOptions } from '$format/active-formats.svelte';
import type { LocaleSource } from '$locale';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineActiveFormat(options)` produces a service factory for the
* `format` slot.
*
* Format runs entirely from a `LocaleSource`. Resolution priority:
*
* 1. `options.localeSource` (explicit override — escape hatch).
* 2. `App.prefs.locale` — the user's regional formatting locale,
* always present when the prefs schema declares a `locale`
* dimension (true for `standardPrefsDimensions(...)` callers).
*
* Apps that ship a custom prefs schema without a `locale` dimension
* must pass `options.localeSource` explicitly.
*/
export function defineActiveFormat(
options: ActiveFormatOptions = {}
): AppServiceFactory<'format', readonly ['timers', 'prefs'], readonly [], ActiveFormat> {
return {
name: 'format',
coreDependencies: ['timers', 'prefs'],
initMode: 'lazy',
create({ core }): ActiveFormat {
let localeSource: LocaleSource | undefined = options.localeSource;
if (localeSource === undefined) {
const localeDim = (core.prefs as unknown as Record<string, unknown>)['locale'] as
| {
get(): string;
onChange(handler: (value: string) => void): () => void;
}
| undefined;
if (localeDim !== undefined) {
localeSource = {
get: () => localeDim.get(),
onChange: (fn) => localeDim.onChange(fn)
};
}
}
return createActiveFormat({
...options,
localeSource,
clock: options.clock ?? core.timers.clock
});
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,118 @@
import { createActiveFrontend } from '$frontend/active-frontend.svelte';
import type { ActiveFrontend, ActiveFrontendOptions } from '$frontend/active-frontend.svelte';
import type { ActiveDom } from '$adom';
import type { LocaleSource } from '$locale';
import type { AppServiceFactory } from '../services.ts';
/**
* Active dimension shape accessed off `core.prefs.<key>`. Service
* factories stay defensive about which dimensions a given app declares
* — when a dimension is missing, the factory degrades gracefully.
*/
interface PrefsSlot<T> {
get(): T;
onChange(handler: (value: T) => void): () => void;
}
function readSlot<T>(prefs: unknown, key: string): PrefsSlot<T> | undefined {
const slot = (prefs as Record<string, unknown>)[key];
if (
slot !== null &&
typeof slot === 'object' &&
typeof (slot as { get?: unknown }).get === 'function' &&
typeof (slot as { onChange?: unknown }).onChange === 'function'
) {
return slot as PrefsSlot<T>;
}
return undefined;
}
/**
* `defineActiveFrontend(options)` produces a service factory for the
* `frontend` slot.
*
* Frontend integrates with `core.prefs` for theme / density / motion /
* direction (when those dimensions are declared in the prefs schema)
* and with `dom` when declared as a service. The locale source for
* `direction = auto` derivation comes from `core.prefs.language` —
* Frontend follows the writing system, which is a property of the
* *language*, not the regional formatting locale.
*
* Each integration is conditional on the dimension being present, so
* apps with custom prefs schemas don't break by omitting one.
*/
export function defineActiveFrontend(
options: ActiveFrontendOptions = {}
): AppServiceFactory<'frontend', readonly ['prefs'], readonly ['dom'], ActiveFrontend> {
const detachers: Array<() => void> = [];
return {
name: 'frontend',
coreDependencies: ['prefs'],
serviceDependencies: ['dom'],
initMode: 'lazy',
create({ core, services }): ActiveFrontend {
const dom = options.dom ?? (services.dom as ActiveDom | undefined);
const languageSlot = readSlot<string>(core.prefs, 'language');
let localeSource: LocaleSource | undefined = options.localeSource;
if (localeSource === undefined && languageSlot !== undefined) {
localeSource = {
get: () => languageSlot.get(),
onChange: (fn) => languageSlot.onChange(fn)
};
}
const frontend = createActiveFrontend({
...options,
dom,
localeSource
});
// Theme / density / motion / direction integrations are
// per-dimension — each only fires when its own value
// changes. `prefs.theme` (light|dark|system) maps to
// Frontend.MODE — Frontend's "theme" is a deeper UI variant
// name, "mode" is the light/dark scheme, and prefs's
// effective theme is exactly the latter.
const themeSlot = readSlot<'light' | 'dark'>(core.prefs, 'theme');
if (themeSlot !== undefined) {
frontend.setMode(themeSlot.get());
detachers.push(themeSlot.onChange((value) => frontend.setMode(value)));
}
const densitySlot = readSlot<string>(core.prefs, 'density');
if (densitySlot !== undefined) {
frontend.setDensity(densitySlot.get() as never);
detachers.push(
densitySlot.onChange((value) => frontend.setDensity(value as never))
);
}
const motionSlot = readSlot<'allow' | 'reduce'>(core.prefs, 'motion');
if (motionSlot !== undefined) {
frontend.setReducedMotion(motionSlot.get() === 'reduce');
detachers.push(
motionSlot.onChange((value) => frontend.setReducedMotion(value === 'reduce'))
);
}
const directionSlot = readSlot<'ltr' | 'rtl'>(core.prefs, 'direction');
if (directionSlot !== undefined) {
frontend.setDir(directionSlot.get());
detachers.push(directionSlot.onChange((value) => frontend.setDir(value)));
}
return frontend;
},
dispose(instance) {
for (const off of detachers.splice(0)) {
try {
off();
} catch {
// best-effort; teardown must not throw
}
}
instance.dispose();
}
};
}

@ -0,0 +1,32 @@
import { createEngineHttp } from '$http/engine-http';
import type { EngineHttp, EngineHttpOptions } from '$http/types';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineEngineHttp(options)` produces a service factory for the `http`
* slot. `arts/http` is engine-only (no Active wrapper); reactive
* consumers wrap responses themselves at the call site.
*
* Logger is wired from the core for diagnostics consistency. The
* `now()` clock is wired from `core.timers.clock`, so retry math and
* `Retry-After` arithmetic flow through the same time source the rest
* of the ecosystem uses. `random` and `setTimeout` keep their host
* defaults — App-level injection is opt-in for callers that need
* fully deterministic backoff (tests, replay).
*/
export function defineEngineHttp(
options: Omit<EngineHttpOptions, 'logger'> = {}
): AppServiceFactory<'http', readonly ['logger', 'timers'], readonly [], EngineHttp> {
return {
name: 'http',
coreDependencies: ['logger', 'timers'],
initMode: 'lazy',
create({ core }): EngineHttp {
return createEngineHttp({
...options,
logger: core.logger,
now: options.now ?? (() => core.timers.clock.now())
});
}
};
}

@ -0,0 +1,33 @@
/**
* Service factories for `arts/active-app`. Each `defineActiveX()` /
* `defineEngineX()` adapts an art's runtime factory to the
* `AppServiceFactory` shape consumed by `createActiveApp({ services })`.
*
* **The arts themselves know nothing about App.** Service factories live
* here, in `arts/active-app/`, because they import from arts (and
* sometimes from each other through the schema), and that crosses the
* "arts must not know about other arts" rule.
*
* For services that historically had `bus.on(APP_EVENT_*)` auto-
* subscriptions inside the art (cache, perm, connections), the factories
* intentionally do NOT enable those. The recommended path to react to
* lifecycle events is to register an orca preset from
* `arts/active-app/presets/`.
*/
export { defineActiveAuth } from './auth.ts';
export { defineActiveCache } from './cache.ts';
export { defineActiveConnections } from './connections.ts';
export { defineActiveDom } from './dom.ts';
export { defineActiveFormat } from './format.ts';
export { defineActiveFrontend } from './frontend.ts';
export { defineActiveLang, type DefineActiveLangOptions } from './lang.ts';
export { defineActivePerm } from './perm.ts';
// `prefs` is part of the core (see `arts/active-app/services.ts` →
// `CoreServices.prefs`). It does not have a service-factory because
// every App ALWAYS has it; configure it via `createActiveApp({ prefs:
// { ... } })`.
export { defineActiveSession } from './session.ts';
export { defineActiveStorage } from './storage.ts';
export { defineEngineHttp } from './http.ts';
export { defineEngineSium } from './sium.ts';

@ -0,0 +1,64 @@
import { createActiveLang } from '$lang/active-lang.svelte';
import type { ActiveLang } from '$lang';
import type { LangNode, SupportedLocale } from '$libs/lang';
import type { AppServiceFactory } from '../services.ts';
/**
* Options for `defineActiveLang`. Wraps the positional arguments of
* `createActiveLang(schema, defaultLocale, fallbackChain)` in an
* options object that fits the service-schema shape.
*/
export interface DefineActiveLangOptions<S extends LangNode> {
readonly schema: S;
readonly defaultLocale?: SupportedLocale;
readonly fallbackChain?: readonly SupportedLocale[];
}
/**
* `defineActiveLang({ schema, defaultLocale?, fallbackChain? })` produces
* a service factory for the `lang` slot. The schema generic flows
* through to `App.lang` so `t('a.b.c')` keeps end-to-end type safety.
*
* Lang follows `App.prefs.language` automatically — it reads the
* dimension's current value at construction time, then subscribes via
* `language.onChange(...)` to forward future changes through
* `lang.setLocale(...)`. Apps that compose their prefs schema without a
* `language` dimension still get a working `lang` (it falls back to the
* configured `defaultLocale`); the contract is "if you want lang to
* track user intent, declare a `language` dimension in prefs".
*/
export function defineActiveLang<S extends LangNode>(
options: DefineActiveLangOptions<S>
): AppServiceFactory<'lang', readonly ['logger', 'prefs'], readonly [], ActiveLang<S>> {
let unsubscribe: (() => void) | undefined;
return {
name: 'lang',
coreDependencies: ['logger', 'prefs'],
initMode: 'lazy',
create({ core }): ActiveLang<S> {
const lang = createActiveLang<S>(
options.schema,
options.defaultLocale ?? 'es',
options.fallbackChain ? [...options.fallbackChain] : undefined
);
lang.setLogger(core.logger);
const languageDim = (core.prefs as unknown as Record<string, unknown>)['language'] as
| { get(): string; onChange(handler: (value: string) => void): () => void }
| undefined;
if (languageDim !== undefined) {
lang.setLocale(languageDim.get() as SupportedLocale);
unsubscribe = languageDim.onChange((next) => {
lang.setLocale(next as SupportedLocale);
});
}
return lang;
},
dispose(instance) {
unsubscribe?.();
unsubscribe = undefined;
instance.dispose();
}
};
}

@ -0,0 +1,48 @@
import { createActivePerms } from '$perm/active-permissions.svelte';
import type { EngineHttp } from '$http';
import type { ActivePerms, ActivePermsOptions } from '$perm/types';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineActivePerm(options)` produces a service factory for the `perm`
* slot.
*
* Auto-invalidation is OFF by default — same reasoning as
* `defineActiveCache`. Use the orca preset
* `applyPermInvalidateOnIdentityChange` to react to identity changes.
*
* The factory wires `logger` and `clock` from the core, so decision
* cache TTL math runs on `App.timers.clock`. If `App` declares an `http`
* service, this factory also wires `App.http` as the perm client's
* `http` transport so retry/timeout/auth hooks composed at the App
* level apply uniformly. Apps that prefer their own transport can pass
* `http` or `fetcher` explicitly in `options` — those win over the
* App-level default.
*
* The application still needs to provide `endpoint` — the perm client
* cannot work without a backend.
*/
export function defineActivePerm(
options: Omit<ActivePermsOptions, 'logger'>
): AppServiceFactory<'perm', readonly ['logger', 'timers'], readonly ['http'], ActivePerms> {
return {
name: 'perm',
coreDependencies: ['logger', 'timers'],
serviceDependencies: ['http'],
initMode: 'lazy',
create({ core, services }): ActivePerms {
const httpFromApp = services.http as EngineHttp | undefined;
return createActivePerms({
...options,
logger: core.logger,
clock: options.clock ?? { now: () => core.timers.clock.now() },
// Caller-provided `http`/`fetcher` win; otherwise inherit
// `App.http` when declared.
http: options.http ?? (options.fetcher === undefined ? httpFromApp : undefined)
});
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,37 @@
import { createActiveSession } from '$session/active-session.svelte';
import type { ActiveSession, EngineSessionOptions } from '$session/types';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineActiveSession<TUser, TCredential?, TData?>(options)` produces a
* service factory for the `session` slot.
*
* Session publishes its own `SESSION_EVENT_*` events on `core.bus`.
* Orca presets in `arts/active-app/presets/` translate those into
* reactions across other services without `arts/session` having to know
* who listens.
*/
export function defineActiveSession<TUser, TCredential = undefined, TData = undefined>(
options: Omit<EngineSessionOptions<TUser, TCredential, TData>, 'logger' | 'bus'>
): AppServiceFactory<
'session',
readonly ['logger', 'bus'],
readonly [],
ActiveSession<TUser, TCredential, TData>
> {
return {
name: 'session',
coreDependencies: ['logger', 'bus'],
initMode: 'lazy',
create({ core }): ActiveSession<TUser, TCredential, TData> {
return createActiveSession<TUser, TCredential, TData>({
...(options as EngineSessionOptions<TUser, TCredential, TData>),
logger: core.logger,
bus: core.bus
});
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,38 @@
import {
createEngineSium,
type EngineSium,
type EngineSiumOptions
} from '$sium/engine-sium';
import type { EngineLang } from '$lang';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineEngineSium(options)` produces a service factory for the `sium`
* slot.
*
* Sium is a validation engine. It takes `logger` from the core and, if
* the application declares `lang` in its schema, wires it through so
* issue messages can be translated. Without `lang`, Sium falls back to
* English literals.
*/
export function defineEngineSium(
options: Omit<EngineSiumOptions, 'logger' | 'lang'> = {}
): AppServiceFactory<'sium', readonly ['logger'], readonly ['lang'], EngineSium> {
return {
name: 'sium',
coreDependencies: ['logger'],
serviceDependencies: ['lang'],
initMode: 'lazy',
create({ core, services }): EngineSium {
// `ActiveLang` extends `EngineLang` structurally but TS sees the
// `t()` signatures as distinct (optional vs required params), so
// cross via `unknown` to keep the contract loose at the boundary.
const lang = services.lang as unknown as EngineLang | undefined;
return createEngineSium({
...options,
logger: core.logger,
lang
});
}
};
}

@ -0,0 +1,29 @@
import { createActiveStorage } from '$storage/active-storage.svelte';
import type { ActiveStorage, EngineStorageOptions } from '$storage/types';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineActiveStorage(options)` produces a service factory for the
* `storage` slot. Wires `logger` and `clock` from the core so
* envelope TTL math runs through `App.timers.clock` — the same
* time source the rest of the ecosystem uses.
*/
export function defineActiveStorage(
options: Omit<EngineStorageOptions, 'logger'> = {}
): AppServiceFactory<'storage', readonly ['logger', 'timers'], readonly [], ActiveStorage> {
return {
name: 'storage',
coreDependencies: ['logger', 'timers'],
initMode: 'lazy',
create({ core }): ActiveStorage {
return createActiveStorage({
...options,
logger: core.logger,
clock: options.clock ?? { now: () => core.timers.clock.now() }
});
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,139 @@
/**
* Service-schema contract for `arts/active-app`.
*
* `aapp` is built on top of two layers:
*
* - **Core** — fixed runtime infrastructure that always exists:
* `logger`, `bus`, `timers`, `orca` and `prefs`. Configurable via
* the `ActiveAppOptions` root, never declared as a service. The
* core surface is exposed in lowercase on the App
* (`App.logger`, `App.bus`, `App.prefs`, …) — the same convention
* services use, because the asymmetric "PascalCase for core" rule
* was decorative and went against JS property convention.
*
* - **Services** — opt-in runtime pieces that the application declares
* in `services: { … }`. If a service is not declared, it does not
* exist on the App, and TypeScript reports an error when the
* consumer tries to access it.
*
* Each service is built from an `AppServiceFactory` produced by a
* `defineActive*` / `defineEngine*` helper that lives in
* `arts/active-app/service-factories/`. The arts themselves stay pure —
* they do not know they are wired into a service.
*/
import type { EngineBus } from '$bus';
import type { EngineLogger } from '$logger';
import type { EngineOrca } from '$orca';
import type { ActivePrefs } from '$prefs';
import type { PrefsSchema } from '$libs/prefs';
import type { ActiveTimers } from '$timer';
// ── Core ────────────────────────────────────────────────────────────────
/**
* The five pieces of the core. Always built before any service. A factory
* may declare a subset of these as `coreDependencies`; the builder
* supplies only the declared keys to `create()`.
*
* `prefs` is generic over the user-defined `PrefsSchema`. Service
* factories that declare `coreDependencies: ['prefs']` see
* `core.prefs` typed as `ActivePrefs` (open) — they read dimensions
* defensively or document their schema requirements (e.g. "requires
* a `language` dimension").
*/
export interface CoreServices<S extends PrefsSchema = PrefsSchema> {
readonly logger: EngineLogger;
readonly bus: EngineBus;
readonly timers: ActiveTimers;
readonly orca: EngineOrca;
readonly prefs: ActivePrefs<S>;
}
export type CoreServiceKey = keyof CoreServices;
// ── Service lifecycle ───────────────────────────────────────────────────
/**
* Construction policy for a service.
*
* `lazy` (default) — built on first access via `App.<name>`. Suitable for
* services that may never be used in some flows.
*
* `immediate` — built during `createActiveApp()` after the core is up.
* Suitable for services with construction-time side effects (subscribing
* to BroadcastChannel, hydrating from storage on boot, etc.).
*/
export type ServiceInitMode = 'immediate' | 'lazy';
/**
* Observable state of a service. The builder exposes a snapshot of
* `{ [name]: ServiceStatus }` via `App.services`. Useful for devtools
* and tests; the application itself rarely reads this.
*/
export type ServiceStatus = 'absent' | 'present' | 'failed';
// ── Factory ─────────────────────────────────────────────────────────────
/**
* Factory contract for a service. Each art that participates in App is
* adapted to this contract by a `defineActive*` / `defineEngine*` helper
* in `arts/active-app/service-factories/`.
*
* Generics:
* - `TName` — string literal name, must match the schema key.
* - `TCoreDeps` — subset of `CoreServiceKey` the service consumes.
* - `TServiceDeps` — keys of OTHER services the service depends on.
* Resolved against the schema; if a declared dependency is not in the
* schema, the slot is `undefined` at `create()` time. The factory
* decides whether to error or degrade.
* - `TInstance` — type of the constructed instance.
*/
export interface AppServiceFactory<
TName extends string = string,
TCoreDeps extends readonly CoreServiceKey[] = readonly CoreServiceKey[],
TServiceDeps extends readonly string[] = readonly string[],
TInstance = unknown
> {
readonly name: TName;
readonly coreDependencies: TCoreDeps;
readonly serviceDependencies?: TServiceDeps;
readonly initMode?: ServiceInitMode;
create(deps: {
readonly core: Pick<CoreServices, TCoreDeps[number]>;
readonly services: Partial<Record<TServiceDeps[number], unknown>>;
}): TInstance;
dispose?(instance: TInstance): void;
}
// ── Schema ──────────────────────────────────────────────────────────────
/**
* A schema is a record `{ [name]: AppServiceFactory }`. The key MUST equal
* `factory.name`; the builder validates this at construction time.
*
* Note on `any`: the wider `unknown` does not preserve the structural
* assignability we need for the factory-shape pattern. Concrete factories
* (returned by `defineActive*`) keep precise types; this widened alias is
* only used at the schema-of-schemas boundary.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AppServiceSchema = Record<string, AppServiceFactory<string, any, any, unknown>>;
/**
* Resolves the instance shape from a schema. Used by `ActiveApp<TSchema>`
* so `App.cache` is typed as `ActiveCache` when `services.cache` is
* declared, and `never` (i.e. compile error on access) when it is not.
*/
export type ResolveServiceInstances<TSchema extends AppServiceSchema> = {
readonly [K in keyof TSchema]: TSchema[K] extends AppServiceFactory<
string,
readonly CoreServiceKey[],
readonly string[],
infer I
>
? I
: never;
};

@ -0,0 +1,255 @@
/**
* Compound ecosystem test: cross-actor data isolation. The audit's
* P1 transversal finding said the unit tests pass per module but
* nothing proves the canonical scenario:
*
* "User A logs in, caches private data; user A logs out, user B
* logs in; B doesn't see anything that belonged to A."
*
* Wires real `createActiveCache` + `createEngineSession` + a
* behaviour-only `perm` double + the `applyStandardOrca` preset, then
* drives the realistic flow `adopt(A) → revoke → adopt(B)` to confirm
* the cache/perm/connection reactions fire on the canonical lifecycle
* transitions (`identity.changed` whenever the session state moves
* between `none/anonymous/identified`, `revoked` on logout).
*
* NOTE — `SESSION_EVENT_IDENTITY_CHANGED` only fires when the session
* **identity state** transitions (none ↔ anonymous ↔ identified). It
* does *not* fire for in-place `adopt(A) → adopt(B)` between two
* identified users; the framework's contract is that "switching user"
* always goes through a logout. Tests below model exactly that.
*
* `connections` is exercised through the same preset to confirm
* `reauthenticateAll()` / `closeAll()` run; we don't open real
* sockets — a stub is enough.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { applyStandardOrca } from '../presets/index.ts';
import { createActiveCache } from '$cache/active-cache.svelte';
import { createEngineSession } from '$session';
import { createSvelteEngineBus, type EngineBus } from '$bus';
import { createEngineLogger, type EngineLogger } from '$logger';
import { createEngineOrca, type EngineOrca } from '$orca';
import { createActiveTimers, type ActiveTimers } from '$timer/active-timers.svelte';
import {
SESSION_EVENT_IDENTITY_CHANGED,
SESSION_EVENT_REVOKED
} from '$session';
import type { ActiveCache } from '$cache/types';
import type { ActiveConnections } from '$connection/types';
import type { ActivePerms } from '$perm/types';
import type { ActiveSession, Session } from '$session/types';
interface User {
readonly id: string;
}
interface FakeAppCore {
logger: EngineLogger;
bus: EngineBus<Record<string, unknown>>;
timers: ActiveTimers;
orca: EngineOrca;
cache: ActiveCache;
perm: ActivePerms;
connections: ActiveConnections;
session: ActiveSession<User>;
dispose(): void;
}
function buildApp(): FakeAppCore {
const Logger = createEngineLogger({});
const Timers = createActiveTimers({ logger: Logger });
const Bus = createSvelteEngineBus<Record<string, unknown>>({
logger: Logger,
clock: Timers.clock
});
const Orca = createEngineOrca({ bus: Bus, timers: Timers, logger: Logger });
const cache = createActiveCache({ logger: Logger, clock: { now: () => Timers.clock.now() } });
// Stub `perm`: in-memory map keyed by `actorId`. `invalidate()` clears
// it; `check(action)` reads the current snapshot. The realistic
// permission engine is exercised by `arts/perm` tests; here we only
// need observable state to prove cross-actor isolation.
const permState = { actorId: null as string | null };
const perm = {
invalidate: vi.fn(() => {
permState.actorId = null;
}),
__currentActor: () => permState.actorId,
__seedActor: (id: string) => {
permState.actorId = id;
}
} as unknown as ActivePerms & {
__currentActor: () => string | null;
__seedActor: (id: string) => void;
};
const connections = {
reauthenticateAll: vi.fn(async () => []),
closeAll: vi.fn()
} as unknown as ActiveConnections;
const session = createEngineSession<User>({
logger: Logger,
bus: Bus
}) as unknown as ActiveSession<User>;
return {
logger: Logger,
bus: Bus,
timers: Timers,
orca: Orca,
cache,
perm,
connections,
session,
dispose() {
Orca.dispose();
Bus.dispose();
Timers.dispose();
Logger.dispose();
}
};
}
// Drain microtasks + setTimeout(0) until orca reports idle. Bus events
// schedule orca runs through `queueMicrotask`/`drainQueue`, and those runs
// chain async work (cache.clear, perm.invalidate, …). Waiting on a fixed
// number of rounds is flaky; this loop polls the engine's own `running`
// flag, capped at `maxRounds` so a stuck run can't hang the test.
async function flush(orca: EngineOrca, maxRounds = 50): Promise<void> {
for (let i = 0; i < maxRounds; i++) {
await new Promise((r) => queueMicrotask(() => r(undefined)));
await new Promise((r) => setTimeout(r, 0));
if (!orca.running) {
// One more round so a freshly enqueued downstream run gets a
// chance to start before we read state.
await new Promise((r) => queueMicrotask(() => r(undefined)));
await new Promise((r) => setTimeout(r, 0));
if (!orca.running) return;
}
}
}
const NOW = 1_700_000_000_000;
const ONE_HOUR = 60 * 60 * 1000;
function sessionFor(user: User, expiresInMs = ONE_HOUR): Session<User> {
return { user, issuedAt: NOW, expiresAt: NOW + expiresInMs };
}
describe('ecosystem — cross-actor isolation', () => {
let app: FakeAppCore;
let detachOrca: () => void;
beforeEach(() => {
app = buildApp();
detachOrca = applyStandardOrca(app);
});
afterEach(() => {
detachOrca();
app.dispose();
});
it('logout → re-login: B sees no cache or perm state from A', async () => {
// User A logs in. Drain orca reactions to the initial null → A
// transition before we mutate the cache.
await app.session.adopt(sessionFor({ id: 'user-A' }));
await flush(app.orca);
(app.perm as ActivePerms & { __seedActor: (id: string) => void }).__seedActor('user-A');
// Cache something private for A.
await app.cache.set(['user-A:profile'], { name: 'Ana' }, { scope: 'public' });
expect(await app.cache.get(['user-A:profile'], { scope: 'public' })).toEqual({ name: 'Ana' });
const permApi = app.perm as ActivePerms & {
__currentActor: () => string | null;
};
expect(permApi.__currentActor()).toBe('user-A');
// Logout: identity transitions identified → none. Fires
// SESSION_EVENT_IDENTITY_CHANGED (cache.clear, perm.invalidate,
// connections.reauth) plus SESSION_EVENT_REVOKED (closeAll).
await app.session.revoke();
await flush(app.orca);
expect(permApi.__currentActor()).toBeNull();
expect(await app.cache.get(['user-A:profile'], { scope: 'public' })).toBeUndefined();
expect(app.connections.closeAll).toHaveBeenCalled();
// User B logs in. None → identified fires identity-changed again.
// Even without re-asserting cleanup, the previous step proved the
// invariant: nothing belonging to A survives into B's session.
await app.session.adopt(sessionFor({ id: 'user-B' }));
await flush(app.orca);
expect(await app.cache.get(['user-A:profile'], { scope: 'public' })).toBeUndefined();
});
it('revoke clears cache and closes connections', async () => {
await app.session.adopt(sessionFor({ id: 'user-A' }));
await flush(app.orca);
await app.cache.set(['user-A:doc'], { title: 'Privado' }, { scope: 'public' });
// Revoke the session. The session art emits `SESSION_EVENT_REVOKED`
// (which the orca preset wires to `connections.closeAll()`) plus
// `SESSION_EVENT_IDENTITY_CHANGED` (user-A → null) which clears the
// cache via the same identity-change reaction.
await app.session.revoke();
await flush(app.orca);
expect(await app.cache.get(['user-A:doc'], { scope: 'public' })).toBeUndefined();
expect(app.connections.closeAll).toHaveBeenCalled();
});
it('reauthenticateAll fires only on identity-state transitions', async () => {
// First adopt: none → identified → reauth fires once.
await app.session.adopt(sessionFor({ id: 'user-A' }));
await flush(app.orca);
expect(app.connections.reauthenticateAll).toHaveBeenCalledTimes(1);
// adopt(user-B) on top of an active identified session does NOT
// transition the identity state (still `identified`), so no
// extra reauth — that is the framework's documented contract.
await app.session.adopt(sessionFor({ id: 'user-B' }));
await flush(app.orca);
expect(app.connections.reauthenticateAll).toHaveBeenCalledTimes(1);
// Logout + re-login: identified → none → identified counts as two
// transitions, so reauth fires twice more (3 total).
await app.session.revoke();
await flush(app.orca);
await app.session.adopt(sessionFor({ id: 'user-C' }));
await flush(app.orca);
expect(app.connections.reauthenticateAll).toHaveBeenCalledTimes(3);
});
it('detaching the preset stops cross-actor reactions', async () => {
// Initial adopt + revoke runs the reactions (cache cleared on
// identity-state transition).
await app.session.adopt(sessionFor({ id: 'user-A' }));
await flush(app.orca);
await app.cache.set(['user-A:doc'], { title: 'doc' }, { scope: 'public' });
await app.session.revoke();
await flush(app.orca);
expect(await app.cache.get(['user-A:doc'], { scope: 'public' })).toBeUndefined();
// Detach. The next identity change should leave cache untouched.
detachOrca();
await app.cache.set(['user-A:doc'], { title: 'doc-2' }, { scope: 'public' });
await app.session.adopt(sessionFor({ id: 'user-C' }));
await flush(app.orca);
expect(await app.cache.get(['user-A:doc'], { scope: 'public' })).toEqual({ title: 'doc-2' });
// Re-attach so the afterEach detacher matches what's wired.
detachOrca = applyStandardOrca(app);
});
});
// Sanity export of `SESSION_EVENT_*` to keep the imports honest if
// the file is ever pruned by an unused-import rule. Not part of the
// behavioural contract.
void SESSION_EVENT_IDENTITY_CHANGED;
void SESSION_EVENT_REVOKED;

@ -0,0 +1,248 @@
/**
* End-to-end ecosystem test that validates the canonical "user A → user
* B" identity-change scenario that motivated `arts/orca`. With
* `applyStandardOrca` registered, publishing
* `SESSION_EVENT_IDENTITY_CHANGED` on the bus should:
*
* 1. clear the actor-scoped cache,
* 2. invalidate permission decisions,
* 3. reauthenticate every active connection.
*
* Equivalent flow on `SESSION_EVENT_REVOKED`:
*
* 1. clear cache,
* 2. close every active connection.
*
* The fakes replace each art with a behaviour-only double that just
* counts calls and captures arguments — the test is about cross-art
* orchestration, not the internals of any single art.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { applyStandardOrca } from '../presets/index.ts';
import { createSvelteEngineBus, type EngineBus } from '$bus';
import { createEngineLogger, type EngineLogger } from '$logger';
import { createEngineOrca, type EngineOrca } from '$orca';
import { createActiveTimers, type ActiveTimers } from '$timer/active-timers.svelte';
import { SESSION_EVENT_IDENTITY_CHANGED, SESSION_EVENT_REVOKED } from '$session';
import type { ActiveCache } from '$cache/types';
import type { ActiveConnections } from '$connection/types';
import type { ActivePerms } from '$perm/types';
interface FakeAppCore {
logger: EngineLogger;
bus: EngineBus<Record<string, unknown>>;
timers: ActiveTimers;
orca: EngineOrca;
}
function buildCore(): FakeAppCore {
const logger = createEngineLogger({});
const timers = createActiveTimers({ logger });
const bus = createSvelteEngineBus<Record<string, unknown>>({ logger, clock: timers.clock });
const orca = createEngineOrca({ bus, timers, logger });
return { logger, bus, timers, orca };
}
function disposeCore(core: FakeAppCore): void {
core.orca.dispose();
core.bus.dispose();
core.timers.dispose();
core.logger.dispose();
}
async function flush(rounds = 4): Promise<void> {
for (let i = 0; i < rounds; i++) {
await new Promise((r) => queueMicrotask(() => r(undefined)));
await new Promise((r) => setTimeout(r, 0));
}
}
const userA = {
event: 'session.lifecycle.adopted',
generation: 1,
identity: { from: 'anon', to: 'user-A' },
previousActorId: 'anon',
nextActorId: 'user-A'
};
const userB = {
event: 'session.lifecycle.adopted',
generation: 2,
identity: { from: 'user-A', to: 'user-B' },
previousActorId: 'user-A',
nextActorId: 'user-B'
};
const revokedB = {
event: 'session.lifecycle.revoked',
generation: 3,
identity: { from: 'user-B', to: null },
previousActorId: 'user-B',
nextActorId: null
};
describe('ecosystem orca — user A → user B switch', () => {
let core: FakeAppCore;
beforeEach(() => {
core = buildCore();
});
afterEach(() => {
disposeCore(core);
});
it('runs cache clear, perm invalidate and connection reauth in one orca trace', async () => {
const order: string[] = [];
const cacheClear = vi.fn(async () => {
order.push('cache.clear');
});
const permInvalidate = vi.fn(() => {
order.push('perm.invalidate');
});
const connectionsReauth = vi.fn(async () => {
order.push('connections.reauthenticateAll');
return [];
});
const connectionsClose = vi.fn(() => {
order.push('connections.closeAll');
});
const cache = { clear: cacheClear } as unknown as ActiveCache;
const perm = { invalidate: permInvalidate } as unknown as ActivePerms;
const connections = {
reauthenticateAll: connectionsReauth,
closeAll: connectionsClose
} as unknown as ActiveConnections;
applyStandardOrca({ orca: core.orca, cache, perm, connections });
// User A logs in. No identity change yet (anon → A is the first
// adoption); the preset only listens to IDENTITY_CHANGED, so we
// exercise it explicitly with userA → userB below. The userA
// publish here is for symmetry / realism.
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, userA);
await flush();
// At this point all three reactions have fired once for the A
// adoption.
expect(cacheClear).toHaveBeenCalledTimes(1);
expect(permInvalidate).toHaveBeenCalledTimes(1);
expect(connectionsReauth).toHaveBeenCalledTimes(1);
// Now switch to user B.
order.length = 0;
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, userB);
await flush();
expect(cacheClear).toHaveBeenCalledTimes(2);
expect(permInvalidate).toHaveBeenCalledTimes(2);
expect(connectionsReauth).toHaveBeenCalledTimes(2);
expect(connectionsClose).not.toHaveBeenCalled();
// All three actions belong to the same orca run on
// SESSION_EVENT_IDENTITY_CHANGED — one event, one trace, three
// actions sharing a runId.
const runs = core.orca.recentRuns();
const lastSwitchRun = runs[runs.length - 1];
const actionIds = lastSwitchRun.actions.map((a) => a.id).sort();
expect(actionIds).toEqual([
'cache.clear-on-identity-change',
'connections.reauth-on-identity-change',
'perm.invalidate-on-identity-change'
]);
expect(lastSwitchRun.actions.every((a) => a.status === 'success')).toBe(true);
// Run-level token bag carries every preset's `provides` token.
expect(lastSwitchRun.tokens.sort()).toEqual([
'cache:cleared-on-identity',
'connections:reauthenticated-on-identity',
'perm:invalidated-on-identity'
]);
});
it('runs cache clear and connections close on revoke; reauth is not called', async () => {
const cacheClear = vi.fn(async () => {});
const permInvalidate = vi.fn();
const connectionsReauth = vi.fn(async () => []);
const connectionsClose = vi.fn();
const cache = { clear: cacheClear } as unknown as ActiveCache;
const perm = { invalidate: permInvalidate } as unknown as ActivePerms;
const connections = {
reauthenticateAll: connectionsReauth,
closeAll: connectionsClose
} as unknown as ActiveConnections;
applyStandardOrca({ orca: core.orca, cache, perm, connections });
core.bus.publish(SESSION_EVENT_REVOKED, revokedB);
await flush();
// Revoke triggers the cache-clear-on-revoke and
// connections-close-on-revoke actions. Identity-change actions do
// NOT fire (they listen to a different event).
expect(cacheClear).toHaveBeenCalledTimes(1);
expect(connectionsClose).toHaveBeenCalledTimes(1);
expect(connectionsClose).toHaveBeenCalledWith('session-revoked');
expect(permInvalidate).not.toHaveBeenCalled();
expect(connectionsReauth).not.toHaveBeenCalled();
});
it('records action errors per-run when one art fails, without aborting the others', async () => {
const cacheError = new Error('cache adapter offline');
const cache = {
clear: vi.fn(async () => {
throw cacheError;
})
} as unknown as ActiveCache;
const perm = { invalidate: vi.fn() } as unknown as ActivePerms;
const connections = {
reauthenticateAll: vi.fn(async () => []),
closeAll: vi.fn()
} as unknown as ActiveConnections;
applyStandardOrca({ orca: core.orca, cache, perm, connections });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, userB);
await flush();
// Each preset registers `onError: 'continue'`, so the cache failure
// must NOT prevent perm and connection reactions from running.
expect((perm.invalidate as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(1);
expect(
(connections.reauthenticateAll as ReturnType<typeof vi.fn>)
).toHaveBeenCalledTimes(1);
const run = core.orca.recentRuns()[0];
const cacheAction = run.actions.find((a) => a.id === 'cache.clear-on-identity-change');
expect(cacheAction?.status).toBe('error');
expect(cacheAction?.error).toBe(cacheError);
expect(run.status).toBe('partial');
});
it('detacher unregisters every preset so subsequent events are inert', async () => {
const cache = { clear: vi.fn(async () => {}) } as unknown as ActiveCache;
const perm = { invalidate: vi.fn() } as unknown as ActivePerms;
const connections = {
reauthenticateAll: vi.fn(async () => []),
closeAll: vi.fn()
} as unknown as ActiveConnections;
const detach = applyStandardOrca({ orca: core.orca, cache, perm, connections });
detach();
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, userB);
core.bus.publish(SESSION_EVENT_REVOKED, revokedB);
await flush();
expect((cache.clear as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
expect((perm.invalidate as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
expect(
(connections.reauthenticateAll as ReturnType<typeof vi.fn>)
).not.toHaveBeenCalled();
expect((connections.closeAll as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
expect(core.orca.recentRuns()).toEqual([]);
});
});

@ -0,0 +1,156 @@
/**
* Verifies the cross-cutting wiring done by the consumer factories
* (`defineActiveLang`, `defineActiveFormat`, `defineActiveFrontend`):
* because `prefs` is part of the core, those consumers always source
* their locale / language / theme from the prefs engine — the wiring
* is not conditional on a service declaration.
*
* The "App-as-a-whole" lifecycle (storage bridge attached at root,
* disposed before the engine) is covered by the `service-factories`
* suite plus `arts/prefs/test/storage-bridge.test.ts`.
*/
import { describe, expect, it } from 'vitest';
import { createSvelteEngineBus } from '$bus';
import { createEngineLogger } from '$logger/engine-logger';
import { createEngineOrca } from '$orca';
import { createActivePrefs, standardPrefsDimensions } from '$prefs';
import { createActiveTimers } from '$timer/active-timers.svelte';
import { buildServiceBuilders } from '../service-builder.ts';
import {
defineActiveDom,
defineActiveFormat,
defineActiveFrontend,
defineActiveLang
} from '../service-factories/index.ts';
import type { CoreServices } from '../services.ts';
const NEXO_SCHEMA = {
...standardPrefsDimensions({
languages: ['es-ES', 'en-US', 'ar-EG'],
locales: ['es-ES', 'en-US', 'ar-EG'],
currencies: ['EUR', 'USD'],
defaults: {
language: 'es-ES',
locale: 'es-ES',
currency: 'EUR',
timezone: 'Europe/Madrid'
}
})
};
type Scene = { core: CoreServices; prefs: ReturnType<typeof buildPrefs> };
function buildPrefs() {
return createActivePrefs({ schema: NEXO_SCHEMA });
}
function buildScene(): Scene {
const logger = createEngineLogger({});
const timers = createActiveTimers({ logger });
const bus = createSvelteEngineBus({ logger, clock: timers.clock });
const orca = createEngineOrca({ bus, timers, logger });
const prefs = buildPrefs();
return { core: { logger, bus, timers, orca, prefs }, prefs };
}
const LANG_SCHEMA = {
hello: { 'es-ES': 'Hola', 'en-US': 'Hello' }
};
describe('prefs → consumer wiring', () => {
it('lang.setLocale fires when prefs.language changes', () => {
const scene = buildScene();
const core = scene.core;
const builders = buildServiceBuilders(
{ lang: defineActiveLang({ schema: LANG_SCHEMA, defaultLocale: 'es-ES' }) },
core
);
const { lang } = builders.proxies as {
lang: { getLocale(): string; t(k: 'hello'): string };
};
expect(lang.getLocale()).toBe('es-ES');
expect(lang.t('hello')).toBe('Hola');
scene.prefs.language.set('en-US');
expect(lang.getLocale()).toBe('en-US');
expect(lang.t('hello')).toBe('Hello');
builders.disposeAll();
core.prefs.dispose();
});
it('format follows prefs.locale', () => {
const scene = buildScene();
const core = scene.core;
const builders = buildServiceBuilders({ format: defineActiveFormat() }, core);
const { format } = builders.proxies as { format: { getLocale(): string } };
expect(format.getLocale()).toBe('es-ES');
scene.prefs.locale.set('en-US');
expect(format.getLocale()).toBe('en-US');
builders.disposeAll();
core.prefs.dispose();
});
it('frontend follows prefs.language for direction derivation', () => {
const scene = buildScene();
const core = scene.core;
const builders = buildServiceBuilders(
{
dom: defineActiveDom(),
frontend: defineActiveFrontend({ applyDom: false })
},
core
);
const { frontend } = builders.proxies as { frontend: { getLocale(): string } };
expect(frontend.getLocale()).toBe('es-ES');
scene.prefs.language.set('en-US');
expect(frontend.getLocale()).toBe('en-US');
builders.disposeAll();
core.prefs.dispose();
});
it('frontend mode/density/motion/dir track prefs end-to-end', () => {
const scene = buildScene();
const core = scene.core;
const builders = buildServiceBuilders(
{
dom: defineActiveDom(),
frontend: defineActiveFrontend({ applyDom: false })
},
core
);
const { frontend } = builders.proxies as {
frontend: {
getMode(): string;
getDensity(): string;
getReducedMotion(): boolean;
getDir(): string;
};
};
expect(frontend.getMode()).toBe('light');
expect(frontend.getDensity()).toBe('comfortable');
expect(frontend.getReducedMotion()).toBe(false);
expect(frontend.getDir()).toBe('ltr');
scene.prefs.theme.set('dark');
expect(frontend.getMode()).toBe('dark');
scene.prefs.density.set('compact');
expect(frontend.getDensity()).toBe('compact');
scene.prefs.motion.set('reduce');
expect(frontend.getReducedMotion()).toBe(true);
scene.prefs.language.set('ar-EG');
expect(frontend.getDir()).toBe('rtl');
builders.disposeAll();
core.prefs.dispose();
});
});

@ -0,0 +1,310 @@
/**
* End-to-end tests for the orca-based presets in
* `arts/active-app/presets/`. Validate that:
* - Publishing a SESSION_EVENT_* on the bus triggers the registered
* orca action.
* - The action calls the imperative API on the relevant service.
* - Detachers actually unregister the action.
* - The aggregator only registers presets whose services are present.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
applyCacheClearOnIdentityChange,
applyCacheClearOnRevoke,
applyConnectionsCloseOnRevoke,
applyConnectionsReauthOnIdentityChange,
applyPermInvalidateOnIdentityChange,
applyStandardOrca
} from '../presets/index.ts';
import { createSvelteEngineBus } from '$bus';
import { createEngineLogger } from '$logger/engine-logger';
import { createEngineOrca, type EngineOrca } from '$orca';
import { createActiveTimers } from '$timer/active-timers.svelte';
import { SESSION_EVENT_IDENTITY_CHANGED, SESSION_EVENT_REVOKED } from '$session';
import type { EngineLogger } from '$logger';
import type { EngineBus } from '$bus';
import type { ActiveTimers } from '$timer';
import type { ActiveCache } from '$cache/types';
import type { ActiveConnections } from '$connection/types';
import type { ActivePerms } from '$perm/types';
interface CoreState {
logger: EngineLogger;
bus: EngineBus<Record<string, unknown>>;
timers: ActiveTimers;
orca: EngineOrca;
}
function buildCore(): CoreState {
const logger = createEngineLogger({});
const timers = createActiveTimers({ logger });
const bus = createSvelteEngineBus<Record<string, unknown>>({ logger, clock: timers.clock });
const orca = createEngineOrca({ bus, timers, logger });
return { logger, bus, timers, orca };
}
function disposeCore(core: CoreState): void {
core.orca.dispose();
core.bus.dispose();
core.timers.dispose();
core.logger.dispose();
}
const samplePayload = {
event: 'session.lifecycle.adopted',
generation: 1,
identity: { from: 'anon', to: 'user-42' },
previousActorId: null,
nextActorId: 'user-42'
};
async function flush(rounds = 4) {
for (let i = 0; i < rounds; i++) {
await new Promise((r) => queueMicrotask(() => r(undefined)));
await new Promise((r) => setTimeout(r, 0));
}
}
describe('applyCacheClearOnIdentityChange', () => {
let core: CoreState;
beforeEach(() => {
core = buildCore();
});
afterEach(() => {
disposeCore(core);
});
it('clears the cache when SESSION_EVENT_IDENTITY_CHANGED is published', async () => {
const clear = vi.fn(() => Promise.resolve());
const cache = { clear } as unknown as ActiveCache;
applyCacheClearOnIdentityChange({ orca: core.orca, cache });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
expect(clear).toHaveBeenCalledTimes(1);
});
it('detacher unregisters the action', async () => {
const clear = vi.fn(() => Promise.resolve());
const cache = { clear } as unknown as ActiveCache;
const detach = applyCacheClearOnIdentityChange({ orca: core.orca, cache });
detach();
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
expect(clear).not.toHaveBeenCalled();
});
it('records action failure in run trace when clear() rejects', async () => {
const error = new Error('clear failed');
const cache = { clear: vi.fn(() => Promise.reject(error)) } as unknown as ActiveCache;
applyCacheClearOnIdentityChange({ orca: core.orca, cache });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
const runs = core.orca.recentRuns();
expect(runs).toHaveLength(1);
expect(runs[0].actions[0].status).toBe('error');
expect(runs[0].actions[0].error).toBe(error);
});
});
describe('applyPermInvalidateOnIdentityChange', () => {
let core: CoreState;
beforeEach(() => {
core = buildCore();
});
afterEach(() => {
disposeCore(core);
});
it('invalidates perms when SESSION_EVENT_IDENTITY_CHANGED is published', async () => {
const invalidate = vi.fn();
const perm = { invalidate } as unknown as ActivePerms;
applyPermInvalidateOnIdentityChange({ orca: core.orca, perm });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
expect(invalidate).toHaveBeenCalledTimes(1);
});
});
describe('applyCacheClearOnRevoke', () => {
let core: CoreState;
beforeEach(() => {
core = buildCore();
});
afterEach(() => {
disposeCore(core);
});
it('clears the cache when SESSION_EVENT_REVOKED is published', async () => {
const clear = vi.fn(() => Promise.resolve());
const cache = { clear } as unknown as ActiveCache;
applyCacheClearOnRevoke({ orca: core.orca, cache });
const revokePayload = {
...samplePayload,
event: 'session.lifecycle.revoked'
};
core.bus.publish(SESSION_EVENT_REVOKED, revokePayload);
await flush();
expect(clear).toHaveBeenCalledTimes(1);
});
});
describe('applyConnectionsReauthOnIdentityChange', () => {
let core: CoreState;
beforeEach(() => {
core = buildCore();
});
afterEach(() => {
disposeCore(core);
});
it('reauthenticates connections when SESSION_EVENT_IDENTITY_CHANGED is published', async () => {
const reauthenticateAll = vi.fn(() => Promise.resolve([]));
const connections = { reauthenticateAll } as unknown as ActiveConnections;
applyConnectionsReauthOnIdentityChange({ orca: core.orca, connections });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
expect(reauthenticateAll).toHaveBeenCalledTimes(1);
});
it('records action failure in run trace when reauthenticateAll() rejects', async () => {
const error = new Error('reauth failed');
const connections = {
reauthenticateAll: vi.fn(() => Promise.reject(error))
} as unknown as ActiveConnections;
applyConnectionsReauthOnIdentityChange({ orca: core.orca, connections });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
const runs = core.orca.recentRuns();
expect(runs).toHaveLength(1);
expect(runs[0].actions[0].status).toBe('error');
expect(runs[0].actions[0].error).toBe(error);
});
});
describe('applyConnectionsCloseOnRevoke', () => {
let core: CoreState;
beforeEach(() => {
core = buildCore();
});
afterEach(() => {
disposeCore(core);
});
it('closes connections when SESSION_EVENT_REVOKED is published', async () => {
const closeAll = vi.fn();
const connections = { closeAll } as unknown as ActiveConnections;
applyConnectionsCloseOnRevoke({ orca: core.orca, connections });
const revokePayload = {
...samplePayload,
event: 'session.lifecycle.revoked'
};
core.bus.publish(SESSION_EVENT_REVOKED, revokePayload);
await flush();
expect(closeAll).toHaveBeenCalledTimes(1);
expect(closeAll).toHaveBeenCalledWith('session-revoked');
});
});
describe('applyStandardOrca', () => {
let core: CoreState;
beforeEach(() => {
core = buildCore();
});
afterEach(() => {
disposeCore(core);
});
it('registers cache + perm reactions when both services are present', async () => {
const cacheClear = vi.fn(() => Promise.resolve());
const permInvalidate = vi.fn();
const cache = { clear: cacheClear } as unknown as ActiveCache;
const perm = { invalidate: permInvalidate } as unknown as ActivePerms;
applyStandardOrca({ orca: core.orca, cache, perm });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
expect(cacheClear).toHaveBeenCalledTimes(1);
expect(permInvalidate).toHaveBeenCalledTimes(1);
});
it('registers connection reauth + close when connections is present', async () => {
const reauthenticateAll = vi.fn(() => Promise.resolve([]));
const closeAll = vi.fn();
const connections = { reauthenticateAll, closeAll } as unknown as ActiveConnections;
applyStandardOrca({ orca: core.orca, connections });
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
expect(reauthenticateAll).toHaveBeenCalledTimes(1);
core.bus.publish(SESSION_EVENT_REVOKED, {
...samplePayload,
event: 'session.lifecycle.revoked'
});
await flush();
expect(closeAll).toHaveBeenCalledTimes(1);
});
it('skips cache reactions when cache is absent', async () => {
const permInvalidate = vi.fn();
const perm = { invalidate: permInvalidate } as unknown as ActivePerms;
applyStandardOrca({ orca: core.orca, perm });
// No cache action registered -> no error from publish
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
expect(permInvalidate).toHaveBeenCalledTimes(1);
});
it('detach removes every registered preset', async () => {
const cacheClear = vi.fn(() => Promise.resolve());
const permInvalidate = vi.fn();
const cache = { clear: cacheClear } as unknown as ActiveCache;
const perm = { invalidate: permInvalidate } as unknown as ActivePerms;
const detach = applyStandardOrca({ orca: core.orca, cache, perm });
detach();
core.bus.publish(SESSION_EVENT_IDENTITY_CHANGED, samplePayload);
await flush();
expect(cacheClear).not.toHaveBeenCalled();
expect(permInvalidate).not.toHaveBeenCalled();
});
});

@ -0,0 +1,124 @@
/**
* Verifies the new declarative service schema integrates with
* createActiveApp(): services are exposed as lowercase properties on
* the App, build lazily on first access, and are disposed in reverse
* order alongside the legacy core.
*/
import { describe, expect, it } from 'vitest';
import { LogLevel } from '$logger';
import { createActiveApp } from '../active-app.svelte.ts';
import {
defineActiveCache,
defineActiveDom,
defineActiveFormat,
defineActiveStorage,
defineEngineHttp
} from '../service-factories/index.ts';
const SILENT_LOGGER = { level: LogLevel.NONE, transports: [] };
describe('createActiveApp — declarative service schema', () => {
it('exposes declared services as lowercase properties', () => {
const App = createActiveApp({
logger: SILENT_LOGGER,
services: {
cache: defineActiveCache(),
http: defineEngineHttp({ baseUrl: '/api' })
}
});
expect(App.cache).toBeDefined();
expect(App.http).toBeDefined();
expect(typeof App.http.get).toBe('function');
App.dispose();
});
it('builds services lazily by default', () => {
const App = createActiveApp({
logger: SILENT_LOGGER,
services: {
cache: defineActiveCache()
}
});
expect(App.services.cache).toBe('absent');
// touching it triggers construction
void App.cache;
expect(App.services.cache).toBe('present');
App.dispose();
});
it('exposes only the five core members alongside declared services', () => {
const App = createActiveApp({
logger: SILENT_LOGGER,
services: {
cache: defineActiveCache()
}
});
// Core: always present.
expect(App.logger).toBeDefined();
expect(App.bus).toBeDefined();
expect(App.timers).toBeDefined();
expect(App.orca).toBeDefined();
expect(App.prefs).toBeDefined();
// Schema-declared service exposed as lowercase property.
expect(App.cache).toBeDefined();
// Legacy uppercase aliases are gone — `Cache` is no longer a core.
expect((App as Record<string, unknown>).Cache).toBeUndefined();
App.dispose();
});
it('disposes schema services on App.dispose()', () => {
let disposed = false;
const App = createActiveApp({
logger: SILENT_LOGGER,
services: {
probe: {
name: 'probe' as const,
coreDependencies: [] as const,
initMode: 'immediate' as const,
create: () => ({ value: 1 }),
dispose: () => {
disposed = true;
}
}
}
});
App.dispose();
expect(disposed).toBe(true);
});
it('exposes services introspection map via App.services', () => {
const App = createActiveApp({
logger: SILENT_LOGGER,
services: {
cache: defineActiveCache(),
dom: defineActiveDom(),
format: defineActiveFormat(),
storage: defineActiveStorage()
}
});
expect(Object.keys(App.services).sort()).toEqual(['cache', 'dom', 'format', 'storage']);
// All lazy: nothing built yet.
for (const status of Object.values(App.services)) {
expect(status).toBe('absent');
}
App.dispose();
});
it('returns an empty services map when no schema is declared', () => {
const App = createActiveApp({ logger: SILENT_LOGGER });
expect(App.services).toEqual({});
App.dispose();
});
});

@ -0,0 +1,413 @@
/**
* Unit tests for `service-builder.ts`. The builder is the load-bearing
* piece of `arts/active-app/`; if it has bugs, every service composed
* through it breaks.
*
* These tests use a synthetic `CoreServices` mock — they don't pull in
* real `arts/logger`, `arts/bus`, etc. The contract under test is
* purely:
* - schema validation
* - topological order with cycle detection
* - lazy / immediate construction
* - dispose order
* - status reporting
* - failure handling
*/
import { describe, expect, it, vi } from 'vitest';
import {
AappServiceConstructionFailedError,
AappServiceDependencyCycleError,
AappServiceNameMismatchError
} from '../errors.ts';
import { _internalsForTesting, buildServiceBuilders } from '../service-builder.ts';
import type {
AppServiceFactory,
AppServiceSchema,
CoreServices
} from '../services.ts';
// ── Test harness ───────────────────────────────────────────────────────
function mockCore(): CoreServices {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
logger: {} as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
bus: {} as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
timers: {} as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
orca: {} as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prefs: {} as any
};
}
interface FakeInstance {
name: string;
disposed: boolean;
}
function fakeFactory<TName extends string>(
name: TName,
overrides: Partial<AppServiceFactory<TName, readonly [], readonly [], FakeInstance>> = {}
): AppServiceFactory<TName, readonly [], readonly [], FakeInstance> {
return {
name,
coreDependencies: [],
initMode: 'lazy',
create() {
return { name, disposed: false };
},
dispose(instance) {
instance.disposed = true;
},
...overrides
};
}
// ── Schema validation ─────────────────────────────────────────────────
describe('buildServiceBuilders — schema validation', () => {
it('throws AappServiceNameMismatchError when key !== factory.name', () => {
const schema = {
cache: fakeFactory('not-cache')
} as unknown as AppServiceSchema;
expect(() => buildServiceBuilders(schema, mockCore())).toThrow(
AappServiceNameMismatchError
);
});
it('accepts a valid schema with matching keys', () => {
const schema = {
cache: fakeFactory('cache')
} as unknown as AppServiceSchema;
expect(() => buildServiceBuilders(schema, mockCore())).not.toThrow();
});
});
// ── Topological order ─────────────────────────────────────────────────
describe('buildServiceBuilders — topological order', () => {
it('orders dependencies before dependents', () => {
const schema = {
a: fakeFactory('a'),
b: fakeFactory('b', { serviceDependencies: ['a'] }),
c: fakeFactory('c', { serviceDependencies: ['b'] })
} as unknown as AppServiceSchema;
const order = _internalsForTesting.topologicalOrder(schema);
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
expect(order.indexOf('b')).toBeLessThan(order.indexOf('c'));
});
it('detects direct cycles and reports the cycle path', () => {
const schema = {
a: fakeFactory('a', { serviceDependencies: ['b'] }),
b: fakeFactory('b', { serviceDependencies: ['a'] })
} as unknown as AppServiceSchema;
expect(() => _internalsForTesting.topologicalOrder(schema)).toThrow(
AappServiceDependencyCycleError
);
});
it('detects indirect cycles', () => {
const schema = {
a: fakeFactory('a', { serviceDependencies: ['b'] }),
b: fakeFactory('b', { serviceDependencies: ['c'] }),
c: fakeFactory('c', { serviceDependencies: ['a'] })
} as unknown as AppServiceSchema;
expect(() => _internalsForTesting.topologicalOrder(schema)).toThrow(
AappServiceDependencyCycleError
);
});
it('ignores missing dependencies (slot stays undefined at create-time)', () => {
const schema = {
a: fakeFactory('a', { serviceDependencies: ['missing'] })
} as unknown as AppServiceSchema;
expect(() => _internalsForTesting.topologicalOrder(schema)).not.toThrow();
});
});
// ── Lazy construction ─────────────────────────────────────────────────
describe('buildServiceBuilders — lazy construction', () => {
it('does not call create() until the proxy is read', () => {
const create = vi.fn(() => ({ name: 'cache', disposed: false }));
const schema = {
cache: fakeFactory('cache', { create })
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
expect(create).not.toHaveBeenCalled();
const value = builders.proxies.cache;
expect(create).toHaveBeenCalledTimes(1);
expect(value).toEqual({ name: 'cache', disposed: false });
});
it('caches the constructed instance across repeated reads', () => {
const create = vi.fn(() => ({ name: 'cache', disposed: false }));
const schema = {
cache: fakeFactory('cache', { create })
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
const a = builders.proxies.cache;
const b = builders.proxies.cache;
expect(create).toHaveBeenCalledTimes(1);
expect(a).toBe(b);
});
});
// ── Immediate construction ────────────────────────────────────────────
describe('buildServiceBuilders — immediate construction', () => {
it('builds immediate services during buildServiceBuilders()', () => {
const create = vi.fn(() => ({ name: 'session', disposed: false }));
const schema = {
session: fakeFactory('session', { initMode: 'immediate', create })
} as unknown as AppServiceSchema;
buildServiceBuilders(schema, mockCore());
expect(create).toHaveBeenCalledTimes(1);
});
it('builds immediate services in topological order', () => {
const log: string[] = [];
const schema = {
a: fakeFactory('a', {
initMode: 'immediate',
create() {
log.push('a');
return { name: 'a', disposed: false };
}
}),
b: fakeFactory('b', {
initMode: 'immediate',
serviceDependencies: ['a'],
create() {
log.push('b');
return { name: 'b', disposed: false };
}
})
} as unknown as AppServiceSchema;
buildServiceBuilders(schema, mockCore());
expect(log).toEqual(['a', 'b']);
});
});
// ── Status reporting ──────────────────────────────────────────────────
describe('buildServiceBuilders — status', () => {
it('reports absent before construction, present after', () => {
const schema = {
cache: fakeFactory('cache')
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
expect(builders.statusMap()).toEqual({ cache: 'absent' });
void builders.proxies.cache;
expect(builders.statusMap()).toEqual({ cache: 'present' });
});
it('reports failed when create() throws', () => {
const schema = {
cache: fakeFactory('cache', {
create() {
throw new Error('boom');
}
})
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
expect(() => builders.proxies.cache).toThrow(AappServiceConstructionFailedError);
expect(builders.statusMap()).toEqual({ cache: 'failed' });
});
it('re-throws the same error on repeated access of a failed service', () => {
const schema = {
cache: fakeFactory('cache', {
create() {
throw new Error('boom');
}
})
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
const firstError = (() => {
try {
void builders.proxies.cache;
} catch (e) {
return e;
}
})();
const secondError = (() => {
try {
void builders.proxies.cache;
} catch (e) {
return e;
}
})();
expect(firstError).toBeInstanceOf(AappServiceConstructionFailedError);
expect(secondError).toBeInstanceOf(AappServiceConstructionFailedError);
});
});
// ── Dispose order ─────────────────────────────────────────────────────
describe('buildServiceBuilders — dispose', () => {
it('disposes constructed services in reverse construction order', () => {
const log: string[] = [];
const schema = {
a: fakeFactory('a', {
initMode: 'immediate',
dispose() {
log.push('a');
}
}),
b: fakeFactory('b', {
initMode: 'immediate',
serviceDependencies: ['a'],
dispose() {
log.push('b');
}
}),
c: fakeFactory('c', {
initMode: 'immediate',
serviceDependencies: ['b'],
dispose() {
log.push('c');
}
})
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
builders.disposeAll();
expect(log).toEqual(['c', 'b', 'a']);
});
it('does not dispose services that were never constructed', () => {
const dispose = vi.fn();
const schema = {
cache: fakeFactory('cache', { dispose })
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
// never read builders.proxies.cache
builders.disposeAll();
expect(dispose).not.toHaveBeenCalled();
});
it('swallows errors thrown during dispose', () => {
const schema = {
a: fakeFactory('a', {
initMode: 'immediate',
dispose() {
throw new Error('dispose failed');
}
}),
b: fakeFactory('b', {
initMode: 'immediate'
})
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
expect(() => builders.disposeAll()).not.toThrow();
});
it('is idempotent', () => {
const dispose = vi.fn();
const schema = {
cache: fakeFactory('cache', { initMode: 'immediate', dispose })
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
builders.disposeAll();
builders.disposeAll();
expect(dispose).toHaveBeenCalledTimes(1);
});
});
// ── Core dependency injection ─────────────────────────────────────────
describe('buildServiceBuilders — core injection', () => {
it('passes only the declared core deps to create()', () => {
const create = vi.fn(({ core }) => ({ name: 'cache', disposed: false, deps: core }));
const schema = {
cache: {
name: 'cache',
coreDependencies: ['logger'],
initMode: 'lazy',
create
} as AppServiceFactory<'cache', readonly ['logger'], readonly [], unknown>
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
void builders.proxies.cache;
const { core } = create.mock.calls[0][0] as { core: Record<string, unknown> };
expect(Object.keys(core)).toEqual(['logger']);
expect(core).not.toHaveProperty('bus');
expect(core).not.toHaveProperty('timers');
expect(core).not.toHaveProperty('orca');
});
it('passes service deps that exist in the schema', () => {
const aInstance = { name: 'a', disposed: false };
let capturedDeps: Record<string, unknown> | undefined;
const schema = {
a: fakeFactory('a', { create: () => aInstance }),
b: {
name: 'b',
coreDependencies: [],
serviceDependencies: ['a'],
initMode: 'lazy',
create({ services }) {
capturedDeps = services;
return { name: 'b', disposed: false };
}
} as AppServiceFactory<'b', readonly [], readonly ['a'], unknown>
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
void builders.proxies.b;
expect(capturedDeps).toBeDefined();
expect(capturedDeps?.a).toBe(aInstance);
});
it('leaves missing service deps as undefined', () => {
let capturedDeps: Record<string, unknown> | undefined;
const schema = {
b: {
name: 'b',
coreDependencies: [],
serviceDependencies: ['missing'],
initMode: 'lazy',
create({ services }) {
capturedDeps = services;
return { name: 'b', disposed: false };
}
} as AppServiceFactory<'b', readonly [], readonly ['missing'], unknown>
} as unknown as AppServiceSchema;
const builders = buildServiceBuilders(schema, mockCore());
void builders.proxies.b;
expect(capturedDeps).toBeDefined();
expect(capturedDeps?.missing).toBeUndefined();
});
});

@ -0,0 +1,150 @@
/**
* End-to-end test of service-factories: build a real App-like state
* from a schema and verify each factory produces a working instance.
* This validates that the factories declare the right core deps and
* that the builder wires them correctly.
*/
import { describe, expect, it } from 'vitest';
import { buildServiceBuilders } from '../service-builder.ts';
import {
defineActiveDom,
defineActiveFormat,
defineActiveFrontend,
defineActiveLang,
defineActiveStorage,
defineEngineHttp,
defineEngineSium
} from '../service-factories/index.ts';
import { createSvelteEngineBus } from '$bus';
import { createEngineLogger } from '$logger/engine-logger';
import { createEngineOrca } from '$orca';
import { createActivePrefs, standardPrefsDimensions } from '$prefs';
import { createActiveTimers } from '$timer/active-timers.svelte';
import type { CoreServices } from '../services.ts';
const NEUTRAL_SCHEMA = {
...standardPrefsDimensions({
languages: ['es', 'en'],
locales: ['es-ES', 'en-US'],
currencies: ['EUR', 'USD'],
defaults: {
language: 'es',
locale: 'es-ES',
currency: 'EUR',
timezone: 'Europe/Madrid'
}
})
};
function buildCore(): CoreServices {
const logger = createEngineLogger({});
const timers = createActiveTimers({ logger });
const bus = createSvelteEngineBus({ logger, clock: timers.clock });
const orca = createEngineOrca({ bus, timers, logger });
const prefs = createActivePrefs({ schema: NEUTRAL_SCHEMA });
return { logger, bus, timers, orca, prefs };
}
describe('service-factories — integration', () => {
it('builds storage / format / dom / http / sium with declared core deps', () => {
const core = buildCore();
const builders = buildServiceBuilders(
{
storage: defineActiveStorage(),
format: defineActiveFormat(),
dom: defineActiveDom(),
http: defineEngineHttp(),
sium: defineEngineSium()
},
core
);
const proxies = builders.proxies as {
storage: { entries(): unknown };
format: { dispose?: () => void };
dom: { breakpoints: unknown };
http: { get: unknown };
sium: { string: unknown };
};
expect(typeof proxies.storage.entries).toBe('function');
expect(typeof proxies.dom.breakpoints).toBe('object');
expect(typeof proxies.http.get).toBe('function');
expect(typeof proxies.sium.string).toBe('function');
expect(proxies.format).toBeDefined();
builders.disposeAll();
});
it('builds lang from a schema and types t() correctly', () => {
const core = buildCore();
const langSchema = {
hello: { es: 'Hola', en: 'Hello' }
};
const builders = buildServiceBuilders(
{
lang: defineActiveLang({ schema: langSchema, defaultLocale: 'es' })
},
core
);
const lang = (builders.proxies as { lang: { t: (path: string) => string } }).lang;
expect(lang.t('hello')).toBe('Hola');
builders.disposeAll();
});
it('builds frontend after dom in topological order', () => {
const core = buildCore();
const builders = buildServiceBuilders(
{
dom: defineActiveDom(),
frontend: defineActiveFrontend({ applyDom: false })
},
core
);
const status = builders.statusMap();
// All lazy: nothing built yet.
expect(status).toEqual({ dom: 'absent', frontend: 'absent' });
// Touching frontend pulls it (and its declared deps if reachable).
const fe = (builders.proxies as { frontend: object }).frontend;
expect(fe).toBeDefined();
builders.disposeAll();
});
it('format reads its locale from core.prefs', () => {
// Prefs is core, so format gets its locale source through
// `core.prefs` without declaring a service dependency.
const core = buildCore();
const builders = buildServiceBuilders(
{ format: defineActiveFormat() },
core
);
const format = (builders.proxies as { format: { getLocale(): string } }).format;
expect(format.getLocale()).toBe('es-ES');
builders.disposeAll();
core.prefs.dispose();
});
it('reports failed status when a factory throws on construct', () => {
const core = buildCore();
const builders = buildServiceBuilders(
{
broken: {
name: 'broken' as const,
coreDependencies: [] as const,
create() {
throw new Error('cannot init');
}
}
},
core
);
expect(() => (builders.proxies as { broken: unknown }).broken).toThrow();
expect(builders.statusMap()).toEqual({ broken: 'failed' });
builders.disposeAll();
core.prefs.dispose();
});
});

@ -0,0 +1,112 @@
/**
* Tests for the `applySessionAutoRefresh` preset. The preset's job is
* to wire `App.timers` and `App.timers.clock` into `withAutoRefresh`
* so the refresh ticker runs through the App's single time source
* instead of falling back to `setInterval` + `Date.now`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { applySessionAutoRefresh } from '../presets/index.ts';
import { createEngineSession } from '$session';
import { createActiveTimers, type ActiveTimers } from '$timer/active-timers.svelte';
import { createSvelteEngineBus, type EngineBus } from '$bus';
import { createEngineLogger, type EngineLogger } from '$logger';
import { createEngineOrca, type EngineOrca } from '$orca';
import type { ActiveSession, Session } from '$session/types';
interface User {
id: string;
}
const NOW = 1_700_000_000_000;
function alice(expiresAt: number): Session<User> {
return { user: { id: 'u1' }, issuedAt: NOW, expiresAt };
}
interface Core {
logger: EngineLogger;
bus: EngineBus<Record<string, unknown>>;
timers: ActiveTimers;
orca: EngineOrca;
dispose: () => void;
}
function buildCore(): Core {
const Logger = createEngineLogger({});
const Timers = createActiveTimers({ logger: Logger });
const Bus = createSvelteEngineBus<Record<string, unknown>>({
logger: Logger,
clock: Timers.clock
});
const Orca = createEngineOrca({ bus: Bus, timers: Timers, logger: Logger });
return {
logger: Logger,
bus: Bus,
timers: Timers,
orca: Orca,
dispose() {
Orca.dispose();
Bus.dispose();
Timers.dispose();
Logger.dispose();
}
};
}
describe('applySessionAutoRefresh', () => {
let core: Core;
let session: ActiveSession<User>;
let onRefresh: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
core = buildCore();
onRefresh = vi.fn(async () => null);
session = createEngineSession<User>({ onRefresh }) as unknown as ActiveSession<User>;
});
afterEach(() => {
core.dispose();
vi.useRealTimers();
});
it('routes the refresh ticker through App.timers (not setInterval)', async () => {
await session.adopt(alice(NOW + 60_000)); // 60s ahead, margin 90s
const stop = applySessionAutoRefresh(
{ ...core, session },
{
tickMs: 30_000,
marginMs: 90_000,
jitterMs: 0,
refreshOnVisible: false
}
);
// Initial maybeRefresh runs synchronously inside startTicker; expiration < margin → refresh fires.
await vi.advanceTimersByTimeAsync(0);
expect(onRefresh).toHaveBeenCalled();
stop();
});
it('honors caller-provided `random` when jitter is enabled', async () => {
await session.adopt(alice(NOW + 100_000)); // 100s ahead
const customRandom = vi.fn(() => 0.0);
const stop = applySessionAutoRefresh(
{ ...core, session },
{
tickMs: 30_000,
marginMs: 90_000,
jitterMs: 30_000,
refreshOnVisible: false,
random: customRandom
}
);
await vi.advanceTimersByTimeAsync(0);
// jitter=30_000, random=0 -> margin stays at 90_000; remaining=100_000
// → not within margin → no refresh on the first tick.
expect(onRefresh).not.toHaveBeenCalled();
expect(customRandom).toHaveBeenCalled();
stop();
});
});

@ -0,0 +1,160 @@
/**
* Public types for `arts/active-app`.
*
* `ActiveApp<TSchema, TPrefsSchema>` is the composed surface seen by
* the application:
*
* - `ActiveAppCore` — `logger`, `bus`, `timers`, `orca`, `prefs` plus
* `dispose`. Always present. Lowercase, like every other JS
* property — the previous PascalCase rule existed for visual
* signalling that the property was core, not because of any
* technical constraint.
* - `ResolveServiceInstances<TSchema>` — every entry the application
* declared in `services: { … }` is exposed as a property with the
* exact instance type returned by its factory.
* - `ActiveAppServicesIntrospection` — `services` map for devtools.
*/
import type { EngineBus, EngineBusOptions } from '$bus';
import type { EngineLogger, LoggerOptions } from '$logger';
import type { EngineOrca, EngineOrcaOptions } from '$orca';
import type {
ActivePrefs,
PrefsIntentStorage,
PrefsStorageOp
} from '$prefs';
import type {
PrefsEnvironment,
PrefsIntentOf,
PrefsSchema
} from '$libs/prefs';
import type { ActiveTimers, EngineTimersOptions } from '$timer';
import type { AppEventMap } from './events.ts';
import type {
AppServiceSchema,
ResolveServiceInstances,
ServiceStatus
} from './services.ts';
/**
* Bus event map seen by `App.bus`. Includes App-owned events and any
* module-level event maps that App is intended to surface.
*
* Module event maps (e.g. `SessEventMap`, `ConnectionEventMap`) are
* intentionally NOT pre-declared here. Each app augments this type via
* declaration merging if it wants typed access; the default contract is
* App-level only.
*/
export interface ActiveAppBusEvents extends AppEventMap {}
// ── Options ────────────────────────────────────────────────────────────
/**
* Options for the App-wide `prefs` engine. Generic over the schema so
* dimension keys flow through to `App.prefs.<key>` autocomplete and
* to the `intent` / `storage` shapes.
*
* Apps that don't need persistence omit `storage`; apps that do supply
* a `PrefsIntentStorage<TSchema>` (typically a thin adapter over
* `arts/storage`) and an optional `onStorageError` reporter.
*/
export interface ActiveAppPrefsOptions<S extends PrefsSchema> {
readonly schema: S;
readonly environment?: PrefsEnvironment;
readonly intent?: PrefsIntentOf<S>;
readonly storage?: PrefsIntentStorage<S>;
readonly onStorageError?: (error: unknown, op: PrefsStorageOp) => void;
}
/**
* Options for `createActiveApp()`. All sections are optional.
*
* - `logger` defaults to engine defaults (`level: WARN`,
* `consoleTransport()`). Pass `{ level: NONE, transports: [] }` for
* silence.
* - `timers` and `bus` build with engine defaults; App injects
* `logger` (and `clock` for the bus) automatically.
* - `orca` builds with engine defaults; App injects `bus`, `timers`
* and `logger`.
* - `prefs` defaults to a neutral `standardPrefsDimensions` preset
* (single `en` / `en-US` / `USD` baseline) so apps that don't care
* about preferences still get a valid `App.prefs` instance. Apps
* that do care declare their full `schema` here; the dimension
* keys flow through to `App.prefs.<key>`.
* - `services` declares the opt-in service schema. If omitted, only
* the core is built and `App.services` is `{}`.
*
* Whatever lived in the previous root-level options (`lang`, `formats`,
* `frontend`, `dom`, `storage`, `http`, `cache`) now belongs in
* `services` via the corresponding `defineActive*` factory.
*/
export interface ActiveAppOptions<
TSchema extends AppServiceSchema = AppServiceSchema,
TPrefsSchema extends PrefsSchema = PrefsSchema
> {
logger?: LoggerOptions;
timers?: Omit<EngineTimersOptions, 'logger'>;
bus?: Omit<EngineBusOptions, 'logger' | 'clock'>;
orca?: Omit<EngineOrcaOptions, 'bus' | 'timers' | 'logger'>;
prefs?: ActiveAppPrefsOptions<TPrefsSchema>;
services?: TSchema;
}
// ── Surface ────────────────────────────────────────────────────────────
/**
* The fixed core surface, present on every App: `logger`, `bus`,
* `timers`, `orca`, `prefs` plus the lifecycle helper `dispose`. None
* of these are services — they are the substrate every service
* depends on. Lowercase, like all JS properties.
*/
export interface ActiveAppCore<TPrefsSchema extends PrefsSchema = PrefsSchema> {
readonly logger: EngineLogger;
readonly bus: EngineBus<ActiveAppBusEvents>;
readonly timers: ActiveTimers;
/**
* Orchestration engine. Always present, inert until the application
* registers actions via `App.orca.onEvent(...)` or applies presets
* from `arts/active-app/presets/`.
*/
readonly orca: EngineOrca;
/**
* Reactive preference state, generic over the user-defined schema.
* Each schema key becomes a typed dimension at `App.prefs.<key>`
* with `.get()` / `.set()` / `.clear()` / `.onChange()` verbs.
*/
readonly prefs: ActivePrefs<TPrefsSchema>;
/**
* Tear down every constructed service in reverse order, then the
* core in reverse build order. Idempotent.
*/
dispose(): void;
}
/**
* Service introspection — used by tests, devtools, and any UI that
* wants to render the current schema state. Most application code does
* not read this.
*/
export interface ActiveAppServicesIntrospection {
readonly services: Readonly<Record<string, ServiceStatus>>;
}
/**
* Composed application surface.
*
* Type-safe access:
* - `App.logger` / `App.bus` / `App.timers` / `App.orca` / `App.prefs`
* always exist.
* - `App.<serviceName>` exists IFF the service was declared in
* `options.services`. Reading an undeclared name is a TypeScript
* error.
*/
export type ActiveApp<
TSchema extends AppServiceSchema = AppServiceSchema,
TPrefsSchema extends PrefsSchema = PrefsSchema
> = ActiveAppCore<TPrefsSchema> &
ResolveServiceInstances<TSchema> &
ActiveAppServicesIntrospection;

@ -0,0 +1,247 @@
# ActiveDom
`adom` contiene `ActiveDom`: el servicio DOM reactivo de aplicación.
## Qué es hoy
Ahora mismo `ActiveDom` no es un bus de eventos semánticos ni un reflector de
`data-event*`.
Su responsabilidad actual es más pequeña y más concreta:
- exponer el ancho de viewport de forma reactiva
- resolver el breakpoint actual
- mantener la definición de breakpoints de la app
- resolver valores responsive
- ofrecer helpers de consulta (`isAtLeast`, `matches`)
- aplicar/remover atributos DOM de forma controlada (`apply`, `remove`)
En otras palabras:
```text
libs/dom -> arts/adom -> App.dom / app.dom
puro reactivo consumo de app
```
## Composicion via aapp
`createActiveApp(...)` siempre construye `App.dom` (con defaults si no se
configura) y se lo pasa a `App.frontend` para que comparta la misma instancia.
Ver `$active-app/README.md`. Construir `createActiveDom()` directamente solo es
necesario en tests aislados o en consumidores fuera de la composicion estandar.
## Qué pertenece a cada capa
### `libs/dom`
Primitives DOM puras o casi puras:
- guards y traversal DOM
- focus helpers
- tabbable helpers
- responsive helpers puros
Importable como `import { ... } from '$libs/dom'`.
No mantiene estado de aplicación.
### `arts/adom`
Runtime reactivo de DOM:
- `viewport`
- `breakpoints`
- `currentBreakpoint`
- `resolve(...)`
- `isAtLeast(...)`
- `matches(...)`
- `apply(...)` / `remove(...)` como superficie unica de mutacion de atributos
- `BodyScrollLock` como helper global de body scroll lock, sin bloquear eventos de puntero
- `DOMContext` como helper scoped para `Document` / `ShadowRoot`
- `RovingFocusGroup` como helper runtime para navegación compuesta por teclado
`ActiveDom` sí mantiene estado reactivo y por eso vive aquí, no en `$libs/dom`.
## Posición en App
`ActiveDom` vive a nivel de aplicación:
```ts
App.dom;
app.dom;
```
La implementación se consume desde la capa de aplicación y desde artefactos que
necesitan escribir atributos finales sobre un target DOM, por ejemplo `fend`.
## API actual
La API pública real de `ActiveDom` hoy es esta:
```ts
export type ActiveDom = {
breakpoints: Active<Breakpoints>;
viewport: { readonly width: number };
currentBreakpoint: Active<Breakpoint>;
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined;
isAtLeast(breakpoint: Breakpoint): boolean;
matches(breakpoint: Breakpoint): boolean;
apply(change: StructuralChange): void;
remove(target: HTMLElement, names: readonly string[]): void;
dispose(): void;
};
```
Creación:
```ts
const dom = createActiveDom({
breakpoints: readableActive(() => ({
lg: 1100
}))
});
const domWithDefaults = createActiveDom();
```
Responsive:
```ts
const columns = dom.resolve({ base: 1, sm: 2, lg: 3 });
const tone = dom.resolve({ base: 'compact', md: 'normal', xl: 'wide' });
```
Mutacion DOM:
```ts
dom.apply({
target: node,
attrs: {
'data-state': 'open',
'aria-busy': true,
'data-hidden': false
}
});
dom.remove(node, ['data-state', 'aria-busy']);
```
Reglas:
- los breakpoints se definen en la creación del `dom`
- `breakpoints` es opcional; si no se pasa, usa `BREAKPOINTS_DEFAULT`
- no hay herencia de `dom` padre
- `ActiveDom` es servicio de app, no scope anidado
- `ActiveDom` registra el listener de `resize` al construirse (por instancia).
El singleton compartido (`shareViewport: true`) pospone la asignación del
listener a la primera lectura del viewport — importar `$adom` no aloca
estado reactivo si nadie consume el viewport.
- `apply` solo escribe atributos; no interpreta semantica ni eventos
- `false | null | undefined` remueven atributos
- `viewport` es **per-instancia por defecto**: cada `ActiveDom` posee su
propio listener de `resize`, scoped al `targetWindow` (default `window`).
Esto evita filtraciones entre tests, iframes, popups y entornos happy-dom.
Llamar `dispose()` desadjunta ese listener.
### Compartir el viewport entre instancias
Para apps "single window" donde toda la composición vive en el mismo
documento, opt-in al singleton evita N listeners para el mismo evento:
```ts
const dom = createActiveDom({ shareViewport: true });
```
Bajo esta opción, `dispose()` es no-op para el viewport (el singleton vive
toda la vida del proceso). Lo que sí se libera es `breakpoints`,
`currentBreakpoint` y los demás reactivos por-instancia.
### Tracking de un window distinto
Para iframes, popups o entornos de test:
```ts
const iframeDom = createActiveDom({ targetWindow: iframe.contentWindow! });
const popupDom = createActiveDom({ targetWindow: popup });
```
Ignorado cuando `shareViewport: true` (el singleton siempre rastrea el
top-level `window`).
## Qué no es
`ActiveDom` no es:
- `SemanticEngine`
- broker de eventos
- reflector de `data-event*`
- hub de `MutationObserver`
- sistema de theme
- sistema de modal, backdrop o inert
- reemplazo de `$libs/dom`
Además, `uix/adom` puede alojar helpers DOM con estado global real, como
`BodyScrollLock`, o helpers scoped de runtime como `DOMContext`, cuando ya no
son primitives puras de `$libs/dom` pero tampoco pertenecen a un componente UI concreto.
También caben aquí helpers runtime de foco con estado propio, como
`RovingFocusGroup`, que reutilizan `$libs/dom` por debajo pero ya no son
solo utilidades puras.
## Pagina De Prueba
La pagina manual esta en `/test/adom` y cubre:
- viewport y breakpoint actual
- `resolve()` responsive
- `apply()` / `remove()`
- `BodyScrollLock`
- `DOMContext`
- `RovingFocusGroup`
## Relación con otras piezas
### Semántica
La semántica pertenece a `Sema` y a `SemanticEngine`, no a `ActiveDom`.
Si mañana `ActiveDom` refleja eventos al DOM, será como consumidor de
`SemanticEngine`, no como autoridad semántica.
### Theme
El theme no pertenece a `dom`.
Va en `app.presentation`, porque es estado de presentación de aplicación, no una
primitive DOM.
### Air y Terra
`air` y `terra` no consumen esta capa nueva.
Su código actual sirve como referencia histórica para extraer utilidades hacia
`$libs/dom`, pero no forman parte del runtime nuevo.
## Estado del diseño
`ActiveDom` está en fase fundacional.
Lo que ya está cerrado:
- `app.dom`
- `App.dom`
- `viewport`
- `breakpoints`
- `currentBreakpoint`
- resolución responsive
Lo que queda para fases posteriores, si de verdad hace falta:
- reflexión de eventos semánticos al DOM
- observers compartidos
- APIs por `Document` o `ShadowRoot`
- introspección/diagnóstico de runtime más rica
La regla importante por ahora es simple:
> `ActiveDom` es el servicio reactivo de DOM de la app; `$libs/dom` es su base pura.

@ -0,0 +1,108 @@
import { readableActive, type Active } from '$reactive';
import {
applyChange,
BREAKPOINTS_DEFAULT,
getCurrentBreakpoint,
removeAttrs,
resolveResponsiveProp,
type Breakpoint,
type Breakpoints,
type ResponsiveProp,
type StructuralChange
} from '$libs/dom';
import {
createViewportTracker,
getSharedViewport,
type ViewportTracker
} from './viewport.svelte.js';
export type ActiveDomProps = {
breakpoints?: Active<Partial<Breakpoints>>;
/**
* Share the process-wide viewport singleton across every `ActiveDom`
* instance. When `false` (default) each instance owns its own resize
* tracker scoped to `targetWindow` — safer for iframes, popups,
* happy-dom test environments and SSR snapshots, where bleeding state
* between instances is a hard-to-find bug.
*
* Set to `true` for the legacy "single app, single window" behavior.
*
* @default false
*/
shareViewport?: boolean;
/**
* Window whose `resize` events feed the viewport state. Defaults to the
* global `window`. Use this to track an iframe, popup, or a happy-dom
* instance in tests. Ignored when `shareViewport: true` (the singleton
* always tracks the top-level `window`).
*/
targetWindow?: Window;
};
export type ActiveDom = {
breakpoints: Active<Breakpoints>;
viewport: { readonly width: number };
currentBreakpoint: Active<Breakpoint>;
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined;
isAtLeast(breakpoint: Breakpoint): boolean;
matches(breakpoint: Breakpoint): boolean;
apply(change: StructuralChange): void;
remove(target: HTMLElement, names: readonly string[]): void;
/**
* Detach the resize listener owned by this instance. No-op when the
* instance was constructed with `shareViewport: true` (the singleton's
* listener is process-lifetime and shared with every other consumer).
*
* Idempotent.
*/
dispose: () => void;
};
export function createActiveDom(props: ActiveDomProps = {}): ActiveDom {
const tracker: ViewportTracker = props.shareViewport
? getSharedViewport()
: createViewportTracker(props.targetWindow);
const breakpoints = readableActive(() => ({
...BREAKPOINTS_DEFAULT,
...props.breakpoints?.current
}));
const currentBreakpoint = readableActive(() =>
getCurrentBreakpoint(tracker.width, breakpoints.current)
);
const viewportReadonly: { readonly width: number } = {
get width() {
return tracker.width;
}
};
let disposed = false;
return {
breakpoints,
viewport: viewportReadonly,
currentBreakpoint,
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined {
return resolveResponsiveProp(value, tracker.width, breakpoints.current);
},
isAtLeast(breakpoint: Breakpoint): boolean {
return tracker.width >= breakpoints.current[breakpoint];
},
matches(breakpoint: Breakpoint): boolean {
return currentBreakpoint.current === breakpoint;
},
apply(change: StructuralChange): void {
applyChange(change);
},
remove(target: HTMLElement, names: readonly string[]): void {
removeAttrs(target, names);
},
dispose(): void {
if (disposed) return;
disposed = true;
tracker.dispose();
}
};
}

@ -0,0 +1,225 @@
import { SvelteMap } from 'svelte/reactivity';
import { writableActive, type State } from '$reactive';
import { isBrowser, isIOS } from '$libs/dom';
/**
* Options reserved for future expansion (scrollbar padding/margin compensation
* strategies). Kept as an exported type so consumers can type-forward.
*/
export type BodyScrollLockOption = Record<string, never>;
/**
* Properties we mutate on `<body>` while locked. Must all be saved so we can
* restore the exact pre-lock state when the last lock releases.
*/
const MANAGED_PROPERTIES = [
'overflow',
'overflow-x',
'overflow-y',
'padding-right',
'--scrollbar-width'
] as const;
type ManagedProperty = (typeof MANAGED_PROPERTIES)[number];
type SavedProperties = Partial<Record<ManagedProperty, string>>;
/**
* Module-singleton state coordinating every `BodyScrollLock` instance against
* the single `<body>` element. Allocated lazily on first access so importing
* this module is side-effect-free — the `SvelteMap` is built only when a lock
* is actually constructed (or `BodyScrollLock.reset()` is called from tests).
*/
type ModuleState = {
lockMap: SvelteMap<string, boolean>;
savedProperties: SavedProperties | null;
stopTouchMoveListener: (() => void) | null;
cleanupTimeoutId: number | null;
cleanupScheduledAt: number | null;
idCounter: number;
};
let _state: ModuleState | undefined;
function state(): ModuleState {
return (_state ??= {
lockMap: new SvelteMap<string, boolean>(),
savedProperties: null,
stopTouchMoveListener: null,
cleanupTimeoutId: null,
cleanupScheduledAt: null,
idCounter: 0
});
}
function nextId(): string {
const s = state();
s.idCounter += 1;
return `body-scroll-lock-${s.idCounter}`;
}
function isAnyLocked(): boolean {
for (const [, value] of state().lockMap) {
if (value) return true;
}
return false;
}
function cancelPendingCleanup() {
const s = state();
if (s.cleanupTimeoutId === null || !isBrowser) return;
window.clearTimeout(s.cleanupTimeoutId);
s.cleanupTimeoutId = null;
}
function captureBodyProperties() {
const s = state();
if (!isBrowser || s.savedProperties !== null) return;
const style = document.body.style;
const saved: SavedProperties = {};
for (const prop of MANAGED_PROPERTIES) {
const value = style.getPropertyValue(prop);
if (value) saved[prop] = value;
}
s.savedProperties = saved;
}
function restoreBodyProperties() {
if (!isBrowser) return;
const s = state();
const style = document.body.style;
const saved = s.savedProperties ?? {};
for (const prop of MANAGED_PROPERTIES) {
const original = saved[prop];
if (original === undefined) {
style.removeProperty(prop);
} else {
style.setProperty(prop, original);
}
}
detachTouchMoveListener();
s.savedProperties = null;
}
function detachTouchMoveListener() {
const s = state();
s.stopTouchMoveListener?.();
s.stopTouchMoveListener = null;
}
function attachTouchMoveListener() {
const s = state();
if (!isBrowser || !isIOS || s.stopTouchMoveListener) return;
const listener = (event: TouchEvent) => {
if (event.target !== document.documentElement) return;
if (event.touches.length > 1) return;
event.preventDefault();
};
document.addEventListener('touchmove', listener, { passive: false });
s.stopTouchMoveListener = () => {
document.removeEventListener('touchmove', listener);
state().stopTouchMoveListener = null;
};
}
function applyBodyLock() {
if (!isBrowser) return;
cancelPendingCleanup();
captureBodyProperties();
const style = document.body.style;
const htmlStyle = getComputedStyle(document.documentElement);
const bodyStyle = getComputedStyle(document.body);
const hasStableGutter =
htmlStyle.scrollbarGutter?.includes('stable') || bodyStyle.scrollbarGutter?.includes('stable');
const verticalScrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
const paddingRight = Number.parseInt(bodyStyle.paddingRight || '0', 10);
if (verticalScrollbarWidth > 0 && !hasStableGutter) {
style.paddingRight = `${paddingRight + verticalScrollbarWidth}px`;
style.setProperty('--scrollbar-width', `${verticalScrollbarWidth}px`);
}
style.overflow = 'hidden';
attachTouchMoveListener();
}
function scheduleCleanupIfNoNewLocks(delay: number | null, callback: () => void) {
if (!isBrowser) return;
cancelPendingCleanup();
const s = state();
s.cleanupScheduledAt = Date.now();
const currentCleanupId = s.cleanupScheduledAt;
const cleanupFn = () => {
const inner = state();
inner.cleanupTimeoutId = null;
if (inner.cleanupScheduledAt !== currentCleanupId) return;
if (!isAnyLocked()) {
callback();
}
};
s.cleanupTimeoutId = window.setTimeout(cleanupFn, delay ?? 24);
}
export class BodyScrollLock {
readonly id = nextId();
readonly locked: State<boolean>;
constructor(
initialState?: boolean,
private readonly restoreScrollDelay: () => number | null = () => null
) {
state().lockMap.set(this.id, initialState ?? false);
this.locked = writableActive(
() => state().lockMap.get(this.id) ?? false,
(value: boolean) => {
state().lockMap.set(this.id, value);
if (value || isAnyLocked()) {
applyBodyLock();
return;
}
scheduleCleanupIfNoNewLocks(this.restoreScrollDelay(), restoreBodyProperties);
}
);
if (initialState) applyBodyLock();
}
destroy() {
const s = state();
const wasLocked = s.lockMap.get(this.id) ?? false;
s.lockMap.delete(this.id);
if (isAnyLocked()) {
applyBodyLock();
return;
}
if (wasLocked) {
scheduleCleanupIfNoNewLocks(this.restoreScrollDelay(), restoreBodyProperties);
}
}
static reset() {
// Skip when no instance was ever created — `state()` would lazily
// allocate the module-level holder for nothing.
if (_state === undefined) return;
_state.lockMap.clear();
cancelPendingCleanup();
restoreBodyProperties();
_state.savedProperties = null;
_state.cleanupScheduledAt = null;
_state.idCounter = 0;
}
}

@ -0,0 +1,82 @@
import { readableActive, type Active, type State } from '$reactive';
import {
getActiveElement,
getDocument,
getWindow,
isBrowser,
isDocument,
isShadowRoot
} from '$libs/dom';
type ElementGetter = () => HTMLElement | null;
type ContextElement = Active<HTMLElement | null> | State<HTMLElement | null> | ElementGetter;
function getDefaultProvider(): Document | null {
return isBrowser ? document : null;
}
export class DOMContext {
readonly element: Active<HTMLElement | null>;
readonly provider = readableActive<Document | ShadowRoot | null>(() => {
const element = this.element.current;
if (!element) return getDefaultProvider();
const providerNode = element.getRootNode();
if (isDocument(providerNode) || isShadowRoot(providerNode)) {
return providerNode;
}
return getDocument(element);
});
constructor(element: ContextElement) {
this.element =
typeof element === 'function'
? readableActive(element)
: (element as Active<HTMLElement | null>);
}
getDocument = (): Document => {
return getDocument(this.provider.current ?? undefined);
};
getWindow = (): Window => {
return getWindow(this.provider.current ?? undefined);
};
getActiveElement = (): Element | null => {
const provider = this.provider.current;
if (!provider) return null;
return getActiveElement(provider);
};
isActiveElement = (node: HTMLElement | null): boolean => {
return node === this.getActiveElement();
};
getElementById<T extends Element = HTMLElement>(id: string): T | null {
const provider = this.provider.current;
if (!provider || !('getElementById' in provider)) return null;
return provider.getElementById(id) as T | null;
}
querySelector = <T extends Element = Element>(selector: string): T | null => {
const provider = this.provider.current;
if (!provider) return null;
return provider.querySelector(selector) as T | null;
};
querySelectorAll = <T extends Element = Element>(selector: string): NodeListOf<T> => {
const provider = this.provider.current;
if (!provider) return [] as unknown as NodeListOf<T>;
return provider.querySelectorAll(selector) as NodeListOf<T>;
};
setTimeout = (callback: () => void, delay: number): number => {
return this.getWindow().setTimeout(callback, delay);
};
clearTimeout = (timeoutId: number): void => {
this.getWindow().clearTimeout(timeoutId);
};
}

@ -0,0 +1,19 @@
// Public surface of the adom artifact. Named re-exports so consumers who
// only use `createActiveDom` do not pay for `BodyScrollLock`, `DOMContext`,
// or `RovingFocusGroup`. Each primitive is in its own `.svelte.ts` file with
// lazy module-level state — importing the barrel does not allocate any of
// them.
export { createActiveDom } from './active-dom.svelte.js';
export type { ActiveDom, ActiveDomProps } from './active-dom.svelte.js';
export { applyChange, removeAttrs } from '$libs/dom';
export type { DomApplier, DomAttrValue, StructuralChange } from '$libs/dom';
export { BodyScrollLock } from './body-scroll-lock.svelte.js';
export type { BodyScrollLockOption } from './body-scroll-lock.svelte.js';
export { DOMContext } from './dom-context.svelte.js';
export { RovingFocusGroup } from './roving-focus-group.svelte.js';
export type { RovingFocusOrientation } from './roving-focus-group.svelte.js';

@ -0,0 +1,162 @@
import { state, type Active, type State } from '$reactive';
import { isBrowser, isHTMLElement, getElementDirection } from '$libs/dom';
export type RovingFocusOrientation = 'horizontal' | 'vertical';
type DirectionalKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown';
const KEYS = {
ARROW_LEFT: 'ArrowLeft',
ARROW_RIGHT: 'ArrowRight',
ARROW_UP: 'ArrowUp',
ARROW_DOWN: 'ArrowDown',
HOME: 'Home',
END: 'End'
} as const;
type RovingFocusGroupOptions = (
| { candidateAttr: string; candidateSelector?: undefined }
| { candidateSelector: string; candidateAttr?: undefined }
) & {
providerNode: Active<HTMLElement | null> | State<HTMLElement | null>;
loop: Active<boolean>;
orientation: Active<RovingFocusOrientation>;
onCandidateFocus?: (node: HTMLElement) => void;
};
function getDirectionalKeys(
dir: 'ltr' | 'rtl',
orientation: RovingFocusOrientation
): { nextKey: DirectionalKey; prevKey: DirectionalKey } {
if (orientation === 'vertical') {
return { nextKey: KEYS.ARROW_DOWN, prevKey: KEYS.ARROW_UP };
}
return {
nextKey: dir === 'rtl' ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT,
prevKey: dir === 'rtl' ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT
};
}
function escapeId(id: string): string {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(id);
return id;
}
export class RovingFocusGroup {
readonly opts: RovingFocusGroupOptions;
readonly currentTabStopId = state<string | null>(null);
constructor(opts: RovingFocusGroupOptions) {
this.opts = opts;
}
/**
* Collects the current candidate nodes. Disabled candidates (marked with
* `data-disabled`) are filtered out regardless of which selector strategy
* the consumer chose.
*/
getCandidateNodes(): HTMLElement[] {
if (!isBrowser) return [];
const providerNode = this.opts.providerNode.current;
if (!providerNode) return [];
const selector = this.opts.candidateSelector ?? `[${this.opts.candidateAttr}]`;
const nodes = providerNode.querySelectorAll<HTMLElement>(selector);
return Array.from(nodes).filter((node) => !node.hasAttribute('data-disabled'));
}
/**
* Ensures a default tab stop exists. Safe to call in an effect — idempotent
* once `currentTabStopId` is set. Call this from the consumer's mount hook
* so `getTabIndex` can be a pure read.
*/
initializeDefaultTabStop(): void {
if (this.currentTabStopId.current !== null) return;
const first = this.getCandidateNodes()[0];
if (first) this.currentTabStopId.current = first.id;
}
focusFirstCandidate(): void {
const first = this.getCandidateNodes()[0];
if (!first) return;
first.focus();
this.currentTabStopId.current = first.id;
}
handleKeydown(
node: HTMLElement | null | undefined,
event: KeyboardEvent,
both = false
): HTMLElement | undefined {
const providerNode = this.opts.providerNode.current;
if (!providerNode || !node) return;
const items = this.getCandidateNodes();
if (!items.length) return;
const currentIndex = items.indexOf(node);
const dir = getElementDirection(providerNode);
const { nextKey, prevKey } = getDirectionalKeys(dir, this.opts.orientation.current);
const loop = this.opts.loop.current;
const keyToIndex: Partial<Record<string, number>> = {
[nextKey]: currentIndex + 1,
[prevKey]: currentIndex - 1,
[KEYS.HOME]: 0,
[KEYS.END]: items.length - 1
};
if (both) {
const altNextKey = nextKey === KEYS.ARROW_DOWN ? KEYS.ARROW_RIGHT : KEYS.ARROW_DOWN;
const altPrevKey = prevKey === KEYS.ARROW_UP ? KEYS.ARROW_LEFT : KEYS.ARROW_UP;
keyToIndex[altNextKey] = currentIndex + 1;
keyToIndex[altPrevKey] = currentIndex - 1;
}
let itemIndex = keyToIndex[event.key];
if (itemIndex === undefined) return;
event.preventDefault();
if (itemIndex < 0 && loop) {
itemIndex = items.length - 1;
} else if (itemIndex === items.length && loop) {
itemIndex = 0;
}
const itemToFocus = items[itemIndex];
if (!itemToFocus) return;
itemToFocus.focus();
this.currentTabStopId.current = itemToFocus.id;
this.opts.onCandidateFocus?.(itemToFocus);
return itemToFocus;
}
/**
* Pure reader — returns the tabindex a candidate should render with given
* the current state. Never mutates. Call `initializeDefaultTabStop()` from
* the consumer's mount hook to seed the initial value.
*/
getTabIndex(node: HTMLElement | null | undefined): 0 | -1 {
if (!node) return -1;
const currentId = this.currentTabStopId.current;
if (currentId === null) {
return this.getCandidateNodes()[0] === node ? 0 : -1;
}
return node.id === currentId ? 0 : -1;
}
setCurrentTabStopId(id: string): void {
this.currentTabStopId.current = id;
}
focusCurrentTabStop(): void {
const id = this.currentTabStopId.current;
if (!id) return;
const node = this.opts.providerNode.current?.querySelector(`#${escapeId(id)}`);
if (!node || !isHTMLElement(node)) return;
node.focus();
}
}

@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest';
import { readableActive } from '$reactive';
import { createActiveDom } from '../active-dom.svelte';
describe('ActiveDom', () => {
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: 1024
});
document.body.innerHTML = '';
window.dispatchEvent(new Event('resize'));
});
it('uses default breakpoints when no overrides are provided', () => {
const dom = createActiveDom();
expect(dom.currentBreakpoint.current).toBe('lg');
expect(dom.isAtLeast('md')).toBe(true);
expect(dom.matches('lg')).toBe(true);
});
it('merges partial breakpoint overrides', () => {
const dom = createActiveDom({
breakpoints: readableActive(() => ({ lg: 1200 }))
});
expect(dom.breakpoints.current.lg).toBe(1200);
expect(dom.currentBreakpoint.current).toBe('md');
});
it('tracks window resize and updates viewport-derived state', () => {
const dom = createActiveDom();
expect(dom.viewport.width).toBe(1024);
expect(dom.currentBreakpoint.current).toBe('lg');
window.innerWidth = 460;
window.dispatchEvent(new Event('resize'));
expect(dom.viewport.width).toBe(460);
expect(dom.currentBreakpoint.current).toBe('base');
expect(dom.isAtLeast('sm')).toBe(false);
expect(dom.resolve({ base: 'stack', sm: 'inline' })).toBe('stack');
});
});

@ -0,0 +1,115 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BodyScrollLock } from '../body-scroll-lock.svelte';
describe('BodyScrollLock', () => {
beforeEach(() => {
vi.useFakeTimers();
BodyScrollLock.reset();
document.body.setAttribute('style', 'background: red;');
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: 1200
});
Object.defineProperty(document.documentElement, 'clientWidth', {
configurable: true,
value: 1180
});
});
afterEach(() => {
BodyScrollLock.reset();
vi.useRealTimers();
});
it('locks body scroll and restores the initial body style when unlocked', async () => {
const lock = new BodyScrollLock(true);
await Promise.resolve();
expect(document.body.style.overflow).toBe('hidden');
expect(document.body.style.getPropertyValue('--scrollbar-width')).toBe('20px');
lock.locked.current = false;
vi.runAllTimers();
await Promise.resolve();
expect(document.body.getAttribute('style')).toBe('background: red;');
});
it('does not block pointer events while locked', async () => {
const lock = new BodyScrollLock(true);
await Promise.resolve();
expect(document.body.style.pointerEvents).toBe('');
lock.destroy();
vi.runAllTimers();
await Promise.resolve();
});
it('keeps the body locked until the last lock is released', async () => {
const first = new BodyScrollLock(true);
const second = new BodyScrollLock(true);
await Promise.resolve();
first.locked.current = false;
vi.runAllTimers();
await Promise.resolve();
expect(document.body.style.overflow).toBe('hidden');
second.destroy();
vi.runAllTimers();
await Promise.resolve();
expect(document.body.getAttribute('style')).toBe('background: red;');
});
it('uses the last active lock delay instead of stale unlocked instances', async () => {
const first = new BodyScrollLock(true, () => 200);
const second = new BodyScrollLock(true, () => 50);
await Promise.resolve();
first.locked.current = false;
vi.runAllTimers();
await Promise.resolve();
expect(document.body.style.overflow).toBe('hidden');
second.locked.current = false;
vi.advanceTimersByTime(49);
await Promise.resolve();
expect(document.body.style.overflow).toBe('hidden');
vi.advanceTimersByTime(1);
await Promise.resolve();
expect(document.body.getAttribute('style')).toBe('background: red;');
});
it('keeps the final lock restore delay when destroyed', async () => {
const lock = new BodyScrollLock(true, () => 120);
await Promise.resolve();
lock.destroy();
vi.advanceTimersByTime(119);
await Promise.resolve();
expect(document.body.style.overflow).toBe('hidden');
vi.advanceTimersByTime(1);
await Promise.resolve();
expect(document.body.getAttribute('style')).toBe('background: red;');
});
it('allows creating unlocked instances without mutating the body', () => {
new BodyScrollLock(false);
expect(document.body.getAttribute('style')).toBe('background: red;');
});
});

@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DOMContext } from '../dom-context.svelte';
describe('DOMContext', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('falls back to the global document when no element is present', () => {
const node = document.createElement('div');
node.id = 'global-node';
document.body.appendChild(node);
const context = new DOMContext(() => null);
expect(context.getDocument()).toBe(document);
expect(context.getWindow()).toBe(window);
expect(context.getElementById('global-node')).toBe(node);
expect(context.querySelector('#global-node')).toBe(node);
});
it('scopes queries and active element to the element root', () => {
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
const input = document.createElement('input');
input.id = 'shadow-input';
shadow.appendChild(input);
document.body.appendChild(host);
const context = new DOMContext(() => input);
input.focus();
expect(context.provider.current).toBe(shadow);
expect(context.querySelector('#shadow-input')).toBe(input);
expect(context.getActiveElement()).toBe(input);
expect(context.isActiveElement(input)).toBe(true);
});
it('proxies timers through the provider window', () => {
vi.useFakeTimers();
const context = new DOMContext(() => null);
const callback = vi.fn();
const timeoutId = context.setTimeout(callback, 10);
vi.advanceTimersByTime(10);
expect(callback).toHaveBeenCalledTimes(1);
context.clearTimeout(timeoutId);
vi.useRealTimers();
});
});

@ -0,0 +1,144 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { readableActive } from '$reactive';
import { RovingFocusGroup } from '../roving-focus-group.svelte';
function createGroup(
root: HTMLElement,
orientation: 'horizontal' | 'vertical' = 'horizontal',
loop = true
) {
return new RovingFocusGroup({
candidateAttr: 'data-roving-item',
providerNode: readableActive(() => root),
loop: readableActive(() => loop),
orientation: readableActive(() => orientation)
});
}
function createKeydownEvent(key: string): KeyboardEvent {
return new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
}
describe('RovingFocusGroup', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('assigns the first candidate as the default tab stop', () => {
const root = document.createElement('div');
const first = document.createElement('button');
const second = document.createElement('button');
first.id = 'first';
second.id = 'second';
first.setAttribute('data-roving-item', '');
second.setAttribute('data-roving-item', '');
root.append(first, second);
document.body.appendChild(root);
const group = createGroup(root);
expect(group.getTabIndex(first)).toBe(0);
expect(group.getTabIndex(second)).toBe(-1);
group.initializeDefaultTabStop();
expect(group.currentTabStopId.current).toBe('first');
});
it('filters out disabled candidates', () => {
const root = document.createElement('div');
const first = document.createElement('button');
const disabled = document.createElement('button');
const second = document.createElement('button');
first.id = 'first';
disabled.id = 'disabled';
second.id = 'second';
first.setAttribute('data-roving-item', '');
disabled.setAttribute('data-roving-item', '');
disabled.setAttribute('data-disabled', '');
second.setAttribute('data-roving-item', '');
root.append(first, disabled, second);
document.body.appendChild(root);
const group = createGroup(root);
expect(group.getCandidateNodes()).toEqual([first, second]);
group.handleKeydown(first, createKeydownEvent('ArrowRight'));
expect(document.activeElement).toBe(second);
});
it('moves focus to the next candidate and loops when configured', () => {
const root = document.createElement('div');
const first = document.createElement('button');
const second = document.createElement('button');
first.id = 'first';
second.id = 'second';
first.setAttribute('data-roving-item', '');
second.setAttribute('data-roving-item', '');
root.append(first, second);
document.body.appendChild(root);
const group = createGroup(root);
const firstEvent = createKeydownEvent('ArrowRight');
const secondEvent = createKeydownEvent('ArrowRight');
group.handleKeydown(first, firstEvent);
expect(document.activeElement).toBe(second);
expect(group.currentTabStopId.current).toBe('second');
group.handleKeydown(second, secondEvent);
expect(document.activeElement).toBe(first);
expect(group.currentTabStopId.current).toBe('first');
});
it('respects rtl horizontal navigation', () => {
const root = document.createElement('div');
root.style.direction = 'rtl';
const first = document.createElement('button');
const second = document.createElement('button');
first.id = 'first';
second.id = 'second';
first.setAttribute('data-roving-item', '');
second.setAttribute('data-roving-item', '');
root.append(first, second);
document.body.appendChild(root);
const group = createGroup(root);
const event = createKeydownEvent('ArrowLeft');
group.handleKeydown(first, event);
expect(document.activeElement).toBe(second);
expect(group.currentTabStopId.current).toBe('second');
});
it('calls onCandidateFocus and can refocus the current tab stop', () => {
const root = document.createElement('div');
const first = document.createElement('button');
const second = document.createElement('button');
first.id = 'first';
second.id = 'second';
first.setAttribute('data-roving-item', '');
second.setAttribute('data-roving-item', '');
root.append(first, second);
document.body.appendChild(root);
const onCandidateFocus = vi.fn();
const group = new RovingFocusGroup({
candidateAttr: 'data-roving-item',
providerNode: readableActive(() => root),
loop: readableActive(() => true),
orientation: readableActive(() => 'horizontal'),
onCandidateFocus
});
group.handleKeydown(first, createKeydownEvent('ArrowRight'));
group.focusCurrentTabStop();
expect(onCandidateFocus).toHaveBeenCalledWith(second);
expect(document.activeElement).toBe(second);
});
});

@ -0,0 +1,82 @@
import { isBrowser } from '$libs/dom';
/**
* Read-only view over a tracked viewport width plus a `dispose()` to detach
* any window listener it owns.
*
* Two factories produce a `ViewportTracker`:
*
* - `getSharedViewport()` returns the process-wide singleton. The first call
* allocates the reactive cell and attaches a single resize listener on
* `window`. All subsequent calls return the same object. `dispose()` is a
* no-op — the singleton lives for the lifetime of the process.
* - `createViewportTracker(targetWindow?)` returns a per-instance tracker
* scoped to the supplied window (defaults to the global `window`). Owns
* its own `resize` listener; `dispose()` removes it.
*
* `ActiveDom` consumes `getSharedViewport()` only when constructed with
* `{ shareViewport: true }`; otherwise it owns its own tracker so iframes,
* popups and happy-dom test environments do not bleed into each other.
*
* Both factories defer all reactive-cell allocation, `window.innerWidth`
* reads and listener registration to first call so importing this module is
* a pure side-effect-free operation.
*/
export interface ViewportTracker {
readonly width: number;
dispose: () => void;
}
// ── Shared singleton ────────────────────────────────────────────────────────
let sharedTracker: ViewportTracker | undefined;
export function getSharedViewport(): ViewportTracker {
if (sharedTracker !== undefined) return sharedTracker;
const state = $state({ width: isBrowser ? window.innerWidth : 0 });
if (isBrowser) {
const update = (): void => {
state.width = window.innerWidth;
};
window.addEventListener('resize', update, { passive: true });
// Process-lifetime listener — intentionally not removable.
}
sharedTracker = {
get width() {
return state.width;
},
dispose: () => {
// Singleton — no-op. Removing the listener would break every other
// ActiveDom instance still consuming the shared tracker.
}
};
return sharedTracker;
}
// ── Per-instance tracker ────────────────────────────────────────────────────
export function createViewportTracker(targetWindow?: Window): ViewportTracker {
const win = targetWindow ?? (isBrowser ? window : undefined);
const local = $state({ width: win?.innerWidth ?? 0 });
let detach: (() => void) | undefined;
if (win) {
const update = (): void => {
local.width = win.innerWidth;
};
update();
win.addEventListener('resize', update, { passive: true });
detach = () => win.removeEventListener('resize', update);
}
return {
get width() {
return local.width;
},
dispose: () => detach?.()
};
}

@ -0,0 +1,416 @@
# auth
`auth` es la capa activa de autenticación para Svelte. Su trabajo no es
decidir permisos ni guardar sesiones por su cuenta: su trabajo es reflejar en
cliente el resultado de un motor autoritativo server-side.
El módulo está partido en tres capas, igual que `perm` y `cach`:
| Capa | Entrada | Qué contiene |
| ---------------- | ------------ | ---------------------------------------------------------------------------------- |
| Lenguaje común | `$libs/auth` | Constantes, tipos, errores, eventos, helpers CSRF/token, contratos de adapters |
| Autoridad server | `$svrs/auth` | `createEngineAuth()`, handlers HTTP, password flow, CSRF, devices, session binding |
| Cliente activo | `$auth` | `createActiveAuth()`, estado reactivo, llamadas HTTP, CSRF header wiring |
La regla mental es sencilla:
```txt
auth prueba identidad
sess mantiene continuidad de sesión
perm decide autorización
cach invalida datos derivados de identidad
stor no guarda secretos
```
## Qué Está Implementado
En esta versión el camino sólido es:
- Password sign-up.
- Password sign-in.
- Current session snapshot.
- Local sign-out.
- Global sign-out.
- CSRF issue/verify.
- Email verification request/complete sobre flows.
- Password reset request/complete sobre flows.
- Device records básicos.
- Eventos/auditoría.
- Invalidación de cache por identidad.
- Typed errors con `code` (`ErrCode` canónico tipo `'auth::credential_invalid'`).
- Memory/test adapters.
- Adapter DB genérico sin dependencia de ORM.
- Adapter `scrypt` Node sin dependencia externa.
- Contratos para OAuth, MFA y WebAuthn.
OAuth, MFA y WebAuthn existen como superficie de extensión y algunos métodos
base, pero no deben documentarse como un flujo production-ready completo aún.
## Uso Rápido en Cliente
La forma normal en UI es crearlo desde `aapp`, porque `App` ya tiene `Http` y
`Cache`:
```ts
import { createActiveApp } from '$active-app';
const App = createActiveApp({
http: { baseUrl: '' }
});
const Auth = App.createActiveAuth({
initial: data.auth
});
```
`App.auth` empieza en `undefined` y queda definido tras llamar
`App.createActiveAuth(...)`. Igual que session y permissions, solo puede haber
un active auth por App:
```ts
const Auth = App.createActiveAuth();
Auth.authenticated;
Auth.loading;
Auth.lastError;
Auth.current.session.status;
```
Sign-in:
```ts
try {
await Auth.signInPassword({
identifier: 'ada@example.com',
password: 'correct horse battery staple'
});
} catch {
// El error seguro queda normalizado en Auth.lastError.
console.log(Auth.lastError?.code);
}
```
Sign-up:
```ts
await Auth.signUpPassword({
identifier: 'ada@example.com',
password: 'correct horse battery staple',
profile: {
displayName: 'Ada Lovelace'
}
});
```
Logout:
```ts
await Auth.signOut();
```
Logout global:
```ts
await Auth.signOutGlobal();
```
Current session:
```ts
await Auth.loadCurrent();
if (Auth.authenticated) {
console.log(Auth.current.actor?.primaryIdentifier);
}
```
Devices:
```ts
const devices = await Auth.listDevices();
await Auth.revokeDevice({
deviceId: devices[0].id
});
```
## Estado Reactivo
`ActiveAuth` sigue el contrato común `ActiveEngine`: getters directos,
`snapshot()`, `onChange()`, `clearError()` y `dispose()`.
```ts
Auth.current; // AuthCurrentView
Auth.loading; // boolean
Auth.lastError; // AuthClientSafeError | null
Auth.authenticated; // boolean
Auth.mfaRequired; // boolean
Auth.disposed; // boolean
Auth.snapshot(); // AuthCurrentView
```
En Svelte:
```svelte
<script lang="ts">
const Auth = App.createActiveAuth({ initial: data.auth });
</script>
{#if Auth.loading}
<p>Validando...</p>
{:else if Auth.authenticated}
<p>Hola {Auth.current.actor?.displayName}</p>
{:else}
<p>Sesión anónima</p>
{/if}
{#if Auth.lastError}
<p>{Lang.t(codeToLangPath(Auth.lastError.code))}</p>
{/if}
```
## Cómo Funciona CSRF
Las operaciones mutables del active client hacen esto automáticamente:
1. `GET AUTH_ROUTE_PATHS.CSRF`
2. reciben `{ token, expiresAt }`
3. envían el token en `AUTH_HEADER_NAMES.CSRF`
4. el server compara header + cookie firmada
El token no se guarda en `stor`. Si se pasa `stor`, solo se marca que CSRF fue
emitido para debugging/UX, pero no se persiste un secreto.
Ejemplo interno equivalente:
```ts
const csrf = await http.get(AUTH_ROUTE_PATHS.CSRF);
await http.post(AUTH_ROUTE_PATHS.SIGN_IN_PASSWORD, body, {
headers: {
[AUTH_HEADER_NAMES.CSRF]: csrf.token
}
});
```
## Patrón SvelteKit Recomendado
El server resuelve la sesión y se la pasa al layout o página:
```ts
// +layout.server.ts
import { Auth } from '$lib/server/auth';
import { resolveTenantId } from '$lib/server/tenant';
export async function load({ request }) {
const tenantId = resolveTenantId(request);
const auth = await Auth.current({ tenantId, request });
return { auth };
}
```
En cliente:
```svelte
<!-- +layout.svelte -->
<script lang="ts">
import { createActiveApp } from '$active-app';
let { data, children } = $props();
const App = createActiveApp();
const Auth = App.createActiveAuth({
initial: data.auth
});
</script>
{@render children()}
```
Las rutas HTTP de auth se montan server-side:
```ts
// src/web/routes/api/auth/[...path]/+server.ts
import { Auth } from '$lib/server/auth';
import { resolveTenantId } from '$lib/server/tenant';
function input(request: Request) {
return {
request,
tenantId: resolveTenantId(request)
};
}
export const GET = ({ request }) => Auth.handlers.handle(input(request));
export const POST = ({ request }) => Auth.handlers.handle(input(request));
```
El handler despacha por las rutas constantes:
```ts
AUTH_ROUTE_PATHS.CURRENT;
AUTH_ROUTE_PATHS.CSRF;
AUTH_ROUTE_PATHS.SIGN_UP_PASSWORD;
AUTH_ROUTE_PATHS.SIGN_IN_PASSWORD;
AUTH_ROUTE_PATHS.SIGN_OUT;
AUTH_ROUTE_PATHS.SIGN_OUT_GLOBAL;
AUTH_ROUTE_PATHS.EMAIL_VERIFY_REQUEST;
AUTH_ROUTE_PATHS.EMAIL_VERIFY_COMPLETE;
AUTH_ROUTE_PATHS.PASSWORD_RESET_REQUEST;
AUTH_ROUTE_PATHS.PASSWORD_RESET_COMPLETE;
```
## Errores en Cliente
Los errores seguros tienen esta forma:
```ts
interface AuthClientSafeError {
code: AuthErrorCode; // ErrCode canónico, p.ej. 'auth::credential_invalid'
}
```
Ejemplo:
```ts
import { AUTH_ERR_CREDENTIAL_INVALID } from '$auth';
try {
await Auth.signInPassword({ identifier, password });
} catch {
const error = Auth.lastError;
if (error?.code === AUTH_ERR_CREDENTIAL_INVALID) {
// Mostrar mensaje traducido desde Lang.
}
}
```
No uses el texto del error como lógica. Usa los constantes `AUTH_ERR_*` o
`matches(err, AUTH_ERR)` para descubrir cualquier error de auth.
## Relación con `sess`
`auth` no emite la cookie principal de sesión. En un login correcto:
1. `auth` verifica credencial.
2. `auth` crea/actualiza device.
3. `auth` llama a `AuthSessPort.start(...)`.
4. `sess` devuelve `sessionId` y snapshot.
5. `auth` guarda un binding `sessionId -> actorRef`.
6. `auth` emite eventos e invalida cache.
En logout:
1. `auth` lee la sesión desde `sess`.
2. revoca el binding auth.
3. llama a `sess.end(...)` o `sess.endMany(...)`.
4. invalida `cach`.
5. emite auditoría.
## Relación con `perm`
`auth` no decide permisos. Lo único que entrega al resto del sistema es:
```ts
actorRef;
sessionId;
aal;
amr;
authTime;
deviceId;
```
`perm` puede usar ese contexto para decidir:
```ts
Perms.can({
actor,
action: 'project:update',
resource
});
```
Si cambia la identidad, roles o permisos, el consumer debe invalidar las
decisiones de `perm` y los datos en `cach`. Cuando `ActiveAuth` se crea desde
`App.createActiveAuth()`, App inyecta un port de cache y limpia `App.cache`
automáticamente tras login/logout/reset/revoke-device para evitar datos de una
identidad anterior.
## Relación con `stor`
No guardes access tokens, refresh tokens, OTPs, CSRF tokens ni passwords en
`stor`. Si necesitas persistencia de preferencias de auth, guarda solo datos
no sensibles:
```ts
stor.entry('last-login-email', '', { raw: true });
```
## Test Page
La página `/test/auth` monta un harness en memoria y permite probar:
- sign-up
- sign-in
- sign-out
- global sign-out
- reset del harness
- CSRF roundtrip
- CSRF expirado
- snapshot actual
- devices
- credentials
- audit events
- cache invalidations
Es una página de diagnóstico funcional, no un ejemplo de UI final.
## Archivos Principales
```txt
src/libs/auth/
consts.ts # rutas, headers, cookies, eventos, códigos, defaults
types.ts # tipos públicos
contracts.ts # puertos/adapters
errors.ts # errores tipados + guards
csrf.ts # issue/verify
tokens.ts # random/base64/hash helpers
src/svrs/auth/
engine-auth.ts # createEngineAuth()
handlers.ts # HTTP handlers
options.ts # EngineAuthOptions
adapters/ # memory, db, crypto, password, mail...
integrations/ # bridges con sess, perm, cach, logr, timr server-side...
src/arts/auth/
active-auth.svelte.ts
client.ts
types.ts
```
## Qué No Debe Hacer `auth`
- No guarda perfiles completos de usuario.
- No decide autorización.
- No persiste secretos en cliente.
- No hace magia dentro de `App.http`.
- No mezcla tenants.
- No autolinka OAuth por email no verificado.
- No expone raw password hashes, tokens o OTPs a UI.
## Checklist de Producción
Antes de usarlo fuera de tests:
- Sustituir memory adapters por adapters reales.
- Usar un `AuthStoreAdapter` transaccional.
- Conectar `AuthSessPort` al módulo real `sess`.
- Conectar `AuthLogrPort` a `logr`.
- Conectar `AuthCachePort` a `cach`.
- Usar `createNodeScryptPasswordHasher()` o un adapter Argon2id propio.
- Resolver `tenantId` desde request, subdominio, organización o app config.
- Mantener `security.csrf.signingKey` fuera del repo.
- Validar cookies `Secure` en producción.
- Añadir rate-limit real a sign-in, reset y verification.

@ -0,0 +1,111 @@
import {
AUTH_CLIENT_DIAGNOSTIC_EVENTS,
AUTH_HEADER_NAMES,
AUTH_METHOD_LIST_DEVICES,
AUTH_ROUTE_PATHS,
AUTH_STORAGE_KEYS
} from './consts.ts';
import { authCacheTagsForIdentity } from '$libs/auth/helpers';
import { AUTH_ERR_ADAPTER_FAILED } from '$libs/auth/errors';
import { AuthInvalidResponseError, isAuthRequestFailedError } from './errors.ts';
import { emitAuthClientDiagnostic, type AuthClientDiagnostics } from './diagnostics.ts';
import type {
AuthClientSafeError,
AuthCurrentView,
AuthDevicePublicView,
AuthEventName
} from '$libs/auth/types';
import type { ActiveAuthOptions } from './types';
export async function ensureAuthCsrf(
options: ActiveAuthOptions
): Promise<{ readonly token: string }> {
const result = readAuthCsrf(
await options.http.get<unknown>(AUTH_ROUTE_PATHS.CSRF),
AUTH_ROUTE_PATHS.CSRF
);
options.stor?.set(AUTH_STORAGE_KEYS.NON_SECRET_CSRF_CACHE, { issued: true });
return result;
}
export function csrfHeaders(csrf: { readonly token: string }) {
return { headers: { [AUTH_HEADER_NAMES.CSRF]: csrf.token } };
}
export async function postAuthWithCsrf<T>(
options: ActiveAuthOptions,
path: string,
body?: unknown
): Promise<T> {
const csrf = await ensureAuthCsrf(options);
return options.http.post<T>(path, body, csrfHeaders(csrf));
}
export function normalizeAuthClientError(error: unknown): AuthClientSafeError {
if (isAuthRequestFailedError(error) && error.safeError) return error.safeError;
if (typeof error === 'object' && error && 'error' in error) {
const wrapped = (error as { readonly error?: AuthClientSafeError }).error;
if (wrapped?.code) return wrapped;
}
return { code: AUTH_ERR_ADAPTER_FAILED };
}
export async function invalidateAuthClientCache(
options: ActiveAuthOptions,
diagnostics: AuthClientDiagnostics,
reason: AuthEventName
): Promise<void> {
if (!options.cache) return;
try {
await options.cache.invalidate({ tags: authCacheTagsForIdentity(), reason });
} catch (error) {
emitAuthClientDiagnostic(
diagnostics,
AUTH_CLIENT_DIAGNOSTIC_EVENTS.CACHE_INVALIDATION_FAILED,
{ reason, error }
);
options.onCacheError?.(error);
}
}
export function readAuthCurrent(value: unknown, method: string): AuthCurrentView {
if (isAuthCurrentView(value)) return value;
throw new AuthInvalidResponseError(method);
}
export function readAuthCurrentResult(
value: unknown,
method: string
): { readonly current: AuthCurrentView } {
if (isRecord(value) && isAuthCurrentView(value.current)) {
return { current: value.current };
}
throw new AuthInvalidResponseError(method);
}
export function readAuthDeviceList(
value: unknown,
method = AUTH_METHOD_LIST_DEVICES
): readonly AuthDevicePublicView[] {
if (Array.isArray(value)) return value as readonly AuthDevicePublicView[];
throw new AuthInvalidResponseError(method);
}
function readAuthCsrf(value: unknown, method: string): { readonly token: string } {
if (isRecord(value) && typeof value.token === 'string') return { token: value.token };
throw new AuthInvalidResponseError(method);
}
function isAuthCurrentView(value: unknown): value is AuthCurrentView {
if (!isRecord(value) || !isRecord(value.session)) return false;
const session = value.session;
return (
typeof session.status === 'string' &&
typeof session.aal === 'string' &&
Array.isArray(session.amr)
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

@ -0,0 +1,246 @@
import {
AUTH_EVENT_NAMES,
AUTH_CLIENT_DIAGNOSTIC_EVENTS,
AUTH_METHOD_CLEAR_ERROR,
AUTH_METHOD_COMPLETE_EMAIL_VERIFICATION,
AUTH_METHOD_COMPLETE_PASSWORD_RESET,
AUTH_METHOD_LIST_DEVICES,
AUTH_METHOD_LOAD_CURRENT,
AUTH_METHOD_ON_CHANGE,
AUTH_METHOD_REQUEST_EMAIL_VERIFICATION,
AUTH_METHOD_REQUEST_PASSWORD_RESET,
AUTH_METHOD_REVOKE_DEVICE,
AUTH_METHOD_SIGN_IN_PASSWORD,
AUTH_METHOD_SIGN_OUT,
AUTH_METHOD_SIGN_OUT_GLOBAL,
AUTH_METHOD_SIGN_UP_PASSWORD,
AUTH_ROUTE_PATHS,
AUTH_SESSION_STATUSES
} from './consts.ts';
import { createAnonymousAuthCurrent } from '$libs/auth/helpers';
import { AuthDisposedError } from './errors.ts';
import {
invalidateAuthClientCache,
normalizeAuthClientError,
postAuthWithCsrf,
readAuthCurrent,
readAuthCurrentResult,
readAuthDeviceList
} from './active-auth-runtime.ts';
import { createAuthClientDiagnostics, emitAuthClientDiagnostic } from './diagnostics.ts';
import type { AuthClientSafeError, AuthCurrentView, AuthDevicePublicView } from '$libs/auth/types';
import type {
ActiveAuth,
ActiveAuthOptions,
AuthActiveEmailVerificationCompleteInput,
AuthActiveEmailVerificationRequestInput,
AuthActivePasswordResetCompleteInput,
AuthActivePasswordResetRequestInput,
AuthActivePasswordSignInInput,
AuthActivePasswordSignUpInput,
AuthActiveRevokeDeviceInput
} from './types';
export function createActiveAuth(options: ActiveAuthOptions): ActiveAuth {
const diagnostics = createAuthClientDiagnostics(options.logger);
let current = $state<AuthCurrentView>(options.initial ?? createAnonymousAuthCurrent());
let loading = $state(false);
let lastError = $state<AuthClientSafeError | null>(null);
let disposed = false;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- listeners are notified manually, not rendered state.
const listeners = new Set<(snapshot: AuthCurrentView) => void>();
function notify(): void {
const next = current;
for (const listener of [...listeners]) listener(next);
}
async function loadCurrent(): Promise<AuthCurrentView> {
return run(AUTH_METHOD_LOAD_CURRENT, async () => {
current = readAuthCurrent(
await options.http.get<unknown>(AUTH_ROUTE_PATHS.CURRENT),
AUTH_METHOD_LOAD_CURRENT
);
notify();
return current;
});
}
async function signInPassword(input: AuthActivePasswordSignInInput): Promise<AuthCurrentView> {
return run(AUTH_METHOD_SIGN_IN_PASSWORD, async () => {
const result = readAuthCurrentResult(
await postAuthWithCsrf(options, AUTH_ROUTE_PATHS.SIGN_IN_PASSWORD, input),
AUTH_METHOD_SIGN_IN_PASSWORD
);
current = result.current;
await invalidateAuthClientCache(options, diagnostics, AUTH_EVENT_NAMES.SIGN_IN_SUCCEEDED);
notify();
return current;
});
}
async function signUpPassword(input: AuthActivePasswordSignUpInput): Promise<AuthCurrentView> {
return run(AUTH_METHOD_SIGN_UP_PASSWORD, async () => {
const result = readAuthCurrentResult(
await postAuthWithCsrf(options, AUTH_ROUTE_PATHS.SIGN_UP_PASSWORD, input),
AUTH_METHOD_SIGN_UP_PASSWORD
);
current = result.current;
await invalidateAuthClientCache(options, diagnostics, AUTH_EVENT_NAMES.SIGN_UP_SUCCEEDED);
notify();
return current;
});
}
async function signOut(): Promise<void> {
await run(AUTH_METHOD_SIGN_OUT, async () => {
await postAuthWithCsrf(options, AUTH_ROUTE_PATHS.SIGN_OUT);
current = createAnonymousAuthCurrent();
await invalidateAuthClientCache(options, diagnostics, AUTH_EVENT_NAMES.SIGN_OUT_SUCCEEDED);
notify();
});
}
async function signOutGlobal(): Promise<void> {
await run(AUTH_METHOD_SIGN_OUT_GLOBAL, async () => {
await postAuthWithCsrf(options, AUTH_ROUTE_PATHS.SIGN_OUT_GLOBAL);
current = createAnonymousAuthCurrent();
await invalidateAuthClientCache(
options,
diagnostics,
AUTH_EVENT_NAMES.SIGN_OUT_GLOBAL_SUCCEEDED
);
notify();
});
}
async function requestPasswordReset(input: AuthActivePasswordResetRequestInput): Promise<void> {
await run(AUTH_METHOD_REQUEST_PASSWORD_RESET, async () => {
await postAuthWithCsrf(options, AUTH_ROUTE_PATHS.PASSWORD_RESET_REQUEST, input);
});
}
async function completePasswordReset(input: AuthActivePasswordResetCompleteInput): Promise<void> {
await run(AUTH_METHOD_COMPLETE_PASSWORD_RESET, async () => {
await postAuthWithCsrf(options, AUTH_ROUTE_PATHS.PASSWORD_RESET_COMPLETE, input);
current = createAnonymousAuthCurrent();
await invalidateAuthClientCache(
options,
diagnostics,
AUTH_EVENT_NAMES.PASSWORD_RESET_COMPLETED
);
notify();
});
}
async function requestEmailVerification(
input: AuthActiveEmailVerificationRequestInput
): Promise<void> {
await run(AUTH_METHOD_REQUEST_EMAIL_VERIFICATION, async () => {
await postAuthWithCsrf(options, AUTH_ROUTE_PATHS.EMAIL_VERIFY_REQUEST, input);
});
}
async function completeEmailVerification(
input: AuthActiveEmailVerificationCompleteInput
): Promise<void> {
await run(AUTH_METHOD_COMPLETE_EMAIL_VERIFICATION, async () => {
await postAuthWithCsrf(options, AUTH_ROUTE_PATHS.EMAIL_VERIFY_COMPLETE, input);
await loadCurrent();
await invalidateAuthClientCache(options, diagnostics, AUTH_EVENT_NAMES.EMAIL_VERIFIED);
});
}
async function listDevices(): Promise<readonly AuthDevicePublicView[]> {
return run(AUTH_METHOD_LIST_DEVICES, async () =>
readAuthDeviceList(
await options.http.get<unknown>(AUTH_ROUTE_PATHS.DEVICES),
AUTH_METHOD_LIST_DEVICES
)
);
}
async function revokeDevice(input: AuthActiveRevokeDeviceInput): Promise<void> {
await run(AUTH_METHOD_REVOKE_DEVICE, async () => {
await postAuthWithCsrf(options, AUTH_ROUTE_PATHS.DEVICE_REVOKE, input);
await loadCurrent();
await invalidateAuthClientCache(options, diagnostics, AUTH_EVENT_NAMES.DEVICE_REVOKED);
});
}
async function run<T>(method: string, operation: () => Promise<T>): Promise<T> {
ensureLive(method);
loading = true;
lastError = null;
try {
return await operation();
} catch (error) {
lastError = normalizeAuthClientError(error);
emitAuthClientDiagnostic(diagnostics, AUTH_CLIENT_DIAGNOSTIC_EVENTS.OPERATION_FAILED, {
method,
error
});
throw error;
} finally {
loading = false;
}
}
function ensureLive(method: string): void {
if (disposed) throw new AuthDisposedError(method);
}
return {
get current() {
return current;
},
get loading() {
return loading;
},
get lastError() {
return lastError;
},
get disposed() {
return disposed;
},
get authenticated() {
return current.session.status === AUTH_SESSION_STATUSES.AUTHENTICATED;
},
get mfaRequired() {
return current.session.status === AUTH_SESSION_STATUSES.MFA_REQUIRED;
},
loadCurrent,
signInPassword,
signUpPassword,
signOut,
signOutGlobal,
requestPasswordReset,
completePasswordReset,
requestEmailVerification,
completeEmailVerification,
listDevices,
revokeDevice,
clearError() {
ensureLive(AUTH_METHOD_CLEAR_ERROR);
lastError = null;
},
snapshot() {
return current;
},
onChange(listener) {
ensureLive(AUTH_METHOD_ON_CHANGE);
listeners.add(listener);
listener(current);
return () => {
listeners.delete(listener);
};
},
dispose() {
if (disposed) return;
disposed = true;
loading = false;
lastError = null;
listeners.clear();
}
};
}

@ -0,0 +1,113 @@
import type { StandardSchemaV1 } from '$libs/standard-schema';
import type { EngineHttp, HttpBodyInit, HttpResult } from '$http';
import type { AuthClientHttpPort, AuthClientRequestOptions } from '$libs/auth/contracts';
import { AUTH_CONTENT_TYPES, AUTH_HEADER_NAMES, AUTH_HTTP_METHODS } from './consts.ts';
import { AUTH_ERROR_MSG_RESPONSE_NOT_JSON, AuthRequestFailedError } from './errors.ts';
import type { AuthClientSafeError } from '$libs/auth/types';
const AUTH_JSON_PASSTHROUGH_SCHEMA: StandardSchemaV1<unknown, unknown> = {
'~standard': {
version: 1,
vendor: 'active-auth',
validate(value) {
return { value };
}
}
};
export function createFetchAuthClient(fetchImpl: typeof fetch = fetch): AuthClientHttpPort {
return {
async get<T>(url: string, options?: AuthClientRequestOptions) {
const response = await fetchImpl(url, {
method: AUTH_HTTP_METHODS.GET,
headers: options?.headers
});
return readJson<T>(response);
},
async post<T>(url: string, body?: unknown, options?: AuthClientRequestOptions) {
const response = await fetchImpl(url, {
method: AUTH_HTTP_METHODS.POST,
headers: {
[AUTH_HEADER_NAMES.CONTENT_TYPE]: AUTH_CONTENT_TYPES.JSON,
...(options?.headers ?? {})
},
body: body === undefined ? undefined : JSON.stringify(body)
});
return readJson<T>(response);
}
};
}
export function createEngineHttpAuthClient(http: EngineHttp): AuthClientHttpPort {
return {
async get<T>(url: string, options?: AuthClientRequestOptions) {
return unwrapHttpResult<T>(
await http.get(url, { headers: options?.headers, schema: AUTH_JSON_PASSTHROUGH_SCHEMA })
);
},
async post<T>(url: string, body?: unknown, options?: AuthClientRequestOptions) {
return unwrapHttpResult<T>(
await http.post(url, {
body: toHttpBody(body),
headers: options?.headers,
schema: AUTH_JSON_PASSTHROUGH_SCHEMA
})
);
}
};
}
async function readJson<T>(response: Response): Promise<T> {
const payload = await readResponseJson(response);
if (!response.ok) {
throw new AuthRequestFailedError(
response.url,
response.status,
response.statusText,
safeErrorFromPayload(payload),
payload
);
}
return payload as T;
}
async function readResponseJson(response: Response): Promise<unknown> {
const text = await response.text();
if (!text) return undefined;
try {
return JSON.parse(text) as unknown;
} catch (error) {
throw new AuthRequestFailedError(
response.url,
response.status,
response.statusText || AUTH_ERROR_MSG_RESPONSE_NOT_JSON,
undefined,
text,
error
);
}
}
function safeErrorFromPayload(payload: unknown): AuthClientSafeError | undefined {
if (typeof payload !== 'object' || payload === null || !('error' in payload)) return undefined;
const error = (payload as { readonly error?: unknown }).error;
if (typeof error !== 'object' || error === null) return undefined;
if ('code' in error && typeof (error as { code: unknown }).code === 'string') {
return error as AuthClientSafeError;
}
return undefined;
}
function unwrapHttpResult<T>(result: HttpResult<unknown>): T {
if (result.ok) return result.value as T;
if (result.kind === 'http') throw result.body;
throw result;
}
function toHttpBody(body: unknown): HttpBodyInit {
if (body === undefined || body === null) return body;
if (Array.isArray(body)) return body;
if (typeof body === 'object') return body as Record<string, unknown>;
return String(body);
}

@ -0,0 +1,38 @@
export {
AUTH_CONTENT_TYPES,
AUTH_COOKIE_NAMES,
AUTH_EVENT_NAMES,
AUTH_HEADER_NAMES,
AUTH_HTTP_METHODS,
AUTH_HTTP_STATUS,
AUTH_MODULE,
AUTH_ROUTE_PATHS,
AUTH_SESSION_STATUSES,
AUTH_STORAGE_KEYS
} from '$libs/auth/consts';
export const AUTH_CLIENT_DIAGNOSTIC_EVENTS = {
OPERATION_FAILED: 'auth.client.operation_failed',
CACHE_INVALIDATION_FAILED: 'auth.client.cache_invalidation_failed'
} as const;
export const AUTH_CLIENT_LOG_MESSAGE_OPERATION_FAILED = 'auth client operation failed';
export const AUTH_CLIENT_LOG_MESSAGE_CACHE_INVALIDATION_FAILED =
'auth client cache invalidation failed';
// Method labels for `ensureLive(method)` error messages. Scoped with the
// `auth.` prefix so aggregated diagnostic streams never collide with
// identically-named methods from other artifacts.
export const AUTH_METHOD_LOAD_CURRENT = 'auth.loadCurrent';
export const AUTH_METHOD_SIGN_IN_PASSWORD = 'auth.signInPassword';
export const AUTH_METHOD_SIGN_UP_PASSWORD = 'auth.signUpPassword';
export const AUTH_METHOD_SIGN_OUT = 'auth.signOut';
export const AUTH_METHOD_SIGN_OUT_GLOBAL = 'auth.signOutGlobal';
export const AUTH_METHOD_REQUEST_PASSWORD_RESET = 'auth.requestPasswordReset';
export const AUTH_METHOD_COMPLETE_PASSWORD_RESET = 'auth.completePasswordReset';
export const AUTH_METHOD_REQUEST_EMAIL_VERIFICATION = 'auth.requestEmailVerification';
export const AUTH_METHOD_COMPLETE_EMAIL_VERIFICATION = 'auth.completeEmailVerification';
export const AUTH_METHOD_LIST_DEVICES = 'auth.listDevices';
export const AUTH_METHOD_REVOKE_DEVICE = 'auth.revokeDevice';
export const AUTH_METHOD_CLEAR_ERROR = 'auth.clearError';
export const AUTH_METHOD_ON_CHANGE = 'auth.onChange';

@ -0,0 +1,61 @@
import {
LogLevel,
createCatalogDiagnostics,
type DiagnosticCatalog,
type DiagnosticEvent,
type Diagnostics,
type Logger
} from '$libs/logger';
import {
AUTH_CLIENT_DIAGNOSTIC_EVENTS,
AUTH_CLIENT_LOG_MESSAGE_CACHE_INVALIDATION_FAILED,
AUTH_CLIENT_LOG_MESSAGE_OPERATION_FAILED,
AUTH_MODULE
} from './consts.ts';
import type { AuthEventName } from '$libs/auth/types';
export type AuthClientDiagnosticType =
(typeof AUTH_CLIENT_DIAGNOSTIC_EVENTS)[keyof typeof AUTH_CLIENT_DIAGNOSTIC_EVENTS];
export interface AuthClientDiagnosticMeta {
readonly method?: string;
readonly reason?: AuthEventName;
readonly error?: unknown;
}
export type AuthClientDiagnosticEvent = DiagnosticEvent<
AuthClientDiagnosticType,
AuthClientDiagnosticMeta
>;
export type AuthClientDiagnostics = Diagnostics<AuthClientDiagnosticEvent>;
const AUTH_CLIENT_DIAGNOSTIC_LOGS: DiagnosticCatalog<AuthClientDiagnosticEvent> = {
[AUTH_CLIENT_DIAGNOSTIC_EVENTS.OPERATION_FAILED]: {
level: LogLevel.WARN,
message: AUTH_CLIENT_LOG_MESSAGE_OPERATION_FAILED
},
[AUTH_CLIENT_DIAGNOSTIC_EVENTS.CACHE_INVALIDATION_FAILED]: {
level: LogLevel.WARN,
message: AUTH_CLIENT_LOG_MESSAGE_CACHE_INVALIDATION_FAILED
}
};
export function createAuthClientDiagnostics(logger?: Logger): AuthClientDiagnostics {
return createCatalogDiagnostics({
logger,
defaultCategory: AUTH_MODULE,
catalog: AUTH_CLIENT_DIAGNOSTIC_LOGS
});
}
export function emitAuthClientDiagnostic(
diagnostics: AuthClientDiagnostics,
type: AuthClientDiagnosticType,
meta: AuthClientDiagnosticMeta
): void {
diagnostics.emit({
artifact: AUTH_MODULE,
type,
meta
});
}

@ -0,0 +1,78 @@
import { CodeError, errCode, type ErrCode, type ErrorMessages } from '$libs/errs';
import { AUTH_ERR } from '$libs/auth/errors';
import type { AuthClientSafeError } from '$libs/auth/types';
// ── Error codes (client-side only) ─────────────────────────────────────
//
// These three describe failures of the active HTTP client. The libs/auth
// seed is reused so all auth codes share the `'auth::'` prefix.
export const AUTH_ERR_REQUEST_FAILED: ErrCode = errCode(AUTH_ERR, 'request_failed');
export const AUTH_ERR_DISPOSED: ErrCode = errCode(AUTH_ERR, 'disposed');
export const AUTH_ERR_INVALID_RESPONSE: ErrCode = errCode(AUTH_ERR, 'invalid_response');
// ── Error message strings ──────────────────────────────────────────────
export const AUTH_ERROR_MSG_REQUEST_FAILED_PREFIX = 'Authentication request failed: ';
export const AUTH_ERROR_MSG_RESPONSE_NOT_JSON = 'Authentication response is not valid JSON';
export const AUTH_ERROR_MSG_DISPOSED_SUFFIX = '() called on a disposed auth client';
export const AUTH_ERROR_MSG_INVALID_RESPONSE_PREFIX = 'Invalid authentication response for ';
// ── Error messages ─────────────────────────────────────────────────────
export const AUTH_CLIENT_ERROR_MESSAGES: ErrorMessages = {
[AUTH_ERR_REQUEST_FAILED]: AUTH_ERROR_MSG_REQUEST_FAILED_PREFIX,
[AUTH_ERR_DISPOSED]: AUTH_ERROR_MSG_DISPOSED_SUFFIX,
[AUTH_ERR_INVALID_RESPONSE]: AUTH_ERROR_MSG_INVALID_RESPONSE_PREFIX
};
// ── Error classes ──────────────────────────────────────────────────────
export class AuthRequestFailedError extends CodeError {
override readonly cause?: unknown;
constructor(
readonly url: string,
readonly status: number,
readonly statusText: string,
readonly safeError?: AuthClientSafeError,
readonly responseBody?: unknown,
cause?: unknown
) {
super(AUTH_ERR_REQUEST_FAILED, {
message: `${AUTH_ERROR_MSG_REQUEST_FAILED_PREFIX}${status} ${statusText}`.trim(),
cause
});
this.cause = cause;
}
}
export class AuthDisposedError extends CodeError {
constructor(method: string) {
super(AUTH_ERR_DISPOSED, {
message: `${method}${AUTH_ERROR_MSG_DISPOSED_SUFFIX}`
});
}
}
export class AuthInvalidResponseError extends CodeError {
constructor(readonly method: string) {
super(AUTH_ERR_INVALID_RESPONSE, {
message: `${AUTH_ERROR_MSG_INVALID_RESPONSE_PREFIX}${method}`
});
}
}
// ── Type guards ────────────────────────────────────────────────────────
export function isAuthRequestFailedError(error: unknown): error is AuthRequestFailedError {
return error instanceof AuthRequestFailedError;
}
export function isAuthDisposedError(error: unknown): error is AuthDisposedError {
return error instanceof AuthDisposedError;
}
export function isAuthInvalidResponseError(error: unknown): error is AuthInvalidResponseError {
return error instanceof AuthInvalidResponseError;
}

@ -0,0 +1,2 @@
export { AUTH_EVENT_NAMES } from '$libs/auth/consts';
export type { AuthEventName } from '$libs/auth/types';

@ -0,0 +1,98 @@
export { createActiveAuth } from './active-auth.svelte.ts';
export { createEngineHttpAuthClient, createFetchAuthClient } from './client.ts';
export { AUTH_EVENT_NAMES } from './events.ts';
export { createInMemoryAuthActiveSync } from './sync.ts';
export {
AUTH_CONTENT_TYPES,
AUTH_CLIENT_DIAGNOSTIC_EVENTS,
AUTH_CLIENT_LOG_MESSAGE_CACHE_INVALIDATION_FAILED,
AUTH_CLIENT_LOG_MESSAGE_OPERATION_FAILED,
AUTH_COOKIE_NAMES,
AUTH_HEADER_NAMES,
AUTH_HTTP_METHODS,
AUTH_HTTP_STATUS,
AUTH_METHOD_CLEAR_ERROR,
AUTH_METHOD_COMPLETE_EMAIL_VERIFICATION,
AUTH_METHOD_COMPLETE_PASSWORD_RESET,
AUTH_METHOD_LIST_DEVICES,
AUTH_METHOD_LOAD_CURRENT,
AUTH_METHOD_ON_CHANGE,
AUTH_METHOD_REQUEST_EMAIL_VERIFICATION,
AUTH_METHOD_REQUEST_PASSWORD_RESET,
AUTH_METHOD_REVOKE_DEVICE,
AUTH_METHOD_SIGN_IN_PASSWORD,
AUTH_METHOD_SIGN_OUT,
AUTH_METHOD_SIGN_OUT_GLOBAL,
AUTH_METHOD_SIGN_UP_PASSWORD,
AUTH_ROUTE_PATHS,
AUTH_SESSION_STATUSES,
AUTH_STORAGE_KEYS,
AUTH_MODULE
} from './consts.ts';
export {
AUTH_ERR,
AUTH_ERR_ACCOUNT_NOT_LINKED,
AUTH_ERR_ADAPTER_FAILED,
AUTH_ERR_ASSURANCE_REQUIRED,
AUTH_ERR_CONFIG_INVALID,
AUTH_ERR_CREDENTIAL_INVALID,
AUTH_ERR_CSRF_INVALID,
AUTH_ERR_FLOW_EXPIRED,
AUTH_ERR_FLOW_INVALID,
AUTH_ERR_IDENTIFIER_TAKEN,
AUTH_ERR_MFA_REQUIRED,
AUTH_ERR_OAUTH_PROVIDER_FAILED,
AUTH_ERR_OAUTH_STATE_INVALID,
AUTH_ERR_OTP_INVALID,
AUTH_ERR_RATE_LIMITED,
AUTH_ERR_ROUTE_NOT_FOUND,
AUTH_ERR_SESSION_REQUIRED,
AUTH_ERR_SESSION_REVOKED,
AUTH_ERR_TENANT_BOUNDARY,
AUTH_ERR_TOKEN_REUSE_DETECTED,
AUTH_ERR_WEBAUTHN_FAILED,
AUTH_ERROR_MESSAGES
} from '$libs/auth/errors';
export { createAuthClientDiagnostics, emitAuthClientDiagnostic } from './diagnostics.ts';
export {
AUTH_CLIENT_ERROR_MESSAGES,
AUTH_ERR_DISPOSED,
AUTH_ERR_INVALID_RESPONSE,
AUTH_ERR_REQUEST_FAILED,
AUTH_ERROR_MSG_DISPOSED_SUFFIX,
AUTH_ERROR_MSG_INVALID_RESPONSE_PREFIX,
AUTH_ERROR_MSG_REQUEST_FAILED_PREFIX,
AUTH_ERROR_MSG_RESPONSE_NOT_JSON,
AuthDisposedError,
AuthInvalidResponseError,
AuthRequestFailedError,
isAuthDisposedError,
isAuthInvalidResponseError,
isAuthRequestFailedError
} from './errors.ts';
export type { AuthEventName } from './events.ts';
export type {
AuthClientDiagnosticEvent,
AuthClientDiagnosticMeta,
AuthClientDiagnostics,
AuthClientDiagnosticType
} from './diagnostics.ts';
export type { AuthActiveSyncPort } from './sync.ts';
export type {
ActiveAuth,
ActiveAuthOptions,
AuthActiveEmailVerificationCompleteInput,
AuthActiveEmailVerificationRequestInput,
AuthActivePasswordResetCompleteInput,
AuthActivePasswordResetRequestInput,
AuthActivePasswordSignInInput,
AuthActivePasswordSignUpInput,
AuthActiveRevokeDeviceInput
} from './types.ts';
export type {
AuthClientCacheInvalidationInput,
AuthClientCachePort,
AuthClientHttpPort,
AuthClientRequestOptions,
AuthClientStoragePort
} from '$libs/auth/contracts';

@ -0,0 +1,17 @@
export interface AuthActiveSyncPort {
notifyAuthChanged(): void;
subscribeAuthChanged(handler: () => void): () => void;
}
export function createInMemoryAuthActiveSync(): AuthActiveSyncPort {
const handlers = new Set<() => void>();
return {
notifyAuthChanged() {
for (const handler of handlers) handler();
},
subscribeAuthChanged(handler) {
handlers.add(handler);
return () => handlers.delete(handler);
}
};
}

@ -0,0 +1,175 @@
import { describe, expect, it, vi } from 'vitest';
import {
AUTH_ERR_ADAPTER_FAILED,
AUTH_ERROR_MSG_DISPOSED_SUFFIX,
AUTH_HEADER_NAMES,
AUTH_METHOD_CLEAR_ERROR,
AUTH_ROUTE_PATHS,
AUTH_SESSION_STATUSES,
AUTH_STORAGE_KEYS,
createActiveAuth,
createFetchAuthClient,
isAuthDisposedError,
isAuthInvalidResponseError,
isAuthRequestFailedError
} from '$auth';
function json(body: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(body), {
status: init.status ?? 200,
statusText: init.statusText,
headers: {
[AUTH_HEADER_NAMES.CONTENT_TYPE]: 'application/json',
...(init.headers as Record<string, string> | undefined)
}
});
}
describe('createActiveAuth', () => {
it('loads current state through the fetch auth client', async () => {
const fetcher = vi.fn(async () =>
json({
session: {
status: AUTH_SESSION_STATUSES.ANONYMOUS,
aal: 'aal0',
amr: []
}
})
) as typeof fetch;
const Auth = createActiveAuth({ http: createFetchAuthClient(fetcher) });
await Auth.loadCurrent();
expect(Auth.current.session.status).toBe(AUTH_SESSION_STATUSES.ANONYMOUS);
expect(fetcher).toHaveBeenCalledWith(AUTH_ROUTE_PATHS.CURRENT, {
method: 'GET',
headers: undefined
});
});
it('uses the dedicated non-secret csrf storage key and sends the csrf header', async () => {
const stored: Record<string, unknown> = {};
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url === AUTH_ROUTE_PATHS.CSRF) return json({ token: 'csrf-token', expiresAt: 1 });
expect((init?.headers as Record<string, string>)[AUTH_HEADER_NAMES.CSRF]).toBe('csrf-token');
return json({
current: {
session: {
status: AUTH_SESSION_STATUSES.AUTHENTICATED,
aal: 'aal1',
amr: ['pwd']
}
}
});
}) as typeof fetch;
const Auth = createActiveAuth({
http: createFetchAuthClient(fetcher),
stor: {
get: <T>(key: string) => stored[key] as T | undefined,
set: (key, value) => {
stored[key] = value;
},
remove: (key) => {
delete stored[key];
}
}
});
await Auth.signInPassword({ identifier: 'ada@example.com', password: 'correct horse' });
expect(stored[AUTH_STORAGE_KEYS.NON_SECRET_CSRF_CACHE]).toEqual({ issued: true });
expect(Auth.authenticated).toBe(true);
});
it('invalidates client cache after identity changes', async () => {
const invalidations: unknown[] = [];
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url === AUTH_ROUTE_PATHS.CSRF) return json({ token: 'csrf-token', expiresAt: 1 });
expect((init?.headers as Record<string, string>)[AUTH_HEADER_NAMES.CSRF]).toBe('csrf-token');
return json({
current: {
session: {
status: AUTH_SESSION_STATUSES.AUTHENTICATED,
aal: 'aal1',
amr: ['pwd']
}
}
});
}) as typeof fetch;
const Auth = createActiveAuth({
http: createFetchAuthClient(fetcher),
cache: {
invalidate(input) {
invalidations.push(input);
}
}
});
await Auth.signInPassword({ identifier: 'ada@example.com', password: 'correct horse' });
expect(invalidations).toHaveLength(1);
});
it('rejects malformed auth responses with a typed error', async () => {
const fetcher = vi.fn(async () => json({ nope: true })) as typeof fetch;
const Auth = createActiveAuth({ http: createFetchAuthClient(fetcher) });
try {
await Auth.loadCurrent();
} catch (error) {
expect(isAuthInvalidResponseError(error)).toBe(true);
expect(Auth.lastError?.code).toBe(AUTH_ERR_ADAPTER_FAILED);
}
});
it('preserves HTTP status when the response body is not JSON', async () => {
const fetcher = vi.fn(
async () =>
new Response('<html>bad gateway</html>', {
status: 502,
statusText: 'Bad Gateway',
headers: { [AUTH_HEADER_NAMES.CONTENT_TYPE]: 'text/html' }
})
) as typeof fetch;
const Auth = createActiveAuth({ http: createFetchAuthClient(fetcher) });
try {
await Auth.loadCurrent();
} catch (error) {
expect(isAuthRequestFailedError(error)).toBe(true);
expect(Auth.lastError?.code).toBe(AUTH_ERR_ADAPTER_FAILED);
}
});
it('throws a typed error after dispose()', async () => {
const Auth = createActiveAuth({
http: createFetchAuthClient(vi.fn() as unknown as typeof fetch)
});
Auth.dispose();
try {
await Auth.loadCurrent();
} catch (error) {
expect(isAuthDisposedError(error)).toBe(true);
}
});
it('reports the real method name after dispose()', () => {
const Auth = createActiveAuth({
http: createFetchAuthClient(vi.fn() as unknown as typeof fetch)
});
Auth.dispose();
expect(() => Auth.clearError()).toThrow(
`${AUTH_METHOD_CLEAR_ERROR}${AUTH_ERROR_MSG_DISPOSED_SUFFIX}`
);
});
});

@ -0,0 +1,60 @@
import type {
AuthClientSafeError,
AuthCurrentView,
AuthDevicePublicView,
AuthEmailVerificationCompleteInput,
AuthEmailVerificationRequestInput,
AuthPasswordResetCompleteInput,
AuthPasswordResetRequestInput,
AuthRevokeDeviceInput,
AuthSignInPasswordInput,
AuthSignUpPasswordInput
} from '$libs/auth/types';
import type { ActiveChangeListener, ActiveEngine } from '$libs/active';
import type {
AuthClientCachePort,
AuthClientHttpPort,
AuthClientStoragePort
} from '$libs/auth/contracts';
import type { Logger } from '$libs/logger';
export interface ActiveAuthOptions {
readonly http: AuthClientHttpPort;
readonly cache?: AuthClientCachePort;
readonly stor?: AuthClientStoragePort;
readonly initial?: AuthCurrentView;
readonly logger?: Logger;
readonly onCacheError?: (error: unknown) => void;
}
export type AuthActivePasswordSignInInput = Omit<AuthSignInPasswordInput, 'tenantId'>;
export type AuthActivePasswordSignUpInput = Omit<AuthSignUpPasswordInput, 'tenantId'>;
export type AuthActivePasswordResetRequestInput = Omit<AuthPasswordResetRequestInput, 'tenantId'>;
export type AuthActivePasswordResetCompleteInput = Omit<AuthPasswordResetCompleteInput, 'tenantId'>;
export type AuthActiveEmailVerificationRequestInput = Omit<
AuthEmailVerificationRequestInput,
'tenantId'
>;
export type AuthActiveEmailVerificationCompleteInput = Omit<
AuthEmailVerificationCompleteInput,
'tenantId'
>;
export type AuthActiveRevokeDeviceInput = Pick<AuthRevokeDeviceInput, 'deviceId'>;
export interface ActiveAuth extends ActiveEngine<AuthCurrentView, AuthClientSafeError> {
readonly current: AuthCurrentView;
readonly authenticated: boolean;
readonly mfaRequired: boolean;
loadCurrent(): Promise<AuthCurrentView>;
signInPassword(input: AuthActivePasswordSignInInput): Promise<AuthCurrentView>;
signUpPassword(input: AuthActivePasswordSignUpInput): Promise<AuthCurrentView>;
signOut(): Promise<void>;
signOutGlobal(): Promise<void>;
requestPasswordReset(input: AuthActivePasswordResetRequestInput): Promise<void>;
completePasswordReset(input: AuthActivePasswordResetCompleteInput): Promise<void>;
requestEmailVerification(input: AuthActiveEmailVerificationRequestInput): Promise<void>;
completeEmailVerification(input: AuthActiveEmailVerificationCompleteInput): Promise<void>;
listDevices(): Promise<readonly AuthDevicePublicView[]>;
revokeDevice(input: AuthActiveRevokeDeviceInput): Promise<void>;
onChange(listener: ActiveChangeListener<AuthCurrentView>): () => void;
}

@ -0,0 +1,828 @@
# buss
> **Status**: layer split into `libs/buss` (pure contracts) and
> `arts/buss` (engine implementation) is **done**. Modules consume the
> bus through interfaces in `$libs/bus`; the implementation in `$bus`
> is reserved for the composition root (`aapp`) 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](#deferred-work).
`buss` 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 `sess`,
`auth`, `cach`, `perm`, `conn`, users, tenants, permissions, or any
business rule.
Applications never instantiate one bus per module. `aapp` 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 → aapp translators → app events → consumer reactions
```
## Position
`buss` 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/aapp/integrations/*-translator.ts` 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) |
| `aapp/integrations/*-translator.ts` | 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/buss/ ← 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/buss/svelte/ ← Svelte adapter
index.ts createSvelteEngineBus()
wraps listener invocation in untrack
arts/buss/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/aapp/integrations/ ← translators (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/buss/engine-bus.ts` does not import from `svelte`.
Svelte-specific behaviour (untrack, runes wrappers, `$effect.root`)
lives in `arts/buss/svelte/` and `arts/buss/active-bus.svelte.ts`. The
engine must be testable without DOM or Svelte runtime.
## The `EngineBus` contract
```ts
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
```ts
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
```ts
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](#sveltesveltekit-runtime-contract)).
### Listener failure shape
`BusListenerFailure` carries enough context for distributed debugging:
```ts
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:
```ts
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`.
```ts
// arts/sess/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/sess/types.ts
export interface SessLifecyclePayload {
readonly event: SessionEvent;
readonly generation: number;
readonly identity: {
readonly from: SessionIdentityState;
readonly to: SessionIdentityState;
};
}
// arts/sess/bus-helpers.ts
import type { BusPublishOptions, EventPublisher } from '$libs/bus';
// One publisher fans out to every event the lifecycle implies.
// `sess.changed` is always emitted; `sess.identity.changed` only when
// identity actually transitions; `sess.revoked` / `sess.expired` /
// `sess.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](../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/](../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
`aapp` 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).
```ts
// arts/aapp/active-app.svelte.ts (sketch)
import { createSvelteEngineBus } from '$bus/svelte';
const Bus = createSvelteEngineBus({
logger: Logger,
clock: Timers.clock
});
const App = { Logger, Lang, Format, Frontend, 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](#mandatory-tests)).
### Rule 1 — No mutable bus singleton
```ts
// 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`:
```ts
// 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:
```svelte
<!-- 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`:
```svelte
<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`:
```ts
// arts/buss/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:
```ts
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:
```ts
// 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 `buss` 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:
```txt
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/](../active-app/presets/) and wired
en bloc by `applyStandardOrca(App)`:
```ts
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:
```ts
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:
```ts
// arts/buss/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](../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). `SemanticEngine` is a different
registry for a different purpose; do not unify.
- Stor's internal entry-bus. Per-`EngineStorage` synchronization stays
inside `stor`; it is not `App.bus`.
## Testing patterns
```ts
import { flushSync } from 'svelte';
import {
APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED,
publishAppUserIdentityChanged
} from '$libs/aapp/events';
flushSync(() => {
publishAppUserIdentityChanged(Bus, {
event: 'session.lifecycle.adopted',
generation: 2,
identity: { from: 'ada', to: 'linus' },
cause: APP_USER_IDENTITY_CAUSE_SESSION_ADOPTED
});
});
expect(screen.getByText('Linus')).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:
```ts
const Bus = createEngineBus({
listenerErrorMode: 'throw',
logger: testLogger
});
```
For isolating a feature, disable translators:
```ts
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 implementations under `arts/aapp/integrations/`.
## Bundle budget
`arts/buss` 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/buss/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()`.
- `libs/aapp/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/buss`).
- `APP_EVENT_*` — public app event names (live in `libs/aapp/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/buss` does not import other artifacts.
- `arts/buss` does not export domain event constants. Domain events
belong to their owning artifact; `APP_EVENT_*` belongs to `libs/aapp`.
- 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.

@ -0,0 +1,58 @@
/**
* `arts/buss/active-bus.svelte.ts` — minimal reactive wrapper around the
* engine bus.
*
* Components that want a reactive view of the latest event of a given
* type can use `createBusRecent`: it exposes `lastEvent` and `count` as
* `$state`, and a `dispose()` to detach the listener. The wrapper stays
* intentionally tiny — anything richer (history, derived projections,
* windowing) waits until real usage patterns emerge.
*
* Lives in `arts/buss` (not `libs/bus`) because it depends on Svelte's
* `$state` rune.
*/
import type { BusEnvelope, BusListener } from '$libs/bus';
import type { EngineBus } from './types.ts';
export interface BusRecent<TPayload> {
readonly lastEvent: BusEnvelope<string, TPayload> | undefined;
readonly count: number;
clear(): void;
dispose(): void;
}
interface SubscribableBus {
subscribe(
type: string,
listener: BusListener<unknown>
): () => void;
}
export function createBusRecent<TPayload = unknown>(
bus: EngineBus,
type: string
): BusRecent<TPayload> {
let lastEvent = $state<BusEnvelope<string, TPayload> | undefined>(undefined);
let count = $state(0);
const subscribable = bus as unknown as SubscribableBus;
const unsubscribe = subscribable.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
};
}

@ -0,0 +1,424 @@
import { SILENT_LOGGER } from '$logger';
import {
BUS_DEFAULT_MAX_LISTENERS_PER_EVENT,
BUS_DEFAULT_MAX_REENTRANCY_DEPTH,
BUS_DEFAULT_SOURCE,
BUS_EVENT_ALL,
BUS_ID_PREFIX,
BUS_LISTENER_ERROR_MODE_LOG_AND_CONTINUE,
BUS_LISTENER_ERROR_MODE_THROW,
BUS_LISTENER_ID_PREFIX,
BusAggregateListenerError,
BusDisposedError,
BusInvalidPayloadError,
BusReentrancyLimitError,
createBusDiagnostics,
emitBusListenerFailureDiagnostic,
emitBusListenerLeakWarningDiagnostic
} from '$libs/bus';
import type {
BusEnvelope,
BusEventMap,
BusListenOptions,
BusListener,
BusListenerContext,
BusListenerErrorMode,
BusListenerFailure,
BusPublishOptions,
BusPublishResult,
BusSubscription
} from '$libs/bus';
import type { EngineBus, EngineBusOptions } from './types.ts';
interface ListenerRecord {
readonly id: string;
readonly type: string | typeof BUS_EVENT_ALL;
readonly listener: BusListener<unknown>;
readonly controller: AbortController;
readonly externalSignal?: AbortSignal;
readonly once: boolean;
readonly order: number;
active: boolean;
}
const DEFAULT_CLOCK = {
now: () => Date.now()
};
const IS_DEV =
typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production';
export function createEngineBus<TEvents extends BusEventMap = BusEventMap>(
options: EngineBusOptions = {}
): EngineBus<TEvents> {
const logger = options.logger ?? SILENT_LOGGER;
const diagnostics = createBusDiagnostics(logger);
const clock = options.clock ?? DEFAULT_CLOCK;
const idFactory = options.idFactory ?? createIncrementalIdFactory(BUS_ID_PREFIX);
const maxListenersPerEvent =
options.maxListenersPerEvent ?? BUS_DEFAULT_MAX_LISTENERS_PER_EVENT;
const maxReentrancyDepth =
options.maxReentrancyDepth ?? BUS_DEFAULT_MAX_REENTRANCY_DEPTH;
const defaultErrorMode =
options.listenerErrorMode ?? BUS_LISTENER_ERROR_MODE_LOG_AND_CONTINUE;
const invokeListener = options.invokeListener ?? defaultInvokeListener;
const listeners = new Map<string, ListenerRecord[]>();
const anyListeners: ListenerRecord[] = [];
let listenerSequence = 0;
let publishDepth = 0;
let disposed = false;
function ensureLive(): void {
if (disposed) throw new BusDisposedError();
}
function nextListenerId(): string {
listenerSequence += 1;
return `${BUS_LISTENER_ID_PREFIX}${listenerSequence}`;
}
function assertPayloadCloneable<TType extends string>(
type: TType,
payload: unknown
): void {
if (!IS_DEV) return;
try {
structuredClone(payload);
} catch (error) {
throw new BusInvalidPayloadError(type, error);
}
}
function createEnvelope<TType extends keyof TEvents & string>(
type: TType,
payload: TEvents[TType],
publishOptions: BusPublishOptions | undefined
): BusEnvelope<TType, TEvents[TType]> {
const envelope: BusEnvelope<TType, TEvents[TType]> = {
id: idFactory(),
type,
payload,
at: clock.now(),
source: publishOptions?.source ?? BUS_DEFAULT_SOURCE
};
return withOptionalEnvelopeFields(envelope, publishOptions);
}
function currentErrorMode(publishOptions: BusPublishOptions | undefined): BusListenerErrorMode {
return publishOptions?.listenerErrorMode ?? defaultErrorMode;
}
function subscribe(
type: string | typeof BUS_EVENT_ALL,
listener: BusListener<unknown>,
listenOptions: BusListenOptions = {}
): BusSubscription {
ensureLive();
const controller = new AbortController();
const record: ListenerRecord = {
id: listenOptions.id ?? nextListenerId(),
type,
listener,
controller,
externalSignal: listenOptions.signal,
once: listenOptions.once ?? false,
order: listenerSequence,
active: true
};
const remove = (): void => {
removeRecord(record);
};
if (listenOptions.signal?.aborted === true) {
record.active = false;
} else {
listenOptions.signal?.addEventListener('abort', remove, { once: true });
listenerBucket(type).push(record);
warnIfListenerLeak(type);
}
return createSubscription(record, remove);
}
function listenerBucket(type: string | typeof BUS_EVENT_ALL): ListenerRecord[] {
if (type === BUS_EVENT_ALL) return anyListeners;
let bucket = listeners.get(type);
if (bucket === undefined) {
bucket = [];
listeners.set(type, bucket);
}
return bucket;
}
function warnIfListenerLeak(type: string | typeof BUS_EVENT_ALL): void {
const count = listenerBucket(type).length;
if (count <= maxListenersPerEvent) return;
emitBusListenerLeakWarningDiagnostic({
diagnostics,
type,
count,
max: maxListenersPerEvent
});
}
function createSubscription(record: ListenerRecord, remove: () => void): BusSubscription {
return {
id: record.id,
type: record.type,
get active() {
return record.active;
},
unsubscribe() {
remove();
}
};
}
function removeRecord(record: ListenerRecord): void {
if (!record.active) return;
record.active = false;
record.controller.abort();
const bucket = record.type === BUS_EVENT_ALL ? anyListeners : listeners.get(record.type);
if (bucket === undefined) return;
const index = bucket.indexOf(record);
if (index >= 0) bucket.splice(index, 1);
if (record.type !== BUS_EVENT_ALL && bucket.length === 0) listeners.delete(record.type);
}
function publish<TType extends keyof TEvents & string>(
type: TType,
payload: TEvents[TType],
publishOptions?: BusPublishOptions
): BusPublishResult<TType, TEvents[TType]> {
ensureLive();
assertPayloadCloneable(type, payload);
if (publishDepth >= maxReentrancyDepth) {
throw new BusReentrancyLimitError(type, publishDepth + 1, maxReentrancyDepth);
}
const envelope = createEnvelope(type, payload, publishOptions);
const errors: BusListenerFailure[] = [];
const mode = currentErrorMode(publishOptions);
publishDepth += 1;
try {
for (const record of recordsFor(type)) {
invokeSync(record, envelope, mode, errors);
}
} finally {
publishDepth -= 1;
}
throwIfRequested(mode, errors, envelope);
return { envelope, errors };
}
async function publishAsync<TType extends keyof TEvents & string>(
type: TType,
payload: TEvents[TType],
publishOptions?: BusPublishOptions
): Promise<BusPublishResult<TType, TEvents[TType]>> {
ensureLive();
assertPayloadCloneable(type, payload);
const envelope = createEnvelope(type, payload, publishOptions);
const errors: BusListenerFailure[] = [];
const mode = currentErrorMode(publishOptions);
for (const record of recordsFor(type)) {
await invokeAsync(record, envelope, mode, errors);
}
throwIfRequested(mode, errors, envelope);
return { envelope, errors };
}
function publishCausedBy<TType extends keyof TEvents & string>(
parent: BusEnvelope,
type: TType,
payload: TEvents[TType],
publishOptions?: BusPublishOptions
): BusPublishResult<TType, TEvents[TType]> {
const merged: BusPublishOptions = {
...publishOptions,
causationId: publishOptions?.causationId ?? parent.id,
correlationId: publishOptions?.correlationId ?? parent.correlationId ?? parent.id
};
return publish(type, payload, merged);
}
function recordsFor(type: string): ListenerRecord[] {
const exact = listeners.get(type) ?? [];
return [...exact, ...anyListeners].filter((record) => record.active);
}
function invokeSync<TType extends keyof TEvents & string>(
record: ListenerRecord,
envelope: BusEnvelope<TType, TEvents[TType]>,
mode: BusListenerErrorMode,
errors: BusListenerFailure[]
): void {
if (shouldSkip(record)) return;
if (record.once) removeRecord(record);
let returnValue: void | Promise<void>;
try {
returnValue = invokeListener(() =>
record.listener(envelope as BusEnvelope<string, unknown>, listenerContext(record))
);
} catch (error) {
if (isFatalBusError(error)) throw error;
handleFailure(record, envelope, error, mode, errors);
return;
}
if (isPromiseLike(returnValue)) {
void (returnValue as PromiseLike<void>).then(undefined, (error: unknown) => {
if (isFatalBusError(error)) throw error;
handleFailure(record, envelope, error, mode, []);
});
}
}
async function invokeAsync<TType extends keyof TEvents & string>(
record: ListenerRecord,
envelope: BusEnvelope<TType, TEvents[TType]>,
mode: BusListenerErrorMode,
errors: BusListenerFailure[]
): Promise<void> {
if (shouldSkip(record)) return;
if (record.once) removeRecord(record);
try {
await invokeListener(() =>
record.listener(envelope as BusEnvelope<string, unknown>, listenerContext(record))
);
} catch (error) {
if (isFatalBusError(error)) throw error;
handleFailure(record, envelope, error, mode, errors);
}
}
function shouldSkip(record: ListenerRecord): boolean {
if (!record.active) return true;
return record.controller.signal.aborted || record.externalSignal?.aborted === true;
}
function listenerContext(record: ListenerRecord): BusListenerContext {
return {
signal: record.controller.signal,
logger
};
}
function handleFailure(
record: ListenerRecord,
envelope: BusEnvelope,
error: unknown,
mode: BusListenerErrorMode,
errors: BusListenerFailure[]
): void {
const failure: BusListenerFailure = {
listenerId: record.id,
type: envelope.type,
envelopeId: envelope.id,
...(envelope.correlationId !== undefined ? { correlationId: envelope.correlationId } : {}),
...(envelope.causationId !== undefined ? { causationId: envelope.causationId } : {}),
error
};
errors.push(failure);
if (mode === BUS_LISTENER_ERROR_MODE_LOG_AND_CONTINUE) {
emitBusListenerFailureDiagnostic({ diagnostics, envelope, failure });
}
}
function throwIfRequested(
mode: BusListenerErrorMode,
errors: readonly BusListenerFailure[],
envelope: BusEnvelope
): void {
if (mode !== BUS_LISTENER_ERROR_MODE_THROW || errors.length === 0) return;
throw new BusAggregateListenerError(errors, envelope);
}
function listenerCount(type?: keyof TEvents & string): number {
if (type !== undefined) return listeners.get(type)?.length ?? 0;
let count = anyListeners.length;
for (const bucket of listeners.values()) count += bucket.length;
return count;
}
function clearForTesting(type?: keyof TEvents & string | typeof BUS_EVENT_ALL): void {
if (type === BUS_EVENT_ALL) {
for (const record of [...anyListeners]) removeRecord(record);
return;
}
if (type !== undefined) {
for (const record of [...(listeners.get(type) ?? [])]) removeRecord(record);
return;
}
for (const record of [...anyListeners]) removeRecord(record);
for (const bucket of [...listeners.values()]) {
for (const record of [...bucket]) removeRecord(record);
}
}
return {
publish,
publishAsync,
publishCausedBy,
on(type, listener, listenOptions) {
return subscribe(type, listener as BusListener<unknown>, listenOptions);
},
subscribe(type, listener, listenOptions) {
return subscribe(type, listener as BusListener<unknown>, listenOptions).unsubscribe;
},
onAny(listener, listenOptions) {
return subscribe(
BUS_EVENT_ALL,
listener as unknown as BusListener<unknown>,
listenOptions
);
},
once(type, listener, listenOptions) {
return subscribe(type, listener as BusListener<unknown>, {
...listenOptions,
once: true
});
},
listenerCount,
_clearForTesting: clearForTesting,
dispose() {
if (disposed) return;
clearForTesting();
disposed = true;
}
};
}
function defaultInvokeListener(fn: () => void | Promise<void>): void | Promise<void> {
return fn();
}
function createIncrementalIdFactory(prefix: string): () => string {
let next = 0;
return () => {
next += 1;
return `${prefix}${next}`;
};
}
function withOptionalEnvelopeFields<TType extends string, TPayload>(
envelope: BusEnvelope<TType, TPayload>,
options: BusPublishOptions | undefined
): BusEnvelope<TType, TPayload> {
return {
...envelope,
...(options?.correlationId !== undefined ? { correlationId: options.correlationId } : {}),
...(options?.causationId !== undefined ? { causationId: options.causationId } : {}),
...(options?.context !== undefined ? { context: options.context } : {}),
...(options?.tags !== undefined ? { tags: [...options.tags] } : {})
};
}
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return (
typeof value === 'object' &&
value !== null &&
'then' in value &&
typeof value.then === 'function'
);
}
function isFatalBusError(error: unknown): boolean {
return error instanceof BusReentrancyLimitError || error instanceof BusDisposedError;
}

@ -0,0 +1,21 @@
/**
* `arts/bus` — runtime implementation of the event bus.
*
* Pure types (`BusEnvelope`, `BusListener`, `BusEventMap`, `EventPublisher`,
* generic constants, error codes/messages, helpers) live in `$libs/bus`.
* Runtime types (`EngineBus`, `EngineBusOptions`, `SILENT_BUS`) and the
* factory live here.
*
* Modules that need to publish or subscribe MUST import their types
* from `$libs/bus` (for the abstract contract `EventPublisher`) or from
* `$bus` (for the concrete `EngineBus`). Only the composition root
* (`active-app`) and tests reach for `createEngineBus`.
*/
export { createEngineBus } from './engine-bus.ts';
export { createSvelteEngineBus } from './svelte/index.ts';
export { setBus, getBus } from './svelte/context.svelte.ts';
export { createBusRecent } from './active-bus.svelte.ts';
export type { BusRecent } from './active-bus.svelte.ts';
export { SILENT_BUS } from './silent-bus.ts';
export type { EngineBus, EngineBusOptions } from './types.ts';

@ -0,0 +1,138 @@
import {
BUS_DEFAULT_SOURCE,
BUS_EVENT_ALL,
type BusAnyListener,
type BusEnvelope,
type BusEventMap,
type BusListenOptions,
type BusListener,
type BusPublishOptions,
type BusPublishResult,
type BusSubscription
} from '$libs/bus';
import type { EngineBus } from './types.ts';
/**
* No-op bus that satisfies the `EngineBus` contract. Modules that take
* `bus?: EngineBus | EventPublisher` use this as a default so they can
* publish unconditionally without forcing every caller to wire a real
* bus. Mirrors `SILENT_LOGGER` in `$libs/logger`.
*
* Every read returns a plausible but inert value: `publish` returns a
* synthetic envelope with no errors, listeners are no-ops, subscriptions
* are inert, `dispose` is idempotent.
*/
const inertSubscription: BusSubscription = {
id: 'silent-bus',
type: BUS_EVENT_ALL,
get active() {
return false;
},
unsubscribe() {}
};
function inertEnvelope<TType extends string, TPayload>(
type: TType,
payload: TPayload,
options?: BusPublishOptions
): BusEnvelope<TType, TPayload> {
const envelope: BusEnvelope<TType, TPayload> = {
id: 'silent-bus',
type,
payload,
at: 0,
source: options?.source ?? BUS_DEFAULT_SOURCE
};
if (options?.correlationId !== undefined) {
Object.assign(envelope, { correlationId: options.correlationId });
}
if (options?.causationId !== undefined) {
Object.assign(envelope, { causationId: options.causationId });
}
if (options?.context !== undefined) {
Object.assign(envelope, { context: options.context });
}
if (options?.tags !== undefined) {
Object.assign(envelope, { tags: [...options.tags] });
}
return envelope;
}
function silentResult<TType extends string, TPayload>(
type: TType,
payload: TPayload,
options?: BusPublishOptions
): BusPublishResult<TType, TPayload> {
return {
envelope: inertEnvelope(type, payload, options),
errors: []
};
}
function noopUnsubscribe(): void {}
export const SILENT_BUS: EngineBus = {
publish<TEvents extends BusEventMap, TType extends keyof TEvents & string>(
type: TType,
payload: TEvents[TType],
options?: BusPublishOptions
): BusPublishResult<TType, TEvents[TType]> {
return silentResult(type, payload, options);
},
async publishAsync<TEvents extends BusEventMap, TType extends keyof TEvents & string>(
type: TType,
payload: TEvents[TType],
options?: BusPublishOptions
): Promise<BusPublishResult<TType, TEvents[TType]>> {
return silentResult(type, payload, options);
},
publishCausedBy<TEvents extends BusEventMap, TType extends keyof TEvents & string>(
_parent: BusEnvelope,
type: TType,
payload: TEvents[TType],
options?: BusPublishOptions
): BusPublishResult<TType, TEvents[TType]> {
return silentResult(type, payload, options);
},
on<TEvents extends BusEventMap, TType extends keyof TEvents & string>(
_type: TType,
_listener: BusListener<TEvents[TType]>,
_options?: BusListenOptions
): BusSubscription {
return inertSubscription;
},
subscribe<TEvents extends BusEventMap, TType extends keyof TEvents & string>(
_type: TType,
_listener: BusListener<TEvents[TType]>,
_options?: BusListenOptions
): () => void {
return noopUnsubscribe;
},
onAny<TEvents extends BusEventMap>(
_listener: BusAnyListener<TEvents>,
_options?: BusListenOptions
): BusSubscription {
return inertSubscription;
},
once<TEvents extends BusEventMap, TType extends keyof TEvents & string>(
_type: TType,
_listener: BusListener<TEvents[TType]>,
_options?: BusListenOptions
): BusSubscription {
return inertSubscription;
},
listenerCount(): number {
return 0;
},
_clearForTesting(): void {},
dispose(): void {}
} as unknown as EngineBus;

@ -0,0 +1,38 @@
/**
* Svelte context bridge for the bus.
*
* `setBus(bus)` writes the bus into the component-tree's context once
* near the root; `getBus()` retrieves it from anywhere deeper without
* threading the bus through props. The pattern is a propagation aid for
* Svelte trees, not an orchestration mechanism — direct subscriptions
* via `$effect(() => bus.on(...))` belong here; cross-module reactions
* belong in orca presets.
*
* `getBus()` throws `BusNoContextError` if no bus is in scope —
* forgetting to call `setBus()` is always a wiring bug, not a degraded
* mode.
*
* Lives under `arts/bus/svelte/` because the bus is the semantic owner
* of the pattern: any consumer holding an `EngineBus` (the App's bus,
* an isolated test bus, a secondary bus for an embedded sub-tree) can
* propagate it through Svelte context with this helper.
*/
import { getContext, setContext } from 'svelte';
import { BusNoContextError, type BusEventMap } from '$libs/bus';
import type { EngineBus } from '../types.ts';
const BUS_CONTEXT = Symbol('arts.bus.context');
export function setBus<TEvents extends BusEventMap = BusEventMap>(
bus: EngineBus<TEvents>
): EngineBus<TEvents> {
setContext(BUS_CONTEXT, bus);
return bus;
}
export function getBus<TEvents extends BusEventMap = BusEventMap>(): EngineBus<TEvents> {
const bus = getContext<EngineBus<TEvents> | undefined>(BUS_CONTEXT);
if (!bus) throw new BusNoContextError();
return bus;
}

@ -0,0 +1,36 @@
/**
* `arts/buss/svelte` — Svelte runtime adapter for the engine bus.
*
* Reading `$state` from a listener invocation creates a reactive
* dependency on whatever `$effect` or `$derived` is active at publish
* time. That is almost never intended: a publishing component should
* not transitively re-run because a listener happens to read state.
*
* This adapter wraps every listener invocation in `untrack`, so reads
* inside listeners do not register dependencies on the publisher's
* reactive scope. Listeners that genuinely want reactive reads opt in
* explicitly with their own `$derived` / `$effect`.
*
* The pure engine in `./engine-bus.ts` never imports from `svelte`;
* only this adapter does. Composition roots that run in a Svelte
* runtime (the App) should call `createSvelteEngineBus` instead of
* `createEngineBus` directly.
*/
import { untrack } from 'svelte';
import type { BusEventMap } from '$libs/bus';
import type { EngineBus, EngineBusOptions } from '../types.ts';
import { createEngineBus } from '../engine-bus.ts';
export function createSvelteEngineBus<TEvents extends BusEventMap = BusEventMap>(
options: EngineBusOptions = {}
): EngineBus<TEvents> {
const userInvoke = options.invokeListener;
const invokeListener = userInvoke
? (fn: () => void | Promise<void>): void | Promise<void> => untrack(() => userInvoke(fn))
: (fn: () => void | Promise<void>): void | Promise<void> => untrack(fn);
return createEngineBus<TEvents>({
...options,
invokeListener
});
}

@ -0,0 +1,436 @@
import { describe, expect, it, vi } from 'vitest';
import {
BUS_EVENT_ALL,
BUS_LISTENER_ERROR_MODE_COLLECT,
BUS_LISTENER_ERROR_MODE_LOG_AND_CONTINUE,
BUS_LISTENER_ERROR_MODE_THROW,
BusAggregateListenerError,
BusDisposedError,
BusInvalidPayloadError,
BusReentrancyLimitError
} from '$libs/bus';
import { createEngineBus } from '../index.ts';
import type { LogFn, Logger } from '$libs/logger';
const EVENT_IDENTITY_CHANGED = 'session.identity.changed';
const EVENT_AUTH_SIGNED_OUT = 'auth.signed_out';
interface TestEvents {
[EVENT_IDENTITY_CHANGED]: {
readonly previousActorId: string | null;
readonly nextActorId: string | null;
};
[EVENT_AUTH_SIGNED_OUT]: {
readonly actorId: string;
};
}
interface CapturedLog {
readonly level: keyof Logger;
readonly category: string;
readonly message: string;
readonly error?: unknown;
}
function createCapturedLogger(): { logger: Logger; entries: CapturedLog[] } {
const entries: CapturedLog[] = [];
function log(level: keyof Logger): LogFn {
return (category, message, input) => {
entries.push({
level,
category,
message: typeof message === 'function' ? message() : message,
error: input?.error
});
};
}
return {
entries,
logger: {
trace: log('trace'),
debug: log('debug'),
info: log('info'),
warn: log('warn'),
error: log('error'),
fatal: log('fatal')
}
};
}
describe('createEngineBus', () => {
it('publishes typed envelopes in deterministic order and calls onAny last', () => {
const Bus = createEngineBus<TestEvents>({
clock: { now: () => 42 },
idFactory: () => 'event-1'
});
const calls: string[] = [];
Bus.on(EVENT_IDENTITY_CHANGED, (event) => {
calls.push(`first:${event.payload.nextActorId}`);
});
Bus.on(EVENT_IDENTITY_CHANGED, (event) => {
calls.push(`second:${event.payload.previousActorId}`);
});
Bus.onAny((event) => {
calls.push(`any:${event.type}`);
});
const result = Bus.publish(EVENT_IDENTITY_CHANGED, {
previousActorId: 'actor-ada',
nextActorId: 'actor-linus'
});
expect(result.envelope).toMatchObject({
id: 'event-1',
type: EVENT_IDENTITY_CHANGED,
at: 42,
source: 'bus',
payload: {
previousActorId: 'actor-ada',
nextActorId: 'actor-linus'
}
});
expect(result.errors).toHaveLength(0);
expect(calls).toEqual([
'first:actor-linus',
'second:actor-ada',
`any:${EVENT_IDENTITY_CHANGED}`
]);
});
it('includes optional envelope metadata from publish options', () => {
const Bus = createEngineBus<TestEvents>({
clock: { now: () => 42 },
idFactory: () => 'event-1'
});
const tags = ['security', 'identity'] as const;
const result = Bus.publish(
EVENT_AUTH_SIGNED_OUT,
{ actorId: 'actor-ada' },
{
source: 'auth',
correlationId: 'corr-1',
causationId: 'event-0',
context: { tenantId: 'tenant-acme' },
tags
}
);
expect(result.envelope).toEqual({
id: 'event-1',
type: EVENT_AUTH_SIGNED_OUT,
payload: { actorId: 'actor-ada' },
at: 42,
source: 'auth',
correlationId: 'corr-1',
causationId: 'event-0',
context: { tenantId: 'tenant-acme' },
tags: ['security', 'identity']
});
expect(result.envelope.tags).not.toBe(tags);
});
it('supports once listeners and explicit unsubscribe', () => {
const Bus = createEngineBus<TestEvents>();
const once = vi.fn();
const persistent = vi.fn();
const subscription = Bus.once(EVENT_AUTH_SIGNED_OUT, once);
const persistentSubscription = Bus.on(EVENT_AUTH_SIGNED_OUT, persistent);
Bus.publish(EVENT_AUTH_SIGNED_OUT, { actorId: 'actor-ada' });
Bus.publish(EVENT_AUTH_SIGNED_OUT, { actorId: 'actor-ada' });
persistentSubscription.unsubscribe();
Bus.publish(EVENT_AUTH_SIGNED_OUT, { actorId: 'actor-ada' });
expect(once).toHaveBeenCalledTimes(1);
expect(subscription.active).toBe(false);
expect(persistent).toHaveBeenCalledTimes(2);
expect(persistentSubscription.active).toBe(false);
});
it('removes listeners when their AbortSignal aborts', () => {
const Bus = createEngineBus<TestEvents>();
const controller = new AbortController();
const listener = vi.fn();
const subscription = Bus.on(EVENT_AUTH_SIGNED_OUT, listener, {
signal: controller.signal
});
expect(Bus.listenerCount(EVENT_AUTH_SIGNED_OUT)).toBe(1);
controller.abort();
Bus.publish(EVENT_AUTH_SIGNED_OUT, { actorId: 'actor-ada' });
expect(listener).not.toHaveBeenCalled();
expect(subscription.active).toBe(false);
expect(Bus.listenerCount(EVENT_AUTH_SIGNED_OUT)).toBe(0);
});
it('collects listener failures and continues without logging in collect mode', () => {
const { logger, entries } = createCapturedLogger();
const Bus = createEngineBus<TestEvents>({
logger,
listenerErrorMode: BUS_LISTENER_ERROR_MODE_COLLECT
});
const afterFailure = vi.fn();
const error = new Error('boom');
Bus.on(EVENT_AUTH_SIGNED_OUT, () => {
throw error;
});
Bus.on(EVENT_AUTH_SIGNED_OUT, afterFailure);
const result = Bus.publish(EVENT_AUTH_SIGNED_OUT, { actorId: 'actor-ada' });
expect(afterFailure).toHaveBeenCalledTimes(1);
expect(result.errors).toEqual([
expect.objectContaining({
type: EVENT_AUTH_SIGNED_OUT,
error
})
]);
expect(entries).toEqual([]);
});
it('lets publish override listenerErrorMode', () => {
const { logger, entries } = createCapturedLogger();
const Bus = createEngineBus<TestEvents>({
logger,
listenerErrorMode: BUS_LISTENER_ERROR_MODE_LOG_AND_CONTINUE
});
Bus.on(EVENT_AUTH_SIGNED_OUT, () => {
throw new Error('collect me');
});
const result = Bus.publish(
EVENT_AUTH_SIGNED_OUT,
{ actorId: 'actor-ada' },
{ listenerErrorMode: BUS_LISTENER_ERROR_MODE_COLLECT }
);
expect(result.errors).toHaveLength(1);
expect(entries).toEqual([]);
});
it('logs and continues in log-and-continue mode', () => {
const { logger, entries } = createCapturedLogger();
const Bus = createEngineBus<TestEvents>({
logger,
listenerErrorMode: BUS_LISTENER_ERROR_MODE_LOG_AND_CONTINUE
});
const afterFailure = vi.fn();
Bus.on(EVENT_AUTH_SIGNED_OUT, () => {
throw new Error('log me');
});
Bus.on(EVENT_AUTH_SIGNED_OUT, afterFailure);
const result = Bus.publish(EVENT_AUTH_SIGNED_OUT, { actorId: 'actor-ada' });
expect(result.errors).toHaveLength(1);
expect(afterFailure).toHaveBeenCalledTimes(1);
expect(entries).toEqual([
expect.objectContaining({
level: 'error',
category: 'bus',
message: 'bus.listener.failed'
})
]);
});
it('throws an aggregate listener error in throw mode after collecting failures', () => {
const Bus = createEngineBus<TestEvents>({
listenerErrorMode: BUS_LISTENER_ERROR_MODE_THROW
});
const afterFailure = vi.fn(() => {
throw new Error('second');
});
Bus.on(EVENT_AUTH_SIGNED_OUT, () => {
throw new Error('first');
});
Bus.on(EVENT_AUTH_SIGNED_OUT, afterFailure);
expect(() => Bus.publish(EVENT_AUTH_SIGNED_OUT, { actorId: 'actor-ada' })).toThrow(
BusAggregateListenerError
);
expect(afterFailure).toHaveBeenCalledTimes(1);
});
it('awaits async listeners and collects async failures', async () => {
const Bus = createEngineBus<TestEvents>({
listenerErrorMode: BUS_LISTENER_ERROR_MODE_COLLECT
});
const calls: string[] = [];
Bus.on(EVENT_AUTH_SIGNED_OUT, async () => {
calls.push('first:start');
await Promise.resolve();
calls.push('first:end');
});
Bus.on(EVENT_AUTH_SIGNED_OUT, async () => {
calls.push('second:start');
throw new Error('async boom');
});
Bus.on(EVENT_AUTH_SIGNED_OUT, () => {
calls.push('third');
});
const result = await Bus.publishAsync(EVENT_AUTH_SIGNED_OUT, { actorId: 'actor-ada' });
expect(calls).toEqual(['first:start', 'first:end', 'second:start', 'third']);
expect(result.errors).toHaveLength(1);
});
it('warns when listener count exceeds the configured maximum', () => {
const { logger, entries } = createCapturedLogger();
const Bus = createEngineBus<TestEvents>({ logger, maxListenersPerEvent: 1 });
Bus.on(EVENT_AUTH_SIGNED_OUT, () => {});
Bus.on(EVENT_AUTH_SIGNED_OUT, () => {});
expect(entries).toEqual([
expect.objectContaining({
level: 'warn',
category: 'bus',
message: 'bus.listener.leak_warning'
})
]);
});
it('subscribe returns the unsubscribe function directly', () => {
const Bus = createEngineBus<TestEvents>();
const listener = vi.fn();
const unsubscribe = Bus.subscribe(EVENT_AUTH_SIGNED_OUT, listener);
Bus.publish(EVENT_AUTH_SIGNED_OUT, { actorId: 'actor-ada' });
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
Bus.publish(EVENT_AUTH_SIGNED_OUT, { actorId: 'actor-ada' });
expect(listener).toHaveBeenCalledTimes(1);
});
it('publishCausedBy populates causationId and correlationId from the parent', () => {
const Bus = createEngineBus<TestEvents>();
const captured: Array<{ correlationId?: string; causationId?: string }> = [];
Bus.on(EVENT_AUTH_SIGNED_OUT, (event) => {
captured.push({
correlationId: event.correlationId,
causationId: event.causationId
});
});
const parent = Bus.publish(EVENT_IDENTITY_CHANGED, {
previousActorId: null,
nextActorId: 'actor-ada'
}).envelope;
const child = Bus.publishCausedBy(parent, EVENT_AUTH_SIGNED_OUT, {
actorId: 'actor-ada'
}).envelope;
expect(captured).toEqual([
{ correlationId: parent.id, causationId: parent.id }
]);
expect(child.correlationId).toBe(parent.id);
expect(child.causationId).toBe(parent.id);
});
it('publishCausedBy preserves the parent correlationId when present', () => {
const Bus = createEngineBus<TestEvents>();
const parent = Bus.publish(
EVENT_IDENTITY_CHANGED,
{ previousActorId: null, nextActorId: 'actor-ada' },
{ correlationId: 'corr-shared' }
).envelope;
const child = Bus.publishCausedBy(parent, EVENT_AUTH_SIGNED_OUT, {
actorId: 'actor-ada'
}).envelope;
expect(child.correlationId).toBe('corr-shared');
expect(child.causationId).toBe(parent.id);
});
it('throws BusReentrancyLimitError when synchronous publish recurses past the depth cap', () => {
const Bus = createEngineBus<TestEvents>({ maxReentrancyDepth: 4 });
Bus.on(EVENT_AUTH_SIGNED_OUT, () => {
Bus.publish(EVENT_AUTH_SIGNED_OUT, { actorId: 'actor-loop' });
});
expect(() =>
Bus.publish(EVENT_AUTH_SIGNED_OUT, { actorId: 'actor-ada' })
).toThrow(BusReentrancyLimitError);
});
it('listener failures carry envelopeId, correlationId, and causationId', () => {
const Bus = createEngineBus<TestEvents>({
listenerErrorMode: BUS_LISTENER_ERROR_MODE_COLLECT
});
Bus.on(EVENT_AUTH_SIGNED_OUT, () => {
throw new Error('boom');
});
const result = Bus.publish(
EVENT_AUTH_SIGNED_OUT,
{ actorId: 'actor-ada' },
{ correlationId: 'corr-1', causationId: 'cause-1' }
);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toMatchObject({
type: EVENT_AUTH_SIGNED_OUT,
envelopeId: result.envelope.id,
correlationId: 'corr-1',
causationId: 'cause-1'
});
});
it('invokeListener hook wraps every listener invocation', () => {
const wrapped: number[] = [];
const Bus = createEngineBus<TestEvents>({
invokeListener: (fn) => {
wrapped.push(wrapped.length + 1);
return fn();
}
});
Bus.on(EVENT_AUTH_SIGNED_OUT, () => {});
Bus.on(EVENT_AUTH_SIGNED_OUT, () => {});
Bus.publish(EVENT_AUTH_SIGNED_OUT, { actorId: 'actor-ada' });
expect(wrapped).toEqual([1, 2]);
});
it('throws BusInvalidPayloadError in DEV when payload is not cloneable', () => {
const Bus = createEngineBus<TestEvents>();
const naughty = { actorId: 'actor-ada', fn: () => 'not-cloneable' } as unknown as {
actorId: string;
};
expect(() => Bus.publish(EVENT_AUTH_SIGNED_OUT, naughty)).toThrow(
BusInvalidPayloadError
);
});
it('clears listeners for tests and rejects operations after dispose', () => {
const Bus = createEngineBus<TestEvents>();
const listener = vi.fn();
Bus.on(EVENT_AUTH_SIGNED_OUT, listener);
Bus.onAny(listener);
expect(Bus.listenerCount()).toBe(2);
Bus._clearForTesting(BUS_EVENT_ALL);
expect(Bus.listenerCount()).toBe(1);
Bus._clearForTesting();
expect(Bus.listenerCount()).toBe(0);
Bus.dispose();
expect(() => Bus.publish(EVENT_AUTH_SIGNED_OUT, { actorId: 'actor-ada' })).toThrow(
BusDisposedError
);
});
});

@ -0,0 +1,85 @@
/**
* Runtime contracts implemented by `arts/bus`. The pure type vocabulary
* (`BusEnvelope`, `BusListener`, `BusEventMap`, `EventPublisher`, etc.)
* stays in `$libs/bus` because it is consumed by code paths that never
* touch the runtime (event-map authoring, type-only utilities). The
* `Engine*` shapes describe the runtime itself and live here.
*/
import type {
BUS_EVENT_ALL,
BusAnyListener,
BusClock,
BusEnvelope,
BusEventMap,
BusListenOptions,
BusListener,
BusListenerErrorMode,
BusPublishOptions,
BusPublishResult,
BusSubscription,
EventPublisher,
EventSubscriber
} from '$libs/bus';
import type { Logger } from '$libs/logger';
export interface EngineBusOptions {
readonly logger?: Logger;
readonly clock?: BusClock;
readonly idFactory?: () => string;
readonly maxListenersPerEvent?: number;
/**
* Maximum re-entrancy depth for synchronous `publish` calls. A listener
* that publishes recursively into its own subscription chain is
* detected when the depth exceeds this number; the engine throws
* `BusReentrancyLimitError` instead of looping. Default 32.
*/
readonly maxReentrancyDepth?: number;
readonly listenerErrorMode?: BusListenerErrorMode;
/**
* Hook called for every listener invocation. Default is identity. The
* Svelte adapter (`$bus/svelte`) uses this hook to wrap listeners in
* `untrack` so reading `$state` from a listener does not register
* spurious reactive dependencies on the publishing `$effect`.
*/
readonly invokeListener?: (fn: () => void | Promise<void>) => void | Promise<void>;
}
export interface EngineBus<TEvents extends BusEventMap = BusEventMap>
extends EventPublisher<TEvents>,
EventSubscriber<TEvents> {
publishAsync<TType extends keyof TEvents & string>(
type: TType,
payload: TEvents[TType],
options?: BusPublishOptions
): Promise<BusPublishResult<TType, TEvents[TType]>>;
/**
* Publish with `causationId` automatically set to `parent.id`. Use
* inside translators so the causation chain is populated without the
* caller having to thread the parent envelope id manually.
*/
publishCausedBy<TType extends keyof TEvents & string>(
parent: BusEnvelope,
type: TType,
payload: TEvents[TType],
options?: BusPublishOptions
): BusPublishResult<TType, TEvents[TType]>;
/**
* Sugar for `$effect`: returns the unsubscribe function directly so a
* Svelte component can write `$effect(() => Bus.subscribe(type, fn))`
* without juggling an intermediate subscription object.
*/
subscribe<TType extends keyof TEvents & string>(
type: TType,
listener: BusListener<TEvents[TType]>,
options?: BusListenOptions
): () => void;
onAny(listener: BusAnyListener<TEvents>, options?: BusListenOptions): BusSubscription;
listenerCount(type?: keyof TEvents & string): number;
_clearForTesting(type?: keyof TEvents & string | typeof BUS_EVENT_ALL): void;
dispose(): void;
}

@ -0,0 +1,425 @@
# cach
`arts/cach` es la capa de cache de datos del framework. No es un `Map` con TTL:
es un motor de coherencia para decidir si un dato se puede servir, si está fresco,
si debe revalidarse, bajo qué scope de seguridad vive y qué invalidaciones lo
afectan.
El patrón sigue el resto de artefactos:
```ts
import { createEngineCache } from '$svrs/cache';
import { createActiveCache } from '$cache';
```
- `createEngineCache()` vive en `$svrs/cache` y es la API imperativa para server, servicios, repositorios, workers y tests.
- `createActiveCache()` vive en `$cache` y añade estado reactivo para Svelte.
- `App.cache` está siempre presente cuando usas `createActiveApp()`.
- El core puro vive en `$libs/cache` como `createCacheRuntime()`.
## Qué Resuelve
`cach` responde a preguntas que una cache simple no contesta:
- Si el dato existe, si está `fresh`, `stale`, `expired` o invalidado.
- Si puede servirse stale mientras se refresca en background.
- Si puede servirse stale cuando el origen falla.
- Si pertenece a scope `public`, `tenant`, `actor`, `permission` o `custom`.
- Si un cambio de tag o key prefix invalidó la entrada.
- Si hay otra petición idéntica en vuelo y debe deduplicarse.
- Por qué tomó una decisión, vía `explain()`.
## Uso Mínimo
```ts
import { createEngineCache, memoryCacheAdapter, CACHE_POLICY_INTERACTIVE } from '$svrs/cache';
const Cache = createEngineCache({
adapter: memoryCacheAdapter(),
defaultPolicy: CACHE_POLICY_INTERACTIVE
});
const project = await Cache.query({
key: ['project', projectId],
scope: 'public',
tags: [{ type: 'project', id: projectId }],
fetcher: () => ProjectRepo.findById(projectId)
});
```
La segunda lectura con la misma key/scope/policy servirá el valor cacheado si sigue válido.
## App.cache
`createActiveApp()` crea `App.cache` automáticamente con un adapter memory por defecto:
```ts
const App = createActiveApp();
const value = await App.cache.query({
key: ['settings'],
scope: 'public',
fetcher: loadSettings
});
```
For real apps, configure policies, adapter or scope resolver:
```ts
const App = createActiveApp({
services: {
cache: defineActiveCache({
scopeResolver: () => ({
tenantId: App.session?.current?.data?.tenantId,
actorId: App.session?.current?.user?.id,
permissionHash: App.perm?.currentSnapshot.version,
locale: App.lang.getLocale()
})
})
}
});
```
Si una página demo o test se prerenderiza en modo producción y usa ese adapter memory de forma
intencional, configúralo de forma explícita:
```ts
const App = createActiveApp({
cache: {
defaultMemoryAdapter: {
suppressProductionWarning: true
}
}
});
```
No uses esa opción para esconder una cache de producción accidental. En una app real, pasa
`cache.adapter` con el backend que quieras usar o deja el warning activo hasta decidir la estrategia.
Si usas `scope: 'actor'` o `scope: 'permission'`, el resolver debe aportar los valores
necesarios. Si faltan, el motor falla en vez de mezclar datos de usuarios.
### Reacting to identity / session changes
`ActiveCache` is a passive runtime: it never subscribes to the bus on its
own. Cross-module reactions live in **orca presets** declared at the App
level. The standard preset clears the cache on session identity change
and on session revoke:
```ts
import { createActiveApp, applyStandardOrca } from '$active-app';
const App = createActiveApp({
services: {
cache: defineActiveCache({}),
session: defineActiveSession({ ... })
}
});
applyStandardOrca(App);
// → on SESSION_EVENT_IDENTITY_CHANGED: App.cache.clear()
// → on SESSION_EVENT_REVOKED: App.cache.clear()
```
Cherry-pick if the standard set is too aggressive:
```ts
import {
applyCacheClearOnIdentityChange,
applyCacheClearOnRevoke
} from '$active-app';
applyCacheClearOnIdentityChange(App);
// SESSION_EVENT_REVOKED is not handled — caches survive sign-out.
```
Cache itself listens to nothing. It exposes `clear()` / `invalidate()` and
trusts the orchestration layer to call them. This keeps cache invalidation
policy out of the cache art and on the App composition.
## Keys
Las keys son arrays deterministas:
```ts
['posts', { page: 1, filters: { status: 'published' } }][('tenant', tenantId, 'projects')][
'profile'
];
```
El normalizador:
- Ordena keys de objetos.
- Omite campos `undefined` en objetos.
- Soporta `Date`, `Map`, `Set`, `URLSearchParams` y `BigInt`.
- Rechaza funciones, símbolos, números no finitos y referencias circulares.
## Scopes
Los scopes evitan fugas de datos:
```ts
scope: 'public' // compartible
scope: 'tenant' // requiere tenantId
scope: 'actor' // requiere actorId; tenantId opcional
scope: 'permission' // requiere actorId + permissionHash
scope: { mode: 'custom', values: { tenantId, reportId } }
```
Regla de calidad: datos privados nunca deberían cachearse como `public`.
## Policies
Las políticas expresan intención:
```ts
import {
CACHE_POLICY_INTERACTIVE,
CACHE_POLICY_CATALOG,
CACHE_POLICY_PRIVATE_SESSION
} from '$svrs/cache';
```
Incluidas:
- `interactive`: UI normal, `stale-while-revalidate`.
- `catalog`: datos estables, ventanas largas.
- `privateSession`: datos privados, persistencia desactivada por defecto.
- `realtime`: casi sin cache.
- `immutable`: datos versionados o inmutables.
Puedes definir las tuyas:
```ts
import { createEngineCache } from '$svrs/cache';
const Cache = createEngineCache({
policies: {
dashboard: {
freshFor: '15s',
staleFor: '2m',
staleIfErrorFor: '10m',
gcAfter: '30m',
mode: CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
persist: true
}
}
});
```
## Modos
```ts
CACHE_READ_MODE_CACHE_FIRST;
CACHE_READ_MODE_STALE_WHILE_REVALIDATE;
CACHE_READ_MODE_MUST_REVALIDATE;
CACHE_READ_MODE_BYPASS_CACHE;
CACHE_READ_MODE_NO_STORE;
```
- `cache-first`: sirve caché mientras esté fresh.
- `stale-while-revalidate`: sirve stale dentro de ventana y refresca en background.
- `must-revalidate`: si no está fresh, bloquea y va al origen.
- `bypass-cache`: ignora lectura, llama al origen y escribe resultado.
- `no-store`: ignora lectura, llama al origen y no escribe.
## Tags Y Epochs
Las entradas pueden declarar tags:
```ts
tags: [
{ type: 'project', id: projectId },
{ type: 'project', id: 'LIST' }
];
```
Invalidar un tag no escanea ni borra todas las keys. Incrementa un epoch:
```ts
await Cache.invalidate({
tag: { type: 'project', id: projectId },
scope: 'tenant'
});
```
Cada entry guarda los epochs con los que fue escrita. En la siguiente lectura,
si el epoch actual no coincide, la entry queda invalidada.
También hay invalidación por key exacta y key prefix:
```ts
await Cache.invalidate({ key: ['project', projectId], scope: 'tenant' });
await Cache.invalidate({ keyPrefix: ['projects'], scope: 'tenant' });
```
## ActiveCache
`createActiveCache()` expone la misma API que el engine y añade estado reactivo:
```ts
const Cache = createActiveCache();
const entry = Cache.entry({
key: ['dashboard'],
scope: 'public',
fetcher: loadDashboard
});
await entry.load();
entry.data;
entry.status;
entry.error;
entry.loading;
```
`ActiveCacheEntry` no hace magia en efectos. Cargas explícitamente con `load()` o
`refresh()`, y el estado cambia sin bucles reactivos.
`entry.set(value)` reutiliza por defecto la `policy`, `tags`, `schemaVersion`,
`persist` y `scope` definidos en la entry, y permite sobrescribirlos por llamada.
## Mutate
La mutación v1 es conservadora:
```ts
await Cache.mutate({
commit: () => App.http.patch(`/projects/${projectId}`, { body: patch }),
invalidate: [{ tag: { type: 'project', id: 'LIST' }, scope: 'tenant' }],
update: [
{
key: ['project', projectId],
scope: 'tenant',
reducer: (project) => ({ ...project, ...patch })
}
]
});
```
Semántica:
1. Ejecuta `commit()`.
2. Si falla, no toca la cache.
3. Si funciona, invalida tags/prefix/keys.
4. Aplica updates exactos.
Optimistic journal queda fuera de v1.
## Explain
`explain()` existe desde v1 porque cache sin introspección se vuelve opaca:
```ts
const info = await Cache.explain(['project', projectId], {
scope: 'tenant',
schemaVersion: 'Project:v1'
});
```
Devuelve adapter, key efectiva, estado, decisión, razón, schema y epochs.
## Adapters
### Memory
```ts
const adapter = memoryCacheAdapter({
maxEntries: 5000,
maxSizeBytes: 64 * 1024 * 1024
});
```
Soporta TTL, LRU aproximado, epochs e introspección.
Emite el warning constante `CACHE_MEMORY_ADAPTER_PRODUCTION_WARNING` si se crea
en runtime de producción. Es correcto para tests, demos, L1 por proceso y
desarrollo local; para producción multi-instancia usa un adapter intencional
o una composición tiered cuando esté disponible.
### Storage
```ts
const adapter = storageCacheAdapter({
storage: App.storage.adapter,
namespace: 'cach'
});
```
Útil para persistencia cliente. Respeta `persist: false`, por lo que una policy
privada puede impedir escritura persistente.
El adapter mantiene un índice interno para que `Cache.clear()` pueda borrar tanto
entries como epochs persistidos; esto es importante en logout/wipe.
Redis, tiered cache, browser Cache API y adapters edge quedan para fases posteriores.
El contrato está preparado para que Redis se integre sin dependencia dura.
## Eventos Y Logger
El engine emite eventos:
```ts
Cache.on(CACHE_EVENT_ALL, (event) => {
console.log(event.type, event.reason);
});
```
`arts/cach` enruta esos eventos al `EngineLogger` inyectado:
- Hits/misses/sets/invalidation en `debug`.
- Errores de adapter, refresh y stale-if-error en `warn`.
Las categorías y mensajes viven en `consts.ts`, no como strings dispersos.
## Seguridad
Defaults y recomendaciones:
- No uses `public` para datos con `Authorization`, sesión o permisos.
- Usa `tenant`, `actor` o `permission` para datos privados.
- `privateSession` no persiste por defecto.
- Cambios de permisos deben invalidar scope `permission` o cambiar `permissionHash`.
- Logout o cambio de identidad debería llamar a `Cache.clear()` / invalidar scopes privados;
use `applyStandardOrca(App)` (or the individual presets) so this happens automatically
when the session art emits `SESSION_EVENT_*`.
- El cliente cachea para UX, no para seguridad. Las decisiones autoritativas viven en servidor.
## Página De Prueba
La demo interactiva está en:
```txt
/test/cach
```
Muestra `App.cache`, entry reactiva, invalidación por tag, scope actor/tenant,
eventos y `explain()`.
## Roadmap
v1 incluido:
- `createEngineCache()`
- `createActiveCache()`
- `memoryCacheAdapter()`
- `storageCacheAdapter()`
- key normalization
- scopes
- policies
- stale-while-revalidate
- stale-if-error
- singleflight
- tag/prefix epochs
- mutate conservador
- explain
- logger integration
- `App.cache`
- orca presets (`applyCacheClearOnIdentityChange`, `applyCacheClearOnRevoke`)
Siguiente:
- `tieredCacheAdapter()`
- adapter Redis por interfaz, sin dependencia dura
- integración HTTP explícita
- optimistic journal
- browser Cache API para `Request/Response`

@ -0,0 +1,149 @@
import {
CACHE_ACTIVE_ENTRY_EVENT_INVALIDATE,
CACHE_ACTIVE_ENTRY_EVENT_LOAD,
CACHE_ACTIVE_ENTRY_EVENT_REFRESH,
CACHE_ACTIVE_ENTRY_EVENT_SET,
CACHE_ACTIVE_STATUS_ERROR,
CACHE_ACTIVE_STATUS_IDLE,
CACHE_ACTIVE_STATUS_LOADING,
CACHE_ACTIVE_STATUS_REFRESHING,
CACHE_ACTIVE_STATUS_STALE,
CACHE_ACTIVE_STATUS_SUCCESS
} from './consts.ts';
import { CacheActiveEntryDisposedError } from './errors.ts';
import { normalizeActiveCacheError } from './active-cache-helpers.ts';
import type {
ActiveCache,
ActiveCacheEntry,
ActiveCacheEntryEvent,
ActiveCacheEntryListener,
ActiveCacheEntryOptions,
ActiveCacheEntrySnapshot,
ActiveCacheEntryStatus
} from './types.ts';
import type { CacheClock } from '$libs/cache';
export function createActiveCacheEntry<T>(
options: ActiveCacheEntryOptions<T>,
clock: CacheClock,
engine: Pick<ActiveCache, 'query' | 'set' | 'invalidate'>,
onDispose?: () => void
): ActiveCacheEntry<T> {
let dataCell = $state<T | undefined>(undefined);
let errorCell = $state<Error | null>(null);
let statusCell = $state<ActiveCacheEntryStatus>(CACHE_ACTIVE_STATUS_IDLE);
let updatedAtCell = $state<number | null>(null);
let eventCell = $state<ActiveCacheEntryEvent | null>(null);
let disposed = false;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- listeners are notified manually, not rendered.
const listeners = new Set<ActiveCacheEntryListener<T>>();
function snapshot(): ActiveCacheEntrySnapshot<T> {
return {
key: options.key,
data: dataCell,
error: errorCell,
status: statusCell,
updatedAt: updatedAtCell,
event: eventCell
};
}
function notify(): void {
const current = snapshot();
for (const listener of [...listeners]) listener(current);
}
function assertActive(): void {
if (disposed) {
throw new CacheActiveEntryDisposedError();
}
}
async function runLoad(event: ActiveCacheEntryEvent): Promise<T> {
assertActive();
eventCell = event;
statusCell =
dataCell === undefined ? CACHE_ACTIVE_STATUS_LOADING : CACHE_ACTIVE_STATUS_REFRESHING;
errorCell = null;
notify();
try {
const value = await engine.query(options);
dataCell = value;
updatedAtCell = clock.now();
statusCell = CACHE_ACTIVE_STATUS_SUCCESS;
notify();
return value;
} catch (error) {
errorCell = normalizeActiveCacheError(error);
statusCell = CACHE_ACTIVE_STATUS_ERROR;
notify();
throw error;
}
}
return {
get key() {
return options.key;
},
get data() {
return dataCell;
},
get error() {
return errorCell;
},
get status() {
return statusCell;
},
get loading() {
return (
statusCell === CACHE_ACTIVE_STATUS_LOADING || statusCell === CACHE_ACTIVE_STATUS_REFRESHING
);
},
get updatedAt() {
return updatedAtCell;
},
load: () => runLoad(CACHE_ACTIVE_ENTRY_EVENT_LOAD),
refresh: () => runLoad(CACHE_ACTIVE_ENTRY_EVENT_REFRESH),
async set(value, setOptions = {}) {
assertActive();
eventCell = CACHE_ACTIVE_ENTRY_EVENT_SET;
await engine.set(options.key, value, {
policy: options.policy,
mode: options.mode,
tags: options.tags,
schemaVersion: options.schemaVersion,
persist: options.persist,
...setOptions,
scope: options.scope
});
dataCell = value;
errorCell = null;
statusCell = CACHE_ACTIVE_STATUS_SUCCESS;
updatedAtCell = clock.now();
notify();
},
async invalidate() {
assertActive();
eventCell = CACHE_ACTIVE_ENTRY_EVENT_INVALIDATE;
await engine.invalidate({ key: options.key, scope: options.scope });
statusCell = dataCell === undefined ? CACHE_ACTIVE_STATUS_IDLE : CACHE_ACTIVE_STATUS_STALE;
notify();
},
snapshot,
onChange(listener) {
listeners.add(listener);
listener(snapshot());
return () => {
listeners.delete(listener);
};
},
dispose() {
if (disposed) return;
disposed = true;
listeners.clear();
onDispose?.();
}
};
}

@ -0,0 +1,3 @@
export function normalizeActiveCacheError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}

@ -0,0 +1,196 @@
import { CACHE_EVENT_ALL, type CacheClock, type CacheEvent } from '$libs/cache';
import { createEngineCache } from '$libs/cache';
import {
CACHE_ACTIVE_DIAGNOSTIC_EVENTS,
CACHE_METHOD_CLEAR,
CACHE_METHOD_ENTRY,
CACHE_METHOD_EXPLAIN,
CACHE_METHOD_GET,
CACHE_METHOD_INVALIDATE,
CACHE_METHOD_MUTATE,
CACHE_METHOD_ON,
CACHE_METHOD_QUERY,
CACHE_METHOD_SET,
CACHE_METHOD_STATS
} from './consts.ts';
import { disposedCacheMessage } from '$libs/cache';
import { createActiveCacheEntry } from './active-cache-entry.svelte.ts';
import { normalizeActiveCacheError } from './active-cache-helpers.ts';
import { createActiveCacheDiagnostics, emitActiveCacheDiagnostic } from './diagnostics.ts';
import { CacheDisposedError } from './errors.ts';
import type {
ActiveCache,
ActiveCacheEntry,
ActiveCacheEntryOptions,
ActiveCacheOptions
} from './types.ts';
export function createActiveCache(options: ActiveCacheOptions = {}): ActiveCache {
const diagnostics = createActiveCacheDiagnostics(options.logger);
const engine = createEngineCache(options);
const clock = options.clock ?? systemClock;
let disposed = false;
let lastEventCell = $state<CacheEvent | null>(null);
let eventCountCell = $state(0);
let loadingCount = $state(0);
let lastErrorCell = $state<Error | null>(null);
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- active entries are manually disposed, not rendered state.
const entries = new Set<ActiveCacheEntry<unknown>>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- listeners are notified manually, not rendered state.
const listeners = new Set<
(snapshot: ActiveCache['snapshot'] extends () => infer T ? T : never) => void
>();
const offEvents = engine.on(CACHE_EVENT_ALL, (event) => {
lastEventCell = event;
eventCountCell += 1;
notify();
});
function updateLoading(delta: number): void {
loadingCount = Math.max(0, loadingCount + delta);
notify();
}
async function track<T>(method: string, task: () => Promise<T>): Promise<T> {
updateLoading(1);
try {
const value = await task();
lastErrorCell = null;
notify();
return value;
} catch (error) {
lastErrorCell = normalizeActiveCacheError(error);
emitActiveCacheDiagnostic(diagnostics, CACHE_ACTIVE_DIAGNOSTIC_EVENTS.OPERATION_FAILED, {
method,
error
});
notify();
throw error;
} finally {
updateLoading(-1);
}
}
function entry<T>(entryOptions: ActiveCacheEntryOptions<T>): ActiveCacheEntry<T> {
ensureLive(CACHE_METHOD_ENTRY);
const built = createActiveCacheEntry(
entryOptions,
clock,
{
query: (queryOptions) => track(CACHE_METHOD_QUERY, () => engine.query(queryOptions)),
set: (key, value, setOptions) =>
track(CACHE_METHOD_SET, () => engine.set(key, value, setOptions)),
invalidate: (invalidateOptions) =>
track(CACHE_METHOD_INVALIDATE, () => engine.invalidate(invalidateOptions))
},
() => {
entries.delete(built as ActiveCacheEntry<unknown>);
}
);
entries.add(built as ActiveCacheEntry<unknown>);
return built;
}
function ensureLive(method: string): void {
if (disposed) throw new CacheDisposedError(disposedCacheMessage(method));
}
function snapshot() {
return {
lastEvent: lastEventCell,
eventCount: eventCountCell,
loading: loadingCount > 0,
lastError: lastErrorCell,
disposed
};
}
function notify(): void {
const current = snapshot();
for (const listener of [...listeners]) listener(current);
}
return {
get lastEvent() {
return lastEventCell;
},
get eventCount() {
return eventCountCell;
},
get loading() {
return loadingCount > 0;
},
get lastError() {
return lastErrorCell;
},
get disposed() {
return disposed;
},
query(queryOptions) {
ensureLive(CACHE_METHOD_QUERY);
return track(CACHE_METHOD_QUERY, () => engine.query(queryOptions));
},
get(key, getOptions) {
ensureLive(CACHE_METHOD_GET);
return track(CACHE_METHOD_GET, () => engine.get(key, getOptions));
},
set(key, value, setOptions) {
ensureLive(CACHE_METHOD_SET);
return track(CACHE_METHOD_SET, () => engine.set(key, value, setOptions));
},
invalidate(invalidateOptions) {
ensureLive(CACHE_METHOD_INVALIDATE);
return track(CACHE_METHOD_INVALIDATE, () => engine.invalidate(invalidateOptions));
},
mutate(mutateOptions) {
ensureLive(CACHE_METHOD_MUTATE);
return track(CACHE_METHOD_MUTATE, () => engine.mutate(mutateOptions));
},
explain(key, explainOptions) {
ensureLive(CACHE_METHOD_EXPLAIN);
return track(CACHE_METHOD_EXPLAIN, () => engine.explain(key, explainOptions));
},
stats() {
ensureLive(CACHE_METHOD_STATS);
return engine.stats();
},
on(type, handler) {
ensureLive(CACHE_METHOD_ON);
return engine.on(type, handler);
},
clear() {
ensureLive(CACHE_METHOD_CLEAR);
return track(CACHE_METHOD_CLEAR, () => engine.clear());
},
entry,
snapshot,
clearError() {
ensureLive(CACHE_METHOD_CLEAR);
lastErrorCell = null;
notify();
},
onChange(listener) {
ensureLive(CACHE_METHOD_ON);
listeners.add(listener);
listener(snapshot());
return () => {
listeners.delete(listener);
};
},
dispose() {
if (disposed) return;
disposed = true;
for (const activeEntry of entries) activeEntry.dispose();
entries.clear();
offEvents();
engine.dispose();
listeners.clear();
}
};
}
const systemClock: CacheClock = {
now: () => Date.now()
};

@ -0,0 +1,69 @@
export {
CACHE_LOG_MESSAGE_ADAPTER_ERROR,
CACHE_LOG_MESSAGE_BY_EVENT,
CACHE_LOG_MESSAGE_DELETE,
CACHE_LOG_MESSAGE_EVICTION,
CACHE_LOG_MESSAGE_HIT,
CACHE_LOG_MESSAGE_INVALIDATE,
CACHE_LOG_MESSAGE_MISS,
CACHE_LOG_MESSAGE_REFRESH_ERROR,
CACHE_LOG_MESSAGE_REFRESH_START,
CACHE_LOG_MESSAGE_REFRESH_SUCCESS,
CACHE_LOG_MESSAGE_SCHEMA_MISMATCH,
CACHE_LOG_MESSAGE_SCOPE_ERROR,
CACHE_LOG_MESSAGE_SET,
CACHE_LOG_MESSAGE_SINGLEFLIGHT_JOIN,
CACHE_LOG_MESSAGE_STALE_HIT,
CACHE_LOG_MESSAGE_STALE_IF_ERROR,
CACHE_METHOD_CLEAR,
CACHE_METHOD_EXPLAIN,
CACHE_METHOD_GET,
CACHE_METHOD_INVALIDATE,
CACHE_METHOD_MUTATE,
CACHE_METHOD_ON,
CACHE_METHOD_QUERY,
CACHE_METHOD_SET,
CACHE_METHOD_STATS,
CACHE_MODULE
} from '$libs/cache';
export const CACHE_ACTIVE_STATUS_IDLE = 'idle';
export const CACHE_ACTIVE_STATUS_LOADING = 'loading';
export const CACHE_ACTIVE_STATUS_SUCCESS = 'success';
export const CACHE_ACTIVE_STATUS_STALE = 'stale';
export const CACHE_ACTIVE_STATUS_REFRESHING = 'refreshing';
export const CACHE_ACTIVE_STATUS_ERROR = 'error';
export const CACHE_ACTIVE_STATUS_DEGRADED = 'degraded';
export const CACHE_ACTIVE_STATUSES = [
CACHE_ACTIVE_STATUS_IDLE,
CACHE_ACTIVE_STATUS_LOADING,
CACHE_ACTIVE_STATUS_SUCCESS,
CACHE_ACTIVE_STATUS_STALE,
CACHE_ACTIVE_STATUS_REFRESHING,
CACHE_ACTIVE_STATUS_ERROR,
CACHE_ACTIVE_STATUS_DEGRADED
] as const;
// Active cache entry event names. Scoped with `cache.entry.` so they do
// not collide with the engine-level `cach.*` events nor with any other
// artifact's events.
export const CACHE_ACTIVE_ENTRY_EVENT_LOAD = 'cache.entry.load';
export const CACHE_ACTIVE_ENTRY_EVENT_REFRESH = 'cache.entry.refresh';
export const CACHE_ACTIVE_ENTRY_EVENT_INVALIDATE = 'cache.entry.invalidate';
export const CACHE_ACTIVE_ENTRY_EVENT_SET = 'cache.entry.set';
export const CACHE_ACTIVE_DIAGNOSTIC_EVENTS = {
OPERATION_FAILED: 'cache.active.operation_failed'
} as const;
export const CACHE_ACTIVE_LOG_MESSAGE_OPERATION_FAILED = 'active cache operation failed';
export const CACHE_METHOD_ENTRY = 'cache.entry';
export const CACHE_ACTIVE_ENTRY_EVENTS = [
CACHE_ACTIVE_ENTRY_EVENT_LOAD,
CACHE_ACTIVE_ENTRY_EVENT_REFRESH,
CACHE_ACTIVE_ENTRY_EVENT_INVALIDATE,
CACHE_ACTIVE_ENTRY_EVENT_SET
] as const;

@ -0,0 +1,56 @@
import {
LogLevel,
createCatalogDiagnostics,
type DiagnosticCatalog,
type DiagnosticEvent,
type Diagnostics,
type Logger
} from '$libs/logger';
import type { CacheKey } from '$libs/cache';
import {
CACHE_ACTIVE_DIAGNOSTIC_EVENTS,
CACHE_ACTIVE_LOG_MESSAGE_OPERATION_FAILED,
CACHE_MODULE
} from './consts.ts';
export type ActiveCacheDiagnosticType =
(typeof CACHE_ACTIVE_DIAGNOSTIC_EVENTS)[keyof typeof CACHE_ACTIVE_DIAGNOSTIC_EVENTS];
export interface ActiveCacheDiagnosticMeta {
readonly method: string;
readonly key?: CacheKey;
readonly error?: unknown;
}
export type ActiveCacheDiagnosticEvent = DiagnosticEvent<
ActiveCacheDiagnosticType,
ActiveCacheDiagnosticMeta
>;
export type ActiveCacheDiagnostics = Diagnostics<ActiveCacheDiagnosticEvent>;
const ACTIVE_CACHE_DIAGNOSTIC_LOGS: DiagnosticCatalog<ActiveCacheDiagnosticEvent> = {
[CACHE_ACTIVE_DIAGNOSTIC_EVENTS.OPERATION_FAILED]: {
level: LogLevel.WARN,
message: CACHE_ACTIVE_LOG_MESSAGE_OPERATION_FAILED
}
};
export function createActiveCacheDiagnostics(logger?: Logger): ActiveCacheDiagnostics {
return createCatalogDiagnostics({
logger,
defaultCategory: CACHE_MODULE,
catalog: ACTIVE_CACHE_DIAGNOSTIC_LOGS
});
}
export function emitActiveCacheDiagnostic(
diagnostics: ActiveCacheDiagnostics,
type: ActiveCacheDiagnosticType,
meta: ActiveCacheDiagnosticMeta
): void {
diagnostics.emit({
artifact: CACHE_MODULE,
type,
meta
});
}

@ -0,0 +1,20 @@
import { CodeError } from '$libs/errs';
import { CACHE_ERR_ACTIVE_ENTRY_DISPOSED } from '$libs/cache';
import { CACHE_MODULE } from '$libs/cache';
export { CACHE_ERROR_MSG_DISPOSED_SUFFIX, CacheDisposedError, isCacheDisposedError } from '$libs/cache';
export const CACHE_ERROR_MSG_ACTIVE_ENTRY_DISPOSED =
`[${CACHE_MODULE}] ActiveCacheEntry used after dispose().`;
export class CacheActiveEntryDisposedError extends CodeError {
constructor(message = CACHE_ERROR_MSG_ACTIVE_ENTRY_DISPOSED) {
super(CACHE_ERR_ACTIVE_ENTRY_DISPOSED, { message });
}
}
export function isCacheActiveEntryDisposedError(
error: unknown
): error is CacheActiveEntryDisposedError {
return error instanceof CacheActiveEntryDisposedError;
}

@ -0,0 +1,190 @@
export { createActiveCache } from './active-cache.svelte.ts';
export { CACHE_ERR, CACHE_ERR_ACTIVE_ENTRY_DISPOSED, CACHE_ERR_DISPOSED } from '$libs/cache';
export {
CACHE_ACTIVE_ENTRY_EVENT_INVALIDATE,
CACHE_ACTIVE_ENTRY_EVENT_LOAD,
CACHE_ACTIVE_ENTRY_EVENT_REFRESH,
CACHE_ACTIVE_ENTRY_EVENT_SET,
CACHE_ACTIVE_ENTRY_EVENTS,
CACHE_ACTIVE_DIAGNOSTIC_EVENTS,
CACHE_ACTIVE_LOG_MESSAGE_OPERATION_FAILED,
CACHE_ACTIVE_STATUS_DEGRADED,
CACHE_ACTIVE_STATUS_ERROR,
CACHE_ACTIVE_STATUS_IDLE,
CACHE_ACTIVE_STATUS_LOADING,
CACHE_ACTIVE_STATUS_REFRESHING,
CACHE_ACTIVE_STATUS_STALE,
CACHE_ACTIVE_STATUS_SUCCESS,
CACHE_ACTIVE_STATUSES,
CACHE_LOG_MESSAGE_ADAPTER_ERROR,
CACHE_LOG_MESSAGE_BY_EVENT,
CACHE_LOG_MESSAGE_DELETE,
CACHE_LOG_MESSAGE_EVICTION,
CACHE_LOG_MESSAGE_HIT,
CACHE_LOG_MESSAGE_INVALIDATE,
CACHE_LOG_MESSAGE_MISS,
CACHE_LOG_MESSAGE_REFRESH_ERROR,
CACHE_LOG_MESSAGE_REFRESH_START,
CACHE_LOG_MESSAGE_REFRESH_SUCCESS,
CACHE_LOG_MESSAGE_SCHEMA_MISMATCH,
CACHE_LOG_MESSAGE_SCOPE_ERROR,
CACHE_LOG_MESSAGE_SET,
CACHE_LOG_MESSAGE_SINGLEFLIGHT_JOIN,
CACHE_LOG_MESSAGE_STALE_HIT,
CACHE_LOG_MESSAGE_STALE_IF_ERROR,
CACHE_METHOD_CLEAR,
CACHE_METHOD_ENTRY,
CACHE_METHOD_EXPLAIN,
CACHE_METHOD_GET,
CACHE_METHOD_INVALIDATE,
CACHE_METHOD_MUTATE,
CACHE_METHOD_ON,
CACHE_METHOD_QUERY,
CACHE_METHOD_SET,
CACHE_METHOD_STATS,
CACHE_MODULE
} from './consts.ts';
export { createActiveCacheDiagnostics, emitActiveCacheDiagnostic } from './diagnostics.ts';
export {
CACHE_ERROR_MSG_ACTIVE_ENTRY_DISPOSED,
CACHE_ERROR_MSG_DISPOSED_SUFFIX,
CacheActiveEntryDisposedError,
CacheDisposedError,
isCacheActiveEntryDisposedError,
isCacheDisposedError
} from './errors.ts';
export { disposedCacheMessage } from '$libs/cache';
export type {
ActiveCacheDiagnosticEvent,
ActiveCacheDiagnosticMeta,
ActiveCacheDiagnostics,
ActiveCacheDiagnosticType
} from './diagnostics.ts';
export type {
ActiveCache,
ActiveCacheEntry,
ActiveCacheEntryEvent,
ActiveCacheEntryListener,
ActiveCacheEntryOptions,
ActiveCacheEntrySnapshot,
ActiveCacheEntryStatus,
ActiveCacheOptions,
EngineCache,
EngineCacheOptions
} from './types.ts';
export {
CACHE_ADAPTER_MEMORY,
CACHE_ADAPTER_STORAGE,
CACHE_DECISION_ACTION_DELETE_AND_FETCH,
CACHE_DECISION_ACTION_FETCH,
CACHE_DECISION_ACTION_SERVE,
CACHE_DECISION_ACTION_SERVE_AND_REFRESH,
CACHE_DECISION_ACTION_SKIP_CACHE,
CACHE_DECISION_REASON_BYPASS_CACHE,
CACHE_DECISION_REASON_CACHE_MISS,
CACHE_DECISION_REASON_EXPIRED,
CACHE_DECISION_REASON_FRESH,
CACHE_DECISION_REASON_NO_STORE,
CACHE_DECISION_REASON_PREFIX_EPOCH_CHANGED,
CACHE_DECISION_REASON_SCHEMA_VERSION_MISMATCH,
CACHE_DECISION_REASON_SCOPE_MISMATCH,
CACHE_DECISION_REASON_STALE_WINDOW_VALID,
CACHE_DECISION_REASON_TAG_EPOCH_CHANGED,
CACHE_ENVELOPE_STATE_DEGRADED,
CACHE_ENVELOPE_STATE_EXPIRED,
CACHE_ENVELOPE_STATE_FRESH,
CACHE_ENVELOPE_STATE_INVALIDATED,
CACHE_ENVELOPE_STATE_STALE,
CACHE_EVENT_ADAPTER_ERROR,
CACHE_EVENT_ALL,
CACHE_EVENT_DELETE,
CACHE_EVENT_HIT,
CACHE_EVENT_INVALIDATE,
CACHE_EVENT_MISS,
CACHE_EVENT_REFRESH_ERROR,
CACHE_EVENT_REFRESH_START,
CACHE_EVENT_REFRESH_SUCCESS,
CACHE_EVENT_SCHEMA_MISMATCH,
CACHE_EVENT_SCOPE_ERROR,
CACHE_EVENT_SET,
CACHE_EVENT_SINGLEFLIGHT_JOIN,
CACHE_EVENT_STALE_HIT,
CACHE_EVENT_STALE_IF_ERROR,
CACHE_EVENT_EVICTION,
CACHE_POLICY_CATALOG,
CACHE_POLICY_IMMUTABLE,
CACHE_POLICY_INTERACTIVE,
CACHE_POLICY_PRIVATE_SESSION,
CACHE_POLICY_REALTIME,
CACHE_READ_MODE_BYPASS_CACHE,
CACHE_READ_MODE_CACHE_FIRST,
CACHE_READ_MODE_MUST_REVALIDATE,
CACHE_READ_MODE_NO_STORE,
CACHE_READ_MODE_STALE_WHILE_REVALIDATE,
CACHE_SCOPE_ACTOR,
CACHE_SCOPE_CUSTOM,
CACHE_SCOPE_PERMISSION,
CACHE_SCOPE_PUBLIC,
CACHE_SCOPE_TENANT,
CacheKeyError,
CachePolicyError,
CacheScopeError,
createMapStorage,
defaultCachePolicies,
durationToMs,
isCacheKeyError,
isCachePolicyError,
isCacheScopeError,
memoryCacheAdapter,
normalizeKey,
normalizeKeyPrefixes,
normalizeTag,
normalizeTags,
resolveScope,
stableHash,
stableStringify,
storageCacheAdapter
} from '$libs/cache';
export type {
CacheAdapter,
CacheAdapterSetOptions,
CacheClock,
CacheDecision,
CacheDecisionAction,
CacheDecisionReason,
CacheEnvelope,
CacheEnvelopeState,
CacheEvent,
CacheEventHandler,
CacheEventSelector,
CacheEventType,
CacheExplain,
CacheKey,
CachePolicy,
CacheReadMode,
CacheRuntime,
CacheRuntimeConfig,
CacheScopeInput,
CacheScopeMode,
CacheStats,
CacheTagLike,
CacheTagObject,
CustomCacheScope,
ExplainOptions,
GetOptions,
InvalidateOptions,
MemoryCacheAdapter,
MemoryCacheAdapterOptions,
MemoryCacheEvictReason,
MutateOptions,
QueryOptions,
ResolvedCachePolicy,
ResolvedCacheScope,
ResolvedScopeValues,
ScopeResolver,
SetOptions,
StorageCacheAdapterOptions,
StorageLike
} from '$libs/cache';

@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import { createActiveApp } from '$active-app';
import { defineActiveCache } from '$active-app/services';
import {
CACHE_ACTIVE_STATUS_STALE,
CACHE_ACTIVE_STATUS_SUCCESS,
CACHE_POLICY_INTERACTIVE,
CACHE_SCOPE_PUBLIC,
createActiveCache,
isCacheActiveEntryDisposedError,
memoryCacheAdapter,
type CacheClock
} from '../index.ts';
function testClock(initial = 0): CacheClock & { advance(ms: number): void } {
let now = initial;
return {
now: () => now,
advance(ms: number) {
now += ms;
}
};
}
describe('createActiveCache', () => {
it('creates reactive entries over the engine cache', async () => {
const clock = testClock();
const Cache = createActiveCache({
clock,
adapter: memoryCacheAdapter({ clock })
});
const entry = Cache.entry({
key: ['dashboard'],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => ({ cards: 3 })
});
await entry.load();
expect(entry.data).toEqual({ cards: 3 });
expect(entry.status).toBe(CACHE_ACTIVE_STATUS_SUCCESS);
await entry.invalidate();
expect(entry.status).toBe(CACHE_ACTIVE_STATUS_STALE);
expect(Cache.eventCount).toBeGreaterThan(0);
Cache.dispose();
});
it('entry.set() inherits policy metadata from the entry options', async () => {
const clock = testClock();
const Cache = createActiveCache({
clock,
adapter: memoryCacheAdapter({ clock })
});
const entry = Cache.entry<number>({
key: ['project', 7],
scope: CACHE_SCOPE_PUBLIC,
policy: CACHE_POLICY_INTERACTIVE,
schemaVersion: 'Project:v1',
tags: [{ type: 'project', id: 7 }],
fetcher: async () => 1
});
await entry.set(2);
await expect(
Cache.get<number>(['project', 7], {
scope: CACHE_SCOPE_PUBLIC,
policy: CACHE_POLICY_INTERACTIVE,
schemaVersion: 'Project:v1'
})
).resolves.toBe(2);
Cache.dispose();
});
it('throws typed errors after active entry dispose()', async () => {
expect.assertions(1);
const Cache = createActiveCache();
const entry = Cache.entry({
key: ['entry-disposed'],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => 'never'
});
entry.dispose();
try {
await entry.load();
} catch (error) {
expect(isCacheActiveEntryDisposedError(error)).toBe(true);
}
Cache.dispose();
});
it('disposes active entries owned by the parent cache', async () => {
expect.assertions(1);
const Cache = createActiveCache();
const entry = Cache.entry({
key: ['parent-disposed-entry'],
scope: CACHE_SCOPE_PUBLIC,
fetcher: async () => 'never'
});
Cache.dispose();
try {
await entry.load();
} catch (error) {
expect(isCacheActiveEntryDisposedError(error)).toBe(true);
}
});
it('builds via defineActiveCache() in the App service schema', () => {
const App = createActiveApp({
services: {
cache: defineActiveCache()
}
});
expect(App.cache).toBeTruthy();
expect(App.cache.stats().counters).toEqual({});
App.dispose();
});
});

@ -0,0 +1,60 @@
import type { CACHE_ACTIVE_ENTRY_EVENTS, CACHE_ACTIVE_STATUSES } from './consts.ts';
import type { CacheClock, CacheEvent, CacheKey, QueryOptions, SetOptions } from '$libs/cache';
import type { EngineCache, EngineCacheOptions } from '$libs/cache';
import type { ActiveChangeListener, ActiveEngine } from '$libs/active';
export type { EngineCache, EngineCacheOptions } from '$libs/cache';
export type ActiveCacheEntryStatus = (typeof CACHE_ACTIVE_STATUSES)[number];
export type ActiveCacheEntryEvent = (typeof CACHE_ACTIVE_ENTRY_EVENTS)[number];
export interface ActiveCacheEntrySnapshot<T> {
readonly key: CacheKey;
readonly data: T | undefined;
readonly error: Error | null;
readonly status: ActiveCacheEntryStatus;
readonly updatedAt: number | null;
readonly event: ActiveCacheEntryEvent | null;
}
export type ActiveCacheEntryListener<T> = (snapshot: ActiveCacheEntrySnapshot<T>) => void;
export interface ActiveCacheEntry<T> {
readonly key: CacheKey;
readonly data: T | undefined;
readonly error: Error | null;
readonly status: ActiveCacheEntryStatus;
readonly loading: boolean;
readonly updatedAt: number | null;
load(): Promise<T>;
refresh(): Promise<T>;
set(value: T, options?: Omit<SetOptions, 'scope'>): Promise<void>;
invalidate(): Promise<void>;
snapshot(): ActiveCacheEntrySnapshot<T>;
onChange(listener: ActiveCacheEntryListener<T>): () => void;
dispose(): void;
}
export interface ActiveCacheEntryOptions<T> extends QueryOptions<T> {
clock?: CacheClock;
}
export interface ActiveCacheOptions extends EngineCacheOptions {}
export interface ActiveCacheSnapshot {
readonly lastEvent: CacheEvent | null;
readonly eventCount: number;
readonly loading: boolean;
readonly lastError: Error | null;
readonly disposed: boolean;
}
export interface ActiveCache extends EngineCache, ActiveEngine<ActiveCacheSnapshot, Error> {
readonly lastEvent: CacheEvent | null;
readonly eventCount: number;
readonly loading: boolean;
readonly lastError: Error | null;
entry<T>(options: ActiveCacheEntryOptions<T>): ActiveCacheEntry<T>;
onChange(listener: ActiveChangeListener<ActiveCacheSnapshot>): () => void;
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,483 @@
# conn
`conn` es el artefacto de conexiones realtime. No es "un wrapper de
WebSocket": es un registro de conexiones con transporte intercambiable,
reconexión, heartbeat, request/reply, canales, integración opcional con sesión
y diagnósticos estructurados.
La regla de nombres es importante:
| Concepto | Nombre |
| -------- | ------ |
| Raíz imperativa | `createEngineConnections()` / `EngineConnections` |
| Raíz reactiva | `createActiveConnections()` / `ActiveConnections` |
| Unidad individual | `Connection` |
| Topic lógico dentro de una conexión | `ConnectionChannel` |
No existe `EngineConnection` ni `ActiveConnection`. `Engine*` y `Active*`
quedan reservados para raíces de artefacto; una conexión individual no es una
raíz, es una entidad gestionada por `EngineConnections`.
## Uso Mínimo
```ts
import { createEngineConnections, createWebSocketTransport } from '$connection';
const Connections = createEngineConnections();
const Main = Connections.createConnection('main', {
transport: createWebSocketTransport({ url: () => '/realtime' }),
heartbeat: false
});
await Main.connect();
```
La raíz mantiene el mapa de conexiones:
```ts
Connections.names();
Connections.connection('main');
await Connections.openConnection('main');
Connections.closeConnection('main', 'manual');
await Connections.reconnectAll('network-restored');
Connections.dispose();
```
`dispose()` de la raíz cierra conexiones, cancela timers propios y limpia los
listeners. Después de `dispose()`, las operaciones públicas lanzan errores
tipados `Conn*`.
## ActiveConnections
La capa activa añade estado derivado para UI:
```ts
const Connections = createActiveConnections();
Connections.size;
Connections.activeNames;
Connections.states;
Connections.connectedNames;
Connections.failedNames;
Connections.anyConnected;
Connections.anyFailed;
```
`ActiveConnections` conserva la misma API de creación/consulta que el engine,
pero sus colecciones reflejan los cambios de estado de cada conexión.
## Integración Con App
`aapp` expone una factory porque las conexiones son app-scoped y suelen tener
tipos específicos del proyecto:
```ts
const Connections = App.createActiveConnections<AppConnections>();
```
App inyecta:
- `App.logger`, como `Logger` común de `$libs/logger`.
- `App.timers`, para reconexión, heartbeat y timeouts de ack.
### Reacción a cambios de identidad
El patrón canónico para reaccionar a cambios de sesión vive en los
**presets de `arts/active-app`**, no dentro del registry de
conexiones:
```ts
import { applyStandardOrca } from '$active-app/presets';
const App = createActiveApp({
services: {
connections: defineActiveConnections({}),
session: defineActiveSession({ ... }),
cache: defineActiveCache({ ... }),
perm: defineActivePerms({ ... })
}
});
applyStandardOrca(App);
// → registra applyConnectionsReauthOnIdentityChange y
// applyConnectionsCloseOnRevoke entre otros, todos vía orca.
```
`applyConnectionsReauthOnIdentityChange` escucha
`SESSION_EVENT_IDENTITY_CHANGED` y llama
`App.connections.reauthenticateAll()`. `applyConnectionsCloseOnRevoke`
escucha `SESSION_EVENT_REVOKED` y llama `App.connections.closeAll()`.
Apps que prefieran granularidad pueden llamar a los presets
individuales en lugar del agregador.
### Sesión per-connection (modo manual)
Para casos donde una conexión concreta tiene un `ConnectionSessionSource`
propio (ajeno al `App.session` global), o para usos standalone sin
orca, cada conexión sigue aceptando `session` en sus opciones:
```ts
const Main = Connections.createConnection('main', {
transport: createWebSocketTransport({ url: '/realtime' }),
auth: () => ({ token: App.session?.current?.credential }),
session: {
enabled: true,
reauthOnRefresh: true,
disconnectOnExpire: true
}
});
```
Si la conexión declara `session.enabled`, su lógica interna
(`session-wiring.ts`) escucha el `onChange` del source y reacciona
con `reauthenticate()` / `disconnect()`. La reautenticación necesita
`auth`: define qué credencial nueva se envía cuando el source dice
"identidad cambió". Sin `auth` no se puede emitir un frame de reauth
y debe resolverse con reconnect/disconnect manual.
Las dos vías (preset orca a nivel App y `session` per-connection)
coexisten. La regla práctica: usa el preset cuando uses `arts/orca` y
`arts/session`; usa `session` per-connection para casos standalone o
cuando la conexión vive fuera del ciclo App.
Cuando `Timers` no se inyecta, `createEngineConnections()` crea un scheduler
privado con el mismo logger. Los diagnósticos del scheduler salen bajo la
categoría `timr`; los de conexiones salen bajo `conn` o `conn:<name>`.
## Estados
Estados de conexión:
```txt
idle -> connecting -> open
open -> reconnecting -> open
open -> closing -> closed
connecting/reconnecting -> failed
```
Campos útiles:
```ts
Main.state;
Main.connected;
Main.generation;
Main.error;
Main.openedAt;
Main.closedAt;
Main.lastMessageAt;
Main.reconnectAttempt;
```
`generation` cambia cuando se abre una conexión nueva. Los timeouts de ack,
heartbeat y reconexión usan esa generación para no resolver trabajo viejo sobre
una conexión nueva.
## Transports
El contrato mínimo es `ConnectionTransport`:
```ts
interface ConnectionTransport {
readonly kind: string;
readonly state: ConnectionTransportState;
readonly bufferedAmount: number;
readonly canSend: boolean;
open(): Promise<void>;
send(data: string | ArrayBuffer): Promise<void> | void;
close(code?: number, reason?: string): void;
onOpen(listener: () => void): () => void;
onMessage(listener: (message: string | ArrayBuffer) => void): () => void;
onClose(listener: (event: ConnectionCloseEvent) => void): () => void;
onError(listener: (error: unknown) => void): () => void;
}
```
Incluidos:
- `createWebSocketTransport()` para navegador/runtime con WebSocket.
- `createMockTransport()` para tests, loopback y páginas de diagnóstico.
El transporte no decide reconexión, auth, heartbeat ni canales. Solo abre,
envía, cierra y emite eventos.
## Frames
El frame canónico:
```ts
interface ConnectionFrame<TType extends string = string, TPayload = unknown> {
readonly id?: string;
readonly topic?: string;
readonly type: TType;
readonly payload: TPayload;
readonly ts?: number;
readonly ack?: boolean;
readonly replyTo?: string;
readonly error?: ConnectionFrameError;
}
```
El serializer por defecto es JSON y valida la forma mínima del frame. Errores
de encode/decode no se lanzan como strings dispersos: devuelven resultados
tagged o errores `ConnectionInvalidFrameError` según el punto de entrada.
## Send Y Request/Reply
`send()` devuelve un resultado tagged:
```ts
const result = await Main.send('project.updated', { id: 'p1' });
if (!result.ok) {
console.log(result.reason);
}
```
`request()` usa `ack: true`, genera un `id` y espera un frame entrante con
`replyTo` igual a ese id:
```ts
const reply = await Main.request<{ id: string }, { ok: boolean }>('project.sync', {
id: 'p1'
});
if (reply.ok) {
reply.payload.ok;
}
```
Razones de fallo principales:
- `timeout`
- `closed`
- `rejected`
- `transport_error`
- `invalid_reply`
## Canales
Los canales son topics nombrados dentro de una conexión. Se cachean por nombre:
```ts
type ProjectEvents = {
'project.updated': { id: string; version: number };
};
const Projects = Main.channel<ProjectEvents>('tenant:projects');
Projects.on('project.updated', (payload, meta) => {
console.log(payload.id, meta.receivedAt);
});
await Projects.join({ tenantId: 'acme' });
await Projects.send('project.updated', { id: 'p1', version: 2 });
await Projects.leave();
```
Estados de canal:
```txt
idle -> joining -> joined -> leaving -> left
joining -> failed
```
`dispose()` del canal limpia listeners y deja el canal en estado terminal
`left`.
## Reconexión
La reconexión usa `timr` y backoff configurable:
```ts
Connections.createConnection('main', {
transport,
reconnect: {
enabled: true,
minDelayMs: 500,
maxDelayMs: 15_000,
factor: 1.8,
jitterMs: 500,
maxAttempts: 8,
reconnectOnVisible: true,
reconnectOnOnline: true
}
});
```
Si `reconnectOnVisible` o `reconnectOnOnline` están activos, el módulo escucha
eventos del navegador y pide reconexión cuando la conexión está cerrada o
fallida. Esa capa no recibe funciones reducidas por severidad; recibe `Diagnostics`, que
incluye el `Logger` completo y emite eventos catalogados.
## Heartbeat
```ts
Connections.createConnection('main', {
transport,
heartbeat: {
enabled: true,
intervalMs: 25_000,
timeoutMs: 10_000,
pingType: 'connection.ping',
pongType: 'connection.pong'
}
});
```
El heartbeat envía `pingType` periódicamente y espera `pongType`. Si vence el
timeout, cierra la conexión con `heartbeat_timeout` y deja que la política de
reconexión decida el siguiente paso.
## Auth Y Sesión
Auth de conexión:
```ts
Connections.createConnection('main', {
transport,
auth: {
getAuth: () => ({ token }),
authType: 'connection.auth',
timeoutMs: 10_000
}
});
```
El resultado de auth es tagged:
```ts
await Main.reauthenticate(); // { ok: true } | { ok: false, reason, error? }
```
The full flow with the orca preset (`applyStandardOrca` or
`applyConnectionsReauthOnIdentityChange` /
`applyConnectionsCloseOnRevoke`) is:
```txt
session -> SESSION_EVENT_IDENTITY_CHANGED on App.bus
orca -> connections-reauth-on-identity action runs
-> App.connections.reauthenticateAll()
-> each connection calls auth() with the new credential
session -> SESSION_EVENT_REVOKED on App.bus
orca -> connections-close-on-revoke action runs
-> App.connections.closeAll('session-revoked')
```
The connection art does not subscribe to `session.*` events directly:
the orca preset is the canonical bridge. For standalone setups (a
connection that lives outside an App composition or that needs a
custom session source), each connection still accepts
`session: { enabled, reauthOnRefresh, disconnectOnExpire, ... }` and
its internal `session-wiring` listens to the source's `onChange`.
Both routes coexist: pick the orca preset when using `arts/orca` +
`arts/session`; pick the per-connection `session` option for manual
control.
`conn` no crea sesiones ni decide permisos. En servidor, los joins/sends de un
canal deben validarse con `auth/sess/perm`.
## Buffer
Cuando la conexión no está abierta, `send()` puede comportarse según policy:
```ts
buffer: {
policy: 'buffer', // 'buffer' | 'drop' | 'fail'
maxMessages: 100,
maxBytes: 1_000_000
}
```
- `buffer`: encola y drena al abrir.
- `drop`: acepta la llamada pero descarta.
- `fail`: devuelve `{ ok: false, reason: 'closed' }`.
## Diagnostics Y Logger
Las opciones públicas aceptan `logger?: Logger` desde `$libs/logger`. No existe
un contrato local de logger para `conn`.
```ts
import type { Logger } from '$libs/logger';
```
Internamente `conn` usa `createConnectionDiagnostics(logger)` y eventos
catalogados en `CONNECTION_DIAGNOSTIC_EVENTS`:
- `connect_failed`
- `transport_error`
- `send_failed`
- `frame_decode_failed`
- `frame_encode_failed`
- `auth_failed`
- `reauth_failed`
- `heartbeat_timeout`
- `reconnect_exhausted`
- `browser_reconnect`
- `session_refreshed`
- `session_expired`
- `session_revoked`
- `listener_threw`
Los mensajes y categorías viven en `consts.ts`. Si quieres enviar eventos a
Sentry, Loki o Datadog, inyecta un `EngineLogger` con el transporte adecuado;
`conn` solo emite al contrato común.
## Errores
Programmer errors lanzan clases tipadas:
- `ConnectionDisposedError`
- `ConnectionAlreadyExistsError`
- `ConnectionNotFoundError`
- `ConnectionInvalidNameError`
- `ConnectionInvalidFrameError`
- `ConnectionChannelAlreadyExistsError`
- `ConnectionChannelNotFoundError`
- `ConnectionWebSocketUnavailableError`
Fallos runtime de transporte/envío/auth/request devuelven resultados tagged
para que el consumidor pueda decidir sin `try/catch` obligatorio.
## Página De Prueba
La página interactiva está en:
```txt
/test/conn
```
Incluye chat WebSocket real usando `scripts/conn-chat-server.mjs`:
```txt
npm run dev:conn-chat
```
También `/test/ecosystem` usa `conn` dentro de la demo total con transporte
mock loopback para probar integración con `aapp`, `timr`, `logr`, `perm` y
`cach`.
## Testing
Tests principales:
```txt
src/arts/conn/test/engine-connections.test.ts
src/arts/conn/test/active-connections.test.ts
src/arts/conn/test/connection.test.ts
src/arts/conn/test/connection-state.test.ts
src/arts/conn/test/websocket.test.ts
```
Casos que deben mantenerse cubiertos:
- creación duplicada y lookup inexistente;
- `this` no requerido al desestructurar métodos del engine;
- reloj inyectado para timestamps deterministas;
- reconnect con fake timers;
- heartbeat timeout;
- request/reply con timeout y rechazo;
- channel join/leave/dispose;
- bridge de sesión refresh/expire/revoke;
- serializer inválido y transport errors.

@ -0,0 +1,82 @@
import type { TimerScheduler } from '$libs/timer';
import {
CONNECTION_ACK_REASON_CLOSED,
CONNECTION_ACK_REASON_REJECTED,
CONNECTION_ACK_REASON_TIMEOUT,
CONNECTION_ACK_REASON_TRANSPORT_ERROR,
CONNECTION_SEND_REASON_CLOSED,
CONNECTION_TIMER_KEY_ACK
} from './consts.ts';
import { timerKey } from './helpers.ts';
import type { ConnectionAckResult, ConnectionFrame, ConnectionSendResult } from './types.ts';
interface PendingAck {
readonly timer: string;
readonly resolve: (result: ConnectionAckResult<unknown>) => void;
}
export interface ConnectionAckRegistry {
wait<TResult>(id: string, timeoutMs: number): Promise<ConnectionAckResult<TResult>>;
resolve(id: string, result: ConnectionAckResult<unknown>): void;
resolveAll(result: ConnectionAckResult<unknown>): void;
resolveFromFrame(frame: ConnectionFrame): void;
mapSendFailure(result: ConnectionSendResult): ConnectionAckResult<unknown>;
}
export function createConnectionAckRegistry(
connectionName: string,
timers: TimerScheduler
): ConnectionAckRegistry {
const pending = new Map<string, PendingAck>();
function resolve(id: string, result: ConnectionAckResult<unknown>): void {
const ack = pending.get(id);
if (ack === undefined) return;
pending.delete(id);
timers.cancel(ack.timer);
ack.resolve(result);
}
return {
wait<TResult>(id: string, timeoutMs: number): Promise<ConnectionAckResult<TResult>> {
const ackTimer = timerKey(connectionName, CONNECTION_TIMER_KEY_ACK, id);
return new Promise<ConnectionAckResult<TResult>>((resolveWaiter) => {
pending.set(id, {
timer: ackTimer,
resolve: resolveWaiter as (result: ConnectionAckResult<unknown>) => void
});
timers.schedule(
ackTimer,
timeoutMs,
() => {
resolve(id, { ok: false, reason: CONNECTION_ACK_REASON_TIMEOUT });
},
{ replace: true }
);
});
},
resolve,
resolveAll(result: ConnectionAckResult<unknown>): void {
for (const id of [...pending.keys()]) resolve(id, result);
},
resolveFromFrame(frame: ConnectionFrame): void {
if (frame.replyTo === undefined) return;
if (frame.error !== undefined) {
resolve(frame.replyTo, {
ok: false,
reason: CONNECTION_ACK_REASON_REJECTED,
error: frame.error
});
return;
}
resolve(frame.replyTo, { ok: true, payload: frame.payload });
},
mapSendFailure(result: ConnectionSendResult): ConnectionAckResult<unknown> {
if (result.ok) return { ok: true, payload: undefined };
if (result.reason === CONNECTION_SEND_REASON_CLOSED) {
return { ok: false, reason: CONNECTION_ACK_REASON_CLOSED, error: result.error };
}
return { ok: false, reason: CONNECTION_ACK_REASON_TRANSPORT_ERROR, error: result.error };
}
};
}

@ -0,0 +1,131 @@
import { createEngineConnections } from './engine-connections.ts';
import {
CONNECTION_STATE_CLOSED,
CONNECTION_STATE_CONNECTING,
CONNECTION_STATE_FAILED,
CONNECTION_STATE_OPEN,
CONNECTION_STATE_RECONNECTING
} from './consts.ts';
import type {
ActiveConnections,
ActiveConnectionsOptions,
Connection,
ConnectionChannelMap,
ConnectionMap,
ConnectionOptions,
ConnectionState
} from './types.ts';
export function createActiveConnections<TConnections extends ConnectionMap = ConnectionMap>(
options: ActiveConnectionsOptions
): ActiveConnections<TConnections> {
const engine = createEngineConnections<TConnections>(options);
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- detach callbacks are bookkeeping, not rendered state.
const detachers = new Map<string, () => void>();
let activeNames = $state<readonly string[]>(engine.names());
let states = $state<Readonly<Record<string, ConnectionState>>>({});
let disposed = false;
function refreshNames(): void {
activeNames = engine.names();
}
function setConnectionState(name: string, state: ConnectionState): void {
states = { ...states, [name]: state };
}
function namesBy(state: ConnectionState): readonly string[] {
return activeNames.filter((name) => states[name] === state);
}
function observe(name: string, connection: Connection): void {
detachers.get(name)?.();
setConnectionState(name, connection.state);
detachers.set(
name,
connection.onState((change) => {
setConnectionState(change.connection, change.to);
})
);
refreshNames();
}
const active: ActiveConnections<TConnections> = {
get size() {
return activeNames.length;
},
get activeNames() {
return activeNames;
},
get states() {
return states;
},
get connectedNames() {
return namesBy(CONNECTION_STATE_OPEN);
},
get connectingNames() {
return namesBy(CONNECTION_STATE_CONNECTING);
},
get reconnectingNames() {
return namesBy(CONNECTION_STATE_RECONNECTING);
},
get failedNames() {
return namesBy(CONNECTION_STATE_FAILED);
},
get closedNames() {
return namesBy(CONNECTION_STATE_CLOSED);
},
get allConnected() {
return (
activeNames.length > 0 &&
activeNames.every((name) => states[name] === CONNECTION_STATE_OPEN)
);
},
get anyConnected() {
return activeNames.some((name) => states[name] === CONNECTION_STATE_OPEN);
},
get anyConnecting() {
return activeNames.some((name) => states[name] === CONNECTION_STATE_CONNECTING);
},
get anyReconnecting() {
return activeNames.some((name) => states[name] === CONNECTION_STATE_RECONNECTING);
},
get anyFailed() {
return activeNames.some((name) => states[name] === CONNECTION_STATE_FAILED);
},
createConnection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
name: string,
connectionOptions: ConnectionOptions<TChannels>
): Connection<TChannels> {
const connection = engine.createConnection<TChannels>(name, connectionOptions);
observe(name, connection as Connection);
return connection;
},
connection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
name: string
): Connection<TChannels> {
return engine.connection<TChannels>(name);
},
has: (name) => engine.has(name),
names: () => engine.names(),
openConnection: (name) => engine.openConnection(name),
closeConnection: (name, reason) => engine.closeConnection(name, reason),
reconnectConnection: (name, reason) => engine.reconnectConnection(name, reason),
openAll: () => engine.openAll(),
closeAll: (reason) => engine.closeAll(reason),
reconnectAll: (reason) => engine.reconnectAll(reason),
reauthenticateAll: () => engine.reauthenticateAll(),
close: (name, reason) => engine.close(name, reason),
dispose() {
if (disposed) return;
disposed = true;
for (const detach of detachers.values()) detach();
detachers.clear();
activeNames = [];
states = {};
engine.dispose();
}
};
return active;
}

@ -0,0 +1,78 @@
import {
CONNECTION_BROWSER_EVENT_ONLINE,
CONNECTION_BROWSER_EVENT_VISIBILITY_CHANGE,
CONNECTION_DIAGNOSTIC_EVENTS,
CONNECTION_DEFAULT_RECONNECT_ON_ONLINE,
CONNECTION_DEFAULT_RECONNECT_ON_VISIBLE,
CONNECTION_DOCUMENT_VISIBILITY_VISIBLE
} from './consts.ts';
import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts';
import type { ConnectionReconnectOptions } from './types.ts';
interface BrowserReconnectOptions {
readonly disabled: boolean;
readonly reconnectOptions?: ConnectionReconnectOptions;
readonly shouldReconnect: () => boolean;
readonly reconnect: () => void;
readonly diagnostics: ConnectionDiagnostics;
}
export function wireBrowserReconnect(options: BrowserReconnectOptions): () => void {
if (options.disabled) return () => {};
const detachers: Array<() => void> = [];
const reconnectOnOnline =
options.reconnectOptions?.reconnectOnOnline ?? CONNECTION_DEFAULT_RECONNECT_ON_ONLINE;
const reconnectOnVisible =
options.reconnectOptions?.reconnectOnVisible ?? CONNECTION_DEFAULT_RECONNECT_ON_VISIBLE;
const target = globalThis as {
addEventListener?: (type: string, listener: () => void) => void;
removeEventListener?: (type: string, listener: () => void) => void;
document?: {
readonly visibilityState?: string;
addEventListener?: (type: string, listener: () => void) => void;
removeEventListener?: (type: string, listener: () => void) => void;
};
};
if (reconnectOnOnline && target.addEventListener && target.removeEventListener) {
const onOnline = (): void => {
if (!options.shouldReconnect()) return;
emitConnectionDiagnostic(
options.diagnostics,
CONNECTION_DIAGNOSTIC_EVENTS.BROWSER_RECONNECT,
{ trigger: CONNECTION_BROWSER_EVENT_ONLINE }
);
options.reconnect();
};
target.addEventListener(CONNECTION_BROWSER_EVENT_ONLINE, onOnline);
detachers.push(() => {
target.removeEventListener?.(CONNECTION_BROWSER_EVENT_ONLINE, onOnline);
});
}
if (
reconnectOnVisible &&
target.document?.addEventListener &&
target.document.removeEventListener
) {
const onVisible = (): void => {
if (target.document?.visibilityState !== CONNECTION_DOCUMENT_VISIBILITY_VISIBLE) return;
if (!options.shouldReconnect()) return;
emitConnectionDiagnostic(
options.diagnostics,
CONNECTION_DIAGNOSTIC_EVENTS.BROWSER_RECONNECT,
{ trigger: CONNECTION_BROWSER_EVENT_VISIBILITY_CHANGE }
);
options.reconnect();
};
target.document.addEventListener(CONNECTION_BROWSER_EVENT_VISIBILITY_CHANGE, onVisible);
detachers.push(() => {
target.document?.removeEventListener?.(CONNECTION_BROWSER_EVENT_VISIBILITY_CHANGE, onVisible);
});
}
return () => {
for (const detach of detachers) detach();
detachers.length = 0;
};
}

@ -0,0 +1,85 @@
import { createConnectionChannel, type InternalConnectionChannel } from './channel.ts';
import type {
Connection,
ConnectionChannel,
ConnectionChannelMap,
ConnectionChannelOptions,
ConnectionEventMap,
ConnectionFrame,
ConnectionMessageMeta
} from './types.ts';
export interface ConnectionChannelRegistry<TChannels extends ConnectionChannelMap> {
receive(topic: string, frame: ConnectionFrame, meta: ConnectionMessageMeta): void;
getOrCreate<TEvents extends ConnectionEventMap = ConnectionEventMap>(
name: string,
options: ConnectionChannelOptions,
connection: Connection<Record<string, TEvents>>
): ConnectionChannel<TEvents>;
channels(): readonly ConnectionChannel[];
has(name: string): boolean;
leave(name: string): Promise<void>;
joinConfigured(
reconnect: boolean,
configured: Readonly<Record<string, ConnectionChannelOptions | undefined>>,
connection: Connection<TChannels>
): Promise<void>;
dispose(): void;
}
export function createConnectionChannelRegistry<TChannels extends ConnectionChannelMap>(
reportListenerError: (event: string, error: unknown) => void
): ConnectionChannelRegistry<TChannels> {
const channels = new Map<string, InternalConnectionChannel>();
return {
receive(topic, frame, meta): void {
channels.get(topic)?.receive(frame, meta);
},
getOrCreate<TEvents extends ConnectionEventMap = ConnectionEventMap>(
channelName: string,
channelOptions: ConnectionChannelOptions,
connection: Connection<Record<string, TEvents>>
): ConnectionChannel<TEvents> {
const existing = channels.get(channelName);
if (existing !== undefined) return existing as ConnectionChannel<TEvents>;
const created = createConnectionChannel<TEvents>(channelName, channelOptions, {
connection,
reportListenerError
});
channels.set(channelName, created as InternalConnectionChannel);
return created;
},
channels(): readonly ConnectionChannel[] {
return [...channels.values()];
},
has(name): boolean {
return channels.has(name);
},
async leave(name): Promise<void> {
await channels.get(name)?.leave();
},
async joinConfigured(reconnect, configured, connection): Promise<void> {
const entries = (configured ?? {}) as Record<string, ConnectionChannelOptions | undefined>;
for (const channelName of Object.keys(entries)) {
const configuredOptions = entries[channelName] ?? {};
const current = this.getOrCreate(
channelName,
configuredOptions,
connection as unknown as Connection<Record<string, ConnectionEventMap>>
) as InternalConnectionChannel;
if (configuredOptions.autoJoin === true || (reconnect && current.shouldRejoin)) {
await current.join();
}
}
if (!reconnect) return;
for (const current of channels.values()) {
if (current.shouldRejoin) await current.rejoin();
}
},
dispose(): void {
for (const current of channels.values()) current.dispose();
channels.clear();
}
};
}

@ -0,0 +1,175 @@
import {
CONNECTION_CHANNEL_JOIN_REASON_CLOSED,
CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR,
CONNECTION_CHANNEL_STATE_FAILED,
CONNECTION_CHANNEL_STATE_IDLE,
CONNECTION_CHANNEL_STATE_JOINED,
CONNECTION_CHANNEL_STATE_JOINING,
CONNECTION_CHANNEL_STATE_LEAVING,
CONNECTION_CHANNEL_STATE_LEFT,
CONNECTION_FRAME_TYPE_JOIN,
CONNECTION_FRAME_TYPE_LEAVE,
CONNECTION_SEND_REASON_CLOSED
} from './consts.ts';
import type {
Connection,
ConnectionAckResult,
ConnectionChannel,
ConnectionChannelJoinResult,
ConnectionChannelOptions,
ConnectionChannelState,
ConnectionEventMap,
ConnectionFrame,
ConnectionMessageMeta,
ConnectionRequestOptions,
ConnectionSendOptions,
ConnectionSendResult
} from './types.ts';
export interface InternalConnectionChannel<
TEvents extends ConnectionEventMap = ConnectionEventMap
> extends ConnectionChannel<TEvents> {
readonly shouldRejoin: boolean;
receive(frame: ConnectionFrame, meta: ConnectionMessageMeta): void;
rejoin(): Promise<ConnectionChannelJoinResult>;
}
export interface ConnectionChannelRuntime<TEvents extends ConnectionEventMap = ConnectionEventMap> {
readonly connection: Connection<Record<string, TEvents>>;
readonly reportListenerError?: (event: string, error: unknown) => void;
}
function mapSendToJoinResult(result: ConnectionSendResult): ConnectionChannelJoinResult {
if (result.ok) return { ok: true };
if (result.reason === CONNECTION_SEND_REASON_CLOSED) {
return { ok: false, reason: CONNECTION_CHANNEL_JOIN_REASON_CLOSED, error: result.error };
}
return {
ok: false,
reason: CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR,
error: result.error
};
}
export function createConnectionChannel<TEvents extends ConnectionEventMap = ConnectionEventMap>(
name: string,
options: ConnectionChannelOptions = {},
runtime: ConnectionChannelRuntime<TEvents>
): InternalConnectionChannel<TEvents> {
let state: ConnectionChannelState = CONNECTION_CHANNEL_STATE_IDLE;
let desiredJoined = options.autoJoin === true;
let disposed = false;
const anyListeners = new Set<(frame: ConnectionFrame, meta: ConnectionMessageMeta) => void>();
const typeListeners = new Map<
string,
Set<(payload: unknown, meta: ConnectionMessageMeta) => void>
>();
function setState(next: ConnectionChannelState): void {
state = next;
}
function emitToListeners(frame: ConnectionFrame, meta: ConnectionMessageMeta): void {
for (const listener of [...anyListeners]) {
try {
listener(frame, meta);
} catch (error) {
runtime.reportListenerError?.(frame.type, error);
}
}
const listeners = typeListeners.get(frame.type);
if (listeners === undefined) return;
for (const listener of [...listeners]) {
try {
listener(frame.payload, meta);
} catch (error) {
runtime.reportListenerError?.(frame.type, error);
}
}
}
async function resolveJoinPayload(explicitParams: unknown): Promise<unknown> {
if (explicitParams !== undefined) return explicitParams;
return options.params?.();
}
const channel: InternalConnectionChannel<TEvents> = {
name,
get state() {
return state;
},
get shouldRejoin() {
return desiredJoined && options.rejoinOnReconnect !== false;
},
async join(params) {
if (disposed) return { ok: false, reason: CONNECTION_CHANNEL_JOIN_REASON_CLOSED };
setState(CONNECTION_CHANNEL_STATE_JOINING);
const payload = await resolveJoinPayload(params);
const result = await runtime.connection.send(CONNECTION_FRAME_TYPE_JOIN, payload, {
topic: name
});
const mapped = mapSendToJoinResult(result);
if (mapped.ok) {
desiredJoined = true;
setState(CONNECTION_CHANNEL_STATE_JOINED);
} else {
setState(CONNECTION_CHANNEL_STATE_FAILED);
}
return mapped;
},
async leave() {
if (disposed) return;
setState(CONNECTION_CHANNEL_STATE_LEAVING);
desiredJoined = false;
await runtime.connection.send(CONNECTION_FRAME_TYPE_LEAVE, undefined, { topic: name });
setState(CONNECTION_CHANNEL_STATE_LEFT);
},
send(type, payload, sendOptions?: ConnectionSendOptions): Promise<ConnectionSendResult> {
return runtime.connection.send(type, payload, { ...sendOptions, topic: name });
},
request<K extends keyof TEvents & string, TResult = unknown>(
type: K,
payload: TEvents[K],
requestOptions?: ConnectionRequestOptions
): Promise<ConnectionAckResult<TResult>> {
return runtime.connection.request(type, payload, { ...requestOptions, topic: name });
},
on(type, listener) {
let listeners = typeListeners.get(type);
if (listeners === undefined) {
listeners = new Set();
typeListeners.set(type, listeners);
}
const wrapped = listener as (payload: unknown, meta: ConnectionMessageMeta) => void;
listeners.add(wrapped);
return () => {
listeners?.delete(wrapped);
if (listeners?.size === 0) typeListeners.delete(type);
};
},
onAny(listener) {
anyListeners.add(listener);
return () => {
anyListeners.delete(listener);
};
},
receive(frame, meta) {
if (disposed) return;
emitToListeners(frame, meta);
},
rejoin() {
if (!channel.shouldRejoin) return Promise.resolve({ ok: true });
return channel.join();
},
dispose() {
desiredJoined = false;
setState(CONNECTION_CHANNEL_STATE_LEFT);
disposed = true;
anyListeners.clear();
typeListeners.clear();
}
};
return channel;
}

@ -0,0 +1,59 @@
import {
CONNECTION_ACK_REASON_CLOSED,
CONNECTION_ACK_REASON_REJECTED,
CONNECTION_ACK_REASON_TIMEOUT,
CONNECTION_AUTH_REASON_CLOSED,
CONNECTION_AUTH_REASON_NO_PROVIDER,
CONNECTION_AUTH_REASON_REJECTED,
CONNECTION_AUTH_REASON_TIMEOUT,
CONNECTION_AUTH_REASON_TRANSPORT_ERROR,
CONNECTION_FRAME_TYPE_AUTH
} from './consts.ts';
import type {
ConnectionAckResult,
ConnectionAuthPayload,
ConnectionAuthResult,
ConnectionOptions
} from './types.ts';
export type ConnectionAuthProvider = () =>
| ConnectionAuthPayload
| null
| Promise<ConnectionAuthPayload | null>;
export interface ResolvedConnectionAuth {
readonly provider: ConnectionAuthProvider | null;
readonly authType: string;
readonly timeoutMs?: number;
}
export function resolveConnectionAuth(auth: ConnectionOptions['auth']): ResolvedConnectionAuth {
if (typeof auth === 'function') {
return { provider: auth, authType: CONNECTION_FRAME_TYPE_AUTH };
}
return {
provider: auth?.getAuth ?? null,
authType: auth?.authType ?? CONNECTION_FRAME_TYPE_AUTH,
timeoutMs: auth?.timeoutMs
};
}
export function missingConnectionAuthProvider(): ConnectionAuthResult {
return { ok: false, reason: CONNECTION_AUTH_REASON_NO_PROVIDER };
}
export function mapConnectionAckToAuthResult(
result: ConnectionAckResult<unknown>
): ConnectionAuthResult {
if (result.ok) return { ok: true };
if (result.reason === CONNECTION_ACK_REASON_TIMEOUT) {
return { ok: false, reason: CONNECTION_AUTH_REASON_TIMEOUT, error: result.error };
}
if (result.reason === CONNECTION_ACK_REASON_CLOSED) {
return { ok: false, reason: CONNECTION_AUTH_REASON_CLOSED, error: result.error };
}
if (result.reason === CONNECTION_ACK_REASON_REJECTED) {
return { ok: false, reason: CONNECTION_AUTH_REASON_REJECTED, error: result.error };
}
return { ok: false, reason: CONNECTION_AUTH_REASON_TRANSPORT_ERROR, error: result.error };
}

@ -0,0 +1,58 @@
import {
CONNECTION_ACK_REASON_CLOSED,
CONNECTION_STATE_CLOSED,
CONNECTION_STATE_CLOSING,
CONNECTION_STATE_FAILED,
CONNECTION_STATE_RECONNECTING,
CONNECTION_TIMER_KEY_RECONNECT
} from './consts.ts';
import type { ConnectionAckRegistry } from './acks.ts';
import type { ConnectionStateTracker } from './connection-state.ts';
import type { ConnectionTimerControls } from './connection-timers.ts';
import type { ConnectionTransportRuntime } from './connection-transport-runtime.ts';
import type { ConnectionHeartbeat } from './heartbeat.ts';
import type { ConnectionCloseEvent, ConnectionState } from './types.ts';
export interface ConnectionCloseRuntime {
readonly timers: ConnectionTimerControls;
readonly heartbeat: ConnectionHeartbeat;
readonly acks: ConnectionAckRegistry;
readonly state: ConnectionStateTracker;
readonly transport: ConnectionTransportRuntime;
isDisposed(): boolean;
isIntentionalClose(): boolean;
setIntentionalClose(value: boolean): void;
shouldReconnect(): boolean;
scheduleReconnect(): void;
markClosed(state: ConnectionState, cause?: unknown): void;
}
export function handleConnectionTransportClose(
event: ConnectionCloseEvent,
runtime: ConnectionCloseRuntime
): void {
if (runtime.isDisposed()) return;
if (runtime.isIntentionalClose()) {
runtime.markClosed(CONNECTION_STATE_CLOSED);
return;
}
runtime.state.setError(event);
if (runtime.shouldReconnect()) {
runtime.markClosed(CONNECTION_STATE_RECONNECTING, event);
runtime.scheduleReconnect();
return;
}
runtime.markClosed(CONNECTION_STATE_FAILED, event);
}
export function closeConnectionTransport(reason: string, runtime: ConnectionCloseRuntime): void {
runtime.setIntentionalClose(true);
runtime.timers.cancel(CONNECTION_TIMER_KEY_RECONNECT);
runtime.heartbeat.stop();
runtime.acks.resolveAll({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED });
runtime.state.transition(CONNECTION_STATE_CLOSING);
runtime.transport.close(reason);
if (runtime.state.state !== CONNECTION_STATE_CLOSED) {
runtime.markClosed(CONNECTION_STATE_CLOSED);
}
}

@ -0,0 +1,51 @@
import {
CONNECTION_CONNECT_REASON_DISPOSED,
CONNECTION_STATE_OPEN
} from './consts.ts';
import type { ConnectionConnectResult, ConnectionState } from './types.ts';
export interface ConnectionConnectController {
connect(reconnect: boolean): Promise<ConnectionConnectResult>;
}
export interface ConnectionConnectControllerOptions {
getState(): ConnectionState;
isDisposed(): boolean;
isConnected(): boolean;
beforeAttempt(): void;
runConnect(reconnect: boolean): Promise<ConnectionConnectResult>;
}
export function createConnectionConnectController(
options: ConnectionConnectControllerOptions
): ConnectionConnectController {
let connectPromise: Promise<ConnectionConnectResult> | null = null;
function connect(reconnect: boolean): Promise<ConnectionConnectResult> {
const state = options.getState();
if (options.isDisposed()) {
return Promise.resolve({
ok: false,
state,
reason: CONNECTION_CONNECT_REASON_DISPOSED
});
}
if (state === CONNECTION_STATE_OPEN && options.isConnected()) {
return Promise.resolve({ ok: true, state, reused: true });
}
if (connectPromise !== null) return connectPromise;
connectPromise = (async () => {
options.beforeAttempt();
try {
return await options.runConnect(reconnect);
} finally {
connectPromise = null;
}
})();
return connectPromise;
}
return { connect };
}

@ -0,0 +1,76 @@
import {
CONNECTION_CLOSE_REASON_AUTH_FAILED,
CONNECTION_CONNECT_REASON_AUTH_FAILED,
CONNECTION_CONNECT_REASON_TRANSPORT_ERROR,
CONNECTION_DIAGNOSTIC_EVENTS,
CONNECTION_STATE_CONNECTING,
CONNECTION_STATE_FAILED,
CONNECTION_STATE_RECONNECTING,
CONNECTION_TIMER_KEY_RECONNECT
} from './consts.ts';
import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts';
import type { ConnectionRequestRuntime } from './connection-requests.ts';
import type { ConnectionStateTracker } from './connection-state.ts';
import type { ConnectionTimerControls } from './connection-timers.ts';
import type { ConnectionSender } from './sender.ts';
import type { ConnectionConnectResult, ConnectionTransport } from './types.ts';
export interface RunConnectionConnectInput {
readonly reconnect: boolean;
readonly timers: ConnectionTimerControls;
resolveTransport(): ConnectionTransport;
attachTransport(transport: ConnectionTransport): void;
detachTransport(): void;
markOpen(): void;
markClosedFailed(error: unknown): void;
joinConfiguredChannels(reconnect: boolean): Promise<void>;
readonly state: ConnectionStateTracker;
readonly requests: ConnectionRequestRuntime;
readonly sender: ConnectionSender;
readonly diagnostics: ConnectionDiagnostics;
}
export async function runConnectionConnect(
input: RunConnectionConnectInput
): Promise<ConnectionConnectResult> {
input.timers.cancel(CONNECTION_TIMER_KEY_RECONNECT);
const nextTransport = input.resolveTransport();
input.attachTransport(nextTransport);
input.state.transition(input.reconnect ? CONNECTION_STATE_RECONNECTING : CONNECTION_STATE_CONNECTING);
try {
await nextTransport.open();
input.markOpen();
if (input.requests.hasAuthProvider()) {
const auth = await input.requests.runAuth();
if (!auth.ok) {
const authError = auth.error ?? auth.reason;
input.state.setError(authError);
emitConnectionDiagnostic(input.diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.AUTH_FAILED, auth);
input.detachTransport();
nextTransport.close(undefined, CONNECTION_CLOSE_REASON_AUTH_FAILED);
input.markClosedFailed(authError);
return {
ok: false,
state: input.state.state,
reason: CONNECTION_CONNECT_REASON_AUTH_FAILED,
error: authError
};
}
}
await input.sender.flushBuffer();
await input.joinConfiguredChannels(input.reconnect);
return { ok: true, state: input.state.state };
} catch (err) {
input.state.setError(err);
emitConnectionDiagnostic(input.diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.CONNECT_FAILED, {
error: err
});
input.state.transition(CONNECTION_STATE_FAILED, err);
return {
ok: false,
state: input.state.state,
reason: CONNECTION_CONNECT_REASON_TRANSPORT_ERROR,
error: err
};
}
}

@ -0,0 +1,68 @@
import { CONNECTION_DIAGNOSTIC_EVENTS } from './consts.ts';
import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts';
import type { ConnectionFrame, ConnectionMessageMeta, ConnectionStateChange } from './types.ts';
type GlobalListener = (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void;
type StateListener = (change: ConnectionStateChange) => void;
interface ConnectionEventBusOptions {
readonly diagnostics: ConnectionDiagnostics;
}
export interface ConnectionEventBus {
emitState(change: ConnectionStateChange): void;
emitGlobal(frame: ConnectionFrame, meta: ConnectionMessageMeta): void;
onState(listener: StateListener): () => void;
onAny(listener: GlobalListener): () => void;
clear(): void;
}
export function createConnectionEventBus(options: ConnectionEventBusOptions): ConnectionEventBus {
const stateListeners = new Set<StateListener>();
const globalListeners = new Set<GlobalListener>();
return {
emitState(change): void {
for (const listener of [...stateListeners]) {
try {
listener(change);
} catch (err) {
emitConnectionDiagnostic(
options.diagnostics,
CONNECTION_DIAGNOSTIC_EVENTS.LISTENER_THREW,
{ error: err, event: change.to }
);
}
}
},
emitGlobal(frame, meta): void {
for (const listener of [...globalListeners]) {
try {
listener(frame, meta);
} catch (err) {
emitConnectionDiagnostic(
options.diagnostics,
CONNECTION_DIAGNOSTIC_EVENTS.LISTENER_THREW,
{ error: err, event: frame.type }
);
}
}
},
onState(listener): () => void {
stateListeners.add(listener);
return () => {
stateListeners.delete(listener);
};
},
onAny(listener): () => void {
globalListeners.add(listener);
return () => {
globalListeners.delete(listener);
};
},
clear(): void {
stateListeners.clear();
globalListeners.clear();
}
};
}

@ -0,0 +1,49 @@
import {
CONNECTION_ACK_REASON_CLOSED,
CONNECTION_STATE_OPEN,
CONNECTION_TRANSPORT_STATE_OPEN
} from './consts.ts';
import type { ConnectionAckRegistry } from './acks.ts';
import type { ConnectionStateTracker } from './connection-state.ts';
import type { ConnectionTransportRuntime } from './connection-transport-runtime.ts';
import type { ConnectionHeartbeat } from './heartbeat.ts';
import type { ConnectionState } from './types.ts';
export interface ConnectionLifecycle {
markOpen(): void;
markClosed(nextState: ConnectionState, cause?: unknown): void;
isConnected(): boolean;
}
interface ConnectionLifecycleOptions {
readonly acks: ConnectionAckRegistry;
readonly heartbeat: ConnectionHeartbeat;
readonly state: ConnectionStateTracker;
readonly transport: ConnectionTransportRuntime;
}
export function createConnectionLifecycle(
options: ConnectionLifecycleOptions
): ConnectionLifecycle {
const { acks, heartbeat, state, transport } = options;
function markOpen(): void {
if (state.markOpen()) heartbeat.start();
}
function markClosed(nextState: ConnectionState, cause?: unknown): void {
state.markClosed(nextState, cause, () => {
heartbeat.stop();
acks.resolveAll({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED });
});
}
function isConnected(): boolean {
return (
state.state === CONNECTION_STATE_OPEN &&
transport.current?.state === CONNECTION_TRANSPORT_STATE_OPEN
);
}
return { markOpen, markClosed, isConnected };
}

@ -0,0 +1,65 @@
import {
CONNECTION_DIAGNOSTIC_EVENTS,
CONNECTION_FRAME_TYPE_PONG
} from './consts.ts';
import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts';
import { assertConnectionFrame } from './serializer.ts';
import type { ConnectionAckRegistry } from './acks.ts';
import type { ConnectionChannelRegistry } from './channel-registry.ts';
import type { ConnectionEventBus } from './connection-events.ts';
import type { ConnectionStateTracker } from './connection-state.ts';
import type { ConnectionHeartbeat } from './heartbeat.ts';
import type {
ConnectionChannelMap,
ConnectionFrame,
ConnectionMessageMeta,
ConnectionSerializer
} from './types.ts';
export interface RouteConnectionMessageInput<TChannels extends ConnectionChannelMap> {
readonly name: string;
readonly raw: string | ArrayBuffer;
readonly serializer: ConnectionSerializer;
readonly state: ConnectionStateTracker;
readonly heartbeat: ConnectionHeartbeat;
readonly acks: ConnectionAckRegistry;
readonly channels: ConnectionChannelRegistry<TChannels>;
readonly events: ConnectionEventBus;
readonly diagnostics: ConnectionDiagnostics;
}
export function routeConnectionMessage<TChannels extends ConnectionChannelMap>(
input: RouteConnectionMessageInput<TChannels>
): void {
const receivedAt = input.state.touchMessage();
input.heartbeat.received();
let frame: ConnectionFrame;
try {
frame = input.serializer.decode(input.raw);
assertConnectionFrame(frame);
} catch (err) {
input.state.setError(err);
emitConnectionDiagnostic(input.diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.FRAME_DECODE_FAILED, {
error: err
});
return;
}
if (frame.replyTo !== undefined) {
input.acks.resolveFromFrame(frame);
return;
}
if (frame.type === CONNECTION_FRAME_TYPE_PONG) return;
const meta: ConnectionMessageMeta = {
connection: input.name,
topic: frame.topic,
receivedAt,
generation: input.state.generation
};
if (frame.topic !== undefined) {
input.channels.receive(frame.topic, frame, meta);
}
input.events.emitGlobal(frame, meta);
}

@ -0,0 +1,35 @@
import {
CONNECTION_DIAGNOSTIC_EVENTS,
CONNECTION_STATE_FAILED,
CONNECTION_LOG_MSG_RECONNECT_EXHAUSTED,
CONNECTION_TIMER_KEY_RECONNECT
} from './consts.ts';
import { emitConnectionDiagnostic, type ConnectionDiagnostics } from './diagnostics.ts';
import type { ConnectionReconnectPolicy } from './reconnect.ts';
import type { ConnectionStateTracker } from './connection-state.ts';
import type { ConnectionTimerControls } from './connection-timers.ts';
export interface ScheduleConnectionReconnectInput {
readonly policy: ConnectionReconnectPolicy;
readonly state: ConnectionStateTracker;
readonly timers: ConnectionTimerControls;
readonly diagnostics: ConnectionDiagnostics;
readonly connect: () => void;
}
export function scheduleConnectionReconnect(input: ScheduleConnectionReconnectInput): void {
const nextReconnect = input.policy.next(input.state.reconnectAttempt);
if (!nextReconnect.ok) {
emitConnectionDiagnostic(input.diagnostics, CONNECTION_DIAGNOSTIC_EVENTS.RECONNECT_EXHAUSTED, {
reconnectAttempt: nextReconnect.reconnectAttempt,
maxAttempts: nextReconnect.maxAttempts
});
const exhaustedError = new Error(CONNECTION_LOG_MSG_RECONNECT_EXHAUSTED);
input.state.setError(exhaustedError);
input.state.transition(CONNECTION_STATE_FAILED, exhaustedError);
return;
}
input.state.setReconnectAttempt(nextReconnect.attempt);
input.timers.schedule(CONNECTION_TIMER_KEY_RECONNECT, nextReconnect.delayMs, input.connect);
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.