Files
Portfolio/scripts/export-cloudflare.mjs

143 lines
7.5 KiB
JavaScript

import { createHash, randomUUID } from 'node:crypto';
import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { parseSiteConfig } from '../src/content/schemas.ts';
import { serializeStaticHostHeaders } from '../src/security/security-policy.ts';
import { parseBuildAuthority } from './build-contract.mjs';
import { prerender } from './prerender.mjs';
import { verifyArtifacts } from './verify-artifacts.mjs';
const scriptPath = fileURLToPath(import.meta.url);
const root = resolve(dirname(scriptPath), '..');
const configPath = 'config/site.production.build.json';
const output = resolve(root, 'dist-cloudflare');
const rootFiles = ['index.html', 'robots.txt', 'sitemap.xml'];
const prohibited = /__portfolio(?:_|JourneyInstrumentation)|PORTFOLIO_TEST_(?:BUILD|TOKEN)|drive-trace\.v1|sourceMappingURL|-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----|\bgh[pousr]_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{40,}\b|\bAKIA[A-Z0-9]{16}\b|\/home\/luca\//iu;
export const assertProductionEnvironment = (environment) => {
if (Object.entries(environment).some(([key, value]) =>
value && (/^(?:PORTFOLIO_TEST_|VITE_)/u.test(key) || (key === 'NODE_ENV' && value !== 'production')))) {
throw new Error('Production export rejects test flags, VITE_* overrides and non-production NODE_ENV.');
}
};
export const assertPublicFiles = (files, expectedPaths) => {
const expected = [...new Set(expectedPaths)].sort();
if (JSON.stringify([...files.keys()].sort()) !== JSON.stringify(expected)) {
throw new Error('Cloudflare export inventory differs from the verified public closure.');
}
if (files.size > 20_000) throw new Error('Cloudflare free static file limit exceeded.');
for (const [path, bytes] of files) {
if (bytes.length > 25 * 1024 * 1024) throw new Error(`Cloudflare asset exceeds 25 MiB: ${path}`);
if (prohibited.test(bytes.toString('utf8'))) throw new Error(`Prohibited production surface in ${path}.`);
}
};
// Revalidate all URLs: approved media have stable, unhashed names. One rule avoids
// Cloudflare combining contradictory Cache-Control values from matching rules.
export const renderCloudflareHeaders = (site, hash) => serializeStaticHostHeaders(site, hash);
const regularBytes = async (path) => {
const stats = await lstat(path);
if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1) throw new Error('Export input must be a regular, non-linked file.');
return readFile(path);
};
const expectedExport = async () => {
await verifyArtifacts(configPath);
const site = parseSiteConfig(JSON.parse(await regularBytes(resolve(root, configPath))));
if (site.environment !== 'production') throw new Error('Cloudflare export requires production inputs.');
const authority = parseBuildAuthority(JSON.parse(await regularBytes(resolve(root, 'dist-ssr/artifact-authority.json'))));
const files = new Map();
for (const path of [...rootFiles, ...authority.artifactContract.files.map((file) => file.path)]) {
files.set(path, await regularBytes(resolve(root, 'dist', path)));
}
const html = files.get('index.html').toString('utf8');
const jsonLd = html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/u)?.[1];
if (!jsonLd) throw new Error('Production structured data is missing.');
const hash = `sha256-${createHash('sha256').update(jsonLd).digest('base64')}`;
files.set('_headers', Buffer.from(renderCloudflareHeaders(site, hash)));
assertPublicFiles(files, [...rootFiles, '_headers', ...authority.artifactContract.files.map((file) => file.path)]);
return files;
};
const readExport = async (directory) => {
const stats = await lstat(directory);
if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error('Export directory must not be linked.');
const files = new Map();
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (entry.name === 'assets' && entry.isDirectory()) {
for (const asset of await readdir(resolve(directory, 'assets'))) {
files.set(`assets/${asset}`, await regularBytes(resolve(directory, 'assets', asset)));
}
} else {
files.set(entry.name, await regularBytes(resolve(directory, entry.name)));
}
}
return files;
};
const verifyExportBytes = async (directory, expected) => {
const actual = await readExport(directory);
assertPublicFiles(actual, [...expected.keys()]);
for (const [path, bytes] of expected) {
if (!actual.get(path).equals(bytes)) throw new Error(`Cloudflare export bytes changed: ${path}`);
}
};
export const verifyCloudflareExport = async () => {
const expected = await expectedExport();
await verifyExportBytes(output, expected);
console.log(`Verified ${expected.size} production files; no benchmark hooks or prohibited export surfaces found.`);
};
const run = (script, args = []) => {
const result = spawnSync(process.execPath, [resolve(root, script), ...args], {
cwd: root, stdio: 'inherit', env: { ...process.env, NODE_ENV: 'production' },
});
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`Production build step failed: ${script}`);
};
export const buildCloudflareExport = async () => {
assertProductionEnvironment(process.env);
const site = parseSiteConfig(JSON.parse(await regularBytes(resolve(root, 'config/site.production.json'))));
if (site.environment !== 'production') throw new Error('Production site config is required.');
// Refuse unsafe paths before Vite writes its intermediate build directories.
for (const name of ['dist-client', 'dist-ssr', 'dist', 'dist-cloudflare']) {
try {
const stats = await lstat(resolve(root, name));
if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error(`Unsafe build directory: ${name}`);
} catch (error) { if (error.code !== 'ENOENT') throw error; }
}
run('scripts/materialize-empty-asset-set.mjs');
run('node_modules/vue-tsc/bin/vue-tsc.js', ['--noEmit', '--project', 'tsconfig.production.json']);
run('node_modules/vite/bin/vite.js', ['build', '--mode', 'production-export', '--outDir', 'dist-client', '--manifest']);
run('node_modules/vite/bin/vite.js', ['build', '--mode', 'production-export', '--ssr', 'src/entry-server.ts', '--outDir', 'dist-ssr']);
// Every export receives a distinct identity, including uncommitted local builds.
const generatedSite = { ...site, buildId: `production-${randomUUID()}` };
await writeFile(resolve(root, configPath), `${JSON.stringify(generatedSite, null, 2)}\n`);
await prerender(configPath);
run('scripts/check-bundle-budgets.mjs', ['dist', '--transfer-only']);
const expected = await expectedExport();
const stage = await mkdtemp(resolve(root, '.cloudflare-stage-'));
try {
await mkdir(resolve(stage, 'assets'));
for (const [path, bytes] of expected) await writeFile(resolve(stage, path), bytes, { flag: 'wx' });
await verifyExportBytes(stage, expected);
await rm(output, { recursive: true, force: true });
await rename(stage, output);
} finally {
await rm(stage, { recursive: true, force: true });
}
console.log(`Exported ${expected.size} verified files to dist-cloudflare for ${site.canonicalUrl}`);
};
if (process.argv[1] && resolve(process.argv[1]) === scriptPath) {
if (process.argv.slice(2).join(' ') === '--verify') await verifyCloudflareExport();
else if (process.argv.length === 2) await buildCloudflareExport();
else throw new Error('Usage: node scripts/export-cloudflare.mjs [--verify]');
}