From f353a244fa57f423d901a646986d327b0d7261b6 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 5 Nov 2026 17:15:25 +0200 Subject: [PATCH] fix(website): serve blog pages correctly, add serving regression test - Remove -s flag from serve in Dockerfile (SPA mode rewrote all blog URLs to the landing page) - Add website/tests/serving.mjs regression test - Run test in CI before Docker build - Document fix in CHANGELOG.md --- .github/workflows/stacker-website.yml | 11 +++ CHANGELOG.md | 4 + website/Dockerfile | 2 +- website/package.json | 3 +- website/tests/serving.mjs | 135 ++++++++++++++++++++++++++ 5 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 website/tests/serving.mjs diff --git a/.github/workflows/stacker-website.yml b/.github/workflows/stacker-website.yml index 53a12ca0..e58764c8 100644 --- a/.github/workflows/stacker-website.yml +++ b/.github/workflows/stacker-website.yml @@ -44,6 +44,17 @@ jobs: type=sha,prefix={{branch}}-,format=short type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Serving regression test + working-directory: ./website + run: | + npm ci + npm test + - name: Build and push uses: docker/build-push-action@v6 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0927ad98..06ae4ad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ All notable changes to this project will be documented in this file. - A root admin may smoke-clone an unpromoted bake (`channel: "unstable"`), matching the access that already allowed `POST /api/admin/bake`. +### Fixed — Website blog serving + +- `stacker.my/blog` and its article URLs serve the blog pages again: the website image no longer runs `serve` in SPA mode, which rewrote every blog path to the landing page. + ## [0.3.5] — 2026-10-19 ### Added — Managed server billing diff --git a/website/Dockerfile b/website/Dockerfile index 055b09a3..7e85af66 100644 --- a/website/Dockerfile +++ b/website/Dockerfile @@ -12,4 +12,4 @@ WORKDIR /app RUN npm install -g serve COPY --from=builder /app/dist ./dist EXPOSE 3456 -CMD ["serve", "-s", "dist", "-l", "3456"] +CMD ["serve", "dist", "-l", "3456"] diff --git a/website/package.json b/website/package.json index 9e3f76ce..ffaa2a22 100644 --- a/website/package.json +++ b/website/package.json @@ -5,7 +5,8 @@ "scripts": { "build": "tsc && cp src/index.html dist/ && cp src/styles.css dist/ && cp -r assets dist/ && cp -r src/blog dist/blog", "dev": "tsc --watch", - "serve": "npx serve dist -p 3456" + "serve": "npx serve dist -p 3456", + "test": "node tests/serving.mjs" }, "devDependencies": { "typescript": "^5.3.0", diff --git a/website/tests/serving.mjs b/website/tests/serving.mjs new file mode 100644 index 00000000..41dc752f --- /dev/null +++ b/website/tests/serving.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +// Regression test for the website serving contract. +// +// https://stacker.my/blog must serve the blog index page. The `serve` SPA flag +// (`-s` / `--single`) in the Dockerfile CMD rewrites every unmatched path -- +// including /blog and /blog/
-- to the root index.html, so the +// landing page is served at every blog URL. This test extracts the exact +// `serve` invocation from the Dockerfile CMD and asserts that the blog pages +// are served from their own files. +// +// Run from website/: npm test + +import { readFileSync, existsSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { setTimeout as sleep } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const PORT = 4173; +const BASE = `http://127.0.0.1:${PORT}`; + +function fail(message) { + console.error(`FAIL: ${message}`); + process.exit(1); +} + +function serveArgsFromDockerfile() { + const dockerfile = readFileSync(join(root, "Dockerfile"), "utf8"); + const match = dockerfile.match(/^CMD\s+(\[[^\n]*\])\s*$/m); + if (!match) fail("no JSON-array CMD found in Dockerfile"); + let argv; + try { + argv = JSON.parse(match[1]); + } catch { + fail(`CMD is not valid JSON: ${match[1]}`); + } + if (!argv[0] || !argv[0].endsWith("serve")) { + fail(`CMD does not invoke serve: ${argv.join(" ")}`); + } + if (argv.includes("-s") || argv.includes("--single")) { + console.error( + "note: CMD uses serve SPA mode (-s); it rewrites /blog to the root page" + ); + } + // Keep every CMD argument except the listen address: routing is + // port-independent and a fixed test port avoids clashing with a dev server. + const args = []; + for (let i = 1; i < argv.length; i++) { + if (argv[i] === "-l" || argv[i] === "--listen") { + i++; + continue; + } + args.push(argv[i]); + } + args.push("-l", String(PORT)); + return args; +} + +const dist = join(root, "dist"); +if (!existsSync(join(dist, "index.html"))) { + fail("dist/index.html missing -- run `npm run build` in website/ first"); +} +if (!existsSync(join(dist, "blog", "index.html"))) { + fail("dist/blog/index.html missing -- run `npm run build` in website/ first"); +} + +const serveBin = join(root, "node_modules", ".bin", "serve"); +if (!existsSync(serveBin)) { + fail("node_modules/.bin/serve missing -- run `npm ci` in website/ first"); +} + +const args = serveArgsFromDockerfile(); +console.log(`serving dist with: serve ${args.join(" ")}`); + +const server = spawn(serveBin, args, { cwd: root, stdio: "ignore" }); +server.on("error", (err) => fail(`could not start serve: ${err.message}`)); + +async function waitForServer() { + for (let i = 0; i < 100; i++) { + try { + await fetch(BASE, { redirect: "manual" }); + return; + } catch { + await sleep(100); + } + } + fail(`serve did not start listening on port ${PORT}`); +} + +const cases = [ + { + path: "/", + title: /Stacker /, + label: "landing page", + }, + { + path: "/blog", + title: /<title>Blog - Stacker<\/title>/, + label: "blog index", + hint: "serve SPA mode (-s) rewrites /blog to the root index.html", + }, + { + path: "/blog/ai-startup-hosting.html", + title: /<title>How to Deploy an AI-Built Startup/, + label: "blog article", + hint: "serve SPA mode (-s) rewrites blog article URLs to the root index.html", + }, +]; + +let failed = 0; +try { + await waitForServer(); + for (const c of cases) { + const res = await fetch(BASE + c.path, { redirect: "follow" }); + const body = await res.text(); + if (res.status !== 200) { + failed++; + console.error(`FAIL: GET ${c.path} -> HTTP ${res.status} (${c.label})`); + } else if (!c.title.test(body)) { + failed++; + console.error(`FAIL: GET ${c.path} does not serve the ${c.label}`); + if (c.hint) console.error(` ${c.hint}`); + } else { + console.log(`PASS: GET ${c.path} serves the ${c.label}`); + } + } +} finally { + server.kill(); +} + +if (failed > 0) { + process.exit(1); +} +console.log("serving contract OK");