Skip to main content

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(), and email())

Routing Convention

Files in server/ map to routes based on their path. Dynamic segments use bracket syntax [param]:

File PathRoute PathURL
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:

PropertyTypeDescription
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)
cryptoobjectCryptographic helpers (see below)
markdownasync (text) => stringConvert markdown text to HTML using marked
logfunctionStructured logging (see below)
requestobjectThe incoming request (see below)
envobjectApp environment variables

Request Object

PropertyTypeDescription
request.methodstringHTTP method (GET, POST, etc.)
request.pathstringRoute path (e.g., /orders/:id)
request.paramsobjectPath parameters (e.g., { id: '123' })
request.queryobjectQuery string parameters
request.bodyanyParsed request body (JSON)
request.headersobjectRequest headers
request.rolesstring[]Current user's resolved roles
request.userobjectCurrent user identity (see below)

User Object

PropertyTypeDescription
request.user.usernamestringLogin username
request.user.displayNamestringUser's display name
request.user.emailstring | nullEmail address
request.user.timezonestring | nullTimezone (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 AUTHORIZATION for 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:

ParameterTypeDescription
pathstringAPI path (with or without /api/ prefix)
opts.methodstringHTTP method (default: GET)
opts.bodyobjectRequest 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:

ParameterTypeDescription
eventstringEvent name (must match an agent's on trigger)
payloadobjectData 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:

ParameterTypeDescription
usernamestringInformer username to notify
message.titlestringRequired. Notification title
message.bodystringNotification body text
message.pathstringDeep link path within the app (e.g., /orders/123)
message.dataobjectAdditional custom data (values are coerced to strings)

Bulk: Array of { username, title, body, path?, data? } objects.

Delivery

  • Messages are enqueued with status pending and 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 dead status
  • 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:

ParameterTypeDescription
tostringRequired. Recipient email address
message.subjectstringRequired. Email subject line
message.htmlstringHTML email body
message.fromstringSender address (defaults to tenant's configured defaultFromAddress)

Bulk: Array of { to, subject, html, from? } objects.

Delivery

  • Emails are queued in app_message and 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');
ParameterTypeDefaultDescription
algorithmstringHash algorithm (sha256, sha512, etc.)
keystringSecret key
datastringData to sign
encodingstring'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

MethodDescription
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
ParameterTypeDescription
messagestringLog message
dataobject | nullOptional 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
// ...
}
FieldTypeDefaultDescription
timeoutnumber30000Max execution time in milliseconds
rolesstring[][]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(), and log() callbacks
  • btoa() / atob() — standard base64 encode/decode (Latin-1 only)
  • UTF-8 base64 helpersbase64Decode(), base64Encode(), base64UrlDecode(), base64UrlEncode() (async, handles multi-byte UTF-8)
  • crypto.hmac(algorithm, key, data, encoding?) — HMAC digest computation
  • markdown(text) — convert markdown text to HTML (async, uses marked)
  • 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:

ParameterTypeDescription
idstringApp 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:

  1. Runs pending SQL migrations (see Persistence)
  2. Scans server/ directory for authenticated route handlers
  3. Scans webhooks/ directory for public webhook handlers (see Webhooks)
  4. Bundles all handlers with esbuild (IIFE format, stored in _bundles/)
  5. Rebuilds the route table in the database
  6. Invalidates route and credential caches
  7. Extracts widget definitions from informer.yaml into app.defn.widgets
  8. Resolves resource references from informer.yaml access section into junction tables (app_dataset, app_query, app_datasource, app_integration) — fails with 400 if any referenced resource doesn't exist
  9. Resolves toolkit references from agent definitions into app_toolkit junction rows
  10. Scans tools/ directory for tool definitions and bundles them
  11. Extracts agent definitions from informer.yaml and upserts app_agent records (see Agents)

Error Responses:

  • 404 Not Found - App doesn't exist
  • 403 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

  1. Client code calls fetch('/api/_server/orders') (dev mode) or the view proxy detects _server/ prefix
  2. Server strips the _server/ prefix and matches against registered routes
  3. If a route matches, the bundled handler is loaded and executed in an isolate
  4. 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' });