@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
pnpm add @studnicky/contextRequires @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.
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.
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.
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:
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.
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.
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.
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.
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():
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.
| 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 |
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.
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.
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 Context configuration, state, and storage contracts.
import type {
ContextRunResultInterface,
ContextScopeInterface,
ContextStorageInterface,
} from "@studnicky/context/interfaces";Public entrypoints
| Import path | Use it when |
|---|---|
@studnicky/context/node | A Northstar Books Node process needs AsyncLocalStorage-backed scopes. |
@studnicky/context/node/transform | A Node build needs the async-context transform as a runtime-specific tool. |
@studnicky/context/browser | A browser needs context scopes; it is the runtime alternative to /node. |
@studnicky/context/browser/transform | A Vite or Rollup browser build needs automatic await-boundary propagation. |
@studnicky/context/entities | A consumer needs context configuration schemas as contracts. |
@studnicky/context/interfaces | A consumer needs context-scope and storage ports as contracts. |
@studnicky/context/store/node | A Node scope needs one backing Store per active context. |
@studnicky/context/store/browser | A browser scope needs the same ContextStore primitive; it is the runtime alternative to /store/node. |
@studnicky/context/store/interfaces | A consumer needs ContextStore construction contracts. |
Exports
| Symbol | Purpose | Import path |
|---|---|---|
Context | Creates AsyncLocalStorage-backed context scopes. | @studnicky/context/node |
ContextStore | Resolves a backing Store for each active Context scope. | @studnicky/context/store/node |
ContextScopeInactiveError | A ContextStore is used outside an active Context scope. | @studnicky/context/store/node |
ContextStoreFactoryError | A ContextStore factory returned a value that is not a StoreInterface. | @studnicky/context/store/node |
ContextStoreKeyConflictError | The Context key holds a value that is not a ContextStore backing Store. | @studnicky/context/store/node |
ContextStoreOptionsError | ContextStore construction received invalid options. | @studnicky/context/store/node |
SynchronizationIdentityMismatchError | A backing Store reports a different synchronization identity. | @studnicky/context/store/node |
ContextAsyncRuntime | Internal continuation runtime used by the transform. | @studnicky/context/node |
ContextConfigError | Reports invalid Context configuration. | @studnicky/context/node |
ContextError | Reports context lifecycle and lookup failures. | @studnicky/context/node |
UnsupportedSourceExtensionError | Reports a module extension the async Context transform cannot parse. | @studnicky/context/node |
Context | Creates browser context scopes. | @studnicky/context/browser |
ContextAsyncRuntime | Internal continuation runtime used by the transform. | @studnicky/context/browser |
ContextConfigError | Reports invalid Context configuration. | @studnicky/context/browser |
ContextError | Reports context lifecycle and lookup failures. | @studnicky/context/browser |
UnsupportedSourceExtensionError | Reports a module extension the async Context transform cannot parse. | @studnicky/context/browser |
transform | Registers the Vite or Rollup async Context transform. | @studnicky/context/browser/transform |
ContextStorageInterface | Defines the shared async storage contract. | @studnicky/context/interfaces |
ContextStoreOptionsInterface | Defines Context, key, backing-store factory, and synchronization identity. | @studnicky/context/store/interfaces |
ContextRunResultInterface | Describes the value and final snapshot returned by Context.run/Context.runAsync. | @studnicky/context/interfaces |