@oamm/runtime-context
v1.0.7
Published
Shared, injectable request-scoped runtime context for Node.js and Edge runtimes
Maintainers
Readme
@oamm/runtime-context
A production-ready TypeScript library that provides a shared, injectable request-scoped runtime context. Primarily backed by Node.js AsyncLocalStorage, but designed to be framework-agnostic and safe for environments where ALS is unavailable (like Edge runtimes).
Why?
In complex applications, multiple libraries or modules might need access to request-scoped data (like trace IDs, user information, etc.). If each library creates its own AsyncLocalStorage instance, they won't share data, leading to a "split-brain" problem.
This package provides a single, shared storage instance that can be injected into or shared across multiple libraries.
Installation
npm install @oamm/runtime-contextUsage
Basic Usage (Node.js)
import { runWithContext, getContext, setContext } from '@oamm/runtime-context';
const myContext = { requestId: '123' };
runWithContext(myContext, () => {
const ctx = getContext(); // { requestId: '123' }
setContext('userId', 'abc');
// ctx is now { requestId: '123', userId: 'abc' }
});Context Management Scenarios
The library supports three distinct scenarios for managing context, giving you flexibility depending on your needs.
1. Map-based Multi-Context (Automatic)
Use this when you want a clean, Map-based storage for various pieces of data. This allows you to use setContext and getContext immediately with keys.
import { runWithContext, setContext, getContext } from '@oamm/runtime-context';
runWithContext(() => {
setContext('requestId', '123');
// ...
const id = getContext('requestId'); // '123'
});2. Keyed Context (Multi-tenant/Shared)
Use this when you have multiple independent contexts (e.g., a Database context and a User context) and you want to keep them separated without mixing.
import { runWithContext, getContext } from '@oamm/runtime-context';
const dbCtx = { connection: '...' };
const userCtx = { id: 'abc' };
runWithContext('db', dbCtx, () => {
runWithContext('user', userCtx, () => {
const db = getContext('db'); // { connection: '...' }
const user = getContext('user'); // { id: 'abc' }
// getContext() returns the whole Map containing both
});
});3. Raw Context (Simple Object)
Use this for simple scenarios where you just need a single object as context and want to avoid the overhead of a Map.
import { runWithContext, getContext, setContext } from '@oamm/runtime-context';
const myContext = { requestId: '123' };
runWithContext(myContext, () => {
const ctx = getContext(); // { requestId: '123' }
setContext('userId', 'abc');
// myContext is now { requestId: '123', userId: 'abc' }
});Store Architecture
The library automatically manages the underlying storage structure based on how you initialize your context. Understanding the distinction between the Store and Context Objects is key:
- Store: The top-level container held by
AsyncLocalStorage. It is either aMap(Scenarios 1 & 2) or a Raw Object (Scenario 3). - Context Object: An object stored inside the Map (Scenario 2) or the Store itself if it's a Raw Object.
Visual Representation
1. Map-based Store (Scenarios 1 & 2)
ALS Store (Map)
│
├── 'db' ──> { connection: '...' } (Keyed Context)
├── 'user' ──> { id: 'abc' } (Keyed Context)
└── [Default] ──> { requestId: '123' } (Default Fallback Context)2. Raw Object Store (Scenario 3)
ALS Store (Object)
│
└── { requestId: '123' }Shared Storage (Injection)
If you are building a library that depends on this one, you can allow users to inject their own storage to ensure consistency:
import { initRuntimeContext } from '@oamm/runtime-context';
// In your app entry point
initRuntimeContext({ storage: mySharedStorage });Debug Mode
You can enable internal debug logging to gain visibility into context operations (context entry, retrieval, and modifications). This is useful for troubleshooting context availability or state changes.
import { initRuntimeContext } from '@oamm/runtime-context';
initRuntimeContext({
debug: true
});When enabled, the library logs detailed information to console.debug prefixed with [runtime-context].
ensureContext
Ensures a context exists without double-wrapping, reusing the existing one if available. It supports the same three scenarios:
1. Map-based context (Default)
Ensures that a Map-based storage is available.
import { ensureContext, setItem } from '@oamm/runtime-context';
await ensureContext(async () => {
setItem('traceId', 'abc');
});2. Keyed context
Ensures a specific key exists within a Map-based context.
await ensureContext('my-key', () => ({ data: 1 }), () => {
// If 'my-key' already exists, it is reused.
});3. Raw (object-based) context
Ensures a specific context object exists. No Map is created if it's missing.
await ensureContext(() => ({ requestId: '123' }), async () => {
// If a context already existed, it is reused.
// Otherwise, a new object context is created for this scope.
});Sharing across libraries
If you are building a library that needs access to the request context, simply import getContext from this package.
// my-library.ts
import { getContext } from '@oamm/runtime-context';
export function myLibraryFunction() {
const ctx = getContext();
// ... do something with ctx ...
}As long as the main application uses @oamm/runtime-context, your library will automatically have access to the same context.
This works even if your library is bundled separately, thanks to our use of a global storage key.
API
Core
initRuntimeContext(config): Initializes the global storage and configuration (e.g.,debugmode,storageinjection).getContext<T>(key?): Returns the current context orundefined. Supports automatic type inference when using classes as keys.requireContext<T>(key?): Returns the current context or throws.runWithContext(fn): Runsfnwith an automatically initialized Map-based context (Scenario 1).runWithContext(key, ctx, fn): Runsfnwith a specific key in a Map-based multi-context (Scenario 2).runWithContext(ctx, fn): Runsfnwithin the given context, explicitly avoiding Map creation (Scenario 3).ensureContext(fn): Ensures a Map-based context exists (Scenario 1).ensureContext(key, create, fn): Ensures a specific keyed context exists in a Map (Scenario 2).ensureContext(create, fn): Ensures a context exists, avoiding Map creation if it needs to be created (Scenario 3).
Multi-Context (Map-based)
When you need to manage multiple independent contexts (e.g., a TokenKit and a Session), you can use keys (strings, symbols, or classes). The library automatically handles Map creation and clones the parent Map when nesting contexts to ensure changes in a nested scope do not leak back to the parent.
class TokenKit {
constructor(public token: string) {}
}
const tk = new TokenKit('abc');
runWithContext(TokenKit, tk, () => {
// Type is automatically inferred as TokenKit | undefined
const context = getContext(TokenKit);
});Bound Accessors (Method References)
If you need a reference to a context accessor (e.g., for dependency injection), use an arrow function:
// For classes:
const getSessionContext = () => getContext(SessionContext);
// For interfaces or default context (uses the whole store):
interface MyContext { user: string }
const getMyContext = () => getContext<MyContext>();
// For specific keys with interfaces:
const getSpecific = () => getContext<MyContext>('my-key');
// Later, call them without arguments
const session = getSessionContext(); // inferred as SessionContext | undefinedHelpers
The library provides several helpers to interact with the context. There is an important distinction between interacting with the Store (Map or Object) and interacting with Properties within a context object.
Store Helpers (Top-level)
setContext(key, value): Directly interacts with the top-level ALS store.- Map-based: Performs
map.set(key, value). - Raw Object: Performs
object[key] = value.
- Map-based: Performs
getContext(key?): Retrieves a value from the ALS store.requireContext(key?): Retrieves a value from the ALS store or throws an error.
Property Helpers (Object-level)
These helpers are designed to work with properties inside context objects, and they automatically handle the "Default Context Fallback".
setValue(key, value, contextKey?): Sets a property on a context object.- If
contextKeyis provided, it targets the object stored under that key in the Map. - If not provided, it targets the Default Context object. If no default object exists, it falls back to
setContext(key, value).
- If
getValue(key, contextKey?): Gets a property from a context object.- If
contextKeyis provided, it targets the object at that key. - If not provided, it first checks the Map for
key. If not found, it falls back to checking the Default Context object for that property.
- If
mergeContext(partial, contextKey?): Merges an object into a context object (targets the Default Context if nocontextKeyis provided).
Default Context Fallback
When transitioning from a Raw Object context (Scenario 3) to a Map-based context (Scenarios 1 & 2), the library automatically preserves the parent object as a "default" context.
const globalContext = { traceId: '123' };
runWithContext(globalContext, () => {
// We are in Scenario 3 (Raw Object)
runWithContext('user', { id: 'abc' }, () => {
// We are now in Scenario 2 (Map-based)
// The Map contains: { 'user' => { id: 'abc' }, [Default] => globalContext }
const traceId = getValue('traceId'); // '123' (fallback to default context)
const userId = getValue('id', 'user'); // 'abc' (from 'user' keyed context)
setValue('newVal', 'foo'); // Sets globalContext.newVal = 'foo'
});
});Helpers (Map-based context)
setItem(key, value)getItem(key)
Environment Support
- Node.js: Automatically uses
AsyncLocalStorage. - Edge/Other: You must inject an
AsyncContextStorageimplementation or useinitRuntimeContext({ storage })if you want to share storage across libraries. By default, it will throw an error if you try torunwithout a valid storage.
Testing
Use resetForTests() in beforeEach to ensure test isolation.
import { resetForTests } from '@oamm/runtime-context';
beforeEach(() => {
resetForTests();
});