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.

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

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('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:

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

HookClassWhen it firesArgs
onInitializeContextAfter initialize() creates the scopeinitial: Record<string, unknown> | undefined, scope: ContextScopeInterface
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
ts
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.

Source on GitHub

Entities

@studnicky/context/entities exports every schema namespace in src/entities.

typescript
import { ContextConfigEntity } from '@studnicky/context/entities';

Interfaces

@studnicky/context/interfaces exports every TypeScript interface in src/interfaces, including configuration and state contracts.

typescript
import type { ContextScopeInterface } from '@studnicky/context/interfaces';

Exports

SymbolPurposeImport path
ContextProvides context functionality.@studnicky/context
ContextConfigErrorRepresents context config failures.@studnicky/context
ContextErrorRepresents context failures.@studnicky/context