Skip to content

Repository files navigation

nctx

npm versionLicense: MIT

What is nctx?

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 (IoC) and Dependency Injection

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.

Table of Contents

Installation

# Using npm
npm install nctx
# Using yarn
yarn add nctx

Requirements: Node.js 16 or higher

Core Concepts

Async Context

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.

Context

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

Registry

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

Providing and Accessing Context

The core workflow with nctx involves:

  1. Creating a context
  2. Providing the context for an async operation
  3. Setting values in the context
  4. Getting values from the context within the async tree

Basic Usage

JavaScript Example

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

TypeScript Example

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

Common Use Cases

Forking Contexts

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.

Why Fork Contexts?

  • Run parallel operations with different context values
  • Isolate changes to prevent them from affecting the parent context
  • Create temporary context modifications

Simple Forking Example

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}

Deep vs. Shallow Forking

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.

Express Integration

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

Logging and Error Handling

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

API Reference

Context Creation

// Create a new contextconstmyContext=nctx.create(Symbol('myContext'));

Context Methods

MethodDescriptionExample
provide(callback, ref?, syncFollowers?, forceOverride?)Establishes a context for the async operationmyContext.provide(() => { /* async operations */ })
get(key)Retrieves a value from the contextconst value = myContext.get('key')
set(key, value)Sets a value in the contextmyContext.set('key', 'value')
require(key, strict?)Gets a value, throws if not foundconst value = myContext.require('key')
fork(callback, deepFork?, syncFollowers?)Creates an isolated copy of the contextmyContext.fork(() => { /* operations with isolated context */ })
isProvided()Checks if the context is providedif (myContext.isProvided()) { /* ... */ }
share(ref)Shares context with a referencemyContext.share(req)
endShare(ref)Ends context sharingmyContext.endShare(req)
follow(ctx)Makes this context follow anothermyContext.follow(otherContext)
unfollow(ctx)Stops following another contextmyContext.unfollow(otherContext)
fallback(ctx)Sets a fallback contextmyContext.fallback(defaultContext)
merge(...params)Merges values into the contextmyContext.merge({ key1: 'value1', key2: 'value2' })
assign(obj)Assigns an object to the contextmyContext.assign({ key1: 'value1', key2: 'value2' })
replace(key, callback)Updates a value using a callbackmyContext.replace('counter', count => count + 1)

Static Methods

MethodDescriptionExample
nctx.create(name?)Creates a new contextconst ctx = nctx.create(Symbol('name'))
nctx.provide(ctxArr, callback, ref?, syncFollowers?, forceOverride?)Provides multiple contextsnctx.provide([ctx1, ctx2], () => { /* ... */ })
nctx.fork(ctxArr, callback, deepFork?, syncFollowers?)Forks multiple contextsnctx.fork([ctx1, ctx2], () => { /* ... */ })

Advanced Usage

Context Relationships

nctx allows you to establish relationships between contexts:

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

Fallback Contexts

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

Sharing Contexts

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

Extending Contexts

You can extend contexts with custom getter and setter methods to create a more intuitive and type-safe API:

JavaScript Example

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

TypeScript Example

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:

  1. Type safety for both getting and setting values
  2. Method chaining for a more fluent API
  3. Better encapsulation of the underlying implementation
  4. Improved developer experience with IDE autocompletion

Best Practices

Do's

  • 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 of get() when a value must be present

Don'ts

  • Don't forget to provide a context before using it
  • Don't rely on context outside of its async tree without explicit sharing

Running the Examples

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

Related Libraries

Contributing

We 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:

About

NodeJS Contextual Dependency Injection using native async_hooks - IoC

Resources

Stars

6 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages