Skip to content

@studnicky/context ​

Async context isolation for Node and browser code.

What it is ​

@studnicky/context is a composable async scope primitive for Node and browser runtimes, with optional transforms and scope-local store composition. It carries values across admitted async boundaries; it is not an application request framework.

What it is for ​

Use it when Northstar Books needs request-local identifiers, an isolated shopping-cart scope, or scoped dependencies across asynchronous catalogue work. Consumers choose what enters a scope, its lifetime, and any durable backing store.

Northstar Books examples ​

  • Context browser scopes — overlapping async isolation maps concurrent browser book-detail requests to separate scopes. It proves values stay isolated across explicit await boundaries even when the requests overlap.

Install ​

bash
pnpm add @studnicky/context

Requires @studnicky:registry=https://npm.pkg.github.com in .npmrc.

@studnicky/context/node uses AsyncLocalStorage. @studnicky/context/browser uses the supplied transform for ordinary await; without the transform, use scope.await(value) across an await boundary and scope.bind(callback) for opaque callbacks. Entities and interfaces retain their canonical public subpaths. ContextStore supplies scope-local state composition through @studnicky/context/store/node or @studnicky/context/store/browser; its options are available from @studnicky/context/store/interfaces.

Node usage ​

Say a Northstar Books request comes in and needs its own requestId to follow it everywhere it goes — into logging, into every handler it touches — without threading that value through every function signature by hand. The /node entrypoint handles this automatically: once a scope is open, ordinary await keeps carrying it along for you. Below, a scope opens with a seed requestId, two more values get set from inside execute(), and terminate() hands back everything the scope collected as a frozen snapshot you can still read after the scope itself has gone inactive.

ts
import { Context } from '@studnicky/context/node';
import assert from 'node:assert/strict';

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()}`);

One-shot scopes ​

Context.run(initial, operation) creates a scope, runs a synchronous operation, terminates the scope, and returns the operation value with its final snapshot. Context.runAsync(initial, operation) does the same for an asynchronous operation.

typescript
import { Context } from "@studnicky/context/node";

const context = Context.create({ name: "request" });
const result = await context.runAsync({ requestId: "req-001" }, async () => {
  const response = await fetch("/api/profile");
  return response.status;
});

console.log(result.value, result.snapshot.get("requestId"));

Browser usage ​

Register the supplied Context transform in Vite before running browser code. It preserves Context across ordinary native await continuations.

typescript
import { defineConfig } from "vite";
import { transform as contextAsyncTransform } from "@studnicky/context/browser/transform";

export default defineConfig({
  plugins: [contextAsyncTransform()],
});

Import Context from the browser entrypoint and use ordinary await inside an active scope:

typescript
import { Context } from "@studnicky/context/browser";

const context = Context.create({ name: "request" });
const scope = context.initialize({ requestId: "req-001" });

await scope.execute(async () => {
  const response = await fetch("/api/profile");
  context.set("statusCode", response.status);
});

scope.terminate();

Browser code without the transform ​

Use scope.await(value) when browser code crosses an await boundary without the transform. Context.runAsync closes the scope automatically.

typescript
const result = await context.runAsync({ requestId: "req-001" }, async (scope) => {
  const response = await scope.await(fetch("/api/profile"));
  return response.status;
});

console.log(result.value, result.snapshot.get("requestId"));

Opaque callback boundaries ​

Callbacks invoked later need an explicitly managed scope. Remove the callback and terminate the scope when the subscription ends; Context.run/Context.runAsync are for operations that end when they return or their promise settles.

typescript
const scope = context.initialize({ requestId: "req-001" });
const onMessage = scope.bind((event: MessageEvent) => {
  context.set("lastMessage", event.data);
});

channel.addEventListener("message", onMessage);

// When the subscription ends:
channel.removeEventListener("message", onMessage);
const snapshot = scope.terminate();

Context-scoped stores ​

ContextStore<TState> resolves one backing StoreInterface<TState> for each active Context scope. It is available from both runtime entrypoints and carries a stable synchronization identity, which each backing store must report when its scope first resolves it. A StrataStore can compose the scoped store with a durable Store layer.

typescript
import { Mutex } from "@studnicky/concurrency/mutex";
import { Context } from "@studnicky/context/node";
import { ContextStore } from "@studnicky/context/store/node";
import type { ContextStoreOptionsInterface } from "@studnicky/context/store/interfaces";
import { MemoryPersistence, Store } from "@studnicky/store/node";

const context = Context.create({ name: "request" });
const mutex = Mutex.create<string>();
const options: ContextStoreOptionsInterface<{ readonly items: string[] }> = {
  context,
  key: "request.cart",
  synchronizationIdentity: { key: "request.cart", mutex },
  createStore: () =>
    Store.create({
      initialState: { items: [] },
      key: "request.cart",
      mutex,
      persistence: MemoryPersistence.create(),
    }),
};
const cart = ContextStore.create(options);
const scope = context.initialize();

await scope.execute(async () => {
  await cart.update((state) => ({ items: [...state.items, "sku-42"] }));
});
scope.terminate();

Try it ​

Picture two Northstar Books readers opening different book-detail pages at nearly the same moment — one page's data comes back in a few milliseconds, the other takes a bit longer. Both requests want to carry their own requestId through whatever async work they trigger, and neither should ever see the other's value by accident. This demo first runs one scope automatically through runAsync, then opens two scopes side by side with different delays and interleaves them with Promise.all, confirming each one still reports back its own requestId once the race finishes. Because the playground does not run the Vite transform, the demo reaches for scope.await(value) at each wait; a real browser app wired up with the transform plugin keeps using ordinary await.

Loading example…

Scope and lookup behavior ​

Context.initialize(initial?) creates a reusable scope, while Context.run(initial, operation)/Context.runAsync(initial, operation) create, execute, terminate, and return { value, snapshot }. ContextScopeInterface provides execute(fn), await(value), bind(callback), and terminate(). On Node, ordinary await retains Context; browser code uses the transform or the explicit scope methods. tryGet always returns a ContextLookupEntity.Type lookup object ({ found: boolean; value: unknown }); when no scope is active or the key is absent, it returns { found: false, value: undefined } rather than a bare undefined. get throws ContextError in either case.

Public API ​

@studnicky/context/node provides AsyncLocalStorage-backed scopes. @studnicky/context/browser provides browser context scopes. ContextStore and its errors are available only from the @studnicky/context/store/node and @studnicky/context/store/browser leaves; its contract is available only from @studnicky/context/store/interfaces. Schemas use @studnicky/context/entities, and Context contracts use @studnicky/context/interfaces.

Extending ​

Say Northstar Books wants every audit-trail scope to carry a _createdAt timestamp, but doesn't want every call site to remember to set it by hand — that is exactly the gap onInitialize closes. Subclassing Context and overriding this one hook lets AuditContext stamp the timestamp into every scope the moment it is created, so a caller that only passes operation and resource still finds _createdAt sitting in the scope when it reads from inside execute():

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

import { Context } from '@studnicky/context/node';
import assert from 'node:assert/strict';

import { ContextConfigEntity } from '../src/entities/ContextConfigEntity.js';
import { NodeContextStorage } from '../src/node/NodeContextStorage.js';

/**
 * A Context subclass that automatically seeds `_createdAt` on every scope.
 */
class AuditContext extends Context {
  static override create(
    config: ContextConfigEntity.InputType,
    storage: NodeContextStorage = new NodeContextStorage()
  ): AuditContext {
    return new AuditContext(ContextConfigEntity.create(config), storage);
  }
  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());
    });
  }
}

// AuditContext.create returns audit contexts with normalized configuration and Node storage.
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:', [...snapshot.keys()].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?: string → boolean
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

Wire up every hook at once and a scope's entire lifecycle becomes visible: its creation, every get, every set, and both a deletion that finds something and one that does not. The example below drives one scope through that full sequence and records which hook fired with which arguments at each step.

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

import { Context } from '@studnicky/context/node';
import assert from 'node:assert/strict';

import { ContextConfigEntity } from '../src/entities/ContextConfigEntity.js';
import { NodeContextStorage } from '../src/node/NodeContextStorage.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 }[] = [];

  static override create(
    config: ContextConfigEntity.InputType,
    storage: NodeContextStorage = new NodeContextStorage()
  ): ObservedContext {
    return new ObservedContext(ContextConfigEntity.create(config), storage);
  }

  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:', [...snapshot.keys()].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 Context configuration, state, and storage contracts.

typescript
import type {
  ContextRunResultInterface,
  ContextScopeInterface,
  ContextStorageInterface,
} from "@studnicky/context/interfaces";

Public entrypoints ​

Import pathUse it when
@studnicky/context/nodeA Northstar Books Node process needs AsyncLocalStorage-backed scopes.
@studnicky/context/node/transformA Node build needs the async-context transform as a runtime-specific tool.
@studnicky/context/browserA browser needs context scopes; it is the runtime alternative to /node.
@studnicky/context/browser/transformA Vite or Rollup browser build needs automatic await-boundary propagation.
@studnicky/context/entitiesA consumer needs context configuration schemas as contracts.
@studnicky/context/interfacesA consumer needs context-scope and storage ports as contracts.
@studnicky/context/store/nodeA Node scope needs one backing Store per active context.
@studnicky/context/store/browserA browser scope needs the same ContextStore primitive; it is the runtime alternative to /store/node.
@studnicky/context/store/interfacesA consumer needs ContextStore construction contracts.

Exports ​

SymbolPurposeImport path
ContextCreates AsyncLocalStorage-backed context scopes.@studnicky/context/node
ContextStoreResolves a backing Store for each active Context scope.@studnicky/context/store/node
ContextScopeInactiveErrorA ContextStore is used outside an active Context scope.@studnicky/context/store/node
ContextStoreFactoryErrorA ContextStore factory returned a value that is not a StoreInterface.@studnicky/context/store/node
ContextStoreKeyConflictErrorThe Context key holds a value that is not a ContextStore backing Store.@studnicky/context/store/node
ContextStoreOptionsErrorContextStore construction received invalid options.@studnicky/context/store/node
SynchronizationIdentityMismatchErrorA backing Store reports a different synchronization identity.@studnicky/context/store/node
ContextAsyncRuntimeInternal continuation runtime used by the transform.@studnicky/context/node
ContextConfigErrorReports invalid Context configuration.@studnicky/context/node
ContextErrorReports context lifecycle and lookup failures.@studnicky/context/node
UnsupportedSourceExtensionErrorReports a module extension the async Context transform cannot parse.@studnicky/context/node
ContextCreates browser context scopes.@studnicky/context/browser
ContextAsyncRuntimeInternal continuation runtime used by the transform.@studnicky/context/browser
ContextConfigErrorReports invalid Context configuration.@studnicky/context/browser
ContextErrorReports context lifecycle and lookup failures.@studnicky/context/browser
UnsupportedSourceExtensionErrorReports a module extension the async Context transform cannot parse.@studnicky/context/browser
transformRegisters the Vite or Rollup async Context transform.@studnicky/context/browser/transform
ContextStorageInterfaceDefines the shared async storage contract.@studnicky/context/interfaces
ContextStoreOptionsInterfaceDefines Context, key, backing-store factory, and synchronization identity.@studnicky/context/store/interfaces
ContextRunResultInterfaceDescribes the value and final snapshot returned by Context.run/Context.runAsync.@studnicky/context/interfaces