diff --git a/.changeset/fix-endpoint-trailing-slash-static-build.md b/.changeset/fix-endpoint-trailing-slash-static-build.md new file mode 100644 index 000000000000..c0b15bca3c62 --- /dev/null +++ b/.changeset/fix-endpoint-trailing-slash-static-build.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes `trailingSlash: "always"` producing redirect HTML instead of the actual response for extensionless endpoints during static builds diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 398590965899..4a7b94f222cb 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -10,7 +10,9 @@ import { prepareAssetsGenerationEnv, } from '../../assets/build/generate.js'; import { + appendForwardSlash, collapseDuplicateTrailingSlashes, + hasFileExtension, joinPaths, removeLeadingForwardSlash, removeTrailingForwardSlash, @@ -618,7 +620,13 @@ function getUrlForPath( } } else if (routeType === 'endpoint') { const buildPathRelative = removeLeadingForwardSlash(pathname); - buildPathname = joinPaths(base, buildPathRelative); + let endpointPathname = joinPaths(base, buildPathRelative); + if (trailingSlash === 'always' && !hasFileExtension(pathname)) { + endpointPathname = appendForwardSlash(endpointPathname); + } else if (trailingSlash === 'never') { + endpointPathname = removeTrailingForwardSlash(endpointPathname); + } + buildPathname = endpointPathname; } else { const buildPathRelative = removeTrailingForwardSlash(removeLeadingForwardSlash(pathname)) + ending; diff --git a/packages/astro/test/units/build/generate.test.js b/packages/astro/test/units/build/generate.test.js index 1d9df331b17f..826a14480868 100644 --- a/packages/astro/test/units/build/generate.test.js +++ b/packages/astro/test/units/build/generate.test.js @@ -211,6 +211,44 @@ describe('renderPath()', () => { assert.ok(errors.length > 0, 'error should be logged before re-throwing'); }); + // Regression: #16185 — extensionless endpoints with trailingSlash: 'always' + // must have a trailing slash in the prerender request URL so that BaseApp.render() + // does not emit a redirect instead of the endpoint's actual response. + it('sends a trailing-slash request URL for extensionless endpoints when trailingSlash is always', async () => { + const endpointOptions = await createStaticBuildOptions({ + inlineConfig: { trailingSlash: 'always' }, + }); + + let capturedUrl; + const prerenderer = createMockPrerenderer({ '/demo': 'hello' }); + const originalRender = prerenderer.render.bind(prerenderer); + prerenderer.render = async (request, opts) => { + capturedUrl = new URL(request.url); + return originalRender(request, opts); + }; + + const route = createRouteData({ + route: '/demo', + type: 'endpoint', + trailingSlash: 'always', + component: 'src/pages/demo.ts', + }); + + await renderPath({ + prerenderer, + pathname: '/demo', + route, + options: endpointOptions, + logger: endpointOptions.logger, + }); + + assert.ok(capturedUrl, 'prerenderer.render should have been called'); + assert.ok( + capturedUrl.pathname.endsWith('/'), + `expected trailing slash in request URL pathname, got "${capturedUrl.pathname}"`, + ); + }); + it('writes the rendered body to the filesystem (integration smoke)', async () => { const html = 'Written to disk'; const prerenderer = createMockPrerenderer({ '/disk-test': html });