diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index 1fc8c2b522fdb3..2108ca02cca1e0 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -1,3 +1,7 @@ +import type { + DevRuntime as DevRuntimeType, + Messenger, +} from 'rolldown/experimental/runtime-types' import type { ErrorPayload, HotPayload } from '#types/hmrPayload' import type { ViteHotContext } from '#types/hot' import { HMRClient, HMRContext } from '../shared/hmr' @@ -20,6 +24,7 @@ declare const __HMR_BASE__: string declare const __HMR_TIMEOUT__: number declare const __HMR_ENABLE_OVERLAY__: boolean declare const __WS_TOKEN__: string +declare const __FULL_BUNDLE_MODE__: boolean console.debug('[vite] connecting...') @@ -37,6 +42,7 @@ const directSocketHost = __HMR_DIRECT_TARGET__ const base = __BASE__ || '/' const hmrTimeout = __HMR_TIMEOUT__ const wsToken = __WS_TOKEN__ +const isFullBundleMode = __FULL_BUNDLE_MODE__ const transport = normalizeModuleRunnerTransport( (() => { @@ -140,32 +146,53 @@ const hmrClient = new HMRClient( debug: (...msg) => console.debug('[vite]', ...msg), }, transport, - async function importUpdatedModule({ - acceptedPath, - timestamp, - explicitImportRequired, - isWithinCircularImport, - }) { - const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`) - const importPromise = import( - /* @vite-ignore */ - base + - acceptedPathWithoutQuery.slice(1) + - `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${ - query ? `&${query}` : '' - }` - ) - if (isWithinCircularImport) { - importPromise.catch(() => { - console.info( - `[hmr] ${acceptedPath} failed to apply HMR as it's within a circular import. Reloading page to reset the execution order. ` + - `To debug and break the circular import, you can run \`vite --debug hmr\` to log the circular dependency path if a file change triggered it.`, + isFullBundleMode + ? async function importUpdatedModule({ + url, + acceptedPath, + isWithinCircularImport, + }) { + const importPromise = import(base + url!).then(() => + // @ts-expect-error globalThis.__rolldown_runtime__ + globalThis.__rolldown_runtime__.loadExports(acceptedPath), ) - pageReload() - }) - } - return await importPromise - }, + if (isWithinCircularImport) { + importPromise.catch(() => { + console.info( + `[hmr] ${acceptedPath} failed to apply HMR as it's within a circular import. Reloading page to reset the execution order. ` + + `To debug and break the circular import, you can run \`vite --debug hmr\` to log the circular dependency path if a file change triggered it.`, + ) + pageReload() + }) + } + return await importPromise + } + : async function importUpdatedModule({ + acceptedPath, + timestamp, + explicitImportRequired, + isWithinCircularImport, + }) { + const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`) + const importPromise = import( + /* @vite-ignore */ + base + + acceptedPathWithoutQuery.slice(1) + + `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${ + query ? `&${query}` : '' + }` + ) + if (isWithinCircularImport) { + importPromise.catch(() => { + console.info( + `[hmr] ${acceptedPath} failed to apply HMR as it's within a circular import. Reloading page to reset the execution order. ` + + `To debug and break the circular import, you can run \`vite --debug hmr\` to log the circular dependency path if a file change triggered it.`, + ) + pageReload() + }) + } + return await importPromise + }, ) transport.connect!(createHMRHandler(handleMessage)) @@ -593,3 +620,41 @@ export function injectQuery(url: string, queryToInject: string): string { } export { ErrorOverlay } + +declare const DevRuntime: typeof DevRuntimeType + +if (isFullBundleMode && typeof DevRuntime !== 'undefined') { + class ViteDevRuntime extends DevRuntime { + override createModuleHotContext(moduleId: string) { + const ctx = createHotContext(moduleId) + // @ts-expect-error TODO: support CSS properly + ctx._internal = { updateStyle, removeStyle } + return ctx + } + + override applyUpdates(_boundaries: [string, string][]): void { + // noop, handled in the HMR client + } + } + + const wrappedSocket: Messenger = { + send(message) { + switch (message.type) { + case 'hmr:module-registered': { + transport.send({ + type: 'custom', + event: 'vite:module-loaded', + // clone array as the runtime reuses the array instance + data: { modules: message.modules.slice() }, + }) + break + } + default: + throw new Error(`Unknown message type: ${JSON.stringify(message)}`) + } + }, + } + ;(globalThis as any).__rolldown_runtime__ ??= new ViteDevRuntime( + wrappedSocket, + ) +} diff --git a/packages/vite/src/node/build.ts b/packages/vite/src/node/build.ts index e2fde3e66b3d47..c9a09fa8c22585 100644 --- a/packages/vite/src/node/build.ts +++ b/packages/vite/src/node/build.ts @@ -419,6 +419,7 @@ export function resolveBuildEnvironmentOptions( raw: BuildEnvironmentOptions, logger: Logger, consumer: 'client' | 'server' | undefined, + isFullBundledDev: boolean, ): ResolvedBuildEnvironmentOptions { const deprecatedPolyfillModulePreload = raw.polyfillModulePreload const { polyfillModulePreload, ...rest } = raw @@ -439,7 +440,7 @@ export function resolveBuildEnvironmentOptions( { ..._buildEnvironmentOptionsDefaults, cssCodeSplit: !raw.lib, - minify: consumer === 'server' ? false : 'oxc', + minify: consumer === 'server' || isFullBundledDev ? false : 'oxc', rollupOptions: {}, rolldownOptions: undefined, ssr: consumer === 'server', @@ -500,11 +501,12 @@ export async function resolveBuildPlugins(config: ResolvedConfig): Promise<{ pre: Plugin[] post: Plugin[] }> { + const isBuild = config.command === 'build' return { pre: [ completeAmdWrapPlugin(), completeSystemWrapPlugin(), - ...(!config.isWorker ? [prepareOutDirPlugin()] : []), + ...(isBuild && !config.isWorker ? [prepareOutDirPlugin()] : []), perEnvironmentPlugin( 'vite:rollup-options-plugins', async (environment) => @@ -517,11 +519,11 @@ export async function resolveBuildPlugins(config: ResolvedConfig): Promise<{ ...(config.isWorker ? [webWorkerPostPlugin(config)] : []), ], post: [ - ...buildImportAnalysisPlugin(config), + ...(isBuild ? buildImportAnalysisPlugin(config) : []), ...(config.nativePluginEnabledLevel >= 1 ? [] : [buildOxcPlugin()]), ...(config.build.minify === 'esbuild' ? [buildEsbuildPlugin()] : []), - terserPlugin(config), - ...(!config.isWorker + ...(isBuild ? [terserPlugin(config)] : []), + ...(isBuild && !config.isWorker ? [ licensePlugin(), manifestPlugin(config), @@ -563,10 +565,10 @@ function resolveConfigToBuild( ) } -function resolveRolldownOptions( +export function resolveRolldownOptions( environment: Environment, chunkMetadataMap: ChunkMetadataMap, -) { +): RolldownOptions { const { root, packageCache, build: options } = environment.config const libOptions = options.lib const { logger } = environment @@ -884,7 +886,7 @@ async function buildEnvironment( } } -function enhanceRollupError(e: RollupError) { +export function enhanceRollupError(e: RollupError): void { const stackOnly = extractStack(e) let msg = colors.red((e.plugin ? `[${e.plugin}] ` : '') + e.message) @@ -1050,7 +1052,7 @@ const dynamicImportWarningIgnoreList = [ `statically analyzed`, ] -function clearLine() { +export function clearLine(): void { const tty = process.stdout.isTTY && !process.env.CI if (tty) { process.stdout.clearLine(0) diff --git a/packages/vite/src/node/cli.ts b/packages/vite/src/node/cli.ts index b2f95d4f3809d6..3b000a31c6e799 100644 --- a/packages/vite/src/node/cli.ts +++ b/packages/vite/src/node/cli.ts @@ -53,6 +53,10 @@ interface GlobalCLIOptions { w?: boolean } +interface ExperimentalDevOptions { + fullBundle?: boolean +} + interface BuilderCLIOptions { app?: boolean } @@ -195,93 +199,105 @@ cli '--force', `[boolean] force the optimizer to ignore the cache and re-bundle`, ) - .action(async (root: string, options: ServerOptions & GlobalCLIOptions) => { - filterDuplicateOptions(options) - // output structure is preserved even after bundling so require() - // is ok here - const { createServer } = await import('./server') - try { - const server = await createServer({ - root, - base: options.base, - mode: options.mode, - configFile: options.config, - configLoader: options.configLoader, - logLevel: options.logLevel, - clearScreen: options.clearScreen, - server: cleanGlobalCLIOptions(options), - forceOptimizeDeps: options.force, - }) + .option('--fullBundle', `[boolean] use experimental full bundle mode`) + .action( + async ( + root: string, + options: ServerOptions & ExperimentalDevOptions & GlobalCLIOptions, + ) => { + filterDuplicateOptions(options) + // output structure is preserved even after bundling so require() + // is ok here + const { createServer } = await import('./server') + try { + const server = await createServer({ + root, + base: options.base, + mode: options.mode, + configFile: options.config, + configLoader: options.configLoader, + logLevel: options.logLevel, + clearScreen: options.clearScreen, + server: cleanGlobalCLIOptions(options), + forceOptimizeDeps: options.force, + experimental: { + fullBundleMode: options.fullBundle, + }, + }) - if (!server.httpServer) { - throw new Error('HTTP server not available') - } + if (!server.httpServer) { + throw new Error('HTTP server not available') + } - await server.listen() + await server.listen() - const info = server.config.logger.info + const info = server.config.logger.info - const modeString = - options.mode && options.mode !== 'development' - ? ` ${colors.bgGreen(` ${colors.bold(options.mode)} `)}` + const modeString = + options.mode && options.mode !== 'development' + ? ` ${colors.bgGreen(` ${colors.bold(options.mode)} `)}` + : '' + const viteStartTime = global.__vite_start_time ?? false + const startupDurationString = viteStartTime + ? colors.dim( + `ready in ${colors.reset( + colors.bold(Math.ceil(performance.now() - viteStartTime)), + )} ms`, + ) : '' - const viteStartTime = global.__vite_start_time ?? false - const startupDurationString = viteStartTime - ? colors.dim( - `ready in ${colors.reset( - colors.bold(Math.ceil(performance.now() - viteStartTime)), - )} ms`, - ) - : '' - const hasExistingLogs = - process.stdout.bytesWritten > 0 || process.stderr.bytesWritten > 0 + const hasExistingLogs = + process.stdout.bytesWritten > 0 || process.stderr.bytesWritten > 0 - info( - `\n ${colors.green( - `${colors.bold('ROLLDOWN-VITE')} v${VERSION}`, - )}${modeString} ${startupDurationString}\n`, - { - clear: !hasExistingLogs, - }, - ) + info( + `\n ${colors.green( + `${colors.bold('ROLLDOWN-VITE')} v${VERSION}`, + )}${modeString} ${startupDurationString}\n`, + { + clear: !hasExistingLogs, + }, + ) - server.printUrls() - const customShortcuts: CLIShortcut[] = [] - if (profileSession) { - customShortcuts.push({ - key: 'p', - description: 'start/stop the profiler', - async action(server) { - if (profileSession) { - await stopProfiler(server.config.logger.info) - } else { - const inspector = await import('node:inspector').then( - (r) => r.default, - ) - await new Promise((res) => { - profileSession = new inspector.Session() - profileSession.connect() - profileSession.post('Profiler.enable', () => { - profileSession!.post('Profiler.start', () => { - server.config.logger.info('Profiler started') - res() + server.printUrls() + const customShortcuts: CLIShortcut[] = [] + if (profileSession) { + customShortcuts.push({ + key: 'p', + description: 'start/stop the profiler', + async action(server) { + if (profileSession) { + await stopProfiler(server.config.logger.info) + } else { + const inspector = await import('node:inspector').then( + (r) => r.default, + ) + await new Promise((res) => { + profileSession = new inspector.Session() + profileSession.connect() + profileSession.post('Profiler.enable', () => { + profileSession!.post('Profiler.start', () => { + server.config.logger.info('Profiler started') + res() + }) }) }) - }) - } + } + }, + }) + } + server.bindCLIShortcuts({ print: true, customShortcuts }) + } catch (e) { + const logger = createLogger(options.logLevel) + logger.error( + colors.red(`error when starting dev server:\n${e.stack}`), + { + error: e, }, - }) + ) + await stopProfiler(logger.info) + process.exit(1) } - server.bindCLIShortcuts({ print: true, customShortcuts }) - } catch (e) { - const logger = createLogger(options.logLevel) - logger.error(colors.red(`error when starting dev server:\n${e.stack}`), { - error: e, - }) - await stopProfiler(logger.info) - process.exit(1) - } - }) + }, + ) // build cli diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 4110e04ae0b1d2..cda19600ee8b9e 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -125,6 +125,7 @@ import { basePluginContextMeta, } from './server/pluginContainer' import { nodeResolveWithVite } from './nodeResolve' +import { FullBundleDevEnvironment } from './server/environments/fullBundleEnvironment' const debug = createDebugger('vite:config', { depth: 10 }) const promisifiedRealpath = promisify(fs.realpath) @@ -238,6 +239,13 @@ function defaultCreateClientDevEnvironment( config: ResolvedConfig, context: CreateDevEnvironmentContext, ) { + if (config.experimental.fullBundleMode) { + return new FullBundleDevEnvironment(name, config, { + hot: true, + transport: context.ws, + }) + } + return new DevEnvironment(name, config, { hot: true, transport: context.ws, @@ -556,6 +564,13 @@ export interface ExperimentalOptions { * @default 'v1' */ enableNativePlugin?: boolean | 'resolver' | 'v1' + /** + * Enable full bundle mode in dev. + * + * @experimental + * @default false + */ + fullBundleMode?: boolean } export interface LegacyOptions { @@ -634,6 +649,8 @@ export interface ResolvedConfig cacheDir: string command: 'build' | 'serve' mode: string + /** `true` when build or full-bundle mode dev */ + isBundled: boolean isWorker: boolean // in nested worker bundle to find the main config /** @internal */ @@ -770,6 +787,7 @@ const configDefaults = Object.freeze({ renderBuiltUrl: undefined, hmrPartialAccept: false, enableNativePlugin: process.env._VITE_TEST_JS_PLUGIN ? false : 'v1', + fullBundleMode: false, }, future: { removePluginHookHandleHotUpdate: undefined, @@ -852,6 +870,7 @@ function resolveEnvironmentOptions( forceOptimizeDeps: boolean | undefined, logger: Logger, environmentName: string, + isFullBundledDev: boolean, // Backward compatibility isSsrTargetWebworkerSet?: boolean, preTransformRequests?: boolean, @@ -914,6 +933,7 @@ function resolveEnvironmentOptions( options.build ?? {}, logger, consumer, + isFullBundledDev, ), plugins: undefined!, // to be resolved later // will be set by `setOptimizeDepsPluginNames` later @@ -1500,6 +1520,9 @@ export async function resolveConfig( config.ssr?.target === 'webworker', ) + const isFullBundledDev = + command === 'serve' && !!config.experimental?.fullBundleMode + // Backward compatibility: merge config.environments.client.resolve back into config.resolve config.resolve ??= {} config.resolve.conditions = config.environments.client.resolve?.conditions @@ -1516,6 +1539,7 @@ export async function resolveConfig( inlineConfig.forceOptimizeDeps, logger, environmentName, + isFullBundledDev, config.ssr?.target === 'webworker', config.server?.preTransformRequests, ) @@ -1539,6 +1563,7 @@ export async function resolveConfig( config.build ?? {}, logger, undefined, + isFullBundledDev, ) // Backward compatibility: merge config.environments.ssr back into config.ssr @@ -1780,6 +1805,10 @@ export async function resolveConfig( configDefaults.experimental, config.experimental ?? {}, ) + if (command === 'serve' && experimental.fullBundleMode) { + // full bundle mode does not support experimental.renderBuiltUrl + experimental.renderBuiltUrl = undefined + } resolved = { configFile: configFile ? normalizePath(configFile) : undefined, @@ -1795,6 +1824,7 @@ export async function resolveConfig( cacheDir, command, mode, + isBundled: config.experimental?.fullBundleMode || isBuild, isWorker: false, mainConfig: null, bundleChain: [], diff --git a/packages/vite/src/node/plugins/asset.ts b/packages/vite/src/node/plugins/asset.ts index 4ec192676dce0a..ac648db238dd07 100644 --- a/packages/vite/src/node/plugins/asset.ts +++ b/packages/vite/src/node/plugins/asset.ts @@ -240,20 +240,24 @@ export function assetPlugin(config: ResolvedConfig): Plugin { }, }, - renderChunk(code, chunk, opts) { - const s = renderAssetUrlInJS(this, chunk, opts, code) - - if (s) { - return { - code: s.toString(), - map: this.environment.config.build.sourcemap - ? s.generateMap({ hires: 'boundary' }) - : null, + ...(config.command === 'build' + ? { + renderChunk(code, chunk, opts) { + const s = renderAssetUrlInJS(this, chunk, opts, code) + + if (s) { + return { + code: s.toString(), + map: this.environment.config.build.sourcemap + ? s.generateMap({ hires: 'boundary' }) + : null, + } + } else { + return null + } + }, } - } else { - return null - } - }, + : {}), generateBundle(_, bundle) { // Remove empty entry point file @@ -303,6 +307,10 @@ export function assetPlugin(config: ResolvedConfig): Plugin { } } }, + + watchChange(id) { + assetCache.get(this.environment)?.delete(normalizePath(id)) + }, } } @@ -311,7 +319,7 @@ export async function fileToUrl( id: string, ): Promise { const { environment } = pluginContext - if (environment.config.command === 'serve') { + if (!environment.config.isBundled) { return fileToDevUrl(environment, id) } else { return fileToBuiltUrl(pluginContext, id) @@ -460,7 +468,23 @@ async function fileToBuiltUrl( postfix = postfix.replace(noInlineRE, '').replace(/^&/, '?') } - url = `__VITE_ASSET__${referenceId}__${postfix ? `$_${postfix}__` : ``}` + if (environment.config.experimental.fullBundleMode) { + const outputFilename = pluginContext.getFileName(referenceId) + const outputUrl = toOutputFilePathInJS( + environment, + outputFilename, + 'asset', + 'assets/dummy.js', + 'js', + () => { + throw new Error('unreachable') + }, + ) + if (typeof outputUrl === 'object') throw new Error('unreachable') + url = outputUrl + } else { + url = `__VITE_ASSET__${referenceId}__${postfix ? `$_${postfix}__` : ``}` + } } cache.set(id, url) diff --git a/packages/vite/src/node/plugins/clientInjections.ts b/packages/vite/src/node/plugins/clientInjections.ts index 6790b4582fbac0..a456cf900b69ca 100644 --- a/packages/vite/src/node/plugins/clientInjections.ts +++ b/packages/vite/src/node/plugins/clientInjections.ts @@ -1,4 +1,5 @@ import path from 'node:path' +import fs from 'node:fs' import type { Plugin } from '../plugin' import type { ResolvedConfig } from '../config' import { CLIENT_ENTRY, ENV_ENTRY } from '../constants' @@ -34,66 +35,7 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin { return { name: 'vite:client-inject', async buildStart() { - const resolvedServerHostname = (await resolveHostname(config.server.host)) - .name - const resolvedServerPort = config.server.port! - const devBase = config.base - - const serverHost = `${resolvedServerHostname}:${resolvedServerPort}${devBase}` - - let hmrConfig = config.server.hmr - hmrConfig = isObject(hmrConfig) ? hmrConfig : undefined - const host = hmrConfig?.host || null - const protocol = hmrConfig?.protocol || null - const timeout = hmrConfig?.timeout || 30000 - const overlay = hmrConfig?.overlay !== false - const isHmrServerSpecified = !!hmrConfig?.server - const hmrConfigName = path.basename(config.configFile || 'vite.config.js') - - // hmr.clientPort -> hmr.port - // -> (24678 if middleware mode and HMR server is not specified) -> new URL(import.meta.url).port - let port = hmrConfig?.clientPort || hmrConfig?.port || null - if (config.server.middlewareMode && !isHmrServerSpecified) { - port ||= 24678 - } - - let directTarget = hmrConfig?.host || resolvedServerHostname - directTarget += `:${hmrConfig?.port || resolvedServerPort}` - directTarget += devBase - - let hmrBase = devBase - if (hmrConfig?.path) { - hmrBase = path.posix.join(hmrBase, hmrConfig.path) - } - - const modeReplacement = escapeReplacement(config.mode) - const baseReplacement = escapeReplacement(devBase) - const serverHostReplacement = escapeReplacement(serverHost) - const hmrProtocolReplacement = escapeReplacement(protocol) - const hmrHostnameReplacement = escapeReplacement(host) - const hmrPortReplacement = escapeReplacement(port) - const hmrDirectTargetReplacement = escapeReplacement(directTarget) - const hmrBaseReplacement = escapeReplacement(hmrBase) - const hmrTimeoutReplacement = escapeReplacement(timeout) - const hmrEnableOverlayReplacement = escapeReplacement(overlay) - const hmrConfigNameReplacement = escapeReplacement(hmrConfigName) - const wsTokenReplacement = escapeReplacement(config.webSocketToken) - - injectConfigValues = (code: string) => { - return code - .replace(`__MODE__`, modeReplacement) - .replace(/__BASE__/g, baseReplacement) - .replace(`__SERVER_HOST__`, serverHostReplacement) - .replace(`__HMR_PROTOCOL__`, hmrProtocolReplacement) - .replace(`__HMR_HOSTNAME__`, hmrHostnameReplacement) - .replace(`__HMR_PORT__`, hmrPortReplacement) - .replace(`__HMR_DIRECT_TARGET__`, hmrDirectTargetReplacement) - .replace(`__HMR_BASE__`, hmrBaseReplacement) - .replace(`__HMR_TIMEOUT__`, hmrTimeoutReplacement) - .replace(`__HMR_ENABLE_OVERLAY__`, hmrEnableOverlayReplacement) - .replace(`__HMR_CONFIG_NAME__`, hmrConfigNameReplacement) - .replace(`__WS_TOKEN__`, wsTokenReplacement) - } + injectConfigValues = await createClientConfigValueReplacer(config) }, async transform(code, id) { const ssr = this.environment.config.consumer === 'server' @@ -122,3 +64,83 @@ function escapeReplacement(value: string | number | boolean | null) { const jsonValue = JSON.stringify(value) return () => jsonValue } + +async function createClientConfigValueReplacer( + config: ResolvedConfig, +): Promise<(code: string) => string> { + const resolvedServerHostname = (await resolveHostname(config.server.host)) + .name + const resolvedServerPort = config.server.port! + const devBase = config.base + + const serverHost = `${resolvedServerHostname}:${resolvedServerPort}${devBase}` + + let hmrConfig = config.server.hmr + hmrConfig = isObject(hmrConfig) ? hmrConfig : undefined + const host = hmrConfig?.host || null + const protocol = hmrConfig?.protocol || null + const timeout = hmrConfig?.timeout || 30000 + const overlay = hmrConfig?.overlay !== false + const isHmrServerSpecified = !!hmrConfig?.server + const hmrConfigName = path.basename(config.configFile || 'vite.config.js') + + // hmr.clientPort -> hmr.port + // -> (24678 if middleware mode and HMR server is not specified) -> new URL(import.meta.url).port + let port = hmrConfig?.clientPort || hmrConfig?.port || null + if (config.server.middlewareMode && !isHmrServerSpecified) { + port ||= 24678 + } + + let directTarget = hmrConfig?.host || resolvedServerHostname + directTarget += `:${hmrConfig?.port || resolvedServerPort}` + directTarget += devBase + + let hmrBase = devBase + if (hmrConfig?.path) { + hmrBase = path.posix.join(hmrBase, hmrConfig.path) + } + + const modeReplacement = escapeReplacement(config.mode) + const baseReplacement = escapeReplacement(devBase) + const serverHostReplacement = escapeReplacement(serverHost) + const hmrProtocolReplacement = escapeReplacement(protocol) + const hmrHostnameReplacement = escapeReplacement(host) + const hmrPortReplacement = escapeReplacement(port) + const hmrDirectTargetReplacement = escapeReplacement(directTarget) + const hmrBaseReplacement = escapeReplacement(hmrBase) + const hmrTimeoutReplacement = escapeReplacement(timeout) + const hmrEnableOverlayReplacement = escapeReplacement(overlay) + const hmrConfigNameReplacement = escapeReplacement(hmrConfigName) + const wsTokenReplacement = escapeReplacement(config.webSocketToken) + const fullBundleModeReplacement = escapeReplacement( + config.experimental.fullBundleMode || false, + ) + + return (code) => + code + .replace(`__MODE__`, modeReplacement) + .replace(/__BASE__/g, baseReplacement) + .replace(`__SERVER_HOST__`, serverHostReplacement) + .replace(`__HMR_PROTOCOL__`, hmrProtocolReplacement) + .replace(`__HMR_HOSTNAME__`, hmrHostnameReplacement) + .replace(`__HMR_PORT__`, hmrPortReplacement) + .replace(`__HMR_DIRECT_TARGET__`, hmrDirectTargetReplacement) + .replace(`__HMR_BASE__`, hmrBaseReplacement) + .replace(`__HMR_TIMEOUT__`, hmrTimeoutReplacement) + .replace(`__HMR_ENABLE_OVERLAY__`, hmrEnableOverlayReplacement) + .replace(`__HMR_CONFIG_NAME__`, hmrConfigNameReplacement) + .replace(`__WS_TOKEN__`, wsTokenReplacement) + .replaceAll(`__FULL_BUNDLE_MODE__`, fullBundleModeReplacement) +} + +export async function getHmrImplementation( + config: ResolvedConfig, +): Promise { + const content = fs.readFileSync(normalizedClientEntry, 'utf-8') + const replacer = await createClientConfigValueReplacer(config) + return ( + replacer(content) + // the rolldown runtime cannot import a module + .replace(/import\s*['"]@vite\/env['"]/, '') + ) +} diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index 3f321fd551d5b1..8387bf2f8181d6 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -398,7 +398,7 @@ export function cssPlugin(config: ResolvedConfig): Plugin { url = injectQuery(url, `t=${mod.lastHMRTimestamp}`) } } - return [url, resolved] + return [url, cleanUrl(resolved)] } if (config.command === 'build') { const isExternal = config.build.rollupOptions.external @@ -586,9 +586,12 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { const cssContent = await getContentWithSourcemap(css) const code = [ - `import { updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle } from ${JSON.stringify( - path.posix.join(config.base, CLIENT_PUBLIC_PATH), - )}`, + config.isBundled + ? // TODO: support CSS + `const { updateStyle: __vite__updateStyle, removeStyle: __vite__removeStyle } = import.meta.hot._internal` + : `import { updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle } from ${JSON.stringify( + path.posix.join(config.base, CLIENT_PUBLIC_PATH), + )}`, `const __vite__id = ${JSON.stringify(id)}`, `const __vite__css = ${JSON.stringify(cssContent)}`, `__vite__updateStyle(__vite__id, __vite__css)`, @@ -631,337 +634,361 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { }, }, - async renderChunk(code, chunk, opts, meta) { - let chunkCSS: string | undefined - const renderedModules = new Proxy( - {} as Record, - { - get(_target, p) { - for (const name in meta.chunks) { - const modules = meta.chunks[name].modules - const module = modules[p as string] - if (module) { - return module - } - } - }, - }, - ) - // the chunk is empty if it's a dynamic entry chunk that only contains a CSS import - const isJsChunkEmpty = code === '' && !chunk.isEntry - let isPureCssChunk = chunk.exports.length === 0 - const ids = Object.keys(chunk.modules) - for (const id of ids) { - if (styles.has(id)) { - // ?transform-only is used for ?url and shouldn't be included in normal CSS chunks - if (transformOnlyRE.test(id)) { - continue - } - - // If this CSS is scoped to its importers exports, check if those importers exports - // are rendered in the chunks. If they are not, we can skip bundling this CSS. - const cssScopeTo = this.getModuleInfo(id)?.meta?.vite?.cssScopeTo - if ( - cssScopeTo && - !isCssScopeToRendered(cssScopeTo, renderedModules) - ) { - continue - } - - // a css module contains JS, so it makes this not a pure css chunk - if (cssModuleRE.test(id)) { - isPureCssChunk = false - } - - chunkCSS = (chunkCSS || '') + styles.get(id) - } else if (!isJsChunkEmpty) { - // if the module does not have a style, then it's not a pure css chunk. - // this is true because in the `transform` hook above, only modules - // that are css gets added to the `styles` map. - isPureCssChunk = false - } - } - - const publicAssetUrlMap = publicAssetUrlCache.get(config)! - - // resolve asset URL placeholders to their built file URLs - const resolveAssetUrlsInCss = ( - chunkCSS: string, - cssAssetName: string, - ) => { - const encodedPublicUrls = encodePublicUrlsInCSS(config) - - const relative = config.base === './' || config.base === '' - const cssAssetDirname = - encodedPublicUrls || relative - ? slash(getCssAssetDirname(cssAssetName)) - : undefined - - const toRelative = (filename: string) => { - // relative base + extracted CSS - const relativePath = normalizePath( - path.relative(cssAssetDirname!, filename), - ) - return relativePath[0] === '.' ? relativePath : './' + relativePath - } - - // replace asset url references with resolved url. - chunkCSS = chunkCSS.replace(assetUrlRE, (_, fileHash, postfix = '') => { - const filename = this.getFileName(fileHash) + postfix - chunk.viteMetadata!.importedAssets.add(cleanUrl(filename)) - return encodeURIPath( - toOutputFilePathInCss( - filename, - 'asset', - cssAssetName, - 'css', - config, - toRelative, - ), - ) - }) - // resolve public URL from CSS paths - if (encodedPublicUrls) { - const relativePathToPublicFromCSS = normalizePath( - path.relative(cssAssetDirname!, ''), - ) - chunkCSS = chunkCSS.replace(publicAssetUrlRE, (_, hash) => { - const publicUrl = publicAssetUrlMap.get(hash)!.slice(1) - return encodeURIPath( - toOutputFilePathInCss( - publicUrl, - 'public', - cssAssetName, - 'css', - config, - () => `${relativePathToPublicFromCSS}/${publicUrl}`, - ), + ...(config.command === 'build' + ? { + async renderChunk(code, chunk, opts, meta) { + let chunkCSS: string | undefined + const renderedModules = new Proxy( + {} as Record, + { + get(_target, p) { + for (const name in meta.chunks) { + const modules = meta.chunks[name].modules + const module = modules[p as string] + if (module) { + return module + } + } + }, + }, ) - }) - } - return chunkCSS - } - - function ensureFileExt(name: string, ext: string) { - return normalizePath( - path.format({ ...path.parse(name), base: undefined, ext }), - ) - } + // the chunk is empty if it's a dynamic entry chunk that only contains a CSS import + const isJsChunkEmpty = code === '' && !chunk.isEntry + let isPureCssChunk = chunk.exports.length === 0 + const ids = Object.keys(chunk.modules) + for (const id of ids) { + if (styles.has(id)) { + // ?transform-only is used for ?url and shouldn't be included in normal CSS chunks + if (transformOnlyRE.test(id)) { + continue + } - let s: MagicString | undefined - const urlEmitTasks: Array<{ - cssAssetName: string - originalFileName: string - content: string - start: number - end: number - }> = [] - - if (code.includes('__VITE_CSS_URL__')) { - let match: RegExpExecArray | null - cssUrlAssetRE.lastIndex = 0 - while ((match = cssUrlAssetRE.exec(code))) { - const [full, idHex] = match - const id = Buffer.from(idHex, 'hex').toString() - const originalFileName = cleanUrl(id) - const cssAssetName = ensureFileExt( - path.basename(originalFileName), - '.css', - ) - if (!styles.has(id)) { - throw new Error( - `css content for ${JSON.stringify(id)} was not found`, - ) - } + // If this CSS is scoped to its importers exports, check if those importers exports + // are rendered in the chunks. If they are not, we can skip bundling this CSS. + const cssScopeTo = + this.getModuleInfo(id)?.meta?.vite?.cssScopeTo + if ( + cssScopeTo && + !isCssScopeToRendered(cssScopeTo, renderedModules) + ) { + continue + } - let cssContent = styles.get(id)! + // a css module contains JS, so it makes this not a pure css chunk + if (cssModuleRE.test(id)) { + isPureCssChunk = false + } - cssContent = resolveAssetUrlsInCss(cssContent, cssAssetName) + chunkCSS = (chunkCSS || '') + styles.get(id) + } else if (!isJsChunkEmpty) { + // if the module does not have a style, then it's not a pure css chunk. + // this is true because in the `transform` hook above, only modules + // that are css gets added to the `styles` map. + isPureCssChunk = false + } + } - urlEmitTasks.push({ - cssAssetName, - originalFileName, - content: cssContent, - start: match.index, - end: match.index + full.length, - }) - } - } + const publicAssetUrlMap = publicAssetUrlCache.get(config)! + + // resolve asset URL placeholders to their built file URLs + const resolveAssetUrlsInCss = ( + chunkCSS: string, + cssAssetName: string, + ) => { + const encodedPublicUrls = encodePublicUrlsInCSS(config) + + const relative = config.base === './' || config.base === '' + const cssAssetDirname = + encodedPublicUrls || relative + ? slash(getCssAssetDirname(cssAssetName)) + : undefined + + const toRelative = (filename: string) => { + // relative base + extracted CSS + const relativePath = normalizePath( + path.relative(cssAssetDirname!, filename), + ) + return relativePath[0] === '.' + ? relativePath + : './' + relativePath + } - // should await even if this chunk does not include __VITE_CSS_URL__ - // so that code after this line runs in the same order - await urlEmitQueue.run(async () => - Promise.all( - urlEmitTasks.map(async (info) => { - info.content = await finalizeCss(info.content, config) - }), - ), - ) - if (urlEmitTasks.length > 0) { - const toRelativeRuntime = createToImportMetaURLBasedRelativeRuntime( - opts.format, - config.isWorker, - ) - s ||= new MagicString(code) + // replace asset url references with resolved url. + chunkCSS = chunkCSS.replace( + assetUrlRE, + (_, fileHash, postfix = '') => { + const filename = this.getFileName(fileHash) + postfix + chunk.viteMetadata!.importedAssets.add(cleanUrl(filename)) + return encodeURIPath( + toOutputFilePathInCss( + filename, + 'asset', + cssAssetName, + 'css', + config, + toRelative, + ), + ) + }, + ) + // resolve public URL from CSS paths + if (encodedPublicUrls) { + const relativePathToPublicFromCSS = normalizePath( + path.relative(cssAssetDirname!, ''), + ) + chunkCSS = chunkCSS.replace(publicAssetUrlRE, (_, hash) => { + const publicUrl = publicAssetUrlMap.get(hash)!.slice(1) + return encodeURIPath( + toOutputFilePathInCss( + publicUrl, + 'public', + cssAssetName, + 'css', + config, + () => `${relativePathToPublicFromCSS}/${publicUrl}`, + ), + ) + }) + } + return chunkCSS + } - for (const { - cssAssetName, - originalFileName, - content, - start, - end, - } of urlEmitTasks) { - const referenceId = this.emitFile({ - type: 'asset', - name: cssAssetName, - originalFileName, - source: content, - }) + function ensureFileExt(name: string, ext: string) { + return normalizePath( + path.format({ ...path.parse(name), base: undefined, ext }), + ) + } - const filename = this.getFileName(referenceId) - chunk.viteMetadata!.importedAssets.add(cleanUrl(filename)) - const replacement = toOutputFilePathInJS( - this.environment, - filename, - 'asset', - chunk.fileName, - 'js', - toRelativeRuntime, - ) - const replacementString = - typeof replacement === 'string' - ? JSON.stringify(encodeURIPath(replacement)).slice(1, -1) - : `"+${replacement.runtime}+"` - s.update(start, end, replacementString) - } - } + let s: MagicString | undefined + const urlEmitTasks: Array<{ + cssAssetName: string + originalFileName: string + content: string + start: number + end: number + }> = [] + + if (code.includes('__VITE_CSS_URL__')) { + let match: RegExpExecArray | null + cssUrlAssetRE.lastIndex = 0 + while ((match = cssUrlAssetRE.exec(code))) { + const [full, idHex] = match + const id = Buffer.from(idHex, 'hex').toString() + const originalFileName = cleanUrl(id) + const cssAssetName = ensureFileExt( + path.basename(originalFileName), + '.css', + ) + if (!styles.has(id)) { + throw new Error( + `css content for ${JSON.stringify(id)} was not found`, + ) + } - if (chunkCSS !== undefined) { - if (isPureCssChunk && (opts.format === 'es' || opts.format === 'cjs')) { - // this is a shared CSS-only chunk that is empty. - pureCssChunks.add(chunk) - } + let cssContent = styles.get(id)! - if (this.environment.config.build.cssCodeSplit) { - if ( - (opts.format === 'es' || opts.format === 'cjs') && - !chunk.fileName.includes('-legacy') - ) { - const isEntry = chunk.isEntry && isPureCssChunk - const cssFullAssetName = ensureFileExt(chunk.name, '.css') - // if facadeModuleId doesn't exist or doesn't have a CSS extension, - // that means a JS entry file imports a CSS file. - // in this case, only use the filename for the CSS chunk name like JS chunks. - const cssAssetName = - chunk.isEntry && - (!chunk.facadeModuleId || !isCSSRequest(chunk.facadeModuleId)) - ? path.basename(cssFullAssetName) - : cssFullAssetName - const originalFileName = getChunkOriginalFileName( - chunk, - config.root, - this.environment.config.isOutputOptionsForLegacyChunks?.(opts) ?? - false, - ) + cssContent = resolveAssetUrlsInCss(cssContent, cssAssetName) - chunkCSS = resolveAssetUrlsInCss(chunkCSS, cssAssetName) + urlEmitTasks.push({ + cssAssetName, + originalFileName, + content: cssContent, + start: match.index, + end: match.index + full.length, + }) + } + } - // wait for previous tasks as well - chunkCSS = await codeSplitEmitQueue.run(async () => { - return finalizeCss(chunkCSS!, config) - }) + // should await even if this chunk does not include __VITE_CSS_URL__ + // so that code after this line runs in the same order + await urlEmitQueue.run(async () => + Promise.all( + urlEmitTasks.map(async (info) => { + info.content = await finalizeCss(info.content, config) + }), + ), + ) + if (urlEmitTasks.length > 0) { + const toRelativeRuntime = + createToImportMetaURLBasedRelativeRuntime( + opts.format, + config.isWorker, + ) + s ||= new MagicString(code) - // emit corresponding css file - const referenceId = this.emitFile({ - type: 'asset', - name: cssAssetName, - originalFileName, - source: chunkCSS, - }) - if (isEntry) { - cssEntriesMap.get(this.environment)!.set(chunk.name, referenceId) + for (const { + cssAssetName, + originalFileName, + content, + start, + end, + } of urlEmitTasks) { + const referenceId = this.emitFile({ + type: 'asset', + name: cssAssetName, + originalFileName, + source: content, + }) + + const filename = this.getFileName(referenceId) + chunk.viteMetadata!.importedAssets.add(cleanUrl(filename)) + const replacement = toOutputFilePathInJS( + this.environment, + filename, + 'asset', + chunk.fileName, + 'js', + toRelativeRuntime, + ) + const replacementString = + typeof replacement === 'string' + ? JSON.stringify(encodeURIPath(replacement)).slice(1, -1) + : `"+${replacement.runtime}+"` + s.update(start, end, replacementString) + } } - chunk.viteMetadata!.importedCss.add(this.getFileName(referenceId)) - } else if (this.environment.config.consumer === 'client') { - // legacy build and inline css - - // Entry chunk CSS will be collected into `chunk.viteMetadata.importedCss` - // and injected later by the `'vite:build-html'` plugin into the `index.html` - // so it will be duplicated. (https://github.com/vitejs/vite/issues/2062#issuecomment-782388010) - // But because entry chunk can be imported by dynamic import, - // we shouldn't remove the inlined CSS. (#10285) - - chunkCSS = await finalizeCss(chunkCSS, config) - let cssString = JSON.stringify(chunkCSS) - cssString = - renderAssetUrlInJS(this, chunk, opts, cssString)?.toString() || - cssString - const style = `__vite_style__` - const injectCode = - `var ${style} = document.createElement('style');` + - `${style}.textContent = ${cssString};` + - `document.head.appendChild(${style});` - - let injectionPoint: number - if (opts.format === 'iife' || opts.format === 'umd') { - const m = ( - opts.format === 'iife' ? IIFE_BEGIN_RE : UMD_BEGIN_RE - ).exec(code) - if (!m) { - this.error('Injection point for inlined CSS not found') - return + + if (chunkCSS !== undefined) { + if ( + isPureCssChunk && + (opts.format === 'es' || opts.format === 'cjs') + ) { + // this is a shared CSS-only chunk that is empty. + pureCssChunks.add(chunk) } - injectionPoint = m.index + m[0].length - } else if (opts.format === 'es') { - // legacy build - if (code.startsWith('#!')) { - let secondLinePos = code.indexOf('\n') - if (secondLinePos === -1) { - secondLinePos = 0 + + if (this.environment.config.build.cssCodeSplit) { + if ( + (opts.format === 'es' || opts.format === 'cjs') && + !chunk.fileName.includes('-legacy') + ) { + const isEntry = chunk.isEntry && isPureCssChunk + const cssFullAssetName = ensureFileExt(chunk.name, '.css') + // if facadeModuleId doesn't exist or doesn't have a CSS extension, + // that means a JS entry file imports a CSS file. + // in this case, only use the filename for the CSS chunk name like JS chunks. + const cssAssetName = + chunk.isEntry && + (!chunk.facadeModuleId || + !isCSSRequest(chunk.facadeModuleId)) + ? path.basename(cssFullAssetName) + : cssFullAssetName + const originalFileName = getChunkOriginalFileName( + chunk, + config.root, + this.environment.config.isOutputOptionsForLegacyChunks?.( + opts, + ) ?? false, + ) + + chunkCSS = resolveAssetUrlsInCss(chunkCSS, cssAssetName) + + // wait for previous tasks as well + chunkCSS = await codeSplitEmitQueue.run(async () => { + return finalizeCss(chunkCSS!, config) + }) + + // emit corresponding css file + const referenceId = this.emitFile({ + type: 'asset', + name: cssAssetName, + originalFileName, + source: chunkCSS, + }) + if (isEntry) { + cssEntriesMap + .get(this.environment)! + .set(chunk.name, referenceId) + } + chunk.viteMetadata!.importedCss.add( + this.getFileName(referenceId), + ) + } else if (this.environment.config.consumer === 'client') { + // legacy build and inline css + + // Entry chunk CSS will be collected into `chunk.viteMetadata.importedCss` + // and injected later by the `'vite:build-html'` plugin into the `index.html` + // so it will be duplicated. (https://github.com/vitejs/vite/issues/2062#issuecomment-782388010) + // But because entry chunk can be imported by dynamic import, + // we shouldn't remove the inlined CSS. (#10285) + + chunkCSS = await finalizeCss(chunkCSS, config) + let cssString = JSON.stringify(chunkCSS) + cssString = + renderAssetUrlInJS( + this, + chunk, + opts, + cssString, + )?.toString() || cssString + const style = `__vite_style__` + const injectCode = + `var ${style} = document.createElement('style');` + + `${style}.textContent = ${cssString};` + + `document.head.appendChild(${style});` + + let injectionPoint: number + if (opts.format === 'iife' || opts.format === 'umd') { + const m = ( + opts.format === 'iife' ? IIFE_BEGIN_RE : UMD_BEGIN_RE + ).exec(code) + if (!m) { + this.error('Injection point for inlined CSS not found') + return + } + injectionPoint = m.index + m[0].length + } else if (opts.format === 'es') { + // legacy build + if (code.startsWith('#!')) { + let secondLinePos = code.indexOf('\n') + if (secondLinePos === -1) { + secondLinePos = 0 + } + injectionPoint = secondLinePos + } else { + injectionPoint = 0 + } + } else { + this.error('Non supported format') + return + } + + s ||= new MagicString(code) + s.appendRight(injectionPoint, injectCode) } - injectionPoint = secondLinePos } else { - injectionPoint = 0 + // resolve public URL from CSS paths, we need to use absolute paths + chunkCSS = resolveAssetUrlsInCss(chunkCSS, getCssBundleName()) + // finalizeCss is called for the aggregated chunk in generateBundle + + chunkCSSMap.set(chunk.fileName, chunkCSS) } - } else { - this.error('Non supported format') - return } - s ||= new MagicString(code) - s.appendRight(injectionPoint, injectCode) - } - } else { - // resolve public URL from CSS paths, we need to use absolute paths - chunkCSS = resolveAssetUrlsInCss(chunkCSS, getCssBundleName()) - // finalizeCss is called for the aggregated chunk in generateBundle - - chunkCSSMap.set(chunk.fileName, chunkCSS) - } - } - - if (s) { - if (config.build.sourcemap) { - return { - code: s.toString(), - map: s.generateMap({ hires: 'boundary' }), - } - } else { - return { code: s.toString() } - } - } - return null - }, + if (s) { + if (config.build.sourcemap) { + return { + code: s.toString(), + map: s.generateMap({ hires: 'boundary' }), + } + } else { + return { code: s.toString() } + } + } + return null + }, - augmentChunkHash(chunk) { - if (chunk.viteMetadata?.importedCss.size) { - let hash = '' - for (const id of chunk.viteMetadata.importedCss) { - hash += id + augmentChunkHash(chunk) { + if (chunk.viteMetadata?.importedCss.size) { + let hash = '' + for (const id of chunk.viteMetadata.importedCss) { + hash += id + } + return hash + } + }, } - return hash - } - }, + : {}), async generateBundle(opts, bundle) { // to avoid emitting duplicate assets for modern build and legacy build diff --git a/packages/vite/src/node/plugins/define.ts b/packages/vite/src/node/plugins/define.ts index 63c7ec66236f29..d8a610062f34dc 100644 --- a/packages/vite/src/node/plugins/define.ts +++ b/packages/vite/src/node/plugins/define.ts @@ -12,6 +12,7 @@ const importMetaEnvKeyReCache = new Map() const escapedDotRE = /(? = {} if (isBuild) { importMetaKeys['import.meta.hot'] = `undefined` + } + if (isBundled) { for (const key in config.env) { const val = JSON.stringify(config.env[key]) importMetaKeys[`import.meta.env.${key}`] = val @@ -115,7 +118,7 @@ export function definePlugin(config: ResolvedConfig): Plugin { return pattern } - if (isBuild && config.nativePluginEnabledLevel >= 1) { + if (isBundled && config.nativePluginEnabledLevel >= 1) { return { name: 'vite:define', options(option) { @@ -135,7 +138,7 @@ export function definePlugin(config: ResolvedConfig): Plugin { transform: { async handler(code, id) { - if (this.environment.config.consumer === 'client' && !isBuild) { + if (this.environment.config.consumer === 'client' && !isBundled) { // for dev we inject actual global defines in the vite client to // avoid the transform cost. see the `clientInjection` and // `importAnalysis` plugin. diff --git a/packages/vite/src/node/plugins/dynamicImportVars.ts b/packages/vite/src/node/plugins/dynamicImportVars.ts index a41f165be705fe..9200f393ec7907 100644 --- a/packages/vite/src/node/plugins/dynamicImportVars.ts +++ b/packages/vite/src/node/plugins/dynamicImportVars.ts @@ -173,7 +173,7 @@ export function dynamicImportVarsPlugin(config: ResolvedConfig): Plugin { extensions: [], }) - if (config.command === 'build' && config.nativePluginEnabledLevel >= 1) { + if (config.isBundled && config.nativePluginEnabledLevel >= 1) { return perEnvironmentPlugin('native:dynamic-import-vars', (environment) => { const { include, exclude } = environment.config.build.dynamicImportVarsOptions diff --git a/packages/vite/src/node/plugins/importMetaGlob.ts b/packages/vite/src/node/plugins/importMetaGlob.ts index 796894d44f454a..e661f800439412 100644 --- a/packages/vite/src/node/plugins/importMetaGlob.ts +++ b/packages/vite/src/node/plugins/importMetaGlob.ts @@ -42,7 +42,7 @@ interface ParsedGeneralImportGlobOptions extends GeneralImportGlobOptions { } export function importGlobPlugin(config: ResolvedConfig): Plugin { - if (config.command === 'build' && config.nativePluginEnabledLevel >= 1) { + if (config.isBundled && config.nativePluginEnabledLevel >= 1) { return nativeImportGlobPlugin({ root: config.root, restoreQueryExtension: config.experimental.importGlobRestoreExtension, diff --git a/packages/vite/src/node/plugins/index.ts b/packages/vite/src/node/plugins/index.ts index 0ba0672011b87f..b3948df9a317ec 100644 --- a/packages/vite/src/node/plugins/index.ts +++ b/packages/vite/src/node/plugins/index.ts @@ -41,8 +41,9 @@ export async function resolvePlugins( postPlugins: Plugin[], ): Promise { const isBuild = config.command === 'build' + const isBundled = config.isBundled const isWorker = config.isWorker - const buildPlugins = isBuild + const buildPlugins = isBundled ? await (await import('../build')).resolveBuildPlugins(config) : { pre: [], post: [] } const { modulePreload } = config.build @@ -50,10 +51,10 @@ export async function resolvePlugins( const enableNativePluginV1 = config.nativePluginEnabledLevel >= 1 return [ - !isBuild ? optimizedDepsPlugin() : null, + !isBundled ? optimizedDepsPlugin() : null, !isWorker ? watchPackageDataPlugin(config.packageCache) : null, - !isBuild ? preAliasPlugin(config) : null, - isBuild && + !isBundled ? preAliasPlugin(config) : null, + isBundled && enableNativePluginV1 && !config.resolve.alias.some((v) => v.customResolver) ? nativeAliasPlugin({ @@ -116,7 +117,7 @@ export async function resolvePlugins( wasmFallbackPlugin(config), definePlugin(config), cssPostPlugin(config), - isBuild && buildHtmlPlugin(config), + isBundled && buildHtmlPlugin(config), workerImportMetaUrlPlugin(config), assetImportMetaUrlPlugin(config), ...buildPlugins.pre, @@ -128,7 +129,7 @@ export async function resolvePlugins( ...buildPlugins.post, // internal server-only plugins are always applied after everything else - ...(isBuild + ...(isBundled ? [] : [ clientInjectionsPlugin(config), diff --git a/packages/vite/src/node/plugins/modulePreloadPolyfill.ts b/packages/vite/src/node/plugins/modulePreloadPolyfill.ts index b3bc1c3ee0d48e..b7d3354a8394db 100644 --- a/packages/vite/src/node/plugins/modulePreloadPolyfill.ts +++ b/packages/vite/src/node/plugins/modulePreloadPolyfill.ts @@ -8,7 +8,7 @@ export const modulePreloadPolyfillId = 'vite/modulepreload-polyfill' const resolvedModulePreloadPolyfillId = '\0' + modulePreloadPolyfillId + '.js' export function modulePreloadPolyfillPlugin(config: ResolvedConfig): Plugin { - if (config.command === 'build' && config.nativePluginEnabledLevel >= 1) { + if (config.isBundled && config.nativePluginEnabledLevel >= 1) { return perEnvironmentPlugin( 'native:modulepreload-polyfill', (environment) => { diff --git a/packages/vite/src/node/plugins/oxc.ts b/packages/vite/src/node/plugins/oxc.ts index da5992d6a710da..a9acf64abee279 100644 --- a/packages/vite/src/node/plugins/oxc.ts +++ b/packages/vite/src/node/plugins/oxc.ts @@ -321,7 +321,7 @@ function resolveTsconfigTarget(target: string | undefined): number | 'next' { } export function oxcPlugin(config: ResolvedConfig): Plugin { - if (config.command === 'build' && config.nativePluginEnabledLevel >= 1) { + if (config.isBundled && config.nativePluginEnabledLevel >= 1) { return perEnvironmentPlugin('native:transform', (environment) => { const { jsxInject, diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index ec3cd59735384b..eb6892319f8ef0 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -236,7 +236,7 @@ export function oxcResolvePlugin( overrideEnvConfig: (ResolvedConfig & ResolvedEnvironmentOptions) | undefined, ): Plugin[] { return [ - ...(!resolveOptions.isBuild + ...(resolveOptions.optimizeDeps && !resolveOptions.isBuild ? [optimizerResolvePlugin(resolveOptions)] : []), ...perEnvironmentOrWorkerPlugin( @@ -248,6 +248,7 @@ export function oxcResolvePlugin( const depsOptimizerEnabled = resolveOptions.optimizeDeps && !resolveOptions.isBuild && + !partialEnv.config.experimental.fullBundleMode && !isDepOptimizationDisabled(partialEnv.config.optimizeDeps) const getDepsOptimizer = () => { const env = getEnv() @@ -396,6 +397,12 @@ function optimizerResolvePlugin( return { name: 'vite:resolve-dev', + applyToEnvironment(environment) { + return ( + !environment.config.experimental.fullBundleMode && + !isDepOptimizationDisabled(environment.config.optimizeDeps) + ) + }, resolveId: { filter: { id: { diff --git a/packages/vite/src/node/plugins/wasm.ts b/packages/vite/src/node/plugins/wasm.ts index 5b3ad98b3fc92c..7c0ec3e706fd7b 100644 --- a/packages/vite/src/node/plugins/wasm.ts +++ b/packages/vite/src/node/plugins/wasm.ts @@ -54,7 +54,7 @@ const wasmHelper = async (opts = {}, url: string) => { const wasmHelperCode = wasmHelper.toString() export const wasmHelperPlugin = (config: ResolvedConfig): Plugin => { - if (config.command === 'build' && config.nativePluginEnabledLevel >= 1) { + if (config.isBundled && config.nativePluginEnabledLevel >= 1) { return nativeWasmHelperPlugin({ decodedBase: config.decodedBase, }) diff --git a/packages/vite/src/node/plugins/worker.ts b/packages/vite/src/node/plugins/worker.ts index e311b93fd27168..344dd1f0035de9 100644 --- a/packages/vite/src/node/plugins/worker.ts +++ b/packages/vite/src/node/plugins/worker.ts @@ -312,7 +312,7 @@ export async function workerFileToUrl( } export function webWorkerPostPlugin(config: ResolvedConfig): Plugin { - if (config.command === 'build' && config.nativePluginEnabledLevel >= 1) { + if (config.isBundled && config.nativePluginEnabledLevel >= 1) { return perEnvironmentPlugin( 'native:web-worker-post-plugin', (environment) => { @@ -540,58 +540,68 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { }, }, - renderChunk(code, chunk, outputOptions) { - let s: MagicString - const result = () => { - return ( - s && { - code: s.toString(), - map: this.environment.config.build.sourcemap - ? s.generateMap({ hires: 'boundary' }) - : null, - } - ) - } - workerAssetUrlRE.lastIndex = 0 - if (workerAssetUrlRE.test(code)) { - const toRelativeRuntime = createToImportMetaURLBasedRelativeRuntime( - outputOptions.format, - this.environment.config.isWorker, - ) - - let match: RegExpExecArray | null - s = new MagicString(code) - workerAssetUrlRE.lastIndex = 0 - - // Replace "__VITE_WORKER_ASSET__5aa0ddc0__" using relative paths - const workerOutputCache = workerOutputCaches.get( - config.mainConfig || config, - )! - - while ((match = workerAssetUrlRE.exec(code))) { - const [full, hash] = match - const filename = workerOutputCache.getEntryFilenameFromHash(hash) - if (!filename) { - this.warn(`Could not find worker asset for hash: ${hash}`) - continue - } - const replacement = toOutputFilePathInJS( - this.environment, - filename, - 'asset', - chunk.fileName, - 'js', - toRelativeRuntime, - ) - const replacementString = - typeof replacement === 'string' - ? JSON.stringify(encodeURIPath(replacement)).slice(1, -1) - : `"+${replacement.runtime}+"` - s.update(match.index, match.index + full.length, replacementString) + ...(isBuild + ? { + renderChunk(code, chunk, outputOptions) { + let s: MagicString + const result = () => { + return ( + s && { + code: s.toString(), + map: this.environment.config.build.sourcemap + ? s.generateMap({ hires: 'boundary' }) + : null, + } + ) + } + workerAssetUrlRE.lastIndex = 0 + if (workerAssetUrlRE.test(code)) { + const toRelativeRuntime = + createToImportMetaURLBasedRelativeRuntime( + outputOptions.format, + this.environment.config.isWorker, + ) + + let match: RegExpExecArray | null + s = new MagicString(code) + workerAssetUrlRE.lastIndex = 0 + + // Replace "__VITE_WORKER_ASSET__5aa0ddc0__" using relative paths + const workerOutputCache = workerOutputCaches.get( + config.mainConfig || config, + )! + + while ((match = workerAssetUrlRE.exec(code))) { + const [full, hash] = match + const filename = + workerOutputCache.getEntryFilenameFromHash(hash) + if (!filename) { + this.warn(`Could not find worker asset for hash: ${hash}`) + continue + } + const replacement = toOutputFilePathInJS( + this.environment, + filename, + 'asset', + chunk.fileName, + 'js', + toRelativeRuntime, + ) + const replacementString = + typeof replacement === 'string' + ? JSON.stringify(encodeURIPath(replacement)).slice(1, -1) + : `"+${replacement.runtime}+"` + s.update( + match.index, + match.index + full.length, + replacementString, + ) + } + } + return result() + }, } - } - return result() - }, + : {}), generateBundle(opts, bundle) { // to avoid emitting duplicate assets for modern build and legacy build diff --git a/packages/vite/src/node/server/environment.ts b/packages/vite/src/node/server/environment.ts index 10392ee9888dae..db5f0b70c62e55 100644 --- a/packages/vite/src/node/server/environment.ts +++ b/packages/vite/src/node/server/environment.ts @@ -44,6 +44,8 @@ export interface DevEnvironmentContext { inlineSourceMap?: boolean } depsOptimizer?: DepsOptimizer + /** @internal used for full bundle mode */ + disableDepsOptimizer?: boolean } export class DevEnvironment extends BaseEnvironment { @@ -145,7 +147,7 @@ export class DevEnvironment extends BaseEnvironment { this.hot.on( 'vite:invalidate', async ({ path, message, firstInvalidatedBy }) => { - invalidateModule(this, { + this.invalidateModule({ path, message, firstInvalidatedBy, @@ -153,17 +155,19 @@ export class DevEnvironment extends BaseEnvironment { }, ) - const { optimizeDeps } = this.config - if (context.depsOptimizer) { - this.depsOptimizer = context.depsOptimizer - } else if (isDepOptimizationDisabled(optimizeDeps)) { - this.depsOptimizer = undefined - } else { - this.depsOptimizer = ( - optimizeDeps.noDiscovery - ? createExplicitDepsOptimizer - : createDepsOptimizer - )(this) + if (!context.disableDepsOptimizer) { + const { optimizeDeps } = this.config + if (context.depsOptimizer) { + this.depsOptimizer = context.depsOptimizer + } else if (isDepOptimizationDisabled(optimizeDeps)) { + this.depsOptimizer = undefined + } else { + this.depsOptimizer = ( + optimizeDeps.noDiscovery + ? createExplicitDepsOptimizer + : createDepsOptimizer + )(this) + } } } @@ -246,6 +250,36 @@ export class DevEnvironment extends BaseEnvironment { } } + protected invalidateModule(m: { + path: string + message?: string + firstInvalidatedBy: string + }): void { + const mod = this.moduleGraph.urlToModuleMap.get(m.path) + if ( + mod && + mod.isSelfAccepting && + mod.lastHMRTimestamp > 0 && + !mod.lastHMRInvalidationReceived + ) { + mod.lastHMRInvalidationReceived = true + this.logger.info( + colors.yellow(`hmr invalidate `) + + colors.dim(m.path) + + (m.message ? ` ${m.message}` : ''), + { timestamp: true }, + ) + const file = getShortName(mod.file!, this.config.root) + updateModules( + this, + file, + [...mod.importers].filter((imp) => imp !== mod), // ignore self-imports + mod.lastHMRTimestamp, + m.firstInvalidatedBy, + ) + } + } + async close(): Promise { this._closing = true @@ -287,39 +321,6 @@ export class DevEnvironment extends BaseEnvironment { } } -function invalidateModule( - environment: DevEnvironment, - m: { - path: string - message?: string - firstInvalidatedBy: string - }, -) { - const mod = environment.moduleGraph.urlToModuleMap.get(m.path) - if ( - mod && - mod.isSelfAccepting && - mod.lastHMRTimestamp > 0 && - !mod.lastHMRInvalidationReceived - ) { - mod.lastHMRInvalidationReceived = true - environment.logger.info( - colors.yellow(`hmr invalidate `) + - colors.dim(m.path) + - (m.message ? ` ${m.message}` : ''), - { timestamp: true }, - ) - const file = getShortName(mod.file!, environment.config.root) - updateModules( - environment, - file, - [...mod.importers].filter((imp) => imp !== mod), // ignore self-imports - mod.lastHMRTimestamp, - m.firstInvalidatedBy, - ) - } -} - const callCrawlEndIfIdleAfterMs = 50 interface CrawlEndFinder { diff --git a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts new file mode 100644 index 00000000000000..51ab4c1f8cead0 --- /dev/null +++ b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts @@ -0,0 +1,405 @@ +import { randomUUID } from 'node:crypto' +import { setTimeout } from 'node:timers/promises' +import { + type BindingClientHmrUpdate, + type DevEngine, + dev, +} from 'rolldown/experimental' +import colors from 'picocolors' +import getEtag from 'etag' +import type { Update } from '#types/hmrPayload' +import { ChunkMetadataMap, resolveRolldownOptions } from '../../build' +import { getHmrImplementation } from '../../plugins/clientInjections' +import { DevEnvironment, type DevEnvironmentContext } from '../environment' +import type { ResolvedConfig } from '../../config' +import type { ViteDevServer } from '../../server' +import { createDebugger } from '../../utils' +import { type NormalizedHotChannelClient, getShortName } from '../hmr' +import { prepareError } from '../middlewares/error' + +const debug = createDebugger('vite:full-bundle-mode') + +type HmrOutput = BindingClientHmrUpdate['update'] + +type MemoryFile = { + source: string | Uint8Array + etag?: string +} + +export class MemoryFiles { + private files = new Map MemoryFile)>() + + get size(): number { + return this.files.size + } + + get(file: string): MemoryFile | undefined { + const result = this.files.get(file) + if (result === undefined) { + return undefined + } + if (typeof result === 'function') { + const content = result() + this.files.set(file, content) + return content + } + return result + } + + set(file: string, content: MemoryFile | (() => MemoryFile)): void { + this.files.set(file, content) + } + + has(file: string): boolean { + return this.files.has(file) + } + + clear(): void { + this.files.clear() + } +} + +export class FullBundleDevEnvironment extends DevEnvironment { + private devEngine!: DevEngine + private clients = new Clients() + private invalidateCalledModules = new Map< + NormalizedHotChannelClient, + Set + >() + private debouncedFullReload = debounce(20, () => { + this.hot.send({ type: 'full-reload', path: '*' }) + this.logger.info(colors.green(`page reload`), { timestamp: true }) + }) + + memoryFiles: MemoryFiles = new MemoryFiles() + + constructor( + name: string, + config: ResolvedConfig, + context: DevEnvironmentContext, + ) { + if (name !== 'client') { + throw new Error( + 'currently full bundle mode is only available for client environment', + ) + } + + super(name, config, { ...context, disableDepsOptimizer: true }) + } + + override async listen(_server: ViteDevServer): Promise { + this.hot.listen() + + debug?.('INITIAL: setup bundle options') + const rollupOptions = await this.getRolldownOptions() + // NOTE: only single outputOptions is supported here + if ( + Array.isArray(rollupOptions.output) && + rollupOptions.output.length > 1 + ) { + throw new Error('multiple output options are not supported in dev mode') + } + const outputOptions = ( + Array.isArray(rollupOptions.output) + ? rollupOptions.output[0] + : rollupOptions.output + )! + + this.hot.on('vite:module-loaded', (payload, client) => { + const clientId = this.clients.setupIfNeeded(client) + this.devEngine.registerModules(clientId, payload.modules) + }) + this.hot.on('vite:client:disconnect', (_payload, client) => { + const clientId = this.clients.delete(client) + if (clientId) { + this.devEngine.removeClient(clientId) + } + }) + this.hot.on('vite:invalidate', (payload, client) => { + this.handleInvalidateModule(client, payload) + }) + + this.devEngine = await dev(rollupOptions, outputOptions, { + onHmrUpdates: (result) => { + if (result instanceof Error) { + // TODO: send to the specific client + for (const client of this.clients.getAll()) { + client.send({ + type: 'error', + err: prepareError(result), + }) + } + return + } + const { updates, changedFiles } = result + if (changedFiles.length === 0) { + return + } + if (updates.every((update) => update.update.type === 'Noop')) { + debug?.(`ignored file change for ${changedFiles.join(', ')}`) + return + } + for (const { clientId, update } of updates) { + const client = this.clients.get(clientId) + if (client) { + this.invalidateCalledModules.get(client)?.clear() + this.handleHmrOutput(client, changedFiles, update) + } + } + }, + onOutput: (result) => { + if (result instanceof Error) { + // TODO: handle error + this.logger.error(colors.red(`✘ Build error: ${result.message}`), { + error: result, + }) + return + } + + // NOTE: don't clear memoryFiles here as incremental build re-uses the files + for (const outputFile of result.output) { + this.memoryFiles.set(outputFile.fileName, () => { + const source = + outputFile.type === 'chunk' ? outputFile.code : outputFile.source + return { + source, + etag: getEtag(Buffer.from(source), { weak: true }), + } + }) + } + }, + watch: { + skipWrite: true, + }, + }) + debug?.('INITIAL: setup dev engine') + this.devEngine.run().then( + () => { + debug?.('INITIAL: run done') + }, + (e) => { + debug?.('INITIAL: run error', e) + }, + ) + this.waitForInitialBuildFinish().then(() => { + debug?.('INITIAL: build done') + this.hot.send({ type: 'full-reload', path: '*' }) + }) + } + + private async waitForInitialBuildFinish(): Promise { + await this.devEngine.ensureCurrentBuildFinish() + while (this.memoryFiles.size === 0) { + await setTimeout(10) + await this.devEngine.ensureCurrentBuildFinish() + } + } + + override async warmupRequest(_url: string): Promise { + // no-op + } + + protected override invalidateModule(_m: unknown): void { + // no-op, handled via `server.ws` instead + } + + private handleInvalidateModule( + client: NormalizedHotChannelClient, + m: { + path: string + message?: string + firstInvalidatedBy: string + }, + ): void { + ;(async () => { + const invalidateCalledModules = this.invalidateCalledModules.get(client) + if (invalidateCalledModules?.has(m.path)) { + debug?.( + `INVALIDATE: invalidate received from ${m.path}, but ignored because it was already invalidated`, + ) + return + } + + debug?.( + `INVALIDATE: invalidate received from ${m.path}, re-triggering HMR`, + ) + if (!invalidateCalledModules) { + this.invalidateCalledModules.set(client, new Set([])) + } + this.invalidateCalledModules.get(client)!.add(m.path) + + // TODO: how to handle errors? + const _update = await this.devEngine.invalidate( + m.path, + m.firstInvalidatedBy, + ) + const update = _update.find( + (u) => this.clients.get(u.clientId) === client, + )?.update + if (!update) return + + if (update.type === 'Patch') { + this.logger.info( + colors.yellow(`hmr invalidate `) + + colors.dim(m.path) + + (m.message ? ` ${m.message}` : ''), + { timestamp: true }, + ) + } + + this.handleHmrOutput(client, [m.path], update, { + firstInvalidatedBy: m.firstInvalidatedBy, + }) + })() + } + + async triggerBundleRegenerationIfStale(): Promise { + const hasLatestBuildOutput = await this.devEngine.hasLatestBuildOutput() + if (!hasLatestBuildOutput) { + this.devEngine.ensureLatestBuildOutput().then(() => { + this.debouncedFullReload() + }) + debug?.(`TRIGGER: access to stale bundle, triggered bundle re-generation`) + } + return !hasLatestBuildOutput + } + + override async close(): Promise { + this.memoryFiles.clear() + await Promise.all([super.close(), this.devEngine.close()]) + } + + private async getRolldownOptions() { + const chunkMetadataMap = new ChunkMetadataMap() + const rolldownOptions = resolveRolldownOptions(this, chunkMetadataMap) + rolldownOptions.experimental ??= {} + rolldownOptions.experimental.hmr = { + implement: await getHmrImplementation(this.getTopLevelConfig()), + } + + if (rolldownOptions.optimization) { + // disable inlineConst optimization due to a bug in Rolldown + rolldownOptions.optimization.inlineConst = false + } + + // set filenames to make output paths predictable so that `renderChunk` hook does not need to be used + if (Array.isArray(rolldownOptions.output)) { + for (const output of rolldownOptions.output) { + output.entryFileNames = 'assets/[name].js' + output.chunkFileNames = 'assets/[name]-[hash].js' + output.assetFileNames = 'assets/[name]-[hash][extname]' + output.minify = false + output.sourcemap = true + } + } else { + rolldownOptions.output ??= {} + rolldownOptions.output.entryFileNames = 'assets/[name].js' + rolldownOptions.output.chunkFileNames = 'assets/[name]-[hash].js' + rolldownOptions.output.assetFileNames = 'assets/[name]-[hash][extname]' + rolldownOptions.output.minify = false + rolldownOptions.output.sourcemap = true + } + + return rolldownOptions + } + + private handleHmrOutput( + client: NormalizedHotChannelClient, + files: string[], + hmrOutput: HmrOutput, + invalidateInformation?: { firstInvalidatedBy: string }, + ) { + if (hmrOutput.type === 'Noop') return + + const shortFile = files + .map((file) => getShortName(file, this.config.root)) + .join(', ') + if (hmrOutput.type === 'FullReload') { + const reason = hmrOutput.reason + ? colors.dim(` (${hmrOutput.reason})`) + : '' + this.logger.info( + colors.green(`trigger page reload `) + colors.dim(shortFile) + reason, + { clear: !invalidateInformation, timestamp: true }, + ) + this.devEngine.ensureLatestBuildOutput().then(() => { + this.debouncedFullReload() + }) + return + } + + debug?.(`handle hmr output for ${shortFile}`, { + ...hmrOutput, + code: typeof hmrOutput.code === 'string' ? '[code]' : hmrOutput.code, + }) + + this.memoryFiles.set(hmrOutput.filename, { source: hmrOutput.code }) + if (hmrOutput.sourcemapFilename && hmrOutput.sourcemap) { + this.memoryFiles.set(hmrOutput.sourcemapFilename, { + source: hmrOutput.sourcemap, + }) + } + const updates: Update[] = hmrOutput.hmrBoundaries.map((boundary: any) => { + return { + type: 'js-update', + url: hmrOutput.filename, + path: boundary.boundary, + acceptedPath: boundary.acceptedVia, + firstInvalidatedBy: invalidateInformation?.firstInvalidatedBy, + timestamp: Date.now(), + } + }) + client.send({ + type: 'update', + updates, + }) + this.logger.info( + colors.green(`hmr update `) + + colors.dim([...new Set(updates.map((u) => u.path))].join(', ')), + { clear: !invalidateInformation, timestamp: true }, + ) + } +} + +class Clients { + private clientToId = new Map() + private idToClient = new Map() + + setupIfNeeded(client: NormalizedHotChannelClient): string { + const id = this.clientToId.get(client) + if (id) return id + + const newId = randomUUID() + this.clientToId.set(client, newId) + this.idToClient.set(newId, client) + return newId + } + + get(id: string): NormalizedHotChannelClient | undefined { + return this.idToClient.get(id) + } + + getAll(): NormalizedHotChannelClient[] { + return Array.from(this.idToClient.values()) + } + + delete(client: NormalizedHotChannelClient): string | undefined { + const id = this.clientToId.get(client) + if (id) { + this.clientToId.delete(client) + this.idToClient.delete(id) + return id + } + } +} + +function debounce(time: number, cb: () => void) { + let timer: ReturnType | null + return () => { + if (timer) { + globalThis.clearTimeout(timer) + timer = null + } + timer = globalThis.setTimeout(cb, time) + } +} diff --git a/packages/vite/src/node/server/hmr.ts b/packages/vite/src/node/server/hmr.ts index 5042f09ebc7545..6b3e4e46fe5bb5 100644 --- a/packages/vite/src/node/server/hmr.ts +++ b/packages/vite/src/node/server/hmr.ts @@ -419,6 +419,11 @@ export async function handleHMRUpdate( return } + if (config.experimental.fullBundleMode) { + // TODO: support handleHotUpdate / hotUpdate + return + } + const timestamp = monotonicDateNow() const contextMeta = { type, diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 09ec6a376374e9..a12b5c4944c35d 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -101,6 +101,8 @@ import { searchForPackageRoot, searchForWorkspaceRoot } from './searchRoot' import type { DevEnvironment } from './environment' import { hostValidationMiddleware } from './middlewares/hostCheck' import { rejectInvalidRequestMiddleware } from './middlewares/rejectInvalidRequest' +import { memoryFilesMiddleware } from './middlewares/memoryFiles' +import { rejectNoCorsRequestMiddleware } from './middlewares/rejectNoCorsRequest' const usedConfigs = new WeakSet() @@ -502,7 +504,7 @@ export async function _createServer( ? (chokidar.watch( // config file dependencies and env file might be outside of root [ - root, + ...(config.experimental.fullBundleMode ? [] : [root]), ...config.configFileDependencies, ...getEnvFilesForMode(config.mode, config.envDir), // Watch the public directory explicitly because it might be outside @@ -880,8 +882,8 @@ export async function _createServer( middlewares.use(timeMiddleware(root)) } - // disallows request that contains `#` in the URL middlewares.use(rejectInvalidRequestMiddleware()) + middlewares.use(rejectNoCorsRequestMiddleware()) // cors const { cors } = serverConfig @@ -909,7 +911,9 @@ export async function _createServer( // Internal middlewares ------------------------------------------------------ - middlewares.use(cachedTransformMiddleware(server)) + if (!config.experimental.fullBundleMode) { + middlewares.use(cachedTransformMiddleware(server)) + } // proxy const { proxy } = serverConfig @@ -944,16 +948,26 @@ export async function _createServer( middlewares.use(servePublicMiddleware(server, publicFiles)) } - // main transform middleware - middlewares.use(transformMiddleware(server)) + if (config.experimental.fullBundleMode) { + middlewares.use(memoryFilesMiddleware(server)) + } else { + // main transform middleware + middlewares.use(transformMiddleware(server)) - // serve static files - middlewares.use(serveRawFsMiddleware(server)) - middlewares.use(serveStaticMiddleware(server)) + // serve static files + middlewares.use(serveRawFsMiddleware(server)) + middlewares.use(serveStaticMiddleware(server)) + } // html fallback if (config.appType === 'spa' || config.appType === 'mpa') { - middlewares.use(htmlFallbackMiddleware(root, config.appType === 'spa')) + middlewares.use( + htmlFallbackMiddleware( + root, + config.appType === 'spa', + server.environments.client, + ), + ) } // apply configureServer post hooks ------------------------------------------ @@ -983,10 +997,12 @@ export async function _createServer( if (initingServer) return initingServer initingServer = (async function () { - // For backward compatibility, we call buildStart for the client - // environment when initing the server. For other environments - // buildStart will be called when the first request is transformed - await environments.client.pluginContainer.buildStart() + if (!config.experimental.fullBundleMode) { + // For backward compatibility, we call buildStart for the client + // environment when initing the server. For other environments + // buildStart will be called when the first request is transformed + await environments.client.pluginContainer.buildStart() + } // ensure ws server started if (onListen || options.listen) { diff --git a/packages/vite/src/node/server/middlewares/htmlFallback.ts b/packages/vite/src/node/server/middlewares/htmlFallback.ts index ab9356f4a3d96c..24d08f9537171c 100644 --- a/packages/vite/src/node/server/middlewares/htmlFallback.ts +++ b/packages/vite/src/node/server/middlewares/htmlFallback.ts @@ -1,15 +1,31 @@ import path from 'node:path' import fs from 'node:fs' import type { Connect } from '#dep-types/connect' -import { createDebugger } from '../../utils' +import { createDebugger, joinUrlSegments } from '../../utils' import { cleanUrl } from '../../../shared/utils' +import type { DevEnvironment } from '../environment' +import { FullBundleDevEnvironment } from '../environments/fullBundleEnvironment' const debug = createDebugger('vite:html-fallback') export function htmlFallbackMiddleware( root: string, spaFallback: boolean, + clientEnvironment?: DevEnvironment, ): Connect.NextHandleFunction { + const memoryFiles = + clientEnvironment instanceof FullBundleDevEnvironment + ? clientEnvironment.memoryFiles + : undefined + + function checkFileExists(relativePath: string) { + return ( + memoryFiles?.has( + relativePath.slice(1), // remove first / + ) ?? fs.existsSync(path.join(root, relativePath)) + ) + } + // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` return function viteHtmlFallbackMiddleware(req, _res, next) { if ( @@ -40,8 +56,7 @@ export function htmlFallbackMiddleware( // .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)) { + if (checkFileExists(pathname)) { debug?.(`Rewriting ${req.method} ${req.url} to ${url}`) req.url = url return next() @@ -49,8 +64,7 @@ export function htmlFallbackMiddleware( } // trailing slash should check for fallback index.html else if (pathname.endsWith('/')) { - const filePath = path.join(root, pathname, 'index.html') - if (fs.existsSync(filePath)) { + if (checkFileExists(joinUrlSegments(pathname, 'index.html'))) { const newUrl = url + 'index.html' debug?.(`Rewriting ${req.method} ${req.url} to ${newUrl}`) req.url = newUrl @@ -59,8 +73,7 @@ export function htmlFallbackMiddleware( } // non-trailing slash should check for fallback .html else { - const filePath = path.join(root, pathname + '.html') - if (fs.existsSync(filePath)) { + if (checkFileExists(pathname + '.html')) { const newUrl = url + '.html' debug?.(`Rewriting ${req.method} ${req.url} to ${newUrl}`) req.url = newUrl diff --git a/packages/vite/src/node/server/middlewares/indexHtml.ts b/packages/vite/src/node/server/middlewares/indexHtml.ts index 180f14a6562cd2..98ee520bbdca58 100644 --- a/packages/vite/src/node/server/middlewares/indexHtml.ts +++ b/packages/vite/src/node/server/middlewares/indexHtml.ts @@ -49,6 +49,8 @@ import { BasicMinimalPluginContext, basePluginContextMeta, } from '../pluginContainer' +import { FullBundleDevEnvironment } from '../environments/fullBundleEnvironment' +import { getHmrImplementation } from '../../plugins/clientInjections' import { checkLoadingAccess, respondWithAccessDenied } from './static' interface AssetNode { @@ -442,6 +444,10 @@ export function indexHtmlMiddleware( server: ViteDevServer | PreviewServer, ): Connect.NextHandleFunction { const isDev = isDevServer(server) + const fullBundleEnv = + isDev && server.environments.client instanceof FullBundleDevEnvironment + ? server.environments.client + : undefined // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` return async function viteIndexHtmlMiddleware(req, res, next) { @@ -452,6 +458,43 @@ 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') { + if (fullBundleEnv) { + const pathname = decodeURIComponent(url) + const filePath = pathname.slice(1) // remove first / + + let file = fullBundleEnv.memoryFiles.get(filePath) + if (!file && fullBundleEnv.memoryFiles.size !== 0) { + return next() + } + const secFetchDest = req.headers['sec-fetch-dest'] + if ( + [ + 'document', + 'iframe', + 'frame', + 'fencedframe', + '', + undefined, + ].includes(secFetchDest) && + ((await fullBundleEnv.triggerBundleRegenerationIfStale()) || + file === undefined) + ) { + file = { source: await generateFallbackHtml(server as ViteDevServer) } + } + if (!file) { + return next() + } + + const html = + typeof file.source === 'string' + ? file.source + : Buffer.from(file.source) + const headers = isDev + ? server.config.server.headers + : server.config.preview.headers + return send(req, res, html, 'html', { headers, etag: file.etag }) + } + let filePath: string if (isDev && url.startsWith(FS_PREFIX)) { filePath = decodeURIComponent(fsPathFromId(url)) @@ -511,3 +554,66 @@ function preTransformRequest( decodedUrl = unwrapId(stripBase(decodedUrl, decodedBase)) server.warmupRequest(decodedUrl) } + +async function generateFallbackHtml(server: ViteDevServer) { + const hmrRuntime = await getHmrImplementation(server.config) + return /* html */ ` + + + + ', '<\\/script>')} + + + + +
+

Bundling in progress

+

The page will automatically reload when ready.

+
+
+ + +` +} diff --git a/packages/vite/src/node/server/middlewares/memoryFiles.ts b/packages/vite/src/node/server/middlewares/memoryFiles.ts new file mode 100644 index 00000000000000..152989eb031cb7 --- /dev/null +++ b/packages/vite/src/node/server/middlewares/memoryFiles.ts @@ -0,0 +1,52 @@ +import * as mrmime from 'mrmime' +import type { Connect } from '#dep-types/connect' +import { cleanUrl } from '../../../shared/utils' +import type { ViteDevServer } from '..' +import { FullBundleDevEnvironment } from '../environments/fullBundleEnvironment' + +export function memoryFilesMiddleware( + server: ViteDevServer, +): Connect.NextHandleFunction { + const memoryFiles = + server.environments.client instanceof FullBundleDevEnvironment + ? server.environments.client.memoryFiles + : undefined + if (!memoryFiles) { + throw new Error('memoryFilesMiddleware can only be used for fullBundleMode') + } + const headers = server.config.server.headers + + return function viteMemoryFilesMiddleware(req, res, next) { + const cleanedUrl = cleanUrl(req.url!) + if (cleanedUrl.endsWith('.html')) { + return next() + } + + const pathname = decodeURIComponent(cleanedUrl) + const filePath = pathname.slice(1) // remove first / + + const file = memoryFiles.get(filePath) + if (file) { + if (file.etag) { + if (req.headers['if-none-match'] === file.etag) { + res.statusCode = 304 + res.end() + return + } + res.setHeader('Etag', file.etag) + } + + const mime = mrmime.lookup(filePath) + if (mime) { + res.setHeader('Content-Type', mime) + } + + for (const name in headers) { + res.setHeader(name, headers[name]!) + } + + return res.end(file.source) + } + next() + } +} diff --git a/packages/vite/src/node/server/middlewares/rejectInvalidRequest.ts b/packages/vite/src/node/server/middlewares/rejectInvalidRequest.ts index 943ce9bd98fa4c..fba9dbfc1d5adf 100644 --- a/packages/vite/src/node/server/middlewares/rejectInvalidRequest.ts +++ b/packages/vite/src/node/server/middlewares/rejectInvalidRequest.ts @@ -1,5 +1,8 @@ import type { Connect } from '#dep-types/connect' +/** + * disallows request that contains `#` in the URL + */ export function rejectInvalidRequestMiddleware(): Connect.NextHandleFunction { // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` return function viteRejectInvalidRequestMiddleware(req, res, next) { diff --git a/packages/vite/src/node/server/middlewares/rejectNoCorsRequest.ts b/packages/vite/src/node/server/middlewares/rejectNoCorsRequest.ts new file mode 100644 index 00000000000000..29c67af9dab9f1 --- /dev/null +++ b/packages/vite/src/node/server/middlewares/rejectNoCorsRequest.ts @@ -0,0 +1,32 @@ +import type { Connect } from '#dep-types/connect' + +/** + * A middleware that rejects no-cors mode requests that are not same-origin. + * + * We should avoid untrusted sites to load the script to avoid attacks like GHSA-4v9v-hfq4-rm2v. + * This is because: + * - the path of HMR patch files / entry point files can be predictable + * - the HMR patch files may not include ESM syntax + * (if they include ESM syntax, loading as a classic script would fail) + * - the HMR runtime in the browser has the list of all loaded modules + * + * https://github.com/webpack/webpack-dev-server/security/advisories/GHSA-4v9v-hfq4-rm2v + * https://green.sapphi.red/blog/local-server-security-best-practices#_2-using-xssi-and-modifying-the-prototype + * https://green.sapphi.red/blog/local-server-security-best-practices#properly-check-the-request-origin + */ +export function rejectNoCorsRequestMiddleware(): Connect.NextHandleFunction { + // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` + return function viteRejectNoCorsRequestMiddleware(req, res, next) { + // While we can set Cross-Origin-Resource-Policy header instead of rejecting requests, + // we choose to reject the request to be safer in case the request handler has any side-effects. + if ( + req.headers['sec-fetch-mode'] === 'no-cors' && + req.headers['sec-fetch-site'] !== 'same-origin' + ) { + res.statusCode = 403 + res.end('Cross-origin requests must be made with CORS mode enabled.') + return + } + return next() + } +} diff --git a/packages/vite/types/customEvent.d.ts b/packages/vite/types/customEvent.d.ts index f142e3fecebb42..7ce03cae168d1f 100644 --- a/packages/vite/types/customEvent.d.ts +++ b/packages/vite/types/customEvent.d.ts @@ -15,6 +15,8 @@ export interface CustomEventMap { 'vite:invalidate': InvalidatePayload 'vite:ws:connect': WebSocketConnectionPayload 'vite:ws:disconnect': WebSocketConnectionPayload + // TODO: polish this + 'vite:module-loaded': { modules: string[] } // server events 'vite:client:connect': undefined diff --git a/packages/vite/types/hmrPayload.d.ts b/packages/vite/types/hmrPayload.d.ts index 0cbd649f7279da..8796a6b05cfd79 100644 --- a/packages/vite/types/hmrPayload.d.ts +++ b/packages/vite/types/hmrPayload.d.ts @@ -24,6 +24,12 @@ export interface UpdatePayload { export interface Update { type: 'js-update' | 'css-update' + /** + * URL of HMR patch chunk + * + * This only exists when full-bundle mode is enabled. + */ + url?: string path: string acceptedPath: string timestamp: number diff --git a/playground/fs-serve/__tests__/fs-serve.spec.ts b/playground/fs-serve/__tests__/fs-serve.spec.ts index 996c9fd505260b..d48c6e4d17072f 100644 --- a/playground/fs-serve/__tests__/fs-serve.spec.ts +++ b/playground/fs-serve/__tests__/fs-serve.spec.ts @@ -584,3 +584,49 @@ describe.runIf(!isServe)('preview HTML', () => { .toBe('404') }) }) + +test.runIf(isServe)( + 'load script with no-cors mode from a different origin', + async () => { + const viteTestUrlUrl = new URL(viteTestUrl) + + // NOTE: fetch cannot be used here as `fetch` sets some headers automatically + const res = await new Promise((resolve, reject) => { + http + .get( + viteTestUrl + '/src/code.js', + { + headers: { + 'Sec-Fetch-Dest': 'script', + 'Sec-Fetch-Mode': 'no-cors', + 'Sec-Fetch-Site': 'same-site', + Origin: 'http://vite.dev', + Host: viteTestUrlUrl.host, + }, + }, + (res) => { + resolve(res) + }, + ) + .on('error', (e) => { + reject(e) + }) + }) + expect(res.statusCode).toBe(403) + const body = Buffer.concat(await ArrayFromAsync(res)).toString() + expect(body).toBe( + 'Cross-origin requests must be made with CORS mode enabled.', + ) + }, +) + +// Note: Array.fromAsync is only supported in Node.js 22+ +async function ArrayFromAsync( + asyncIterable: AsyncIterable, +): Promise { + const chunks = [] + for await (const chunk of asyncIterable) { + chunks.push(chunk) + } + return chunks +} diff --git a/playground/hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts b/playground/hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts new file mode 100644 index 00000000000000..7458c670554cd1 --- /dev/null +++ b/playground/hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts @@ -0,0 +1,124 @@ +import { setTimeout } from 'node:timers/promises' +import { expect, test } from 'vitest' +import { editFile, isBuild, page } from '~utils' + +if (isBuild) { + test('should render', async () => { + expect(await page.textContent('h1')).toContain('HMR Full Bundle Mode') + await expect.poll(() => page.textContent('.app')).toBe('hello') + await expect.poll(() => page.textContent('.hmr')).toBe('hello') + }) +} else { + // INITIAL -> BUNDLING -> BUNDLED + test('show bundling in progress', async () => { + const reloadPromise = page.waitForEvent('load') + await expect + .poll(() => page.textContent('body')) + .toContain('Bundling in progress') + await reloadPromise // page shown after reload + await expect.poll(() => page.textContent('h1')).toBe('HMR Full Bundle Mode') + await expect.poll(() => page.textContent('.app')).toBe('hello') + }) + + // BUNDLED -> GENERATE_HMR_PATCH -> BUNDLING -> BUNDLE_ERROR -> BUNDLING -> BUNDLED + test('handle bundle error', async () => { + editFile('main.js', (code) => + code.replace("text('.app', 'hello')", "text('.app', 'hello'); text("), + ) + await expect.poll(() => page.isVisible('vite-error-overlay')).toBe(true) + editFile('main.js', (code) => + code.replace("text('.app', 'hello'); text(", "text('.app', 'hello')"), + ) + await expect.poll(() => page.isVisible('vite-error-overlay')).toBe(false) + await expect.poll(() => page.textContent('.app')).toBe('hello') + }) + + // BUNDLED -> GENERATE_HMR_PATCH -> BUNDLING -> BUNDLED + test('update bundle', async () => { + editFile('main.js', (code) => + code.replace("text('.app', 'hello')", "text('.app', 'hello1')"), + ) + await expect.poll(() => page.textContent('.app')).toBe('hello1') + + editFile('main.js', (code) => + code.replace("text('.app', 'hello1')", "text('.app', 'hello')"), + ) + await expect.poll(() => page.textContent('.app')).toBe('hello') + }) + + // BUNDLED -> GENERATE_HMR_PATCH -> BUNDLING -> BUNDLING -> BUNDLED + test('debounce bundle', async () => { + editFile('main.js', (code) => + code.replace( + "text('.app', 'hello')", + "text('.app', 'hello1')\n" + '// @delay-transform', + ), + ) + await setTimeout(100) + editFile('main.js', (code) => + code.replace("text('.app', 'hello1')", "text('.app', 'hello2')"), + ) + await expect.poll(() => page.textContent('.app')).toBe('hello2') + + editFile('main.js', (code) => + code.replace( + "text('.app', 'hello2')\n" + '// @delay-transform', + "text('.app', 'hello')", + ), + ) + await expect.poll(() => page.textContent('.app')).toBe('hello') + }) + + // BUNDLED -> GENERATING_HMR_PATCH -> BUNDLED + test('handle generate hmr patch error', async () => { + await expect.poll(() => page.textContent('.hmr')).toBe('hello') + editFile('hmr.js', (code) => + code.replace("const foo = 'hello'", "const foo = 'hello"), + ) + await expect.poll(() => page.isVisible('vite-error-overlay')).toBe(true) + + editFile('hmr.js', (code) => + code.replace("const foo = 'hello", "const foo = 'hello'"), + ) + await expect.poll(() => page.isVisible('vite-error-overlay')).toBe(false) + await expect.poll(() => page.textContent('.hmr')).toContain('hello') + }) + + // BUNDLED -> GENERATING_HMR_PATCH -> BUNDLED + test('generate hmr patch', async () => { + await expect.poll(() => page.textContent('.hmr')).toBe('hello') + editFile('hmr.js', (code) => + code.replace("const foo = 'hello'", "const foo = 'hello1'"), + ) + await expect.poll(() => page.textContent('.hmr')).toBe('hello1') + + editFile('hmr.js', (code) => + code.replace("const foo = 'hello1'", "const foo = 'hello'"), + ) + await expect.poll(() => page.textContent('.hmr')).toContain('hello') + }) + + // BUNDLED -> GENERATING_HMR_PATCH -> GENERATING_HMR_PATCH -> BUNDLED + // FIXME: https://github.com/rolldown/rolldown/issues/6648 + test.skip('continuous generate hmr patch', async () => { + editFile('hmr.js', (code) => + code.replace( + "const foo = 'hello'", + "const foo = 'hello1'\n" + '// @delay-transform', + ), + ) + await setTimeout(100) + editFile('hmr.js', (code) => + code.replace("const foo = 'hello1'", "const foo = 'hello2'"), + ) + await expect.poll(() => page.textContent('.hmr')).toBe('hello2') + + editFile('hmr.js', (code) => + code.replace( + "const foo = 'hello2'\n" + '// @delay-transform', + "const foo = 'hello'", + ), + ) + await expect.poll(() => page.textContent('.hmr')).toBe('hello') + }) +} diff --git a/playground/hmr-full-bundle-mode/hmr.js b/playground/hmr-full-bundle-mode/hmr.js new file mode 100644 index 00000000000000..9f01c0ef741ee6 --- /dev/null +++ b/playground/hmr-full-bundle-mode/hmr.js @@ -0,0 +1,13 @@ +export const foo = 'hello' + +text('.hmr', foo) + +function text(el, text) { + document.querySelector(el).textContent = text +} + +import.meta.hot?.accept((mod) => { + if (mod) { + text('.hmr', mod.foo) + } +}) diff --git a/playground/hmr-full-bundle-mode/index.html b/playground/hmr-full-bundle-mode/index.html new file mode 100644 index 00000000000000..8bb880b25ac710 --- /dev/null +++ b/playground/hmr-full-bundle-mode/index.html @@ -0,0 +1,6 @@ +

HMR Full Bundle Mode

+ +
+
+ + diff --git a/playground/hmr-full-bundle-mode/main.js b/playground/hmr-full-bundle-mode/main.js new file mode 100644 index 00000000000000..3a8003456f6a3e --- /dev/null +++ b/playground/hmr-full-bundle-mode/main.js @@ -0,0 +1,7 @@ +import './hmr.js' + +text('.app', 'hello') + +function text(el, text) { + document.querySelector(el).textContent = text +} diff --git a/playground/hmr-full-bundle-mode/package.json b/playground/hmr-full-bundle-mode/package.json new file mode 100644 index 00000000000000..dcd3f5e9ed014b --- /dev/null +++ b/playground/hmr-full-bundle-mode/package.json @@ -0,0 +1,12 @@ +{ + "name": "@vitejs/test-hmr-full-bundle-mode", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "debug": "node --inspect-brk ../../packages/vite/bin/vite", + "preview": "vite preview" + } +} diff --git a/playground/hmr-full-bundle-mode/vite.config.ts b/playground/hmr-full-bundle-mode/vite.config.ts new file mode 100644 index 00000000000000..2471a1eb5ead97 --- /dev/null +++ b/playground/hmr-full-bundle-mode/vite.config.ts @@ -0,0 +1,59 @@ +import { type Plugin, defineConfig } from 'vite' + +export default defineConfig({ + experimental: { + fullBundleMode: true, + }, + plugins: [waitBundleCompleteUntilAccess(), delayTransformComment()], +}) + +function waitBundleCompleteUntilAccess(): Plugin { + let resolvers: PromiseWithResolvers + + return { + name: 'wait-bundle-complete-until-access', + apply: 'serve', + configureServer(server) { + let accessCount = 0 + resolvers = promiseWithResolvers() + + server.middlewares.use((_req, _res, next) => { + accessCount++ + if (accessCount === 1) { + resolvers.resolve() + } + next() + }) + }, + async generateBundle() { + await resolvers.promise + await new Promise((resolve) => setTimeout(resolve, 300)) + }, + } +} + +function delayTransformComment(): Plugin { + return { + name: 'delay-transform-comment', + async transform(code) { + if (code.includes('// @delay-transform')) { + await new Promise((resolve) => setTimeout(resolve, 300)) + } + }, + } +} + +interface PromiseWithResolvers { + promise: Promise + resolve: (value: T | PromiseLike) => void + reject: (reason?: any) => void +} +function promiseWithResolvers(): PromiseWithResolvers { + let resolve: any + let reject: any + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve + reject = _reject + }) + return { promise, resolve, reject } +} diff --git a/playground/hmr/vite.config.ts b/playground/hmr/vite.config.ts index 28e28d723d5af0..21b0b1aa296a06 100644 --- a/playground/hmr/vite.config.ts +++ b/playground/hmr/vite.config.ts @@ -4,11 +4,29 @@ import { defineConfig } from 'vite' import type { Plugin } from 'vite' import { TestCssLinkPlugin } from './css-link/plugin' -export default defineConfig({ +export default defineConfig(({ command }) => ({ experimental: { hmrPartialAccept: true, }, build: { + rollupOptions: { + input: [ + path.resolve(import.meta.dirname, './index.html'), + ...(command === 'build' + ? [] + : [path.resolve(import.meta.dirname, './missing-import/index.html')]), + path.resolve( + import.meta.dirname, + './unicode-path/中文-にほんご-한글-🌕🌖🌗/index.html', + ), + path.resolve(import.meta.dirname, './counter/index.html'), + path.resolve( + import.meta.dirname, + './self-accept-within-circular/index.html', + ), + path.resolve(import.meta.dirname, './css-deps/index.html'), + ], + }, assetsInlineLimit(filePath) { if (filePath.endsWith('logo-no-inline.svg')) { return false @@ -41,7 +59,7 @@ export default defineConfig({ TestCssLinkPlugin(), hotEventsPlugin(), ], -}) +})) function virtualPlugin(): Plugin { let num = 0 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 970dc1de74d653..9a39d00c540abe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -853,6 +853,8 @@ importers: playground/hmr: {} + playground/hmr-full-bundle-mode: {} + playground/hmr-ssr: {} playground/html: {}