OfficeGest
Module System

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:

StageWhereWhat it produces
Discovery@/App (import.meta.glob)A discovered map of path → lazy import function
LicensingcreateRegistry in @/core/registryA filtered list of imports — unlicensed entries are never called
AssemblycreateRegistryA ModuleRegistry { modules, sidebar, routes, slots }
DistributionRegistryProvider + hooks in @/core/registry-providerThe 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 eager option. Without eager: true, import.meta.glob returns 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). With eager: true Vite 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 createRegistry treats 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:

PathExtracted id
@/modules/invoices/module.tsinvoices
@/addons/custom-reports/module.tscustom-reports
/src/modules/crm/module.tscrm

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

ConditionBehaviour
definition.id is missing/falsyconsole.warn("[module-registry] Skipping module: missing \"id\""), skip
modules already has definition.idconsole.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 to 50 when absent: allSidebar.sort((a, b) => (a.order ?? 50) - (b.order ?? 50)). Items without an explicit order cluster 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.slot into the slotMap, and each group is sorted by contribution.order, again defaulting to 50: (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():

HookReturnsUse for
useRegistry()the full ModuleRegistryreading 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