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 BrowserRouter — useParams,
useNavigate, useLocation, and Link all work natively. Modules do
not mount their own router.
Visual + behavioural composition
Section titled “Visual + behavioural composition”| 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 |
What modules cannot do
Section titled “What modules cannot do”- Read host Zustand stores directly — only
remoteSafeSelectorsis exposed. - Mount providers that override host-mounted ones, or change the host’s
route base /
BrowserRouterconfig. - Write to
inEditModeorisNavOpen— 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.
Dynamic origin allow-list
Section titled “Dynamic origin allow-list”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.
Token refresh
Section titled “Token refresh”requestAccessToken calls authService.getValidatedAccessToken(), which
refreshes the token if it’s near expiry before replying. The child never
implements refresh itself.
Rendering
Section titled “Rendering”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.
How brittleness is contained
Section titled “How brittleness is contained”1. One source of truth for the schema
Section titled “1. One source of truth for the schema”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.
2. Tiny module-facing API surface
Section titled “2. Tiny module-facing API surface”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.
4. Admin-aware Lambda merge
Section titled “4. Admin-aware Lambda merge”mergeManifestForUpsert(existing, incoming) is the single function the
publisher Lambda uses to ingest a new manifest:
- Top-level
enabledandaccess.allowedPersonascome 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 byname: 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.
9. Host port is pinned at 3001
Section titled “9. Host port is pinned at 3001”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.
Planned / proposed
Section titled “Planned / proposed”- 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 bumppinnedHostContractVersionautomatically once shipped; today the flag exists but prints a deferral notice.@nike/whisker-iframe-protocol— a types-only package so thepostMessageprotocol 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.
