@@ -4075,6 +4075,9 @@ const jsenvPluginFsRedirection = ({
40754075 const { search, hash } = urlObject ;
40764076 urlObject . search = "" ;
40774077 urlObject . hash = "" ;
4078+ // must be read before applyFsStatEffectsOnUrlObject which forces the
4079+ // trailing slash on directories
4080+ const specifierUsesTrailingSlash = urlObject . pathname . endsWith ( "/" ) ;
40784081 applyFsStatEffectsOnUrlObject ( urlObject , fsStat ) ;
40794082 const shouldApplyFilesystemMagicResolution =
40804083 reference . type === "js_import" ;
@@ -4104,20 +4107,16 @@ const jsenvPluginFsRedirection = ({
41044107 // url has an extension, we assume it's a file request -> let 404 happen
41054108 return null ;
41064109 }
4107- const { requestedUrl, rootDirectoryUrl, mainFilePath } =
4108- reference . ownerUrlInfo . context ;
4109- if ( ! requestedUrl ) {
4110- // the SPA fallback answers a request; during build there is none
4110+ if ( specifierUsesTrailingSlash ) {
4111+ // the trailing slash asks for a directory and there is none here
4112+ // -> let 404 happen (same reasoning as the extension above)
41114113 return null ;
41124114 }
4113- const closestHtmlRootFile = getClosestHtmlRootFile (
4114- requestedUrl ,
4115- rootDirectoryUrl ,
4116- ) ;
4117- if ( closestHtmlRootFile ) {
4118- return closestHtmlRootFile ;
4115+ const spaFallbackUrl = getSpaFallbackUrl ( reference ) ;
4116+ if ( spaFallbackUrl ) {
4117+ return spaFallbackUrl ;
41194118 }
4120- return new URL ( mainFilePath , rootDirectoryUrl ) ;
4119+ return null ;
41214120 }
41224121 if ( fsStat . isDirectory ( ) ) {
41234122 // When requesting a directory, check if we have an HTML entry file for that directory
@@ -4126,6 +4125,21 @@ const jsenvPluginFsRedirection = ({
41264125 reference . fsStat = readEntryStatSync ( directoryEntryFileUrl ) ;
41274126 return directoryEntryFileUrl ;
41284127 }
4128+ if ( ! specifierUsesTrailingSlash ) {
4129+ // the trailing slash is what tells a directory apart from a route:
4130+ // "/join/" is the directory, "/join" is a route owned by the SPA
4131+ // even when "join/" exists in the source files.
4132+ // Without this a source directory would shadow the route having
4133+ // the same name and the SPA would be unreachable in dev while
4134+ // being perfectly fine once built
4135+ const spaFallbackUrl = getSpaFallbackUrl ( reference ) ;
4136+ if ( spaFallbackUrl ) {
4137+ reference . fsStat = readEntryStatSync ( spaFallbackUrl , {
4138+ nullIfNotFound : true ,
4139+ } ) ;
4140+ return spaFallbackUrl ;
4141+ }
4142+ }
41294143 }
41304144 }
41314145 if ( ! fsStat ) {
@@ -4188,6 +4202,22 @@ const getDirectoryEntryFileUrl = (directoryUrl) => {
41884202 }
41894203 return null ;
41904204} ;
4205+ const getSpaFallbackUrl = ( reference ) => {
4206+ const { requestedUrl, rootDirectoryUrl, mainFilePath } =
4207+ reference . ownerUrlInfo . context ;
4208+ if ( ! requestedUrl ) {
4209+ // the SPA fallback answers a request; during build there is none
4210+ return null ;
4211+ }
4212+ const closestHtmlRootFile = getClosestHtmlRootFile (
4213+ requestedUrl ,
4214+ rootDirectoryUrl ,
4215+ ) ;
4216+ if ( closestHtmlRootFile ) {
4217+ return closestHtmlRootFile ;
4218+ }
4219+ return String ( new URL ( mainFilePath , rootDirectoryUrl ) ) ;
4220+ } ;
41914221const getClosestHtmlRootFile = ( requestedUrl , serverRootDirectoryUrl ) => {
41924222 let directoryUrl = new URL ( "./" , requestedUrl ) ;
41934223 while ( true ) {
@@ -4995,7 +5025,9 @@ const jsenvPluginDirectoryReferenceEffect = (
49955025 } else if ( reference . specifierPathname . endsWith ( "./" ) ) ; else {
49965026 const directoryRelativeUrl = urlToRelativeUrl (
49975027 reference . url ,
4998- reference . ownerUrlInfo . originalUrl ,
5028+ // the root url info has no originalUrl; it owns the reference
5029+ // created for an incoming request ("http_request")
5030+ reference . ownerUrlInfo . originalUrl || reference . ownerUrlInfo . url ,
49995031 ) ;
50005032 reference . filenameHint = directoryRelativeUrl ;
50015033 }
@@ -11421,6 +11453,14 @@ const devServerPluginServeSourceFiles = ({
1142111453 ! inlineParentUrlInfo &&
1142211454 ! urlInfo . response &&
1142311455 urlInfo . content !== undefined &&
11456+ // content can be defined while a cook is still in flight (a file
11457+ // watcher invalidation re-cooking in the background, for
11458+ // instance): at that point it holds the raw fetched content,
11459+ // transformations not applied yet. Serving that would send an
11460+ // html without any of the injected scripts. Only finalized
11461+ // content is a complete response; anything else must go through
11462+ // cook() below, which joins the pending cook (see debounceCook).
11463+ urlInfo . contentFinalized &&
1142411464 ! cacheIsDisabledInResponseHeader ( urlInfo ) &&
1142511465 // a "?hot" request exists to bypass every cache, this one
1142611466 // included: it must be cooked, because cooking is what rewrites
@@ -11465,7 +11505,10 @@ const devServerPluginServeSourceFiles = ({
1146511505 }
1146611506 response = {
1146711507 url : reference . url ,
11468- status : 200 ,
11508+ // a plugin can cook a complete response body for an url that is
11509+ // not a 200: the directory listing does this to answer a request
11510+ // for a file that does not exist with the explorer page
11511+ status : urlInfo . status ,
1146911512 headers : {
1147011513 // when we send eTag to the client the next request to the server
1147111514 // will send etag in request headers.
0 commit comments