Skip to main content

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.

One sandbox, three surfaces

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

  1. Create a server/ directory in your project root
  2. Add .js handler files using file-convention routing (like Next.js)
  3. Run npm run deploy — Informer scans, bundles, and registers routes automatically
  4. 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:

FileRouteExample 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 as request.params.param)
  • index.js files 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:

PropertyTypeDescription
queryasync (sql, params?) => rowsExecute SQL against the app's workspace. Returns an array of row objects.
fetchasync (path, options?) => { status, body }Make an authenticated API call through Informer (subject to the app's allowlist).
contextobjectTyped dependency proxies keyed by slot name — context.<slot>.<method>(args). See informer.yaml.
respondasync (response) => voidSend an early HTTP response while the handler keeps running. See Using respond().
emitasync (event, payload) => voidEmit an app event to trigger agents.
notifyasync (username, message) => { id }Enqueue a push notification to a user's Informer GO devices. See Using notify().
emailasync (to, message) => { id }Enqueue an email via the tenant's mail transport. See Using email().
cryptoobjectCryptographic helpers — hmac, hash, randomUUID, randomBytes, timingSafeEqual, verifyHmac, encrypt/decrypt (AES-256-GCM), verify. All async. See Crypto helpers.
logfunctionStructured logging — log(message, data?) or log.info/warn/error/debug(). See Using log().
envobjectApp environment variables (from app.defn.env).
requestobjectThe 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.

GlobalTypeDescription
markdownasync (text) => stringConvert markdown to HTML (marked).
base64Decode / base64EncodeasyncBase64 ↔ UTF-8 text (handles multi-byte; safer than atob/btoa).
base64UrlDecode / base64UrlEncodeasyncBase64url ↔ UTF-8 (Gmail API payloads, JWT segments).
atob / btoa(string) => stringStandard Web APIs (Latin-1 only). Prefer the base64* helpers for non-ASCII.

The request object

PropertyTypeDescription
request.methodstringHTTP method
request.pathstringMatched route path (e.g. /orders/:id)
request.paramsobjectRoute parameters (e.g. { id: 'abc123' })
request.queryobjectQuery string parameters
request.bodyanyRequest body (parsed JSON for POST/PUT/PATCH)
request.headersobjectAlways {} for server routes (see Sanitized inputs)
request.rolesstring[]The viewer's assigned role IDs
request.userobjectCurrent user identity — username, displayName, email, timezone

Sanitized request inputs

For security, Informer strips auth-bearing values before your handler runs (I5-12444):

  • request.headers is always {} for server routes — use request.user / request.roles for identity. (A handler controls both ends of its own call, so there's no legitimate reason to read raw headers.)
  • request.query has Informer's token stripped; 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.

Standard base64 only

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 fieldTypeNotes
statusnumberHTTP status code
headersobjectcontent-type forwarded verbatim; defaults to application/json
bodyanyObject/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/...'.

The sandboxed fetch is not the browser fetch

It 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);
}
Prefer typed dependency slots over raw 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 };
}
MethodLevelUse case
log(message, data?)infoShorthand
log.debugdebugVerbose diagnostics
log.infoinfoNormal operational events
log.warnwarnUnexpected but recoverable
log.errorerrorFailures 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 }.

Available on every surface

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.

MethodReturns
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
};
ConfigTypeDefaultDescription
timeoutnumber30000Wall-clock timeout in ms. Handler is killed if exceeded.
rolesstring[][] (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 no node_modules or 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.

Dev uses Vite, not the deploy bundler

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).