Server Routes
Apps can define server-side route handlers that run in a sandboxed V8 isolate. Handlers have access to the app's Postgres workspace via query() and authenticated Informer API access via fetch().
Architecture
Server routes use file-convention routing: files in the server/ directory map to URL paths. Each file exports named HTTP method handlers (GET, POST, etc.) that receive a context object with query, fetch, request, and env callbacks.
Handlers are:
- Bundled with esbuild during deploy (IIFE format)
- Executed in an isolated-vm isolate per request (no shared state between requests)
- Sandboxed with no access to Node.js APIs, filesystem, or network (only
query(),fetch(),respond(),emit(),notify(), andemail())
Routing Convention
Files in server/ map to routes based on their path. Dynamic segments use bracket syntax [param]:
| File Path | Route Path | URL |
|---|---|---|
server/index.js | / | /api/apps/{id}/view/api/_server/ |
server/orders/index.js | /orders | /api/apps/{id}/view/api/_server/orders |
server/orders/[id].js | /orders/:id | /api/apps/{id}/view/api/_server/orders/123 |
server/orders/[id]/approve.js | /orders/:id/approve | /api/apps/{id}/view/api/_server/orders/123/approve |
server/health.js | /health | /api/apps/{id}/view/api/_server/health |
Handler Structure
Each file exports named functions for the HTTP methods it handles:
// server/orders/index.js
export async function GET({ query, request }) {
const rows = await query('SELECT * FROM orders ORDER BY created_at DESC');
return rows;
}
export async function POST({ query, request }) {
const { name, quantity } = request.body;
const rows = await query(
'INSERT INTO orders (name, quantity) VALUES ($1, $2) RETURNING *',
[name, quantity]
);
return { status: 201, body: rows[0] };
}
Context Object
Every handler receives a single context object with these properties:
| Property | Type | Description |
|---|---|---|
query | (sql, params?) => Promise<Row[]> | Execute SQL against the app's workspace |
fetch | (path, opts?) => Promise<{status, body}> | Make authenticated API calls to Informer |
respond | (body) => Promise<void> | Send early HTTP response (handler continues running) |
emit | (event, payload) => Promise<void> | Create an app event (can trigger agents) |
notify | (username, message) => Promise<{id}> | Enqueue a push notification (see below) |
email | (to, message) => Promise<{id}> | Enqueue an email (see below) |
crypto | object | Cryptographic helpers (see below) |
markdown | async (text) => string | Convert markdown text to HTML using marked |
log | function | Structured logging (see below) |
request | object | The incoming request (see below) |
env | object | App environment variables |
Request Object
| Property | Type | Description |
|---|---|---|
request.method | string | HTTP method (GET, POST, etc.) |
request.path | string | Route path (e.g., /orders/:id) |
request.params | object | Path parameters (e.g., { id: '123' }) |
request.query | object | Query string parameters |
request.body | any | Parsed request body (JSON) |
request.headers | object | Request headers |
request.roles | string[] | Current user's resolved roles |
request.user | object | Current user identity (see below) |
User Object
| Property | Type | Description |
|---|---|---|
request.user.username | string | Login username |
request.user.displayName | string | User's display name |
request.user.email | string | null | Email address |
request.user.timezone | string | null | Timezone (e.g., America/New_York) |
Return Values
Handlers can return values in several formats:
// Plain value → 200 JSON response
return rows;
// Explicit status + body
return { status: 201, body: { id: 1, name: 'New Order' } };
// Explicit status + headers + body
return {
status: 200,
headers: { 'x-custom': 'value' },
body: { data: rows }
};
// No return / return null → 204 No Content
return;
The query() Callback
Execute parameterized SQL against the app's dedicated Postgres workspace schema. Returns an array of row objects.
// Simple query
const rows = await query('SELECT * FROM orders');
// Parameterized query (prevents SQL injection)
const rows = await query(
'SELECT * FROM orders WHERE status = $1 AND amount > $2',
['pending', 100]
);
// Insert with RETURNING
const rows = await query(
'INSERT INTO orders (name, quantity) VALUES ($1, $2) RETURNING *',
['Widget', 5]
);
// rows[0] = { id: 1, name: 'Widget', quantity: 5, ... }
Important:
- Always use parameterized queries (
$1,$2) — never interpolate user input into SQL strings - The connection is scoped to the app's schema with
SET SESSION AUTHORIZATIONfor tenant isolation - Statement timeout is enforced (default: 30s, configurable via
config.timeout) - Requires a workspace — the app must have run migrations at least once
The fetch() Callback
Make authenticated API calls to the Informer server. The call is validated against the app's API whitelist (from informer.yaml).
// GET request
const result = await fetch('datasets-list');
// result = { status: 200, body: [...] }
// POST request with body
const result = await fetch('datasets/admin:sales-data/_search', {
method: 'POST',
body: {
query: { match_all: {} },
size: 10
}
});
Parameters:
| Parameter | Type | Description |
|---|---|---|
path | string | API path (with or without /api/ prefix) |
opts.method | string | HTTP method (default: GET) |
opts.body | object | Request body (for POST/PUT/PATCH) |
Return value:
{
status: number, // HTTP status code
body: any // Parsed response body
}
Whitelist enforcement:
- The path and method must match an entry in the app's whitelist
- Requests to non-whitelisted endpoints throw an error:
API endpoint not allowed: GET /api/users
The respond() Callback
Send an early HTTP response while the handler keeps running in the background. Useful when an external caller has a tight response deadline (e.g., Slack's 3-second limit).
export const config = { timeout: 25000 };
export async function POST({ query, fetch, respond, request }) {
const { text, response_url } = request.body;
// Send acknowledgment immediately — the HTTP response is sent now
await respond({ response_type: 'ephemeral', text: 'Processing...' });
// Everything below runs in the background
const result = await fetch('datasets/admin:sales/_search', {
method: 'POST',
body: { query: { match_all: {} }, size: 50 }
});
// Post the real answer back via response_url
await fetch('integrations/slack/request', {
method: 'POST',
body: { url: response_url, method: 'POST', data: { text: `Found ${result.body.hits.total} records` } }
});
}
Key behavior:
- Only the first
respond()call takes effect — subsequent calls are ignored - The response is always
200 JSON(Content-Type: application/json) - The isolate, database connection, and timeout remain active until the handler fully returns
- If background work throws after
respond(), the error is logged server-side but doesn't affect the already-sent response - If
respond()is never called, the handler returns normally
The emit() Callback
Create an app event that can trigger AI agents. Events are dispatched in near-real-time via Redis pub/sub.
export async function POST({ query, emit, request }) {
const [order] = await query(
'INSERT INTO orders (customer, total) VALUES ($1, $2) RETURNING *',
[request.body.customer, request.body.total]
);
// This triggers any agent listening for 'order_created'
await emit('order_created', { orderId: order.id, total: order.total });
return { status: 201, body: order };
}
Parameters:
| Parameter | Type | Description |
|---|---|---|
event | string | Event name (must match an agent's on trigger) |
payload | object | Data passed to the agent as trigger context |
The notify() Callback
Enqueue a push notification for delivery to a user's registered devices (Informer GO mobile or desktop). Messages are queued in the app_message table and delivered asynchronously via FCM. Returns immediately with the message ID.
The app's ID is automatically attached to the notification — tapping it in Informer GO opens this app (optionally at the specified sub-page).
export async function POST({ notify, request }) {
// Single notification
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 };
}
Bulk Notifications
Pass an array to send multiple notifications in a single call:
const { ids, queued } = await notify([
{ username: 'jane', title: 'Report Ready', body: 'Your Q1 report is ready' },
{ username: 'bob', title: 'Report Ready', body: 'Your Q1 report is ready' },
]);
// ids = ['uuid-1', 'uuid-2'], queued = 2
Parameters
Single:
| Parameter | Type | Description |
|---|---|---|
username | string | Informer username to notify |
message.title | string | Required. Notification title |
message.body | string | Notification body text |
message.path | string | Deep link path within the app (e.g., /orders/123) |
message.data | object | Additional custom data (values are coerced to strings) |
Bulk: Array of { username, title, body, path?, data? } objects.
Delivery
- Messages are enqueued with status
pendingand processed by a background dispatcher - Push delivery uses FCM via the device tokens registered by Informer GO
- Failed deliveries are retried up to 3 times, then moved to
deadstatus - Stale device tokens are automatically cleaned up on failure
- Message history is viewable via
GET /apps/{id}/messages
The email() Callback
Enqueue an email for delivery via the tenant's configured mail transport (SMTP, Gmail API, Microsoft Graph, etc.). Returns immediately with the message ID.
export async function POST({ email, request }) {
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 };
}
Bulk Emails
const { ids, queued } = await email([
{ to: 'jane@acme.com', subject: 'Monthly Report', html: '<p>See attached</p>' },
{ to: 'bob@acme.com', subject: 'Monthly Report', html: '<p>See attached</p>', from: 'reports@acme.com' },
]);
Parameters
Single:
| Parameter | Type | Description |
|---|---|---|
to | string | Required. Recipient email address |
message.subject | string | Required. Email subject line |
message.html | string | HTML email body |
message.from | string | Sender address (defaults to tenant's configured defaultFromAddress) |
Bulk: Array of { to, subject, html, from? } objects.
Delivery
- Emails are queued in
app_messageand sent by the background message dispatcher - Uses the tenant's mail configuration (
server.app.mail.send()) - Failed deliveries are retried up to 3 times
- Requires the tenant to have email configured (SMTP, Gmail, or Microsoft mail)
- Message history is viewable via
GET /apps/{id}/messages
The crypto Object
Provides cryptographic utilities available in the sandbox.
crypto.hmac(algorithm, key, data, encoding?)
Compute an HMAC digest:
const signature = await crypto.hmac('sha256', secret, payload, 'hex');
| Parameter | Type | Default | Description |
|---|---|---|---|
algorithm | string | — | Hash algorithm (sha256, sha512, etc.) |
key | string | — | Secret key |
data | string | — | Data to sign |
encoding | string | 'hex' | Output encoding (hex, base64, etc.) |
The log() Callback
Write structured log entries for debugging and monitoring. In production, log entries are written to the app_log table and associated with the current invocation. In dev mode, they print to the console.
export async function POST({ query, log, request }) {
log('Processing order', { orderId: request.body.id });
try {
const rows = await query('INSERT INTO orders (name) VALUES ($1) RETURNING *', [request.body.name]);
log.info('Order created', { id: rows[0].id });
return { status: 201, body: rows[0] };
} catch (err) {
log.error('Order creation failed', { error: err.message });
return { status: 500, body: { error: 'Internal error' } };
}
}
Methods
| Method | Description |
|---|---|
log(message, data?) | Alias for log.info() |
log.debug(message, data?) | Debug-level entry |
log.info(message, data?) | Info-level entry |
log.warn(message, data?) | Warning-level entry |
log.error(message, data?) | Error-level entry |
| Parameter | Type | Description |
|---|---|---|
message | string | Log message |
data | object | null | Optional structured data attached to the entry |
Handler Config
Export a config object to configure handler behavior:
export const config = {
timeout: 60000, // Handler timeout in ms (default: 30000)
roles: ['editor', 'admin'] // Required roles (any one suffices)
};
export async function POST({ query, request }) {
// Only runs if user has 'editor' or 'admin' role
// ...
}
| Field | Type | Default | Description |
|---|---|---|---|
timeout | number | 30000 | Max execution time in milliseconds |
roles | string[] | [] | Required roles (OR logic — user needs any one) |
Sandbox Constraints
Server route handlers run in an isolated-vm isolate with strict limits:
- No Node.js APIs — no
fs,path,http,process,Buffer, etc. - No network access — only
query(),fetch(),respond(),emit(),notify(),email(), andlog()callbacks btoa()/atob()— standard base64 encode/decode (Latin-1 only)- UTF-8 base64 helpers —
base64Decode(),base64Encode(),base64UrlDecode(),base64UrlEncode()(async, handles multi-byte UTF-8) crypto.hmac(algorithm, key, data, encoding?)— HMAC digest computationmarkdown(text)— convert markdown text to HTML (async, usesmarked)- 128 MB memory limit — isolate is disposed if exceeded
- Timeout enforced — default 30s, configurable via
config.timeout - Ephemeral isolates — a new isolate is created per request (no shared state)
- Pure JavaScript — ES2022 features available, no npm packages at runtime (bundle with esbuild at deploy time)
POST /api/apps/{id}/_deploy
Bundle server routes and run pending migrations. This is typically called automatically by npm run deploy.
Authentication: Required
Permissions Required: Member+ role (write permission)
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
Response:
{
"migrated": ["001-create-orders.sql", "002-add-status.sql"],
"routes": ["GET /orders", "POST /orders", "GET /orders/:id"],
"webhooks": ["POST /stripe"],
"tools": ["send-email", "update-inventory"],
"agents": ["order-processor", "daily-report"],
"refs": {
"datasets": ["admin:sales-data"],
"queries": [],
"datasources": [],
"integrations": ["salesforce"],
"toolkits": ["admin:crm-toolkit"]
}
}
What it does:
- Runs pending SQL migrations (see Persistence)
- Scans
server/directory for authenticated route handlers - Scans
webhooks/directory for public webhook handlers (see Webhooks) - Bundles all handlers with esbuild (IIFE format, stored in
_bundles/) - Rebuilds the route table in the database
- Invalidates route and credential caches
- Extracts widget definitions from
informer.yamlintoapp.defn.widgets - Resolves resource references from
informer.yamlaccesssection into junction tables (app_dataset,app_query,app_datasource,app_integration) — fails with 400 if any referenced resource doesn't exist - Resolves toolkit references from agent definitions into
app_toolkitjunction rows - Scans
tools/directory for tool definitions and bundles them - Extracts agent definitions from
informer.yamland upsertsapp_agentrecords (see Agents)
Error Responses:
404 Not Found- App doesn't exist403 Forbidden- User lacks write permission
Server Route Dispatch
When an app makes a fetch call to a URL containing _server/, the proxy detects the prefix and dispatches to the matching server route handler instead of proxying to a real API endpoint.
How It Works
- Client code calls
fetch('/api/_server/orders')(dev mode) or the view proxy detects_server/prefix - Server strips the
_server/prefix and matches against registered routes - If a route matches, the bundled handler is loaded and executed in an isolate
- The response is returned to the client
Calling from Client Code
In development (Vite dev server):
const response = await fetch('/api/_server/orders');
const data = await response.json();
In production (running inside Informer):
// The view proxy handles _server/ routing automatically
const response = await fetch('/api/_server/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New Order', quantity: 5 })
});
The URL pattern is the same in both environments — the Vite plugin middleware and the Informer view proxy both handle _server/ dispatch.
Full Example: CRUD Orders
Migration
-- migrations/001-create-orders.sql
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Server Routes
// server/orders/index.js
export async function GET({ query }) {
return await query('SELECT * FROM orders ORDER BY created_at DESC');
}
export async function POST({ query, request }) {
const { name, quantity } = request.body;
const rows = await query(
'INSERT INTO orders (name, quantity) VALUES ($1, $2) RETURNING *',
[name, quantity || 0]
);
return { status: 201, body: rows[0] };
}
// server/orders/[id].js
export async function GET({ query, request }) {
const rows = await query('SELECT * FROM orders WHERE id = $1', [request.params.id]);
if (rows.length === 0) return { status: 404, body: { error: 'Not found' } };
return rows[0];
}
export async function PUT({ query, request }) {
const { name, quantity, status } = request.body;
const rows = await query(
'UPDATE orders SET name = COALESCE($1, name), quantity = COALESCE($2, quantity), status = COALESCE($3, status) WHERE id = $4 RETURNING *',
[name, quantity, status, request.params.id]
);
if (rows.length === 0) return { status: 404, body: { error: 'Not found' } };
return rows[0];
}
export async function DELETE({ query, request }) {
const rows = await query('DELETE FROM orders WHERE id = $1 RETURNING id', [request.params.id]);
if (rows.length === 0) return { status: 404, body: { error: 'Not found' } };
return { status: 204 };
}
// server/orders/[id]/approve.js
export const config = { roles: ['approver'] };
export async function POST({ query, request }) {
const rows = await query(
'UPDATE orders SET status = $1, approved_by = $2 WHERE id = $3 RETURNING *',
['approved', request.user.displayName, request.params.id]
);
if (rows.length === 0) return { status: 404, body: { error: 'Not found' } };
return rows[0];
}
Client Code
// List orders
const orders = await fetch('/api/_server/orders').then(r => r.json());
// Create order
await fetch('/api/_server/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Widget', quantity: 10 })
});
// Approve order
await fetch('/api/_server/orders/1/approve', { method: 'POST' });