290 lines
13 KiB
JavaScript
290 lines
13 KiB
JavaScript
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, '>')
|
|
.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 [
|
|
` <meta name="description" content="${escapeHtml(metadata.description)}" />`,
|
|
` <meta name="robots" content="${escapeHtml(metadata.robots)}" />`,
|
|
` <link rel="canonical" href="${escapeHtml(metadata.canonicalUrl)}" />`,
|
|
` <meta property="og:type" content="${metadata.openGraph.type}" />`,
|
|
` <meta property="og:title" content="${escapeHtml(metadata.openGraph.title)}" />`,
|
|
` <meta property="og:description" content="${escapeHtml(metadata.openGraph.description)}" />`,
|
|
` <meta property="og:url" content="${escapeHtml(metadata.openGraph.url)}" />`,
|
|
...(metadata.openGraph.image === undefined
|
|
? []
|
|
: [` <meta property="og:image" content="${escapeHtml(metadata.openGraph.image)}" />`]),
|
|
` <meta name="twitter:card" content="${metadata.twitter.card}" />`,
|
|
` <meta name="twitter:title" content="${escapeHtml(metadata.twitter.title)}" />`,
|
|
` <meta name="twitter:description" content="${escapeHtml(metadata.twitter.description)}" />`,
|
|
...(metadata.twitter.image === undefined
|
|
? []
|
|
: [` <meta name="twitter:image" content="${escapeHtml(metadata.twitter.image)}" />`]),
|
|
` <script type="application/ld+json">${metadata.serializedJsonLd}</script>`,
|
|
].join('\n');
|
|
};
|
|
|
|
export const assemblePrerenderedHtml = (template, manifest, markup, inputs) => {
|
|
const closure = resolveViteAssetClosure(manifest);
|
|
const entryUrl = `/${closure.entryModule}`;
|
|
exactlyOne(template, /<title>[^<]*<\/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)}</title>`)
|
|
.replace('<!--portfolio-head-->', `${preloads}\n${metadataMarkup(metadata)}`)
|
|
.replace('<div id="app"></div>', `<div id="app">${markup}</div>`);
|
|
const clientBootstrap = [
|
|
`<script id="portfolio-inputs" type="application/json">${snapshot}</script>`,
|
|
`<script type="module" crossorigin src="${entryUrl}"></script>`,
|
|
].join('\n ');
|
|
return withHead.replace('<!--portfolio-body-->', 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);
|
|
}
|