feat: add Bulwark Lite build for Stalwart Applications

This commit is contained in:
Linus Rath
2026-09-19 00:53:53 +02:00
parent 2d4a562432
commit fdfcb1711e
30 changed files with 2267 additions and 84 deletions
+32 -8
View File
@@ -5,15 +5,19 @@
// 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/
// (Stalwart target: the entry document, then the zip)
// 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).
// Inputs (env): LITE_TARGET (static | stalwart), LITE_JMAP_SERVER_URL,
// LITE_APP_NAME, LITE_ALLOW_CUSTOM_ENDPOINT, LITE_REMEMBER_ME, LITE_DEMO_MODE,
// LITE_LOCALES, NEXT_PUBLIC_BASE_PATH (static target only),
// NEXT_PUBLIC_DEFAULT_LOCALE, GIT_COMMIT. Flags: --target=stalwart,
// --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 { readFileSync } from "node:fs";
import { join } from "node:path";
import { makeBuildId, normalizeBasePath, parseLiteTarget, resolveRepoRoot } from "./lib.mjs";
import { runPrepare } from "./prepare.mjs";
import { runPostbuild } from "./postbuild.mjs";
import { verifyExport } from "./verify.mjs";
@@ -21,6 +25,20 @@ import { verifyExport } from "./verify.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
const argv = process.argv.slice(2);
const dryRun = argv.includes("--dry-run");
const target = parseLiteTarget(argv, process.env);
if (target === "stalwart" && normalizeBasePath(process.env.NEXT_PUBLIC_BASE_PATH)) {
console.error("[lite] --target=stalwart detects its mount prefix at runtime (Stalwart's urlPrefix); unset NEXT_PUBLIC_BASE_PATH");
process.exit(1);
}
let version = "0.0.0";
try {
version = readFileSync(join(repoRoot, "VERSION"), "utf8").trim();
} catch {
// no VERSION file
}
const commit = (process.env.GIT_COMMIT ?? "").trim().slice(0, 7) || "local";
const env = {
...process.env,
@@ -29,11 +47,17 @@ const env = {
// in every URL.
NEXT_PUBLIC_LOCALE_PREFIX: "always",
NEXT_TELEMETRY_DISABLED: process.env.NEXT_TELEMETRY_DISABLED ?? "1",
LITE_TARGET: target,
NEXT_PUBLIC_LITE_TARGET: target,
// One id for the bundle and the entry document that fetches from it.
NEXT_PUBLIC_LITE_BUILD_ID: process.env.NEXT_PUBLIC_LITE_BUILD_ID?.trim() || makeBuildId({ version, commit }),
};
if (dryRun) {
runPrepare({ argv: ["--dry-run"], env });
console.log("[lite] would run: next build --turbopack with", {
LITE_TARGET: target,
LITE_BUILD_ID: env.NEXT_PUBLIC_LITE_BUILD_ID,
LITE_JMAP_SERVER_URL: env.LITE_JMAP_SERVER_URL ?? "",
LITE_APP_NAME: env.LITE_APP_NAME ?? "",
LITE_LOCALES: env.LITE_LOCALES ?? "(all)",
@@ -49,7 +73,7 @@ if (!argv.includes("--skip-prepare")) {
const require = createRequire(import.meta.url);
const nextBin = require.resolve("next/dist/bin/next", { paths: [repoRoot] });
console.log("[lite] next build --turbopack (static export)");
console.log(`[lite] next build --turbopack (static export, ${target} target, build ${env.NEXT_PUBLIC_LITE_BUILD_ID})`);
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}`);
@@ -58,10 +82,10 @@ if (build.status !== 0) {
runPostbuild({ env });
const { problems, locales, apiStrings } = verifyExport();
const { problems, locales, apiStrings, 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] done: out/ holds ${locales.length} locales; ${apiStrings.length} documented /api strings in chunks`);
console.log(`[lite] done: out/ holds ${locales.length} locales; ${apiStrings.length} documented /api strings in chunks${zipBytes ? `; zip ${(zipBytes / 1024 / 1024).toFixed(1)} MB` : ""}`);
+830 -4
View File
@@ -4,6 +4,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { crc32, deflateRawSync, inflateRawSync } from "node:zlib";
/**
* Repository root for a script under scripts/lite/. Falls back to the current
@@ -131,14 +132,21 @@ export function normalizeBasePath(raw) {
return trimmed;
}
/** config.json contents from the LITE_* build inputs. */
export function buildLiteConfig(env = {}) {
/**
* config.json contents from the LITE_* build inputs. The Stalwart target is
* served by the mail server itself: an empty server URL means "this origin"
* there, so the server field stays hidden unless a build asks for it.
*/
export function buildLiteConfig(env = {}, { target = "static" } = {}) {
const jmapServerUrl = (env.LITE_JMAP_SERVER_URL ?? "").trim().replace(/\/+$/, "");
const stalwart = target === "stalwart";
return {
_comment: "Bulwark Lite runtime configuration. Edit and re-upload; no rebuild needed. See LITE-README.md.",
_comment: stalwart
? "Bulwark Lite for Stalwart. Read-only inside the Application bundle: to change it, build your own zip (see LITE-README.md). An empty jmapServerUrl means the Stalwart server that serves this page."
: "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 === ""),
allowCustomJmapEndpoint: parseBool(env.LITE_ALLOW_CUSTOM_ENDPOINT, !stalwart && jmapServerUrl === ""),
rememberMeEnabled: parseBool(env.LITE_REMEMBER_ME, true),
demoMode: parseBool(env.LITE_DEMO_MODE, false),
loginShowTotp: true,
@@ -532,3 +540,821 @@ export function collectApiStrings(chunksDir) {
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))));
}
// ---------------------------------------------------------------------------
// Stalwart `Application` target (`npm run build:lite -- --target=stalwart`)
// ---------------------------------------------------------------------------
//
// Stalwart 0.16 downloads a zip from the Application's `resourceUrl` and
// serves it under `urlPrefix` (crates/common/src/manager/application.rs,
// crates/http/src/request.rs). The rules that shape this target:
//
// - lookup is an exact match of the path after `/<prefix>/` against a zip
// entry name; everything else (directories, deep links, typos) gets the
// root index.html - there is no directory index and no 404;
// - only that root index.html is rewritten (`<base href="/"` -> the prefix,
// `<meta name="oauth-client-id" content=""` -> the Application's client
// id) and sent `no-cache`; every other entry is `immutable` for a year;
// - content types come from a fixed extension map (below), the rest is
// application/octet-stream;
// - the bundle is same-origin with /jmap, /api/auth and /auth/token.
export const LITE_TARGETS = ["static", "stalwart"];
/** `--target=stalwart` (or `--target stalwart`) wins over LITE_TARGET. */
export function parseLiteTarget(argv = [], env = {}) {
let value = env.LITE_TARGET;
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith("--target=")) value = argv[i].slice("--target=".length);
else if (argv[i] === "--target" && argv[i + 1]) value = argv[i + 1];
}
const target = (value ?? "").trim().toLowerCase() || "static";
if (!LITE_TARGETS.includes(target)) throw new Error(`unknown Lite target ${JSON.stringify(value)} (expected ${LITE_TARGETS.join(" or ")})`);
return target;
}
/** Release asset name; `releases/latest/download/<this>` is the stable resourceUrl. */
export const STALWART_ZIP_NAME = "bulwark-lite-stalwart.zip";
/** The mount prefix the docs and the one-line install use. */
export const STALWART_DEFAULT_PREFIX = "/webmail";
/**
* First path segments Stalwart routes itself before it ever looks at an
* Application (crates/http/src/request.rs, 0.16.22), plus the WebUI's own
* default Applications. An Application mounted at one of these is
* unreachable (`mail` and `calendar` included: Stalwart answers
* /mail/config-v1.1.xml and /calendar/rsvp and 404s everything else there).
*/
export const STALWART_RESERVED_SEGMENTS = [
"jmap", "dav", ".well-known", "auth", "scim", "api", "mail", "calendar",
"autodiscover", "robots.txt", "healthz", "metrics", "logo", "form", "login", "device",
"admin", "account",
];
/** Case-insensitive for autodiscover, which Stalwart matches in three spellings. */
export function isReservedStalwartSegment(segment) {
const s = String(segment ?? "");
return STALWART_RESERVED_SEGMENTS.includes(s) || s.toLowerCase() === "autodiscover";
}
/** Problems with an admin-chosen `urlPrefix` (empty list = fine). */
export function stalwartPrefixProblems(prefix) {
const raw = String(prefix ?? "");
const trimmed = raw.replace(/^\/+/, "").replace(/\/+$/, "");
if (!trimmed) return ["the prefix is empty (Stalwart needs one path segment, e.g. /webmail)"];
if (trimmed.includes("/")) return [`${raw} has more than one segment; Stalwart matches the first segment only`];
if (!/^[A-Za-z0-9._~-]+$/.test(trimmed)) return [`${raw} contains characters outside [A-Za-z0-9._~-]`];
if (isReservedStalwartSegment(trimmed)) return [`/${trimmed} is routed by Stalwart itself and would never reach the Application`];
return [];
}
/** Stalwart's extension -> content type map (application.rs). No charset. */
export const STALWART_CONTENT_TYPES = {
html: "text/html",
css: "text/css",
wasm: "application/wasm",
js: "application/javascript",
json: "application/json",
png: "image/png",
svg: "image/svg+xml",
ico: "image/x-icon",
};
export function stalwartContentType(name) {
const text = String(name);
const at = text.lastIndexOf(".");
const ext = at === -1 ? "" : text.slice(at + 1);
return STALWART_CONTENT_TYPES[ext] ?? "application/octet-stream";
}
/** Stalwart refuses bundles above this (MAX_APP_SIZE, compressed). */
export const STALWART_MAX_BUNDLE_BYTES = 100 * 1024 * 1024;
/** verify.mjs fails the build above this, leaving headroom for growth. */
export const STALWART_SAFE_BUNDLE_BYTES = 90 * 1024 * 1024;
/** The two literals Stalwart replaces in the root index.html. */
export const STALWART_BASE_HREF_LITERAL = '<base href="/"';
export const STALWART_OAUTH_META_LITERAL = '<meta name="oauth-client-id" content=""';
/** Marks the entry document so it never writes itself into the page. */
export const STALWART_ENTRY_MARKER = "data-bulwark-lite-entry";
/** Files of the static-host target that make no sense inside a Stalwart bundle. */
export const STALWART_EXCLUDED_FILES = ["404.html", "_redirects", "_headers", "nginx.conf.example", "Caddyfile.example", "manifest.webmanifest"];
export const STALWART_EXCLUDED_DIRS = ["404"];
/** A build id that changes with every build: version, commit, build time. */
export function makeBuildId({ version = "0.0.0", commit = "local", now = Date.now() } = {}) {
const safe = (s) => String(s).replace(/[^A-Za-z0-9.-]/g, "");
return `${safe(version)}-${safe(commit).slice(0, 7) || "local"}-${Math.floor(now / 1000).toString(36)}`;
}
/**
* Directories below `<outDir>/<locale>/` that hold an index.html: the shells
* the entry can boot ('' is the locale root, i.e. the mail client).
*/
export function discoverShellDirs(outDir, locale) {
const root = join(outDir, locale);
const found = [];
const walk = (dir, rel) => {
if (existsSync(join(dir, "index.html"))) found.push(rel);
for (const entry of readdirSync(dir)) {
if (entry.startsWith("__next") || entry.startsWith("_")) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) walk(full, rel ? `${rel}/${entry}` : entry);
}
};
if (existsSync(root)) walk(root, "");
return found.sort();
}
/*
* The three functions below run in the browser: buildStalwartEntry embeds
* them with Function#toString, and lib/__tests__/lite-scripts.test.ts tests
* the very same code. Keep them self-contained (no imports, no module scope).
*/
/**
* Which shell to boot for `pathname` under `mount`. Returns
* `{ locale, shell, canonical }`: `shell` is the zip entry to fetch
* (`en/calendar/index.html`), `canonical` a URL to `replaceState` to first
* (no locale in the address, or a path no shell owns) or null to keep the
* address. Returns null when `pathname` is not below `mount`.
*/
export function resolveStalwartBoot(pathname, mount, locales, shells, preferredLocale) {
var rest = pathname;
if (mount) {
if (rest !== mount && rest.indexOf(mount + "/") !== 0) return null;
rest = rest.slice(mount.length) || "/";
}
var parts = rest.split("/").filter(function (p) { return p.length > 0; });
var locale = locales.indexOf(parts[0]) !== -1 ? parts[0] : null;
if (!locale) {
return { locale: preferredLocale, shell: preferredLocale + "/index.html", canonical: mount + "/" + preferredLocale + "/" };
}
var best = null;
for (var i = 0; i < shells.length; i++) {
var dir = shells[i];
if (!dir) continue;
var segs = dir.split("/");
var match = true;
for (var j = 0; j < segs.length; j++) if (parts[1 + j] !== segs[j]) { match = false; break; }
if (match && (!best || segs.length > best.split("/").length)) best = dir;
}
if (best) return { locale: locale, shell: locale + "/" + best + "/index.html", canonical: null };
if (parts.length === 1) return { locale: locale, shell: locale + "/index.html", canonical: null };
return { locale: locale, shell: locale + "/index.html", canonical: mount + "/" + locale + "/" };
}
/**
* Points a prerendered shell at the runtime mount. Next wrote root-absolute
* URLs (`/_next/...`, the manifest, the favicon) into the HTML and into the
* inline RSC payload; both spellings (`"/_next/` and `\"/_next/`) contain
* `"/_next/`, so one split/join covers them. `bootScript` goes first into
* <head> so the globals exist before any chunk runs.
*/
export function rewriteShellForMount(html, mount, buildId, bootScript) {
// The needles are assembled so the entry itself holds no root-absolute
// literal (verify.mjs checks for them).
var q = '"';
var out = html
.split(q + "/_next/").join(q + mount + "/_next/")
.split(q + "/manifest.webmanifest" + q).join(q + mount + "/manifest.json?v=" + encodeURIComponent(buildId) + q)
.split(q + "/branding/").join(q + mount + "/branding/");
var at = out.indexOf("<head>");
return at === -1 ? bootScript + out : out.slice(0, at + 6) + bootScript + out.slice(at + 6);
}
/**
* The same mount rewrite for a prerendered RSC payload (`*.txt`), which the
* router fetches on client-side navigation and whose module rows name their
* chunks as `/_next/static/chunks/...` (Turbopack loads those URLs as they
* are). Rows are `<id>:<json>\n`, except `<id>:T<hex byte length>,<text>`
* rows, which are length-prefixed and copied untouched so the lengths stay
* right (verify.mjs checks that no text row ever holds one of the needles).
*/
export function rewriteFlightForMount(text, mount, buildId) {
var q = '"';
function fix(part) {
return part
.split(q + "/_next/").join(q + mount + "/_next/")
.split(q + "/manifest.webmanifest" + q).join(q + mount + "/manifest.json?v=" + encodeURIComponent(buildId) + q)
.split(q + "/branding/").join(q + mount + "/branding/");
}
var out = "";
var i = 0;
var n = text.length;
while (i < n) {
var colon = text.indexOf(":", i);
var newline = text.indexOf("\n", i);
if (colon !== -1 && (newline === -1 || colon < newline) && /^[0-9a-f]*$/.test(text.slice(i, colon)) && text.charAt(colon + 1) === "T") {
var comma = text.indexOf(",", colon);
var bytes = comma === -1 ? NaN : parseInt(text.slice(colon + 2, comma), 16);
if (!(bytes >= 0)) {
out += text.slice(i);
break;
}
var j = comma + 1;
var seen = 0;
while (j < n && seen < bytes) {
var code = text.charCodeAt(j);
if (code < 0x80) seen += 1;
else if (code < 0x800) seen += 2;
else if (code >= 0xd800 && code <= 0xdbff) { seen += 4; j++; }
else seen += 3;
j++;
}
out += text.slice(i, j);
i = j;
continue;
}
var end = newline === -1 ? n : newline + 1;
out += fix(text.slice(i, end));
i = end;
}
return out;
}
/**
* Makes Next's client-side navigation work on Stalwart. For RSC payloads
* (same origin, below the mount, `.txt`):
*
* - Stalwart labels them application/octet-stream, and Next's router only
* accepts text/plain: anything else turns every navigation into a full
* page load. They are relabeled.
* - their chunk URLs are pointed at the mount (`rewriteFlight`).
*
* HTML (Stalwart's answer for a payload the bundle lacks) and everything
* else pass through untouched.
*/
export function installStalwartFetchShim(win, mount, buildId, rewriteFlight) {
if (!win || !win.fetch || win.__bulwarkLiteFetchShim) return;
var original = win.fetch;
win.__bulwarkLiteFetchShim = true;
win.fetch = function () {
return original.apply(this, arguments).then(function (res) {
var url;
try {
var type = res.headers.get("content-type") || "";
if (type.indexOf("application/octet-stream") !== 0 && type.indexOf("text/plain") !== 0) return res;
url = new URL(res.url);
if (url.origin !== win.location.origin || !/\.txt$/.test(url.pathname)) return res;
if (mount && url.pathname.indexOf(mount + "/") !== 0) return res;
} catch {
return res;
}
return res.text().then(function (body) {
var headers = new Headers(res.headers);
headers.set("content-type", "text/plain; charset=utf-8");
headers.delete("content-length");
var relabeled = new Response(rewriteFlight(body, mount, buildId), { status: res.status, statusText: res.statusText, headers: headers });
Object.defineProperty(relabeled, "url", { value: res.url });
Object.defineProperty(relabeled, "redirected", { value: res.redirected });
return relabeled;
});
});
};
}
// Built from char codes: the literal escapes would be unescaped by editors and tools.
const BACKSLASH = String.fromCharCode(92);
const LINE_SEPARATOR = new RegExp(String.fromCharCode(0x2028), "g");
const PARAGRAPH_SEPARATOR = new RegExp(String.fromCharCode(0x2029), "g");
/** Serialises a value for an inline <script> (no `</script>` breakout). */
export function inlineJson(value) {
return JSON.stringify(value)
.replace(/</g, BACKSLASH + "u003c")
.replace(LINE_SEPARATOR, BACKSLASH + "u2028")
.replace(PARAGRAPH_SEPARATOR, BACKSLASH + "u2029");
}
/**
* The root index.html of the Stalwart bundle, the only document Stalwart
* ever serves for a route. It reads the mount from the rewritten
* `<base href>` and the client id from the rewritten meta tag, fetches the
* matching prerendered shell (build-id URL, since shells are cached
* immutably), points it at the mount and writes it into the page in place:
* the address bar keeps the deep link and the shell hydrates exactly as it
* would behind an nginx SPA fallback.
*/
export function buildStalwartEntry({ locales, shells, defaultLocale = "en", buildId, appName = "Bulwark Webmail" }) {
const fallback = locales.includes(defaultLocale) ? defaultLocale : locales[0];
const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const script = `
(function () {
var BUILD_ID = ${inlineJson(buildId)};
var LOCALES = ${inlineJson(locales)};
var SHELLS = ${inlineJson(shells)};
var FALLBACK = ${inlineJson(fallback)};
var MOUNT_GLOBAL = "__BULWARK_LITE_MOUNT__";
var CLIENT_ID_GLOBAL = "__BULWARK_LITE_OAUTH_CLIENT_ID__";
var ENTRY_MARKER = ${inlineJson(STALWART_ENTRY_MARKER)};
var resolveStalwartBoot = ${resolveStalwartBoot.toString()};
var rewriteShellForMount = ${rewriteShellForMount.toString()};
var rewriteFlightForMount = ${rewriteFlightForMount.toString()};
var installStalwartFetchShim = ${installStalwartFetchShim.toString()};
try {
var theme = JSON.parse(localStorage.getItem("theme-storage") || "null");
var chosen = theme && theme.state && theme.state.theme;
if (chosen === "dark" || chosen === "light") document.documentElement.className = chosen;
} catch (e) {}
function fail(message) {
function show() {
var box = document.getElementById("bulwark-lite-boot");
if (!box) return;
var p = document.createElement("p");
p.textContent = message;
box.innerHTML = "";
box.appendChild(p);
box.removeAttribute("aria-busy");
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", show); else show();
}
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) {
if (/zh-(tw|hk|mo|hant)/.test(lower) && 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 preferred = null;
try {
var stored = JSON.parse(localStorage.getItem(${inlineJson(LOCALE_STORAGE_KEY)}) || "null");
preferred = pick(stored && stored.state && stored.state.locale);
} catch (e) {}
var langs = navigator.languages || [navigator.language];
for (var j = 0; !preferred && j < langs.length; j++) preferred = pick(langs[j]);
preferred = preferred || FALLBACK;
// Stalwart rewrote the base element to point at the Application's prefix.
var mount = new URL(document.baseURI).pathname.replace(/[/]+$/, "");
if (!/^([/][A-Za-z0-9._~-]+)?$/.test(mount)) {
return fail("Bulwark Webmail cannot run under " + mount + "/: the mount prefix must be a single path segment such as /webmail.");
}
var metaTag = document.querySelector('meta[name="oauth-client-id"]');
var clientId = (metaTag && metaTag.getAttribute("content")) || "";
var boot = resolveStalwartBoot(location.pathname, mount, LOCALES, SHELLS, preferred);
if (!boot) {
return fail("This page was opened at " + location.pathname + ", but the bundle is mounted at " + (mount || "/") + ".");
}
if (boot.canonical) {
try { history.replaceState(history.state, "", boot.canonical + location.search + location.hash); } catch (e) {}
}
window[MOUNT_GLOBAL] = mount;
window[CLIENT_ID_GLOBAL] = clientId;
window.TURBOPACK_CHUNK_BASE_PATH = mount + "/_next/";
installStalwartFetchShim(window, mount, BUILD_ID, rewriteFlightForMount);
// Repeated inside the written shell, in case a browser gives it a fresh global.
var bootScript = "<scr" + "ipt>(function(w){w[" + JSON.stringify(MOUNT_GLOBAL) + "]=" + JSON.stringify(mount) +
";w[" + JSON.stringify(CLIENT_ID_GLOBAL) + "]=" + JSON.stringify(clientId).replace(/</g, "\\\\u003c") +
";w.TURBOPACK_CHUNK_BASE_PATH=" + JSON.stringify(mount + "/_next/") +
";(" + installStalwartFetchShim.toString() + ")(w," + JSON.stringify(mount) + "," + JSON.stringify(BUILD_ID) + "," + rewriteFlightForMount.toString() + ");})(window)</scr" + "ipt>";
fetch(mount + "/" + boot.shell + "?v=" + encodeURIComponent(BUILD_ID), { credentials: "same-origin" })
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
return res.text();
})
.then(function (html) {
// A shell missing from the zip is answered with this very document.
if (html.indexOf(ENTRY_MARKER) !== -1) throw new Error(boot.shell + " is not part of this bundle");
var page = rewriteShellForMount(html, mount, BUILD_ID, bootScript);
document.open();
document.write(page);
document.close();
})
.catch(function (err) {
fail("Bulwark Webmail could not start (" + (err && err.message ? err.message : err) + "). Reload the page; if this keeps happening, the installed bundle may be incomplete.");
});
})();
`;
return `<!doctype html>
<html lang="en" ${STALWART_ENTRY_MARKER}="">
<head>
<meta charset="utf-8" />
${STALWART_BASE_HREF_LITERAL} />
${STALWART_OAUTH_META_LITERAL} />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex" />
<title>${esc(appName)}</title>
<style>
html, body { margin: 0; min-height: 100vh; background: #fff; color: #111; font-family: system-ui, sans-serif; }
html.dark, html.dark body { background: #0a0a0a; color: #ededed; }
@media (prefers-color-scheme: dark) { html:not(.light), html:not(.light) body { background: #0a0a0a; color: #ededed; } }
#bulwark-lite-boot { display: flex; min-height: 100vh; align-items: center; justify-content: center; padding: 1rem; box-sizing: border-box; text-align: center; }
#bulwark-lite-boot p { max-width: 36rem; opacity: .8; line-height: 1.5; }
</style>
<script>${script}</script>
</head>
<body>
<div id="bulwark-lite-boot" aria-busy="true"><noscript><p>Bulwark Webmail needs JavaScript.</p></noscript></div>
</body>
</html>
`;
}
/** manifest.json with relative URLs: they resolve against the manifest's own (mounted) URL. */
export function buildStalwartManifest({ appName }) {
return {
name: appName,
short_name: appName,
description: `${appName} - webmail for Stalwart`,
start_url: "./",
scope: "./",
display: "standalone",
background_color: "#ffffff",
theme_color: "#ffffff",
icons: [
{ src: "icon-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "icon-512x512.png", sizes: "512x512", type: "image/png" },
{ src: "icon-maskable-light-192x192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "icon-maskable-light-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}
/** Problems with the root index.html of a Stalwart bundle (empty = fine). */
export function stalwartEntryProblems(html) {
const problems = [];
const count = (needle) => html.split(needle).length - 1;
if (count(STALWART_BASE_HREF_LITERAL) !== 1) problems.push(`index.html must contain ${STALWART_BASE_HREF_LITERAL} exactly once (found ${count(STALWART_BASE_HREF_LITERAL)})`);
if (count(STALWART_OAUTH_META_LITERAL) !== 1) problems.push(`index.html must contain ${STALWART_OAUTH_META_LITERAL} exactly once (found ${count(STALWART_OAUTH_META_LITERAL)})`);
if (!html.includes(STALWART_ENTRY_MARKER)) problems.push("index.html is not the Stalwart entry document");
// Root-absolute URLs would escape the mount prefix Stalwart put into <base>.
for (const attr of html.replace(STALWART_BASE_HREF_LITERAL, "").match(/\b(?:src|href)="\/(?!\/)[^"]*"/g) ?? []) {
problems.push(`index.html has a root-absolute ${attr}; it must follow the runtime mount`);
}
for (const absolute of ['"/_next/static', '"/config.json', '"/policy.json']) {
if (html.includes(absolute)) problems.push(`index.html references ${absolute.slice(1)} absolutely; it must follow the runtime mount`);
}
return problems;
}
/** Problems with the entry list of a Stalwart zip (empty = fine). */
export function stalwartZipEntryProblems(names) {
const problems = [];
if (!names.includes("index.html")) problems.push("index.html is not at the archive root (no wrapping folder allowed)");
if (names.length >= 0xffff) problems.push(`${names.length} entries need ZIP64, which this writer does not produce`);
const reserved = new Set();
for (const name of names) {
const first = name.split("/")[0];
if (isReservedStalwartSegment(first)) reserved.add(first);
if (STALWART_EXCLUDED_FILES.includes(name) || (name.includes("/") && STALWART_EXCLUDED_DIRS.includes(first))) {
problems.push(`${name} belongs to the static-host target and must not ship in the Stalwart bundle`);
}
if (name.includes("\\") || name.startsWith("/") || name.split("/").includes("..")) problems.push(`unsafe entry name ${JSON.stringify(name)}`);
}
for (const first of reserved) problems.push(`top-level entry ${first} shares its name with a Stalwart route; keep top-level names app-specific`);
return problems;
}
// --- zip ---------------------------------------------------------------------
function dosDateTime(date) {
const d = new Date(date);
const year = Math.max(1980, d.getFullYear());
return {
time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
day: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
};
}
/**
* Minimal zip writer (no dependencies, no ZIP64): deflate when it helps,
* store otherwise, UTF-8 names, entries in the given order, a fixed
* timestamp so identical inputs give identical archives.
*/
export function buildZip(entries, { date = new Date(2000, 0, 1) } = {}) {
const { time, day } = dosDateTime(date);
const locals = [];
const centrals = [];
let offset = 0;
for (const { name, data } of entries) {
const nameBuf = Buffer.from(name, "utf8");
const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
const deflated = deflateRawSync(raw, { level: 9 });
const useDeflate = deflated.length < raw.length;
const body = useDeflate ? deflated : raw;
const crc = crc32(raw) >>> 0;
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(useDeflate ? 8 : 0, 8);
local.writeUInt16LE(time, 10);
local.writeUInt16LE(day, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(body.length, 18);
local.writeUInt32LE(raw.length, 22);
local.writeUInt16LE(nameBuf.length, 26);
local.writeUInt16LE(0, 28);
locals.push(local, nameBuf, body);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE((3 << 8) | 20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(useDeflate ? 8 : 0, 10);
central.writeUInt16LE(time, 12);
central.writeUInt16LE(day, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(body.length, 20);
central.writeUInt32LE(raw.length, 24);
central.writeUInt16LE(nameBuf.length, 28);
central.writeUInt32LE((0o100644 << 16) >>> 0, 38);
central.writeUInt32LE(offset, 42);
centrals.push(central, nameBuf);
offset += 30 + nameBuf.length + body.length;
}
const centralDir = Buffer.concat(centrals);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralDir.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...locals, centralDir, end]);
}
/** Reads a zip's central directory: `[{ name, method, compressedSize, size, offset }]`. */
export function listZipEntries(buffer) {
let eocd = -1;
for (let i = buffer.length - 22; i >= Math.max(0, buffer.length - 22 - 0xffff); i--) {
if (buffer.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
}
if (eocd === -1) throw new Error("not a zip archive (no end-of-central-directory record)");
const count = buffer.readUInt16LE(eocd + 10);
let at = buffer.readUInt32LE(eocd + 16);
const entries = [];
for (let n = 0; n < count; n++) {
if (buffer.readUInt32LE(at) !== 0x02014b50) throw new Error("corrupt central directory");
const nameLen = buffer.readUInt16LE(at + 28);
const extraLen = buffer.readUInt16LE(at + 30);
const commentLen = buffer.readUInt16LE(at + 32);
entries.push({
name: buffer.toString("utf8", at + 46, at + 46 + nameLen),
method: buffer.readUInt16LE(at + 10),
compressedSize: buffer.readUInt32LE(at + 20),
size: buffer.readUInt32LE(at + 24),
offset: buffer.readUInt32LE(at + 42),
});
at += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
/** Extracts one entry listed by `listZipEntries`. */
export function readZipEntry(buffer, entry) {
const nameLen = buffer.readUInt16LE(entry.offset + 26);
const extraLen = buffer.readUInt16LE(entry.offset + 28);
const start = entry.offset + 30 + nameLen + extraLen;
const body = buffer.subarray(start, start + entry.compressedSize);
if (entry.method === 0) return Buffer.from(body);
if (entry.method === 8) return inflateRawSync(body);
throw new Error(`unsupported compression method ${entry.method} for ${entry.name}`);
}
/** Every file below `dir` as a zip entry, `/`-separated and sorted. */
export function collectZipEntries(dir) {
const out = [];
const walk = (full, rel) => {
for (const entry of readdirSync(full).sort()) {
const path = join(full, entry);
const name = rel ? `${rel}/${entry}` : entry;
if (statSync(path).isDirectory()) walk(path, name);
else out.push({ name, data: readFileSync(path) });
}
};
walk(dir, "");
return out;
}
/** LITE-README.md for the Stalwart bundle. */
export function buildStalwartReadme({ version, commit, locales, buildId, demoMode = false }) {
const url = `https://github.com/bulwarkmail/webmail/releases/latest/download/${STALWART_ZIP_NAME}`;
return `# Bulwark Lite ${version} (${commit}) for Stalwart
Bulwark Webmail as a Stalwart \`Application\`: Stalwart downloads this zip,
serves it under a path of its own HTTP listener, and the webmail talks to the
same server's JMAP, login and token endpoints. There is nothing to configure:
no server URL, no CORS, no second web server.
Build: ${buildId}
Locales: ${locales.join(", ")}
Requires: Stalwart 0.16.0 or later (0.16.19 or later to use \`oauthClientId\`).
${demoMode ? "\nDemo mode is ON in this build: the login page offers the built-in demo account.\n" : ""}
## Install
An administrator creates one Application and then runs the \`UpdateApps\`
action: Stalwart only downloads and mounts Applications on that action or at
startup, so creating the object alone serves nothing. Permissions:
\`sysApplicationCreate\` (plus \`sysApplicationGet\`/\`sysApplicationQuery\` to
list, \`sysApplicationUpdate\` to change and \`sysApplicationDestroy\` to remove
it) and \`actionUpdateApps\`. Any of these is equivalent:
- Web admin: **Settings > Web Applications > Create**, description
\`Bulwark Webmail\`, resource URL \`${url}\`, URL prefix \`/webmail\`; then run
the update-applications action (or restart Stalwart).
- CLI:
\`\`\`bash
stalwart-cli create Application \\
--field description='Bulwark Webmail' \\
--field resourceUrl='${url}' \\
--field 'urlPrefix={"/webmail":true}'
stalwart-cli create Action/UpdateApps
\`\`\`
- JMAP (\`urn:stalwart:jmap\`), as an administrator:
\`\`\`json
{
"using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"],
"methodCalls": [
["x:Application/set", { "create": { "webmail": {
"description": "Bulwark Webmail",
"resourceUrl": "${url}",
"urlPrefix": { "/webmail": true }
} } }, "0"],
["x:Action/set", { "create": { "u": { "@type": "UpdateApps" } } }, "1"]
]
}
\`\`\`
Then open \`https://<your stalwart host>/webmail/\` and sign in with a mail
account.
## Mount prefix
\`urlPrefix\` is matched against the first path segment only (\`/webmail\`,
not \`/apps/webmail\`), and several prefixes may serve the same bundle. The
bundle works under any prefix: it reads it from the \`<base href>\` Stalwart
writes into index.html. Stalwart routes these first segments itself, so an
Application mounted there is never reached: \`jmap\`, \`dav\`, \`.well-known\`,
\`auth\`, \`api\`, \`scim\`, \`mail\`, \`calendar\`, \`autodiscover\`, \`login\`,
\`device\`, \`metrics\`, \`healthz\`, \`logo\`, \`form\`, \`robots.txt\`, and the web
admin's own \`admin\` and \`account\`.
## Updates
Stalwart keeps the downloaded zip for \`autoUpdateFrequency\` (default 90
days) and downloads \`resourceUrl\` again at the first restart or
\`UpdateApps\` after that, so the \`latest/download\` URL above follows new
releases on that schedule. \`UpdateApps\` always downloads again: run it to
update right away. A failed download keeps the previous bundle online.
Open tabs move to the new build on their next navigation (one full page
load); a freshly opened tab gets it immediately. Nothing stays stuck on old
files although Stalwart sends a year-long cache header for every file but
index.html: every file the app fetches without a content hash carries the
build id.
To pin a version, point \`resourceUrl\` at a tagged release instead:
\`https://github.com/bulwarkmail/webmail/releases/download/v${version}/${STALWART_ZIP_NAME}\`.
## Sign-in
Passwords (and TOTP codes) go to Stalwart's \`/api/auth\` and are exchanged at
\`/auth/token\` for an access token and a refresh token, all on the same
origin. "Remember me" keeps the refresh token in localStorage, otherwise it
lives with the tab.
The OAuth client id is the Application's \`oauthClientId\` (Stalwart writes it
into index.html) or \`bulwark-webmail\` when that field is empty. If your
server requires registered OAuth clients, register that client id with the
redirect URI \`https://<your stalwart host>/<prefix>/\` (for example
\`https://mail.example.com/webmail/\`), once per prefix you mount.
## Branding and settings
The bundle is read-only once Stalwart has unpacked it. To change the app
name, turn "remember me" off or build any other variant, run the
**Build Static Lite** workflow (or \`npm run build:lite -- --target=stalwart\`)
with your settings, publish the resulting \`${STALWART_ZIP_NAME}\` at a URL
you control, and use that URL as \`resourceUrl\`. \`config.json\` and
\`policy.json\` inside the zip take the same keys as in the static-host build.
## Differences from the static-host zip
- One entry document (\`index.html\`) boots every route: Stalwart has no
rewrite rules and answers every unknown path with it.
- No \`_redirects\`, \`_headers\`, nginx/Caddy examples or \`404.html\`.
- \`config.json\` defaults to the serving Stalwart and hides the server field;
no CORS settings are needed.
- The mount prefix is detected at runtime instead of being baked in.
- \`manifest.json\` replaces \`manifest.webmanifest\` (Stalwart would serve the
latter as \`application/octet-stream\`).
Everything the static-host build leaves out (admin console, plugins,
settings sync, OAuth/SSO via an external identity provider, web push, office
editing, ICS subscriptions) is also missing here.
`;
}
/** Stalwart's escaping of the client id for the meta attribute (application.rs). */
export function stalwartOauthMeta(clientId) {
const escaped = String(clientId).replace(/[&"<>]/g, (ch) => ({ "&": "&amp;", '"': "&quot;", "<": "&lt;", ">": "&gt;" })[ch]);
return `<meta name="oauth-client-id" content="${escaped}"`;
}
/**
* Stalwart's rewrite of the root index.html (0.16.19+: first occurrence of
* each literal; 0.16.0-0.16.18 replaced every `<base href="/"` and had no
* client id support - `legacy: true` emulates that).
*/
export function stalwartRewriteIndex(html, prefix, oauthClientId, { legacy = false } = {}) {
const base = `<base href="/${prefix}/"`;
if (legacy) return html.split(STALWART_BASE_HREF_LITERAL).join(base);
let out = html.replace(STALWART_BASE_HREF_LITERAL, () => base);
if (oauthClientId) out = out.replace(STALWART_OAUTH_META_LITERAL, () => stalwartOauthMeta(oauthClientId));
return out;
}
/**
* One request as Stalwart's HTTP listener answers it for Applications
* (the `external =>` arm of crates/http/src/request.rs plus
* WebApplications::serve). `entries` maps zip entry names to contents,
* `prefixes` are the mounted urlPrefix values.
*
* @param {{ pathname: string, entries: Map<string, Buffer>, prefixes: string[], oauthClientId?: string, legacy?: boolean }} request
* @returns {{ status: number, headers: Record<string, string>, body: Buffer }}
*/
export function emulateStalwartRequest({ pathname, entries, prefixes, oauthClientId = "", legacy = false }) {
const mounted = prefixes.map((p) => String(p).replace(/^\/+|\/+$/g, ""));
const segments = pathname.split("/");
const first = segments[1] ?? "";
const notFound = { status: 404, headers: { "content-type": "application/problem+json" }, body: Buffer.from('{"type":"about:blank","status":404,"title":"Not Found"}') };
if (!first || isReservedStalwartSegment(first) || !mounted.includes(first)) return notFound;
// HttpResponse::redirect answers 302 (verified against 0.16.22).
if (segments.length === 2) return { status: 302, headers: { location: `/${first}/` }, body: Buffer.alloc(0) };
const path = pathname.slice(first.length + 2);
const direct = entries.get(path);
const isIndex = direct ? path === "index.html" : true;
const name = direct ? path : "index.html";
const contents = direct ?? entries.get("index.html");
if (!contents) return notFound;
const body = isIndex ? Buffer.from(stalwartRewriteIndex(contents.toString("utf8"), first, oauthClientId, { legacy })) : contents;
return {
status: 200,
headers: {
"content-type": stalwartContentType(name),
"cache-control": isIndex ? "no-cache" : "public, max-age=31536000, immutable",
},
body,
};
}
/**
* RSC text rows (`<id>:T<hex length>,...`) that contain a string the runtime
* mount rewrite replaces. Those rows are length-prefixed, so
* rewriteFlightForMount leaves them alone; a hit here would mean a URL that
* is never pointed at the mount. Returns the offending row ids.
*/
export function flightTextRowsWithNeedles(text) {
const needles = ['"/_next/', '"/manifest.webmanifest"', '"/branding/'];
const hits = [];
const buffer = Buffer.from(text, "utf8");
const pattern = /(?:^|\n)([0-9a-f]+):T([0-9a-f]+),/g;
let match;
while ((match = pattern.exec(text))) {
const start = Buffer.byteLength(text.slice(0, match.index + match[0].length), "utf8");
const body = buffer.subarray(start, start + parseInt(match[2], 16)).toString("utf8");
if (needles.some((needle) => body.includes(needle))) hits.push(match[1]);
}
return hits;
}
/**
* Next 16.3 exports segment-prefetch payloads as nested directories
* (`<route>/__next.<a>/<b>/__PAGE__.txt`) but requests them with dots
* (`<route>/__next.<a>.<b>.__PAGE__.txt`). On Stalwart the dotted request is
* answered with the entry index.html, which the router ignores before
* fetching `index.txt`, so the nested trees are never read: the Stalwart
* target drops them (about 40% of the zip). `__next._tree.txt` and
* `__next._full.txt` are plain files and stay.
*/
export function findSegmentPrefetchDirs(outDir) {
const found = [];
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (!statSync(full).isDirectory()) continue;
if (entry.startsWith("__next.")) found.push(full);
else if (entry !== "_next") walk(full);
}
};
if (existsSync(outDir)) walk(outDir);
return found;
}
+56 -11
View File
@@ -2,10 +2,17 @@
// 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";
//
// The Stalwart target (`LITE_TARGET=stalwart`) writes a different set: the
// entry document Stalwart serves for every route, config/policy/manifest that
// follow the runtime mount, no host snippets, no 404.html - and packs out/
// into bulwark-lite-stalwart.zip.
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
buildCaddyExample, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildNotFoundShim, buildReadme, buildRedirects, buildRootRedirect, discoverBuiltLocales, isMainModule, normalizeBasePath, resolveRepoRoot,
STALWART_EXCLUDED_DIRS, STALWART_EXCLUDED_FILES, STALWART_ZIP_NAME,
buildCaddyExample, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildNotFoundShim, buildReadme, buildRedirects, buildRootRedirect,
buildStalwartEntry, buildStalwartManifest, buildStalwartReadme, buildZip, collectZipEntries, discoverBuiltLocales, discoverShellDirs, findSegmentPrefetchDirs, isMainModule, makeBuildId, normalizeBasePath, parseLiteTarget, resolveRepoRoot,
} from "./lib.mjs";
const repoRoot = resolveRepoRoot(import.meta.url);
@@ -14,7 +21,9 @@ export function runPostbuild({ root = repoRoot, env = process.env, log = console
const outDir = join(root, "out");
if (!existsSync(outDir)) throw new Error(`[lite] ${outDir} does not exist - run the export first`);
const target = parseLiteTarget([], env);
const basePath = normalizeBasePath(env.NEXT_PUBLIC_BASE_PATH);
if (target === "stalwart" && basePath) throw new Error("[lite] the Stalwart target learns its mount prefix at runtime; unset 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";
@@ -26,11 +35,33 @@ export function runPostbuild({ root = repoRoot, env = process.env, log = console
// no VERSION file
}
const commit = (env.GIT_COMMIT ?? "").trim().slice(0, 7) || "local";
const buildId = (env.NEXT_PUBLIC_LITE_BUILD_ID ?? "").trim() || makeBuildId({ version, commit });
const config = buildLiteConfig(env);
const config = buildLiteConfig(env, { target });
const files = target === "stalwart"
? stalwartFiles({ outDir, config, locales, defaultLocale, version, commit, buildId })
: staticFiles({ config, basePath, locales, defaultLocale, version, commit });
files["lite-build.json"] = JSON.stringify({ version, commit, target, buildId, 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/ (${target} target, ${locales.length} locales, base path ${target === "stalwart" ? "runtime" : basePath || "/"})`);
let zipPath = null;
if (target === "stalwart") {
zipPath = join(root, STALWART_ZIP_NAME);
const zip = buildZip(collectZipEntries(outDir));
writeFileSync(zipPath, zip);
log(`[lite] packed ${STALWART_ZIP_NAME}: ${(zip.length / 1024 / 1024).toFixed(1)} MB`);
}
return { outDir, locales, basePath, target, buildId, zipPath, files: Object.keys(files) };
}
function staticFiles({ config, basePath, locales, defaultLocale, version, commit }) {
const connectSrc = config.allowCustomJmapEndpoint || !config.jmapServerUrl ? "*" : new URL(config.jmapServerUrl).origin;
const files = {
return {
"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",
@@ -43,15 +74,29 @@ export function runPostbuild({ root = repoRoot, env = process.env, log = console
"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);
function stalwartFiles({ outDir, config, locales, defaultLocale, version, commit, buildId }) {
// Next's default not-found page and the static-host helpers have no place
// in a bundle Stalwart serves: unknown paths always get the entry document.
for (const name of STALWART_EXCLUDED_FILES) rmSync(join(outDir, name), { force: true });
for (const name of STALWART_EXCLUDED_DIRS) rmSync(join(outDir, name), { recursive: true, force: true });
for (const dir of findSegmentPrefetchDirs(outDir)) rmSync(dir, { recursive: true, force: true });
// Stalwart serves .mjs as application/octet-stream, which browsers refuse
// for module workers (pdf.js); lib/lite.ts liteMountedAssetUrl asks for these.
for (const { name, data } of collectZipEntries(join(outDir, "_next"))) {
if (name.endsWith(".mjs")) writeFileSync(join(outDir, "_next", `${name.slice(0, -4)}.js`), data);
}
log(`[lite] wrote ${Object.keys(files).length} files into out/ (${locales.length} locales, base path ${basePath || "/"})`);
return { outDir, locales, basePath, files: Object.keys(files) };
const shellLocale = locales.includes(defaultLocale) ? defaultLocale : locales[0];
const shells = discoverShellDirs(outDir, shellLocale);
return {
"index.html": buildStalwartEntry({ locales, shells, defaultLocale, buildId, appName: config.appName }),
"config.json": JSON.stringify(config, null, 2) + "\n",
"policy.json": JSON.stringify(buildLitePolicy(), null, 2) + "\n",
"manifest.json": JSON.stringify(buildStalwartManifest({ appName: config.appName }), null, 2) + "\n",
"LITE-README.md": buildStalwartReadme({ version, commit, locales, buildId, demoMode: config.demoMode }),
};
}
if (isMainModule(import.meta.url)) {
+83 -13
View File
@@ -8,14 +8,32 @@
// `--plain` switches the SPA fallback off and answers unknown paths with
// 404.html (status 404), the way GitHub Pages or an S3 bucket would; that is
// the path the 404 shim's park-and-replay exists for.
import { createServer } from "node:http";
import { createReadStream, existsSync, statSync } from "node:fs";
//
// `--stalwart=/webmail[,/other]` emulates a Stalwart Application instead
// (emulateStalwartRequest in lib.mjs): exact entry lookup, the root
// index.html for everything else with <base href> and the OAuth meta tag
// rewritten, no-cache vs immutable, Stalwart's extension -> MIME map, its
// reserved first segments. The source may be out/ or the zip itself:
//
// node scripts/lite/serve.mjs bulwark-lite-stalwart.zip 4180 --stalwart=/webmail,/bulwark
// [--oauth-client-id=my-client] [--legacy] [--upstream=http://127.0.0.1:18081]
//
// `--upstream` forwards Stalwart's own routes (/jmap, /api, /auth,
// /.well-known, ...) to a real server, so the app stays same-origin with it.
import { createServer, request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
import { extname, join, normalize, resolve } from "node:path";
import { LITE_SURFACES } from "./lib.mjs";
import { LITE_SURFACES, collectZipEntries, emulateStalwartRequest, isReservedStalwartSegment, listZipEntries, readZipEntry } from "./lib.mjs";
const flags = process.argv.slice(2).filter((a) => a.startsWith("--"));
const args = process.argv.slice(2).filter((a) => !a.startsWith("--"));
const plain = flags.includes("--plain");
const flag = (name) => {
const hit = flags.find((f) => f === `--${name}` || f.startsWith(`--${name}=`));
return hit === undefined ? undefined : hit.includes("=") ? hit.slice(hit.indexOf("=") + 1) : "";
};
const plain = flag("plain") !== undefined;
const stalwartPrefixes = flag("stalwart");
const root = resolve(args[0] || "out");
const port = Number(args[1] || 4173);
const basePath = (args[2] || "").replace(/\/+$/, "");
@@ -71,12 +89,64 @@ export function resolveRequest(pathname) {
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}/${plain ? " (plain: no SPA fallback, 404.html for unknown paths)" : ""}`);
});
/** Zip entries (or the files of an unpacked out/) by entry name. */
function loadEntries(source) {
if (source.endsWith(".zip")) {
const buffer = readFileSync(source);
return new Map(listZipEntries(buffer).map((entry) => [entry.name, readZipEntry(buffer, entry)]));
}
return new Map(collectZipEntries(source).map(({ name, data }) => [name, data]));
}
function proxy(req, res, upstream) {
const target = new URL(req.url ?? "/", upstream);
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
const headers = { ...req.headers, host: target.host };
const out = send(target, { method: req.method, headers }, (up) => {
res.writeHead(up.statusCode ?? 502, up.headers);
up.pipe(res);
});
out.on("error", (err) => {
res.writeHead(502, { "content-type": "text/plain" });
res.end(`upstream error: ${err.message}`);
});
req.pipe(out);
}
if (stalwartPrefixes !== undefined) {
const prefixes = stalwartPrefixes.split(",").map((p) => p.trim()).filter(Boolean);
if (prefixes.length === 0) throw new Error("--stalwart needs at least one prefix, e.g. --stalwart=/webmail");
const oauthClientId = flag("oauth-client-id") ?? "";
const legacy = flag("legacy") !== undefined;
const upstream = flag("upstream");
let entries = loadEntries(root);
let loadedAt = Date.now();
createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
// Reload when the source changes, like Stalwart picking up a new bundle.
try {
if (statSync(root).mtimeMs > loadedAt) {
entries = loadEntries(root);
loadedAt = Date.now();
}
} catch {
// keep the previous bundle, as Stalwart does on a failed refresh
}
if (upstream && isReservedStalwartSegment(url.pathname.split("/")[1] ?? "")) return proxy(req, res, upstream);
const { status, headers, body } = emulateStalwartRequest({ pathname: url.pathname, entries, prefixes, oauthClientId, legacy });
res.writeHead(status, headers);
res.end(req.method === "HEAD" ? undefined : body);
}).listen(port, () => {
console.log(`[lite] Stalwart emulator: ${root} (${entries.size} entries) at ${prefixes.map((p) => `http://localhost:${port}${p.replace(/\/+$/, "")}/`).join(", ")}${upstream ? `, upstream ${upstream}` : ""}${legacy ? " (0.16.0-0.16.18 rewrite)" : ""}`);
});
} else {
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}/${plain ? " (plain: no SPA fallback, 404.html for unknown paths)" : ""}`);
});
}
+115
View File
@@ -0,0 +1,115 @@
import { test, expect, type Page } from "@playwright/test";
import { stalwartClientId, stalwartOrigin, stalwartPrefixes } from "./playwright.stalwart.config";
/**
* Drives the Stalwart Application bundle (demo build) through the Stalwart
* emulator in scripts/lite/serve.mjs. Real-server verification is recorded
* in the PR; this keeps the serving contract from regressing.
*
* What it proves, under two different prefixes of the same zip:
* - /<prefix> redirects, /<prefix>/ boots without a redirect loop and ends
* on the login page of a real locale,
* - Stalwart's rewrites reach the app (mount prefix, OAuth client id),
* - the demo account signs in and surface switches stay client-side
* (no document load), although Stalwart labels RSC payloads
* application/octet-stream,
* - deep links and unknown paths survive a full reload,
* - nothing is requested outside the prefix, no chunk is answered with
* the index.html fallback, and the console stays clean.
*/
interface Watch {
outside: string[];
htmlAssets: string[];
documents: string[];
errors: string[];
}
function watch(page: Page, prefix: string): Watch {
const seen: Watch = { outside: [], htmlAssets: [], documents: [], errors: [] };
page.on("request", (req) => {
const url = new URL(req.url());
if (url.origin !== stalwartOrigin) return;
if (!url.pathname.startsWith(`${prefix}/`)) seen.outside.push(url.pathname);
if (req.isNavigationRequest() && req.frame() === page.mainFrame()) seen.documents.push(url.pathname);
});
page.on("response", (res) => {
const url = new URL(res.url());
if (url.pathname.includes("/_next/") && (res.headers()["content-type"] ?? "").startsWith("text/html")) seen.htmlAssets.push(url.pathname);
});
page.on("console", (msg) => {
if (msg.type() === "error" || /hydrat/i.test(msg.text())) seen.errors.push(msg.text());
});
page.on("pageerror", (err) => seen.errors.push(err.message));
return seen;
}
for (const prefix of stalwartPrefixes) {
test.describe(`Stalwart bundle mounted at ${prefix}`, () => {
test("serves the entry for every route with both rewrites applied", async ({ request }) => {
const bare = await request.get(prefix, { maxRedirects: 0 });
expect(bare.status()).toBe(302);
expect(bare.headers().location).toBe(`${prefix}/`);
for (const path of [`${prefix}/`, `${prefix}/en/mail/thread/abc`, `${prefix}/nope`]) {
const res = await request.get(path);
expect(res.headers()["cache-control"]).toBe("no-cache");
const html = await res.text();
expect(html).toContain(`<base href="${prefix}/" />`);
expect(html).toContain(`<meta name="oauth-client-id" content="${stalwartClientId}" />`);
}
const shell = await request.get(`${prefix}/en/mail/index.html`);
expect(shell.headers()["cache-control"]).toContain("immutable");
const rsc = await request.get(`${prefix}/en/mail/index.txt`);
expect(rsc.headers()["content-type"]).toBe("application/octet-stream");
});
test("boots, signs in, switches surfaces client-side and survives reloads", async ({ page }) => {
const seen = watch(page, prefix);
await page.goto(`${prefix}/`);
await page.waitForURL(new RegExp(`${prefix}/en/login$`), { timeout: 30_000 });
expect(await page.evaluate(() => (window as unknown as Record<string, unknown>).__BULWARK_LITE_MOUNT__)).toBe(prefix);
expect(await page.evaluate(() => (window as unknown as Record<string, unknown>).__BULWARK_LITE_OAUTH_CLIENT_ID__)).toBe(stalwartClientId);
const demo = page.getByRole("button", { name: /demo/i }).first();
await expect(demo).toBeVisible({ timeout: 30_000 });
await demo.click();
await page.waitForURL(new RegExp(`${prefix}/en/(mail/?.*)?$`), { timeout: 30_000 });
await expect(page.locator("body")).toContainText(/inbox/i, { timeout: 30_000 });
seen.documents.length = 0;
for (const [name, url] of [["calendar", /\/calendar/], ["contacts", /\/contacts/], ["settings", /\/settings/]] as const) {
await page.getByRole("link", { name: new RegExp(`^${name}`, "i") }).first().click();
await page.waitForURL(url, { timeout: 30_000 });
expect(new URL(page.url()).pathname.startsWith(`${prefix}/en/`)).toBe(true);
}
// Client-side: the RSC payloads were accepted despite octet-stream.
expect(seen.documents).toEqual([]);
await page.goto(`${prefix}/en/calendar/week/2026-09-17`);
await expect(page.locator("body")).toContainText(/September 2026/, { timeout: 30_000 });
expect(new URL(page.url()).pathname).toMatch(new RegExp(`^${prefix}/en/calendar/\\w+/2026-09-17$`));
await page.goto(`${prefix}/nope/at/all`);
await page.waitForURL(new RegExp(`${prefix}/en/(mail/.*)?$`), { timeout: 30_000 });
expect(seen.outside).toEqual([]);
expect(seen.htmlAssets).toEqual([]);
expect(seen.errors).toEqual([]);
});
});
}
test("a prefix the bundle was never told about still works (no build-time base path)", async ({ page }) => {
// Both prefixes above come from the same zip; this pins that the entry did
// not fall back to a baked path by checking every chunk URL it used.
const chunkPrefixes = new Set<string>();
page.on("request", (req) => {
const url = new URL(req.url());
if (url.pathname.includes("/_next/static/")) chunkPrefixes.add(url.pathname.slice(0, url.pathname.indexOf("/_next/")));
});
await page.goto("/bulwark/en/login");
await expect(page.getByRole("button", { name: /demo/i }).first()).toBeVisible({ timeout: 30_000 });
expect([...chunkPrefixes]).toEqual(["/bulwark"]);
});
+2 -1
View File
@@ -10,7 +10,8 @@ export const plainPort = Number(process.env.LITE_SMOKE_PLAIN_PORT || port + 1);
export default defineConfig({
testDir: ".",
testMatch: /.*\.spec\.ts$/,
// lite-stalwart.spec.ts needs the Stalwart build (playwright.stalwart.config.ts).
testMatch: /lite-demo\.spec\.ts$/,
timeout: 60_000,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
@@ -0,0 +1,35 @@
import { defineConfig } from "@playwright/test";
// Smoke test for the Stalwart Application bundle (`npm run build:lite --
// --target=stalwart` with LITE_DEMO_MODE=true). serve.mjs --stalwart emulates
// how Stalwart serves an Application (exact zip-entry lookup, the root
// index.html for everything else with <base href> and the OAuth meta tag
// rewritten, immutable vs no-cache, Stalwart's MIME map) from the zip itself,
// mounted at two prefixes to prove the bundle does not care which.
const port = Number(process.env.LITE_STALWART_SMOKE_PORT || 4183);
export const stalwartOrigin = `http://localhost:${port}`;
export const stalwartPrefixes = ["/webmail", "/bulwark"];
export const stalwartClientId = "smoke-client";
export default defineConfig({
testDir: ".",
testMatch: /lite-stalwart\.spec\.ts$/,
timeout: 90_000,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
use: {
baseURL: stalwartOrigin,
locale: "en-US",
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
webServer: [
{
command: `node scripts/lite/serve.mjs bulwark-lite-stalwart.zip ${port} --stalwart=${stalwartPrefixes.join(",")} --oauth-client-id=${stalwartClientId}`,
port,
reuseExistingServer: !process.env.CI,
cwd: process.cwd(),
},
],
});
+67 -12
View File
@@ -2,17 +2,36 @@
// 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, readFileSync } from "node:fs";
//
// 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, collectApiStrings, discoverBuiltLocales, isMainModule, resolveRepoRoot, unexpectedApiStrings,
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);
export function verifyExport({ root = repoRoot } = {}) {
/** 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)");
@@ -24,33 +43,69 @@ export function verifyExport({ root = repoRoot } = {}) {
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"]) {
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}`);
}
// 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");
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)`);
return { problems, locales, apiStrings };
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 { problems, locales, apiStrings } = verifyExport();
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] export verified: ${locales.length} locales, ${apiStrings.length} known /api strings in chunks`);
console.log(`[lite] ${checked} export verified: ${locales.length} locales, ${apiStrings.length} known /api strings in chunks${zipBytes ? `, zip ${(zipBytes / 1024 / 1024).toFixed(1)} MB` : ""}`);
}