# LogosDX > Focused TypeScript utilities for building JavaScript applications in any runtime. Zero dependencies, type-safe, and designed for production resilience. LogosDX provides a collection of packages that work together or independently. Each package follows consistent patterns: error tuples with `attempt()`, event-driven architecture, and comprehensive TypeScript support. ## Documentation - [Getting Started](https://logosdx.dev/getting-started): Installation and basic usage - [API Reference](https://typedoc.logosdx.dev): Full TypeScript API documentation - [Cheat Sheet](https://logosdx.dev/cheat-sheet): Quick reference for common patterns --- # @logosdx/dom Package Summary Lightweight (~10KB) element-centric DOM utility for embeddable widgets. Chainable `$()` collections backed by standalone tree-shakeable functions. AbortController-based lifecycle, modern observer wrappers, and accessibility-first aria namespace. ## Core: `$()` Selector ```ts import { $, DomCollection } from '@logosdx/dom'; // Selection — returns DomCollection const btns = $('.btn'); const scoped = $('.item', { container: sidebar }); // scoped to parent const chat = $('.chat', { signal: controller.signal }); // auto-cleanup const both = $('.btn', { container, signal }); // scoped + signal const wrap = $(element); // wrap single element const wrap = $([el1, el2], { signal }); // wrap array + options // Properties btns.elements // HTMLButtonElement[] btns.length // number btns.first // HTMLButtonElement | undefined btns.at(2) // HTMLButtonElement | undefined // Iteration btns.each(el => { ... }) // chainable btns.map(el => el.textContent) // string[] btns.filter(el => !el.disabled) // new DomCollection for (const btn of btns) { ... } // iterable // Element creation $.create('div', { text: 'Hello', css: { padding: '1rem', '--theme': 'dark' }, attrs: { 'data-id': '123' }, class: ['card', 'active'], children: [$.create('span', { text: 'child' }).first], on: { click: handler }, signal: controller.signal }) ``` > **NEVER use `document.querySelector`, `document.querySelectorAll`, `document.getElementById`, or `element.querySelector`.** Always use `$()` from `@logosdx/dom` for ALL element selection. This applies everywhere — initial queries, scoped queries within containers, finding child elements, and filtering. The `$()` function returns a `DomCollection` that integrates with all @logosdx/dom features (events, CSS, ARIA, animation, cleanup). ```ts // Scoped query within a container const rows = $('tr', { container: tableBody }); // NOT tableBody.querySelectorAll('tr') const btn = $('button', { container: dialog }); // NOT dialog.querySelector('button') // Wrap + filter instead of scoped querySelector const active = $('li', { container: nav }).filter(el => el.classList.contains('active')); // Wrap a known element — still use $() for consistency const header = $(document.body.firstElementChild!); ``` ## CSS — Callable Namespace ```ts import { css } from '@logosdx/dom'; // Standalone css(el, { color: 'red', '--theme': 'dark' }); // set css(el, 'color'); // get → string css(el, ['color', 'fontSize']); // get → Record css.remove(el, ['color', '--theme']); // remove // Chained $('.btn').css({ color: 'red' }).css.remove('fontSize'); $('.btn').css('color'); // get from first element ``` ## Attributes — Callable Namespace ```ts import { attr } from '@logosdx/dom'; attr(el, { role: 'button', 'data-id': '1' }); // set attr(el, 'role'); // get → string | null attr(el, ['role', 'data-id']); // get → Record attr.remove(el, 'role'); // remove attr.has(el, 'disabled'); // → boolean // Chained $('.btn').attr({ role: 'button' }).attr.remove('tabindex'); ``` ## Classes — Namespace ```ts import { classify } from '@logosdx/dom'; classify.add(el, ['active', 'highlighted']); classify.remove(el, 'active'); classify.toggle(el, 'active'); classify.has(el, 'active'); // → boolean classify.swap(el, 'active', 'inactive'); // Chained $('.btn').class.add('active').class.toggle('highlight'); $('.btn').class.has('active'); // boolean from first ``` ## Data — Callable Namespace ```ts import { data } from '@logosdx/dom'; data(el, { userId: '123', role: 'admin' }); // set via dataset data(el, 'userId'); // get → string | undefined data(el, ['userId', 'role']); // get → Record data.remove(el, 'userId'); // remove // Chained $('.card').data({ id: '1' }).data.remove('stale'); ``` ## Aria — Accessibility ```ts import { aria } from '@logosdx/dom'; // Auto-prefixes with aria- aria(el, { pressed: 'true', expanded: 'false' }); // set aria(el, 'pressed'); // get → string | null aria.remove(el, 'pressed'); // remove single aria.remove(el, ['pressed', 'expanded']); // remove multiple // Shorthand methods aria.role(el, 'button'); aria.role(el); // set/get role aria.label(el, 'Submit'); aria.label(el); // set/get aria-label aria.hide(el); // aria-hidden="true" aria.show(el); // remove aria-hidden aria.live(el, 'polite'); // aria-live // Chained $('.modal').aria({ modal: 'true' }).aria.role('dialog').aria.label('Settings'); ``` ## Events — AbortController Integration ```ts import { on, once, off, emit } from '@logosdx/dom'; on(el, 'click', handler); on(el, ['pointerenter', 'focus'], handler); // multiple events on(el, 'click', handler, { capture: true }); on(el, 'click', handler, { signal: controller.signal }); // auto-cleanup on(parent, 'click', handler, { delegate: '.child' }); // delegation once(el, 'click', handler); // fires once off(el, 'click', handler); // remove off(el, ['pointerenter', 'focus'], handler); // multiple events emit(el, 'widget:open', { chatId: 123 }); // CustomEvent // Chained — collection signal auto-inherited const chat = $('.chat', { signal: controller.signal }); chat.on('click', openMenu); // signal auto-attached chat.once('keydown', sendMsg); // fires once, signal auto-attached chat.off('click', openMenu); // remove specific listener chat.emit('widget:open', { chatId: 1 }); // dispatch custom event controller.abort(); // removes all listeners ``` ## Error Handling in DOM Operations Use `attemptSync` from `@logosdx/utils` for DOM operations that might throw (animations, observer setup, element manipulation on detached nodes). Do not use try-catch. ```ts import { attemptSync } from '@logosdx/utils'; // Use attemptSync for operations that might throw const [, err] = attemptSync(() => panel.animate({ opacity: [0, 1] })); if (err) console.warn('Animation failed:', err); // Guard observer setup on elements that may not exist const [stop, obsErr] = attemptSync(() => watchResize(el, relayout)); if (obsErr) console.warn('Resize observer failed:', obsErr); ``` ## Animate — Web Animations API ```ts import { animate } from '@logosdx/dom'; animate(el, [{ opacity: 0 }, { opacity: 1 }], { duration: 300 }); animate([el1, el2], [{ opacity: 0 }, { opacity: 1 }], 300); // multiple animate.fadeIn(el, 300); animate.fadeIn([el1, el2], 300); // multiple animate.fadeOut(el, 300); animate.slideTo(el, { x: 10, y: -20 }, 300); // Chained await $('.modal').animate.fadeIn(200); // Automatically respects prefers-reduced-motion ``` ## Observers ```ts import { observe, watchVisibility, watchResize } from '@logosdx/dom'; // MutationObserver — auto-bind behaviors const stop = observe('[data-tooltip]', (el) => { const tip = new Tooltip(el); return () => tip.destroy(); // per-element cleanup }); stop(); // disconnects + runs all cleanups // IntersectionObserver const stop = watchVisibility(el, (entry) => { if (entry.isIntersecting) loadImage(el); }, { threshold: 0.5, once: true }); // ResizeObserver const stop = watchResize(el, (entry) => { if (entry.contentRect.width < 400) compact(el); }); // All support signal observe(sel, init, { signal: controller.signal }); ``` ## Viewport ```ts import { viewport } from '@logosdx/dom'; viewport.width(); viewport.height(); viewport.scrollX(); viewport.scrollY(); viewport.scrollProgress(); // 0–1 (page) viewport.scrollProgress(el); // 0–1 (element) viewport.pixelRatio(); viewport.isAtTop(10); viewport.isAtBottom(10); viewport.scrollTo(el, { behavior: 'smooth' }); viewport.scrollTo(0, 500, { behavior: 'smooth' }); ``` ## DOM Manipulation ```ts import { create, append, prepend, remove, replace } from '@logosdx/dom'; const el = create('div', { text: 'Hello', class: ['card'] }); append(parent, el, otherEl); prepend(parent, el); remove(el); replace(oldEl, newEl); ``` ## Templates — Clone & Hydrate ```ts import { $, TemplateStamper } from '@logosdx/dom'; // Configure once — base styling, events, accessibility const userCard = $.template('#user-card-template', { signal: controller.signal, map: { '.username': { css: { fontWeight: 'bold' } }, '.user-email': { css: { color: 'gray' } }, '.view-profile': { on: { click: handleView }, aria: { label: 'View profile' } } } }); // Stamp single — per-instance data merges with base config userCard.stamp({ '.username': 'Alice Johnson', '.user-email': 'alice@example.com', '.view-profile': { attrs: { 'data-id': '1' } } }).into(container); // Stamp many — mapper function per data item userCard.stamp(users, u => ({ '.username': u.name, '.user-email': u.email, '.view-profile': { data: { userId: u.id } } })).into(container); // String shorthand — equivalent to { text: '...' } stamper.stamp({ '.name': 'Alice' }); // Same as: stamper.stamp({ '.name': { text: 'Alice' } }); // StampOptions per selector: text, css, class, attrs, data, aria, on // Base config + stamp data are shallow-merged per selector (stamp wins on conflict) ``` ## Widget Lifecycle Example ```ts function initChatWidget(container: HTMLElement) { const controller = new AbortController(); const ui = $(container, { signal: controller.signal }); ui.class.add('chat-active'); ui.aria({ role: 'dialog', label: 'Customer support' }); ui.css({ '--chat-bg': '#fff' }); ui.on('click', handler); observe('[data-emoji]', initEmoji, { signal: controller.signal }); watchResize(container, relayout, { signal: controller.signal }); return () => controller.abort(); // single cleanup for everything } ``` ## Module Structure ``` @logosdx/dom ├── index.ts # $, $.create, $.template, all exports ├── collection.ts # DomCollection class ├── css.ts # css() + css.remove() ├── attr.ts # attr() + attr.remove() + attr.has() ├── class.ts # classify.add/remove/toggle/has/swap ├── data.ts # data() + data.remove() ├── aria.ts # aria() + shorthand methods ├── events.ts # on, once, off, emit ├── animate.ts # animate() + presets ├── observe.ts # observe (MutationObserver) ├── watch.ts # watchVisibility, watchResize ├── viewport.ts # viewport namespace ├── template.ts # TemplateStamper class ├── dom.ts # create, append, prepend, remove, replace ├── helpers.ts # internal utilities └── types.ts # shared types ``` --- # @logosdx/fetch HTTP client with retry logic, request/response interception, and comprehensive error handling for production applications. ## Table of Contents - [Core API](#core-api) — Setup, error handling, global instance - [HTTP Methods](#http-methods) — GET/POST/PUT/PATCH/DELETE, FetchPromise, FetchResponse - [Configuration](#configuration) — Config interface, timeout, retry, validation, policies - [Error Handling](#error-handling) — FetchError, isFetchError, lifecycle events - [Headers & Parameters Management](#headers--parameters-management) — set/remove/resolve - [State Management](#state-management) — Internal state for auth, sessions - [Event System](#event-system) — All FetchEventNames, event data fields - [Request Deduplication](#request-deduplication) — Prevent duplicate concurrent requests - [Response Caching](#response-caching) — TTL and stale-while-revalidate - [Rate Limiting](#rate-limiting) — Token bucket per-endpoint throttling - [Timeout Options](#timeout-options) — totalTimeout vs attemptTimeout - [Response Chaining](#response-chaining) — .json(), .text(), .blob(), .stream() - [Stream Mode](#stream-mode) — Async iteration over response chunks - [Advanced Features](#advanced-features) — Custom retry, type determination - [TypeScript Patterns](#typescript-patterns) — Module augmentation, typed instances - [Lifecycle Management](#lifecycle-management) — destroy(), cleanup - [Request Serializers](#request-serializers) — Built-in and custom key generators - [Policy Architecture](#policy-architecture) — Hook pipeline execution order - [Plugin Architecture](#plugin-architecture) — Plugin factories, engine.use() ## Core API ```typescript import { FetchEngine, FetchError, FetchEvent, FetchEventNames, FetchResponse, isFetchError } from '@logosdx/fetch'; import { attempt } from '@logosdx/utils'; // Basic setup const api = new FetchEngine({ baseUrl: 'https://api.example.com', defaultType: 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData', headers: { Authorization: 'Bearer token' }, totalTimeout: 5000, // Total timeout for entire operation (including retries) attemptTimeout: 2000 // Per-attempt timeout (allows retries on timeout) }); // Error handling pattern — always narrow with isFetchError const [response, err] = await attempt(() => api.get('/users/123')); if (err) { if (isFetchError(err)) { console.error('Request failed:', err.status, err.message); if (err.isTimeout()) console.warn('Timed out on attempt', err.attempt); } return; } const { data: user } = response; console.log('User:', user); console.log('Rate limit:', response.headers['x-rate-limit-remaining']); // Global instance (simplified usage) import fetch, { get, post, headers, params, state, config, on, off } from '@logosdx/fetch'; // Global instance auto-uses current domain as base URL const [{ data: users }, usersErr] = await attempt(() => fetch.get('/api/users')); if (usersErr) { if (isFetchError(usersErr)) { console.error('Status:', usersErr.status, 'Step:', usersErr.step); } return; } // Or use destructured methods headers.set('Authorization', 'Bearer token'); state.set('userId', '123'); const [{ data: user }, userErr] = await attempt(() => get('/api/users/123')); if (userErr) { if (isFetchError(userErr)) { if (userErr.status === 404) console.warn('User not found'); } return; } ``` ## HTTP Methods ```typescript // All methods return FetchPromise> interface FetchPromise extends Promise> { isFinished: boolean; isAborted: boolean; abort(reason?: string): void; // Response chaining — declare expected response type before awaiting json(): FetchPromise; text(): FetchPromise; blob(): FetchPromise; arrayBuffer(): FetchPromise; formData(): FetchPromise; raw(): FetchPromise; stream(): FetchStreamPromise; } // Stream promise supports async iteration over chunks interface FetchStreamPromise extends FetchPromise, AsyncIterable {} // Enhanced response object with typed headers, params, and response headers interface FetchResponse { data: T; // Parsed response body headers: Partial; // Response headers as typed plain object status: number; // HTTP status code request: Request; // Original request object config: FetchConfig; // Typed configuration used for request } // Configuration object with typed headers and params interface FetchConfig { baseUrl?: string; timeout?: number; headers?: H; // Typed headers from your custom interface params?: P; // Typed params from your custom interface retry?: RetryConfig | false; method?: string; determineType?: any; } // HTTP convenience methods - all return FetchPromise (chainable) api.get(path, options?): FetchPromise api.post(path, payload?, options?): FetchPromise api.put(path, payload?, options?): FetchPromise api.patch, RH>(path, payload?, options?): FetchPromise api.delete(path, payload?, options?): FetchPromise api.options(path, options?): FetchPromise // Generic request method api.request(method, path, options & { payload?: RequestData }): FetchPromise // Request cancellation const request = api.get('/users'); setTimeout(() => request.abort('User cancelled'), 2000); ``` ## Configuration ```typescript interface FetchEngine.Config { baseUrl: string; defaultType?: 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData'; // Timeout options totalTimeout?: number; // Total timeout for entire request lifecycle (ms) attemptTimeout?: number; // Per-attempt timeout (ms) - allows retries on timeout // Headers - global and method-specific headers?: Headers; methodHeaders?: { GET?: Headers; POST?: Headers; // ... other methods }; // URL parameters - global and method-specific params?: Params

; methodParams?: { GET?: Params

; // ... other methods }; // Retry configuration (set to false to disable retries) retry?: { maxAttempts?: number; // default: 3 baseDelay?: number; // default: 1000 (in milliseconds) maxDelay?: number; // default: 10000 useExponentialBackoff?: boolean; // default: true retryableStatusCodes?: number[]; // default: [408, 429, 500, 502, 503, 504] shouldRetry?: (error: FetchError, attempt: number) => boolean | number; } | false; // Validation validate?: { headers?: (headers: Headers, method?: HttpMethods) => void; params?: (params: Params

, method?: HttpMethods) => void; state?: (state: S) => void; perRequest?: { headers?: boolean; params?: boolean; }; }; // Request ID tracing generateRequestId?: () => string; // Custom ID generator (default: generateId from utils) requestIdHeader?: string; // Header name for sending requestId to server // Response type determination determineType?: (response: Response) => 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | Symbol; // Deduplication policy (prevents duplicate concurrent requests) dedupePolicy?: boolean | DeduplicationConfig; // Cache policy (caches responses with TTL and SWR support) cachePolicy?: boolean | CacheConfig; // Rate limit policy (controls outgoing request rate with token bucket) rateLimitPolicy?: boolean | RateLimitConfig; } ``` ## Error Handling > **Every error from FetchEngine should be narrowed with `isFetchError(err)`.** This gives access to `.status`, `.isCancelled()`, `.isTimeout()`, `.isConnectionLost()`, `.attempt`, `.step`, and `.requestId`. Without narrowing, these properties are inaccessible. ```typescript interface FetchError extends Error { data: T | null; status: number; method: HttpMethods; path: string; aborted?: boolean; // True if request was aborted (any cause) timedOut?: boolean; // True if aborted due to timeout (attemptTimeout or totalTimeout) requestId?: string; // Unique request ID for tracing (consistent across retries) attempt?: number; step?: 'fetch' | 'parse' | 'response'; url?: string; headers?: H; // Convenience methods for distinguishing 499 errors isCancelled(): boolean; // status === 499 && aborted && !timedOut isTimeout(): boolean; // status === 499 && timedOut isConnectionLost(): boolean; // status === 499 && step === 'fetch' && !aborted } // Always inspect errors with isFetchError — never use generic Error checks const [response, err] = await attempt(() => api.get('/products').json()); if (isFetchError(err)) { if (err.isCancelled()) return; // request was aborted if (err.isTimeout()) return retry(); // timed out if (err.isConnectionLost()) return offline(); // network down if (err.status === 401) return refreshToken(); if (err.status === 404) return notFound(); if (err.status >= 500) return serverError(err.requestId, err.attempt); // err.step tells you where it failed: 'request' | 'response' | 'parse' } // Wrapper function pattern — log all error properties for diagnostics async function safeGet(path: string): Promise { const [response, err] = await attempt(() => api.get(path).json()); if (isFetchError(err)) { console.error(`[${err.method}] ${err.path} failed (attempt ${err.attempt}):`, { status: err.status, timedOut: err.isTimeout(), cancelled: err.isCancelled(), step: err.step, requestId: err.requestId }); return null; } return response.data; } // Lifecycle events api.on('error', (event: FetchEvent) => { console.error('Request failed:', event.error); }); api.on('retry', (event: FetchEvent) => { console.log(`Retrying attempt ${event.nextAttempt} after ${event.delay}ms`); }); ``` ## Headers & Parameters Management ```typescript // Headers api.headers.set('Authorization', 'Bearer new-token'); api.headers.set({ 'X-API-Version': 'v2', 'X-Client': 'web' }); api.headers.set('Content-Type', 'application/json', 'POST'); // method-specific api.headers.remove('Authorization'); api.headers.remove(['X-API-Version', 'X-Client']); api.headers.has('Authorization'); // boolean // Parameters api.params.set('version', 'v1'); api.params.set({ api_key: 'abc123', format: 'json' }); api.params.set('page', '1', 'GET'); // method-specific api.params.remove('version'); api.params.has('api_key'); // boolean // Access current configuration api.headers.defaults; // Default headers (global) api.headers.all; // All headers including method overrides api.headers.resolve('GET'); // Resolved headers for a specific method api.params.defaults; // Default params (global) api.params.all; // All params including method overrides api.params.resolve('GET'); // Resolved params for a specific method // With global instance and destructured managers import { headers, params, config } from '@logosdx/fetch'; headers.set('X-API-Key', 'key123'); params.set('version', 'v1'); ``` ## State Management ```typescript // Internal state for auth tokens, user context, etc. api.state.set('authToken', 'bearer-token-123'); api.state.set({ userId: '456', sessionId: 'abc', preferences: { theme: 'dark' } }); const currentState = api.state.get(); // deep clone api.state.reset(); // clear all state // --- Auth token pattern: store token in state, attach via before-request hook --- // 1. Store the Bearer token in state api.state.set('authToken', 'my-jwt-token'); // 2. Use a before-request hook to attach it to every outgoing request api.on('before-request', (event) => { const { authToken } = event.state; if (authToken) { api.headers.set('Authorization', `Bearer ${authToken}`); } }); // Now all requests automatically include the Authorization header. // To update the token (e.g., after refresh): api.state.set('authToken', 'refreshed-jwt-token'); // Access response metadata with typed config const response = await api.get('/users'); if (response.status === 200) { console.log('Success! Users:', response.data); console.log('Request URL:', response.request.url); console.log('Config used:', response.config); console.log('Rate limit:', response.headers['x-rate-limit-remaining']); // response.config.headers is typed as MyHeaders // response.config.params is typed as MyParams // response.headers is typed as Partial } ``` ## Event System ```typescript enum FetchEventNames { // Request lifecycle 'before-request' = 'before-request', 'after-request' = 'after-request', 'abort' = 'abort', 'error' = 'error', 'response' = 'response', 'retry' = 'retry', // Configuration changes 'header-add' = 'header-add', 'header-remove' = 'header-remove', 'param-add' = 'param-add', 'param-remove' = 'param-remove', 'state-set' = 'state-set', 'state-reset' = 'state-reset', 'url-change' = 'url-change', 'config-change' = 'config-change', // Deduplication events 'dedupe-start' = 'dedupe-start', // New request tracked 'dedupe-join' = 'dedupe-join', // Caller joined existing // Caching events 'cache-hit' = 'cache-hit', // Fresh cache hit 'cache-stale' = 'cache-stale', // Stale cache hit (SWR) 'cache-miss' = 'cache-miss', // No cache entry 'cache-set' = 'cache-set', // New cache entry stored 'cache-revalidate' = 'cache-revalidate', // SWR background refresh started 'cache-revalidate-error' = 'cache-revalidate-error', // SWR background refresh failed // Rate limiting events 'ratelimit-wait' = 'ratelimit-wait', // Waiting for token 'ratelimit-reject' = 'ratelimit-reject', // Rejected (waitForToken: false) 'ratelimit-acquire' = 'ratelimit-acquire' // Token acquired } // Event listeners (use regex to match all events) api.on(/./, ({ event, data }) => console.log('Event:', event, data)); api.on('before-request', (event) => console.log('Request starting:', event.url)); api.on('error', (event) => console.error('Request failed:', event.error)); api.off('error', errorHandler); // remove listener // Event timing — terminal events include requestStart/requestEnd api.on('response', (event) => { const duration = event.requestEnd - event.requestStart; console.log(`[${event.requestId}] ${event.method} ${event.path} completed in ${duration}ms`); }); ``` ### Event Data Fields Request lifecycle events receive `EventData`: ```typescript interface EventData { state: S; url?: string | URL; method?: HttpMethods; headers?: DictAndT; params?: DictAndT

; error?: Error | FetchError; response?: Response; data?: unknown; payload?: unknown; attempt?: number; nextAttempt?: number; delay?: number; step?: 'fetch' | 'parse' | 'response'; status?: number; path?: string; aborted?: boolean; requestId?: string; // Unique ID for this request (consistent across retries) requestStart?: number; // Date.now() when request entered pipeline (all request events) requestEnd?: number; // Date.now() when request resolved (response, error, abort only) } ``` | Field | Present in | Description | |-------|-----------|-------------| | `requestStart` | All request events | Timestamp when the request entered the execution pipeline | | `requestEnd` | `response`, `error`, `abort` | Timestamp when the request resolved | | `requestId` | All request events | Unique ID, consistent across retries of the same request | ## Request Deduplication Prevents duplicate concurrent requests by sharing the same in-flight promise among callers with identical request keys. ```typescript // Enable with defaults (GET requests only) const api = new FetchEngine({ baseUrl: 'https://api.example.com', dedupePolicy: true }); // Three concurrent calls → one network request const [user1, user2, user3] = await Promise.all([ api.get('/users/123'), api.get('/users/123'), api.get('/users/123') ]); // Full configuration const api = new FetchEngine({ baseUrl: 'https://api.example.com', dedupePolicy: { enabled: true, methods: ['GET', 'POST'], // Default: ['GET'] serializer: (ctx) => `${ctx.method}:${ctx.path}`, shouldDedupe: (ctx) => !ctx.payload?.skipDedupe, rules: [ { startsWith: '/admin', enabled: false }, { startsWith: '/api/v2', serializer: customSerializer } ] } }); // Events api.on('dedupe-start', (e) => console.log('New request:', e.key)); api.on('dedupe-join', (e) => console.log('Joined request:', e.key, 'waiters:', e.waitingCount)); ``` ### Deduplication Types ```typescript interface DeduplicationConfig { enabled?: boolean; // Default: true methods?: HttpMethod[]; // Default: ['GET'] serializer?: RequestSerializer; // Default: defaultRequestSerializer shouldDedupe?: (ctx: RequestKeyOptions) => boolean; // Dynamic skip rules?: DedupeRule[]; // Route-specific config } interface DedupeRule extends MatchTypes { methods?: HttpMethod[]; enabled?: boolean; serializer?: RequestSerializer; } interface RequestKeyOptions { method: string; path: string; payload?: unknown; headers?: H; params?: P; state?: S; } ``` ## Response Caching Cache responses with TTL and stale-while-revalidate (SWR) support. ```typescript // Enable with defaults (GET requests, 60s TTL) const api = new FetchEngine({ baseUrl: 'https://api.example.com', cachePolicy: true }); // Full configuration with SWR const api = new FetchEngine({ baseUrl: 'https://api.example.com', cachePolicy: { enabled: true, methods: ['GET'], ttl: 300000, // 5 minutes staleIn: 60000, // Stale after 1 minute (triggers background revalidation) skip: (ctx) => ctx.path.includes('/realtime'), rules: [ { startsWith: '/static', ttl: 3600000 }, // 1 hour for static { startsWith: '/admin', enabled: false } // No caching for admin ] } }); // Cache events api.on('cache-hit', (e) => console.log('Cache hit:', e.key, 'stale:', e.isStale)); api.on('cache-miss', (e) => console.log('Cache miss:', e.key)); api.on('cache-set', (e) => console.log('Cached:', e.key, 'expires in:', e.expiresIn)); api.on('cache-stale', (e) => console.log('Stale:', e.key)); api.on('cache-revalidate', (e) => console.log('Revalidating:', e.key)); // Cache invalidation API await api.clearCache(); // Clear all await api.deleteCache(key); // Delete specific key await api.invalidateCache((key) => key.includes('user')); // By predicate await api.invalidatePath('/users'); // By path prefix await api.invalidatePath(/^\/api\/v\d+/); // By regex await api.invalidatePath((key) => key.includes('user')); // By predicate (custom serializers) const stats = api.cacheStats(); // { cacheSize, inflightCount } ``` ### Caching Types ```typescript interface CacheConfig { enabled?: boolean; // Default: true methods?: HttpMethod[]; // Default: ['GET'] ttl?: number; // Default: 60000 (1 minute) staleIn?: number; // Default: undefined (no SWR) serializer?: RequestSerializer; // Default: defaultRequestSerializer skip?: (ctx: RequestKeyOptions) => boolean; // Dynamic skip rules?: CacheRule[]; // Route-specific config adapter?: CacheAdapter; // Custom storage backend (Redis, IndexedDB, etc.) } interface CacheRule extends MatchTypes { methods?: HttpMethod[]; enabled?: boolean; ttl?: number; staleIn?: number; serializer?: RequestSerializer; skip?: (ctx: RequestKeyOptions) => boolean; } ``` ## Rate Limiting Control outgoing request rates using a token bucket algorithm. Each unique request key gets its own rate limiter, enabling per-endpoint or per-user throttling. ```typescript // Enable with defaults (100 requests/minute, all methods) const api = new FetchEngine({ baseUrl: 'https://api.example.com', rateLimitPolicy: true }); // Full configuration const api = new FetchEngine({ baseUrl: 'https://api.example.com', rateLimitPolicy: { enabled: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], maxCalls: 100, // 100 requests per window windowMs: 60000, // 1 minute window waitForToken: true, // Wait for token (false = reject immediately) serializer: (ctx) => `${ctx.method}|${ctx.url.pathname}`, shouldRateLimit: (ctx) => !ctx.headers?.['X-Bypass-RateLimit'], onRateLimit: (ctx, waitTimeMs) => console.log(`Rate limited, waiting ${waitTimeMs}ms`), rules: [ { startsWith: '/api/search', maxCalls: 10, windowMs: 60000 }, // Stricter for search { startsWith: '/api/bulk', waitForToken: false }, // Reject bulk if limited { startsWith: '/health', enabled: false } // No limits for health checks ] } }); // Cache checks run BEFORE rate limiting // Cached responses return immediately without consuming rate limit tokens // Events api.on('ratelimit-wait', (e) => console.log('Waiting for token:', e.key, e.waitTimeMs)); api.on('ratelimit-reject', (e) => console.log('Rate limited:', e.key)); api.on('ratelimit-acquire', (e) => console.log('Token acquired:', e.key, 'remaining:', e.currentTokens)); ``` ### Rate Limiting Types ```typescript interface RateLimitConfig { enabled?: boolean; // Default: true methods?: HttpMethod[]; // Default: all methods maxCalls?: number; // Default: 100 windowMs?: number; // Default: 60000 (1 minute) waitForToken?: boolean; // Default: true (wait vs reject) serializer?: RequestSerializer; // Default: defaultRateLimitSerializer shouldRateLimit?: (ctx: RequestKeyOptions) => boolean; // Dynamic bypass onRateLimit?: (ctx: RequestKeyOptions, waitTimeMs: number) => void | Promise; rules?: RateLimitRule[]; // Route-specific config } interface RateLimitRule extends MatchTypes { methods?: HttpMethod[]; enabled?: boolean; maxCalls?: number; windowMs?: number; waitForToken?: boolean; serializer?: RequestSerializer; } // Default serializer groups by method + pathname (per-endpoint limiting) // defaultRateLimitSerializer: (ctx) => `${ctx.method}|${ctx.url.pathname}` ``` ### Route Matching Deduplication, caching, and rate limiting all support flexible route matching: ```typescript interface MatchTypes { is?: string; // Exact path match startsWith?: string; // Path prefix match endsWith?: string; // Path suffix match includes?: string; // Path contains match match?: RegExp; // Regex match } // Multiple match types use AND logic (except 'is' which is exclusive) const rules = [ { is: '/users' }, // Exact match only { startsWith: '/api', endsWith: '.json' }, // Must satisfy both { includes: 'admin', match: /\/v\d+\// }, // Must satisfy both ]; ``` ## Timeout Options FetchEngine provides two timeout types that can be used independently or together: ```typescript const api = new FetchEngine({ baseUrl: 'https://api.example.com', // totalTimeout: Caps entire operation including all retries // When triggered, stops everything immediately totalTimeout: 30000, // 30s max for entire request lifecycle // attemptTimeout: Per-attempt timeout (creates fresh controller per attempt) // When triggered, that attempt fails but can be retried attemptTimeout: 5000, // 5s per attempt retry: { maxAttempts: 3, shouldRetry: (error) => error.status === 499 // Retry on timeout } }); // timeout is deprecated - use totalTimeout instead // timeout: 5000 is equivalent to totalTimeout: 5000 ``` ### How They Work Together ``` totalTimeout (parent controller) │ ├── attempt 1: attemptTimeout (child controller 1) → times out → retry ├── attempt 2: attemptTimeout (child controller 2) → times out → retry └── attempt 3: attemptTimeout (child controller 3) → still running... │ totalTimeout fires ─────────────────────────────────────────┘ → Immediately stops all attempts, no more retries ``` ### Distinguishing Abort Causes FetchError provides helper methods to distinguish between different types of 499 errors: ```typescript const [, err] = await attempt(() => api.get('/slow')); if (isFetchError(err)) { if (err.isCancelled()) { // Manual abort - user navigated away, component unmounted, etc. // Don't show error, don't log return; } if (err.isTimeout()) { // Our timeout fired (attemptTimeout or totalTimeout) toast.warn('Request timed out. Retrying...'); } else if (err.isConnectionLost()) { // Server dropped connection or network failed (NOT our abort) toast.error('Connection lost. Check your internet.'); } } ``` **Helper Methods (all return `false` for non-499 errors):** | Method | Logic | Use Case | |--------|-------|----------| | `isCancelled()` | `status === 499 && aborted && !timedOut` | User/app intentionally cancelled | | `isTimeout()` | `status === 499 && timedOut` | Our timeout fired | | `isConnectionLost()` | `status === 499 && step === 'fetch' && !aborted` | Server/network dropped us | **Raw Property Reference:** | Scenario | `status` | `aborted` | `timedOut` | `step` | |----------|----------|-----------|------------|--------| | Manual abort (`promise.abort()`) | 499 | `true` | `undefined` | `'fetch'` | | `attemptTimeout` fires | 499 | `true` | `true` | `'fetch'` | | `totalTimeout` fires | 499 | `true` | `true` | `'fetch'` | | Server closed connection | 499 | `false` | `undefined` | `'fetch'` | | Network error | 499 | `false` | `undefined` | `'fetch'` | ## Response Chaining Declare how the response body should be parsed by chaining a directive method before awaiting. No directive means auto-detection based on content-type (backwards compatible). ```typescript // Explicit response type via chaining const { data: user } = await api.get('/users/123').json(); const { data: html } = await api.get('/page').text(); const { data: file } = await api.get('/file').blob(); const { data: buf } = await api.get('/binary').arrayBuffer(); const { data: form } = await api.get('/form').formData(); const { data: res } = await api.get('/endpoint').raw(); // No directive — auto-parse based on content-type (backwards compatible) const { data: auto } = await api.get('/users/123'); // Override guard — setting directive twice throws api.get('/users').json().text(); // throws: 'Response type already set' // Works with error handling — always narrow with isFetchError const [response, err] = await attempt(() => api.get('/users/123').json()); if (err) { if (isFetchError(err)) { if (err.isTimeout()) return showRetryPrompt(); if (err.status === 404) return showNotFound(); } return; } console.log(response.data); // typed as User // Works with abort const request = api.get('/slow').json(); setTimeout(() => request.abort('Too slow'), 5000); ``` ## Stream Mode Return raw `Response` objects with unconsumed body streams via `.stream()`. Cache and deduplication are skipped (each caller needs its own readable stream). Rate limiting and lifecycle events still fire normally. ```typescript // Stream mode via .stream() — supports async iteration for await (const chunk of api.get('/events').stream()) { console.log(new TextDecoder().decode(chunk)); } // Cache and deduplication are skipped (each caller needs its own stream) // Rate limiting and lifecycle events still fire normally // Type: .stream() returns FetchStreamPromise (AsyncIterable) ``` ## Advanced Features ```typescript // Disable retries completely const noRetryApi = new FetchEngine({ baseUrl: 'https://api.example.com', retry: false // No retries at all }); // Custom retry logic with shouldRetry controlling delays const customRetryApi = new FetchEngine({ baseUrl: 'https://api.example.com', retry: { maxAttempts: 5, baseDelay: 1000, // Base delay for exponential backoff shouldRetry: (error, attempt) => { // Return custom delay in milliseconds for rate limits if (error.status === 429) { const retryAfter = error.headers?.['retry-after']; return retryAfter ? parseInt(retryAfter) * 1000 : 5000; } // Don't retry client errors if (error.status >= 400 && error.status < 500) { return false; } // Return custom delay for server errors if (error.status >= 500) { // Custom delay calculation overrides exponential backoff return Math.min(2000 * attempt, 10000); } return true; // Use default exponential backoff } } }); // Custom type determination const api = new FetchEngine({ baseUrl: 'https://api.example.com', determineType: (response) => { if (response.url.includes('/download')) return 'blob'; if (response.url.includes('/csv')) return 'text'; return FetchEngine.useDefault; // fall back to built-in detection } }); // Environment switching api.config.set('baseUrl', 'https://api.staging.com'); ``` ## TypeScript Patterns ```typescript // Extend interfaces for type safety declare module '@logosdx/fetch' { namespace FetchEngine { interface InstanceHeaders { Authorization?: string; 'Content-Type'?: string; 'X-API-Key'?: string; } interface InstanceParams { version?: string; format?: 'json' | 'xml'; } interface InstanceResponseHeaders extends Record { 'x-rate-limit-remaining'?: string; 'x-rate-limit-reset'?: string; 'x-request-id'?: string; 'content-type'?: string; } interface InstanceState { authToken?: string; userId?: string; sessionId?: string; } } } // Now both custom instances and global instance use the same types const api = new FetchEngine< FetchEngine.InstanceHeaders, FetchEngine.InstanceParams, FetchEngine.InstanceState, FetchEngine.InstanceResponseHeaders >({ baseUrl: 'https://api.example.com', validate: { headers: (headers) => { if (!headers.Authorization) { throw new Error('Authorization required'); } }, state: (state) => { if (state.userId && !state.sessionId) { throw new Error('Session required with user'); } } } }); // Global instance automatically gets the extended types import { state, get, put, post, patch, del, options } from '@logosdx/fetch'; state.set('authToken', 'token123'); // Typed // Response is properly typed with FetchResponse including typed config const response = await get('/api/user'); response.data; // ✅ Typed as User response.status; // ✅ Typed as number response.headers; // ✅ Typed as Partial response.headers['x-rate-limit-remaining']; // ✅ Typed access to response headers response.request; // ✅ Typed as Request response.config.headers; // ✅ Typed as InstanceHeaders response.config.params; // ✅ Typed as InstanceParams // Per-request response header typing interface CustomHeaders { 'x-custom-header': string; } const customResponse = await get('/api/user'); customResponse.headers['x-custom-header']; // ✅ Typed ``` ## Lifecycle Management ```typescript // Destroy instances when done (component unmount, app teardown) api.destroy(); api.isDestroyed(); // true await api.get('/users'); // throws: "Cannot make requests on destroyed FetchEngine instance" // on() returns cleanup function; all listeners auto-removed on destroy() const cleanup = api.on('error', (e) => console.error(e)); cleanup(); // manual removal // off() for named handler removal api.off('error', errorHandler); ``` ## Request Serializers Serializers generate unique keys for identifying requests. Used by dedupe, cache, and rate limit policies. ### Built-in Serializers ```typescript import { endpointSerializer, requestSerializer } from '@logosdx/fetch'; // requestSerializer (Default for Cache & Dedupe) // Format: method|path+query|payload|stableHeaders // Only includes stable headers: authorization, accept, accept-language, content-type, accept-encoding // Excludes dynamic headers: X-Timestamp, X-HMAC-Signature, X-Request-Id, etc. // endpointSerializer (Default for Rate Limit) // Format: method|pathname // Groups all requests to same endpoint regardless of params ``` ### Custom Serializers ```typescript // Custom serializer example: user-scoped rate limiting const api = new FetchEngine({ baseUrl: 'https://api.example.com', rateLimitPolicy: { serializer: (ctx) => `user:${ctx.state?.userId ?? 'anonymous'}`, maxCalls: 100 } }); ``` ### Serializer Signature ```typescript type RequestSerializer = (ctx: RequestKeyOptions) => string; // RequestKeyOptions defined in Deduplication Types above ``` ## Policy Architecture FetchEngine policies share a common architecture for consistent behavior and performance. ### Policy Execution Order FetchEngine uses a 3-phase hook pipeline powered by `@logosdx/hooks`: ``` beforeRequest (run): -30: cache plugin → return cached if hit (skip network entirely) -20: rate-limit plugin → wait or reject if exceeded 0: user hooks execute (pipe — onion middleware): -30: dedupe plugin → join in-flight if exists -20: retry plugin → wrap with retry logic 0: user hooks core: actual HTTP call (makeCall) afterRequest (run): -10: cache plugin → store response 0: user hooks ``` **Key implications:** - Cached responses don't consume rate limit tokens (cache runs first) - Rate limiting only gates cache misses - Dedupe and retry wrap the actual network call via pipe middleware - Only the request initiator consumes a rate limit token; joiners share the result ## Plugin Architecture FetchEngine's resilience features are implemented as plugins using `@logosdx/hooks`. Plugins install hooks on the engine's `HookEngine` instance. ### Plugin Factories ```typescript import { cachePlugin, dedupePlugin, retryPlugin, rateLimitPlugin, cookiePlugin } from '@logosdx/fetch'; // Create plugins const cache = cachePlugin({ ttl: 300000, staleIn: 60000 }); const dedupe = dedupePlugin(true); const retry = retryPlugin({ maxAttempts: 3 }); const rateLimit = rateLimitPlugin({ maxCalls: 100, windowMs: 60000 }); const cookies = cookiePlugin(); // Use with FetchEngine const api = new FetchEngine({ baseUrl: 'https://api.example.com', plugins: [cache, dedupe, retry, rateLimit, cookies] }); // Access plugin methods directly cache.clearCache(); cache.stats(); // { cacheSize, inflightCount } dedupe.inflightCount(); ``` ### engine.use(plugin) Install a plugin at runtime. Returns a cleanup function. ```typescript const cleanup = api.use(myPlugin); // Later: cleanup() to uninstall ``` ## Cookie Management ```typescript import { FetchEngine, cookiePlugin } from '@logosdx/fetch'; import type { Cookie, CookieAdapter, CookieConfig } from '@logosdx/fetch'; // Shorthand — in-memory jar, session only const api = new FetchEngine({ baseUrl: '...', cookies: true }); // Shorthand with config — exclude domains, adjust limits const api = new FetchEngine({ baseUrl: '...', cookies: { exclude: ['cdn.example.com'] } }); // Explicit plugin — when you need init/flush/jar access const cookies = cookiePlugin({ adapter: { async load(): Promise { return JSON.parse(await redis.get('cookies') ?? '[]'); }, async save(cookies: Cookie[]): Promise { await redis.set('cookies', JSON.stringify(cookies)); } }, syncOnRequest: true, // re-load from adapter before each request (for shared backends) }); await cookies.init(); // MUST call before first request when using an adapter const api = new FetchEngine({ baseUrl: '...', plugins: [cookies] }); // After login — server sets session cookie, plugin captures it automatically await api.post('/login', credentials); // Subsequent requests automatically include the Cookie header await api.get('/me'); // On logout — clear session (non-persistent) cookies cookies.jar.clearSession(); // Manual jar access const all: Cookie[] = cookies.jar.all(); const url = new URL('https://api.example.com/'); const matching = cookies.jar.get(url); cookies.jar.clear(); // clear all cookies.jar.clear('example.com'); // clear by domain cookies.jar.delete('example.com', '/', 'sid'); // delete one cookie // Graceful shutdown — force any pending coalesced save and await the final write await cookies.flush(); ``` **Persistence flow:** 1. `afterRequest` hook captures `Set-Cookie` → `parseSetCookieHeader()` → `jar.set(cookie)` 2. `CookieJar` fires its `onChange` callback; the plugin's `schedulePersist()` queues a microtask (coalesced — one per tick regardless of burst size) 3. On the microtask, `adapter.save(jar.all())` runs fire-and-forget; errors are swallowed 4. For graceful shutdown, `await cookies.flush()` forces `adapter.save(jar.all())` and surfaces rejection 5. `beforeRequest` hook calls `jar.get(url)` → `serializeCookies()` → injects `Cookie` header. `jar.get()` also bumps `lastAccessTime` on retrieved cookies (RFC 6265 §5.4) which triggers another coalesced save 6. With `syncOnRequest: true`, step 5 first calls `adapter.load()` to refresh the jar **RFC 6265 compliance:** full — date parser, domain matching, path matching, `Max-Age` > `Expires` precedence, host-only flag, eviction limits (4096 bytes/cookie, 50/domain, 3000 total). --- # @logosdx/hooks - LLM Helper > **Error handling rule:** Use `attempt()`/`attemptSync()` from `@logosdx/utils` for ALL error-prone operations in hook callbacks, pipe functions, and any I/O. Never use try-catch. A lightweight, type-safe lifecycle hook system for extending behavior without modifying code. ## Core Concept Lifecycle hooks let you respond to named events with bidirectional communication. Unlike traditional events (fire-and-forget), hooks support arg modification, short-circuiting, and result injection via `HookContext`. Distinct verbs from Observer: `add`/`run` (hooks) vs `on`/`emit` (observer). ## API Overview ```typescript import { HookEngine, HookScope, HookError, isHookError } from '@logosdx/hooks'; interface Lifecycle { beforeFetch(url: string, options: RequestInit): Promise; afterFetch(result: Response, url: string): Promise; } const hooks = new HookEngine() .register('beforeFetch', 'afterFetch'); // Subscribe — callbacks receive spread args + ctx as last param const cleanup = hooks.add('beforeFetch', (url, options, ctx) => { ctx.args(url, { ...options, cache: 'no-store' }); }); // Run const result = await hooks.run('beforeFetch', url, options); // result.args, result.result, result.returned, result.scope // Wrap function with pre/post hooks const wrapped = hooks.wrap(fn, { pre: 'beforeFetch', post: 'afterFetch' }); // Clear all hooks.clear(); // Remove subscription cleanup(); ``` ## HookEngine Methods | Method | Description | |--------|-------------| | `register(...names)` | Register hooks for runtime validation. Returns `this`. | | `add(name, callback, options?)` | Subscribe to hook. Returns cleanup function. | | `run(name, ...args)` | Run hook async. Returns `Promise`. | | `runSync(name, ...args)` | Run hook sync. Returns `RunResult`. | | `pipe(name, coreFn, ...args)` | Pipe hook async (onion middleware). Returns `Promise`. | | `pipeSync(name, coreFn, ...args)` | Pipe hook sync. Returns result. | | `wrap(fn, { pre?, post? })` | Wrap async function with pre/post hooks. | | `wrapSync(fn, { pre?, post? })` | Wrap sync function with pre/post hooks. | | `clear()` | Remove all hooks, reset to permissive mode. | ## HookContext Methods Passed as the last argument to every callback: | Method | Returns | Effect | |--------|---------|--------| | `ctx.args(...newArgs)` | `EarlyReturnSignal` | Replace args for downstream callbacks | | `return ctx.args(...)` | — | Replace args **and** stop the chain | | `ctx.returns(value)` | `EarlyReturnSignal` | Set result and stop (always use with `return`) | | `ctx.fail(...args)` | `never` | Abort with error | | `ctx.removeHook()` | `void` | Remove this callback from future runs | | `ctx.scope` | `HookScope` | Request-scoped state bag | **Short-circuit rules:** | Code | Args changed | Chain stops | |------|-------------|-------------| | `ctx.args(...)` | yes | no | | `return ctx.args(...)` | yes | yes | | `return ctx.returns(value)` | n/a | yes | | `ctx.fail(...)` | n/a | throws | ## RunResult ```typescript interface RunResult { args: Parameters; // Final args (possibly modified) result?: ReturnType; // Result if set via ctx.returns() returned: boolean; // Whether chain was short-circuited scope: HookScope; // Scope used during this run } ``` ## AddOptions ```typescript hooks.add('name', callback, { once: true, // Remove after first run (sugar for times: 1) times: 3, // Run N times then auto-remove ignoreOnFail: true, // Continue if callback throws priority: -10 // Lower runs first, default 0 }); ``` ## RunOptions ```typescript await hooks.run('beforeRequest', url, opts, { append: (url, opts, ctx) => { /* ephemeral, runs last */ }, scope: existingScope // Share state across runs/engines }); ``` ## pipe() — Middleware Composition Onion/middleware pattern where each callback wraps the next. Used for cross-cutting concerns like retry, dedupe, caching execution. ```typescript // Core function is the innermost call const result = await hooks.pipe('execute', async (opts) => { const [res, err] = await attempt(() => fetch(opts.url, opts)); if (err) throw err; return res; }, opts ); // Callbacks: (next, ...args, ctx) — call next() to proceed hooks.add('execute', async (next, opts, ctx) => { const start = Date.now(); const result = await next(); console.log(`Took ${Date.now() - start}ms`); return result; }, { priority: -10 }); ``` **PipeContext** — simpler than HookContext: | Method | Effect | |--------|--------| | `ctx.args(...newArgs)` | Replace args for downstream callbacks | | `ctx.fail(...args)` | Abort with error | | `ctx.removeHook()` | Remove this callback from future runs | | `ctx.scope` | Request-scoped state bag | Note: No `ctx.returns()` in pipe — return from callback directly. ## HookScope Request-scoped state bag that flows across hook runs and engine instances. ```typescript import { HookScope } from '@logosdx/hooks'; const scope = new HookScope(); scope.set(Symbol('private'), value); // Symbol keys for private state scope.set('shared', value); // String keys for cross-plugin contracts scope.get(key); scope.has(key); scope.delete(key); ``` **Flowing across runs:** ```typescript const scope = new HookScope(); const pre = await hooks.run('beforeRequest', url, opts, { scope }); const post = await hooks.run('afterRequest', res, url, opts, { scope }); // Both runs share the same scope ``` **Flowing across engine instances:** ```typescript // Main engine hook calls a plugin's own engine with the same scope mainEngine.add('process', async (data, ctx) => { await pluginEngine.run('validate', data, { scope: ctx.scope }); }); ``` ## Priority & Execution Order Hooks execute in priority order (lower first). Built-in plugins use negative values, user hooks default to 0. **FetchEngine hook priorities:** ``` beforeRequest (run): -30: cache plugin (return cached before consuming tokens) -20: rate-limit plugin (gate requests on cache miss) 0: user hooks (default) ∞: per-request hook (via RunOptions.append) execute (pipe): -30: dedupe plugin (join in-flight requests) -20: retry plugin (wrap with retry logic) 0: user hooks (default) afterRequest (run): -10: cache plugin (store response) 0: user hooks (default) ``` ## Library Integration Use `run()` to provide extension points in your library: ```typescript async function fetchWithHooks(url: string, options: RequestInit = {}) { const scope = new HookScope(); const pre = await hooks.run('beforeFetch', url, options, { scope }); if (pre.returned) return pre.result!; const [response, err] = await attempt(() => fetch(...pre.args)); if (err) throw err; const post = await hooks.run('afterFetch', response, url, { scope }); return post.returned ? post.result! : response; } ``` ## Registration Catches typos at runtime: ```typescript const hooks = new HookEngine() .register('beforeFetch', 'afterFetch'); hooks.add('beforeFecth', cb); // Error: Hook "beforeFecth" is not registered. // Registered hooks: beforeFetch, afterFetch ``` ## Custom Error Handler ```typescript // Error constructor const hooks = new HookEngine({ handleFail: HttpsError }); // Function that throws const hooks = new HookEngine({ handleFail: (msg, data) => { throw Boom.badRequest(msg, data); } }); ``` ## Common Patterns ### Caching with Early Return ```typescript hooks.add('beforeGet', async (url, opts, ctx) => { const [cached, err] = await attempt(() => cache.get(url)); if (err) return; // skip cache on error if (cached) return ctx.returns(cached); }); ``` ### Validation ```typescript hooks.add('validate', (user, ctx) => { if (!user.email) ctx.fail('Email required'); }); ``` ### Arg Modification ```typescript hooks.add('beforeRequest', (url, opts, ctx) => { ctx.args(url, { ...opts, headers: { ...opts.headers, 'X-Trace': traceId } }); }); ``` ### Non-Critical Hooks ```typescript hooks.add('analytics', async (event) => { const [, err] = await attempt(() => track(event)); if (err) console.warn('Analytics failed:', err); }, { ignoreOnFail: true }); ``` ## Type Parameters ```typescript new HookEngine() ``` - `Lifecycle` — Interface defining hooks (default: permissive `Record`) - `FailArgs` — Tuple for `ctx.fail()` args (default: `[string]`) Only function properties are valid hook names: ```typescript interface Doc { id: string; // Excluded — data property save(): Promise; // Available as hook } hooks.add('save', cb); // ✓ OK hooks.add('id', cb); // ✗ Type error ``` --- # @logosdx/localize Usage Patterns > **Error handling rule:** Use `attempt()` from `@logosdx/utils` for ALL async locale operations — `changeTo()`, lazy loader functions, and any I/O. Never use try-catch. Type-safe i18n with async lazy loading, ICU-lite pluralization, Intl formatting, namespace scoping, and observer-based locale events. ## Core Setup ```typescript import { LocaleManager } from '@logosdx/localize' // Define your locale shape — all leaf values are strings interface AppLocale extends LocaleManager.LocaleType { greeting: string nav: { home: string logout: string } products: { count: string // ICU plural syntax } } type LocaleCode = 'en' | 'es' | 'ja' const english: AppLocale = { greeting: 'Hello, {name}!', nav: { home: 'Home', logout: 'Logout' }, products: { count: '{count, plural, one {# product} other {# products}} in cart' } } const i18n = new LocaleManager({ current: 'en', fallback: 'en', // missing keys in current locale fall back to this locales: { en: { code: 'en', text: 'English', labels: english }, es: { code: 'es', text: 'Español', labels: spanishPartial } // DeepOptional } }) ``` ## LocaleManager Class ```typescript class LocaleManager { current: Code // mutable — reflects active locale code fallback: Code // mutable — merge base for missing keys // t() is an alias for text() — both are identical t>(key: K, values?: LocaleFormatArgs): string text>(key: K, values?: LocaleFormatArgs): string readonly intl: IntlFormatters // lazy-created, re-created on locale change readonly locales: { code: Code; text: string }[] // loaded + registered-but-unloaded on(ev: LocaleEventName, listener: LocaleListener): () => void once(ev: LocaleEventName, listener: LocaleListener): () => void off(ev: LocaleEventName, listener?: LocaleListener): void register(code: C, opts: LazyLocale): void isLoaded(code: Code): boolean async changeTo(code: Code): Promise updateLang(code: C, locale: DeepOptional): void ns(prefix: string): ScopedLocale clone(): LocaleManager } ``` ## Types ```typescript namespace LocaleManager { type LocaleType = { [K in StrOrNum]: StrOrNum | LocaleType } type LocaleReacher = PathLeaves type LocaleFormatArgs = Array | Record type ManyLocales = { [P in Code]: { code: Code; text: string; labels: Locale | DeepOptional } } interface LocaleOpts { current: Code fallback: Code locales: ManyLocales } interface LazyLocale { text: string // human-readable label for language picker loader: () => Promise // called on first changeTo() } interface LocaleEventShape { change: { code: Code } loading: { code: Code } error: { code: Code } } type LocaleEventName = keyof LocaleEventShape type LocaleListener = (data: { code: Code }) => void interface IntlFormatters { number(value: number, opts?: Intl.NumberFormatOptions): string date(value: Date | number, opts?: Intl.DateTimeFormatOptions): string relative(value: number, unit: Intl.RelativeTimeFormatUnit, opts?: Intl.RelativeTimeFormatOptions): string } } ``` ## Translation — t() / text() ```typescript // Named object substitution — {name}, {count}, ... i18n.t('greeting', { name: 'Maria' }) // "Hello, Maria!" // Positional array substitution — {0}, {1}, ... i18n.t('greeting', ['Maria']) // "Hello, Maria!" (if greeting = 'Hello, {0}!') // Nested key using dot-path — fully type-safe via PathLeaves i18n.t('nav.logout') // "Logout" // Missing key returns bracketed key string + warns in non-production i18n.t('does.not.exist' as any) // "[does.not.exist]" + console.warn ``` ## ICU-lite Pluralization ```typescript // ICU plural syntax in locale labels: // {varName, plural, zero {…} one {# item} other {# items}} // # is replaced by the numeric count value const labels = { cart: '{count, plural, one {# item} other {# items}} in cart', inbox: '{unread, plural, zero {No messages} one {# message} other {# messages}}' } i18n.t('cart', { count: 0 }) // "0 items in cart" (falls through to 'other') i18n.t('cart', { count: 1 }) // "1 item in cart" i18n.t('cart', { count: 5 }) // "5 items in cart" i18n.t('inbox', { unread: 0 }) // "No messages" (explicit 'zero' category) // Plural categories resolved via Intl.PluralRules — locale-aware // Supported: zero | one | two | few | many | other // 'zero' exact-counts override Intl category; 'other' is final fallback ``` ## Intl Formatting > **Always use `manager.intl.date()`, `manager.intl.number()`, and `manager.intl.relative()` for locale-aware formatting.** Do NOT create raw `Intl.DateTimeFormat`, `Intl.NumberFormat`, or `Intl.RelativeTimeFormat` instances directly — the manager's intl formatters are cached, locale-aware, and automatically re-created when the locale changes. ```typescript // Intl formatters — lazy-created, re-created on locale change // All Intl formatters are cached by locale + serialized options // Access via manager.intl (or i18n.intl) // // CORRECT: i18n.intl.date(new Date(), { dateStyle: 'long' }) // INCORRECT: new Intl.DateTimeFormat(locale, opts).format(date) const { intl } = i18n; // Date formatting intl.date(new Date(), { dateStyle: 'long' }); // "March 15, 2026" / "15 mars 2026" intl.date(new Date(), { dateStyle: 'full' }); // full locale-aware date intl.date(new Date()); // "3/15/2026" — default short format intl.date(Date.now()); // accepts number or Date // Number formatting intl.number(1234.56); // "1,234.56" / "1.234,56" intl.number(1234.56, { style: 'currency', currency: 'USD' }); // "$1,234.56" intl.number(0.42, { style: 'percent' }); // "42%" // Relative time intl.relative(-3, 'day'); // "3 days ago" / "hace 3 días" intl.relative(2, 'hour'); // "in 2 hours" intl.relative(1, 'month', { numeric: 'auto' }); // "next month" ``` ## Async Lazy Loading ```typescript import { attempt } from '@logosdx/utils' // Register lazy loaders — called on first changeTo() // The loader function returns the locale labels (must be async) i18n.register('ja', { text: '日本語', loader: async () => { const [mod, err] = await attempt(() => import('./locales/ja.json')); if (err) throw err; return mod.default; } }) i18n.register('es', { text: 'Español', loader: async () => { const [mod, err] = await attempt(() => import('./locales/es.json')); if (err) throw err; return mod.default; } }) // Check load status i18n.isLoaded('en') // true — provided in constructor i18n.isLoaded('ja') // false — registered but not fetched yet // Switch locale — triggers lazy load // ALWAYS wrap changeTo() with attempt() const [, err] = await attempt(() => i18n.changeTo('ja')) if (err) console.error('Failed to load locale:', err) i18n.isLoaded('ja') // true now // Race guard: concurrent changeTo() calls share one loader execution const [, raceErr] = await attempt(() => Promise.all([ i18n.changeTo('de'), i18n.changeTo('de') ])) // loader ran exactly once if (raceErr) console.error('Failed:', raceErr) // Unknown code with no registration: warns + falls back to fallback locale const [, unknownErr] = await attempt(() => i18n.changeTo('xx' as LocaleCode)) if (unknownErr) console.error('Unknown locale:', unknownErr) ``` ## Lifecycle Events ```typescript import { attempt } from '@logosdx/utils' // Event sequence for a lazy-loaded locale: // 1. 'loading' fires when loader starts // 2. 'change' fires on success // 3. 'error' fires on failure (promise also rejects) // on() returns a cleanup function const stopLoading = i18n.on('loading', ({ code }) => showSpinner(`Loading ${code}...`)) const stopChange = i18n.on('change', ({ code }) => console.log('Active locale:', code)) const stopError = i18n.on('error', ({ code }) => showToast(`Failed: ${code}`)) const [, changeErr] = await attempt(() => i18n.changeTo('ja')) if (changeErr) console.error('Locale change failed:', changeErr) stopLoading() // cleanup stopChange() stopError() // once() for one-shot reactions i18n.once('change', ({ code }) => analytics.track('locale_changed', { code })) // off() to remove by reference or all i18n.on('change', handler) i18n.off('change', handler) // remove specific i18n.off('change') // remove all change listeners ``` ## Namespace Scoping ```typescript // ns() creates a ScopedLocale that prepends a prefix to all t() keys const authT = i18n.ns('auth') authT.t('login.title') // resolves to i18n.t('auth.login.title') authT.t('errors.invalid') // resolves to i18n.t('auth.errors.invalid') // Scopes nest const loginT = authT.ns('login') loginT.t('title') // resolves to i18n.t('auth.login.title') // ScopedLocale passes intl and format values through to parent authT.intl.number(9.99, { style: 'currency', currency: 'USD' }) authT.t('welcome', { name: 'Maria' }) ``` ```typescript class ScopedLocale { t(key: string, values?: LocaleManager.LocaleFormatArgs): string ns(subPrefix: string): ScopedLocale readonly intl: LocaleManager.IntlFormatters } ``` ## Runtime Label Updates ```typescript // updateLang() merges new labels into an existing locale // If the target is the current locale, emits 'change' and resets intl cache i18n.updateLang('en', { nav: { home: 'Dashboard' } // partial update — other keys untouched }) // Use case: override specific strings from a server config const [overrides, err] = await attempt(() => fetch('/api/locale-overrides/en').then(r => r.json())) if (!err) i18n.updateLang('en', overrides) ``` ## Cloning ```typescript // clone() creates an independent LocaleManager with the same config // Useful for isolated contexts (email templating, SSR) const serverI18n = i18n.clone() const [, cloneErr] = await attempt(() => serverI18n.changeTo('ja')) // doesn't affect the original if (cloneErr) console.error('Clone locale change failed:', cloneErr) ``` ## Standalone Helpers ```typescript import { format, getMessage, reachIn } from '@logosdx/localize' // reachIn — deep path accessor with default fallback reachIn(labels, 'nav.home', '[nav.home]') // "Home" — or "[nav.home]" if key is missing // format — replace {key} / {0} placeholders in a string format('Hello, {name}!', { name: 'Maria' }) // "Hello, Maria!" format('Items: {0}, {1}', ['apples', 'bananas']) // "Items: apples, bananas" // getMessage — full pipeline: reachIn + parsePlural + format // This is what LocaleManager.t() uses internally getMessage(labels, 'products.count', { count: 3 }, 'en') // "3 products in cart" ``` ```typescript import { parsePlural } from '@logosdx/localize' // ICU-lite plural resolver — useful outside of LocaleManager parsePlural('{count, plural, one {# thing} other {# things}}', { count: 5 }, 'en') // "5 things" ``` ```typescript import { createIntlFormatters } from '@logosdx/localize' // Cached IntlFormatters factory — same as LocaleManager.intl uses internally const fmt = createIntlFormatters('fr') fmt.number(1234.56) // "1 234,56" fmt.date(new Date(), { dateStyle: 'full' }) // "mercredi 18 février 2026" fmt.relative(-1, 'day') // "il y a 1 jour" ``` ## Type Extractor CLI ```bash # Generate TypeScript types from locale JSON files npx logosdx-locale extract --dir ./i18n --out ./src/locale-keys.ts # Options # --dir Directory containing locale JSON files (required) # --out Output path for generated .ts file (required) # --locale Which JSON to use as type source (default: 'en') # --name Interface name to generate (default: 'AppLocale') # --watch Re-generate on file changes ``` ```typescript // Programmatic extractor API import { scanDirectory, generateOutput } from '@logosdx/localize/extractor' import { writeFile } from 'node:fs/promises' import { attempt } from '@logosdx/utils' const scan = scanDirectory('./i18n', 'en') const source = generateOutput(scan, 'AppLocale') const [, writeErr] = await attempt(() => writeFile('./src/locale-keys.ts', source)) if (writeErr) console.error('Failed to write locale types:', writeErr) ``` ```typescript interface ScanResult { rootShape: Record | null // from flat en.json namespaces: Record> // from subdirectories codes: string[] // all locale codes discovered } ``` ## React Integration ```typescript import { LocaleManager } from '@logosdx/localize' import { createLocalizeContext } from '@logosdx/react' const i18n = new LocaleManager({ current: 'en', fallback: 'en', locales }) // Returns [Provider, useHook] tuple export const [AppLocaleProvider, useAppLocale] = createLocalizeContext(i18n) ``` ```tsx ``` ```typescript // In any child component const { t, locale, changeTo, locales, instance } = useAppLocale() t('home.greeting', { name: 'World' }) // type-safe translation locale // current code, triggers re-render on change changeTo('es') // switch locale locales // [{ code: 'en', text: 'English' }, ...] instance // raw LocaleManager escape hatch ``` ## Package Exports ```typescript // Main entry import { LocaleManager, ScopedLocale, createIntlFormatters, parsePlural, format, getMessage, reachIn, } from '@logosdx/localize' // Extractor subpath (Node.js only) import { scanDirectory, generateOutput } from '@logosdx/localize/extractor' // CLI binary // npx logosdx-locale extract ... ``` --- # @logosdx/observer Usage Patterns Advanced type-safe event system with async iteration, queuing, and component observation. > Use `attempt()`/`attemptSync()` from `@logosdx/utils` for any error-prone operation inside event handlers and queue processors. Never use try-catch. ## Core Types ```ts // Define your event shape interface AppEvents { 'user:login': { userId: string; timestamp: number } 'user:logout': { userId: string } 'data:update': any[] 'system:error': Error } // Generic types for constraints type Events = keyof Shape type EventData> = Shape[E] type EventCallback = (data: T, info?: { event: string, listener: Function }) => void type Cleanup = () => void type FuncName = 'on' | 'once' | 'off' | 'emit' | 'cleanup' type ListenerOptions = { signal?: AbortSignal } type ObserveOptions = { signal?: AbortSignal } type SpyAction = { event: keyof Ev | RegExp | '*' listener?: Function | null data?: unknown fn: FuncName context: ObserverEngine } ``` ## ObserverEngine - Core Event Emitter ```ts import { ObserverEngine } from '@logosdx/observer' // Create typed observer const controller = new AbortController() const observer = new ObserverEngine({ name: 'app-events', spy: (action) => console.log(action.fn, action.event), emitValidator: (event, data) => { /* validate data */ }, signal: controller.signal // aborts all listeners when signal fires }) // Instance properties observer.name // 'app-events' (non-enumerable, defaults to random string) // Set/replace spy after construction observer.spy(newSpyFn) // throws if spy already set observer.spy(newSpyFn, true) // force replace existing spy // Basic event patterns observer.on('user:login', (data) => { // data is { userId: string; timestamp: number } }) observer.once('user:logout', (data) => { // fires once, data is { userId: string } }) observer.emit('user:login', { userId: '123', timestamp: Date.now() }) // Cleanup patterns const cleanup = observer.on('data:update', callback) cleanup() // remove listener observer.off('user:login', specificCallback) observer.off('user:login') // remove all listeners observer.clear() // remove all listeners // AbortSignal cleanup — all methods accept { signal } const ac = new AbortController() observer.on('user:login', handler, { signal: ac.signal }) observer.once('user:logout', handler, { signal: ac.signal }) const gen = observer.on('user:login', { signal: ac.signal }) ac.abort() // removes all listeners at once // Works with AbortSignal.timeout() observer.on('heartbeat', handler, { signal: AbortSignal.timeout(30_000) }) ``` ## Regex Event Matching ```ts // Listen to patterns observer.on(/^user:/, ({ event, data }) => { // Matches 'user:login', 'user:logout', etc. // event: string, data: any }) observer.on(/error$/, ({ event, data }) => { // Matches 'system:error', 'validation:error', etc. }) // Emit to regex (matches all matching listeners) observer.emit(/^user:/, { type: 'broadcast' }) ``` ## EventGenerator - Async Iteration Events are buffered internally in FIFO order. No events are dropped even if they arrive faster than the consumer iterates. ```ts // Generator from on() without callback const userEvents = observer.on('user:login') // Async iteration (events buffered while doing async work between iterations) for await (const loginData of userEvents) { console.log('User logged in:', loginData.userId) await saveToDatabase(loginData) // buffered events won't be lost here if (shouldStop) { userEvents.cleanup() break } } // Manual iteration const loginData = await userEvents.next() console.log(loginData) // { userId: string; timestamp: number } // Events emitted before next() is called are also buffered observer.emit('user:login', first) observer.emit('user:login', second) await userEvents.next() // first await userEvents.next() // second // Generator properties userEvents.lastValue // last received value userEvents.done // boolean userEvents.emit(data) // emit to underlying observer userEvents.cleanup() // stop listening // Regex generators const allUserEvents = observer.on(/^user:/) for await (const { event, data } of allUserEvents) { console.log(`${event}:`, data) } ``` ## Promise-Based once() ```ts // Promise without callback const loginPromise = observer.once('user:login') const userData = await loginPromise loginPromise.cleanup() // optional cleanup // Promise with timeout const userData = await Promise.race([ observer.once('user:login'), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000) ) ]) ``` ## EventQueue - Processing Pipeline ```ts // Create queue from observer const queue = observer.queue('data:process', async (data) => { // Process each data item - can return Promise or any (MaybePromise) await processData(data) return result // optional return value }, { name: 'data-processor', concurrency: 3, type: 'fifo', // or 'lifo' // Rate limiting rateLimitCapacity: 100, rateLimitIntervalMs: 1000, // Timing pollIntervalMs: 100, processIntervalMs: 50, taskTimeoutMs: 30000, jitterFactor: 0.1, // Limits maxQueueSize: 1000, autoStart: true, debug: 'verbose' }) // Queue processors should use attempt() for error-prone work const queue = bus.queue('process', async (item) => { const [result, err] = await attempt(() => handleItem(item)); if (err) console.warn('Processing failed:', err); }, { concurrency: 1, taskTimeoutMs: 5000 }); // Queue lifecycle queue.start() queue.pause() queue.resume() queue.stop() queue.shutdown() // drain and stop queue.shutdown(true) // force stop // Add items (returns boolean: true if added, false if rejected) queue.add(data, priority) // higher priority = processed first observer.emit('data:process', data) // also adds to queue // Queue operations queue.flush(10) // process 10 items queue.purge() // clear all items await queue.shutdown() // drain and stop // Queue state queue.isRunning queue.isPaused queue.isStopped queue.isDraining queue.isIdle queue.isWaiting queue.pending // items in queue queue.state // 'running' | 'paused' | 'stopped' | 'draining' ``` ## Queue Events ```ts // Lifecycle events queue.on('start', () => {}) queue.on('started', () => {}) queue.on('stopped', () => {}) queue.on('paused', () => {}) queue.on('resumed', () => {}) // Processing events queue.on('added', (item) => {}) // item added to queue queue.on('processing', (item) => {}) // item being processed queue.on('success', (item) => {}) // item processed successfully queue.on('error', (item) => {}) // item processing failed queue.on('timeout', (item) => {}) // item processing timed out queue.on('rejected', (item) => {}) // item rejected (queue full, etc.) // Queue state events queue.on('empty', () => {}) // queue became empty queue.on('idle', () => {}) // queue idle (no pending items) queue.on('rate-limited', (item) => {}) // rate limit hit queue.on('drain', ({ pending }) => {}) // starting to drain queue.on('drained', ({ drained }) => {}) // finished draining queue.on('flush', ({ pending }) => {}) // starting flush queue.on('flushed', ({ flushed }) => {}) // finished flushing queue.on('purged', ({ count }) => {}) // items purged queue.on('shutdown', ({ force }) => {}) // queue shutdown queue.on('cleanup', () => {}) // queue cleaned up // Promise-based event waiting const success = await queue.once('success') const errorItem = await queue.once('error') ``` ## Component Observation ```ts // Extend any object with event capabilities const modal = { isOpen: false } const enhancedModal = observer.observe(modal) // accepts optional { signal } for auto-cleanup // Now modal has event methods (returns C & Component & { cleanup: () => void }) enhancedModal.on('open', () => enhancedModal.isOpen = true) enhancedModal.on('close', () => enhancedModal.isOpen = false) enhancedModal.emit('open') // Two cleanup methods available enhancedModal.clear() // clears all listeners for this component enhancedModal.cleanup() // cleans up and removes internal tracking // Component types from the package type Component = { on: ObserverEngine['on'] once: ObserverEngine['once'] emit: ObserverEngine['emit'] off: ObserverEngine['off'] } type Child = C & Component & { cleanup: () => void } ``` ## Listener Transfer & Copy ```ts // Transfer: move listeners from source to target (source loses them) ObserverEngine.transfer(source, target) // Copy: duplicate listeners to target (source keeps them) ObserverEngine.copy(source, target) // Opt-in filter: only transfer specific events ObserverEngine.transfer(source, target, { filter: ['analytics', /^user:/] }) // Opt-out exclude: transfer everything except these ObserverEngine.copy(source, target, { exclude: [/^internal:/] }) // Compose: filter first, then exclude from that set ObserverEngine.transfer(source, target, { filter: [/^fetch:/], exclude: ['fetch:debug'] }) // Stacking: target's existing listeners are untouched target.on('analytics', existingHandler) ObserverEngine.transfer(source, target) // existingHandler still works // TransferOptions type type TransferOptions = { filter?: (Events | RegExp)[] // opt-in whitelist (applied first) exclude?: (Events | RegExp)[] // opt-out blacklist (applied second) } ``` ## Advanced Features ```ts // Debugging and tracing observer.debug(true) // enable stack traces observer.debug(false) // disable // Spy on all operations const spy = (action) => { console.log(`${action.fn}(${action.event})`, action.data) } const observer = new ObserverEngine({ spy }) // Emit validation const emitValidator = (event, data, context) => { if (!isValid(data)) throw new Error('Invalid data') } const observer = new ObserverEngine({ emitValidator }) // Inspector methods observer.$has('event') // check if event has listeners observer.$has(/pattern/) // check if regex has matches observer.$facts() // listener counts and state observer.$internals() // internal maps (debugging only, returns cloned copies) // Queue exported types import { QueueState, QueueRejectionReason, InternalQueueEvent } from '@logosdx/observer' // QueueState: 'running' | 'paused' | 'stopped' | 'draining' // QueueRejectionReason: 'Queue is full' | 'Queue is not running' // InternalQueueEvent: wrapper with .data property, used to mark queue events // Error handling import { EventError, isEventError } from '@logosdx/observer' // EventErrors are thrown by EventGenerator methods const generator = observer.on('user:login') // no callback = EventGenerator try { generator.cleanup() // Mark as destroyed await generator.next() // This will throw EventError } catch (err) { if (isEventError(err)) { console.log(err.event, err.data, err.listener) } } // EmitValidator can also throw (but not EventError) const observer = new ObserverEngine({ emitValidator: (event, data) => { if (event === 'restricted') throw new Error('Access denied') } }) try { observer.emit('restricted', {}) } catch (err) { console.log('Validation failed:', err.message) } ``` ## ObserverRelay - Cross-Boundary Event Bridge Abstract class that bridges ObserverEngine events across network/process boundaries via two internal engines (pub and sub). Subclasses implement `send()` for the transport and call `receive()` when messages arrive. ```ts import { ObserverRelay, type RelayEvents, type ObserverRelayOptions } from '@logosdx/observer' // Event shape (same for both pub and sub sides) interface OrderEvents { 'order:placed': { id: string; total: number } 'order:shipped': { id: string; trackingNo: string } } // Transport context (only appears on the receiving side) interface RedisCtx { ack(): void nack(): void } // Subclass implements send() and calls receive() class RedisRelay extends ObserverRelay { #redis: RedisClient constructor(redis: RedisClient, channel: string) { super({ name: 'redis' }) this.#redis = redis redis.subscribe(channel, (msg) => { const { event, data } = JSON.parse(msg.body) this.receive(event, data, { ack: () => msg.ack(), nack: () => msg.nack(), }) }) } protected send(event: string, data: unknown) { const [, err] = attemptSync(() => this.#redis.publish('orders', JSON.stringify({ event, data }))) if (err) console.warn('Relay send failed:', err) } } // Usage — emit is pure data, on receives { data, ctx } const relay = new RedisRelay(redisClient, 'orders') relay.emit('order:placed', { id: '123', total: 99.99 }) relay.on('order:placed', ({ data, ctx }) => { const [, err] = attemptSync(() => processOrder(data)) if (err) { ctx.nack(); return } ctx.ack() }) // Queue — concurrency-controlled inbound processing const queue = relay.queue('order:placed', async ({ data, ctx }) => { const [, err] = await attempt(() => fulfillOrder(data)) if (err) { ctx.nack(); return } ctx.ack() }, { name: 'order-processing', concurrency: 5 }) // Observability — spy, $has, $facts, $internals all return { pub, sub } relay.spy((action) => telemetry.track(action)) relay.$facts() // → { pub: { listeners: [...] }, sub: { listeners: [...] } } // Lifecycle relay.isShutdown // false relay.shutdown() // clears both engines, permanently inoperable ``` ### RelayEvents Type Wraps event data with transport context for the sub engine: ```ts type RelayEvents = { [K in keyof TEvents]: { data: TEvents[K]; ctx: TCtx } } ``` ### Public API | Method | Delegates to | Notes | |--------|-------------|-------| | `emit` | `#pub.emit` | Pure `TEvents` data | | `on` | `#sub.on` | Receives `{ data, ctx }` | | `once` | `#sub.once` | Receives `{ data, ctx }` | | `off` | `#sub.off` | | | `queue` | `#sub.queue` | Processes inbound messages | | `spy()` | both engines | Attached to both with `force: true` | | `$has()` | both engines | Returns `{ pub: boolean, sub: boolean }` | | `$facts()` | both engines | Returns `{ pub: Facts, sub: Facts }` | | `$internals()` | both engines | Returns `{ pub: Internals, sub: Internals }` | | `shutdown()` | both `.clear()` | Permanently inoperable after call | | `isShutdown` | relay state | Getter returning `boolean` | ### Constructor Options ```ts interface ObserverRelayOptions { name?: string // auto-suffixed to name:pub and name:sub spy?: Spy // passed to both engines signal?: AbortSignal // passed to both engines + sets #isShutdown emitValidator?: { pub?: EmitValidator // validates outbound data sub?: EmitValidator // validates inbound data } } ``` --- # @logosdx/react Usage Patterns React bindings for LogosDX engines. Factory-pattern context providers and hooks with full type inference. ## Core Pattern Every factory takes an engine instance and returns a `[Provider, useHook]` tuple: ```typescript import { ObserverEngine } from '@logosdx/observer' import { FetchEngine } from '@logosdx/fetch' import { StorageAdapter } from '@logosdx/storage' import { LocaleManager } from '@logosdx/localize' import { createObserverContext, createFetchContext, createStorageContext, createLocalizeContext, createStateMachineContext, } from '@logosdx/react' // Create instances const observer = new ObserverEngine() const api = new FetchEngine({ baseUrl: '/api' }) const storage = new StorageAdapter({ driver: new WebStorageDriver(localStorage), prefix: 'app', }) const i18n = new LocaleManager({ current: 'en', fallback: 'en', locales }) const machine = new StateMachine({ initial: 'idle', context: {}, transitions: {} }) // Create context + hook pairs — rename freely export const [AppObserver, useAppObserver] = createObserverContext(observer) export const [ApiFetch, useApiFetch] = createFetchContext(api) export const [AppStorage, useAppStorage] = createStorageContext(storage) export const [AppLocale, useAppLocale] = createLocalizeContext(i18n) export const [GameProvider, useGame] = createStateMachineContext(machine) ``` Compose providers into a single wrapper: ```typescript import { composeProviders } from '@logosdx/react' // First entry = outermost wrapper export const Providers = composeProviders( AppObserver, ApiFetch, AppStorage, AppLocale, GameProvider, ) ``` ```tsx ``` Providers that need props beyond children use `[Provider, props]` tuples: ```typescript const Providers = composeProviders( AppObserver, [ThemeProvider, { theme: 'dark' }], ApiFetch, ) ``` ## Observer Hook ```typescript const { on, once, oncePromise, emit, emitFactory, instance } = useAppObserver() // Subscribe — auto-cleans on unmount, re-subscribes on callback change const handler = useCallback((data) => console.log(data.userId), []) on('user.login', handler) // One-shot with callback once('app.init', useCallback((cfg) => bootstrap(cfg), [])) // One-shot reactive tuple — no callback needed const [waiting, data, cancel] = oncePromise('notification') // waiting: boolean, data: Shape[E] | null, cancel: () => void // Emit directly (stable, bound to engine) emit('user.logout', { userId: '123' }) // Memoized emitter for a specific event const logout = emitFactory('user.logout') //