diff --git a/quc/README.md b/quc/README.md new file mode 100644 index 0000000..68b06ac --- /dev/null +++ b/quc/README.md @@ -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. diff --git a/quc/deploy.py b/quc/deploy.py new file mode 100644 index 0000000..0cae32e --- /dev/null +++ b/quc/deploy.py @@ -0,0 +1,54 @@ +"""Deploy quc to quc. (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") diff --git a/quc/dev.mjs b/quc/dev.mjs new file mode 100644 index 0000000..a3520bc --- /dev/null +++ b/quc/dev.mjs @@ -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); diff --git a/quc/package.json b/quc/package.json new file mode 100644 index 0000000..1136e98 --- /dev/null +++ b/quc/package.json @@ -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" + } +} diff --git a/quc/src/worker.js b/quc/src/worker.js new file mode 100644 index 0000000..c61a1fd --- /dev/null +++ b/quc/src/worker.js @@ -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 = ` + + +`; + +export const PAGE = ` + +quc + + +
quc
+

Coming together.

The app shell is up. Nothing to do here yet.

checking…

+ + +`; + +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 }); + }, +}; diff --git a/quc/test/worker.test.js b/quc/test/worker.test.js new file mode 100644 index 0000000..6a75bb6 --- /dev/null +++ b/quc/test/worker.test.js @@ -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(), /^