Fastify & Node HTTP
New in 0.2.0, extended in 0.3.0
The Node handler and the Fastify adapter arrived in 0.2.0. 0.3.0 adds raw-byte uploads through both (uploadBodyLimit on Fastify), NodeHandlerOverrides.error, the 413 answer in the server's own envelope, and RouteResult.close so a stream ends as soon as the client leaves.
Use the Node handler for plain node:http (or any stack that gives you IncomingMessage / ServerResponse) and the Fastify adapter when the API lives under a Fastify prefix, possibly one with parameters (/api/portal/:portalKey). Both apply the server's cors setting.
Fastify
import Fastify from 'fastify';
import { createAgentServer } from '@cognipeer/agent-server';
import { mountOnFastify } from '@cognipeer/agent-server/fastify';
const app = Fastify();
const server = createAgentServer({ basePath: '/unused', storage, resolveContext });
// Static or parameterised prefix. Params arrive as req.mountParams in resolveContext.
mountOnFastify(app, server, { prefix: '/api/portal/:portalKey', bodyLimit: 15 * 1024 * 1024 });Or register the plugin yourself: app.register(createFastifyPlugin(server), { prefix }).
Behaviour:
- Routes are resolved relative to the prefix; the server's own
basePathis not used. - Fastify has already parsed the JSON body when our route runs - the parsed body is used (the request stream is not read again).
- A file uploaded as itself (
POST /files?name=...with the file's ownContent-Type) reaches the route as aBuffer: the plugin registers buffer parsers fortext/plainand for every type Fastify has no parser for, in its own scope - your app's other routes keep Fastify's parsers (and a parser you registered for the same type in the mounted scope is left alone). - The reply is hijacked and written on the raw response (needed for SSE). Headers set by earlier hooks/plugins (request ids, CORS) are copied over.
- Raise
bodyLimit(Fastify default 1 MiB) if clients upload base64 files, or useuploadBodyLimit, the body limit ofPOST /filesalone (a base64 file is a third bigger than the file; a raw one is its size). A body over the limit is answered in our envelope (413,{ error: { code: "PAYLOAD_TOO_LARGE", message } }), not Fastify's own error shape; every other Fastify error still goes to your error handler. fastifyis an optional peer dependency (>= 4).
Options of mountOnFastify(app, server, options) / createFastifyPlugin(server, options):
| Option | Default | |
|---|---|---|
prefix | mountOnFastify only: the Fastify prefix, static or parameterised | |
bodyLimit | Fastify's 1 MiB | body limit of the mounted routes. Raise it when clients upload base64 files |
uploadBodyLimit | unset | body limit of POST /files alone; set it above your largest file |
maxBodyBytes | limits.maxBodySize, else ~1.4 x the largest file an agent accepts | the Node handler's own limit, applied to what it reads itself |
A complete example of an embedded chat that authenticates with a bearer token per request (no cookies) and is bound to the portal in the URL:
import Fastify from 'fastify';
import { createAgentServer } from '@cognipeer/agent-server';
import { mountOnFastify } from '@cognipeer/agent-server/fastify';
const server = createAgentServer({
basePath: '/unused', // ignored by the Fastify adapter
storage,
cors: { enabled: true, origins: ['https://intranet.example.com'] },
resolveContext: async (req) => {
const header = req.headers.authorization;
const token = typeof header === 'string' && header.startsWith('Bearer ') ? header.slice(7) : undefined;
const session = token ? await sessions.lookup(token, { portal: req.mountParams.portalKey }) : undefined;
return session ? { user: { id: session.userId }, data: { portal: session.portal } } : null;
},
});
const app = Fastify();
mountOnFastify(app, server, { prefix: '/api/portal/:portalKey', uploadBodyLimit: 30 * 1024 * 1024 });
await app.listen({ port: 3000 });Raw Node HTTP
import http from 'node:http';
import { createNodeHandler } from '@cognipeer/agent-server';
const handle = createNodeHandler(server, { basePath: '/api/agents' });
http.createServer(async (req, res) => {
const handled = await handle(req, res); // false: path is outside basePath, nothing written
if (!handled) { res.statusCode = 404; res.end(); }
}).listen(3000);createNodeHandler(server, options):
| Option | Default | |
|---|---|---|
basePath | server.getBasePath() | prefix to strip; "" if a router already stripped it |
maxBodyBytes | limits.maxBodySize, else ~1.4 x the largest file an agent accepts (server.maxUploadBytes()) | larger bodies answer 413 |
The returned function takes optional per-call overrides { path, body, mountParams, raw, error } for hosts that did part of the work (this is how the Fastify adapter is built). error answers with that { statusCode, code, message } instead of routing, with the usual CORS headers.
It handles token auth, resolveContext, JSON parsing (400 on invalid JSON) - and the bytes of a file uploaded as itself (POST /files with a non-JSON Content-Type) - CORS (config.cors), SSE with backpressure, and stops consuming the stream when the client disconnects.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
404 Route not found under the prefix | Routes are relative to the Fastify prefix: with prefix: '/api/portal/:portalKey' the agents route is /api/portal/<key>/agents. The server's basePath is not used. |
413 PAYLOAD_TOO_LARGE with { error: { code, message } } | The body is over bodyLimit (or uploadBodyLimit for POST /files). Raise it above your largest file, a third more for base64 uploads. |
resolveContext gets an empty mountParams | The prefix has no parameter, or the plugin was registered without the prefix option. Parameters of the prefix become req.mountParams. |
| The browser blocks the call (CORS) | Set cors.enabled and the exact origins on the server (the adapter applies it), or register a Fastify CORS plugin: headers set by earlier hooks are carried over to the answer. |
| A JSON parser of your own breaks the routes | An application/json parser that keeps the body as a string or Buffer is fine: the adapters decode it (invalid JSON is a 400). A parser you register for text/plain or * in the same scope is left alone. |

