nctx is a lightweight, powerful Dependency Injection (DI) container for Node.js applications, enhanced with native async_hooks capabilities. It implements the Inversion of Control (IoC) pattern, allowing you to decouple components and manage dependencies throughout your application's asynchronous execution flow.
Inversion of Control is a design principle where the control flow of a program is inverted: instead of your code controlling when and how dependencies are created and used, this control is delegated to an external container. Dependency Injection is a specific implementation of IoC where dependencies are "injected" into components rather than created within them.
nctx provides an elegant solution for implementing these patterns in Node.js applications, making it easier to:
- Share request-scoped data across your application without passing it through function parameters
- Isolate execution contexts in concurrent operations
- Implement clean dependency injection patterns
- Avoid callback hell and parameter pollution
Built on Node.js's native async_hooks API, nctx maintains context across asynchronous boundaries automatically, with minimal overhead.
- What is nctx?
- Table of Contents
- Installation
- Core Concepts
- Basic Usage
- Common Use Cases
- API Reference
- Advanced Usage
- Best Practices
- Running the Examples
- Related Libraries
- Contributing
# Using npm
npm install nctx
# Using yarn
yarn add nctxRequirements: Node.js 16 or higher
In asynchronous applications, tracking the execution context across callbacks, promises, and event handlers can be challenging. nctx leverages Node.js's AsyncLocalStorage to maintain context throughout the entire asynchronous execution tree.
A Context is a container for storing and retrieving values within an asynchronous execution flow. Each context:
- Has a unique identifier (name)
- Can store values using keys
- Maintains isolation between different execution paths
- Can be forked to create isolated sub-contexts
A Registry is the internal storage mechanism for a context. It contains:
- An object store for string/number keys
- A Map for non-primitive keys (like Symbols)
- Optional parent reference for hierarchical lookups
The core workflow with nctx involves:
- Creating a context
- Providing the context for an async operation
- Setting values in the context
- Getting values from the context within the async tree
// CommonJSconstnctx=require('nctx');// Create a contextconstmyContext=nctx.create(Symbol('myContext'));asyncfunctionmain(){// Provide a context for the async operationawaitmyContext.provide(async()=>{// Set a value in the contextmyContext.set('message','Hello, World!');// The value is available anywhere in this async treeawaitsomeAsyncOperation();});}asyncfunctionsomeAsyncOperation(){// Get the value from the contextconstmessage=myContext.get('message');console.log(message);// Outputs: Hello, World!}main().catch(console.error);importnctxfrom'nctx';import{Context}from'nctx';// Create a context with type annotationconstmyContext: Context=nctx.create(Symbol('myContext'));// Define an interface for your context data (optional but recommended)interfaceAppContext{message: string;count: number;user?: {id: string;role: 'admin'|'user';};}asyncfunctionmain(): Promise<void>{awaitmyContext.provide(async()=>{// Set values with proper typesmyContext.set('message','Hello, TypeScript!');myContext.set('count',42);myContext.set('user',{id: 'user-123',role: 'admin'asconst});awaitsomeAsyncOperation();});}asyncfunctionsomeAsyncOperation(): Promise<void>{// Get values with type assertionsconstmessage=myContext.get('message')asstring;constcount=myContext.get('count')asnumber;constuser=myContext.get('user')asAppContext['user'];console.log(message);// Hello, TypeScript!console.log(`Count: ${count}`);// Count: 42if(user){console.log(`User: ${user.id}, Role: ${user.role}`);}}main().catch((error: Error)=>{console.error('Error:',error.message);});Forking allows you to create isolated copies of a context, which is particularly useful for handling concurrent operations where each needs its own context values.
- Run parallel operations with different context values
- Isolate changes to prevent them from affecting the parent context
- Create temporary context modifications
constnctx=require('nctx');constuserContext=nctx.create(Symbol('userContext'));asyncfunctionprocessUsers(users){awaituserContext.provide(async()=>{// Set a default valueuserContext.set('role','guest');// Process each user in parallel with isolated contextsconstresults=awaitPromise.all(users.map(user=>// Fork the context for each parallel operationnctx.fork([userContext],async()=>{// This change only affects this forked contextuserContext.set('userId',user.id);userContext.set('role',user.role);returnprocessUserData(user);})));// Here, userContext still has role='guest' and no userIdconsole.log(userContext.get('role'));// 'guest'returnresults;});}asyncfunctionprocessUserData(user){// Access the forked context valuesconstuserId=userContext.get('userId');constrole=userContext.get('role');console.log(`Processing user ${userId} with role ${role}`);// ... processing logic}nctx supports two forking modes:
// Shallow fork (default) - Object references are sharednctx.fork([myContext],()=>{/* ... */});// Deep fork - Creates deep copies of objectsnctx.fork([myContext],()=>{/* ... */},true);With shallow forking (the default), object references are shared between the parent and forked context. With deep forking, objects are deeply cloned, allowing you to modify nested properties without affecting the parent context.
nctx is particularly useful in web applications where you need to maintain request-scoped data. Here's how to integrate it with Express:
// ctx/req.jsconstnctx=require('nctx');constreqCtx=nctx.create(Symbol('req'));// Create middleware to establish the request contextreqCtx.createAppMiddleware=()=>{return(req,res,next)=>{reqCtx.provide(()=>{// Share the context with the request objectreqCtx.share(req);// Clean up when the response is finishedres.on('finish',()=>{reqCtx.endShare(req);});// Store the request object in the contextreqCtx.set('req',req);next();});};};// Middleware for routers to ensure they have access to the contextreqCtx.createRouterMiddleware=()=>{return(req,_res,next)=>{reqCtx.share(req);if(next){next();}};};module.exports=reqCtx;// app.jsconstexpress=require('express');constreqCtx=require('./ctx/req');constapp=express();// Apply the context middlewareapp.use(reqCtx.createAppMiddleware());// Add request-specific data to the contextapp.use(async(req,_res,next)=>{constlogger=createLogger().child({requestId: req.id,path: req.path});reqCtx.set('logger',logger);// You could also add user info after authentication// reqCtx.set('user', req.user);next();});constrouter=express.Router();router.use(reqCtx.createRouterMiddleware());app.use(router);// Now you can access the context anywhere in your route handlersrouter.get('/api/data',async(req,res)=>{// Get the request-scoped loggerconstlogger=reqCtx.get('logger');logger.info('Processing request');// Business logic...constdata=awaitfetchData();res.json(data);});// Even in deeply nested service functionsasyncfunctionfetchData(){constlogger=reqCtx.get('logger');logger.debug('Fetching data');// The logger is specific to the current requestreturn{/* ... */};}app.listen(3000);nctx makes it easy to implement consistent logging with request-specific information:
// Create a logger contextconstloggerCtx=nctx.create(Symbol('logger'));// Middleware to set up the loggerfunctionloggerMiddleware(req,res,next){loggerCtx.provide(()=>{constrequestId=generateRequestId();// Create a request-specific loggerconstlogger=createBaseLogger().child({
requestId,path: req.path,method: req.method});// Store in contextloggerCtx.set('logger',logger);loggerCtx.set('requestId',requestId);// Add requestId to response headersres.setHeader('X-Request-ID',requestId);// Log the requestlogger.info(`Received ${req.method} request to ${req.path}`);// Track timingconststartTime=Date.now();res.on('finish',()=>{constduration=Date.now()-startTime;logger.info(`Request completed in ${duration}ms with status ${res.statusCode}`);});next();});}// Now you can access the logger anywherefunctionbusinessLogic(){constlogger=loggerCtx.get('logger');logger.debug('Executing business logic');try{// ... logic}catch(error){// Log with request context already includedlogger.error('Error in business logic',{error: error.message});throwerror;}}// Create a new contextconstmyContext=nctx.create(Symbol('myContext'));| Method | Description | Example |
|---|---|---|
provide(callback, ref?, syncFollowers?, forceOverride?) | Establishes a context for the async operation | myContext.provide(() => { /* async operations */ }) |
get(key) | Retrieves a value from the context | const value = myContext.get('key') |
set(key, value) | Sets a value in the context | myContext.set('key', 'value') |
require(key, strict?) | Gets a value, throws if not found | const value = myContext.require('key') |
fork(callback, deepFork?, syncFollowers?) | Creates an isolated copy of the context | myContext.fork(() => { /* operations with isolated context */ }) |
isProvided() | Checks if the context is provided | if (myContext.isProvided()) { /* ... */ } |
share(ref) | Shares context with a reference | myContext.share(req) |
endShare(ref) | Ends context sharing | myContext.endShare(req) |
follow(ctx) | Makes this context follow another | myContext.follow(otherContext) |
unfollow(ctx) | Stops following another context | myContext.unfollow(otherContext) |
fallback(ctx) | Sets a fallback context | myContext.fallback(defaultContext) |
merge(...params) | Merges values into the context | myContext.merge({ key1: 'value1', key2: 'value2' }) |
assign(obj) | Assigns an object to the context | myContext.assign({ key1: 'value1', key2: 'value2' }) |
replace(key, callback) | Updates a value using a callback | myContext.replace('counter', count => count + 1) |
| Method | Description | Example |
|---|---|---|
nctx.create(name?) | Creates a new context | const ctx = nctx.create(Symbol('name')) |
nctx.provide(ctxArr, callback, ref?, syncFollowers?, forceOverride?) | Provides multiple contexts | nctx.provide([ctx1, ctx2], () => { /* ... */ }) |
nctx.fork(ctxArr, callback, deepFork?, syncFollowers?) | Forks multiple contexts | nctx.fork([ctx1, ctx2], () => { /* ... */ }) |
nctx allows you to establish relationships between contexts:
When context A follows context B, operations on B will also affect A:
constcontextA=nctx.create(Symbol('A'));constcontextB=nctx.create(Symbol('B'));// Make A follow BcontextA.follow(contextB);// Now when you provide B, A is also providedcontextB.provide(()=>{contextB.set('key','value');// A can access the valueconsole.log(contextA.get('key'));// 'value'});You can set a fallback context to use when a key isn't found:
constmainContext=nctx.create(Symbol('main'));constdefaultContext=nctx.create(Symbol('default'));// Set up the default contextdefaultContext.provide(()=>{defaultContext.set('theme','dark');defaultContext.set('language','en');// Set main context to fall back to defaultmainContext.fallback(defaultContext);mainContext.provide(()=>{// Override just one settingmainContext.set('theme','light');// This comes from main contextconsole.log(mainContext.get('theme'));// 'light'// This falls back to default contextconsole.log(mainContext.get('language'));// 'en'});});The share method allows you to associate a context with a reference (like a request object):
constreqCtx=nctx.create(Symbol('req'));functionmiddleware(req,res,next){reqCtx.provide(()=>{// Associate this context with the requestreqCtx.share(req);// Later, in another middleware or route handler// that has the same req object:reqCtx.share(req);// This will reuse the same context// Clean up when doneres.on('finish',()=>{reqCtx.endShare(req);});next();});}You can extend contexts with custom getter and setter methods to create a more intuitive and type-safe API:
constnctx=require('nctx');// Create a base contextconstappContext=nctx.create(Symbol('app'));// Extend the context with custom gettersappContext.getLogger=function(){returnthis.get('logger');};appContext.getConfig=function(){returnthis.get('config');};// Add custom settersappContext.setLogger=function(logger){this.set('logger',logger);returnthis;// For method chaining};appContext.setConfig=function(config){this.set('config',config);returnthis;// For method chaining};// UsageappContext.provide(()=>{// Use the setterappContext.setLogger(createLogger());// Use the getterconstlogger=appContext.getLogger();logger.info('Application started');// Method chainingappContext.setConfig({env: 'production'}).set('version','1.0.0');});importnctxfrom'nctx';import{Context}from'nctx';import{Logger}from'your-logger-library';// Define extended context interfaceinterfaceAppContextextendsContext{// GettersgetLogger(): Logger;getConfig(): Record<string,any>;// SetterssetLogger(logger: Logger): this;setConfig(config: Record<string,any>): this;}// Create and extend the contextconstbaseContext=nctx.create(Symbol('app'));constappContext=baseContextasAppContext;// Implement the gettersappContext.getLogger=function(this: AppContext): Logger{returnthis.get('logger')asLogger;};appContext.getConfig=function(this: AppContext): Record<string,any>{returnthis.get('config')asRecord<string,any>;};// Implement the settersappContext.setLogger=function(this: AppContext,logger: Logger): AppContext{this.set('logger',logger);returnthis;};appContext.setConfig=function(this: AppContext,config: Record<string,any>): AppContext{this.set('config',config);returnthis;};// Usage with proper typingappContext.provide(()=>{// Use the setterappContext.setLogger(createLogger());// Use the getter with proper typeconstlogger: Logger=appContext.getLogger();logger.info('Application started with typed logger');// Method chaining with type safetyappContext.setConfig({env: 'production'}).set('version','1.0.0');});This approach provides several benefits:
- Type safety for both getting and setting values
- Method chaining for a more fluent API
- Better encapsulation of the underlying implementation
- Improved developer experience with IDE autocompletion
- Use symbols for context names to avoid naming collisions
- Clean up shared contexts when they're no longer needed
- Use TypeScript interfaces to define your context structure
- Keep contexts focused on specific concerns (e.g., request context, user context)
- Use
require()instead ofget()when a value must be present
- Don't forget to provide a context before using it
- Don't rely on context outside of its async tree without explicit sharing
The package includes examples for both CommonJS and TypeScript usage:
# Run the CommonJS example
npm run example:js
# Run the TypeScript example (requires ts-node)
npm run example:ts
# Check TypeScript types (verify that the types compile correctly)
npm run check-typesWe welcome contributions! If you encounter a bug or have a feature suggestion, please open an issue. To contribute code, simply fork the repository and submit a pull request.
This repository is mirrored on both GitHub and Codeberg. Contributions can be made on either platform, as the repositories are synchronized bidirectionally.
For more information: