mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
112 lines
5.9 KiB
JavaScript
112 lines
5.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// Guards for a finished Lite export: the files a deployer relies on exist,
|
|
// and the client chunks reference no server endpoint outside the documented
|
|
// allowlist (lib.mjs). Exit 1 with a readable list otherwise.
|
|
//
|
|
// The Stalwart target additionally checks the entry document (both literals
|
|
// Stalwart rewrites, exactly once; nothing root-absolute), the zip layout and
|
|
// its size against Stalwart's 100 MB limit.
|
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import {
|
|
LITE_PENDING_PATH_KEY, STALWART_EXCLUDED_FILES, STALWART_MAX_BUNDLE_BYTES, STALWART_SAFE_BUNDLE_BYTES, STALWART_ZIP_NAME,
|
|
collectApiStrings, collectZipEntries, discoverBuiltLocales, flightTextRowsWithNeedles, isMainModule, listZipEntries, parseLiteTarget, resolveRepoRoot, stalwartEntryProblems, stalwartZipEntryProblems, unexpectedApiStrings,
|
|
} from "./lib.mjs";
|
|
|
|
const repoRoot = resolveRepoRoot(import.meta.url);
|
|
|
|
/** The target recorded by postbuild, so `node verify.mjs` needs no flags. */
|
|
function recordedTarget(outDir) {
|
|
try {
|
|
return JSON.parse(readFileSync(join(outDir, "lite-build.json"), "utf8")).target ?? "static";
|
|
} catch {
|
|
return "static";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {{ root?: string, target?: "static" | "stalwart", safeBundleBytes?: number }} [options]
|
|
* `target` defaults to the one postbuild recorded in out/lite-build.json.
|
|
*/
|
|
export function verifyExport({ root = repoRoot, target, safeBundleBytes = STALWART_SAFE_BUNDLE_BYTES } = {}) {
|
|
const outDir = join(root, "out");
|
|
const problems = [];
|
|
const resolvedTarget = target ?? recordedTarget(outDir);
|
|
|
|
const locales = discoverBuiltLocales(outDir);
|
|
if (locales.length === 0) problems.push("no locale shells found (out/<locale>/mail/index.html)");
|
|
|
|
for (const locale of locales) {
|
|
for (const surface of ["mail", "calendar", "contacts", "files", "settings", "login"]) {
|
|
if (!existsSync(join(outDir, locale, surface, "index.html"))) problems.push(`missing out/${locale}/${surface}/index.html`);
|
|
}
|
|
if (!existsSync(join(outDir, locale, "index.html"))) problems.push(`missing out/${locale}/index.html`);
|
|
}
|
|
|
|
const required = resolvedTarget === "stalwart"
|
|
? ["index.html", "config.json", "policy.json", "manifest.json", "LITE-README.md", "lite-build.json"]
|
|
: ["index.html", "404.html", "config.json", "policy.json", "manifest.webmanifest", "_redirects", "_headers", "LITE-README.md", "lite-build.json"];
|
|
for (const file of required) {
|
|
if (!existsSync(join(outDir, file))) problems.push(`missing out/${file}`);
|
|
}
|
|
for (const file of ["_next/static", "branding"]) {
|
|
if (!existsSync(join(outDir, file))) problems.push(`missing out/${file}`);
|
|
}
|
|
|
|
if (resolvedTarget !== "stalwart") {
|
|
// Next writes its own default not-found page as 404.html; postbuild must
|
|
// have replaced it with the shim that parks and replays deep links.
|
|
const notFound = join(outDir, "404.html");
|
|
if (existsSync(notFound) && !readFileSync(notFound, "utf8").includes(LITE_PENDING_PATH_KEY)) {
|
|
problems.push("out/404.html is not the Lite shim (deep links on hosts without rewrites would dead-end); run postbuild");
|
|
}
|
|
}
|
|
|
|
const apiStrings = collectApiStrings(join(outDir, "_next", "static"));
|
|
const unexpected = unexpectedApiStrings(apiStrings);
|
|
for (const s of unexpected) problems.push(`client chunk references undocumented server endpoint ${s} (add an IS_LITE gate or document it in scripts/lite/lib.mjs)`);
|
|
|
|
let zipBytes = 0;
|
|
if (resolvedTarget === "stalwart") {
|
|
const entry = join(outDir, "index.html");
|
|
if (existsSync(entry)) problems.push(...stalwartEntryProblems(readFileSync(entry, "utf8")).map((p) => `out/${p}`));
|
|
for (const file of STALWART_EXCLUDED_FILES) {
|
|
if (existsSync(join(outDir, file))) problems.push(`out/${file} belongs to the static-host target`);
|
|
}
|
|
// The runtime mount rewrite skips length-prefixed RSC text rows.
|
|
for (const { name, data } of existsSync(outDir) ? collectZipEntries(outDir) : []) {
|
|
if (!name.endsWith(".txt")) continue;
|
|
const rows = flightTextRowsWithNeedles(data.toString("utf8"));
|
|
if (rows.length) problems.push(`out/${name}: RSC text rows ${rows.join(", ")} hold root-absolute URLs the mount rewrite cannot reach`);
|
|
}
|
|
const zipPath = join(root, STALWART_ZIP_NAME);
|
|
if (!existsSync(zipPath)) {
|
|
problems.push(`missing ${STALWART_ZIP_NAME} (postbuild packs it)`);
|
|
} else {
|
|
zipBytes = statSync(zipPath).size;
|
|
const mb = (n) => `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
if (zipBytes > STALWART_MAX_BUNDLE_BYTES) problems.push(`${STALWART_ZIP_NAME} is ${mb(zipBytes)}; Stalwart refuses bundles above ${mb(STALWART_MAX_BUNDLE_BYTES)}`);
|
|
else if (zipBytes > safeBundleBytes) problems.push(`${STALWART_ZIP_NAME} is ${mb(zipBytes)}, above the ${mb(safeBundleBytes)} safety margin under Stalwart's ${mb(STALWART_MAX_BUNDLE_BYTES)} limit`);
|
|
try {
|
|
problems.push(...stalwartZipEntryProblems(listZipEntries(readFileSync(zipPath)).map((e) => e.name)).map((p) => `${STALWART_ZIP_NAME}: ${p}`));
|
|
} catch (err) {
|
|
problems.push(`${STALWART_ZIP_NAME} cannot be read: ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { problems, locales, apiStrings, target: resolvedTarget, zipBytes };
|
|
}
|
|
|
|
if (isMainModule(import.meta.url)) {
|
|
const argv = process.argv.slice(2);
|
|
const target = argv.some((a) => a.startsWith("--target")) || process.env.LITE_TARGET ? parseLiteTarget(argv, process.env) : undefined;
|
|
const { problems, locales, apiStrings, target: checked, zipBytes } = verifyExport({ target });
|
|
if (problems.length > 0) {
|
|
console.error(`[lite] verification failed (${problems.length} problems):`);
|
|
for (const p of problems) console.error(` - ${p}`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`[lite] ${checked} export verified: ${locales.length} locales, ${apiStrings.length} known /api strings in chunks${zipBytes ? `, zip ${(zipBytes / 1024 / 1024).toFixed(1)} MB` : ""}`);
|
|
}
|