Skip to content

Commit 2d51e86

Browse files
committed
fix(@angular/build): redirect to path with trailing slash for asset directories
Prior to this commit, accessing a static asset directory without a trailing slash resulted in a 404 error. With this change, we now redirect to the path with a trailing slash, aligning with the behavior of express static. Closes #27949 (cherry picked from commit 9a1c059)
1 parent 67bf901 commit 2d51e86

File tree

2 files changed

+83
-33
lines changed

2 files changed

+83
-33
lines changed

packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts

Lines changed: 47 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { executeOnceAndFetch } from '../execute-fetch';
1111
import { describeServeBuilder } from '../jasmine-helpers';
1212
import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup';
1313

14-
describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget, isVite) => {
14+
describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget) => {
1515
const javascriptFileContent =
1616
"import {foo} from 'unresolved'; /* a comment */const foo = `bar`;\n\n\n";
1717

@@ -95,31 +95,51 @@ describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupT
9595
expect(await response?.text()).toContain('<h1>Login page</h1>');
9696
});
9797

98-
(isVite ? it : xit)(
99-
`should return the asset that matches '.html' when path has no trailing '/'`,
100-
async () => {
101-
await harness.writeFile(
102-
'src/login/new.html',
103-
'<html><body><h1>Login page</h1></body><html>',
104-
);
105-
106-
setupTarget(harness, {
107-
assets: ['src/login'],
108-
optimization: {
109-
scripts: true,
110-
},
111-
});
112-
113-
harness.useTarget('serve', {
114-
...BASE_OPTIONS,
115-
});
116-
117-
const { result, response } = await executeOnceAndFetch(harness, 'login/new');
118-
119-
expect(result?.success).toBeTrue();
120-
expect(await response?.status).toBe(200);
121-
expect(await response?.text()).toContain('<h1>Login page</h1>');
122-
},
123-
);
98+
it(`should return the asset that matches '.html' when path has no trailing '/'`, async () => {
99+
await harness.writeFile('src/login/new.html', '<html><body><h1>Login page</h1></body><html>');
100+
101+
setupTarget(harness, {
102+
assets: ['src/login'],
103+
optimization: {
104+
scripts: true,
105+
},
106+
});
107+
108+
harness.useTarget('serve', {
109+
...BASE_OPTIONS,
110+
});
111+
112+
const { result, response } = await executeOnceAndFetch(harness, 'login/new');
113+
114+
expect(result?.success).toBeTrue();
115+
expect(await response?.status).toBe(200);
116+
expect(await response?.text()).toContain('<h1>Login page</h1>');
117+
});
118+
119+
it(`should return a redirect when an asset directory is accessed without a trailing '/'`, async () => {
120+
await harness.writeFile(
121+
'src/login/index.html',
122+
'<html><body><h1>Login page</h1></body><html>',
123+
);
124+
125+
setupTarget(harness, {
126+
assets: ['src/login'],
127+
optimization: {
128+
scripts: true,
129+
},
130+
});
131+
132+
harness.useTarget('serve', {
133+
...BASE_OPTIONS,
134+
});
135+
136+
const { result, response } = await executeOnceAndFetch(harness, 'login', {
137+
request: { redirect: 'manual' },
138+
});
139+
140+
expect(result?.success).toBeTrue();
141+
expect(await response?.status).toBe(301);
142+
expect(await response?.headers.get('Location')).toBe('/login/');
143+
});
124144
});
125145
});

packages/angular/build/src/tools/vite/angular-memory-plugin.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions):
126126
// The base of the URL is unused but required to parse the URL.
127127
const pathname = pathnameWithoutBasePath(req.url, server.config.base);
128128
const extension = extname(pathname);
129+
const pathnameHasTrailingSlash = pathname[pathname.length - 1] === '/';
129130

130131
// Rewrite all build assets to a vite raw fs URL
131132
const assetSourcePath = assets.get(pathname);
@@ -146,12 +147,11 @@ export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions):
146147
// HTML fallbacking
147148
// This matches what happens in the vite html fallback middleware.
148149
// ref: https://github.com/vitejs/vite/blob/main/packages/vite/src/node/server/middlewares/htmlFallback.ts#L9
149-
const htmlAssetSourcePath =
150-
pathname[pathname.length - 1] === '/'
151-
? // Trailing slash check for `index.html`.
152-
assets.get(pathname + 'index.html')
153-
: // Non-trailing slash check for fallback `.html`
154-
assets.get(pathname + '.html');
150+
const htmlAssetSourcePath = pathnameHasTrailingSlash
151+
? // Trailing slash check for `index.html`.
152+
assets.get(pathname + 'index.html')
153+
: // Non-trailing slash check for fallback `.html`
154+
assets.get(pathname + '.html');
155155

156156
if (htmlAssetSourcePath) {
157157
req.url = `${server.config.base}@fs/${encodeURI(htmlAssetSourcePath)}`;
@@ -180,6 +180,19 @@ export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions):
180180
}
181181
}
182182

183+
// If the path has no trailing slash and it matches a servable directory redirect to the same path with slash.
184+
// This matches the default express static behaviour.
185+
// See: https://github.com/expressjs/serve-static/blob/89fc94567fae632718a2157206c52654680e9d01/index.js#L182
186+
if (!pathnameHasTrailingSlash) {
187+
for (const assetPath of assets.keys()) {
188+
if (pathname === assetPath.substring(0, assetPath.lastIndexOf('/'))) {
189+
redirect(res, req.url + '/');
190+
191+
return;
192+
}
193+
}
194+
}
195+
183196
next();
184197
});
185198

@@ -362,3 +375,20 @@ function lookupMimeTypeFromRequest(url: string): string | undefined {
362375

363376
return extension && lookupMimeType(extension);
364377
}
378+
379+
function redirect(res: ServerResponse, location: string): void {
380+
res.statusCode = 301;
381+
res.setHeader('Content-Type', 'text/html');
382+
res.setHeader('Location', location);
383+
res.end(`
384+
<!DOCTYPE html>
385+
<html lang="en">
386+
<head>
387+
<meta charset="utf-8">
388+
<title>Redirecting</title>
389+
</head>
390+
<body>
391+
<pre>Redirecting to <a href="${location}">${location}</a></pre>
392+
</body>
393+
</html>`);
394+
}

0 commit comments

Comments
 (0)