# Documentation [![@server/next](https://img.shields.io/npm/v/@server/next?label=@server/next&color=greenlime)](https://www.npmjs.com/package/@server/next) [![tests](https://github.com/franciscop/server-next/workflows/tests/badge.svg)](https://github.com/franciscop/server-next/actions) A web server for Bun, Node.js and Functions/Workers with the basics built-in: ```js import server from "@server/next"; export default server(options) .get("/books", () => Book.list()) .post("/books", (ctx) => { return Book.create(ctx.body).save(); }); ``` It includes all the things you would expect from a modern Server framework, like routing, static file serving, body+file parsing, streaming, testing, error handling, websockets, etc. We also have integrations and adaptors for these: - KV Stores: in-memory, Redis, Consul, DynamoDB, [Level](https://github.com/Level/level). - Buckets: AWS S3, Cloudflare R2, Backblaze B2. - Auth: Cookie sessions, Bearer tokens, JWT, API keys, Social login, Email/password. ```js // index.test.js // How to test your server with the built-in methods import app from "./"; // Import your normal app // Convenient helper; each call returns a standard Response const api = app.test(); it("can retrieve the book list", async () => { const res = await api.get("/books/"); expect(res.status).toBe(200); const books = await res.json(); expect(books[0]).toEqual({ id: 0, name: ... }); }); ``` # Getting started First install it: ``` npm install @server/next bun add @server/next ``` Server.js has no dependencies of its own. For key-value stores and file storage you'll usually also want [`polystore`](https://polystore.dev/) and [`bucket`](https://bucketjs.com) (optional but recommended); see [Dependencies](#dependencies). Now you can create your first simple server: ```js // index.js import server from "@server/next"; export default server() .get("/", () => "Hello world") .post("/", (ctx) => { console.log(ctx.body); return 201; }); ``` Then run `node .` or `bun .` and open your browser on http://localhost:3000/ to see the message. See [Basic usage](#basic-usage) for the configuration options you'll most likely want next. # Guides ## Basic usage After [getting started](#getting-started), there are some important bits that you might want to update: `SECRET`: an environment variable with a long, unique random secret. It will be used to sign and/or encrypt things as needed. Put it in `.env` (+gitignore it) or in your provider's secret manager. `store`: almost anything you want to persist will need a KV store to do so. For dev you can use an in-memory or a file-based store, but for production you would normally use something like Redis. Use a `polystore` instance for this. `uploads`: if you want to accept user-uploaded files you need to tell the server where to put them. Pass a path for storing files locally, or a `Bucket` instance for cloud storage. See [File handling](#file-handling) for the full guide. ## Dependencies Server.js has no required runtime dependencies. Two optional companion libraries provide the adapters most apps need: - **[`polystore`](https://polystore.dev/)** powers the `store` and `session` options: one key-value interface over an in-memory `Map`, Redis, DynamoDB, the filesystem, and more. See [Stores](#stores). - **[`bucket`](https://bucketjs.com)** powers the `uploads`, `public`, and `favicon` options: one interface over local disk, S3, Cloudflare R2, and others. See [File handling](#file-handling). ```bash npm install polystore bucket bun add polystore bucket ``` Both are optional: anywhere a store is expected you can pass any object with the Polystore interface, and anywhere a bucket is expected any object with the `Bucket` interface (`file(name)` returning a file handle, and `folder(prefix)`). The companion libraries just save you from writing those adapters. ## Middleware Middleware are plain functions that receive `ctx` and optionally return a response. You can attach them globally with `.use()` or per-route inline: ```js // Global middleware, runs for every request const logger = (ctx) => { console.log(ctx.method, ctx.url.pathname); }; // Auth guard, returns early with 401 if not logged in const requireUser = (ctx) => { if (!ctx.user) return 401; }; export default server() .use(logger) .get('/public', () => 'Anyone can see this') .get('/private', requireUser, () => 'Logged-in users only'); ``` If a middleware returns a value, the chain stops and that value is sent as the response. If it returns nothing (`undefined`), the next middleware runs. See the [Router docs](#router) for how middleware order and path matching work. ## Stores A store is a key-value interface used to persist sessions, auth tokens, and other data. You can pass any compatible store to the `store` or `session` options. We use [Polystore](https://polystore.dev/) to manage the stores, so you'll need to install and pass it: ```js import kv from 'polystore'; const store = kv(new Map()); export default server({ store }) .get('/', () => 'Hello'); ``` For production, use a proper store like Redis: ```js import kv from 'polystore'; import { createClient } from 'redis'; const url = process.env.REDIS_URL; const store = kv(createClient({ url }).connect()); export default server({ store }) .get('/', () => 'Hello'); ``` ## Login with GitHub A step-by-step walkthrough of adding "Sign in with GitHub" with the [`auth`](#optionsauth) option. The same steps work for any [OAuth provider](#oauth-providers), swap `github` for `google`, `discord`, and so on. For the full option, strategy and route reference, see [Authentication](#authentication). ### 1. Register a GitHub OAuth app In GitHub, go to **Settings → Developer settings → OAuth Apps → New OAuth App**. Set the **Authorization callback URL** to `https:///auth/callback/github` (for local development, `http://localhost:3000/auth/callback/github`). GitHub gives you a **Client ID** and a **Client Secret**. ### 2. Set the environment variables ```bash GITHUB_ID=your-client-id GITHUB_SECRET=your-client-secret SECRET=a-long-random-string # signs the session cookie ``` ### 3. Configure the server Auth keeps users and sessions in a [store](#stores), so pass one along with `auth: 'cookie:github'`: ```js import server from '@server/next'; import kv from 'polystore'; export default server({ store: kv(new Map()), // use Redis or similar in production auth: 'cookie:github', }) .get('/', (ctx) => ctx.user ? `Hi ${ctx.user.name}` : 'Login with GitHub', ); ``` ### 4. Send users to the login route Link or redirect to `GET /auth/login/github`. The server runs the OAuth handshake, handles the callback, upserts the user into your store, sets a signed `HttpOnly` session cookie, and redirects back (to `/user` by default; change it with the object form's `redirect`). ### 5. Read and protect The signed-in user is on [`ctx.user`](#ctxuser) on every request (`undefined` when logged out), so a one-line middleware guards a route: ```js const requireUser = (ctx) => { if (!ctx.user) return 401; }; export default server({ store, auth: 'cookie:github' }) .get('/account', requireUser, (ctx) => `Hello ${ctx.user.email}`); ``` Log out with `POST /auth/logout`, which clears the session. ## File handling By default, file fields in multipart form submissions are silently skipped; only text fields are parsed. To enable uploads, configure the `uploads` option. Files are streamed to their destination as they arrive, so even large uploads aren't buffered in memory. Adding validation (below) is the one exception, since a file must be read whole to check its size. ### Local storage Pass a directory path and files are saved there automatically: ```js export default server({ uploads: './uploads' }) .post('/profile', (ctx) => { console.log(ctx.body.avatar); // { // name: 'photo.jpg', // original filename // id: 'xKj3mN9pQr2s4tUv.jpg', // storage key // path: '/abs/.../xKj3...Uv.jpg', // abs path // type: 'image/jpeg', // MIME type // size: 45231, // bytes // } }); ``` ### Cloud storage Pass any object that implements the `Bucket` interface: `file(name)` returns a file handle you `write(data)` / `remove()` / `stream()` (where `data` is a string, `Buffer`, or `ReadableStream`), and `folder(prefix)` returns a sub-scoped bucket. The uploaded-file object shape is identical: ```js import S3 from 'bucket/s3'; const uploads = S3('my-bucket', { id: process.env.S3_ID, key: process.env.S3_KEY, }); export default server({ uploads }) .post('/profile', (ctx) => { console.log(ctx.body.avatar); // { name, id, path, type, size } }); ``` ### Multiple values and files Repeat a field name to collect multiple values into an array on `ctx.body` (HTML's `name="tags[]"` works too; the `[]` is stripped). This applies to files as well, so a repeated file field gives you an array of `UploadedFile`s. ### Validation To add size or type constraints, pass `uploads` an object with the destination `bucket` plus the limits, instead of a bare path: ```js export default server({ uploads: { bucket: './uploads', // a path or a Bucket maxSize: '5mb', fileType: ['image/jpeg', 'image/png', '.jpg', '.png'], }, }) .post('/profile', (ctx) => { // { name, id, path, type, size } console.log(ctx.body.avatar); }); ``` If a file fails validation, the request throws before any handler runs. Object-form options: | Option | Type | Description | |--------|------|-------------| | `bucket` | `path \| Bucket` | Where to store the files (required) | | `maxSize` | `number \| string` | Maximum size in bytes or as a string: `'5mb'`, `'500kb'` | | `minSize` | `number \| string` | Minimum size, same format | | `fileType` | `string[]` | Allowed extensions (`.jpg`) and/or MIME types (`image/jpeg`), OR logic | With no `maxSize`/`minSize`/`fileType` set, files stream through unvalidated, the same as passing the bucket directly (`uploads: './uploads'`). ### Serving stored files [`public`](#optionspublic) serves a folder as open static assets. To serve a file from a bucket **behind your own logic** (an auth check, an ownership check, a signed link), return it from a normal route instead. Nothing is exposed until your handler says so: ```js import server, { file, type, redirect } from '@server/next'; export default server({ store, auth: 'cookie:github' }) .get('/files/:id', async (ctx) => { if (!ctx.user) return 401; const stored = bucket.file(ctx.url.params.id); if (!(await isOwner(ctx.user, stored))) return 403; // your check return stored; // streamed back, typed, 404 if missing }); ``` Returning the bucket file handle streams it with the file's own `Content-Type` and a `404` when it doesn't exist. Three shapes work, pick per need: ```js // 1. The handle: streamed (no buffering), type from the name, 404 if missing .get('/avatar', () => bucket.file('avatars/me.jpg')) // 2. Through file(): same, but chainable (add download(), headers(), a status) .get('/avatar', () => file(bucket.file('avatars/me.jpg'))) // 3. Raw bytes: buffered in memory; set the type yourself (bytes carry no name) .get('/avatar', async () => type('jpg').send(await bucket.file('avatars/me.jpg').bytes())) ``` Prefer 1 or 2 (streaming) for large files; use 3 only when you need the bytes in hand. **Signed temporary URLs.** Streaming a private file routes its bytes through your server. If your storage provider issues short-lived signed URLs, redirect straight to the object after your auth check so the transfer offloads to the provider: ```js .get('/files/:id', async (ctx) => { if (!ctx.user) return 401; const url = await signUrl(bucket, ctx.url.params.id, { expires: '5m' }); // provider-specific return redirect(url); }); ``` ### Streaming, raw, and large uploads The default `parse` mode handles forms and files for you. For the cases it doesn't, set the [`body` option](#optionsbody) per route: - **A raw, single-file body.** Posting a file as the whole request body (e.g. `Content-Type: image/png`, no form) is stored as one file in `parse` mode; `ctx.body` is its `{ name, id, path, type, size }` reference, just like a form field. - **Very large files, or a per-request destination.** Use `body: 'stream'` to receive the request's `ReadableStream` as `ctx.body` and pipe it to storage yourself, after your own checks, without buffering. `bucket().folder(prefix)` scopes a sub-path: ```js import S3 from 'bucket/s3'; const uploads = S3('my-bucket', { id: process.env.S3_ID, key: process.env.S3_KEY, }); export default server({ uploads }) .post('/videos/:id', { body: 'stream' }, requireUser, async (ctx) => { await uploads .folder(ctx.url.params.id) .file('video.mp4') .write(ctx.body); return 201; }); ``` - **Webhooks or exact bytes.** Use `body: 'raw'` to get the unparsed `Buffer` (for example, to verify a signature). ## WebSockets Define WebSocket handlers with `.socket(event, fn)`, where `event` is `'open'`, `'message'`, or `'close'`. Inside a handler, `ctx.socket` is the current connection, `ctx.sockets` is every open connection (for broadcasting), and on a `'message'` event `ctx.body` is the data the client sent. ```js import server, { file } from '@server/next'; export default server() .get('/', () => file('./index.html')) .socket('open', (ctx) => { ctx.socket.send('welcome'); }) .socket('message', (ctx) => { // echo back to the sender ctx.socket.send(`You said: ${ctx.body}`); // ...or broadcast to everyone for (const socket of ctx.sockets) { socket.send('someone said something'); } }) .socket('close', () => { // a client disconnected }); ``` Connect from the browser with the standard `WebSocket` API: ```js const ws = new WebSocket(`ws://${location.host}`); ws.onopen = () => ws.send('hello'); ws.onmessage = (e) => console.log(e.data); ``` The upgrade is handled for you; you don't open a separate port or socket path. `.socket()` works the same on **Node** and **Bun**. Edge runtimes (Cloudflare and Netlify) don't support long-lived socket connections. ### Authenticated sockets If [`auth`](#optionsauth) is configured, the connecting user is resolved during the WebSocket handshake and exposed as `ctx.user` in every socket handler, the same object you get on HTTP routes (`undefined` when nobody is signed in). A browser sends its session cookie automatically on the upgrade, so the `cookie` strategy just works; there's nothing extra to pass from the client. As with HTTP, this only loads the user, it does not reject the connection, so guard inside the handler and `close()` unauthenticated sockets yourself: ```js export default server({ store, auth: 'cookie:github' }) .socket('open', (ctx) => { if (!ctx.user) return ctx.socket.close(); ctx.socket.send(`welcome ${ctx.user.name}`); }); ``` A browser `WebSocket` can't set headers, so the `cookie` strategy authenticates browser sockets; non-browser clients can send an `Authorization: Bearer` header on the upgrade for the `token` / `jwt` / `key` strategies. A missing or expired credential connects as anonymous (`ctx.user` undefined); a present-but-invalid one is rejected at the handshake with `401`. ## Proxying requests `fetch()` returns a web `Response`, and returning one from a route forwards it as-is: status, headers, and a **streamed** body (never buffered into memory). That's the whole mechanism behind a backend-for-frontend, an API gateway, or hiding an upstream credential from the browser: ```js export default server() .get('/gh/*', (ctx) => fetch(`https://api.github.com${ctx.url.pathname.replace('/gh', '')}`, { headers: { authorization: `Bearer ${process.env.GH_TOKEN}` }, }), ); ``` To transform the response instead of forwarding it verbatim, read it and return something new: ```js .get('/data', async () => { const res = await fetch('https://upstream.example/data'); if (!res.ok) return res.status; // forward the upstream error status const data = await res.json(); return { ...data, cachedAt: new Date().toISOString() }; }); ``` ## Templates Server.js has no built-in view engine, and doesn't need one: a route that returns an HTML string is sent as `text/html`, so any template engine works by returning its rendered output, and none of them is a dependency of the core. Compile your templates once and call them from your routes. ```js // EJS import ejs from "ejs"; export default server() .get("/users/:id", (ctx) => ejs.renderFile("./views/user.ejs", { id: ctx.url.params.id }), ); ``` ```js // Pug: compileFile caches the compiled template import pug from "pug"; const home = pug.compileFile("./views/home.pug"); export default server().get("/", () => home({ name: "World" })); ``` ```js // Handlebars: compile once from disk import Handlebars from "handlebars"; import { readFile } from "node:fs/promises"; const tpl = Handlebars.compile(await readFile("./views/home.hbs", "utf8")); export default server().get("/", () => tpl({ name: "World" })); ``` When the rendered output doesn't start with `<` (a partial or fragment), set the type explicitly: ```js import { type } from "@server/next"; .get("/fragment", () => type("html").send(tpl(data))); ``` ## JSX JSX is an amazing template language and so Server.js supports it **when using Bun**. Configure it once in your `tsconfig.json`, which sets it up for both Bun (the runtime) and your editor (types): ```json { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "@server/next" } } ``` Both lines are needed: `jsx` turns on JSX and the automatic runtime, and `jsxImportSource` points it at Server.js instead of React. Keep them directly in the file, since Bun doesn't read JSX settings through `extends`. Then name your files `.jsx` (or `.tsx`) and you are ready to go! ```jsx import server from '@server/next'; export default server() .get('/', () => ) .get('/:page', ctx => ); ``` The main difference from normal JSX is that you include the whole document (the ``, ``, etc. tags) since whatever you return is sent as the HTML. The trade-offs: - We will send the html fragments unmodified, so `() =>
Hello
` will render `"
Hello
"`. - Exception: if you define `...` as the top level, we will automatically inject ``, since it's not possible to inject that with JSX. - You also need to define the top level tags and html structure such as ``, ``, ``, etc. We recommend putting those into a template and reusing it, but that's up to your preferences. - This is [a great match for HTMX](#htmx)! - You can use fragments as usual with `<>` (but not with ``). - Since you are using JSX, normal interpolation is safe from XSS since any special characters are encoded as their html entities. - You can set up raw HTML, e.g. to avoid having inline JS scripts escaped, like this: ``. Some examples: ```js export default server() .get('/', () => ( My first app

My first app

Your name:
)); ``` > [!NOTE] > You cannot render JSX to a plain string. It is only turned into HTML as the response is sent, so its escaping can never be bypassed by concatenating the result into other markup. ## HTMX There's no explicit HTMX integration, but two approaches work well. First, the vanilla version: ```js export default server() .get('/', () => file('index.html')) .post('/action', async (ctx) => { // do sth return '
Success!
'; }); ``` In here, we assume you have a template in `index.html` and are loading HTMX from there. Then when an action occurs, you can return the raw HTML string. > [!WARNING] > If you interpolate strings like that, you might be subject to XSS attacks! ```js .post('/action', async (ctx) => { // DO NOT DO THIS return `
Success! ${ctx.url.query.name}
`; // DO NOT DO THIS }); ``` For that reason we recommend that you [set up JSX with Server.js](#jsx) and then instead reply like this: ```js .post('/action', async (ctx) => { // This is safe since html entities will be encoded: return
Success! {ctx.url.query.name}
; }); ``` Since JSX will treat that interpolation as a _text_ interpolation and not a _html_ interpolation, html entities will be escaped as expected and presented as plain text. ## Migration from 1.x Server.js 2.x is a ground-up rewrite on top of web standards (`Request`/`Response`), with **no required dependencies** and first-class support for Bun, Node, and [other platforms](#platforms). The concepts from 1.x carry over, but the syntax changed. > **⚠️ Defaults that changed silently. Check these first.** These don't throw an error, so they're easy to miss on a migrated app: > > - **No CSRF token middleware** (1.x bundled Csurf). SameSite cookies and the OAuth `state` check still cover the common vector, see [`security`](#optionssecurity). > - **`public` is off**: 1.x served `./public` automatically; 2.x serves nothing until you set [`public`](#optionspublic). > - **Uploads are dropped** without an [`uploads`](#optionsuploads) destination, file fields are skipped, no error. > - **Option precedence flipped**: an explicit `server({...})` option now wins over its env var, the reverse of 1.x. Overriding a hardcoded default via env in production no longer takes effect. ### Runtime and dependencies 1.x ran on **Express** (pulling in Express, socket.io, formidable, and more). 2.x is built directly on the web-standard `Request`/`Response`, needs no dependencies, and runs unchanged on Bun, Node, and edge runtimes. - **No Express underneath**, `server.utils.modern()` and raw Express middleware are gone; middleware are plain `(ctx) => …` functions (see [Middleware](#middleware)). - **`ctx.req` / `ctx.res` are the web `Request` / `Response`**, not Node's, code using `req.pipe` / `res.writeHead` needs updating. - **Optional companions** replace the built-in adapters: [`polystore`](#stores) for `store`/`session`, [`bucket`](#file-handling) for `uploads`/`public`/`favicon`. ### Creating the server 1.x took an options object and an array of routes; 2.x chains routes onto the default export and exports it: ```js // 1.x const server = require('server'); const { get, post } = server.router; server({ port: 3000 }, [ get('/', (ctx) => 'Hello world'), post('/', (ctx) => `Received ${ctx.data}`), ]); // 2.x import server from '@server/next'; export default server({ port: 3000 }) .get('/', (ctx) => 'Hello world') .post('/', (ctx) => `Received ${ctx.body}`); ``` ### Routing Router methods are chained onto the server (or a standalone [`router()`](#router)) instead of imported from `server.router`: | 1.x | 2.x | |-----|-----| | `get`, `post`, `put`, `head`, `options` | same, chained: `.get('/x', fn)` | | `del(...)` | `.delete(...)` | | `patch` | `.patch(...)` | | `socket(...)` | `.socket(event, fn)`, see [WebSockets](#websockets) | | `error('name', fn)` | [`onError`](#optionsonerror) option | | `sub('/prefix', ...)` | [`router()`](#router) + [`.use()`](#middleware) | Error handling is now the single [`onError`](#optionsonerror) option instead of per-name routes; branch on `error.code` inside it for the namespaced cases. Routing is first-match, one route per request, Express regex paths and `/prefix/*`-then-specific layering don't apply, so use [`.use()`](#middleware) for shared guards. See [Router](#router). ### Replies Return a plain value (string, object, number, `Response`) instead of a `server.reply` helper; the helpers still exist but are now [imported from the package](#inline-replies): | 1.x reply | 2.x | |-----------|-----| | `send`, `json`, `redirect`, `file`, `download`, `status`, `type` | same, imported from `@server/next` | | `header(...)` | `headers(...)` | | `cookie(...)` | `cookies(...)` | | `render(view, locals)` | *removed*, import a template engine ([Templates](#templates)) | | `jsonp(...)` | *removed* | |, | `cache(...)` (new) | ### Context (`ctx`) Everything about the URL now lives under `ctx.url` (a `URL` instance), and the parsed body is always `ctx.body`: | 1.x | 2.x | |-----|-----| | `ctx.data` | `ctx.body` | | `ctx.url` (a string) | `ctx.url` (a `URL`, string operations like `ctx.url.includes('?')` break) | | `ctx.params` | `ctx.url.params` | | `ctx.query` | `ctx.url.query` | | `ctx.path` | `ctx.url.pathname` | | `ctx.files` | `ctx.body` (files sit alongside text fields) | | `ctx.secure`, `ctx.xhr` | *removed* (derive from `ctx.url.protocol` / `ctx.headers`) | | `ctx.method`, `ctx.headers`, `ctx.cookies`, `ctx.ip`, `ctx.session`, `ctx.options` | same | New in 2.x: [`ctx.user`](#ctxuser), [`ctx.platform`](#ctxplatform), [`ctx.socket` / `ctx.sockets`](#ctxsocket), `ctx.app`. ### Options | 1.x option | 2.x | |------------|-----| | `port`, `favicon` | same | | `secret` | same, now settable as an option (1.x was env-only) | | `public` | [off by default](#optionspublic) (1.x served `./public`) | | `security` | headers only, no [CSRF middleware](#optionssecurity) | | `log` | [on/off only](#optionslog), no levels, `ctx.log`, or `report` | | `session` | a [KV store](#optionssession), not a config object | | `parse` | [`body`](#optionsbody), `parse`/`raw`/`stream` + `max` (1mb default) | | `views` + `engine` | *removed*, import a template engine ([Templates](#templates)) | | `socket` (socket.io) | *removed*, native [`.socket()`](#websockets) | | `env` | *removed*, use [`ctx.platform.production`](#ctxplatform) | |, | `store`, `cookies`, `uploads`, `cors`, `auth`, `openapi`, `onError`, `onResponse`, `cache` (new) | Environment variables: `LOG` → `LOG_LEVEL`; `VIEWS` / `ENGINE` are gone (templating removed). `PORT`, `SECRET`, `PUBLIC`, `FAVICON`, `CORS`, `AUTH` still work. ### WebSockets socket.io is replaced by native WebSockets, no dependency, the same on Node and Bun: | 1.x (socket.io) | 2.x (native) | |-----------------|--------------| | `socket('chat', fn)` (any event name) | `.socket('open' \| 'message' \| 'close', fn)` | | `ctx.io`, `ctx.socket` | `ctx.socket` (this one), `ctx.sockets` (all) | | payload arg / `ctx.data` | `ctx.body` | | `ctx.io.emit(...)` | `ctx.socket.send(...)`, or loop `ctx.sockets` | | socket.io client | browser `WebSocket` | socket.io's rooms, namespaces, named events, reconnection, and transport fallbacks have no equivalent, build what you need yourself. Edge runtimes (Cloudflare, Netlify) don't support sockets. See [WebSockets](#websockets). # Options The `server(options)` function initializes a server instance with a variety of configuration options. These options allow you to customize server behavior and integrate essential features. Below is a detailed description of each option available: - **[`port`](#optionsport)** (`number`): port the server listens on. - **[`secret`](#optionssecret)** (`string`): key used to sign cookies and tokens. - **[`public`](#optionspublic)** (`path | Bucket`): directory or bucket of static assets. - **[`uploads`](#optionsuploads)** (`path | Bucket`): where uploaded files are stored. - **[`body`](#optionsbody)** (`options`): how request bodies are read into `ctx.body`, and their size limit. - **[`cache`](#optionscache)** (`duration | number | false`): default `Cache-Control` for GET responses, plus auto-`ETag`. - **[`store`](#optionsstore)** (`Store`): general-purpose key-value store. - **[`session`](#optionssession)** (`Store`): store for user sessions. - **[`cors`](#optionscors)** (`options`): Cross-Origin Resource Sharing settings. - **[`auth`](#optionsauth)** (`options`): authentication method and provider, e.g. `cookie:github`. - **[`cookies`](#optionscookies)** (`Store`): store for signed cookie persistence. - **[`onError`](#optionsonerror)** (`function`): handler for uncaught middleware errors. - **[`onResponse`](#optionsonresponse)** (`function`): hook over every outgoing response. - **[`log`](#optionslog)** (`options`): built-in startup, connection and request logs. - **[`favicon`](#optionsfavicon)** (`path | BucketFile`): icon served at `/favicon.ico`. - **[`security`](#optionssecurity)** (`options`): secure-by-default response headers and `trustProxy`. ### Usage Example ```js import server from '@server/next'; export default server({ port: 3000, secret: 'your-secret-key', public: './public', uploads: './uploads', store: kv(new Map()), cors: true, auth: 'cookie:github', }); ``` ## Environment variables Several options fall back to environment variables, so you can configure them without touching code. Anything in your `.env` (or the platform's environment) is read at startup: | Variable | Option | Notes | |----------|--------|-------| | `PORT` | `port` | Port to listen on. Defaults to `3000`. | | `SECRET` | `secret` | Key used to sign cookies and tokens. Set a long, random value in production. | | `PUBLIC` | `public` | Directory of static assets to serve. | | `FAVICON` | `favicon` | Path to the icon served at `/favicon.ico`. | | `CORS` | `cors` | Allowed origin(s), same values as the option. | | `AUTH` | `auth` | Auth string, e.g. `cookie:github`. | | `AUTH_KEY` | `auth.key` | Shared secret for the `key` strategy. | | `LOG_LEVEL` | `log` | Set to `info` to turn on logging. | | `NODE_ENV` | (none) | `production` enables production behavior, such as `Secure` cookies. | An explicit option passed to `server()` always wins over its environment variable. Auth providers read their own credentials from the environment too (`GITHUB_ID` / `GITHUB_SECRET`, etc.); see the [`auth`](#optionsauth) option for the per-provider list. ## options.port The port number on which the server listens for incoming connections. Defaults to the `PORT` environment variable, or `3000` if neither the option nor the variable are set. ```js server({ port: 2000 }); ``` Order of reading: 1. The option passed as a number. 2. The environment variable, which can come from `.env`, the CLI, etc. 3. Port `3000` otherwise. ## options.secret The key used to sign and encrypt cookies and tokens. Keep it long, random and private. Defaults to the `SECRET` environment variable; if neither is set, an `unsafe-` prefixed value is generated, which is fine for local development but not for production. It **must** be set and stable for the [`jwt`](#optionsauth) strategy, whose tokens are signed with it, otherwise a per-process secret invalidates every token on restart. ```js server({ secret: process.env.SECRET }); ``` Order of reading: 1. The option passed as a string. 2. The `SECRET` environment variable. 3. A generated `unsafe-` value otherwise. ## options.cors Cross-Origin Resource Sharing settings, letting a browser on another origin call your server. Off by default. ```js server({ cors: true }) // allow any origin server({ cors: 'https://app.example.com' }) // a single origin ``` For finer control, pass an object: ```js server({ cors: { origin: 'https://app.example.com', credentials: true, }, }); ``` With `credentials: true`, the request's origin is reflected back instead of `*` (the wildcard isn't allowed alongside credentials). Preflight `OPTIONS` requests are answered automatically. Order of reading: 1. The option (`boolean`, origin string, or object). 2. The `CORS` environment variable. 3. Off otherwise. ## options.public A directory or [`Bucket`](https://bucketjs.com) served as static assets, such as images, CSS and JavaScript files. Requests are matched against it by path and returned with the right content type. Served with `Cache-Control`, an `ETag` and `Last-Modified` (conditional requests get a `304 Not Modified`), and `Range` requests are honored with `206 Partial Content` so media can be seeked and downloads resumed. Off by default. ```js server({ public: './public' }); ``` ## options.uploads Where uploaded files are stored: a local directory path, a [`Bucket`](https://bucketjs.com) for cloud storage like S3 or R2, or an object `{ bucket, maxSize, minSize, fileType }` that adds size and type checks before storing. When it's not set, file fields in multipart submissions are silently skipped. See [File handling](#file-handling). ```js server({ uploads: './uploads' }); ``` ## options.body Controls how the request body is read into [`ctx.body`](#ctxbody). The value is a **mode** (`parse` | `raw` | `stream`, default `parse`), or an object `{ mode, max }`: ```js server() // defaults to `parse` server({ body: 'raw' }) // read it into a single Buffer server({ body: 'stream' }) // receive the raw stream server({ body: { max: '1mb' } }) // capped at 1mb ``` The three modes: - **`parse`** (default): the body is parsed by its content-type. JSON and form fields become an object; uploaded files stream to `uploads` as they arrive (never buffered whole) and `ctx.body` holds a reference to each. A raw non-form body (say a posted `image/png`) is streamed to `uploads` as a single file too. - **`raw`**: the body is read into a single `Buffer` and left unparsed, for when you need the exact bytes (for example, verifying a webhook signature). - **`stream`**: the body is not read at all. `ctx.body` is the request's web `ReadableStream`, so you can pipe it straight to storage without ever buffering it in memory. Set it globally as above, or on a per route basis: ```js server() .post('/webhook', { body: 'raw' }, (ctx) => { // ctx.body is the exact Buffer, e.g. to verify a signature }); ``` `stream` mode is ideal for receiving a single large file without buffering it. Because middleware runs before the body is read, you can authenticate or validate first; see [File handling](#file-handling): ```js export default server() .post('/videos/:id', { body: 'stream' }, async (ctx) => { const id = ctx.url.params.id; // validate this as well await bucket.file(`${id}.mp4`).write(ctx.body); return 201; }); ``` ### Limiting the body size `max` caps how much of a request Server.js holds **in memory**, defaulting to `'1mb'`. Only buffered content counts: JSON, text and url-encoded bodies, `raw` mode, and multipart *text* fields; files stream to [`uploads`](#optionsuploads) and are limited by the [`uploads` object form](#file-handling) instead. An oversized request is rejected with `413 Payload Too Large`. Pass a byte count or a size string (`'500kb'`, `'10mb'`), or `max: false` to disable it. `stream` mode is never capped, since its bytes are never buffered. ```js server({ body: { max: '1mb' } }) // global default .post('/proxy', { body: { max: false } }, h); // no limit for this route ``` > `uploads` is where files are stored; `body` is how the body is read. They are independent: `uploads` applies only in `parse` mode and is ignored when you read the body as `raw` or `stream`. ## options.cache Response caching, split into two independent parts: - A default **`Cache-Control`** header, set from this option. It tells the browser (and any CDN) how long the response is fresh, so it isn't re-requested at all during that window. Off by default. - An automatic **`ETag`**, always on. Any buffered GET `200` response is hashed into a strong `ETag`; a later request with a matching `If-None-Match` gets a `304 Not Modified` with no body, so the bytes travel only when they've actually changed. Streaming responses are skipped, since they can't be hashed without buffering. The value is a **duration** (`'1h'`, `'7d'`), a **number of seconds**, or `false`/`0` for `no-store`. It sets `Cache-Control: public, max-age=`, and only applies to GET responses with a `200` status, so mutations and errors are never cached. For anything more specific (`private`, `s-maxage`, `immutable`, ...), set the header yourself with [`headers()`](#headers). ```js // global default: Cache-Control: public, max-age=3600 on GET responses export default server({ cache: '1h' }) .get('/posts', () => Post.list()) // a route's `cache` overrides the global default (local wins, like `body`) .get('/me', { cache: false }, (ctx) => ctx.user) // per-request, when the value is only known at runtime .get('/report', (ctx) => cache(ctx.user ? false : '1h').json(build(ctx))) // full control: just set the header .get('/avatar', () => headers('cache-control', 'private, max-age=300').file('./me.png')); ``` Precedence is `cache()` (a [reply helper](#cache), in the handler) over the route's `cache` option over the global one. A route that sets `Cache-Control` itself is never overridden, and `false`/`0` emits `no-store` to punch through a global default. ## options.store A general-purpose key-value store used for sessions, auth, cookies and your own data. Pass any [`polystore`](https://polystore.dev/) instance, backed by an in-memory `Map`, Redis, DynamoDB, the filesystem, and more. Off by default, though several features (like [`auth`](#optionsauth)) need it. See [Stores](#stores). ```js import kv from 'polystore'; server({ store: kv(new Map()) }); ``` ## options.session The store used for user sessions. Defaults to your [`store`](#optionsstore) prefixed with `session:`, so you rarely set it directly; pass it only to keep sessions in a separate store. ```js server({ store, session: sessionStore }); ``` ## options.auth Configures authentication and loads the signed-in user onto [`ctx.user`](#ctxuser). The string form is `:` (e.g. `cookie:google`); for several providers use `{ strategy, providers: [...] }`. Defaults to the `AUTH` environment variable, or off. See the full [Authentication guide](#authentication). ```js export default server({ store, auth: 'cookie:google' }) .get('/me', (ctx) => ctx.user || 401); // visit /auth/login/google to sign in ``` This option only loads the user; it does not gate any route. Protect the routes you want by checking `ctx.user` yourself (`if (!ctx.user) return 401`), as shown in [Protecting routes](#protecting-routes). Strategies are `cookie` (a session cookie), `token` (an opaque bearer token), and `jwt` (a self-contained, signed bearer token). For machine-to-machine access, the provider-less `key` strategy checks a shared secret from `AUTH_KEY` (or `auth.key`) against an `Authorization: Bearer ` header. Each provider registers its own routes and reads its credentials from environment variables: | Provider | Env vars | Login route | |----------|----------|-------------| | `email` | (none, uses your `store`) | `POST /auth/register/email`, `POST /auth/login/email` | | `github` | `GITHUB_ID`, `GITHUB_SECRET` | `GET /auth/login/github` | | `google` | `GOOGLE_ID`, `GOOGLE_SECRET` | `GET /auth/login/google` | | `microsoft` | `MICROSOFT_ID`, `MICROSOFT_SECRET` | `GET /auth/login/microsoft` | | `discord` | `DISCORD_ID`, `DISCORD_SECRET` | `GET /auth/login/discord` | | `facebook` | `FACEBOOK_ID`, `FACEBOOK_SECRET` | `GET /auth/login/facebook` | | `apple` | `APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY` | `GET /auth/login/apple` | Every OAuth provider (all except `email`) exposes `GET /auth/login/` to start the flow and `/auth/callback/` for the redirect back (a `GET`, except Apple which uses `POST`). Point each provider's OAuth redirect URI at `https:///auth/callback/`. > **Apple** needs a little more setup: its client secret is a short-lived ES256 JWT, signed from your `.p8` key. Provide the key contents as `APPLE_PRIVATE_KEY`, your Team ID as `APPLE_TEAM_ID`, the key's ID as `APPLE_KEY_ID`, and your Services ID as `APPLE_ID`. Session cookies are set `HttpOnly` and `SameSite=Lax` (plus `Secure` in production), and the OAuth flows are CSRF-protected with a `state` token kept in a short-lived cookie, so the browser must accept cookies for login to complete. Invalid or missing credentials respond with `401` (a failed OAuth `state` check is `403`). ## options.cookies A store used to persist signed cookies across requests. When it's not set, cookies are stored client-side only and are not signed. ```js server({ cookies: store }); ``` ## options.onError A handler invoked whenever a middleware throws an unhandled error. It receives the error and the current [`ctx`](#context), and must return a `Response`. Without it, the default responds with the error's `message` and its `status` (or `500` when none is set). ```js import server, { status } from '@server/next'; export default server({ onError: (error, ctx) => { console.error(error); return status(500).json({ error: error.message }); }, }); ``` ## options.onResponse A hook over every outgoing HTTP response: routes, static files, 404s, and [`onError`](#optionsonerror) output alike (never WebSocket traffic). It receives the finalized `Response` and the [`ctx`](#context), and may be `async`. Return a `Response` to replace the outgoing one, it's sent **as-is**, so the hook owns its status and headers (nothing is re-finalized). Return nothing to leave the response unchanged. ```js // Add a header to every response export default server({ onResponse: (res, ctx) => { res.headers.set('x-app-version', '1.2.3'); return res; }, }); ``` A common use is a custom 404 (or any status-based rewrite), since the hook sees the finalized status: ```js export default server({ onResponse: (res) => res.status === 404 ? new Response('Not found here', { status: 404 }) : res, }).get('/', () => 'home'); ``` A returned `Response` is sent verbatim, so a brand-new one won't carry the framework's security headers or `ETag`, mutate and return the original when you want those kept. ## options.log Enables Server.js' built-in logging. Off by default; set it to `'info'`, or use the `LOG_LEVEL=info` environment variable, to log three things, all prefixed with `[server:]`: - **Options**: one line per configured module on startup, e.g. `[server:auth] github auth enabled` or `[server:uploads] ./uploads`. - **Connection**: the server URL once it starts listening, e.g. `[server:start] http://localhost:3000/`. - **Requests**: one line per request, e.g. `[server:api] POST /hello 1kb → 200 OK 10kb` (redirects also show their target). ```js export default server({ log: 'info' }) .get('/', () => 'Hello world'); // [server:start] http://localhost:3000/ // [server:api] GET / → 200 OK 11b ``` ## options.favicon The icon served at `/favicon.ico`, as a file path (read from disk) or a [`bucket`](https://bucketjs.com) file (e.g. from S3). When set, a `GET /favicon.ico` route is registered: the bytes are read once on the first request and cached until restart, then served with `Cache-Control` and an `ETag` (so conditional requests get a `304`). A `favicon.ico` in your [`public`](#optionspublic) folder still wins. When it's not set, no route is registered, so your own `/favicon.ico` route (if any) handles it, and otherwise it's a normal `404`. ```js // from disk server({ favicon: './assets/favicon.ico' }); // from a bucket server({ favicon: bucket.file('favicon.ico') }); ``` ## options.security Security-related settings, grouped under one option. Server.js sends a set of secure-by-default response headers on every response (routes, static files, 404s and errors alike). Pass `security: false` to turn them all off, or an object to override or extend individual pieces. > **CSRF.** `security` covers response headers only, there's no synchronizer-token middleware. CSRF is instead mitigated by `SameSite=Lax` session/auth cookies (a cross-site POST won't carry them) and, for OAuth logins, the `state` parameter. Add a token layer yourself if you need that defense-in-depth. ```js server() // secure headers on by default server({ security: false }) // turn them all off ``` Pass an object to override or extend individual pieces: ```js server({ security: { frameguard: 'DENY', referrerPolicy: false, csp: "default-src 'self'", }, }); ``` On by default. Each accepts `false` to disable it; `frameguard`, `referrerPolicy`, and `hsts` also accept a string to override their value (`noSniff` and `xssProtection` are on/off only): - **`frameguard`**: `X-Frame-Options`, default `SAMEORIGIN`. Stops your pages being framed by other sites (clickjacking). - **`noSniff`**: `X-Content-Type-Options: nosniff`, default on. Stops the browser MIME-sniffing a response into a different type. - **`referrerPolicy`**: `Referrer-Policy`, default `strict-origin-when-cross-origin`. - **`xssProtection`**: `X-XSS-Protection: 0`, default on. Disables the legacy XSS auditor. - **`hsts`**: `Strict-Transport-Security`, default `max-age=15552000; includeSubDomains`. Only sent on production responses, since it applies to HTTPS. Opt-in, off unless you set them: - **`csp`**: `Content-Security-Policy`. Pass the full policy string. - **`coop`**: `Cross-Origin-Opener-Policy`, e.g. `same-origin`. - **`corp`**: `Cross-Origin-Resource-Policy`, e.g. `same-origin`. Note this can block other origins from embedding your `public` or `uploads` assets. - **`permissionsPolicy`**: `Permissions-Policy`, e.g. `geolocation=()`. A header that a route sets itself is never overridden, so you can still control any of these per route with the [`headers()`](#headers) reply helper. Setting `security: false` disables every header above, but not the `trustProxy` default below. - **`trustProxy`** (`boolean`, default `true`): whether to trust the `x-forwarded-for` and `x-real-ip` headers when computing [`ctx.ip`](#ctxip). On by default, since most deployments sit behind a reverse proxy or load balancer. Set it to `false` when clients connect directly to your server, otherwise a client could spoof its own IP by sending the header. Platform headers that can't be forged (`cf-connecting-ip`, `x-nf-client-connection-ip`) are always honored regardless of this setting. ```js server({ security: { trustProxy: false } }); ``` # Router The routes can be created in two ways; through the main `server()`, or through the `router()` (from `import { router } from '@server/next';`). For simple applications just using the `server()` instance is usually enough and recommended. But once you start to have too many routes (a good rule of thumb is 10 routes), it's convenient to split into different files with `router()`. Let's see first a simple example of how to define different routes: ```js import server from '@server/next'; export default server() .get('/', () => 'Hello from the homepage') .get('/info', () => '

Greetings from the info page

') .post('/contact', ctx => { console.log(ctx.body); return 'Message sent successfully'; }) .put('/users/:id', ctx => { console.log(ctx.url.params.id, ctx.body); return 200; }); ```` A route is composed of the method, the path, an optional options object, and the middleware: ```js .METHOD(PATH, OPTIONS?, ...MIDDLEWARE) ```` The method can be any of the well-known HTTP methods: - `get()`: Used to request data from a specified resource. Does not accept a body. - `post()`: Used to send data to a server to create/update a resource. Accepts a body. - `put()`: Used to update a current resource with new data. Accepts a body. - `patch()`: Used to apply partial modifications to a resource. Accepts a body. - `delete()`: Used to delete a specified resource. Does not usually accept a body. - `options()`: Used to describe the communication options for the target resource. Does not accept a body. - `head()`: Similar to GET, but it requests a response without the body content. Does not accept a body. The path is detailed [in the path documentation](#parameters), but generally is one of these: omitted, an exact match, a parameter-based path, or a wildcard path: - *omitted*: it will match any path, in the router it is the same as `*` - `/info`: exact match to `localhost:3000/info` - `/posts/:id`: a match by parameter - `/posts/:id(number)`: a match by parameter, including types - `/posts/*`: a match to any sub-path that has not been previously matched Finally, the middleware is a function that gets called when both the method and paths match the current request. There are two types, one is a simple middleware that does *not* return anything; no return, `return undefined`, `return null` or `return false` all work like that. These will be called in sequence as detailed below, until finding a middleware that returns something or there is no middleware left. ```js export default server() .use(middleware1) .get('/', middleware2, middleware3, middleware4); ``` In this example, opening `http://localhost:3000/` calls `middleware1`, `middleware2`, `middleware3`, then `middleware4` in order, stopping as soon as one of them returns a response. If in that example middleware 4 returns something, that will be used to respond to the browser. If it does not, a 404 will be triggered, since once a method is matched it will not continue to other methods. ## Route options An options object can go between the path and the middleware to override the server defaults for that route only (local wins): ```js server({ cache: false }) // cache this route for an hour .get('/posts', { cache: '1h' }, () => Post.list()) // read the raw bytes for a webhook signature .post('/hook', { body: 'raw' }, (ctx) => verify(ctx.body)); ``` | Option | Type | Description | |--------|------|-------------| | `body` | `'parse' \| 'raw' \| 'stream'` or `{ mode, max }` | How the request body is read, see [`body`](#optionsbody) | | `cache` | `duration \| number \| false` | `Cache-Control` for this route, see [`cache`](#optionscache) | | `tags` | `string \| string[]` | Group the route (used by OpenAPI) | | `title` | `string` | Short label for the route (OpenAPI) | | `description` | `string` | Longer description for the route (OpenAPI) | ## Global middleware with `.use()` `.use()` registers cross-cutting middleware that run on **every** request, before the matched route. It accepts either a middleware function or a whole `router()`. It does **not** take a path or an options object; those belong on the route methods. ```js export default server() // a middleware: runs for every request .use(logger) .use(otherRouter) // merge another router's routes in .get('/', () => 'Hello'); ``` ### Scoping middleware to some paths Because `.use()` is global, when a middleware should apply only to some paths you have three options: **1. Check the path inside the middleware:** ```js const requireAdmin = (ctx) => { // not our concern, skip if (!ctx.url.pathname.startsWith('/admin/')) return; if (!ctx.user) return 401; }; export default server() .use(requireAdmin) .get('/admin/settings', settingsReply); ``` **2. Put the middleware on a router**, where it applies to that router's routes only: ```js const admin = router() .use(requireAuth) .get('/admin/settings', settingsReply) .get('/admin/users', usersReply); export default server().use(admin); ``` **3. Repeat the middleware on each route:** ```js export default server() .get('/admin/settings', requireAuth, settingsReply) .get('/admin/users', requireAuth, usersReply); ``` ### For express users There are 2 key differences between express.js and server.js, with the goal of making it very obvious what paths are being matched by requests: - Paths do not match subpaths by default. This means that if you write `.get('/')` and the browser calls `/info`, that path will _not_ get matched. If you want that funcionality, please be more explicit and write `.get('/*')`. - Only one HTTP method match for server.js. Once a method is matched, no other method can be matched. This means that if you write `.get('/*')` and then `.get('/info')`, that one can never be matched since all the paths go into the first one. For this, please write the `.get('/info')` paths first. One consequence: prefix layering like `.get('/admin/*', authMiddleware).get('/admin/settings', …)` won't work, because once `/admin/*` matches, `/admin/settings` is never reached. See [Scoping middleware to some paths](#scoping-middleware-to-some-paths) for the patterns to use instead. ## Parameters The route parameters are represented in the path as `/:name` and then are passed as a string to `ctx.url.params.name` (see [`url` docs as well](#ctxurl)): ```js export default server() .get('/:page', ctx => ({ page: ctx.url.params.page, name: ctx.url.query.name })); // test.js const res = await app.test().get('/hello?name=Francisco'); const { body } = res; expect(body).toEqual({ page: 'hello', name: 'Francisco' }); ``` Parameters can _also_ have types explicitly set, in which case they will be casted to that type. There's only 3 possible types, `string` (default), `number` and `date`: ```js // When requesting `/users/25 export default server().get('/users/:id(number)', ctx => { console.log( ctx.url.pathname, ctx.url.params.id, typeof ctx.url.params.id ); // /users/25 25 number }); // When requesting `/calendar/2025-01-01` export default server().get('/calendar/:day(date)', ctx => { const day = ctx.url.params.day; console.log(day, day instanceof Date); // Date('2025-01-01) true }); ``` ## Wildcards By default path matches are exact ([unlike express.js](#for-express-users)): ```js import server, { file } from '@server/next'; export default server() .get('/', () => file('./home.html')) .get('/info', () => file('./info.html')); ``` To match any sub-path, use `*`: ```js export default server() .get('/files/*', (ctx) => { return `Requested: ${ctx.url.pathname}`; }); ``` ## Using router() For larger applications, split your routes into separate files using `router()`. A router defines routes with their full paths and is merged in with `.use()`: ```js // routes/users.js import { router } from '@server/next'; export default router() .get('/users', () => User.list()) .get('/users/:id(number)', (ctx) => User.find(ctx.url.params.id)) .post('/users', (ctx) => User.create(ctx.body)) .put('/users/:id(number)', (ctx) => User.update(ctx.url.params.id, ctx.body)) .delete('/users/:id(number)', (ctx) => User.delete(ctx.url.params.id)); ``` ```js // index.js import server from '@server/next'; import usersRouter from './routes/users.js'; export default server().use(usersRouter); ``` Routers are merged at the root, so write the full path on each route. # Context Every middleware receives a `ctx` object as its only argument. It contains everything about the incoming request, the server configuration, and some useful helpers. ```js export default server() .get('/hello', (ctx) => { console.log(ctx.method); // 'get' console.log(ctx.url.pathname); // '/hello' return 'Hello world'; }); ``` The `ctx` object carries the following: - [`ctx.method`](#ctxmethod): the HTTP method, lowercased (`'get'`, `'post'`, ...). - [`ctx.url`](#ctxurl): the request URL, extended with `params` and `query`. - [`ctx.ip`](#ctxip): the client IP, proxy-aware through [`trustProxy`](#optionssecurity). - [`ctx.body`](#ctxbody): the parsed body, a `Buffer` or a stream, per the [`body`](#optionsbody) option. - [`ctx.headers`](#ctxheaders): the request headers as a plain object, lowercased keys. - [`ctx.cookies`](#ctxcookies): the parsed request cookies as a plain object. - [`ctx.session`](#ctxsession): the current session data, persisted in the session store. - [`ctx.user`](#ctxuser): the authenticated user, when [`auth`](#optionsauth) is configured. - [`ctx.options`](#ctxoptions): the resolved server settings. - [`ctx.time`](#ctxtime): a `Server-Timing` helper to measure segments of a request. - [`ctx.platform`](#ctxplatform): info about the runtime (`runtime`, `production`, `provider`). - [`ctx.socket`](#ctxsocket): the active WebSocket connection, inside [`.socket()`](#websockets) handlers. - [`ctx.sockets`](#ctxsockets): every open WebSocket connection, for broadcasting. ## ctx.method The HTTP method of the request, lowercased: ```js .get('/example', (ctx) => { console.log(ctx.method); // 'get' }); ``` Possible values: `'get'`, `'post'`, `'put'`, `'patch'`, `'delete'`, `'head'`, `'options'`. ## ctx.url The full URL of the request, extended with `params` and `query`: ```js .get('/users/:id', (ctx) => { console.log(ctx.url.pathname); // '/users/42' console.log(ctx.url.params.id); // '42' console.log(ctx.url.query.page); // '2' (from ?page=2) console.log(ctx.url.hostname); // 'localhost' }); ``` - **`ctx.url.params`**: URL path parameters, e.g. `/users/:id` → `ctx.url.params.id`. Types can be inferred; see [Parameters](#parameters). - **`ctx.url.query`**: Parsed query string as a plain object, e.g. `?page=2&sort=asc` → `{ page: '2', sort: 'asc' }`. - All standard [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) properties are available (`pathname`, `hostname`, `href`, `origin`, etc.). - Use `ctx.url.protocol` to check the scheme (`'https:'` for a secure request). ## ctx.ip The IP address of the client making the request: ```js .get('/', (ctx) => { console.log(ctx.ip); // '203.0.113.5' }); ``` Behind a proxy, the real client IP is read from forwarding headers, governed by the [`security.trustProxy`](#optionssecurity) option (on by default). Set `security: { trustProxy: false }` when clients connect directly. ## ctx.body The request body. By default it is parsed by content-type; the [`body` option](#optionsbody) controls how it is read. ```js .post('/users', (ctx) => { console.log(ctx.body.name); // 'Francisco' console.log(ctx.body.email); // 'hi@francisco.io' }); ``` With the default `parse` mode: - For `application/json` requests, it is the parsed JSON value. - For `application/x-www-form-urlencoded` or `multipart/form-data` text fields, it is a plain object of field values. A repeated field name collects into an array (HTML's `name="tags[]"` works too; the `[]` is stripped). - For file uploads, the field value is an `UploadedFile` object `{ name, id, path, type, size }` (or an array of them for a repeated field name). Files stream to `uploads` as they arrive, so their bytes are never all in memory. See [File handling](#file-handling). - For a raw non-form body with a binary content-type (e.g. `image/png`, `video/mp4`), the whole body is streamed to `uploads` as one `UploadedFile`. With no `uploads` configured it is the raw `Buffer` instead. - `undefined` for methods that do not send a body (`GET`, `DELETE`, etc.). The other [`body` modes](#optionsbody) give `ctx.body` a different shape: | `body` | `ctx.body` | |--------|------------| | `parse` (default) | the parsed object / file references above | | `raw` | a `Buffer` of the unparsed bytes | | `stream` | the request's web `ReadableStream`, unread | ## ctx.headers The request headers as a plain object, with lowercased keys: ```js .get('/', (ctx) => { // 'application/json' console.log(ctx.headers['content-type']); // 'Bearer ...' console.log(ctx.headers['authorization']); }); ``` To set headers in the response, use the [`headers()` reply helper](#headers). ## ctx.cookies The parsed request cookies as a plain object: ```js .get('/', (ctx) => { console.log(ctx.cookies.token); // 'abc123' }); ``` To set cookies in the response, use the [`cookies()` reply helper](#cookies). ## ctx.session The session data for the current user. The session is persisted in the store configured via the `session` option. An empty object `{}` when no session exists. ```js export default server({ session: store }) .post('/login', (ctx) => { ctx.session.userId = 42; return 200; }) .get('/me', (ctx) => { return { id: ctx.session.userId }; }); ``` You can type the session by passing a generic to `server()`: ```ts type MySession = { userId: number }; export default server() .get('/me', (ctx) => { // ctx.session.userId is typed as number return { id: ctx.session.userId }; }); ``` ## ctx.user > Requires the [`auth`](#optionsauth) option to be configured. The authenticated user, populated when a user is logged in. `undefined` when not authenticated. Available on HTTP routes and, resolved from the handshake, inside [`.socket()`](#websockets) handlers too. ```js .get('/profile', (ctx) => { if (!ctx.user) return 401; return { name: ctx.user.name, email: ctx.user.email }; }); ``` Standard fields always present on `ctx.user`: - **`id`**: unique user identifier - **`email`**: user email - **`provider`**: the auth provider used (`'github'`, `'email'`, etc.) - **`strategy`**: the auth strategy (`'cookie'`, `'jwt'`, `'token'`, `'key'`) You can type the user by passing a second generic to `server()`: ```ts type MyUser = { id: number; name: string; email: string }; export default server<{}, MyUser>() .get('/profile', (ctx) => { return ctx.user?.name; }); ``` ## ctx.options The resolved server settings (the processed version of the options passed to `server()`): ```js .get('/', (ctx) => { console.log(ctx.options.port); // 3000 }); ``` Mainly useful in middleware that needs to inspect server configuration. ## ctx.time A timing helper for [`Server-Timing`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing). Call `ctx.time('label')` to mark a point; the time spent in each labeled segment (since the previous mark) is sent back in the `Server-Timing` response header, which browsers show in their Network panel. No header is added if you never call it. ```js .get('/report', async (ctx) => { const rows = await db.query('...'); // time from start of request to here ctx.time('query'); const html = render(rows); // time since the previous mark ctx.time('render'); return html; }); // Server-Timing: query;dur=42, render;dur=8 ``` ## ctx.platform Information about the runtime environment: ```js .get('/', (ctx) => { // 'node' | 'bun' | 'cloudflare' | ... console.log(ctx.platform.runtime); // true | false console.log(ctx.platform.production); // 'netlify' | 'aws' | null | ... console.log(ctx.platform.provider); }); ``` ## ctx.socket The WebSocket connection for the current event, available inside [`.socket()`](#websockets) handlers (`open`, `message`, `close`). It's the single client this event is about; call `ctx.socket.send(data)` to reply to just them. ```js export default server() .get('/', () => file('./index.html')) .socket('open', (ctx) => { ctx.socket.send('Welcome!'); }) .socket('message', (ctx) => { // ctx.body is what this client sent ctx.socket.send(`You said: ${ctx.body}`); }); ``` `send()` accepts a string or binary data. On a `message` event the incoming payload is on [`ctx.body`](#ctxbody). WebSockets work on both **Node** and **Bun**; see the [WebSockets guide](#websockets) for the full picture. ## ctx.sockets Every open WebSocket connection, as an array, available inside [`.socket()`](#websockets) handlers. A connection is added when it opens and removed when it closes, so iterating `ctx.sockets` is how you broadcast to everyone. ```js .socket('message', (ctx) => { // Relay this message to every connected client for (const socket of ctx.sockets) { socket.send(ctx.body); } }); ``` To broadcast to everyone except the sender, skip `ctx.socket`: ```js .socket('message', (ctx) => { for (const socket of ctx.sockets) { if (socket === ctx.socket) continue; socket.send(ctx.body); } }); ``` # Reply Middleware can return different types of values to send a response. For simple cases you can return a value directly; for more control, Server.js exports reply helpers that can be used standalone or chained together: - [`status`](#status): set the response's HTTP status code, like `201` or `404`. - [`json`](#json): stringify the objects and send them as `Content-Type: application/json`. - [`redirect`](#redirect): send a `302` (or custom) redirect through the `Location` header. - [`headers`](#headers): set one or many response headers at once. - [`type`](#type): set the `Content-Type` from a MIME type or a file extension like `csv`. - [`cache`](#cache): set `Cache-Control` from a duration like `'1h'` or a number (seconds). - [`cookies`](#cookies): set or delete response cookies, with options like `path` and `expires`. - [`send`](#send): send any body, auto-detecting text, HTML, JSON, a `Buffer` or a stream. - [`file`](#file): stream a file from disk, guessing its `Content-Type`, `404` if missing. - [`download`](#download): send a file as an attachment so the browser downloads it. ```js import { status, json, redirect, ... } from '@server/next'; ``` The chainable helpers can be strung together and end with either `.send()` or a terminal method like `.json()` or `.redirect()`. ## Inline replies The simplest way to reply is to just return a value from your middleware: ```js // Plain string → text/plain // (or text/html if it starts with '<') .get('/text', () => 'Hello world') .get('/html', () => '

Hello world

') // Object or array → application/json .get('/json', () => ({ hello: 'world' })) // HTTP status code shorthand (empty body) .post('/create', () => 201) // A bare chainable helper is finalized for you (empty body) .get('/gone', () => status(410)) // Buffer / Blob → raw bytes (a Blob keeps its own type) .get('/bytes', () => Buffer.from([0x89, 0x50])) .get('/pdf', () => new Blob([data], { type: 'application/pdf' })) // A ReadableStream, or a (async) generator, streams chunk by chunk .get('/stream', async function* () { yield 'a'; yield 'b'; }) // A bucket file handle → streamed back, typed, 404 if missing .get('/avatar', () => bucket.file('avatars/me.jpg')) // A web-standard Response object (e.g. from fetch()) .get('/custom', () => new Response('ok', { status: 200 })) ``` ## status() Set the HTTP status code. It's chainable, so pair it with a terminal helper like `.json()` or `.send()`, or return it on its own for an empty-body response: ```js import { status, json } from '@server/next'; // Just a status code, empty body .delete('/users/:id', () => status(204)) // With a JSON body .post('/users', (ctx) => { const user = createUser(ctx.body); return status(201).json(user); }) ``` Reach for `status()` when you need a body or other helpers; for a bare status code, `return 201` is the shorthand. ## json() Stringify a value and send it as `Content-Type: application/json`. It's a terminal helper, returning the `Response`. ```js import { status, json } from '@server/next'; .get('/users', () => json({ users: [] })) // With a status code .post('/users', (ctx) => { return status(201).json({ id: 1, ...ctx.body }); }) ``` Returning a plain object also sends JSON; use `json()` explicitly when you need to chain `status()` or `headers()`. ## redirect() Redirect the client to another URL. The helper sends a `302` with the `Location` header; chain `status()` before it for a different code, like `301`, `307` or `308`. It's terminal, returning the `Response`. ```js import { redirect, status } from '@server/next'; // 302 by default .get('/old-path', () => redirect('/new-path')) // Permanent redirect .get('/moved', () => { return status(301).redirect('/permanent-path'); }) ``` ## headers() Set one or more response headers. Pass a key and value, or an object to set several at once. It's chainable, so finish with a terminal helper like `.send()`. ```js import { headers } from '@server/next'; // A single header .get('/', () => headers('x-custom', 'value').send('Hello')) // Several at once .get('/', () => { return headers({ 'x-foo': 'bar', 'x-baz': 'qux' }) .send('Hello'); }) ``` `headers()` appends rather than replacing, and a value can be an array to send the same header more than once. ## type() Set the `Content-Type` header. Accepts a file extension (`csv`, `.csv`) or a full MIME type (`text/csv`). It's chainable. ```js import { type } from '@server/next'; // By extension .get('/data.csv', () => type('csv').send('a,b,c')) // By full MIME type .get('/data', () => { return type('application/json').send('{"a":1}'); }) ``` Extensions are looked up in the built-in MIME table (the leading dot is optional); an unknown value is used as the content type verbatim. The full table: | Name | `Content-Type` | |------|----------------| | `epub` | `application/epub+zip` | | `gz` | `application/gzip` | | `jar` | `application/java-archive` | | `json` | `application/json` | | `jsonld` | `application/ld+json` | | `doc` | `application/msword` | | `bin` | `application/octet-stream` | | `ogx` | `application/ogg` | | `pdf` | `application/pdf` | | `rtf` | `application/rtf` | | `azw` | `application/vnd.amazon.ebook` | | `mpkg` | `application/vnd.apple.installer+xml` | | `xul` | `application/vnd.mozilla.xul+xml` | | `xls` | `application/vnd.ms-excel` | | `eot` | `application/vnd.ms-fontobject` | | `ppt` | `application/vnd.ms-powerpoint` | | `odp` | `application/vnd.oasis.opendocument.presentation` | | `ods` | `application/vnd.oasis.opendocument.spreadsheet` | | `odt` | `application/vnd.oasis.opendocument.text` | | `pptx` | `application/vnd.openxmlformats-officedocument.presentationml.presentation` | | `xlsx` | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` | | `docx` | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | | `rar` | `application/vnd.rar` | | `vsd` | `application/vnd.visio` | | `7z` | `application/x-7z-compressed` | | `abw` | `application/x-abiword` | | `bz` | `application/x-bzip` | | `bz2` | `application/x-bzip2` | | `cda` | `application/x-cdf` | | `csh` | `application/x-csh` | | `arc` | `application/x-freearc` | | `php` | `application/x-httpd-php` | | `sh` | `application/x-sh` | | `tar` | `application/x-tar` | | `xhtml` | `application/xhtml+xml` | | `xml` | `application/xml` | | `zip` | `application/zip` | | `aac` | `audio/aac` | | `mid` / `midi` | `audio/midi` | | `mp3` | `audio/mpeg` | | `oga` | `audio/ogg` | | `opus` | `audio/opus` | | `wav` | `audio/wav` | | `weba` | `audio/webm` | | `otf` | `font/otf` | | `ttf` | `font/ttf` | | `woff` | `font/woff` | | `woff2` | `font/woff2` | | `avif` | `image/avif` | | `bmp` | `image/bmp` | | `gif` | `image/gif` | | `jpeg` / `jpg` | `image/jpeg` | | `png` | `image/png` | | `svg` | `image/svg+xml` | | `tif` / `tiff` | `image/tiff` | | `ico` | `image/vnd.microsoft.icon` | | `webp` | `image/webp` | | `ics` | `text/calendar` | | `css` | `text/css` | | `csv` | `text/csv` | | `htm` / `html` | `text/html` | | `js` / `mjs` | `text/javascript` | | `md` | `text/markdown` | | `text` / `txt` | `text/plain` | | `3gp` | `video/3gpp` | | `3g2` | `video/3gpp2` | | `ts` | `video/mp2t` | | `mp4` | `video/mp4` | | `mpeg` | `video/mpeg` | | `ogv` | `video/ogg` | | `webm` | `video/webm` | | `avi` | `video/x-msvideo` | ## cache() Set the `Cache-Control` header. Accepts a duration like `'1h'`, a number of seconds, or `false`/`0` for `no-store`. It sets `public, max-age=` and is chainable. For anything more specific (`private`, `s-maxage`, ...), set the header directly with [`headers()`](#headers). ```js import { cache, headers } from '@server/next'; // Cache-Control: public, max-age=3600 .get('/posts', () => cache('1h').json(posts)) // Full control: set the header yourself .get('/me', (ctx) => headers('cache-control', 'private, max-age=300').json(ctx.user)) ``` Use this when the value depends on the request; for a fixed default across routes, set the [`cache` option](#optionscache) instead. A `Cache-Control` set here always wins over that default. ## cookies() Set or delete response cookies. Pass a string value, an options object (`value`, `path`, `expires`, and the rest), `null` to delete, or an object of names to set several. It's chainable. ```js import { cookies } from '@server/next'; // Simple string value .post('/login', () => cookies('token', 'abc123').send()) // With options .post('/login', () => { return cookies('token', { value: 'abc123', path: '/', expires: '7d', }).send(); }) ``` Delete a cookie by setting it to `null`, or pass an object to set several at once: ```js // Delete a cookie .post('/logout', () => cookies('token', null).send()) // Several at once .get('/', () => { return cookies({ theme: 'dark', lang: 'en' }).send(); }) ``` `expires` accepts a duration like `'7d'` or a `Date`; deleting sets an already-expired cookie so the browser drops it. ## send() The base terminal method that every chain ends on. Sends the response with an optional body, or an empty one when called with no arguments. ```js import { send, status } from '@server/next'; .get('/ping', () => send('pong')) .delete('/item', () => status(204).send()) ``` Unless you've already set the `Content-Type` (via [`type()`](#type) or [`headers()`](#headers)), `send()` auto-detects it from the body: - `string` starting with `<` → `text/html` - other `string` → `text/plain` - `Buffer` → sent as-is - `ReadableStream` → streamed - anything else → `application/json` Set the type yourself to override this, e.g. to send XML or raw bytes: ```js import { type } from '@server/next'; // XML string .get('/feed', () => type('xml').send('')) // A Buffer (set the type; send() won't guess it) .get('/report.csv', () => type('csv').send(csvBuffer)) // A ReadableStream is streamed through .get('/proxy', () => send(upstream.body)) ``` `send()` handles strings, `Buffer`s and `ReadableStream`s. A `Blob` or a generator only work as a **direct return** (see [Inline replies](#inline-replies)), not through `send()`. ## file() Stream a file, from a disk path or a [`Bucket`](#file-handling) file handle. A missing file responds with `404`. ```js import { file } from '@server/next'; // A disk path .get('/', () => file('./index.html')) .get('/report', () => file('./report.pdf')) // A stored bucket file (e.g. serving an upload behind auth) .get('/avatar', () => file(bucket.file('avatars/me.jpg'))) ``` `file()` is async and streams the contents rather than buffering, so it's fine for large files. Return it directly, or chain it after [`download()`](#download) to force a download. To serve a stored file behind auth, see [Serving stored files](#serving-stored-files). ## download() Send a file as an attachment, so the browser downloads it instead of rendering it (`Content-Disposition: attachment`). Pass a filename to name the download, then chain [`.file()`](#file) for the contents. ```js import { download } from '@server/next'; // Named download .get('/export', () => { return download('export.csv') .file('./data/export.csv'); }) // Without a filename .get('/raw', () => { return download().file('./data/export.csv'); }) ``` When the filename has an extension and no `Content-Type` is set yet, `download()` also sets the type from that extension. ## Chaining examples The chainable helpers (`status`, `type`, `headers`, `cache`, `cookies`, `download`) return a `Reply` instance, so you can chain as many as needed before a terminal call (`send`, `json`, `redirect`, `file`), which returns the `Response`: ```js import { status, headers, cookies, json, } from '@server/next'; .post('/login', async (ctx) => { const user = await authenticate(ctx.body); if (!user) return status(401).json({ error: 'Invalid credentials', }); return status(200) .cookies('session', { value: user.sessionId, path: '/', }) .json({ ok: true, user }); }) ``` # Authentication > Stable and running in production; the API is close to final before 1.0. The [`auth` option](#optionsauth) enables authentication. Its value is a `':'` string or an [object](#configuring-auth). It registers the provider [routes](#routes) and, on every request, verifies the credential and populates [`ctx.user`](#ctxuser). It does **not** block unauthenticated requests; guard those yourself (see [Protecting routes](#protecting-routes)). A [`store`](#optionsstore) is required for every strategy except [`key`](#shared-secret-key). ```js auth: 'cookie:github' // or, for finer control: auth: { strategy: 'cookie', providers: ['github', 'google'] } ``` - **strategy**: `cookie`, `token`, `jwt` or `key` (see [Strategies](#strategies)). - **provider**: `email`, `github`, `google`, `microsoft`, `discord`, `facebook`, `apple`. The string form takes one; use the object form's `providers` array for several. `key` is provider-less. For a step-by-step setup, see the [Login with GitHub](#login-with-github) guide. ## Configuring auth The string form expands to the object form, which exposes every field: ```js export default server({ auth: { strategy: 'cookie', providers: ['google', 'github'], redirect: '/dashboard', store: users, session: sessions, cleanUser: (user) => { const { internalId, ...rest } = user; return rest; }, }, }); ``` | Field | Type | Default | Description | |-------|------|---------|-------------| | `strategy` | `'cookie' \| 'token' \| 'jwt' \| 'key'` | *(required)* | How the session credential is issued and checked. See [Strategies](#strategies). | | `providers` | `Provider[]` | `[]` | OAuth providers and/or `email` to enable. | | `key` | `string` | `AUTH_KEY` env | Shared secret for the `key` strategy. | | `redirect` | `string` | `'/user'` | Where a `cookie` login redirects the browser. | | `store` | `Store` | server `store`, `user:` prefix | Where users are kept. | | `session` | `Store` | server `store`, `auth:` prefix | Where sessions are kept. | | `cleanUser` | `(user) => user` | strips `password` | Transforms the user before it is stored and before it reaches `ctx.user`. | ## Users and sessions Auth uses two key-value stores, both derived from your [`store`](#optionsstore) by default: - **Users**: the `user:` prefix. An OAuth user is keyed by its provider id, an email user by its email. - **Sessions**: the `auth:` prefix, lasting a week. The cookie or bearer token is only a session id; the user is looked up from it on each request. ## OAuth providers Each provider reads its credentials from the environment: | Provider | Env vars | |----------|----------| | `github` | `GITHUB_ID`, `GITHUB_SECRET` | | `google` | `GOOGLE_ID`, `GOOGLE_SECRET` | | `microsoft` | `MICROSOFT_ID`, `MICROSOFT_SECRET` | | `discord` | `DISCORD_ID`, `DISCORD_SECRET` | | `facebook` | `FACEBOOK_ID`, `FACEBOOK_SECRET` | | `apple` | `APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY` | Set each provider's authorized callback URL to `https:///auth/callback/`. The flow is CSRF-protected with a short-lived `state` cookie. **Apple** signs its client secret as a short-lived ES256 JWT from `APPLE_PRIVATE_KEY`, and its callback is a `POST`. ## Email and password The `email` provider keeps users in your [`store`](#optionsstore), with no third party. Passwords are hashed, never stored or returned in clear. | Method | Route | Body | Notes | |--------|-------|------|-------| | `POST` | `/auth/register/email` | `{ email, password, ...fields }` | `email` must contain `@`, `password` ≥ 8 chars. Extra fields are stored on the user. The session is active on success. | | `POST` | `/auth/login/email` | `{ email, password }` | | | `PUT` | `/auth/password/email` | `{ previous, updated }` | Changes the password for the signed-in user. | Register and login finish the session the same way as OAuth (a cookie or a token, per strategy). ## Strategies - **`cookie`** (browsers): on success a signed, `HttpOnly` `authentication` cookie is set (`SameSite=Lax`, plus `Secure` in production) and the browser is redirected to `redirect`. - **`token`** (APIs, mobile): login responds with JSON containing a `token` (an opaque id stored server-side); send it as `Authorization: Bearer `. - **`jwt`** (stateless APIs): login responds with a `token` that is a signed JWT (HS256, using your [`secret`](#optionssecret)) carrying the session itself. No server-side session is stored, so it scales without a shared store. Trade-offs: a token can't be revoked before it expires (one week), changing `secret` invalidates all of them, and `secret` **must** be set; an unset per-process secret breaks every token on restart. Sent as `Authorization: Bearer `. - **`key`** (machine-to-machine): a single shared secret, no users or sessions (see [Shared-secret key](#shared-secret-key)). ## Shared-secret key For service-to-service access with no users, the `key` strategy checks one shared secret sent as `Authorization: Bearer ` against `AUTH_KEY` (or `auth.key`) in constant time. There are no providers, store, sessions or login/logout routes. ```js // AUTH_KEY=s3cr3t-api-key in the environment const requireKey = (ctx) => { if (!ctx.user) return 401; }; server({ auth: 'key' }) .get('/data', requireKey, () => ({ ok: true })); ``` - Pass the secret inline with the object form instead of the env var: `auth: { strategy: 'key', key: 'secret' }`. - On a match, `ctx.user` is the fixed marker `{ id: 'key', strategy: 'key', provider: 'key' }` (no user record, so no `email`). - No header leaves the request anonymous (`ctx.user` undefined); a wrong or malformed header responds with `401`. - Booting without a key configured throws, so a missing `AUTH_KEY` fails fast at startup rather than running unprotected. ## The user object `ctx.user` is `undefined` when nobody is signed in. When present, every user carries `id`, `provider` and `strategy`; the rest depends on the provider: | Provider | `ctx.user` | |----------|-----------| | OAuth (`github`, `google`, …) | `{ id, name, email, picture, provider, strategy }` | | `email` | `{ id, email, ...fields, provider, strategy }` | | `key` | `{ id: 'key', provider: 'key', strategy: 'key' }` | Type `ctx.user` with a second generic to `server()`; see [`ctx.user`](#ctxuser). ## Protecting routes Auth only verifies the credential and sets `ctx.user`; it never rejects a request on its own. Guard the routes you want to protect with a `ctx.user` check: ```js const requireUser = (ctx) => { if (!ctx.user) return 401; }; export default server({ store, auth: 'cookie:github' }) .get('/public', () => 'anyone') .get('/account', requireUser, (ctx) => `Hello ${ctx.user.email}`); ``` ## Responses | Situation | Status | |-----------|--------| | No credential | anonymous (`ctx.user` is `undefined`) | | Invalid or expired token / cookie | `401` | | Wrong email or password, or an unverifiable JWT | `500` | | Failed OAuth `state` check | `403` | `POST /auth/logout` ends the session for `cookie`/`token`. Stateless `jwt` has nothing to revoke server-side (the client discards the token); `key` has no session. ## Routes | Method | Route | Provider | |--------|-------|----------| | `GET` | `/auth/login/` | OAuth | | `GET` | `/auth/callback/` | OAuth (Apple `POST`) | | `POST` | `/auth/register/email` | email | | `POST` | `/auth/login/email` | email | | `PUT` | `/auth/password/email` | email | | `POST` | `/auth/logout` | all except `key` | The `key` strategy registers none of these; it only checks the `Authorization` header on your own routes. # Testing Testing is a first-class feature of Server.js. Call `.test()` on your app to get a lightweight test client that runs requests through the full middleware stack without starting an HTTP server. ## Basic setup Keep your server in its own file so tests can import it: ```js // src/index.js import server from '@server/next'; export default server() .get('/', () => 'Hello world') .get('/users', () => User.list()) .post('/users', (ctx) => User.create(ctx.body)); ``` ```js // src/index.test.js import app from './index.js'; const api = app.test(); it('returns hello world', async () => { const res = await api.get('/'); expect(await res.text()).toBe('Hello world'); }); ``` ## Available methods The test client mirrors the HTTP methods. Each call returns a standard `Response`: ```js const api = app.test(); api.get('/path', options?) api.post('/path', body?, options?) api.put('/path', body?, options?) api.patch('/path', body?, options?) api.delete('/path', options?) api.head('/path', options?) api.options('/path', options?) ``` - **`body`**: the request body. Accepts a string, plain object (serialized as JSON), `FormData`, or `ReadableStream`. - **`options`**: standard `RequestInit` options (e.g. `headers`). ## Reading the response Each method returns a standard web [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response): ```js const res = await api.get('/users'); res.status // 200 res.headers.get('content-type') // 'application/json' await res.text() // raw body as string await res.json() // parsed JSON body ``` ## Sending a body Pass a plain object and Server.js will serialize it as JSON and set `Content-Type: application/json` automatically: ```js const res = await api.post('/users', { name: 'Francisco', email: 'hi@francisco.io', }); expect(res.status).toBe(201); expect(await res.json()).toMatchObject({ name: 'Francisco', }); ``` For other body types: ```js // Plain text await api.post('/echo', 'Hello world'); // FormData (e.g. file uploads) const form = new FormData(); form.append('name', 'Francisco'); await api.post('/upload', form); // Custom headers await api.get('/secure', { headers: { authorization: 'Bearer token123' }, }); ``` ## Full example ```js // src/books.test.js import app from './index.js'; const api = app.test(); describe('books API', () => { it('lists books', async () => { const res = await api.get('/books'); expect(res.status).toBe(200); expect(await res.json()).toEqual([]); }); it('creates a book', async () => { const res = await api.post('/books', { title: 'Dune', author: 'Herbert', }); expect(res.status).toBe(201); const body = await res.json(); expect(body.title).toBe('Dune'); }); it('returns 404 for unknown books', async () => { const res = await api.get('/books/9999'); expect(res.status).toBe(404); }); it('deletes a book', async () => { const res = await api.delete('/books/1'); expect(res.status).toBe(204); }); }); ``` ## Limitations The test client simulates requests in-process; it does not open a real TCP connection. This means: - Compression (`brotli`, `gzip`) and `content-encoding` headers are not applied (those are usually set by edge proxies). - Edge-only features like `cf-*` headers won't be present. For integration tests that require a real HTTP connection, start the server on a test port and use `fetch` directly. # Platforms Server.js is designed to run the same code unmodified across all supported runtimes and platforms. No adapter imports, no platform-specific entrypoints. ```js // This file works on Node.js, Bun, Cloudflare // Workers, and Netlify Functions import server from '@server/next'; export default server() .get('/', () => 'Hello world'); ``` The same default export adapts to each runtime: - [Node.js](#nodejs): run the file with `node .`; served over the built-in HTTP server. - [Bun](#bun): run it with `bun .`; faster, and unlocks JSX and native S3 buckets. - [Cloudflare Workers](#cloudflare-workers): the default export is a standard fetch handler. - [Netlify Functions](#netlify-functions): exports a Netlify-compatible handler automatically. ## Node.js The default `export default server()` also starts an HTTP server and listens on the configured port (default `3000`). Run with: ```bash node index.js ``` Node.js 24+ is required. ## Bun Run with: ```bash bun index.js ``` No extra setup needed. Bun also unlocks [JSX templates](#jsx) and native S3 bucket support. ## Cloudflare Workers The default export is a standard [fetch handler](https://developers.cloudflare.com/workers/runtime-apis/handlers/fetch/), which is what Cloudflare Workers expect. Deploy as usual with `wrangler`. ```js import server from '@server/next'; export default server() .get('/', () => 'Hello from the edge'); ``` ## Netlify Functions Server.js exports a `callback` method compatible with Netlify's function handler signature. The `export default` is set up automatically. ```js import server from '@server/next'; export default server() .get('/', () => 'Hello from Netlify'); ``` ## Environment variables Regardless of platform, these environment variables are read automatically if set: | Variable | Option | Default | |----------|-----------|---------| | `PORT` | `port` | `3000` | | `SECRET` | `secret` | random | | `PUBLIC` | `public` | `null` | | `FAVICON`| `favicon` | `null` | | `CORS` | `cors` | `null` | | `AUTH` | `auth` | `null` | See [Options › Environment variables](#environment-variables) for the full list. # FAQ ### How is it different from Hono? Server.js attempts to run your code unmodified in all runtimes. With Hono you need to change the code for different runtimes: ```js // Server.js code for Node.js, Bun and Netlify import server from "@server/next"; export default server().get("/", () => "Hello server!"); ``` ```js // Hono code for Node.js import { serve } from '@hono/node-server' import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Hello Node.js!')) serve(app) // Hono code for Bun import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Hello Bun!')) export default app // Hono code for Netlify import { Hono } from 'jsr:@hono/hono' import { handle } from 'jsr:@hono/hono/netlify' const app = new Hono() app.get('/', (c) => c.text('Hello Hono!')) export default handle(app) ``` ### How do I render HTML templates? Server.js has no built-in template engine; return the rendered string from your route and it's served as `text/html`. See [Templates](#templates) for EJS, Pug and Handlebars examples, or [JSX](#jsx) on Bun.