Skip to content

Host ↔ Module Contract

The contract has two distinct shapes — one for each integration path — and a small set of platform mechanisms keep them honest as the host and modules evolve independently.

Federation modules — a typed runtime API

Section titled “Federation modules — a typed runtime API”

Federation modules are loaded by Vite Module Federation. The host calls loadRemoteModule(name, componentPath) for each page/widget/nav-icon contribution and renders the returned component inside its own router and provider stack.

Navigation icons are usually resolved from the Nike design-system icon name in ui.navigation[].icon; ui.navigation[].componentPath is an optional override for module-provided custom icon components.

remoteSafeSelectors.ts — the only door modules go through

Section titled “remoteSafeSelectors.ts — the only door modules go through”
import { useInEditMode, useIsNavOpen } from './appSettingsStore';
import { authService } from './authStore/authService';
import { useAuth } from './authStore/selectors';
export { authService, useAuth, useInEditMode, useIsNavOpen };

Four exports. That’s the entire API a federation module can pull from the host. Anything else in whisker-host/src/** is unreachable from a remote.

Export Type Notes
useAuth() React hook { accessToken, loggedIn, login, logout, userInfo }
authService Object Non-React access; getValidatedAccessToken() refreshes transparently
useInEditMode() React hook True when the dashboard is in edit mode
useIsNavOpen() React hook True when the host’s nav sidebar is expanded

Modules render inside the host’s BrowserRouteruseParams, useNavigate, useLocation, and Link all work natively. Modules do not mount their own router.

Concern How it works
Theme Modules wrap content in MUI ThemeProvider with nikeTheme / nikeDarkTheme from WCL
Slide-outs SlideOutProvider (WCL, host-mounted) is a singleton — opening a second slide-out closes the first
Inventory InventoryProvider (host-mounted) shares one burst-loading cache across modules
Store context NikeStoreProvider (WCL, host-mounted) gives every module storeInfo / storeConfig / athletes from one fetch
Analytics Modules call useModuleAnalytics(moduleName) from WCL — no NR setup per module
  • Read host Zustand stores directly — only remoteSafeSelectors is exposed.
  • Mount providers that override host-mounted ones, or change the host’s route base / BrowserRouter config.
  • Write to inEditMode or isNavOpen — they are read-only.

The contract is small and stable on purpose; the fewer surfaces modules can pull on, the less the host’s internal refactoring breaks them.

Iframe consumers — a postMessage protocol

Section titled “Iframe consumers — a postMessage protocol”

The same protocol legacy rwe-core and T.R.U.E apps speak, by design — existing iframe apps need zero code changes to move under whisker-host; they just register a manifest. Every message today is child-initiated (the child asks, the host responds or acts):

Message Sent by child Host behaviour
requestAccessToken Replies with { name: 'accessToken', token, authType }
getLanguage Replies with { name: 'langLocale', i18nLanguageCode, ncssLanguageCode }
getUrl Replies with { url: window.location.href } (used by Launchpad-style boot handshakes)
addressbar Updates the host’s address bar via history.replaceState (same-origin URLs only)
setTitle Sets document.title
close Navigates the host to /dashboard
logout Logs the host user out

useIframeMessageHandler validates the message origin against the dynamic allow-list (below) before acting on anything.

Built at runtime by reading every enabled iframe consumer manifest from DynamoDB and extracting integration.urls.{test,prod}. Adding a new iframe consumer = publishing a manifest; nothing in the host repo changes. For local development, VITE_LOCAL_IFRAME_APPS trusts the locally-running app too.

requestAccessToken calls authService.getValidatedAccessToken(), which refreshes the token if it’s near expiry before replying. The child never implements refresh itself.

IframeRenderer.tsx mounts a sandboxed iframe with the default sandbox allow-same-origin allow-scripts allow-top-navigation allow-modals allow-downloads (overridable via the manifest’s integration.sandbox). Height is calc(100vh - APPLICATION_HEADER_HEIGHT). The iframe’s title attribute is set to the module name, and the child’s body is expected to add a whisker-embedded class to suppress internal scrollbars — keeping the seam between host and child invisible.

ModuleManifestSchema lives in @nike/whisker-module-kit. The kit, CI (whisker validate-manifest), and the Lambda publisher all import the exact same Zod object. A manifest that passes locally is guaranteed to pass ingest. See Manifest schema reference.

remoteSafeSelectors.ts exports four symbols. The host can refactor everything else in src/store/** without affecting any federated module — Vite federation makes import { x } from 'whisker-host/somewhereDeep' physically impossible.

3. pinnedHostContractVersion + live whisker-contract.json

Section titled “3. pinnedHostContractVersion + live whisker-contract.json”

config/whisker.config.json records the host contract version the module was scaffolded against. The host publishes the live contract at whisker-host/public/whisker-contract.json, regenerated on every host commit by scripts/generate-whisker-contract.js. sharedPackages isn’t hand-maintained — the script reads the same getSharedConfig() object Vite’s federation plugin consumes, so a package added to or removed from the real shared config shows up automatically.

{
"schemaVersion": "1.0.0",
"host": { "name": "sim-host", "version": "0.0.0" },
"federation": {
"name": "retailUXShell",
"exposedSelectors": ["useAuth", "authService", "useInEditMode", "useIsNavOpen"],
"exposes": { "./useAuth": "./src/store/sharedStores/remoteSafeSelectors.ts" }
},
"sharedPackages": [
/* name + version, sourced from getSharedConfig() */
],
"manifestSchemaVersion": "1.0.0",
"moduleKitMinVersion": "0.1.0"
}

Any module can fetch('/whisker-contract.json') and diff its pinned values against live. Removing a selector or bumping a shared dep major is a breaking change consumers will see in their next contract diff.

mergeManifestForUpsert(existing, incoming) is the single function the publisher Lambda uses to ingest a new manifest:

  • Top-level enabled and access.allowedPersonas come from the existing DynamoDB row when one exists — a redeploy can’t accidentally flip a production module on or off.
  • Per-component buckets (ui.widgets, ui.navigation, ui.avatarMenu, ui.appHeader, integration.pages) are matched by name: source-controlled fields overwrite; admin-editable fields (enabled, allowedPersonas) are preserved.
  • Removed components are hard-deleted. First-time ingest writes the manifest as-is.

5. Identical contract for federation and iframe consumers

Section titled “5. Identical contract for federation and iframe consumers”

Both shapes flow through the same schema, pipeline, and DynamoDB row. integration is a discriminated union on kind — the schema makes “both” or “neither” unrepresentable rather than rejecting it at runtime.

6. SessionStorage fallback for manifest fetch

Section titled “6. SessionStorage fallback for manifest fetch”

whisker-host/src/utils/manifestFetcher.ts implements stale-while-revalidate: cached manifests render immediately, the BFF refreshes in the background. If the BFF is down, the host still renders from sessionStorage.

7. whisker:prep / whisker:rebuild-loader / whisker:restore

Section titled “7. whisker:prep / whisker:rebuild-loader / whisker:restore”

The kit’s whisker dev orchestration shells into three named scripts on the host’s package.json. whisker doctor checks they exist — the kit gets a stable surface to call into and the host can refactor what those scripts do without coordinating with every module.

8. whisker doctor enforces shared-package placement

Section titled “8. whisker doctor enforces shared-package placement”

Every package the host provides as a Module Federation singleton must live in the module’s devDependencies, never dependencies (bundling it would ship a second live copy at runtime). whisker doctor re-checks this invariant on every run, not just at scaffold time.

Okta + BERM auth is registered to this origin. The platform treats 3001 as inviolable — enforced by strictPort: true in the host’s Vite config, and documented in the architecture rule and doctor checks.

  • Enforcement of pinnedHostContractVersion — the diff path is real and queryable today, but the host doesn’t yet refuse to mount a module whose pinned contract is older than its declared minimum. Today it’s informational only.
  • thundercats update --contract — will bump pinnedHostContractVersion automatically once shipped; today the flag exists but prints a deferral notice.
  • @nike/whisker-iframe-protocol — a types-only package so the postMessage protocol becomes compile-checked on both sides instead of hand-coded. See Risks & improvements.

See Risks & improvements for the full honest list of what’s still brittle here — notably that a misconfigured componentPath fails only at runtime, and the postMessage protocol has no compiler-checked surface.