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:
- Source forms (ES module, CommonJS, a script that evaluates to a function), handler shapes and every limit with its error: runtime/index.md. A source that only declares a named function is refused when invoked ("Function source must evaluate to a function object").
db.collection(name), filters, the chain, records and query results: runtime/collections-and-chains.md, runtime/records.md.- SQL and transactions: runtime/sql.md. Key-value stores: runtime/kv.md.
fetch, streams, timers and the other globals: runtime/index.md, "Differences from Workers" and "Globals".consoleoutput and failures: runtime/errors-and-logs.md, function-errors.md.
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 infunctions/, and its source isfunctions/<name>.js. Every other.jsfile anywhere in the version (functions/lib/db.js,shared/util.jsat the root) is a module: it runs only when a function imports it, and is never a function of its own. Afunctions/<name>.jsonwithout its.jsis 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 conditionsimport,moduleanddefaultin 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:
- 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; - opens its
dependenciesand itspeerDependenciesthat 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 refusedPACKAGE_VERSION_CONFLICT, naming both; - 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.