Skip to main content

JavaScript Environment

Functions execute JavaScript in serverless containers. This page covers the execution environment, available objects, and best practices.

Two Function Forms​

A function is written in one of two forms — and you must pick exactly one per file. They are not interchangeable, and you must never mix them. Both forms expose the same platform objects — item, res, req, me, api, secrets, validationErrors, collectionName, the data-access helpers (get/post/put/patch/del, user.*), and the loggers (log/logInfo/logWarn/logError). The only difference is how you reach them:

  • Script form — those objects are bare globals. There is no export default.
  • Default-export form — those objects are members of the ctx argument: ctx.item, ctx.res, etc.

The two examples below are the same function written each way. The platform objects map 1:1: a bare global x in script form is ctx.x in the default-export form.

Standard JavaScript globals are not on ctx

fetch, console, JSON, Date, Math and other built-in JavaScript globals are available directly in both forms — they are not members of ctx. Call fetch(...), never ctx.fetch(...). Only the platform objects listed above move onto ctx in the default-export form.

Only the default-export form can be described

The platform reads an HTTP function's request and response types straight out of its code and publishes them to /api/_openapi and types.d.ts. That needs a signature to read, so it works for the default-export form only — a script-form file is described as an opaque object unless you declare its types explicitly. See Typed Parameters.

Script form (bare globals)​

// No export default. Every object is a bare global.
log('Request from:', me?.name ?? 'anonymous');

const existing = await get(`/products?filter=sku eq "${req.query.sku}"`);

if (existing.data.length > 0) {
validationErrors.push('SKU already exists'); // non-empty array → 400
} else {
res = {
status: 201,
bodyJson: { ok: true, hasKey: Boolean(secrets?.UPSTREAM_KEY) },
};
}

Default-export form (everything through ctx)​

import type { FunctionContext } from '../types';

// `../types` resolves to the generated types.d.ts at your project root.
// This is exactly what `rat` scaffolds for a new TypeScript function.
export default async function (ctx: FunctionContext) {
ctx.log('Request from:', ctx.me?.name ?? 'anonymous');

const existing = await ctx.get(`products?filter=sku eq "${ctx.req.query.sku}"`);

if (existing.data.length > 0) {
ctx.validationErrors.push('SKU already exists');
} else {
ctx.res = {
status: 201,
bodyJson: { ok: true, hasKey: Boolean(ctx.secrets?.UPSTREAM_KEY) },
};
}

return ctx.item; // return a value to replace the item; return nothing to leave it unchanged
}
Never mix the two forms

Pick one form per file and commit to it. Do not combine export default with bare-global assignments.

The moment a file has an export default, the bare globals are dead. Assigning a bare global res, item, validationErrors (or referencing bare me, secrets, req, get, …) — whether at the top level or inside the exported function — has no effect and is silently discarded. The single most common mistake is writing export default (ctx) => { ... } and then setting res = {...} instead of ctx.res = {...}: the response is lost and the function appears to do nothing.

For clarity the rest of this page documents each platform object in script form (bare global). In the default-export form, reach the identical object via ctx. — e.g. res → ctx.res, get(...) → ctx.get(...), secrets → ctx.secrets. (Standard JavaScript globals like fetch, console, and JSON are the exception — always call them directly, never ctx.fetch.)

Type-checking catches the mistake before deploy

In the typed default-export form this slip is a compile error, not a silent failure: a bare res = {...} inside the function reports Cannot find name 'res'. Type-check your functions with rat lint before deploying and the mix can't get through — whereas at runtime the stray assignment is simply discarded with no error.

Available Objects​

Core Objects​

ObjectDescriptionAvailable In
itemThe current record being created, updated, or deletedTrigger functions
meCurrent authenticated userAll functions (when authenticated)
apiInformation about the APIAll functions
secretsConfigured secrets (key/value pairs)All functions
reqIncoming HTTP request detailsTrigger & HTTP functions
resResponse object for custom responsesHTTP functions
collectionNameName of the collection being processedTrigger functions
validationErrorsArray for validation errors (returns 400 if non-empty)All functions

The item Object​

For trigger functions, item contains the record being processed:

// Access item properties
log('Processing:', item.name);
log('Item ID:', item.id);

// Modify the item (for validation/transformation)
item.slug = item.title.toLowerCase().replace(/\s+/g, '-');

The me Object​

Information about the current user:

PropertyDescription
idUser ID (GUID)
emailUser email
nameUser display name
roleIdsArray of role IDs assigned to the user
log('Request by:', me.name);
log('User email:', me.email);
log('User roles:', me.roleIds);

The api Object​

Information about the current API:

PropertyDescription
idAPI ID (GUID)
nameAPI name
tenantIdTenant ID (GUID)
log('Running in API:', api.name);
log('Tenant:', api.tenantId);

The req Object​

Request details (trigger and HTTP functions):

PropertyDescription
methodHTTP method (GET, POST, PUT, PATCH, DELETE)
headersRequest headers (filtered for security)
queryQuery parameters
bodyRaw body string
bodyJsonParsed JSON body
// Access request data
const method = req.method;
const contentType = req.headers['content-type'];
const page = req.query.page || 1;
const payload = req.bodyJson;

The res Object (HTTP Only)​

Set custom responses in HTTP functions:

PropertyDescription
statusHTTP status code (default: 200)
headersResponse headers
bodyJsonJSON response body
bodyPlain text response body
// Return custom response
res = {
status: 201,
headers: { 'X-Custom-Header': 'value' },
bodyJson: { success: true, id: newItem.id }
};

Real-Time Channels​

Functions can manage private channels (channels with : in the name) using the built-in HTTP methods:

// Add client to a private channel with send permission
await post('/_rt/join', {
connectionId: req.bodyJson.connectionId,
channel: 'room:123',
readOnly: false // Client can send messages
});

// Add client as read-only listener
await post('/_rt/join', {
connectionId: req.bodyJson.connectionId,
channel: 'announcements:system',
readOnly: true // Client can only receive
});

// Send message to any channel
await post('/_rt/send', {
channel: 'announcements:system',
body: { type: 'notification', text: 'New update available' }
});

// Call a method on the SignalR client in a private channel and get its return value
const reply = await post('/_rt/invoke', {
channel: 'agent:erp',
method: 'query',
body: { sql: 'SELECT 1' }
});

Private channels (with :) can only be joined/messaged by functions. The readOnly parameter controls whether the client can send messages after joining. For /_rt/invoke, see Invoking a Client.

See Real Time for channel types and client-side usage.

Built-in HTTP Methods​

Functions have built-in async methods for API calls:

MethodDescription
get(url)GET request to API endpoint
post(url, data)POST request
put(url, data)PUT request
patch(url, data)PATCH request
del(url)DELETE request
// Get all active products
const products = await get('/products?filter=status eq "active"');

// Create a new order
const order = await post('/orders', {
customerId: item.customerId,
total: item.total
});

// Update a record
await patch(`/customers/${item.customerId}`, {
lastOrderDate: new Date().toISOString()
});

// Delete a record
await del(`/temp-items/${item.id}`);

These methods automatically include authentication. Paths are root-relative within your API and must start with a forward slash — get('/products'), not get('products').

Never Call a Function From a Function​

A function must never invoke another function — including itself:

  • Don't call an HTTP function's endpoint from function code — not with the built-in methods, not with fetch.
  • Don't write to a collection in order to make its trigger function run. Writes made from function code using the tokens the platform provides do not fire trigger functions — this is deliberate, and it is what makes patterns like cascade operations safe.
  • Don't send unauthenticated requests to your own API from a function.

Function-to-function calls can produce infinite invocation loops that burn through your API's resources. They also make behavior hard to reason about: each hop has its own token, log and context — no single trace, no shared transaction, no type checking across hops.

Share logic with modules instead​

File-based functions bundle the workspace's files, so shared logic lives in a plain TypeScript file and gets imported — in-process, type-checked, no HTTP. The example uses the typed default-export form (module imports require a file-based function anyway); DataFunction — a typed signature for default exports — and collection types like Order come from the generated types file:

// functions/lib/pricing.ts
export function totalWithVat(net: number): number {
return Math.round(net * 1.25 * 100) / 100;
}

// functions/order-created.ts
import type { DataFunction, Order } from '../types';
import { totalWithVat } from './lib/pricing.ts';

const fn: DataFunction<Order> = async (ctx) => {
ctx.item.total = totalWithVat(ctx.item.net);
return ctx.item;
};
export default fn;
File-based functions only

Imports need the file bundle. An inline function — code typed directly in the portal editor without a file name — cannot import; save it as a file first.

If the same rule must run for several collections, attach a thin function to each and let them import the shared module — instead of one function calling another.

Two Authentication Contexts​

Functions expose two parallel families of HTTP helpers, each backed by a different bearer token:

FamilyMethodsIdentity used
Built-inget, post, put, patch, del, getFileBase64, postFileBase64The function's configured Token Options (None / Current User / Service Account)
User-contextuser.get, user.post, user.put, user.patch, user.delete, user.getFileBase64, user.postFileBase64Always the original triggering user, regardless of Token Options

The two slots are populated independently. Pick the family that matches the identity you actually want the request made under:

// Runs as whatever Token Options is set to on this action.
const result = await get('/orders');

// Runs as the user who triggered this function — independent of Token Options.
const userOrders = await user.get('/orders');

When user.* has no token​

The user.* family relies on a real triggering user being present. If a function runs without an authenticated caller — most commonly a timer function or any other server-initiated execution — the user-context slot is empty, and user.* requests go out with no Authorization header. The downstream API treats them as anonymous, which usually means a 401 against any non-public endpoint.

This is the most common cause of "my timer function gets 401 even though I configured a Service Account":

// Timer function configured with Token Options = Service Account.

// ❌ Wrong — `user.*` ignores Token Options. There is no triggering user
// for a timer, so this goes out unauthenticated and gets 401.
await user.get('/orders');

// ✅ Right — the built-in family uses the configured Service Account.
await get('/orders');

For trigger and HTTP functions invoked by a real user, user.* does carry that user's identity, and is useful when you want to enforce that user's access permissions inside your function logic.

Token Options and the built-in family​

The built-in get/post/etc. use whichever identity was selected on the function action:

  • None — no Authorization header sent. Works only against endpoints that allow anonymous access.
  • Current User — the user who triggered the function. For server-initiated runs there is no triggering user, so this resolves to no token.
  • Service Account — a token minted for the configured service account. Works for both user-triggered and server-triggered runs.

The action's Skip Security Policy and Skip Roles flags, if set, are baked into the token used by both families — they make the configured identity bypass row-level access and role checks, but they do not change which identity is used.

See Trigger Functions and Timer Functions for where to configure these.

External HTTP Requests (fetch)​

Use fetch for external API calls:

const response = await fetch('https://api.example.com/data', {
method: 'POST',
headers: {
'Authorization': `Bearer ${secrets.EXTERNAL_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ key: 'value' })
});

const data = await response.json();
Free Tier

The fetch function for external HTTP requests is available on all tiers, including the free tier.

File Operations​

MethodDescription
getFileBase64(url)Get file as Base64 string
postFileBase64(url, fileName, content, mimeType)Upload Base64 file
// Download a file
const fileContent = await getFileBase64(`products/${item.id}/image`);

// Upload a file
await postFileBase64(
`products/${item.id}/thumbnail`,
'thumbnail.jpg',
resizedImageBase64,
'image/jpeg'
);

Sequences​

Use getSequence(sequenceName) to generate atomic, auto-incrementing numbers. Each call returns the next value in the named sequence, guaranteed to be unique even under concurrent execution.

The sequenceName argument is required — it identifies which sequence to advance. Sequences with different names are independent, so each kind of number gets its own counter.

const orderNumber = await getSequence('orders');
item.orderNumber = orderNumber; // e.g., 1, 2, 3, ...

const invoiceNumber = await getSequence('invoices'); // independent counter

This is useful for:

  • Order numbers — Human-readable sequential IDs
  • Invoice numbers — Sequential numbering for accounting
  • Ticket numbers — Support ticket or queue numbers
  • Any sequential identifier — When UUIDs aren't appropriate
Atomic Guarantees

getSequence(sequenceName) uses database-level sequencing, ensuring no duplicates even when multiple function instances run concurrently.

Secrets​

Secrets are securely stored credentials injected into your function.

Configuring Secrets​

  1. Create secrets in Settings → Secrets
  2. Select which secrets to inject when configuring your function
  3. Access them via the secrets object

Using Secrets​

// Access secrets
const apiKey = secrets.STRIPE_API_KEY;
const webhookSecret = secrets.WEBHOOK_SECRET;

// Use in external calls
const response = await fetch('https://api.stripe.com/v1/charges', {
headers: {
'Authorization': `Bearer ${secrets.STRIPE_SECRET_KEY}`
}
});
warning

Never log secrets or include them in error messages.

Logging​

FunctionSeverity
log(...args)Info (multiple arguments supported)
logInfo(message)Info
logWarn(message)Warning
logError(message)Error
log('Processing started');
log('Item:', item.name, 'Status:', item.status); // Multiple args
logInfo('Single info message');
logWarn('Customer has pending balance');
logError('Failed to sync with external service');

Logs include timestamps and milliseconds from execution start.

Context by Function Type​

Trigger Functions​

// Available: item, me, api, secrets, req, collectionName, validationErrors
log('Processing item:', item.id);
log('Collection:', collectionName);
log('Triggered by:', me.name);
log('Method:', req.method);

// Validate
if (!item.title) {
validationErrors.push('Title is required');
}

// Modify item before save
item.updatedBy = me.id;

Timer Functions​

// Available: api, me (service account), secrets
// No item or request context
log('Running scheduled task for API:', api.name);

const ordersRes = await get('/orders?filter=status eq "pending"');
for (const order of ordersRes.data) {
// Process each order...
}

HTTP Functions​

// Available: req, res, me, api, secrets
const input = req.bodyJson;
const result = processData(input);

res = {
status: 200,
bodyJson: { success: true, result }
};

Error Handling​

Validation Errors​

Use validationErrors to return user-facing errors. If this array has any items when the function completes, the operation returns 400 Bad Request with the errors in the response body.

if (!item.email) {
validationErrors.push('Email is required');
}

if (item.price < 0) {
validationErrors.push('Price cannot be negative');
}

// Multiple errors are returned together

Response when validation fails:

["Email is required", "Price cannot be negative"]

Runtime Errors vs Validation Errors​

TypeExampleReturned to ClientLogged
Validation errorsvalidationErrors.push('...')Yes (400 response)Yes
Runtime errorsundefined.foo, failed fetchNoYes

Runtime errors (like undefined variables or failed external requests) are only logged server-side and not exposed to the client. Use validationErrors for errors you want users to see.

Try-Catch​

Handle external failures gracefully:

try {
const external = await fetch(externalUrl);
if (!external.ok) {
logError('External API returned: ' + external.status);
validationErrors.push('Unable to verify with external service');
}
} catch (error) {
logError('External call failed: ' + error.message);
// Decide: add to validationErrors or let it proceed?
}

Filtered Headers​

For security, these headers are removed from req.headers:

  • authorization, cookie, host
  • x-forwarded-*, x-azure-*, x-arr-*
  • Other infrastructure headers

Best Practices​

  • Use item not body — For trigger functions, the record is in item
  • Use built-in methods — get(), post(), etc. handle auth automatically
  • Use validationErrors for user-facing errors — These are returned in the response
  • Handle external failures with try-catch — Decide whether to block or continue
  • Log important operations — Aids debugging and auditing
  • Keep secrets secure — Never expose in logs or responses