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