Server Utilities Overview
Package: @tagna/udiot/server
Runtime: Node.js 22.17+
Primary use case: Express-based applications that want focused UDIOT helpers without adopting the custom router/server stack.
What This Module Provides
@tagna/udiot/server is a lightweight subpath for server-rendered and API-heavy apps. It exports utilities that compose cleanly with Express:
createNunjucksEngine()for Nunjucks setup, including per-request directory rendering viarenderFrom()(install the optional peernunjucks)createSessionMiddleware()for session wiring with either a custom store or PostgreSQL shorthandflashMiddleware()for request flash messages with accumulation supportcsrfMiddleware()for CSRF protection with route exclusionscreateUploadMiddleware()for disk uploads or custom Multer storage enginesexpressCacheMiddleware()for response caching on standard Express handlerscreateRequestScopeMiddleware()for per-request DI containers backed byAsyncLocalStoragetoExpressRouter()for mounting@Controller()classes as Express routers — reads@RequireAuth,@CurrentUser,@UseRateLimit, and route-level validation schemasparam()for normalizing Express 5req.params[key]values to a single stringcreateEventStream()for Server-Sent Events with memory or Redis-backed publish/subscribe
Production primitives (also re-exported from @tagna/udiot/web):
HttpError+ helpers (badRequest,unauthorized,forbidden,notFound,conflict,unprocessableEntity,tooManyRequests,internalServerError) — thrown errors auto-serialize as RFC 7807application/problem+jsonfileResponse(body, options)— returning aFileResponsefrom a controller method sends a typed binary download (Buffer, Uint8Array, string, orReadable)createSecurityHeadersExpress(options)— full CSP / HSTS / COOP / CORP / Permissions-Policy as Express middlewarecreateExpressRateLimit(options)— Express adapter for the universal rate limiter, withip/user/userOrIp/custom key strategies and pluggable storecreateEventStream(options)— Server-Sent Events helper for browser live updates, with heartbeat, auth gate, memory backend, and Redis backendgenerateOpenApi({ controllers, info, servers, securitySchemes })— emits an OpenAPI 3.0 document from controller decorator metadataredactSecrets(value, options)— masks values of secret-shaped keys (password,secret,token,api_key, …) for safe diagnosticscompileRouteValidator/runRouteValidation— building blocks behind the route-level schemas wired intotoExpressRouter()
Quick Start
import express from 'express';
import {
createNunjucksEngine,
createRequestScopeMiddleware,
createSessionMiddleware,
csrfMiddleware,
flashMiddleware,
toExpressRouter,
} from '@tagna/udiot/server';
import { Container } from '@tagna/udiot';
const app = express();
const container = new Container();
const views = createNunjucksEngine(['./views', './themes/default/views']);
const csrf = csrfMiddleware({
exclude: ['/webhooks/*'],
});
views.express(app);
app.use(express.json());
app.use(createSessionMiddleware({ secret: process.env.SESSION_SECRET! }));
app.use(flashMiddleware());
app.use(createRequestScopeMiddleware({ container }));
app.use(csrf.token);
app.use('/api', toExpressRouter(MyController, { container, includeBasePath: false }));
Server-Sent Events
Use createEventStream() for one-way browser updates such as live agent
progress, build logs, notifications, or run-event tails:
import { createEventStream } from '@tagna/udiot/server';
const runs = createEventStream({
channel: 'runs',
heartbeatMs: 15000,
// Application-owned: verify credentials AND access to this stream/channel.
authorize: async (req) => verifyRunAccess(req),
});
app.get('/events/runs', runs.handler);
await runs.publish({ runId: 'run_123', status: 'started' }, 'run');
For multi-instance deployments, pass backend: 'redis' plus separate ioredis
publisher and subscriber clients. See event-streams.md.
Templates
Use renderFrom() when the template search path depends on the request, such as multi-site theming:
const engine = createNunjucksEngine('./views');
app.get('/:siteSlug', async (req, res, next) => {
try {
const html = engine.renderFrom(
`./themes/${req.params.siteSlug}/views`,
'home.njk',
{ site: req.params.siteSlug },
);
res.send(html);
} catch (error) {
next(error);
}
});
For absolute or already-resolved template paths, use renderFile(). Async Nunjucks filters can be registered with addAsyncFilter().
renderFrom() reuses one environment per set of view directories, so template
caches survive between requests, and globals or filters added later reach every
environment.
Built-in filters are autoescape-safe. {{ comment | nl2br }} escapes the text
and only marks the inserted <br> tags as safe, so never add | safe after it.
truncate(length, killwords = true, end = '…') accepts Nunjucks' built-in
arguments; pass killwords: false to cut at a word boundary.
Sessions, Flash, and CSRF
Sessions
createSessionMiddleware() accepts:
storefor anyexpress-sessioncompatible storepgPoolas a shorthand forconnect-pg-simplecookieName, defaulting toudiot.sid
If you omit both store and pgPool, Express uses its default in-memory store, which is useful for local development.
Cookies are secure in production. Behind a reverse proxy that terminates TLS,
set trustProxy on createWebServer() (or app.set('trust proxy', 1) on a
plain Express app); otherwise Express sees an insecure connection and never
sends the cookie. Prefer a hop count or the proxies' addresses over true,
which trusts X-Forwarded-For from any client and lets callers choose the IP
that rate limits see. Sessions expire after 7 days without activity (maxAgeDays;
set rolling: false to count from sign-in instead), and pgPool stores them
in the udiot_sessions table (tableName).
Flash
flashMiddleware() now accumulates multiple messages per type. Read arrays from res.locals.flashMessages:
req.flash('error', 'Title is required');
req.flash('error', 'Slug already exists');
// Later in the request lifecycle:
res.locals.flashMessages.error;
// ['Title is required', 'Slug already exists']
CSRF
Use csrfMiddleware({ exclude: [...] }) to skip CSRF validation for webhooks, health checks, or bearer-token APIs:
const csrf = csrfMiddleware({
exclude: ['/webhooks/*', '/health'],
// Other sites allowed to post forms here, if any.
trustedOrigins: ['https://admin.example.com'],
});
app.post('/login', csrf.protect, async (req, res) => {
// ...authenticate, then regenerate the session and issue a fresh token.
res.json({ csrfToken: csrf.rotate(req) });
});
At sign-in, give the session a new id so an id planted before login is useless afterwards (session fixation), then issue a fresh CSRF token:
import { regenerateSession } from '@tagna/udiot/server';
app.post('/login', csrf.protect, async (req, res) => {
const user = await authenticate(req.body);
await regenerateSession(req); // new session id, existing session data kept
req.session.userId = user.id;
res.json({ csrfToken: csrf.rotate(req) });
});
As defense in depth, protect also rejects unsafe requests whose Origin
header (or, without one, Referer) names a different host, and Origin: null.
Requests without either header still need a valid token. Set
checkOrigin: false only if a trusted proxy rewrites these headers. Call
rotate(req) after login or privilege changes so a token captured before
sign-in stops working.
Single-page apps
A single-page app has no server-rendered form to carry the _csrf field.
Expose the token through an endpoint and send it in the X-CSRF-Token header
(or the headerName you configured) on every unsafe request:
app.get('/api/csrf', csrf.token, (_req, res) => {
res.json({ csrfToken: res.locals.csrfToken });
});
// In the browser
const { csrfToken } = await fetch('/api/csrf', { credentials: 'same-origin' }).then((r) => r.json());
await fetch('/api/orders', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ sku: 'lamp-1' }),
});
Keep the token in memory rather than localStorage, and fetch it again after
sign-in, because rotate() invalidates the old one. The session cookie
defaults to SameSite=Lax, which already stops cross-site form posts; the
token covers the rest. When the app and the API are on different origins, add
the app's origin to trustedOrigins and allow it with credentials in your CORS
settings. Requests authenticated only by an Authorization: Bearer header,
with no cookie, cannot be forged cross-site; list those routes in exclude.
Uploads
Disk uploads still work via destDir, but you can now inject any Multer storage engine:
const upload = createUploadMiddleware({
storage: multer.memoryStorage(),
allowedMimeTypes: ['image/png', 'image/webp'],
});
This makes it straightforward to plug in multer-s3, multer-gcs, or any custom storage backend.
With destDir, the stored file's extension always comes from the validated
MIME type, never from the client's file name: evil.html uploaded as
application/pdf is saved as .pdf. Types without a known extension keep the
original one unless it is executable or renderable (.html, .svg, .js,
...), which becomes .bin. File contents are checked against the claimed type
by default for images, PDF and ZIP (verifyMagicBytes: false turns this off).
Request-Scoped DI
UDIOT's Container already supports ServiceScope.SCOPED. createRequestScopeMiddleware() connects that scope to Express requests:
import { Inject, Injectable, ServiceScope } from '@tagna/udiot';
import { EXPRESS_REQUEST, createRequestScopeMiddleware } from '@tagna/udiot/server';
@Injectable({ scope: ServiceScope.SCOPED })
class CurrentRequest {
constructor(@Inject(EXPRESS_REQUEST) private readonly req: Request) {}
get id(): string {
return String(this.req.headers['x-request-id'] ?? '');
}
}
Each request gets a child container, and toExpressRouter() resolves controller classes against that request container automatically.
Controllers as Express Routers
If you prefer decorators but need Express compatibility, adapt controller classes directly:
import { Controller, Get, Param } from '@tagna/udiot';
import { toExpressRouter } from '@tagna/udiot/server';
@Controller('/posts')
class PostsController {
@Get('/:id')
show(@Param('id') id: string) {
return { id };
}
}
app.use(toExpressRouter(PostsController, { container }));
toExpressRouter() respects controller base paths, route middleware, request-scoped DI, and the standard parameter decorators (@Param, @Query, @Body, @Header, @Req, @Res, @CurrentUser).
ToExpressRouterOptions
| Field | Purpose |
|---|---|
container |
DI container used to resolve controller instances. Defaults to globalContainer. |
includeBasePath |
false returns the inner router without mounting the controller's path prefix. |
mergeParams |
Forwarded to Router({ mergeParams }). Defaults to true. |
rateLimits |
Record<string, RequestHandler> — pre-built named policies looked up via @UseRateLimit('name'). The web server populates this from WebAppServerConfig.rateLimits automatically. |
authResolver |
(req) => unknown | Promise<unknown> — resolves the auth principal for @RequireAuth-tagged routes. Falsy result → 401 Problem Details (unless the route declares optional: true). |
problemDetails |
When true (default), thrown HttpErrors serialize as application/problem+json. Set false for the legacy { error, message } envelope. |
HTTP Errors and Problem Details
HttpError is the server-side error meant to be thrown from controller
methods. Distinct from the existing client-side HttpError in
enterprise/http.ts (which represents a failed fetch() response) — this
one is exported from @tagna/udiot/server and re-exported from
@tagna/udiot/web as ServerHttpError to avoid collision at the package root.
import {
badRequest,
conflict,
notFound,
unprocessableEntity,
} from '@tagna/udiot/server';
@Post('/leads')
async create(@Body() body: { email?: string }) {
if (!body.email) {
throw badRequest('Email is required', { code: 'EMAIL_REQUIRED' });
}
// ...
}
Default response shape (RFC 7807):
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Email is required",
"code": "EMAIL_REQUIRED"
}
Pass errors: [...] for field-level validation details, extensions: {...}
for arbitrary additional members, and headers: {...} for headers to set
alongside the response (e.g. Retry-After, WWW-Authenticate).
File and Binary Responses
import { fileResponse } from '@tagna/udiot/server';
@Get('/reports/:id/download')
async download(@Param('id') id: string) {
const report = await reports.findById(id);
return fileResponse(report.pdfBytes, {
fileName: `${report.slug}.pdf`,
contentType: 'application/pdf',
cacheControl: 'private, no-store',
});
}
Returning a FileResponse from a controller is enough — the adapter sets
Content-Type, Content-Length, and an RFC 5987 Content-Disposition
(with safe filename escaping). Body accepts Buffer | Uint8Array | string | NodeJS.ReadableStream.
Configurable Security Headers
createSecurityHeadersExpress(options) is the Express adapter for the
options bag accepted by WebAppServerConfig.securityHeaders:
import { createSecurityHeadersExpress } from '@tagna/udiot/server';
app.use(createSecurityHeadersExpress({
contentSecurityPolicy:
"default-src 'self'; script-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'",
strictTransportSecurity: 'max-age=31536000; includeSubDomains; preload',
frameOptions: 'DENY',
permissionsPolicy: 'camera=(), microphone=(), geolocation=()',
}));
Pass false for any header to skip it. Sensible production defaults applied
when fields are omitted.
Express Rate Limiting
import { createExpressRateLimit } from '@tagna/udiot/server';
app.use('/api/checkout', createExpressRateLimit({
maxRequests: 20,
windowMs: 60_000,
key: 'userOrIp', // 'ip' | 'user' | 'userOrIp' | (req) => string
}));
Returns Retry-After and X-RateLimit-Limit / X-RateLimit-Remaining
headers. With @RequireAuth routes, user and userOrIp limits run after the
principal is resolved, while IP limits run first so anonymous floods are cut
off before authentication work; override with afterAuth. Behind a proxy, IP
keys need trustProxy to see the real client address. Distributed deployments can pass an ExpressRateLimitStore for
shared state (e.g. Redis). When wiring through createWebServer, prefer
named policies (WebAppServerConfig.rateLimits) plus @UseRateLimit
on the route so policy stays out of the handler.
OpenAPI Generation
import { generateOpenApi } from '@tagna/udiot/server';
const doc = generateOpenApi({
info: { title: 'Catalog API', version: '1.0.0' },
controllers: [ProductsController, OrdersController],
servers: [{ url: 'https://api.example.com' }],
securitySchemes: { bearer: { type: 'http', scheme: 'bearer' } },
});
The generator walks every controller's route metadata, translates UDIOT
ValidationSchema maps to JSON Schema, infers path parameters, and emits
security for @RequireAuth-tagged routes plus x-rateLimit for
@UseRateLimit. When using createWebServer, pass the same options under
the openapi key and the framework serves the document at
/openapi.json automatically — see web overview.
Redacting Secrets
import { redactSecrets } from '@tagna/udiot/server';
const safe = redactSecrets({
databaseUrl: 'postgres://user:pass@host/db',
paymentApiKey: 'sk-secret-12345',
features: { newCheckout: true },
});
// { databaseUrl: '...', paymentApiKey: '***', features: { newCheckout: true } }
Default patterns mask password, secret, token, api_key,
private_key, access_key, authorization, credential (case-insensitive,
key-name match). Pass patterns to extend, maxDepth to bound recursion.
Response Caching
expressCacheMiddleware() mirrors the existing cache manager but for Express request/response objects:
app.get(
'/api/posts',
expressCacheMiddleware({ ttl: 300, tags: ['posts'] }),
postsHandler,
);
Responses are marked with X-Cache: MISS on fill and X-Cache: HIT on replay.
Only complete 200 responses are stored, and binary bodies are stored as base64
so they replay byte for byte from JSON-based stores such as Redis.
Param Helper
Express 5 parameter values can be string | string[]. Use param(req, 'id') when you need a single string consistently:
import { param } from '@tagna/udiot/server';
const id = param(req, 'id');