From 387bdda7970487b9f7d477d7b857a8bbbf363653 Mon Sep 17 00:00:00 2001 From: sebastien Date: Thu, 5 Nov 2026 17:55:32 +0100 Subject: [PATCH 01/15] refactor(core): move each CLI command into its own folder (AI-assisted) Every command of @docusaurus/core now lives in /.ts, like build/build.ts and start/start.ts already did. Pure move: only import paths change, and the package exports keep the same names. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/__tests__/cli.test.ts | 4 ++-- .../src/commands/{ => clear}/clear.ts | 2 +- packages/docusaurus/src/commands/cli.ts | 17 ++++++++++------- .../src/commands/{ => deploy}/deploy.ts | 4 ++-- .../src/commands/{ => external}/external.ts | 4 ++-- .../src/commands/{ => serve}/serve.ts | 12 ++++++------ ...{index.test.ts.snap => swizzle.test.ts.snap} | 0 .../{index.test.ts => swizzle.test.ts} | 2 +- .../commands/swizzle/{index.ts => swizzle.ts} | 0 .../__tests__/writeHeadingIds.test.ts | 0 .../{ => writeHeadingIds}/writeHeadingIds.ts | 4 ++-- .../writeTranslations.ts | 8 ++++---- packages/docusaurus/src/index.ts | 14 +++++++------- 13 files changed, 37 insertions(+), 34 deletions(-) rename packages/docusaurus/src/commands/{ => clear}/clear.ts (97%) rename packages/docusaurus/src/commands/{ => deploy}/deploy.ts (98%) rename packages/docusaurus/src/commands/{ => external}/external.ts (87%) rename packages/docusaurus/src/commands/{ => serve}/serve.ts (90%) rename packages/docusaurus/src/commands/swizzle/__tests__/__snapshots__/{index.test.ts.snap => swizzle.test.ts.snap} (100%) rename packages/docusaurus/src/commands/swizzle/__tests__/{index.test.ts => swizzle.test.ts} (99%) rename packages/docusaurus/src/commands/swizzle/{index.ts => swizzle.ts} (100%) rename packages/docusaurus/src/commands/{ => writeHeadingIds}/__tests__/writeHeadingIds.test.ts (100%) rename packages/docusaurus/src/commands/{ => writeHeadingIds}/writeHeadingIds.ts (97%) rename packages/docusaurus/src/commands/{ => writeTranslations}/writeTranslations.ts (92%) diff --git a/packages/docusaurus/src/commands/__tests__/cli.test.ts b/packages/docusaurus/src/commands/__tests__/cli.test.ts index ea435e66b5b9..8e9e82a03894 100644 --- a/packages/docusaurus/src/commands/__tests__/cli.test.ts +++ b/packages/docusaurus/src/commands/__tests__/cli.test.ts @@ -11,8 +11,8 @@ import {Command} from 'commander'; import {createCLIProgram} from '../cli'; import * as buildCommand from '../build/build'; import * as startCommand from '../start/start'; -import * as serveCommand from '../serve'; -import * as writeHeadingIdsCommand from '../writeHeadingIds'; +import * as serveCommand from '../serve/serve'; +import * as writeHeadingIdsCommand from '../writeHeadingIds/writeHeadingIds'; const ExitOverrideError = new Error('exitOverride'); diff --git a/packages/docusaurus/src/commands/clear.ts b/packages/docusaurus/src/commands/clear/clear.ts similarity index 97% rename from packages/docusaurus/src/commands/clear.ts rename to packages/docusaurus/src/commands/clear/clear.ts index 9647e3c75a09..ccc47cac0e4e 100644 --- a/packages/docusaurus/src/commands/clear.ts +++ b/packages/docusaurus/src/commands/clear/clear.ts @@ -12,7 +12,7 @@ import { GENERATED_FILES_DIR_NAME, } from '@docusaurus/utils'; import {pathExists, realpath} from '@docusaurus/fs'; -import clearPath from './utils/clearPath'; +import clearPath from '../utils/clearPath'; async function removePath(entry: {path: string; description: string}) { if (!(await pathExists(entry.path))) { diff --git a/packages/docusaurus/src/commands/cli.ts b/packages/docusaurus/src/commands/cli.ts index e988063dea74..a3e77f29cbcc 100755 --- a/packages/docusaurus/src/commands/cli.ts +++ b/packages/docusaurus/src/commands/cli.ts @@ -10,14 +10,17 @@ import {Command, Option} from 'commander'; import {DOCUSAURUS_VERSION} from '@docusaurus/utils'; import {build} from './build/build'; -import {clear} from './clear'; -import {deploy} from './deploy'; -import {externalCommand} from './external'; -import {serve} from './serve'; +import {clear} from './clear/clear'; +import {deploy} from './deploy/deploy'; +import {externalCommand} from './external/external'; +import {serve} from './serve/serve'; import {start} from './start/start'; -import {swizzle} from './swizzle'; -import {writeHeadingIds, HeadingIdSyntaxes} from './writeHeadingIds'; -import {writeTranslations} from './writeTranslations'; +import {swizzle} from './swizzle/swizzle'; +import { + writeHeadingIds, + HeadingIdSyntaxes, +} from './writeHeadingIds/writeHeadingIds'; +import {writeTranslations} from './writeTranslations/writeTranslations'; function concatLocaleOptions(locale: string, locales: string[] = []): string[] { return locales.concat(locale); diff --git a/packages/docusaurus/src/commands/deploy.ts b/packages/docusaurus/src/commands/deploy/deploy.ts similarity index 98% rename from packages/docusaurus/src/commands/deploy.ts rename to packages/docusaurus/src/commands/deploy/deploy.ts index a7b3da9c3a31..29b20b380db4 100644 --- a/packages/docusaurus/src/commands/deploy.ts +++ b/packages/docusaurus/src/commands/deploy/deploy.ts @@ -11,8 +11,8 @@ import logger from '@docusaurus/logger'; import {execa} from 'execa'; import {hasSSHProtocol, buildSshUrl, buildHttpsUrl} from '@docusaurus/utils'; import {fs, realpath} from '@docusaurus/fs'; -import {loadContext, type LoadContextParams} from '../server/site'; -import {build} from './build/build'; +import {loadContext, type LoadContextParams} from '../../server/site'; +import {build} from '../build/build'; export type DeployCLIOptions = Pick & { locale?: [string, ...string[]]; diff --git a/packages/docusaurus/src/commands/external.ts b/packages/docusaurus/src/commands/external/external.ts similarity index 87% rename from packages/docusaurus/src/commands/external.ts rename to packages/docusaurus/src/commands/external/external.ts index 46b65a3e4bc4..625e701e8a58 100644 --- a/packages/docusaurus/src/commands/external.ts +++ b/packages/docusaurus/src/commands/external/external.ts @@ -6,8 +6,8 @@ */ import {realpath} from '@docusaurus/fs'; -import {loadContext} from '../server/site'; -import {initPlugins} from '../server/plugins/init'; +import {loadContext} from '../../server/site'; +import {initPlugins} from '../../server/plugins/init'; import type {Command} from 'commander'; export async function externalCommand({ diff --git a/packages/docusaurus/src/commands/serve.ts b/packages/docusaurus/src/commands/serve/serve.ts similarity index 90% rename from packages/docusaurus/src/commands/serve.ts rename to packages/docusaurus/src/commands/serve/serve.ts index ee09bf1a8f0f..21c317d565c7 100644 --- a/packages/docusaurus/src/commands/serve.ts +++ b/packages/docusaurus/src/commands/serve/serve.ts @@ -12,12 +12,12 @@ import {DEFAULT_BUILD_DIR_NAME} from '@docusaurus/utils'; import serveHandler from 'serve-handler'; import {applyTrailingSlash} from '@docusaurus/utils-common'; import {realpath} from '@docusaurus/fs'; -import openBrowser from './utils/openBrowser/openBrowser'; -import {loadSiteConfig} from '../server/config'; -import {build} from './build/build'; -import {getHostPort, type HostPortOptions} from '../server/getHostPort'; -import {listenToServer} from './utils/listenToServer'; -import type {LoadContextParams} from '../server/site'; +import openBrowser from '../utils/openBrowser/openBrowser'; +import {loadSiteConfig} from '../../server/config'; +import {build} from '../build/build'; +import {getHostPort, type HostPortOptions} from '../../server/getHostPort'; +import {listenToServer} from '../utils/listenToServer'; +import type {LoadContextParams} from '../../server/site'; function redirect(res: http.ServerResponse, location: string) { res.writeHead(302, { diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus/src/commands/swizzle/__tests__/__snapshots__/swizzle.test.ts.snap similarity index 100% rename from packages/docusaurus/src/commands/swizzle/__tests__/__snapshots__/index.test.ts.snap rename to packages/docusaurus/src/commands/swizzle/__tests__/__snapshots__/swizzle.test.ts.snap diff --git a/packages/docusaurus/src/commands/swizzle/__tests__/index.test.ts b/packages/docusaurus/src/commands/swizzle/__tests__/swizzle.test.ts similarity index 99% rename from packages/docusaurus/src/commands/swizzle/__tests__/index.test.ts rename to packages/docusaurus/src/commands/swizzle/__tests__/swizzle.test.ts index 2a08febb79ed..eeb66c2aaa0a 100644 --- a/packages/docusaurus/src/commands/swizzle/__tests__/index.test.ts +++ b/packages/docusaurus/src/commands/swizzle/__tests__/swizzle.test.ts @@ -12,7 +12,7 @@ import {tree} from 'tree-node-cli'; import {escapePath, posixPath} from '@docusaurus/utils'; import {glob} from '@docusaurus/glob'; import {ThemePath, createTempSiteDir, Components} from './testUtils'; -import {swizzle} from '../index'; +import {swizzle} from '../swizzle'; const FixtureThemeName = 'fixture-theme-name'; diff --git a/packages/docusaurus/src/commands/swizzle/index.ts b/packages/docusaurus/src/commands/swizzle/swizzle.ts similarity index 100% rename from packages/docusaurus/src/commands/swizzle/index.ts rename to packages/docusaurus/src/commands/swizzle/swizzle.ts diff --git a/packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts b/packages/docusaurus/src/commands/writeHeadingIds/__tests__/writeHeadingIds.test.ts similarity index 100% rename from packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts rename to packages/docusaurus/src/commands/writeHeadingIds/__tests__/writeHeadingIds.test.ts diff --git a/packages/docusaurus/src/commands/writeHeadingIds.ts b/packages/docusaurus/src/commands/writeHeadingIds/writeHeadingIds.ts similarity index 97% rename from packages/docusaurus/src/commands/writeHeadingIds.ts rename to packages/docusaurus/src/commands/writeHeadingIds/writeHeadingIds.ts index 192a35f647c4..2fa8d1a2ac4a 100644 --- a/packages/docusaurus/src/commands/writeHeadingIds.ts +++ b/packages/docusaurus/src/commands/writeHeadingIds/writeHeadingIds.ts @@ -14,8 +14,8 @@ import { } from '@docusaurus/utils'; import {safeGlob} from '@docusaurus/glob'; import {fs, realpath} from '@docusaurus/fs'; -import {loadContext} from '../server/site'; -import {initPlugins} from '../server/plugins/init'; +import {loadContext} from '../../server/site'; +import {initPlugins} from '../../server/plugins/init'; import type {LoadContext, MarkdownConfig} from '@docusaurus/types'; export const HeadingIdSyntaxes = [ diff --git a/packages/docusaurus/src/commands/writeTranslations.ts b/packages/docusaurus/src/commands/writeTranslations/writeTranslations.ts similarity index 92% rename from packages/docusaurus/src/commands/writeTranslations.ts rename to packages/docusaurus/src/commands/writeTranslations/writeTranslations.ts index fea7fd7890a7..fd544d543204 100644 --- a/packages/docusaurus/src/commands/writeTranslations.ts +++ b/packages/docusaurus/src/commands/writeTranslations/writeTranslations.ts @@ -8,16 +8,16 @@ import path from 'node:path'; import {globTranslatableSourceFiles} from '@docusaurus/glob'; import {realpath} from '@docusaurus/fs'; -import {loadContext, type LoadContextParams} from '../server/site'; -import {initPlugins} from '../server/plugins/init'; +import {loadContext, type LoadContextParams} from '../../server/site'; +import {initPlugins} from '../../server/plugins/init'; import { writePluginTranslations, writeCodeTranslations, type WriteTranslationsOptions, loadPluginsDefaultCodeTranslationMessages, applyDefaultCodeTranslations, -} from '../server/translations/translations'; -import {extractSiteSourceCodeTranslations} from '../server/translations/translationsExtractor'; +} from '../../server/translations/translations'; +import {extractSiteSourceCodeTranslations} from '../../server/translations/translationsExtractor'; import type {InitializedPlugin} from '@docusaurus/types'; export type WriteTranslationsCLIOptions = Pick< diff --git a/packages/docusaurus/src/index.ts b/packages/docusaurus/src/index.ts index 3a6a2e1362e6..d1c9aea952bf 100644 --- a/packages/docusaurus/src/index.ts +++ b/packages/docusaurus/src/index.ts @@ -7,11 +7,11 @@ export {runCLI} from './commands/cli'; export {build} from './commands/build/build'; -export {clear} from './commands/clear'; -export {deploy} from './commands/deploy'; -export {externalCommand} from './commands/external'; -export {serve} from './commands/serve'; +export {clear} from './commands/clear/clear'; +export {deploy} from './commands/deploy/deploy'; +export {externalCommand} from './commands/external/external'; +export {serve} from './commands/serve/serve'; export {start} from './commands/start/start'; -export {swizzle} from './commands/swizzle'; -export {writeHeadingIds} from './commands/writeHeadingIds'; -export {writeTranslations} from './commands/writeTranslations'; +export {swizzle} from './commands/swizzle/swizzle'; +export {writeHeadingIds} from './commands/writeHeadingIds/writeHeadingIds'; +export {writeTranslations} from './commands/writeTranslations/writeTranslations'; From 69feb029fc480a59490594442601ae5882fa205e Mon Sep 17 00:00:00 2001 From: sebastien Date: Thu, 5 Nov 2026 16:39:22 +0100 Subject: [PATCH 02/15] test(core): capture how docusaurus serve serves static files (AI-assisted) Extract the request handling of docusaurus serve into createServeRequestHandler(), and capture its current behavior against a fixture static deployment that extends the one of https://github.com/slorber/trailing-slash-guide: one snapshot table per baseUrl and trailingSlash value, following the redirect chains. it.fails() tests make the known bugs visible: - Latin-1 file names crash the server, see vercel/serve-handler#205 - serve-handler redirects drop the baseUrl - Under a baseUrl with trailingSlash: true, assets with a query string or a long extension get a trailing slash and 404 Co-Authored-By: Claude Opus 5.5 --- .../__fixtures__/site/build/404.html | 1 + .../site/build/assets/css/styles.css | 1 + .../__fixtures__/site/build/both.html | 1 + .../__fixtures__/site/build/both/index.html | 1 + .../__fixtures__/site/build/caf\303\251.html" | 1 + .../__fixtures__/site/build/file.html | 1 + .../__fixtures__/site/build/folder/index.html | 1 + .../__fixtures__/site/build/index.html | 1 + .../__fixtures__/site/build/site.webmanifest | 1 + .../__fixtures__/site/build/with space.html | 1 + .../\321\204\320\260\320\271\320\273.html" | 1 + .../__fixtures__/site/custom.config.js | 5 + .../__fixtures__/site/docusaurus.config.js | 6 + .../serve/__tests__/requestHandler.test.ts | 443 ++++++++++++++++++ .../commands/serve/__tests__/serve.test.ts | 84 ++++ .../src/commands/serve/__tests__/testUtils.ts | 68 +++ .../src/commands/serve/requestHandler.ts | 74 +++ .../docusaurus/src/commands/serve/serve.ts | 54 +-- project-words.txt | 2 + 19 files changed, 697 insertions(+), 50 deletions(-) create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/404.html create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/assets/css/styles.css create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/both.html create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/both/index.html create mode 100644 "packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/caf\303\251.html" create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/file.html create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/folder/index.html create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/index.html create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/site.webmanifest create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/with space.html create mode 100644 "packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/\321\204\320\260\320\271\320\273.html" create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/custom.config.js create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/docusaurus.config.js create mode 100644 packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts create mode 100644 packages/docusaurus/src/commands/serve/__tests__/serve.test.ts create mode 100644 packages/docusaurus/src/commands/serve/__tests__/testUtils.ts create mode 100644 packages/docusaurus/src/commands/serve/requestHandler.ts diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/404.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/404.html new file mode 100644 index 000000000000..7bcd6c0f7147 --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/404.html @@ -0,0 +1 @@ +404.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/assets/css/styles.css b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/assets/css/styles.css new file mode 100644 index 000000000000..5bce0aa11833 --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/assets/css/styles.css @@ -0,0 +1 @@ +assets/css/styles.css diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/both.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/both.html new file mode 100644 index 000000000000..98fe32c3e37a --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/both.html @@ -0,0 +1 @@ +both.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/both/index.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/both/index.html new file mode 100644 index 000000000000..29f8d82f6055 --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/both/index.html @@ -0,0 +1 @@ +both/index.html diff --git "a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/caf\303\251.html" "b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/caf\303\251.html" new file mode 100644 index 000000000000..29a666b1d191 --- /dev/null +++ "b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/caf\303\251.html" @@ -0,0 +1 @@ +café.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/file.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/file.html new file mode 100644 index 000000000000..e9e7c7fc7ea2 --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/file.html @@ -0,0 +1 @@ +file.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/folder/index.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/folder/index.html new file mode 100644 index 000000000000..299ace561c23 --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/folder/index.html @@ -0,0 +1 @@ +folder/index.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/index.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/index.html new file mode 100644 index 000000000000..dcaf71693e4a --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/index.html @@ -0,0 +1 @@ +index.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/site.webmanifest b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/site.webmanifest new file mode 100644 index 000000000000..d647982bbf34 --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/site.webmanifest @@ -0,0 +1 @@ +site.webmanifest diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/with space.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/with space.html new file mode 100644 index 000000000000..8fc503d9c5dd --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/with space.html @@ -0,0 +1 @@ +with space.html diff --git "a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/\321\204\320\260\320\271\320\273.html" "b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/\321\204\320\260\320\271\320\273.html" new file mode 100644 index 000000000000..bbceaa143181 --- /dev/null +++ "b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/\321\204\320\260\320\271\320\273.html" @@ -0,0 +1 @@ +файл.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/custom.config.js b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/custom.config.js new file mode 100644 index 000000000000..b794684a769b --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/custom.config.js @@ -0,0 +1,5 @@ +export default { + title: 'Serve fixture site with a custom config', + url: 'https://example.com', + baseUrl: '/custom/', +}; diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/docusaurus.config.js b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/docusaurus.config.js new file mode 100644 index 000000000000..f28751716744 --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/docusaurus.config.js @@ -0,0 +1,6 @@ +export default { + title: 'Serve fixture site', + url: 'https://example.com', + baseUrl: '/base/', + trailingSlash: false, +}; diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts new file mode 100644 index 000000000000..1d0d8e97e5f3 --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -0,0 +1,443 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {describe, expect, it} from 'vitest'; +import http from 'node:http'; +import {listenToServer} from '../../utils/listenToServer'; +import {createServeRequestHandler} from '../requestHandler'; +import {CrashHeader, describeRequest, fixtureOutDir} from './testUtils'; +import type {AddressInfo} from 'node:net'; +import type {DocusaurusConfig} from '@docusaurus/types'; + +type ServeOptions = Pick; + +// Appended to the baseUrl. The fixture build extends the static deployment of +// https://github.com/slorber/trailing-slash-guide +const RequestPaths = [ + '/', + '/index', + '/index.html', + + '/file', + '/file/', + '/file.html', + + '/folder', + '/folder/', + '/folder/index', + '/folder/index.html', + + '/both', + '/both/', + '/both.html', + '/both/index.html', + + '/assets/css/styles.css', + '/assets/css/styles.css?v=1', + '/assets/', + '/site.webmanifest', + + '/missing', + '/missing/', + '/missing.html', + + '/file?query=1', + '/file/?query=1', + '/file.html?query=1', + '/folder?query=1', + + '/with%20space', + '/with%20space.html', + '/файл', + '/файл.html', + '/café', + + '//file', + '/%E0%A4%A', // Malformed percent-encoding + '/..%2Fdocusaurus.config.js', // Path traversal +]; + +describe('createServeRequestHandler', () => { + describe('baseUrl /', () => { + it('trailingSlash undefined', async () => { + const result = await describeServing({ + baseUrl: '/', + trailingSlash: undefined, + }); + expect(result).toMatchInlineSnapshot(` + "/ 200 index.html + /index 301 / → 200 index.html + /index.html 301 /index → 301 / → 200 index.html + /file 200 file.html + /file/ 200 file.html + /file.html 301 /file → 200 file.html + /folder 200 folder/index.html + /folder/ 200 folder/index.html + /folder/index 301 /folder → 200 folder/index.html + /folder/index.html 301 /folder/index → 301 /folder → 200 folder/index.html + /both 200 both/index.html + /both/ 200 both/index.html + /both.html 301 /both → 200 both/index.html + /both/index.html 301 /both/index → 301 /both → 200 both/index.html + /assets/css/styles.css 200 assets/css/styles.css + /assets/css/styles.css?v=1 200 assets/css/styles.css + /assets/ 404 404.html + /site.webmanifest 200 site.webmanifest + /missing 404 404.html + /missing/ 404 404.html + /missing.html 301 /missing → 404 404.html + /file?query=1 200 file.html + /file/?query=1 200 file.html + /file.html?query=1 301 /file → 200 file.html + /folder?query=1 200 folder/index.html + /with%20space 200 with space.html + /with%20space.html 301 /with%20space → 200 with space.html + /файл 200 файл.html + /файл.html 301 /%D1%84%D0%B0%D0%B9%D0%BB → 200 файл.html + /café crash ERR_INVALID_CHAR + //file 200 file.html + /%E0%A4%A 400 Bad Request + /..%2Fdocusaurus.config.js 400 Bad Request" + `); + }); + + it('trailingSlash true', async () => { + const result = await describeServing({ + baseUrl: '/', + trailingSlash: true, + }); + expect(result).toMatchInlineSnapshot(` + "/ 200 index.html + /index 301 / → 200 index.html + /index.html 301 /index → 301 / → 200 index.html + /file 301 /file/ → 200 file.html + /file/ 200 file.html + /file.html 301 /file → 301 /file/ → 200 file.html + /folder 301 /folder/ → 200 folder/index.html + /folder/ 200 folder/index.html + /folder/index 301 /folder → 301 /folder/ → 200 folder/index.html + /folder/index.html 301 /folder/index → 301 /folder → 301 /folder/ → 200 folder/index.html + /both 301 /both/ → 200 both/index.html + /both/ 200 both/index.html + /both.html 301 /both → 301 /both/ → 200 both/index.html + /both/index.html 301 /both/index → 301 /both → 301 /both/ → 200 both/index.html + /assets/css/styles.css 200 assets/css/styles.css + /assets/css/styles.css?v=1 200 assets/css/styles.css + /assets/ 404 404.html + /site.webmanifest 200 site.webmanifest + /missing 301 /missing/ → 404 404.html + /missing/ 404 404.html + /missing.html 301 /missing → 301 /missing/ → 404 404.html + /file?query=1 301 /file/ → 200 file.html + /file/?query=1 200 file.html + /file.html?query=1 301 /file → 301 /file/ → 200 file.html + /folder?query=1 301 /folder/ → 200 folder/index.html + /with%20space 301 /with%20space/ → 200 with space.html + /with%20space.html 301 /with%20space → 301 /with%20space/ → 200 with space.html + /файл 301 /%D1%84%D0%B0%D0%B9%D0%BB/ → 200 файл.html + /файл.html 301 /%D1%84%D0%B0%D0%B9%D0%BB → 301 /%D1%84%D0%B0%D0%B9%D0%BB/ → 200 файл.html + /café 301 /caf%C3%A9/ → crash ERR_INVALID_CHAR + //file 301 /file → 301 /file/ → 200 file.html + /%E0%A4%A 400 Bad Request + /..%2Fdocusaurus.config.js 400 Bad Request" + `); + }); + + it('trailingSlash false', async () => { + const result = await describeServing({ + baseUrl: '/', + trailingSlash: false, + }); + expect(result).toMatchInlineSnapshot(` + "/ 200 index.html + /index 301 / → 200 index.html + /index.html 301 /index → 301 / → 200 index.html + /file 200 file.html + /file/ 301 /file → 200 file.html + /file.html 301 /file → 200 file.html + /folder 200 folder/index.html + /folder/ 301 /folder → 200 folder/index.html + /folder/index 301 /folder → 200 folder/index.html + /folder/index.html 301 /folder/index → 301 /folder → 200 folder/index.html + /both 200 both/index.html + /both/ 301 /both → 200 both/index.html + /both.html 301 /both → 200 both/index.html + /both/index.html 301 /both/index → 301 /both → 200 both/index.html + /assets/css/styles.css 200 assets/css/styles.css + /assets/css/styles.css?v=1 200 assets/css/styles.css + /assets/ 301 /assets → 404 404.html + /site.webmanifest 200 site.webmanifest + /missing 404 404.html + /missing/ 301 /missing → 404 404.html + /missing.html 301 /missing → 404 404.html + /file?query=1 200 file.html + /file/?query=1 301 /file → 200 file.html + /file.html?query=1 301 /file → 200 file.html + /folder?query=1 200 folder/index.html + /with%20space 200 with space.html + /with%20space.html 301 /with%20space → 200 with space.html + /файл 200 файл.html + /файл.html 301 /%D1%84%D0%B0%D0%B9%D0%BB → 200 файл.html + /café crash ERR_INVALID_CHAR + //file 301 /file → 200 file.html + /%E0%A4%A 400 Bad Request + /..%2Fdocusaurus.config.js 400 Bad Request" + `); + }); + }); + + describe('baseUrl /base/', () => { + it('trailingSlash undefined', async () => { + const result = await describeServing({ + baseUrl: '/base/', + trailingSlash: undefined, + }); + expect(result).toMatchInlineSnapshot(` + "/base/ 200 index.html + /base/index 301 / → 302 /base/ → 200 index.html + /base/index.html 301 /index → 302 /base/ → 200 index.html + /base/file 200 file.html + /base/file/ 200 file.html + /base/file.html 301 /file → 302 /base/ → 200 index.html + /base/folder 200 folder/index.html + /base/folder/ 200 folder/index.html + /base/folder/index 301 /folder → 302 /base/ → 200 index.html + /base/folder/index.html 301 /folder/index → 302 /base/ → 200 index.html + /base/both 200 both/index.html + /base/both/ 200 both/index.html + /base/both.html 301 /both → 302 /base/ → 200 index.html + /base/both/index.html 301 /both/index → 302 /base/ → 200 index.html + /base/assets/css/styles.css 200 assets/css/styles.css + /base/assets/css/styles.css?v=1 200 assets/css/styles.css + /base/assets/ 404 404.html + /base/site.webmanifest 200 site.webmanifest + /base/missing 404 404.html + /base/missing/ 404 404.html + /base/missing.html 301 /missing → 302 /base/ → 200 index.html + /base/file?query=1 200 file.html + /base/file/?query=1 200 file.html + /base/file.html?query=1 301 /file → 302 /base/ → 200 index.html + /base/folder?query=1 200 folder/index.html + /base/with%20space 200 with space.html + /base/with%20space.html 301 /with%20space → 302 /base/ → 200 index.html + /base/файл 200 файл.html + /base/файл.html 301 /%D1%84%D0%B0%D0%B9%D0%BB → 302 /base/ → 200 index.html + /base/café crash ERR_INVALID_CHAR + /base//file 200 file.html + /base/%E0%A4%A 400 Bad Request + /base/..%2Fdocusaurus.config.js 400 Bad Request" + `); + }); + + it('trailingSlash true', async () => { + const result = await describeServing({ + baseUrl: '/base/', + trailingSlash: true, + }); + expect(result).toMatchInlineSnapshot(` + "/base/ 200 index.html + /base/index 302 /base/index/ → 200 index.html + /base/index.html 301 /index → 302 /base/ → 200 index.html + /base/file 302 /base/file/ → 200 file.html + /base/file/ 200 file.html + /base/file.html 301 /file → 302 /base/ → 200 index.html + /base/folder 302 /base/folder/ → 200 folder/index.html + /base/folder/ 200 folder/index.html + /base/folder/index 302 /base/folder/index/ → 200 folder/index.html + /base/folder/index.html 301 /folder/index → 302 /base/ → 200 index.html + /base/both 302 /base/both/ → 200 both/index.html + /base/both/ 200 both/index.html + /base/both.html 301 /both → 302 /base/ → 200 index.html + /base/both/index.html 301 /both/index → 302 /base/ → 200 index.html + /base/assets/css/styles.css 200 assets/css/styles.css + /base/assets/css/styles.css?v=1 302 /base/assets/css/styles.css/?v=1 → 404 404.html + /base/assets/ 404 404.html + /base/site.webmanifest 302 /base/site.webmanifest/ → 404 404.html + /base/missing 302 /base/missing/ → 404 404.html + /base/missing/ 404 404.html + /base/missing.html 301 /missing → 302 /base/ → 200 index.html + /base/file?query=1 302 /base/file/?query=1 → 200 file.html + /base/file/?query=1 200 file.html + /base/file.html?query=1 302 /base/file.html/?query=1 → 404 404.html + /base/folder?query=1 302 /base/folder/?query=1 → 200 folder/index.html + /base/with%20space 302 /base/with%20space/ → 200 with space.html + /base/with%20space.html 301 /with%20space → 302 /base/ → 200 index.html + /base/файл 302 /base/%D1%84%D0%B0%D0%B9%D0%BB/ → 200 файл.html + /base/файл.html 301 /%D1%84%D0%B0%D0%B9%D0%BB → 302 /base/ → 200 index.html + /base/café 302 /base/caf%C3%A9/ → crash ERR_INVALID_CHAR + /base//file 302 /base//file/ → 301 /file/ → 302 /base/ → 200 index.html + /base/%E0%A4%A 302 /base/%E0%A4%A/ → 400 Bad Request + /base/..%2Fdocusaurus.config.js 400 Bad Request" + `); + }); + + it('trailingSlash false', async () => { + const result = await describeServing({ + baseUrl: '/base/', + trailingSlash: false, + }); + expect(result).toMatchInlineSnapshot(` + "/base/ 200 index.html + /base/index 301 / → 302 /base/ → 200 index.html + /base/index.html 301 /index → 302 /base/ → 200 index.html + /base/file 200 file.html + /base/file/ 302 /base/file → 200 file.html + /base/file.html 301 /file → 302 /base/ → 200 index.html + /base/folder 200 folder/index.html + /base/folder/ 302 /base/folder → 200 folder/index.html + /base/folder/index 301 /folder → 302 /base/ → 200 index.html + /base/folder/index.html 301 /folder/index → 302 /base/ → 200 index.html + /base/both 200 both/index.html + /base/both/ 302 /base/both → 200 both/index.html + /base/both.html 301 /both → 302 /base/ → 200 index.html + /base/both/index.html 301 /both/index → 302 /base/ → 200 index.html + /base/assets/css/styles.css 200 assets/css/styles.css + /base/assets/css/styles.css?v=1 200 assets/css/styles.css + /base/assets/ 302 /base/assets → 404 404.html + /base/site.webmanifest 200 site.webmanifest + /base/missing 404 404.html + /base/missing/ 302 /base/missing → 404 404.html + /base/missing.html 301 /missing → 302 /base/ → 200 index.html + /base/file?query=1 200 file.html + /base/file/?query=1 302 /base/file?query=1 → 200 file.html + /base/file.html?query=1 301 /file → 302 /base/ → 200 index.html + /base/folder?query=1 200 folder/index.html + /base/with%20space 200 with space.html + /base/with%20space.html 301 /with%20space → 302 /base/ → 200 index.html + /base/файл 200 файл.html + /base/файл.html 301 /%D1%84%D0%B0%D0%B9%D0%BB → 302 /base/ → 200 index.html + /base/café crash ERR_INVALID_CHAR + /base//file 301 /file → 302 /base/ → 200 index.html + /base/%E0%A4%A 400 Bad Request + /base/..%2Fdocusaurus.config.js 400 Bad Request" + `); + }); + + it('redirects requests outside the baseUrl', async () => { + const result = await describeRequests( + {baseUrl: '/base/', trailingSlash: undefined}, + ['/', '/file', '/base', '/base?query=1', '/basement'], + ); + expect(result).toMatchInlineSnapshot(` + "/ 302 /base/ → 200 index.html + /file 302 /base/ → 200 index.html + /base 302 /base/ → 200 index.html + /base?query=1 302 /base/ → 200 index.html + /basement 302 /base/ → 200 index.html" + `); + }); + }); + + // Also captured by the snapshots above. it.fails() passes while the bug is + // there: fixing one turns its test red, replace it.fails() with it() + describe('known bugs', () => { + // See https://github.com/vercel/serve-handler/issues/205 + it.fails('serves file names with Latin-1 characters', async () => { + await expect( + serveRequest({baseUrl: '/', trailingSlash: undefined}, '/café'), + ).resolves.toBe('200 café.html'); + }); + + // See https://github.com/meta/docusaurus/issues/10078#issuecomment-2084932934 + it.fails('keeps the baseUrl in serve-handler redirects', async () => { + await expect( + serveRequest( + {baseUrl: '/base/', trailingSlash: undefined}, + '/base/file.html', + ), + ).resolves.toBe('301 /base/file → 200 file.html'); + }); + + // The looksLikeAsset regex misses them, and adds a trailing slash + // See https://github.com/meta/docusaurus/pull/10090 + it.fails('serves assets with a query string under a baseUrl', async () => { + await expect( + serveRequest( + {baseUrl: '/base/', trailingSlash: true}, + '/base/assets/css/styles.css?v=1', + ), + ).resolves.toBe('200 assets/css/styles.css'); + }); + + it.fails('serves assets with a long extension under a baseUrl', async () => { + await expect( + serveRequest( + {baseUrl: '/base/', trailingSlash: true}, + '/base/site.webmanifest', + ), + ).resolves.toBe('200 site.webmanifest'); + }); + }); +}); + +async function describeServing(options: ServeOptions): Promise { + const baseUrlPrefix = options.baseUrl.replace(/\/$/, ''); + const paths = RequestPaths.map((requestPath) => baseUrlPrefix + requestPath); + return describeRequests(options, paths); +} + +// One line per request, aligned to be easy to review in snapshots +async function describeRequests( + options: ServeOptions, + paths: string[], +): Promise { + const descriptions = await withServer(options, (origin) => + Promise.all(paths.map((path) => describeRequest(origin, path))), + ); + const pathWidth = Math.max(...paths.map((path) => path.length)); + return paths + .map((path, index) => `${path.padEnd(pathWidth)} ${descriptions[index]}`) + .join('\n'); +} + +async function serveRequest( + options: ServeOptions, + path: string, +): Promise { + return withServer(options, (origin) => describeRequest(origin, path)); +} + +async function withServer( + options: ServeOptions, + callback: (origin: string) => Promise, +): Promise { + const handleRequest = createServeRequestHandler({ + outDir: fixtureOutDir, + ...options, + }); + const server = http.createServer((req, res) => { + handleRequest(req, res).catch((error: NodeJS.ErrnoException) => + respondWithCrash(res, error), + ); + }); + + await listenToServer({server, host: '127.0.0.1', port: 0}); + try { + const {port} = server.address() as AddressInfo; + return await callback(`http://127.0.0.1:${port}`); + } finally { + server.closeAllConnections(); + server.close(); + } +} + +// In production, this rejection is unhandled and crashes the server process +function respondWithCrash( + res: http.ServerResponse, + error: NodeJS.ErrnoException, +) { + if (res.headersSent) { + res.destroy(); + return; + } + // A failed writeHead() can leave the Content-Length of the file to serve + res.writeHead(500, { + [CrashHeader]: error.code ?? 'unknown', + 'Content-Length': 0, + }); + res.end(); +} diff --git a/packages/docusaurus/src/commands/serve/__tests__/serve.test.ts b/packages/docusaurus/src/commands/serve/__tests__/serve.test.ts new file mode 100644 index 000000000000..52aca3b773aa --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/serve.test.ts @@ -0,0 +1,84 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import http from 'node:http'; +import path from 'node:path'; +import logger from '@docusaurus/logger'; +import {realpath} from '@docusaurus/fs'; +import {build} from '../../build/build'; +import {serve, type ServeCLIOptions} from '../serve'; +import {describeRequest, fixtureSiteDir} from './testUtils'; +import type {AddressInfo} from 'node:net'; + +vi.mock('../../../server/getHostPort', () => ({ + // Port 0 lets the OS pick a free port + getHostPort: async () => ({host: '127.0.0.1', port: 0}), +})); + +vi.mock('../../build/build', () => ({build: vi.fn()})); + +describe('serve', () => { + const servers: http.Server[] = []; + + beforeEach(() => { + vi.spyOn(logger, 'success').mockImplementation(() => {}); + }); + + afterEach(() => { + servers.forEach((server) => { + server.closeAllConnections(); + server.close(); + }); + servers.length = 0; + vi.restoreAllMocks(); + }); + + // Returns the origin of the server that serve() started + async function serveFixtureSite( + cliOptions: Partial = {}, + ): Promise { + const createServer = vi.spyOn(http, 'createServer'); + await serve(fixtureSiteDir, {open: false, ...cliOptions}); + + const server = createServer.mock.results[0]!.value as http.Server; + servers.push(server); + const {port} = server.address() as AddressInfo; + return `http://127.0.0.1:${port}`; + } + + it('serves the build directory with the baseUrl and trailingSlash of the site config', async () => { + const origin = await serveFixtureSite(); + await expect(describeRequest(origin, '/base/file/')).resolves.toBe( + '302 /base/file → 200 file.html', + ); + }); + + it('serves the --dir directory', async () => { + const origin = await serveFixtureSite({dir: 'build/folder'}); + await expect(describeRequest(origin, '/base/')).resolves.toBe( + '200 folder/index.html', + ); + }); + + it('loads the --config site config', async () => { + const origin = await serveFixtureSite({config: 'custom.config.js'}); + await expect(describeRequest(origin, '/custom/file')).resolves.toBe( + '200 file.html', + ); + }); + + it('builds the site in the served directory with --build', async () => { + await serveFixtureSite({build: true, config: 'custom.config.js'}); + + const siteDir = await realpath(fixtureSiteDir); + expect(build).toHaveBeenCalledWith(siteDir, { + config: 'custom.config.js', + outDir: path.join(siteDir, 'build'), + }); + }); +}); diff --git a/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts b/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts new file mode 100644 index 000000000000..0aeda9379d79 --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import path from 'node:path'; +import {pathExists} from '@docusaurus/fs'; + +export const fixtureSiteDir = path.join(__dirname, '__fixtures__/site'); +export const fixtureOutDir = path.join(fixtureSiteDir, 'build'); + +// Added by test servers when the request handler rejects +export const CrashHeader = 'x-test-crash'; + +const MaxRedirects = 5; + +/** + * Requests a path and follows its redirects, to describe how it's served: + * "301 /file → 200 file.html" + */ +export async function describeRequest( + origin: string, + requestPath: string, +): Promise { + const descriptions: string[] = []; + + // Concatenated: new URL('//file', origin) would read "file" as a host name + let url = origin + requestPath; + while (descriptions.length <= MaxRedirects) { + const response = await fetch(url, {redirect: 'manual'}); + descriptions.push(await describeResponse(response)); + + const location = response.headers.get('location'); + if (location === null) { + break; + } + url = new URL(location, url).href; + } + + return descriptions.join(' → '); +} + +async function describeResponse(response: Response): Promise { + const body = await response.text(); + + const crashCode = response.headers.get(CrashHeader); + if (crashCode !== null) { + return `crash ${crashCode}`; + } + + const location = response.headers.get('location'); + if (location !== null) { + return `${response.status} ${location}`; + } + + const servedFile = (await getServedFixtureFile(body)) ?? response.statusText; + return `${response.status} ${servedFile}`; +} + +// Each fixture build file contains its own path +async function getServedFixtureFile(body: string): Promise { + const filePath = body.trim(); + const isFixtureFile = + filePath !== '' && (await pathExists(path.join(fixtureOutDir, filePath))); + return isFixtureFile ? filePath : undefined; +} diff --git a/packages/docusaurus/src/commands/serve/requestHandler.ts b/packages/docusaurus/src/commands/serve/requestHandler.ts new file mode 100644 index 000000000000..8437b0ccca2c --- /dev/null +++ b/packages/docusaurus/src/commands/serve/requestHandler.ts @@ -0,0 +1,74 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import serveHandler from 'serve-handler'; +import {applyTrailingSlash} from '@docusaurus/utils-common'; +import type http from 'node:http'; +import type {DocusaurusConfig} from '@docusaurus/types'; + +type ServeRequestHandler = ( + req: http.IncomingMessage, + res: http.ServerResponse, +) => Promise; + +export function createServeRequestHandler({ + outDir, + baseUrl, + trailingSlash, +}: {outDir: string} & Pick< + DocusaurusConfig, + 'baseUrl' | 'trailingSlash' +>): ServeRequestHandler { + return async (req, res) => { + // Automatically redirect requests to /baseUrl/ + if (!req.url?.startsWith(baseUrl)) { + redirect(res, baseUrl); + return; + } + + // We do the redirect ourselves for a good reason + // server-handler is annoying and won't include /baseUrl/ in redirects + // See https://github.com/meta/docusaurus/issues/10078#issuecomment-2084932934 + if (baseUrl !== '/') { + // Not super robust, but should be good enough for our use case + // See https://github.com/meta/docusaurus/pull/10090 + const looksLikeAsset = !!req.url.match(/\.[a-z\d]{1,4}$/i); + if (!looksLikeAsset) { + const normalizedUrl = applyTrailingSlash(req.url, { + trailingSlash, + baseUrl, + }); + if (req.url !== normalizedUrl) { + redirect(res, normalizedUrl); + return; + } + } + } + + // Remove baseUrl before calling serveHandler, because /baseUrl/ should + // serve /build/index.html, not /build/baseUrl/index.html (does not exist) + // Note server-handler is really annoying here: + // - no easy way to do rewrites such as "/baseUrl/:path" => "/:path" + // - no easy way to "reapply" the baseUrl to the redirect "Location" header + // See also https://github.com/meta/docusaurus/pull/10090 + req.url = req.url.replace(baseUrl, '/'); + + await serveHandler(req, res, { + cleanUrls: true, + public: outDir, + trailingSlash, + directoryListing: false, + }); + }; +} + +function redirect(res: http.ServerResponse, location: string) { + res.writeHead(302, { + Location: location, + }); + res.end(); +} diff --git a/packages/docusaurus/src/commands/serve/serve.ts b/packages/docusaurus/src/commands/serve/serve.ts index 21c317d565c7..b43a9078f875 100644 --- a/packages/docusaurus/src/commands/serve/serve.ts +++ b/packages/docusaurus/src/commands/serve/serve.ts @@ -9,23 +9,15 @@ import http from 'node:http'; import path from 'node:path'; import logger from '@docusaurus/logger'; import {DEFAULT_BUILD_DIR_NAME} from '@docusaurus/utils'; -import serveHandler from 'serve-handler'; -import {applyTrailingSlash} from '@docusaurus/utils-common'; import {realpath} from '@docusaurus/fs'; import openBrowser from '../utils/openBrowser/openBrowser'; import {loadSiteConfig} from '../../server/config'; import {build} from '../build/build'; import {getHostPort, type HostPortOptions} from '../../server/getHostPort'; import {listenToServer} from '../utils/listenToServer'; +import {createServeRequestHandler} from './requestHandler'; import type {LoadContextParams} from '../../server/site'; -function redirect(res: http.ServerResponse, location: string) { - res.writeHead(302, { - Location: location, - }); - res.end(); -} - export type ServeCLIOptions = HostPortOptions & Pick & { dir?: string; @@ -64,47 +56,9 @@ export async function serve( const servingUrl = `http://${host}:${port}`; - const server = http.createServer((req, res) => { - // Automatically redirect requests to /baseUrl/ - if (!req.url?.startsWith(baseUrl)) { - redirect(res, baseUrl); - return; - } - - // We do the redirect ourselves for a good reason - // server-handler is annoying and won't include /baseUrl/ in redirects - // See https://github.com/meta/docusaurus/issues/10078#issuecomment-2084932934 - if (baseUrl !== '/') { - // Not super robust, but should be good enough for our use case - // See https://github.com/meta/docusaurus/pull/10090 - const looksLikeAsset = !!req.url.match(/\.[a-z\d]{1,4}$/i); - if (!looksLikeAsset) { - const normalizedUrl = applyTrailingSlash(req.url, { - trailingSlash, - baseUrl, - }); - if (req.url !== normalizedUrl) { - redirect(res, normalizedUrl); - return; - } - } - } - - // Remove baseUrl before calling serveHandler, because /baseUrl/ should - // serve /build/index.html, not /build/baseUrl/index.html (does not exist) - // Note server-handler is really annoying here: - // - no easy way to do rewrites such as "/baseUrl/:path" => "/:path" - // - no easy way to "reapply" the baseUrl to the redirect "Location" header - // See also https://github.com/meta/docusaurus/pull/10090 - req.url = req.url.replace(baseUrl, '/'); - - serveHandler(req, res, { - cleanUrls: true, - public: outDir, - trailingSlash, - directoryListing: false, - }); - }); + const server = http.createServer( + createServeRequestHandler({outDir, baseUrl, trailingSlash}), + ); const url = servingUrl + baseUrl; logger.success`Serving path=${buildDir} directory at: url=${url}`; diff --git a/project-words.txt b/project-words.txt index e085dc646771..34c7533e7635 100644 --- a/project-words.txt +++ b/project-words.txt @@ -84,6 +84,7 @@ ERRNAMETOOLONG etlua externalwaiting failfast +fdocusaurus Fienny Foldr forwardable @@ -352,3 +353,4 @@ zedspencermilnes Zhou zpao ödingers +файл From b2f3b65d9adf361beb90a138c02f86aba70d2b5c Mon Sep 17 00:00:00 2001 From: slorber <749374+slorber@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:46:12 +0000 Subject: [PATCH 03/15] refactor: apply lint autofix --- project-words.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project-words.txt b/project-words.txt index 34c7533e7635..2bad384e1098 100644 --- a/project-words.txt +++ b/project-words.txt @@ -84,7 +84,7 @@ ERRNAMETOOLONG etlua externalwaiting failfast -fdocusaurus +Fdocusaurus Fienny Foldr forwardable From babf0583c20844594d44311715d6ffa981c11989 Mon Sep 17 00:00:00 2001 From: sebastien Date: Thu, 5 Nov 2026 16:48:29 +0100 Subject: [PATCH 04/15] test(core): skip the serve snapshot that differs on Windows (AI-assisted) Under a baseUrl with trailingSlash: true, serve adds a trailing slash to some existing file URLs. Windows then serves the file, other platforms 404. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/serve/__tests__/requestHandler.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index 1d0d8e97e5f3..d2664ea2e005 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -233,7 +233,8 @@ describe('createServeRequestHandler', () => { `); }); - it('trailingSlash true', async () => { + // Windows serves file paths with a trailing slash, other platforms 404 + it.skipIf(process.platform === 'win32')('trailingSlash true', async () => { const result = await describeServing({ baseUrl: '/base/', trailingSlash: true, From d2d259ba3b3d5d406e1ccaf8ad6cf267af989009 Mon Sep 17 00:00:00 2001 From: sebastien Date: Thu, 5 Nov 2026 17:00:23 +0100 Subject: [PATCH 05/15] test(core): check that serve --build builds the --dir it serves (AI-assisted) Co-Authored-By: Claude Opus 5.5 --- .../src/commands/serve/__tests__/serve.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/docusaurus/src/commands/serve/__tests__/serve.test.ts b/packages/docusaurus/src/commands/serve/__tests__/serve.test.ts index 52aca3b773aa..fabdd05e6034 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/serve.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/serve.test.ts @@ -72,13 +72,20 @@ describe('serve', () => { ); }); - it('builds the site in the served directory with --build', async () => { - await serveFixtureSite({build: true, config: 'custom.config.js'}); + it('builds the site in the served --dir directory with --build', async () => { + const origin = await serveFixtureSite({ + build: true, + dir: 'build/folder', + config: 'custom.config.js', + }); const siteDir = await realpath(fixtureSiteDir); expect(build).toHaveBeenCalledWith(siteDir, { config: 'custom.config.js', - outDir: path.join(siteDir, 'build'), + outDir: path.join(siteDir, 'build/folder'), }); + await expect(describeRequest(origin, '/custom/')).resolves.toBe( + '200 folder/index.html', + ); }); }); From 8a3497388917c77b1de983ef066e41d207b77c81 Mon Sep 17 00:00:00 2001 From: sebastien Date: Thu, 5 Nov 2026 17:59:13 +0100 Subject: [PATCH 06/15] test(core): ignore the encoded path spelling in the serve tests only (AI-assisted) CSpell reads `%2Fdocusaurus` as the word `Fdocusaurus`: keep it out of the project words. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/serve/__tests__/requestHandler.test.ts | 1 + project-words.txt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index d2664ea2e005..63d19c5a7a92 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -58,6 +58,7 @@ const RequestPaths = [ '//file', '/%E0%A4%A', // Malformed percent-encoding + // cspell:ignore Fdocusaurus '/..%2Fdocusaurus.config.js', // Path traversal ]; diff --git a/project-words.txt b/project-words.txt index 2bad384e1098..ddcd760b6159 100644 --- a/project-words.txt +++ b/project-words.txt @@ -84,7 +84,6 @@ ERRNAMETOOLONG etlua externalwaiting failfast -Fdocusaurus Fienny Foldr forwardable From 7c8d318c5b393436c8f4e295e4d35cb3d877c763 Mon Sep 17 00:00:00 2001 From: sebastien Date: Thu, 5 Nov 2026 18:00:34 +0100 Subject: [PATCH 07/15] refactor(core): move listenToServer into the serve command folder (AI-assisted) serve is its only user. clearPath (clear + build) and openBrowser (start + serve) are shared, so they stay in commands/utils. Co-Authored-By: Claude Opus 5.5 --- .../docusaurus/src/commands/{utils => serve}/listenToServer.ts | 0 packages/docusaurus/src/commands/serve/serve.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/docusaurus/src/commands/{utils => serve}/listenToServer.ts (100%) diff --git a/packages/docusaurus/src/commands/utils/listenToServer.ts b/packages/docusaurus/src/commands/serve/listenToServer.ts similarity index 100% rename from packages/docusaurus/src/commands/utils/listenToServer.ts rename to packages/docusaurus/src/commands/serve/listenToServer.ts diff --git a/packages/docusaurus/src/commands/serve/serve.ts b/packages/docusaurus/src/commands/serve/serve.ts index 21c317d565c7..09a4dfb92989 100644 --- a/packages/docusaurus/src/commands/serve/serve.ts +++ b/packages/docusaurus/src/commands/serve/serve.ts @@ -16,7 +16,7 @@ import openBrowser from '../utils/openBrowser/openBrowser'; import {loadSiteConfig} from '../../server/config'; import {build} from '../build/build'; import {getHostPort, type HostPortOptions} from '../../server/getHostPort'; -import {listenToServer} from '../utils/listenToServer'; +import {listenToServer} from './listenToServer'; import type {LoadContextParams} from '../../server/site'; function redirect(res: http.ServerResponse, location: string) { From 357921d2aa043f60b698706c121dc85324f4a0c1 Mon Sep 17 00:00:00 2001 From: sebastien Date: Thu, 5 Nov 2026 18:11:37 +0100 Subject: [PATCH 08/15] refactor(core): extract the serve request handler into its own module (AI-assisted) createServeRequestHandler({outDir, baseUrl, trailingSlash}) receives explicit args instead of closing over serve() variables, so it can be unit tested. The handler body is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/serve/requestHandler.ts | 74 +++++++++++++++++++ .../docusaurus/src/commands/serve/serve.ts | 54 +------------- 2 files changed, 78 insertions(+), 50 deletions(-) create mode 100644 packages/docusaurus/src/commands/serve/requestHandler.ts diff --git a/packages/docusaurus/src/commands/serve/requestHandler.ts b/packages/docusaurus/src/commands/serve/requestHandler.ts new file mode 100644 index 000000000000..8437b0ccca2c --- /dev/null +++ b/packages/docusaurus/src/commands/serve/requestHandler.ts @@ -0,0 +1,74 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import serveHandler from 'serve-handler'; +import {applyTrailingSlash} from '@docusaurus/utils-common'; +import type http from 'node:http'; +import type {DocusaurusConfig} from '@docusaurus/types'; + +type ServeRequestHandler = ( + req: http.IncomingMessage, + res: http.ServerResponse, +) => Promise; + +export function createServeRequestHandler({ + outDir, + baseUrl, + trailingSlash, +}: {outDir: string} & Pick< + DocusaurusConfig, + 'baseUrl' | 'trailingSlash' +>): ServeRequestHandler { + return async (req, res) => { + // Automatically redirect requests to /baseUrl/ + if (!req.url?.startsWith(baseUrl)) { + redirect(res, baseUrl); + return; + } + + // We do the redirect ourselves for a good reason + // server-handler is annoying and won't include /baseUrl/ in redirects + // See https://github.com/meta/docusaurus/issues/10078#issuecomment-2084932934 + if (baseUrl !== '/') { + // Not super robust, but should be good enough for our use case + // See https://github.com/meta/docusaurus/pull/10090 + const looksLikeAsset = !!req.url.match(/\.[a-z\d]{1,4}$/i); + if (!looksLikeAsset) { + const normalizedUrl = applyTrailingSlash(req.url, { + trailingSlash, + baseUrl, + }); + if (req.url !== normalizedUrl) { + redirect(res, normalizedUrl); + return; + } + } + } + + // Remove baseUrl before calling serveHandler, because /baseUrl/ should + // serve /build/index.html, not /build/baseUrl/index.html (does not exist) + // Note server-handler is really annoying here: + // - no easy way to do rewrites such as "/baseUrl/:path" => "/:path" + // - no easy way to "reapply" the baseUrl to the redirect "Location" header + // See also https://github.com/meta/docusaurus/pull/10090 + req.url = req.url.replace(baseUrl, '/'); + + await serveHandler(req, res, { + cleanUrls: true, + public: outDir, + trailingSlash, + directoryListing: false, + }); + }; +} + +function redirect(res: http.ServerResponse, location: string) { + res.writeHead(302, { + Location: location, + }); + res.end(); +} diff --git a/packages/docusaurus/src/commands/serve/serve.ts b/packages/docusaurus/src/commands/serve/serve.ts index 09a4dfb92989..e60534c405df 100644 --- a/packages/docusaurus/src/commands/serve/serve.ts +++ b/packages/docusaurus/src/commands/serve/serve.ts @@ -9,23 +9,15 @@ import http from 'node:http'; import path from 'node:path'; import logger from '@docusaurus/logger'; import {DEFAULT_BUILD_DIR_NAME} from '@docusaurus/utils'; -import serveHandler from 'serve-handler'; -import {applyTrailingSlash} from '@docusaurus/utils-common'; import {realpath} from '@docusaurus/fs'; import openBrowser from '../utils/openBrowser/openBrowser'; import {loadSiteConfig} from '../../server/config'; import {build} from '../build/build'; import {getHostPort, type HostPortOptions} from '../../server/getHostPort'; import {listenToServer} from './listenToServer'; +import {createServeRequestHandler} from './requestHandler'; import type {LoadContextParams} from '../../server/site'; -function redirect(res: http.ServerResponse, location: string) { - res.writeHead(302, { - Location: location, - }); - res.end(); -} - export type ServeCLIOptions = HostPortOptions & Pick & { dir?: string; @@ -64,47 +56,9 @@ export async function serve( const servingUrl = `http://${host}:${port}`; - const server = http.createServer((req, res) => { - // Automatically redirect requests to /baseUrl/ - if (!req.url?.startsWith(baseUrl)) { - redirect(res, baseUrl); - return; - } - - // We do the redirect ourselves for a good reason - // server-handler is annoying and won't include /baseUrl/ in redirects - // See https://github.com/meta/docusaurus/issues/10078#issuecomment-2084932934 - if (baseUrl !== '/') { - // Not super robust, but should be good enough for our use case - // See https://github.com/meta/docusaurus/pull/10090 - const looksLikeAsset = !!req.url.match(/\.[a-z\d]{1,4}$/i); - if (!looksLikeAsset) { - const normalizedUrl = applyTrailingSlash(req.url, { - trailingSlash, - baseUrl, - }); - if (req.url !== normalizedUrl) { - redirect(res, normalizedUrl); - return; - } - } - } - - // Remove baseUrl before calling serveHandler, because /baseUrl/ should - // serve /build/index.html, not /build/baseUrl/index.html (does not exist) - // Note server-handler is really annoying here: - // - no easy way to do rewrites such as "/baseUrl/:path" => "/:path" - // - no easy way to "reapply" the baseUrl to the redirect "Location" header - // See also https://github.com/meta/docusaurus/pull/10090 - req.url = req.url.replace(baseUrl, '/'); - - serveHandler(req, res, { - cleanUrls: true, - public: outDir, - trailingSlash, - directoryListing: false, - }); - }); + const server = http.createServer( + createServeRequestHandler({outDir, baseUrl, trailingSlash}), + ); const url = servingUrl + baseUrl; logger.success`Serving path=${buildDir} directory at: url=${url}`; From 9bb24b12c981382686bc5ae5505e7791843369d1 Mon Sep 17 00:00:00 2001 From: sebastien Date: Thu, 5 Nov 2026 18:42:01 +0100 Subject: [PATCH 09/15] refactor(core): extract the serve redirect rules into a pure function (AI-assisted) getRedirectLocation(url, {baseUrl, trailingSlash}) returns where to redirect a request, or undefined to serve it. The rules are unchanged, and the handler only applies the result. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/serve/requestHandler.ts | 61 ++++++++++++------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/packages/docusaurus/src/commands/serve/requestHandler.ts b/packages/docusaurus/src/commands/serve/requestHandler.ts index 8437b0ccca2c..5ac45c8c26c1 100644 --- a/packages/docusaurus/src/commands/serve/requestHandler.ts +++ b/packages/docusaurus/src/commands/serve/requestHandler.ts @@ -24,38 +24,23 @@ export function createServeRequestHandler({ 'baseUrl' | 'trailingSlash' >): ServeRequestHandler { return async (req, res) => { - // Automatically redirect requests to /baseUrl/ - if (!req.url?.startsWith(baseUrl)) { - redirect(res, baseUrl); + const redirectLocation = getRedirectLocation(req.url, { + baseUrl, + trailingSlash, + }); + if (redirectLocation !== undefined) { + redirect(res, redirectLocation); return; } - // We do the redirect ourselves for a good reason - // server-handler is annoying and won't include /baseUrl/ in redirects - // See https://github.com/meta/docusaurus/issues/10078#issuecomment-2084932934 - if (baseUrl !== '/') { - // Not super robust, but should be good enough for our use case - // See https://github.com/meta/docusaurus/pull/10090 - const looksLikeAsset = !!req.url.match(/\.[a-z\d]{1,4}$/i); - if (!looksLikeAsset) { - const normalizedUrl = applyTrailingSlash(req.url, { - trailingSlash, - baseUrl, - }); - if (req.url !== normalizedUrl) { - redirect(res, normalizedUrl); - return; - } - } - } - // Remove baseUrl before calling serveHandler, because /baseUrl/ should // serve /build/index.html, not /build/baseUrl/index.html (does not exist) // Note server-handler is really annoying here: // - no easy way to do rewrites such as "/baseUrl/:path" => "/:path" // - no easy way to "reapply" the baseUrl to the redirect "Location" header // See also https://github.com/meta/docusaurus/pull/10090 - req.url = req.url.replace(baseUrl, '/'); + // Requests without url were redirected to the baseUrl + req.url = req.url!.replace(baseUrl, '/'); await serveHandler(req, res, { cleanUrls: true, @@ -66,6 +51,36 @@ export function createServeRequestHandler({ }; } +function getRedirectLocation( + url: string | undefined, + {baseUrl, trailingSlash}: Pick, +): string | undefined { + // Automatically redirect requests to /baseUrl/ + if (!url?.startsWith(baseUrl)) { + return baseUrl; + } + + // We do the redirect ourselves for a good reason + // server-handler is annoying and won't include /baseUrl/ in redirects + // See https://github.com/meta/docusaurus/issues/10078#issuecomment-2084932934 + if (baseUrl !== '/') { + // Not super robust, but should be good enough for our use case + // See https://github.com/meta/docusaurus/pull/10090 + const looksLikeAsset = !!url.match(/\.[a-z\d]{1,4}$/i); + if (!looksLikeAsset) { + const normalizedUrl = applyTrailingSlash(url, { + trailingSlash, + baseUrl, + }); + if (url !== normalizedUrl) { + return normalizedUrl; + } + } + } + + return undefined; +} + function redirect(res: http.ServerResponse, location: string) { res.writeHead(302, { Location: location, From f34eea3a56fc481a97fd9572cc3ef8871e051974 Mon Sep 17 00:00:00 2001 From: sebastien Date: Thu, 5 Nov 2026 19:10:43 +0100 Subject: [PATCH 10/15] test(core): focus the serve tests on the request handler, capture its bugs with TODOs (AI-assisted) - Remove the serve() wiring tests and their site configs: the request handler is what needs coverage now. The fixture build moves to __fixtures__/build. - Known bugs are now tests of the current buggy behavior, each with a TODO describing the fix, instead of it.fails() tests of the fixed behavior. Add the query string that serve-handler redirects drop. Co-Authored-By: Claude Opus 5.5 --- .../__fixtures__/{site => }/build/404.html | 0 .../{site => }/build/assets/css/styles.css | 0 .../__fixtures__/{site => }/build/both.html | 0 .../{site => }/build/both/index.html | 0 .../__fixtures__/build/caf\303\251.html" | 0 .../__fixtures__/{site => }/build/file.html | 0 .../{site => }/build/folder/index.html | 0 .../__fixtures__/{site => }/build/index.html | 0 .../{site => }/build/site.webmanifest | 0 .../{site => }/build/with space.html | 0 .../\321\204\320\260\320\271\320\273.html" | 0 .../__fixtures__/site/custom.config.js | 5 - .../__fixtures__/site/docusaurus.config.js | 6 -- .../serve/__tests__/requestHandler.test.ts | 56 ++++++++---- .../commands/serve/__tests__/serve.test.ts | 91 ------------------- .../src/commands/serve/__tests__/testUtils.ts | 3 +- 16 files changed, 37 insertions(+), 124 deletions(-) rename packages/docusaurus/src/commands/serve/__tests__/__fixtures__/{site => }/build/404.html (100%) rename packages/docusaurus/src/commands/serve/__tests__/__fixtures__/{site => }/build/assets/css/styles.css (100%) rename packages/docusaurus/src/commands/serve/__tests__/__fixtures__/{site => }/build/both.html (100%) rename packages/docusaurus/src/commands/serve/__tests__/__fixtures__/{site => }/build/both/index.html (100%) rename "packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/caf\303\251.html" => "packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/caf\303\251.html" (100%) rename packages/docusaurus/src/commands/serve/__tests__/__fixtures__/{site => }/build/file.html (100%) rename packages/docusaurus/src/commands/serve/__tests__/__fixtures__/{site => }/build/folder/index.html (100%) rename packages/docusaurus/src/commands/serve/__tests__/__fixtures__/{site => }/build/index.html (100%) rename packages/docusaurus/src/commands/serve/__tests__/__fixtures__/{site => }/build/site.webmanifest (100%) rename packages/docusaurus/src/commands/serve/__tests__/__fixtures__/{site => }/build/with space.html (100%) rename "packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/\321\204\320\260\320\271\320\273.html" => "packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/\321\204\320\260\320\271\320\273.html" (100%) delete mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/custom.config.js delete mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/docusaurus.config.js delete mode 100644 packages/docusaurus/src/commands/serve/__tests__/serve.test.ts diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/404.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/404.html similarity index 100% rename from packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/404.html rename to packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/404.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/assets/css/styles.css b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/css/styles.css similarity index 100% rename from packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/assets/css/styles.css rename to packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/css/styles.css diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/both.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/both.html similarity index 100% rename from packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/both.html rename to packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/both.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/both/index.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/both/index.html similarity index 100% rename from packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/both/index.html rename to packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/both/index.html diff --git "a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/caf\303\251.html" "b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/caf\303\251.html" similarity index 100% rename from "packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/caf\303\251.html" rename to "packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/caf\303\251.html" diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/file.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/file.html similarity index 100% rename from packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/file.html rename to packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/file.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/folder/index.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/folder/index.html similarity index 100% rename from packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/folder/index.html rename to packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/folder/index.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/index.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/index.html similarity index 100% rename from packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/index.html rename to packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/index.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/site.webmanifest b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/site.webmanifest similarity index 100% rename from packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/site.webmanifest rename to packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/site.webmanifest diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/with space.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/with space.html similarity index 100% rename from packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/with space.html rename to packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/with space.html diff --git "a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/\321\204\320\260\320\271\320\273.html" "b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/\321\204\320\260\320\271\320\273.html" similarity index 100% rename from "packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/build/\321\204\320\260\320\271\320\273.html" rename to "packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/\321\204\320\260\320\271\320\273.html" diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/custom.config.js b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/custom.config.js deleted file mode 100644 index b794684a769b..000000000000 --- a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/custom.config.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - title: 'Serve fixture site with a custom config', - url: 'https://example.com', - baseUrl: '/custom/', -}; diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/docusaurus.config.js b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/docusaurus.config.js deleted file mode 100644 index f28751716744..000000000000 --- a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/site/docusaurus.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - title: 'Serve fixture site', - url: 'https://example.com', - baseUrl: '/base/', - trailingSlash: false, -}; diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index c646e0b55610..79c295403292 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -334,45 +334,61 @@ describe('createServeRequestHandler', () => { }); }); - // Also captured by the snapshots above. it.fails() passes while the bug is - // there: fixing one turns its test red, replace it.fails() with it() + // These tests capture the buggy behavior, also visible in the snapshots above describe('known bugs', () => { + // TODO fix: serve café.html instead of crashing the server process // See https://github.com/vercel/serve-handler/issues/205 - it.fails('serves file names with Latin-1 characters', async () => { + it('crashes on file names with Latin-1 characters', async () => { await expect( serveRequest({baseUrl: '/', trailingSlash: undefined}, '/café'), - ).resolves.toBe('200 café.html'); + ).resolves.toBe('crash ERR_INVALID_CHAR'); }); + // TODO fix: keep the baseUrl, "301 /base/file → 200 file.html" // See https://github.com/meta/docusaurus/issues/10078#issuecomment-2084932934 - it.fails('keeps the baseUrl in serve-handler redirects', async () => { + it('drops the baseUrl in serve-handler redirects', async () => { await expect( serveRequest( {baseUrl: '/base/', trailingSlash: undefined}, '/base/file.html', ), - ).resolves.toBe('301 /base/file → 200 file.html'); + ).resolves.toBe('301 /file → 302 /base/ → 200 index.html'); }); - // The looksLikeAsset regex misses them, and adds a trailing slash - // See https://github.com/meta/docusaurus/pull/10090 - it.fails('serves assets with a query string under a baseUrl', async () => { + // TODO fix: keep the query string, "301 /file?query=1 → 200 file.html" + it('drops the query string in serve-handler redirects', async () => { await expect( serveRequest( - {baseUrl: '/base/', trailingSlash: true}, - '/base/assets/css/styles.css?v=1', + {baseUrl: '/', trailingSlash: undefined}, + '/file.html?query=1', ), - ).resolves.toBe('200 assets/css/styles.css'); + ).resolves.toBe('301 /file → 200 file.html'); }); - it.fails('serves assets with a long extension under a baseUrl', async () => { - await expect( - serveRequest( - {baseUrl: '/base/', trailingSlash: true}, - '/base/site.webmanifest', - ), - ).resolves.toBe('200 site.webmanifest'); - }); + // TODO fix: serve these assets without adding a trailing slash + // The looksLikeAsset regex misses them + // See https://github.com/meta/docusaurus/pull/10090 + // Windows serves file paths with a trailing slash, other platforms 404 + describe.skipIf(process.platform === 'win32')( + 'adds a trailing slash to some assets under a baseUrl', + () => { + const options: ServeOptions = {baseUrl: '/base/', trailingSlash: true}; + + it('with a query string', async () => { + await expect( + serveRequest(options, '/base/assets/css/styles.css?v=1'), + ).resolves.toBe( + '302 /base/assets/css/styles.css/?v=1 → 404 404.html', + ); + }); + + it('with a long extension', async () => { + await expect( + serveRequest(options, '/base/site.webmanifest'), + ).resolves.toBe('302 /base/site.webmanifest/ → 404 404.html'); + }); + }, + ); }); }); diff --git a/packages/docusaurus/src/commands/serve/__tests__/serve.test.ts b/packages/docusaurus/src/commands/serve/__tests__/serve.test.ts deleted file mode 100644 index fabdd05e6034..000000000000 --- a/packages/docusaurus/src/commands/serve/__tests__/serve.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import http from 'node:http'; -import path from 'node:path'; -import logger from '@docusaurus/logger'; -import {realpath} from '@docusaurus/fs'; -import {build} from '../../build/build'; -import {serve, type ServeCLIOptions} from '../serve'; -import {describeRequest, fixtureSiteDir} from './testUtils'; -import type {AddressInfo} from 'node:net'; - -vi.mock('../../../server/getHostPort', () => ({ - // Port 0 lets the OS pick a free port - getHostPort: async () => ({host: '127.0.0.1', port: 0}), -})); - -vi.mock('../../build/build', () => ({build: vi.fn()})); - -describe('serve', () => { - const servers: http.Server[] = []; - - beforeEach(() => { - vi.spyOn(logger, 'success').mockImplementation(() => {}); - }); - - afterEach(() => { - servers.forEach((server) => { - server.closeAllConnections(); - server.close(); - }); - servers.length = 0; - vi.restoreAllMocks(); - }); - - // Returns the origin of the server that serve() started - async function serveFixtureSite( - cliOptions: Partial = {}, - ): Promise { - const createServer = vi.spyOn(http, 'createServer'); - await serve(fixtureSiteDir, {open: false, ...cliOptions}); - - const server = createServer.mock.results[0]!.value as http.Server; - servers.push(server); - const {port} = server.address() as AddressInfo; - return `http://127.0.0.1:${port}`; - } - - it('serves the build directory with the baseUrl and trailingSlash of the site config', async () => { - const origin = await serveFixtureSite(); - await expect(describeRequest(origin, '/base/file/')).resolves.toBe( - '302 /base/file → 200 file.html', - ); - }); - - it('serves the --dir directory', async () => { - const origin = await serveFixtureSite({dir: 'build/folder'}); - await expect(describeRequest(origin, '/base/')).resolves.toBe( - '200 folder/index.html', - ); - }); - - it('loads the --config site config', async () => { - const origin = await serveFixtureSite({config: 'custom.config.js'}); - await expect(describeRequest(origin, '/custom/file')).resolves.toBe( - '200 file.html', - ); - }); - - it('builds the site in the served --dir directory with --build', async () => { - const origin = await serveFixtureSite({ - build: true, - dir: 'build/folder', - config: 'custom.config.js', - }); - - const siteDir = await realpath(fixtureSiteDir); - expect(build).toHaveBeenCalledWith(siteDir, { - config: 'custom.config.js', - outDir: path.join(siteDir, 'build/folder'), - }); - await expect(describeRequest(origin, '/custom/')).resolves.toBe( - '200 folder/index.html', - ); - }); -}); diff --git a/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts b/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts index 0aeda9379d79..1905cd0a293f 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts @@ -8,8 +8,7 @@ import path from 'node:path'; import {pathExists} from '@docusaurus/fs'; -export const fixtureSiteDir = path.join(__dirname, '__fixtures__/site'); -export const fixtureOutDir = path.join(fixtureSiteDir, 'build'); +export const fixtureOutDir = path.join(__dirname, '__fixtures__/build'); // Added by test servers when the request handler rejects export const CrashHeader = 'x-test-crash'; From a4797feb2a955c50dc07e67a39451c73aad03b27 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 11:09:14 +0100 Subject: [PATCH 11/15] test(core): capture how serve handles asset extensions of 1 to 5 characters (AI-assisted) Add .c, .pdf, .jpeg and .woff2 fixture assets. With a baseUrl and trailingSlash: true, the looksLikeAsset regex misses the 5-character .woff2 extension: capture this bug with a TODO, like .webmanifest. Co-Authored-By: Claude Opus 5.5 --- .../build/assets/files/document.pdf | Bin 0 -> 26 bytes .../__fixtures__/build/assets/files/main.c | 1 + .../build/assets/fonts/font.woff2 | 1 + .../build/assets/images/photo.jpeg | 1 + .../serve/__tests__/requestHandler.test.ts | 34 +++++++++++++++++- 5 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/files/document.pdf create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/files/main.c create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/fonts/font.woff2 create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/images/photo.jpeg diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/files/document.pdf b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/files/document.pdf new file mode 100644 index 0000000000000000000000000000000000000000..cadc432f2545ea453f1940258412617650c942f8 GIT binary patch literal 26 hcmYc+E>0~e)=$gKNiEh-$xklLP0cIOD@aM>0sx9u3Dy7r literal 0 HcmV?d00001 diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/files/main.c b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/files/main.c new file mode 100644 index 000000000000..e5a13d14fdf9 --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/files/main.c @@ -0,0 +1 @@ +assets/files/main.c diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/fonts/font.woff2 b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/fonts/font.woff2 new file mode 100644 index 000000000000..d32448578df1 --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/fonts/font.woff2 @@ -0,0 +1 @@ +assets/fonts/font.woff2 diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/images/photo.jpeg b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/images/photo.jpeg new file mode 100644 index 000000000000..233fd1f21999 --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/assets/images/photo.jpeg @@ -0,0 +1 @@ +assets/images/photo.jpeg diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index 79c295403292..d2aad8524fe6 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -39,6 +39,11 @@ const RequestPaths = [ '/assets/css/styles.css', '/assets/css/styles.css?v=1', '/assets/', + // Extensions of 1, 3, 4, 5 and 11 characters + '/assets/files/main.c', + '/assets/files/document.pdf', + '/assets/images/photo.jpeg', + '/assets/fonts/font.woff2', '/site.webmanifest', '/missing', @@ -87,6 +92,10 @@ describe('createServeRequestHandler', () => { /assets/css/styles.css 200 assets/css/styles.css /assets/css/styles.css?v=1 200 assets/css/styles.css /assets/ 404 404.html + /assets/files/main.c 200 assets/files/main.c + /assets/files/document.pdf 200 assets/files/document.pdf + /assets/images/photo.jpeg 200 assets/images/photo.jpeg + /assets/fonts/font.woff2 200 assets/fonts/font.woff2 /site.webmanifest 200 site.webmanifest /missing 404 404.html /missing/ 404 404.html @@ -129,6 +138,10 @@ describe('createServeRequestHandler', () => { /assets/css/styles.css 200 assets/css/styles.css /assets/css/styles.css?v=1 200 assets/css/styles.css /assets/ 404 404.html + /assets/files/main.c 200 assets/files/main.c + /assets/files/document.pdf 200 assets/files/document.pdf + /assets/images/photo.jpeg 200 assets/images/photo.jpeg + /assets/fonts/font.woff2 200 assets/fonts/font.woff2 /site.webmanifest 200 site.webmanifest /missing 301 /missing/ → 404 404.html /missing/ 404 404.html @@ -171,6 +184,10 @@ describe('createServeRequestHandler', () => { /assets/css/styles.css 200 assets/css/styles.css /assets/css/styles.css?v=1 200 assets/css/styles.css /assets/ 301 /assets → 404 404.html + /assets/files/main.c 200 assets/files/main.c + /assets/files/document.pdf 200 assets/files/document.pdf + /assets/images/photo.jpeg 200 assets/images/photo.jpeg + /assets/fonts/font.woff2 200 assets/fonts/font.woff2 /site.webmanifest 200 site.webmanifest /missing 404 404.html /missing/ 301 /missing → 404 404.html @@ -215,6 +232,10 @@ describe('createServeRequestHandler', () => { /base/assets/css/styles.css 200 assets/css/styles.css /base/assets/css/styles.css?v=1 200 assets/css/styles.css /base/assets/ 404 404.html + /base/assets/files/main.c 200 assets/files/main.c + /base/assets/files/document.pdf 200 assets/files/document.pdf + /base/assets/images/photo.jpeg 200 assets/images/photo.jpeg + /base/assets/fonts/font.woff2 200 assets/fonts/font.woff2 /base/site.webmanifest 200 site.webmanifest /base/missing 404 404.html /base/missing/ 404 404.html @@ -258,6 +279,10 @@ describe('createServeRequestHandler', () => { /base/assets/css/styles.css 200 assets/css/styles.css /base/assets/css/styles.css?v=1 302 /base/assets/css/styles.css/?v=1 → 404 404.html /base/assets/ 404 404.html + /base/assets/files/main.c 200 assets/files/main.c + /base/assets/files/document.pdf 200 assets/files/document.pdf + /base/assets/images/photo.jpeg 200 assets/images/photo.jpeg + /base/assets/fonts/font.woff2 302 /base/assets/fonts/font.woff2/ → 404 404.html /base/site.webmanifest 302 /base/site.webmanifest/ → 404 404.html /base/missing 302 /base/missing/ → 404 404.html /base/missing/ 404 404.html @@ -300,6 +325,10 @@ describe('createServeRequestHandler', () => { /base/assets/css/styles.css 200 assets/css/styles.css /base/assets/css/styles.css?v=1 200 assets/css/styles.css /base/assets/ 302 /base/assets → 404 404.html + /base/assets/files/main.c 200 assets/files/main.c + /base/assets/files/document.pdf 200 assets/files/document.pdf + /base/assets/images/photo.jpeg 200 assets/images/photo.jpeg + /base/assets/fonts/font.woff2 200 assets/fonts/font.woff2 /base/site.webmanifest 200 site.webmanifest /base/missing 404 404.html /base/missing/ 302 /base/missing → 404 404.html @@ -382,7 +411,10 @@ describe('createServeRequestHandler', () => { ); }); - it('with a long extension', async () => { + it('with an extension longer than 4 characters', async () => { + await expect( + serveRequest(options, '/base/assets/fonts/font.woff2'), + ).resolves.toBe('302 /base/assets/fonts/font.woff2/ → 404 404.html'); await expect( serveRequest(options, '/base/site.webmanifest'), ).resolves.toBe('302 /base/site.webmanifest/ → 404 404.html'); From 1770a6b9637c86510d9bff59af9d16de695e1a31 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 11:15:19 +0100 Subject: [PATCH 12/15] test(core): capture that serve 404s folders with a dot in their name (AI-assisted) docs/2.x/index.html is never served, whatever the baseUrl and trailingSlash: serve-handler reads paths with an extension directly and skips their index.html. Capture this bug with a TODO. Co-Authored-By: Claude Opus 5.5 --- .../__fixtures__/build/docs/2.x/index.html | 1 + .../serve/__tests__/requestHandler.test.ts | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/docs/2.x/index.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/docs/2.x/index.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/docs/2.x/index.html new file mode 100644 index 000000000000..ab659e0512d7 --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/docs/2.x/index.html @@ -0,0 +1 @@ +docs/2.x/index.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index d2aad8524fe6..2a66f2a13ca8 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -36,6 +36,10 @@ const RequestPaths = [ '/both.html', '/both/index.html', + // A folder with a dot, like a docs version + '/docs/2.x', + '/docs/2.x/', + '/assets/css/styles.css', '/assets/css/styles.css?v=1', '/assets/', @@ -89,6 +93,8 @@ describe('createServeRequestHandler', () => { /both/ 200 both/index.html /both.html 301 /both → 200 both/index.html /both/index.html 301 /both/index → 301 /both → 200 both/index.html + /docs/2.x 404 404.html + /docs/2.x/ 404 404.html /assets/css/styles.css 200 assets/css/styles.css /assets/css/styles.css?v=1 200 assets/css/styles.css /assets/ 404 404.html @@ -135,6 +141,8 @@ describe('createServeRequestHandler', () => { /both/ 200 both/index.html /both.html 301 /both → 301 /both/ → 200 both/index.html /both/index.html 301 /both/index → 301 /both → 301 /both/ → 200 both/index.html + /docs/2.x 404 404.html + /docs/2.x/ 404 404.html /assets/css/styles.css 200 assets/css/styles.css /assets/css/styles.css?v=1 200 assets/css/styles.css /assets/ 404 404.html @@ -181,6 +189,8 @@ describe('createServeRequestHandler', () => { /both/ 301 /both → 200 both/index.html /both.html 301 /both → 200 both/index.html /both/index.html 301 /both/index → 301 /both → 200 both/index.html + /docs/2.x 404 404.html + /docs/2.x/ 301 /docs/2.x → 404 404.html /assets/css/styles.css 200 assets/css/styles.css /assets/css/styles.css?v=1 200 assets/css/styles.css /assets/ 301 /assets → 404 404.html @@ -229,6 +239,8 @@ describe('createServeRequestHandler', () => { /base/both/ 200 both/index.html /base/both.html 301 /both → 302 /base/ → 200 index.html /base/both/index.html 301 /both/index → 302 /base/ → 200 index.html + /base/docs/2.x 404 404.html + /base/docs/2.x/ 404 404.html /base/assets/css/styles.css 200 assets/css/styles.css /base/assets/css/styles.css?v=1 200 assets/css/styles.css /base/assets/ 404 404.html @@ -276,6 +288,8 @@ describe('createServeRequestHandler', () => { /base/both/ 200 both/index.html /base/both.html 301 /both → 302 /base/ → 200 index.html /base/both/index.html 301 /both/index → 302 /base/ → 200 index.html + /base/docs/2.x 404 404.html + /base/docs/2.x/ 404 404.html /base/assets/css/styles.css 200 assets/css/styles.css /base/assets/css/styles.css?v=1 302 /base/assets/css/styles.css/?v=1 → 404 404.html /base/assets/ 404 404.html @@ -322,6 +336,8 @@ describe('createServeRequestHandler', () => { /base/both/ 302 /base/both → 200 both/index.html /base/both.html 301 /both → 302 /base/ → 200 index.html /base/both/index.html 301 /both/index → 302 /base/ → 200 index.html + /base/docs/2.x 404 404.html + /base/docs/2.x/ 302 /base/docs/2.x → 404 404.html /base/assets/css/styles.css 200 assets/css/styles.css /base/assets/css/styles.css?v=1 200 assets/css/styles.css /base/assets/ 302 /base/assets → 404 404.html @@ -394,6 +410,14 @@ describe('createServeRequestHandler', () => { ).resolves.toBe('301 /file → 200 file.html'); }); + // TODO fix: serve docs/2.x/index.html, like the index.html of any folder + // serve-handler doesn't look for it when the path has an extension (.x) + it('does not serve folders with a dot in their name', async () => { + await expect( + serveRequest({baseUrl: '/', trailingSlash: undefined}, '/docs/2.x/'), + ).resolves.toBe('404 404.html'); + }); + // TODO fix: serve these assets without adding a trailing slash // The looksLikeAsset regex misses them // See https://github.com/meta/docusaurus/pull/10090 From 7b848dd183db2de68191cf6b73f5d888b00ced40 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 11:16:45 +0100 Subject: [PATCH 13/15] test(core): move the serve test helpers into testUtils (AI-assisted) The test file keeps the request paths and the test cases only. describeServing() receives the request paths as an argument. Co-Authored-By: Claude Opus 5.5 --- .../serve/__tests__/requestHandler.test.ts | 130 ++++-------------- .../src/commands/serve/__tests__/testUtils.ts | 92 ++++++++++++- 2 files changed, 118 insertions(+), 104 deletions(-) diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index 2a66f2a13ca8..3244e212d55f 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -6,14 +6,12 @@ */ import {describe, expect, it} from 'vitest'; -import http from 'node:http'; -import {listenToServer} from '../listenToServer'; -import {createServeRequestHandler} from '../requestHandler'; -import {CrashHeader, describeRequest, fixtureOutDir} from './testUtils'; -import type {AddressInfo} from 'node:net'; -import type {DocusaurusConfig} from '@docusaurus/types'; - -type ServeOptions = Pick; +import { + describeRequests, + describeServing, + serveRequest, + type ServeOptions, +} from './testUtils'; // Appended to the baseUrl. The fixture build extends the static deployment of // https://github.com/slorber/trailing-slash-guide @@ -74,10 +72,10 @@ const RequestPaths = [ describe('createServeRequestHandler', () => { describe('baseUrl /', () => { it('trailingSlash undefined', async () => { - const result = await describeServing({ - baseUrl: '/', - trailingSlash: undefined, - }); + const result = await describeServing( + {baseUrl: '/', trailingSlash: undefined}, + RequestPaths, + ); expect(result).toMatchInlineSnapshot(` "/ 200 index.html /index 301 / → 200 index.html @@ -122,10 +120,10 @@ describe('createServeRequestHandler', () => { }); it('trailingSlash true', async () => { - const result = await describeServing({ - baseUrl: '/', - trailingSlash: true, - }); + const result = await describeServing( + {baseUrl: '/', trailingSlash: true}, + RequestPaths, + ); expect(result).toMatchInlineSnapshot(` "/ 200 index.html /index 301 / → 200 index.html @@ -170,10 +168,10 @@ describe('createServeRequestHandler', () => { }); it('trailingSlash false', async () => { - const result = await describeServing({ - baseUrl: '/', - trailingSlash: false, - }); + const result = await describeServing( + {baseUrl: '/', trailingSlash: false}, + RequestPaths, + ); expect(result).toMatchInlineSnapshot(` "/ 200 index.html /index 301 / → 200 index.html @@ -220,10 +218,10 @@ describe('createServeRequestHandler', () => { describe('baseUrl /base/', () => { it('trailingSlash undefined', async () => { - const result = await describeServing({ - baseUrl: '/base/', - trailingSlash: undefined, - }); + const result = await describeServing( + {baseUrl: '/base/', trailingSlash: undefined}, + RequestPaths, + ); expect(result).toMatchInlineSnapshot(` "/base/ 200 index.html /base/index 301 / → 302 /base/ → 200 index.html @@ -269,10 +267,10 @@ describe('createServeRequestHandler', () => { // Windows serves file paths with a trailing slash, other platforms 404 it.skipIf(process.platform === 'win32')('trailingSlash true', async () => { - const result = await describeServing({ - baseUrl: '/base/', - trailingSlash: true, - }); + const result = await describeServing( + {baseUrl: '/base/', trailingSlash: true}, + RequestPaths, + ); expect(result).toMatchInlineSnapshot(` "/base/ 200 index.html /base/index 302 /base/index/ → 200 index.html @@ -317,10 +315,10 @@ describe('createServeRequestHandler', () => { }); it('trailingSlash false', async () => { - const result = await describeServing({ - baseUrl: '/base/', - trailingSlash: false, - }); + const result = await describeServing( + {baseUrl: '/base/', trailingSlash: false}, + RequestPaths, + ); expect(result).toMatchInlineSnapshot(` "/base/ 200 index.html /base/index 301 / → 302 /base/ → 200 index.html @@ -447,71 +445,3 @@ describe('createServeRequestHandler', () => { ); }); }); - -async function describeServing(options: ServeOptions): Promise { - const baseUrlPrefix = options.baseUrl.replace(/\/$/, ''); - const paths = RequestPaths.map((requestPath) => baseUrlPrefix + requestPath); - return describeRequests(options, paths); -} - -// One line per request, aligned to be easy to review in snapshots -async function describeRequests( - options: ServeOptions, - paths: string[], -): Promise { - const descriptions = await withServer(options, (origin) => - Promise.all(paths.map((path) => describeRequest(origin, path))), - ); - const pathWidth = Math.max(...paths.map((path) => path.length)); - return paths - .map((path, index) => `${path.padEnd(pathWidth)} ${descriptions[index]}`) - .join('\n'); -} - -async function serveRequest( - options: ServeOptions, - path: string, -): Promise { - return withServer(options, (origin) => describeRequest(origin, path)); -} - -async function withServer( - options: ServeOptions, - callback: (origin: string) => Promise, -): Promise { - const handleRequest = createServeRequestHandler({ - outDir: fixtureOutDir, - ...options, - }); - const server = http.createServer((req, res) => { - handleRequest(req, res).catch((error: NodeJS.ErrnoException) => - respondWithCrash(res, error), - ); - }); - - await listenToServer({server, host: '127.0.0.1', port: 0}); - try { - const {port} = server.address() as AddressInfo; - return await callback(`http://127.0.0.1:${port}`); - } finally { - server.closeAllConnections(); - server.close(); - } -} - -// In production, this rejection is unhandled and crashes the server process -function respondWithCrash( - res: http.ServerResponse, - error: NodeJS.ErrnoException, -) { - if (res.headersSent) { - res.destroy(); - return; - } - // A failed writeHead() can leave the Content-Length of the file to serve - res.writeHead(500, { - [CrashHeader]: error.code ?? 'unknown', - 'Content-Length': 0, - }); - res.end(); -} diff --git a/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts b/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts index 1905cd0a293f..523c9f973791 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/testUtils.ts @@ -5,21 +5,105 @@ * LICENSE file in the root directory of this source tree. */ +import http from 'node:http'; import path from 'node:path'; import {pathExists} from '@docusaurus/fs'; +import {listenToServer} from '../listenToServer'; +import {createServeRequestHandler} from '../requestHandler'; +import type {AddressInfo} from 'node:net'; +import type {DocusaurusConfig} from '@docusaurus/types'; -export const fixtureOutDir = path.join(__dirname, '__fixtures__/build'); +export type ServeOptions = Pick; -// Added by test servers when the request handler rejects -export const CrashHeader = 'x-test-crash'; +const fixtureOutDir = path.join(__dirname, '__fixtures__/build'); + +// Added by the test server when the request handler rejects +const CrashHeader = 'x-test-crash'; const MaxRedirects = 5; +// The request paths are appended to the baseUrl +export async function describeServing( + options: ServeOptions, + requestPaths: string[], +): Promise { + const baseUrlPrefix = options.baseUrl.replace(/\/$/, ''); + const paths = requestPaths.map((requestPath) => baseUrlPrefix + requestPath); + return describeRequests(options, paths); +} + +// One line per request, aligned to be easy to review in snapshots +export async function describeRequests( + options: ServeOptions, + paths: string[], +): Promise { + const descriptions = await withServer(options, (origin) => + Promise.all( + paths.map((requestPath) => describeRequest(origin, requestPath)), + ), + ); + const pathWidth = Math.max(...paths.map((requestPath) => requestPath.length)); + return paths + .map( + (requestPath, index) => + `${requestPath.padEnd(pathWidth)} ${descriptions[index]}`, + ) + .join('\n'); +} + +export async function serveRequest( + options: ServeOptions, + requestPath: string, +): Promise { + return withServer(options, (origin) => describeRequest(origin, requestPath)); +} + +async function withServer( + options: ServeOptions, + callback: (origin: string) => Promise, +): Promise { + const handleRequest = createServeRequestHandler({ + outDir: fixtureOutDir, + ...options, + }); + const server = http.createServer((req, res) => { + handleRequest(req, res).catch((error: NodeJS.ErrnoException) => + respondWithCrash(res, error), + ); + }); + + await listenToServer({server, host: '127.0.0.1', port: 0}); + try { + const {port} = server.address() as AddressInfo; + return await callback(`http://127.0.0.1:${port}`); + } finally { + server.closeAllConnections(); + server.close(); + } +} + +// In production, this rejection is unhandled and crashes the server process +function respondWithCrash( + res: http.ServerResponse, + error: NodeJS.ErrnoException, +) { + if (res.headersSent) { + res.destroy(); + return; + } + // A failed writeHead() can leave the Content-Length of the file to serve + res.writeHead(500, { + [CrashHeader]: error.code ?? 'unknown', + 'Content-Length': 0, + }); + res.end(); +} + /** * Requests a path and follows its redirects, to describe how it's served: * "301 /file → 200 file.html" */ -export async function describeRequest( +async function describeRequest( origin: string, requestPath: string, ): Promise { From 9f9d40a4fe47e4d6211ba6865abdabd3574b8c07 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 12:01:50 +0100 Subject: [PATCH 14/15] test(core): tie the serve Windows skips to the looksLikeAsset bug (AI-assisted) Co-Authored-By: Claude Opus 5.5 --- .../src/commands/serve/__tests__/requestHandler.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index 3244e212d55f..77ba8b61c684 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -265,7 +265,8 @@ describe('createServeRequestHandler', () => { `); }); - // Windows serves file paths with a trailing slash, other platforms 404 + // TODO remove the skip once looksLikeAsset is fixed: it adds a trailing + // slash to some file URLs, which Windows serves while other platforms 404 it.skipIf(process.platform === 'win32')('trailingSlash true', async () => { const result = await describeServing( {baseUrl: '/base/', trailingSlash: true}, @@ -419,7 +420,7 @@ describe('createServeRequestHandler', () => { // TODO fix: serve these assets without adding a trailing slash // The looksLikeAsset regex misses them // See https://github.com/meta/docusaurus/pull/10090 - // Windows serves file paths with a trailing slash, other platforms 404 + // Skipped on Windows until the fix: it serves URLs with a trailing slash describe.skipIf(process.platform === 'win32')( 'adds a trailing slash to some assets under a baseUrl', () => { From 029d9b10ffcce5d655f36b1240a70c18ef91ecfd Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 12:04:15 +0100 Subject: [PATCH 15/15] test(core): capture the serve looksLikeAsset bug on Windows too (AI-assisted) Windows serves file URLs with a trailing slash, other platforms 404. Co-Authored-By: Claude Opus 5.5 --- .../serve/__tests__/requestHandler.test.ts | 105 +++++++++++++++--- 1 file changed, 92 insertions(+), 13 deletions(-) diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index 77ba8b61c684..0a3e5893deec 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -265,8 +265,8 @@ describe('createServeRequestHandler', () => { `); }); - // TODO remove the skip once looksLikeAsset is fixed: it adds a trailing - // slash to some file URLs, which Windows serves while other platforms 404 + // Windows serves file URLs with a trailing slash, other platforms 404 + // TODO merge once looksLikeAsset is fixed and doesn't add one to files it.skipIf(process.platform === 'win32')('trailingSlash true', async () => { const result = await describeServing( {baseUrl: '/base/', trailingSlash: true}, @@ -315,6 +315,57 @@ describe('createServeRequestHandler', () => { `); }); + it.skipIf(process.platform !== 'win32')( + 'trailingSlash true, on Windows', + async () => { + const result = await describeServing( + {baseUrl: '/base/', trailingSlash: true}, + RequestPaths, + ); + expect(result).toMatchInlineSnapshot(` + "/base/ 200 index.html + /base/index 302 /base/index/ → 200 index.html + /base/index.html 301 /index → 302 /base/ → 200 index.html + /base/file 302 /base/file/ → 200 file.html + /base/file/ 200 file.html + /base/file.html 301 /file → 302 /base/ → 200 index.html + /base/folder 302 /base/folder/ → 200 folder/index.html + /base/folder/ 200 folder/index.html + /base/folder/index 302 /base/folder/index/ → 200 folder/index.html + /base/folder/index.html 301 /folder/index → 302 /base/ → 200 index.html + /base/both 302 /base/both/ → 200 both/index.html + /base/both/ 200 both/index.html + /base/both.html 301 /both → 302 /base/ → 200 index.html + /base/both/index.html 301 /both/index → 302 /base/ → 200 index.html + /base/docs/2.x 404 404.html + /base/docs/2.x/ 404 404.html + /base/assets/css/styles.css 200 assets/css/styles.css + /base/assets/css/styles.css?v=1 302 /base/assets/css/styles.css/?v=1 → 200 assets/css/styles.css + /base/assets/ 404 404.html + /base/assets/files/main.c 200 assets/files/main.c + /base/assets/files/document.pdf 200 assets/files/document.pdf + /base/assets/images/photo.jpeg 200 assets/images/photo.jpeg + /base/assets/fonts/font.woff2 302 /base/assets/fonts/font.woff2/ → 200 assets/fonts/font.woff2 + /base/site.webmanifest 302 /base/site.webmanifest/ → 200 site.webmanifest + /base/missing 302 /base/missing/ → 404 404.html + /base/missing/ 404 404.html + /base/missing.html 301 /missing → 302 /base/ → 200 index.html + /base/file?query=1 302 /base/file/?query=1 → 200 file.html + /base/file/?query=1 200 file.html + /base/file.html?query=1 302 /base/file.html/?query=1 → 200 file.html + /base/folder?query=1 302 /base/folder/?query=1 → 200 folder/index.html + /base/with%20space 302 /base/with%20space/ → 200 with space.html + /base/with%20space.html 301 /with%20space → 302 /base/ → 200 index.html + /base/файл 302 /base/%D1%84%D0%B0%D0%B9%D0%BB/ → 200 файл.html + /base/файл.html 301 /%D1%84%D0%B0%D0%B9%D0%BB → 302 /base/ → 200 index.html + /base/café 302 /base/caf%C3%A9/ → crash ERR_INVALID_CHAR + /base//file 302 /base//file/ → 301 /file/ → 302 /base/ → 200 index.html + /base/%E0%A4%A 302 /base/%E0%A4%A/ → 400 Bad Request + /base/..%2Fdocusaurus.config.js 400 Bad Request" + `); + }, + ); + it('trailingSlash false', async () => { const result = await describeServing( {baseUrl: '/base/', trailingSlash: false}, @@ -420,29 +471,57 @@ describe('createServeRequestHandler', () => { // TODO fix: serve these assets without adding a trailing slash // The looksLikeAsset regex misses them // See https://github.com/meta/docusaurus/pull/10090 - // Skipped on Windows until the fix: it serves URLs with a trailing slash - describe.skipIf(process.platform === 'win32')( - 'adds a trailing slash to some assets under a baseUrl', - () => { - const options: ServeOptions = {baseUrl: '/base/', trailingSlash: true}; + // Windows serves file URLs with a trailing slash, other platforms 404 + describe('adds a trailing slash to some assets under a baseUrl', () => { + const options: ServeOptions = {baseUrl: '/base/', trailingSlash: true}; - it('with a query string', async () => { + it.skipIf(process.platform === 'win32')( + 'with a query string', + async () => { await expect( serveRequest(options, '/base/assets/css/styles.css?v=1'), ).resolves.toBe( '302 /base/assets/css/styles.css/?v=1 → 404 404.html', ); - }); + }, + ); - it('with an extension longer than 4 characters', async () => { + it.skipIf(process.platform !== 'win32')( + 'with a query string, on Windows', + async () => { + await expect( + serveRequest(options, '/base/assets/css/styles.css?v=1'), + ).resolves.toBe( + '302 /base/assets/css/styles.css/?v=1 → 200 assets/css/styles.css', + ); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'with an extension longer than 4 characters', + async () => { await expect( serveRequest(options, '/base/assets/fonts/font.woff2'), ).resolves.toBe('302 /base/assets/fonts/font.woff2/ → 404 404.html'); await expect( serveRequest(options, '/base/site.webmanifest'), ).resolves.toBe('302 /base/site.webmanifest/ → 404 404.html'); - }); - }, - ); + }, + ); + + it.skipIf(process.platform !== 'win32')( + 'with an extension longer than 4 characters, on Windows', + async () => { + await expect( + serveRequest(options, '/base/assets/fonts/font.woff2'), + ).resolves.toBe( + '302 /base/assets/fonts/font.woff2/ → 200 assets/fonts/font.woff2', + ); + await expect( + serveRequest(options, '/base/site.webmanifest'), + ).resolves.toBe('302 /base/site.webmanifest/ → 200 site.webmanifest'); + }, + ); + }); }); });