Skip to content
flypod
API & library

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 flypod

Everything 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() or rollback() 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 the manage_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

FieldTypeRequiredDescription
filesRecord<string, Uint8Array>YesRelative path → file bytes. Build it with collectDist, or construct it by hand.
apiKeystringNoAn account token. Pass it to make the deploy owned and permanent. See Owned deploys.
baseUrlstringNoAPI host to deploy against. Defaults to https://flypod.dev; override it to target a self-hosted server.
handle(req: Request) => Promise<Response>NoAdvanced: 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.
sourceDeploySourceNoThe 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

FieldTypeDescription
urlstringThe live URL, e.g. https://a3f1c2d4e5b60789.flypod.page. Serves immediately.
manage_tokenstringSave 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_idstringStable site identifier. Used in HTTP API paths (/sites/:id/...).
slugstringThe site's URL slug (the subdomain in url).
version_idstringIdentifier of the version this deploy created — pass it to rollback later.
expires_atnumber | nullEpoch-ms expiry for anonymous deploys; null when the site is owned (never expires).
owner_account_idstring?The owning account's ID. Present only on owned (authenticated) deploys — the key is omitted, not null, on anonymous ones.
claim_tokenstring?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).
healedstring[]?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_actionsunknown[]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 later

DeployError

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:

statuscodeMeaning
400invalid_uploadThe upload couldn't be read (empty, or an invalid file path or archive).
401token_rejectedapiKey was sent but not accepted. Sign in again, or drop apiKey to deploy anonymously.
403blocked / challenge_requiredThe content screen refused the upload. Don't retry it unchanged.
413too_largeToo many files or too many bytes.
429rate_limitedToo 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 .flypodignore itself 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 explicit set, that path is returned as-is.
  • Otherwise it scans BUILD_DIR_CANDIDATES in order (relative to cwd, default process.cwd()) and returns the first folder that exists and contains an index.html.
  • Next, a candidate with exactly one app folder inside that has an index.html (dist/<app>/ or dist/<app>/browser/, as Angular builds) returns that folder.
  • Next, cwd itself, when it has an index.html, no package.json (or other project file such as _config.yml or pyproject.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 set

See Tokens & ownership for how tokens and ownership fit together, and Accounts & claiming to attach sites you already deployed anonymously.

On this page