Releases: solcreek/groundflare
Release list
groundflare v0.5.5 — Bun /__health fix + capstan extraction
Two changes. One is a fix for a Bun-track regression that had been silently broken since the track shipped. The other is a refactor with no CLI-visible impact — the multi-provider VPS abstraction now lives in a separate package, capstan, which groundflare consumes as a library.
🐛 Fixed: Bun track /__health returned non-JSON
The deploy probe at the end of groundflare up curls /__health and waits for {"status": "ok", …}. On the workerd track this has always worked because the generated Router Worker intercepts /__health upstream of tenant workers.
On the Bun track, the generated Bun.serve shim was forwarding every request — including /__health — straight to the user's fetch(). The deploy probe would then parse whatever the user's app emitted (usually not JSON), JSON.parse would throw, and the probe would treat the response as ok=false and retry up to six times before failing:
DeployError: health probe returned status 200 ok=false after
6 attempts — workerd is reachable but /__health is not reporting ok
The fix: the emitted Bun shim now intercepts /__health before user code sees the request, returning the same JSON shape the workerd Router emits (status, uptime_seconds, version). 4 new unit tests assert the intercept exists, runs before user code, embeds the version, and defaults sensibly.
What this doesn't fix yet: /__metrics and /__scheduled on the Bun track still aren't intercepted. Those aren't reachable as user-visible routes today; when Bun-track metrics/scheduled support lands they'll need the same treatment.
🔀 Changed: VPS provider abstraction moved to capstan
The four-provider VPS lifecycle code (Hetzner / DigitalOcean / Linode / Vultr) used to live under src/provider/ in this repo. We extracted it as a standalone npm package: capstan.
groundflare now imports from capstan instead of carrying its own copy. Net diff in this repo: −3,962 LOC. Same types, same behavior, same 107 unit tests — they now live in capstan's repo and pass there.
What to do
- groundflare CLI users: nothing.
groundflare up/down/status/destroybehave identically to v0.5.4. - Anyone deep-importing
groundflare/dist/provider/...(you probably weren't — those paths were never officially exported): switch toimport { HetznerProvider } from 'capstan'. Class shapes and types are identical; only the import specifier changed. - Other Creek-family tooling: capstan is now the L2 ops library. Use it from lab scripts, orchestrators, and future tools.
Full changelog
See packages/groundflare/CHANGELOG.md.
Install
npm i -g groundflare@0.5.5Published via OIDC Trusted Publishers with SLSA provenance attestation.
groundflare v0.5.1 — R2 follow-ups + Bun track parity
v0.5.1 — R2 follow-ups + Bun track parity
Patch release that finishes the v0.5 R2 work. Non-breaking: existing
workerd-track deploys don't need any config changes. Bun-track
deploys pick up new default behaviour (local SeaweedFS sidecar
instead of CF-R2-passthrough) — existing Bun users who set
R2_<BINDING>_ACCOUNT_ID keep working via the backwards-compat
branch.
New: Caddy /media/* reverse proxy for R2 public reads
wrangler R2 bindings now accept an optional groundflare.public_path.
When set, Caddy exposes the bucket at that path prefix on the
tenant's domain, reverse-proxied to the local SeaweedFS sidecar. No
Worker round-trip, no signed URLs — a direct byte pipe:
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "my-emdash-media",
"groundflare": { "public_path": "/media" }
}
]GET https://emdash.example.com/media/photos/cat.jpg
↓ Caddy handle_path strips /media, rewrites to /my-emdash-media
↓ reverse_proxy 127.0.0.1:8333
← weed serves the bytes
Needed for CMS-style apps (emdash being the canonical case) where
user-uploaded media in R2 has a public URL distinct from the app's
signed-URL flow. Before this, self-hosted emdash could upload media
but the public reader URL 404'd.
New: Bun track R2 parity
The Bun R2 adapter gained the same two modes as the workerd adapter:
- Local (default):
http://127.0.0.1:8333anonymous. Matches the
workerd track's zero-config self-host story. - External: set
groundflare.endpoint+ credential secrets on the
binding; deploy resolves the*_secretnames to values and writes
them to/etc/groundflare/environmentasR2_<BINDING>_*KEY=VALUE
lines (root:root 0600). systemd loads them at boot; the Bun shim's
makeR2Facadereads them fromprocess.env.
The old CF-R2-passthrough mode (via R2_<BINDING>_ACCOUNT_ID) still
works unchanged — it's now just one of three presets. Credentials are
optional for anonymous endpoints; paired validation
(both-or-neither) lives inside the adapter constructor with a clear
TypeError.
Bun-track bootstraps now install SeaweedFS on cloud-init (previously
only workerd track did). Skip cost: ~50 MB idle RAM; the service is
opt-out via systemctl disable groundflare-r2 if you don't use R2.
New: multipart live-validation endpoint
examples/r2-smoke/ gained a /multipart-test endpoint that drives
the full R2 multipart flow server-side (create → 2 × 5 MiB parts →
complete → GET-back → delete), returning a JSON report. Run-time on
a 1 GB DO droplet: ~800 ms end-to-end. Designed for operators to
drive a single curl against a fresh deploy and see the complete
size/etag (with the -<partCount> suffix S3 uses for multipart)
in one response.
New: examples/emdash-demo/
The v0.5 live-validation target checked in as an example.
Derivative of emdash-cms/templates/starter-cloudflare (MIT) with a
single addition — a [groundflare] block in wrangler.jsonc.
Exercises Astro SSR + D1 + R2 + KV + worker_loaders on a $6 DO
droplet. Includes a README with the measured deploy timings +
runtime RAM footprint.
Fixes
packages/groundflare's pre-publish lint gate rejected the v0.5.0
tag on first push: two inlineimport('…').Typeannotations and
three unused imports snuck past localnpm run build. Patched
out; v0.5.0 actually shipped on the retry.
Test coverage
- L1: +7 Caddy r2PublicRoutes cases + 6 run.ts env-file cases
- L2/L3: unchanged (adapter surface identical; Bun deploy path gets
new unit coverage instead) - Bun native: +5 adapter constructor cases covering
endpoint/accountId/anonymous modes
Totals: 1001 unit + integration, 85 bun. 1086 tests green.
groundflare v0.5.0 — self-host R2 end-to-end
v0.5.0 — Self-host R2 end-to-end
The headline: R2 bindings work on self-hosted boxes with zero config.
A fresh groundflare up provisions a VPS that ships with a SeaweedFS
sidecar installed and running; any subsequent deploy that declares
[[r2_buckets]] hooks up automatically — env.MEDIA.put(...),
env.MEDIA.get(...), and the rest of the R2 surface hit the sidecar
with no operator intervention. Live-validated on a $6/mo DigitalOcean
1 GB droplet: PUT/GET/HEAD/LIST/DELETE all round-trip, httpMetadata +
customMetadata preserved, total RAM footprint (workerd + Caddy + weed)
well under 500 MB.
Also supports hybrid + BYO-S3 modes: point a bucket at Backblaze B2,
Wasabi, Tigris, real R2, MinIO, or anything else S3-compatible via a
tiny [r2_buckets.groundflare] override resolved at deploy time from
credentials in groundflare secret set.
New: R2 ↔ S3 adapter Worker
groundflare's workerd config now emits one adapter Worker service per
R2 binding. The adapter translates workerd's internal R2 wire protocol
into S3 REST calls and back; user code is completely unaware:
// Default — local SeaweedFS sidecar, anonymous mode
"r2_buckets": [
{ "binding": "MEDIA", "bucket_name": "uploads" }
]
// Hybrid — point at B2 / Wasabi / real R2 / anywhere S3-compatible
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "my-bucket",
"groundflare": {
"endpoint": "https://s3.us-west-002.backblazeb2.com",
"region": "us-west-002",
"access_key_id_secret": "B2_KEY_ID",
"secret_access_key_secret": "B2_APP_KEY"
}
}
]All 9 R2 operations supported: head / get / put / delete /
list + createMultipartUpload / uploadPart /
completeMultipartUpload / abortMultipartUpload. R2HttpFields,
customFields, conditional headers (etagMatches / etagDoesNotMatch /
uploadedBefore / uploadedAfter), range requests, storage class, AWS
error-code → R2 v4-code mapping — all round-trip. Streaming PUT /
uploadPart through the adapter without buffering (verified with 1 MB
- 5 MB payloads in tests).
New: SeaweedFS sidecar
Workerd-track bootstraps now install SeaweedFS v4.20 (+ ~30 MB binary
download, ~50 MB idle RAM). A groundflare-r2.service systemd unit
runs it on 127.0.0.1:8333 with Before=groundflare-worker.service so
workerd's first start always sees the sidecar ready. Weed's default
volume server port (8080) collided with workerd's listen port; the
unit explicitly pins master.port=9333 volume.port=8088 filer.port=8888 to prevent that regression from surfacing again.
Bun-track bootstraps skip weed (Bun adapter still talks to external S3
directly; see Deferred below).
deploy detects R2 bindings and:
- Runs esbuild once per deploy to produce the adapter Worker bundle;
embeds it in the generated capnp viainline. - Resolves
*_secretreferences fromFileSecretStore
(~/.config/groundflare/secrets.json) and injects plaintext values as
adapter env bindings. Mixed-presence credentials rejected at
config-read time. - Idempotently pre-creates each bucket on the local sidecar (weed's
anonymous mode refuses AccessDenied on first-PUT-to-missing-bucket).
Testing
1002 tests across three tiers:
- L1 (
test/unit/runtime/workerd/r2/, 126 tests): pure-function
codec + mapping coverage. Wire-protocol edge cases (malformed JSON,
body shorter than declared, streaming PUT chunk straddling metadata/
payload boundary, forward-compat unknown op methods); conditional
discriminator forms; AWS → R2 error code precedence; list XML quirks. - L2 (
test/integration/r2-adapter/, 27 tests): real workerd
binary driving the real adapter Worker against a Nodehttp.Server
mocking S3. Catches wire bugs no pure-function test can — specifically
the GET-header / PUT-body-prefix asymmetry that cost two hours during
the PoC. Covers full multipart sequence, large-object streaming,
every S3 error-code mapping, Unicode keys. - L3 (
test/e2e/r2/, 14 tests): real workerd + real adapter + real
SeaweedFS. Downloads + caches the weed binary for the current
platform (.cache/weed-4.20-<arch>), verifies actual disk
persistence, round-trips 5 MB objects, exercises full multipart
upload end-to-end.
Bug fixes
atomicInstall'sgroundflareOwnedDirsnow includes per-binding D1
and KV state dirs. workerd's localDisk services refuse to start if
their path is missing; the fix adds them to the same mkdir script
that sets up worker bundle dirs. Originally discovered during the
v0.4 emdash live validation.
Deferred to v0.6
- The existing Bun R2 adapter still hits CF R2 passthrough, not the
local SeaweedFS sidecar. Matching the workerd-track defaults requires
reshaping the Bun deploy pipeline and shared S3 client helpers —
tracked with the rest of the Bun parity gap (DO, WorkerLoader, Cache
API). - SigV4 payload signing for streaming PUTs uses
UNSIGNED-PAYLOAD; the
headers are still authenticated so the wire remains tamper-evident,
but a future revision will add chunked signing for operators who
need end-to-end payload integrity over untrusted paths.
groundflare v0.4.0 — DigitalOcean, framework support, WorkerLoader
Headline: groundflare deploys real-world frameworks now. Astro SSR sites with custom build commands and static assets work end-to-end, validated live on DigitalOcean. emdash CMS's WorkerLoader-based plugin sandbox runs unmodified on workerd's open-source dynamic worker loading.
New: DigitalOcean provider
Second cloud provider after Hetzner. Full lifecycle (auth, regions, sizes, SSH keys, droplet create/get/list/destroy) verified live with bootstrap + deploy on a $6/mo SGP1 droplet.
provider = "digitalocean"in wrangler config or--provider digitaloceanCLI flag- 17 unit tests covering all API translation + error paths (401/404/429/500/network)
- IPv4 polling: DO assigns IPs asynchronously — provision stage now polls
getVPS()until an IPv4 appears (default 120s timeout) groundflare-estimategained DO as a target with live/v2/sizesprice refresh
New: framework support (Astro, Next.js, Remix, …)
Adopts wrangler's [build] section semantics so any framework that produces a Worker bundle works with zero groundflare-specific config:
[build]
command = "astro build"
[assets]
directory = "./dist"Pipeline: run [build].command (or auto-detect from pnpm-lock.yaml / yarn.lock / bun.lockb / default npm) → esbuild re-bundles multi-file output (Astro's dist/_worker.js/ with chunks/) into a single ES module → SCP bundle + capnp + Caddyfile + assets → Caddy file_server for static files, reverse_proxy to workerd for the rest.
[assets].directory is uploaded to /var/lib/groundflare/workers/<name>/assets/, with _worker.js/ filtered out (already deployed; would leak source if served).
New: WorkerLoader binding (CF Workers for Platforms compat)
workerd's open-source WorkerLoader API — dynamically compile + run Workers in isolated V8 isolates at runtime. Apps using this for plugin sandboxing (emdash, etc.) work without code changes:
[[worker_loaders]]
binding = "LOADER"- New
workerLoadercapnp binding (with optionalidfor shared isolate cache pools) - systemd unit gains
--experimentalflag (workerd gates the feature behind it) - Docker e2e: deploys a worker that uses
env.LOADER.get(name, codeCallback)to dynamically load a sub-worker; verifies 200 round-trip - emdash specifically uses
WorkerLoader.get(name, getCodeCallback), not the CF-proprietaryDispatchNamespace.get(name)— confirmed via source review. groundflare's binding is the exact API emdash expects.
New: native wrangler config compatibility
Reads more of the standard wrangler config surface so existing projects work unchanged:
[[routes]] custom_domain = true— domain resolution falls back to the first custom_domain route when[groundflare].domainisn't set- JSONC trailing commas — emdash's
wrangler.jsoncand many other CF projects rely on these worker_loaders— mapped from wrangler config to capnp bindings- Known-unsupported binding types (
ai,vectorize,browser,queues,hyperdrive,analytics_engine_datasets,send_email) detected and warned about — deploy continues with the remaining bindings rather than aborting
Bootstrap robustness (real-world VPS deploy validated)
Live testing on DigitalOcean surfaced a series of robustness issues, all fixed:
- cloud-init now installs workerd directly on the VPS by downloading the correct architecture (amd64/arm64) from npm registry — replaces the previous "scp local binary" approach which failed when operator is on macOS-arm64 and target is Linux-amd64
package_upgrade: trueremoved from cloud-init (saves 3-8 minutes on first boot; unattended-upgrades runs in background instead)ssh-authorized-keys→ssh_authorized_keys(cloud-init v18.3+ deprecation causeddegraded doneexit code 2)- wait-ssh stage retries SSH handshake until cloud-init finishes user setup; default timeout extended to 10 min for DO-class CPUs
- SSH ping timeout 5s → 30s (handshake is slow under cloud-init load)
StrictHostKeyChecking=nofor fresh VPS (host keys may regenerate during cloud-init)- SCP default timeout 30s → 60s;
ensureRemoteDir10s → 30s
Other
- Caddyfile:
persist_configonly emitted asoff— Caddy's adapter rejectspersist_config on(it was always invalid syntax) - Capnp + state directory paths normalized between bootstrap and deploy
- systemd
EnvironmentFileprefixed with-(load-if-exists) so a Worker without[vars]doesn't crash the service - ed25519 SSH keypair format fixed: was emitting PKCS#8 PEM which modern OpenSSH rejects for ed25519. Now emits OpenSSH's native format. Caught by Tier 3 e2e infrastructure.
- Tier 3 Docker e2e: 6 scenarios (smoke, bootstrap, deploy, Bun deploy, WorkerLoader, custom domain via routes)
- 824 unit + integration tests pass
Install
npm install -g groundflare@0.4.0
# or per-project:
npx groundflare@0.4.0 upgroundflare v0.3.0 — node:sqlite, three-track conformance
Highlights
- Drop better-sqlite3 — replaced with Node 22+'s built-in
node:sqlite. No more native-module build on install; no moreprebuild-installdeprecation warning. Engines bumped to>=22. - Three-track conformance — the same KV + D1 spec now runs against
node:sqlite(vitest),bun:sqlite(bun:test), AND real workerd (integration). Behavioral drift across any track fails CI. - Microbenchmark suite —
npm run bench(node:sqlite) +npm run bench:bun(bun:sqlite) for regression tracking. - Stress scripts —
npm run stress:bun/stress:workerdvia autocannon for HN-burst simulation.
Install
npm install -g groundflareBreaking changes
engines.nodebumped from>=20to>=22.better-sqlite3removed from dependencies.
See the full CHANGELOG for details.
create-groundflare-app v0.2.0 — Hono template
Adds the Hono template — a production-shaped REST API that runs on both Cloudflare Workers and groundflare with zero code changes.
npx create-groundflare-app my-api --template honoTemplates
| Template | Description |
|---|---|
minimal |
Hello-world Worker |
hono (new) |
Hono REST API with D1 CRUD + KV cache. Includes Deploy to CF button. |
Dual-target proof
The Hono template is integration-tested on every commit:
- Scaffold → substitutions applied
bundleWorker()→ esbuild succeeds (same pipeline asgroundflare deploy)analyzeWorkspace()→ no blockers (same pipeline asgroundflare bun analyze)spawnWorkerd()→ routes serve correct responses on real workerd
If step 4 passes, the code runs on Cloudflare Workers — workerd IS CF's runtime.
Zero-config start
The app serves / + /health with zero bindings configured. D1/KV routes return a helpful 501 with setup instructions until the binding is enabled.
cd my-api && npm install
npx wrangler dev # local dev (CF-compatible)
npx wrangler deploy # → Cloudflare
npx groundflare up # → your VPScreate-groundflare-app v0.1.0 — initial release
First release of the project scaffold.
npx create-groundflare-app my-workerTemplates
| Template | Description |
|---|---|
minimal (default) |
Hello-world Worker + commented-out [groundflare] section |
Next steps after scaffold
cd my-worker
npx groundflare bun analyze # check Bun-track compatibility
npx groundflare up # provision VPS + deploygroundflare v0.2.1 — packaging fix
Fix the packaging bug in v0.2.0 that made npx groundflare fail with ERR_MODULE_NOT_FOUND.
esbuild (deploy bundler), better-sqlite3 (Mirror-track KV/D1 driver), and workerd (binary staged onto the VPS) were in devDependencies but imported at runtime. Moved to dependencies.
No behavioural changes from v0.2.0; same feature set.
npm install -g groundflaregroundflare v0.2.0 — parallel release with the Bun track
Deprecated — use v0.2.1 or later. This release shipped
esbuild,better-sqlite3, andworkerdas devDependencies instead of dependencies, sonpx groundflarefails immediately withERR_MODULE_NOT_FOUND.
What v0.2.0 introduced
The first release shipping both runtime tracks from a single CLI:
- Mirror track (workerd) — zero code changes to existing CF Workers
- Bun track —
Bun.servewithbun:sqliteKV/D1 adapters + S3-compat R2 passthrough
Bun track highlights
BunKVAdapter/BunD1Adapteronbun:sqlite— same schema as Node-side adaptersBunR2Adapterwith self-contained SigV4 signer (no AWS SDK dep)- Shared conformance spec between
better-sqlite3(Node) andbun:sqlite(Bun) groundflare bun analyze— oxc-parser AST scan for compatibilitygroundflare bun prepare— flip[groundflare] runtime = "bun"in wrangler.toml- Bootstrap auto-installs Bun when
runtime = "bun" - Tier-3 Docker e2e for the Bun deploy loop
Measured performance
- $6/mo shared VPS: ~7,300 rps/binding, 0 errors through 1000-concurrent burst
- $21/mo shared: ~9,900 rps/binding
- $84/mo dedicated: ~9,000 rps/binding
# Don't install this version — use 0.2.1+
npm install -g groundflare@latest