diff --git a/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts b/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts index f104bcaa0025..57679680ddb6 100644 --- a/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts +++ b/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts @@ -11,7 +11,7 @@ import { executeOnceAndFetch } from '../execute-fetch'; import { describeServeBuilder } from '../jasmine-helpers'; import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup'; -describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget, isVite) => { +describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget) => { const javascriptFileContent = "import {foo} from 'unresolved'; /* a comment */const foo = `bar`;\n\n\n"; @@ -95,31 +95,51 @@ describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupT expect(await response?.text()).toContain('

Login page

'); }); - (isVite ? it : xit)( - `should return the asset that matches '.html' when path has no trailing '/'`, - async () => { - await harness.writeFile( - 'src/login/new.html', - '

Login page

', - ); - - setupTarget(harness, { - assets: ['src/login'], - optimization: { - scripts: true, - }, - }); - - harness.useTarget('serve', { - ...BASE_OPTIONS, - }); - - const { result, response } = await executeOnceAndFetch(harness, 'login/new'); - - expect(result?.success).toBeTrue(); - expect(await response?.status).toBe(200); - expect(await response?.text()).toContain('

Login page

'); - }, - ); + it(`should return the asset that matches '.html' when path has no trailing '/'`, async () => { + await harness.writeFile('src/login/new.html', '

Login page

'); + + setupTarget(harness, { + assets: ['src/login'], + optimization: { + scripts: true, + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'login/new'); + + expect(result?.success).toBeTrue(); + expect(await response?.status).toBe(200); + expect(await response?.text()).toContain('

Login page

'); + }); + + it(`should return a redirect when an asset directory is accessed without a trailing '/'`, async () => { + await harness.writeFile( + 'src/login/index.html', + '

Login page

', + ); + + setupTarget(harness, { + assets: ['src/login'], + optimization: { + scripts: true, + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'login', { + request: { redirect: 'manual' }, + }); + + expect(result?.success).toBeTrue(); + expect(await response?.status).toBe(301); + expect(await response?.headers.get('Location')).toBe('/login/'); + }); }); }); diff --git a/packages/angular/build/src/tools/vite/angular-memory-plugin.ts b/packages/angular/build/src/tools/vite/angular-memory-plugin.ts index e991d0395406..9354d200ab99 100644 --- a/packages/angular/build/src/tools/vite/angular-memory-plugin.ts +++ b/packages/angular/build/src/tools/vite/angular-memory-plugin.ts @@ -126,6 +126,7 @@ export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions): // The base of the URL is unused but required to parse the URL. const pathname = pathnameWithoutBasePath(req.url, server.config.base); const extension = extname(pathname); + const pathnameHasTrailingSlash = pathname[pathname.length - 1] === '/'; // Rewrite all build assets to a vite raw fs URL const assetSourcePath = assets.get(pathname); @@ -146,12 +147,11 @@ export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions): // HTML fallbacking // This matches what happens in the vite html fallback middleware. // ref: https://github.com/vitejs/vite/blob/main/packages/vite/src/node/server/middlewares/htmlFallback.ts#L9 - const htmlAssetSourcePath = - pathname[pathname.length - 1] === '/' - ? // Trailing slash check for `index.html`. - assets.get(pathname + 'index.html') - : // Non-trailing slash check for fallback `.html` - assets.get(pathname + '.html'); + const htmlAssetSourcePath = pathnameHasTrailingSlash + ? // Trailing slash check for `index.html`. + assets.get(pathname + 'index.html') + : // Non-trailing slash check for fallback `.html` + assets.get(pathname + '.html'); if (htmlAssetSourcePath) { req.url = `${server.config.base}@fs/${encodeURI(htmlAssetSourcePath)}`; @@ -180,6 +180,19 @@ export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions): } } + // If the path has no trailing slash and it matches a servable directory redirect to the same path with slash. + // This matches the default express static behaviour. + // See: https://github.com/expressjs/serve-static/blob/89fc94567fae632718a2157206c52654680e9d01/index.js#L182 + if (!pathnameHasTrailingSlash) { + for (const assetPath of assets.keys()) { + if (pathname === assetPath.substring(0, assetPath.lastIndexOf('/'))) { + redirect(res, req.url + '/'); + + return; + } + } + } + next(); }); @@ -362,3 +375,20 @@ function lookupMimeTypeFromRequest(url: string): string | undefined { return extension && lookupMimeType(extension); } + +function redirect(res: ServerResponse, location: string): void { + res.statusCode = 301; + res.setHeader('Content-Type', 'text/html'); + res.setHeader('Location', location); + res.end(` + + + + + Redirecting + + +
Redirecting to ${location}
+ + `); +}