mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: harden Bulwark Lite login, deep-link replay and settings gating
This commit is contained in:
+126
-13
@@ -53,6 +53,16 @@ export const LITE_SURFACES = ["mail", "calendar", "contacts", "files", "settings
|
||||
/** Locales written right-to-left; the root shim sets `dir` for them. */
|
||||
export const RTL_LOCALES = ["ar", "fa", "he"];
|
||||
|
||||
/**
|
||||
* sessionStorage key the 404 shim parks a deep link under. Mirrors
|
||||
* LITE_PENDING_PATH_KEY in lib/lite.ts (a TS module this script cannot
|
||||
* import); lib/__tests__/lite-scripts.test.ts pins the two together.
|
||||
*/
|
||||
export const LITE_PENDING_PATH_KEY = "bulwark-lite:pending-path";
|
||||
|
||||
/** zustand persist key of stores/locale-store.ts (the user's language choice). */
|
||||
export const LOCALE_STORAGE_KEY = "locale-storage";
|
||||
|
||||
/**
|
||||
* `/api/` strings that legitimately survive in the Lite client chunks. Each
|
||||
* one is either gated at call time (`IS_LITE`, a policy flag, a config flag
|
||||
@@ -197,7 +207,11 @@ export function buildRootRedirect({ basePath = "", locales, defaultLocale = "en"
|
||||
return null;
|
||||
}
|
||||
var chosen = null;
|
||||
try { chosen = pick(localStorage.getItem("NEXT_LOCALE")); } catch (e) {}
|
||||
// The language picked in Settings (stores/locale-store.ts) wins over the browser's list.
|
||||
try {
|
||||
var stored = JSON.parse(localStorage.getItem(${JSON.stringify(LOCALE_STORAGE_KEY)}) || "null");
|
||||
chosen = pick(stored && stored.state && stored.state.locale);
|
||||
} catch (e) {}
|
||||
var langs = navigator.languages || [navigator.language];
|
||||
for (var j = 0; !chosen && j < langs.length; j++) chosen = pick(langs[j]);
|
||||
location.replace(base + "/" + (chosen || fallback) + "/" + location.search + location.hash);
|
||||
@@ -209,6 +223,66 @@ export function buildRootRedirect({ basePath = "", locales, defaultLocale = "en"
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 404.html for hosts without rewrite rules (GitHub Pages, S3 website hosting).
|
||||
*
|
||||
* `next build` exports Next's default not-found page as 404.html: the
|
||||
* `[...rest]` catch-all that renders app/(main)/not-found.tsx in the server
|
||||
* build is deleted for the export, and a route group's not-found.tsx only
|
||||
* serves `notFound()` calls inside that group. So the park-and-replay logic
|
||||
* lives in this dependency-free shim instead: a deep link below a known
|
||||
* surface is parked in sessionStorage and the surface shell is loaded, which
|
||||
* hands the link over (hooks/use-lite-link-segments.ts). Only `<locale>` and
|
||||
* `<surface>` from the allowlists ever reach `location.replace`, so a crafted
|
||||
* URL cannot turn this into an open redirect.
|
||||
*/
|
||||
export function buildNotFoundShim({ basePath = "", locales, surfaces = LITE_SURFACES }) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>404</title>
|
||||
<script>
|
||||
(function () {
|
||||
var base = ${JSON.stringify(basePath)};
|
||||
var locales = ${JSON.stringify(locales)};
|
||||
var surfaces = ${JSON.stringify(surfaces)};
|
||||
var path = location.pathname;
|
||||
if (base) {
|
||||
if (path !== base && path.indexOf(base + "/") !== 0) return;
|
||||
path = path.slice(base.length) || "/";
|
||||
}
|
||||
var parts = path.split("/").filter(Boolean);
|
||||
if (parts.length < 2 || locales.indexOf(parts[0]) === -1 || surfaces.indexOf(parts[1]) === -1) return;
|
||||
var target = base + "/" + parts[0] + "/" + parts[1] + "/";
|
||||
// The shell itself is missing (host misconfigured): show the 404 instead of looping.
|
||||
if (location.pathname === target) return;
|
||||
try { sessionStorage.setItem(${JSON.stringify(LITE_PENDING_PATH_KEY)}, location.pathname + location.search); } catch (e) {}
|
||||
location.replace(target);
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; font-family: system-ui, sans-serif; background: #fff; color: #111; }
|
||||
@media (prefers-color-scheme: dark) { body { background: #0a0a0a; color: #ededed; } }
|
||||
main { text-align: center; padding: 1rem; }
|
||||
h1 { font-size: 2.25rem; margin: 0 0 .5rem; }
|
||||
p { margin: 0 0 1.5rem; opacity: .7; }
|
||||
a { color: inherit; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>404</h1>
|
||||
<p>This page could not be found.</p>
|
||||
<a href="${basePath}/">Go home</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Netlify / Cloudflare Pages rewrite rules. */
|
||||
export function buildRedirects({ basePath = "", locales, surfaces = LITE_SURFACES }) {
|
||||
const lines = [
|
||||
@@ -252,24 +326,46 @@ ${basePath || ""}/_next/static/*
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the unzipped folder has to live for the nginx/Caddy examples: every
|
||||
* rule below is root-relative, so a sub-path mount means the files sit in a
|
||||
* folder named after the mount path inside the web root (no `alias` needed,
|
||||
* which does not work inside a regex `location`).
|
||||
*/
|
||||
export function exampleDocRoot(basePath = "") {
|
||||
return { root: "/var/www/bulwark-lite", files: `/var/www/bulwark-lite${basePath}` };
|
||||
}
|
||||
|
||||
export function buildNginxExample({ basePath = "", surfaces = LITE_SURFACES }) {
|
||||
const location = basePath ? `${basePath}/` : "/";
|
||||
const { root, files } = exampleDocRoot(basePath);
|
||||
const surfaceAlternation = surfaces.join("|");
|
||||
return `# Bulwark Lite - nginx example. Serve the unzipped folder at ${location}
|
||||
# and rewrite deep links to the owning surface's shell.
|
||||
return `# Bulwark Lite - nginx example. Unzip the archive into ${files}
|
||||
# so that ${basePath}/index.html is served at ${location}; deep links below a surface
|
||||
# are rewritten to that surface's shell.
|
||||
server {
|
||||
listen 80;
|
||||
server_name webmail.example.com;
|
||||
root /var/www/bulwark-lite;
|
||||
root ${root};
|
||||
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
# See _headers for a Content-Security-Policy that matches your JMAP server.
|
||||
|
||||
# Hashed assets never change. (add_header inside a location replaces the
|
||||
# inherited set, so the security headers are repeated here.)
|
||||
location ${basePath}/_next/static/ {
|
||||
${basePath ? `alias /var/www/bulwark-lite/_next/static/;\n ` : ""}expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
}
|
||||
|
||||
# Older mime.types files do not know the PWA manifest extension.
|
||||
location = ${basePath}/manifest.webmanifest {
|
||||
types { }
|
||||
default_type application/manifest+json;
|
||||
}
|
||||
|
||||
# /<locale>/<surface>/anything -> /<locale>/<surface>/index.html
|
||||
@@ -278,7 +374,7 @@ server {
|
||||
}
|
||||
|
||||
location ${location} {
|
||||
${basePath ? `alias /var/www/bulwark-lite/;\n ` : ""}try_files $uri $uri/ $uri/index.html =404;
|
||||
try_files $uri $uri/ $uri/index.html =404;
|
||||
error_page 404 ${basePath}/404.html;
|
||||
}
|
||||
}
|
||||
@@ -286,10 +382,12 @@ server {
|
||||
}
|
||||
|
||||
export function buildCaddyExample({ basePath = "", surfaces = LITE_SURFACES }) {
|
||||
const { root, files } = exampleDocRoot(basePath);
|
||||
const surfaceAlternation = surfaces.join("|");
|
||||
return `# Bulwark Lite - Caddy example.
|
||||
return `# Bulwark Lite - Caddy example. Unzip the archive into ${files}
|
||||
# so that ${basePath}/index.html is served at ${basePath || ""}/.
|
||||
webmail.example.com {
|
||||
root * /var/www/bulwark-lite
|
||||
root * ${root}
|
||||
encode gzip
|
||||
|
||||
header {
|
||||
@@ -298,16 +396,27 @@ webmail.example.com {
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
}
|
||||
|
||||
@manifest path ${basePath}/manifest.webmanifest
|
||||
header @manifest Content-Type application/manifest+json
|
||||
|
||||
# /<locale>/<surface>/anything -> /<locale>/<surface>/index.html
|
||||
@surface path_regexp surface ^${basePath}/([a-zA-Z-]+)/(${surfaceAlternation})(/.*)?$
|
||||
handle @surface {
|
||||
try_files {path} {path}/ {path}/index.html ${basePath}/{re.surface.1}/{re.surface.2}/index.html
|
||||
}
|
||||
|
||||
handle {
|
||||
try_files {path} {path}/ {path}/index.html ${basePath}/404.html
|
||||
try_files {path} {path}/ {path}/index.html
|
||||
}
|
||||
|
||||
file_server
|
||||
|
||||
# Anything else is a real 404, answered by the shim that replays parked deep links.
|
||||
handle_errors {
|
||||
@notfound expression {http.error.status_code} == 404
|
||||
rewrite @notfound ${basePath}/404.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -351,7 +460,7 @@ Deep links such as \`${basePath}/en/mail/thread/abc\` are served by the shell at
|
||||
- Netlify / Cloudflare Pages: the shipped \`_redirects\` and \`_headers\` files do this.
|
||||
- nginx: see \`nginx.conf.example\`.
|
||||
- Caddy: see \`Caddyfile.example\`.
|
||||
- GitHub Pages and other hosts without rewrites: \`404.html\` replays the link
|
||||
${basePath ? ` Both examples serve root-relative paths, so unzip into \`<web root>${basePath}\`\n (e.g. \`${exampleDocRoot(basePath).files}\`), not into the web root itself.\n` : ""}- GitHub Pages and other hosts without rewrites: \`404.html\` replays the link
|
||||
in the browser. It works, with one extra page load.
|
||||
|
||||
\`_headers\` also carries the recommended security headers. Adjust
|
||||
@@ -367,7 +476,8 @@ Not available in Lite (they need the Node.js server): the admin console and
|
||||
setup wizard, plugins and sidebar apps, settings sync across devices, OAuth /
|
||||
SSO login, the account security tab (app passwords, 2FA setup), ICS URL
|
||||
subscriptions and CalDAV discovery, sender favicons, office (WOPI) editing,
|
||||
web push notifications, the update banner, device pairing.
|
||||
web push notifications, the update banner, device pairing, and the
|
||||
"Default apps" page (registering Lite as the mailto:/webcal: handler).
|
||||
|
||||
## Security notes
|
||||
|
||||
@@ -380,7 +490,10 @@ web push notifications, the update banner, device pairing.
|
||||
control and keep the Content-Security-Policy from \`_headers\`.
|
||||
- Servers without Stalwart's token login (\`/api/auth\`) fall back to Basic
|
||||
auth. "Remember me" is then hidden; the credentials stay in sessionStorage
|
||||
for the lifetime of the tab so a reload does not sign you out.
|
||||
for the lifetime of the tab so a reload does not sign you out. The same
|
||||
fallback applies when a reverse proxy in front of Stalwart answers
|
||||
\`/api/auth\` without CORS headers: the login still works, but "remember me"
|
||||
then only lasts for the tab, so add the CORS rule from step 3 there too.
|
||||
- Because the export contains inline scripts, \`script-src\` must allow
|
||||
\`'unsafe-inline'\`. Email HTML is still rendered sanitised in a sandboxed
|
||||
frame, exactly as in the full build.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
buildCaddyExample, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildReadme, buildRedirects, buildRootRedirect, discoverBuiltLocales, isMainModule, normalizeBasePath, resolveRepoRoot,
|
||||
buildCaddyExample, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildNotFoundShim, buildReadme, buildRedirects, buildRootRedirect, discoverBuiltLocales, isMainModule, normalizeBasePath, resolveRepoRoot,
|
||||
} from "./lib.mjs";
|
||||
|
||||
const repoRoot = resolveRepoRoot(import.meta.url);
|
||||
@@ -35,6 +35,9 @@ export function runPostbuild({ root = repoRoot, env = process.env, log = console
|
||||
"policy.json": JSON.stringify(buildLitePolicy(), null, 2) + "\n",
|
||||
"manifest.webmanifest": JSON.stringify(buildManifest({ appName: config.appName, basePath }), null, 2) + "\n",
|
||||
"index.html": buildRootRedirect({ basePath, locales, defaultLocale }),
|
||||
// Replaces Next's default not-found page: only this shim replays deep links
|
||||
// on hosts without rewrite rules (see buildNotFoundShim).
|
||||
"404.html": buildNotFoundShim({ basePath, locales }),
|
||||
"_redirects": buildRedirects({ basePath, locales }),
|
||||
"_headers": buildHeaders({ basePath, connectSrc }),
|
||||
"nginx.conf.example": buildNginxExample({ basePath }),
|
||||
|
||||
+13
-6
@@ -3,15 +3,22 @@
|
||||
// fallback the shipped nginx/Caddy snippets do. Used by the Playwright smoke
|
||||
// test and handy for a local look at out/:
|
||||
//
|
||||
// node scripts/lite/serve.mjs [outDir=out] [port=4173] [basePath=]
|
||||
// node scripts/lite/serve.mjs [outDir=out] [port=4173] [basePath=] [--plain]
|
||||
//
|
||||
// `--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";
|
||||
import { extname, join, normalize, resolve } from "node:path";
|
||||
import { LITE_SURFACES } from "./lib.mjs";
|
||||
|
||||
const root = resolve(process.argv[2] || "out");
|
||||
const port = Number(process.argv[3] || 4173);
|
||||
const basePath = (process.argv[4] || "").replace(/\/+$/, "");
|
||||
const 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 root = resolve(args[0] || "out");
|
||||
const port = Number(args[1] || 4173);
|
||||
const basePath = (args[2] || "").replace(/\/+$/, "");
|
||||
|
||||
const TYPES = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
@@ -56,7 +63,7 @@ export function resolveRequest(pathname) {
|
||||
}
|
||||
const file = fileFor(pathname);
|
||||
if (file) return { status: 200, file };
|
||||
const match = pathname.match(surfacePattern);
|
||||
const match = plain ? null : pathname.match(surfacePattern);
|
||||
if (match) {
|
||||
const shell = join(root, match[1], match[2], "index.html");
|
||||
if (existsSync(shell)) return { status: 200, file: shell };
|
||||
@@ -71,5 +78,5 @@ createServer((req, res) => {
|
||||
res.writeHead(404, { "content-type": "text/plain" });
|
||||
res.end("not found");
|
||||
}).listen(port, () => {
|
||||
console.log(`[lite] serving ${root} at http://localhost:${port}${basePath}/`);
|
||||
console.log(`[lite] serving ${root} at http://localhost:${port}${basePath}/${plain ? " (plain: no SPA fallback, 404.html for unknown paths)" : ""}`);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { plainPort } from "./playwright.config";
|
||||
|
||||
/**
|
||||
* Drives a Lite export built with LITE_DEMO_MODE=true. Everything runs in the
|
||||
@@ -10,16 +11,20 @@ import { test, expect, type Page } from "@playwright/test";
|
||||
* - the demo account signs in and the mail list renders,
|
||||
* - top-level navigation between static shells works client-side,
|
||||
* - a deep link reload keeps its URL and the shell still hydrates,
|
||||
* - a deep link parked by the 404 shim is replayed by the surface,
|
||||
* - on a host without rewrites, 404.html parks the deep link and the
|
||||
* surface replays it (the exported 404.html must be the Lite shim, not
|
||||
* Next's default not-found page),
|
||||
* - no request ever targets a server endpoint on the Lite origin.
|
||||
*/
|
||||
|
||||
async function loginAsDemo(page: Page) {
|
||||
await page.goto("/en/login/");
|
||||
const PLAIN_ORIGIN = `http://localhost:${plainPort}`;
|
||||
|
||||
async function loginAsDemo(page: Page, origin = "") {
|
||||
await page.goto(`${origin}/en/login/`);
|
||||
const demoButton = page.getByRole("button", { name: /demo/i }).first();
|
||||
await expect(demoButton).toBeVisible({ timeout: 30_000 });
|
||||
await demoButton.click();
|
||||
await page.waitForURL(/\/en\/?(mail\/?)?(\?.*)?$/, { timeout: 30_000 });
|
||||
await page.waitForURL(/\/en\/?(mail\/?.*)?(\?.*)?$/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Bulwark Lite demo export", () => {
|
||||
@@ -45,6 +50,15 @@ test.describe("Bulwark Lite demo export", () => {
|
||||
expect(apiRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("the root shim honours the language chosen in Settings", async ({ page }) => {
|
||||
await page.goto("/en/login/");
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("locale-storage", JSON.stringify({ state: { locale: "de" }, version: 0 }));
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.waitForURL(/\/de\/$/);
|
||||
});
|
||||
|
||||
test("demo login, surface switch and deep-link reload", async ({ page }) => {
|
||||
await loginAsDemo(page);
|
||||
|
||||
@@ -68,16 +82,31 @@ test.describe("Bulwark Lite demo export", () => {
|
||||
expect(apiRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("a deep link parked by the 404 shim is replayed on the surface", async ({ page }) => {
|
||||
await loginAsDemo(page);
|
||||
// What app/(main)/not-found.tsx does on a host without rewrite rules:
|
||||
// park the link, load the surface root.
|
||||
await page.evaluate(() => {
|
||||
sessionStorage.setItem("bulwark-lite:pending-path", "/en/mail/folder/inbox");
|
||||
});
|
||||
await page.goto("/en/mail/");
|
||||
test("404.html is the Lite shim, not Next's default not-found page", async ({ page }) => {
|
||||
const res = await page.request.get("/404.html");
|
||||
expect(res.ok()).toBe(true);
|
||||
const html = await res.text();
|
||||
expect(html).toContain("bulwark-lite:pending-path");
|
||||
expect(html).not.toContain("__next_f");
|
||||
});
|
||||
|
||||
test("on a host without rewrites a deep link is parked by 404.html and replayed", async ({ page }) => {
|
||||
// The plain server answers /en/mail/folder/inbox with 404.html (status
|
||||
// 404), exactly like GitHub Pages. The shim parks the link, loads
|
||||
// /en/mail/, and the surface restores the URL.
|
||||
await loginAsDemo(page, PLAIN_ORIGIN);
|
||||
const response = await page.goto(`${PLAIN_ORIGIN}/en/mail/folder/inbox`);
|
||||
expect(response?.status()).toBe(404);
|
||||
await page.waitForFunction(() => window.location.pathname === "/en/mail/folder/inbox", null, { timeout: 30_000 });
|
||||
await expect(page.locator("body")).toContainText(/inbox/i, { timeout: 30_000 });
|
||||
expect(await page.evaluate(() => sessionStorage.getItem("bulwark-lite:pending-path"))).toBeNull();
|
||||
|
||||
// A path outside the known surfaces stays a plain 404 page.
|
||||
const dead = await page.goto(`${PLAIN_ORIGIN}/en/nope`);
|
||||
expect(dead?.status()).toBe(404);
|
||||
await page.waitForTimeout(500);
|
||||
expect(new URL(page.url()).pathname).toBe("/en/nope");
|
||||
await expect(page.locator("body")).toContainText(/404/);
|
||||
expect(apiRequests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
// Smoke test for a finished Lite export (`npm run build:lite` with
|
||||
// LITE_DEMO_MODE=true). Serves out/ with the dependency-free static server
|
||||
// that applies the same SPA fallback the shipped host snippets do.
|
||||
// LITE_DEMO_MODE=true). Serves out/ twice with the dependency-free static
|
||||
// server: once with the SPA fallback the shipped host snippets apply, and
|
||||
// once "plain" (unknown paths answer 404.html with status 404, like GitHub
|
||||
// Pages or an S3 bucket) to exercise the 404 shim's park-and-replay path.
|
||||
const port = Number(process.env.LITE_SMOKE_PORT || 4173);
|
||||
export const plainPort = Number(process.env.LITE_SMOKE_PLAIN_PORT || port + 1);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
@@ -17,10 +20,18 @@ export default defineConfig({
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
|
||||
webServer: {
|
||||
command: `node scripts/lite/serve.mjs out ${port}`,
|
||||
port,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command: `node scripts/lite/serve.mjs out ${port}`,
|
||||
port,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
{
|
||||
command: `node scripts/lite/serve.mjs out ${plainPort} "" --plain`,
|
||||
port: plainPort,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
// Guards for a finished Lite export: the files a deployer relies on exist,
|
||||
// and the client chunks reference no server endpoint outside the documented
|
||||
// allowlist (lib.mjs). Exit 1 with a readable list otherwise.
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
collectApiStrings, discoverBuiltLocales, isMainModule, resolveRepoRoot, unexpectedApiStrings,
|
||||
LITE_PENDING_PATH_KEY, collectApiStrings, discoverBuiltLocales, isMainModule, resolveRepoRoot, unexpectedApiStrings,
|
||||
} from "./lib.mjs";
|
||||
|
||||
const repoRoot = resolveRepoRoot(import.meta.url);
|
||||
@@ -31,6 +31,13 @@ export function verifyExport({ root = repoRoot } = {}) {
|
||||
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");
|
||||
}
|
||||
|
||||
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)`);
|
||||
|
||||
Reference in New Issue
Block a user