Modular Frontend
ION Guard modules bring their own frontend (React + Inertia). Each module is built independently and distributed as an ESM bundle; the core loads that bundle at runtime and integrates its pages, translations, and slot components — without recompiling the core.
How the core loads a module
On install, the module's dist/ is copied to public/module-bundles/{identifier}/. At runtime, the core's resources/js/runtime/module-loader.ts fetches the bundle's entry.js on demand and uses its exports.
A module bundle can export:
| Export | Signature | Role |
|---|---|---|
pages | Record<string, () => Promise<unknown>> | Map of Inertia pages, keyed by the name the backend passes in Inertia::render |
registerSlots? | () => void | Called once per module after the load; registers components in core slots |
registerLocales? | () => void | Registers i18n translation bundles at runtime |
The loader exposes:
prefetchInstalledModules(modules)— loads each installed module and callsregisterLocales()and thenregisterSlots().loadModulePage(module, pageName)— resolves and returns the page component (entry.pages[pageName]), with a clear error if the page map or the page does not exist.findInstalledModuleForPage(pageName, modules)— discovers which module a page belongs to by the prefix of its name (prefix/...= moduleidentifier).
The page name starts with the module's identifier (kebab-case). E.g.: indoor-location/dashboard. findInstalledModuleForPage uses exactly this prefix to route the page to the correct bundle.
The module's entry.ts
Each module defines a resources/js/entry.ts that builds the page map via import.meta.glob and exposes the registration hooks:
// Auto-discovery of Inertia pages
const rawPages = import.meta.glob('./pages/**/*.tsx');
export const pages: Record<string, () => Promise<unknown>> = {};
for (const [filePath, resolver] of Object.entries(rawPages)) {
const name = filePath.replace(/^\.\/pages\//, '').replace(/\.tsx$/, '');
pages[name] = resolver;
}
export function registerLocales(): void {
// Import and register translation bundles here
}
export function registerSlots(): void {
// Register components in core slots here
}
Host API — shared dependencies
The module is built by externalizing React, Inertia, Lucide, and the core's @/... surface. At runtime, these dependencies are resolved from window.__host__, published by the core in resources/js/runtime/host-api.ts:
react → window.__host__.react
react/jsx-runtime → window.__host__.reactJsxRuntime
@inertiajs/react → window.__host__.inertia
lucide-react → window.__host__.lucide
@/<path> → window.__host__.modules['@/<path>']
This ensures the module uses the same instance of React, i18n, and the core components (buttons, cards, tables, charts, layouts, etc.), instead of embedding its own copies.
host-api.ts exports HOST_API_VERSION (semver; 1.0.0 in v0.8.7). Adding a symbol to the surface is the contract. An incompatible change requires bumping HOST_API_VERSION and the compatibleHostApi field of each distributed module — the module-loader loads that field along with the bundle.
Slots — UI extension points
Slots allow a module to inject components at points in the core without altering it. Registration is done in resources/js/lib/slot-registry.ts:
registerSlotComponent(key, component); // register
getSlotComponent(key); // retrieve
In the core, the <Slot name="..." /> component renders all components registered for that slot. It reads the entries from usePage().props.slots[name] and resolves each one via getSlotComponent(entry.component) — silently ignoring empty slots or unregistered components. It is inside the registerSlots() of entry.ts that the module calls registerSlotComponent().
Next steps
- Module System — Full lifecycle (backend + frontend)
- Creating a Module — Where
entry.tsfits