npm.io
0.6.0 • Published 3 months ago

@modular-frontend/core

Licence
—
Version
0.6.0
Deps
1
Size
404 kB
Vulns
0
Weekly
0
Stars
7

@modular-frontend/core

Framework-neutral primitives for building modular frontend applications. Provides module descriptor types, slot/navigation builders, validation, journey contracts, and a lightweight store, with no UI-framework dependency.

This is the shared foundation the framework bindings build on. The React binding (@modular-react/core) re-exports this package; a future @modular-vue/core will do the same. Components are carried as opaque values through a small UiComponent / UiNode type seam, which each binding refines to its own component type.

Installation

npm install @modular-frontend/core

Most apps depend on a framework binding (@modular-react/core, and downstream @react-router-modules/* / @tanstack-react-modules/*) rather than on this package directly. Use @modular-frontend/core when building framework-agnostic tooling or a new framework binding.

What's included

  • Types: ModuleDescriptor, AnyModuleDescriptor, LazyModuleDescriptor, NavigationItem, ModuleLifecycle, ReactiveService, SlotMap, SlotMapOf, ZoneMap, ZoneMapOf, Store, RegistryConfig, NavigationGroup, NavigationManifest, ModuleEntry, DynamicSlotFactory, SlotFilter
  • UI-type seam: UiComponent, UiNode — framework-neutral stand-ins refined by each binding
  • Slots: buildSlotsManifest, collectDynamicSlotFactories, evaluateDynamicSlots
  • Navigation: buildNavigationManifest, resolveNavHref
  • Route data: mergeRouteStaticData (router-agnostic merge helper used by useZones / useRouteData in the runtime packages)
  • Validation: validateNoDuplicateIds, validateDependencies, validateEntryExitShape
  • Store: createStore (a lightweight zustand-compatible store, no middleware)
  • Detection: isStore, isStoreApi (alias), isReactiveService, separateDeps
  • Helpers: defineModule, defineSlots, buildDepsSnapshot, runLifecycleHooks
  • Subject-keyed panels: definePanelGroup, resolvePanels, and the PanelEntry / PanelGroupHandle types — the pure, framework-neutral engine for a render-all, predicate-gated, open-contribution slot projection (the render-all sibling of the component-pairing helpers). The thin per-binding hosts (usePanels / <PanelsOutlet>) live in @modular-react/react and @modular-vue/vue. See docs/subject-panels.md
  • Journey contracts: type-only surfaces describing a journey runtime (implemented in @modular-react/journeys)
  • Semver subset: satisfies, parseRange, parseVersion, compareVersions

The UiComponent seam

The core never renders, calls, or inspects a component — it only carries them as values on descriptors (component, zones, entry-point component, NavigationItem.icon). So instead of depending on any UI framework, those positions use two neutral aliases:

export type UiComponent<P = any> = ((props: P) => any) | (new (props: P) => any);
export type UiNode = any;

UiComponent<P> is "callable or constructable with props P", which admits React's full ComponentType (function components via the call arm, class components via the construct arm) as well as any other function/constructor component, and stays usable as a JSX element type in a React binding without pulling in @types/react. The call arm keeps function components props-checked against ModuleEntryProps. A Vue binding narrows the same alias to Vue's component type.

UiNode stays any rather than unknown so a binding can pass these values straight into its framework's render slots (e.g. a React <Suspense fallback>), which unknown would reject.

Usage

import { buildSlotsManifest, createStore } from "@modular-frontend/core";
import type { ModuleDescriptor, Store } from "@modular-frontend/core";

Generic NavigationItem

NavigationItem has four optional generics that let hosts opt into stricter typing — typed i18n labels, dynamic-href context, an app-owned meta bag, and an app-owned dispatchable action union:

import type { NavigationItem } from "@modular-frontend/core";
// `ParseKeys` from i18next resolves to your app's translation keys once you
// augment `CustomTypeOptions.resources` (see the i18next TypeScript guide).
import type { ParseKeys } from "i18next";

interface NavContext {
  workspaceId: string;
}

type Permission = "managePortalRequests" | "viewReports";

interface NavMeta {
  permission?: Permission;
  badge?: "beta" | "new";
}

type NavAction =
  | { kind: "open-module"; moduleId: string; entry: string; input?: unknown }
  | { kind: "journey-start"; journeyId: string; buildInput?: (ctx?: unknown) => unknown };

// Alias once in app-shared and use everywhere
export type AppNavItem = NavigationItem<ParseKeys, NavContext, NavMeta, NavAction>;

action defaults to never, so apps that don't need dispatchable nav intents pay no cost — the field is absent from the item surface.

At render time, resolve the href with context:

import { resolveNavHref } from "@modular-frontend/core";

const href = resolveNavHref(item, { workspaceId });

See docs/navigation.md for the full guide.

AnyModuleDescriptor

ModuleDescriptor has four type parameters (TSharedDependencies, TSlots, TMeta, TNavItem). Internal helpers — navigation builders, lazy-field warnings, test fixtures — often only care about one of them (usually TNavItem), and writing ModuleDescriptor<any, any, any, AppNavItem> everywhere is noisy.

AnyModuleDescriptor<TNavItem> is the shorthand:

import type { AnyModuleDescriptor, NavigationItem } from "@modular-frontend/core";

// Accept any module shape, but preserve the nav item narrowing.
function collectNav<TNavItem extends NavigationItem>(
  modules: readonly AnyModuleDescriptor<TNavItem>[],
) {
  return modules.flatMap((m) => m.navigation ?? []);
}

Prefer the full ModuleDescriptor<...> at user-facing boundaries — the alias is intended for generic plumbing where the extra positional anys would be pure filler.

mergeRouteStaticData

Router-agnostic merge helper used internally by the useZones and useRouteData hooks in the runtime packages. The two routers diverge on where they park per-route static data (handle in React Router, staticData in TanStack Router) but agree on the merge rules — so the shared helper takes the merge rules and a getter that plucks the data field.

Semantics: iterates matches in the order given (root → leaf), deeper matches overwrite shallower ones per key, undefined values are skipped (so a leaf can't silently clobber an ancestor by omitting the key or setting it to undefined). Arrays at the data position are ignored rather than enumerated as index-keyed objects.

Full documentation

See the main documentation for the full guide.