@studnicky/context
Per-request async context isolation using AsyncLocalStorage.
Install
pnpm add @studnicky/contextRequires @studnicky:registry=https://npm.pkg.github.com in .npmrc.
@studnicky/context declares a root usage API and explicit public subpaths.
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('requestId')}`);
console.log(`statusCode: ${context.get('statusCode')}`);
console.log(`isActive inside execute: ${context.isActive()}`);
});
const snapshot = scope.terminate();
console.log('snapshot:', snapshot);
console.log(`isActive after terminate: ${context.isActive()}`);Scope and lookup behavior
Context.initialize(initial?) is the sole context-scope construction path. It returns ContextScopeInterface, whose public operations are execute(fn) and terminate(). tryGet returns undefined when no scope is active or a key is absent; get throws ContextError in either case.
Public API
The root exports Context, ContextError, and ContextConfigError. Context schemas use @studnicky/context/entities, while context contracts use @studnicky/context/interfaces.
Extending
Override onInitialize to seed default values into every scope without requiring callers to pass them:
import type { ContextScopeInterface } from '../src/interfaces/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: ContextScopeInterface
): 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('_createdAt');
const operation = auditContext.get('operation');
const resource = auditContext.get('resource');
console.log(`operation: ${operation}`);
console.log(`resource: ${resource}`);
console.log(`_createdAt: ${createdAt}`);
});
const snapshot = scope.terminate();
console.log('snapshot keys:', Object.keys(snapshot).toSorted());Observability hooks
Context exposes protected hook methods that a subclass can override. Scope instances remain factory-owned behind ContextScopeInterface. All hooks are no-ops by default, and the base class never calls a logger or metrics library.
| Hook | Class | When it fires | Args |
|---|---|---|---|
onInitialize | Context | After initialize() creates the scope | initial: Record<string, unknown> | undefined, scope: ContextScopeInterface |
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 |
import type { ContextScopeInterface } from '../src/interfaces/index.js';
import { Context } from '../src/index.js';
class ObservedContext extends Context {
readonly deleteEvents: { 'existed': boolean; 'key': string }[] = [];
readonly getEvents: { 'key': string; 'value': unknown }[] = [];
readonly initializeEvents: string[] = [];
readonly missingContextEvents: string[] = [];
readonly setEvents: { 'key': string; 'value': unknown }[] = [];
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 onGet(key: string, value: unknown): void {
console.log(`[context] onGet key=${key} value=${String(value)}`);
this.getEvents.push({ 'key': key, 'value': value });
}
protected override onInitialize(
_initial: Record<string, unknown> | undefined,
_scope: ContextScopeInterface
): void {
console.log('[context] onInitialize');
this.initializeEvents.push('initialize');
}
protected override onMissingContext(_key?: string): boolean {
console.log('[context] onMissingContext');
this.missingContextEvents.push('missing');
return false;
}
protected override onSet(key: string, value: unknown): void {
console.log(`[context] onSet key=${key} value=${String(value)}`);
this.setEvents.push({ 'key': key, 'value': value });
}
}
const context = ObservedContext.create({ 'name': 'request' });
const scope = context.initialize({ 'requestId': 'req-001' });
scope.execute(() => {
context.set('userId', 'u-42');
context.set('tempKey', 'will-be-deleted');
context.get('requestId');
context.get('userId');
context.delete('tempKey');
context.delete('nonexistent');
});
const snapshot = scope.terminate();
console.log('Final snapshot keys:', Object.keys(snapshot).toSorted());The base class never calls any logger or metrics library. All hooks are no-ops by default.
Entities
@studnicky/context/entities exports every schema namespace in src/entities.
import { ContextConfigEntity } from '@studnicky/context/entities';Interfaces
@studnicky/context/interfaces exports every TypeScript interface in src/interfaces, including configuration and state contracts.
import type { ContextScopeInterface } from '@studnicky/context/interfaces';Exports
| Symbol | Purpose | Import path |
|---|---|---|
Context | Provides context functionality. | @studnicky/context |
ContextConfigError | Represents context config failures. | @studnicky/context |
ContextError | Represents context failures. | @studnicky/context |