Skip to content

Commit 0f9e1bf

Browse files
perf: in-memory public files check (#15195)
Co-authored-by: 翠 / green <green@sapphi.red>
1 parent 35a5bcf commit 0f9e1bf

File tree

10 files changed

+121
-40
lines changed

10 files changed

+121
-40
lines changed

packages/vite/src/node/plugins/asset.ts

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import path from 'node:path'
22
import { parse as parseUrl } from 'node:url'
3-
import fs from 'node:fs'
43
import fsp from 'node:fs/promises'
54
import { Buffer } from 'node:buffer'
65
import * as mrmime from 'mrmime'
@@ -17,6 +16,7 @@ import {
1716
} from '../build'
1817
import type { Plugin } from '../plugin'
1918
import type { ResolvedConfig } from '../config'
19+
import { checkPublicFile } from '../publicDir'
2020
import {
2121
cleanUrl,
2222
getHash,
@@ -249,31 +249,6 @@ export function assetPlugin(config: ResolvedConfig): Plugin {
249249
}
250250
}
251251

252-
export function checkPublicFile(
253-
url: string,
254-
{ publicDir }: ResolvedConfig,
255-
): string | undefined {
256-
// note if the file is in /public, the resolver would have returned it
257-
// as-is so it's not going to be a fully resolved path.
258-
if (!publicDir || url[0] !== '/') {
259-
return
260-
}
261-
const publicFile = path.join(publicDir, cleanUrl(url))
262-
if (
263-
!normalizePath(publicFile).startsWith(
264-
withTrailingSlash(normalizePath(publicDir)),
265-
)
266-
) {
267-
// can happen if URL starts with '../'
268-
return
269-
}
270-
if (fs.existsSync(publicFile)) {
271-
return publicFile
272-
} else {
273-
return
274-
}
275-
}
276-
277252
export async function fileToUrl(
278253
id: string,
279254
config: ResolvedConfig,

packages/vite/src/node/plugins/css.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
} from '../constants'
3737
import type { ResolvedConfig } from '../config'
3838
import type { Plugin } from '../plugin'
39+
import { checkPublicFile } from '../publicDir'
3940
import {
4041
arraify,
4142
asyncReplace,
@@ -62,7 +63,6 @@ import type { Logger } from '../logger'
6263
import { addToHTMLProxyTransformResult } from './html'
6364
import {
6465
assetUrlRE,
65-
checkPublicFile,
6666
fileToUrl,
6767
generatedAssets,
6868
publicAssetUrlCache,

packages/vite/src/node/plugins/html.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ import {
2424
urlCanParse,
2525
} from '../utils'
2626
import type { ResolvedConfig } from '../config'
27+
import { checkPublicFile } from '../publicDir'
2728
import { toOutputFilePathInHtml } from '../build'
2829
import { resolveEnvPrefix } from '../env'
2930
import type { Logger } from '../logger'
3031
import {
3132
assetUrlRE,
32-
checkPublicFile,
3333
getPublicAssetFilename,
3434
publicAssetUrlRE,
3535
urlToBuiltUrl,

packages/vite/src/node/plugins/importAnalysis.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,13 @@ import {
5252
withTrailingSlash,
5353
wrapId,
5454
} from '../utils'
55+
import { checkPublicFile } from '../publicDir'
5556
import { getDepOptimizationConfig } from '../config'
5657
import type { ResolvedConfig } from '../config'
5758
import type { Plugin } from '../plugin'
5859
import { shouldExternalizeForSSR } from '../ssr/ssrExternal'
5960
import { getDepsOptimizer, optimizedDepNeedsInterop } from '../optimizer'
60-
import { checkPublicFile, urlRE } from './asset'
61+
import { urlRE } from './asset'
6162
import { throwOutdatedRequest } from './optimizedDeps'
6263
import { isCSSRequest, isDirectCSSRequest } from './css'
6364
import { browserExternalId } from './resolve'
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
import type { ResolvedConfig } from './config'
4+
import {
5+
cleanUrl,
6+
normalizePath,
7+
recursiveReaddir,
8+
withTrailingSlash,
9+
} from './utils'
10+
11+
const publicFilesMap = new WeakMap<ResolvedConfig, Set<string>>()
12+
13+
export async function initPublicFiles(
14+
config: ResolvedConfig,
15+
): Promise<Set<string>> {
16+
const fileNames = await recursiveReaddir(config.publicDir)
17+
const publicFiles = new Set(
18+
fileNames.map((fileName) => fileName.slice(config.publicDir.length)),
19+
)
20+
publicFilesMap.set(config, publicFiles)
21+
return publicFiles
22+
}
23+
24+
function getPublicFiles(config: ResolvedConfig): Set<string> | undefined {
25+
return publicFilesMap.get(config)
26+
}
27+
28+
export function checkPublicFile(
29+
url: string,
30+
config: ResolvedConfig,
31+
): string | undefined {
32+
// note if the file is in /public, the resolver would have returned it
33+
// as-is so it's not going to be a fully resolved path.
34+
const { publicDir } = config
35+
if (!publicDir || url[0] !== '/') {
36+
return
37+
}
38+
39+
const fileName = cleanUrl(url)
40+
41+
// short-circuit if we have an in-memory publicFiles cache
42+
const publicFiles = getPublicFiles(config)
43+
if (publicFiles) {
44+
return publicFiles.has(fileName)
45+
? normalizePath(path.join(publicDir, fileName))
46+
: undefined
47+
}
48+
49+
const publicFile = normalizePath(path.join(publicDir, fileName))
50+
if (!publicFile.startsWith(withTrailingSlash(normalizePath(publicDir)))) {
51+
// can happen if URL starts with '../'
52+
return
53+
}
54+
55+
return fs.existsSync(publicFile) ? publicFile : undefined
56+
}

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { CLIENT_DIR, DEFAULT_DEV_PORT } from '../constants'
5050
import type { Logger } from '../logger'
5151
import { printServerUrls } from '../logger'
5252
import { createNoopWatcher, resolveChokidarOptions } from '../watch'
53+
import { initPublicFiles } from '../publicDir'
5354
import type { PluginContainer } from './pluginContainer'
5455
import { ERR_CLOSED_SERVER, createPluginContainer } from './pluginContainer'
5556
import type { WebSocketServer } from './ws'
@@ -378,6 +379,8 @@ export async function _createServer(
378379
): Promise<ViteDevServer> {
379380
const config = await resolveConfig(inlineConfig, 'serve')
380381

382+
const initPublicFilesPromise = initPublicFiles(config)
383+
381384
const { root, server: serverConfig } = config
382385
const httpsOptions = await resolveHttpsConfig(config.server.https)
383386
const { middlewareMode } = serverConfig
@@ -623,6 +626,8 @@ export async function _createServer(
623626
}
624627
}
625628

629+
const publicFiles = await initPublicFilesPromise
630+
626631
const onHMRUpdate = async (file: string, configOnly: boolean) => {
627632
if (serverConfig.hmr !== false) {
628633
try {
@@ -639,6 +644,12 @@ export async function _createServer(
639644
const onFileAddUnlink = async (file: string, isUnlink: boolean) => {
640645
file = normalizePath(file)
641646
await container.watchChange(file, { event: isUnlink ? 'delete' : 'create' })
647+
648+
if (config.publicDir && file.startsWith(config.publicDir)) {
649+
publicFiles[isUnlink ? 'delete' : 'add'](
650+
file.slice(config.publicDir.length),
651+
)
652+
}
642653
await handleFileAddUnlink(file, server, isUnlink)
643654
await onHMRUpdate(file, true)
644655
}
@@ -648,7 +659,6 @@ export async function _createServer(
648659
await container.watchChange(file, { event: 'update' })
649660
// invalidate module graph cache on file change
650661
moduleGraph.onFileChange(file)
651-
652662
await onHMRUpdate(file, false)
653663
})
654664

@@ -733,7 +743,7 @@ export async function _createServer(
733743
// this applies before the transform middleware so that these files are served
734744
// as-is without transforms.
735745
if (config.publicDir) {
736-
middlewares.use(servePublicMiddleware(server))
746+
middlewares.use(servePublicMiddleware(server, publicFiles))
737747
}
738748

739749
// main transform middleware

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ import {
4141
unwrapId,
4242
wrapId,
4343
} from '../../utils'
44+
import { checkPublicFile } from '../../publicDir'
4445
import { isCSSRequest } from '../../plugins/css'
45-
import { checkPublicFile } from '../../plugins/asset'
4646
import { getCodeWithSourcemap, injectSourcesContent } from '../sourcemap'
4747

4848
interface AssetNode {

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

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import {
1616
isParentDirectory,
1717
isSameFileUri,
1818
isWindows,
19+
normalizePath,
1920
removeLeadingSlash,
20-
shouldServeFile,
2121
slash,
2222
withTrailingSlash,
2323
} from '../../utils'
@@ -26,10 +26,8 @@ const knownJavascriptExtensionRE = /\.[tj]sx?$/
2626

2727
const sirvOptions = ({
2828
getHeaders,
29-
shouldServe,
3029
}: {
3130
getHeaders: () => OutgoingHttpHeaders | undefined
32-
shouldServe?: (p: string) => void
3331
}): Options => {
3432
return {
3533
dev: true,
@@ -51,26 +49,43 @@ const sirvOptions = ({
5149
}
5250
}
5351
},
54-
shouldServe,
5552
}
5653
}
5754

5855
export function servePublicMiddleware(
5956
server: ViteDevServer,
57+
publicFiles: Set<string>,
6058
): Connect.NextHandleFunction {
6159
const dir = server.config.publicDir
6260
const serve = sirv(
6361
dir,
6462
sirvOptions({
6563
getHeaders: () => server.config.server.headers,
66-
shouldServe: (filePath) => shouldServeFile(filePath, dir),
6764
}),
6865
)
6966

67+
const toFilePath = (url: string) => {
68+
let filePath = cleanUrl(url)
69+
if (filePath.indexOf('%') !== -1) {
70+
try {
71+
filePath = decodeURI(filePath)
72+
} catch (err) {
73+
/* malform uri */
74+
}
75+
}
76+
return normalizePath(filePath)
77+
}
78+
7079
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
7180
return function viteServePublicMiddleware(req, res, next) {
72-
// skip import request and internal requests `/@fs/ /@vite-client` etc...
73-
if (isImportRequest(req.url!) || isInternalRequest(req.url!)) {
81+
// To avoid the performance impact of `existsSync` on every request, we check against an
82+
// in-memory set of known public files. This set is updated on restarts.
83+
// also skip import request and internal requests `/@fs/ /@vite-client` etc...
84+
if (
85+
!publicFiles.has(toFilePath(req.url!)) ||
86+
isImportRequest(req.url!) ||
87+
isInternalRequest(req.url!)
88+
) {
7489
return next()
7590
}
7691
serve(req, res, next)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
timeFrom,
2323
unwrapId,
2424
} from '../utils'
25-
import { checkPublicFile } from '../plugins/asset'
25+
import { checkPublicFile } from '../publicDir'
2626
import { getDepsOptimizer } from '../optimizer'
2727
import { applySourcemapIgnoreList, injectSourcesContent } from './sourcemap'
2828
import { isFileServingAllowed } from './middlewares/static'

packages/vite/src/node/utils.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { builtinModules, createRequire } from 'node:module'
88
import { promises as dns } from 'node:dns'
99
import { performance } from 'node:perf_hooks'
1010
import type { AddressInfo, Server } from 'node:net'
11+
import fsp from 'node:fs/promises'
1112
import type { FSWatcher } from 'chokidar'
1213
import remapping from '@ampproject/remapping'
1314
import type { DecodedSourceMap, RawSourceMap } from '@ampproject/remapping'
@@ -622,6 +623,29 @@ export function copyDir(srcDir: string, destDir: string): void {
622623
}
623624
}
624625

626+
export async function recursiveReaddir(dir: string): Promise<string[]> {
627+
if (!fs.existsSync(dir)) {
628+
return []
629+
}
630+
let dirents: fs.Dirent[]
631+
try {
632+
dirents = await fsp.readdir(dir, { withFileTypes: true })
633+
} catch (e) {
634+
if (e.code === 'EACCES') {
635+
// Ignore permission errors
636+
return []
637+
}
638+
throw e
639+
}
640+
const files = await Promise.all(
641+
dirents.map((dirent) => {
642+
const res = path.resolve(dir, dirent.name)
643+
return dirent.isDirectory() ? recursiveReaddir(res) : normalizePath(res)
644+
}),
645+
)
646+
return files.flat(1)
647+
}
648+
625649
// `fs.realpathSync.native` resolves differently in Windows network drive,
626650
// causing file read errors. skip for now.
627651
// https://github.com/nodejs/node/issues/37737

0 commit comments

Comments
 (0)