diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index ad57ce65371ecd..9c9fd3641492d1 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -1,3 +1,4 @@ +/// import type { ErrorPayload, HotPayload } from 'types/hmrPayload' import type { ViteHotContext } from 'types/hot' import { HMRClient, HMRContext } from '../shared/hmr' @@ -20,6 +21,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 +39,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 +143,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)) @@ -575,3 +599,28 @@ export function injectQuery(url: string, queryToInject: string): string { } export { ErrorOverlay } + +if (isFullBundleMode && typeof DevRuntime !== 'undefined') { + class ViteDevRuntime extends DevRuntime { + override createModuleHotContext(moduleId: string) { + const ctx = createHotContext(moduleId) + // @ts-expect-error TODO: support CSS + ctx._internal = { + updateStyle, + removeStyle, + } + // @ts-expect-error TODO: support this function (used by plugin-react) + ctx.getExports = async () => + // @ts-expect-error __rolldown_runtime__ / ctx.ownerPath + __rolldown_runtime__.loadExports(ctx.ownerPath) + return ctx + } + + override applyUpdates(_boundaries: string[]): void { + // TODO: how should this be handled? + // noop, handled in the HMR client + } + } + + ;(globalThis as any).__rolldown_runtime__ ??= new ViteDevRuntime() +} diff --git a/packages/vite/src/node/build.ts b/packages/vite/src/node/build.ts index 004b848e54271b..55b636e329a9ee 100644 --- a/packages/vite/src/node/build.ts +++ b/packages/vite/src/node/build.ts @@ -407,6 +407,7 @@ export function resolveBuildEnvironmentOptions( raw: BuildEnvironmentOptions, logger: Logger, consumer: 'client' | 'server' | undefined, + isFullBundledDev: boolean, ): ResolvedBuildEnvironmentOptions { const deprecatedPolyfillModulePreload = raw.polyfillModulePreload const { polyfillModulePreload, ...rest } = raw @@ -427,7 +428,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', @@ -488,10 +489,11 @@ export async function resolveBuildPlugins(config: ResolvedConfig): Promise<{ pre: Plugin[] post: Plugin[] }> { + const isBuild = config.command === 'build' return { pre: [ completeSystemWrapPlugin(), - ...(!config.isWorker ? [prepareOutDirPlugin()] : []), + ...(isBuild && !config.isWorker ? [prepareOutDirPlugin()] : []), perEnvironmentPlugin( 'vite:rollup-options-plugins', async (environment) => @@ -504,7 +506,7 @@ export async function resolveBuildPlugins(config: ResolvedConfig): Promise<{ ...(config.isWorker ? [webWorkerPostPlugin(config)] : []), ], post: [ - ...buildImportAnalysisPlugin(config), + ...(isBuild ? buildImportAnalysisPlugin(config) : []), ...(config.nativePluginEnabledLevel >= 1 ? [] : [ @@ -513,8 +515,8 @@ export async function resolveBuildPlugins(config: ResolvedConfig): Promise<{ ? [buildEsbuildPlugin()] : []), ]), - terserPlugin(config), - ...(!config.isWorker + ...(isBuild ? [terserPlugin(config)] : []), + ...(isBuild && !config.isWorker ? [ manifestPlugin(config), ssrManifestPlugin(), @@ -555,10 +557,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 @@ -867,7 +869,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) @@ -1033,7 +1035,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 86dcae3e1870e7..44d7a8429eb17b 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, }, - }) + ) + 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, - }) - 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 e010ea910827ad..1cfcb032d700df 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -120,6 +120,7 @@ import { BasicMinimalPluginContext, basePluginContextMeta, } from './server/pluginContainer' +import { FullBundleDevEnvironment } from './server/environments/fullBundleEnvironment' const debug = createDebugger('vite:config', { depth: 10 }) const promisifiedRealpath = promisify(fs.realpath) @@ -233,6 +234,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, @@ -551,6 +559,13 @@ export interface ExperimentalOptions { * @default 'v1' */ enableNativePlugin?: boolean | 'resolver' | 'v1' + /** + * Enable full bundle mode in dev. + * + * @experimental + * @default false + */ + fullBundleMode?: boolean } export interface LegacyOptions { @@ -618,6 +633,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 */ @@ -754,6 +771,7 @@ export const configDefaults = Object.freeze({ renderBuiltUrl: undefined, hmrPartialAccept: false, enableNativePlugin: process.env._VITE_TEST_JS_PLUGIN ? false : 'v1', + fullBundleMode: false, }, future: { removePluginHookHandleHotUpdate: undefined, @@ -836,6 +854,7 @@ function resolveEnvironmentOptions( forceOptimizeDeps: boolean | undefined, logger: Logger, environmentName: string, + isFullBundledDev: boolean, // Backward compatibility isSsrTargetWebworkerSet?: boolean, preTransformRequests?: boolean, @@ -898,6 +917,7 @@ function resolveEnvironmentOptions( options.build ?? {}, logger, consumer, + isFullBundledDev, ), plugins: undefined!, // to be resolved later // will be set by `setOptimizeDepsPluginNames` later @@ -1485,6 +1505,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 @@ -1501,6 +1524,7 @@ export async function resolveConfig( inlineConfig.forceOptimizeDeps, logger, environmentName, + isFullBundledDev, config.ssr?.target === 'webworker', config.server?.preTransformRequests, ) @@ -1524,6 +1548,7 @@ export async function resolveConfig( config.build ?? {}, logger, undefined, + isFullBundledDev, ) // Backward compatibility: merge config.environments.ssr back into config.ssr @@ -1765,6 +1790,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, @@ -1780,6 +1809,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 c4e8259bb2e414..27454f4cc9ed86 100644 --- a/packages/vite/src/node/plugins/asset.ts +++ b/packages/vite/src/node/plugins/asset.ts @@ -237,20 +237,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 @@ -300,6 +304,10 @@ export function assetPlugin(config: ResolvedConfig): Plugin { } } }, + + watchChange(id) { + assetCache.get(this.environment)?.delete(id) + }, } } @@ -308,7 +316,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) @@ -457,7 +465,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 3fa745f7d2f220..ee309eebdd6b25 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' @@ -33,66 +34,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' @@ -120,3 +62,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 shouldn't be importer 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 9f7af3ce3e84f7..ef4d72b48625a8 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -584,9 +584,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)`, diff --git a/packages/vite/src/node/plugins/define.ts b/packages/vite/src/node/plugins/define.ts index 5cc527c060a391..c40c531ccd07e4 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) { @@ -134,7 +137,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. @@ -217,6 +220,7 @@ export async function replaceDefine( }) if (result.errors.length > 0) { + // TODO: better error message throw new AggregateError(result.errors, 'oxc transform error') } diff --git a/packages/vite/src/node/plugins/dynamicImportVars.ts b/packages/vite/src/node/plugins/dynamicImportVars.ts index 3c233db53c4ec3..f3a8d6689cfbce 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 6bbebfe93fac53..4d52f68ce85c40 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 a0cd0d2d80f5e2..96afbc22eef9f2 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({ @@ -115,7 +116,7 @@ export async function resolvePlugins( wasmFallbackPlugin(config), definePlugin(config), cssPostPlugin(config), - isBuild && buildHtmlPlugin(config), + isBundled && buildHtmlPlugin(config), workerImportMetaUrlPlugin(config), assetImportMetaUrlPlugin(config), ...buildPlugins.pre, @@ -127,7 +128,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 8c1cc3b8c55be5..9fa3fa28c4e4e9 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 1633c602a11832..ae5059c570e1d8 100644 --- a/packages/vite/src/node/plugins/oxc.ts +++ b/packages/vite/src/node/plugins/oxc.ts @@ -281,7 +281,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 82161b200a1c38..faa746bdadb334 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -225,7 +225,7 @@ export function oxcResolvePlugin( overrideEnvConfig: (ResolvedConfig & ResolvedEnvironmentOptions) | undefined, ): Plugin[] { return [ - ...(!resolveOptions.isBuild + ...(resolveOptions.optimizeDeps && !resolveOptions.isBuild ? [optimizerResolvePlugin(resolveOptions)] : []), ...perEnvironmentOrWorkerPlugin( @@ -237,6 +237,7 @@ export function oxcResolvePlugin( const depsOptimizerEnabled = resolveOptions.optimizeDeps && !resolveOptions.isBuild && + !partialEnv.config.experimental.fullBundleMode && !isDepOptimizationDisabled(partialEnv.config.optimizeDeps) const getDepsOptimizer = () => { const env = getEnv() @@ -380,6 +381,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 1b3288b8df8f2c..51836a275fd322 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 390700ed31ca80..65efb6c9fb9c57 100644 --- a/packages/vite/src/node/plugins/worker.ts +++ b/packages/vite/src/node/plugins/worker.ts @@ -234,7 +234,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) => { @@ -463,53 +463,62 @@ 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 workerMap = workerCache.get(config.mainConfig || config)! - const { fileNameHash } = workerMap - - while ((match = workerAssetUrlRE.exec(code))) { - const [full, hash] = match - const filename = fileNameHash.get(hash)! - 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 workerMap = workerCache.get(config.mainConfig || config)! + const { fileNameHash } = workerMap + + while ((match = workerAssetUrlRE.exec(code))) { + const [full, hash] = match + const filename = fileNameHash.get(hash)! + 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 281cec3b5de1b8..1177d76ab27ee1 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 { @@ -138,7 +140,7 @@ export class DevEnvironment extends BaseEnvironment { this.hot.on( 'vite:invalidate', async ({ path, message, firstInvalidatedBy }) => { - invalidateModule(this, { + this.invalidateModule({ path, message, firstInvalidatedBy, @@ -146,17 +148,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) + } } } @@ -239,6 +243,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], + mod.lastHMRTimestamp, + m.firstInvalidatedBy, + ) + } + } + async close(): Promise { this._closing = true @@ -280,39 +314,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], - 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..78b29b3ed112ec --- /dev/null +++ b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts @@ -0,0 +1,520 @@ +import type { RolldownBuild, RolldownOptions } from 'rolldown' +import type { Update } from 'types/hmrPayload' +import colors from 'picocolors' +import type { FSWatcher } from 'chokidar' +import { + ChunkMetadataMap, + clearLine, + enhanceRollupError, + 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 { arraify, createDebugger, tryStatSync } from '../../utils' +import { prepareError } from '../middlewares/error' +import { getShortName } from '../hmr' + +const debug = createDebugger('vite:full-bundle-mode') + +type HmrOutput = Exclude< + Awaited>, + undefined +> + +export class MemoryFiles { + private files = new Map< + string, + string | Uint8Array | (() => string | Uint8Array) + >() + + get size(): number { + return this.files.size + } + + get(file: string): string | Uint8Array | 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: string | Uint8Array | (() => string | Uint8Array), + ): 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 state: BundleState = { type: 'initial' } + private invalidateCalledModules = new Set() + + watcher!: FSWatcher + watchFiles = new Set() + 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() + this.watcher = server.watcher + + debug?.('INITIAL: setup bundle options') + const rollupOptions = await this.getRolldownOptions() + const { rolldown } = await import('rolldown') + const bundle = await rolldown(rollupOptions) + debug?.('INITIAL: bundle created') + + debug?.('BUNDLING: trigger initial bundle') + this.triggerGenerateBundle({ options: rollupOptions, bundle }) + } + + async onFileChange( + _type: 'create' | 'update' | 'delete', + file: string, + ): Promise { + if (this.state.type === 'initial') { + return + } + + if (this.state.type === 'bundling') { + // FIXME: we should retrigger only when we know that file is watched. + // but for the initial bundle we don't know that and need to trigger after the initial bundle + debug?.( + `BUNDLING: file update detected ${file}, retriggering bundle generation`, + ) + this.triggerGenerateBundle(this.state) + return + } + if (this.state.type === 'bundle-error') { + const files = await this.state.bundle.watchFiles + if (files.includes(file)) { + debug?.( + `BUNDLE-ERROR: file update detected ${file}, retriggering bundle generation`, + ) + this.triggerGenerateBundle(this.state) + } else { + debug?.( + `BUNDLE-ERROR: file update detected ${file}, but ignored as it is not a dependency`, + ) + } + return + } + + if ( + this.state.type === 'bundled' || + this.state.type === 'generating-hmr-patch' + ) { + if (this.state.type === 'bundled') { + debug?.(`BUNDLED: file update detected ${file}, generating HMR patch`) + } else if (this.state.type === 'generating-hmr-patch') { + debug?.( + `GENERATING-HMR-PATCH: file update detected ${file}, regenerating HMR patch`, + ) + } + + this.state = { + type: 'generating-hmr-patch', + options: this.state.options, + bundle: this.state.bundle, + patched: this.state.patched, + } + + const startTime = Date.now() + let hmrOutput: HmrOutput[] + try { + // NOTE: only single outputOptions is supported here + hmrOutput = await this.state.bundle.generateHmrPatch([file]) + } catch (e) { + // TODO: support multiple errors + this.hot.send({ type: 'error', err: prepareError(e.errors[0]) }) + + this.state = { + type: 'bundled', + options: this.state.options, + bundle: this.state.bundle, + patched: this.state.patched, + } + return + } finally { + this.invalidateCalledModules.clear() + } + + if (hmrOutput.every((output) => output.type === 'Noop')) { + debug?.(`ignored file change for ${file}`) + return + } + const generateTime = Date.now() + debug?.( + `GENERATING-HMR-PATCH: patch generated in ${generateTime - startTime}ms`, + ) + + for (const output of hmrOutput) { + this.handleHmrOutput(file, output, this.state) + } + return + } + this.state satisfies never // exhaustive check + } + + override async warmupRequest(_url: string): Promise { + // no-op + } + + protected override invalidateModule(m: { + path: string + message?: string + firstInvalidatedBy: string + }): void { + ;(async () => { + if (this.invalidateCalledModules.has(m.path)) { + debug?.( + `${this.state.type.toUpperCase()}: invalidate received, but ignored because it was already invalidated`, + ) + return + } + + if ( + this.state.type === 'initial' || + this.state.type === 'bundling' || + this.state.type === 'bundle-error' + ) { + debug?.( + `${this.state.type.toUpperCase()}: invalidate received, but ignored because the state type has changed`, + ) + return + } + this.state.type satisfies 'bundled' | 'generating-hmr-patch' // exhaustive check + + debug?.( + `${this.state.type.toUpperCase()}: invalidate received, re-triggering HMR`, + ) + + // TODO: should this be a separate state? + this.state = { + type: 'generating-hmr-patch', + options: this.state.options, + bundle: this.state.bundle, + patched: this.state.patched, + } + this.invalidateCalledModules.add(m.path) + + let hmrOutput: HmrOutput + try { + // NOTE: only single outputOptions is supported here + hmrOutput = await this.state.bundle.hmrInvalidate( + m.path, + m.firstInvalidatedBy, + ) + } catch (e) { + // TODO: support multiple errors + this.hot.send({ type: 'error', err: prepareError(e.errors[0]) }) + + this.state = { + type: 'bundled', + options: this.state.options, + bundle: this.state.bundle, + patched: this.state.patched, + } + return + } + + if (hmrOutput.type === 'Patch') { + this.logger.info( + colors.yellow(`hmr invalidate `) + + colors.dim(m.path) + + (m.message ? ` ${m.message}` : ''), + { timestamp: true }, + ) + } + + // TODO: need to check if this is enough + this.handleHmrOutput(m.path, hmrOutput, this.state, m.firstInvalidatedBy) + })() + } + + triggerBundleRegenerationIfStale(): boolean { + if ( + (this.state.type === 'bundled' || + this.state.type === 'generating-hmr-patch') && + this.state.patched + ) { + this.triggerGenerateBundle(this.state) + debug?.( + `${this.state.type.toUpperCase()}: access to stale bundle, triggered bundle re-generation`, + ) + return true + } + return false + } + + override async close(): Promise { + await Promise.all([ + super.close(), + (async () => { + if (this.state.type === 'initial') { + return + } + if (this.state.type === 'bundling') { + this.state.abortController.abort() + } + const bundle = this.state.bundle + this.state = { type: 'initial' } + + this.watchFiles.clear() + this.memoryFiles.clear() + await bundle.close() + })(), + ]) + } + + private async getRolldownOptions() { + const chunkMetadataMap = new ChunkMetadataMap() + const rolldownOptions = resolveRolldownOptions(this, chunkMetadataMap) + rolldownOptions.experimental ??= {} + rolldownOptions.experimental.hmr = { + implement: await getHmrImplementation(this.getTopLevelConfig()), + } + + // 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]' + } + } else { + rolldownOptions.output ??= {} + rolldownOptions.output.entryFileNames = 'assets/[name].js' + rolldownOptions.output.chunkFileNames = 'assets/[name]-[hash].js' + rolldownOptions.output.assetFileNames = 'assets/[name]-[hash][extname]' + } + + return rolldownOptions + } + + private triggerGenerateBundle({ + options, + bundle, + }: BundleStateCommonProperties) { + if (this.state.type === 'bundling') { + this.state.abortController.abort() + } + + const controller = new AbortController() + const promise = this.generateBundle( + options.output, + bundle, + controller.signal, + ) + this.state = { + type: 'bundling', + options, + bundle, + promise, + abortController: controller, + } + } + + private async generateBundle( + outOpts: RolldownOptions['output'], + bundle: RolldownBuild, + signal: AbortSignal, + ) { + try { + const startTime = Date.now() + const newMemoryFiles = new Map string | Uint8Array>() + for (const outputOpts of arraify(outOpts)) { + const output = await bundle.generate(outputOpts) + if (signal.aborted) return + + for (const outputFile of output.output) { + newMemoryFiles.set(outputFile.fileName, () => + outputFile.type === 'chunk' ? outputFile.code : outputFile.source, + ) + } + } + const generateTime = Date.now() + + this.memoryFiles.clear() + for (const [file, code] of newMemoryFiles) { + this.memoryFiles.set(file, code) + } + + // TODO: should this be done for hmr patch file generation? + const bundleWatchFiles = new Set(await bundle.watchFiles) + for (const file of this.watchFiles) { + if (!bundleWatchFiles.has(file)) { + this.watcher.unwatch(file) + } + } + for (const file of bundleWatchFiles) { + if (!this.watchFiles.has(file)) { + if (tryStatSync(file)) { + this.watcher.add(file) + } + this.watchFiles.add(file) + } + } + if (signal.aborted) return + const postGenerateTime = Date.now() + + if (this.state.type === 'initial') throw new Error('unreachable') + this.state = { + type: 'bundled', + bundle: this.state.bundle, + options: this.state.options, + patched: false, + } + debug?.( + `BUNDLED: bundle generated in ${generateTime - startTime}ms + ${postGenerateTime - generateTime}ms`, + ) + + this.hot.send({ type: 'full-reload' }) + this.logger.info(colors.green(`page reload`), { timestamp: true }) + } catch (e) { + enhanceRollupError(e) + clearLine() + this.logger.error(`${colors.red('✗')} Build failed` + e.stack) + + // TODO: support multiple errors + this.hot.send({ type: 'error', err: prepareError(e.errors[0]) }) + + if (this.state.type === 'initial') throw new Error('unreachable') + this.state = { + type: 'bundle-error', + bundle: this.state.bundle, + options: this.state.options, + } + debug?.('BUNDLED: bundle errored') + } + } + + private handleHmrOutput( + file: string, + hmrOutput: HmrOutput, + { options, bundle }: BundleStateCommonProperties, + firstInvalidatedBy?: string, + ) { + if (hmrOutput.type === 'Noop') return + + const shortFile = getShortName(file, this.config.root) + if (hmrOutput.type === 'FullReload') { + this.triggerGenerateBundle({ options, bundle }) + + const reason = hmrOutput.reason + ? colors.dim(` (${hmrOutput.reason})`) + : '' + this.logger.info( + colors.green(`trigger page reload `) + colors.dim(shortFile) + reason, + { clear: !firstInvalidatedBy, timestamp: true }, + ) + return + } + + debug?.(`handle hmr output for ${shortFile}`, { + ...hmrOutput, + code: typeof hmrOutput.code === 'string' ? '[code]' : hmrOutput.code, + }) + + this.memoryFiles.set(hmrOutput.filename, hmrOutput.code) + if (hmrOutput.sourcemapFilename && hmrOutput.sourcemap) { + this.memoryFiles.set(hmrOutput.sourcemapFilename, hmrOutput.sourcemap) + } + const updates: Update[] = hmrOutput.hmrBoundaries.map((boundary: any) => { + return { + type: 'js-update', + url: hmrOutput.filename, + path: boundary.boundary, + acceptedPath: boundary.acceptedVia, + firstInvalidatedBy, + timestamp: Date.now(), + } + }) + this.hot.send({ + type: 'update', + updates, + }) + this.logger.info( + colors.green(`hmr update `) + + colors.dim([...new Set(updates.map((u) => u.path))].join(', ')), + { clear: !firstInvalidatedBy, timestamp: true }, + ) + + this.state = { + type: 'bundled', + options, + bundle, + patched: true, + } + } +} + +// https://mermaid.live/edit#pako:eNqdUk1v4jAQ_SujuRSkFAUMJOSwalWuPXVPq0jIjYfEWmeMHKe0i_jvaxJoqcRuUX2x3se8mZFmh4VVhBmujd0WlXQefi5zhvAaH9Bg0H3DIdze_gDN2mtpev0IOuG5ZWU0l71yQkECcs66Dw-tOuLMd2QO3rU2BGEILumL1OudTVsU1DRnE6jz5upSWklMTvqQsKpqt9pIX1R90SXl0pbq__bTUIPADr9RxhY-V76v_q_S61bsM-7vdtBUckMZeHr1ERj5TCaDHLcVMRC_aGe5JvagGyiMbUhFoD1stTFQWvAWbo7XcZMj7HPGCGtytdQqnNru0CZHX1FNOR5ylXS_c8x5H3yy9fbpjQvMvGspQmfbssJsLU0TULtR0tNSy9LJ-p3dSP5lbX0qIaW9dY_9YXf3HWHpDr2PkcSK3INt2WM2XswnXQJmO3wNOJmOxCIdx0ksRDwX4zTCN8zS-SidTRbxNAlkIvYR_uk6xqNkFk9TMZ2JSSKSREz2fwERkhWq +type BundleState = + | BundleStateInitial + | BundleStateBundling + | BundleStateBundled + | BundleStateBundleError + | BundleStateGeneratingHmrPatch +type BundleStateInitial = { type: 'initial' } +type BundleStateBundling = { + type: 'bundling' + promise: Promise + abortController: AbortController +} & BundleStateCommonProperties +type BundleStateBundled = { + type: 'bundled' + /** + * Whether a hmr patch was generated. + * + * In other words, whether the bundle is stale. + */ + patched: boolean +} & BundleStateCommonProperties +type BundleStateBundleError = { + type: 'bundle-error' +} & BundleStateCommonProperties +type BundleStateGeneratingHmrPatch = { + type: 'generating-hmr-patch' + /** + * Whether a hmr patch was generated. + * + * In other words, whether the bundle is stale. + */ + patched: boolean +} & BundleStateCommonProperties + +type BundleStateCommonProperties = { + options: RolldownOptions + bundle: RolldownBuild +} diff --git a/packages/vite/src/node/server/hmr.ts b/packages/vite/src/node/server/hmr.ts index b30a6999394c25..7d29f86f208968 100644 --- a/packages/vite/src/node/server/hmr.ts +++ b/packages/vite/src/node/server/hmr.ts @@ -30,6 +30,7 @@ import { BasicMinimalPluginContext, basePluginContextMeta, } from './pluginContainer' +import type { FullBundleDevEnvironment } from './environments/fullBundleEnvironment' import type { HttpServer } from '.' import { restartServerWithUrls } from '.' @@ -418,6 +419,13 @@ export async function handleHMRUpdate( return } + if (config.experimental.fullBundleMode) { + // TODO: support handleHotUpdate / hotUpdate + const environment = server.environments.client as FullBundleDevEnvironment + environment.onFileChange(type, file) + 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 ded3263bc8aca3..c17cd7b5fffe39 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -101,6 +101,7 @@ 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' const usedConfigs = new WeakSet() @@ -892,7 +893,9 @@ export async function _createServer( // Internal middlewares ------------------------------------------------------ - middlewares.use(cachedTransformMiddleware(server)) + if (!config.experimental.fullBundleMode) { + middlewares.use(cachedTransformMiddleware(server)) + } // proxy const { proxy } = serverConfig @@ -927,16 +930,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 ------------------------------------------ @@ -966,10 +979,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 b61b44bf061180..6729c924cf8283 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 ( @@ -34,8 +50,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() @@ -43,8 +58,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 @@ -53,8 +67,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 7b85d8fb1ccceb..ccf9dbf7140268 100644 --- a/packages/vite/src/node/server/middlewares/indexHtml.ts +++ b/packages/vite/src/node/server/middlewares/indexHtml.ts @@ -48,6 +48,8 @@ import { BasicMinimalPluginContext, basePluginContextMeta, } from '../pluginContainer' +import { FullBundleDevEnvironment } from '../environments/fullBundleEnvironment' +import { getHmrImplementation } from '../../plugins/clientInjections' interface AssetNode { start: number @@ -440,6 +442,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) { @@ -450,6 +456,29 @@ 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 content = fullBundleEnv.memoryFiles.get(filePath) + if (!content && fullBundleEnv.memoryFiles.size !== 0) { + return next() + } + if ( + fullBundleEnv.triggerBundleRegenerationIfStale() || + content === undefined + ) { + content = await generateFallbackHtml(server as ViteDevServer) + } + + const html = + typeof content === 'string' ? content : Buffer.from(content.buffer) + const headers = isDev + ? server.config.server.headers + : server.config.preview.headers + return send(req, res, html, 'html', { headers }) + } + let filePath: string if (isDev && url.startsWith(FS_PREFIX)) { filePath = decodeURIComponent(fsPathFromId(url)) @@ -490,3 +519,50 @@ 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..76d4bdabb60bea --- /dev/null +++ b/packages/vite/src/node/server/middlewares/memoryFiles.ts @@ -0,0 +1,43 @@ +import type { Connect } from 'dep-types/connect' +import * as mrmime from 'mrmime' +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) { + 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) + } + next() + } +} 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/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..b2629d786153d6 --- /dev/null +++ b/playground/hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts @@ -0,0 +1,123 @@ +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 + test('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 9ee8024ee2bf44..556d986cd938a3 100644 --- a/playground/hmr/vite.config.ts +++ b/playground/hmr/vite.config.ts @@ -8,6 +8,22 @@ export default defineConfig({ hmrPartialAccept: true, }, build: { + rollupOptions: { + input: [ + path.resolve(import.meta.dirname, './index.html'), + 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 550cb6bdc0eb8f..49e1a1654ca11d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -825,6 +825,8 @@ importers: playground/hmr: {} + playground/hmr-full-bundle-mode: {} + playground/hmr-ssr: {} playground/html: {}