Skip to content

Shared, injectable request-scoped runtime context for Node.js and Edge runtimes

License

Notifications You must be signed in to change notification settings

oamm/runtime-context

Repository files navigation

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

Usage

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 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)

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., debug mode, storage injection).
  • getContext<T>(key?): Returns the current context or undefined. Supports automatic type inference when using classes as keys.
  • requireContext<T>(key?): 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).

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 | undefined

Helpers

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.
  • 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 contextKey is 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).
  • getValue(key, contextKey?): Gets a property from a context object.
    • If contextKey is 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.
  • 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.

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

Testing

Use resetForTests() in beforeEach to ensure test isolation.

import { resetForTests } from '@oamm/runtime-context';

beforeEach(() => {
  resetForTests();
});

About

Shared, injectable request-scoped runtime context for Node.js and Edge runtimes

Resources

License

Stars

Watchers

Forks

Packages

No packages published