mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
feat: add Bulwark Lite build for Stalwart Applications
This commit is contained in:
+32
-8
@@ -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
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
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) => ({ "&": "&", '"': """, "<": "<", ">": ">" })[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
@@ -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
@@ -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)" : ""}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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
@@ -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` : ""}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user