build: add Cloudflare static export and deploy config
This commit is contained in:
142
scripts/export-cloudflare.mjs
Normal file
142
scripts/export-cloudflare.mjs
Normal file
@@ -0,0 +1,142 @@
|
||||
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]');
|
||||
}
|
||||
Reference in New Issue
Block a user