feat: add Bulwark Lite static export build

This commit is contained in:
Linus Rath
2026-09-17 23:54:33 +02:00
parent 321188f08a
commit 5de98bc04b
59 changed files with 3397 additions and 108 deletions
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
// `npm run build:lite` - the whole Lite pipeline in one process so no
// cross-platform env juggling is needed:
//
// 1. prepare delete server-only trees (disposable checkout only)
// 2. next build with NEXT_PUBLIC_BULWARK_LITE=1 (output: "export")
// 3. postbuild write config.json, manifest, host snippets into out/
// 4. verify fail on missing files or undocumented /api references
//
// Inputs (env): LITE_JMAP_SERVER_URL, LITE_APP_NAME, LITE_ALLOW_CUSTOM_ENDPOINT,
// LITE_REMEMBER_ME, LITE_DEMO_MODE, LITE_LOCALES, NEXT_PUBLIC_BASE_PATH,
// NEXT_PUBLIC_DEFAULT_LOCALE, GIT_COMMIT. Flags: --skip-prepare (tree already
// pruned), --dry-run (print the plan only).
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { resolveRepoRoot } from "./lib.mjs";
import { runPrepare } from "./prepare.mjs";
import { runPostbuild } from "./postbuild.mjs";
import { verifyExport } from "./verify.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
const argv = process.argv.slice(2);
const dryRun = argv.includes("--dry-run");
const env = {
...process.env,
NEXT_PUBLIC_BULWARK_LITE: "1",
// Without the proxy nothing rewrites /mail to /en/mail: the locale must be
// in every URL.
NEXT_PUBLIC_LOCALE_PREFIX: "always",
NEXT_TELEMETRY_DISABLED: process.env.NEXT_TELEMETRY_DISABLED ?? "1",
};
if (dryRun) {
runPrepare({ argv: ["--dry-run"], env });
console.log("[lite] would run: next build --turbopack with", {
LITE_JMAP_SERVER_URL: env.LITE_JMAP_SERVER_URL ?? "",
LITE_APP_NAME: env.LITE_APP_NAME ?? "",
LITE_LOCALES: env.LITE_LOCALES ?? "(all)",
LITE_DEMO_MODE: env.LITE_DEMO_MODE ?? "false",
NEXT_PUBLIC_BASE_PATH: env.NEXT_PUBLIC_BASE_PATH ?? "",
});
process.exit(0);
}
if (!argv.includes("--skip-prepare")) {
runPrepare({ argv: argv.filter((a) => a === "--in-place"), env });
}
const require = createRequire(import.meta.url);
const nextBin = require.resolve("next/dist/bin/next", { paths: [repoRoot] });
console.log("[lite] next build --turbopack (static export)");
const build = spawnSync(process.execPath, [nextBin, "build", "--turbopack"], { cwd: repoRoot, env, stdio: "inherit" });
if (build.status !== 0) {
console.error(`[lite] next build failed with exit code ${build.status}`);
process.exit(build.status ?? 1);
}
runPostbuild({ env });
const { problems, locales, apiStrings } = verifyExport();
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] done: out/ holds ${locales.length} locales; ${apiStrings.length} documented /api strings in chunks`);
+421
View File
@@ -0,0 +1,421 @@
// Shared, side-effect-free helpers for the Lite build scripts. Kept separate
// so lib/__tests__/lite-scripts.test.ts can exercise them without touching
// the filesystem or spawning `next build`.
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/**
* Repository root for a script under scripts/lite/. Falls back to the current
* directory when `import.meta.url` is not a file URL (vitest's transform).
*/
export function resolveRepoRoot(metaUrl) {
try {
return resolve(fileURLToPath(new URL("../..", metaUrl)));
} catch {
return process.cwd();
}
}
/** True when the module was started directly (`node scripts/lite/x.mjs`). */
export function isMainModule(metaUrl) {
try {
return !!process.argv[1] && resolve(process.argv[1]) === fileURLToPath(metaUrl);
} catch {
return false;
}
}
/** Server-only trees the static export cannot contain. */
export const LITE_REMOVED_PATHS = [
"proxy.ts",
"app/api",
"app/(main)/admin",
"app/(main)/setup",
"app/(sandbox)",
"app/manifest.ts",
"instrumentation.ts",
"instrumentation.node.ts",
"app/(main)/[...rest]",
"app/(main)/protocol",
"e2e",
"integration",
];
/** Test trees are pruned too: they import the routes removed above. */
export const LITE_TEST_DIR_NAME = "__tests__";
export const LITE_TEST_FILE_PATTERN = /\.test\.[cm]?[jt]sx?$/;
export const LITE_PRUNE_SKIP_DIRS = new Set(["node_modules", ".git", ".next", "out", "repos", "scripts"]);
/** The client shells a deployer's host must SPA-fallback to. */
export const LITE_SURFACES = ["mail", "calendar", "contacts", "files", "settings"];
/** Locales written right-to-left; the root shim sets `dir` for them. */
export const RTL_LOCALES = ["ar", "fa", "he"];
/**
* `/api/` strings that legitimately survive in the Lite client chunks. Each
* one is either gated at call time (`IS_LITE`, a policy flag, a config flag
* the Lite config pins off) or tolerates a 404 by design. Anything else is a
* new server dependency and fails `verify.mjs`.
*/
export const LITE_API_STRING_ALLOWLIST = [
// Path-independent generic prefix used by `apiFetch` docs/comments.
"/api/",
// stores/update-store.ts - startPolling() is a no-op in Lite.
"/api/system/update-status",
// stores/plugin-store.ts + theme-store.ts - initializePlugins()/syncServerThemes() return early in Lite.
"/api/plugins",
"/api/plugin-approval-status",
"/api/plugin-signing-pubkey",
"/api/admin/plugins",
"/api/admin/themes",
// stores/settings-store.ts - settingsSyncEnabled is pinned off.
"/api/settings",
// OAuth / SSO / pairing - oauthEnabled and stalwartFeaturesEnabled are pinned off.
"/api/auth/token",
"/api/auth/oauth/metadata",
"/api/auth/sso/start",
"/api/auth/sso/complete",
"/api/auth/reauth/sso/complete",
"/api/auth/pair/create",
"/api/auth/totp-token-exchange",
"/api/auth/session",
"/api/auth/verify",
"/api/auth/stalwart-context",
"/api/account/stalwart/jmap",
// Admin shield probe - skipped in Lite (components/layout/navigation-rail.tsx).
"/api/admin/auth",
// Settings/policy - replaced by config.json / policy.json.
"/api/config",
"/api/admin/policy",
// WOPI (#425) - use-wopi-status returns disabled in Lite.
"/api/wopi/status",
"/api/wopi/launch",
// Calendar: ICS URL import/subscriptions + CalDAV discovery are hidden in Lite.
"/api/fetch-ical",
"/api/caldav/discover",
"/api/webdav",
// Sender favicons - disabled in Lite (initials fallback).
"/api/favicon",
// Web push relay paths (relative to the *relay* origin, not this host).
"/api/push/vapid-public-key",
"/api/push/register",
"/api/push/active",
"/api/push/verify",
"/api/push/jmap",
// Dev-only JMAP mock.
"/api/jmap",
"/api/dev-jmap",
];
export function parseBool(value, fallback = false) {
if (value === undefined || value === null || value === "") return fallback;
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
}
export function normalizeBasePath(raw) {
const trimmed = (raw ?? "").trim().replace(/\/+$/, "");
if (!trimmed) return "";
if (!trimmed.startsWith("/")) throw new Error(`base path must start with "/" (got ${JSON.stringify(raw)})`);
return trimmed;
}
/** config.json contents from the LITE_* build inputs. */
export function buildLiteConfig(env = {}) {
const jmapServerUrl = (env.LITE_JMAP_SERVER_URL ?? "").trim().replace(/\/+$/, "");
return {
_comment: "Bulwark Lite runtime configuration. Edit and re-upload; no rebuild needed. See LITE-README.md.",
appName: (env.LITE_APP_NAME ?? "").trim() || "Bulwark Webmail",
jmapServerUrl,
allowCustomJmapEndpoint: parseBool(env.LITE_ALLOW_CUSTOM_ENDPOINT, jmapServerUrl === ""),
rememberMeEnabled: parseBool(env.LITE_REMEMBER_ME, true),
demoMode: parseBool(env.LITE_DEMO_MODE, false),
loginShowTotp: true,
loginShowVersion: true,
};
}
/** policy.json: only the gates Lite must pin; the app merges the rest over its defaults. */
export function buildLitePolicy() {
return {
_comment: "Optional admin policy for Bulwark Lite (same shape as the admin dashboard's policy). Plugins and sidebar apps stay off in Lite.",
features: {
pluginsEnabled: false,
sidebarAppsEnabled: false,
},
};
}
export function buildManifest({ appName, basePath = "" }) {
const p = (path) => `${basePath}${path}`;
return {
name: appName,
short_name: appName,
description: `${appName} - webmail for JMAP servers`,
start_url: p("/"),
scope: p("/"),
display: "standalone",
background_color: "#ffffff",
theme_color: "#ffffff",
icons: [
{ src: p("/icon-192x192.png"), sizes: "192x192", type: "image/png" },
{ src: p("/icon-512x512.png"), sizes: "512x512", type: "image/png" },
{ src: p("/icon-maskable-light-192x192.png"), sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: p("/icon-maskable-light-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}
/** Root index.html: pick the visitor's locale in the browser and jump to it. */
export function buildRootRedirect({ basePath = "", locales, defaultLocale = "en" }) {
const list = JSON.stringify(locales);
const fallback = locales.includes(defaultLocale) ? defaultLocale : locales[0];
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="robots" content="noindex">
<title>Loading</title>
<script>
(function () {
var locales = ${list};
var fallback = ${JSON.stringify(fallback)};
var base = ${JSON.stringify(basePath)};
function pick(tag) {
if (!tag) return null;
tag = String(tag);
if (locales.indexOf(tag) !== -1) return tag;
var lower = tag.toLowerCase();
if (lower.indexOf("zh") === 0) {
var traditional = /zh-(tw|hk|mo|hant)/.test(lower);
if (traditional && locales.indexOf("zh-TW") !== -1) return "zh-TW";
if (locales.indexOf("zh") !== -1) return "zh";
}
var short = lower.split("-")[0];
for (var i = 0; i < locales.length; i++) if (locales[i].toLowerCase() === short) return locales[i];
return null;
}
var chosen = null;
try { chosen = pick(localStorage.getItem("NEXT_LOCALE")); } catch (e) {}
var langs = navigator.languages || [navigator.language];
for (var j = 0; !chosen && j < langs.length; j++) chosen = pick(langs[j]);
location.replace(base + "/" + (chosen || fallback) + "/" + location.search + location.hash);
})();
</script>
</head>
<body><noscript>This app needs JavaScript. <a href="${basePath}/${fallback}/">Continue</a></noscript></body>
</html>
`;
}
/** Netlify / Cloudflare Pages rewrite rules. */
export function buildRedirects({ basePath = "", locales, surfaces = LITE_SURFACES }) {
const lines = [
"# Bulwark Lite SPA fallback (Netlify / Cloudflare Pages). Deep links below a",
"# surface serve that surface's shell; everything else keeps its own file.",
];
for (const locale of locales) {
for (const surface of surfaces) {
lines.push(`${basePath}/${locale}/${surface}/* ${basePath}/${locale}/${surface}/index.html 200`);
}
// /<locale>/login etc. resolve directly; a bare locale root goes to mail.
}
return lines.join("\n") + "\n";
}
/** Security headers for hosts that read a `_headers` file. */
export function buildHeaders({ basePath = "", connectSrc = "*" }) {
const csp = [
"default-src 'self'",
// The static export ships inline hydration scripts, so no nonce is possible.
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' https: data:",
`connect-src 'self' ${connectSrc}`,
"frame-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"media-src 'self' blob:",
].join("; ");
return `${basePath || ""}/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Content-Security-Policy: ${csp}
${basePath || ""}/_next/static/*
Cache-Control: public, max-age=31536000, immutable
`;
}
export function buildNginxExample({ basePath = "", surfaces = LITE_SURFACES }) {
const location = basePath ? `${basePath}/` : "/";
const surfaceAlternation = surfaces.join("|");
return `# Bulwark Lite - nginx example. Serve the unzipped folder at ${location}
# and rewrite deep links to the owning surface's shell.
server {
listen 80;
server_name webmail.example.com;
root /var/www/bulwark-lite;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# See _headers for a Content-Security-Policy that matches your JMAP server.
location ${basePath}/_next/static/ {
${basePath ? `alias /var/www/bulwark-lite/_next/static/;\n ` : ""}expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# /<locale>/<surface>/anything -> /<locale>/<surface>/index.html
location ~ ^${basePath}/(?<locale>[a-zA-Z-]+)/(?<surface>${surfaceAlternation})(/.*)?$ {
try_files $uri $uri/ $uri/index.html ${basePath}/$locale/$surface/index.html;
}
location ${location} {
${basePath ? `alias /var/www/bulwark-lite/;\n ` : ""}try_files $uri $uri/ $uri/index.html =404;
error_page 404 ${basePath}/404.html;
}
}
`;
}
export function buildCaddyExample({ basePath = "", surfaces = LITE_SURFACES }) {
const surfaceAlternation = surfaces.join("|");
return `# Bulwark Lite - Caddy example.
webmail.example.com {
root * /var/www/bulwark-lite
encode gzip
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
}
@surface path_regexp surface ^${basePath}/([a-zA-Z-]+)/(${surfaceAlternation})(/.*)?$
handle @surface {
try_files {path} {path}/ {path}/index.html ${basePath}/{re.surface.1}/{re.surface.2}/index.html
}
handle {
try_files {path} {path}/ {path}/index.html ${basePath}/404.html
}
file_server
}
`;
}
export function buildReadme({ version, commit, basePath = "", locales, jmapServerUrl = "", demoMode = false }) {
return `# Bulwark Lite ${version} (${commit})
A static build of Bulwark Webmail: the same mail, calendar, contacts and files
client, without the Node.js server. Upload this folder to any static host and
point it at your Stalwart (or other JMAP) server.
Built for mount path: ${basePath || "/ (site root)"}
Locales included: ${locales.join(", ")}
${demoMode ? "Demo mode is ON: the login page offers a built-in demo account and no server is needed.\n" : ""}
## Three steps
1. Unzip this archive and upload the whole folder to your web host, so that
\`${basePath || ""}/index.html\` and \`${basePath || ""}/config.json\` are served from the mount path above.
2. Edit \`config.json\`:
- \`jmapServerUrl\`: your mail server, e.g. \`https://mail.example.com\`${jmapServerUrl ? ` (currently \`${jmapServerUrl}\`)` : ""}.
- \`appName\`: the name shown in the tab and on the login page.
- \`allowCustomJmapEndpoint\`: \`true\` shows a server field on the login page.
- \`rememberMeEnabled\`: \`false\` hides "remember me" (sessions then end with the tab).
Optional keys: \`demoMode\`, \`jmapServers\`, \`jmapServerAutoPickByDomain\`, the login logo/company/link keys and \`loginShow*\` toggles (same names as the Docker env vars, camelCased).
3. Allow the browser to talk to the mail server (CORS). In Stalwart:
\`\`\`toml
[http]
permissive-cors = true
\`\`\`
or an equivalent reverse-proxy rule that allows your Lite origin with the
\`Authorization\` and \`Content-Type\` headers on \`/.well-known/jmap\`, \`/jmap/*\`,
\`/api/auth\` and \`/auth/token\`.
## Host configuration
Deep links such as \`${basePath}/en/mail/thread/abc\` are served by the shell at
\`${basePath}/en/mail/index.html\`. Configure your host to fall back to it:
- Netlify / Cloudflare Pages: the shipped \`_redirects\` and \`_headers\` files do this.
- nginx: see \`nginx.conf.example\`.
- Caddy: see \`Caddyfile.example\`.
- GitHub Pages and other hosts without rewrites: \`404.html\` replays the link
in the browser. It works, with one extra page load.
\`_headers\` also carries the recommended security headers. Adjust
\`connect-src\` to your JMAP server's origin if you prefer a strict policy.
## What is different from the full Bulwark Webmail
Everything that runs in the browser works: mail, threads, search, compose,
multiple accounts, calendar, contacts, files, themes, settings export/import,
password and TOTP login, demo mode, deep links.
Not available in Lite (they need the Node.js server): the admin console and
setup wizard, plugins and sidebar apps, settings sync across devices, OAuth /
SSO login, the account security tab (app passwords, 2FA setup), ICS URL
subscriptions and CalDAV discovery, sender favicons, office (WOPI) editing,
web push notifications, the update banner, device pairing.
## Security notes
- No cookies and no server session. Requests to the mail server carry the
token or credentials of the signed-in account only.
- "Remember me" keeps a Stalwart refresh token in the browser's localStorage
(a plain token, never the password). Without "remember me" the token lives
in sessionStorage and the session ends with the tab. Both are readable by
any script running on your Lite origin, so serve Lite from an origin you
control and keep the Content-Security-Policy from \`_headers\`.
- Servers without Stalwart's token login (\`/api/auth\`) fall back to Basic
auth. "Remember me" is then hidden; the credentials stay in sessionStorage
for the lifetime of the tab so a reload does not sign you out.
- Because the export contains inline scripts, \`script-src\` must allow
\`'unsafe-inline'\`. Email HTML is still rendered sanitised in a sandboxed
frame, exactly as in the full build.
`;
}
/** Locales actually present in the export (dirs that hold a mail shell). */
export function discoverBuiltLocales(outDir) {
if (!existsSync(outDir)) return [];
return readdirSync(outDir)
.filter((name) => {
const dir = join(outDir, name);
return statSync(dir).isDirectory() && existsSync(join(dir, "mail", "index.html"));
})
.sort();
}
/** Every `"/api/...` string literal found in the client chunks, deduplicated. */
export function collectApiStrings(chunksDir) {
const found = new Set();
if (!existsSync(chunksDir)) return [];
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) walk(full);
else if (entry.endsWith(".js")) {
const text = readFileSync(full, "utf8");
for (const match of text.matchAll(/["'`](\/api\/[A-Za-z0-9/_-]*)/g)) found.add(match[1]);
}
}
};
walk(chunksDir);
return [...found].sort();
}
export function unexpectedApiStrings(strings, allowlist = LITE_API_STRING_ALLOWLIST) {
return strings.filter((s) => !allowlist.some((allowed) => s === allowed || s.startsWith(`${allowed}/`) || (allowed.endsWith("/") && s === allowed.slice(0, -1))));
}
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
// Writes the deployer-facing files into out/ after `next build` exported the
// shells: runtime config, policy, manifest, the root locale shim and the host
// snippets. Everything is derived from the LITE_* env the build ran with.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
buildCaddyExample, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildReadme, buildRedirects, buildRootRedirect, discoverBuiltLocales, isMainModule, normalizeBasePath, resolveRepoRoot,
} from "./lib.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
export function runPostbuild({ root = repoRoot, env = process.env, log = console.log } = {}) {
const outDir = join(root, "out");
if (!existsSync(outDir)) throw new Error(`[lite] ${outDir} does not exist - run the export first`);
const basePath = normalizeBasePath(env.NEXT_PUBLIC_BASE_PATH);
const locales = discoverBuiltLocales(outDir);
if (locales.length === 0) throw new Error("[lite] no locale shells found under out/ (expected out/<locale>/mail/index.html)");
const defaultLocale = (env.NEXT_PUBLIC_DEFAULT_LOCALE ?? "").trim() || "en";
let version = "0.0.0";
try {
version = readFileSync(join(root, "VERSION"), "utf8").trim();
} catch {
// no VERSION file
}
const commit = (env.GIT_COMMIT ?? "").trim().slice(0, 7) || "local";
const config = buildLiteConfig(env);
const connectSrc = config.allowCustomJmapEndpoint || !config.jmapServerUrl ? "*" : new URL(config.jmapServerUrl).origin;
const files = {
"config.json": JSON.stringify(config, null, 2) + "\n",
"policy.json": JSON.stringify(buildLitePolicy(), null, 2) + "\n",
"manifest.webmanifest": JSON.stringify(buildManifest({ appName: config.appName, basePath }), null, 2) + "\n",
"index.html": buildRootRedirect({ basePath, locales, defaultLocale }),
"_redirects": buildRedirects({ basePath, locales }),
"_headers": buildHeaders({ basePath, connectSrc }),
"nginx.conf.example": buildNginxExample({ basePath }),
"Caddyfile.example": buildCaddyExample({ basePath }),
"LITE-README.md": buildReadme({ version, commit, basePath, locales, jmapServerUrl: config.jmapServerUrl, demoMode: config.demoMode }),
"lite-build.json": JSON.stringify({ version, commit, basePath, locales, builtAt: new Date().toISOString() }, null, 2) + "\n",
};
mkdirSync(outDir, { recursive: true });
for (const [name, content] of Object.entries(files)) {
writeFileSync(join(outDir, name), content);
}
log(`[lite] wrote ${Object.keys(files).length} files into out/ (${locales.length} locales, base path ${basePath || "/"})`);
return { outDir, locales, basePath, files: Object.keys(files) };
}
if (isMainModule(import.meta.url)) {
try {
runPostbuild();
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
}
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
// Strips the server-only trees from a *disposable* checkout so `next build`
// with `output: "export"` has nothing dynamic left to trip over. It never
// rewrites a file - it only deletes - and it refuses to run unless you say
// the tree is throwaway (`CI=true`, as in GitHub Actions, or `--in-place`).
//
// node scripts/lite/prepare.mjs --dry-run # list what would go
// node scripts/lite/prepare.mjs --in-place # delete (in a git worktree!)
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import {
LITE_PRUNE_SKIP_DIRS, LITE_REMOVED_PATHS, LITE_TEST_DIR_NAME, LITE_TEST_FILE_PATTERN, isMainModule, resolveRepoRoot,
} from "./lib.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
export function collectTestPaths(root) {
const out = [];
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
if (LITE_PRUNE_SKIP_DIRS.has(entry)) continue;
const full = join(dir, entry);
let info;
try {
info = statSync(full);
} catch {
continue;
}
if (info.isDirectory()) {
if (entry === LITE_TEST_DIR_NAME) out.push(full);
else walk(full);
} else if (LITE_TEST_FILE_PATTERN.test(entry)) {
out.push(full);
}
}
};
walk(root);
return out;
}
export function planRemovals(root) {
const fixed = LITE_REMOVED_PATHS.map((p) => join(root, ...p.split("/"))).filter((p) => existsSync(p));
return [...fixed, ...collectTestPaths(root)];
}
export function runPrepare({ root = repoRoot, argv = process.argv.slice(2), env = process.env, log = console.log } = {}) {
const dryRun = argv.includes("--dry-run");
const inPlace = argv.includes("--in-place") || ["true", "1"].includes(String(env.CI).toLowerCase());
const removals = planRemovals(root);
if (dryRun) {
log(`[lite] would remove ${removals.length} paths from ${root}:`);
for (const p of removals) log(` - ${relative(root, p)}`);
return { removed: [], planned: removals };
}
if (!inPlace) {
throw new Error(
"[lite] refusing to delete files from a working tree. Run this in a disposable checkout " +
"(CI=true) or pass --in-place inside a throwaway `git worktree`. Use --dry-run to preview.",
);
}
for (const p of removals) rmSync(p, { recursive: true, force: true });
log(`[lite] removed ${removals.length} server-only paths from ${root}`);
return { removed: removals, planned: removals };
}
if (isMainModule(import.meta.url)) {
try {
runPrepare();
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
}
}
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
// Dependency-free static server for a Lite export, applying the same SPA
// fallback the shipped nginx/Caddy snippets do. Used by the Playwright smoke
// test and handy for a local look at out/:
//
// node scripts/lite/serve.mjs [outDir=out] [port=4173] [basePath=]
import { createServer } from "node:http";
import { createReadStream, existsSync, statSync } from "node:fs";
import { extname, join, normalize, resolve } from "node:path";
import { LITE_SURFACES } from "./lib.mjs";
const root = resolve(process.argv[2] || "out");
const port = Number(process.argv[3] || 4173);
const basePath = (process.argv[4] || "").replace(/\/+$/, "");
const TYPES = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".webmanifest": "application/manifest+json",
".txt": "text/plain; charset=utf-8",
".md": "text/markdown; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
".woff2": "font/woff2",
".woff": "font/woff",
".map": "application/json",
};
function send(res, file, status = 200) {
res.writeHead(status, { "content-type": TYPES[extname(file)] || "application/octet-stream" });
createReadStream(file).pipe(res);
}
function fileFor(pathname) {
const rel = normalize(decodeURIComponent(pathname)).replace(/^[/\\]+/, "");
const direct = join(root, rel);
if (!direct.startsWith(root)) return null;
if (existsSync(direct) && statSync(direct).isFile()) return direct;
const index = join(direct, "index.html");
if (existsSync(index)) return index;
const html = `${direct}.html`;
if (existsSync(html)) return html;
return null;
}
const surfacePattern = new RegExp(`^/([^/]+)/(${LITE_SURFACES.join("|")})(?:/|$)`);
export function resolveRequest(pathname) {
if (basePath) {
if (pathname !== basePath && !pathname.startsWith(`${basePath}/`)) return { status: 404, file: join(root, "404.html") };
pathname = pathname.slice(basePath.length) || "/";
}
const file = fileFor(pathname);
if (file) return { status: 200, file };
const match = pathname.match(surfacePattern);
if (match) {
const shell = join(root, match[1], match[2], "index.html");
if (existsSync(shell)) return { status: 200, file: shell };
}
return { status: 404, file: join(root, "404.html") };
}
createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
const { status, file } = resolveRequest(url.pathname);
if (file && existsSync(file)) return send(res, file, status);
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found");
}).listen(port, () => {
console.log(`[lite] serving ${root} at http://localhost:${port}${basePath}/`);
});
+83
View File
@@ -0,0 +1,83 @@
import { test, expect, type Page } from "@playwright/test";
/**
* Drives a Lite export built with LITE_DEMO_MODE=true. Everything runs in the
* browser against the built-in demo client, so no mail server is needed.
*
* What it proves:
* - the root shim picks a locale and the login shell hydrates,
* - config.json (not /api/config) configures the app,
* - the demo account signs in and the mail list renders,
* - top-level navigation between static shells works client-side,
* - a deep link reload keeps its URL and the shell still hydrates,
* - a deep link parked by the 404 shim is replayed by the surface,
* - no request ever targets a server endpoint on the Lite origin.
*/
async function loginAsDemo(page: Page) {
await page.goto("/en/login/");
const demoButton = page.getByRole("button", { name: /demo/i }).first();
await expect(demoButton).toBeVisible({ timeout: 30_000 });
await demoButton.click();
await page.waitForURL(/\/en\/?(mail\/?)?(\?.*)?$/, { timeout: 30_000 });
}
test.describe("Bulwark Lite demo export", () => {
let apiRequests: string[];
test.beforeEach(async ({ page }) => {
apiRequests = [];
page.on("request", (req) => {
const url = new URL(req.url());
if (url.origin === new URL(page.url() || "http://localhost").origin && url.pathname.includes("/api/")) {
apiRequests.push(url.pathname);
}
});
});
test("root shim redirects to a locale and serves config.json", async ({ page }) => {
await page.goto("/");
await page.waitForURL(/\/(en|de)\/$/);
const config = await page.request.get("/config.json");
expect(config.ok()).toBe(true);
const body = await config.json();
expect(body.demoMode).toBe(true);
expect(apiRequests).toEqual([]);
});
test("demo login, surface switch and deep-link reload", async ({ page }) => {
await loginAsDemo(page);
// Mail list rendered from the demo client.
await expect(page.locator("body")).toContainText(/inbox/i, { timeout: 30_000 });
// Top-level navigation to other static shells.
await page.getByRole("link", { name: /calendar/i }).first().click();
await page.waitForURL(/\/en\/calendar\/?/);
await page.getByRole("link", { name: /contacts/i }).first().click();
await page.waitForURL(/\/en\/contacts\/?/);
// A deep link below a surface must keep its URL after a full reload and
// still hydrate the shell (served through the SPA fallback).
// (The calendar re-serialises its own state into the URL once applied,
// so the view segment may change; the date must survive.)
await page.goto("/en/calendar/week/2026-09-17");
await expect(page.locator("body")).toContainText(/September 2026/, { timeout: 30_000 });
expect(new URL(page.url()).pathname).toMatch(/^\/en\/calendar\/\w+\/2026-09-17$/);
expect(apiRequests).toEqual([]);
});
test("a deep link parked by the 404 shim is replayed on the surface", async ({ page }) => {
await loginAsDemo(page);
// What app/(main)/not-found.tsx does on a host without rewrite rules:
// park the link, load the surface root.
await page.evaluate(() => {
sessionStorage.setItem("bulwark-lite:pending-path", "/en/mail/folder/inbox");
});
await page.goto("/en/mail/");
await page.waitForFunction(() => window.location.pathname === "/en/mail/folder/inbox", null, { timeout: 30_000 });
expect(await page.evaluate(() => sessionStorage.getItem("bulwark-lite:pending-path"))).toBeNull();
expect(apiRequests).toEqual([]);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from "@playwright/test";
// Smoke test for a finished Lite export (`npm run build:lite` with
// LITE_DEMO_MODE=true). Serves out/ with the dependency-free static server
// that applies the same SPA fallback the shipped host snippets do.
const port = Number(process.env.LITE_SMOKE_PORT || 4173);
export default defineConfig({
testDir: ".",
testMatch: /.*\.spec\.ts$/,
timeout: 60_000,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
use: {
baseURL: `http://localhost:${port}`,
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
webServer: {
command: `node scripts/lite/serve.mjs out ${port}`,
port,
reuseExistingServer: !process.env.CI,
cwd: process.cwd(),
},
});
+49
View File
@@ -0,0 +1,49 @@
#!/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.
import { existsSync } from "node:fs";
import { join } from "node:path";
import {
collectApiStrings, discoverBuiltLocales, isMainModule, resolveRepoRoot, unexpectedApiStrings,
} from "./lib.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
export function verifyExport({ root = repoRoot } = {}) {
const outDir = join(root, "out");
const problems = [];
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`);
}
for (const file of ["index.html", "404.html", "config.json", "policy.json", "manifest.webmanifest", "_redirects", "_headers", "LITE-README.md", "lite-build.json"]) {
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}`);
}
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)`);
return { problems, locales, apiStrings };
}
if (isMainModule(import.meta.url)) {
const { problems, locales, apiStrings } = verifyExport();
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] export verified: ${locales.length} locales, ${apiStrings.length} known /api strings in chunks`);
}