Skip to content

feat: route a remote's server functions to its own server - #19

Merged
Nsttt merged 1 commit into
mainfrom
feat/remote-server-function-routing
Nov 7, 2026
Merged

Nsttt merged 1 commit into
mainfrom
feat/remote-server-function-routing

Conversation

@Nsttt

@Nsttt Nsttt commented Nov 3, 2026

Copy link
Copy Markdown
Member

Stacked on #18. Review that PR first; this one's diff is against its branch.

Problem

A remote's exposed component that calls its own createServerFn doesn't work when a host renders it:

This is the problem @mes113 demonstrated in mes113/module-federation-ssr. The approach follows that prototype (per-remote namespace, a host gateway, HTTP during SSR), rebuilt on public TanStack Start APIs.

Change

Remote: set tanstackStart({ serverFns: { base: "/_federation/<name>/_serverFn" } }). The adapter defines the remote's federation name in its Node container. It fails the build, naming the exact base to use, when the container calls server functions and the base doesn't match.

Host: a new @module-federation/tanstack/server-functions export.

const remoteServerFunctions = createRemoteServerFunctions({
  remotes: { catalog: { origin: () => process.env.CATALOG_ORIGIN! } },
});
export const startInstance = createStart(() => ({
  requestMiddleware: [
    createCsrfMiddleware({ filter: ({ handlerType }) => handlerType === "serverFn" }),
    createMiddleware().server(({ next, request }) => remoteServerFunctions.handle(request) ?? next()),
  ],
}));
  • Browser path: handle is a plain function the host mounts in its own request middleware. That's public API, so middleware placed before it (such as authentication) runs first. The prototype's server-entry wrapper bypassed it. The gateway:

    • accepts same-origin callers only (Sec-Fetch-Site, then Origin, then Referer);
    • forwards only Start's protocol headers (accept, content-type, x-tsr-serverFn);
    • sends the remote's own Origin, so the remote's default CSRF middleware accepts the server-to-server hop;
    • drops Set-Cookie and hop-by-hop headers;
    • rejects function paths containing / or %;
    • answers 502 when the remote is down.

    The timeout covers response headers only, so streamed responses aren't cut off.

  • SSR path: the container's createSsrRpc wraps Start's public createClientRpc with a per-call fetch. Serialization is Start's own. The call goes over HTTP to the origin the host registered (the registry lives on a globalThis symbol, because the container is a separate bundle). The origin lives only in host config and can be read at runtime. Unlike the prototype, the remote never bakes in its own origin, so one remote build works in every environment.

  • Credentials: cookies and authorization reach the remote only when the host's headers({ request }) callback returns them. It receives the incoming request in the browser path, and the host's current request (from Start's shared AsyncLocalStorage) during SSR.

Examples: the Rsbuild SSR remote's card gets an "Ask the remote's server" button. The Rsbuild and Vite SSR hosts both add src/start.ts and call the remote's function from their route loader, so the answer is in the initial HTML.

Proof

  • New end-to-end scenario, run on dev servers and production builds. For both the Rsbuild and Vite hosts:

    • the remote's answer is in the server-rendered HTML;
    • clicking the remote's button in the browser gets the answer through the host;
    • a cross-site POST to the gateway gets 403.

    The standalone remote also answers its own button.

  • test/server-functions.test.mjs uses a real HTTP upstream to check:

    • the header allowlist and that Origin can't be overridden;
    • the CSRF matrix (cross-site/same-site/foreign Origin/foreign Referer/none → 403);
    • 405/404;
    • an unreachable remote → 502;
    • the header timeout firing, while a slow streamed body still completes;
    • a runtime origin function;
    • invalid origins and remote names;
    • the container path, including an unregistered remote.
  • Removing serverFns.base from the remote makes its build fail with the exact base to set.

  • pnpm format:check, pnpm typecheck, and pnpm test (92/92) pass locally. Codex autoreview is unavailable (local auth 401), so the Claude engine reviewed this: scoped-clean at P2.

Known limits (documented)

  • During SSR, values are decoded with the host's serialization adapters, not the remote's.
  • Remotes with a router basepath aren't supported.
  • Cookies the remote sets aren't passed to the browser.
  • A raw 3xx Response returned by a handler passes through unchanged. Start's own redirects are serialized in the payload, so they're unaffected.
  • Vite remotes still publish handlers (see fix(rsbuild): keep server function handlers out of the remote's Node container #18).

@pkg-pr-new

pkg-pr-new Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/module-federation/tanstack/@module-federation/tanstack@19

commit: 9a7d22b

@gioboa gioboa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

👍

Base automatically changed from feat/federated-server-functions to main November 7, 2026 17:41
@Nsttt
Nsttt force-pushed the feat/remote-server-function-routing branch from 28f3c60 to 9a7d22b Compare November 7, 2026 17:41
@Nsttt
Nsttt merged commit f45e8d5 into main Nov 7, 2026
5 checks passed
@Nsttt
Nsttt deleted the feat/remote-server-function-routing branch November 7, 2026 17:44
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.

2 participants