diff --git a/docs/guide/migration.md b/docs/guide/migration.md index a1510c98ca9994..16f63bdceb2e57 100644 --- a/docs/guide/migration.md +++ b/docs/guide/migration.md @@ -102,10 +102,29 @@ In Vite 4, `worker.plugins` accepted an array of plugins (`(Plugin | Plugin[])[] ### Allow path containing `.` to fallback to index.html -In Vite 4, accessing a path containing `.` did not fallback to index.html even if `appType` is set to `'SPA'` (default). -From Vite 5, it will fallback to index.html. +In Vite 4, accessing a path in dev containing `.` did not fallback to index.html even if `appType` is set to `'spa'` (default). From Vite 5, it will fallback to index.html. -Note that the browser will no longer show the 404 error message in the console if you point the image path to a non-existent file (e.g. ``). +Note that the browser will no longer show a 404 error message in the console if you point the image path to a non-existent file (e.g. ``). + +### Align dev and preview HTML serving behaviour + +In Vite 4, the dev and preview servers serve HTML based on its directory structure and trailing slash differently. This causes inconsistencies when testing your built app. Vite 5 refactors into a single behaviour like below, given the following file structure: + +``` +├── index.html +├── file.html +└── dir + └── index.html +``` + +| Request | Before (dev) | Before (preview) | After (dev & preview) | +| ----------------- | ---------------------------- | ----------------- | ---------------------------- | +| `/dir/index.html` | `/dir/index.html` | `/dir/index.html` | `/dir/index.html` | +| `/dir` | `/index.html` (SPA fallback) | `/dir/index.html` | `/dir.html` (SPA fallback) | +| `/dir/` | `/dir/index.html` | `/dir/index.html` | `/dir/index.html` | +| `/file.html` | `/file.html` | `/file.html` | `/file.html` | +| `/file` | `/index.html` (SPA fallback) | `/file.html` | `/file.html` | +| `/file/` | `/index.html` (SPA fallback) | `/file.html` | `/index.html` (SPA fallback) | ### Manifest files are now generated in `.vite` directory by default diff --git a/packages/vite/LICENSE.md b/packages/vite/LICENSE.md index 03b0ef717114e4..7b45e464cf268f 100644 --- a/packages/vite/LICENSE.md +++ b/packages/vite/LICENSE.md @@ -911,35 +911,6 @@ Repository: senchalabs/connect --------------------------------------- -## connect-history-api-fallback -License: MIT -By: Ben Ripkens, Craig Myles -Repository: http://github.com/bripkens/connect-history-api-fallback.git - -> The MIT License -> -> Copyright (c) 2022 Ben Blackmore and contributors -> -> Permission is hereby granted, free of charge, to any person obtaining a copy -> of this software and associated documentation files (the "Software"), to deal -> in the Software without restriction, including without limitation the rights -> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -> copies of the Software, and to permit persons to whom the Software is -> furnished to do so, subject to the following conditions: -> -> The above copyright notice and this permission notice shall be included in -> all copies or substantial portions of the Software. -> -> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -> THE SOFTWARE. - ---------------------------------------- - ## convert-source-map License: MIT By: Thorsten Lorenz diff --git a/packages/vite/package.json b/packages/vite/package.json index 1606719790f67f..831a138585f27f 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -98,7 +98,6 @@ "cac": "^6.7.14", "chokidar": "^3.5.3", "connect": "^3.7.0", - "connect-history-api-fallback": "^2.0.0", "convert-source-map": "^2.0.0", "cors": "^2.8.5", "cross-spawn": "^7.0.3", diff --git a/packages/vite/src/node/preview.ts b/packages/vite/src/node/preview.ts index e678bc6341d658..3d9329e581f3d8 100644 --- a/packages/vite/src/node/preview.ts +++ b/packages/vite/src/node/preview.ts @@ -15,6 +15,9 @@ import { } from './http' import { openBrowser } from './server/openBrowser' import compression from './server/middlewares/compression' +import { htmlFallbackMiddleware } from './server/middlewares/htmlFallback' +import { indexHtmlMiddleware } from './server/middlewares/indexHtml' +import { notFoundMiddleware } from './server/middlewares/notFound' import { proxyMiddleware } from './server/middlewares/proxy' import { resolveHostname, resolveServerUrls, shouldServeFile } from './utils' import { printServerUrls } from './logger' @@ -170,7 +173,7 @@ export async function preview( sirv(distDir, { etag: true, dev: true, - single: config.appType === 'spa', + extensions: [], ignores: false, setHeaders(res) { if (headers) { @@ -186,9 +189,29 @@ export async function preview( app.use(previewBase, viteAssetMiddleware) + // html fallback + if (config.appType === 'spa' || config.appType === 'mpa') { + app.use( + previewBase, + htmlFallbackMiddleware( + distDir, + config.appType === 'spa', + previewBase !== '/', + ), + ) + } + // apply post server hooks from plugins postHooks.forEach((fn) => fn && fn()) + if (config.appType === 'spa' || config.appType === 'mpa') { + // transform index.html + app.use(previewBase, indexHtmlMiddleware(distDir, server)) + + // handle 404s + app.use(previewBase, notFoundMiddleware()) + } + const hostname = await resolveHostname(options.host) const port = options.port ?? DEFAULT_PREVIEW_PORT const protocol = options.https ? 'https' : 'http' diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index e58dc4d1e3ea47..25b1e4efd0e7a4 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -66,6 +66,7 @@ import { import { timeMiddleware } from './middlewares/time' import type { ModuleNode } from './moduleGraph' import { ModuleGraph } from './moduleGraph' +import { notFoundMiddleware } from './middlewares/notFound' import { errorMiddleware, prepareError } from './middlewares/error' import type { HmrOptions } from './hmr' import { @@ -692,14 +693,10 @@ export async function _createServer( if (config.appType === 'spa' || config.appType === 'mpa') { // transform index.html - middlewares.use(indexHtmlMiddleware(server)) + middlewares.use(indexHtmlMiddleware(root, server)) // handle 404s - // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` - middlewares.use(function vite404Middleware(_, res) { - res.statusCode = 404 - res.end() - }) + middlewares.use(notFoundMiddleware()) } // error handler diff --git a/packages/vite/src/node/server/middlewares/htmlFallback.ts b/packages/vite/src/node/server/middlewares/htmlFallback.ts index 39bf8ae2e32e17..85f5e396e54f8c 100644 --- a/packages/vite/src/node/server/middlewares/htmlFallback.ts +++ b/packages/vite/src/node/server/middlewares/htmlFallback.ts @@ -1,48 +1,86 @@ import fs from 'node:fs' import path from 'node:path' -import history from 'connect-history-api-fallback' import type { Connect } from 'dep-types/connect' -import { createDebugger } from '../../utils' +import { cleanUrl, createDebugger } from '../../utils' + +const debug = createDebugger('vite:html-fallback') export function htmlFallbackMiddleware( root: string, spaFallback: boolean, + mounted = false, ): Connect.NextHandleFunction { - const historyHtmlFallbackMiddleware = history({ - disableDotRule: true, - logger: createDebugger('vite:html-fallback'), - rewrites: [ - // support /dir/ without explicit index.html - { - from: /\/$/, - to({ parsedUrl, request }: any) { - const rewritten = - decodeURIComponent(parsedUrl.pathname) + 'index.html' - - if (fs.existsSync(path.join(root, rewritten))) { - return rewritten - } - - return spaFallback ? `/index.html` : request.url - }, - }, - { - from: /\.html$/, - to({ parsedUrl, request }: any) { - // .html files are not handled by serveStaticMiddleware - // so we need to check if the file exists - const pathname = decodeURIComponent(parsedUrl.pathname) - if (fs.existsSync(path.join(root, pathname))) { - return request.url - } - return spaFallback ? `/index.html` : request.url - }, - }, - ], - }) + // When this middleware is mounted on a route, we need to re-assign `req.url` with a + // leading `.` to signal a relative rewrite. Returning with a leading `/` returns a + // buggy `req.url`. e.g.: + // + // mount /foo/bar: + // req.url = /index.html + // final = /foo/barindex.html + // + // mount /foo/bar: + // req.url = ./index.html + // final = /foo/bar/index.html + const prepend = mounted ? '.' : '' // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` return function viteHtmlFallbackMiddleware(req, res, next) { - return historyHtmlFallbackMiddleware(req, res, next) + if ( + // Only accept GET or HEAD + (req.method !== 'GET' && req.method !== 'HEAD') || + // Require Accept header + !req.headers || + typeof req.headers.accept !== 'string' || + // Ignore JSON requests + req.headers.accept.includes('application/json') || + // Require Accept: text/html or */* + !( + req.headers.accept.includes('text/html') || + req.headers.accept.includes('*/*') + ) + ) { + return next() + } + + const url = cleanUrl(req.url!) + const pathname = decodeURIComponent(url) + + // .html files are not handled by serveStaticMiddleware + // so we need to check if the file exists + if (pathname.endsWith('.html')) { + const filePath = path.join(root, pathname) + if (fs.existsSync(filePath)) { + debug?.(`Rewriting ${req.method} ${req.url} to ${url}`) + req.url = prepend + url + return next() + } + } + // trailing slash should check for fallback index.html + else if (pathname[pathname.length - 1] === '/') { + const filePath = path.join(root, pathname, 'index.html') + if (fs.existsSync(filePath)) { + const newUrl = url + 'index.html' + debug?.(`Rewriting ${req.method} ${req.url} to ${newUrl}`) + req.url = prepend + newUrl + return next() + } + } + // non-trailing slash should check for fallback .html + else { + const filePath = path.join(root, pathname + '.html') + if (fs.existsSync(filePath)) { + const newUrl = url + '.html' + debug?.(`Rewriting ${req.method} ${req.url} to ${newUrl}`) + req.url = prepend + newUrl + return next() + } + } + + if (spaFallback) { + debug?.(`Rewriting ${req.method} ${req.url} to /index.html`) + req.url = prepend + '/index.html' + } + + next() } } diff --git a/packages/vite/src/node/server/middlewares/indexHtml.ts b/packages/vite/src/node/server/middlewares/indexHtml.ts index c25aaa3f02e3ee..874c94f84790fa 100644 --- a/packages/vite/src/node/server/middlewares/indexHtml.ts +++ b/packages/vite/src/node/server/middlewares/indexHtml.ts @@ -23,7 +23,7 @@ import { resolveHtmlTransforms, traverseHtml, } from '../../plugins/html' -import type { ResolvedConfig, ViteDevServer } from '../..' +import type { PreviewServer, ResolvedConfig, ViteDevServer } from '../..' import { send } from '../send' import { CLIENT_PUBLIC_PATH, FS_PREFIX } from '../../constants' import { @@ -32,6 +32,7 @@ import { fsPathFromId, getHash, injectQuery, + isDevServer, isJSRequest, joinUrlSegments, normalizePath, @@ -378,8 +379,14 @@ const devHtmlHook: IndexHtmlTransformHook = async ( } export function indexHtmlMiddleware( - server: ViteDevServer, + root: string, + server: ViteDevServer | PreviewServer, ): Connect.NextHandleFunction { + const isDev = isDevServer(server) + const headers = isDev + ? server.config.server.headers + : server.config.preview.headers + // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` return async function viteIndexHtmlMiddleware(req, res, next) { if (res.writableEnded) { @@ -389,14 +396,20 @@ export function indexHtmlMiddleware( const url = req.url && cleanUrl(req.url) // htmlFallbackMiddleware appends '.html' to URLs if (url?.endsWith('.html') && req.headers['sec-fetch-dest'] !== 'script') { - const filename = getHtmlFilename(url, server) - if (fs.existsSync(filename)) { + let filePath: string + if (isDev && url.startsWith(FS_PREFIX)) { + filePath = decodeURIComponent(fsPathFromId(url)) + } else { + filePath = path.join(root, decodeURIComponent(url)) + } + + if (fs.existsSync(filePath)) { try { - let html = await fsp.readFile(filename, 'utf-8') - html = await server.transformIndexHtml(url, html, req.originalUrl) - return send(req, res, html, 'html', { - headers: server.config.server.headers, - }) + let html = await fsp.readFile(filePath, 'utf-8') + if (isDev) { + html = await server.transformIndexHtml(url, html, req.originalUrl) + } + return send(req, res, html, 'html', { headers }) } catch (e) { return next(e) } diff --git a/packages/vite/src/node/server/middlewares/notFound.ts b/packages/vite/src/node/server/middlewares/notFound.ts new file mode 100644 index 00000000000000..4ecf6823dcaf89 --- /dev/null +++ b/packages/vite/src/node/server/middlewares/notFound.ts @@ -0,0 +1,9 @@ +import type { Connect } from 'dep-types/connect' + +export function notFoundMiddleware(): Connect.NextHandleFunction { + // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` + return function vite404Middleware(_, res) { + res.statusCode = 404 + res.end() + } +} diff --git a/packages/vite/src/node/shortcuts.ts b/packages/vite/src/node/shortcuts.ts index 0a382c25b93c0c..8f8205d8e8bddd 100644 --- a/packages/vite/src/node/shortcuts.ts +++ b/packages/vite/src/node/shortcuts.ts @@ -2,6 +2,7 @@ import readline from 'node:readline' import colors from 'picocolors' import { restartServerWithUrls } from './server' import type { ViteDevServer } from './server' +import { isDevServer } from './utils' import type { PreviewServer } from './preview' import { openBrowser } from './server/openBrowser' @@ -86,12 +87,6 @@ export function bindCLIShortcuts( server.httpServer.on('close', () => rl.close()) } -function isDevServer( - server: ViteDevServer | PreviewServer, -): server is ViteDevServer { - return 'pluginContainer' in server -} - const BASE_DEV_SHORTCUTS: CLIShortcut[] = [ { key: 'r', diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index dbd48ad43f5cc5..1e039d9beced7e 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -32,6 +32,7 @@ import { import type { DepOptimizationConfig } from './optimizer' import type { ResolvedConfig } from './config' import type { ResolvedServerUrls, ViteDevServer } from './server' +import type { PreviewServer } from './preview' import { type PackageCache, findNearestPackageData, @@ -1322,3 +1323,9 @@ export function getPackageManagerCommand( throw new TypeError(`Unknown command type: ${type}`) } } + +export function isDevServer( + server: ViteDevServer | PreviewServer, +): server is ViteDevServer { + return 'pluginContainer' in server +} diff --git a/packages/vite/src/types/shims.d.ts b/packages/vite/src/types/shims.d.ts index 03b1941201b17f..9f637b00e2b292 100644 --- a/packages/vite/src/types/shims.d.ts +++ b/packages/vite/src/types/shims.d.ts @@ -13,11 +13,6 @@ declare module 'http-proxy' { export = proxy } -declare module 'connect-history-api-fallback' { - const plugin: any - export = plugin -} - declare module 'launch-editor-middleware' { const plugin: any export = plugin diff --git a/playground/assets/__tests__/assets.spec.ts b/playground/assets/__tests__/assets.spec.ts index 498a1d380c6358..d3de2304894584 100644 --- a/playground/assets/__tests__/assets.spec.ts +++ b/playground/assets/__tests__/assets.spec.ts @@ -43,17 +43,13 @@ test('should get a 404 when using incorrect case', async () => { ) // fallback to index.html const iconPngResult = await fetchPath('ICON.png') - expect(iconPngResult.headers.get('Content-Type')).toBe( - isBuild ? 'text/html;charset=utf-8' : 'text/html', - ) + expect(iconPngResult.headers.get('Content-Type')).toBe('text/html') expect(iconPngResult.status).toBe(200) expect((await fetchPath('bar')).headers.get('Content-Type')).toBe('') // fallback to index.html const barResult = await fetchPath('BAR') - expect(barResult.headers.get('Content-Type')).toContain( - isBuild ? 'text/html;charset=utf-8' : 'text/html', - ) + expect(barResult.headers.get('Content-Type')).toContain('text/html') expect(barResult.status).toBe(200) }) diff --git a/playground/html/__tests__/html.spec.ts b/playground/html/__tests__/html.spec.ts index 879f9cb1edc0e7..01f0eca2e7e445 100644 --- a/playground/html/__tests__/html.spec.ts +++ b/playground/html/__tests__/html.spec.ts @@ -11,6 +11,12 @@ import { withRetry, } from '~utils' +function fetchHtml(p: string) { + return fetch(viteTestUrl + p, { + headers: { Accept: 'text/html,*/*' }, + }) +} + function testPage(isNested: boolean) { test('pre transform', async () => { expect(await page.$('head meta[name=viewport]')).toBeTruthy() @@ -355,3 +361,56 @@ describe.runIf(isServe)('warmup', () => { }) }) }) + +test('html serve behavior', async () => { + const [ + file, + fileSlash, + fileDotHtml, + + folder, + folderSlash, + folderSlashIndexHtml, + + both, + bothSlash, + bothDotHtml, + bothSlashIndexHtml, + ] = await Promise.all([ + fetchHtml('/serve/file'), // -> serve/file.html + fetchHtml('/serve/file/'), // -> index.html (404 in mpa) + fetchHtml('/serve/file.html'), // -> serve/file.html + + fetchHtml('/serve/folder'), // -> index.html (404 in mpa) + fetchHtml('/serve/folder/'), // -> serve/folder/index.html + fetchHtml('/serve/folder/index.html'), // -> serve/folder/index.html + + fetchHtml('/serve/both'), // -> serve/both.html + fetchHtml('/serve/both/'), // -> serve/both/index.html + fetchHtml('/serve/both.html'), // -> serve/both.html + fetchHtml('/serve/both/index.html'), // -> serve/both/index.html + ]) + + expect(file.status).toBe(200) + expect(await file.text()).toContain('file.html') + expect(fileSlash.status).toBe(200) + expect(await fileSlash.text()).toContain('index.html (fallback)') + expect(fileDotHtml.status).toBe(200) + expect(await fileDotHtml.text()).toContain('file.html') + + expect(folder.status).toBe(200) + expect(await folder.text()).toContain('index.html (fallback)') + expect(folderSlash.status).toBe(200) + expect(await folderSlash.text()).toContain('folder/index.html') + expect(folderSlashIndexHtml.status).toBe(200) + expect(await folderSlashIndexHtml.text()).toContain('folder/index.html') + + expect(both.status).toBe(200) + expect(await both.text()).toContain('both.html') + expect(bothSlash.status).toBe(200) + expect(await bothSlash.text()).toContain('both/index.html') + expect(bothDotHtml.status).toBe(200) + expect(await bothDotHtml.text()).toContain('both.html') + expect(bothSlashIndexHtml.status).toBe(200) + expect(await bothSlashIndexHtml.text()).toContain('both/index.html') +}) diff --git a/playground/html/index.html b/playground/html/index.html index 783cad93172f7a..829b362f433a9a 100644 --- a/playground/html/index.html +++ b/playground/html/index.html @@ -8,3 +8,4 @@

Hello

+

index.html (fallback)

diff --git a/playground/html/serve/both.html b/playground/html/serve/both.html new file mode 100644 index 00000000000000..9eebf466b653b5 --- /dev/null +++ b/playground/html/serve/both.html @@ -0,0 +1 @@ +

both.html

diff --git a/playground/html/serve/both/index.html b/playground/html/serve/both/index.html new file mode 100644 index 00000000000000..00a4791ad0a8b8 --- /dev/null +++ b/playground/html/serve/both/index.html @@ -0,0 +1 @@ +

both/index.html

diff --git a/playground/html/serve/file.html b/playground/html/serve/file.html new file mode 100644 index 00000000000000..f956e1216f1a1f --- /dev/null +++ b/playground/html/serve/file.html @@ -0,0 +1 @@ +

file.html

diff --git a/playground/html/serve/folder/index.html b/playground/html/serve/folder/index.html new file mode 100644 index 00000000000000..d66d0959648da4 --- /dev/null +++ b/playground/html/serve/folder/index.html @@ -0,0 +1 @@ +

folder/index.html

diff --git a/playground/html/vite.config.js b/playground/html/vite.config.js index 4edc6f12abcbf3..6bcfebdb677f92 100644 --- a/playground/html/vite.config.js +++ b/playground/html/vite.config.js @@ -30,6 +30,10 @@ export default defineConfig({ env: resolve(__dirname, 'env.html'), sideEffects: resolve(__dirname, 'side-effects/index.html'), 'a á': resolve(__dirname, 'a á.html'), + serveFile: resolve(__dirname, 'serve/file.html'), + serveFolder: resolve(__dirname, 'serve/folder/index.html'), + serveBothFile: resolve(__dirname, 'serve/both.html'), + serveBothFolder: resolve(__dirname, 'serve/both/index.html'), }, }, }, diff --git a/playground/legacy/__tests__/legacy.spec.ts b/playground/legacy/__tests__/legacy.spec.ts index f71665c4939aeb..405c61f47e2700 100644 --- a/playground/legacy/__tests__/legacy.spec.ts +++ b/playground/legacy/__tests__/legacy.spec.ts @@ -52,13 +52,13 @@ test('generates assets', async () => { () => page.textContent('#assets'), isBuild ? [ - 'index: text/html;charset=utf-8', - 'index-legacy: text/html;charset=utf-8', - 'chunk-async: text/html;charset=utf-8', - 'chunk-async-legacy: text/html;charset=utf-8', + 'index: text/html', + 'index-legacy: text/html', + 'chunk-async: text/html', + 'chunk-async-legacy: text/html', 'immutable-chunk: application/javascript', 'immutable-chunk-legacy: application/javascript', - 'polyfills-legacy: text/html;charset=utf-8', + 'polyfills-legacy: text/html', ].join('\n') : [ 'index: text/html', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dae840203fe7fa..3987537ee49d5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -285,9 +285,6 @@ importers: connect: specifier: ^3.7.0 version: 3.7.0 - connect-history-api-fallback: - specifier: ^2.0.0 - version: 2.0.0 convert-source-map: specifier: ^2.0.0 version: 2.0.0 @@ -5002,11 +4999,6 @@ packages: /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - /connect-history-api-fallback@2.0.0: - resolution: {integrity: sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==} - engines: {node: '>=0.8'} - dev: true - /connect@3.7.0: resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} engines: {node: '>= 0.10.0'}