@broberg/deploy-core
The broberg.ai fleet's shared deploy execution layer. It contains three things:
| Part | What it does |
|---|---|
Fly Live (flyLiveDeploy, syncContent, diffManifests, …) | HMAC-signed incremental content sync to a Fly app, plus the one-time infra build. |
FlyClient (since 0.4.0) | The Fly.io API: apps and machines over the Machines REST API, and the GraphQL calls the fleet uses. |
createDeployEventBus (since 0.10.0) | The in-process deploy-event bus: webhook receivers publish, SSE endpoints subscribe, per orgId:siteId. |
It does not contain Cloudflare Pages or GitHub Pages providers (planned in F033.2).
Deploy-event bus (0.10.0, F033.3)
import { createDeployEventBus } from "@broberg/deploy-core";
const bus = createDeployEventBus({
dispatch: (e) => sendPush(e), // optional: only for "built" / "errored"
onError: (err, where) => log.warn(where, err), // default console.warn
});
const off = bus.subscribe(orgId, siteId, (e) => sse.send(e));
bus.publish({ type: "page-build", orgId, siteId, status: "built", url, ts: new Date().toISOString() });
bus.listenerCount(orgId, siteId);
- One bus per app, from the factory — never a module-level Map, so two buses in one process never hear each other.
- No
originatorfield. F033.4 planned one for Upmetrics' relay and was closed 30/10: Upmetrics derives the sender from the project itself. - Publishing to a site nobody listens to is a no-op. The push hook still fires for a terminal event, because push reaches users who have no tab open.
- A throwing listener or a failed push is reported via
onError, never thrown at the webhook that published, and never swallowed silently (cms's original had an empty catch). - Single process. Several instances of one app would need Redis pub/sub; out of scope.
Fly Live: a server fix reaches apps that already run (0.8.0, F033.18)
flyLiveDeploy syncs CONTENT to an app that answers /_icd/health; it never replaced the server code inside the container. A server fix (0.7.0's Cache-Control) therefore reached only apps that happened to be re-provisioned. Now the server reports FLY_LIVE_SERVER_VERSION — a hash of its own code and Dockerfile, so it changes whenever the server does — and flyLiveDeploy takes the infra path when a live app reports a different version. The result says why: { mode: "infra", reason: "server version 1.0.0 ≠ 82b3…" }.
The first deploy after upgrading is a full infra deploy, once per app (every running server reports the old "1.0.0"). After that, deploys are content syncs again. chooseDeployMode(health, expected, forceInfra) is exported if you want to decide yourself.
FlyClient
import { FlyClient } from "@broberg/deploy-core";
const fly = new FlyClient(); // token from FLY_API_TOKEN, or { token }
await fly.listApps("personal"); // one org
await fly.listAllApps(); // every org the token sees, all pages
const [m] = await fly.listMachines("my-app");
await fly.updateMachine("my-app", m.id, { // resize: pass the WHOLE config
...m.config,
guest: { ...m.config.guest!, memory_mb: 512 },
});
await fly.waitForState("my-app", m.id, "started", 120);
const { state, exitCode } = await fly.waitForExit("my-app", jobId);
const { entries, nextToken } = await fly.getMachineLogs("my-app", m.id); // since 0.6.0
await fly.getMachineLogs("my-app", m.id, { nextToken }); // only newer lines — poll this to tail
await fly.listVolumes("my-app"); // destroyed ones included — filter on state
await fly.deleteVolume("my-app", "vol_123"); // 0.11.0 — Fly refuses an attached volume (FlyApiError). A lost answer + retry can throw 404 although it IS gone: re-read listVolumes before saying "not deleted"
await fly.updateVolume("my-app", "vol_123", { snapshot_retention: 14, auto_backup_enabled: true }); // 0.11.0 — only the fields you give are sent
await fly.listIps("my-app"); // 0.9.0 — [{ ip, shared, region, serviceName, createdAt }]; a failure throws, never []
await fly.promQuery("personal", 'sum(fly_instance_memory_mem_available{app="my-app"})');
Also: getApp, createApp, deleteApp, getMachine, createMachine,
startMachine, stopMachine, destroyMachine, listOrgs, setSecrets,
allocateIp.
What it does that a hand-rolled client usually does not
Each of these was measured — in a consumer's own copy or against the live API (24 Sep 2026) — and each has a test that goes red if it is undone:
- Retries only what can succeed on a second try: network errors, 408, 429,
5xx. A 401, 403, 404 or 422 throws at once with
FlyApiError { status, method, path }. (A copy that retried everything turned a wrong token into 30 minutes of polling.) waitForExitreportsexitCode: nullwhen Fly recorded none. It does not call that a success; you decide.waitForExitthrows a 404 for a machine it never saw (since 0.5.0). Fly answers 404 "machine not found" both for a wrong id and for a WRONG TOKEN, so a 404 on the first poll is not "destroyed". Only a machine seen in an earlier poll that then disappears (auto_destroy) is reported asdestroyed.promQueryhides Fly's Prometheus auth trap (since 0.5.0). Fly's Prometheus answers 401 toBearerand wantsFlyV1 <token>; the Machines API takes Bearer. HTTP 200 withstatus: "error"throws.waitForStatereads Fly's 408 as "not yet". Fly's/waitanswers 408 when its own timeout runs out; the client keeps asking until your timeout is spent, then throwsFlyTimeoutError.- GraphQL HTTP 200 is not success. Fly answers 200 for a bad token with the
error in the body; the client throws a
FlyApiErrorwith status 401. listAllAppsis complete or it throws. It walks every page and checks the count against Fly's owntotalCount. A partial list never looks like a whole one — which matters when the caller deletes what is missing from it.getMachineLogsuses Fly's logs API, not the obvious address (since 0.6.0).api.machines.dev/v1/apps/<app>/machines/<id>/logsanswers 404 — measured 29/9 2026 — so a hand-rolled poller that swallows errors shows an empty log forever. The real endpoint isapi.fly.io/api/v1/apps/<app>/logs?instance=<id>and, like Prometheus, wantsFlyV1 <token>. A 200 without adataarray throws.- The token never appears in an error message or body.
Verified against the real Fly API
test/live/fly-api.live.ts runs read-only against an existing app (compared
with flyctl machines list / flyctl volumes list, plus promQuery and the
never-seen-machine 404; FLY_LIVE_READ_ONLY=1 stops there) and runs the write path — create app, create
machine, resize and read back, stop, a process that exits 3, destroy, delete
app — inside a throwaway app it always deletes. Run by hand; it is not part of
vitest run.