Cloud functions

Deploy server-side JavaScript functions that run next to your data, with direct access to collections, SQL and key-value stores.

Docs menu

Each app's functions run isolated from every other app's. The commands below use an app key in $SUBSTRIO_KEY and the server in $SUBSTRIO_URL; quickstart.md shows how to get both.

Deploying a Cloud Function

function.deploy (POST /v1/functions) takes the name and the source:

jq -n --rawfile script my_func.js '{name: "my_func", script: $script}' \
  | curl -s -X POST $SUBSTRIO_URL/v1/functions -H "Authorization: Bearer $SUBSTRIO_KEY" \
      -H "Content-Type: application/json" -d @-

With the CLI, substrio deploy bundles and uploads every file in the project's functions/ directory, and substrio push does the same into the dev sandbox; see Using npm packages for what "bundles" means. Routes, main and vars per function are set in substrio.toml (project-files.md).

Replacing one function

substrio deploy NAME... builds and sends only the named functions, through the same bundling step as a full substrio deploy: a function with npm imports is still bundled into one file first, so this is also how you replace a function that imports a package without deploying anything else. Nothing else is touched: no collection, role, auth provider, rule, MCP tool or plan is read or sent, and the rest of functions/ is left alone.

substrio deploy my_func              # replace one function
substrio deploy my_func other_func   # replace several, in the order given
substrio deploy my_func --dry-run    # show what would be sent, without sending it

Each name must be a function the project declares (substrio.toml's [functions.NAME]) or has a file for in functions/; an unknown name fails before anything is sent. --prune (a whole-project decision about collection drift) and --functions/--assets (ambiguous once names are given) are refused together with names.

Routes, main and vars come from substrio.toml exactly as in a full deploy. Every deploy whose source differs moves the function deploy counter on; see History below.

A function replaced this way sits outside any version; substrio promote can silently overwrite or delete it. See Versions and functions deployed on their own.

History

Every deploy whose source differs from what is currently deployed (substrio deploy, function.deploy, a version promote) moves the function's deploy counter on by one; redeploying byte-for-byte the same source does not. That counter is what function.errors reports and filters on, so the throws of one deploy are one page.

The source of a past deploy is in the app's branch history, where every commit holds the whole app and shares every unchanged byte with its parent:

substrio op version.log path=functions/my_func.js         # the commits that changed it
substrio op version.unit version=ID path=functions/my_func.js   # its source in one of them

Going back is one more commit (version.commit with that source, then version.live.set to make it live), which keeps the history forward only: nothing is rewritten and no past source is ever aged out.

Calling a Cloud Function

function.invoke (POST /v1/functions/{name}/_invoke) passes the JSON body to the function as its event:

curl -s -X POST $SUBSTRIO_URL/v1/functions/my_func/_invoke -H "Authorization: Bearer $SUBSTRIO_KEY" \
  -H "Content-Type: application/json" -d '{"userId": "42"}'

With the CLI: substrio invoke my_func --data '{"userId": "42"}'.

Writing Cloud Functions

What a function can call and what limits it is described once, in the runtime guide (runtime/index.md; the typed names are GET /runtime.d.ts). This page does not repeat it:

A minimal function:

(function (event) {
    // event is the parsed JSON body of the invoke request
    const found = db.collection("users").find({ age: { $gte: 18 } });
    return { ok: true, count: found.length, userId: event.userId };
})

The return value is sent as the JSON response.

event.rawBody is a Uint8Array of the request body exactly as received, before JSON parsing, for verifying a webhook signature (Stripe, GitHub: an HMAC over the bytes as sent, which event.body can no longer give you once it has been parsed):

(function (event) {
    const digest = require("node:crypto")
        .createHmac("sha256", env.WEBHOOK_SECRET)
        .update(event.rawBody)
        .digest("hex");
    if (digest !== event.signature) throw new Error("bad signature");
    return { ok: true };
})

Imports between files

A function shipped in a version (version.commit, version.create or substrio push, then version.live.set) imports the version's other files by relative path, with no bundler. The server resolves each import against the importing file's path in the version, so an agent that has only MCP tools can split an app into as many files as it likes:

// functions/api.js
import { rows } from "./lib/db.js";
export default { fetch() { return new Response(rows()); } };

// functions/lib/db.js
import { tag } from "../shared/util.js";
import routes from "./routes.json" with { type: "json" };
import { extname } from "node:path";
export function rows() { return tag("db") + " " + routes.paths.join(",") + extname("a.txt"); }
  • A specifier that starts with ./ or ../ names a file of the same version. It is looked for as written, then with .js, then as a directory's index.js.
  • with { type: "json" } imports a JSON file as its parsed value.
  • A builtin (node:path, substrio:env, see builtin modules) can be imported from any file, not only the function's own.
  • Two files may import each other: ES modules allow cycles and the loader links them.
  • A function is declared by functions/<name>.json (its entry point, routes and vars) directly in functions/, and its source is functions/<name>.js. Every other .js file anywhere in the version (functions/lib/db.js, shared/util.js at the root) is a module: it runs only when a function imports it, and is never a function of its own. A functions/<name>.json without its .js is refused.
  • Each function that loads gets its own instance of every file it imports.

Imports are resolved when the function loads (once per worker per promote), never per request: a load reads each directory on the way once, then each imported file once, and parses it once. A request pays nothing for them.

function.check, version.check and version.live.set follow every import before anything ships, and refuse with a diagnostic per import that cannot load:

Code When Diagnostic fields
MODULE_NOT_FOUND the version holds no file at the path an import names (a bare name: see packages in a version) path (the importing file), specifier, tried (every path looked for, in order)
IMPORT_OUTSIDE_PROJECT a ../ climbs above the version's root path, specifier
IMPORT_NEEDS_VERSION a function deployed outside any version (function.deploy, function.check of a lone source) imports a file: it has no files to import; ship it in a version path (the function), specifier

A file of the graph that does not parse, or a JSON import that is not JSON, is a diagnostic naming that file in path. A load that meets the same problem (a function promoted with check: false) throws an error whose name and code are the same word and whose message names the importing path and the specifier.

Using npm packages

There are two ways to use a package: the version holds it (below, with nothing but MCP tools), or the CLI bundles it into the function before upload (after that).

Packages in a version

A bare import that names no builtin module (import { h } from "preact", import { useState } from "preact/hooks", import x from "@scope/pkg/dist/x.js") is a package of the version. The version holds each package flat at its root, node_modules/<name>/ with its package.json, and every dependency beside it, so a name has one version in a version. package.add fetches a package and what it depends on from the npm registry and commits them in one commit; package.remove takes one out.

A bare import loads:

  • node_modules/<name>/package.json, read once per load (never per request);
  • the entry its "exports" names for the import's subpath, taking the conditions import, module and default in the object's own order (nested conditions, arrays and "./*" patterns as Node takes them). A target outside the package is not taken;
  • with no "exports": "module", then "main", then index.js for the package itself; a deep import (pkg/dist/x.js) is that file;
  • the files it imports, by relative specifier inside the package and by bare name for its dependencies, the same way.

Only ES modules load, by the package's own declaration (no file is parsed to guess): an import or module condition, the "module" field, a .mjs file, or "type": "module" say ESM. A .cjs file, or a .js entry of a package without "type": "module" reached by "main", default or a deep path, is CommonJS and refused. package.add refuses such a package before anything is committed, so a version never holds one it cannot load.

function.check and version.check walk the same graph and name each refusal:

code when
PACKAGE_NOT_FOUND the version holds no node_modules/<name>/package.json; tried names it
PACKAGE_NOT_EXPORTED "exports" names no entry for the subpath
PACKAGE_NOT_ESM the entry is CommonJS; tried names the entry
PACKAGE_MANIFEST_INVALID the package's package.json is not a JSON object
MODULE_NOT_FOUND the entry package.json names is not in the package; tried names it

In a function deployed outside a version, a bare name that is no builtin is MODULE_NOT_FOUND: no package can be there.

Where packages come from

package.add fetches from the registry the server setting package_registry_origin names, such as https://registry.npmjs.org (an origin: scheme, host, optional port, no path). It is empty by default, and then package.add is refused PACKAGE_REGISTRY_UNSET: no registry is configured. One add:

  1. picks the release: no version is dist-tags.latest; a dist-tag (next), a version or a range (^10, ~1.2, >=1 <2, 1.x, 1.2.3 - 2, ||, as npm reads them) takes the highest published version meeting it, and a prerelease only when the range names one of the same version;
  2. opens its dependencies and its peerDependencies that are not optional, each by one metadata GET. A name the version already holds, or one this add already picked, is reused when its version meets the new range; when it does not, the add is refused PACKAGE_VERSION_CONFLICT, naming both;
  3. fetches each picked release's tarball, only under the registry origin, checks its SHA-512 against dist.integrity, unpacks it (no links, no path outside the package), and checks it is an ES module.

Nothing is committed unless every package passes. A load never reaches the registry.

code status when
PACKAGE_REGISTRY_UNSET 503 package_registry_origin is unset
PACKAGE_UNKNOWN 404 the registry has no package of the name
PACKAGE_NAME_INVALID 400 not an npm package name (lowercase letters, digits, - . _ ~, an optional @scope/)
PACKAGE_RANGE_INVALID 400 not a version, range or dist-tag (git, URL and file dependencies included)
PACKAGE_VERSION_NOT_FOUND 404 no published version meets the range, or the tag names none
PACKAGE_VERSION_CONFLICT 409 one name would need two versions
PACKAGE_REGISTRY_FAILED 502 the registry could not be reached or answered an error (retryable)
PACKAGE_TARBALL_OUTSIDE_REGISTRY 502 the metadata names a tarball outside the registry origin
PACKAGE_INTEGRITY_MISMATCH 502 the tarball's SHA-512 is not dist.integrity, or there is no sha512
PACKAGE_TARBALL_INVALID 502 not a gzip tar of a package: a link, an unsafe path, no package.json
PACKAGE_TOO_LARGE 413 a package unpacks past 256 MiB, or its metadata is past 64 MiB
PACKAGE_NOT_ESM 400 the package's entry is CommonJS

Bundled by the CLI

A package name (import leftPad from "left-pad", say) can instead be bundled into the one file the server runs, by the CLI, before it is uploaded:

import leftPad from "left-pad";
export default { fetch() { return Response.json({ padded: leftPad("hi", 5) }); } };

substrio deploy and substrio push bundle every function this way, with what it imports from the project itself. Nothing is sent to a registry: the package must already be in the project's node_modules (npm install, same as any other Node project). Bundling needs esbuild as a devDependency: substrio init adds it to a new project's package.json.

A function that imports only builtins is sent as is: esbuild is never loaded, so a project with no npm imports in its functions needs no bundler installed at all. Without esbuild installed, a function that does import a package is also sent as is, unbundled; the server then refuses it as it would any source it cannot turn into a handler, and the CLI adds one line to that refusal: run npm install --save-dev esbuild and deploy again.

Try before you promote

substrio push runs your functions in the app's dev sandbox, on a copy of the app's data, at https://<app>--dev.substrio.com; call them there before you promote (best-practices/versions-and-test-hosts).

Limits and speed

Time, memory and request limits, and every error a function can throw, are in runtime/index.md and function-errors.md. substrio logs --follow tails what your functions print.