@@ -241,4 +241,346 @@ describe('rsc-mf remote modern.server middleware contracts', () => {
241241 expect ( next ) . toHaveBeenCalledTimes ( 1 ) ;
242242 expect ( context . res ) . toBeUndefined ( ) ;
243243 } ) ;
244+
245+ it ( 'recovers stale css expose assets via manifest fallback' , async ( ) => {
246+ const handler = getRecoverMiddlewareHandler ( ) ;
247+ const next = jest . fn ( async ( ) : Promise < void > => undefined ) ;
248+ const fetchMock = installFetchMock (
249+ jest
250+ . fn ( )
251+ . mockResolvedValueOnce (
252+ new Response (
253+ JSON . stringify ( {
254+ exposes : [
255+ {
256+ assets : {
257+ js : {
258+ sync : [ ] ,
259+ async : [ ] ,
260+ } ,
261+ css : {
262+ sync : [
263+ 'static/css/async/__federation_expose_RemoteClientCounter.9f773de2aa.css' ,
264+ ] ,
265+ async : [ ] ,
266+ } ,
267+ } ,
268+ } ,
269+ ] ,
270+ } ) ,
271+ {
272+ status : 200 ,
273+ headers : {
274+ 'content-type' : 'application/json' ,
275+ } ,
276+ } ,
277+ ) ,
278+ )
279+ . mockResolvedValueOnce (
280+ new Response ( '.fallback-style{}' , {
281+ status : 200 ,
282+ headers : {
283+ 'content-type' : 'text/css' ,
284+ } ,
285+ } ) ,
286+ ) ,
287+ ) ;
288+ const context : {
289+ req : { url : string ; headers ?: { get ?: ( name : string ) => string | null } } ;
290+ res ?: Response ;
291+ } = {
292+ req : {
293+ url : 'http://127.0.0.1:3008/static/css/async/__federation_expose_RemoteClientCounter.css?cache=1' ,
294+ } ,
295+ } ;
296+
297+ await handler ( context , next ) ;
298+
299+ expect ( fetchMock ) . toHaveBeenNthCalledWith (
300+ 2 ,
301+ 'http://127.0.0.1:3008/static/css/async/__federation_expose_RemoteClientCounter.9f773de2aa.css?cache=1' ,
302+ {
303+ headers : {
304+ [ INTERNAL_FALLBACK_HEADER ] : '1' ,
305+ } ,
306+ } ,
307+ ) ;
308+ expect ( next ) . not . toHaveBeenCalled ( ) ;
309+ await expect ( context . res ?. text ( ) ) . resolves . toBe ( '.fallback-style{}' ) ;
310+ } ) ;
311+
312+ it ( 'falls through when manifest response body is invalid json' , async ( ) => {
313+ const handler = getRecoverMiddlewareHandler ( ) ;
314+ const next = jest . fn ( async ( ) : Promise < void > => undefined ) ;
315+ const fetchMock = installFetchMock (
316+ jest . fn ( ) . mockResolvedValueOnce (
317+ new Response ( 'not-json-manifest' , {
318+ status : 200 ,
319+ headers : {
320+ 'content-type' : 'application/json' ,
321+ } ,
322+ } ) ,
323+ ) ,
324+ ) ;
325+ const context : {
326+ req : { url : string ; headers ?: { get ?: ( name : string ) => string | null } } ;
327+ res ?: Response ;
328+ } = {
329+ req : {
330+ url : 'http://127.0.0.1:3008/static/js/async/__federation_expose_RemoteClientCounter.js' ,
331+ } ,
332+ } ;
333+
334+ await handler ( context , next ) ;
335+
336+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
337+ expect ( next ) . toHaveBeenCalledTimes ( 1 ) ;
338+ expect ( context . res ) . toBeUndefined ( ) ;
339+ } ) ;
340+
341+ it ( 'falls through when manifest request throws' , async ( ) => {
342+ const handler = getRecoverMiddlewareHandler ( ) ;
343+ const next = jest . fn ( async ( ) : Promise < void > => undefined ) ;
344+ const fetchMock = installFetchMock ( async ( ) => {
345+ throw new Error ( 'manifest-fetch-failed' ) ;
346+ } ) ;
347+ const context : {
348+ req : { url : string ; headers ?: { get ?: ( name : string ) => string | null } } ;
349+ res ?: Response ;
350+ } = {
351+ req : {
352+ url : 'http://127.0.0.1:3008/static/js/async/__federation_expose_RemoteClientCounter.js' ,
353+ } ,
354+ } ;
355+
356+ await handler ( context , next ) ;
357+
358+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
359+ expect ( next ) . toHaveBeenCalledTimes ( 1 ) ;
360+ expect ( context . res ) . toBeUndefined ( ) ;
361+ } ) ;
362+
363+ it ( 'falls through when manifest fallback lookup has no canonical asset match' , async ( ) => {
364+ const handler = getRecoverMiddlewareHandler ( ) ;
365+ const next = jest . fn ( async ( ) : Promise < void > => undefined ) ;
366+ const fetchMock = installFetchMock (
367+ jest . fn ( ) . mockResolvedValueOnce (
368+ new Response (
369+ JSON . stringify ( {
370+ exposes : [
371+ {
372+ assets : {
373+ js : {
374+ sync : [
375+ 'static/js/async/__federation_expose_other.abc123.js' ,
376+ ] ,
377+ async : [ ] ,
378+ } ,
379+ css : {
380+ sync : [ ] ,
381+ async : [ ] ,
382+ } ,
383+ } ,
384+ } ,
385+ ] ,
386+ } ) ,
387+ {
388+ status : 200 ,
389+ headers : {
390+ 'content-type' : 'application/json' ,
391+ } ,
392+ } ,
393+ ) ,
394+ ) ,
395+ ) ;
396+ const context : {
397+ req : { url : string ; headers ?: { get ?: ( name : string ) => string | null } } ;
398+ res ?: Response ;
399+ } = {
400+ req : {
401+ url : 'http://127.0.0.1:3008/static/js/async/__federation_expose_RemoteClientCounter.js' ,
402+ } ,
403+ } ;
404+
405+ await handler ( context , next ) ;
406+
407+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
408+ expect ( next ) . toHaveBeenCalledTimes ( 1 ) ;
409+ expect ( context . res ) . toBeUndefined ( ) ;
410+ } ) ;
411+
412+ it ( 'falls through when fallback asset fetch returns non-ok response' , async ( ) => {
413+ const handler = getRecoverMiddlewareHandler ( ) ;
414+ const next = jest . fn ( async ( ) : Promise < void > => undefined ) ;
415+ const fetchMock = installFetchMock (
416+ jest
417+ . fn ( )
418+ . mockResolvedValueOnce (
419+ new Response (
420+ JSON . stringify ( {
421+ exposes : [
422+ {
423+ assets : {
424+ js : {
425+ sync : [
426+ 'static/js/async/__federation_expose_RemoteClientCounter.7745fe5f0a.js' ,
427+ ] ,
428+ async : [ ] ,
429+ } ,
430+ css : {
431+ sync : [ ] ,
432+ async : [ ] ,
433+ } ,
434+ } ,
435+ } ,
436+ ] ,
437+ } ) ,
438+ {
439+ status : 200 ,
440+ headers : {
441+ 'content-type' : 'application/json' ,
442+ } ,
443+ } ,
444+ ) ,
445+ )
446+ . mockResolvedValueOnce (
447+ new Response ( 'missing-fallback-asset' , {
448+ status : 404 ,
449+ headers : {
450+ 'content-type' : 'text/plain' ,
451+ } ,
452+ } ) ,
453+ ) ,
454+ ) ;
455+ const context : {
456+ req : { url : string ; headers ?: { get ?: ( name : string ) => string | null } } ;
457+ res ?: Response ;
458+ } = {
459+ req : {
460+ url : 'http://127.0.0.1:3008/static/js/async/__federation_expose_RemoteClientCounter.js' ,
461+ } ,
462+ } ;
463+
464+ await handler ( context , next ) ;
465+
466+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 2 ) ;
467+ expect ( next ) . toHaveBeenCalledTimes ( 1 ) ;
468+ expect ( context . res ) . toBeUndefined ( ) ;
469+ } ) ;
470+
471+ it ( 'merges request query params into absolute same-origin manifest fallback assets' , async ( ) => {
472+ const handler = getRecoverMiddlewareHandler ( ) ;
473+ const next = jest . fn ( async ( ) : Promise < void > => undefined ) ;
474+ const fetchMock = installFetchMock (
475+ jest
476+ . fn ( )
477+ . mockResolvedValueOnce (
478+ new Response (
479+ JSON . stringify ( {
480+ shared : [
481+ {
482+ assets : {
483+ js : {
484+ sync : [
485+ 'http://127.0.0.1:3008/static/js/async/__federation_expose_RemoteClientCounter.7745fe5f0a.js?manifest=1' ,
486+ ] ,
487+ async : [ ] ,
488+ } ,
489+ css : {
490+ sync : [ ] ,
491+ async : [ ] ,
492+ } ,
493+ } ,
494+ } ,
495+ ] ,
496+ } ) ,
497+ {
498+ status : 200 ,
499+ headers : {
500+ 'content-type' : 'application/json' ,
501+ } ,
502+ } ,
503+ ) ,
504+ )
505+ . mockResolvedValueOnce (
506+ new Response ( 'absolute-fallback-asset' , {
507+ status : 200 ,
508+ headers : {
509+ 'content-type' : 'application/javascript' ,
510+ } ,
511+ } ) ,
512+ ) ,
513+ ) ;
514+ const context : {
515+ req : { url : string ; headers ?: { get ?: ( name : string ) => string | null } } ;
516+ res ?: Response ;
517+ } = {
518+ req : {
519+ url : 'http://127.0.0.1:3008/static/js/async/__federation_expose_RemoteClientCounter.js?cache=1' ,
520+ } ,
521+ } ;
522+
523+ await handler ( context , next ) ;
524+
525+ expect ( fetchMock ) . toHaveBeenNthCalledWith (
526+ 2 ,
527+ 'http://127.0.0.1:3008/static/js/async/__federation_expose_RemoteClientCounter.7745fe5f0a.js?manifest=1&cache=1' ,
528+ {
529+ headers : {
530+ [ INTERNAL_FALLBACK_HEADER ] : '1' ,
531+ } ,
532+ } ,
533+ ) ;
534+ expect ( next ) . not . toHaveBeenCalled ( ) ;
535+ await expect ( context . res ?. text ( ) ) . resolves . toBe ( 'absolute-fallback-asset' ) ;
536+ } ) ;
537+
538+ it ( 'falls through when fallback asset resolves to the same request url' , async ( ) => {
539+ const handler = getRecoverMiddlewareHandler ( ) ;
540+ const next = jest . fn ( async ( ) : Promise < void > => undefined ) ;
541+ const fetchMock = installFetchMock (
542+ jest . fn ( ) . mockResolvedValueOnce (
543+ new Response (
544+ JSON . stringify ( {
545+ exposes : [
546+ {
547+ assets : {
548+ js : {
549+ sync : [
550+ 'static/js/async/__federation_expose_RemoteClientCounter.js' ,
551+ ] ,
552+ async : [ ] ,
553+ } ,
554+ css : {
555+ sync : [ ] ,
556+ async : [ ] ,
557+ } ,
558+ } ,
559+ } ,
560+ ] ,
561+ } ) ,
562+ {
563+ status : 200 ,
564+ headers : {
565+ 'content-type' : 'application/json' ,
566+ } ,
567+ } ,
568+ ) ,
569+ ) ,
570+ ) ;
571+ const context : {
572+ req : { url : string ; headers ?: { get ?: ( name : string ) => string | null } } ;
573+ res ?: Response ;
574+ } = {
575+ req : {
576+ url : 'http://127.0.0.1:3008/static/js/async/__federation_expose_RemoteClientCounter.js' ,
577+ } ,
578+ } ;
579+
580+ await handler ( context , next ) ;
581+
582+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
583+ expect ( next ) . toHaveBeenCalledTimes ( 1 ) ;
584+ expect ( context . res ) . toBeUndefined ( ) ;
585+ } ) ;
244586} ) ;
0 commit comments