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
parent
4546e448bd
commit
f5a2a7fb49
@ -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,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…
Reference in new issue