Skip to content

@studnicky/context

Per-request async context isolation using AsyncLocalStorage.

Install

bash
pnpm add @studnicky/context

Requires @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:

ts
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:

ts
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

SubpathContents
@studnicky/contextContext, ContextBuilder, ContextScope, ContextScopeBuilder, ContextScopeOptionsInterface, ContextError, ContextConfigError
@studnicky/context/errorsContextError, ContextConfigError
@studnicky/context/interfacesContextInterface, ContextScopeInterface

Extending

Override onInitialize to seed default values into every scope without requiring callers to pass them:

ts
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.

HookClassWhen it firesArgs
onInitializeContextAfter initialize() creates the scopeinitial: Record<string, unknown> | undefined, scope: ContextScope
onMissingContextContextWhen get/set/etc. is called with no active store; return true to suppress throwkey?: stringboolean
onGetContextAfter a successful get() retrievalkey: string, value: unknown
onSetContextAfter set() stores a valuekey: string, value: unknown
onDeleteContextAfter delete() removes (or attempts to remove) a keykey: string, existed: boolean
onExitContextScopeBefore FSM leaves a state (before #state updates)from: ContextScopeState, to: ContextScopeState
onEnterContextScopeAfter FSM enters a new stateto: ContextScopeState, from: ContextScopeState
onBeforeExecuteContextScopeBefore each execute() invocation (success path only)
onAfterExecuteContextScopeAfter each execute() completes without error
onErrorContextScopeWhen the fn passed to execute() throws; error re-throws aftererror: unknown
onTerminatedAccessContextScopeWhen execute() is called on a terminated scope; throw always follows
onDisposeContextScopeAfter internal store is cleared in terminate()
onTerminateContextScopeDuring terminate(), after dispose; can augment the snapshotsnapshot: Record<string, unknown>Record<string, unknown>
ts
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.

Source on GitHub