server#
An HTTP server with explicit routes, request hooks, HTML rendering helpers, and JARPC responses.
Install#
npm i @nodedk/server
Usage#
// app.js
var server = require('@nodedk/server')
server({
port: 3000,
workers: 1,
routes: [
{
name: 'home',
paths: '/',
methods: 'GET',
handler: function (state) {
return '<h1>Hello</h1>'
}
},
{
name: 'echo',
paths: '/echo',
methods: 'POST',
handler: function (state) {
return state.jarpc({ ok: true, data: state.req.body.params })
}
}
]
})
node app.js
curl -H 'content-type: application/json' \
-d '{"id":"request-1","params":{"name":"Ada"}}' \
http://127.0.0.1:3000/echo
Configuration#
server(config = {}) starts listening and returns the native http.Server
in a serving process. With multiple workers, the primary process forks
workers and returns undefined. ready(info) runs when listening starts.
mode: defaultNODE_ENVor'production'.'development'checks duplicate routes, logs requests, disables asset caching, and enables reload WebSockets.port: defaultAPP_PORTor3000;0selects an available port.workers: defaultAPP_WORKERSor1.0uses available CPU parallelism. A worker exit causes the primary process to exit with status1.root: application directory; defaults toprocess.cwd().base: application code directory relative to root; defaults to'app'.static: public directory relative to root; defaults to'app/assets'.routes: array of route objects. If omitted, loads modules recursively fromapp/pagesandapp/actions.autoroute: array of directory names beneathbaseto scan whenroutesis omitted. Defaults to['pages', 'actions'].plugins: application services exposed asstate.plugins.lang: default language,'en'.languages: recognized language codes, default['en'].locales: translations keyed by language, merged with built-in validation messages. Defaults to{}.bundle:{ css: [...], js: [...] }used bystate.styleandstate.script.revision: asset query-string version. Default is''in development and the root directory’s basename in production.begin,before,after,error,done: application request hooks.ready({ port, mode, pid, ms }): listening callback; defaults to logging this object.portis the actual bound port andmsis initialization time.
The configuration object and route definitions are prepared in place.
Routes#
Each route accepts:
name: identifier used bystate.link(name).paths: exact path string, an array of paths, or a language-to-path object.'*'is the catchall. Paths are not parameter patterns.methods: HTTP method string or array, default['GET'].GETincludesHEAD. Use uppercase names or strings that can be uppercased.before,after,error: functions or module names underapp/hooks.middleware: function/module name or an array of either. Modules resolve underapp/middleware, including nested paths.schema: validation rule object, function, or module name underapp/schemas. A rule object validatesstate.req.body.params.handler: function or module name underapp/handlers.done: function called after response handling, without awaiting its return.
All route functions receive state and may be async except that done is
not awaited. Keep each path in one route; combine methods on that route.
// app/pages/about.js — discovered when routes is omitted
module.exports = {
name: 'about',
paths: { en: '/about', no: '/no/om' },
methods: 'GET',
handler: function (state) {
return '<h1>' + state.t('about.title') + '</h1>'
}
}
Request state#
state.req and state.res are the native HTTP request and response.
Additional request properties are pathname, query, body, host, ip,
proto, href, lang, UUIDv7 id, and per-process request count.
ip prefers forwarded headers and may contain a comma-separated proxy chain.
Language comes from a recognized two-letter URL prefix, then the lang
cookie, then the configured default.
query contains parsed URL parameters. Only POST bodies are parsed, and they
must be JSON objects; forms use the browser helper to send { params }.
begin sees the initial empty body. Route hooks see the parsed body.
Use state.data for application data and state.page for page metadata; both
start as {} per request. state.route is the selected route, state.result
is the response result, and state.err is set on an exception. Timing fields
are start, then end and ms when processing finishes. Configuration is
inherited by state.
Middleware and handlers return values directly: any value other than
undefined ends the route chain, while undefined continues it.
Requests rejected during verification or POST body parsing receive status
400 without a result. The shared sender returns {} for POST and an empty
response for other methods. The app after hook may provide a custom result
before the response is sent.
Applications set their own status and return their own result when needed:
state.res.statusCode = 403
state.res.setHeader('x-reason', 'forbidden')
return { ok: false, message: state.t('forbidden') }
Return values and responses#
App begin runs before gate checks and route or static asset lookup. It
receives request metadata and the initial empty req.body, before POST body
parsing. A non-undefined return skips gate checks and routing.
Requests handled by begin must enforce their own validation and limits.
Every function in the route chain may also produce the response result. The
chain runs app before, route before, middleware, schema, and handler in
order. App before runs after route selection and POST body parsing.
undefined: continue the chain.null: stop the chain, using the default empty response.- Object or array: stop and send JSON with
application/json; charset=utf-8. - String, number, boolean, or another value: stop and send
String(value)withtext/html; charset=utf-8.
A final nullish result becomes {} for POST and '' for other methods.
Falsy values such as false, 0, and '' are explicit results and stop the
chain. Only undefined continues it. An existing content-type header is
preserved.
After the chain, route after and app after hooks may replace
state.result. If processing throws, the response status becomes 500 and
route and app error hooks run instead. The existing result is not cleared;
error hooks may keep or replace it.
res.statusCode starts at 200. Request denial and body-parser failures use
400, an unmatched path/method combination uses 404, and an unhandled
processing error uses 500. These statuses do not create a result, so the
same nullish POST/other defaults apply unless a hook provides one.
The sender suppresses the body for every HEAD response, status 204, status
304, and all 3xx responses. Calling res.writeHead(), res.write(), or
res.end() directly takes over the response and makes the route responsible
for completing it, but does not stop the route chain. Redirects set a 3xx
status and return null, so they also end without a body.
Nested middleware paths resolve from app/middleware:
var route = {
paths: '/account',
middleware: ['guards/auth', 'control/throttle']
}
OPTIONS and CORS#
An app begin hook can answer CORS preflight before route lookup, so individual
routes do not need to declare OPTIONS when they share one CORS policy:
server({
begin: function (state) {
state.cors({ origin: 'https://example.com' })
if (state.req.method === 'OPTIONS') {
state.res.statusCode = 204
return null
}
}
})
Explicit OPTIONS routes and catchalls remain available when the app hook
continues with undefined.
Request limits#
- Request headers: 16 KiB and 100 fields.
- Query string: 8 KiB and 1,000 parsed fields.
- JSON body: 128 KiB.
- Header receive time: 10 seconds.
- Full request receive time: 30 seconds.
Query parsing uses Node's built-in 1,000-field limit. Additional fields are ignored. Request limits are fixed and are not configurable.
Helpers#
take(keys, value)#
Returns a new object containing the named, defined properties of value.
For an array of objects, returns one selected object per item. keys is an
array of literal property names; no nested path lookup is performed.
var params = state.take(['name', 'email'], state.req.body.params)
revive(value, reviver)#
Walks an object or array in place, deepest properties first, replacing each
property with reviver(key, value). Returns the original root object. The
root itself is not passed to the callback.
state.revive(data, function (key, value) {
return key === 'created_at' ? new Date(value) : value
})
t(path, ...args) and link(name)#
t reads a nested locale key and formats optional arguments with
node:util.format. Missing translations return the language-qualified key.
link returns the path for a named route, or '' if absent. Prefix either
argument with lang@ to select a language explicitly.
state.t('account.welcome', 'Ada')
state.t('no@account.welcome', 'Ada')
state.link('about')
state.link('no@about')
req.cookie(key, value, options = {})#
Omit value to read a decoded cookie value or null. Set a value to queue a
response cookie, or null to delete it. Writes return no value. Options are
days (default 30, including when 0 is passed), path (default '/'),
sameSite (default 'Lax'), httpOnly (default false), and secure (default
false). The server sends queued cookies when it sends the response.
var token = 'example-session-token'
state.req.cookie('session', token, { httpOnly: true, secure: true, days: 7 })
var stored = state.req.cookie('session')
state.req.cookie('session', null)
redirect(url, statusCode = 302)#
Sets the redirect status and Location header, then returns null. Return
that value from a handler or middleware to stop the chain.
return state.redirect('/account')
cors(options = {})#
Sets CORS headers and returns no value. Options are origin (default '*'),
methods (default 'GET, POST, HEAD, OPTIONS'), and headers (default
'content-type, authorization'). Answer OPTIONS explicitly as shown above.
basicauth(options = {})#
Checks HTTP Basic credentials. options.username and options.password
default to ''. Returns undefined on success, or sets status 401 and a
WWW-Authenticate header and returns null on failure.
function authenticate(state) {
return state.basicauth({
username: process.env.ADMIN_USER,
password: process.env.ADMIN_PASSWORD
})
}
jarpc(result)#
Use state.jarpc to echo a JARPC request ID in a JSON response:
return state.jarpc({
ok: true,
data
})
When state.req.body.id is present, the helper adds it to the returned object.
Timing#
Use state.timer to collect elapsed times during a request:
state.timer.start('database')
await query()
var ms = state.timer.end('database')
start(key) records a start time and returns no value. end(key) returns
elapsed milliseconds, removes the start time, and stores the measurement in
state.timer.values[key]. Ending a key without starting it throws. Reusing
a key overwrites its previous measurement.
validate(schema, data)#
Request handlers can validate data with state.validate. It returns null
when the data is valid, otherwise it returns translated issues grouped by
field:
var schema = {
name: {
required: true,
is: 'string'
}
}
var issues = state.validate(schema, state.req.body.params)
// {
// name: ['is required']
// }
A route schema stops the request chain when validation finds issues:
var result = {
ok: false,
id: 'request-id',
message: 'validation error',
fields: {
name: ['is required']
}
}
POST validation errors echo state.req.body.id when present.
Asset tags#
bust(path) normalizes slashes and adds ?v=<revision> (or &v=) when a
revision is configured and no version parameter exists. Returns a URL string.
style(paths) and script(paths) accept arrays of asset URLs and return HTML
tags with versioned URLs. In development, /bundle.css and /bundle.js
expand to the individual configured files. Production uses the bundle URLs.
var { html } = require('@nodedk/page')
function Layout(state, slot = []) {
return html`<!doctype html>
<html lang="${state.req.lang}">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
${state.style(['/bundle.css'])} ${state.script(['/bundle.js'])}
</head>
<body>
${slot}
</body>
</html>`
}
Static files are served for GET and HEAD after route lookup, before the
catchall. Production files receive a one-year cache header. Run
ndk build from cli to create dist, then serve it
through your production web server or set static: 'dist':
var mode = process.env.NODE_ENV || 'production'
server({
mode,
static: mode === 'development' ? 'app/assets' : 'dist',
bundle: require('./app/config/bundle.js')
})
Testing#
Middleware and handlers receive state so they can be tested without starting
a server. Set up only the state and plugins each test needs:
var take = require('@nodedk/server/lib/take.js')
function setup() {
return {
req: { body: {} },
data: {},
plugins: {},
take
}
}
var state = setup()
state.plugins.search = {
index: function () {
return {
search: async function () {
return { estimatedTotalHits: 0, hits: [] }
}
}
}
}
var result = await route.handler(state)
Created by Vidar Eldøy