|
|
5 months ago | |
|---|---|---|
| .. | ||
| integrations | 6 months ago | |
| test | 5 months ago | |
| testing | 6 months ago | |
| README.md | 5 months ago | |
| active-app.svelte.ts | 5 months ago | |
| consts.ts | 5 months ago | |
| errors.ts | 5 months ago | |
| index.ts | 5 months ago | |
| types.ts | 5 months ago | |
README.md
aapp — ActiveApp
aapp is the application-level composition that wires the runtime artifacts
under a single namespace, with a single locale source of truth and a single
logger.
import { createActiveApp } from '$aapp';
import { translations } from './lang/schema';
const App = createActiveApp({
lang: { schema: translations, defaultLocale: 'es', fallbackChain: ['en'] },
logger: {
level: LogLevel.INFO,
globalContext: { appVersion: '1.0.0', env: 'prod' },
transports: [consoleTransport()]
},
frontend: { theme: 'base', mode: 'auto', density: 'normal' }
});
App.setLocale('es-MX');
App.Lang.t('common.ok');
App.Formats.currency.format(12.5);
App.Frontend.setTheme('forest');
App.Logger.info('boot', 'app ready');
App.dispose();
What it composes
| Member | Always present | Default when not configured |
|---|---|---|
App.Logger |
yes | engine default — level: WARN + consoleTransport(). Pass { level: LogLevel.NONE, transports: [] } for silence |
App.Lang |
yes | mono — t('a.b') returns 'a.b', t('a.b|Fallback') returns 'Fallback', and DEV warns once per unresolved path through Logger under lang.mono |
App.Formats |
yes | real, locale = DEFAULT_LOCALE ('en-US') |
App.Frontend |
yes | real with default theme/mode/density |
App.Dom |
yes | real with default breakpoints |
App.Storage |
yes | in-memory adapter (resets on reload). Configure storage: { adapter: localAdapter } for real persistence; storage diagnostics are wired through the shared Logger |
App.Http |
yes | engine default — globalThis.fetch, no baseUrl, idempotent-by-default retry, 10s per-attempt timeout. The shared Logger is wired automatically; configure http: { baseUrl, timeout, retry } |
App.Timers |
yes | ActiveTimers scheduler owned by App. Used by artifacts that need keyed runtime timers (sess auto-refresh, conn reconnect/heartbeat/ack) and disposed by App.dispose() |
App.Cache |
yes | ActiveCache backed by memory by default. Configure cache: { adapter, policies, scopeResolver } for persistence, custom policies or tenant/actor/permission-aware keys |
App.Sess |
no | created lazily through App.createActiveSession(...). Logger is injected automatically; storage, refresh/revoke handlers and HTTP hooks remain explicit so auth policy does not become hidden magic |
App.Auth |
no | created lazily through App.createActiveAuth(...). App injects Http and Timers; the server authority remains $svrs/auth |
Connections |
no | created lazily through App.createActiveConnections(...). App injects Logger, Timers and a structural session bridge; each connection opts into session behavior independently |
Server-authoritative engines that have a browser reflector live under
$svrs/*: use $svrs/auth for createEngineAuth() and auth HTTP handlers,
$svrs/perm for createEnginePermissions() and authorization handlers, and
$svrs/cach for createEngineCache() in services, repositories or server
load code. aapp composes only the active/client side.
Sium is not a member of App. Validation is page-scoped — pages with
forms construct their own engine via the one-line App.createSiumEngine()
method that wires App.Lang and App.Logger automatically:
const sium = App.createSiumEngine();
const result = await sium.validate(LoginSchema, input);
Equivalent to createEngineSium({ lang: App.Lang, logger: App.Logger, locale: App.Lang.getLocale() }).
Each call returns a fresh engine. The method takes no arguments — Lang,
Logger and the active locale all flow from App, so there is nothing left to
override at this layer.
The locale passed is a snapshot of App.Lang.getLocale() at the
moment of construction. It is the fallback used when the caller invokes
sium.resolveIssue(issue) without an explicit locale; the snapshot does
not react to subsequent App.setLocale(...) calls. Pages that need
locale-reactive issue messages either pass App.Lang.getLocale() per call
(sium.resolveIssue(issue, App.Lang.getLocale())) or rebuild the engine
inside an $effect that depends on the locale. If a page needs a custom
Sium engine (different logger category, different locale default, etc.) it
constructs createEngineSium(...) directly from $sium.
The method lives on App rather than as a standalone helper because App is
already in scope on every page via context — App.createSiumEngine() is
the natural call site.
Connections is also lazy, but for the opposite reason: realtime is
application-scoped infrastructure, while the connection map is app-specific and
benefits from call-site generics:
const Connections = App.createActiveConnections<AppConnections>();
Each registry is disposed by App.dispose(). Individual connections decide
whether they react to session refresh/expire events via their own session
option.
What it solves
- Single locale source.
App.setLocale('es-MX')propagates toLang,FormatsandFrontendthrough a sharedLocaleSource. No bridge code per call site. - Single logger. Built once and piped into
Lang.setLoggerso every artifact emits structured entries through the same transports (console, Sentry, Datadog, ...). - Uniform call sites.
App.Lang.t(label)andApp.Formats.*always work, whether or not the caller configured i18n or fmts. No null checks. - Single lifecycle.
App.dispose()tears down the optional session, timers, persistence bridge, frontend, dom, formats, storage, lang and logger in a deterministic order.
Composition order
- Logger —
createEngineLogger(options.logger). The engine applies its own defaults whenoptions.loggeris undefined. - Lang — real
createActiveLang(...)whenoptions.lang.schemais provided; mono otherwise. Both wireLang.setLoggerto the shared Logger. - Storage — built next so Frontend can read persisted preferences before construction. Storage diagnostics are wired to the shared Logger.
- Formats — built with a
localeSourcederived from Lang. - Dom — built before Frontend.
- Frontend — receives Dom and the same
localeSource. Whenfrontend.persistis configured, persisted values seed the initial options andonPreferenceChangeis wired to write back to Storage. - Http — built with the shared
Loggerinjected automatically so request/retry/error events land under category'http'. In SvelteKitload, scope to the request viaApp.Http.with({ fetch: event.fetch }). - Timers — built with the shared
Logger. This is the App-owned scheduler used by long-lived runtime tasks; no module-global singleton. - Cache — built with the shared
Logger. It is always present with a memory adapter unlesscache.adapteris configured. Scopes remain explicit through each query and can usecache.scopeResolver. - Sess — created lazily via
App.createActiveSession(...), not fromcreateActiveApp(...)options. App injects Logger, but the consumer keeps auth policy explicit (storage,onRefresh,onRevoke, HTTP hooks).
dispose() runs in reverse order.
Common shapes
Full multilingual app
const App = createActiveApp({
lang: { schema, defaultLocale: 'es', fallbackChain: ['en'] },
logger: { level: LogLevel.INFO, transports: [consoleTransport()] },
frontend: { theme: 'base', mode: 'auto' }
});
Monolingual app with fixed currency
const App = createActiveApp({
formats: { currency: { currency: 'EUR' } },
frontend: { theme: 'base' }
});
App.Formats.currency.format(99.5); // "99,50 €" with default locale
App.Lang is mono — components calling App.Lang.t('actions.save|Save')
render 'Save' without ever loading a translation table.
Headless / API surface
const App = createActiveApp({
logger: { level: LogLevel.WARN, transports: [httpTransport({ url })] }
});
Frontend and Dom are still constructed but their browser-only effects (viewport tracking, attribute writes) no-op in SSR.
Locale flow
Lang is the single source of truth. Formats and Frontend subscribe to it via a
common LocaleSource ($locale). The locale value is BCP 47:
App.setLocale('es'); // bare base
App.setLocale('es-MX'); // exact regional variant
App.setLocale('pt-BR'); // works end-to-end
See $lang/README.md for the BCP 47 resolution rules in Lang.t() /
Lang.ts().
When lang is not configured, App.setLocale still updates the mono lang's
internal locale and notifies Formats/Frontend — locale switching keeps working.
SSR locale resolution + hydration
createActiveApp(...) does not read navigator.language. That is a
deliberate decision: reading the navigator on the client while the server
rendered with a different locale produces a hydration mismatch and a
one-frame text flash. Locale is the app's responsibility — resolve it on the
server, pass it as data to the client, and use it as defaultLocale when
constructing App.
The canonical SvelteKit pattern:
// src/web/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
const SUPPORTED = ['es', 'en', 'es-MX', 'es-AR', 'en-GB', 'pt-BR'] as const;
const DEFAULT = 'es';
function pickLocale(accept: string | null, supported: readonly string[]): string {
if (!accept) return DEFAULT;
const ranked = accept
.split(',')
.map((entry) => {
const [tag, q] = entry.trim().split(';q=');
return { tag: tag.toLowerCase(), q: q ? Number(q) : 1 };
})
.sort((a, b) => b.q - a.q);
for (const { tag } of ranked) {
// Exact BCP 47 match first, then base.
if (supported.includes(tag)) return tag;
const base = tag.split('-')[0];
if (supported.includes(base)) return base;
}
return DEFAULT;
}
export const load: LayoutServerLoad = ({ request, cookies }) => {
const cookie = cookies.get('locale');
if (cookie && SUPPORTED.includes(cookie)) return { locale: cookie };
const locale = pickLocale(request.headers.get('accept-language'), SUPPORTED);
return { locale };
};
<!-- src/web/routes/+layout.svelte -->
<script lang="ts">
import { setContext, onDestroy } from 'svelte';
import { createActiveApp } from '$aapp';
import { translations } from '$lib/lang/schema';
let { data, children } = $props();
const App = createActiveApp({
lang: { schema: translations, defaultLocale: data.locale, fallbackChain: ['en'] },
logger: {
/* ... */
}
});
setContext('app', App);
onDestroy(() => App.dispose());
</script>
{@render children()}
Server and client agree on the locale on first render — no mismatch, no flash.
To let the user change locale at runtime, persist the choice to a cookie so the next request re-renders with the same value:
async function changeLocale(locale: SupportedLocale): Promise<void> {
App.setLocale(locale);
document.cookie = `locale=${locale}; path=/; max-age=31536000; SameSite=Lax`;
}
If you genuinely want to honor navigator.language on first visit, do it
once in the server load when no cookie exists and no Accept-Language is
set — never on the client.
Storage
App.Storage is always present. Without configuration it uses an in-memory
adapter — values exist for the lifetime of the App and never persist. For
real persistence, pass an adapter:
import { createActiveApp, localAdapter } from '$aapp';
const App = createActiveApp({
storage: { adapter: localAdapter, namespace: 'my-app' }
});
const cart = App.Storage.entry('cart', { items: [] as string[] });
cart.update((p) => ({ ...p, items: [...p.items, 'sku-42'] }));
Per-entry overrides let you mix backends — cookies for SSR-readable values, localStorage for the rest:
import { createActiveApp, localAdapter, cookieAdapter } from '$aapp';
const App = createActiveApp({
storage: { adapter: localAdapter, namespace: 'my-app' }
});
const locale = App.Storage.entry('locale', 'es', {
adapter: cookieAdapter({ path: '/', maxAge: 31_536_000 }),
namespace: false,
raw: true
});
Storage diagnostics are wired automatically through StorageDiagnostics and
the shared App.Logger; failures include { adapter, key, fullKey, op, error }
in the diagnostic context. See $stor/README.md for the full API (adapters,
envelope, versioning, validation).
Reactive keys
When the storage key tracks a runed variable (current user, active
workspace, route param), use App.Storage.dynamicEntry():
<script lang="ts">
let userId = $state(1);
const profile = App.Storage.dynamicEntry(
() => `user-${userId}:profile`,
() => ({ name: '', cart: [] as string[] })
);
// userId = 2 → profile rebinds to 'user-2:profile' (previous entry
// disposed, onChange listeners migrate automatically).
</script>
Must run inside a Svelte component or $effect.root scope.
Cross-tab sync without polling
Wrap any adapter with withBroadcast (re-exported from $aapp) for
instant cross-tab synchronization through BroadcastChannel:
import { createActiveApp, localAdapter, cookieAdapter, withBroadcast } from '$aapp';
const App = createActiveApp({
storage: {
adapter: withBroadcast(localAdapter, { channel: 'my-app' }),
namespace: 'my-app'
}
});
// Cookies + broadcast = changes propagate across tabs the moment they
// happen (browsers do not emit a native event for cookie mutations).
const session = withBroadcast(cookieAdapter({ path: '/', maxAge: 3600 }), {
channel: 'my-app:session'
});
Persisting Frontend preferences
Theme, mode, density, dir, reducedMotion, reducedSound can be persisted with a single flag:
const App = createActiveApp({
storage: { adapter: localAdapter, namespace: 'my-app' },
frontend: { theme: 'base', persist: true }
});
App.Frontend.setTheme('forest'); // → written to localStorage
// next reload → Frontend reads 'forest' from storage during construction
Selective + per-key overrides:
import { createActiveApp, localAdapter, cookieAdapter } from '$aapp';
const App = createActiveApp({
storage: { adapter: localAdapter, namespace: 'my-app' },
frontend: {
theme: 'base',
persist: {
keys: ['theme', 'density', 'mode'],
overrides: {
// theme to a cookie so the server can render the right palette
theme: { adapter: cookieAdapter({ path: '/' }), namespace: false, raw: true }
}
}
}
});
When persist is set but no persistent adapter is configured (the default
in-memory adapter is in use), values still flow through Storage — they just
do not survive reload. No warning is emitted; the absence of persistence is
visible in the storage adapter the caller chose.
State primitive
aapp does not include a stores system. Svelte 5 + runes already provide
the primitive: a .svelte.ts module with $state is your store, scoped to
import graph rather than to a global registry.
// src/lib/stores/cart.svelte.ts
let items = $state<CartItem[]>([]);
export const cart = {
get items() {
return items;
},
add(item: CartItem) {
items.push(item);
},
clear() {
items = [];
}
};
Pages and components import cart directly. The "infrastructure" artifacts
(Logger, Lang, Formats, Frontend, Dom) live in App because they are
cross-cutting and need uniform configuration. Domain state (current user,
cart, session, feature flags) is application-specific — putting it under
App.Stores would couple the framework to a bucket of unrelated nouns.
For a Pinia/Zustand-style central registry, build it in user space — it does
not belong in aapp.
Testing
Use createTestApp(options) instead of createActiveApp(options) in unit
tests. Same shape, plus:
- silent logger by default (no console pollution)
captureLogs: trueattaches a sink and exposes entries asApp.entries
createTestApp lives at the $aapp/testing subpath so it does not ship with
production bundles that import the main $aapp barrel:
import { createTestApp } from '$aapp/testing';
const App = createTestApp({ captureLogs: true, lang: { schema } });
App.Logger.warn('auth', 'token expiring');
expect(App.entries).toHaveLength(1);
expect(App.entries[0].category).toBe('auth');
App.dispose();
If the caller passes their own logger.transports, the capture transport is
appended — both sinks receive every entry.
API
interface ActiveAppOptions<S extends LangNode> {
logger?: LoggerOptions;
lang?: { schema: S; defaultLocale?: SupportedLocale; fallbackChain?: SupportedLocale[] };
formats?: Omit<ActiveFormatsOptions, 'locale' | 'localeSource'>;
frontend?: Omit<ActiveFrontendOptions, 'locale' | 'localeSource' | 'dom'> & {
persist?: FrontendPersist;
};
dom?: ActiveDomProps;
storage?: ActiveAppStorageOptions;
http?: Omit<EngineHttpOptions, 'logger'>;
timers?: Omit<EngineTimersOptions, 'logger'>;
cache?: Omit<ActiveCacheOptions, 'logger'>;
connections?: Omit<ActiveConnectionsOptions, 'logger' | 'timers' | 'session'>;
}
interface ActiveApp<S extends LangNode = LangNode> {
readonly Logger: EngineLogger;
readonly Lang: ActiveLang<S>;
readonly Formats: ActiveFormats;
readonly Frontend: ActiveFrontend;
readonly Dom: ActiveDom;
readonly Storage: ActiveStorage;
readonly Http: EngineHttp;
readonly Timers: ActiveTimers;
readonly Cache: ActiveCache;
readonly Sess: ActiveSession<unknown, unknown, unknown> | undefined;
getLocale(): SupportedLocale;
setLocale(locale: SupportedLocale): void;
onLocaleChange(fn: (locale: SupportedLocale) => void): () => void;
createSiumEngine(): EngineSium;
createActiveSession<TUser, TCredential = undefined, TData = undefined>(
options?: Omit<EngineSessionOptions<TUser, TCredential, TData>, 'logger'>
): ActiveSession<TUser, TCredential, TData>;
createActiveConnections<TConnections extends ConnectionMap = ConnectionMap>(
options?: Omit<ActiveConnectionsOptions, 'logger' | 'timers' | 'session'>
): ActiveConnections<TConnections>;
dispose(): void;
}
Why Sium is out
Sium is a validation library that reaches into per-page data — login forms, profile editors, signup wizards. Putting it in App would force every page (including those without forms) to load the entire schema/types/issues machinery just to use Lang or Formats. Keeping Sium page-scoped means:
- Pages without validation pay nothing for it.
- Each form can use a sium engine tuned to its own needs (custom logger category, validation context, etc.).
- App stays focused on the runtime contract every page needs.
The standard pattern is one line at the top of the page module:
const sium = createEngineSium({ lang: App.Lang, logger: App.Logger });