All checks were successful
deploy / Build api + worker (static musl) (push) Successful in 6m11s
deploy / Build prerendered web (push) Successful in 7m28s
deploy / Deploy web to oolon (push) Successful in 55s
deploy / Deploy moments-api to nikola (push) Successful in 59s
deploy / Deploy moments-worker to frootmig (push) Successful in 1m1s
The fedora-44 runner reaches the public internet (the CV gist on api.github.com prerendered fine) but not the public rob.tn — split-horizon DNS — so every moments-API-sourced route baked empty loading states while the build still "succeeded". prefetchQuery swallows fetch errors, so the empty snapshot shipped silently. - Point VITE_API_BASE at the internal API the deploy already reaches (http://nikola.kosherinata.internal:42424/v1), verified reachable over the mesh. Only affects the SSR/prerender build; the browser bundle still uses the relative /api/v1. - run-prerender.mjs now aborts (exit 1) if zero dynamic routes are enumerated, i.e. the API was unreachable — so an empty snapshot can never deploy again. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
144 lines
5.5 KiB
JavaScript
144 lines
5.5 KiB
JavaScript
// Drives the SSR bundle (built by `vite build --ssr`) to bake one static HTML
|
|
// file per route into dist/. Reads the client build's dist/index.html as the
|
|
// template, then for each route injects: route <title>/description/og tags, a
|
|
// schema.org JSON-LD block, the server-rendered markup into #root, and the
|
|
// dehydrated react-query cache as window.__RQ_STATE__ for hydration.
|
|
//
|
|
// nginx serves these via `try_files $uri $uri/ /index.html`: /cv → cv/index.html,
|
|
// /blog/<slug> → blog/<slug>/index.html, etc., with unbaked routes (e.g.
|
|
// /activity/:timespan) SPA-falling-back to the home page and rendering client-side.
|
|
|
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const distDir = join(here, 'dist');
|
|
const SITE_URL = 'https://rob.tn';
|
|
|
|
const { collectRoutes, renderRoute } = await import(
|
|
pathToFileURL(join(here, 'dist-server', 'entry-server.js')).href
|
|
);
|
|
|
|
const escapeText = (s) =>
|
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
const escapeAttr = (s) => escapeText(s).replace(/"/g, '"');
|
|
|
|
// JS line/paragraph separators are valid in JSON strings but illegal in a
|
|
// <script>; written here as escape sequences so the source file stays ASCII.
|
|
const JS_LINE_SEPARATORS = new RegExp('[\u2028\u2029]', 'g');
|
|
|
|
/** Safe for embedding JSON inside an inline <script>: neutralise `<` (so a
|
|
* `</script>` in the data can't close the tag) and the line separators. */
|
|
const serialize = (value) =>
|
|
JSON.stringify(value)
|
|
.replace(/</g, '\\u003c')
|
|
.replace(JS_LINE_SEPARATORS, (c) => '\\u' + c.charCodeAt(0).toString(16));
|
|
|
|
function replaceOrAppendHead(html, regex, tag) {
|
|
return regex.test(html)
|
|
? html.replace(regex, tag)
|
|
: html.replace('</head>', ` ${tag}\n </head>`);
|
|
}
|
|
|
|
function applyHead(html, head, url) {
|
|
let out = html.replace(
|
|
/<title>[\s\S]*?<\/title>/,
|
|
`<title>${escapeText(head.title)}</title>`,
|
|
);
|
|
|
|
const desc = escapeAttr(head.description);
|
|
out = replaceOrAppendHead(
|
|
out,
|
|
/<meta\s+name="description"[^>]*>/i,
|
|
`<meta name="description" content="${desc}" />`,
|
|
);
|
|
out = replaceOrAppendHead(
|
|
out,
|
|
/<meta\s+property="og:title"[^>]*>/i,
|
|
`<meta property="og:title" content="${escapeAttr(head.title)}" />`,
|
|
);
|
|
out = replaceOrAppendHead(
|
|
out,
|
|
/<meta\s+property="og:description"[^>]*>/i,
|
|
`<meta property="og:description" content="${desc}" />`,
|
|
);
|
|
out = replaceOrAppendHead(
|
|
out,
|
|
/<meta\s+property="og:url"[^>]*>/i,
|
|
`<meta property="og:url" content="${escapeAttr(url)}" />`,
|
|
);
|
|
|
|
const ld = `<script type="application/ld+json">${serialize(head.jsonLd)}</script>`;
|
|
return out.replace('</head>', ` ${ld}\n </head>`);
|
|
}
|
|
|
|
function outPathFor(route) {
|
|
if (route === '/') return join(distDir, 'index.html');
|
|
return join(distDir, route.replace(/^\//, ''), 'index.html');
|
|
}
|
|
|
|
const template = await readFile(join(distDir, 'index.html'), 'utf8');
|
|
let routes = await collectRoutes();
|
|
|
|
// Guard: the dynamic routes (blog posts, projects) are enumerated from the
|
|
// moments API. If none came back, the API was unreachable at build time — fail
|
|
// loudly rather than bake and deploy an empty, content-free snapshot. (The CV,
|
|
// sourced from the public GitHub gist, can still succeed independently.)
|
|
const dynamicRouteCount = routes.filter(
|
|
(r) => r.startsWith('/project/') || r.startsWith('/blog/'),
|
|
).length;
|
|
if (dynamicRouteCount === 0) {
|
|
console.error(
|
|
'prerender: 0 dynamic routes enumerated — the moments API was unreachable ' +
|
|
'at build time. Aborting so an empty snapshot is never deployed. ' +
|
|
`(VITE_API_BASE=${process.env.VITE_API_BASE ?? '(unset)'})`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Optional filter args: `node run-prerender.mjs /activity /cv` bakes only the
|
|
// routes whose path starts with one of the given prefixes (useful for quick
|
|
// iteration). With no args, every route is baked.
|
|
const filters = process.argv.slice(2);
|
|
if (filters.length > 0) {
|
|
routes = routes.filter((r) => filters.some((f) => r === f || r.startsWith(f)));
|
|
}
|
|
|
|
let ok = 0;
|
|
let failed = 0;
|
|
|
|
for (const route of routes) {
|
|
const url = route === '/' ? `${SITE_URL}/` : `${SITE_URL}${route}`;
|
|
try {
|
|
const { html, state, head } = await renderRoute(route);
|
|
let page = applyHead(template, head, url);
|
|
page = page.replace(
|
|
'<div id="root"></div>',
|
|
`<div id="root">${html}</div>\n <script>window.__RQ_STATE__=${serialize(state)}</script>`,
|
|
);
|
|
const outPath = outPathFor(route);
|
|
await mkdir(dirname(outPath), { recursive: true });
|
|
await writeFile(outPath, page);
|
|
ok += 1;
|
|
console.log(`prerendered ${route}`);
|
|
} catch (err) {
|
|
// Don't fail the whole build for one route — write the bare template so the
|
|
// route still resolves and hydrates client-side, and surface the error.
|
|
failed += 1;
|
|
console.error(`prerender FAILED for ${route}: ${err?.stack || err}`);
|
|
const outPath = outPathFor(route);
|
|
await mkdir(dirname(outPath), { recursive: true });
|
|
await writeFile(outPath, template);
|
|
}
|
|
}
|
|
|
|
console.log(`prerender complete: ${ok} ok, ${failed} failed, ${routes.length} routes`);
|
|
// A handful of failed routes still ship a working client-rendered fallback, so
|
|
// don't block the deploy for those. Only fail the build if nothing prerendered
|
|
// at all (e.g. the API was unreachable), which signals a genuinely broken build.
|
|
if (ok === 0) {
|
|
console.error('prerender produced no pages — failing the build');
|
|
process.exitCode = 1;
|
|
}
|