diff options
-rw-r--r-- | .changeset/spotty-turtles-complain.md | 5 | ||||
-rw-r--r-- | packages/astro/src/core/dev/template/4xx.ts | 87 | ||||
-rw-r--r-- | packages/astro/src/core/path.ts | 4 | ||||
-rw-r--r-- | packages/astro/src/core/preview/index.ts | 166 | ||||
-rw-r--r-- | packages/astro/test/preview-routing.test.js | 32 |
5 files changed, 150 insertions, 144 deletions
diff --git a/.changeset/spotty-turtles-complain.md b/.changeset/spotty-turtles-complain.md new file mode 100644 index 000000000..021eac91f --- /dev/null +++ b/.changeset/spotty-turtles-complain.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fix preview issues triggered by pageUrlFormat & trailingSlash options diff --git a/packages/astro/src/core/dev/template/4xx.ts b/packages/astro/src/core/dev/template/4xx.ts index af63feea7..7f90b6e16 100644 --- a/packages/astro/src/core/dev/template/4xx.ts +++ b/packages/astro/src/core/dev/template/4xx.ts @@ -17,45 +17,45 @@ interface ErrorTemplateOptions { /** Display all errors */ export default function template({ title, pathname, statusCode = 404, tabTitle, body }: ErrorTemplateOptions): string { return `<!doctype html> - <html lang="en"> - <head> - <meta charset="UTF-8"> - <title>${tabTitle}</title> - <style> - ${baseCSS} +<html lang="en"> + <head> + <meta charset="UTF-8"> + <title>${tabTitle}</title> + <style> + ${baseCSS} - .center { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - height: 100vh; - width: 100vw; - } + .center { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100vh; + width: 100vw; + } - .statusCode { - color: var(--orange); - } + .statusCode { + color: var(--orange); + } - .astro { - height: 120px; - width: 120px; - } - </style> - </head> - <body> - <main class="center"> - <svg class="astro" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M163.008 18.929c1.944 2.413 2.935 5.67 4.917 12.181l43.309 142.27a180.277 180.277 0 00-51.778-17.53l-28.198-95.29a3.67 3.67 0 00-7.042.01l-27.857 95.232a180.225 180.225 0 00-52.01 17.557l43.52-142.281c1.99-6.502 2.983-9.752 4.927-12.16a15.999 15.999 0 016.484-4.798c2.872-1.154 6.271-1.154 13.07-1.154h31.085c6.807 0 10.211 0 13.086 1.157a16.004 16.004 0 016.487 4.806z" fill="white"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M168.19 180.151c-7.139 6.105-21.39 10.268-37.804 10.268-20.147 0-37.033-6.272-41.513-14.707-1.602 4.835-1.961 10.367-1.961 13.902 0 0-1.056 17.355 11.015 29.426 0-6.268 5.081-11.349 11.349-11.349 10.743 0 10.731 9.373 10.721 16.977v.679c0 11.542 7.054 21.436 17.086 25.606a23.27 23.27 0 01-2.339-10.2c0-11.008 6.463-15.107 13.974-19.87 5.976-3.79 12.616-8.001 17.192-16.449a31.024 31.024 0 003.743-14.82c0-3.299-.513-6.479-1.463-9.463z" fill="#ff5d01"></path></svg> - <h1>${statusCode ? `<span class="statusCode">${statusCode}: </span> ` : ''}<span class="statusMessage">${title}</span></h1> - ${ - body || - ` - <pre>Path: ${encode(pathname)}</pre> - ` - } - </main> - </body> - </html>`; + .astro { + height: 120px; + width: 120px; + } + </style> + </head> + <body> + <main class="center"> + <svg class="astro" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M163.008 18.929c1.944 2.413 2.935 5.67 4.917 12.181l43.309 142.27a180.277 180.277 0 00-51.778-17.53l-28.198-95.29a3.67 3.67 0 00-7.042.01l-27.857 95.232a180.225 180.225 0 00-52.01 17.557l43.52-142.281c1.99-6.502 2.983-9.752 4.927-12.16a15.999 15.999 0 016.484-4.798c2.872-1.154 6.271-1.154 13.07-1.154h31.085c6.807 0 10.211 0 13.086 1.157a16.004 16.004 0 016.487 4.806z" fill="white"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M168.19 180.151c-7.139 6.105-21.39 10.268-37.804 10.268-20.147 0-37.033-6.272-41.513-14.707-1.602 4.835-1.961 10.367-1.961 13.902 0 0-1.056 17.355 11.015 29.426 0-6.268 5.081-11.349 11.349-11.349 10.743 0 10.731 9.373 10.721 16.977v.679c0 11.542 7.054 21.436 17.086 25.606a23.27 23.27 0 01-2.339-10.2c0-11.008 6.463-15.107 13.974-19.87 5.976-3.79 12.616-8.001 17.192-16.449a31.024 31.024 0 003.743-14.82c0-3.299-.513-6.479-1.463-9.463z" fill="#ff5d01"></path></svg> + <h1>${statusCode ? `<span class="statusCode">${statusCode}: </span> ` : ''}<span class="statusMessage">${title}</span></h1> + ${ + body || + ` + <pre>Path: ${encode(pathname)}</pre> + ` + } + </main> + </body> +</html>`; } export function subpathNotUsedTemplate(base: string, pathname: string) { @@ -64,9 +64,16 @@ export function subpathNotUsedTemplate(base: string, pathname: string) { statusCode: 404, title: 'Not found', tabTitle: '404: Not Found', - body: ` - <p>In your <code>buildOptions.site</code> you have your base path set to <a href="${base}">${base}</a>. Do you want to go there instead?</p> - <p>Come to our <a href="https://astro.build/chat">Discord</a> if you need help.</p> - `, + body: `<p>In your <code>buildOptions.site</code> you have your base path set to <a href="${base}">${base}</a>. Do you want to go there instead?</p> +<p>Come to our <a href="https://astro.build/chat">Discord</a> if you need help.</p>`, + }); +} + +export function notFoundTemplate(pathname: string, message = 'Not found') { + return template({ + pathname, + statusCode: 404, + title: message, + tabTitle: `404: ${message}`, }); } diff --git a/packages/astro/src/core/path.ts b/packages/astro/src/core/path.ts index 2d6f62dff..9f66baf28 100644 --- a/packages/astro/src/core/path.ts +++ b/packages/astro/src/core/path.ts @@ -34,3 +34,7 @@ export function prependDotSlash(path: string) { return './' + path; } + +export function trimSlashes(path: string) { + return path.replace(/^\/|\/$/g, ''); +} diff --git a/packages/astro/src/core/preview/index.ts b/packages/astro/src/core/preview/index.ts index aa742b716..60296bc73 100644 --- a/packages/astro/src/core/preview/index.ts +++ b/packages/astro/src/core/preview/index.ts @@ -1,16 +1,16 @@ import type { AstroConfig } from '../../@types/astro'; import type { LogOptions } from '../logger'; +import type { Stats } from 'fs'; import http from 'http'; import { performance } from 'perf_hooks'; import send from 'send'; import { fileURLToPath } from 'url'; +import fs from 'fs'; import * as msg from '../dev/messages.js'; import { error, info } from '../logger.js'; -import { subpathNotUsedTemplate, default as template } from '../dev/template/4xx.js'; -import { prependForwardSlash } from '../path.js'; -import * as npath from 'path'; -import * as fs from 'fs'; +import { subpathNotUsedTemplate, notFoundTemplate, default as template } from '../dev/template/4xx.js'; +import { appendForwardSlash, trimSlashes } from '../path.js'; interface PreviewOptions { logging: LogOptions; @@ -23,100 +23,94 @@ export interface PreviewServer { stop(): Promise<void>; } -type SendStreamWithPath = send.SendStream & { path: string }; - -function removeBase(base: string, pathname: string) { - if (base === pathname) { - return '/'; - } - let requrl = pathname.substr(base.length); - return prependForwardSlash(requrl); -} - /** The primary dev action */ export default async function preview(config: AstroConfig, { logging }: PreviewOptions): Promise<PreviewServer> { const startServerTime = performance.now(); - const base = config.buildOptions.site ? new URL(config.buildOptions.site).pathname : '/'; + const pageUrlFormat = config.buildOptions.pageUrlFormat; + const trailingSlash = config.devOptions.trailingSlash; + const forceTrailingSlash = trailingSlash === 'always'; + const blockTrailingSlash = trailingSlash === 'never'; + + /** Default file served from a directory. */ + const defaultFile = 'index.html'; + + const defaultOrigin = 'http://localhost'; + + const sendOptions = { + extensions: pageUrlFormat === 'file' ? ['html'] : false, + index: false, + root: fileURLToPath(config.dist), + }; + + /** Base request URL. */ + let baseURL = new URL(appendForwardSlash(config.buildOptions.site || ''), defaultOrigin); // Create the preview server, send static files out of the `dist/` directory. const server = http.createServer((req, res) => { - if (!req.url!.startsWith(base)) { + const requestURL = new URL(req.url as string, defaultOrigin); + + // respond 404 to requests outside the base request directory + if (!requestURL.pathname.startsWith(baseURL.pathname)) { res.statusCode = 404; - res.end(subpathNotUsedTemplate(base, req.url!)); + res.end(subpathNotUsedTemplate(baseURL.pathname, requestURL.pathname)); return; } - switch (config.devOptions.trailingSlash) { - case 'always': { - if (!req.url?.endsWith('/')) { - res.statusCode = 404; - res.end( - template({ - title: 'Not found', - tabTitle: 'Not found', - pathname: req.url!, - }) - ); - return; - } - break; - } - case 'never': { - if (req.url?.endsWith('/')) { - res.statusCode = 404; - res.end( - template({ - title: 'Not found', - tabTitle: 'Not found', - pathname: req.url!, - }) - ); - return; - } - break; - } - case 'ignore': { - break; - } - } + /** Relative request path. */ + const pathname = requestURL.pathname.slice(baseURL.pathname.length - 1); + + const isRoot = pathname === '/'; + const hasTrailingSlash = isRoot || pathname.endsWith('/'); + + let tryTrailingSlash = true; + let tryHtmlExtension = true; + + let url: URL; - let sendpath = removeBase(base, req.url!); - const sendOptions: send.SendOptions = { - root: fileURLToPath(config.dist), + const onErr = (message: string) => { + res.statusCode = 404; + res.end(notFoundTemplate(pathname, message)); }; - if (config.buildOptions.pageUrlFormat === 'file' && !sendpath.endsWith('.html')) { - sendOptions.index = false; - const parts = sendpath.split('/'); - let lastPart = parts.pop(); - switch (config.devOptions.trailingSlash) { - case 'always': { - lastPart = parts.pop(); - break; - } - case 'never': { - // lastPart is the actually last part like `page` - break; - } - case 'ignore': { - // this could end in slash, so resolve either way - if (lastPart === '') { - lastPart = parts.pop(); - } - break; - } + + const onStat = (err: NodeJS.ErrnoException | null, stat: Stats) => { + switch (true) { + // retry nonexistent paths without an html extension + case err && tryHtmlExtension && hasTrailingSlash && !blockTrailingSlash: + case err && tryHtmlExtension && !hasTrailingSlash && !forceTrailingSlash && !pathname.endsWith('.html'): + tryHtmlExtension = false; + return fs.stat((url = new URL(url.pathname + '.html', url)), onStat); + + // 404 on nonexistent paths (that are yet handled) + case err !== null: + return onErr('Path not found'); + + // 404 on directories when a trailing slash is present but blocked + case stat.isDirectory() && hasTrailingSlash && blockTrailingSlash && !isRoot: + return onErr('Prohibited trailing slash'); + + // 404 on directories when a trailing slash is missing but forced + case stat.isDirectory() && !hasTrailingSlash && forceTrailingSlash && !isRoot: + return onErr('Required trailing slash'); + + // retry on directories when a default file is missing but allowed (that are yet handled) + case stat.isDirectory() && tryTrailingSlash: + tryTrailingSlash = false; + return fs.stat((url = new URL(url.pathname + (url.pathname.endsWith('/') ? defaultFile : '/' + defaultFile), url)), onStat); + + // 404 on existent directories (that are yet handled) + case stat.isDirectory(): + return onErr('Path not found'); + + // handle existent paths + default: + send(req, fileURLToPath(url), { + extensions: false, + index: false, + }).pipe(res); } - const part = lastPart || 'index'; - sendpath = npath.sep + npath.join(...parts, `${part}.html`); - } - send(req, sendpath, sendOptions) - .once('directory', function (this: SendStreamWithPath, _res, path) { - if (config.buildOptions.pageUrlFormat === 'directory' && !path.endsWith('index.html')) { - return this.sendIndex(path); - } else { - this.error(404); - } - }) - .pipe(res); + }; + + fs.stat((url = new URL(trimSlashes(pathname), config.dist)), onStat); }); let { hostname, port } = config.devOptions; @@ -132,7 +126,7 @@ export default async function preview(config: AstroConfig, { logging }: PreviewO httpServer = server.listen(port, hostname, () => { if (!showedListenMsg) { info(logging, 'astro', msg.devStart({ startupTime: performance.now() - timerStart })); - info(logging, 'astro', msg.devHost({ host: `http://${hostname}:${port}${base}` })); + info(logging, 'astro', msg.devHost({ host: `http://${hostname}:${port}${baseURL.pathname}` })); } showedListenMsg = true; resolve(); diff --git a/packages/astro/test/preview-routing.test.js b/packages/astro/test/preview-routing.test.js index 8c94a6879..4f382bd8b 100644 --- a/packages/astro/test/preview-routing.test.js +++ b/packages/astro/test/preview-routing.test.js @@ -30,15 +30,15 @@ describe('Preview Routing', () => { expect(response.status).to.equal(404); }); - it('404 when loading subpath root with trailing slash', async () => { + it('200 when loading subpath root with trailing slash', async () => { const response = await fixture.fetch('/blog/'); - expect(response.status).to.equal(404); + expect(response.status).to.equal(200); + expect(response.redirected).to.equal(false); }); - it('200 when loading subpath root without trailing slash', async () => { + it('404 when loading subpath root without trailing slash', async () => { const response = await fixture.fetch('/blog'); - expect(response.status).to.equal(200); - expect(response.redirected).to.equal(false); + expect(response.status).to.equal(404); }); it('404 when loading another page with subpath used', async () => { @@ -92,7 +92,6 @@ describe('Preview Routing', () => { it('404 when loading subpath root without trailing slash', async () => { const response = await fixture.fetch('/blog'); expect(response.status).to.equal(404); - expect(response.redirected).to.equal(false); }); it('200 when loading another page with subpath used', async () => { @@ -148,10 +147,9 @@ describe('Preview Routing', () => { expect(response.status).to.equal(200); }); - it('200 when loading subpath root without trailing slash', async () => { + it('404 when loading subpath root without trailing slash', async () => { const response = await fixture.fetch('/blog'); - expect(response.status).to.equal(200); - expect(response.redirected).to.equal(false); + expect(response.status).to.equal(404); }); it('200 when loading another page with subpath used', async () => { @@ -207,15 +205,15 @@ describe('Preview Routing', () => { expect(response.status).to.equal(404); }); - it('404 when loading subpath root with trailing slash', async () => { + it('200 when loading subpath root with trailing slash', async () => { const response = await fixture.fetch('/blog/'); - expect(response.status).to.equal(404); + expect(response.status).to.equal(200); + expect(response.redirected).to.equal(false); }); - it('200 when loading subpath root without trailing slash', async () => { + it('404 when loading subpath root without trailing slash', async () => { const response = await fixture.fetch('/blog'); - expect(response.status).to.equal(200); - expect(response.redirected).to.equal(false); + expect(response.status).to.equal(404); }); it('404 when loading another page with subpath used', async () => { @@ -272,7 +270,6 @@ describe('Preview Routing', () => { it('404 when loading subpath root without trailing slash', async () => { const response = await fixture.fetch('/blog'); expect(response.status).to.equal(404); - expect(response.redirected).to.equal(false); }); it('200 when loading another page with subpath used', async () => { @@ -331,10 +328,9 @@ describe('Preview Routing', () => { expect(response.status).to.equal(200); }); - it('200 when loading subpath root without trailing slash', async () => { + it('404 when loading subpath root without trailing slash', async () => { const response = await fixture.fetch('/blog'); - expect(response.status).to.equal(200); - expect(response.redirected).to.equal(false); + expect(response.status).to.equal(404); }); it('200 when loading another page with subpath used', async () => { |