@nxtedition/app
v3.0.3
Published
Application bootstrap and the standard middleware stack used by nxtedition services (`createDefaultApp`).
Maintainers
Keywords
Readme
@nxtedition/app
Application bootstrap and the standard middleware stack used by nxtedition
services (createDefaultApp).
Configuration schemas
createDefaultApp accepts either the existing defaults object or a Zod 4
object schema. Defaults objects remain useful for simple config: their keys and
nested shape form an implicit runtime schema.
Use Zod when config needs precise initialization validation, required values,
constraints, or transforms. Zod's output type is inferred as app.config, and
field-level Zod defaults are applied after environment and command-line config
has been collected:
import { createDefaultApp } from '@nxtedition/app'
import * as z from 'zod'
const configSchema = z.object({
host: z.string().min(1).default('localhost'),
port: z.coerce.number().int().positive().default(8000),
metrics: z
.object({
enabled: z.union([z.boolean(), z.stringbool()]).default(true),
})
.prefault({}),
})
const app = createDefaultApp({
name: 'example',
config: configSchema,
})
app.run((app) => {
app.logger.info({ port: app.config.port }, 'configured')
})Environment parsing and coercion
Zod mode deliberately gives Zod ownership of parsing. Environment values for
schema-declared keys bypass nconf's parseValues; this applies both to
top-level values and to nested leaves formed with the __ separator:
environment text -> nconf precedence/nesting -> Zod parse -> typed app.configFor the introductory schema above, the flow is:
| Environment input | Value received by Zod | app.config output |
| -------------------------------- | --------------------------- | ------------------------------------- |
| port=9000 | port: "9000" | port: 9000 |
| metrics__enabled=false | metrics.enabled: "false" | metrics.enabled: false |
| port and metrics are omitted | both fields are undefined | port: 8000, metrics.enabled: true |
z.coerce.number() applies JavaScript's
Number(input) and then runs the chained number checks. Thus "1e3" becomes
1000; an empty or whitespace-only string becomes 0, which the example's
.positive() rejects. Coercion is intentionally broad, so constrain the input
first when only strings and already-parsed numbers are acceptable. The
real-service example below constrains its string-or-number union before
.pipe(...), so empty strings and values such as booleans are rejected before
numeric coercion.
Use z.stringbool() for environment
booleans. It maps strings such as "true", "1", "yes", and "on" to
true, and "false", "0", "no", and "off" to false. Do not use
z.coerce.boolean() for this: it calls Boolean(input), so every non-empty
string, including "false", becomes true. The union with z.boolean() in the
example also accepts an actual boolean already produced by nconf's command-line
argument parser.
default(value) applies only when the input is
undefined; an invalid explicit value still fails validation. A default also
returns immediately without parsing the default value. By contrast,
prefault(value) parses its value. The
metrics.prefault({}) above therefore passes {} through the nested object so
enabled.default(true) can run. A metrics.default({ enabled: true }) would
instead require a complete output value and return it without running child
parsing.
Plain z.string() preserves environment text. JSON object and array blobs also
remain strings, so z.object() and z.array() do not implicitly call
JSON.parse. Prefer nested __ keys, or add a guarded z.preprocess() or a
JSON codec when a JSON blob is intentional. Command-line
values retain the types produced by nconf's argument parser. Built-in config
keys not declared by the Zod schema retain the legacy parseValues behavior.
nxt-23.1.x migration reference
The asset-indexer config from nxt-23.1.x
is a useful migration example: it is a real service with nested URL settings,
integers, and several booleans. Its service-owned config can
be expressed as a Zod schema so environment overrides are parsed and validated
during initialization:
import * as z from 'zod'
const isProduction = process.env.NODE_ENV === 'production'
// Environment input is string; argv input may already be number. Reject other
// input types before applying Number(...), then validate the numeric output.
const configInteger = (min = 1, max = Number.MAX_SAFE_INTEGER) =>
z.union([z.string().trim().min(1), z.number()]).pipe(z.coerce.number().int().min(min).max(max))
const configBoolean = z.union([z.boolean(), z.stringbool()])
const serviceUrl = (production: string, development: string) =>
z.url().prefault(isProduction ? production : development)
const assetIndexerConfigSchema = z.object({
elasticsearch: z
.object({
url: z.url().prefault('http://127.0.0.1:9200'),
})
.prefault({}),
http: z
.object({
port: configInteger(1, 65_535).default(38_703),
})
.prefault({}),
statePath: z.string().min(1).default('./.nxt'),
couchdb: z
.object({
url: serviceUrl('http://tasks.deepstream:6100/nxt/', 'http://127.0.0.1:6100/nxt/'),
})
.prefault({}),
ollama: z
.object({
url: serviceUrl('http://ollama:11434', 'http://127.0.0.1:11434'),
model: z.string().min(1).default('mxbai-embed-large'),
tokenizer: z.string().min(1).default('mixedbread-ai/mxbai-embed-large-v1'),
maxTokens: configInteger(3).default(512),
chunkOverlap: configInteger(0).default(48),
})
.refine(({ maxTokens, chunkOverlap }) => chunkOverlap < maxTokens - 2, {
message: 'chunkOverlap must be less than maxTokens - 2',
path: ['chunkOverlap'],
})
.prefault({}),
query: z
.object({
url: serviceUrl('http://tasks.query:6500/nxt/', 'http://127.0.0.1:6500/nxt/'),
})
.prefault({}),
trace: z
.object({
url: serviceUrl('http://elasticsearch-log:9200', 'http://127.0.0.1:9200'),
})
.prefault({}),
remote: serviceUrl('http://proxy:8888', 'http://127.0.0.1:8888'),
legacy: configBoolean.default(false),
purge: configBoolean.default(true),
resolve: configBoolean.default(true),
index: configBoolean.default(true),
seed: configBoolean.default(true),
})For example, ollama__maxTokens=768 legacy=false reaches the schema as
{ ollama: { maxTokens: "768" }, legacy: "false" }. The inferred output has
ollama.maxTokens: number with value 768 and legacy: boolean with value
false, so downstream code does not need to cast them. z.url() validates the
URL strings without changing their type; using .prefault(url) also validates
the configured URL defaults. Each .prefault({}) lets nested field defaults run
when its whole section is omitted. The Ollama object refinement keeps its
token-chunking stride positive by requiring chunkOverlap < maxTokens - 2.
The source's standard deepstream credential and monitor wiring are omitted from
the excerpt so it stays focused on service-owned configuration. In a full
migration, the monitor wiring stays unchanged; either pass the credential via
createDefaultApp's deepstream option or declare it in the schema.
The schema root must be z.object() or z.strictObject(). Root-level wrappers,
loose objects, and catchalls are not supported; loose/catchall behavior remains
available on nested objects. Only declared top-level keys are passed to Zod, so
unrelated process environment variables do not break strict schemas. The
standard logger, deepstream, numa, dailyOffpeakTime, and http keys keep
their legacy defaults-based behavior unless the Zod schema explicitly declares
them.
Parsing is synchronous during app initialization. Invalid config fails app
initialization with a ZodError; configMiddleware surfaces it directly, while
createDefaultApp().run() applies its normal exitOnError handling. Schemas
with asynchronous refinements or transforms are not supported. Zod-owned proxy
branches remain open after this one-time validation, so nested unknown-key
access diagnostics come from Zod at initialization rather than from later proxy
reads; top-level diagnostics and sensitive-value redaction remain active.
When explicitly supplying the Records and RpcMethods generic arguments,
also pass typeof configSchema as the third argument so TypeScript can retain
the schema output type:
createDefaultApp<Records, RpcMethods, typeof configSchema>({
name: 'example',
config: configSchema,
})The lower-level configMiddleware follows the same two forms:
configMiddleware({ port: 8000 })
configMiddleware(z.object({ port: z.coerce.number().default(8000) }))Default ports
Several middlewares open their own HTTP server. Unless a port is passed
explicitly (via the middleware options or config.http.port for http), these
defaults apply. Ports suffixed with + threadId mean one server per worker
thread (the main thread is threadId 0).
| Middleware | Production port | Dev port | Scope | Purpose |
| ------------- | ----------------------------- | ------------------ | ---------------- | --------------------------------------------------------------------------------------------- |
| http | 8000 | off unless set | main app server | Application HTTP server — /healthcheck plus user middleware. |
| inspect | 38603 (loopback) | ephemeral (0) | main thread only | V8 inspector control plane: lists worker threads, opens/closes --inspect per thread. |
| utils | 38604 (loopback) | ephemeral (0) | main thread only | Ops endpoints: GET /stats, GET /status, POST /gc, POST /writeHeapSnapshot. |
| monitor | 60000 + threadId (loopback) | 60000 + threadId | every thread | App stats/status: GET /stats (aggregated across threads on the main thread), GET /status. |
| diagnostics | 50000 + threadId (loopback) | 50000 + threadId | every thread | Native profiling & health endpoints — see below. Runs in dev and production. |
Notes:
- These are internal/diagnostic servers, expected to sit behind the
container/network boundary — they are unauthenticated.
utils,monitor,diagnosticsandinspectdefault to loopback (127.0.0.1) since they expose stats/state or debugger control;httpbinds all interfaces. Do not route any of them publicly. Setinspect.hostexplicitly (for example, to0.0.0.0) only when the debugger control plane intentionally needs a broader bind address. inspectandutilsrun only on the main thread but coordinate every thread over aBroadcastChannel.monitoranddiagnosticsrun per thread so each V8 isolate is reachable; formonitor, the main thread's/statsreturns the aggregated app stats (all threads), while each worker's/statsreturns its own.
diagnostics middleware
Native, dependency-free profiling and health endpoints (uses node:inspector,
node:v8, node:perf_hooks, process.report). One server per thread on
50000 + threadId; the main thread (:50000) also aggregates the whole fleet.
| Endpoint | Cost | Returns |
| ------------------------------- | -------------- | -------------------------------------------------------------------------------------------- |
| GET / | none | Endpoint directory + this thread's identity. |
| GET /fleet | low | Live rss/heapUsed/ELU/event-loop-delay for every thread. Start here. |
| GET /workers | none | Thread roster — names, threadIds and each thread's diagnostics url. |
| GET /stats | low | This thread: memory, V8 heap stats, resource usage, event-loop delay/utilization. |
| GET /report | low | This thread: process.report — JS + native stacks, libuv handles, env. |
| GET /cpu?seconds=10 | moderate | This thread: CPU profile (.cpuprofile, opens in Chrome DevTools). |
| GET /heap-sampling?seconds=10 | low | This thread: allocation sampling profile (.heapprofile). |
| GET /heap | stop-world | This thread: full heap snapshot (.heapsnapshot), streamed to the client. Use deliberately. |
A thread whose event loop is blocked will not answer /fleet in time and is
reported as ok: false (unresponsive within timeout) — usually the very
thread worth profiling. From /fleet, find the busy/leaking thread and its
port, then hit that port's /cpu, /heap-sampling, or /report.
Options
createDefaultApp({
// ...
diagnostics: {
port: 50000, // optional; defaults to 50000 + threadId
host: '127.0.0.1', // optional; loopback by default. Set '0.0.0.0' to expose on all interfaces.
},
})Pass diagnostics: false to disable.
Off-peak state
createDefaultApp exposes a live, read-only app.isOffPeak boolean for the local-time window
configured by dailyOffpeakTime (default 01:00-04:00) and app.isOffPeak$ as its observable
equivalent. The observable emits the current state immediately and follows both window changes and
nxt:offPeak broadcasts. Both values are false when the window is disabled. The standard
event-loop monitor and toobusy middleware discard lag recorded during this window so expected
maintenance pauses do not affect health after off-peak ends.
monitor middleware
Aggregates app health — event-loop lag, memory, deepstream record counts,
undici/http pending, per-thread stats, plus any user-supplied stats/status —
and exposes it over HTTP (one server per thread on 60000 + threadId), in
addition to the existing deepstream monitor.stats/monitor.status records.
| Endpoint | Returns |
| ------------- | ----------------------------------------------------------------------------------------- |
| GET /stats | Main thread: aggregated AppStats (includes a threads[] array). Worker: its own stats. |
| GET /status | { messages: [...] } — the current health/status messages. |
createDefaultApp({
// ...
monitor: {
port: 60000, // optional; defaults to 60000 + threadId
host: '127.0.0.1', // optional; loopback by default. Set '0.0.0.0' to expose on all interfaces.
// stats / status: custom providers (see MonitorOptions)
},
})Pass monitor: false to disable.
