HTTP routes and responses
Serve KetJS functions, module routes, dynamic paths, HTML, JSON, binary data, and streamed responses.
KetJS provides a Node HTTP runtime around the composed application. The framework mounts function, asset, island, and agent endpoints; modules and the app may add routes through declared factories.
Module-owned routes#
Declare reusable routes on the module that owns them:
// File: src/modules/sales/index.ts
import { defineModule, json } from '@ketvietlab/ketjs'
export const sales = defineModule({
name: 'sales',
routes: {
'/api/orders/{id}': (ctx) => async (url, request, params) => {
const order = await ctx.call('sales.getOrder', { id: params.id }, url, request)
return json(order)
},
},
})A parameter occupies one complete path segment. Values are decoded and passed in params. Two modules
claiming the same path fail during composition.
Module route dispatch checks the live manifest. Disabling a module therefore returns 404 for its
routes instead of leaving stale handlers mounted.
Reserved API prefixes#
A contract-owning module can reserve a static prefix such as /api/customer/v1/. Routes below a reserved
prefix must come from the owner or from a dependent extension using the owner's route factory. KetJS records
the route method, request schema, response schemas, capability, and idempotency behavior in the manifest so
OpenAPI can be generated from the deployed composition rather than maintained as a separate document.
See HTTP contracts and OpenAPI for the framework contract fields, extension rules, and generator boundary.
Anonymous routes#
Routes default to requiring a session. Public endpoints must opt in:
// File: src/modules/order/routes.tsx
routes: {
'/health': {
anonymous: true,
handler: () => () => json({ ok: true }),
},
}Use anonymous exposure only for routes that truly precede identity: health checks, login, provider
callbacks, and deliberately public content. The functions called by an anonymous route must also be
declared anonymous: true.
App-level routes#
serve.routes is useful for application shell routes that do not belong to a reusable module:
// File: src/app.ts
import { defineDeployment, json } from '@ketvietlab/ketjs'
const app = defineDeployment({
name: 'orders_api',
modules: [sales],
headless: true,
serve: {
routes: (ctx) => ({
'/': () => json({ app: 'orders_api' }),
'/api/orders': async (url, request) =>
json(await ctx.call('sales.listOrders', {}, url, request)),
}),
},
})Prefer module routes when install state should control availability. Keep deployment health and
application-level orchestration in serve.routes.
ServeContext#
Route factories receive live runtime services:
| Member | Use |
|---|---|
manifest |
Full manifest shipped by the deployment. |
live(request) |
Manifest restricted to modules enabled for this request's tenant. |
reportsOf(url, request, target) |
Installed reports for a model whose source the viewer may call. |
appsOf(request) |
Installed/available module information for this tenant. |
scopeOf(url, request) |
Company and branch scope resolved from the session or development shim. |
requestIdentityOf(url, request) |
The identity the request runs as, or null: user, companies, branches and origin — session for a cookie session, request for an identity resolveIdentity asserted. Read the viewer from here, not from sessionsOf(...).of(request), which sees only cookies. |
signOutPath |
Where a viewer with a request identity signs out (serve.signOutPath), or null. A cookie session signs out through POST /logout; a gateway login must end at the gateway so its upstream login ends too. |
call(name, input, url, request) |
Function call carrying tenant, session, permissions, actor, and scope. |
callUnchecked(...) |
Internal authorization bootstrap only; deliberately easy to audit by name. |
callUncheckedForVerifiedCompany(...) |
Exact-company function dispatch after an external credential has cryptographically authenticated that company. |
sessionsOf(url, request) |
Session manager for the request's tenant, or null. |
storageOf(url, request) |
Tenant-namespaced blob storage. |
translate(locale) |
Translator for the composed message catalogue. |
document(...), styles(request) |
Safe document shell and composed module styles. |
joint(...), jointShows(...) |
Installed extension-point output. |
menu(url, request) |
Navigation filtered by install state and function permissions. |
subscribe(channel, onMessage, onReady?, request?) |
Listen for ctx.notify(channel, …). One database listener per channel per database per process. A deployment with a database per tenant passes { url, request } to listen to that request's tenant. |
tenantKeyOf(url, request) |
The request's tenant key, or '' with one datastore. Key per-tenant listener state by it. |
Do not cache live() or a tenant-specific service globally. Which modules, sessions, and storage
apply can change per request.
Provider callbacks may arrive without a staff session even though their durable records are company-scoped.
Authenticate the exact request bytes first, bind the credential to one company, and only then use
callUncheckedForVerifiedCompany(). The method keeps the existing tenant lease and constructs an exact
single-company scope; it does not make a path, query, header, or unsigned body company claim trustworthy.
Response helpers#
Routes return branded RouteResult values. Create them with public helpers:
| Helper | Body | Default content type |
|---|---|---|
page({ body }) |
Escaped TemplateResult document |
text/html with doctype |
fragment(body) |
Escaped HTML fragment | text/html |
navigablePage(request, options) |
Full document or named navigation slots | Negotiated |
json(value) |
JSON serialization | application/json |
text(value) |
String | text/plain |
bytes(value, { type }) |
Uint8Array |
Required non-markup type |
streamed(iterable, { type }) |
AsyncIterable<Uint8Array> |
Required non-markup type |
websocket(session) |
Upgrades the request (RFC 6455) | 426 for a plain request |
raw(value, { type }) |
Trusted prebuilt string | text/html |
withHeaders(result, headers) |
Existing result plus headers | Preserves the original type/status |
Each helper accepts a status option. bytes() and streamed() refuse HTML, XHTML, and SVG content
types so binary APIs cannot accidentally become a markup escape hatch.
Safe HTML#
Write TSX with the ketjs-view automatic JSX runtime, then return page or fragment from @ketvietlab/ketjs:
// File: src/modules/order/routes.tsx
import { page } from '@ketvietlab/ketjs'
routes: {
'/orders/{id}': (ctx) => async (url, request, params) => {
const order = await ctx.call('sales.getOrder', { id: params.id }, url, request)
const body = ctx.document({
lang: ctx.localeOf(url, request),
title: 'Order',
body: <main><h1>{order.number}</h1><p>{order.total}</p></main>,
})
return page({ body })
},
}Template holes are escaped. A plain object that resembles RouteResult is not assignable because the
type is branded. raw() is the deliberate, searchable escape hatch for already-trusted markup; never
pass request data to it.
Fragment navigation#
Use navigablePage() when one GET route can return either a complete document or replaceable slots:
// File: src/modules/order/routes.tsx
import { navigablePage } from '@ketvietlab/ketjs'
return navigablePage(request, {
title: 'Orders',
document: () => ctx.document({ lang: 'en', title: 'Orders', body: screen }),
slots: {
'backend.sidebar-main': () => sidebar,
'backend.topbar': () => topbar,
'backend.content': () => screen,
},
})The callbacks are lazy. A normal request calls only document; a navigation request calls only the
declared slot renderers. Keep stable shell joints and global islands outside replaceable slots so the
server does not construct them during internal navigation.
The protocol is intentionally small:
# File: examples/request.http
X-Ket-Navigation: fragment-v1
Accept: text/vnd.ket.fragments+html<!-- File: src/templates/example.html -->
<ket-fragments data-title="Orders">
<template data-ket-slot="backend.content">...</template>
</ket-fragments>Fragment responses use text/vnd.ket.fragments+html and include
Vary: X-Ket-Navigation. isNavigationRequest(request) checks the request representation. Slot names
are lowercase dotted names, and each returned slot must occur exactly once in both the response and
current document.
The generated browser runtime enhances same-origin GET links and GET forms by default when the page
contains a slot. POST forms stay native unless a module enhances them. Modifier clicks, downloads,
external URLs, hash-only links, and elements under data-ket-reload are left alone. The runtime owns
history, back/forward restoration, request cancellation, title, scroll, hash focus, and aria-busy,
and emits ket:navigation-start, ket:navigation-complete, and ket:navigation-error.
Every routed response and the generated browser runtime carry the same X-Ket-Build identifier. A
fragment from a different deployment build is never reconciled into the current document: the browser
performs a normal navigation to obtain a matching HTML/runtime pair. Low-level servers set
createKetServer({ buildId }); deployment specs set serve.buildId. Production should use an immutable
release or image identifier. The default is a deterministic manifest fingerprint, so changing browser
code without changing a module version requires an explicit buildId.
Module-owned POST enhancement uses the injected BrowserNavigation service. Send the normal fragment
negotiation headers and pass the response to navigation.apply(response, { signal }); it applies the
same cancellation, MIME, slot, island, build, and location checks as link navigation. Do not parse
ket-fragments, mutate slots, or write history independently in each module.
An invalid MIME type, missing or duplicate slot, login redirect, build mismatch, unknown island, failed island update, or any reconciliation error falls back to a full navigation. Without JavaScript, every link and form continues to use ordinary document navigation.
For themed website pages, set serve.pages.region to the built-in theme region that carries the page
body, for example website.page. Themes without that declared slot keep full navigation and remain
backward compatible.
Headers and cookies#
Add response headers without reconstructing the branded object:
// File: src/modules/order/routes.tsx
return withHeaders(json({ ok: true }), {
'cache-control': 'no-store',
'set-cookie': cookie,
})Use withHeaders() rather than spreading a route result into a new plain object.
Binary and streamed output#
Serve an in-memory export:
// File: src/modules/order/routes.tsx
return bytes(csvBytes, {
type: 'text/csv; charset=utf-8',
status: 200,
})Serve large content with backpressure:
// File: src/modules/order/routes.tsx
const stored = await ctx.storageOf(url, request).get(key)
if (!stored) return text('Not found', { status: 404 })
return streamed(stored.body, {
type: stored.meta.type,
})The HTTP layer consumes the async iterable chunk by chunk instead of buffering the object.
WebSockets#
A route opts in by returning websocket(...). The route runs exactly as it does for a
request — the module sign-in check, rateLimit and the route's own authentication all see
the upgrade request — and only a websocket() answer opens a socket. Any other answer is
written back as that HTTP response and the connection closes.
// File: src/modules/order/routes.tsx
'/v1/orders/live': (ctx) => async (url, request) => {
const viewer = await authenticate(ctx, url, request)
if (!viewer) return text('sign in first', { status: 401 })
let stop = async () => {}
return websocket({
protocols: ['orders.v1'],
async open(peer) {
stop = await ctx.subscribe('order_changed', (orderId) => {
if (visibleTo(viewer, orderId)) peer.send(JSON.stringify({ orderId }))
})
},
close: () => void stop(),
})
}| Option | Default | Meaning |
|---|---|---|
protocols |
none | Subprotocols, preferred first. A client offering none of them gets 400 before the upgrade. |
maxMessageBytes |
64 KiB | Largest reassembled message; larger closes with 1009. |
maxBufferedBytes |
1 MiB | Outgoing bytes queued for a peer that stopped reading; beyond it the socket is dropped (1013). |
pingIntervalMs |
25 000 | Server ping cadence. A peer silent for a whole interval is dropped. 0 disables. |
Text and binary messages, fragmentation and the closing handshake are handled; no extension
(including permessage-deflate) is negotiated. close() on the server sends 1001 to every
open socket.
Send hints, not data. Publish with ctx.notify(channel, payload) from a function: inside tx
it is delivered on commit and dropped on rollback. PostgreSQL carries it to every process
(NOTIFY, payload at most 7999 bytes); SQLite delivers to listeners in the same process only.
Delivery is best effort, so a client should re-read on connect and keep a slower poll as a
backstop. onReady runs after every listener reconnect for exactly that reason.
With a database per tenant, subscribe with the upgrade request so the listener lands on that tenant's database, and hear only that tenant's notifications. A listener holds its tenant's database open in the pool until its stop runs: the pool neither idles out nor evicts it, and when every pooled database is being listened to, the next tenant's request is refused rather than a listener cut off. Share one subscription per tenant between that tenant's sockets and stop it when the last one closes; each listened tenant costs PostgreSQL one extra connection.
Function transport#
HTTP-exposed functions are callable at:
# File: packages/docs/content/docs/http.md
POST /_ket/fn/<qualified-function-name>
Content-Type: application/jsonUse TestClient.call() or ket call instead of manually constructing this transport. They preserve
cookies, identity headers, dry-run, idempotency keys, and error parsing.
The generic transport buffers at most 1 MiB of JSON by default. Configure serve.maxJsonBodyBytes (or
maxJsonBodyBytes on createKetServer) when a deployment needs a smaller boundary. KetJS checks both
Content-Length and streamed bytes before it runs scope, permission, or actor resolvers, so an
unauthenticated request cannot make those paths retain an unbounded body first.
Rate limiting#
A deployment may put a ceiling on how often one caller reaches a route:
// File: src/deployment.ts
serve: {
rateLimit: (ctx, url, req) =>
url.pathname === '/api/token'
? { action: 'auth.refresh', key: callerOf(req), limit: 60, windowMs: 15 * 60_000 }
: null,
}A refusal is answered 429 with retry-after, before the route runs — refusing after doing the work
is a limit that costs what it was meant to save. Each refusal leaves a rate_limited record.
Return null for almost everything. The check is durable, which means a database round trip, so pointing it at all traffic hands an attacker a lever rather than taking one away. Name the routes where repetition by one identified caller is the abuse — signing in, refreshing a token, an expensive report. Volumetric floods belong to whatever sits in front of the process.
key says who is being limited: an account id, a hashed address, a device. It is hashed with the
action before storage, so the table holds a counter and not a record of who was where. action is
kept, because it is low-cardinality and it is what an operator greps for.
State lives in ket_rate in the tenant's own database, created the first time a policy is claimed —
a deployment that limits nothing carries no limiter state. The worker prunes counters nobody has
touched for a day; a deployment running no worker should call pruneRateSlots itself.
The window is fixed rather than a token bucket, because limit per windowMs is what a person means
and a bucket quietly changes it. The cost, stated: a caller can spend a full allowance at the end of
one window and another at the start of the next, so the worst case across a boundary is twice the
limit.
claimRateSlot is exported for a module that needs the same ceiling somewhere other than a route.
Error handling#
KetError values serialize their code, message, and optional hint. Use stable codes for machine
decisions and messages for diagnostics. Unexpected errors remain server failures; do not convert every
exception into a successful JSON body.
Malformed request URLs, Host headers, percent encoding, and JSON bodies are client errors. KetJS
answers them with HTTP 400 inside the normal JSON error boundary; they never escape from the async
server listener. As an origin server, KetJS accepts only origin-form request targets (/path?...) and
a Host authority without userinfo, path, query, fragment, encoded delimiters, or whitespace. Absolute
and scheme-relative request targets are rejected before tenant resolution. Function bodies must be
JSON objects rather than arrays or scalar values.
FormValidationError and invalid generic function input use HTTP 422. Their JSON includes flat
issues, grouped fieldErrors, and whole-form formErrors. See Form validation
for the shared browser/server contract. Other KetError values remain HTTP 400 unless a more specific
transport status applies.
The generic JSON limit is only a baseline. Keep content-specific limits and provider authentication on dedicated routes. Use Storage, transport, and streams for bounded multipart uploads and webhook/service boundaries.