Library
The flypod npm package deploys a folder of static files to a live URL from Node or Bun. Reference for deploySite, DeployError, collectDist, collectFiles, resolveDeployDir, and the DeployOptions / DeployResult types.
flypod is a tiny, dependency-light npm package that turns a folder of static
files into a live URL from JS/TS. It's the same deploy primitive the
CLI wraps, exposed as functions you can call directly.
npm install flypod # or: bun add flypodEverything ships from one entry point. It's ESM-only (load it with import, not require) and runs on Node 18+ and Bun:
import {
deploySite,
DeployError,
collectDist,
collectFiles,
resolveDeployDir,
BUILD_DIR_CANDIDATES,
DEFAULT_IGNORES,
IGNORE_FILE,
} from "flypod";
import type { DeployResult, DeployOptions, DeploySource, CollectedFiles, SkippedPath } from "flypod";The mental model
A deploy is two steps: read files into a map, then ship the map.
const files = await collectDist("./dist"); // { "index.html": Uint8Array, ... }
const { url, manage_token } = await deploySite({ files });deploySite creates a new site every time it's called. Save the
manage_token it returns — that token is what lets you update or roll back the
site later.
This is the one place flypod tells you to keep a token, and the reason is
specific: the library has no registry. The CLI saves each
site's manage_token to projects.json (mode 0600) keyed by folder, which is
why it hides the token and why flypod login can claim every remembered site in
one step. Calling deploySite directly, you are that registry. Nothing else
holds what it returns, and the server keeps only a hash — so if you drop it, the
site can never be updated or rolled back, and only the claim_token (if you kept
that) could still attach it to an account.
Two more things worth knowing up front:
- Deploys are anonymous by default (public, expires in ~14 days). Pass an account token to make a site owned and permanent — see Owned deploys.
- The library only deploys. There's no
update()orrollback()function. To ship a new version to an existing site or roll one back, use the HTTP API (POST /sites/:id/deploys) or the CLI with themanage_token.
Want to run something now? The TypeScript demo is a downloadable, working example you can deploy in one command.
deploySite
deploySite(opts: DeployOptions): Promise<DeployResult>Zips a files map, uploads it as a new site, and returns the live URL plus the
tokens you need to manage it. If the server answers with a non-2xx status it
throws a DeployError carrying the status and the server's
error code, so you can tell a rate limit (retry later) from a refused upload
(don't). A network failure rejects with whatever fetch threw.
Called directly, deploySite never reads a saved flypod login session or a
FLYPOD_TOKEN from the environment — a deploy is owned only if you pass
apiKey yourself. The one ambient value it reads is the FLYPOD_SOURCE label
(see source below), which can't change who owns a deploy or where it goes.
DeployOptions
| Field | Type | Required | Description |
|---|---|---|---|
files | Record<string, Uint8Array> | Yes | Relative path → file bytes. Build it with collectDist, or construct it by hand. |
apiKey | string | No | An account token. Pass it to make the deploy owned and permanent. See Owned deploys. |
baseUrl | string | No | API host to deploy against. Defaults to https://flypod.dev; override it to target a self-hosted server. |
handle | (req: Request) => Promise<Response> | No | Advanced: a custom transport used in place of global fetch (for tests, in-process calls, or a proxying fetch). Defaults to fetch. Most callers never set this. |
source | DeploySource | No | The surface reported to the server in the x-flypod-source header, for attribution only. Defaults to the FLYPOD_SOURCE environment variable when it is set, otherwise "library". Recognised values are cli, browser, library, skill, mcp, and ci; the server records anything else as unknown. Set FLYPOD_SOURCE=ci for automated runs that shouldn't count as real usage. |
apiKey is a misnomer: flypod has no separate API keys. The value is sent
verbatim as Authorization: Bearer <value>, so it must be an account session
token — the same credential flypod login saves for the CLI, and what the
CLI reads from FLYPOD_TOKEN. The field name is kept for backward
compatibility. A token the server doesn't accept fails the deploy with a 401
token_rejected DeployError; it never quietly falls back to
an anonymous deploy. See Tokens & ownership.
DeployResult
| Field | Type | Description |
|---|---|---|
url | string | The live URL, e.g. https://a3f1c2d4e5b60789.flypod.page. Serves immediately. |
manage_token | string | Save this. Bearer token that authorizes updates, rollback, and reads for this one site while it's anonymous. Once the site is claimed by an account, only that account can manage it and the manage_token stops working. It never authorizes deletion. |
site_id | string | Stable site identifier. Used in HTTP API paths (/sites/:id/...). |
slug | string | The site's URL slug (the subdomain in url). |
version_id | string | Identifier of the version this deploy created — pass it to rollback later. |
expires_at | number | null | Epoch-ms expiry for anonymous deploys; null when the site is owned (never expires). |
owner_account_id | string? | The owning account's ID. Present only on owned (authenticated) deploys — the key is omitted, not null, on anonymous ones. |
claim_token | string? | Anonymous deploys only (omitted, not null, when owned). A fallback for claiming this site from a different machine via flypod claim. The usual login flow attaches sites with the manage_token and never needs it, and it can't re-point a site that already has a different owner (409). |
healed | string[]? | Problems the server fixed for you, e.g. "spa-fallback" (it detected a client-routed app and serves index.html for unknown paths). |
warnings | { code: string; fix_recipe: string }[]? | Problems it could not fix, e.g. missing-index, each with a fix_recipe naming the change to make. A deploy can succeed and still be broken for visitors, so check warnings before you hand the URL to anyone. |
next_actions | unknown[] | Suggested follow-up actions returned by the server (informational). |
flypod.dev always returns healed and warnings (as empty arrays when there's
nothing to report); they're typed optional so the result still fits an older
self-hosted server. The raw response also carries a render block, which the
type leaves out — see POST /sites.
Example
import { deploySite, collectDist } from "flypod";
const files = await collectDist("./dist");
const result = await deploySite({ files });
for (const w of result.warnings ?? []) console.warn(`${w.code}: ${w.fix_recipe}`);
console.log(result.url); // https://a3f1c2d4e5b60789.flypod.page — live now
console.log(result.manage_token); // save this to update / roll back laterDeployError
class DeployError extends Error {
readonly status: number; // the HTTP status, e.g. 413 or 429
readonly code: string | undefined; // the server's stable error code, when it sent one
readonly body: string; // the response body, verbatim
}What deploySite throws when the server refuses the deploy. Its message is
deploy failed: <status> <code> (or the body, when that's short and carries no
code). Branch on code, not on the message; the codes are the
HTTP API's, for example:
status | code | Meaning |
|---|---|---|
400 | invalid_upload | The upload couldn't be read (empty, or an invalid file path or archive). |
401 | token_rejected | apiKey was sent but not accepted. Sign in again, or drop apiKey to deploy anonymously. |
403 | blocked / challenge_required | The content screen refused the upload. Don't retry it unchanged. |
413 | too_large | Too many files or too many bytes. |
429 | rate_limited | Too many deploys from this IP. Retry later. |
import { deploySite, DeployError } from "flypod";
try {
await deploySite({ files });
} catch (err) {
if (err instanceof DeployError && err.code === "rate_limited") {
// back off and try again later
} else throw err;
}collectDist
collectDist(path: string): Promise<Record<string, Uint8Array>>Reads files off disk into the { path → bytes } map that deploySite expects.
Pass a directory and it's walked recursively (paths are relative to the
directory, with / separators); pass a single file and you get a
one-entry map keyed by its file name. A file you name explicitly is never
filtered.
const site = await collectDist("./dist"); // whole folder
const one = await collectDist("./index.html"); // { "index.html": Uint8Array }A directory walk leaves out the same paths the CLI does:
DEFAULT_IGNORES, wherever they sit in the tree:.git,.hg,.svn,node_modules,.DS_Store,Thumbs.db,.env, and.env.*. Other dotfiles (.well-known/,.nojekyll) are kept.- Patterns in a
.flypodignore(IGNORE_FILE) at the top of the folder, in gitignore syntax:*,?,**, a leading/to anchor, a trailing/for directories only,!to re-include,#for comments. The last matching pattern wins. The.flypodignoreitself is never uploaded. - Symlinks: a link to a file is read through. A link to a directory is followed only when it resolves inside the folder being deployed; one that points outside it, loops back up the tree, or is broken is skipped.
To see what was left out, use collectFiles, which returns
the same map plus the list.
You don't have to use collectDist — any Record<string, Uint8Array> works, so
you can generate files in memory (see the inline demo).
collectFiles
collectFiles(path: string): Promise<CollectedFiles>
interface CollectedFiles {
files: Record<string, Uint8Array>; // what collectDist returns
skipped: SkippedPath[]; // what was left out, sorted by path; empty when nothing was
}
interface SkippedPath {
path: string; // relative to the folder; directories end in "/"
reason: "default" | "flypodignore" | "symlink_outside" | "symlink_loop" | "broken_symlink";
}collectDist with a receipt. Use it to show the user what a deploy will leave
out before it ships:
const { files, skipped } = await collectFiles("./dist");
for (const s of skipped) console.log(`skipped ${s.path} (${s.reason})`);
await deploySite({ files });DEFAULT_IGNORES
The names every directory walk skips, at any depth:
[".git", ".hg", ".svn", "node_modules", ".DS_Store", "Thumbs.db", ".env", ".env.*"]IGNORE_FILE
".flypodignore" — the optional per-folder ignore file that adds to
DEFAULT_IGNORES. It's read from the top of the folder you collect, honoured,
and never uploaded.
resolveDeployDir
resolveDeployDir(opts?: { cwd?: string; explicit?: string }): Promise<string>Figures out which folder to deploy so callers don't have to hardcode dist.
It's async. Behavior:
- With
explicitset, that path is returned as-is. - Otherwise it scans
BUILD_DIR_CANDIDATESin order (relative tocwd, defaultprocess.cwd()) and returns the first folder that exists and contains anindex.html. - Next, a candidate with exactly one app folder inside that has an
index.html(dist/<app>/ordist/<app>/browser/, as Angular builds) returns that folder. - Next,
cwditself, when it has anindex.html, nopackage.json(or other project file such as_config.ymlorpyproject.toml), and is not the home directory: a folder of plain pages. - If nothing matches, it throws — pass the folder explicitly in that case.
import { resolveDeployDir, collectDist, deploySite } from "flypod";
const dir = await resolveDeployDir(); // auto-detect, e.g. "/proj/dist"
const files = await collectDist(dir);
await deploySite({ files });BUILD_DIR_CANDIDATES
The ordered list resolveDeployDir searches. public ranks low because some
tools use it for source assets — requiring an index.html disambiguates a
built site from source.
["dist", "build", "out", ".output/public", ".vercel/output/static", "public", "_site"]Owned deploys
By default a deploy is anonymous: public, noindex, and it expires in ~14 days.
To make a site owned and permanent, pass an account session token as
apiKey. flypod login saves one to its session file for the CLI; for
headless runs the convention is to supply it as the FLYPOD_TOKEN environment
variable, which the CLI reads first. deploySite reads neither on its own, so
pass it explicitly:
const result = await deploySite({
files,
apiKey: process.env.FLYPOD_TOKEN,
});
// result.expires_at === null, result.owner_account_id is setSee Tokens & ownership for how tokens and ownership fit together, and Accounts & claiming to attach sites you already deployed anonymously.