npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.app

Same thing from a shell:

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

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

A 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/connect

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

What 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 activity

Credentials 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.zip

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

make 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.