@studnicky/context
Per-request async context isolation using AsyncLocalStorage.
Install
pnpm add @studnicky/contextRequires @studnicky:registry=https://npm.pkg.github.com in .npmrc.
Live demo unavailable
In-browser execution of this package is not supported. Async context propagation across await boundaries relies on Node's AsyncLocalStorage from node:async_hooks, which browsers do not provide. The examples below are shown statically.
Usage
Create a named context, initialize a scope with seed values, run code inside execute(), then call terminate() to extract the final snapshot:
import { Context } from '../src/index.js';
const context = Context.create({ 'name': 'request' });
const scope = context.initialize({ 'requestId': 'req-001' });
scope.execute(() => {
context.set('statusCode', 200);
context.set('userId', 'u-42');
console.log(`requestId: ${context.get<string>('requestId')}`);
console.log(`statusCode: ${context.get<number>('statusCode')}`);
console.log(`isActive inside execute: ${context.isActive()}`);
});
const snapshot = scope.terminate();
console.log('snapshot:', snapshot);
console.log(`isActive after terminate: ${context.isActive()}`);Builder API and tryGet vs get
Use Context.builder() for the fluent construction style. tryGet returns undefined for absent keys; get throws ContextError:
import { Context, ContextError } from '../src/index.js';
// Build via the fluent builder
const context = Context.builder().name('session').build();
const scope = context.initialize({ 'userId': 'u-42' });
scope.execute(() => {
context.set('role', 'admin');
console.log(`userId: ${context.get<string>('userId')}`);
console.log(`role: ${context.get<string>('role')}`);
});
const snapshot = scope.terminate();
console.log('snapshot:', snapshot);
// --- tryGet vs get ---
const context2 = Context.create({ 'name': 'demo' });
const scope2 = context2.initialize();
scope2.execute(() => {
context2.set('present', 'yes');
// tryGet never throws — returns undefined for missing keys
const missing = context2.tryGet<string>('absent');
console.log('tryGet for absent key returned:', missing);
// get throws ContextError for missing keys
try {
context2.get<string>('absent');
} catch (err) {
console.log('get for absent key threw:', err instanceof ContextError ? 'ContextError' : String(err));
}
// get works normally for present keys
console.log('get for present key returned:', context2.get<string>('present'));
});
scope2.terminate();Subpath exports
| Subpath | Contents |
|---|---|
@studnicky/context | Context, ContextBuilder, ContextScope, ContextScopeBuilder, ContextScopeOptionsInterface, ContextError, ContextConfigError |
@studnicky/context/errors | ContextError, ContextConfigError |
@studnicky/context/interfaces | ContextInterface, ContextScopeInterface |
Extending
Override onInitialize to seed default values into every scope without requiring callers to pass them:
import type { ContextScope } from '../src/index.js';
import { Context } from '../src/index.js';
/**
* A Context subclass that automatically seeds `_createdAt` on every scope.
*/
class AuditContext extends Context {
protected override onInitialize(
_initial: Record<string, unknown> | undefined,
scope: ContextScope
): void {
// Seed a timestamp into the new scope so every execute() can read it
scope.execute(() => {
this.set('_createdAt', Date.now());
});
}
}
// Context.create uses `new this(config)` so the return type is AuditContext
const auditContext = AuditContext.create({ 'name': 'audit' });
const scope = auditContext.initialize({ 'operation': 'delete', 'resource': 'user/99' });
scope.execute(() => {
const createdAt = auditContext.get<number>('_createdAt');
const operation = auditContext.get<string>('operation');
const resource = auditContext.get<string>('resource');
console.log(`operation: ${operation}`);
console.log(`resource: ${resource}`);
console.log(`_createdAt: ${createdAt}`);
});
const snapshot = scope.terminate();
console.log('snapshot keys:', Object.keys(snapshot).sort());Observability hooks
Both Context and ContextScope expose protected hook methods you can override in a subclass. All hooks are no-ops by default. The base class never calls any logger or metrics library.
| Hook | Class | When it fires | Args |
|---|---|---|---|
onInitialize | Context | After initialize() creates the scope | initial: Record<string, unknown> | undefined, scope: ContextScope |
onMissingContext | Context | When get/set/etc. is called with no active store; return true to suppress throw | key?: string → boolean |
onGet | Context | After a successful get() retrieval | key: string, value: unknown |
onSet | Context | After set() stores a value | key: string, value: unknown |
onDelete | Context | After delete() removes (or attempts to remove) a key | key: string, existed: boolean |
onExit | ContextScope | Before FSM leaves a state (before #state updates) | from: ContextScopeState, to: ContextScopeState |
onEnter | ContextScope | After FSM enters a new state | to: ContextScopeState, from: ContextScopeState |
onBeforeExecute | ContextScope | Before each execute() invocation (success path only) | — |
onAfterExecute | ContextScope | After each execute() completes without error | — |
onError | ContextScope | When the fn passed to execute() throws; error re-throws after | error: unknown |
onTerminatedAccess | ContextScope | When execute() is called on a terminated scope; throw always follows | — |
onDispose | ContextScope | After internal store is cleared in terminate() | — |
onTerminate | ContextScope | During terminate(), after dispose; can augment the snapshot | snapshot: Record<string, unknown> → Record<string, unknown> |
import type { ContextScopeOptionsInterface } from '../src/index.js';
import { Context, ContextScope } from '../src/index.js';
// --- Context subclass: traces get/set/delete/initialize/missingContext ---
class ObservedContext extends Context {
readonly setEvents: { 'key': string; 'value': unknown }[] = [];
readonly getEvents: { 'key': string; 'value': unknown }[] = [];
readonly deleteEvents: { 'existed': boolean; 'key': string }[] = [];
readonly initializeEvents: string[] = [];
readonly missingContextEvents: string[] = [];
protected override onInitialize(
_initial: Record<string, unknown> | undefined,
_scope: ContextScope
): void {
console.log('[context] onInitialize');
this.initializeEvents.push('initialize');
}
protected override onSet(key: string, value: unknown): void {
console.log(`[context] onSet key=${key} value=${String(value)}`);
this.setEvents.push({ 'key': key, 'value': value });
}
protected override onGet(key: string, value: unknown): void {
console.log(`[context] onGet key=${key} value=${String(value)}`);
this.getEvents.push({ 'key': key, 'value': value });
}
protected override onDelete(key: string, existed: boolean): void {
console.log(`[context] onDelete key=${key} existed=${String(existed)}`);
this.deleteEvents.push({ 'existed': existed, 'key': key });
}
protected override onMissingContext(_key?: string): boolean {
console.log('[context] onMissingContext');
this.missingContextEvents.push('missing');
return false;
}
}
// --- ContextScope subclass: traces all FSM/execute/terminate hooks ---
//
// NOTE: ContextScope's base constructor fires onExit/onEnter (created → active) via
// transition(). Class field initializers run AFTER super() returns, so they would
// overwrite any arrays stored in the constructor body before super(). We work around
// this by storing events in a WeakMap keyed on `this`, which is accessible as soon
// as `this` exists (inside the hooks, which run during super()).
const scopeEvents = new WeakMap<ObservedScope, {
'afterExecuteCount': number[];
'beforeExecuteCount': number[];
'disposeEvents': string[];
'enterEvents': { 'from': string; 'to': string }[];
'errorEvents': unknown[];
'exitEvents': { 'from': string; 'to': string }[];
'terminatedAccessCount': number[];
'terminateSnapshots': Record<string, unknown>[];
}>();
class ObservedScope extends ContextScope {
static override create(options: ContextScopeOptionsInterface): ObservedScope {
return new ObservedScope(options);
}
protected constructor(options: ContextScopeOptionsInterface) {
// Seed the WeakMap entry BEFORE super() so hooks can write to it immediately
const pending = {
'afterExecuteCount': [] as number[],
'beforeExecuteCount': [] as number[],
'disposeEvents': [] as string[],
'enterEvents': [] as { 'from': string; 'to': string }[],
'errorEvents': [] as unknown[],
'exitEvents': [] as { 'from': string; 'to': string }[],
'terminatedAccessCount': [] as number[],
'terminateSnapshots': [] as Record<string, unknown>[]
};
// `this` is not yet available before super(), but we can use a temporary key
// via a module-level slot that the hooks pick up:
ObservedScope.pending = pending;
super(options);
// After super(), `this` is available — move from slot to WeakMap
scopeEvents.set(this, ObservedScope.pending);
ObservedScope.pending = undefined;
}
// Module-level slot bridging pre-super() → post-super()
private static pending: ReturnType<typeof scopeEvents.get> | undefined;
private get e(): NonNullable<ReturnType<typeof scopeEvents.get>> {
// During super() hooks, WeakMap entry not yet set — read from slot
return scopeEvents.get(this) ?? ObservedScope.pending!;
}
get exitEvents(): { 'from': string; 'to': string }[] { return this.e.exitEvents; }
get enterEvents(): { 'from': string; 'to': string }[] { return this.e.enterEvents; }
get beforeExecuteCount(): number[] { return this.e.beforeExecuteCount; }
get afterExecuteCount(): number[] { return this.e.afterExecuteCount; }
get errorEvents(): unknown[] { return this.e.errorEvents; }
get disposeEvents(): string[] { return this.e.disposeEvents; }
get terminateSnapshots(): Record<string, unknown>[] { return this.e.terminateSnapshots; }
get terminatedAccessCount(): number[] { return this.e.terminatedAccessCount; }
protected override onExit(from: string, to: string): void {
console.log(`[scope] onExit from=${from} to=${to}`);
this.e.exitEvents.push({ 'from': from, 'to': to });
}
protected override onEnter(to: string, from: string): void {
console.log(`[scope] onEnter state=${to} from=${from}`);
this.e.enterEvents.push({ 'from': from, 'to': to });
}
protected override onBeforeExecute(): void {
console.log('[scope] onBeforeExecute');
this.e.beforeExecuteCount.push(1);
}
protected override onAfterExecute(): void {
console.log('[scope] onAfterExecute');
this.e.afterExecuteCount.push(1);
}
protected override onError(error: unknown): void {
console.log(`[scope] onError error=${String(error)}`);
this.e.errorEvents.push(error);
}
protected override onDispose(): void {
console.log('[scope] onDispose');
this.e.disposeEvents.push('dispose');
}
protected override onTerminate(snapshot: Record<string, unknown>): Record<string, unknown> {
console.log(`[scope] onTerminate keys=${Object.keys(snapshot).sort().join(',')}`);
this.e.terminateSnapshots.push(snapshot);
return { ...snapshot, '_observedAt': Date.now() };
}
protected override onTerminatedAccess(): void {
console.log('[scope] onTerminatedAccess');
this.e.terminatedAccessCount.push(1);
}
}
// ── Demonstration 1: ObservedContext hooks ──────────────────────────────────
console.log('\n=== ObservedContext demo ===');
// Context.create returns `Context` statically; cast to ObservedContext to access hooks.
const context = ObservedContext.create({ 'name': 'request' }) as ObservedContext;
const scope = context.initialize({ 'requestId': 'req-001' });
scope.execute(() => {
context.set('userId', 'u-42');
context.set('tempKey', 'will-be-deleted');
context.get<string>('requestId');
context.get<string>('userId');
context.delete('tempKey');
context.delete('nonexistent');
});
const snapshot1 = scope.terminate();
console.log('Final snapshot keys:', Object.keys(snapshot1).sort());
// ── Demonstration 2: ObservedScope hooks ───────────────────────────────────
console.log('\n=== ObservedScope demo ===');
const als = new AsyncLocalStorage<Map<string, unknown>>();
const observedScope = ObservedScope.create({
'initial': { 'requestId': 'req-002' },
'name': 'request',
'storage': als
});
// Clean execute
observedScope.execute(() => { const result = 'clean run';
return result; });
// Throwing execute — onError fires, scope stays usable
try {
observedScope.execute(() => { throw new Error('simulated failure'); });
} catch {
// Expected
}
// Another clean execute after the error
observedScope.execute(() => { const result = 'recovered';
return result; });
// Terminate — triggers onDispose then onTerminate
const snapshot2 = observedScope.terminate();
console.log('Final snapshot keys:', Object.keys(snapshot2).sort());The base class never calls any logger or metrics library. All hooks are no-ops by default.