diff --git a/packages/vite/src/node/preview.ts b/packages/vite/src/node/preview.ts index 3a5cbc0c8e566c..6d56c04961cf11 100644 --- a/packages/vite/src/node/preview.ts +++ b/packages/vite/src/node/preview.ts @@ -17,8 +17,11 @@ import compression from './server/middlewares/compression' import { proxyMiddleware } from './server/middlewares/proxy' import { resolveHostname, resolveServerUrls, shouldServe } from './utils' import { printServerUrls } from './logger' -import { resolveConfig } from '.' +import { htmlFallbackMiddleware } from './server/middlewares/htmlFallback' +import { indexHtmlPreviewMiddleware } from './server/middlewares/indexHtml' +import { notFoundMiddleware } from './server/middlewares/notFound' import type { InlineConfig, ResolvedConfig } from '.' +import { resolveConfig } from '.' export interface PreviewOptions extends CommonServerOptions {} @@ -120,7 +123,7 @@ export async function preview( const assetServer = sirv(distDir, { etag: true, dev: true, - single: config.appType === 'spa', + extensions: [], setHeaders(res) { if (headers) { for (const name in headers) { @@ -129,16 +132,59 @@ export async function preview( } }, }) - app.use(previewBase, async (req, res, next) => { - if (shouldServe(req.url!, distDir)) { - return assetServer(req, res, next) + app.use( + previewBase, + // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` + async function vitePreviewStaticMiddleware(req, res, next) { + if (shouldServe(req.url!, distDir)) { + return assetServer(req, res, next) + } + next() + }, + ) + + // html fallback + if (config.appType === 'spa' || config.appType === 'mpa') { + // append trailing slash when base didn't have it + if (config.rawBase !== config.base) { + // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` + app.use(function viteRewriteBaseAccessWithoutTrailingSlashMiddleware( + req, + res, + next, + ) { + try { + const pathname = decodeURIComponent( + new URL(req.url!, 'http://example.com').pathname, + ) + if (pathname === config.rawBase) { + req.url = config.base + req.url!.slice(config.rawBase.length) + } + } catch {} + next() + }) } - next() - }) + + app.use( + previewBase, + htmlFallbackMiddleware(distDir, config.appType === 'spa'), + ) + } // apply post server hooks from plugins postHooks.forEach((fn) => fn && fn()) + if (config.appType === 'spa' || config.appType === 'mpa') { + // serve index.html + app.use( + previewBase, + indexHtmlPreviewMiddleware(distDir, config.preview.headers), + ) + + // handle 404s + app.use(previewBase, notFoundMiddleware()) + } + const options = config.preview const hostname = await resolveHostname(options.host) const port = options.port ?? 4173 diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index d25ab50aa749e5..a84c207889afd4 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -77,6 +77,7 @@ import { openBrowser } from './openBrowser' import type { TransformOptions, TransformResult } from './transformRequest' import { transformRequest } from './transformRequest' import { searchForWorkspaceRoot } from './searchRoot' +import { notFoundMiddleware } from './middlewares/notFound' export { searchForWorkspaceRoot } from './searchRoot' @@ -600,11 +601,7 @@ export async function createServer( middlewares.use(indexHtmlMiddleware(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/indexHtml.ts b/packages/vite/src/node/server/middlewares/indexHtml.ts index 9701637c0b544a..aed4b6a9667e67 100644 --- a/packages/vite/src/node/server/middlewares/indexHtml.ts +++ b/packages/vite/src/node/server/middlewares/indexHtml.ts @@ -1,5 +1,6 @@ import fs from 'node:fs' import path from 'node:path' +import type { OutgoingHttpHeaders } from 'node:http' import MagicString from 'magic-string' import type { SourceMapInput } from 'rollup' import type { Connect } from 'dep-types/connect' @@ -303,3 +304,32 @@ export function indexHtmlMiddleware( next() } } + +export function indexHtmlPreviewMiddleware( + root: string, + headers: OutgoingHttpHeaders | undefined, +): Connect.NextHandleFunction { + // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` + return async function viteIndexHtmlPreviewMiddleware(req, res, next) { + if (res.writableEnded) { + return next() + } + + const url = req.url && cleanUrl(req.url) + // htmlFallbackMiddleware appends '.html' to URLs + if (url?.endsWith('.html')) { + const filepath = path.join(root, url.slice(1)) + if (fs.existsSync(filepath)) { + try { + const html = fs.readFileSync(filepath, 'utf-8') + return send(req, res, html, 'html', { + headers, + }) + } catch (e) { + return next(e) + } + } + } + next() + } +} 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..b8e7e2949b2a53 --- /dev/null +++ b/packages/vite/src/node/server/middlewares/notFound.ts @@ -0,0 +1,10 @@ +import type { Connect } from 'dep-types/connect' + +// handle 404s +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/playground/assets/__tests__/assets.spec.ts b/playground/assets/__tests__/assets.spec.ts index 482cf5a3b8a599..c9b8db938ed924 100644 --- a/playground/assets/__tests__/assets.spec.ts +++ b/playground/assets/__tests__/assets.spec.ts @@ -24,6 +24,12 @@ const assetMatch = isBuild const iconMatch = `/foo/icon.png` +const fetchPath = (p: string) => { + return fetch(path.posix.join(viteTestUrl, p), { + headers: { Accept: 'text/html,*/*' }, + }) +} + test('should have no 404s', () => { browserLogs.forEach((msg) => { expect(msg).not.toMatch('404') @@ -31,12 +37,49 @@ test('should have no 404s', () => { }) test('should get a 404 when using incorrect case', async () => { - expect((await fetch(path.posix.join(viteTestUrl, 'icon.png'))).status).toBe( - 200, - ) - expect((await fetch(path.posix.join(viteTestUrl, 'ICON.png'))).status).toBe( - 404, - ) + expect((await fetchPath('icon.png')).status).toBe(200) + // won't be wrote to index.html because the url includes `.` + expect((await fetchPath('ICON.png')).status).toBe(404) + + expect((await fetchPath('bar')).status).toBe(200) + // fallback to index.html + const incorrectBarFetch = await fetchPath('BAR') + expect(incorrectBarFetch.status).toBe(200) + expect(incorrectBarFetch.headers.get('Content-Type')).toBe('text/html') +}) + +test('html resolve behavior', async () => { + const [ + nestedIndexHtml, + nested, + nestedSlash, + + nonNestedHtml, + nonNested, + nonNestedSlash, + ] = await Promise.all([ + fetchPath('nested/index.html'), // -> nested/index.html + fetchPath('nested'), // -> index.html + fetchPath('nested/'), // -> nested/index.html + + fetchPath('non-nested.html'), // -> non-nested.html + fetchPath('non-nested'), // -> index.html + fetchPath('non-nested/'), // -> index.html + ]) + + expect(nestedIndexHtml.status).toBe(200) + expect(await nestedIndexHtml.text()).toContain('nested html') + expect(nested.status).toBe(200) + expect(await nested.text()).toContain('Assets') + expect(nestedSlash.status).toBe(200) + expect(await nestedSlash.text()).toContain('nested html') + + expect(nonNestedHtml.status).toBe(200) + expect(await nonNestedHtml.text()).toContain('non-nested html') + expect(nonNested.status).toBe(200) + expect(await nonNested.text()).toContain('Assets') + expect(nonNestedSlash.status).toBe(200) + expect(await nonNestedSlash.text()).toContain('Assets') }) describe('injected scripts', () => { diff --git a/playground/assets/nested/index.html b/playground/assets/nested/index.html new file mode 100644 index 00000000000000..838b89558c229c --- /dev/null +++ b/playground/assets/nested/index.html @@ -0,0 +1 @@ +
nested html
diff --git a/playground/assets/non-nested.html b/playground/assets/non-nested.html new file mode 100644 index 00000000000000..b71ce26b010139 --- /dev/null +++ b/playground/assets/non-nested.html @@ -0,0 +1 @@ +
non-nested html
diff --git a/playground/assets/static/bar b/playground/assets/static/bar new file mode 100644 index 00000000000000..5716ca5987cbf9 --- /dev/null +++ b/playground/assets/static/bar @@ -0,0 +1 @@ +bar diff --git a/playground/assets/vite.config.js b/playground/assets/vite.config.js index e6d2ba3fb460d2..685e073ddf99ad 100644 --- a/playground/assets/vite.config.js +++ b/playground/assets/vite.config.js @@ -1,5 +1,7 @@ const path = require('node:path') +const resolve = (p) => path.resolve(__dirname, p) + /** * @type {import('vite').UserConfig} */ @@ -8,7 +10,7 @@ module.exports = { publicDir: 'static', resolve: { alias: { - '@': path.resolve(__dirname, 'nested'), + '@': resolve('nested'), }, }, assetsInclude: ['**/*.unknown'], @@ -17,5 +19,12 @@ module.exports = { assetsInlineLimit: 8192, // 8kb manifest: true, watch: {}, + rollupOptions: { + input: [ + resolve('./index.html'), + resolve('./nested/index.html'), + resolve('./non-nested.html'), + ], + }, }, }