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 single context bag — request, the typed context, and the service helpers (query, fetch, respond, emit, notify, email, crypto, markdown, extractText, uploads, downloads, log, env). See the Context Object table for the full list.
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 the injected bag helpers (
query,fetch,respond,emit,notify,email,crypto,markdown,extractText,uploads,downloads,log) and the typedcontext
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 |
context | object | Typed bound dependencies — context.<slot>.<method>(...) (see Dependencies) |
respond | (body) => Promise<void> | Send early HTTP response (handler continues running) |
emit | (event, payload) => Promise<void> | Create an app event (can trigger agents) |
broadcast | (channel, event, payload, options?) => Promise<{ok, seq}> | Push a live frame to open pages subscribed to a channel; at most once, no row. seq is the frame's per-channel sequence number; { replay: false } keeps the frame out of the channel's replay buffer (see the Channels guide) |
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 |
extractText | async (data, contentType) => string | Extract plain text from a base64 file (PDF, Excel, Word, text) (see below) |
uploads | object | Resolve a file the browser staged — uploads.get(id) (see below) |
downloads | object | Stage bytes for the browser — downloads.create(opts) (see below) |
log | function | Structured logging (see below) |
request | object | The incoming request (see below) |
env | object | App environment variables |
platform | { version, originMode, capabilities } | What the platform offers: the Informer build version and the app type's capability flags (serverRoutes, webhooks, mcp, storage, embeddings, …). Feature-detect with platform.capabilities.<name> rather than probing for a helper or comparing versions. originMode is true when Apps serve from their own origins, which live channels need. The same object is window.__INFORMER__.platform in the browser. |
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) |
request.apiKey | object | null | { id, name } when an App API key authenticated the call, else null |
request.app | object | This App's identity and addresses (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) |
App Object
| Property | Type | Description |
|---|---|---|
request.app.id | string | App UUID |
request.app.naturalId | string | App natural ID (ownerId:slug) |
request.app.name | string | App name |
request.app.baseUrl | string | The App's view URL on the Informer origin (https://<informer>/api/apps/<naturalId>/view), for links a person opens: scheduled emails, notifications, share messages |
request.app.origin | string | null | The App's own origin (https://<label>.<apps-domain>), or null when the server serves Apps path-mounted or the App has no origin yet. A draft reports its own -draft origin, where its preview runs |
request.app.apiUrl | string | The base an outside caller appends a route to: <origin>/api on an App origin, else the App's own-routes base on the Informer origin (https://<informer>/api/apps/<naturalId>/view/_). The same value as servers[0].url in the App's openapi.json |
apiUrl is what to show a person who will call the App from a script, a scheduler, or another system with an API key:
// server/connect-info.js
export async function GET({ request }) {
return { curl: `curl ${request.app.apiUrl}/orders -H "Authorization: Bearer <key>"` };
}
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;
// A download handle → the staged bytes stream as the response body,
// with the handle's filename and content type (see uploads & downloads)
const dl = await downloads.create({ filename: 'orders.csv' });
await dl.fromQuery('SELECT * FROM orders');
return await dl.end();
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 - A small set of credential-minting endpoints is blocked even when whitelisted (
/api/wsToken,/api/session, the/api/tokenssurface,/api/_impersonate,/api/_sync-user,/api/oauth/authorize,/api/oauth/authorize_device); these answer403 ForbiddenwithAPI endpoint not allowed for this app. See App Runtime.
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) |
message.icalEvent | object | Calendar invite, { method?, content, filename? }. See Calendar Invites. |
Bulk: Array of { to, subject, html, from?, icalEvent? } objects.
Calendar Invites
icalEvent adds a text/calendar part to the email, so Outlook, Gmail and Apple Mail show it as a meeting request with Accept and Decline, rather than as a message with a file to download.
await email('jane@acme.com', {
subject: 'Board meeting',
html: '<p>You are invited to the board meeting.</p>',
from: 'events@acme.com',
icalEvent: { method: 'REQUEST', content: ics, filename: 'invite.ics' }
});
| Field | Type | Description |
|---|---|---|
content | string | Required. The whole iCalendar text (BEGIN:VCALENDAR ... END:VCALENDAR), at most 256KB in UTF-8. A file path or URL is not accepted. |
method | string | REQUEST (default), PUBLISH, CANCEL or REPLY. Match the METHOD: line in content. |
filename | string | Attachment name, up to 64 letters, digits, _, . or -. Defaults to invite.ics. |
email() throws when any field breaks these rules.
For a REQUEST, set ORGANIZER in the iCalendar text to the address the email is sent from. When the two differ, Outlook shows the invite as sent "on behalf of" the organizer or drops Accept and Decline. Pass that address as from. When the tenant's mail settings have Always use the default "From:" address turned on, the email goes out from the tenant's default address instead of from, so ORGANIZER must be that default address.
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
Cryptographic helpers 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.
Hashing & HMAC
| Method | Description |
|---|---|
crypto.hmac(algorithm, key, data, encoding?='hex') | HMAC digest |
crypto.hash(algorithm, data, encoding?='hex') | Plain digest (e.g. sha256, sha512) |
Random
| Method | Description |
|---|---|
crypto.randomUUID() | RFC 4122 v4 UUID |
crypto.randomBytes(length, encoding?='hex') | Cryptographically random bytes (length capped at 1024) |
Comparison & signature verification
| Method | Description |
|---|---|
crypto.timingSafeEqual(a, b) | Constant-time string compare; returns false on length mismatch (never throws). Use instead of === when comparing secrets/signatures. |
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 |
Symmetric encryption (AES-256-GCM)
| Method | Description |
|---|---|
crypto.encrypt(plaintext, key) | Returns a self-describing iv:tag:ciphertext string (base64 parts). key is any passphrase (hashed to a 32-byte key). |
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);
// Round-trip a small secret (e.g. a signed state token)
const token = await crypto.encrypt(JSON.stringify({ userId: 7 }), env.APP_SECRET);
const { userId } = JSON.parse(await crypto.decrypt(token, env.APP_SECRET));
The extractText() Helper
Extract plain text from a base64-encoded file on the host, using the platform's bundled parsers. The isolate has no npm at runtime, so this is the way to read a PDF or Office document into text. The same helper is available in server routes, webhooks, and agent tools, and it is async (the call crosses the isolate boundary), so await it.
const text = await extractText(base64Bytes, 'application/pdf');
| Parameter | Type | Description |
|---|---|---|
data | string | base64-encoded file bytes |
contentType | string | IANA media type (charset params are ignored) |
| Content type | Result |
|---|---|
application/pdf | Extracted page text (empty for a scanned, image-only PDF) |
Excel (.xlsx, .xls) | One CSV block per sheet, under a ### Sheet: <name> heading |
Word (.docx) | Extracted document text |
text/*, application/json, application/csv, application/xml | Returned unchanged |
Any other content type (including images) throws. For a PDF or image you want an agent to read natively as a document rather than as text, return it from a tool with the $file convention instead (see Returning a document or image).
The uploads & downloads Objects
Files and large result sets move through staged byte streams. The bytes never enter the isolate — a handler receives a handle, a plain metadata object whose methods ask the host to move the bytes on its behalf. A 100 MB import therefore costs nothing against the isolate's 128 MB heap, and a million-row export streams from Postgres to the browser without ever being materialized.
Staged streams live in the platform's staging store and expire after config.app.streams.ttlSeconds (1 hour by default). Every method is async.
Both kinds of handle carry:
| Field | Type | Description |
|---|---|---|
id | string | Stream id (UUID) |
filename | string | null | |
contentType | string | null | Inferred from the filename when not supplied |
size | number | Bytes staged |
expiresAt | string | ISO-8601 expiry |
Receiving an upload
The browser stages the file first with __INFORMER__.upload() (see Staged Uploads & Downloads) and passes the resulting id to a server route.
export async function POST({ request, uploads }) {
const upload = await uploads.get(request.body.uploadId);
const { rowCount } = await upload.copyInto('orders', { format: 'csv', header: true });
await upload.discard();
return { status: 201, body: { imported: rowCount } };
}
uploads.get(id) resolves an upload belonging to this app and this user — anything else is a 404, so an id cannot be used to probe for streams outside the app. An upload the client has not yet sealed is a 409.
| Method | Returns | Description |
|---|---|---|
copyInto(table, opts?) | { rowCount } | COPY … FROM STDIN directly into a workspace table |
text(encoding?) | string | Whole upload as text (default utf8) |
json() | any | Whole upload, JSON.parsed |
base64() | string | Whole upload as base64 |
extractText(contentType?) | string | Extract text from a PDF, Excel or Word upload |
discard() | true | Drop the staged bytes now instead of at expiry |
text(), json(), base64() and extractText() read the whole upload out of the staging store, so they refuse anything over config.app.streams.maxInlineBytes (10 MB by default) with a 413 carrying data.maxInlineBytes. copyInto() and query parameters never materialize the upload and are bounded only by maxUploadBytes.
copyInto() runs on the invocation's own workspace connection, so a staging table the same handler created with CREATE TEMP TABLE is visible to it — the usual shape for an upsert import:
export async function POST({ request, uploads, query }) {
const upload = await uploads.get(request.body.uploadId);
await query('CREATE TEMP TABLE staging (LIKE orders INCLUDING DEFAULTS) ON COMMIT DROP');
await upload.copyInto('staging', { header: true });
const [{ count }] = await query(`
INSERT INTO orders SELECT * FROM staging
ON CONFLICT (id) DO UPDATE SET total = EXCLUDED.total
RETURNING 1`);
return { imported: count };
}
copyInto option | Default | Description |
|---|---|---|
format | csv | csv or text |
columns | all columns | Explicit column list |
header | true | CSV only — treat the first line as a header |
delimiter / quote / escape | CSV defaults | Single characters |
null | — | The string that represents NULL |
The table name (optionally schema-qualified) and every column must be plain Postgres identifiers; anything else is rejected rather than escaped.
An upload handle is also a valid query() parameter, binding as bytea. The bytes go from the staging store to Postgres without entering the isolate:
const upload = await uploads.get(request.body.uploadId);
await query('INSERT INTO attachments (name, mime, data) VALUES ($1, $2, $3)',
[upload.filename, upload.contentType, upload]);
Producing a download
downloads.create() stages bytes for the browser. The handle adds a url — a single-use link the page can navigate to for a save-as.
export async function GET({ downloads }) {
const dl = await downloads.create({ filename: 'orders.csv' });
await dl.fromQuery('SELECT id, customer, total FROM orders ORDER BY id');
return await dl.end(); // streams as this response
}
| Method | Returns | Description |
|---|---|---|
write(chunk) | true | Append a string or byte array |
writeRows(rows, opts?) | true | Append rows through a csv / json / jsonl encoder |
fromQuery(sql, params?, opts?) | { rowCount } | Stream a workspace query straight into the download |
end() | handle | Seal it; the returned handle is what you return or respond() with |
discard() | true | Drop the staged bytes |
writeRows and fromQuery take format (csv, json or jsonl — default csv), columns, header, and delimiter. A download created without a contentType takes the encoder's (text/csv, application/json, application/x-ndjson).
A download is bounded by the same maxUploadBytes an upload is. Writing past it fails the call with a 413 and leaves the download unsealed, so a truncated file is never served as a complete one.
There are three ways to deliver one:
// 1. Return it — the staged bytes become the response body
return await dl.end();
// 2. respond() with it, then keep working in the background
const { rowCount } = await dl.fromQuery('SELECT * FROM orders');
await respond(await dl.end());
await query('INSERT INTO export_log (rows) VALUES ($1)', [rowCount]);
// 3. Hand the page a URL to navigate to (survives the handler returning)
return { url: dl.url };
A download's URL is single-use: the staged bytes are destroyed once served, unless the client asks for ?keep=true. A download whose contentType is text/html or image/svg+xml is always sent as an attachment, never rendered — see the download route. Downloads left open when the handler returns are sealed automatically, so background work that keeps writing after respond() still produces a complete file.
Forwarding to an integration
A staged stream can be the body of a context.<slot>.request() call, and an integration's response can land in one — in both cases the host moves the bytes between the staging store and the upstream, and the handler holds only handles.
Outbound — the handle as the body. An upload handle in data is sent as the raw request body; the upstream sees the upload's Content-Type and a Content-Length, unless the call sets its own headers. In form, the handle becomes a file part of a multipart/form-data body and the other fields are sent beside it.
// Raw body — S3 presigned PUT, Drive media upload, GitHub release assets
await context.drive.request({
method: 'POST',
url: '/upload/drive/v3/files?uploadType=media',
data: upload
});
// Multipart — Slack files, Salesforce ContentVersion, Gmail attachments
await context.slack.request({
method: 'POST',
url: '/api/files.upload',
form: { channels: 'C123', title: upload.filename, file: upload }
});
request() returns what it always has — the parsed upstream body — and the upload is not consumed: an upstream failure throws the usual dependency error (its data.upstreamStatus says what the upstream answered) and leaves the bytes staged, so the route or the page can retry with the same id. Call discard() once the push is known to have landed.
The stream describes its own bytes, so Content-Length (and, for form, the generated multipart Content-Type with its boundary) belong to it: setting either of those on the call is a 400 rather than a silent override, because a stale length truncates the body to nothing and a boundary-less multipart type is unparseable upstream. Every other header is yours, including Content-Type for a raw data body when you want to relabel what you are sending.
Inbound — into. Without it, a binary upstream response comes back as a base64 envelope and is read whole into the isolate — there is no size cap on that path, so a large file is a memory cost you pay in the handler. With into, the host streams the upstream body into a staged stream and seals it, request() resolves with the sealed handle instead, and maxUploadBytes bounds it:
// Upstream file → download → the browser
const dl = await downloads.create({ filename: 'statement.pdf' });
await context.sf.request({ method: 'GET', url: '/services/data/v59.0/…/VersionData', into: dl });
return dl;
// Upstream CSV → a fresh upload → COPY into a table
const report = await context.qb.request({ method: 'GET', url: '/v3/…/reports/GeneralLedger?format=csv', into: 'upload' });
await report.copyInto('staging', { format: 'csv', header: true });
await report.discard();
// A body out and a file back: `data` and `into` travel together
await context.sf.request({
method: 'POST',
url: '/services/data/v59.0/analytics/reports/00O…/instances',
data: { reportMetadata: { reportFilters: [...] } },
into: dl
});
into | Fills | Named |
|---|---|---|
| a download handle | that download, which must be unsealed and unwritten; into is terminal — it seals it | by the handle (filename, contentType), falling back to the upstream's |
'upload' | a new upload, returned as a normal upload handle (copyInto(), a bytea parameter, text() under the cap) | after the upstream's Content-Disposition filename, else the last segment of the request path |
An into fill obeys maxUploadBytes: past it the call fails with 413 (data.maxUploadBytes) and never truncates — a download is put back empty and unsealed, so nothing half-written can be served; a fresh upload is discarded. A body that ends short of the Content-Length the upstream declared is refused the same way, with 502 and data.declaredBytes / data.receivedBytes, rather than being sealed as a complete but truncated file. (An into request asks for the bytes uncompressed so that length describes what actually lands; if you set your own Accept-Encoding and the upstream compresses, more bytes may arrive than it declared, and that is not treated as a failure.)
A non-2xx upstream (or one that cannot be reached at all) throws the usual dependency error and the target is released: a download you created is left exactly as it was — empty and unsealed, still yours to end() or discard() — and a fresh 'upload' is discarded, so a failing call costs you nothing against your staging allowance.
A 3xx counts as "no body delivered": it throws 502 with data.upstreamStatus rather than resolving, because a redirect carries no file to stage. Redirects are followed as usual on a plain into pull, but not when the same call is also sending a staged body — a stream body cannot be replayed onto the redirect target — so address the final URL yourself in that case.
Forwarding works for target: integration slots. fetch() and target: app slots do not take handles.
Errors
Stream failures arrive as real errors with the status and payload intact, so a handler can branch on them:
try {
await upload.copyInto('orders');
} catch (err) {
if (err.statusCode === 413) return { status: 413, body: { limit: err.data.maxInlineBytes } };
throw err;
}
| Status | Meaning |
|---|---|
400 | An into target that is neither a download handle nor 'upload', a download handle sent as the request body, or a header the stream owns set by the caller |
404 | No such stream, or it belongs to another app or user |
409 | The upload has not been sealed by the client yet, or an into target is already complete or already holds bytes |
412 | Sealing found missing chunks — data.missing lists them |
413 | Over maxUploadBytes (upload or download), maxChunkBytes, or maxInlineBytes |
422 | A chunk's number or length disagrees with the declared geometry |
429 | Over maxStreamsPerUser — data.maxStreamsPerUser |
502 | An into upstream delivered a short body (data.declaredBytes / data.receivedBytes) or no body at all, such as a redirect (data.upstreamStatus) |
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 the injected bag helpers:
query(),fetch(),respond(),emit(),notify(),email(),log(),crypto,markdown(),extractText(),uploads,downloads, and the typedcontext 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,hash,randomUUID,randomBytes,timingSafeEqual,verifyHmac,encrypt/decrypt(AES-256-GCM),verify(all async — see ThecryptoObject)markdown(text)— convert markdown text to HTML (async, usesmarked)extractText(data, contentType): extract plain text from a base64 file (PDF, Excel, Word, text); throws on unsupported types (async)uploads/downloads— staged byte streams that move files and large result sets without loading them into the isolate (see Theuploads&downloadsObjects)- 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)
Request Body Constraints
Server route handlers take JSON bodies only. A multipart/form-data
request to a _server/ path is refused with 415 Unsupported Media Type
(Server routes do not accept multipart bodies); there is no sandbox
representation for a raw multipart buffer, so it is rejected at the gateway
rather than passed in. To accept a small file, base64-encode it into a JSON field
and decode with base64Decode(). For anything larger — and for anything
headed into a table — have the browser stage it with __INFORMER__.upload()
and pass the handler only the upload id, so the bytes bypass both the JSON
body and the isolate entirely (see The uploads & downloads
Objects).
The same rule reaches the whitelist proxy wherever a data-access config must
be applied: a filtered dataset search or a configured integration request
whose body is multipart, a JSON array, or plain text answers 415. Those
controls work by inspecting and rewriting the JSON body, so a body the gateway
cannot inspect cannot be allowed past it. Unfiltered whitelist paths still
pass any body through untouched.
Dispatch also enforces the App's compute budget: a request arriving after
the budget is exhausted is refused with 402 Payment Required before the
handler is loaded. A marketplace App whose paid license has lapsed past its
grace period gets the same 402.
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: app:write (Designer for Magic Reports, Admin
for full Apps)
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | string | App UUID or natural ID |
Request Body: optional.
| Field | Type | Default | Description |
|---|---|---|---|
managed | boolean | false | Claim the App as platform-managed (origin: "deployed") on a successful deploy |
stagingLibraryId | string | none | Id returned by POST /files/_stage. Presenting it authorises this deploy to promote that staged upload |
Presenting stagingLibraryId is what promotes a staged upload. Without it the
deploy runs against the App's live library and leaves any staged upload exactly
where it is, which matters because this route is not the only way a deploy runs:
lazy deploy on first view, snapshot restore, modernize and draft commit all reach
the same pipeline. If any of those promoted whatever happened to be staged, a CLI
deploy that died part way through its upload would have its incomplete file set
published, and the complete live build discarded, by an unrelated request.
The id must still be the App's current staged upload. Staging is never taken
over, so a superseded id can only come from a deploy whose staged upload was
explicitly discarded (and another staged in its place) while it was uploading;
presenting it gets 409 rather than promoting a library that now holds a
different build. Replaying an already-consumed id gets 409 for the same reason.
The claim is re-checked under a row lock once the deploy's transaction opens, so
a staged upload that is superseded, emptied, or whose App had its library
repointed (by a draft commit, say) during the deploy also answers 409, with
nothing promoted and the live build untouched.
The capability and manifest license gates inspect the staged library when one is
claimed, so a staged webhooks/ or migrations/ directory is refused on an App
type that does not permit it. Gating the live library instead would let staged
content past a check it never faced.
The claim is opt-in because this route is not CLI-only. The Redeploy button
in the App's settings panel and the builder's Save both post here against a
live App, and an unconditional claim would convert user-built Apps to managed.
Since POST /_edit refuses managed Apps unconditionally, that is
a one-way trip out of the builder. Callers that omit the flag leave origin
untouched.
The claim applies only to an App still at origin: "ui". An App already at
"deployed" stays there, and one at "marketplace" is left alone: a
marketplace install stays bound to its listing for version upgrades, and the
App model rejects any write that moves a row off origin: "marketplace".
Deploying against either still runs the full pipeline and returns 200.
The stamp is applied after the deploy succeeds, never before. This route has no wrapping transaction and the deploy owns its own, so a claim written first would survive a failed deploy and strand an editable App as managed.
Releasing a managed App is POST /_unmanage.
{
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/_deploy"
}
},
"migrated": [
"001-create-orders.sql"
],
"routes": [
"GET /orders"
],
"webhooks": [],
"routeShadowWarnings": [],
"areas": [],
"ownedIntegrations": [],
"refs": {
"access": {
"datasets": [],
"queries": [],
"datasources": [],
"integrations": []
},
"dependencies": [],
"warnings": []
},
"tools": [],
"mcpTools": [],
"agents": [],
"automations": [],
"environment": [],
"routeChecks": [
{
"method": "GET",
"path": "/orders",
"status": 200,
"ok": true,
"durationMs": 42
}
]
}
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 - Extracts the
areas:block frominformer.yamlintoapp.defn.areas(unusable names are skipped and named inpartialFailures, never fatal) - Resolves resource references from
informer.yaml(dependencies:slots and theaccess:section) intoapp_entityrows — 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) - Smoke-tests parameterless GET routes by executing each once (results
attach to the response as
routeChecks; these executions show up in the invocation history like real calls). Opt a route out withexport const config = { smokeTest: false }
The response also carries routeShadowWarnings: one message per server route
that a whitelisted access.apis entry in informer.yaml would match at bare
/api, so bare calls reach the platform API and never the app route. The lint
is advisory (the array is empty when nothing collides and the deploy succeeds
either way); rename the route to make it reachable.
When a staged upload was promoted, the response carries stagedLibraryId:
the id this deploy consumed.
When the App had a staged upload and this deploy did not claim it, the
response instead carries stagingIgnored, an object with the stagingLibraryId
and stagedAt of the upload that was left alone:
{
"stagingIgnored": {
"stagingLibraryId": "a1b2c3d4-5678-90ab-cdef-123456789abc",
"stagedAt": "2025-03-04T18:22:41.118Z"
}
}
The deploy itself succeeds; it just ran against the live library and published
nothing from the staged upload. It is reported in the response rather than only
in the server log because every non-CLI deploy path trips it, and a log line is
something no author or CI job ever reads. Seeing it means an earlier deploy left
an upload staged: the App is still refusing content writes, so discard it with
DELETE /files/_stage or re-run the
deploy that owns it.
Error Responses:
400 Bad Request- The app has no associated library, or a handler failed to scan or bundle403 Forbidden- User lacks write permission, or the staged build uses a capability the app type does not permit404 Not Found- App doesn't exist409 Conflict- The presentedstagingLibraryIdis not the app's current staged upload, the staged upload is empty, or the app's library changed while the deploy was starting
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.
Server-side, the canonical dispatch path is
/api/apps/{id}/view/_/{path*} (all methods); the legacy
/view/api/_server/{path*} alias remains supported:
{
"orders": [
{
"customer": "Around the Horn",
"total": 642.5
},
{
"customer": "Berglunds snabbkop",
"total": 1208
}
]
}
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' });