Skip to content

Commit 4f71ae8

Browse files
bluwysapphi-red
andauthored
fix(html)!: align html serving between dev and preview (#14756)
Co-authored-by: 翠 / green <green@sapphi.red>
1 parent a090742 commit 4f71ae8

File tree

21 files changed

+236
-114
lines changed

21 files changed

+236
-114
lines changed

docs/guide/migration.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,29 @@ In Vite 4, `worker.plugins` accepted an array of plugins (`(Plugin | Plugin[])[]
102102

103103
### Allow path containing `.` to fallback to index.html
104104

105-
In Vite 4, accessing a path containing `.` did not fallback to index.html even if `appType` is set to `'SPA'` (default).
106-
From Vite 5, it will fallback to index.html.
105+
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.
107106

108-
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. `<img src="./file-does-not-exist.png">`).
107+
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. `<img src="./file-does-not-exist.png">`).
108+
109+
### Align dev and preview HTML serving behaviour
110+
111+
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:
112+
113+
```
114+
├── index.html
115+
├── file.html
116+
└── dir
117+
└── index.html
118+
```
119+
120+
| Request | Before (dev) | Before (preview) | After (dev & preview) |
121+
| ----------------- | ---------------------------- | ----------------- | ---------------------------- |
122+
| `/dir/index.html` | `/dir/index.html` | `/dir/index.html` | `/dir/index.html` |
123+
| `/dir` | `/index.html` (SPA fallback) | `/dir/index.html` | `/dir.html` (SPA fallback) |
124+
| `/dir/` | `/dir/index.html` | `/dir/index.html` | `/dir/index.html` |
125+
| `/file.html` | `/file.html` | `/file.html` | `/file.html` |
126+
| `/file` | `/index.html` (SPA fallback) | `/file.html` | `/file.html` |
127+
| `/file/` | `/index.html` (SPA fallback) | `/file.html` | `/index.html` (SPA fallback) |
109128

110129
### Manifest files are now generated in `.vite` directory by default
111130

packages/vite/LICENSE.md

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -911,35 +911,6 @@ Repository: senchalabs/connect
911911
912912
---------------------------------------
913913

914-
## connect-history-api-fallback
915-
License: MIT
916-
By: Ben Ripkens, Craig Myles
917-
Repository: http://github.com/bripkens/connect-history-api-fallback.git
918-
919-
> The MIT License
920-
>
921-
> Copyright (c) 2022 Ben Blackmore and contributors
922-
>
923-
> Permission is hereby granted, free of charge, to any person obtaining a copy
924-
> of this software and associated documentation files (the "Software"), to deal
925-
> in the Software without restriction, including without limitation the rights
926-
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
927-
> copies of the Software, and to permit persons to whom the Software is
928-
> furnished to do so, subject to the following conditions:
929-
>
930-
> The above copyright notice and this permission notice shall be included in
931-
> all copies or substantial portions of the Software.
932-
>
933-
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
934-
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
935-
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
936-
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
937-
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
938-
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
939-
> THE SOFTWARE.
940-
941-
---------------------------------------
942-
943914
## convert-source-map
944915
License: MIT
945916
By: Thorsten Lorenz

packages/vite/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@
9898
"cac": "^6.7.14",
9999
"chokidar": "^3.5.3",
100100
"connect": "^3.7.0",
101-
"connect-history-api-fallback": "^2.0.0",
102101
"convert-source-map": "^2.0.0",
103102
"cors": "^2.8.5",
104103
"cross-spawn": "^7.0.3",

packages/vite/src/node/preview.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import {
1515
} from './http'
1616
import { openBrowser } from './server/openBrowser'
1717
import compression from './server/middlewares/compression'
18+
import { htmlFallbackMiddleware } from './server/middlewares/htmlFallback'
19+
import { indexHtmlMiddleware } from './server/middlewares/indexHtml'
20+
import { notFoundMiddleware } from './server/middlewares/notFound'
1821
import { proxyMiddleware } from './server/middlewares/proxy'
1922
import { resolveHostname, resolveServerUrls, shouldServeFile } from './utils'
2023
import { printServerUrls } from './logger'
@@ -170,7 +173,7 @@ export async function preview(
170173
sirv(distDir, {
171174
etag: true,
172175
dev: true,
173-
single: config.appType === 'spa',
176+
extensions: [],
174177
ignores: false,
175178
setHeaders(res) {
176179
if (headers) {
@@ -186,9 +189,29 @@ export async function preview(
186189

187190
app.use(previewBase, viteAssetMiddleware)
188191

192+
// html fallback
193+
if (config.appType === 'spa' || config.appType === 'mpa') {
194+
app.use(
195+
previewBase,
196+
htmlFallbackMiddleware(
197+
distDir,
198+
config.appType === 'spa',
199+
previewBase !== '/',
200+
),
201+
)
202+
}
203+
189204
// apply post server hooks from plugins
190205
postHooks.forEach((fn) => fn && fn())
191206

207+
if (config.appType === 'spa' || config.appType === 'mpa') {
208+
// transform index.html
209+
app.use(previewBase, indexHtmlMiddleware(distDir, server))
210+
211+
// handle 404s
212+
app.use(previewBase, notFoundMiddleware())
213+
}
214+
192215
const hostname = await resolveHostname(options.host)
193216
const port = options.port ?? DEFAULT_PREVIEW_PORT
194217
const protocol = options.https ? 'https' : 'http'

packages/vite/src/node/server/index.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
import { timeMiddleware } from './middlewares/time'
6767
import type { ModuleNode } from './moduleGraph'
6868
import { ModuleGraph } from './moduleGraph'
69+
import { notFoundMiddleware } from './middlewares/notFound'
6970
import { errorMiddleware, prepareError } from './middlewares/error'
7071
import type { HmrOptions } from './hmr'
7172
import {
@@ -692,14 +693,10 @@ export async function _createServer(
692693

693694
if (config.appType === 'spa' || config.appType === 'mpa') {
694695
// transform index.html
695-
middlewares.use(indexHtmlMiddleware(server))
696+
middlewares.use(indexHtmlMiddleware(root, server))
696697

697698
// handle 404s
698-
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
699-
middlewares.use(function vite404Middleware(_, res) {
700-
res.statusCode = 404
701-
res.end()
702-
})
699+
middlewares.use(notFoundMiddleware())
703700
}
704701

705702
// error handler
Lines changed: 73 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,86 @@
11
import fs from 'node:fs'
22
import path from 'node:path'
3-
import history from 'connect-history-api-fallback'
43
import type { Connect } from 'dep-types/connect'
5-
import { createDebugger } from '../../utils'
4+
import { cleanUrl, createDebugger } from '../../utils'
5+
6+
const debug = createDebugger('vite:html-fallback')
67

78
export function htmlFallbackMiddleware(
89
root: string,
910
spaFallback: boolean,
11+
mounted = false,
1012
): Connect.NextHandleFunction {
11-
const historyHtmlFallbackMiddleware = history({
12-
disableDotRule: true,
13-
logger: createDebugger('vite:html-fallback'),
14-
rewrites: [
15-
// support /dir/ without explicit index.html
16-
{
17-
from: /\/$/,
18-
to({ parsedUrl, request }: any) {
19-
const rewritten =
20-
decodeURIComponent(parsedUrl.pathname) + 'index.html'
21-
22-
if (fs.existsSync(path.join(root, rewritten))) {
23-
return rewritten
24-
}
25-
26-
return spaFallback ? `/index.html` : request.url
27-
},
28-
},
29-
{
30-
from: /\.html$/,
31-
to({ parsedUrl, request }: any) {
32-
// .html files are not handled by serveStaticMiddleware
33-
// so we need to check if the file exists
34-
const pathname = decodeURIComponent(parsedUrl.pathname)
35-
if (fs.existsSync(path.join(root, pathname))) {
36-
return request.url
37-
}
38-
return spaFallback ? `/index.html` : request.url
39-
},
40-
},
41-
],
42-
})
13+
// When this middleware is mounted on a route, we need to re-assign `req.url` with a
14+
// leading `.` to signal a relative rewrite. Returning with a leading `/` returns a
15+
// buggy `req.url`. e.g.:
16+
//
17+
// mount /foo/bar:
18+
// req.url = /index.html
19+
// final = /foo/barindex.html
20+
//
21+
// mount /foo/bar:
22+
// req.url = ./index.html
23+
// final = /foo/bar/index.html
24+
const prepend = mounted ? '.' : ''
4325

4426
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
4527
return function viteHtmlFallbackMiddleware(req, res, next) {
46-
return historyHtmlFallbackMiddleware(req, res, next)
28+
if (
29+
// Only accept GET or HEAD
30+
(req.method !== 'GET' && req.method !== 'HEAD') ||
31+
// Require Accept header
32+
!req.headers ||
33+
typeof req.headers.accept !== 'string' ||
34+
// Ignore JSON requests
35+
req.headers.accept.includes('application/json') ||
36+
// Require Accept: text/html or */*
37+
!(
38+
req.headers.accept.includes('text/html') ||
39+
req.headers.accept.includes('*/*')
40+
)
41+
) {
42+
return next()
43+
}
44+
45+
const url = cleanUrl(req.url!)
46+
const pathname = decodeURIComponent(url)
47+
48+
// .html files are not handled by serveStaticMiddleware
49+
// so we need to check if the file exists
50+
if (pathname.endsWith('.html')) {
51+
const filePath = path.join(root, pathname)
52+
if (fs.existsSync(filePath)) {
53+
debug?.(`Rewriting ${req.method} ${req.url} to ${url}`)
54+
req.url = prepend + url
55+
return next()
56+
}
57+
}
58+
// trailing slash should check for fallback index.html
59+
else if (pathname[pathname.length - 1] === '/') {
60+
const filePath = path.join(root, pathname, 'index.html')
61+
if (fs.existsSync(filePath)) {
62+
const newUrl = url + 'index.html'
63+
debug?.(`Rewriting ${req.method} ${req.url} to ${newUrl}`)
64+
req.url = prepend + newUrl
65+
return next()
66+
}
67+
}
68+
// non-trailing slash should check for fallback .html
69+
else {
70+
const filePath = path.join(root, pathname + '.html')
71+
if (fs.existsSync(filePath)) {
72+
const newUrl = url + '.html'
73+
debug?.(`Rewriting ${req.method} ${req.url} to ${newUrl}`)
74+
req.url = prepend + newUrl
75+
return next()
76+
}
77+
}
78+
79+
if (spaFallback) {
80+
debug?.(`Rewriting ${req.method} ${req.url} to /index.html`)
81+
req.url = prepend + '/index.html'
82+
}
83+
84+
next()
4785
}
4886
}

packages/vite/src/node/server/middlewares/indexHtml.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
resolveHtmlTransforms,
2424
traverseHtml,
2525
} from '../../plugins/html'
26-
import type { ResolvedConfig, ViteDevServer } from '../..'
26+
import type { PreviewServer, ResolvedConfig, ViteDevServer } from '../..'
2727
import { send } from '../send'
2828
import { CLIENT_PUBLIC_PATH, FS_PREFIX } from '../../constants'
2929
import {
@@ -32,6 +32,7 @@ import {
3232
fsPathFromId,
3333
getHash,
3434
injectQuery,
35+
isDevServer,
3536
isJSRequest,
3637
joinUrlSegments,
3738
normalizePath,
@@ -378,8 +379,14 @@ const devHtmlHook: IndexHtmlTransformHook = async (
378379
}
379380

380381
export function indexHtmlMiddleware(
381-
server: ViteDevServer,
382+
root: string,
383+
server: ViteDevServer | PreviewServer,
382384
): Connect.NextHandleFunction {
385+
const isDev = isDevServer(server)
386+
const headers = isDev
387+
? server.config.server.headers
388+
: server.config.preview.headers
389+
383390
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
384391
return async function viteIndexHtmlMiddleware(req, res, next) {
385392
if (res.writableEnded) {
@@ -389,14 +396,20 @@ export function indexHtmlMiddleware(
389396
const url = req.url && cleanUrl(req.url)
390397
// htmlFallbackMiddleware appends '.html' to URLs
391398
if (url?.endsWith('.html') && req.headers['sec-fetch-dest'] !== 'script') {
392-
const filename = getHtmlFilename(url, server)
393-
if (fs.existsSync(filename)) {
399+
let filePath: string
400+
if (isDev && url.startsWith(FS_PREFIX)) {
401+
filePath = decodeURIComponent(fsPathFromId(url))
402+
} else {
403+
filePath = path.join(root, decodeURIComponent(url))
404+
}
405+
406+
if (fs.existsSync(filePath)) {
394407
try {
395-
let html = await fsp.readFile(filename, 'utf-8')
396-
html = await server.transformIndexHtml(url, html, req.originalUrl)
397-
return send(req, res, html, 'html', {
398-
headers: server.config.server.headers,
399-
})
408+
let html = await fsp.readFile(filePath, 'utf-8')
409+
if (isDev) {
410+
html = await server.transformIndexHtml(url, html, req.originalUrl)
411+
}
412+
return send(req, res, html, 'html', { headers })
400413
} catch (e) {
401414
return next(e)
402415
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Connect } from 'dep-types/connect'
2+
3+
export function notFoundMiddleware(): Connect.NextHandleFunction {
4+
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
5+
return function vite404Middleware(_, res) {
6+
res.statusCode = 404
7+
res.end()
8+
}
9+
}

packages/vite/src/node/shortcuts.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import readline from 'node:readline'
22
import colors from 'picocolors'
33
import { restartServerWithUrls } from './server'
44
import type { ViteDevServer } from './server'
5+
import { isDevServer } from './utils'
56
import type { PreviewServer } from './preview'
67
import { openBrowser } from './server/openBrowser'
78

@@ -88,12 +89,6 @@ export function bindCLIShortcuts<Server extends ViteDevServer | PreviewServer>(
8889
server.httpServer.on('close', () => rl.close())
8990
}
9091

91-
function isDevServer(
92-
server: ViteDevServer | PreviewServer,
93-
): server is ViteDevServer {
94-
return 'pluginContainer' in server
95-
}
96-
9792
const BASE_DEV_SHORTCUTS: CLIShortcut<ViteDevServer>[] = [
9893
{
9994
key: 'r',

packages/vite/src/node/utils.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
import type { DepOptimizationConfig } from './optimizer'
3333
import type { ResolvedConfig } from './config'
3434
import type { ResolvedServerUrls, ViteDevServer } from './server'
35+
import type { PreviewServer } from './preview'
3536
import {
3637
type PackageCache,
3738
findNearestPackageData,
@@ -1322,3 +1323,9 @@ export function getPackageManagerCommand(
13221323
throw new TypeError(`Unknown command type: ${type}`)
13231324
}
13241325
}
1326+
1327+
export function isDevServer(
1328+
server: ViteDevServer | PreviewServer,
1329+
): server is ViteDevServer {
1330+
return 'pluginContainer' in server
1331+
}

packages/vite/src/types/shims.d.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,6 @@ declare module 'http-proxy' {
1313
export = proxy
1414
}
1515

16-
declare module 'connect-history-api-fallback' {
17-
const plugin: any
18-
export = plugin
19-
}
20-
2116
declare module 'launch-editor-middleware' {
2217
const plugin: any
2318
export = plugin

0 commit comments

Comments
 (0)