diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5717c3d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.gitlab-ci.yml +node_modules +public +data +*.sqlite +.idea +docs +.claude +README.md +CLAUDE.md diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..5e6c881 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,127 @@ +# Event Clock CI/CD +# +# Quality gates run on every push; the image is built only when they pass, and +# published only from the default branch. Nothing here reaches the network for +# source data — the build parses checked-in fixtures, so CI is hermetic and a +# wiki being down never turns the pipeline red. + +stages: + - check + - build + - publish + +default: + image: oven/bun:1.3-alpine + interruptible: true + cache: + key: + files: + - bun.lock + paths: + - node_modules/ + +variables: + # Full history is not needed; a shallow clone is faster. + GIT_DEPTH: "20" + IMAGE: $CI_REGISTRY_IMAGE + +.bun-deps: &bun-deps + before_script: + - bun install --frozen-lockfile + +# ---- check ----------------------------------------------------------------- + +typecheck: + stage: check + <<: *bun-deps + script: + - bun run typecheck + +test: + stage: check + <<: *bun-deps + script: + - bun test + # Tests are offline by design: no fixture is re-fetched, so a red build always + # means the code changed, never that a source was unreachable. + +feed: + stage: check + <<: *bun-deps + script: + - bun run build:feed + # A source that silently stops yielding events is the failure mode this + # pipeline exists to catch, so assert the feed is not empty or truncated. + - | + bun -e ' + const feed = await Bun.file("public/data/events.v1.json").json(); + const games = new Set(feed.events.map((e) => e.game)); + console.log(`${feed.events.length} events across ${games.size} games`); + if (feed.events.length < 20) { + throw new Error(`feed collapsed to ${feed.events.length} events`); + } + const undated = feed.events.filter((e) => !e.startsAt); + if (undated.length > 0) throw new Error("events without a start date"); + ' + artifacts: + paths: + - public/data/events.v1.json + expire_in: 1 week + +# ---- build ----------------------------------------------------------------- + +site: + stage: build + <<: *bun-deps + needs: [typecheck, test] + script: + - bun run build + artifacts: + paths: + - public/ + expire_in: 1 week + +container: + stage: build + needs: [typecheck, test] + image: docker:27 + services: + - docker:27-dind + before_script: + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY" + script: + - docker build --pull -t "$IMAGE:$CI_COMMIT_SHORT_SHA" . + - docker push "$IMAGE:$CI_COMMIT_SHORT_SHA" + rules: + # Building an image for every branch fills the registry; do it where the + # artefact could actually be deployed. + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + - if: $CI_COMMIT_TAG + +# ---- publish --------------------------------------------------------------- + +tag-latest: + stage: publish + needs: [container] + image: docker:27 + services: + - docker:27-dind + before_script: + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY" + script: + - docker pull "$IMAGE:$CI_COMMIT_SHORT_SHA" + - docker tag "$IMAGE:$CI_COMMIT_SHORT_SHA" "$IMAGE:latest" + - docker push "$IMAGE:latest" + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + +pages: + stage: publish + needs: [site] + script: + - mv public .public && mv .public public + artifacts: + paths: + - public + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4e76691 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# Build the static site, then serve it from a distroless-ish runtime. +# +# Two stages so the image ships the built assets and nothing else: no source, +# no fixtures, no toolchain. The build is fully offline — it parses checked-in +# fixtures rather than fetching anything — so the image is reproducible and +# needs no network at build time. + +FROM oven/bun:1.3-alpine AS build +WORKDIR /app + +# Dependencies first, so a source-only change reuses this layer. +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile + +COPY tsconfig.json index.html ./ +COPY src ./src +COPY scripts ./scripts +COPY fixtures ./fixtures +COPY test ./test + +# Fail the image on a type error or a failing test rather than shipping it. +RUN bun run typecheck && bun test +RUN bun run build + + +FROM oven/bun:1.3-alpine AS runtime +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 + +# Run unprivileged. The bun image ships a `bun` user; use it rather than root. +COPY --from=build --chown=bun:bun /app/public ./public +COPY --from=build --chown=bun:bun /app/serve.ts ./serve.ts +USER bun + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD bun -e "await fetch('http://127.0.0.1:'+(process.env.PORT??3000)+'/api/health').then(r=>{if(!r.ok)process.exit(1)})" + +CMD ["bun", "run", "serve.ts"] diff --git a/README.md b/README.md index b79de00..183acd9 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,21 @@ against real parsed data, and it produces exactly the shape the server will serv ```bash bun install -bun run build # parse fixtures → feed, then compile CSS and JS -bunx serve public # or any static file server +bun run dev # build, then serve on :3000 ``` Then open . +Or with Docker: + +```bash +docker build -t event-clock . +docker run --rm -p 3000:3000 event-clock +``` + +The image build runs typecheck and tests, and parses only checked-in fixtures — no network, so it is +reproducible and a wiki being down never breaks it. + ## Commands ```bash @@ -137,6 +146,13 @@ so iteration never re-fetches. Every event links back to its source. | `docs/DATA-MODEL.md` | Event schema, SQLite tables, client storage | | `docs/INGESTION.md` | Parser/adapter/merge layers, pipeline stages, review gate | +## CI + +`.gitlab-ci.yml` runs typecheck, tests and a feed sanity check on every push, then builds and +publishes a container image from the default branch. The feed job fails if the event count collapses +— a source that quietly stops yielding events is the failure mode a parser-only pipeline is most +prone to, and nothing else surfaces it. + ## Licence Not yet chosen. Event data belongs to the sources it came from and is linked back on every event. diff --git a/package.json b/package.json index 55cec71..f7be3be 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,9 @@ "build:css": "bunx @tailwindcss/cli -i src/client/styles.css -o public/styles.css --minify", "build:js": "bun build src/client/main.tsx --outfile public/main.js --minify", "build": "bun run build:feed && bun run build:css && bun run build:js && bun run build:static", - "dev": "bun run build && bunx serve public -l 3000", - "build:static": "cp index.html public/index.html && cp src/client/sw.js public/sw.js && cp src/client/manifest.webmanifest public/manifest.webmanifest && cp src/client/icon.svg public/icon.svg" + "dev": "bun run build && bun run serve.ts", + "build:static": "cp index.html public/index.html && cp src/client/sw.js public/sw.js && cp src/client/manifest.webmanifest public/manifest.webmanifest && cp src/client/icon.svg public/icon.svg", + "serve": "bun run serve.ts" }, "dependencies": { "react": "^19.2.8", diff --git a/serve.ts b/serve.ts new file mode 100644 index 0000000..d778280 --- /dev/null +++ b/serve.ts @@ -0,0 +1,96 @@ +/** + * Static file server for the built app. + * + * A placeholder for the Bun server in docs/ARCHITECTURE.md: it serves what is + * in public/ and nothing else. When the real server lands it will add /api/* + * and the ingest scheduler, and this file goes away. + * + * Deliberately minimal — no framework, no dependencies beyond Bun. + */ +import { resolve } from "node:path"; + +const PORT = Number(process.env.PORT ?? 3000); +const ROOT = "public"; +const ROOT_DIR = resolve(ROOT); + +/** Long-lived for fingerprint-free assets is wrong; keep it short and revalidate. */ +const CACHE: Record = { + ".html": "public, max-age=0, must-revalidate", + ".json": "public, max-age=300", + ".js": "public, max-age=3600", + ".css": "public, max-age=3600", + ".svg": "public, max-age=86400", + ".webmanifest": "public, max-age=3600", +}; + +function extname(path: string): string { + const i = path.lastIndexOf("."); + return i === -1 ? "" : path.slice(i); +} + +const server = Bun.serve({ + port: PORT, + async fetch(request) { + const url = new URL(request.url); + + if (url.pathname === "/api/health") { + const feed = Bun.file(resolve(ROOT_DIR, "data/events.v1.json")); + const ok = await feed.exists(); + return Response.json( + { status: ok ? "ok" : "no-feed", generatedAt: new Date().toISOString() }, + { status: ok ? 200 : 503 }, + ); + } + + let decoded: string; + try { + decoded = decodeURIComponent(url.pathname); + } catch { + return new Response("Bad request", { status: 400 }); + } + if (decoded.includes("\0")) { + return new Response("Bad request", { status: 400 }); + } + + const path = decoded === "/" ? "/index.html" : decoded; + + // Confine every read to public/ by resolving the path and checking it is + // still inside the root. String-matching ".." is not enough: encodings and + // URL normalisation both change what the string looks like, and only the + // resolved path tells the truth about which file would be opened. + const resolved = resolve(ROOT_DIR, `.${path}`); + if (resolved !== ROOT_DIR && !resolved.startsWith(`${ROOT_DIR}/`)) { + return new Response("Bad request", { status: 400 }); + } + + const file = Bun.file(resolved); + + if (await file.exists()) { + return new Response(file, { + headers: { + "cache-control": CACHE[extname(path)] ?? "public, max-age=600", + // The service worker must never be served stale, or a deploy can be + // pinned by an old worker indefinitely. + ...(path === "/sw.js" + ? { "cache-control": "no-cache", "service-worker-allowed": "/" } + : {}), + }, + }); + } + + // Single-page app: unknown paths fall back to the shell so client routing + // and deep links work. Anything under /data or /api is a genuine 404. + if (!path.startsWith("/data") && !path.startsWith("/api")) { + const shell = Bun.file(resolve(ROOT_DIR, "index.html")); + if (await shell.exists()) { + return new Response(shell, { + headers: { "cache-control": "public, max-age=0, must-revalidate" }, + }); + } + } + + return new Response("Not found", { status: 404 }); + }, +}); + +console.log(`Event Clock serving ${ROOT} on http://localhost:${server.port}`); diff --git a/test/serve.test.ts b/test/serve.test.ts new file mode 100644 index 0000000..c84e3e7 --- /dev/null +++ b/test/serve.test.ts @@ -0,0 +1,81 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; + +/** + * The static server is small, but it reads from the filesystem based on a + * user-supplied path, so its confinement is worth pinning down. + */ +let proc: Bun.Subprocess; +let base: string; + +beforeAll(async () => { + const port = 3200 + Math.floor(Math.random() * 300); + base = `http://127.0.0.1:${port}`; + proc = Bun.spawn(["bun", "run", "serve.ts"], { + env: { ...process.env, PORT: String(port) }, + stdout: "ignore", + stderr: "ignore", + }); + + for (let i = 0; i < 50; i += 1) { + try { + await fetch(`${base}/api/health`); + return; + } catch { + await Bun.sleep(100); + } + } + throw new Error("server did not start"); +}); + +afterAll(() => { + proc.kill(); +}); + +describe("static server", () => { + test("serves the shell and the feed", async () => { + expect((await fetch(`${base}/`)).status).toBe(200); + const feed = await fetch(`${base}/data/events.v1.json`); + expect(feed.status).toBe(200); + expect((await feed.json()).schemaVersion).toBe(1); + }); + + test("reports health", async () => { + const res = await fetch(`${base}/api/health`); + expect(res.status).toBe(200); + expect((await res.json()).status).toBe("ok"); + }); + + test("falls back to the shell for unknown routes", async () => { + const res = await fetch(`${base}/deep/link`); + expect(res.status).toBe(200); + expect(await res.text()).toContain(""); + }); + + test("404s missing data rather than serving the shell", async () => { + // A JSON fetch that silently receives HTML is far harder to debug than a + // clean 404. + expect((await fetch(`${base}/data/nope.json`)).status).toBe(404); + }); + + test("never serves a file outside public/", async () => { + for (const path of [ + "/..%2fpackage.json", + "/..%2f..%2fetc/passwd", + "/%2e%2e/package.json", + "/%2e%2e%2f%2e%2e%2fpackage.json", + ]) { + const res = await fetch(`${base}${path}`); + const body = await res.text(); + // Either refused, or normalised to something inside public/ — but never + // the repository file itself. + expect(body).not.toContain('"name": "gacha-event-tracker"'); + expect(body).not.toContain("root:x:0:0"); + } + }); + + test("keeps the service worker uncached", async () => { + // A stale worker can pin an old deploy indefinitely. + const res = await fetch(`${base}/sw.js`); + expect(res.headers.get("cache-control")).toBe("no-cache"); + }); +});