Composition API pattern implementation for vanilla JavaScript libraries with context injection, async support, and namespace management.
—
Async context support enables maintaining context across asynchronous boundaries, solving the problem of context loss after await statements in JavaScript.
/**
* Wrapper for async functions requiring context preservation
* Shows warning if function is not transformed by build plugin
* @param fn - Async function that needs context preservation
* @param transformed - Internal flag indicating if function was transformed
* @returns The same function, potentially enhanced for context preservation
*/
function withAsyncContext<T = any>(
fn: () => Promise<T>,
transformed?: boolean
): () => Promise<T>;
/**
* Execute async function with context restoration helpers
* @param fn - Async function to execute
* @returns Tuple of [promise, restore function] for manual context restoration
*/
function executeAsync<T>(
fn: () => Promise<T>
): [Promise<T>, () => void];
interface ContextOptions {
/**
* Enable native async context support using AsyncLocalStorage
*/
asyncContext?: boolean;
/**
* AsyncLocalStorage implementation for async context
*/
AsyncLocalStorage?: typeof AsyncLocalStorage;
}By default, context is lost after the first await statement:
import { createContext } from "unctx";
const userContext = createContext<User>();
// ❌ Context is lost after await
userContext.call(userData, async () => {
console.log(userContext.use()); // ✅ Works - before await
await fetch("/api/data");
console.log(userContext.tryUse()); // ❌ Returns null - after await
});Use Node.js AsyncLocalStorage for native async context support:
import { createContext } from "unctx";
import { AsyncLocalStorage } from "node:async_hooks";
interface RequestContext {
requestId: string;
userId: number;
}
const requestContext = createContext<RequestContext>({
asyncContext: true,
AsyncLocalStorage
});
// ✅ Context persists across async boundaries
await requestContext.callAsync(
{ requestId: "req-123", userId: 42 },
async () => {
console.log(requestContext.use().requestId); // "req-123"
await fetch("/api/users");
// ✅ Context still available after await
console.log(requestContext.use().requestId); // "req-123"
await processNestedAsync();
}
);
async function processNestedAsync() {
// ✅ Context available in nested async functions
const ctx = requestContext.use();
await new Promise(resolve => setTimeout(resolve, 100));
console.log(ctx.requestId); // "req-123"
}Use build plugins to automatically transform async functions:
import { withAsyncContext } from "unctx";
const userContext = createContext<User>();
// Mark function for transformation
const processUser = withAsyncContext(async () => {
console.log(userContext.use()); // ✅ Available
await fetch("/api/data");
// ✅ Context restored automatically by transform
console.log(userContext.use()); // ✅ Available after await
});
await userContext.callAsync(userData, processUser);Use executeAsync for manual control:
import { executeAsync } from "unctx";
const userContext = createContext<User>();
await userContext.callAsync(userData, async () => {
// Manual async execution with restoration
const [promise, restore] = executeAsync(async () => {
return fetch("/api/data").then(r => r.json());
});
const result = await promise;
restore(); // Manually restore context
// ✅ Context available after manual restoration
console.log(userContext.use());
});AsyncLocalStorage enables proper context isolation for concurrent operations:
import { createContext } from "unctx";
import { AsyncLocalStorage } from "node:async_hooks";
const sessionContext = createContext<Session>({
asyncContext: true,
AsyncLocalStorage
});
// Multiple concurrent requests with isolated contexts
await Promise.all([
sessionContext.callAsync(
{ userId: "user1", sessionId: "sess1" },
async () => {
await new Promise(resolve => setTimeout(resolve, 100));
console.log(sessionContext.use().sessionId); // "sess1"
}
),
sessionContext.callAsync(
{ userId: "user2", sessionId: "sess2" },
async () => {
await new Promise(resolve => setTimeout(resolve, 200));
console.log(sessionContext.use().sessionId); // "sess2"
}
),
sessionContext.callAsync(
{ userId: "user3", sessionId: "sess3" },
async () => {
await new Promise(resolve => setTimeout(resolve, 50));
console.log(sessionContext.use().sessionId); // "sess3"
}
)
]);Context is properly restored even when errors occur:
const errorContext = createContext<string>({
asyncContext: true,
AsyncLocalStorage
});
try {
await errorContext.callAsync("test-value", async () => {
console.log(errorContext.use()); // "test-value"
await new Promise(resolve => setTimeout(resolve, 100));
throw new Error("Something went wrong");
});
} catch (error) {
console.error("Caught error:", error.message);
// Context is cleaned up properly
console.log(errorContext.tryUse()); // null
}const ctx = createContext<Data>();
await ctx.callAsync(data, async () => {
const [promise, restore] = executeAsync(async () => {
await riskyOperation();
return processData();
});
try {
const result = await promise;
restore();
// Use context after successful operation
const contextData = ctx.use();
return processResult(result, contextData);
} catch (error) {
restore(); // Restore even on error
throw error;
}
});const ctx = createContext<Config>();
await ctx.callAsync(config, async () => {
let restore: (() => void) | undefined;
try {
const [promise, restoreFn] = executeAsync(async () => {
return await longRunningOperation();
});
restore = restoreFn;
const result = await promise;
// Process with restored context
const currentConfig = ctx.use();
return finalizeResult(result, currentConfig);
} finally {
restore?.(); // Always restore context
}
});import { createContext } from "unctx";
import { AsyncLocalStorage } from "node:async_hooks";
const nodeContext = createContext({
asyncContext: true,
AsyncLocalStorage
});// Use polyfill or build-time transformation for non-Node environments
import { createContext } from "unctx";
// Without AsyncLocalStorage, use build plugins
const browserContext = createContext();
// Requires withAsyncContext + build transformation
const handler = withAsyncContext(async () => {
await browserContext.callAsync(data, async () => {
// Context preserved by transformation
const value = browserContext.use();
});
});// Cloudflare Workers provide AsyncLocalStorage
const workerContext = createContext({
asyncContext: true,
AsyncLocalStorage: globalThis.AsyncLocalStorage
});// AsyncLocalStorage has minimal overhead for context access
const perfContext = createContext<PerfData>({
asyncContext: true,
AsyncLocalStorage
});
// Efficient: context lookups are fast
function highFrequencyOperation() {
const data = perfContext.use(); // Fast lookup
return processData(data);
}// Native AsyncLocalStorage (recommended for Node.js)
const nativeContext = createContext({
asyncContext: true,
AsyncLocalStorage
});
// Build-time transformation (for universal compatibility)
const transformedHandler = withAsyncContext(async () => {
// Requires bundler plugin
});// ✅ For Node.js servers: Use AsyncLocalStorage
const serverContext = createContext({
asyncContext: true,
AsyncLocalStorage
});
// ✅ For universal libraries: Use withAsyncContext + plugins
const universalHandler = withAsyncContext(async () => {
// Works everywhere with proper build setup
});
// ✅ For manual control: Use executeAsync
const [promise, restore] = executeAsync(asyncOperation);// ✅ Cache context at function start for performance
async function processWithCaching() {
const ctx = myContext.use(); // Cache early
await Promise.all([
operation1(ctx), // Use cached value
operation2(ctx), // Use cached value
operation3(ctx) // Use cached value
]);
}Install with Tessl CLI
npx tessl i tessl/npm-unctx