Skip to content

Federation: standalone library mode, dev externalize, Next.js dual test modes#52

Merged
remorses merged 23 commits into
mainfrom
standalone-federation-consumer
Jun 6, 2026
Merged

Federation: standalone library mode, dev externalize, Next.js dual test modes#52
remorses merged 23 commits into
mainfrom
standalone-federation-consumer

Conversation

@remorses

@remorses remorses commented Jun 3, 2026

Copy link
Copy Markdown
Owner

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 plain index.html served by a static file server instead of vite preview.

  • src/main.tsx exports federationReady + ChatWidget (library entry, no DOM mounting)
  • vite.config.ts uses build.lib with React externalized
  • index.html imports from ./dist/federation-standalone.js with browser import maps
  • Playwright uses npx serve instead of vite preview
  • Added pnpm dev script (vite build --watch) for iterating on the consumer
  • Fixed flaky CSS test: expect.poll() instead of waitForTimeout(1000)

Federation dev externalize (spiceflow/src/federation-dev-externalize.ts)

New Vite plugin that enables federation remotes to work in vite dev mode. Client component chunks now keep bare import "react" specifiers so federation consumers can resolve them via their import map when loading chunks cross-origin.

Three mechanisms:

  • optimizeDeps.exclude prevents pre-bundling of shared modules
  • resolveId returns { external: true } to keep bare specifiers
  • Late transform strips /@id/ prefixes, Fast Refresh/HMR artifacts from "use client" modules, and CSS side-effect imports

The spiceflow plugin also sets oxc.jsx.runtime: 'automatic' for federation remotes, so @vitejs/plugin-react is 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-e2e runs against next dev
  • pnpm test-e2e-start runs against next build + next start

All tests pass: standalone 4/4, host 24/24, Next.js dev 3/3, Next.js start 3/3.

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
@vercel

vercel Bot commented Jun 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
integration-tests Error Error Jun 6, 2026 12:24pm

remorses added 2 commits June 5, 2026 16:40
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
@remorses remorses marked this pull request as ready for review June 5, 2026 14:53

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread example-federation/standalone/index.html Outdated
…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
remorses added 5 commits June 6, 2026 11:34
…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
remorses added 2 commits June 6, 2026 12:28
…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
remorses added 4 commits June 6, 2026 12:45
…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
@remorses remorses changed the title Standalone federation consumer example + API Federation: standalone library mode, dev externalize, Next.js dual test modes Jun 6, 2026
Also expand build-flight-client.ts top comment explaining why the
pre-built Flight client is needed for standalone consumers.

Session: ses_163274e92ffeFeA7v57dx1sFOf
@remorses remorses merged commit d198aee into main Jun 6, 2026
3 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant