import { constants } from 'node:fs'; import { createHash, randomUUID } from 'node:crypto'; import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, relative, resolve, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { loadProductionInputs } from '../src/content/load-production-inputs.ts'; import { createPageMetadata, createStaticHostArtifacts } from '../src/content/metadata.ts'; import { serializeJsonLd } from '../src/security/serialize-json-ld.ts'; import { createArtifactContract, createBuildAuthority, resolveViteAssetClosure, serializeBuildAuthority, } from './build-contract.mjs'; import { parseExactConfigArgument } from './config-argument.mjs'; import { captureDirectoryIdentity, publishStagedDirectory } from './safe-publication.mjs'; import { verifyArtifactDirectory } from './verify-artifacts.mjs'; export { parseExactConfigArgument } from './config-argument.mjs'; const scriptPath = fileURLToPath(import.meta.url); const repositoryRoot = resolve(dirname(scriptPath), '..'); const escapeHtml = (value) => value .replace(/&/gu, '&') .replace(//gu, '>') .replace(/"/gu, '"'); const exactlyOne = (source, expression, label) => { const matches = source.match(expression) ?? []; if (matches.length !== 1) throw new Error(`Expected exactly one ${label}.`); }; const metadataMarkup = (metadata) => { return [ ` `, ` `, ` `, ` `, ` `, ` `, ` `, ...(metadata.openGraph.image === undefined ? [] : [` `]), ` `, ` `, ` `, ...(metadata.twitter.image === undefined ? [] : [` `]), ` `, ].join('\n'); }; export const assemblePrerenderedHtml = (template, manifest, markup, inputs) => { const closure = resolveViteAssetClosure(manifest); const entryUrl = `/${closure.entryModule}`; exactlyOne(template, /[^<]*<\/title>/gu, 'title'); exactlyOne(template, /<div id="app"><\/div>/gu, 'empty app root'); exactlyOne(template, /<script type="module"[^>]*><\/script>/gu, 'client module script'); exactlyOne(template, /<!--portfolio-head-->/gu, 'portfolio head marker'); exactlyOne(template, /<!--portfolio-body-->/gu, 'portfolio body marker'); const metadata = createPageMetadata(inputs.content, inputs.manifest, inputs.site); const snapshot = serializeJsonLd(inputs); const preloads = closure.modulePreloads .map((file) => ` <link rel="modulepreload" href="/${file}" />`) .join('\n'); const withoutModule = template.replace(/\s*<script type="module"[^>]*><\/script>/u, ''); const withHead = withoutModule .replace(/<title>[^<]*<\/title>/u, `<title>${escapeHtml(metadata.title)}`) .replace('', `${preloads}\n${metadataMarkup(metadata)}`) .replace('
', `
${markup}
`); const clientBootstrap = [ ``, ``, ].join('\n '); return withHead.replace('', clientBootstrap); }; const assertInsideRepository = (path) => { const pathRelativeToRoot = relative(repositoryRoot, path); if (pathRelativeToRoot === '..' || pathRelativeToRoot.startsWith(`..${sep}`)) { throw new Error('Build input escaped the repository root.'); } }; const isMissing = (error) => typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'; const fileIdentity = (stats) => ({ dev: stats.dev, ino: stats.ino, size: stats.size, mtimeMs: stats.mtimeMs, }); const sameFileIdentity = (left, right) => left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs; const assertRegularFile = async (path, label) => { const stats = await lstat(path); if (stats.isSymbolicLink() || !stats.isFile() || stats.nlink !== 1) { throw new Error(`${label} must be one regular, non-linked file.`); } return fileIdentity(stats); }; const readSafeRegularFile = async (path, label) => { const initialIdentity = await assertRegularFile(path, label); const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); try { const openedStats = await handle.stat(); if (!openedStats.isFile() || openedStats.nlink !== 1 || !sameFileIdentity(initialIdentity, fileIdentity(openedStats))) { throw new Error(`${label} identity changed while opening.`); } const bytes = await handle.readFile(); if (!sameFileIdentity(initialIdentity, await assertRegularFile(path, label))) { throw new Error(`${label} identity changed while reading.`); } return bytes; } finally { await handle.close(); } }; const assertExactDirectoryMembers = async (root, expectedNames, label) => { await captureDirectoryIdentity(root); const entries = await readdir(root, { withFileTypes: true }); const actualNames = entries.map((entry) => entry.name).sort(); const expected = [...expectedNames].sort(); if (JSON.stringify(actualNames) !== JSON.stringify(expected)) { throw new Error(`${label} inventory is not exact.`); } if (entries.some((entry) => entry.isSymbolicLink())) throw new Error(`${label} contains a symlink.`); return entries; }; const loadTrustedClientBuild = async (clientRoot) => { await assertExactDirectoryMembers(clientRoot, ['.vite', 'assets', 'index.html'], 'Client build'); const viteRoot = resolve(clientRoot, '.vite'); const assetsRoot = resolve(clientRoot, 'assets'); await assertExactDirectoryMembers(viteRoot, ['manifest.json'], 'Client Vite metadata'); await captureDirectoryIdentity(assetsRoot); const template = (await readSafeRegularFile(resolve(clientRoot, 'index.html'), 'Built client template')).toString('utf8'); const manifestSource = (await readSafeRegularFile(resolve(viteRoot, 'manifest.json'), 'Vite manifest')).toString('utf8'); const manifest = JSON.parse(manifestSource); const closure = resolveViteAssetClosure(manifest); const clientPaths = [...new Set([ ...closure.executableModules, ...closure.styles, ...closure.viteAssets, ])].sort(); await assertExactDirectoryMembers( assetsRoot, clientPaths.map((path) => path.slice('assets/'.length)), 'Client assets', ); const bytesByPath = {}; for (const path of clientPaths) { bytesByPath[path] = await readSafeRegularFile(resolve(clientRoot, path), `Client asset ${path}`); } return { template, manifest, closure, bytesByPath }; }; const loadPreparedPublicAssets = async (inputs) => { const preparedRoot = resolve(repositoryRoot, 'inputs/prepared-assets'); await captureDirectoryIdentity(preparedRoot); const bytesByPath = {}; for (const asset of inputs.manifest.assets) { bytesByPath[`assets/${asset.fileName}`] = await readSafeRegularFile( resolve(preparedRoot, asset.fileName), `Prepared public asset ${asset.fileName}`, ); } return bytesByPath; }; const writeFreshStagingArtifacts = async (stagingRoot, html, artifacts, bytesByPath) => { await mkdir(resolve(stagingRoot, 'assets')); for (const [path, bytes] of Object.entries(bytesByPath)) { await writeFile(resolve(stagingRoot, path), bytes, { flag: 'wx' }); } await Promise.all([ writeFile(resolve(stagingRoot, 'index.html'), html, { flag: 'wx' }), writeFile(resolve(stagingRoot, 'robots.txt'), artifacts.robots, { flag: 'wx' }), writeFile(resolve(stagingRoot, '_headers.example'), artifacts.headers, { flag: 'wx' }), writeFile(resolve(stagingRoot, '_redirects.example'), artifacts.redirects, { flag: 'wx' }), ...(artifacts.sitemap === undefined ? [] : [writeFile(resolve(stagingRoot, 'sitemap.xml'), artifacts.sitemap, { flag: 'wx' })]), ]); }; const removeOwnedStaging = async (stagingRoot) => { if (dirname(stagingRoot) !== repositoryRoot || !stagingRoot.startsWith(resolve(repositoryRoot, '.dist-stage-'))) { throw new Error('Refusing to remove an unowned staging path.'); } try { await captureDirectoryIdentity(stagingRoot); } catch (error) { if (isMissing(error) || /existing safe directory/iu.test(String(error))) return; throw error; } await rm(stagingRoot, { recursive: true }); }; const writeAuthorityAtomically = async (authority) => { const serverRoot = resolve(repositoryRoot, 'dist-ssr'); await captureDirectoryIdentity(serverRoot); const authorityPath = resolve(serverRoot, 'artifact-authority.json'); try { await assertRegularFile(authorityPath, 'Existing artifact authority'); } catch (error) { if (!isMissing(error)) throw error; } const temporaryPath = resolve(serverRoot, `.artifact-authority-${randomUUID()}.tmp`); try { await writeFile(temporaryPath, serializeBuildAuthority(authority), { flag: 'wx' }); await assertRegularFile(temporaryPath, 'Temporary artifact authority'); await rename(temporaryPath, authorityPath); } catch (error) { await rm(temporaryPath, { force: true }); throw error; } }; export const prerender = async (configPath) => { const distRoot = resolve(repositoryRoot, 'dist'); const clientRoot = resolve(repositoryRoot, 'dist-client'); const configAbsolutePath = resolve(repositoryRoot, configPath); assertInsideRepository(configAbsolutePath); if (await realpath(repositoryRoot) !== repositoryRoot) throw new Error('Repository root has a symlinked ancestor.'); const inputs = await loadProductionInputs({ contentJson: pathToFileURL(resolve(repositoryRoot, 'content/public/portfolio-content.json')), assetManifestJson: pathToFileURL(resolve(repositoryRoot, 'content/public/asset-manifest.json')), preparedAssetRoot: pathToFileURL(`${resolve(repositoryRoot, 'inputs/prepared-assets')}${sep}`), siteConfigJson: pathToFileURL(configAbsolutePath), }); const clientBuild = await loadTrustedClientBuild(clientRoot); const serverEntryPath = resolve(repositoryRoot, 'dist-ssr/entry-server.js'); const serverEntryIdentity = await assertRegularFile(serverEntryPath, 'SSR entry'); const { render } = await import(`${pathToFileURL(serverEntryPath).href}?build=${encodeURIComponent(inputs.site.buildId)}`); if (typeof render !== 'function') throw new Error('SSR entry does not export render().'); if (!sameFileIdentity(serverEntryIdentity, await assertRegularFile(serverEntryPath, 'SSR entry'))) { throw new Error('SSR entry identity changed during import.'); } const markup = await render('/', inputs); const html = assemblePrerenderedHtml(clientBuild.template, clientBuild.manifest, markup, inputs); const metadata = createPageMetadata(inputs.content, inputs.manifest, inputs.site); const artifacts = createStaticHostArtifacts(metadata, inputs.site); const publicBytes = await loadPreparedPublicAssets(inputs); const bytesByPath = { ...clientBuild.bytesByPath, ...publicBytes }; const digestByPath = Object.fromEntries(Object.entries(bytesByPath).map(([path, bytes]) => [ path, createHash('sha256').update(bytes).digest('hex'), ])); const contract = createArtifactContract( clientBuild.closure, inputs.manifest, digestByPath, inputs.site.buildId, ); const authority = createBuildAuthority(clientBuild.manifest, contract); const stagingRoot = await mkdtemp(resolve(repositoryRoot, '.dist-stage-')); try { await writeFreshStagingArtifacts(stagingRoot, html, artifacts, bytesByPath); await verifyArtifactDirectory(stagingRoot, inputs, contract); const stagingIdentity = await captureDirectoryIdentity(stagingRoot); await publishStagedDirectory({ repositoryRoot, finalRoot: distRoot, stagingRoot, expectedStagingIdentity: stagingIdentity, beforeCommit: async () => writeAuthorityAtomically(authority), }); } catch (error) { await removeOwnedStaging(stagingRoot); throw error; } }; if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) { const configPath = parseExactConfigArgument(process.argv.slice(2)); await prerender(configPath); }