CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 43s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m12s
CI / image-build (pull_request) Successful in 1m0s
CI / deploy-smoke (same-origin) (pull_request) Successful in 56s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s
Module pages move from `/modules/<id>` to `/<module-id>` (`/signage`), beside `/settings` and `/account`, so the implementation concept "module" never appears in a user-facing address. ADR 0057 is rewritten to match. - Core reserves the top-level names the platform actually serves today (`account`, `api`, `assets`, `display`, `settings`), and discovery refuses a module that declares one with `MODULE_ID_RESERVED`. That covers startup, `modules:validate`, and package verification. Tests in `apps/api` hold the set to every top-level route the API registers and every page the shell interprets. - The API serves the Dashboard page at `/<module-id>` and below only for modules discovered with a Dashboard UI, enabled or not: a disabled module's bookmark still opens the Dashboard, which says it is unavailable. An arbitrary root path such as `/definitely-not-a-real-module` stays the sanitized 404. - Both reference proxies forward every top-level path shaped like a module id and let the API decide; the split Dashboard hostname still refuses `/api`, `/display`, and `/assets`. Installing a module needs no proxy change. - `/<module-id>/<page>` is already served and forwarded, so a later module sub-page contract needs no routing or deployment change; the shell shows "Page not found" there until it exists. - The deploy smoke checks a module's address before install, while installed, while disabled, after removal, and after reinstall, plus unknown names and the old `/modules/…` form through the real proxy. Co-Authored-By: Claude Opus 5.5 <[email protected]>
109 lines
5.3 KiB
JavaScript
109 lines
5.3 KiB
JavaScript
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import esbuild from "esbuild";
|
|
|
|
/**
|
|
* Builds the Ekklesia shell into a static browser application.
|
|
*
|
|
* This is **not** the module UI build pipeline, and the two must not be conflated. ADR 0023's
|
|
* `@ekklesia/module-ui-build` compiles a *module's* `src/ui/index.tsx` into the artifact its
|
|
* manifest declares, is owned by the platform so module authors write no bundler configuration, and
|
|
* runs when a module is packaged. This script builds *this application*, is owned by this package
|
|
* alone, and knows nothing about manifests. They happen to share a bundler; they share no
|
|
* configuration, no contract, and no output.
|
|
*
|
|
* Installed module UIs are deliberately **not** bundled in. The shell has no import of any module,
|
|
* so there is nothing for esbuild to reach: every module artifact is fetched at run time from the
|
|
* URL the inventory returned. That is the property that makes modules independently installable, and
|
|
* bundling one would silently destroy it.
|
|
*/
|
|
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
const outputDirectory = path.join(packageDirectory, "dist", "browser");
|
|
const serve = process.argv.includes("--serve");
|
|
|
|
/**
|
|
* Development only: the API origin the development server writes into its copy of the Dashboard page,
|
|
* exactly as `apps/api` does at startup in production (ADR 0045). Empty means "the API is on this
|
|
* page's own origin". Set it to an absolute origin for split-origin development
|
|
* (`EKKLESIA_API_BASE_URL=http://127.0.0.1:3000`), and add this page's own origin to
|
|
* `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` so the API reflects it for CORS and the session cookie travels.
|
|
* A production build never reads it: the built page always carries an empty value, and the bundle
|
|
* carries no origin at all.
|
|
*/
|
|
const apiBaseUrl = serve ? (process.env.EKKLESIA_API_BASE_URL ?? "") : "";
|
|
const API_ORIGIN_PLACEHOLDER = '<meta content="" name="ekklesia-api-origin" />';
|
|
|
|
/** @type {import("esbuild").BuildOptions} */
|
|
const buildOptions = {
|
|
absWorkingDir: packageDirectory,
|
|
// Everything the shell needs is bundled; a browser resolves nothing at run time except the module
|
|
// artifacts it is told about, which are real URLs rather than bare specifiers.
|
|
bundle: true,
|
|
define: {
|
|
// React reads this at module scope and a browser has no `process`.
|
|
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
|
|
},
|
|
entryPoints: {
|
|
// Emitted under the exact path `apps/api` serves it from (ADR 0044), so the development server's
|
|
// `servedir` and production answer the same absolute URL `index.html` names.
|
|
"assets/ekklesia-dashboard/app": path.join(packageDirectory, "src", "browser", "main.tsx"),
|
|
"display/app": path.join(packageDirectory, "src", "display", "main.tsx"),
|
|
// The display service worker (ADR 0043). A separate entry because a worker is a separate
|
|
// realm with its own globals; bundling it into the page would make it unregisterable and its
|
|
// `self` meaningless.
|
|
"display/sw": path.join(packageDirectory, "src", "display", "sw.ts"),
|
|
},
|
|
format: "esm",
|
|
jsx: "automatic",
|
|
logLevel: "info",
|
|
// Unminified, matching the module UI build: an operator is expected to be able to read what their
|
|
// installation actually serves, and nothing else in this repository minifies.
|
|
minify: false,
|
|
outdir: outputDirectory,
|
|
platform: "browser",
|
|
sourcemap: true,
|
|
// Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree.
|
|
target: "es2022",
|
|
};
|
|
|
|
await rm(outputDirectory, { force: true, recursive: true });
|
|
await mkdir(outputDirectory, { recursive: true });
|
|
await mkdir(path.join(outputDirectory, "display"), { recursive: true });
|
|
const dashboardPage = await readFile(path.join(packageDirectory, "index.html"), "utf8");
|
|
if (dashboardPage.split(API_ORIGIN_PLACEHOLDER).length !== 2) {
|
|
throw new Error("index.html must contain exactly one empty ekklesia-api-origin meta element.");
|
|
}
|
|
await writeFile(
|
|
path.join(outputDirectory, "index.html"),
|
|
dashboardPage.replace(
|
|
API_ORIGIN_PLACEHOLDER,
|
|
`<meta content="${apiBaseUrl.replaceAll("&", "&").replaceAll('"', """)}" name="ekklesia-api-origin" />`,
|
|
),
|
|
);
|
|
await copyFile(
|
|
path.join(packageDirectory, "display.html"),
|
|
path.join(outputDirectory, "display", "index.html"),
|
|
);
|
|
|
|
if (serve) {
|
|
const context = await esbuild.context(buildOptions);
|
|
await context.watch();
|
|
// Any path with no file behind it gets the Dashboard page, so a refreshed deep link such as
|
|
// `/example` reaches the shell's router here as it does from `apps/api` (ADR 0057).
|
|
// Development only: the API serves the page only at its platform pages and installed modules'.
|
|
const { host, port } = await context.serve({
|
|
fallback: path.join(outputDirectory, "index.html"),
|
|
servedir: outputDirectory,
|
|
});
|
|
console.log(`Ekklesia shell (development) on http://${host}:${port}`);
|
|
console.log(
|
|
apiBaseUrl === ""
|
|
? "API origin: this page's own origin. Set EKKLESIA_API_BASE_URL for a split-origin API."
|
|
: `API origin: ${apiBaseUrl}`,
|
|
);
|
|
} else {
|
|
await esbuild.build(buildOptions);
|
|
console.log(`Built the Ekklesia shell into ${path.relative(packageDirectory, outputDirectory)}`);
|
|
}
|