Docs/Web applicationsv1.0.0-rc.1

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 configuration
  • definePage() for typed page contracts
  • page discovery based on imported page exports 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 output
  • createWebServer() 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 emit
  • createWebDevServer() for the same runtime surface plus watch/HMR wiring
  • createMikroOrmMigrationCheck() and createPostgresMigrationCheck() 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:

  • route
  • render: ssr, ssg, isr, or csr
  • revalidate
  • loader()
  • 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:

  • off
  • warn
  • block
  • maintenance

Use block to fail fast during startup when pending migrations make the runtime unsafe.

From the canonical UDIOT public documentation.Try it in the playground ↗