@@ -632,6 +632,171 @@ describe("asset-server handler", () => {
632632 expect ( response2 . headers . get ( "link" ) ) . toBeNull ( ) ;
633633 } ) ;
634634
635+ test ( "early hints should resolve relative link hrefs against base href" , async ( {
636+ expect,
637+ } ) => {
638+ const deploymentId = "deployment-" + Math . random ( ) ;
639+ const metadata = createMetadataObject ( { deploymentId } ) as Metadata ;
640+
641+ const findAssetEntryForPath = async ( path : string ) => {
642+ if ( path === "/index.html" ) {
643+ return "asset-key-index-with-base.html" ;
644+ }
645+ return null ;
646+ } ;
647+ const fetchAsset = ( ) =>
648+ Promise . resolve (
649+ Object . assign (
650+ new Response ( `
651+ <!DOCTYPE html>
652+ <html>
653+ <head>
654+ <base href="/" />
655+ <link rel="modulepreload" href="module.js" />
656+ </head>
657+ </html>` ) ,
658+ { contentType : "text/html" }
659+ )
660+ ) ;
661+
662+ const getResponse = async ( ) =>
663+ getTestResponse ( {
664+ request : new Request ( "https://example.com/" ) ,
665+ metadata,
666+ findAssetEntryForPath,
667+ caches,
668+ fetchAsset,
669+ } ) ;
670+
671+ const { response, spies } = await getResponse ( ) ;
672+ expect ( response . status ) . toBe ( 200 ) ;
673+ await Promise . all ( spies . waitUntil ) ;
674+
675+ const earlyHintsCache = await caches . open ( `eh:${ deploymentId } ` ) ;
676+ const earlyHintsRes = await earlyHintsCache . match (
677+ "https://example.com/asset-key-index-with-base.html"
678+ ) ;
679+ if ( ! earlyHintsRes ) {
680+ throw new Error (
681+ "Did not match early hints cache on https://example.com/asset-key-index-with-base.html"
682+ ) ;
683+ }
684+
685+ const linkHeader = earlyHintsRes . headers . get ( "Link" ) ;
686+ // Relative href "module.js" resolved against base "/" → absolute URL
687+ expect ( linkHeader ) . toContain ( "<https://example.com/module.js>" ) ;
688+ expect ( linkHeader ) . not . toContain ( "<module.js" ) ;
689+ } ) ;
690+
691+ test ( "early hints should resolve relative hrefs using URL semantics, not string concat" , async ( {
692+ expect,
693+ } ) => {
694+ const deploymentId = "deployment-" + Math . random ( ) ;
695+ const metadata = createMetadataObject ( { deploymentId } ) as Metadata ;
696+
697+ const findAssetEntryForPath = async ( path : string ) => {
698+ if ( path === "/index.html" ) {
699+ return "asset-key-url-semantics.html" ;
700+ }
701+ return null ;
702+ } ;
703+ const fetchAsset = ( ) =>
704+ Promise . resolve (
705+ Object . assign (
706+ new Response ( `
707+ <!DOCTYPE html>
708+ <html>
709+ <head>
710+ <base href="/subdir/" />
711+ <link rel="preload" href="module.js" as="script" />
712+ <link rel="preload" href="../other.js" as="script" />
713+ </head>
714+ </html>` ) ,
715+ { contentType : "text/html" }
716+ )
717+ ) ;
718+
719+ const { response, spies } = await getTestResponse ( {
720+ request : new Request ( "https://example.com/" ) ,
721+ metadata,
722+ findAssetEntryForPath,
723+ caches,
724+ fetchAsset,
725+ } ) ;
726+ expect ( response . status ) . toBe ( 200 ) ;
727+ await Promise . all ( spies . waitUntil ) ;
728+
729+ const earlyHintsCache = await caches . open ( `eh:${ deploymentId } ` ) ;
730+ const earlyHintsRes = await earlyHintsCache . match (
731+ "https://example.com/asset-key-url-semantics.html"
732+ ) ;
733+ if ( ! earlyHintsRes ) {
734+ throw new Error (
735+ "Did not match early hints cache on https://example.com/asset-key-url-semantics.html"
736+ ) ;
737+ }
738+
739+ const linkHeader = earlyHintsRes . headers . get ( "Link" ) ;
740+ // "module.js" relative to "/subdir/" → "/subdir/module.js"
741+ expect ( linkHeader ) . toContain ( "<https://example.com/subdir/module.js>" ) ;
742+ // "../other.js" relative to "/subdir/" → "/other.js"
743+ expect ( linkHeader ) . toContain ( "<https://example.com/other.js>" ) ;
744+ } ) ;
745+
746+ test ( "early hints should only use the first <base href> element" , async ( {
747+ expect,
748+ } ) => {
749+ const deploymentId = "deployment-" + Math . random ( ) ;
750+ const metadata = createMetadataObject ( { deploymentId } ) as Metadata ;
751+
752+ const findAssetEntryForPath = async ( path : string ) => {
753+ if ( path === "/index.html" ) {
754+ return "asset-key-multi-base.html" ;
755+ }
756+ return null ;
757+ } ;
758+ const fetchAsset = ( ) =>
759+ Promise . resolve (
760+ Object . assign (
761+ new Response ( `
762+ <!DOCTYPE html>
763+ <html>
764+ <head>
765+ <base href="/first/" />
766+ <base href="/second/" />
767+ <link rel="preload" href="module.js" as="script" />
768+ </head>
769+ </html>` ) ,
770+ { contentType : "text/html" }
771+ )
772+ ) ;
773+
774+ const { response, spies } = await getTestResponse ( {
775+ request : new Request ( "https://example.com/" ) ,
776+ metadata,
777+ findAssetEntryForPath,
778+ caches,
779+ fetchAsset,
780+ } ) ;
781+ expect ( response . status ) . toBe ( 200 ) ;
782+ await Promise . all ( spies . waitUntil ) ;
783+
784+ const earlyHintsCache = await caches . open ( `eh:${ deploymentId } ` ) ;
785+ const earlyHintsRes = await earlyHintsCache . match (
786+ "https://example.com/asset-key-multi-base.html"
787+ ) ;
788+ if ( ! earlyHintsRes ) {
789+ throw new Error (
790+ "Did not match early hints cache on https://example.com/asset-key-multi-base.html"
791+ ) ;
792+ }
793+
794+ const linkHeader = earlyHintsRes . headers . get ( "Link" ) ;
795+ // Should use /first/, not /second/
796+ expect ( linkHeader ) . toContain ( "<https://example.com/first/module.js>" ) ;
797+ expect ( linkHeader ) . not . toContain ( "/second/" ) ;
798+ } ) ;
799+
635800 test . todo ( "early hints should temporarily cache failures to parse links" , async ( ) => {
636801 // I couldn't figure out a way to make HTMLRewriter error out
637802 } ) ;
0 commit comments