@xeonr/functions
v0.3.0
Published
Client SDK and CLI for Xeonr Functions — create, configure, deploy, promote and inspect functions over the public API.
Readme
@xeonr/functions
Client SDK and CLI for Xeonr Functions — create, configure, deploy, promote and inspect functions programmatically.
Push Node.js, Go or static source, get https://<name>.fns.xnr.app, and have it
start on the first request and idle back to zero. Console:
functions.xeonr.io.
import { createClient } from "@xeonr/functions";
const fns = createClient({
endpoint: "https://functions.xeonr.io",
token: process.env.XEONR_FN_TOKEN, // xk_… (or a legacy fnt_…)
});
// A token carries its own team, so `ownerTeam` is only needed for a signed-in
// caller: `createClient({ ownerTeam: "urn:xeonr:auth:team:…" })`, or `forTeam()`.
const fn = await fns.functions.create({ displayName: "My API", kind: "node" });
const deploy = await fn.deploy("./"); // archives, uploads, waits, promotes
deploy.check(); // throws if the build failed
console.log(await fns.url(fn.name!)); // https://brave-otter.fns.xnr.appSame thing from a shell:
XEONR_FN_TOKEN=xk_… npx xeonr-fn ship "My API" ./ --kind nodeThe one thing to know
A deploy answers before its build finishes. DeployVersion returns as soon
as the source is stored: a container version comes back BUILDING and promotes
itself minutes later, when the build succeeds. A client that treats that
response as the outcome will report success for a build that fails a minute
after it stopped watching.
So deploy() waits by default, polling the build log — which is also how you
get the log — and returns a settled result:
const deploy = await fn.deploy("./", { onLog: (t) => process.stdout.write(t) });
deploy.ok // did it build and go live?
deploy.status // "ready" | "failed" | "building" | …
deploy.failureMessage // the api's reason, when it failed
deploy.log // the whole build log
deploy.check() // throw BuildFailedError unless okA failed build is a value, not an exception — the RPC succeeded and told you
the build failed. Transport and permission failures throw; build outcomes return.
Pass { wait: false } to get the BUILDING version straight back and watch it
yourself with fn.versions.ref(id).follow().
{ timeout } runs out into a BuildTimeoutError that says plainly that the
build was not cancelled — it is still running, and will still promote itself.
Install
pnpm add @xeonr/functions @bufbuild/protobuf @connectrpc/connectThe two are peer dependencies so a project holding its own copy of protobuf-es does not end up with two schema registries. Node 20+. The package ships ESM and CommonJS, and works in the browser.
Authenticating
| Caller | How |
|---|---|
| CI, scripts, this CLI | a team API key — createClient({ token: "xk_…" }) |
| a browser, same-origin | the console's session cookie — createBrowserClient() |
Mint a key in the console (Team → Tokens) or with teams.createToken(). Keys
are Xeonr Auth API keys: the secret is returned once and auth stores only a
hash. Tokens functions issued itself before keys moved to auth (fnt_…) still
work, and are listed beside xk_… keys with legacyPrefix: "fnt_".
A token reaches FunctionService only. Everything under fns.teams requires a
signed-in user, deliberately: a token that could mint more tokens would make
revocation meaningless.
Teams
Teams are Xeonr Auth teams, shared across the platform — not objects this product owns. Create a team, rename it, delete it, or change who is in it and what they may do at auth.xeonr.io/teams. None of that is reachable from this SDK, by design: there is one place teams live.
Here, a team is only ever a reference, and its value is a URN:
const scoped = fns.forTeam("urn:xeonr:auth:team:2f1c9c1e-…");
await scoped.functions.create({ displayName: "My API", kind: "node" });
// or per call
for await (const f of fns.functions.list({ ownerTeam: urn })) …Every wire field and option that names a team is ownerTeam and takes that URN.
createFunction requires one — bind the client, pass it per call, or use a
token, which carries exactly one team. The retired t-… ids from the old
product-local team model are refused by shape, with a message that says so.
What is still here is the product's own team-scoped surface, under
fns.teams:
await fns.teams.createToken({ name: "ci", role: "editor" }); // secret shown once
await fns.teams.createToken({
name: "release", role: "editor",
description: "tags from the release pipeline", // optional
expiresAt: new Date("2027-01-01"), // optional; default never
});
await fns.teams.tokens(); // live keys
await fns.teams.tokens({ includeRevoked: true }); // and revoked ones
await fns.teams.deleteToken({ tokenId });
for await (const e of fns.teams.activity()) … // the audit feedWhat you can do
// find things
for await (const f of fns.functions.list({ search: "api" })) …
const { total } = await fns.functions.listPage({ pageSize: 1 }); // count every match
const fn = await fns.functions.get("brave-otter"); // 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
});
// deploy, from wherever the source is
await fn.deploy("./my-app"); // a directory (Node)
await fn.deploy(new URL(artifactUrl)); // a zip by URL (anywhere)
await fn.deploy({ "index.js": "…" }); // a file map (anywhere)
await fn.deploy(zipBytes); // a ready zip
await fn.deploy(); // the workspace as it stands
await fn.deployFromGit({ ref: "v1.2.3" }); // the connected repo
// traffic
await fn.promote(versionId); // deploy AND rollback: it is a pointer flip
await fn.rollback(); // promote the previous build
// history and output
for await (const v of fn.versions.list()) …
await fn.versions.ref(id).buildLog();
for await (const chunk of fn.versions.ref(id).follow()) …
// files — the workspace, or a version's frozen snapshot
await fn.files.write("server.js", source);
await fn.files.readText("package.json");
await fn.files.at(versionId).list("/", { recursive: true });
// traffic stats, CI tokens, activity, git connections
await fn.stats({ rangeSec: 3600 });
await fns.teams.tokens();
for await (const e of fns.teams.activity()) …
await fns.git.connections();Anything the SDK does not model is one property away: fns.raw.function and
fns.raw.team are the generated ConnectRPC clients, throwing the same error
taxonomy.
What the SDK owns, so you don't
| Thing | Why it is here |
|---|---|
| Waiting for a build | the deploy response is not the outcome — see above |
| Following a build log | offset polling, resumed by nextOffsetBytes, ending on complete |
| Archiving a source tree | the api reads the zip as a filesystem, so paths must be at the root — zip -r app.zip my-app produces a tree the buildpacks cannot detect |
| Downloading a remote zip | the api never fetches on your behalf, so a URL is fetched here, size-capped while streaming, and checked for being a zip before it is uploaded |
| Excluding node_modules | the cap is 64 MiB and the buildpack installs from your lockfile; sending dependencies is never right |
| JSON, not binary, on the wire | the api is a hand-rolled JSON mux; connect-node's default binary codec fails every call |
| Enums as words | "node", "ready", "editor" — not KIND_NODE, and never a lowercased enum name, which is how the console once matched nothing |
| fn-… vs a name | get() takes either, because the name is what a human has and the id is what a script stored |
| Pagination | every list() is an async iterable over all pages, with a stall guard |
| Partial updates | only fields you pass are sent, so saving scaling cannot clear your git config |
| unimplemented detection | isUnimplemented(err) — this SDK tracks the published proto, a given deployment may be behind it |
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, real
framing, no network. builds scripts what the next build does, so the slow and
unhappy paths are reachable in a unit test.
Recipes
@xeonr/functions/recipes — opinions, separately importable:
import { ship, ensureFunction } from "@xeonr/functions/recipes";
// create if missing, deploy, wait, report the URL — the whole CI story
const { url, deploy } = await ship(fns, "My API", "./", { kind: "node" });
deploy.check();CLI
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 activityCredentials come from XEONR_FN_TOKEN, never a flag, so they stay out of shell
history and CI logs. XEONR_FN_ENDPOINT and XEONR_FN_TEAM override the
defaults; XEONR_FN_TEAM and --team take a team URN. There is no teams
command — teams are managed at
auth.xeonr.io/teams.
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 protojson —
"kind": "KIND_NODE", int64 as a string — not this SDK's in-memory shape.
Exit codes: 0 fine, 1 a failed build or a refused call, 2 a usage mistake.
Deploying a zip from a URL
await fn.deploy(new URL("https://…/build-1234.zip"));xeonr-fn deploy my-api https://…/build-1234.zipThe download happens here, 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. So the SDK retrieves the archive,
caps it at 64 MiB while streaming rather than after, checks it actually starts
like a zip, and uploads the bytes.
That last check is worth the code: an expired presigned link or a login redirect
answers 200 with an HTML page, and without it those bytes travel to the api and
come back as "sourceZip is not a valid zip archive" — blaming the archive rather
than the link. Instead:
… did not return a zip archive (got 50 bytes starting "<!doctype html><title>Sign in</title>…").
Check the link has not expired.A URL object is the explicit form. A string is a directory path unless it
parses as http(s)://, so a config value like process.env.SOURCE does the
right thing instead of being looked up as a directory named https://….
There is no credential handling — presigned URLs are the norm. If the artifact needs a header, fetch it yourself and pass the bytes:
const zip = new Uint8Array(await fetch(url, { headers }).then((r) => r.arrayBuffer()));
await fn.deploy(zip);Known rough edge
Clearing the last environment variable is not expressible. The api reads an
absent env as "leave alone" and a present one as "replace", and protojson omits
an empty map — so update({ env: {} }) is indistinguishable on the wire from not
mentioning env at all. Removing some variables works (send the map without
them); emptying it completely needs a server-side clear_env, or env becoming
an explicit-presence field.
Development
pnpm generate # regenerate from the published proto module
pnpm test # unit suite, no network
pnpm typecheck
pnpm build # esm + cjs, then verify the built packagemake sdk from the repo root runs all of it, and make ci includes it.
The generated client is committed (src/proto/) rather than generated at install
time, so the SDK version pins one schema revision — which is what makes
fns.raw mean something. Regenerate, commit, release.
pnpm generate reads the published module proto.prod.wtf/xeonr/functions;
pnpm generate:local reads ../../protocol/modules/xeonr-functions for
generating ahead of a push.
Releasing
Bump version in package.json and merge it to main. That is the whole
release. CI publishes to npm and then tags what it published as sdk-v<version>.
The version is never derived from a tag: a tag that sets the version could
publish contents whose own manifest says something else, so package.json is the
source of truth and the tag is the record. "Has the version changed" is answered
by asking the registry rather than by diffing, which stays right across squashed
merges, reverts, re-runs, and a publish that failed halfway.
A server change merged to main runs these jobs too — the publish finds the
version already on the registry and skips, so an SDK release stays independent of
a server release despite sharing a repo.
The tag prefix is sdk-v, not v: this repo also holds the api and the edge,
where a bare v0.2.0 would read as a release of the whole product.
