From Flareact to Astro: a Cloudflare status page you can fork
My GitHub profile is mostly small, practical repos. The one with the clearest arc — and the best “I did not expect the deploy pipeline to fight me here” story — is cf-workers-status-page-1: a fork of eidam/cf-workers-status-page that still does the same job — HTTP checks on a schedule, history in KV, Slack / Telegram / Discord when status changes — but ships as an Astro app with SSR on Cloudflare Workers instead of the original Flareact + Workers Sites layout.
Live example: status.tikvite.org
This post is the opinionated version: why the migration was worth it, where Cloudflare politely ignored my wrangler.toml, and how to fork and deploy without losing an evening to “cron says Error and CPU time is basically zero.”
Why leave Flareact for Astro?
The upstream project was a good fit for its time: React on the edge, cron + KV, one Worker artifact. Today, maintaining that stack means babysitting a narrower React-on-Workers path and build plumbing that is not where the ecosystem is throwing documentation and fixes.
The migration goals were deliberately unsexy:
| Goal | Flareact-era pain | Astro-era payoff |
|---|---|---|
| UI delivery | “Everything is React” | HTML-first pages; React only where you opt in |
| Assets | Workers Sites mental overhead | ASSETS → dist/client, one mental model with the adapter |
| Cron + HTTP | Single bundle, easy to reason about | Same idea, but explicit fetch vs scheduled in a tiny wrapper |
| Future you | Touching UI risks touching edge glue | File routes, clear server boundaries, normal npm scripts |
Astro fits that: file-based routes, optional React (@astrojs/react), server endpoints when you need them, and an official Cloudflare adapter that speaks the same language as wrangler deploy — if you teach Wrangler which file is actually your entry (more on that below).
The spicy bit: Wrangler’s “redirected configuration”
Run wrangler deploy against this repo and Wrangler will cheerfully tell you:
Using redirected Wrangler configuration.
Configuration being used:dist/server/wrangler.json
Your hand-written wrangler.toml is still “the original user’s configuration,” but the effective deploy config is the JSON the Astro adapter emits. That file is great for Astro — and it is wrong for three things you probably assumed were covered by the TOML at the repo root:
-
maindefaults toentry.mjs(Astro’s fetch handler only). Your cron handler lives in a generatedcloudflare-worker.mjsthat imports Astro and the cron bundle — but if nobody setsmain, scheduled never runs. The dashboard shows Cron Triggers; every tick errors in microseconds because the runtime is invoking a Worker that does not exportscheduled. Ouch. -
kv_namespacesshows up empty in that JSON. Your KV binding inwrangler.tomldoes not magically merge into the redirected file. Deploy “succeeds”; the Worker has no KV at runtime. -
triggers.cronsis empty unless you patch it in. So you can stare at[triggers]inwrangler.tomlall day; production cron config may still be{}.
The fix is a small post-build script that rewrites dist/server/wrangler.json after astro build: set main, inject KV id, merge cron expressions from wrangler.toml. The important lines look like this (trimmed from the real script):
const path = resolve('dist/server/wrangler.json')
const j = JSON.parse(readFileSync(path, 'utf8'))
j.main = 'cloudflare-worker.mjs'
j.kv_namespaces = [
{ binding: 'KV_STATUS_PAGE', id, preview_id: id },
]
j.triggers = { ...(j.triggers || {}), crons: cronsFromWranglerToml() }
writeFileSync(path, JSON.stringify(j, null, 2))CI sets KV_NAMESPACE_ID from the Cloudflare API after ensuring the namespace exists, then runs this script before wrangler deploy. That is not cargo-cult configuration — it is the difference between “pretty dashboard” and “actually works.”
What actually runs at the edge
After npm run build, a script writes the Worker entry that Cloudflare should load as main:
import astro from './entry.mjs';
import { processCronTrigger } from './cron-bundle.mjs';
export default {
async fetch(request, env, ctx) {
return astro.fetch(request, env, ctx);
},
async scheduled(event, env, ctx) {
return processCronTrigger(event, env, ctx);
},
};Two details that matter for correctness, not trivia:
ctx, notevent, forwaitUntil. In module Workers,waitUntillives on the execution context (third argument). Notifications usectx.waitUntil(...). If you useevent.waitUntil, you getundefinedand the cron row goes red the moment a Slack webhook fires.- Notification promises are wrapped so a bad webhook does not fail the whole cron tick — Cloudflare’s cron UI treats a rejected
waitUntilpromise as a failed run.
Your root wrangler.toml still describes intent (assets, cron schedule, production worker name). For example:
name = "cf-workers-status-page"
main = "dist/server/cloudflare-worker.mjs"
compatibility_flags = ["nodejs_compat"]
[assets]
binding = "ASSETS"
directory = "./dist/client"
[triggers]
crons = ["* * * * *"]
[env.production]
name = "cf-workers-status-page"The inject step reconciles that intent with what the adapter wrote to JSON. Think of it as a merge commit between “what Astro built” and “what operations need.”
The monitoring layer (what you are really running)
Conceptually: define monitors, run checks on a schedule, persist state and notify humans.
Probes — timeouts with AbortSignal, optional retries, expectStatus as one code or a list, optional responseContains / responseNotContains, headers, maxResponseTimeMs for a degraded state. Monitors are checked in parallel each cron tick; watch subrequest limits if you go wild.
State — KV holds the rolling history; the UI reads what cron writes.
Alerts — Slack, Telegram, Discord; alertAfterConsecutiveFailures (global or per monitor) so you do not wake the channel for a single flaky packet.
Config is YAML at the repo root; build emits JSON the Worker imports. A minimal monitor looks like:
settings:
url: "https://status.example.com"
defaultTimeoutMs: 25000
alertAfterConsecutiveFailures: 2
monitors:
- id: api-prod
name: Public API
url: "https://api.example.com/health"
method: GET
expectStatus: [200, 204]
retries: 2
retryDelayMs: 400The fork’s UI goes beyond a traffic light: fleet-style views, latency context, probe age, fail streaks — the stuff you want when someone asks “was that a blip or a trend?”
Full field lists and labels live in the repo README.
Fork and deploy for your infrastructure
1. Fork or clone AndreaTrendafilov/cf-workers-status-page-1.
2. Edit config.yaml — especially settings.url (must match your public status URL). scripts/emit-config.mjs regenerates src/generated/config.json on dev/build; do not hand-edit that JSON.
3. Cloudflare: Workers + KV. The GitHub workflow can create/list the KV_STATUS_PAGE namespace and feed its id into the inject step.
4. Secrets for Actions (minimum):
CF_API_TOKEN,CF_ACCOUNT_ID- Optional:
SECRET_*for Slack / Discord / Telegram so the workflow runswrangler secret putafter deploy.
Push main — build, inject, deploy, secrets, KV GC.
5. Attach your custom hostname to the Worker in the dashboard (or Terraform, or tears — your stack, your rules).
6. Local deploy, if you insist:
npm ci
npm run build
# Same inject CI runs, with KV_NAMESPACE_ID from your account:
KV_NAMESPACE_ID=your_namespace_id node scripts/inject-kv-binding.mjs
npx wrangler deploy --env productionIf you skip the inject step locally, you get the same failure modes as before: no KV, no cron, or wrong entry — only faster because you did it to yourself on purpose.
Closing
This is not a reinvention of monitoring. It is edge-native uptime with a UI you can maintain without feeling like you are debugging a framework museum.
If you want history, read the upstream project. If you want Astro + Workers with the sharp edges filed down in my fork, open an issue on cf-workers-status-page-1.
Elsewhere: tikvi-assets next to the Tikvite homelab stack, and tst-rancher-charts for Helm-shaped day-job scars — same preference for small, inspectable repos.