Feat: show 404 when getStaticPaths doesn't match URL (#2743)

* WIP: return 404 for unmatched getStaticPaths route

* feat: regex on static paths to 404 in dev

* Revert "WIP: return 404 for unmatched getStaticPaths route"

This reverts commit 9c395a2586ca40d44c3ab18edc7ffbc1c4660ed8.

* feat: call getParamsAndProps pre-ssr to catch errs

* fix: remove unused cache regex check

* fix: revert getPattern changes

* fix: remove unused preload props

* fix: log 404 for custom 404 pages

* refactor: rename fixture for clarity

* feat: add getStaticPaths status code tests

* fix: pas rootRelativeUrl to handle subpaths

* fix: update dev-routing tests from 500 -> 404

* refactor: make error handling more explicit

* lint: use typescript no shadow to fix enum issue

* chore: add changeset

* refactor: clarify test names

* refactor: remove variable reassignment

* fix: update dev-routing tests 500 > 404

* refactor: update test file structure

* Fix: revert to old logging

Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>

* Chore: use `const enum` instead

Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>

* chore: format

Co-authored-by: Nate Moore <nate@skypack.dev>
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
This commit is contained in:
Ben Holmes 2022-03-10 13:02:37 -05:00 committed by GitHub
parent 7f99d0de9e
commit a14075e2a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 175 additions and 52 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Fix - show 404 for bad static paths with console message, rather than a 500

View file

@ -14,8 +14,9 @@ module.exports = {
'@typescript-eslint/no-var-requires': 'off', '@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-this-alias': 'off', '@typescript-eslint/no-this-alias': 'off',
'no-console': 'warn', 'no-console': 'warn',
'no-shadow': 'error',
'prefer-const': 'off', 'prefer-const': 'off',
'no-shadow': 'off',
'@typescript-eslint/no-shadow': ['error'],
// 'require-jsdoc': 'error', // re-enable this to enforce JSDoc for all functions // 'require-jsdoc': 'error', // re-enable this to enforce JSDoc for all functions
}, },
}; };

View file

@ -44,12 +44,6 @@ export async function collectPagesData(opts: CollectPagesDataOptions): Promise<C
preload: await ssrPreload({ preload: await ssrPreload({
astroConfig, astroConfig,
filePath: new URL(`./${route.component}`, astroConfig.projectRoot), filePath: new URL(`./${route.component}`, astroConfig.projectRoot),
logging,
mode: 'production',
origin,
pathname: route.pathname,
route,
routeCache,
viteServer, viteServer,
}) })
.then((routes) => { .then((routes) => {
@ -106,12 +100,6 @@ export async function collectPagesData(opts: CollectPagesDataOptions): Promise<C
preload: await ssrPreload({ preload: await ssrPreload({
astroConfig, astroConfig,
filePath: new URL(`./${route.component}`, astroConfig.projectRoot), filePath: new URL(`./${route.component}`, astroConfig.projectRoot),
logging,
mode: 'production',
origin,
pathname: finalPaths[0],
route,
routeCache,
viteServer, viteServer,
}), }),
}; };

View file

@ -15,7 +15,11 @@ interface GetParamsAndPropsOptions {
logging: LogOptions; logging: LogOptions;
} }
async function getParamsAndProps(opts: GetParamsAndPropsOptions): Promise<[Params, Props]> { export const enum GetParamsAndPropsError {
NoMatchingStaticPath,
}
export async function getParamsAndProps(opts: GetParamsAndPropsOptions): Promise<[Params, Props] | GetParamsAndPropsError> {
const { logging, mod, route, routeCache, pathname } = opts; const { logging, mod, route, routeCache, pathname } = opts;
// Handle dynamic routes // Handle dynamic routes
let params: Params = {}; let params: Params = {};
@ -38,7 +42,7 @@ async function getParamsAndProps(opts: GetParamsAndPropsOptions): Promise<[Param
} }
const matchedStaticPath = findPathItemByKey(routeCacheEntry.staticPaths, params); const matchedStaticPath = findPathItemByKey(routeCacheEntry.staticPaths, params);
if (!matchedStaticPath) { if (!matchedStaticPath) {
throw new Error(`[getStaticPaths] route pattern matched, but no matching static path found. (${pathname})`); return GetParamsAndPropsError.NoMatchingStaticPath;
} }
// This is written this way for performance; instead of spreading the props // This is written this way for performance; instead of spreading the props
// which is O(n), create a new object that extends props. // which is O(n), create a new object that extends props.
@ -68,7 +72,7 @@ interface RenderOptions {
export async function render(opts: RenderOptions): Promise<string> { export async function render(opts: RenderOptions): Promise<string> {
const { legacyBuild, links, logging, origin, markdownRender, mod, pathname, scripts, renderers, resolve, route, routeCache, site } = opts; const { legacyBuild, links, logging, origin, markdownRender, mod, pathname, scripts, renderers, resolve, route, routeCache, site } = opts;
const [params, pageProps] = await getParamsAndProps({ const paramsAndPropsRes = await getParamsAndProps({
logging, logging,
mod, mod,
route, route,
@ -76,6 +80,11 @@ export async function render(opts: RenderOptions): Promise<string> {
pathname, pathname,
}); });
if (paramsAndPropsRes === GetParamsAndPropsError.NoMatchingStaticPath) {
throw new Error(`[getStaticPath] route pattern matched, but no matching static path found. (${pathname})`);
}
const [params, pageProps] = paramsAndPropsRes;
// For endpoints, render the content immediately without injecting scripts or styles // For endpoints, render the content immediately without injecting scripts or styles
if (route?.type === 'endpoint') { if (route?.type === 'endpoint') {
return renderEndpoint(mod as any as EndpointHandler, params); return renderEndpoint(mod as any as EndpointHandler, params);

View file

@ -36,7 +36,7 @@ export type ComponentPreload = [Renderer[], ComponentInstance];
const svelteStylesRE = /svelte\?svelte&type=style/; const svelteStylesRE = /svelte\?svelte&type=style/;
export async function preload({ astroConfig, filePath, viteServer }: SSROptions): Promise<ComponentPreload> { export async function preload({ astroConfig, filePath, viteServer }: Pick<SSROptions, 'astroConfig' | 'filePath' | 'viteServer'>): Promise<ComponentPreload> {
// Important: This needs to happen first, in case a renderer provides polyfills. // Important: This needs to happen first, in case a renderer provides polyfills.
const renderers = await resolveRenderers(viteServer, astroConfig); const renderers = await resolveRenderers(viteServer, astroConfig);
// Load the module from the Vite SSR Runtime. // Load the module from the Vite SSR Runtime.
@ -173,9 +173,9 @@ export async function render(renderers: Renderer[], mod: ComponentInstance, ssrO
return content; return content;
} }
export async function ssr(ssrOpts: SSROptions): Promise<string> { export async function ssr(preloadedComponent: ComponentPreload, ssrOpts: SSROptions): Promise<string> {
try { try {
const [renderers, mod] = await preload(ssrOpts); const [renderers, mod] = preloadedComponent;
return await render(renderers, mod, ssrOpts); // note(drew): without "await", errors wont get caught by errorHandler() return await render(renderers, mod, ssrOpts); // note(drew): without "await", errors wont get caught by errorHandler()
} catch (e: unknown) { } catch (e: unknown) {
await errorHandler(e, { viteServer: ssrOpts.viteServer, filePath: ssrOpts.filePath }); await errorHandler(e, { viteServer: ssrOpts.viteServer, filePath: ssrOpts.filePath });

View file

@ -1,11 +1,12 @@
import type * as vite from 'vite'; import type * as vite from 'vite';
import type http from 'http'; import type http from 'http';
import type { AstroConfig, ManifestData } from '../@types/astro'; import type { AstroConfig, ManifestData } from '../@types/astro';
import { info, error, LogOptions } from '../core/logger.js'; import { info, warn, error, LogOptions } from '../core/logger.js';
import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/core.js';
import { createRouteManifest, matchRoute } from '../core/routing/index.js'; import { createRouteManifest, matchRoute } from '../core/routing/index.js';
import stripAnsi from 'strip-ansi'; import stripAnsi from 'strip-ansi';
import { createSafeError } from '../core/util.js'; import { createSafeError } from '../core/util.js';
import { ssr } from '../core/render/dev/index.js'; import { ssr, preload } from '../core/render/dev/index.js';
import * as msg from '../core/messages.js'; import * as msg from '../core/messages.js';
import notFoundTemplate, { subpathNotUsedTemplate } from '../template/4xx.js'; import notFoundTemplate, { subpathNotUsedTemplate } from '../template/4xx.js';
@ -63,6 +64,15 @@ async function handle500Response(viteServer: vite.ViteDevServer, origin: string,
writeHtmlResponse(res, 500, transformedHtml); writeHtmlResponse(res, 500, transformedHtml);
} }
function getCustom404Route(config: AstroConfig, manifest: ManifestData) {
const relPages = config.pages.href.replace(config.projectRoot.href, '');
return manifest.routes.find((r) => r.component === relPages + '404.astro');
}
function log404(logging: LogOptions, pathname: string) {
info(logging, 'serve', msg.req({ url: pathname, statusCode: 404 }));
}
/** The main logic to route dev server requests to pages in Astro. */ /** The main logic to route dev server requests to pages in Astro. */
async function handleRequest( async function handleRequest(
routeCache: RouteCache, routeCache: RouteCache,
@ -79,37 +89,73 @@ async function handleRequest(
const origin = `${viteServer.config.server.https ? 'https' : 'http'}://${req.headers.host}`; const origin = `${viteServer.config.server.https ? 'https' : 'http'}://${req.headers.host}`;
const pathname = decodeURI(new URL(origin + req.url).pathname); const pathname = decodeURI(new URL(origin + req.url).pathname);
const rootRelativeUrl = pathname.substring(devRoot.length - 1); const rootRelativeUrl = pathname.substring(devRoot.length - 1);
try { try {
if (!pathname.startsWith(devRoot)) { if (!pathname.startsWith(devRoot)) {
info(logging, 'serve', msg.req({ url: pathname, statusCode: 404 })); log404(logging, pathname);
return handle404Response(origin, config, req, res); return handle404Response(origin, config, req, res);
} }
// Attempt to match the URL to a valid page route. // Attempt to match the URL to a valid page route.
// If that fails, switch the response to a 404 response. // If that fails, switch the response to a 404 response.
let route = matchRoute(rootRelativeUrl, manifest); let route = matchRoute(rootRelativeUrl, manifest);
const statusCode = route ? 200 : 404; const statusCode = route ? 200 : 404;
// If no match found, lookup a custom 404 page to render, if one exists.
if (!route) { if (!route) {
const relPages = config.pages.href.replace(config.projectRoot.href, ''); log404(logging, pathname);
route = manifest.routes.find((r) => r.component === relPages + '404.astro'); const custom404 = getCustom404Route(config, manifest);
if (custom404) {
route = custom404;
} else {
return handle404Response(origin, config, req, res);
}
} }
// If still no match is found, respond with a generic 404 page.
if (!route) { const filePath = new URL(`./${route.component}`, config.projectRoot);
info(logging, 'serve', msg.req({ url: pathname, statusCode: 404 })); const preloadedComponent = await preload({ astroConfig: config, filePath, viteServer });
handle404Response(origin, config, req, res); const [, mod] = preloadedComponent;
return; // attempt to get static paths
// if this fails, we have a bad URL match!
const paramsAndPropsRes = await getParamsAndProps({
mod,
route,
routeCache,
pathname: rootRelativeUrl,
logging,
});
if (paramsAndPropsRes === GetParamsAndPropsError.NoMatchingStaticPath) {
warn(logging, 'getStaticPaths', `Route pattern matched, but no matching static path found. (${pathname})`);
log404(logging, pathname);
const routeCustom404 = getCustom404Route(config, manifest);
if (routeCustom404) {
const filePathCustom404 = new URL(`./${routeCustom404.component}`, config.projectRoot);
const preloadedCompCustom404 = await preload({ astroConfig: config, filePath: filePathCustom404, viteServer });
const html = await ssr(preloadedCompCustom404, {
astroConfig: config,
filePath: filePathCustom404,
logging,
mode: 'development',
origin,
pathname: rootRelativeUrl,
route: routeCustom404,
routeCache,
viteServer,
});
return writeHtmlResponse(res, statusCode, html);
} else {
return handle404Response(origin, config, req, res);
}
} }
// Route successfully matched! Render it.
const html = await ssr({ const html = await ssr(preloadedComponent, {
astroConfig: config, astroConfig: config,
filePath: new URL(`./${route.component}`, config.projectRoot), filePath,
logging, logging,
mode: 'development', mode: 'development',
origin, origin,
pathname: rootRelativeUrl, pathname: rootRelativeUrl,
route, route,
routeCache: routeCache, routeCache,
viteServer: viteServer, viteServer,
}); });
writeHtmlResponse(res, statusCode, html); writeHtmlResponse(res, statusCode, html);
} catch (_err: any) { } catch (_err: any) {

View file

@ -1,11 +1,9 @@
import { expect } from 'chai'; import { expect } from 'chai';
import { loadFixture } from './test-utils.js'; import { loadFixture } from './test-utils.js';
describe('getStaticPaths()', () => { describe('getStaticPaths - build calls', () => {
let fixture;
before(async () => { before(async () => {
fixture = await loadFixture({ const fixture = await loadFixture({
projectRoot: './fixtures/astro-get-static-paths/', projectRoot: './fixtures/astro-get-static-paths/',
buildOptions: { buildOptions: {
site: 'https://mysite.dev/blog/', site: 'https://mysite.dev/blog/',
@ -14,9 +12,42 @@ describe('getStaticPaths()', () => {
}); });
await fixture.build(); await fixture.build();
}); });
it('is only called once during build', () => { it('is only called once during build', () => {
// useless expect; if build() throws in setup then this test fails // useless expect; if build() throws in setup then this test fails
expect(true).to.equal(true); expect(true).to.equal(true);
}); });
}); });
describe('getStaticPaths - 404 behavior', () => {
let fixture;
let devServer;
before(async () => {
fixture = await loadFixture({ projectRoot: './fixtures/astro-get-static-paths/' });
devServer = await fixture.startDevServer();
});
after(async () => {
devServer && devServer.stop();
});
it('resolves 200 on matching static path - named params', async () => {
const res = await fixture.fetch('/pizza/provolone-sausage');
expect(res.status).to.equal(200);
});
it('resolves 404 on pattern match without static path - named params', async () => {
const res = await fixture.fetch('/pizza/provolone-pineapple');
expect(res.status).to.equal(404);
});
it('resolves 200 on matching static path - rest params', async () => {
const res = await fixture.fetch('/pizza/grimaldis/new-york');
expect(res.status).to.equal(200);
});
it('resolves 404 on pattern match without static path - rest params', async () => {
const res = await fixture.fetch('/pizza/pizza-hut');
expect(res.status).to.equal(404);
});
});

View file

@ -38,9 +38,9 @@ describe('Development Routing', () => {
expect(response.status).to.equal(200); expect(response.status).to.equal(200);
}); });
it('500 when loading invalid dynamic route', async () => { it('404 when loading invalid dynamic route', async () => {
const response = await fixture.fetch('/2'); const response = await fixture.fetch('/2');
expect(response.status).to.equal(500); expect(response.status).to.equal(404);
}); });
}); });
@ -74,9 +74,9 @@ describe('Development Routing', () => {
expect(response.status).to.equal(200); expect(response.status).to.equal(200);
}); });
it('500 when loading invalid dynamic route', async () => { it('404 when loading invalid dynamic route', async () => {
const response = await fixture.fetch('/2'); const response = await fixture.fetch('/2');
expect(response.status).to.equal(500); expect(response.status).to.equal(404);
}); });
}); });
@ -120,9 +120,9 @@ describe('Development Routing', () => {
expect(response.status).to.equal(200); expect(response.status).to.equal(200);
}); });
it('500 when loading invalid dynamic route', async () => { it('404 when loading invalid dynamic route', async () => {
const response = await fixture.fetch('/blog/2/'); const response = await fixture.fetch('/blog/2/');
expect(response.status).to.equal(500); expect(response.status).to.equal(404);
}); });
}); });
@ -166,9 +166,9 @@ describe('Development Routing', () => {
expect(response.status).to.equal(200); expect(response.status).to.equal(200);
}); });
it('500 when loading invalid dynamic route', async () => { it('404 when loading invalid dynamic route', async () => {
const response = await fixture.fetch('/blog/2/'); const response = await fixture.fetch('/blog/2/');
expect(response.status).to.equal(500); expect(response.status).to.equal(404);
}); });
}); });

View file

@ -5,9 +5,9 @@ export function getStaticPaths({ paginate }) {
} }
globalThis.isCalledOnce = true; globalThis.isCalledOnce = true;
return [ return [
{params: {test: 'a'}}, {params: {calledTwiceTest: 'a'}},
{params: {test: 'b'}}, {params: {calledTwiceTest: 'b'}},
{params: {test: 'c'}}, {params: {calledTwiceTest: 'c'}},
]; ];
} }
const { params } = Astro.request; const { params } = Astro.request;
@ -15,7 +15,7 @@ const { params } = Astro.request;
<html> <html>
<head> <head>
<title>Page {params.test}</title> <title>Page {params.calledTwiceTest}</title>
</head> </head>
<body></body> <body></body>
</html> </html>

View file

@ -0,0 +1,22 @@
---
export function getStaticPaths() {
return [{
params: { pizza: 'papa-johns' },
}, {
params: { pizza: 'dominos' },
}, {
params: { pizza: 'grimaldis/new-york' },
}]
}
const { pizza } = Astro.request.params
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{pizza ?? 'The landing page'}</title>
</head>
<body>
<h1>Welcome to {pizza ?? 'The landing page'}</h1>
</body>
</html>

View file

@ -0,0 +1,21 @@
---
export function getStaticPaths() {
return [{
params: { cheese: 'mozzarella', topping: 'pepperoni' },
}, {
params: { cheese: 'provolone', topping: 'sausage' },
}]
}
const { cheese, topping } = Astro.request.params
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{cheese}</title>
</head>
<body>
<h1>🍕 It's pizza time</h1>
<p>{cheese}-{topping}</p>
</body>
</html>