Configuration (exis.config.ts)
ExisJS uses a typed configuration file located at the project root (exis.config.ts or exis.config.js). It controls global server behavior, middleware defaults, file watcher options, cluster concurrency, and runtime environments.
Static Configuration
Export the result of defineConfig() from exis.config.ts for full TypeScript type safety and autocompletion:
import { defineConfig } from 'exisjs/config'
export default defineConfig({ port: 4000, host: '0.0.0.0',
cors: { origin: ['https://example.com', 'https://admin.example.com'], credentials: true, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
logger: { level: 'info', pretty: true },
helmet: { enabled: true, xFrameOptions: 'SAMEORIGIN' },
bodyLimit: 10 * 1024 * 1024 // 10MB})Dynamic Phase-Based Configuration
ExisJS supports exporting an asynchronous function that receives the current lifecycle phase and the framework's default configuration:
import { defineConfig, PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_SERVER, PHASE_TEST} from 'exisjs/config'
export default async (phase: string, { defaultConfig }: any) => { const isDev = phase === PHASE_DEVELOPMENT_SERVER const isTest = phase === PHASE_TEST
return defineConfig({ port: Number(process.env.PORT) || 4000,
logger: isDev ? { level: 'debug', pretty: true } : isTest ? false : { level: 'info', pretty: false },
compression: !isDev && !isTest,
cluster: { workers: phase === PHASE_PRODUCTION_SERVER ? 'auto' : 1 } })}Lifecycle Phase Constants
| Constant | Value | Triggered By |
|---|---|---|
PHASE_DEVELOPMENT_SERVER | 'phase-development-server' | exisjs dev |
PHASE_PRODUCTION_SERVER | 'phase-production-server' | exisjs start |
PHASE_PRODUCTION_BUILD | 'phase-production-build' | exisjs build |
PHASE_TEST | 'phase-test' | exisjs test / NODE_ENV=test |
Environment Variable Management
ExisJS automatically loads and expands .env files in cascade priority:
.env.${NODE_ENV}.local.env.local(ignored whenNODE_ENV=test).env.${NODE_ENV}.env
Type-Safe Validation with tex
To ensure configuration values are valid before starting the HTTP server:
import { tex } from 'exisjs/validator'
export const env = tex .object({ PORT: tex.number({ coerce: true, default: 4000 }), NODE_ENV: tex.enum(['development', 'production', 'test'], { default: 'development' }), DATABASE_URL: tex.string(), REDIS_URL: tex.string({ optional: true }) }) .parse(process.env)import { defineConfig } from 'exisjs/config'import { env } from './src/config/env'
export default defineConfig({ port: env.PORT, env: env.NODE_ENV})Configuration Options Reference
Network & Transport
| Option | Type | Default | Description |
|---|---|---|---|
port | number | 4000 | Port number to bind the HTTP server to. |
host | string | '0.0.0.0' | Network host interface. |
server | 'auto' | 'node' | 'bun' | 'uws' | 'auto' | Underlying server adapter engine. 'auto' uses Bun under Bun, otherwise Node. 'uws' needs uWebSockets.js, which is not on npm and not installed with exisjs: npm install https://github.com/uNetworking/uWebSockets.js/archive/refs/tags/v20.71.0.tar.gz. Without it, or on Linux with glibc older than 2.38 (such as the node:24-slim image; use node:24-trixie-slim), the server falls back to Node with a warning. |
ssl | { key: string | Buffer, cert: string | Buffer } | undefined | SSL certificate credentials for native HTTPS. |
http2 | boolean | true | Enables HTTP/2 (active when SSL is configured). |
redirectHttp | boolean | number | false | Automatically redirects plaintext HTTP traffic to HTTPS. |
trustProxy | boolean | number | false | Trust proxy headers. true trusts every hop (including CDN headers); a number N trusts the N closest hops, counting the socket peer. |
keepAlive | boolean | KeepAliveConfig | false | Configures socket keep-alive (timeoutMs, headersTimeoutMs, maxRequests). |
Middleware Defaults & Security
| Option | Type | Default | Description |
|---|---|---|---|
cors | boolean | CorsConfig | { origin: '*' } | Global CORS policy. Can be overridden in boundaries or routes. credentials: true requires explicit origins: with origin: '*' it is ignored and a warning is logged, because browsers reject that combination. |
helmet | boolean | HelmetConfig | { enabled: true } | Security headers, including a same-origin Content-Security-Policy. contentSecurityPolicy: false sends no CSP; a string replaces the default. |
authorization | AuthorizationConfig | {} | defaultPolicy: 'authenticated' closes controller routes that declare no rule unless they are @Public(). superUser: (user) => boolean lets matching users pass every @Roles / @Permissions rule. |
errors | { legacyFields?: boolean } | {} | legacyFields: true adds the pre-0.9 duplicate fields to error bodies. Deprecated. |
compression | boolean | false | Gzip/Brotli HTTP response compression. |
bodyLimit | number | 10485760 (10MB) | Maximum request payload body size in bytes. |
env | 'development' | 'production' | 'test' | NODE_ENV, else 'production' | Only development sends error messages and stack traces to clients and loads routes lazily. exisjs dev sets it; a server started with plain node or tsx and no NODE_ENV runs as production. |
blockSuspiciousProbes | boolean | BlockProbesOptions | false | Blocks known vulnerability scanners and malicious probes (.php, wp-admin). |
etag | boolean | false | Weak ETags on successful GET/HEAD responses, with 304 Not Modified for a matching If-None-Match. See Caching & Conditional Requests. |
transformResponse | boolean | Function | false | Standardizes handler response payload envelopes. |
Logging & Diagnostics
| Option | Type | Default | Description |
|---|---|---|---|
logger | boolean | LoggerConfig | false | Pino logger settings (level, pretty, redact). |
debugRouting | boolean | false | Outputs detailed route resolution logs per request in development. |
asyncContext | boolean | false | Enables AsyncLocalStorage context tracking (getContext()). |
docs / swagger | boolean | SwaggerConfig | { enabled: false } | OpenAPI 3.1 specification and Swagger UI endpoint generation. Served in every environment when enabled; the project template uses enabled: process.env.NODE_ENV !== 'production' so published API docs do not help attackers. |
healthcheck | boolean | HealthCheckConfig | { enabled: false } | path (/_health): liveness, runs checks. readyPath (/_ready, false to disable): readiness, 503 unless checks pass, registered databases are healthy and the app is not shutting down. drainMs (5000 in production, else 0): on shutdown, how long /_ready answers 503 before the server stops accepting connections. |
metrics | boolean | MetricsConfig | { enabled: false } | Prometheus metrics endpoint (path: '/metrics'). See Prometheus Metrics. |
telemetry | boolean | TelemetryConfig | { enabled: false } | OpenTelemetry tracing and metrics export. |
Concurrency & Clustering
import { defineConfig } from 'exisjs/config'
export default defineConfig({ cluster: { // 1 (single-threaded), 'safe' (up to 2 cores), 'auto'/'max' (all available CPU cores) workers: 'auto' }})Limiting concurrent requests (queue)
queue caps how many requests the app handles at once, so a traffic spike slows down instead of exhausting memory or the database pool:
export default defineConfig({ queue: { maxConcurrent: 500, // handled at once (default 1000) maxQueue: 200, // may wait for a free slot (default 500) timeoutMs: 10_000, // longest wait (default 10 s) },})A request over the limit waits in this process; when maxQueue is full, or the wait exceeds timeoutMs, it gets 503 Service Unavailable. A client that disconnects frees its place. The limit is per process (per worker in cluster mode).
The only driver is 'memory'. Any other driver, unknown keys and invalid numbers stop the server at start-up with a message naming the option. ExisJS has no shared or durable queue yet, so a setting that pretended to provide one would be silently ignored.
File Watcher & Development Configuration
The development server (exisjs dev) provides file watching with debounce and ignore rules:
import { defineConfig } from 'exisjs/config'
export default defineConfig({ watch: { paths: ['src', '.env'], ignore: [/tests\//, 'temp/**'], extensions: ['.ts', '.tsx', '.js', '.json'], debounceMs: 250 },
dev: { usePolling: false, // Set to true for network mounts or WSL interval: 100, lazyRoutes: true // false loads every route at startup, as in production }})Automatically Excluded Artifacts
ExisJS automatically excludes build output and temp folders from triggering watcher reloads:
- Folders:
node_modules,.git,.exis,dist,build,out,.turbo,.next,.cache,logs,tmp,temp,tests,coverage. - Files:
.log,.sqlite,.db,.png,.jpg,.svg,package-lock.json,*.d.ts.
Prometheus Metrics
Set metrics: { enabled: true } (or metrics: true) to serve request and process metrics in the Prometheus text format at GET /metrics (change it with path).
export default defineConfig({ metrics: { enabled: true, path: '/metrics' },})| Metric | Type | Labels |
|---|---|---|
http_requests_total | counter | method, route, status |
http_request_duration_seconds | histogram (5 ms to 10 s) | method, route |
http_requests_in_flight | gauge | |
process_resident_memory_bytes, nodejs_heap_used_bytes | gauge | worker in cluster mode |
nodejs_eventloop_lag_p99_seconds | gauge | worker in cluster mode |
process_start_time_seconds | gauge | worker in cluster mode |
routeis the route pattern (/users/:id), never the requested URL, so the number of series stays bounded. Requests that match no route are counted under(unmatched).- Status
499counts requests whose client disconnected before the response finished. - Cluster mode: a scrape can reach any worker; that worker collects the numbers of every worker through the primary process, so the request counters are always the whole server's. Memory and event-loop lag are reported per worker.
- Keep the endpoint private. It lists every route of the API. Expose it only on an internal network, or block it at the proxy for public traffic.
Plugin Integration
Plugins extend framework capabilities by intercepting server configuration or registering global routes:
import { defineConfig } from 'exisjs/config'import { defineModule } from 'exisjs/module'
const metricsPlugin = defineModule({ name: 'custom-metrics', routes: (app) => { app.get('/metrics/live', (req, res) => { res.json({ memory: process.memoryUsage() }) }) }})
export default defineConfig({ plugins: [metricsPlugin]})Graceful Process Termination
ExisJS automatically listens for SIGTERM and SIGINT signals in containerized environments:
- Closes the HTTP server listener to stop accepting incoming traffic.
- Closes idle keep-alive sockets and waits for in-flight requests to finish (up to the shutdown timeout).
- Runs shutdown hooks in order:
app.onShutdown()hooks (includingonDestroy/onApplicationShutdown(signal)on injectable services), thenonClosehooks. - Disconnects every database registered with
registerDatabase(), once each. - Stops cron jobs and exits.
Use onClose for resources ExisJS does not manage, such as browser sessions, queues or worker threads:
import { exis } from 'exisjs'import { scraper } from '../workers/scraper'
export default exis({ async onStart(app) { // Receives the signal name ('SIGTERM', 'SIGINT') when one triggered the shutdown app.onShutdown(async (signal) => { app.log.info({ signal }, 'stopping background workers') await scraper.stop() }) }, async onClose(app) { await scraper.closeBrowser() },})Databases registered with registerDatabase() are disconnected automatically after requests have drained. Do not also call disconnect() on them in a hook. Each hook has a time limit (half the shutdown timeout, at least 1 second), so a hook that hangs cannot block the process from exiting.