Skip to main content

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:

ExportSignatureRole
pagesRecord<string, () => Promise<unknown>>Map of Inertia pages, keyed by the name the backend passes in Inertia::render
registerSlots?() => voidCalled once per module after the load; registers components in core slots
registerLocales?() => voidRegisters i18n translation bundles at runtime

The loader exposes:

  • prefetchInstalledModules(modules) — loads each installed module and calls registerLocales() and then registerSlots().
  • 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/... = module identifier).
Page naming

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 versioning

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