# Spliffy > Spliffy is a Node.js web framework on top of µWebSockets.js. Routes are files: a directory of > `.rt.mjs` handler files and static assets becomes the URL space of the server, like Apache or PHP. > Package: `@srfnstack/spliffy` (ESM, Node 22 to 26). Version documented here: 1.7.0. The documentation site (https://srfnstack.github.io/spliffy/) is a client rendered single page app, so this file is the complete reference, not a table of contents. Everything below is checked against the 1.7.0 source. The package has no TypeScript types. ## Links - [Source on GitHub](https://github.com/SRFNStack/spliffy): `src/` is small (about 1500 lines) and readable - [Example app](https://github.com/SRFNStack/spliffy/tree/master/example): every feature below has a route file and a jest test here - [npm](https://www.npmjs.com/package/@srfnstack/spliffy) - [µWebSockets.js](https://github.com/uNetworking/uWebSockets.js): the raw `req`, `res` and `ws` objects come from here - [Docs site](https://srfnstack.github.io/spliffy/) ## Quick start ``` app/ serve.mjs www/ index.html -> GET / (static) hello.rt.mjs -> /hello (handler) api/ users/ index.rt.mjs -> /api/users $id.rt.mjs -> /api/users/:id ``` ```js // serve.mjs import spliffy, { moduleDirname } from '@srfnstack/spliffy' spliffy({ routeDir: moduleDirname(import.meta.url) + '/www', // required, absolute path port: 10420, // default serveRoutesWithSlash: true // folder indexes answer /api/users/ as well as /api/users }) ``` ```js // www/api/users/$id.rt.mjs import { NotFoundError, BadRequestError } from '@srfnstack/spliffy' export default { GET: async ({ url }) => { const user = await db.findUser(url.param('id')) if (!user) throw new NotFoundError({ error: 'no such user' }) return user // object -> application/json, 200 }, PUT: async ({ url, bodyPromise }) => { const body = await bodyPromise // parsed by content-type if (!body.name) throw new BadRequestError({ error: 'name is required' }) return db.updateUser(url.param('id'), body) }, DELETE: async ({ url }) => { await db.deleteUser(url.param('id')) // undefined -> 200, empty body } } ``` Run `node serve.mjs`. `spliffy()` returns a promise of the µWebSockets.js app. If any route file fails to load, the error is logged and the process exits with code 420. Only one `spliffy()` call per process is honoured, routes are loaded once. ## Route files and URL mapping Everything is relative to `routeDir`. The scan is recursive and happens once at startup. There is no hot reload, restart the process after adding files. | File | URL | Rule | | --- | --- | --- | | `www/foo.rt.mjs` | `/foo` | the `.rt.mjs` suffix marks a handler module | | `www/foo/index.rt.mjs` | `/foo` and `/foo/` with `serveRoutesWithSlash` | `index` is the folder itself | | `www/index.rt.mjs` | `/` | root handler | | `www/$id.rt.mjs` | `/:id` | a leading `$` makes a path parameter, read with `url.param('id')` | | `www/users/$userId/posts/$postId.rt.mjs` | `/users/:userId/posts/:postId` | parameters nest through folders | | `www/files+.rt.mjs` | `/files` and `/files/anything/deeper` | a trailing `+` makes a catch-all | | `www/logo.png` | `/logo.png` | anything that is not a handler is a static file | | `www/about.html` | `/about.html` and `/about` | `.html` and `.htm` always resolve without extension | | `www/docs/index.html` | `/docs`, `/docs/`, `/docs/index`, `/docs/index.html` | index files are the folder default | Rules and gotchas: - Accepted handler extensions are `.rt.mjs`, `.rt.js` and `.rt.cjs`. Use `.rt.mjs` (or `.rt.js` with `"type": "module"` in package.json). A CommonJS file uses `module.exports = { GET ... }`. - Only one `$variable` file or folder per directory. Two would be ambiguous and the server refuses to start. - Files ending in `.test.js` or `.test.mjs` are skipped, so tests can sit next to routes. Set `allowTestFileRoutes: true` to change that. `ignoreFilesMatching: ['^draft', '\\.bak$']` skips more files by regex on the file name. - URL matching is done by µWebSockets.js, so a static route wins over a parameter route and a parameter route wins over a catch-all. - Trailing slashes are literal. `foo.rt.mjs` answers `/foo` only, never `/foo/`. A folder index (`foo/index.rt.mjs` or `foo/index.html`) answers `/foo`, and also `/foo/` when `serveRoutesWithSlash: true` is set. Without it `/foo/` is not found. Every production app sets it. - A request method with no handler (for example `POST` to a static file, or `HEAD` to a route with only `GET`) falls through to the not found handler. Define `HEAD` yourself if clients send it. `autoOptions: true` adds an `OPTIONS` handler to every route that answers 204 with an `Allow` header. - Query strings never take part in routing. ## Handler module shape ```js export default { // reserved, optional middleware: [ (req, res, next) => next() ], // or { ALL: [...], POST: [...] } streamRequestBody: false, // applies to every method in the file // one key per HTTP method, upper case only GET: ctx => ({ ok: true }), POST: async ctx => { ... }, PUT: { streamRequestBody: true, handler: async ctx => { ... } }, // per method options DELETE: ctx => { ... }, PATCH: ctx => { ... }, OPTIONS: ctx => { ... }, HEAD: ctx => { ... }, WEBSOCKET: { open (ws) {}, message (ws, msg, isBinary) {}, close (ws) {} } // see WebSockets } ``` Allowed keys are `GET POST PUT PATCH DELETE OPTIONS HEAD CONNECT TRACE WEBSOCKET` plus `middleware` and `streamRequestBody`. Anything else, or a lower case method, fails at startup. There is no `ALL` handler, `ALL` only exists in middleware objects. ## The handler argument Each handler receives one context object. Destructure what you need: ```js GET: ({ url, headers, bodyPromise, req, res }) => { ... } ``` - `url.path`: the request path, no query string, not decoded. For catch-all routes this is the full path the client sent, so `url.path.split('/')` is how to read what came after the `+` segment. - `url.query`: object of query parameters. A key that repeats becomes an array. Values are the raw wire bytes unless `decodeQueryParameters: true` is set in the config, which runs `decodeURIComponent` on keys and values and turns `+` into a space. - `url.param(name)`: a path parameter by the `$name` of the file or folder. Never decoded. Call `decodeURIComponent` yourself if the value may contain escapes. `url.param` is a closure, so `({ url: { param } }) => param('id')` works. - `headers`: object of lower case request header names to string values. - `req.cookies`: the parsed `cookie` header as an object, parsed lazily on first access. (A `cookies` shortcut exists on the context for routes without middleware only, so prefer `req.cookies`.) - `body`: the parsed request body (see Request bodies), already read by the time the handler runs, so a `POST` handler can be synchronous. `null` for `GET` and `HEAD`. With `streamRequestBody` it is the `Readable`. - `bodyPromise`: the same body as a promise, for handlers written around `await bodyPromise`. - `req`: the spliffy request (see below). `req.user` is the convention middleware uses to attach an authenticated principal. - `res`: the spliffy response (see below). Reading `res` from the context is what switches the response into the decorated mode, so a handler that just returns a value never pays for it. `req` is a `SpliffyRequest`, not a Node `IncomingMessage`: - `req.path`, `req.url` (path plus query string), `req.method` (upper case), `req.headers`, `req.query`, `req.cookies`, `req.get('Header-Name')` - `req.remoteAddress`, `req.proxiedRemoteAddress` (from the PROXY protocol header, if any) - `req.spliffyUrl` is the same object as `url` in the context - The underlying µWebSockets.js request is only valid while the handler is synchronous. Spliffy copies headers, path, query and parameters before any `await`, so `req` is safe to use after awaiting, but do not keep raw uws request references. - A handler that destructures its argument, `({ url, body }) => ...`, can only reach the names it lists. When `headers`, `cookies` and `req` are not among them the headers are never copied for it, which is the cheapest a request with a body or an async handler gets. A handler that takes the whole context, `ctx => ...`, gets everything copied. - Set `extendIncomingMessage: true` in the config to put `http.IncomingMessage.prototype` behind `req` for middleware such as passport that patches that prototype. ## Returning a response The return value (or the resolved value of a returned promise) decides the response: | Returned | Status | Body | Content-Type | | --- | --- | --- | --- | | `undefined`, `null`, `''`, `false` | 200 | empty | none | | string | 200 | the string | none unless set, so put one in a descriptor | | `Buffer` | 200 | the bytes | none unless set | | any other object or array | 200 | `JSON.stringify` | `application/json` | | `stream.Readable` | 200 | piped | whatever you set | | descriptor `{ statusCode, statusMessage, headers, body }` | as given | `body` by the rules above | as given | A descriptor is any object that has at least one of `statusCode`, `statusMessage`, `headers` or `body`. This is the way to set status and headers from the return value: ```js GET: () => ({ statusCode: 201, headers: { 'content-type': 'text/html', 'cache-control': 'no-store' }, body: '

made

' }) ``` Gotchas: - Because of the descriptor rule, a data object that happens to have a `body`, `headers` or `statusCode` key is treated as a descriptor, not serialized as JSON. Wrap it: `{ body: data }`. - There is no `contentType` key. The content type goes in `headers`. - Header names are lower cased when stored. `set-cookie` may be an array. - Alternatively mutate `res` and return nothing or the body: `res.statusCode = 202`, `res.setHeader('x-thing', '1')`, `res.setCookie(...)`, then `return data`. - A `HEAD` route behaves like any other; spliffy does not strip the body for you. Helpers exported from the package: ```js import { redirect } from '@srfnstack/spliffy' export default { GET: redirect('/new-place') } // 301 export default { GET: redirect('/new-place', false) } // 302 ``` ## Errors Throw to fail a request. Any thrown value ends the response. ```js import { BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, HttpError } from '@srfnstack/spliffy' throw new NotFoundError({ error: 'no such thing' }) // 404, body JSON throw new ForbiddenError('nope') // 403, body text throw new HttpError(418, "I'm a teapot", { short: true }) // any status ``` - Every 4xx and 5xx status has a class named after its reason phrase: `BadRequestError` 400, `UnauthorizedError` 401, `PaymentRequiredError` 402, `ForbiddenError` 403, `NotFoundError` 404, `MethodNotAllowedError` 405, `NotAcceptableError` 406, `ConflictError` 409, `GoneError` 410, `PayloadTooLargeError` 413, `UnsupportedMediaTypeError` 415, `EnhanceYourCalmError` 420, `UnprocessableEntityError` 422, `TooManyRequestsError` 429, `InternalServerError` 500, `NotImplementedError` 501, `BadGatewayError` 502, `ServiceUnavailableError` 503, `GatewayTimeoutError` 504, and the rest. The constructor takes the body only. - An error with a `statusCode` property is sent with that status. The `body` property is the response body; objects are JSON stringified. Any other thrown error is a 500 with an empty body and the error is logged with its stack. - Every error response carries an `x-ref-id` header with a UUID. The same id is passed to `errorTransformer` and is meant to be logged so a user facing id can be matched to a stack trace. - `errorTransformer(err, refId)` in the config runs on every error before it is sent and returns the error to send. The pattern used in production apps: ```js errorTransformer: (err, refId) => { if (err.validationErrors) { err.statusCode = 400 err.body = { validationErrors: err.validationErrors } } else if (!err.statusCode || err.statusCode === 500) { log.error(`500 ${refId}`, err) err.statusMessage = `Something went wrong, reference ${refId}` err.body = { refId } } return err } ``` - Four argument middleware `(err, req, res, next)` runs before the error is sent, see Middleware. ## Request bodies Bodies are buffered in memory by default and parsed before the handler runs, so `body` on the context is ready to use and `bodyPromise` resolves to the same value. `GET` and `HEAD` requests have no body (`null`). For other methods the body is: | Request `content-type` | Resolves to | | --- | --- | | `application/json` | `JSON.parse` of the body (throws, so a 500, on invalid JSON) | | `text/plain` | string | | `application/x-www-form-urlencoded` | object, repeated keys become arrays, values decoded | | `application/octet-stream` | `Buffer`, the bytes as sent | | anything else, or no header | `Buffer`, unless the bytes look like JSON, then parsed JSON | | no body | `Buffer` of length 0 whatever the content type (`null` for `GET` and `HEAD`) | Add or override parsers with `contentHandlers` in the config (keys must be lower case): ```js contentHandlers: { 'application/xml': { deserialize: buf => parseXml(buf.toString()), serialize: obj => toXml(obj) }, 'text/csv': { deserialize: buf => buf.toString(), serialize: s => s } } ``` The same table is used to serialize responses when `res` has a `content-type` the table knows. `multipart/form-data` is not parsed. Stream it instead: ```js import Busboy from 'busboy' export default { streamRequestBody: true, POST: async ({ headers, bodyPromise }) => { const stream = await bodyPromise // a stream.Readable, nothing buffered const bb = Busboy({ headers }) ... stream.pipe(bb) } } ``` With `streamRequestBody: true` (on the module, or on one method with the `{ streamRequestBody, handler }` form) `bodyPromise` resolves to a `stream.Readable` of the raw bytes. Use this for uploads of any size; the buffered path concatenates chunks and is meant for small JSON bodies. There is no built in body size limit, enforce one in middleware or your handler. ## The response object `res` is the µWebSockets.js response with an Express flavoured layer on top. Use the layer, not the raw `writeStatus`, `writeHeader` and `cork` calls, unless you know uws. - `res.statusCode`, `res.statusMessage` - `res.headers` (object), `res.setHeader(name, value)`, `res.getHeader(name)`, `res.removeHeader(name)`, `res.assignHeaders({...})`, `res.writeHead(status, headers)` - `res.status(code)` (chainable), `res.redirect(location)` or `res.redirect(code, location)` - `res.send(body)`, `res.json(body)`: finish the response using the return value rules. After calling these, return nothing from the handler. - `res.setCookie(name, value, options)` and the alias `res.cookie(...)`: options go straight to `cookie.serialize` (`httpOnly`, `secure`, `sameSite`, `maxAge`, `path`, `domain`, `expires`). - `res.write(chunk)`, `res.end(body)`, `res.getWritable()`: streaming, see below. - `res.headersSent`, `res.writableEnded`, `res.ended`, `res.streaming` Headers are sent with the first `write` or `end`, so set status and headers first. ## Streaming responses Return a `Readable` as the body, or pipe into `res.getWritable()`: ```js import fs from 'node:fs' import { pipeline } from 'node:stream/promises' export default { GET: ({ res }) => { res.setHeader('content-type', 'image/jpeg') return fs.createReadStream('/path/to/pic.jpg') // simplest } } export default { GET: async ({ res }) => { res.setHeader('content-type', 'text/csv') await pipeline(rowStream(), res.getWritable()) // backpressure aware } } ``` Writing by hand needs `res.streaming = true` before the handler returns, and you must call `res.end()` yourself or the connection stays open forever: ```js GET: async ({ res }) => { res.setHeader('content-type', 'text/event-stream') res.streaming = true const timer = setInterval(() => res.write(`data: ${Date.now()}\n\n`), 1000) res.onAborted(() => clearInterval(timer)) // client went away } ``` For long lived connections (server sent events, long polling) prefer a WebSocket route; uws tracks aborted clients for you there. ## Middleware Express style `(req, res, next)` functions. `req` and `res` are the spliffy objects described above, not Node's, so middleware that only reads headers, sets headers and cookies, or attaches properties to `req` works. `helmet`, `passport` and `csurf` are used in production with spliffy. Three places to attach it: ```js // 1. config, applies to every route including static files spliffy({ routeDir, middleware: [helmet(), logRequests] }) // 2. a *.mw.mjs file anywhere under routeDir, applies to that folder and everything below it // www/api/secure.mw.mjs export const middleware = { ALL: [requireUser], DELETE: [requireAdmin] } // (export default { middleware: ... } is also accepted) // 3. the route module export default { middleware: [requireUser], GET: ({ req }) => req.user } ``` Semantics: - An array applies to every method. An object maps upper case method names to arrays. `ALL` runs for every method, before the method specific list. - Order: config middleware, then each `.mw.mjs` folder from the top down, then the route's own. Several `.mw.mjs` files in one folder run in no guaranteed order, so keep one per folder. - Call `next()` to continue. Call `next(err)` or throw to fail the request (goes through error middleware and `errorTransformer`). Or end the response yourself with `res.end(...)`, `res.send(...)` or `res.redirect(...)`, after which the handler does not run. Doing none of these hangs the request. - Async middleware is fine, just make sure every path calls `next()` or ends the response. - A function with four parameters `(err, req, res, next)` is error middleware. It runs only when something threw, after the normal chain and before the error response is written. - Middleware does not run for `WEBSOCKET` handlers or declarative responses. A route that has middleware and exports a declarative response fails to load. - Static files under a folder with middleware are served through the middleware, which disables the declarative fast path for them. - A route with middleware reads the body before the handler runs and always copies the request headers. Middleware that calls `next` before returning keeps the whole request on the uws callback, so synchronous middleware and a synchronous handler answer without a promise in between. Compression is built in. `compression()` from the package is middleware that compresses responses the client accepts compressed, brotli first then gzip, for text, json, javascript, xml and svg content types above a threshold: ```js import { compression } from '@srfnstack/spliffy' // www/api/compress.mw.mjs, or in the config middleware for every route export const middleware = [compression({ threshold: 1024, encodings: ['br', 'gzip'] })] ``` Options: `encodings` [`['br', 'gzip']`] in order of preference, `threshold` [1024] bytes, `brotli` and `gzip` are passed to zlib (defaults brotli quality 3, gzip level 6), `filter(contentType)` replaces the content type test. A body sent in one piece is compressed in one call with the compression window fitted to the body: a window larger than the body is memory the compressor allocates and clears for nothing, and for a few kilobytes that costs more than compressing them. Pass `brotli.params[BROTLI_PARAM_LGWIN]` or `gzip.windowBits` to fix the window instead. Bodies with a window of 256KB or less reuse a compressor kept per window size rather than building and tearing one down per call, which is about a fifth of the cost of a small body. A body written in pieces or streamed goes through a zlib stream with the configured options. Responses that already carry a `content-encoding` (precompressed static files) are left alone. A `vary: accept-encoding` header is added. A typical auth middleware: ```js export const middleware = [ async (req, res, next) => { const user = await userFromCookie(req.cookies.session) if (!user) { res.statusCode = 401 res.end(JSON.stringify({ error: 'unauthorized' })) return } req.user = user next() } ] ``` ## Static files Any file under `routeDir` that is not a handler or middleware module is served on `GET`: - Content type comes from the extension. Override or add with `staticContentTypes: { '.wasm': 'application/wasm' }`. - Every response carries `ETag` and `Cache-Control`. The default is `max-age=600` (ten minutes). Set `staticCacheControl: 'no-cache'` to make browsers revalidate every time, which gives a 304 when the ETag still matches. Do this for apps that ship ES modules without hashed file names, otherwise a deploy leaves clients running old modules for up to ten minutes. - Without `cacheStatic` the file is stat'ed and streamed from disk on every request and the ETag comes from size and mtime, so edits show up immediately. - `cacheStatic: true` reads each file into memory on first request and keeps it. The cache follows the disk: every request stats the file and a changed mtime, size or inode makes the next response read it again, so a replaced file is served on the very next request. - With `cacheStatic`, files up to `declarativeStaticMaxSize` (64 KiB) are also compiled into declarative responses served inside uws with no JavaScript per request. Those cannot compare ETags, so conditional requests get a 200 with the full file instead of a 304, and they are read once at startup, so a replaced file is only seen after a restart. Set `declarativeStatic: false` to keep 304s and follow the disk. Files under middleware, the `notFoundRoute`, the `defaultRoute` and files with precompressed twins are never compiled. - `staticPrecompressed: true` serves a `.br` or `.gz` file sitting next to the original when the request accepts that encoding, with the original's content type, a matching `content-encoding` and `vary: accept-encoding`. `app.js.br` answers `/app.js` for `accept-encoding: br` and is not a route of its own. Brotli is preferred over gzip. The twins are cached and revalidated like the original. - A symlinked directory under `routeDir` is walked like any other directory. - `index.*` files answer for their folder: `www/app/index.html` serves `/app`, `/app/`, `/app/index` and `/app/index.html`. - `resolveWithoutExtension: ['.js']` makes `/foo.js` also answer at `/foo`. `.html` and `.htm` are always in this list. - `staticMode: true` serves only static files and ignores `.rt.mjs` modules. - Static routes register `GET` only. Other methods fall through to the not found handler. ## Not found pages and single page apps Both options name a URL path that must match an existing route (a file in `routeDir`, static or handler). Spliffy warns at startup when nothing matches. - `notFoundRoute: '/404.html'`: served with status 404 for any request with no route. - `defaultRoute: '/index.html'`: served with status 200 for GET requests with no route whose last path segment has no file extension. This is the SPA fallback: `/users/42` gets the shell, while `/missing.js`, `/img/logo.png` and any POST fall through to `notFoundRoute` (or a bare 404). - `defaultRouteIgnore: ['/api', /^\/internal\//]`: paths the default route must not answer. String entries are prefixes, regular expressions are tested against the path. Those requests also fall through to `notFoundRoute`. - The default route handler sees the originally requested path in `req.path`, so one handler can pick between several SPA shells: ```js // www/default.rt.mjs, with defaultRoute: '/default' const app = fs.readFileSync(new URL('./index.html', import.meta.url)) const admin = fs.readFileSync(new URL('./admin/index.html', import.meta.url)) export default { GET: ({ req }) => ({ headers: { 'content-type': 'text/html', 'cache-control': 'no-cache' }, body: req.path.startsWith('/admin') ? admin : app }) } ``` - Both must point at a regular handler or static file. A declarative response cannot be the not found or default route. - A folder with an `index.html` can be the target: `defaultRoute: '/ui/'` matches `www/ui/index.html`. ## Serving files from node_modules `nodeModuleRoutes` exposes individual files from `node_modules` without copying them into `routeDir`. Files are listed explicitly, there is no glob or pattern support, on purpose. ```js nodeModuleRoutes: { nodeModulesPath: path.join(projectRoot, 'node_modules'), // optional, see below routePrefix: 'vendor', // default: 'lib/ext' files: [ 'htmx.org/dist/htmx.min.js', // -> /vendor/htmx.org/dist/htmx.min.js { modulePath: 'croppr/dist/croppr.min.js', urlPath: 'croppr.js' } // -> /vendor/croppr.js ], method: 'copy', // optional: copy into routeDir at startup instead of serving in place middleware: [...] // optional, applies to these routes } ``` - Without `nodeModulesPath`, every `node_modules` directory from `routeDir` up to the filesystem root is searched, nearest first, the way node resolves packages. `routeDir: './src/public'` finds the project's `node_modules` on its own. Set `nodeModulesPath` only to serve from one specific directory; the server then throws `Unable to find node_modules dir` if it is missing. - A listed file that does not exist is a warning, not an error. - `method: 'copy'` writes the files under `routeDir//...` and they are then served as ordinary static files. Add that folder to `.gitignore`. ## WebSockets Export a `WEBSOCKET` object. It is handed to µWebSockets.js `app.ws()` as is, so every uws behaviour and option applies. It is registered on every listener, so with `httpsKeyFile` and `httpsCertFile` the same route answers `wss://` on `httpsPort`. ```js // www/ws/room/$roomId.rt.mjs -> ws://host/ws/room/:roomId export default { WEBSOCKET: { idleTimeout: 120, // seconds, uws options are allowed here maxPayloadLength: 16 * 1024, upgrade: (res, req, context) => { // req is only valid synchronously here: copy out what you need const roomId = req.getParameter(0) // path parameters by index const key = req.getHeader('sec-websocket-key') const protocol = req.getHeader('sec-websocket-protocol') const extensions = req.getHeader('sec-websocket-extensions') const cookie = req.getHeader('cookie') let aborted = false res.onAborted(() => { aborted = true }) authenticate(cookie).then(user => { if (aborted) return if (!user) return res.cork(() => res.writeStatus('401').end()) res.cork(() => res.upgrade({ roomId, user }, key, protocol, extensions, context)) }) }, open: ws => { const { roomId } = ws.getUserData() ws.subscribe(roomId) }, message: (ws, message, isBinary) => { const text = Buffer.from(message).toString() // message is an ArrayBuffer ws.publish(ws.getUserData().roomId, text) }, drain: ws => {}, // backpressure cleared close: (ws, code, message) => {} // ws is unusable here, clean up only } } ``` - Omit `upgrade` to accept every connection. `ws.getUserData()` then returns an empty object you can write to. - `ws.send(data, isBinary)` returns 1 on success, 0 when dropped for backpressure, 2 when dropped for high backpressure. `ws.getBufferedAmount()` shows what is queued. - Pub/sub (`subscribe`, `unsubscribe`, `publish`) is process local. - Middleware does not run for WebSocket routes. Authenticate in `upgrade`. - `message` and `close` payloads are `ArrayBuffer`s, convert with `Buffer.from(buf).toString()`. - Spliffy does not add anything to `ws`. See the µWebSockets.js docs for `ping`, `pong`, `subscription`, `dropped`, `compression`, `maxBackpressure` and `sendPingsAutomatically`. ## Declarative responses A handler whose answer is fixed, give or take a value copied from the request, can be described up front and run entirely inside uws. No JavaScript runs per request, which is faster than any function handler. ```js import { declarative } from '@srfnstack/spliffy' export default { GET: declarative() .writeHeader('content-type', 'text/plain') .writeParameterValue('id') // path parameter by $name .end(''), POST: declarative().writeStatus('202 Accepted').end('{"queued":true}'), OPTIONS: declarative().writeStatus('204 No Content').writeHeader('allow', 'GET, POST').end('') } ``` Builder methods: `writeStatus('404 Not Found')`, `writeHeader(name, value)`, `writeParameterValue(name)`, `writeQueryValue(name)`, `writeHeaderValue(name)`, `writeBody()` (echo the request body), `write(text)` (a body chunk), `end(text)`. Always finish with `end`, it returns the compiled response. Status, header names and values are limited to 255 bytes, each `write` and the `end` text to 64 KiB. `compileResponse({ statusCode, statusMessage, headers, body })` compiles a fixed response of any size (a string or Buffer body) into the same form, for example a prebuilt HTML page. Limits: no middleware on the route, not usable for `WEBSOCKET`, not usable as `notFoundRoute` or `defaultRoute`, values are copied raw (no decoding). ## Logging ```js import { log } from '@srfnstack/spliffy' log.debug('x', obj); log.info(); log.warn(); log.error(err) // also trace, fatal ``` - `logLevel: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'NONE'`, default `INFO`. Lines go to the console with an ISO timestamp. - `logger: pino()` (or any object with `trace debug info warn error` methods) replaces the built in logger. `logLevel` is ignored then. - Spliffy writes no access log in 1.5.0. Add config middleware if you need one, and avoid logging full URLs when query strings may carry tokens. - `printRoutes: true` logs every route at startup, useful to check the file to URL mapping. - Uncaught exceptions and unhandled rejections are logged and do not crash the process. ## Full config reference Only `routeDir` is required. Defaults in brackets. Server - `routeDir` absolute path to the route directory, required - `port` [10420] http port - `host` [`::`] listen address - `httpsKeyFile`, `httpsCertFile` PEM files, both or neither. With them a TLS listener (TLS 1.2 and 1.3, no ALPN) serves every route on `httpsPort` - `httpsPort` [14420] - `httpRedirect` [true] with https, `port` answers 301 to the https url. `false` serves every route on both ports - `serveRoutesWithSlash` [false] folder index routes also answer with a trailing slash (`/foo/` as well as `/foo`) - `autoOptions` [false] generate `OPTIONS` handlers answering 204 with `Allow` - `writeDateHeader` [false] add a `Date` header, refreshed once a second - `printRoutes` [false] log the route table at startup - `middleware` [none] array or `{ METHOD: [...] }` applied to every route Routing - `defaultRoute` path of an existing route served with 200 for unmatched extensionless GET requests (SPA fallback) - `defaultRouteIgnore` [[]] path prefixes or regexes the default route must not answer - `notFoundRoute` path of an existing route served with 404 for every other unmatched request - `resolveWithoutExtension` [`['.html', '.htm']`, always included] extensions that also resolve without the extension - `ignoreFilesMatching` [[]] regex strings tested against file names to skip - `allowTestFileRoutes` [false] stop skipping `*.test.js` and `*.test.mjs` - `staticMode` [false] serve static files only, never load handler modules - `decodeQueryParameters` [false] `decodeURIComponent` query keys and values, `+` becomes space - `nodeModuleRoutes` [none] see Serving files from node_modules Static files - `staticCacheControl` [`max-age=600`] `Cache-Control` for static files - `staticContentTypes` [{}] `{ '.ext': 'type/subtype' }` additions and overrides - `cacheStatic` [false] keep file contents in memory, revalidated with a stat per request - `staticPrecompressed` [false] serve `.br` and `.gz` twins by accept-encoding - `declarativeStatic` [true] with `cacheStatic`, serve small files from inside uws (no 304s, never re-read) - `declarativeStaticMaxSize` [65535] largest file compiled into a declarative response Bodies and errors - `contentHandlers` [json, text, form, octet-stream] `{ 'type/subtype': { deserialize, serialize } }`. `application/octet-stream` bodies are handed over as the `Buffer` that arrived, and an empty body is an empty `Buffer` whatever the content type - `acceptsDefault` [`*/*`] handler used when a request has no `content-type` - `defaultContentType` [`*/*`] handler used when a response has no `content-type` - `errorTransformer` [identity] `(err, refId) => err` - `extendIncomingMessage` [false] give `req` the `http.IncomingMessage` prototype for passport style middleware Logging - `logLevel` [`INFO`] - `logger` [built in] Not functional in 1.7.0, accepted but ignored: `parseCookie` (cookies are always available), `decodePathParameters`, `logAccess`, a top level `routePrefix`. ## Package exports ```js import spliffy, { log, redirect, moduleDirname, declarative, compileResponse, compression, pickEncoding, parseQuery, setMultiValueKey, HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, /* ...every 4xx and 5xx */ } from '@srfnstack/spliffy' ``` - `moduleDirname(import.meta.url)` is `path.dirname(fileURLToPath(url))`, handy for `routeDir`. - `parseQuery(string, decode)` is the query parser used for `url.query`. - `compression(options)` is the response compression middleware, see Middleware. - `pickEncoding(acceptEncoding, ['br', 'gzip'])` picks the first offered encoding the header accepts. ## Project conventions that work well Seen across several production spliffy apps: - `routeDir: 'src/public'` for everything the network can reach, `src/private` for database and business code imported by the routes. Nothing in `private` is ever served. - `api/` folder with a `secure.mw.mjs` at its root that authenticates and sets `req.user`, and public endpoints (login, health, webhooks) either outside `api/` or allow listed inside the middleware by `req.path` prefix. - A built SPA in `src/public/ui/` with `defaultRoute: '/ui/'` and `staticCacheControl: 'no-cache'`, or an `index.rt.mjs` that reads the built `index.html` and sets `cache-control: no-store`. - Tests next to routes as `*.test.mjs`, started against a real server on a test port. - `cacheStatic: process.env.NODE_ENV === 'production'` so dev picks up file edits. - Start script reads `PORT` from the environment and passes `parseInt(process.env.PORT || '10420')`. ## Common mistakes - Using `query` or `pathParameters` on the context. Use `body`, `url.query`, `url.param('name')`. - Lower case method keys (`get:`) or an `ALL:` handler. Startup fails with a message naming the file. - Expecting `/foo/` to work. A file route never answers with a trailing slash. A folder index does, but only with `serveRoutesWithSlash: true`. - Returning `{ contentType: 'text/html', body }`. Put the type in `headers`. - Returning a data object that has a `body` or `headers` key and getting it unwrapped. - Setting `defaultRoute` and expecting a POST or a request for `/missing.js` to get the shell. Only extensionless GETs do; add `/api` style prefixes to `defaultRouteIgnore`. - Adding a file to `routeDir` while the server runs. The route table is built once at startup, so the new file is unknown until a restart. - Shipping ES modules with `cacheStatic` and the default ten minute `Cache-Control`, then deploying. Use `staticCacheControl: 'no-cache'` or hashed file names. - Forgetting `res.end()` after manual `res.write()` calls, or forgetting `res.streaming = true`. - Expecting multipart form parsing. Use `streamRequestBody: true` with busboy. - Keeping a reference to the raw uws `req` inside a WebSocket `upgrade` after an `await`. - Expecting `decodeURIComponent` on `url.param()` values. Decode them yourself.