UNPKG

@wooksjs/event-core

Version:
523 lines (513 loc) 16.5 kB
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); let node_async_hooks = require("node:async_hooks"); let node_crypto = require("node:crypto"); //#region packages/event-core/src/key.ts let nextId = 0; /** * Creates a typed, writable context slot. Use `ctx.set(k, value)` to store * and `ctx.get(k)` to retrieve. Throws if read before being set. * * @param name - Debug label (shown in error messages, not used for lookup) * * @example * ```ts * const userIdKey = key<string>('userId') * ctx.set(userIdKey, '123') * ctx.get(userIdKey) // '123' * ``` */ function key(name) { return { _id: nextId++, _name: name }; } /** * Creates a lazily-computed, read-only context slot. The factory runs once * per `EventContext` on first `ctx.get(slot)` call; the result is cached * for the context lifetime. Errors are also cached and re-thrown. * * @param fn - Factory receiving the current `EventContext`, returning the value to cache * * @example * ```ts * const parsedUrl = cached((ctx) => new URL(ctx.get(rawUrlKey))) * // first call computes, subsequent calls return cached result * ctx.get(parsedUrl) * ``` */ function cached(fn) { return { _id: nextId++, _name: `cached:${nextId}`, _fn: fn }; } /** @internal Returns true if the accessor is a `Cached` slot (has a factory function). */ function isCached(accessor) { return "_fn" in accessor; } //#endregion //#region packages/event-core/src/keys.ts /** Context key for route parameters. Set by adapters after route matching. */ const routeParamsKey = key("routeParams"); /** Context key for the event type name (e.g. `'http'`, `'cli'`). Set by `ctx.seed()`. */ const eventTypeKey = key("eventType"); //#endregion //#region packages/event-core/src/context.ts const COMPUTING = Symbol("computing"); const UNDEFINED = Symbol("undefined"); var CachedError = class { constructor(error) { this.error = error; } }; /** * Per-event container for typed slots, propagated via `AsyncLocalStorage`. * Composables read and write data through `get`/`set` using typed `Key` or `Cached` accessors. * * Supports a parent chain: when a slot is not found locally, `get()` traverses * parent contexts. `set()` writes to the nearest context that already holds the slot, * or locally if the slot is new. * * Typically created by adapters (HTTP, CLI, etc.) — application code * interacts with it indirectly through composables. * * @example * ```ts * const ctx = new EventContext({ logger }) * ctx.set(userIdKey, '123') * ctx.get(userIdKey) // '123' * ``` */ var EventContext = class { constructor(options) { this.slots = /* @__PURE__ */ new Map(); this.logger = options.logger; this.parent = options.parent; } /** * Controls whether `get()`, `has()`, and `set()` traverse the parent chain * for a given slot. Override in subclasses to isolate specific slots — * returning `false` forces local computation/storage, preventing inheritance. * * @param _id - The numeric slot identifier (`accessor._id`) * @returns `true` to allow parent traversal (default), `false` to block it */ _shouldTraverseParent(_id) { return true; } /** * Reads a value from a typed slot. * - For `Key<T>`: returns the previously `set` value, checking parent chain if not found locally. * - For `Cached<T>`: returns a cached result from this context or any parent. If not found * anywhere, runs the factory on first access, caches locally, and returns the result. * Throws on circular dependencies. Errors are cached and re-thrown on subsequent access. */ get(accessor) { const id = accessor._id; let val = this.slots.get(id); if (val === void 0 && this.parent && this._shouldTraverseParent(id)) val = this.parent._findSlot(id); if (val !== void 0) { if (val === COMPUTING) throw new Error(`Circular dependency detected for "${accessor._name}"`); if (val instanceof CachedError) throw val.error; if (val === UNDEFINED) return; return val; } if (isCached(accessor)) { this.slots.set(id, COMPUTING); try { const result = accessor._fn(this); this.slots.set(id, result === void 0 ? UNDEFINED : result); return result; } catch (error) { this.slots.set(id, new CachedError(error)); throw error; } } throw new Error(`Key "${accessor._name}" is not set`); } /** * Writes a value to a typed slot. If the slot already exists somewhere in the * parent chain, the value is written there. Otherwise, it is written locally. */ set(key, value) { const encoded = value === void 0 ? UNDEFINED : value; if (this.slots.has(key._id)) { this.slots.set(key._id, encoded); return; } if (this.parent && this._shouldTraverseParent(key._id) && this.parent._setIfExists(key._id, encoded)) return; this.slots.set(key._id, encoded); } /** * Returns `true` if the slot has been set or computed in this context or any parent. */ has(accessor) { const val = this.slots.get(accessor._id); if (val !== void 0 && val !== COMPUTING) return true; if (this.parent && this._shouldTraverseParent(accessor._id)) return this.parent.has(accessor); return false; } /** * Reads a value from a typed slot in this context only, ignoring parents. * Same semantics as `get()` but without parent chain traversal. */ getOwn(accessor) { const id = accessor._id; const val = this.slots.get(id); if (val !== void 0) { if (val === COMPUTING) throw new Error(`Circular dependency detected for "${accessor._name}"`); if (val instanceof CachedError) throw val.error; if (val === UNDEFINED) return; return val; } if (isCached(accessor)) { this.slots.set(id, COMPUTING); try { const result = accessor._fn(this); this.slots.set(id, result === void 0 ? UNDEFINED : result); return result; } catch (error) { this.slots.set(id, new CachedError(error)); throw error; } } throw new Error(`Key "${accessor._name}" is not set`); } /** * Writes a value to a typed slot in this context only, ignoring parents. */ setOwn(key, value) { this.slots.set(key._id, value === void 0 ? UNDEFINED : value); } /** * Returns `true` if the slot has been set or computed in this context only. */ hasOwn(accessor) { const val = this.slots.get(accessor._id); return val !== void 0 && val !== COMPUTING; } seed(kind, seeds, fn) { const entries = kind._entries; for (const [prop, k] of entries) { const v = seeds[prop]; this.slots.set(k._id, v === void 0 ? UNDEFINED : v); } this.setOwn(eventTypeKey, kind.name); if (fn) return fn(); } /** Walk the parent chain looking for a set slot. Returns `undefined` if not found. */ _findSlot(id) { const val = this.slots.get(id); if (val !== void 0) return val; return this.parent?._findSlot(id); } /** Set value in the first context in the chain that has this slot. Returns true if found. */ _setIfExists(id, encoded) { if (this.slots.has(id)) { this.slots.set(id, encoded); return true; } return this.parent?._setIfExists(id, encoded) ?? false; } }; //#endregion //#region packages/event-core/src/context-injector.ts /** * No-op base class for observability integration. Subclass and override * `with()` / `hook()` to add tracing, metrics, or logging around event * lifecycle points. * * The default implementation simply calls the callback with no overhead. * Replace via `replaceContextInjector()` to enable instrumentation. */ var ContextInjector = class { with(name, attributes, cb) { return (typeof attributes === "function" ? attributes : cb)(); } /** * Hook called by adapters at specific lifecycle points (e.g., after route lookup). * Default implementation is a no-op — override for observability. */ hook(_method, _name, _route) {} }; let ci = null; /** * Returns the current `ContextInjector` instance, or `null` if none has been installed. * Used internally by adapters to wrap lifecycle events. */ function getContextInjector() { return ci; } /** * Replaces the global `ContextInjector` with a custom implementation. * Use this to integrate OpenTelemetry or other observability tools. * * @param newCi - Custom `ContextInjector` subclass instance * * @example * ```ts * class OtelInjector extends ContextInjector<string> { * with<T>(name: string, attrs: Record<string, any>, cb: () => T): T { * return tracer.startActiveSpan(name, (span) => { * span.setAttributes(attrs) * try { return cb() } finally { span.end() } * }) * } * } * replaceContextInjector(new OtelInjector()) * ``` */ function replaceContextInjector(newCi) { ci = newCi; } /** * Resets the global `ContextInjector` back to `null` (no-op default). * Useful for tests or when disabling instrumentation. */ function resetContextInjector() { ci = null; } //#endregion //#region packages/event-core/src/storage.ts const STORAGE_KEY = Symbol.for("wooks.core.asyncStorage"); const VERSION_KEY = Symbol.for("wooks.core.asyncStorage.version"); const CURRENT_VERSION = "0.7.21"; const _g = globalThis; if (_g[STORAGE_KEY]) { if (_g[VERSION_KEY] !== CURRENT_VERSION) throw new Error(`[wooks] Incompatible versions of @wooksjs/event-core detected: existing v${_g[VERSION_KEY]}, loading v${CURRENT_VERSION}. All packages must use the same @wooksjs/event-core version.`); } else { _g[STORAGE_KEY] = new node_async_hooks.AsyncLocalStorage(); _g[VERSION_KEY] = CURRENT_VERSION; } const storage = _g[STORAGE_KEY]; /** * Runs a callback with the given `EventContext` as the active context. * All composables and `current()` calls inside `fn` will resolve to `ctx`. * * @param ctx - The event context to make active * @param fn - Callback to execute within the context scope * @returns The return value of `fn` * * @example * ```ts * const ctx = new EventContext({ logger }) * run(ctx, () => { * // current() returns ctx here * const logger = useLogger() * }) * ``` */ function run(ctx, fn) { return storage.run(ctx, fn); } /** * Returns the active `EventContext` for the current async scope. * Throws if called outside an event context (e.g., at module level). * * All composables use this internally. Prefer composables over direct `current()` access. * * @throws Error if no active event context exists */ function current() { const ctx = storage.getStore(); if (!ctx) throw new Error("[Wooks] No active event context"); return ctx; } /** * Returns the active `EventContext`, or `undefined` if none is active. * Use this when context availability is uncertain (e.g., in code that may * run both inside and outside an event handler). */ function tryGetCurrent() { return storage.getStore(); } function useLogger(topicOrCtx, maybeCtx) { const logger = ((typeof topicOrCtx === "string" ? maybeCtx : topicOrCtx) ?? current()).logger; if (typeof topicOrCtx === "string" && logger.createTopic) return logger.createTopic(topicOrCtx); return logger; } function createEventContext(options, kindOrFn, seedsOrUndefined, maybeFn) { const ctx = new EventContext(options); if (typeof kindOrFn === "function") return run(ctx, kindOrFn); return run(ctx, () => { ctx.seed(kindOrFn, seedsOrUndefined); const ci = getContextInjector(); return ci ? ci.with("Event:start", { eventType: kindOrFn.name }, maybeFn) : maybeFn(); }); } //#endregion //#region packages/event-core/src/cached-by.ts /** * Creates a parameterized cached computation. Maintains a `Map<K, V>` per * event context — one cached result per unique key argument. * * @param fn - Factory receiving the lookup key and `EventContext`, returning the value to cache * @returns A function `(key: K, ctx?: EventContext) => V` that computes on first call per key * * @example * ```ts * const parseCookie = cachedBy((name: string, ctx) => { * const raw = ctx.get(cookieHeaderKey) * return parseSingleCookie(raw, name) * }) * * parseCookie('session') // computed and cached for 'session' * parseCookie('theme') // computed and cached for 'theme' * parseCookie('session') // returns cached result * ``` */ function cachedBy(fn) { const mapSlot = cached(() => /* @__PURE__ */ new Map()); return (k, ctx) => { const c = ctx ?? current(); const map = c.get(mapSlot); if (!map.has(k)) map.set(k, fn(k, c)); return map.get(k); }; } //#endregion //#region packages/event-core/src/kind.ts /** * Type-level marker used inside `defineEventKind` schemas. Each `slot<T>()` * becomes a typed `Key<T>` on the resulting `EventKind`. Has no runtime behavior. * * @example * ```ts * const httpKind = defineEventKind('http', { * req: slot<IncomingMessage>(), * response: slot<HttpResponse>(), * }) * ``` */ function slot() { return {}; } /** * Declares a named event kind with typed seed slots. The returned object * contains `keys` — typed accessors for reading seed values from context — * and is passed to `ctx.seed(kind, seeds)` or `createEventContext()`. * * @param name - Unique event kind name (e.g. `'http'`, `'cli'`, `'workflow'`) * @param schema - Object mapping slot names to `slot<T>()` markers * @returns An `EventKind` with typed `keys` for context access * * @example * ```ts * const httpKind = defineEventKind('http', { * req: slot<IncomingMessage>(), * response: slot<HttpResponse>(), * }) * * // Access typed seed values: * const req = ctx.get(httpKind.keys.req) // IncomingMessage * ``` */ function defineEventKind(name, schema) { const keys = {}; const _entries = []; for (const prop of Object.keys(schema)) { const k = key(`${name}.${prop}`); keys[prop] = k; _entries.push([prop, k]); } return { name, keys, _entries }; } //#endregion //#region packages/event-core/src/wook.ts /** * Creates a composable with per-event caching. The factory runs once per * `EventContext`; subsequent calls within the same event return the cached result. * * This is the recommended way to build composables in Wooks. All built-in * composables (`useRequest`, `useResponse`, `useCookies`, etc.) are created with `defineWook`. * * @param factory - Receives the `EventContext` and returns the composable's public API * @returns A composable function with an exposed `_slot` for isolation * * @example * ```ts * export const useCurrentUser = defineWook((ctx) => { * const { basicCredentials } = useAuthorization(ctx) * const username = basicCredentials()?.username * return { * username, * profile: async () => username ? await db.findUser(username) : null, * } * }) * * // In a handler — factory runs once, cached for the request: * const { username, profile } = useCurrentUser() * ``` */ function defineWook(factory) { const _slot = cached(factory); return Object.assign(((ctx) => (ctx ?? current()).get(_slot)), { _slot }); } //#endregion //#region packages/event-core/src/composables.ts const eventIdSlot = cached(() => (0, node_crypto.randomUUID)()); /** * Returns the route parameters for the current event. Works with HTTP * routes, CLI commands, workflow steps — any adapter that sets `routeParamsKey`. * * @param ctx - Optional explicit context (defaults to `current()`) * @returns Object with `params` (the full params record) and `get(name)` for typed access * * @example * ```ts * app.get('/users/:id', () => { * const { params, get } = useRouteParams<{ id: string }>() * console.log(get('id')) // typed as string * }) * ``` */ function useRouteParams(ctx) { const params = (ctx ?? current()).get(routeParamsKey); return { params, get: (name) => params[name] }; } /** * Provides a unique, per-event identifier. The ID is a random UUID, generated * lazily on first `getId()` call and cached for the event lifetime. * * @param ctx - Optional explicit context (defaults to `current()`) * * @example * ```ts * const { getId } = useEventId() * logger.info(`Request ${getId()}`) * ``` */ function useEventId(ctx) { const c = ctx ?? current(); return { getId: () => c.get(eventIdSlot) }; } //#endregion exports.ContextInjector = ContextInjector; exports.EventContext = EventContext; exports.cached = cached; exports.cachedBy = cachedBy; exports.createEventContext = createEventContext; exports.current = current; exports.defineEventKind = defineEventKind; exports.defineWook = defineWook; exports.eventTypeKey = eventTypeKey; exports.getContextInjector = getContextInjector; exports.key = key; exports.replaceContextInjector = replaceContextInjector; exports.resetContextInjector = resetContextInjector; exports.routeParamsKey = routeParamsKey; exports.run = run; exports.slot = slot; exports.tryGetCurrent = tryGetCurrent; exports.useEventId = useEventId; exports.useLogger = useLogger; exports.useRouteParams = useRouteParams;