Web Application Layer Overview
Package: @tagna/udiot/web
Runtime: Node.js 22.17+
Primary use case: server-rendered apps, static sites, and hybrid web apps that want typed app/page contracts and a public, stable-candidate web build/runtime surface.
What This Module Provides
@tagna/udiot/web is the public application layer on top of UDIOT's lower-level routing, SSR, SEO, and server primitives. It provides:
defineWebApp()for app-level site, SEO, asset, server, and startup-check configurationdefinePage()for typed page contracts- page discovery based on imported
pageexports rather than regex metadata parsing - page loaders and static path generation through the public RC API
WebBuilder/createWebBuilder()for route manifests, diagnostics, sitemap/robots generation, and static outputcreateWebServer()for Express-based runtime bootstrap with diagnostics endpoints, startup checks, configurable security headers, named rate-limit policies, auth resolver, RFC 7807 Problem Details, route-level validation, and OpenAPI emitcreateWebDevServer()for the same runtime surface plus watch/HMR wiringcreateMikroOrmMigrationCheck()andcreatePostgresMigrationCheck()for fail-fast migration safety integration
Looking for the end-to-end production setup (CSP, auth, file downloads, rate limits, OpenAPI, k8s probes, Postgres migrations)? See the production composition recipe.
Quick Start
import { defineWebApp, definePage, createWebBuilder } from '@tagna/udiot/web';
export const app = defineWebApp({
site: {
name: 'HeroReh',
url: 'https://heroreh.example',
},
seo: {
sitemap: true,
robots: true,
defaultIndexing: 'index',
},
});
export const page = definePage({
route: '/',
render: 'ssg',
loader: async () => ({
props: { message: 'Hello' },
seo: {
title: 'Home',
description: 'Landing page',
},
}),
});
const builder = createWebBuilder({
rootDir: process.cwd(),
outDir: './dist',
app,
});
await builder.build();
Page Contracts
Pages can define:
routerender:ssr,ssg,isr, orcsrrevalidateloader()staticPaths()seo
Example:
export const page = definePage({
route: '/blog/[slug]',
render: 'isr',
staticPaths: async () => [{ params: { slug: 'first-post' } }],
loader: async ({ params }) => ({
props: { slug: params.slug },
cache: { revalidate: 1800, tags: ['blog'] },
}),
seo: ({ params }) => ({
title: `Post ${params.slug}`,
description: 'Blog article page',
}),
});
Runtime Endpoints
createWebServer() exposes the following configurable endpoints. All paths are
configurable on WebServerOptions and any one can be disabled by passing
false.
Diagnostics fail closed. The route manifest, diagnostics and config dump are
served only when NODE_ENV is development or test; an unset NODE_ENV
counts as production. Health probes always report check names and status, but
check messages appear only under the same rule. Use exposeDiagnostics to
change this: true always exposes them, false never does, and a function
decides per request.
const server = await createWebServer({
rootDir: process.cwd(),
// Staging: allow operators who present a valid token.
exposeDiagnostics: (req) => req.headers['x-ops-token'] === process.env.OPS_TOKEN,
// Behind one reverse proxy or load balancer that terminates TLS.
trustProxy: 1,
});
Set trustProxy whenever a proxy terminates TLS. Without it req.secure is
false, so production secure session cookies are never set, and every client
shares the proxy's address for IP rate limits. The server also disables the
X-Powered-By header.
Every request gets a correlation id: a well-formed incoming X-Request-Id is
reused, otherwise one is generated. It is available as req.requestId, echoed
on the response and included in server-error logs (requestId: false turns
this off). JSON and URL-encoded bodies are limited by bodyLimit (default
'100kb'); larger bodies answer 413. timeouts sets Node's requestMs,
headersMs and keepAliveMs server timeouts.
server.stop({ timeoutMs }) drains gracefully: readiness answers 503, idle
keep-alive connections close at once, in-flight requests may finish until the
deadline (default 10 s), then remaining connections close. With
handleSignals: true (or { signals, timeoutMs, onShutdown }), SIGTERM and
SIGINT trigger that drain, run onShutdown (close pools there) and exit.
const server = await createWebServer({
rootDir: process.cwd(),
bodyLimit: '1mb',
timeouts: { requestMs: 30_000, headersMs: 10_000 },
handleSignals: { timeoutMs: 15_000, onShutdown: () => pool.end() },
});
Errors that reach the final handler keep their status: malformed JSON answers
400, oversized bodies 413, and a thrown HttpError its own status (as
Problem Details unless problemDetails: false). Only 5xx messages are hidden
in production.
| Endpoint | Default path | Purpose |
|---|---|---|
| Liveness | /health/live |
Cheapest probe — returns 200 ok whenever the process is up. Suitable for k8s livenessProbe. |
| Readiness | /health/ready |
Runs configured readiness.checks (database, cache, queue, providers). Returns 503 when any check fails. Suitable for k8s readinessProbe. |
| Startup | /health/startup |
One-shot startup check results. Returns 503 while a block-policy startup check is failing. Suitable for k8s startupProbe. |
| Health (legacy) | /health |
Original combined endpoint — preserved for backward compat. |
| Routes (page) | /__udiot/routes |
Page route manifest. Development and test only, or per exposeDiagnostics. |
| Diagnostics | /__udiot/diagnostics |
Page-level warnings and discovery output. Development and test only, or per exposeDiagnostics. |
| Config diagnostics | /diagnostics/config |
Snapshot of resolved config with secrets redacted (only when diagnosticsConfig.config is provided). Development and test only, or per exposeDiagnostics. |
| OpenAPI | /openapi.json |
Generated OpenAPI 3.0 document (only when openapi.info is provided). Always available. |
Programmatic access
const server = await createWebServer({ ... });
server.getRouteManifest(); // page routes only (legacy)
server.getFullRouteManifest(); // pages + controllers + static + health + diagnostics
server.getOpenApiSpec(); // OpenApiDocument | undefined
getFullRouteManifest() returns a RuntimeRouteManifestEntry[] annotated
with HTTP method, controller name, handler method, auth requirement
('none' | 'optional' | 'required'), and rate-limit policy — useful for
ops dashboards, security scanners, and OpenAPI generators.
Production Server Configuration
Every option below is additive. Existing apps that pass only securityHeaders: true
keep working unchanged.
Custom middleware (slots)
import { createWebServer } from '@tagna/udiot/web';
import { requestId } from './middleware/request-id.js';
await createWebServer({
rootDir: process.cwd(),
middleware: {
beforeAll: [requestId()], // before built-in security headers
beforeStatic: [], // before static asset serving
beforePages: [], // before page routing
beforeControllers: [], // before controller routes
afterControllers: [], // after controllers (e.g. tail logging)
error: [], // 4-arg Express error middleware
},
});
A flat RequestHandler[] is shorthand for beforeControllers.
Configurable security headers
await createWebServer({
rootDir: process.cwd(),
securityHeaders: {
contentSecurityPolicy:
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'",
strictTransportSecurity: 'max-age=31536000; includeSubDomains; preload',
frameOptions: 'DENY',
permissionsPolicy: 'camera=(), microphone=(), geolocation=()',
},
});
Pass true for the conservative default preset, false to disable, or a
SecurityHeadersOptions object for full CSP / HSTS / COOP / CORP / Permissions-Policy control.
Named rate-limit policies
await createWebServer({
rootDir: process.cwd(),
controllers: [CheckoutController],
rateLimits: {
default: { maxRequests: 120, windowMs: 60_000 },
checkout: { maxRequests: 20, windowMs: 60_000, key: 'userOrIp' },
reportDownload: { maxRequests: 60, windowMs: 60_000, key: 'userOrIp' },
},
});
Routes opt in via @UseRateLimit('checkout'). Key strategies: 'ip'
(default), 'user' (req.user?.id), 'userOrIp', or a custom
(req) => string. Distributed deployments can supply a custom store.
Auth resolver + guards
await createWebServer({
rootDir: process.cwd(),
controllers: [ReportsController],
authResolver: async (req) => {
const auth = req.headers['authorization'];
if (typeof auth !== 'string') return undefined;
return await sessionStore.findUserByToken(auth.replace(/^Bearer\s+/i, ''));
},
});
Routes/controllers tagged with @RequireAuth invoke the resolver; falsy
results fail closed with application/problem+json (code: UNAUTHENTICATED).
The resolved user is exposed via @CurrentUser(). See the
decorator reference.
Readiness checks
import { createPostgresMigrationCheck } from '@tagna/udiot/database';
await createWebServer({
rootDir: process.cwd(),
readiness: {
timeoutMs: 3_000,
checks: {
database: async () => {
const client = await pool.connect();
try { await client.query('SELECT 1'); return { name: 'database', ok: true }; }
finally { client.release(); }
},
migrations: createPostgresMigrationCheck({ pool, directory: 'migrations' }),
},
},
});
Each check is timed (durationMs exposed in the response) and runs with a
default 5-second timeout. Any failure flips /health/ready to 503.
Diagnostics: redacted config snapshot
await createWebServer({
rootDir: process.cwd(),
diagnosticsConfig: {
config: () => resolvedConfig,
redactPatterns: ['^stripe_'], // appended to defaults
},
});
Default redaction patterns mask values whose keys match
password | secret | token | api_key | private_key | access_key | authorization | credential (case-insensitive) — values become ***.
Custom regex source strings extend the default set.
OpenAPI emit
await createWebServer({
rootDir: process.cwd(),
controllers: [ReportsController, LeadsController],
openapi: {
info: { title: 'Astra Archive API', version: '2.0.0' },
servers: [{ url: 'https://api.example.com', description: 'production' }],
securitySchemes: { bearer: { type: 'http', scheme: 'bearer' } },
},
});
The framework walks every registered controller's @Get / @Post / etc.
metadata, translates UDIOT ValidationSchema maps to JSON Schema, maps
@RequireAuth → security, and maps @UseRateLimit →
x-rateLimit. Spec available at GET /openapi.json and via
server.getOpenApiSpec(). Use generateOpenApi({ controllers, info })
from @tagna/udiot/server to emit the same document offline.
Problem Details by default
Thrown HttpError instances (and the badRequest / unauthorized /
notFound / etc. helpers from @tagna/udiot/server) are auto-serialized
as RFC 7807 application/problem+json:
{
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": "Validation failed",
"code": "VALIDATION_ERROR",
"errors": [{ "field": "body.age", "message": "must be ≥ 13", "code": "min" }]
}
Disable with problemDetails: false to fall back to the { error, message }
envelope.
Startup Safety Hooks
The web layer can run startup checks before the app begins serving traffic:
import { createMikroOrmMigrationCheck, createWebServer } from '@tagna/udiot/web';
await createWebServer({
rootDir: process.cwd(),
startup: {
checks: { migrations: 'block' },
handlers: {
migrations: createMikroOrmMigrationCheck(orm),
},
},
});
Policies:
offwarnblockmaintenance
Use block to fail fast during startup when pending migrations make the runtime unsafe.