Federation: standalone library mode, dev externalize, Next.js dual test modes#52
Merged
Conversation
Proves that decodeFederationPayload works outside a spiceflow/Vite-RSC app,
in a plain Vite + React SPA.
**spiceflow changes:**
- `federated-payload.ts`: add `setFederationFlightClient()` to inject a custom
Flight client, remove the `typeof document === "undefined"` guard on the
global hook, make `ensureRequirePatched()` set up standalone require globals
(`__webpack_require__`, `__federation_require__`) when no Vite RSC host exists.
Export `loadFederatedClientModules`, `decodeFederationPayloadDetails`,
`resolveFederatedUrl`.
- `package.json`: add `spiceflow/federation-client` export pointing at
`federated-payload.js` (lightweight path avoiding the full `spiceflow/react`
barrel which pulls in `@vitejs/plugin-rsc/browser`).
- `react/index.ts`: re-export new public APIs.
**new `example-federation/standalone/` app:**
- Plain Vite + `@vitejs/plugin-react` SPA (no spiceflow plugin, no RSC)
- `flight-client.ts`: registers bundled React on `__FEDERATION_MODULES__`
global and stubs `__federation_require__`
- `main.tsx`: sets up Flight client via `setFederationFlightClient` using
`react-server-dom-webpack/client.browser` directly
- `chat-widget.tsx`: fetches chart (static) and chat (streaming async
generator) from the remote, decodes federation payloads, injects CSS
- `vite.config.ts`: `patchWebpackRequire` plugin renames `__webpack_require__`
→ `__federation_require__`, `federationImportMap` plugin generates import map
wrapper files post-build so remote chunks resolve bare specifiers to the same
React instance the app bundles
- 4 Playwright e2e tests: chart with interactive client component, streaming
chat via async generator, CSS injection, no React errors
**remote changes:**
- Add `/api/chat` streaming endpoint that yields server-rendered JSX parts via
async generator (simulates AI chat)
**known limitation:**
Streaming federation (`encodeFederationPayload({ stream })`) emits metadata
before client references are discovered, so client components inside streamed
parts cannot be resolved by standalone consumers. Server components in streams
work fine. This is a pre-existing limitation of the metadata-first SSE format.
Session: ses_1765033e2ffe9dWjhsi5FqD9vD
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The `as any` cast on `formState` (removed in the previous commit) was caused by pnpm resolving two copies of `@types/react`: 19.2.14 (pinned by examples) and 19.2.16 (resolved by spiceflow `^19.2.14`). This created two incompatible `@types/react-dom` instances, each peered with a different `@types/react`. `decodeFormState` from `@vitejs/plugin-rsc/rsc` returned `ReactFormState` branded with the 19.2.14 copy, but the object literal expected the 19.2.16 brand. Since `ReactFormState` uses `[REACT_FORM_STATE_SIGIL]`, the two are structurally incompatible despite being identical at runtime. Fix: add pnpm overrides for `@types/react` and `@types/react-dom` in root package.json to force a single version. Update all workspace packages to `@types/react@19.2.16`. Session: ses_167cbd96dffeVxO68x8UlrEPq4
Replace the manual Vite plugin configuration and flight-client.ts setup
with a single `setupFederationConsumer()` call from `spiceflow/federation-client`.
**spiceflow changes:**
- `setupFederationConsumer()`: sets module globals, creates blob URL import
map for `spiceflow/react`, wires up the Flight client from
react-server-dom-webpack, and patches require globals for standalone mode.
- `federationPatchWebpack()`: Vite plugin that both transforms
__webpack_require__ in dev mode AND injects a <script> stub via
transformIndexHtml for production builds where react-server-dom-webpack
is externalized (loaded from esm.sh).
- Replaced dead `import("@vitejs/plugin-rsc/browser")` fallback with a
clear error message. No more need to externalize that package.
- Exported `setupFederationConsumer`, `federationPatchWebpack`,
`decodeFederationPayloadDetails`, `setFederationFlightClient`,
`parseFederationPayload`, `loadFederatedClientModules`, `resolveFederatedUrl`
from `spiceflow/react` and `spiceflow/federation-client`.
**standalone example changes:**
- vite.config.ts: 92 lines to 18 lines. Just react() plugin,
federationPatchWebpack(), and externals for React/esm.sh.
- index.html: added import map with esm.sh URLs for React, ReactDOM,
and react-server-dom-webpack. React is externalized so the app bundle
and remote federation chunks share the same React instance via the
import map, no blob URLs needed for React.
- main.tsx: single setupFederationConsumer() call with real
spiceflow/react (no stub). Only spiceflow/react needs a blob URL
mapping since React is handled by the HTML import map.
- Deleted flight-client.ts entirely (manual global module registration,
require stub, Flight client wiring all moved into setupFederationConsumer).
Bundle size dropped from 412KB to 36KB (React externalized via esm.sh).
Session: ses_1680147daffeu56G74lArzRO0X
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 65b4d85ede
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…h, changeset Address all 5 issues from oracle review: 1. Fix esm.sh import map versions: 19.1.0 -> 19.2.4 to match the remote server. RSC Flight is not a stable cross-version wire protocol. 2. Add SSR guard to setupFederationConsumer(). When typeof document is undefined (Node.js SSR, Next.js prerender), skip DOM-dependent setup (blob URLs, import maps) and only wire up the Flight client. 3. Add changeset for the new spiceflow/federation-client subpath export. 4. Make setupFederationConsumer() idempotent. Check if the global is already set and return early to avoid leaking blob URLs and duplicate import maps during React Strict Mode or HMR. 5. Guard __webpack_require__ stub in federationPatchWebpack() with if (!globalThis.__webpack_require__) to avoid clobbering existing globals in pages that legitimately use webpack. Session: ses_1680147daffeu56G74lArzRO0X
1. ensureRequirePatched() now wraps existing globals with delegation instead of clobbering them. If __webpack_require__ already exists (real webpack runtime, microfrontend), the wrapper checks remoteRegistry first then delegates to the original. 2. Simplify SSR guard in setupFederationConsumer() to a plain early return. The previous version set up a Flight client on the server that could never be used (decodeFederationPayload throws on server). Now the comment correctly describes it as a safe no-op. Session: ses_1680147daffeu56G74lArzRO0X
react-server-dom-webpack is now bundled into the consumer instead of
externalized. Users do not need it as a dependency or in their import map.
The federationPatchWebpack plugin transform hook now also converts CJS
require() calls for react/react-dom inside react-server-dom-webpack to
ESM import statements. This is needed because react and react-dom are
externalized (resolved via import map) but react-server-dom-webpack is
CJS and uses require("react-dom") at the top level. Without conversion,
Rolldown would emit require() in the browser bundle which fails.
The conversion replaces each require("react-dom") call with a hoisted
ESM import namespace, preserving the var declaration structure so
comma-separated declarations stay valid.
Session: ses_1680147daffeu56G74lArzRO0X
Explains the use case: shipping a federation client as an npm package consumed by Next.js or any React app. Covers setup, Vite config, payload consumption, streaming, and the npm package shipping pattern. React is externalized because in real apps the host framework provides it. The esm.sh import map in index.html is just a development convenience for this standalone example. Session: ses_1680147daffeu56G74lArzRO0X
Instead of regex-replacing require() calls in react-server-dom-webpack
source, inject a <script type="module"> that eagerly imports the
externalized React packages via ESM (resolved through the import map)
and provides a synchronous require() polyfill backed by the imported
modules.
This solves both problems with one mechanism:
1. CJS require("react-dom") inside react-server-dom-webpack works
because the polyfill returns the eagerly imported module
2. The Flight protocol __webpack_require__ works because it delegates
to the same registry
Module scripts in <head> execute before module scripts in <body>,
so the polyfill is ready before the main bundle evaluates.
Session: ses_1680147daffeu56G74lArzRO0X
The consumer may not have an HTML page (npm package consumed by Next.js). Replace the transformIndexHtml approach with a virtual module that is injected as an import dependency of react-server-dom-webpack. The virtual module (federation-require-polyfill) eagerly imports the externalized React packages via ESM and sets up globalThis.require and globalThis.__webpack_require__. Since it is imported at the top of react-server-dom-webpack, it evaluates before any CJS require() calls execute. This works in any context: Vite SPA with HTML, npm package builds, or any other bundler setup. No HTML manipulation needed. Session: ses_1680147daffeu56G74lArzRO0X
…pack Add scripts/build-flight-client.ts that generates an embedded string constant from react-server-dom-webpack/client.browser with two transforms: - __webpack_require__ replaced with globalThis.__vite_rsc_require__ (same name vite-rsc uses) - require() shim backed by globalThis.__FEDERATION_MODULES__ for React setupFederationConsumer is now async with reactServerDomWebpack optional. When omitted, the embedded Flight client is loaded via blob URL at runtime. Concurrent callers share one setup promise (safe for Strict Mode / HMR). Add dynamicImport helper (new Function) to bypass all bundler import() transforms, replacing /* webpackIgnore: true */ and /* @vite-ignore */ magic comments in federated-payload.ts and esm-island.tsx. Simplify ensureRequirePatched to only patch the two globals that are actually called: __vite_rsc_client_require__ (Vite RSC host) and __vite_rsc_require__ (standalone embedded client). Remove dead globals __webpack_require__ and __federation_require__. Remove federationPatchWebpack Vite plugin (replaced by embedded approach). Remove registerInWebpackCache (dead code, webpack .c not on global). Remove wrapRequire property preservation (unnecessary for any known env). Session: ses_1677e80c9ffexdBF5pz0SWWoGL
Minimal Next.js 15 App Router app that consumes federation payloads from
a spiceflow remote server. Proves federation works inside webpack-based
hosts without any Vite tooling.
The consumer calls setupFederationConsumer({ modules: { ... } }) which
auto-loads the embedded Flight client via blob URL. No build scripts,
no react-server-dom-webpack dependency, no manual global setup needed.
Includes 3 Playwright e2e tests:
- page loads and federation initializes
- loads remote chart with interactive counter (click +/- buttons)
- no error state after loading
Session: ses_1677e80c9ffexdBF5pz0SWWoGL
Remove react-server-dom-webpack dependency and federationPatchWebpack Vite plugin usage. The standalone consumer now uses the same auto-load path as the Next.js consumer: just pass modules to setupFederationConsumer and the embedded Flight client handles the rest. Update README.md to reflect the simplified API (no more Vite plugin section, no react-server-dom-webpack import). Session: ses_1677e80c9ffexdBF5pz0SWWoGL
CSS from remote federation payloads is now injected automatically when
calling decodeFederationPayloadDetails(). Standalone consumers no longer
need to write their own injectFederationCss helper or call it manually.
Changes:
- Add exported injectFederationCss(metadata, remoteOrigin) in
federated-payload.ts. Returns a Promise<void> that resolves when all
newly injected stylesheets have loaded (via link.onload), preventing
flash of unstyled content.
- decodeFederationPayloadDetails() fires CSS injection first, then loads
JS client modules in parallel, awaits both before returning the decoded
value. Accepts { injectCss: false } to opt out for custom targets like
Shadow DOM.
- RemoteIsland non-isolated path uses Promise.all([treeDecoding, cssReady])
so tree decoding and CSS loading happen in parallel and rendering waits
for both.
- Removed local injectFederationCss from standalone example and Next.js
consumer example since CSS now loads automatically.
All 28 federation e2e tests pass (24 host + 4 standalone), including the
CSS border-color assertion that verifies remote styles are applied.
Session: ses_163ced2d9ffeZaB7VNf4SUrirx
Session: ses_163ced2d9ffeZaB7VNf4SUrirx
The remote Vite build externalizes React, so client component chunks contain bare import specifiers like import "react". When the standalone consumer dynamically imports those chunks at runtime, the browser needs an import map to resolve them. setupFederationConsumer handles this automatically. The import map does not interfere with host bundlers like webpack/turbopack in Next.js. Session: ses_163cc45f8ffep4ZMem1DeGLwgn
…es, gate exports via barrel Switch standalone and nextjs-consumer examples from decodeFederationPayloadDetails to decodeFederationPayload (the simpler wrapper that returns the value directly). None of the examples used ssrHtml, metadata, or remoteOrigin from the details object. Create spiceflow/src/react/federation-client.ts as a barrel file for the spiceflow/federation-client subpath. This gates the public API to 3 functions: - setupFederationConsumer - decodeFederationPayload - decodeFederationPayloadDetails Previously the subpath pointed directly at federated-payload.ts, which leaked 5 internal functions (setFederationFlightClient, loadFederatedClientModules, resolveFederatedUrl, parseFederationPayload, injectFederationCss) that are only used internally by remote-island.tsx and the decode pipeline. Also remove those same 4 re-exports from spiceflow/react index.ts and update changesets and standalone README to match the simplified API. Session: ses_1638df75effewcgFpWNJvYviqs
…mer: dev and build/start e2e modes **standalone/** Restructured to test the npm package pattern properly: - `src/main.tsx` is now a library entry that exports `federationReady` (setup promise) and `ChatWidget` instead of mounting to the DOM - `vite.config.ts` uses `build.lib` (ES format) so the output is a single importable JS file with React externalized - `index.html` imports from `./dist/federation-standalone.js` via an inline `<script type="module">`, proving the built library works from a plain static HTML page with browser import maps - Playwright serves the directory with `npx serve` instead of `vite preview`, validating the static file story end-to-end - `package.json` has `"main": "dist/federation-standalone.js"` to simulate a real npm package structure - README rewritten to reflect library mode, added "Using from a plain HTML file" section **nextjs-consumer/** - Added `test-e2e` (dev mode) and `test-e2e-start` (build+start mode) scripts, controlled by `NEXTJS_MODE=dev` env var in playwright config - `pretest-e2e` only builds remote (dev mode does not need next build) - `pretest-e2e-start` builds both remote and nextjs app - Filtered React "key" prop dev warning from error assertions All e2e tests pass in every mode: - standalone: 4/4 passed - nextjs-consumer dev: 3/3 passed - nextjs-consumer start: 3/3 passed Session: ses_1638728feffe8QKiyCAS1V3gSX
…s federation test Only filter React dev-only warnings (unique key prop) which are not real errors and are stripped in production builds. Network errors like net::ERR_CONNECTION_REFUSED or Failed to load resource should fail the test so they are not silently ignored. Session: ses_163783764ffe6kRam0pFNg09yq
Add `federation-dev-externalize` plugin that enables federation remotes
to serve client chunks in `vite dev` mode with bare import specifiers.
Federation consumers can now load remote dev-mode chunks cross-origin
and resolve shared modules (react, spiceflow/react) via their import map.
The plugin does three things for the client environment:
1. `config()` sets `server.hmr = false` so @vitejs/plugin-react skips
Fast Refresh (no $RefreshReg$/$RefreshSig$ in client chunks), and
sets `oxc.jsx.runtime = automatic` so remotes no longer need
@vitejs/plugin-react at all
2. `resolveId` returns `{ external: true }` for React family modules
so Vite keeps bare specifiers instead of pre-bundling
3. Late transform strips `/@id/` prefixes from externalized modules
and removes CSS side-effect imports (CSS is delivered via federation
metadata instead)
Other changes:
- Remove @vitejs/plugin-react from example-federation/remote (no longer
needed; spiceflow handles JSX and disables Fast Refresh automatically)
- Add `dev` script to standalone consumer for `vite build --watch`
- Fix flaky CSS e2e test: replace `waitForTimeout(1000)` with
`expect.poll()` that waits for the actual computed border-color
- Add AGENTS.md sections for manual testing of standalone and
nextjs-consumer federation examples
- Add changeset
Session: ses_1636eab4effehQG3l4QSNEOgKj
In vite dev, the spiceflow plugin handles requests through Vite SSR middleware. The explicit app.listen() call was starting a second HTTP server on port 3001 which crashed with EADDRINUSE. Session: ses_1636eab4effehQG3l4QSNEOgKj
…er.hmr globally The previous approach set `server.hmr = false` which disabled HMR for the entire dev server, including the RSC and SSR environments where HMR is useful during development. Now the cleanup plugin strips HMR artifacts only from "use client" modules that federation consumers load cross-origin: - Strip `@vite/client` HMR setup imports and `import.meta.hot` assignments - Strip `@react-refresh` Fast Refresh runtime imports and the wrapper IIFE - Stub `$RefreshSig$`/`$RefreshReg$` as no-ops for any inline calls that remain after stripping the wrapper This keeps HMR working for the remote app developer while ensuring federation chunks are clean for cross-origin consumers. Session: ses_163274e92ffeFeA7v57dx1sFOf
The server.hmr=false approach broke import.meta.hot globally, which the remote entry uses to guard app.listen() in dev mode. Reverted to the targeted regex stripping that only affects "use client" modules in the client environment. Also fixes: - Revert broken if(!import.meta.hot) guard on app.listen() - Fix typo "messages" -> "message" in chat response - Update stale comments in plugin and AGENTS.md - Resolve merge conflicts from main Session: ses_1636eab4effehQG3l4QSNEOgKj
Also expand build-flight-client.ts top comment explaining why the pre-built Flight client is needed for standalone consumers. Session: ses_163274e92ffeFeA7v57dx1sFOf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Standalone consumer (
example-federation/standalone/)Switched to Vite library mode so it properly tests the npm package pattern. The built output (
dist/federation-standalone.js) is imported by a plainindex.htmlserved by a static file server instead ofvite preview.src/main.tsxexportsfederationReady+ChatWidget(library entry, no DOM mounting)vite.config.tsusesbuild.libwith React externalizedindex.htmlimports from./dist/federation-standalone.jswith browser import mapsnpx serveinstead ofvite previewpnpm devscript (vite build --watch) for iterating on the consumerexpect.poll()instead ofwaitForTimeout(1000)Federation dev externalize (
spiceflow/src/federation-dev-externalize.ts)New Vite plugin that enables federation remotes to work in
vite devmode. Client component chunks now keep bareimport "react"specifiers so federation consumers can resolve them via their import map when loading chunks cross-origin.Three mechanisms:
optimizeDeps.excludeprevents pre-bundling of shared modulesresolveIdreturns{ external: true }to keep bare specifiers/@id/prefixes, Fast Refresh/HMR artifacts from"use client"modules, and CSS side-effect importsThe spiceflow plugin also sets
oxc.jsx.runtime: 'automatic'for federation remotes, so@vitejs/plugin-reactis no longer needed in the remote's vite config.Next.js consumer (
example-federation/nextjs-consumer/)Added dual test modes matching the integration-tests pattern:
pnpm test-e2eruns againstnext devpnpm test-e2e-startruns againstnext build+next startAll tests pass: standalone 4/4, host 24/24, Next.js dev 3/3, Next.js start 3/3.