CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-unctx

Composition API pattern implementation for vanilla JavaScript libraries with context injection, async support, and namespace management.

Pending
Overview
Eval results
Files

async-context.mddocs/

Async Context Support

Async context support enables maintaining context across asynchronous boundaries, solving the problem of context loss after await statements in JavaScript.

API Reference

/**
 * 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;
}

The Async Context Problem

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

Solutions

1. Native AsyncLocalStorage (Node.js/Modern Runtimes)

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"
}

2. Build-time Transformation

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

3. Manual Context Restoration

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());
});

Concurrent Async Contexts

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"
    }
  )
]);

Error Handling in Async Context

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
}

Manual Context Restoration Patterns

Pattern 1: Try/Catch with Restoration

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

Pattern 2: Finally Block Restoration

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

Platform Compatibility

Node.js Environment

import { createContext } from "unctx";
import { AsyncLocalStorage } from "node:async_hooks";

const nodeContext = createContext({
  asyncContext: true,
  AsyncLocalStorage
});

Browser/Edge Runtime

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

// Cloudflare Workers provide AsyncLocalStorage
const workerContext = createContext({
  asyncContext: true,
  AsyncLocalStorage: globalThis.AsyncLocalStorage
});

Performance Considerations

AsyncLocalStorage Overhead

// 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);
}

Transformation vs Native

// 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
});

Best Practices

Choose the Right Approach

// ✅ 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);

Context Caching

// ✅ 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

docs

async-context.md

build-plugins.md

context-creation.md

index.md

namespace-management.md

tile.json