Shared, injectable request-scoped runtime context for Node.js and Edge runtimes
npm install @oamm/runtime-contextA 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).
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.
``bash`
npm install @oamm/runtime-context
`typescript
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' }
});
`
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.
`typescript
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.
`typescript
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.
`typescript
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' }
});
`
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 a Map (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)
`text`
ALS Store (Map)
│
├── 'db' ──> { connection: '...' } (Keyed Context)
├── 'user' ──> { id: 'abc' } (Keyed Context)
└── [Default] ──> { requestId: '123' } (Default Fallback Context)
2. Raw Object Store (Scenario 3)
`text`
ALS Store (Object)
│
└── { requestId: '123' }
If you are building a library that depends on this one, you can allow users to inject their own storage to ensure consistency:
`typescript
import { initRuntimeContext } from '@oamm/runtime-context';
// In your app entry point
initRuntimeContext({ storage: mySharedStorage });
`
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.
`typescript
import { initRuntimeContext } from '@oamm/runtime-context';
initRuntimeContext({
debug: true
});
`
When enabled, the library logs detailed information to console.debug prefixed with [runtime-context].
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.
`typescript
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.
`typescript`
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.
`typescript`
await ensureContext(() => ({ requestId: '123' }), async () => {
// If a context already existed, it is reused.
// Otherwise, a new object context is created for this scope.
});
If you are building a library that needs access to the request context, simply import getContext from this package.
`typescript
// 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.
* initRuntimeContext(config): Initializes the global storage and configuration (e.g., debug mode, storage injection).getContext
* : Returns the current context or undefined. Supports automatic type inference when using classes as keys.requireContext
* : Returns the current context or throws.runWithContext(fn)
* : Runs fn with an automatically initialized Map-based context (Scenario 1).runWithContext(key, ctx, fn)
* : Runs fn with a specific key in a Map-based multi-context (Scenario 2).runWithContext(ctx, fn)
* : Runs fn within 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).
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.
`typescript
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:
`typescript
// For classes:
const getSessionContext = () => getContext(SessionContext);
// For interfaces or default context (uses the whole store):
interface MyContext { user: string }
const getMyContext = () => getContext
// For specific keys with interfaces:
const getSpecific = () => getContext
// Later, call them without arguments
const session = getSessionContext(); // inferred as SessionContext | undefined
`
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.set(key, value)
* Map-based: Performs .object[key] = value
* Raw Object: 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.contextKey
* If is provided, it targets the object stored under that key in the Map.setContext(key, value)
* If not provided, it targets the Default Context object. If no default object exists, it falls back to .getValue(key, contextKey?)
* : Gets a property from a context object.contextKey
* If is provided, it targets the object at that key.key
* If not provided, it first checks the Map for . If not found, it falls back to checking the Default Context object for that property.mergeContext(partial, contextKey?)
* : Merges an object into a context object (targets the Default Context if no contextKey is 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.
`typescript
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'
});
});
`
* setItem(key, value)getItem(key)
*
* Node.js: Automatically uses AsyncLocalStorage.AsyncContextStorage
* Edge/Other: You must inject an implementation or use initRuntimeContext({ storage }) if you want to share storage across libraries. By default, it will throw an error if you try to run without a valid storage.
Use resetForTests() in beforeEach to ensure test isolation.
`typescript
import { resetForTests } from '@oamm/runtime-context';
beforeEach(() => {
resetForTests();
});
``