ExisJS

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

src/http/users/route.ts
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

src/http/users/route.ts
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 returnsResponse
Object, array, number, boolean, nullJSON (null is the JSON value null)
Stringtext/plain
Buffer / Uint8Arrayapplication/octet-stream (set Content-Type first to change it)
Stream or async iterableStreamed
download(data, name)Attachment
res, or the result of res.json() / res.send()Nothing more: the response was already sent
undefined, nothing sent204 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:

BuilderHTTP StatusResponse StructureDescription
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?)CustomRFC 7807 / RFC 9457 Problem Details objectStandardized HTTP Problem Details
paginate(items, total, options)200 OK{ success: true, data, pagination: { ... } }Paginated response envelope

Example Usage

src/http/users/route.ts
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:

src/http/checkout/route.ts
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:

src/http/products/route.ts
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. multiple Set-Cookie directives).
  • res.type(mimeType): Shorthand for setting Content-Type (e.g. res.type('html') $\to$ text/html).
  • res.removeHeader(name): Removes a previously set header.
  • res.vary(field): Adds a field to the Vary caching header.

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: '/' })
OptionTypeDescription
maxAgenumberExpiration time in seconds from current time
expiresDateAbsolute expiration date
httpOnlybooleanFlags cookie as inaccessible to client scripts
securebooleanRequires HTTPS transport (forced if sameSite: 'None')
sameSite'Strict' | 'Lax' | 'None' | booleanCross-site request cookie behavior
partitionedbooleanEnables CHIPS cookie partitioning
priority'low' | 'medium' | 'high'Storage priority under client disk pressure
pathstringURL path scope (default: '/')
domainstringDomain 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:

src/http/reports/route.ts
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.

src/http/reports/route.ts
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' })    },  }),})
DEFAULT

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 meansUse for
return download(data, name)ContentGenerated CSV, JSON, reports
res.download(path, name?, { root })File pathFiles stored on disk

Stream Piping (res.stream() / res.sendStream())

ExisJS supports direct streaming for:

  • Node.js Readable streams (fs.createReadStream, database cursor streams)
  • Web standard ReadableStream (OpenAI SDK, fetch responses)
  • AsyncIterable and async generators

1. Streaming Node.js Readable Streams

src/http/export/route.ts
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)

src/http/ai/route.ts
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:

src/http/files/route.ts
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:

src/http/events/route.ts
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:

src/http/ai/route.ts
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 headerChecked againstResult when it does not hold
If-MatchETag, strong comparison412 Precondition Failed
If-Unmodified-Since (only without If-Match)Last-Modified412 Precondition Failed
If-None-MatchETag, weak comparison; lists and * supportedMatches: 304 for GET/HEAD, 412 for writes
If-Modified-Since (only without If-None-Match, GET/HEAD)Last-ModifiedUnchanged: 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.

src/http/documents/[id]/route.ts
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    },  }),})
  • etag may be bare (v3 is sent as "v3") or a full tag (W/"v3"). Use a value that changes whenever the representation changes: a version column, a hash, updatedAt.
  • lastModified takes a Date, 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" (PUT with If-None-Match: *), pass exists: false when the resource does not exist yet; an existing one answers 412.
  • On writes, the old validators are not sent back. Set the new ETag after the change, as above.

req.fresh (and req.stale) apply the same rules to the response headers set so far.


Next Steps