Arrow keys: ← previous · → next 26 of 35
@nodedk/server
0.13.9 stable

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: default NODE_ENV or 'production'. 'development' checks duplicate routes, logs requests, disables asset caching, and enables reload WebSockets.
  • port: default APP_PORT or 3000; 0 selects an available port.
  • workers: default APP_WORKERS or 1. 0 uses available CPU parallelism. A worker exit causes the primary process to exit with status 1.
  • root: application directory; defaults to process.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 from app/pages and app/actions.
  • autoroute: array of directory names beneath base to scan when routes is omitted. Defaults to ['pages', 'actions'].
  • plugins: application services exposed as state.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 by state.style and state.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. port is the actual bound port and ms is initialization time.

The configuration object and route definitions are prepared in place.

Routes#

Each route accepts:

  • name: identifier used by state.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']. GET includes HEAD. Use uppercase names or strings that can be uppercased.
  • before, after, error: functions or module names under app/hooks.
  • middleware: function/module name or an array of either. Modules resolve under app/middleware, including nested paths.
  • schema: validation rule object, function, or module name under app/schemas. A rule object validates state.req.body.params.
  • handler: function or module name under app/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) with text/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