Retail Launchpad v2 · High-level design
How RLPv2 works and why
A technical walkthrough of the RLPv2 platform (rwe-core): one host app that serves a small, read-only federation surface to partner modules, embeds full apps through iframes, and keeps every contract it made with RLPv1. Each major decision comes with its reasoning, followed by an honest look at constraints and open questions.
01
System context
One host composes two kinds of content: iframe apps (full screen, from the sidebar) and federated widgets (Home cards). The host owns identity, store context, language, navigation and telemetry. Partner code never talks to those systems directly; it gets what it needs from the host. Hover any box for its relationship to the host.
nikeNative bridge supplies athlete token@nike/typescript-web-loginvrTillSettlements), polled by Variance widget/{app} + /{app}/assets/remoteEntry.js; /shell/* → 301 /host/*Jenkinsfile: develop → test · main → prod02
Runtime composition
The host serves the shell surface to partner modules but does not consume it through federation. Its own code imports the shell by source path. Host and partners still share exactly one instance of the auth context, translations context and New Relic agent, because they end up loading the same JavaScript file.
import './shell/observability/init'
The first line of src/main.tsx. It starts the New Relic Browser Agent before anything else, so it hooks browser globals early. ES import order is preserved, so this stays first.
Render the provider tree
Shell (auth + translations) → WidgetHostProvider → BrowserRouter → UserPermissionsProvider → App. Then track('AppReady'). No async hop.
Partners load retailUXShell/core
A widget remote fetches /host/assets/remoteEntry.js and imports the exposed core chunk. That chunk imports the same context chunk the host entry already loaded, so the browser returns the cached module.
Guardrails
An ESLint no-restricted-imports rule fails the build if anything under src/ imports retailUXShell/*. scripts/check-federation-singleton.sh asserts on dist/ that AuthContext and TranslationsContext are each created in exactly one chunk, and that both the host entry and the core expose chunk import it.
How host and partners share one instance (click a path)
index.html → index-*.jsImports @/shell/… by source pathremoteEntry.js → __federation_expose_Core-*.jsBuilt from remoteSafeSelectors.ts03
Federated contract
The host publishes under the federation name host. Partners alias it as retailUXShell. Only paths with a real downstream consumer are exposed, and nothing exposed lets a partner drive the session.
| Path | Source | Exports |
|---|---|---|
retailUXShell/core | src/shell/remoteSafeSelectors.ts | Shell, useAuth (read-only), useWidgetHost, hostNavigate, SuggestionWidget, trackWidgetEvent, trackWidgetCtaClick |
retailUXShell/observability | src/shell/observability/index.ts | track, notice, timing, withContext, setUserContext. Kept for now; no downstream consumer yet. |
// retailUXShell/core: useAuth() for partner modules
interface RemoteSafeAuth {
accessToken: string | null;
isLoggedIn: boolean;
userInfo: UserInfo | null; // athlete_id, operator_id, nike_store, roles, locale, timezone…
}
// No login, logout, sessionExpiresIn, providers or token refresh.
// The host's internal useAuth (src/shell/auth/useAuth.tsx) keeps those.
Who uses it (audited)
| Consumer | Integration | Imports |
|---|---|---|
rcf-reporting-uiKPI + Variance widgets | Federation | Shell, SuggestionWidget, hostNavigate, trackWidgetEvent, trackWidgetCtaClick, useWidgetHost, useAuth (token + user info) |
rcf-cash-frontendCash mode widget | Federation | Shell, SuggestionWidget, useWidgetHost, useAuth (token). Also calls getStoreId?.(), which the host has never provided. |
sim-web-ui and other sidebar apps | Iframe + postMessage | Nothing |
Contract rules
- A symbol is added to
remoteSafeSelectors.tsonly when a real downstream module needs it, as an explicit named export (neverexport *), and only as read-only session state. Changes require approval because this is the public contract. - Host-only helpers (
initHost,getWidgetHost,trackWidgetDismiss) stay out of the surface. - Partner modules wrap their widgets in
<Shell>to receive the host contexts. They are built against React 16 or 18, withreactandreact-domshared. The contract for mismatched React versions is an open follow-up.
04
Registries & gating
Everything the host can render is declared in files that ship with the host. That keeps it simple and reviewable. The tradeoff is that adding or removing an entry needs a host deploy (see section 12).
| What | Where | Key fields |
|---|---|---|
| Iframe app metadata | src/retail-launch-pad/apps.json | slug, i18n key, scsKey, minimumRoleLevel (20–40 today), banner, per-env URLs (also the origin allowlist) |
| Iframe app constants | src/appConfig.ts | Per-env URLs incl. China EJ endpoints, enabled flags, disable keys, disabledInNative |
| Federated routed apps | config/app-registry.ts | Empty today. The shape for future federated routed apps; keyed by name. |
| Widgets | config/widget-registry.ts | Keyed by moduleName (deduped against apps); type, per-env remoteEntry, enabledInProduction, disabledInNative |
Sidebar app visibility gates
disabledInNativeSource: src/Components/SidebarNavigaton/index.tsx (store flags via UserPermissionsProvider). Widgets have only the environment and native gates. There is no per-store or role targeting and no kill switch.
05
Iframe integration contract
Unchanged by the restructure. Transport is window.postMessage, handled in src/retail-launch-pad/utilities/windowMessageHandling/index.js (message names in constants.ts beside it). The host replies only to origins on the allowlist (every URL in apps.json). Anything else is dropped and recorded as IframeOriginRejected. The message names are identical to RLPv1, which is why v1 apps run in v2 unmodified.
| Message | Dir | Host behavior |
|---|---|---|
requestAccessToken | app → host | Replies { name: 'accessToken', token: { accessToken, refreshToken }, authType: 'retail', reason? }; on failure token: null, error. Telemetry: IframeTokenSent (with durationMs) / IframeTokenSendError |
sendStore | app → host | { storeNumber, country } |
getLanguage | app → host | langLocal plus a legacy langLocale copy for v1 apps: { i18nLanguageCode, ncssLanguageCode } |
getUrl | app → host | { url: location.href } |
setTitle | app → host | Sets document.title |
addressbar | app → host | history.replaceState(…, newurl), keeping deep links and the back button correct |
close | app → host | Navigate to host root (ignored during page unload) |
logout | app → host | Host logout |
| token push | host → app | Fresh token pushed on refresh (IframeTokenPushed, 8,100 last week) |
Iframe element
sandbox="allow-same-origin allow-scripts allow-top-navigation allow-modals allow-downloads" and, since PR #131, allow="local-network-access". Chrome's Local Network Access denies cross-origin iframes access to private-network APIs unless the embedder delegates it. SIMWeb needs this to reach sim-bff.
Iframe router: navigation that crosses the boundary
src/hooks/useIframeRouter.tsx: the app signals readiness and publishes its routes. The host renders them as sidebar sub-items (SOR → KPI, Variance Report, Till Totals…) and navigates the iframe on click. Requests are queued until the app is ready. A 10 s ready SLA emits IframeReadyTimeout (1 occurrence in 34,002 loads last week).
IframeMessageReceived with messageType and appOrigin only. Extra attributes come from a null-prototype safelist (route ≤ 200 chars, title ≤ 100 chars), so a hostile __proto__ message can't crash the handler.06
Identity & session
The host is the only owner of the session. Partner widgets read it through the read-only useAuth(). Iframe apps receive tokens over the allowlisted channel. Neither can log in, log out or schedule refreshes.
| Context | Mechanism | Last 7 days |
|---|---|---|
| Web | RAPv3 OAuth via @nike/typescript-web-login. Before every SSO redirect, a certificate guidance step tells the associate which issuer to pick (Nike ID CA 01 / 02) and how to recover from the wrong choice (AuthCertGuidanceShown). | 19,352 completed · 175 failed |
| iOS | window.webkit.messageHandlers.nikeNative.postMessage({ action: 'getAthleteToken' | 'logout' }); responses via window.nikeNative.onAthleteToken / onUserDidChange. iOS side: NikeNativeBridgeHandler.swift | 34,959 tokens · 19 refresh failures · 7 login failures |
| Partner widgets | retailUXShell/core → useAuth(): { accessToken, isLoggedIn, userInfo } | n/a |
| Iframe apps | Never see credentials. They request tokens over postMessage, and the host pushes renewals. | 36,322 handoffs · 4 errors · p95 ~3 ms |
Translations come from Bodega. Keys Bodega hasn't published yet (currently the certificate guidance copy) resolve from a bundled catalog, src/shell/contexts/localTranslations.ts, then from the English fallback at the call site.
07
Widget platform
Widgets are federated components rendered inside the host's React tree. The host is deliberately generic. It loads, tracks and renders widgets, but the widget decides when it should be visible.
// window.rweWidgetHost, reached by partners via useWidgetHost() (src/widgets/widgetTypes.ts)
registerWidget(entry: WidgetRegistration): void
notifyReady(id: string, payload?: unknown): void // "show me"
notifyHide(id: string, options?: { userDismissed?: boolean }): void // "hide me"
getLastHiddenByUser(id: string): string | null // ISO timestamp
trackEvent(eventName: string, attrs?: Record<string, string | number | boolean>): void
Lifecycle (click a transition)
What the host provides to widgets
SuggestionWidget: shared container, close button and dismiss interaction.hostNavigate(route): deep-link from a widget into any app route (e.g. Variance → SOR Variance Report with date, till and athlete pre-selected).trackWidgetEvent/trackWidgetCtaClick: widget-scoped telemetry without importing the full SDK.WidgetErrorBoundaryaround every widget, plusWidgetLoadError/WidgetRenderErrortelemetry.- Dismissal memory:
localStorage.widgetsLastHiddenByUser[operatorId][widgetId], written only for user dismissals and only whenuserInfo.operator_idis present.
idle until a poll finds something to show, so "still idle after N seconds" is noise, not a failure. The host alerts on widget load and render error rates instead. The tradeoff: a widget that never signals at all isn't detected automatically.Live widgets
| Widget | Shows when | Fetching | Dismissal | Loaded → shown Oct 25–27 |
|---|---|---|---|---|
| SOR Variance rcf-reporting-ui | Any till |over/short| ≥ $25: today first, else yesterday; up to 2 lines + "n more" | Every 60 min in practice (code: 30 min shown / 60 min hidden; hidden in 91% of loads). ~1.6 requests per check (today, then yesterday). | Host API: resurfaces only for variances newer than the dismissal | 13,457 → 1,162 (9%) |
| Nike Cash SOD/EOD rcf-cash-frontend | SOD: mode CLOSED, after midnight, before open. EOD: mode OPEN, within 2 h of close (incl. special hours) | Every 60 s measured in prod (code: 15 s shown / 60 s hidden) | Own keys cashModeWidget_dismissed_{athlete}_{store}_{mode}; parses the JWT itself | 17,548 → 5,249 (30%) |
| SOR KPI rcf-reporting-ui | Persistent | Doesn't poll. ~2.4 requests each time Home mounts. | n/a | 32,459 → 18,000 |
Both suggestion widgets are disabledInNative, so today they appear only on store computers. SOR KPI appears on web and iOS.
getStoreId?.(), which the host has never provided (the store is already on userInfo.nike_store). Because the surface is curated, scoped dismissals and an explicit store selector are the first candidates to add to remoteSafeSelectors.08
Observability & alerting
- One SDK and one New Relic Browser Agent. The host imports it from
@/shell/observability:track(PageAction),notice(JS error),timing(durationMs),withContext,setUserContext. Widgets emit throughtrackWidgetEvent/trackWidgetCtaClick. - 38 named events under a
<Domain><Action><Outcome?>taxonomy enforced by a branded TS type: App, Auth (10), Iframe (10), Nav, Store, Widget, Federated. - PII:
user.givenName/user.familyNameare stripped at runtime from every emitted event. Useuser.athleteId. On iOS, usesessionTraceIdrather than NRsession. - Session Replay is forbidden by org policy, enforced by a
pretestgrep guard.
Alerts as code
newrelic/ holds Terraform for policies, conditions, the Slack channel and workflow, with one runbook per condition. Jenkins runs terraform plan on PRs and apply on develop (TEST) and main (PROD). State lives in S3 + DynamoDB with Retain policies. Everything is named APEX - RLPv2 - ….
| Policy | Conditions |
|---|---|
| Auth (5) | login failure rate · callback latency · unknown-outcome failures · session expiry spike · token refresh failures |
| Iframes (4) | load errors · ready-timeout rate · token send error rate · origin rejected |
| App (4) | federated app load errors · JS errors · LCP regression · INP regression |
| Store (3) | config fetch error rate · views fetch errors · default config returned |
| Widgets (1) | widget error rate (baseline 0.9%, alert > 5% / 1 h, floor 50 loads) |
Single tier (critical only), routed to Slack only. Promotion to PagerDuty is per condition, after 2+ clean weeks and a dedicated PR. Dashboards live in the NR UI under APEX - RLPv2/Companion/RWE. Doctrine: src/shell/observability/README.md.
09
Build, quality & deploy
| Aspect | Detail |
|---|---|
| Repo layout | src/ (app; src/shell/ is the served surface plus internals) · config/ (registries, codeQuality/, cloudFormation/) · newrelic/ · scripts/ · openspec/ · root Jenkinsfile. Mirrors whisker-host. |
| Toolchain | pnpm 10 (single package), Node 22, Vite 6, @originjs/vite-plugin-federation, React 18, React Router 7, TypeScript 5.9, Vitest 3, @nike/podium |
| Pipeline | Jenkins stages: Build (with QMA: lint report, coverage, scan-at-source) → DeployCloudFormation → ApplyNewRelicConfig → InvalidateCloudFront. develop → TEST, main → PRODUCTION, other branches → build/test only. |
| CI guards | pnpm lint:report && pnpm test:coverage (80% global gate) · check:federation-singleton after build · check:observability-init (Session Replay) · Terraform validate.sh |
| Workflow | Test-first: a failing test is committed before the fix. Tests are hermetic (mocked fetch, storage and shell modules; VITE_CLIENT_* pinned empty). |
| Hosting | S3 + CloudFront (config/cloudFormation/rwe-resources.yaml, stack rwe-core). Host at /host; each partner at /{app} and /{app}/assets/remoteEntry.js. No minify or modulePreload. |
| Environments | test rwe-core.retail-webux-test.nikecloud.com · prod rwe-core.retail-webux-prod.nikecloud.com |
| Partner widgets | Deployed to the same bucket using a cop-pipeline template the platform team provides. Updates go live without a host deploy. |
| Public contract | Permanent CloudFront 301 /shell/* → /host/* for downstream repos built before consolidation |
| Secrets | Cerberus SDB shared/rbh-eagles/retail-launchpad-v2, read by CI. Never committed. |
10
Decision records
The "why", in a consistent format. Expand any record for context, alternatives, consequences and the trigger to revisit it.
D1Embed full apps with iframesKeep v1's model and message vocabulary for sidebar apps
postMessage vocabulary preserved exactly.- Zero app changes for v1 → v2 (14 → 546 daily stores in a week).
- Strong isolation and a browser-enforced security boundary; independent deploys.
- Cost: a document load per app (p50 0.6 s, p95 up to 3.8 s); visual consistency relies on the design system. SOR shows it can still feel native. New browser policies (e.g. Local Network Access) must be delegated by the host.
D2Load widgets via Module FederationNative-feeling cards, owned and deployed by partner teams
D3The host serves the shell but doesn't federate with itselfImport by source path; one instance through shared chunks
@originjs/vite-plugin-federation can't dedupe user-owned modules through shared.@/shell/… directly. Federation is used only to serve modules. Rollup places a module reachable from both the app entry and an expose in one chunk, and browsers cache ES modules by URL, so partners get the host's instance.- Self-consuming remote (the host loads its own shell through its own
remoteEntry.jsbehind an async bootstrap): works, but adds a boot hop, a dev/build divergence and custom guardrails. - Switch to
@module-federation/vite: deferred; not required for this property.
dist/ in CI rather than enforced by structure. It is verified on build output, not end to end in a browser.D4A curated, read-only module surfaceretailUXShell/core = remoteSafeSelectors.ts, mirroring whisker-host
login / logout / sessionExpiresIn and token getters. Exposing it would let partners drive the session. An audit of partner repos showed they need seven symbols../core (an explicit seven-symbol file) and ./observability. useAuth() for partners returns { accessToken, isLoggedIn, userInfo } only. The path is named core because that's what partner repos import.Shell, useAuth, Translations, widgetHostSdk). Rejected: it widens the contract without consumers and hands partners session control.<Shell> (then Shell can leave the surface), or a partner adopts observability.D5One package, shaped like whisker-hostNo workspaces; non-source config under config/
whisker-host is the equivalent corporate host.Jenkinsfile, one QMA config, and lint plus the 80% coverage gate enforced in CI.D6Widgets own their display logicThe host stays generic; owners decide what's worth surfacing
notifyReady / notifyHide.- Central rules engine: high coordination cost and a single team bottleneck.
- Backend per widget: forces simple widgets to run infrastructure that monitors every store and user. Disproportionate effort.
D7Polling for MVP, not event-drivenMost conditions require polling somewhere; frontend was the cheapest place to start
- No new infrastructure; shipped fast.
- Polling only runs while Home is open, about 1.5 h per store per day in production. Total widget traffic is ~1.9 M requests/month, under 1% of the original worst-case model.
- Redundant polling: every device in a store fetches the same store-level data, and each widget picks its own interval (Nike Cash: 60 s).
- The host contract doesn't change when a widget moves to events, so migration can happen one widget at a time.
D8Dismissals in localStorage for MVPShip the framework first; centralize state later
D9Observability as a platform standard, alerts as codeOne SDK, one taxonomy, no PII names, no Session Replay
user.athleteId. Alerts, routing and runbooks are versioned as Terraform and ship single-tier to Slack.D10Defer the future-state backend until Home is definedAvoid building AWS infrastructure we'd throw away
11
Polling cost model
The original worst-case model assumed 1,500 stores × 3 devices × 50 widgets, each polling every 20 minutes for 12 hours a day: 243 M requests a month ≈ $731 on a modeled API Gateway + Lambda + DynamoDB stack (≈ $3.01 per million). That model assumes every device sits on Home all day. It doesn't. Widgets are rendered inside Home.tsx, so they fetch only while an associate has the Home page open. The real cost driver is how often and how long associates are on Home, so we measured it.
What production shows
How these numbers were derived (New Relic, prod, Oct 26–27, 2026 UTC)
- Home visits.
NavHomeViewedPageActions on web (client.isNative = false): 10,254 over two days across 389 stores/day (avg of 391 and 387) = 13.2 per store per day. - Time on Home. For each
sessionTraceId, a visit starts atNavHomeViewedand ends at the nextNavRouteChangedaway from/, or, if the user never left, at the session's last event (usually a widget poll). Result: median visit 6 s, mean 10.7 min, p90 34 min. Visits over 30 min are 11% of visits but 85% of Home time. Total 914 Home-open hours/day (2.35 h per store). - Time with polling actually running. Open time overstates polling: browsers suspend timers while a machine sleeps or locks. Nike Cash fires once per mount and then every 60 s, so (39,772 requests/day − 5,127 mounts) × 60 s = 577 polling hours/day = 1.48 h per store. The calculator uses this figure.
- Fetch behavior per widget.
AjaxRequestevents from the host page, split by URL and GraphQLoperationName, with gaps between consecutive requests per session (Oct 27, 14:00–17:00 UTC, 10,325 requests):- Nike Cash (
cash_mode): 87% of gaps ≈ 60 s. 1 request per check. - Variance (
vrTillSettlements): checks about hourly, with 1–2 requests a second apart (today, then yesterday). Calibrated to 1.6 requests per check. - KPI (
storeKpiReport): bursts on mount, no repeating interval. 2.4 requests per web Home visit.
- Nike Cash (
- Check against reality. With those inputs the model gives 61.2 k requests/day on web; New Relic measured 61.3 k (Cash 39.8 k, Variance 9.1 k, KPI 12.4 k). iOS adds ~2.1 k KPI requests/day, for ~1.9 M requests/month in total.
Requests from inside iframe apps are a separate document and aren't counted. 90% of Cash requests carry pageUrl = /; the rest are polls that landed just as the user navigated away.
Usage
Widgets
requests/day = stores × Σwidgets ( visits × requests per mount + polling hours × 3600 ÷ interval × requests per check )
- Exposure is far lower than modeled. The model assumed 36 device-hours on Home per store per day (3 devices × 12 h). Production shows about 1.5. Most visits are a pass-through to an app.
- One widget dominates. Nike Cash polls every 60 s, 20× faster than the model's 20 minutes, and makes up nearly two-thirds of all widget traffic. At 10 minutes it would drop from ~40 k to ~8.6 k requests/day.
- Remounts matter. About 40% of requests come from widgets re-fetching each time Home mounts, not from timers. Variance and KPI traffic is almost entirely remount-driven. A short-lived shared cache in the SDK would cut this without touching intervals.
- Net today: ~1.9 M requests/month, under 1% of the modeled 243 M (≈ $6 on the cost proxy). Frontend polling lands on partner backends, not a platform backend, so the dollar figure is a proxy, not our bill. The risks to watch are fast-polling widgets and redundant per-store requests as widgets multiply and Home becomes a place people stay.
12
Known constraints & tech debt
| Item | Impact | Severity |
|---|---|---|
| No widget kill switch | Disabling a misbehaving widget requires a prod deploy. Iframe apps already have per-store disable keys. | High |
| Static widget registry | Adding, removing or configuring a widget needs a host deploy, and there's no per-store, geo or role targeting. | Medium |
| localStorage dismissals | Per device, not store-wide, and lost on clear. A notification can reappear for a colleague or on a new device. | Medium |
| No polling guardrails | Each widget picks its own interval. Nike Cash polls every 60 s and generates nearly two-thirds of widget traffic. Every Home mount re-fetches (~40% of requests). No SDK wrapper for rate limits, shared caching or per-store dedupe. | Medium |
| SDK gaps → divergent widgets | Nike Cash keeps its own dismissal keys, parses the JWT itself and calls a getStoreId that doesn't exist. | Medium |
Dismissals keyed by operator_id | Not persisted when operator_id is missing, which telemetry shows is common on iOS. Latent today because both suggestion widgets are web-only; key on athlete_id before shipping them to iOS. | Medium |
| React version contract for widgets | Partners build against React 16 or 18 and self-wrap in <Shell>. What shared guarantees across versions is undefined. | Medium |
| Single instance not browser-verified | The guard checks dist/. No end-to-end test loads a real partner widget against the built host. | Medium |
| No remote MFE loader / branch builds | Partners can't try a widget in test or staging without a registry change and deploy. | Medium |
| Silent widgets undetected | There is no idle watchdog, so a widget that never calls notifyReady / notifyHide raises nothing. | Low |
| Spec and doc drift | The align proposal and design D3 say "exposes unchanged + ./remoteSafeSelectors"; the code exposes only ./core. The header of remoteSafeSelectors.ts still says retailUXShell/remoteSafeSelectors. | Low |
observability exposed without a consumer | Either partners adopt it, or it leaves the surface. | Low |
| Widget type name drift | Host 'suggestion' vs SDK 'notification' | Low |
| Split iframe app config | apps.json and appConfig.ts, next to an empty app-registry.ts | Low |
| Store config fetch errors | 1.3% of fetches (436 / 33,885). Fallback behavior needs review. | Low |
| Iframe p95 load | Up to 3.8 s (Store Hours) and 2.6 s (EJ). Owned by the app teams; the platform could add preconnect. | Low |
| Mixed JS/TS in host | Message handling and the iframe component are untyped JS. | Low |
| Widget sizes and patterns not standardized | Inconsistent Home layout as widgets grow. Needs Design. | Low |
13
Open questions
- Home direction: notifications feed, to-do list, or a hybrid? This blocks the backend design.
- Dismissal scope: per athlete, per role group in a store, or the whole store?
- Should polling-driven notifications (SOD/EOD) be centralized, or stay in the widget with an SDK polling wrapper?
- Widget registration: self-service for partner teams, or approved by the platform team?
- Should the kill switch also cover iframe apps, beyond today's per-store disable keys?
- What does
sharedguarantee for widgets built on a different React version, and do we require React 18? - Once partners stop self-wrapping in
<Shell>, doesShellleave the surface? Doesobservabilitystay? - Is there a path for iframe apps to become federated modules, and which app would go first?
- What latency is acceptable per notification type? (Current guidance: 5–30 minutes.)
- Corporate view: what does a non-store user see, and how much should RLPv2 share with
whisker-host?
Sources: rwe-core (AGENTS.md, README.md, vite.config.ts, src/, newrelic/, openspec/changes/align-rwe-core-with-whisker-host), rbh-retail-launchpad-ui, Confluence space COM (Architecture – Current vs Future State; Future State: Requirements; Future State Dependencies; SOR Variance Widget; Nike Cash Widget), New Relic production (rwe-core-host), Oct 21–27, 2026 unless noted.