Xeonr Developer Docs

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/connect
import { 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.app

The same thing from a shell, with no install:

XEONR_FN_TOKEN=fnt_… npx xeonr-fn ship "My API" ./ --kind node

The 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 ok

A 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 repository

The 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.json has to be at the top. This is why zip -r app.zip my-app produces a tree that detects as "no package.json" rather than as a Node app, and why the SDK builds the archive itself.
  • node_modules, .git and vendor are 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, build and public are not excluded. For a static function they are the entire deployable — see static_dir.

From a URL

xeonr-fn deploy my-api https://…/build-1234.zip

The 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 one

Everything 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 wireIn the SDK
KIND_NODE, VERSION_STATE_READY, TEAM_ROLE_EDITOR"node", "ready", "editor"
Timestamps as strings of Unix secondsDate
pageSize + pageToken loopsfor await (… of list())
content or contentBase64, plus a binary flagread() → bytes, readText() → string
base64 in sourceZipa 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_TOKENA team API token. Required.
XEONR_FN_ENDPOINTDefaults to https://functions.xeonr.io.
XEONR_FN_TEAMScopes 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_TOKEN

Older 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
}

On this page