mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: add Bulwark Lite static export build
This commit is contained in:
@@ -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`);
|
||||
@@ -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))));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}/`);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
Reference in New Issue
Block a user