#!/usr/bin/env node import { spawn } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import { generatedSymlinkManifest, sourceFingerprint } from './preflight.mjs'; const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); function run(command, args, options) { return new Promise((resolve, reject) => { const child = spawn(command, args, options); child.once('error', reject); child.once('exit', (code, signal) => { if (code === 0) resolve(); else reject( new Error(signal ? `next build terminated by ${signal}` : `next build exited ${code}`), ); }); }); } const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); async function requireRealDirectory(target, { allowMissing = false } = {}) { try { const stats = await lstat(target); if (!stats.isDirectory() || stats.isSymbolicLink()) { throw new Error(`${target} must be a real directory, not a symbolic link.`); } } catch (error) { if (allowMissing && error.code === 'ENOENT') return; throw error; } } async function acquireBuildLock(root) { const workRoot = path.join(root, '.mosaic-test-work'); const lock = path.join(workRoot, 'web-build.lock'); const nonce = randomUUID(); const owner = JSON.stringify({ pid: process.pid, nonce }); const deadline = Date.now() + 120_000; await mkdir(workRoot, { recursive: true }); while (Date.now() < deadline) { try { await mkdir(lock); await writeFile(path.join(lock, 'owner.json'), owner, { mode: 0o600 }); return async () => { const current = await readFile(path.join(lock, 'owner.json'), 'utf8'); if (current !== owner) throw new Error('Web build lock ownership changed before release.'); const released = `${lock}.released-${nonce}`; await rename(lock, released); await rm(released, { recursive: true, force: true }); }; } catch (error) { if (error.code !== 'EEXIST') throw error; let lockOwner; try { lockOwner = JSON.parse(await readFile(path.join(lock, 'owner.json'), 'utf8')); } catch (ownerError) { if (ownerError.code === 'ENOENT') { await delay(25); continue; } throw new Error(`Web build lock is unreadable at ${lock}.`, { cause: ownerError }); } try { process.kill(lockOwner.pid, 0); } catch (processError) { if (processError.code !== 'ESRCH') throw processError; const stale = `${lock}.stale-${nonce}`; try { await rename(lock, stale); await rm(stale, { recursive: true, force: true }); } catch (renameError) { if (renameError.code !== 'ENOENT') throw renameError; } continue; } await delay(25); } } throw new Error(`Timed out waiting for the web build lock at ${lock}.`); } export async function buildWeb({ root = scriptRoot, fingerprint = sourceFingerprint, runBuild = async (webDir) => run(path.join(webDir, 'node_modules', '.bin', 'next'), ['build'], { cwd: webDir, stdio: 'inherit', }), } = {}) { const releaseLock = await acquireBuildLock(root); try { const webDir = path.join(root, 'apps', 'web'); const nextDir = path.join(webDir, '.next'); const certificationMarker = path.join(nextDir, '.mosaic-source-hash'); const symlinkManifest = path.join(nextDir, '.mosaic-symlink-manifest'); const certificationTemporary = `${certificationMarker}.${randomUUID()}.tmp`; const manifestTemporary = `${symlinkManifest}.${randomUUID()}.tmp`; const before = await fingerprint(root); await requireRealDirectory(nextDir, { allowMissing: true }); await Promise.all([ rm(certificationMarker, { force: true }), rm(symlinkManifest, { force: true }), ]); await runBuild(webDir); await requireRealDirectory(nextDir); const after = await fingerprint(root); if (after !== before) { throw new Error( 'Web build inputs changed during next build; generated output was not certified.', ); } const manifestContents = await generatedSymlinkManifest(nextDir); const certificationContents = `${JSON.stringify({ version: 1, sourceFingerprint: before, symlinkManifestHash: createHash('sha256').update(manifestContents).digest('hex'), })}\n`; await Promise.all([ writeFile(certificationTemporary, certificationContents, { mode: 0o600 }), writeFile(manifestTemporary, manifestContents, { mode: 0o600 }), ]); // The certification marker is the commit point. Publishing the manifest first // leaves interrupted builds untrusted because the marker remains absent. await rename(manifestTemporary, symlinkManifest); await rename(certificationTemporary, certificationMarker); } finally { await releaseLock(); } } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { await buildWeb(); }