Skip to content

Releases: solcreek/groundflare

groundflare v0.5.5 — Bun /__health fix + capstan extraction

Choose a tag to compare

@linyiru linyiru released this 21 May 02:40

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 / destroy behave identically to v0.5.4.
  • Anyone deep-importing groundflare/dist/provider/... (you probably weren't — those paths were never officially exported): switch to import { 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.5

Published via OIDC Trusted Publishers with SLSA provenance attestation.

groundflare v0.5.1 — R2 follow-ups + Bun track parity

Choose a tag to compare

@linyiru linyiru released this 17 Apr 14:13

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:8333 anonymous. Matches the
    workerd track's zero-config self-host story.
  • External: set groundflare.endpoint + credential secrets on the
    binding; deploy resolves the *_secret names to values and writes
    them to /etc/groundflare/environment as R2_<BINDING>_* KEY=VALUE
    lines (root:root 0600). systemd loads them at boot; the Bun shim's
    makeR2Facade reads them from process.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 inline import('…').Type annotations and
    three unused imports snuck past local npm 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

Choose a tag to compare

@linyiru linyiru released this 17 Apr 12:54

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 via inline.
  • Resolves *_secret references from FileSecretStore
    (~/.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 Node http.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's groundflareOwnedDirs now 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

Choose a tag to compare

@linyiru linyiru released this 16 Apr 19:14

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 digitalocean CLI 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-estimate gained DO as a target with live /v2/sizes price 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 workerLoader capnp binding (with optional id for shared isolate cache pools)
  • systemd unit gains --experimental flag (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-proprietary DispatchNamespace.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].domain isn't set
  • JSONC trailing commas — emdash's wrangler.jsonc and 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: true removed from cloud-init (saves 3-8 minutes on first boot; unattended-upgrades runs in background instead)
  • ssh-authorized-keysssh_authorized_keys (cloud-init v18.3+ deprecation caused degraded done exit 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=no for fresh VPS (host keys may regenerate during cloud-init)
  • SCP default timeout 30s → 60s; ensureRemoteDir 10s → 30s

Other

  • Caddyfile: persist_config only emitted as off — Caddy's adapter rejects persist_config on (it was always invalid syntax)
  • Capnp + state directory paths normalized between bootstrap and deploy
  • systemd EnvironmentFile prefixed 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 up

groundflare v0.3.0 — node:sqlite, three-track conformance

Choose a tag to compare

@linyiru linyiru released this 16 Apr 04:12

Highlights

  • Drop better-sqlite3 — replaced with Node 22+'s built-in node:sqlite. No more native-module build on install; no more prebuild-install deprecation 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 suitenpm run bench (node:sqlite) + npm run bench:bun (bun:sqlite) for regression tracking.
  • Stress scriptsnpm run stress:bun / stress:workerd via autocannon for HN-burst simulation.

Install

npm install -g groundflare

Breaking changes

  • engines.node bumped from >=20 to >=22.
  • better-sqlite3 removed from dependencies.

See the full CHANGELOG for details.

create-groundflare-app v0.2.0 — Hono template

Choose a tag to compare

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 hono

Templates

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:

  1. Scaffold → substitutions applied
  2. bundleWorker() → esbuild succeeds (same pipeline as groundflare deploy)
  3. analyzeWorkspace() → no blockers (same pipeline as groundflare bun analyze)
  4. 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 VPS

create-groundflare-app v0.1.0 — initial release

Choose a tag to compare

First release of the project scaffold.

npx create-groundflare-app my-worker

Templates

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 + deploy

groundflare v0.2.1 — packaging fix

Choose a tag to compare

@linyiru linyiru released this 16 Apr 04:32

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 groundflare

groundflare v0.2.0 — parallel release with the Bun track

Choose a tag to compare

@linyiru linyiru released this 16 Apr 04:31

Deprecated — use v0.2.1 or later. This release shipped esbuild, better-sqlite3, and workerd as devDependencies instead of dependencies, so npx groundflare fails immediately with ERR_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 trackBun.serve with bun:sqlite KV/D1 adapters + S3-compat R2 passthrough

Bun track highlights

  • BunKVAdapter / BunD1Adapter on bun:sqlite — same schema as Node-side adapters
  • BunR2Adapter with self-contained SigV4 signer (no AWS SDK dep)
  • Shared conformance spec between better-sqlite3 (Node) and bun:sqlite (Bun)
  • groundflare bun analyze — oxc-parser AST scan for compatibility
  • groundflare 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