1- import type { ModuleFederationRuntimePlugin } from "@module-federation/runtime" ;
1+ import type { ModuleFederation , ModuleFederationRuntimePlugin } from "@module-federation/runtime" ;
22
33type LoadEntryArgs = Parameters < NonNullable < ModuleFederationRuntimePlugin [ "loadEntry" ] > > [ 0 ] ;
44type RemoteEntryExports = NonNullable < LoadEntryArgs [ "remoteEntryExports" ] > ;
55type RemoteInfo = LoadEntryArgs [ "remoteInfo" ] ;
66type FederationHost = LoadEntryArgs [ "origin" ] ;
77type NodeModule = typeof import ( "node:module" ) ;
88
9+ export interface NodeEntryLoaderOptions {
10+ /** Re-check manifest/server entries after this many milliseconds. Omit to cache until exit. */
11+ maxAgeMs ?: number ;
12+ /** Revalidation strategy used by the Vite SSR entry loader. */
13+ strategy ?: "temp-file" | "vm" ;
14+ /** Maximum time for a server-entry request. Defaults to 10 seconds; 0 disables the timeout. */
15+ fetchTimeoutMs ?: number ;
16+ /** Maximum server-entry response size for Vite remotes; 0 disables the limit. */
17+ fetchMaxBytes ?: number ;
18+ }
19+
920/** A remote's server entry failed to load. `phase` says which step failed; `cause` keeps the original error. */
1021export class RemoteEntryError extends Error {
1122 readonly phase : "fetch" | "evaluate" | "loader" ;
@@ -34,14 +45,25 @@ export class RemoteEntryError extends Error {
3445 * handles both production builds and Vite dev servers. It is loaded with Node's
3546 * `require`, not the bundle's, so its `import()` calls work in Rsbuild's runner too.
3647 */
37- export default function nodeEntryLoaderPlugin ( ) : ModuleFederationRuntimePlugin {
48+ export default function nodeEntryLoaderPlugin (
49+ options : NodeEntryLoaderOptions = { } ,
50+ ) : ModuleFederationRuntimePlugin {
51+ const revalidationHooks = isRevalidationEnabled ( options . maxAgeMs )
52+ ? createRevalidationHooks ( options . maxAgeMs )
53+ : { } ;
54+
3855 return {
3956 name : "tanstack-start-node-entry-loader" ,
57+ ...revalidationHooks ,
4058 async loadEntry ( args ) {
4159 const { remoteInfo } = args ;
4260 if ( ! isNodeServer ( ) || ! / ^ h t t p s ? : \/ \/ / . test ( remoteInfo . entry ) ) return undefined ;
43- if ( remoteInfo . type === "commonjs-module" ) return loadCommonJsEntry ( remoteInfo ) ;
44- if ( remoteInfo . type === "module" || remoteInfo . type === "esm" ) return loadModuleEntry ( args ) ;
61+ if ( remoteInfo . type === "commonjs-module" ) {
62+ return loadCommonJsEntry ( remoteInfo , options . fetchTimeoutMs ) ;
63+ }
64+ if ( remoteInfo . type === "module" || remoteInfo . type === "esm" ) {
65+ return loadModuleEntry ( args , options ) ;
66+ }
4567 return undefined ;
4668 } ,
4769 } ;
@@ -50,6 +72,11 @@ export default function nodeEntryLoaderPlugin(): ModuleFederationRuntimePlugin {
5072// Matches @module-federation/vite's SSR fetch timeout.
5173const FETCH_TIMEOUT_MS = 10_000 ;
5274
75+ function requestOptions ( fetchTimeoutMs : number | undefined ) {
76+ const timeout = fetchTimeoutMs ?? FETCH_TIMEOUT_MS ;
77+ return timeout === 0 ? { } : { signal : AbortSignal . timeout ( timeout ) } ;
78+ }
79+
5380function isNodeServer ( ) {
5481 return typeof process !== "undefined" && typeof process . getBuiltinModule === "function" ;
5582}
@@ -68,10 +95,13 @@ function appRequires() {
6895 . map ( ( base ) => createRequire ( base ) ) ;
6996}
7097
71- async function loadCommonJsEntry ( { entry, entryGlobalName, name } : RemoteInfo ) {
98+ async function loadCommonJsEntry (
99+ { entry, entryGlobalName, name } : RemoteInfo ,
100+ fetchTimeoutMs = FETCH_TIMEOUT_MS ,
101+ ) {
72102 let source : string ;
73103 try {
74- const response = await fetch ( entry , { signal : AbortSignal . timeout ( FETCH_TIMEOUT_MS ) } ) ;
104+ const response = await fetch ( entry , requestOptions ( fetchTimeoutMs ) ) ;
75105 if ( ! response . ok ) throw new Error ( `HTTP ${ response . status } ${ response . statusText } ` . trim ( ) ) ;
76106 source = await response . text ( ) ;
77107 } catch ( cause ) {
@@ -141,22 +171,34 @@ const VITE_SHARED_PACKAGES = [
141171] ;
142172
143173type ViteSsrEntryLoaderModule = {
144- default : ( options : { resolvedShared : Record < string , string > } ) => ModuleFederationRuntimePlugin ;
174+ default : ( options : {
175+ resolvedShared : Record < string , string > ;
176+ maxAgeMs ?: number ;
177+ strategy ?: "temp-file" | "vm" ;
178+ fetchTimeoutMs ?: number ;
179+ fetchMaxBytes ?: number ;
180+ } ) => ModuleFederationRuntimePlugin ;
181+ revalidate ?: ( remoteEntryUrl ?: string ) => void ;
145182 SsrEntryHttpError ?: new ( ...args : never [ ] ) => Error ;
146183} ;
147184
148185type ViteLoader = {
149186 httpError ?: ViteSsrEntryLoaderModule [ "SsrEntryHttpError" ] ;
150187 plugin : ModuleFederationRuntimePlugin ;
188+ revalidate ?: ViteSsrEntryLoaderModule [ "revalidate" ] ;
151189 require : NodeJS . Require ;
152190 resolvedShared : Record < string , string > ;
153191} ;
154192
155- let viteLoader : ViteLoader | undefined ;
193+ const viteLoaders = new WeakMap < ModuleFederation , ViteLoader > ( ) ;
156194
157- async function loadModuleEntry ( args : LoadEntryArgs ) {
195+ async function loadModuleEntry ( args : LoadEntryArgs , options : NodeEntryLoaderOptions ) {
158196 const { entry, name } = args . remoteInfo ;
159- viteLoader ??= createViteLoader ( args ) ;
197+ let viteLoader = viteLoaders . get ( args . origin ) ;
198+ if ( ! viteLoader ) {
199+ viteLoader = createViteLoader ( args , options ) ;
200+ viteLoaders . set ( args . origin , viteLoader ) ;
201+ }
160202 // Shared modules the host loaded since the last remote are aligned too.
161203 provideHostModules ( args . origin , viteLoader . resolvedShared , viteLoader . require ) ;
162204
@@ -181,7 +223,7 @@ async function loadModuleEntry(args: LoadEntryArgs) {
181223 if ( ! remoteEntry ) {
182224 const reachable = await fetch ( entry , {
183225 method : "HEAD" ,
184- signal : AbortSignal . timeout ( FETCH_TIMEOUT_MS ) ,
226+ ... requestOptions ( options . fetchTimeoutMs ) ,
185227 } ) . then (
186228 ( response ) => response . ok ,
187229 ( ) => false ,
@@ -197,7 +239,10 @@ async function loadModuleEntry(args: LoadEntryArgs) {
197239 return remoteEntry ;
198240}
199241
200- function createViteLoader ( { origin, remoteInfo } : LoadEntryArgs ) : ViteLoader {
242+ function createViteLoader (
243+ { origin, remoteInfo } : LoadEntryArgs ,
244+ options : NodeEntryLoaderOptions ,
245+ ) : ViteLoader {
201246 const requires = appRequires ( ) ;
202247 const resolve = ( specifier : string ) => {
203248 for ( const require of requires ) {
@@ -229,12 +274,123 @@ function createViteLoader({ origin, remoteInfo }: LoadEntryArgs): ViteLoader {
229274 const loaderModule = loader . require ( loader . path ) as ViteSsrEntryLoaderModule ;
230275 return {
231276 httpError : loaderModule . SsrEntryHttpError ,
232- plugin : loaderModule . default ( { resolvedShared } ) ,
277+ plugin : loaderModule . default ( { resolvedShared, ...options } ) ,
278+ revalidate : loaderModule . revalidate ,
233279 require : loader . require ,
234280 resolvedShared,
235281 } ;
236282}
237283
284+ function isRevalidationEnabled ( maxAgeMs : number | undefined ) : maxAgeMs is number {
285+ return typeof maxAgeMs === "number" && Number . isFinite ( maxAgeMs ) && maxAgeMs >= 0 ;
286+ }
287+
288+ function createRevalidationHooks ( maxAgeMs : number ) {
289+ const lastLoadedAtByOrigin = new WeakMap < ModuleFederation , Map < string , number > > ( ) ;
290+ // Re-registration is synchronous, but loading and evaluating the new entry is not. Keep a
291+ // per-remote barrier so concurrent expired requests share that asynchronous revalidation.
292+ const pendingRevalidationsByOrigin = new WeakMap <
293+ ModuleFederation ,
294+ Map < string , PendingRevalidation >
295+ > ( ) ;
296+
297+ return {
298+ async beforeRequest ( context ) {
299+ const { id, origin } = context ;
300+ const remote = findRemote ( origin , id ) ;
301+ if ( ! remote ) return context ;
302+
303+ const pendingRevalidations = getPendingRevalidations ( origin ) ;
304+ const pendingRevalidation = pendingRevalidations . get ( remote . name ) ;
305+ if ( pendingRevalidation ) {
306+ await pendingRevalidation . promise ;
307+ return context ;
308+ }
309+
310+ const lastLoadedAt = getLastLoadedAt ( origin ) ;
311+ const loadedAt = lastLoadedAt . get ( remote . name ) ;
312+ if ( loadedAt !== undefined && Date . now ( ) - loadedAt >= maxAgeMs ) {
313+ lastLoadedAt . delete ( remote . name ) ;
314+ const nextRevalidation = createPendingRevalidation ( ) ;
315+ pendingRevalidations . set ( remote . name , nextRevalidation ) ;
316+ try {
317+ invalidateRemote ( origin , remote ) ;
318+ } catch ( error ) {
319+ pendingRevalidations . delete ( remote . name ) ;
320+ nextRevalidation . resolve ( ) ;
321+ throw error ;
322+ }
323+ }
324+ return context ;
325+ } ,
326+ afterLoadRemote ( { origin, remote, error } ) {
327+ if ( ! remote ) return ;
328+ const pendingRevalidations = getPendingRevalidations ( origin ) ;
329+ const lastLoadedAt = getLastLoadedAt ( origin ) ;
330+ if ( ! error && ! lastLoadedAt . has ( remote . name ) ) lastLoadedAt . set ( remote . name , Date . now ( ) ) ;
331+ const pendingRevalidation = pendingRevalidations . get ( remote . name ) ;
332+ if ( pendingRevalidation ) {
333+ pendingRevalidations . delete ( remote . name ) ;
334+ pendingRevalidation . resolve ( ) ;
335+ }
336+ } ,
337+ } satisfies Pick < ModuleFederationRuntimePlugin , "beforeRequest" | "afterLoadRemote" > ;
338+
339+ function getLastLoadedAt ( origin : ModuleFederation ) {
340+ let lastLoadedAt = lastLoadedAtByOrigin . get ( origin ) ;
341+ if ( ! lastLoadedAt ) {
342+ lastLoadedAt = new Map ( ) ;
343+ lastLoadedAtByOrigin . set ( origin , lastLoadedAt ) ;
344+ }
345+ return lastLoadedAt ;
346+ }
347+
348+ function getPendingRevalidations ( origin : ModuleFederation ) {
349+ let pendingRevalidations = pendingRevalidationsByOrigin . get ( origin ) ;
350+ if ( ! pendingRevalidations ) {
351+ pendingRevalidations = new Map ( ) ;
352+ pendingRevalidationsByOrigin . set ( origin , pendingRevalidations ) ;
353+ }
354+ return pendingRevalidations ;
355+ }
356+ }
357+
358+ interface PendingRevalidation {
359+ promise : Promise < void > ;
360+ resolve : ( ) => void ;
361+ }
362+
363+ function createPendingRevalidation ( ) : PendingRevalidation {
364+ let resolve = ( ) => { } ;
365+ const promise = new Promise < void > ( ( complete ) => {
366+ resolve = complete ;
367+ } ) ;
368+ return { promise, resolve } ;
369+ }
370+
371+ function findRemote ( origin : ModuleFederation , id : string ) {
372+ return origin . options . remotes . find ( ( remote ) => {
373+ const names = [ remote . name , remote . alias ] . filter (
374+ ( name ) : name is string => typeof name === "string" ,
375+ ) ;
376+ return names . some ( ( name ) => id === name || id . startsWith ( `${ name } /` ) ) ;
377+ } ) ;
378+ }
379+
380+ function invalidateRemote (
381+ origin : ModuleFederation ,
382+ remote : ModuleFederation [ "options" ] [ "remotes" ] [ number ] ,
383+ ) {
384+ if (
385+ ( remote . type === "module" || remote . type === "esm" ) &&
386+ "entry" in remote &&
387+ typeof remote . entry === "string"
388+ ) {
389+ viteLoaders . get ( origin ) ?. revalidate ?.( remote . entry ) ;
390+ }
391+ origin . registerRemotes ( [ { ...remote } ] , { force : true } ) ;
392+ }
393+
238394/**
239395 * Vite remotes can import shared packages from node_modules instead of the share scope:
240396 * a Vite dev server's module runner externalizes them, and resolved imports in production
0 commit comments