SDK and CLI
Deploy from a script or from CI with @xeonr/functions, without writing the HTTP calls yourself.
@xeonr/functions is the TypeScript client and CLI for everything on this
page's neighbours: creating functions, configuring them, deploying, promoting,
rolling back, reading files and build logs.
pnpm add @xeonr/functions @bufbuild/protobuf @connectrpc/connectimport { createClient } from "@xeonr/functions";
const fns = createClient({
endpoint: "https://functions.xeonr.io",
token: process.env.XEONR_FN_TOKEN, // fnt_…
});
const fn = await fns.functions.create({ displayName: "My API", kind: "node" });
const deploy = await fn.deploy("./");
deploy.check();
console.log(await fns.url(fn.name)); // https://quiet-heron-4f2a.fns.xnr.appThe same thing from a shell, with no install:
XEONR_FN_TOKEN=fnt_… npx xeonr-fn ship "My API" ./ --kind nodeThe two peer dependencies are peers on purpose: a project that already uses Connect gets one copy of the schema registry rather than two.
Node 20 or newer. The package ships ESM and CommonJS, and works in the browser
— createBrowserClient() uses the console's session cookie instead of a token.
Deploys answer before they finish
This is the one behaviour worth understanding before you automate anything.
DeployVersion returns as soon as your source is stored. For Node.js and Go
that is before the image is built — the version comes back BUILDING and
promotes itself minutes later, when the build succeeds. A script that treats the
response as the outcome will report success for a build that fails after it
stopped watching.
So deploy() waits by default, and returns a settled result:
const deploy = await fn.deploy("./", {
onLog: (text) => process.stdout.write(text),
});
deploy.ok // did it build and go live?
deploy.status // "ready" | "failed" | "building" | "superseded" | …
deploy.failureMessage // why, when it failed
deploy.log // the whole build log
deploy.check() // throws unless okA failed build is a value, not an exception — the call succeeded and told
you the build failed. Only transport and permission failures throw. Pass
{ wait: false } to get the BUILDING version straight back and follow it
yourself:
for await (const chunk of fn.versions.ref(versionId).follow()) {
process.stdout.write(chunk.text);
}{ timeout } runs out into an error that says plainly that the build was not
cancelled — it is still running, and will still promote itself.
What to deploy
await fn.deploy("./my-app"); // a directory, archived for you (Node)
await fn.deploy(new URL(artifactUrl)); // a zip by URL, downloaded here
await fn.deploy({ "index.js": "…" }); // a file map, in any runtime
await fn.deploy(zipBytes); // an archive you built
await fn.deploy(); // the workspace as it stands
await fn.deployFromGit({ ref: "v1.2" }); // the connected repositoryThe directory form is the one that carries judgement, and the judgement is about what not to send:
- Paths land at the archive root. The build reads the archive as a
filesystem and does not strip a leading directory, so
package.jsonhas to be at the top. This is whyzip -r app.zip my-appproduces a tree that detects as "no package.json" rather than as a Node app, and why the SDK builds the archive itself. node_modules,.gitandvendorare excluded. The upload cap is 64 MiB and the build installs from your lockfile, so sending dependencies is never right. Add more with{ ignore: ["coverage", "*.log"] }.dist,buildandpublicare not excluded. For a static function they are the entire deployable — seestatic_dir.
From a URL
xeonr-fn deploy my-api https://…/build-1234.zipThe download happens in the SDK, not on the server. DeployVersion takes
bytes and the API never makes an outbound request on your behalf — a URL it
fetched would be an SSRF surface pointed at its own network. The archive is
retrieved client-side, capped at 64 MiB while streaming, and checked for being a
zip before anything is uploaded.
That check earns its place: an expired presigned link or a login redirect answers
200 with an HTML page, which would otherwise reach the API and come back as
"sourceZip is not a valid zip archive" — blaming the archive rather than the
link.
A string is a directory path unless it parses as http(s)://; a URL object
is the explicit form. There is no credential handling, because presigned URLs are
the norm — if the artifact needs a header, fetch it yourself and pass the bytes.
Traffic
Promotion is a pointer flip, so a rollback is instant and rebuilds nothing:
await fn.promote(versionId); // both "deploy" and "roll back"
await fn.rollback(); // promote the version before the live oneEverything else
// find things — every list() pages automatically
for await (const f of fns.functions.list({ search: "api" })) …
const fn = await fns.functions.get("quiet-heron-4f2a"); // id or assigned name
// configure — only the fields you pass are touched
await fn.update({
scaling: { minInstances: 0, maxInstances: 10, concurrency: 50 },
build: { packages: ["ffmpeg"], port: 8080 },
env: { LOG_LEVEL: "debug" }, // replaced wholesale
secrets: { API_KEY: "…" }, // merged; never readable again
});
// files — the live workspace, or a version's frozen snapshot
await fn.files.write("server.js", source);
await fn.files.at(versionId).list("/", { recursive: true });
// history, traffic, teams, git connections
for await (const v of fn.versions.list()) …
await fn.stats({ rangeSec: 3600 });
await fns.teams.list();
await fns.git.connections();Anything the SDK does not model is one property away: fns.raw.function and
fns.raw.team are the generated Connect clients, and they throw the same
errors.
Conventions it absorbs
The wire conventions are handled at the boundary, so you work in ordinary values:
| On the wire | In the SDK |
|---|---|
KIND_NODE, VERSION_STATE_READY, TEAM_ROLE_EDITOR | "node", "ready", "editor" |
| Timestamps as strings of Unix seconds | Date |
pageSize + pageToken loops | for await (… of list()) |
content or contentBase64, plus a binary flag | read() → bytes, readText() → string |
base64 in sourceZip | a directory path, a zip URL, a file map, or bytes |
Testing without deploying
import { fakeFunctions } from "@xeonr/functions/testing";
const fake = fakeFunctions();
fake.builds = { outcome: "fail", polls: 2, failureMessage: "no start script" };
const fn = await fake.client.functions.create({ displayName: "x", kind: "node" });
const deploy = await fn.deploy({ "package.json": "{}" }, { pollInterval: 0 });
deploy.ok; // false
deploy.failureMessage; // "no start script"A real client over an in-memory implementation of the API — real protocol, no
network. builds scripts what the next build does, which is how you reach the
slow and unhappy paths in a unit test.
CLI
Every command is a thin wrapper over the library.
xeonr-fn list xeonr-fn logs <fn> [version]
xeonr-fn get <fn> xeonr-fn promote <fn> <version>
xeonr-fn create <name> --kind node xeonr-fn rollback <fn>
xeonr-fn deploy <fn> [dir] xeonr-fn delete <fn>
xeonr-fn ship <name> [dir] --kind go xeonr-fn stats <fn>
xeonr-fn versions <fn> xeonr-fn teams<fn> is a function id or its assigned name — the one in the URL.
| Variable | |
|---|---|
XEONR_FN_TOKEN | A team API token. Required. |
XEONR_FN_ENDPOINT | Defaults to https://functions.xeonr.io. |
XEONR_FN_TEAM | Scopes calls to one team. |
Credentials come from the environment and never from a flag, so they stay out of shell history and CI logs.
Build output goes to stderr and the answer to stdout, so --json | jq
works while a build is streaming. --json prints the API's own encoding —
"kind": "KIND_NODE", timestamps as strings — not a second format to learn.
Exit codes: 0 fine, 1 a failed build or a refused call, 2 a usage mistake.
So this fails the pipeline when the build fails:
deploy:
script:
- npx xeonr-fn ship "My API" ./ --kind node
variables:
XEONR_FN_TOKEN: $XEONR_FN_TOKENOlder deployments
The SDK tracks the published proto, and a given deployment may be behind it. A
method a server does not have answers
unimplemented, which is a state you can
branch on rather than a failure you have to guess at:
import { isUnimplemented } from "@xeonr/functions";
try {
await fn.stats();
} catch (err) {
if (!isUnimplemented(err)) throw err;
// this deployment has no traffic stats yet
}