als-kit
v1.0.1
Published
Ergonomic, TypeScript-first wrapper around Node.js AsyncLocalStorage — typed stores, flat middleware, automatic emitter binding.
Maintainers
Readme
als-kit
Ergonomic, TypeScript-first wrapper around Node.js AsyncLocalStorage.
Typed stores, flat middleware, automatic emitter binding — no prop drilling, no unsafe getStore(), no mutation bleed across concurrent requests.
Requires Node.js ≥ 18
Install
npm install als-kitAPI
createStore<T>()
Creates a typed store instance. Call once per context shape and export it.
import { createStore } from 'als-kit';
type RequestCtx = {
lang: string;
apiKey: string;
userId: string;
};
export const store = createStore<RequestCtx>();store.subscribe(...emitters)
Patches each emitter's .on() so every listener added to it automatically runs in the current async context. Prevents context loss in req/res event callbacks and stream handlers.
Call this at the top of your middleware, before set().
store.subscribe(req, res); // bind both at once
// or individually
store.subscribe(req);
store.subscribe(res);store.set(fn)
Initializes or updates the store for the current request's async context. Uses enterWith() internally — no callback nesting required.
// Initialize
store.set(() => ({
lang: req.headers['content-language'] ?? 'en',
apiKey: req.headers['x-api-key'] ?? '',
userId: '',
}));
// Enrich mid-request (e.g. after JWT decode)
store.set(state => ({ ...state, userId: decoded.sub }));store.get(selector?)
Returns the full store or a selected slice. Throws a descriptive error if called before set().
// Full state
const state = store.get();
// Selected slice — typed and autocompleted
const lang = store.get(s => s.lang);
const apiKey = store.get(s => s.apiKey);store.bind(fn)
Manually wraps a function with AsyncResource.bind() — for emitters you didn't pass to subscribe().
myEmitter.on('data', store.bind((chunk) => {
const lang = store.get(s => s.lang); // context preserved
}));Real-World Example
Middleware
import { createStore } from 'als-kit';
import type { RequestHandler } from 'express';
type RequestCtx = {
lang: string;
apiKey: string;
userId: string;
};
export const store = createStore<RequestCtx>();
export const bindContext: RequestHandler = (req, res, next) => {
store.subscribe(req, res); // patch emitters
store.set(() => ({
lang: req.headers['content-language'] as string ?? 'en',
apiKey: req.headers['x-api-key'] as string ?? '',
userId: '',
}));
next();
};
export const authMiddleware: RequestHandler = async (req, res, next) => {
try {
const token = req.headers['authorization']?.replace('Bearer ', '');
const payload = await verifyJwt(token);
store.set(state => ({ ...state!, userId: payload.sub }));
next();
} catch {
res.status(401).json({ message: 'Unauthorized' });
}
};// app.ts
app.use(bindContext);
app.use(authMiddleware);Service — no parameters needed
import { store } from './store';
export async function createOrder(data: OrderInput) {
const { userId, apiKey } = store.get();
// userId and apiKey are available automatically
return db.orders.create({ ...data, userId });
}Repository — tenant-scoped automatically
import { store } from './store';
export async function findOrders() {
const userId = store.get(s => s.userId);
return db.orders.find({ userId });
}Logger — correlation fields on every line
import { store } from './store';
import pino from 'pino';
const base = pino();
export function getLogger() {
const s = store.get();
return base.child({ lang: s.lang, apiKey: s.apiKey });
}Comparison with continuation-local-storage
| CLS | als-kit |
|---|---|
| createNamespace('default') | createStore<T>() |
| namespace.bindEmitter(req) | store.subscribe(req) |
| namespace.run(() => { ... }) | not needed — set() uses enterWith() |
| namespace.set('key', value) | store.set(s => ({ ...s, key: value })) |
| namespace.get('key') | store.get(s => s.key) |
Key improvement: instead of stringly-typed get('key') / set('key', value), every read and write is fully typed, autocompleted, and refactor-safe.
How it works internally
subscribe— patchesemitter.on()to wrap every listener withAsyncResource.bind(), capturing the current execution context.set— callsals.enterWith(Object.freeze(newState)). Each HTTP request in Node.js runs in its own async context, soenterWith()never leaks across requests.get— callsals.getStore()and throws a clear error if nothing was set, instead of silently returningundefined.bind— thin wrapper aroundAsyncResource.bind()for one-off manual bindings.
