Discovery & Licensing
How OGMF discovers module definitions at build time, filters them by the user's license, and assembles the runtime ModuleRegistry consumed through React context.
Introduction
OGMF is assembled from self-contained modules (src/modules/{id}/) and addons (src/addons/{id}/). Nothing imports those modules by hand. Instead the app discovers every module.ts, keeps only the ones the current user is licensed for, and merges their definitions into a single ModuleRegistry that the rest of the app reads through context.
The pipeline has four stages, and each one is small on purpose:
| Stage | Where | What it produces |
|---|---|---|
| Discovery | @/App (import.meta.glob) | A discovered map of path → lazy import function |
| Licensing | createRegistry in @/core/registry | A filtered list of imports — unlicensed entries are never called |
| Assembly | createRegistry | A ModuleRegistry { modules, sidebar, routes, slots } |
| Distribution | RegistryProvider + hooks in @/core/registry-provider | The registry over React context |
The reason it is split this way: discovery is static (Vite needs literal glob patterns at build time), licensing is dynamic (it depends on window.OG.modules, only known at login), and assembly must be async (licensed modules are lazy and have to be awaited). Keeping them separate lets Vite code-split every module while still letting the license set decide — at runtime — which chunks ever load.
The whole point of the lazy glob is that an unlicensed module's code is never imported. Vite turns each module.ts into its own chunk; if the id is not in the license set, createRegistry never calls that chunk's import function, so it never reaches the network. Licensing is not just a UI gate — it is a load-time boundary.
Discovery
@/App builds the discovered map once, at module scope, by merging two import.meta.glob calls — one for modules, one for addons:
import type {ModuleDefinition} from "@/core";
const discovered = {
...import.meta.glob<{ default: ModuleDefinition }>('@/modules/**/module.ts'),
...import.meta.glob<{ default: ModuleDefinition }>('@/addons/**/module.ts'),
};Two details matter here:
- No
eageroption. Withouteager: true,import.meta.globreturns a map whose keys are file paths and whose values are lazy import functions —() => Promise<{ default: ModuleDefinition }>. Nothing is imported yet; calling a value is what triggers the dynamic import (and the network fetch for that chunk). Witheager: trueVite would inline every module up front and defeat code-splitting. - Modules and addons share one mechanism. They are merged into the same object with the same value shape, so
createRegistrytreats them identically. The only thing distinguishing an addon from a module downstream is the path segment (/modules/vs/addons/), which only affects id extraction — never behaviour.
discovered is built at module scope (not inside the component) so its identity is stable across renders, which keeps the provider's effect from re-running on every render.
Licensing
The license set comes from the auth store, which is hydrated from the host's window.OG global. @/App reads it reactively:
const licensedModules = useAuthStore((s) => s.modules);useAuthStore((s) => s.modules) is window.OG.modules — the array of module ids the login API granted the user (e.g. ["CORE", "CRM", "VENDAS"]). This array is the single source of truth for what gets loaded.
extractModuleId
createRegistry cannot match ids against full file paths, so it derives an id from each discovered key with a regex:
function extractModuleId(path: string): string | null {
const match = path.match(/\/(?:modules|addons)\/([^/]+)\/module\.ts$/);
return match?.[1] ?? null;
}It captures the single path segment between /modules/ (or /addons/) and /module.ts. From its JSDoc, the documented mappings are:
| Path | Extracted id |
|---|---|
@/modules/invoices/module.ts | invoices |
@/addons/custom-reports/module.ts | custom-reports |
/src/modules/crm/module.ts | crm |
A path that does not match (no recognizable segment) yields null; the entry is logged with [module-registry] Could not extract module id from path: ... and skipped.
Filtering
createRegistry turns licensedModules into a Set and then maps over the discovered entries, only calling the import function when the extracted id is in the license set:
const licenseSet = new Set(licensedModules);
const imports = Object.entries(discovered)
.map(([path, importFn]) => {
const id = extractModuleId(path);
if (!id) {
console.warn(`[module-registry] Could not extract module id from path: ${path}`);
return null;
}
if (!licenseSet.has(id)) return null;
return importFn().then((mod) => mod.default);
})
.filter((p): p is Promise<ModuleDefinition> => p !== null);
const definitions = await Promise.all(imports);Unlicensed entries return null and are filtered out before importFn() is ever called — that is the load-time boundary the info callout above describes. Only licensed modules become promises, and only those promises are awaited.
The _development module
@/App appends "_development" to the effective license list, but only in development builds:
const effectiveLicensedModules = useMemo(
() => (import.meta.env.DEV ? [...licensedModules, "_development"] : licensedModules),
[licensedModules],
);This lets @/modules/_development/module.ts flow through the exact same discovery/license pipeline as any real module, with no special casing inside createRegistry. Today that module contributes the licensePlate demo field type to FORM_SLOTS.FIELD_TYPES; future dev-only routes, sidebar items, or slot types can be added to the same ModuleDefinition.
Note what the augmentation does and does not do. It changes only the license list, not the build: _development/module.ts is matched by the @/modules/**/module.ts glob like every other module, so its chunk is part of the bundle regardless of import.meta.env.DEV. What the DEV gate guarantees is that a developer always has the module registered locally without the server having to grant it.
In a production build the augmentation is a no-op, so _development is registered only when the server includes "_development" in that user's window.OG.modules — it follows the same license boundary as every other module. It is therefore absent for ordinary users but can be deliberately granted (for example, to support staff). Because the module is still discovered and bundled, gating it is a runtime licensing decision, not a bundling one — do not add import.meta.env.DEV globs or path filters to strip _development from the production build.
The registry
After awaiting the licensed imports, createRegistry reduces the resolved ModuleDefinition[] into a single ModuleRegistry:
export type ModuleRegistry = {
/** All registered modules, keyed by id */
modules: Map<string, ModuleDefinition>;
/** Merged and sorted sidebar items from all modules */
sidebar: SidebarItem[];
/** Merged routes from all modules */
routes: RouteObject[];
/** Slot contributions grouped by slot name, sorted by order */
slots: Map<string, SlotContribution[]>;
}Each definition is folded in by a single loop. The loop is guarded, and the sidebar and slots are sorted after collection.
Guards
| Condition | Behaviour |
|---|---|
definition.id is missing/falsy | console.warn("[module-registry] Skipping module: missing \"id\""), skip |
modules already has definition.id | console.warn("[module-registry] Duplicate module id \"…\", skipping"), skip |
for (const definition of definitions) {
if (!definition?.id) {
console.warn(`[module-registry] Skipping module: missing "id"`);
continue;
}
if (modules.has(definition.id)) {
console.warn(`[module-registry] Duplicate module id "${definition.id}", skipping`);
continue;
}
modules.set(definition.id, definition);
// ...sidebar / routes / slots collected here
}A duplicate id wins on first registration, not last. The first definition with a given id is kept; every later definition sharing that id is warned about and skipped entirely — its sidebar items, routes, and slot contributions are all dropped. Iteration order follows the merged discovered map (modules then addons), so an addon cannot silently override a module that already claimed the same id. Keep ids globally unique across both src/modules/ and src/addons/.
Sorting
Collection preserves insertion order; ordering is applied once, after the loop:
- Sidebar is sorted by
order, defaulting to50when absent:allSidebar.sort((a, b) => (a.order ?? 50) - (b.order ?? 50)). Items without an explicitordercluster in the middle, so a module can push an item to the top (order: 0) or bottom (order: 100) without every module having to declare a number. - Slots are grouped by
entry.slotinto theslotMap, and each group is sorted bycontribution.order, again defaulting to50:(a, b) => (a.contribution.order ?? 50) - (b.contribution.order ?? 50).
The result { modules, sidebar, routes: allRoutes, slots: slotMap } is the fully-built ModuleRegistry. Note that routes is left in collection order — route precedence is the consumer's concern, covered in Routing.
The provider & hooks
RegistryProvider (in @/core/registry-provider) owns the async lifecycle and publishes the registry over context. It takes the discovered map, the licensedModules list, and an optional fallback:
<RegistryProvider
discovered={discovered}
licensedModules={effectiveLicensedModules}
fallback={<div className="flex-1"/>}
>
<AppRouter/>
</RegistryProvider>Run createRegistry in an effect
On mount — and again whenever discovered or licensedModules changes — the provider sets loading true and calls createRegistry(discovered, licensedModules). Because the license list is a dependency, granting or revoking a module re-runs the whole pipeline and rebuilds the registry.
Guard against stale resolutions
The effect tracks a cancelled flag in its cleanup. If the license list changes while a previous createRegistry promise is still in flight, the stale resolution is ignored — only the latest run can call setRegistry. Failures are caught and logged as [RegistryProvider] Failed to load modules.
Render fallback, then children
While loading is true and a fallback was provided, the provider renders the fallback; otherwise it renders children. When fallback is omitted, children render immediately against the EMPTY_REGISTRY (empty maps and arrays) — useful when you do not want a loading gate. In @/App the fallback is supplied so AppRouter mounts only once the full route tree exists.
Hooks
Three hooks read the context. All are thin selectors over useRegistry():
| Hook | Returns | Use for |
|---|---|---|
useRegistry() | the full ModuleRegistry | reading modules or slots directly |
useSidebar() | registry.sidebar (SidebarItem[]) | rendering the merged, sorted navigation |
useModuleRoutes() | registry.routes (RouteObject[]) | feeding the router |
@/App consumes useModuleRoutes() inside AppRouter, passes the routes through buildRoutes(...), and memoizes the resulting router on the routes array so it is created exactly once after the registry resolves. How those RouteObject[] are framed (layouts, guards, lazy boundaries) is covered next.
Where to go next
Routing
How registry.routes is wrapped by buildRoutes and consumed by the router.
Module Anatomy
The ModuleDefinition shape — id, routes, sidebar, slots — and how to author a module.ts.
Slots
Contributing to other modules' extension points via the slots map.
Module System Overview
Back to the module-system landing page.
Anatomy of a Module
The ModuleDefinition contract every OGMF module exports — its members, the satisfies authoring pattern, and worked examples from the workshop and crm modules.
Routing
How the module registry's routes are merged with the static shell, how dev routes bypass the license registry, and how each module authors its own routes.tsx with lazy views, authorization middleware, and route-level i18n loading.