Response Helpers & Streaming (ExisResponse)
ExisJS wraps standard Node.js ServerResponse objects in an ExisResponse instance (res). Handlers can return JavaScript objects or arrays directly for automatic JSON serialization, or use res methods for status codes, headers, cookie management, file downloads, streaming, and Server-Sent Events (SSE).
Automatic JSON Serialization
When a route handler returns an object or array, ExisJS automatically serializes the data, sets Content-Type: application/json; charset=utf-8, and finishes the response:
Functional Route
import { controller, route } from 'exisjs/router'
export default controller({ listUsers: route.get('/', { handle: async () => { // Automatically serialized as JSON with 200 OK return [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ] }, }),})Class-Based (OOP) Controller
import { Controller, Get, ApiOperation } from 'exisjs/decorators'
@Controller()export default class UsersController { @Get('/') @ApiOperation({ summary: 'List users' }) async listUsers() { return [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ] }}What a Return Value Becomes
Both paradigms answer the same way:
| Handler returns | Response |
|---|---|
Object, array, number, boolean, null | JSON (null is the JSON value null) |
| String | text/plain |
Buffer / Uint8Array | application/octet-stream (set Content-Type first to change it) |
| Stream or async iterable | Streamed |
download(data, name) | Attachment |
res, or the result of res.json() / res.send() | Nothing more: the response was already sent |
undefined, nothing sent | 204 No Content (or the status set with @HttpCode / res.status(), with an empty body) |
Prefer returning a value. Call res.json() / res.send() yourself only when you need to, and do not do both. A handler that receives the response object (@Res(), or (req, res) with no decorators) may also answer later, for example from a callback; it is then responsible for sending exactly one response.
HEAD is answered by the matching GET route without a body. A path that exists under other methods answers 405 METHOD_NOT_ALLOWED with an Allow header instead of 404.
Response Envelope Builders (exisjs/response)
ExisJS exports standardized response builders from exisjs/response to provide uniform { success: boolean, data?: any, error?: any, meta?: any } structures across all endpoints:
| Builder | HTTP Status | Response Structure | Description |
|---|---|---|---|
success(data, message?, meta?) | 200 OK | { success: true, data, message?, meta? } | Standard successful response |
created(data, message?, meta?) | 201 Created | { success: true, data, message: 'Created', meta? } | Newly created entity |
accepted(data?, message?, meta?) | 202 Accepted | { success: true, data, message: 'Accepted', meta? } | Asynchronous or queued action |
noContent() | 204 No Content | { success: true, data: null } | Action succeeded with no payload |
error(message, code?, details?, status?) | 4xx / 5xx | { success: false, error: { message, code?, details? } } | Structured error envelope |
problem(title, status, detail?, opts?) | Custom | RFC 7807 / RFC 9457 Problem Details object | Standardized HTTP Problem Details |
paginate(items, total, options) | 200 OK | { success: true, data, pagination: { ... } } | Paginated response envelope |
Example Usage
import { controller, route } from 'exisjs/router'import { success, created, error, HttpStatus } from 'exisjs/response'
export default controller({ getUser: route.get('/:id', { handle: async ({ params, res }) => { const user = await db.users.find(params.id) if (!user) { res.status(HttpStatus.NOT_FOUND) return error('User not found', 'NOT_FOUND') } return success(user, 'User retrieved') }, }),
createUser: route.post('/', { handle: async ({ body, res }) => { const user = await db.users.create(body) res.status(HttpStatus.CREATED) return created(user, 'User registered') }, }),})RFC 7807 / RFC 9457 Problem Details
For public or enterprise REST APIs, problem() generates standard Problem Details objects:
import { controller, route } from 'exisjs/router'import { problem, HttpStatus } from 'exisjs/response'
export default controller({ checkout: route.post('/', { handle: async ({ req, res }) => { res.status(HttpStatus.UNPROCESSABLE_ENTITY) return problem( 'Out of Stock', 422, 'One or more requested items are no longer available in inventory.', { type: 'https://api.example.com/errors/inventory', instance: req.path, unavailableItems: ['item_101', 'item_204'], } ) }, }),})Standardized Pagination (paginate)
The paginate() helper combines a data array with calculated total counts to produce a uniform pagination object:
import { controller, route } from 'exisjs/router'import { paginate, getPaginationSkip, tex } from 'exisjs/validator'
const QuerySchema = tex.pagination({ defaultLimit: 20, maxLimit: 100 })
export default controller({ list: route.get('/', { query: QuerySchema, handle: async ({ query }) => { const { skip, limit, page } = getPaginationSkip(query)
const [items, total] = await Promise.all([ db.products.findMany({ skip, take: limit }), db.products.count(), ])
return paginate(items, total, { page, limit }) }, }),})JSON Output Format
{ "success": true, "data": [ { "id": "p1", "name": "Keyboard" }, { "id": "p2", "name": "Mouse" } ], "pagination": { "page": 1, "limit": 20, "total": 42, "totalPages": 3, "hasNext": true, "hasPrev": false, "hasNextPage": true, "hasPrevPage": false }}Setting Headers & Status Codes
ExisResponse methods can be chained:
res.status(404) .set('X-Custom-Header', 'custom-value') .append('Set-Cookie', 'pref=dark') .json({ error: 'Not Found' })Header Methods
res.status(code): Sets the numeric HTTP status code.res.set(name, value)/res.header(name, value): Sets a header.res.append(name, value): Appends to a header (e.g. multipleSet-Cookiedirectives).res.type(mimeType): Shorthand for settingContent-Type(e.g.res.type('html')$\to$text/html).res.removeHeader(name): Removes a previously set header.res.vary(field): Adds a field to theVarycaching header.
Cookie Management
Set and clear cookies with modern web standard options (including CHIPS Partitioning and Priority hints):
Setting Cookies
// Standard secure HTTP-only cookieres.cookie('token', 'xyz123', { maxAge: 86400, // 24 hours in seconds httpOnly: true, secure: true, sameSite: 'Strict', path: '/',})
// Cross-site iframe cookie with CHIPS partitioningres.cookie('embed_pref', 'theme_dark', { sameSite: 'None', // Forces secure: true automatically partitioned: true, // Cookies Having Independent Partitioned State (CHIPS) priority: 'high', path: '/',})Clearing Cookies
res.clearCookie('token', { path: '/' })Cookie Options
| Option | Type | Description |
|---|---|---|
maxAge | number | Expiration time in seconds from current time |
expires | Date | Absolute expiration date |
httpOnly | boolean | Flags cookie as inaccessible to client scripts |
secure | boolean | Requires HTTPS transport (forced if sameSite: 'None') |
sameSite | 'Strict' | 'Lax' | 'None' | boolean | Cross-site request cookie behavior |
partitioned | boolean | Enables CHIPS cookie partitioning |
priority | 'low' | 'medium' | 'high' | Storage priority under client disk pressure |
path | string | URL path scope (default: '/') |
domain | string | Domain scope for the cookie |
File Downloads & Streaming
File Downloads
Return download() from a handler to send generated content (a string, Buffer or stream) as an attachment. It sets Content-Disposition and infers Content-Type from the filename:
import { controller, route } from 'exisjs/router'import { download } from 'exisjs/response'
export default controller({ exportCsv: route.get('/export', { async handle() { const csv = await ReportService.buildCsv() return download(csv, 'report.csv') }, }), exportXlsx: route.get('/export.xlsx', { async handle() { const stream = await ReportService.streamWorkbook() return download(stream, 'report.xlsx', { headers: { 'Cache-Control': 'no-store' }, }) }, }),})Pass { contentType } to override the inferred MIME type. A handler that returns a plain Buffer sends it as application/octet-stream.
Files on disk: res.download(path)
res.download() sends a file from disk. A string argument is always a file path, never content; a missing file answers 404. It also accepts a Buffer or stream.
import { controller, route } from 'exisjs/router'
export default controller({ annual: route.get('/annual', { handle: async ({ res }) => { res.download('./storage/annual-report.pdf', 'Annual-Report-2026.pdf') }, }), // The filename comes from the request, so confine it to one directory byName: route.get('/files/:name', { handle: async ({ params, res }) => { res.download(params.name, undefined, { root: './storage/exports' }) }, }),})Security: whenever any part of the path comes from the request (a param, query value or body field), pass root. Paths that resolve outside it, such as ../../.env or an absolute path, answer 404 instead of leaking the file.
| String argument means | Use for | |
|---|---|---|
return download(data, name) | Content | Generated CSV, JSON, reports |
res.download(path, name?, { root }) | File path | Files stored on disk |
Stream Piping (res.stream() / res.sendStream())
ExisJS supports direct streaming for:
- Node.js
Readablestreams (fs.createReadStream, database cursor streams) - Web standard
ReadableStream(OpenAI SDK, fetch responses) AsyncIterableand async generators
1. Streaming Node.js Readable Streams
import { controller, route } from 'exisjs/router'import fs from 'node:fs'
export default controller({ exportCsv: route.get('/', { handle: ({ res }) => { const stream = fs.createReadStream('./data/export.csv') res.type('text/csv') res.stream(stream) }, }),})2. Streaming Web Standard Streams (AI Chat & Fetch)
import { controller, route } from 'exisjs/router'
export default controller({ chat: route.post('/', { handle: async ({ req, res }) => { const upstream = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'gpt-4o', stream: true, messages: req.body.messages, }), })
res.type('text/event-stream') res.stream(upstream.body!) }, }),})3. Automatic Return Stream Piping
If a route handler returns a Readable stream directly, ExisJS detects and pipes it automatically:
import { controller, route } from 'exisjs/router'import fs from 'node:fs'
export default controller({ getFile: route.get('/', { handle: () => { return fs.createReadStream('./data/report.csv') }, }),})Server-Sent Events (SSE)
Use res.sse() to open a real-time event stream:
import { controller, route } from 'exisjs/router'
export default controller({ liveFeed: route.get('/', { handle: ({ res }) => { const sse = res.sse()
sse.send({ event: 'connected', data: { timestamp: Date.now() } })
const interval = setInterval(() => { sse.send({ event: 'price', data: { symbol: 'BTC', price: 95000 } }) }, 1000)
sse.onClose(() => { clearInterval(interval) }) }, }),})Streaming Async Iterables with sse.pipeFrom()
Stream chunks from an async generator or AI SDK directly:
import { controller, route } from 'exisjs/router'
export default controller({ streamTokens: route.post('/', { handle: ({ res }) => { res.sse(async (sse) => { async function* tokenGenerator() { yield 'Hello ' yield 'from ' yield 'ExisJS SSE!' }
await sse.pipeFrom(tokenGenerator(), (chunk) => ({ event: 'token', data: { text: chunk }, }))
sse.close() }) }, }),})Caching & Conditional Requests (ETag, 304, 412)
ExisJS follows the conditional request rules of RFC 9110 ยง13 everywhere: for automatic ETags, in res.checkPreconditions(), for serveStatic and for res.download(path).
| Request header | Checked against | Result when it does not hold |
|---|---|---|
If-Match | ETag, strong comparison | 412 Precondition Failed |
If-Unmodified-Since (only without If-Match) | Last-Modified | 412 Precondition Failed |
If-None-Match | ETag, weak comparison; lists and * supported | Matches: 304 for GET/HEAD, 412 for writes |
If-Modified-Since (only without If-None-Match, GET/HEAD) | Last-Modified | Unchanged: 304 Not Modified |
A 304 carries the ETag, Last-Modified, Cache-Control and Vary of the 200 it replaces, and no body or Content-Type. Errors use the standard envelope (PRECONDITION_FAILED, PRECONDITION_REQUIRED).
Automatic ETags
With etag: true in exis.config.ts (or conditionalGet() on some routes), every successful GET/HEAD response sent with res.json(), res.send() or a returned value gets a weak ETag hashed from its body, and a matching If-None-Match is answered with 304. The handler still runs, so this saves bandwidth, not work. Writes and error responses get no ETag.
Validators from your data: res.checkPreconditions()
When the resource has a version or update time, check it before doing the work. On reads this skips the query for the body and serialization; on writes it prevents lost updates: a client that edited an old copy gets 412 instead of overwriting someone else's change.
import { controller, route } from 'exisjs/router'
export default controller({ read: route.get('/', { async handle({ params, res }) { const meta = await documents.meta(params.id) // cheap: version + updatedAt // Sets ETag / Last-Modified; answers 304 and returns true when current if (res.checkPreconditions({ etag: `v${meta.version}`, lastModified: meta.updatedAt })) return return documents.find(params.id) }, }),
update: route.put('/', { async handle({ params, body, res }) { const current = await documents.meta(params.id) // 412 if the client's If-Match is stale; 428 if it sent no precondition res.checkPreconditions({ etag: `v${current.version}` }, { required: true }) const updated = await documents.update(params.id, body) res.setHeader('ETag', `"v${updated.version}"`) // the new state return updated }, }),})etagmay be bare (v3is sent as"v3") or a full tag (W/"v3"). Use a value that changes whenever the representation changes: a version column, a hash,updatedAt.lastModifiedtakes aDate, a timestamp or an HTTP date. It is rounded to whole seconds, and a time in the future is capped at the current time.- For "create only if absent" (
PUTwithIf-None-Match: *), passexists: falsewhen the resource does not exist yet; an existing one answers412. - On writes, the old validators are not sent back. Set the new
ETagafter the change, as above.
req.fresh (and req.stale) apply the same rules to the response headers set so far.
Next Steps
- Boundaries & Cascading: Folder-level middleware, security guards, and error boundaries.
- Validation Guide: Input validation with
texschema builder. - Controllers: Working with class-based response decorators.
- Requests: Inspecting request details, headers, and abort signals.