Scaffold quc app shell, health check and Cloudflare deploy #237
No files matched your search
@@ -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.
|
||||||
@@ -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")
|
||||||
@@ -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);
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user