Server Routes
Apps can include server-side JavaScript handlers that run on the Informer server in sandboxed V8 isolates. These handlers have direct access to the app's Postgres workspace and can make authenticated API calls — ideal for business logic, data transformations, or anything that shouldn't run in the browser.
Server routes are also the preferred place to access your data dependencies — see Accessing dependencies and the informer.yaml reference.
Server routes, webhooks, and agent tools all run in the same V8 sandbox and receive the same handler bag documented here — context (typed dependencies), query, fetch, respond, emit, notify, email, crypto, markdown, log, env, plus the base64 globals. They differ only in what triggers them and a few trigger-specific fields (webhooks add request.rawBody; tools get args + run instead of request). Learn the bag once and it applies everywhere.
How it works
- Create a
server/directory in your project root - Add
.jshandler files using file-convention routing (like Next.js) - Run
npm run deploy— Informer scans, bundles, and registers routes automatically - Your app calls server routes via
fetch('/api/_server/...')
Handler code runs in an isolated-vm V8 isolate — a separate V8 heap with no access to Node.js APIs, the filesystem, or the network. All I/O goes through injected callbacks.
File-convention routing
File paths under server/ map to URL routes:
| File | Route | Example URL |
|---|---|---|
server/index.js | / | /api/_server/ |
server/orders/index.js | /orders | /api/_server/orders |
server/orders/[id].js | /orders/:id | /api/_server/orders/abc123 |
server/orders/[id]/approve.js | /orders/:id/approve | /api/_server/orders/abc123/approve |
[param]segments become dynamic route parameters (available asrequest.params.param)index.jsfiles map to the parent directory path
Handler structure
Each handler file exports named functions for each HTTP method it supports (GET, POST, PUT, PATCH, DELETE):
// server/orders/index.js
export async function GET({ query, request }) {
const rows = await query('SELECT * FROM orders ORDER BY created_at DESC LIMIT 50');
return rows;
}
export async function POST({ query, request }) {
const { customer, total } = request.body;
const rows = await query(
'INSERT INTO orders (customer, total) VALUES ($1, $2) RETURNING *',
[customer, total]
);
return { status: 201, body: rows[0] };
}
Each handler function receives a single context object:
| Property | Type | Description |
|---|---|---|
query | async (sql, params?) => rows | Execute SQL against the app's workspace. Returns an array of row objects. |
fetch | async (path, options?) => { status, body } | Make an authenticated API call through Informer (subject to the app's allowlist). |
context | object | Typed dependency proxies keyed by slot name — context.<slot>.<method>(args). See informer.yaml. |
respond | async (response) => void | Send an early HTTP response while the handler keeps running. See Using respond(). |
emit | async (event, payload) => void | Emit an app event to trigger agents. |
notify | async (username, message) => { id } | Enqueue a push notification to a user's Informer GO devices. See Using notify(). |
email | async (to, message) => { id } | Enqueue an email via the tenant's mail transport. See Using email(). |
crypto | object | Cryptographic helpers — hmac, hash, randomUUID, randomBytes, timingSafeEqual, verifyHmac, encrypt/decrypt (AES-256-GCM), verify. All async. See Crypto helpers. |
log | function | Structured logging — log(message, data?) or log.info/warn/error/debug(). See Using log(). |
env | object | App environment variables (from app.defn.env). |
request | object | The incoming request (see below). |
Sandbox globals
These are available as globals inside the isolate — not keys on the handler argument. Don't destructure them from the handler arg (they'll be undefined); just call them.
| Global | Type | Description |
|---|---|---|
markdown | async (text) => string | Convert markdown to HTML (marked). |
base64Decode / base64Encode | async | Base64 ↔ UTF-8 text (handles multi-byte; safer than atob/btoa). |
base64UrlDecode / base64UrlEncode | async | Base64url ↔ UTF-8 (Gmail API payloads, JWT segments). |
atob / btoa | (string) => string | Standard Web APIs (Latin-1 only). Prefer the base64* helpers for non-ASCII. |
The request object
| Property | Type | Description |
|---|---|---|
request.method | string | HTTP method |
request.path | string | Matched route path (e.g. /orders/:id) |
request.params | object | Route parameters (e.g. { id: 'abc123' }) |
request.query | object | Query string parameters |
request.body | any | Request body (parsed JSON for POST/PUT/PATCH) |
request.headers | object | Always {} for server routes (see Sanitized inputs) |
request.roles | string[] | The viewer's assigned role IDs |
request.user | object | Current user identity — username, displayName, email, timezone |
Sanitized request inputs
For security, Informer strips auth-bearing values before your handler runs (I5-12444):
request.headersis always{}for server routes — userequest.user/request.rolesfor identity. (A handler controls both ends of its own call, so there's no legitimate reason to read raw headers.)request.queryhas Informer'stokenstripped; your own params pass through untouched.
Webhook handlers use a narrower deny-list because signatures arrive in headers — see Webhooks → Sanitized inputs.
Return values
Pick the shape that matches the response you want:
Simple value — wrapped as a 200 JSON response:
export async function GET({ query }) {
return await query('SELECT * FROM orders'); // → 200 application/json
}
Response object — full control over status, headers, body:
export async function POST({ query, request }) {
const { customer } = request.body;
if (!customer) return { status: 400, body: { error: 'Customer is required' } };
const [order] = await query('INSERT INTO orders (customer) VALUES ($1) RETURNING *', [customer]);
return { status: 201, body: order };
}
No return value — returns 204 No Content.
Text response — when body is a string with no encoding, it's sent as-is. Use for CSV, HTML, SVG, plain text:
export async function GET({ query }) {
const rows = await query('SELECT id, customer, total FROM orders');
const csv = ['id,customer,total', ...rows.map(r => `${r.id},${r.customer},${r.total}`)].join('\n');
return {
status: 200,
headers: {
'content-type': 'text/csv',
'content-disposition': 'attachment; filename="orders.csv"'
},
body: csv
};
}
Binary response — set encoding: 'base64' to send raw bytes (images, PDFs, downloads). The body is decoded from base64 before being written, and your content-type is sent verbatim:
export async function GET({ query, request }) {
const [row] = await query(
`SELECT encode(data, 'base64') AS data, mime_type, name
FROM attachments WHERE id = $1`,
[request.params.id]
);
if (!row) return { status: 404, body: { error: 'Not found' } };
return {
status: 200,
headers: {
'content-type': row.mime_type,
'content-disposition': `inline; filename="${row.name}"`
},
body: row.data,
encoding: 'base64'
};
}
This is the recommended pattern for serving file attachments stored as bytea — Postgres' encode(col, 'base64') does the work in SQL, so the handler just passes the string through.
encoding: 'base64' requires standard base64 — no whitespace, no data: URL prefix, no base64url. A malformed body throws a 500. If your source is base64url (Gmail attachments, JWT segments), normalize first: await base64Encode(await base64UrlDecode(input)).
| Response field | Type | Notes |
|---|---|---|
status | number | HTTP status code |
headers | object | content-type forwarded verbatim; defaults to application/json |
body | any | Object/array → JSON; string → sent as-is; with encoding: 'base64' → decoded to bytes |
encoding | 'base64' | Optional. When set, body must be base64 and is written as raw bytes |
Using query()
query executes SQL against the app's Postgres workspace — the same schema managed by migrations/. Always parameterize with $1, $2, …:
export async function GET({ query, request }) {
const orders = await query(
'SELECT * FROM orders WHERE status = $1 ORDER BY created_at DESC',
[request.query.status || 'pending']
);
const [stats] = await query(
`SELECT COUNT(*) AS count, SUM(total) AS revenue FROM orders WHERE status = 'completed'`
);
return { orders, stats };
}
All query() calls in a request share one connection, closed automatically when the handler completes.
Using fetch()
fetch makes authenticated API calls through Informer using the viewer's credentials, subject to the app's allowlist. The path is relative to /api/ — pass 'datasets/admin:sales-data/_search', not '/api/datasets/...'.
fetch is not the browser fetchIt returns { status, body } — there is no .ok, .json(), .text(), or .headers getter. Pass body as a plain object (never JSON.stringify it, never set Content-Type — the runtime handles both). And don't new URL(request.url); use the pre-parsed request.query / request.params.
export async function GET({ fetch }) {
const result = await fetch('datasets/admin:sales-data/_search', {
method: 'POST',
body: { query: { match_all: {} }, size: 100 }
});
if (result.status !== 200) return { status: result.status, body: { error: 'Search failed' } };
return result.body.hits.hits.map(h => h._source);
}
fetch()For datasets/queries/datasources/integrations, call context.<slot>.<method>() instead of building raw /api/... paths. Slots survive bundle export/import and resource renames; hardcoded paths don't. See informer.yaml.
Using respond()
respond sends an early HTTP response while the handler keeps running in the background — useful when an external caller has a tight deadline (e.g. Slack's 3-second slash-command limit) but the work takes longer.
// server/slack/commands.js
export const config = { timeout: 25000 };
export async function POST({ fetch, respond, request }) {
const { response_url } = request.body;
// Ack immediately — the HTTP response is sent now
await respond({ response_type: 'ephemeral', text: 'Processing your request...' });
// Everything below runs in the background (isolate stays alive)
const result = await fetch('datasets/admin:sales-data/_search', {
method: 'POST',
body: { query: { match_all: {} }, size: 50 }
});
await fetch('integrations/slack/request', {
method: 'POST',
body: { url: response_url, method: 'POST', data: { text: `Found ${result.body.hits.total} records` } }
});
}
- Only the first
respond()call takes effect; later calls are ignored. - It accepts the same shapes as a synchronous return — a plain value,
{ status, body, headers? }, or a binary{ status, headers, body, encoding: 'base64' }. - The isolate, DB connection, and timeout stay active until the handler fully returns. Background errors after
respond()are logged but don't affect the already-sent response. - For normal CRUD handlers, just return the result —
respond()is opt-in.
Using log()
log writes structured entries to the app's log table (app_log), visible in the Logs tab of the App Admin panel in Informer GO.
export async function POST({ query, log, request }) {
const { customer, total } = request.body;
log.info('Creating order', { customer, total });
const [order] = await query('INSERT INTO orders (customer, total) VALUES ($1, $2) RETURNING *', [customer, total]);
log('Order created', { orderId: order.id }); // shorthand for log.info()
return { status: 201, body: order };
}
| Method | Level | Use case |
|---|---|---|
log(message, data?) | info | Shorthand |
log.debug | debug | Verbose diagnostics |
log.info | info | Normal operational events |
log.warn | warn | Unexpected but recoverable |
log.error | error | Failures needing attention |
Logging is fire-and-forget — it never blocks or throws. source and correlation fields (invocationId, or agentId/runId for agent tools) are set automatically. Available in server routes, webhooks, and agent tools.
Using notify()
Enqueue a push notification to a user's registered Informer GO devices. Returns immediately with the message ID; the app's ID is attached automatically so tapping the notification opens this app.
export async function POST({ notify }) {
const { id } = await notify('jane', {
title: 'Order Shipped',
body: 'Your order #1234 has shipped!',
path: '/orders/1234' // optional deep link within this app
});
return { notificationId: id };
}
Pass an array for bulk: notify([{ username, title, body }, ...]) → { ids, queued }. Messages retry up to 3 times before moving to dead.
Using email()
Enqueue an email via the tenant's configured mail transport (SMTP, Gmail API, Microsoft Graph).
export async function POST({ email }) {
const { id } = await email('jane@acme.com', {
subject: 'Invoice #1234',
html: '<h2>Invoice</h2><p>Amount due: <strong>$1,500</strong></p>'
});
return { emailId: id };
}
Pass an array for bulk: email([{ to, subject, html, from? }, ...]) → { ids, queued }.
Since the sandbox unification, notify() and email() are available in server routes, webhooks, and agent tools — all three share the same handler bag.
Crypto helpers
crypto exposes cryptographic primitives backed by Node's crypto on the host. Every method is async (the call crosses the isolate boundary), so await them. The same crypto object is available in server routes, webhooks, and agent tools.
| Method | Returns |
|---|---|
crypto.hmac(algorithm, key, data, encoding?='hex') | HMAC digest |
crypto.hash(algorithm, data, encoding?='hex') | Plain digest (e.g. sha256, sha512) |
crypto.randomUUID() | RFC 4122 v4 UUID |
crypto.randomBytes(length, encoding?='hex') | Cryptographically random bytes (length capped at 1024) |
crypto.timingSafeEqual(a, b) | Constant-time string compare → boolean (false on length mismatch) |
crypto.verifyHmac(algorithm, key, data, signature, encoding?='hex') | Computes the HMAC and constant-time-compares it to signature → boolean. The safe one-liner for verifying webhook signatures. |
crypto.verify(algorithm, data, signature, publicKey, signatureEncoding?='base64') | Verify an asymmetric (RSA/ECDSA) signature against a PEM public key → boolean |
crypto.encrypt(plaintext, key) | AES-256-GCM → a self-describing iv:tag:ciphertext string |
crypto.decrypt(payload, key) | Reverses encrypt; throws if the key is wrong or the payload was tampered with |
const sig = await crypto.hmac('sha256', secret, payload, 'hex');
const id = await crypto.randomUUID();
// Verify an inbound webhook signature without a timing leak
const ok = await crypto.verifyHmac('sha256', secret, request.rawBody, header);
// Encrypt a value at rest
const token = await crypto.encrypt(JSON.stringify({ userId: 7 }), env.APP_SECRET);
const { userId } = JSON.parse(await crypto.decrypt(token, env.APP_SECRET));
Handler config
Export a config object to customize behavior:
export const config = {
timeout: 60000, // wall-clock timeout in ms (default 30000)
roles: ['admin', 'manager'] // restrict to these roles; 403 otherwise
};
| Config | Type | Default | Description |
|---|---|---|---|
timeout | number | 30000 | Wall-clock timeout in ms. Handler is killed if exceeded. |
roles | string[] | [] (open) | If set, only viewers with a matching role can call this route. |
Imports
Files under server/, webhooks/, and tools/ are bundled at deploy by an esbuild plugin that resolves imports only against the app's own library — the host filesystem and node_modules are invisible.
Use relative imports only (./foo, ../shared/util.js); implicit .js / .json / /index.js resolution works. Deploy fails on:
- Bare specifiers (
fs,node:crypto,lodash) — apps have nonode_modulesor Node built-ins - Absolute paths (
/etc/...,C:\...) ..paths that escape the project root
For HTTP, hashing, base64, and markdown, use the injected helpers (fetch, crypto.hmac, the base64* globals, markdown) rather than importing libraries. For third-party logic, copy what you need into a project-local file.
npm run dev runs handlers through Vite, so a forbidden import may work in dev and only fail at deploy. Validate by deploying.
Sandbox constraints
- No Node.js APIs — no
require(),fs,http,process,Buffer - No network access — all external calls go through
fetch()(which enforces the allowlist) - No filesystem — use
query()for persistence - 128 MB memory limit — the isolate is killed if exceeded
- Wall-clock timeout — 30s default, configurable via
config.timeout - Ephemeral — a fresh isolate per request; no state persists between calls
Calling server routes from app code
Server routes are reached through the app's view-API proxy at /api/_server/. The view-token cookie is included automatically:
// GET
const orders = await (await fetch('/api/_server/orders')).json();
// POST
const res = await fetch('/api/_server/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer: 'Acme Corp', total: 1500 })
});
const newOrder = await res.json();
Local development
Server routes run locally during npm run dev via Vite's ssrLoadModule(). The Vite plugin mounts middleware at /api/_server, passes the dev workspace connection to query(), proxies fetch() to your Informer server, and supports HMR — editing a handler takes effect immediately. No extra config beyond .env. If your handlers use query(), ensure migrations/ exists so the workspace is auto-provisioned (see Workspace CLI).