Scaffold quc app shell, health check and Cloudflare deploy #237

Merged
teahubxyz merged 6 commits from time/card-221 into main 2026-10-22 08:40:29 +00:00
6 changed files with 187 additions and 0 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
# quc
App shell + API in one Cloudflare Worker (`src/worker.js`), live at https://quc.yolopreneur.lol/. No dependencies, no build step.
- `npm start` serves it on :8787; `npm test` runs the tests; `npm run deploy` (needs `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`) deploys and waits for `/api/health`.
- `GET /api/health` → `{ok, rev}`. `GET /chair.svg` is the chair-in-the-corner motif (also inlined in the shell).
## Pipeline (needs a human: workers can't edit `.github/workflows`)
Add `.github/workflows/quc.yml`:
```yaml
name: quc
on:
push: { branches: [main], paths: ["quc/**"] }
workflow_dispatch:
permissions: { contents: read }
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 6
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm test --prefix quc
- run: python3 quc/deploy.py
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} # still "missing" in SECRETS.md
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
```
and, in `ci.yml`, `- run: npm test --prefix quc` so PRs run the quc tests.
+54
View File
@@ -0,0 +1,54 @@
"""Deploy quc to quc.<TIME_VENTURE_DOMAIN> (default yolopreneur.lol) as the Worker `time-quc`, then wait for /api/health.
CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... python3 quc/deploy.py
Reuses .github/time/cloudflare.py. Idempotent: the script is replaced in place, the custom domain re-attached.
"""
from __future__ import annotations
import hashlib
import json
import os
import sys
import time
import urllib.request
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / ".github" / "time"))
import cloudflare as cf # noqa: E402
NAME = "time-quc"
SRC = HERE / "src" / "worker.js"
def healthy(url: str, tries: int = 18) -> bool:
for _ in range(tries):
try:
with urllib.request.urlopen(url + "api/health", timeout=10) as r:
if json.load(r).get("ok") is True:
return True
except Exception: # noqa: BLE001 not up yet (DNS, cert, cold start): keep polling
pass
time.sleep(10)
return False
def deploy() -> dict:
if not cf.enabled():
sys.exit("CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are needed")
os.environ.setdefault("TIME_VENTURE_DOMAIN", "yolopreneur.lol")
code = SRC.read_bytes()
rev = hashlib.sha256(code).hexdigest()[:12]
meta = {"main_module": "worker.js", "compatibility_date": cf.COMPAT,
"bindings": [{"type": "plain_text", "name": "REV", "text": rev}]}
data, ctype = cf.multipart([("metadata", "application/json", json.dumps(meta).encode()),
("worker.js", "application/javascript+module", code)])
cf.cf("PUT", f"{cf.account()}/workers/scripts/{NAME}", data=data, ctype=ctype)
return {"url": cf.attach(NAME, cf.host("quc")), "worker": NAME, "rev": rev}
if __name__ == "__main__":
out = deploy()
print(json.dumps(out, indent=1))
sys.exit(0 if healthy(out["url"]) else "deployed, but /api/health never answered ok")
+9
View File
@@ -0,0 +1,9 @@
// Run the Worker locally: `npm start` in quc/, then http://localhost:8787/
import { createServer } from "node:http";
import worker from "./src/worker.js";
createServer(async (q, s) => {
const r = await worker.fetch(new Request(`http://${q.headers.host}${q.url}`, { method: q.method }), { REV: "dev" });
s.writeHead(r.status, Object.fromEntries(r.headers));
s.end(Buffer.from(await r.arrayBuffer()));
}).listen(process.env.PORT || 8787);
+12
View File
@@ -0,0 +1,12 @@
{
"name": "quc",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "quc.yolopreneur.lol: app shell + API in one Cloudflare Worker. No dependencies, no build step.",
"scripts": {
"start": "node dev.mjs",
"test": "node --test",
"deploy": "python3 deploy.py"
}
}
+52
View File
@@ -0,0 +1,52 @@
// quc: app shell + API in one Worker (no build step; the same module runs on Cloudflare and under dev.mjs).
// GET / the shell GET /chair.svg the chair-in-the-corner motif
// GET /api/health {ok, rev} anything else 404
// Fonts: system stacks on purpose (no webfont files, no third-party requests). shortcut: swap in self-hosted fonts when the brand picks them.
export const CHAIR = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" role="img" aria-label="A chair in the corner of the room">
<path d="M0 118h120M118 0v120" stroke="currentColor" stroke-width="2" fill="none" opacity=".35"/>
<g fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round">
<path d="M44 20 40 66M44 20h30l6 46M40 66h46M44 66l-4 44M82 66l4 44M66 66l-2 44"/></g></svg>`;
export const PAGE = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>quc</title>
<meta name="description" content="quc">
<style>
:root{--bg:#faf9f6;--fg:#1d1d1b;--mute:#6b6a65;--line:#e3e1da;--accent:#2f6fdf;--serif:ui-serif,Georgia,"Times New Roman",serif;--sans:system-ui,-apple-system,"Segoe UI",sans-serif}
@media (prefers-color-scheme:dark){:root{--bg:#161614;--fg:#ecebe6;--mute:#9b9a94;--line:#2c2b28;--accent:#7aa5ff}}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;min-height:100dvh;display:flex;flex-direction:column;background:var(--bg);color:var(--fg);font:16px/1.5 var(--sans)}
header,footer{padding:16px;max-width:960px;width:100%;margin:0 auto}
header{border-bottom:1px solid var(--line);font:600 20px var(--serif)}
main{flex:1;max-width:960px;width:100%;margin:0 auto;padding:32px 16px 160px}
h1{font:600 clamp(28px,6vw,48px)/1.1 var(--serif);margin:0 0 12px}
p{margin:0 0 16px;color:var(--mute)}
#health::before{content:"";display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px;background:var(--mute)}
#health[data-ok=true]::before{background:#2a9d4a}#health[data-ok=false]::before{background:#d33}
.chair{position:fixed;right:0;bottom:0;width:clamp(96px,22vw,160px);color:var(--fg);pointer-events:none}
</style></head><body>
<header>quc</header>
<main><h1>Coming together.</h1><p>The app shell is up. Nothing to do here yet.</p><p id="health">checking…</p></main>
<footer></footer>
<img class="chair" src="/chair.svg" alt="">
<script>
// the SVG uses currentColor, which an <img> cannot inherit: inline it instead
fetch("/chair.svg").then((r) => r.text()).then((s) => { const i = document.querySelector(".chair"), d = document.createElement("div"); d.className = "chair"; d.innerHTML = s; d.firstElementChild.setAttribute("aria-hidden", "true"); i.replaceWith(d); }).catch(() => {});
const h = document.getElementById("health");
fetch("/api/health").then((r) => r.json()).then((j) => { h.dataset.ok = !!j.ok; h.textContent = j.ok ? "API healthy" : "API unhealthy"; })
.catch(() => { h.dataset.ok = false; h.textContent = "API unreachable"; });
</script></body></html>`;
const headers = (type, extra = {}) => ({ "content-type": type, ...extra });
export default {
async fetch(req, env = {}) {
const { pathname } = new URL(req.url);
if (req.method !== "GET" && req.method !== "HEAD") return new Response("method not allowed", { status: 405 });
if (pathname === "/api/health") return Response.json({ ok: true, rev: env.REV || "" }, { headers: { "cache-control": "no-store" } });
if (pathname === "/chair.svg") return new Response(CHAIR, { headers: headers("image/svg+xml", { "cache-control": "public, max-age=3600" }) });
if (pathname === "/" || pathname === "/index.html") return new Response(PAGE, { headers: headers("text/html; charset=utf-8") });
return new Response("not found", { status: 404 });
},
};
+27
View File
@@ -0,0 +1,27 @@
import test from "node:test";
import assert from "node:assert/strict";
import worker from "../src/worker.js";
const get = (path, env, method = "GET") => worker.fetch(new Request("http://x" + path, { method }), env);
test("health reports ok and the revision", async () => {
const r = await get("/api/health", { REV: "abc" });
assert.equal(r.status, 200);
assert.deepEqual(await r.json(), { ok: true, rev: "abc" });
});
test("shell is responsive html that loads the motif", async () => {
const r = await get("/");
const html = await r.text();
assert.match(r.headers.get("content-type"), /text\/html/);
assert.match(html, /name="viewport" content="width=device-width/);
assert.match(html, /chair\.svg/);
});
test("motif is an svg, unknown paths 404, writes are refused", async () => {
const svg = await get("/chair.svg");
assert.equal(svg.headers.get("content-type"), "image/svg+xml");
assert.match(await svg.text(), /^<svg/);
assert.equal((await get("/nope")).status, 404);
assert.equal((await get("/api/health", {}, "POST")).status, 405);
});