perf(webui): lazily load module descriptors

This commit is contained in:
2026-07-30 04:35:57 +02:00
parent 9e219bc4d3
commit 47e106684d
14 changed files with 604 additions and 84 deletions

View File

@@ -151,6 +151,10 @@ PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versio
The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
Production builds lazy-load enabled module descriptors and enforce initial and
asynchronous JavaScript budgets. See
[WEBUI_BUNDLE_BUDGETS.md](docs/WEBUI_BUNDLE_BUDGETS.md).
## Module contract
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:

View File

@@ -26,6 +26,7 @@ operator, and roadmap pages.
| Release dependencies and catalogs | `RELEASE_DEPENDENCIES.md` | Release package refs, migration baselines, release lockfiles, catalog trust/licensing, catalog publishing, and release checklist. |
| Dependency vulnerability audits | `DEPENDENCY_AUDITS.md` | Local and CI audit commands plus dated audit result notes. |
| Remote WebUI bundle design | `REMOTE_WEBUI_BUNDLES.md` | Experimental controlled-deployment design; normal releases still use package builds. |
| WebUI loading and bundle budgets | `WEBUI_BUNDLE_BUDGETS.md` | Installed-module lazy boundaries, enforced initial/async budgets, and baseline measurements. |
## Product And Module Planning

View File

@@ -624,11 +624,18 @@ Uninstall remains non-destructive unless the operator explicitly requests
## WebUI Contract
A WebUI module exports a `PlatformWebModule` from its package. The object contributes local/fallback metadata and route render functions.
A WebUI module exports a `PlatformWebModule` from its package. The object
contributes local/fallback metadata and route render functions. The package
must ship `src/module.ts` with the default contribution export: Core's Vite
host imports that descriptor directly after the backend reports the module as
enabled. This keeps package-root re-exports from pulling page implementations
into the initial shell.
Example:
```ts
const FilesPage = lazy(() => import("./features/files/FilesPage"));
export const filesModule: PlatformWebModule = {
id: "files",
label: "Files",
@@ -643,6 +650,11 @@ export const filesModule: PlatformWebModule = {
};
```
Route pages and substantial panels must use stable lazy imports. Core supplies
the shared loading and retryable error state around route rendering. The
initial static import closure and largest asynchronous chunk are enforced by
the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md).
WebUI modules receive only the core route context:
- `settings`

View File

@@ -0,0 +1,71 @@
# WebUI Loading And Bundle Budgets
The Core WebUI host owns the loading boundary for installed module packages.
Vite discovers configured packages at build time, but emits an asynchronous
loader for each package's `src/module.ts` contribution descriptor. At runtime,
Core imports only descriptors whose backend manifests are enabled and identify
the matching `frontend.package_name`.
The direct descriptor entry is intentional. A package root may re-export pages
for consumers; importing that barrel as module wiring can cause those pages to
be evaluated before navigation. Route pages and substantial panels should use
`React.lazy`, and Core wraps routes in the shared loading/error boundary.
## Enforced Budgets
`webui/bundle-budget.json` contains the production limits:
| Measurement | Raw limit | Gzip limit |
| --- | ---: | ---: |
| Initial JavaScript static import closure | 512 KiB | 160 KiB |
| Largest individual asynchronous JavaScript chunk | 384 KiB | 110 KiB |
`npm run build` writes a Vite manifest, measures the entry and its recursive
static imports, writes `dist/bundle-metrics.json`, and fails when either budget
is exceeded. `npm run test:module-permutations` applies the same gate to every
permutation and records the collected results in
`dist/module-permutation-bundle-metrics.json`. In CI, each result is also added
to the step summary.
Budgets are limits, not targets. A change that approaches a limit should add a
new lazy boundary or remove unnecessary entry code instead of raising the
limit without measurement and review.
## 2026-07-30 Baseline
Measurements use the same full-product source tree and Node 22 runtime. The
post-change build additionally includes the Search module in the default and
full-product sets.
| Initial-load measurement | Before | After | Reduction |
| --- | ---: | ---: | ---: |
| JavaScript assets in initial static closure | 1 | 1 | 0% |
| Raw JavaScript | 1,387,043 B | 453,769 B | 67.3% |
| Gzip level 9 | 364,767 B | 141,725 B | 61.1% |
| Brotli quality 11 | 254,797 B | 106,701 B | 58.1% |
| Parse proxy median | 18.776 ms | 7.750 ms | 58.7% |
| Parse proxy p95 | 21.980 ms | 8.739 ms | 60.2% |
The parse proxy constructs a fresh `node:vm` `SourceTextModule` from the entry
source 30 times with a randomized source marker. It is useful for a controlled
before/after comparison, but is not enforced in CI because absolute timings
vary across runners. Transfer budgets use deterministic raw and gzip byte
counts.
The first budgeted full-product build reported:
- initial JavaScript: 453,769 B raw / 141,725 B gzip;
- largest async chunk: `CampaignWorkspace`, 353,724 B raw / 98,142 B gzip.
## Verification
```bash
cd /mnt/DATA/git/govoplan-core/webui
npm run build
npm run check:bundle-budget
npm run test:module-permutations
```
The build gate also catches accidental eager imports: a page pulled into the
entry closure consumes the initial budget, while an oversized page or module
descriptor consumes the asynchronous chunk budget.

10
webui/bundle-budget.json Normal file
View File

@@ -0,0 +1,10 @@
{
"initialJs": {
"rawBytes": 524288,
"gzipBytes": 163840
},
"asyncChunk": {
"rawBytes": 393216,
"gzipBytes": 112640
}
}

View File

@@ -23,7 +23,8 @@
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173",
"prebuild": "npm run audit:i18n-structural",
"build": "tsc && vite build",
"build": "tsc && vite build && node scripts/check-bundle-budget.mjs",
"check:bundle-budget": "node scripts/check-bundle-budget.mjs",
"preview": "vite preview --host 127.0.0.1 --port 4173",
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
"test:i18n-catalog": "node --test tests/i18n-catalog-validation.test.mjs",

View File

@@ -0,0 +1,173 @@
import { appendFileSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { gzipSync } from "node:zlib";
const webuiRoot = new URL("..", import.meta.url);
const distRoot = new URL("./dist/", webuiRoot);
const manifestPath = new URL("./.vite/manifest.json", distRoot);
const budgetPath = new URL("./bundle-budget.json", webuiRoot);
const metricsPath = new URL("./bundle-metrics.json", distRoot);
const buildName = process.env.GOVOPLAN_WEBUI_BUILD_NAME || "default";
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
const budgets = JSON.parse(readFileSync(budgetPath, "utf8"));
const entries = Object.entries(manifest);
const entryKeys = entries
.filter(([, chunk]) => chunk.isEntry && isJavaScript(chunk.file))
.map(([key]) => key);
if (entryKeys.length === 0) {
throw new Error("Vite manifest has no JavaScript entry");
}
const initialKeys = new Set();
for (const entryKey of entryKeys) collectStaticImports(entryKey, initialKeys);
const initialFiles = uniqueChunks(
[...initialKeys].map((key) => [key, manifest[key]]).filter(([, chunk]) => chunk)
);
const initialFileNames = new Set(initialFiles.map(([, chunk]) => chunk.file));
const moduleDescriptorChunks = entries.filter(([key, chunk]) => (
isJavaScript(chunk.file) &&
chunk.isDynamicEntry &&
(
key.startsWith("virtual:govoplan-installed-module/") ||
chunk.name?.startsWith("_40govoplan_2F")
)
));
const eagerModuleDescriptors = moduleDescriptorChunks.filter(
([, chunk]) => initialFileNames.has(chunk.file)
);
const asyncFiles = uniqueChunks(
entries.filter(([, chunk]) => (
isJavaScript(chunk.file) && !initialFileNames.has(chunk.file)
))
);
const initial = summarizeChunks(initialFiles);
const measuredAsyncChunks = asyncFiles.map(measureChunk);
const largestAsync = measuredAsyncChunks.sort(
(left, right) => right.rawBytes - left.rawBytes
)[0] ?? null;
const failures = [
...eagerModuleDescriptors.map(([, chunk]) => (
`Installed module descriptor ${chunk.file} is part of the initial static closure`
)),
...budgetFailures("Initial JavaScript", initial, budgets.initialJs),
...(largestAsync
? budgetFailures("Largest async chunk", largestAsync, budgets.asyncChunk)
: ["No asynchronous JavaScript chunk was produced"])
];
const metrics = {
schemaVersion: 1,
buildName,
measuredAt: new Date().toISOString(),
initial,
largestAsync,
moduleDescriptorCount: moduleDescriptorChunks.length,
eagerModuleDescriptorCount: eagerModuleDescriptors.length,
budgets,
passed: failures.length === 0,
failures
};
writeFileSync(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`);
console.log(`\nWebUI bundle budget (${buildName})`);
console.log(formatMetric("Initial JavaScript", initial, budgets.initialJs));
console.log(formatMetric("Largest async chunk", largestAsync, budgets.asyncChunk));
console.log(`Metrics: ${metricsPath.pathname}`);
appendCiSummary(metrics);
if (failures.length) {
for (const failure of failures) console.error(`Bundle budget exceeded: ${failure}`);
process.exitCode = 1;
}
function collectStaticImports(key, target) {
if (target.has(key)) return;
const chunk = manifest[key];
if (!chunk || !isJavaScript(chunk.file)) return;
target.add(key);
for (const importedKey of chunk.imports ?? []) {
collectStaticImports(importedKey, target);
}
}
function uniqueChunks(chunks) {
const byFile = new Map();
for (const [key, chunk] of chunks) {
if (!chunk || byFile.has(chunk.file)) continue;
byFile.set(chunk.file, [key, chunk]);
}
return [...byFile.values()];
}
function summarizeChunks(chunks) {
const measured = chunks.map(measureChunk);
return {
assetCount: measured.length,
rawBytes: measured.reduce((total, chunk) => total + chunk.rawBytes, 0),
gzipBytes: measured.reduce((total, chunk) => total + chunk.gzipBytes, 0),
assets: measured
};
}
function measureChunk([key, chunk]) {
const assetUrl = new URL(chunk.file, distRoot);
const source = readFileSync(assetUrl);
return {
key,
file: chunk.file,
rawBytes: statSync(assetUrl).size,
gzipBytes: gzipSync(source, { level: 9 }).length
};
}
function budgetFailures(label, measured, budget) {
if (!measured) return [`${label} was not measured`];
const failures = [];
for (const field of ["rawBytes", "gzipBytes"]) {
if (measured[field] > budget[field]) {
failures.push(
`${label} ${field} is ${measured[field]} bytes; budget is ${budget[field]} bytes`
);
}
}
return failures;
}
function formatMetric(label, measured, budget) {
if (!measured) return `${label}: not produced`;
const file = measured.file ? ` (${measured.file})` : ` (${measured.assetCount} assets)`;
return [
`${label}${file}:`,
`${formatBytes(measured.rawBytes)} raw / ${formatBytes(budget.rawBytes)} budget,`,
`${formatBytes(measured.gzipBytes)} gzip / ${formatBytes(budget.gzipBytes)} budget`
].join(" ");
}
function formatBytes(value) {
return `${(value / 1024).toFixed(1)} KiB`;
}
function isJavaScript(file) {
return typeof file === "string" && file.endsWith(".js");
}
function appendCiSummary(result) {
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
if (!summaryPath) return;
const status = result.passed ? "pass" : "fail";
const asyncFile = result.largestAsync?.file ?? "none";
appendFileSync(
summaryPath,
[
"",
`### WebUI bundle budget: ${result.buildName}`,
"",
"| Status | Initial raw | Initial gzip | Largest async | Async raw | Async gzip |",
"| --- | ---: | ---: | --- | ---: | ---: |",
`| ${status} | ${formatBytes(result.initial.rawBytes)} | ${formatBytes(result.initial.gzipBytes)} | \`${asyncFile}\` | ${formatBytes(result.largestAsync?.rawBytes ?? 0)} | ${formatBytes(result.largestAsync?.gzipBytes ?? 0)} |`,
""
].join("\n")
);
}

View File

@@ -1,4 +1,5 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
const packageByModule = {
access: "@govoplan/access-webui",
@@ -19,7 +20,9 @@ const packageByModule = {
ops: "@govoplan/ops-webui",
policy: "@govoplan/policy-webui",
postbox: "@govoplan/postbox-webui",
risk_compliance: "@govoplan/risk-compliance-webui",
scheduling: "@govoplan/scheduling-webui",
search: "@govoplan/search-webui",
views: "@govoplan/views-webui",
workflow: "@govoplan/workflow-webui"
};
@@ -52,19 +55,23 @@ const cases = [
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
{ name: "scheduling-only", modules: ["scheduling"] },
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
{ name: "search-only", modules: ["search"] },
{ name: "risk-compliance-only", modules: ["risk_compliance"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox"] }
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox", "risk_compliance", "search"] }
];
const npmExec = process.env.npm_execpath;
const command = npmExec ? process.execPath : (process.platform === "win32" ? "npm.cmd" : "npm");
const baseArgs = npmExec ? [npmExec, "run", "build"] : ["run", "build"];
const collectedMetrics = [];
for (const testCase of cases) {
const packages = testCase.modules.map((moduleId) => packageByModule[moduleId]).join(",");
const env = {
...process.env,
GOVOPLAN_WEBUI_MODULE_PACKAGES: packages
GOVOPLAN_WEBUI_MODULE_PACKAGES: packages,
GOVOPLAN_WEBUI_BUILD_NAME: testCase.name
};
delete env.npm_config_tmp;
delete env.NPM_CONFIG_TMP;
@@ -81,4 +88,15 @@ for (const testCase of cases) {
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
collectedMetrics.push(
JSON.parse(readFileSync(new URL("../dist/bundle-metrics.json", import.meta.url), "utf8"))
);
}
const metricsDirectory = new URL("../dist/", import.meta.url);
mkdirSync(metricsDirectory, { recursive: true });
writeFileSync(
new URL("module-permutation-bundle-metrics.json", metricsDirectory),
`${JSON.stringify({ schemaVersion: 1, builds: collectedMetrics }, null, 2)}\n`
);
console.log(`\nRecorded bundle metrics for ${collectedMetrics.length} module permutations.`);

View File

@@ -1,5 +1,5 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { lazy, useEffect, useMemo, useState } from "react";
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
@@ -8,7 +8,7 @@ import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal";
import { PermissionBoundary } from "./components/AccessBoundary";
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules, uiCapability } from "./platform/modules";
import { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules";
import { PlatformModulesProvider } from "./platform/ModuleContext";
import { PlatformViewProvider } from "./platform/ViewContext";
import { PLATFORM_VIEW_CHANGED_EVENT } from "./platform/views";
@@ -16,6 +16,7 @@ import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
import ModuleLoadBoundary from "./components/ModuleLoadBoundary";
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
@@ -29,22 +30,24 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
};
export default function App() {
const location = useLocation();
const [settings, setSettings] = useState<ApiSettings>(() => loadApiSettings());
const [auth, setAuth] = useState<AuthInfo | null>(null);
const [checkingSession, setCheckingSession] = useState(true);
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
const [platformPublicModules, setPlatformPublicModules] = useState<PlatformPublicModuleInfo[] | null>(null);
const [localWebModules, setLocalWebModules] = useState<PlatformWebModule[]>([]);
const [localPublicWebModules, setLocalPublicWebModules] = useState<PlatformWebModule[]>([]);
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
const [webModulesLoading, setWebModulesLoading] = useState(true);
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
const [backendReachable, setBackendReachable] = useState(true);
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
const [reloginMessage, setReloginMessage] = useState("");
const [viewProjection, setViewProjection] = useState<EffectiveViewProjection | null>(null);
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
const viewsRuntime = useMemo(
() => uiCapability<ViewsRuntimeUiCapability>("views.runtime", webModules),
@@ -112,6 +115,7 @@ export default function App() {
}
function handlePublicLogin(response: LoginResponse) {
setWebModulesLoading(true);
updateAuth(authFromLoginResponse(response), "");
}
@@ -186,6 +190,7 @@ export default function App() {
const shellAuth = await fetchShellAuth(settings);
if (!cancelled) {
setBackendReachable(true);
setWebModulesLoading(true);
setAuth(normalizeAuthInfo(shellAuth));
}
} catch (error) {
@@ -196,6 +201,7 @@ export default function App() {
saveApiSettings(cleared);
setAuth(null);
setPlatformModules(null);
setWebModulesLoading(false);
setRemoteWebModules([]);
}
} finally {
@@ -218,7 +224,12 @@ export default function App() {
lastRefreshAt = Date.now();
return fetchPlatformModules(settings).
then((response) => {if (!cancelled) setPlatformModules(response.modules);}).
catch(() => {if (!cancelled) setPlatformModules(null);}).
catch(() => {
if (!cancelled) {
setPlatformModules(null);
setWebModulesLoading(false);
}
}).
finally(() => {inFlight = false;});
}
@@ -245,6 +256,70 @@ export default function App() {
};
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
let cancelled = false;
if (!auth) {
setLocalWebModules([]);
setRemoteWebModules([]);
setWebModulesLoading(false);
return () => {cancelled = true;};
}
if (platformModules === null) {
return () => {cancelled = true;};
}
if (platformModules.length === 0) {
setLocalWebModules([]);
setRemoteWebModules([]);
setWebModulesLoading(false);
return () => {cancelled = true;};
}
async function loadWebModules() {
const local = await loadInstalledWebModules(platformModules);
if (cancelled) return;
setLocalWebModules(local);
setWebModulesLoading(false);
const remote = await loadRemoteWebModules(platformModules, local);
if (!cancelled) setRemoteWebModules(remote);
}
void loadWebModules().catch((error) => {
console.error("Failed to load platform WebUI modules", error);
if (!cancelled) {
setLocalWebModules([]);
setRemoteWebModules([]);
setWebModulesLoading(false);
}
});
return () => {cancelled = true;};
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules]);
useEffect(() => {
let cancelled = false;
if (!platformPublicModules?.length) {
setLocalPublicWebModules([]);
setRemotePublicWebModules([]);
return () => {cancelled = true;};
}
async function loadPublicWebModules() {
const local = await loadInstalledPublicWebModules(platformPublicModules);
if (cancelled) return;
setLocalPublicWebModules(local);
const remote = await loadRemotePublicWebModules(platformPublicModules, local);
if (!cancelled) setRemotePublicWebModules(remote);
}
void loadPublicWebModules().catch((error) => {
console.error("Failed to load public WebUI modules", error);
if (!cancelled) {
setLocalPublicWebModules([]);
setRemotePublicWebModules([]);
}
});
return () => {cancelled = true;};
}, [platformPublicModules]);
useEffect(() => {
const preferences = auth?.user.ui_preferences ?? DEFAULT_UI_PREFERENCES;
const root = document.documentElement;
@@ -279,30 +354,6 @@ export default function App() {
auth?.user.ui_preferences?.theme
]);
useEffect(() => {
let cancelled = false;
if (!auth || !platformModules?.length) {
setRemoteWebModules([]);
return () => {cancelled = true;};
}
loadRemoteWebModules(platformModules, localWebModules).
then((modules) => {if (!cancelled) setRemoteWebModules(modules);}).
catch(() => {if (!cancelled) setRemoteWebModules([]);});
return () => {cancelled = true;};
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]);
useEffect(() => {
let cancelled = false;
if (!platformPublicModules?.length) {
setRemotePublicWebModules([]);
return () => {cancelled = true;};
}
loadRemotePublicWebModules(platformPublicModules, localPublicWebModules).
then((modules) => {if (!cancelled) setRemotePublicWebModules(modules);}).
catch(() => {if (!cancelled) setRemotePublicWebModules([]);});
return () => {cancelled = true;};
}, [platformPublicModules, localPublicWebModules]);
useEffect(() => {
if (!auth) return;
@@ -380,7 +431,7 @@ export default function App() {
<PlatformViewProvider modules={publicWebModules} projection={null}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
<ModuleLoadBoundary resetKey={location.pathname}>
<Routes>
{publicRoutes.map((route) =>
<Route
@@ -391,7 +442,7 @@ export default function App() {
)}
<Route path="*" element={<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />} />
</Routes>
</Suspense>
</ModuleLoadBoundary>
</AppShell>
</UnsavedChangesProvider>
</PlatformViewProvider>
@@ -424,7 +475,7 @@ export default function App() {
<PlatformViewProvider modules={webModules} projection={viewProjection}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
<ModuleLoadBoundary resetKey={location.pathname} loading={webModulesLoading}>
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
@@ -455,7 +506,7 @@ export default function App() {
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
<Route path="*" element={<Navigate to={defaultRoute} replace />} />
</Routes>
</Suspense>
</ModuleLoadBoundary>
{reloginMessage &&
<LoginModal
settings={settings}

View File

@@ -0,0 +1,76 @@
import { Component, Suspense, type ErrorInfo, type ReactNode } from "react";
import Button from "./Button";
import DismissibleAlert from "./DismissibleAlert";
import LoadingIndicator from "./LoadingIndicator";
type ModuleLoadBoundaryProps = {
children: ReactNode;
resetKey: string;
loading?: boolean;
};
type ModuleLoadBoundaryState = {
error: Error | null;
};
export default class ModuleLoadBoundary extends Component<
ModuleLoadBoundaryProps,
ModuleLoadBoundaryState
> {
state: ModuleLoadBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ModuleLoadBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("GovOPlaN module route failed to load", error, info);
}
componentDidUpdate(previousProps: ModuleLoadBoundaryProps) {
if (
previousProps.resetKey !== this.props.resetKey &&
this.state.error
) {
this.setState({ error: null });
}
}
render() {
if (this.props.loading) {
return <ModuleLoadProgress />;
}
if (this.state.error) {
return (
<div className="content-pad module-load-error">
<DismissibleAlert tone="danger" dismissible={false}>
<p>i18n:govoplan-core.the_resource_could_not_be_loaded.0d1b6cbf</p>
<Button type="button" onClick={() => window.location.reload()}>
i18n:govoplan-core.reload.cce71553
</Button>
</DismissibleAlert>
</div>
);
}
return (
<Suspense
fallback={<ModuleLoadProgress />}
>
{this.props.children}
</Suspense>
);
}
}
function ModuleLoadProgress() {
return (
<div className="content-pad module-load-progress">
<LoadingIndicator
label="i18n:govoplan-core.loading_module.50161f3c"
size="md"
/>
<span>i18n:govoplan-core.loading_module.50161f3c</span>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, Inbox, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, ShieldCheck, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react";
import installedWebModules from "virtual:govoplan-installed-modules";
import installedWebModuleLoaderSource from "virtual:govoplan-installed-modules";
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule } from "../types";
import {
hasUiCapability as hasUiCapabilityForModules,
@@ -21,12 +21,20 @@ import {
export const fallbackDashboardNavItem: PlatformNavItem =
{ to: "/dashboard", label: "i18n:govoplan-core.dashboard.d87f47b4", iconName: "dashboard", order: 10 };
type InstalledWebModuleLoader = {
packageName: string;
load: () => Promise<{ default: PlatformWebModule }>;
};
const installedWebModuleLoaders =
installedWebModuleLoaderSource as unknown as InstalledWebModuleLoader[];
export function shellNavItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
return moduleInstalledForModules("dashboard", modules) ? [] : [fallbackDashboardNavItem];
}
const localModules: PlatformWebModule[] = installedWebModules;
const localModuleCache = new Map<string, Promise<PlatformWebModule | null>>();
const loadedLocalModules = new Map<string, PlatformWebModule>();
const remoteModuleCache = new Map<string, Promise<PlatformWebModule | null>>();
declare global {
@@ -214,44 +222,39 @@ function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo {
};
}
export function resolveInstalledPublicWebModules(
export async function loadInstalledPublicWebModules(
platformModules: PlatformPublicModuleInfo[] | null | undefined
): PlatformWebModule[] {
): Promise<PlatformWebModule[]> {
if (!platformModules?.length) return [];
const localById = new Map(localModules.map((module) => [module.id, module]));
return platformModules.flatMap((info) => {
const local = localById.get(info.id);
if (!local) return [];
const resolved = applyServerMetadata(local, publicModuleInfo(info));
return resolved.publicRoutes?.length ? [resolved] : [];
});
const loaded = await loadInstalledWebModules(platformModules.map(publicModuleInfo));
return loaded.filter((module) => module.publicRoutes?.length);
}
export async function loadRemotePublicWebModules(
platformModules: PlatformPublicModuleInfo[] | null | undefined,
alreadyLoaded: PlatformWebModule[] = resolveInstalledPublicWebModules(platformModules)
alreadyLoaded: PlatformWebModule[] = []
): Promise<PlatformWebModule[]> {
if (!platformModules?.length) return [];
const loaded = await loadRemoteWebModules(platformModules.map(publicModuleInfo), alreadyLoaded);
return loaded.filter((module) => module.publicRoutes?.length);
}
export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] {
if (!platformModules?.length) return localModules;
export async function loadInstalledWebModules(
platformModules: PlatformModuleInfo[] | null | undefined
): Promise<PlatformWebModule[]> {
if (!platformModules?.length) return [];
const localById = new Map(localModules.map((module) => [module.id, module]));
return platformModules.
filter((module) => module.enabled).
map((module) => {
const local = localById.get(module.id);
return local ? applyServerMetadata(local, module) : null;
}).
filter((module): module is PlatformWebModule => module !== null);
const enabledModules = platformModules.filter((module) => module.enabled);
const resolved = await Promise.all(enabledModules.map(async (info) => {
const local = await loadInstalledWebModule(info);
return local ? applyServerMetadata(local, info) : null;
}));
return resolved.filter((module): module is PlatformWebModule => module !== null);
}
export async function loadRemoteWebModules(
platformModules: PlatformModuleInfo[] | null | undefined,
alreadyLoaded: PlatformWebModule[] = resolveInstalledWebModules(platformModules))
alreadyLoaded: PlatformWebModule[] = [])
: Promise<PlatformWebModule[]> {
if (!platformModules?.length) return [];
const loadedIds = new Set(alreadyLoaded.map((module) => module.id));
@@ -274,6 +277,56 @@ alreadyLoaded: PlatformWebModule[] = resolveInstalledWebModules(platformModules)
return loaded.filter((module): module is PlatformWebModule => module !== null);
}
async function loadInstalledWebModule(info: PlatformModuleInfo): Promise<PlatformWebModule | null> {
if (!info.frontend) return null;
const packageName = info.frontend?.package_name;
if (packageName) {
const loader = installedWebModuleLoaders.find((candidate) => candidate.packageName === packageName);
return loader ? loadInstalledWebModulePackage(loader, info.id) : null;
}
// Legacy manifests may omit package_name. Keep compatibility without making
// every modern module eager: only this fallback imports all installed descriptors.
const candidates = await Promise.all(
installedWebModuleLoaders.map((loader) => loadInstalledWebModulePackage(loader))
);
return candidates.find((candidate) => candidate?.id === info.id) ?? null;
}
async function loadInstalledWebModulePackage(
loader: InstalledWebModuleLoader,
expectedModuleId?: string
): Promise<PlatformWebModule | null> {
let promise = localModuleCache.get(loader.packageName);
if (!promise) {
promise = loader.load().
then((imported) => {
const module = imported.default;
if (!isPlatformWebModule(module)) {
throw new Error(`${loader.packageName} does not export a PlatformWebModule`);
}
loadedLocalModules.set(loader.packageName, module);
return module;
}).
catch((error) => {
localModuleCache.delete(loader.packageName);
console.warn("GovOPlaN installed WebUI module was not loaded:", loader.packageName, error);
return null;
});
localModuleCache.set(loader.packageName, promise);
}
const module = await promise;
if (module && expectedModuleId && module.id !== expectedModuleId) {
console.warn(
"GovOPlaN installed WebUI package id mismatch:",
loader.packageName,
`expected ${expectedModuleId}, received ${module.id}`
);
return null;
}
return module;
}
async function loadRemoteWebModule(info: PlatformModuleInfo): Promise<PlatformWebModule | null> {
const frontend = info.frontend;
if (!frontend?.asset_manifest) return null;
@@ -387,26 +440,26 @@ function constantTimeEqual(left: string, right: string): boolean {
}
export function installedLocalWebModules(): PlatformWebModule[] {
return localModules;
return [...loadedLocalModules.values()];
}
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[] = localModules): T | null {
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[] = installedLocalWebModules()): T | null {
return uiCapabilityForModules<T>(capabilityName, modules);
}
export function uiCapabilities<T = unknown>(capabilityName: string, modules: PlatformWebModule[] = localModules): T[] {
export function uiCapabilities<T = unknown>(capabilityName: string, modules: PlatformWebModule[] = installedLocalWebModules()): T[] {
return uiCapabilitiesForModules<T>(capabilityName, modules);
}
export function hasUiCapability(capabilityName: string, modules: PlatformWebModule[] = localModules): boolean {
export function hasUiCapability(capabilityName: string, modules: PlatformWebModule[] = installedLocalWebModules()): boolean {
return hasUiCapabilityForModules(capabilityName, modules);
}
export function moduleInstalled(moduleId: string, modules: PlatformWebModule[] = localModules): boolean {
export function moduleInstalled(moduleId: string, modules: PlatformWebModule[] = installedLocalWebModules()): boolean {
return moduleInstalledForModules(moduleId, modules);
}
export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, modules: PlatformWebModule[] = localModules): boolean {
export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, modules: PlatformWebModule[] = installedLocalWebModules()): boolean {
return moduleIntegrationEnabledForModules(moduleId, dependencyId, modules);
}
@@ -419,7 +472,7 @@ export function publicRouteContributionsForModules(modules: PlatformWebModule[])
}
export function dashboardWidgetsForModules(
modules: PlatformWebModule[] = localModules,
modules: PlatformWebModule[] = installedLocalWebModules(),
projection?: EffectiveViewProjection | null
): DashboardWidgetContribution[] {
const catalogue = viewSurfaceCatalogueForModules(modules);
@@ -446,7 +499,7 @@ export function navItemsForModules(
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): PlatformNavItem[] {
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = installedLocalWebModules(), projection?: EffectiveViewProjection | null): PlatformNavItem[] {
return navItemsForModules(modules, projection).filter((item) => {
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
@@ -454,6 +507,6 @@ export function visibleNavItems(auth: AuthInfo | null | undefined, modules: Plat
});
}
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): string {
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = installedLocalWebModules(), projection?: EffectiveViewProjection | null): string {
return visibleNavItems(auth, modules, projection)[0]?.to ?? "/dashboard";
}

View File

@@ -2204,6 +2204,29 @@
font-weight: 600;
}
.module-load-progress {
display: flex;
min-height: 120px;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-muted);
}
.module-load-error .alert {
max-width: 640px;
}
.module-load-error .alert-message {
display: grid;
justify-items: start;
gap: 12px;
}
.module-load-error .alert-message p {
margin: 0;
}
/* Collapsible cards */
.card-collapsible .card-header {

View File

@@ -4,7 +4,10 @@
declare module "virtual:govoplan-installed-modules" {
import type { PlatformWebModule } from "@govoplan/core-webui";
const installedWebModules: PlatformWebModule[];
export { installedWebModules };
export default installedWebModules;
const installedWebModuleLoaders: Array<{
packageName: string;
load: () => Promise<{ default: PlatformWebModule }>;
}>;
export default installedWebModuleLoaders;
}

View File

@@ -9,6 +9,8 @@ const require = createRequire(import.meta.url);
const webuiRoot = dirname(fileURLToPath(import.meta.url));
const installedModulesVirtualId = "virtual:govoplan-installed-modules";
const resolvedInstalledModulesVirtualId = `\0${installedModulesVirtualId}`;
const installedModuleVirtualPrefix = "virtual:govoplan-installed-module/";
const resolvedInstalledModuleVirtualPrefix = `\0${installedModuleVirtualPrefix}`;
const defaultWebModulePackages = [
"@govoplan/access-webui",
@@ -31,6 +33,7 @@ const defaultWebModulePackages = [
"@govoplan/postbox-webui",
"@govoplan/risk-compliance-webui",
"@govoplan/scheduling-webui",
"@govoplan/search-webui",
"@govoplan/views-webui",
"@govoplan/workflow-webui"
];
@@ -61,21 +64,42 @@ function availableWebModuleSpecifiers(): string[] {
}
function govoplanInstalledModulesPlugin(): Plugin {
const specifierByModuleVirtualId = new Map(
availableWebModuleSpecifiers().map((specifier) => [
`${installedModuleVirtualPrefix}${encodeURIComponent(specifier)}`,
specifier
])
);
return {
name: "govoplan-installed-modules",
resolveId(id: string) {
if (id === installedModulesVirtualId) return resolvedInstalledModulesVirtualId;
if (specifierByModuleVirtualId.has(id)) return `\0${id}`;
return null;
},
load(id: string) {
if (id !== resolvedInstalledModulesVirtualId) return null;
const imports = availableWebModuleSpecifiers();
if (id === resolvedInstalledModulesVirtualId) {
const loaders = [...specifierByModuleVirtualId].map(([virtualId, specifier]) => (
`{ packageName: ${JSON.stringify(specifier)}, load: () => import(${JSON.stringify(virtualId)}) }`
));
return [
...imports.map((specifier, index) => `import module${index} from ${JSON.stringify(specifier)};`),
`export const installedWebModules = [${imports.map((_specifier, index) => `module${index}`).join(", ")}];`,
"export default installedWebModules;"
`export const installedWebModuleLoaders = [${loaders.join(", ")}];`,
"export default installedWebModuleLoaders;"
].join("\n");
}
if (!id.startsWith(resolvedInstalledModuleVirtualPrefix)) return null;
const publicVirtualId = id.slice(1);
const specifier = specifierByModuleVirtualId.get(publicVirtualId);
if (!specifier) return null;
const moduleEntry = join(packageDirectory(specifier), "src", "module.ts");
if (!existsSync(moduleEntry)) {
throw new Error(`${specifier} does not expose src/module.ts`);
}
// Import the contribution descriptor directly. Package root barrels may
// intentionally re-export pages and would otherwise defeat route splitting.
return `export { default } from ${JSON.stringify(moduleEntry)};`;
}
};
}
@@ -88,9 +112,8 @@ export default defineConfig({
include: ["@xyflow/react"]
},
build: {
// Full-product builds include the host shell plus all installed module wiring.
// Dedicated route/vendor splitting belongs in a separate performance pass.
chunkSizeWarningLimit: 1200
manifest: true,
chunkSizeWarningLimit: 500
},
resolve: {
preserveSymlinks: false,
@@ -132,6 +155,7 @@ export default defineConfig({
fileURLToPath(new URL('../../govoplan-postbox/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-risk-compliance/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-search/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))
]