@wooksjs/event-core
Version:
@wooksjs/event-core
503 lines (493 loc) • 16 kB
JavaScript
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "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 /event-core detected: existing v${_g[VERSION_KEY]}, loading v${CURRENT_VERSION}. All packages must use the same /event-core version.`);
} else {
_g[STORAGE_KEY] = new 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(() => 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
export { ContextInjector, EventContext, cached, cachedBy, createEventContext, current, defineEventKind, defineWook, eventTypeKey, getContextInjector, key, replaceContextInjector, resetContextInjector, routeParamsKey, run, slot, tryGetCurrent, useEventId, useLogger, useRouteParams };