diff --git a/packages/vite/rolldown.config.ts b/packages/vite/rolldown.config.ts index a99b66e54a829f..5e26173dc7b722 100644 --- a/packages/vite/rolldown.config.ts +++ b/packages/vite/rolldown.config.ts @@ -147,7 +147,7 @@ const moduleRunnerConfig = defineConfig({ '@vitejs/devtools/cli-commands', ...Object.keys(pkg.dependencies), ], - plugins: [bundleSizeLimit(54), enableSourceMapsInWatchModePlugin()], + plugins: [bundleSizeLimit(55), enableSourceMapsInWatchModePlugin()], output: { ...sharedNodeOptions.output, minify: { diff --git a/packages/vite/src/module-runner/constants.ts b/packages/vite/src/module-runner/constants.ts index b850d69ac4b680..c00dfb370d5a99 100644 --- a/packages/vite/src/module-runner/constants.ts +++ b/packages/vite/src/module-runner/constants.ts @@ -5,3 +5,8 @@ export const ssrDynamicImportKey = `__vite_ssr_dynamic_import__` export const ssrExportAllKey = `__vite_ssr_exportAll__` export const ssrExportNameKey = `__vite_ssr_exportName__` export const ssrImportMetaKey = `__vite_ssr_import_meta__` + +export const ssrRolldownRuntimeKey = `__rolldown_runtime__` +export const ssrRolldownRuntimeDefineMethod = `__vite_ssr_defineRuntime__` +export const ssrRolldownRuntimeCreateHotContextMethod = `__vite_ssr_createHotContext__` +export const ssrRolldownRuntimeTransport = `__vite_ssr_transport__` diff --git a/packages/vite/src/module-runner/createImportMeta.ts b/packages/vite/src/module-runner/createImportMeta.ts index f007dadde8e343..9cd746620b4f02 100644 --- a/packages/vite/src/module-runner/createImportMeta.ts +++ b/packages/vite/src/module-runner/createImportMeta.ts @@ -14,12 +14,13 @@ const envProxy = new Proxy({} as any, { export function createDefaultImportMeta( modulePath: string, ): ModuleRunnerImportMeta { - const href = posixPathToFileHref(modulePath) - const filename = modulePath - const dirname = posixDirname(modulePath) + const isVirtual = modulePath.startsWith('data:application/javascript,') + const href = isVirtual ? modulePath : posixPathToFileHref(modulePath) + const filename = isVirtual ? undefined : modulePath + const dirname = isVirtual ? undefined : posixDirname(modulePath) return { - filename: isWindows ? toWindowsPath(filename) : filename, - dirname: isWindows ? toWindowsPath(dirname) : dirname, + filename: isWindows && filename ? toWindowsPath(filename) : filename, + dirname: isWindows && dirname ? toWindowsPath(dirname) : dirname, url: href, env: envProxy, resolve(_id: string, _parent?: string) { diff --git a/packages/vite/src/module-runner/esmEvaluator.ts b/packages/vite/src/module-runner/esmEvaluator.ts index b08e7bcf474c18..cf158352ceb2d0 100644 --- a/packages/vite/src/module-runner/esmEvaluator.ts +++ b/packages/vite/src/module-runner/esmEvaluator.ts @@ -9,6 +9,7 @@ import { ssrImportKey, ssrImportMetaKey, ssrModuleExportsKey, + ssrRolldownRuntimeKey, } from './constants' import type { ModuleEvaluator, ModuleRunnerContext } from './types' @@ -28,6 +29,7 @@ export class ESModulesEvaluator implements ModuleEvaluator { ssrDynamicImportKey, ssrExportAllKey, ssrExportNameKey, + ssrRolldownRuntimeKey, // source map should already be inlined by Vite '"use strict";' + code, ) @@ -39,6 +41,7 @@ export class ESModulesEvaluator implements ModuleEvaluator { context[ssrDynamicImportKey], context[ssrExportAllKey], context[ssrExportNameKey], + context[ssrRolldownRuntimeKey], ) Object.seal(context[ssrModuleExportsKey]) diff --git a/packages/vite/src/module-runner/index.ts b/packages/vite/src/module-runner/index.ts index e23db77dde3a86..9bf3494de1e7d7 100644 --- a/packages/vite/src/module-runner/index.ts +++ b/packages/vite/src/module-runner/index.ts @@ -37,5 +37,9 @@ export { ssrImportKey, ssrImportMetaKey, ssrModuleExportsKey, + ssrRolldownRuntimeKey, + ssrRolldownRuntimeDefineMethod, + ssrRolldownRuntimeCreateHotContextMethod, + ssrRolldownRuntimeTransport, } from './constants' export type { InterceptorOptions } from './sourcemap/interceptor' diff --git a/packages/vite/src/module-runner/runner.ts b/packages/vite/src/module-runner/runner.ts index 64b97e2e3b63d5..2bf357ef5e5796 100644 --- a/packages/vite/src/module-runner/runner.ts +++ b/packages/vite/src/module-runner/runner.ts @@ -1,4 +1,4 @@ -import type { ViteHotContext } from '#types/hot' +import type { DevRuntime } from 'rolldown/experimental/runtime-types' import { HMRClient, HMRContext, type HMRLogger } from '../shared/hmr' import { cleanUrl, isPrimitive } from '../shared/utils' import { analyzeImportedModDifference } from '../shared/ssrTransform' @@ -16,7 +16,7 @@ import type { ResolvedResult, SSRImportMetadata, } from './types' -import { posixDirname, posixPathToFileHref, posixResolve } from './utils' +import { posixDirname, posixJoin } from './utils' import { ssrDynamicImportKey, ssrExportAllKey, @@ -24,6 +24,10 @@ import { ssrImportKey, ssrImportMetaKey, ssrModuleExportsKey, + ssrRolldownRuntimeCreateHotContextMethod, + ssrRolldownRuntimeDefineMethod, + ssrRolldownRuntimeKey, + ssrRolldownRuntimeTransport, } from './constants' import { hmrLogger, silentConsole } from './hmrLogger' import { createHMRHandlerForRunner } from './hmrHandler' @@ -47,6 +51,42 @@ export class ModuleRunner { >() private isBuiltin?: (id: string) => boolean private builtinsPromise?: Promise + private rolldownDevRuntime?: DevRuntime + + // We need the proxy because the runtime MUST be ready before the first import is processed. + // Because `context['__rolldown_runtime__']` is passed down before the modules are executed as a function argument. + private rolldownDevRuntimeProxy = new Proxy( + {}, + { + get: (_, p, receiver) => { + // Special `__rolldown_runtime__.__vite_ssr_defineRuntime__` method only for the module runner, + // It's not available in the browser because it's a global there. We cannot have it as a global because + // - It's possible to have multiple runners (`ssrLoadModule` has its own compat runner); + // - We don't want to pollute Dev Server's global namespace. + if (p === ssrRolldownRuntimeDefineMethod) { + return (runtime: DevRuntime) => { + this.rolldownDevRuntime = runtime + } + } + + if (p === ssrRolldownRuntimeCreateHotContextMethod) { + return this.closed + ? () => {} + : (url: string) => this.ensureModuleHotContext(url) + } + + if (p === ssrRolldownRuntimeTransport) { + return this.closed ? undefined : this.transport + } + + if (!this.rolldownDevRuntime) { + throw new Error(`__rolldown_runtime__ was not initialized.`) + } + + return Reflect.get(this.rolldownDevRuntime, p, receiver) + }, + }, + ) as DevRuntime private closed = false @@ -89,7 +129,7 @@ export class ModuleRunner { */ public async import(url: string): Promise { const fetchedModule = await this.cachedModule(url) - return await this.cachedRequest(url, fetchedModule) + return await this.cachedRequest(fetchedModule.url, fetchedModule) } /** @@ -353,7 +393,7 @@ export class ModuleRunner { // it's possible to provide an object with toString() method inside import() dep = String(dep) if (dep[0] === '.') { - dep = posixResolve(posixDirname(url), dep) + dep = posixJoin(posixDirname(url), dep) } return request(dep, { isDynamicImport: true }) } @@ -380,9 +420,10 @@ export class ModuleRunner { const createImportMeta = this.options.createImportMeta ?? createDefaultImportMeta - const modulePath = cleanUrl(file || moduleId) + const modulePath = file + ? cleanUrl(file) + : `data:application/javascript,${code};` // disambiguate the `:/` on windows: see nodejs/node#31710 - const href = posixPathToFileHref(modulePath) const meta = await createImportMeta(modulePath) const exports = Object.create(null) Object.defineProperty(exports, Symbol.toStringTag, { @@ -393,20 +434,19 @@ export class ModuleRunner { mod.exports = exports - let hotContext: ViteHotContext | undefined if (this.hmrClient) { Object.defineProperty(meta, 'hot', { enumerable: true, get: () => { if (!this.hmrClient) { - throw new Error(`[module runner] HMR client was closed.`) + return } this.debug?.('[module runner] creating hmr context for', mod.url) - hotContext ||= new HMRContext(this.hmrClient, mod.url) - return hotContext + this.ensureModuleHotContext(mod.url) + return this.moduleHotContexts.get(mod.url) }, set: (value) => { - hotContext = value + this.moduleHotContexts.set(mod.url, value) }, }) } @@ -423,14 +463,29 @@ export class ModuleRunner { get: getter, }), [ssrImportMetaKey]: meta, + [ssrRolldownRuntimeKey]: this.rolldownDevRuntimeProxy, } - this.debug?.('[module runner] executing', href) + this.debug?.('[module runner] executing', meta.href) await this.evaluator.runInlinedModule(context, code, mod) return exports } + + private moduleHotContexts = new Map() + + private ensureModuleHotContext(url: string) { + if (!this.hmrClient) { + return + } + + if (!this.moduleHotContexts.has(url)) { + const hotContext = new HMRContext(this.hmrClient, url) + this.moduleHotContexts.set(url, hotContext) + } + return this.moduleHotContexts.get(url) + } } function exportAll(exports: any, sourceModule: any) { diff --git a/packages/vite/src/module-runner/types.ts b/packages/vite/src/module-runner/types.ts index 8ccf4b5edb0a4c..4398265f61d104 100644 --- a/packages/vite/src/module-runner/types.ts +++ b/packages/vite/src/module-runner/types.ts @@ -1,3 +1,4 @@ +import type { DevRuntime } from 'rolldown/experimental/runtime-types' import type { ViteHotContext } from '#types/hot' import type { HMRLogger } from '../shared/hmr' import type { @@ -19,6 +20,7 @@ import type { ssrImportKey, ssrImportMetaKey, ssrModuleExportsKey, + ssrRolldownRuntimeKey, } from './constants' import type { InterceptorOptions } from './sourcemap/interceptor' @@ -41,6 +43,10 @@ export interface ModuleRunnerContext { [ssrExportAllKey]: (obj: any) => void [ssrExportNameKey]: (name: string, getter: () => unknown) => void [ssrImportMetaKey]: ModuleRunnerImportMeta + /** + * @internal + */ + [ssrRolldownRuntimeKey]: DevRuntime | undefined } export interface ModuleEvaluator { diff --git a/packages/vite/src/module-runner/utils.ts b/packages/vite/src/module-runner/utils.ts index 576991610b4d6b..15fb954ca284fe 100644 --- a/packages/vite/src/module-runner/utils.ts +++ b/packages/vite/src/module-runner/utils.ts @@ -34,6 +34,7 @@ function encodePathChars(filepath: string) { export const posixDirname: (path: string) => string = pathe.dirname export const posixResolve: (...paths: string[]) => string = pathe.resolve +export const posixJoin: (...paths: string[]) => string = pathe.join export function posixPathToFileHref(posixPath: string): string { let resolved = posixResolve(posixPath) diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index d45c6372f7d77b..d29778503dde24 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -131,6 +131,7 @@ import { } from './server/pluginContainer' import { nodeResolveWithVite } from './nodeResolve' import { FullBundleDevEnvironment } from './server/environments/fullBundleEnvironment' +import { createFullBundleRunnableDevEnvironment } from './server/environments/fullBundleRunnableEnvironment' const debug = createDebugger('vite:config', { depth: 10 }) const promisifiedRealpath = promisify(fs.realpath) @@ -257,6 +258,13 @@ function defaultCreateClientDevEnvironment( }) } +function defaultCreateSSRDevEnvironment(name: string, config: ResolvedConfig) { + if (config.experimental.ssrBundledDev) { + return createFullBundleRunnableDevEnvironment(name, config) + } + return createRunnableDevEnvironment(name, config) +} + function defaultCreateDevEnvironment(name: string, config: ResolvedConfig) { return createRunnableDevEnvironment(name, config) } @@ -585,6 +593,14 @@ export interface ExperimentalOptions { * @default false */ bundledDev?: boolean + /** + * Enable full bundle mode in SSR. + * + * This is highly experimental. + * + * @experimental + */ + ssrBundledDev?: boolean } export interface LegacyOptions { @@ -835,6 +851,7 @@ const configDefaults = Object.freeze({ hmrPartialAccept: false, enableNativePlugin: process.env._VITE_TEST_JS_PLUGIN ? false : 'v2', bundledDev: false, + ssrBundledDev: false, }, future: { removePluginHookHandleHotUpdate: undefined, @@ -897,7 +914,9 @@ export function resolveDevEnvironmentOptions( createEnvironment: environmentName === 'client' ? defaultCreateClientDevEnvironment - : defaultCreateDevEnvironment, + : environmentName === 'ssr' + ? defaultCreateSSRDevEnvironment + : defaultCreateDevEnvironment, recoverable: consumer === 'client', moduleRunnerTransform: consumer === 'server', }, @@ -1888,7 +1907,10 @@ export async function resolveConfig( cacheDir, command, mode, - isBundled: config.experimental?.bundledDev || isBuild, + isBundled: + config.experimental?.bundledDev || + config.experimental?.ssrBundledDev || + isBuild, // TODO: ssr shouldn't break client isWorker: false, mainConfig: null, bundleChain: [], diff --git a/packages/vite/src/node/index.ts b/packages/vite/src/node/index.ts index b35e209fa6ad91..9a325fb1121107 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -49,6 +49,11 @@ export { type RunnableDevEnvironment, type RunnableDevEnvironmentContext, } from './server/environments/runnableEnvironment' +export { + createFullBundleRunnableDevEnvironment, + isFullBundleRunnableDevEnvironment, + type FullBundleRunnableDevEnvironment, +} from './server/environments/fullBundleRunnableEnvironment' export { createFetchableDevEnvironment, isFetchableDevEnvironment, diff --git a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts index d74e65c654291d..06572451e625a0 100644 --- a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts +++ b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts @@ -7,6 +7,7 @@ import { } from 'rolldown/experimental' import colors from 'picocolors' import getEtag from 'etag' +import type { OutputOptions, RolldownOptions } from 'rolldown' import type { Update } from '#types/hmrPayload' import { ChunkMetadataMap, resolveRolldownOptions } from '../../build' import { getHmrImplementation } from '../../plugins/clientInjections' @@ -72,18 +73,13 @@ export class FullBundleDevEnvironment extends DevEnvironment { }) memoryFiles: MemoryFiles = new MemoryFiles() + facadeToChunk: Map = new Map() 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 }) } @@ -158,6 +154,12 @@ export class FullBundleDevEnvironment extends DevEnvironment { // NOTE: don't clear memoryFiles here as incremental build re-uses the files for (const outputFile of result.output) { + if (outputFile.type === 'chunk' && outputFile.facadeModuleId) { + this.facadeToChunk.set( + outputFile.facadeModuleId, + outputFile.fileName, + ) + } this.memoryFiles.set(outputFile.fileName, () => { const source = outputFile.type === 'chunk' ? outputFile.code : outputFile.source @@ -187,6 +189,19 @@ export class FullBundleDevEnvironment extends DevEnvironment { }) } + /** + * @internal + */ + public async _waitForInitialBuildSuccess(): Promise { + await this.devEngine.ensureCurrentBuildFinish() + const bundleState = await this.devEngine.getBundleState() + if (bundleState.lastFullBuildFailed) { + throw new Error( + `The last full bundle mode build has failed. See logs for more information.`, + ) + } + } + private async waitForInitialBuildFinish(): Promise { await this.devEngine.ensureCurrentBuildFinish() while (this.memoryFiles.size === 0) { @@ -275,12 +290,16 @@ export class FullBundleDevEnvironment extends DevEnvironment { await Promise.all([super.close(), this.devEngine.close()]) } - private async getRolldownOptions() { + protected async getDevRuntimeImplementation(): Promise { + return await getHmrImplementation(this.getTopLevelConfig()) + } + + protected async getRolldownOptions(): Promise { const chunkMetadataMap = new ChunkMetadataMap() const rolldownOptions = resolveRolldownOptions(this, chunkMetadataMap) rolldownOptions.experimental ??= {} rolldownOptions.experimental.devMode = { - implement: await getHmrImplementation(this.getTopLevelConfig()), + implement: await this.getDevRuntimeImplementation(), } if (rolldownOptions.optimization) { @@ -291,24 +310,26 @@ export class FullBundleDevEnvironment extends DevEnvironment { // 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 + Object.assign(output, this.getOutputOptions()) } } 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 + Object.assign(rolldownOptions.output, this.getOutputOptions()) } return rolldownOptions } + protected getOutputOptions(): OutputOptions { + return { + entryFileNames: 'assets/[name].js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash][extname]', + minify: false, + sourcemap: true, + } + } + private handleHmrOutput( client: NormalizedHotChannelClient, files: string[], diff --git a/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts new file mode 100644 index 00000000000000..744e2ccc228d30 --- /dev/null +++ b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts @@ -0,0 +1,110 @@ +import type { OutputOptions } from 'rolldown' +import { type ModuleRunner, ssrRolldownRuntimeKey } from 'vite/module-runner' +import { + type DevEnvironmentContext, + type ResolvedConfig, + type RunnableDevEnvironmentContext, + createServerHotChannel, +} from '../../index' +import { + ssrRolldownRuntimeCreateHotContextMethod, + ssrRolldownRuntimeDefineMethod, + ssrRolldownRuntimeTransport, +} from '../../../module-runner/constants' +import { + type ServerModuleRunnerFactory, + defineServerModuleRunnerFactory, +} from '../../ssr/runtime/serverModuleRunner' +import { FullBundleDevEnvironment } from './fullBundleEnvironment' + +/** @experimental */ +class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { + private _runner: ServerModuleRunnerFactory + + constructor( + name: string, + config: ResolvedConfig, + context: RunnableDevEnvironmentContext, + ) { + super(name, config, context as DevEnvironmentContext) + this._runner = defineServerModuleRunnerFactory(this, context) + } + + get runner(): ModuleRunner { + return this._runner.create() + } + + protected override async getDevRuntimeImplementation(): Promise { + return ` + class ViteDevRuntime extends DevRuntime { + createModuleHotContext(moduleId) { + return ${ssrRolldownRuntimeKey}.${ssrRolldownRuntimeCreateHotContextMethod}(moduleId) + } + + applyUpdates() { + // noop, handled in the HMR client + } + } + + const wrappedSocket = { + send(message) { + switch (message.type) { + case 'hmr:module-registered': { + ${ssrRolldownRuntimeKey}.${ssrRolldownRuntimeTransport}?.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)}\`) + } + }, + } + + ;${ssrRolldownRuntimeKey}.${ssrRolldownRuntimeDefineMethod}(new ViteDevRuntime(wrappedSocket)) + ` + } + + protected override getOutputOptions(): OutputOptions { + return { + ...super.getOutputOptions(), + sourcemap: 'inline', + } + } + + override async close(): Promise { + await super.close() + const runner = this._runner.get() + if (runner) { + await runner.close() + } + } +} + +export type { FullBundleRunnableDevEnvironment } + +/** @experimental */ +export function createFullBundleRunnableDevEnvironment( + name: string, + config: ResolvedConfig, + context: RunnableDevEnvironmentContext = {}, +): FullBundleDevEnvironment { + if (context.transport == null) { + context.transport = createServerHotChannel() + } + if (context.hot == null) { + context.hot = true + } + + return new FullBundleRunnableDevEnvironment(name, config, context) +} + +/** @experimental */ +export function isFullBundleRunnableDevEnvironment( + environment: unknown, +): environment is FullBundleRunnableDevEnvironment { + return environment instanceof FullBundleRunnableDevEnvironment +} diff --git a/packages/vite/src/node/server/environments/runnableEnvironment.ts b/packages/vite/src/node/server/environments/runnableEnvironment.ts index f75539e71c5c2b..819d1e2cbb3d05 100644 --- a/packages/vite/src/node/server/environments/runnableEnvironment.ts +++ b/packages/vite/src/node/server/environments/runnableEnvironment.ts @@ -2,8 +2,11 @@ import type { ModuleRunner } from 'vite/module-runner' import type { ResolvedConfig } from '../../config' import type { DevEnvironmentContext } from '../environment' import { DevEnvironment } from '../environment' -import type { ServerModuleRunnerOptions } from '../../ssr/runtime/serverModuleRunner' -import { createServerModuleRunner } from '../../ssr/runtime/serverModuleRunner' +import type { + ServerModuleRunnerFactory, + ServerModuleRunnerOptions, +} from '../../ssr/runtime/serverModuleRunner' +import { defineServerModuleRunnerFactory } from '../../ssr/runtime/serverModuleRunner' import { createServerHotChannel } from '../hmr' import type { Environment } from '../../environment' @@ -22,14 +25,10 @@ export function createRunnableDevEnvironment( return new RunnableDevEnvironment(name, config, context) } -export interface RunnableDevEnvironmentContext extends Omit< - DevEnvironmentContext, - 'hot' -> { - runner?: ( - environment: RunnableDevEnvironment, - options?: ServerModuleRunnerOptions, - ) => ModuleRunner +export interface RunnableDevEnvironmentContext< + E extends DevEnvironment = RunnableDevEnvironment, +> extends Omit { + runner?: (environment: E, options?: ServerModuleRunnerOptions) => ModuleRunner runnerOptions?: ServerModuleRunnerOptions hot?: boolean } @@ -41,14 +40,7 @@ export function isRunnableDevEnvironment( } class RunnableDevEnvironment extends DevEnvironment { - private _runner: ModuleRunner | undefined - private _runnerFactory: - | (( - environment: RunnableDevEnvironment, - options?: ServerModuleRunnerOptions, - ) => ModuleRunner) - | undefined - private _runnerOptions: ServerModuleRunnerOptions | undefined + private _runner: ServerModuleRunnerFactory constructor( name: string, @@ -56,23 +48,18 @@ class RunnableDevEnvironment extends DevEnvironment { context: RunnableDevEnvironmentContext, ) { super(name, config, context as DevEnvironmentContext) - this._runnerFactory = context.runner - this._runnerOptions = context.runnerOptions + this._runner = defineServerModuleRunnerFactory(this, context) } get runner(): ModuleRunner { - if (this._runner) { - return this._runner - } - const factory = this._runnerFactory || createServerModuleRunner - this._runner = factory(this, this._runnerOptions) - return this._runner + return this._runner.create() } override async close(): Promise { await super.close() - if (this._runner) { - await this._runner.close() + const runner = this._runner.get() + if (runner) { + await runner.close() } } } diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index 5c9aac410eddfe..ed45e299fa9c48 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -1,8 +1,14 @@ -import { pathToFileURL } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' +import path from 'node:path' import type { FetchResult } from 'vite/module-runner' -import type { EnvironmentModuleNode, TransformResult } from '..' +import type { TransformResult } from '..' import { tryNodeResolve } from '../plugins/resolve' -import { isBuiltin, isExternalUrl, isFilePathESM } from '../utils' +import { + isBuiltin, + isExternalUrl, + isFilePathESM, + normalizePath, +} from '../utils' import { unwrapId } from '../../shared/utils' import { MODULE_RUNNER_SOURCEMAPPING_SOURCE, @@ -10,6 +16,9 @@ import { } from '../../shared/constants' import { genSourceMapUrl } from '../server/sourcemap' import type { DevEnvironment } from '../server/environment' +import { FullBundleDevEnvironment } from '../server/environments/fullBundleEnvironment' +import type { ViteFetchResult } from '../../shared/invokeMethods' +import { ssrTransform } from './ssrTransform' export interface FetchModuleOptions { cached?: boolean @@ -43,7 +52,13 @@ export async function fetchModule( // if there is no importer, the file is an entry point // entry points are always internalized - if (!isFileUrl && importer && url[0] !== '.' && url[0] !== '/') { + if ( + !isFileUrl && + importer && + url[0] !== '.' && + url[0] !== '/' && + !isChunkUrl(environment, url) + ) { const { isProduction, root } = environment.config const { externalConditions, dedupe, preserveSymlinks } = environment.config.resolve @@ -80,6 +95,78 @@ export async function fetchModule( url = unwrapId(url) + if (environment instanceof FullBundleDevEnvironment) { + await environment._waitForInitialBuildSuccess() + + const outDir = normalizePath( + path.resolve(environment.config.root, environment.config.build.outDir), + ) + + let fileName: string + + if (!importer) { + fileName = resolveEntryFilename(environment, url)! + + if (!fileName) { + const entrypoints = [...environment.facadeToChunk.keys()] + throw new Error( + `[vite] Entrypoint '${url}' was not defined in the config. ` + + (entrypoints.length + ? `Available entry points: \n- ${[...environment.facadeToChunk.keys()].join('\n- ')}` + : `The build did not produce any chunks. Did it finish successfully? See the logs for more information.`), + ) + } + } else if (url[0] === '.') { + // Importer is reported as a full path on the file system. + // This happens because we provide the `file` attribute. + if (importer.startsWith(outDir)) { + importer = importer.slice(outDir.length + 1) + } + + fileName = path.posix.join(path.posix.dirname(importer), url) + } else { + fileName = url + } + + const memoryFile = environment.memoryFiles.get(fileName) + // TODO: how to check caching? + const code = memoryFile?.source + if (code == null) { + throw new Error( + `[vite] the module '${url}' (chunk '${fileName}') ${ + importer ? ` imported from '${importer}'` : '' + } was not bundled. Is server established?`, + ) + } + + const result: ViteFetchResult = { + code: code.toString(), + // To make sure dynamic imports resolve assets correctly. + // (Dynamic import resolves relative urls with importer url) + url: fileName, + id: fileName, + // The potential position on the file system. + // We don't actually keep it there, it's virtual. + file: normalizePath(path.resolve(outDir, fileName)), + // TODO: how to know the file was invalidated? + invalidate: false, + } + // TODO: this should be done in rolldown, there is already a function for it + // output.format = 'module-runner' + // See https://github.com/rolldown/rolldown/issues/8376 + const ssrResult = await ssrTransform(result.code, null, url, result.code) + if (!ssrResult) { + throw new Error(`[vite] cannot apply ssr transform to '${url}'.`) + } + result.code = ssrResult.code + + // remove shebang + if (result.code[0] === '#') + result.code = result.code.replace(/^#!.*/, (s) => ' '.repeat(s.length)) + + return result + } + const mod = await environment.moduleGraph.ensureEntryFromUrl(url) const cached = !!mod.transformResult @@ -99,7 +186,7 @@ export async function fetchModule( } if (options.inlineSourceMap !== false) { - result = inlineSourceMap(mod, result, options.startOffset) + result = inlineSourceMap(mod.id!, result, options.startOffset) } // remove shebang @@ -121,7 +208,7 @@ const OTHER_SOURCE_MAP_REGEXP = new RegExp( ) function inlineSourceMap( - mod: EnvironmentModuleNode, + id: string, result: TransformResult, startOffset: number | undefined, ) { @@ -146,8 +233,41 @@ function inlineSourceMap( }) : map result.code = `${code.trimEnd()}\n//# sourceURL=${ - mod.id + id }\n${MODULE_RUNNER_SOURCEMAPPING_SOURCE}\n//# ${SOURCEMAPPING_URL}=${genSourceMapUrl(sourceMap)}\n` return result } + +function isChunkUrl(environment: DevEnvironment, url: string) { + return ( + environment instanceof FullBundleDevEnvironment && + environment.memoryFiles.has(url) + ) +} + +function resolveEntryFilename( + environment: FullBundleDevEnvironment, + url: string, +) { + // Already resolved by the user to be a url + if (environment.facadeToChunk.has(url)) { + return environment.facadeToChunk.get(url) + } + const moduleId = normalizePath( + url.startsWith('file://') + ? // new URL(path) + fileURLToPath(url) + : // ./index.js + // NOTE: we don't try to find it if extension is not passed + // It will throw an error instead + path.resolve(environment.config.root, url), + ) + if (environment.facadeToChunk.get(moduleId)) { + return environment.facadeToChunk.get(moduleId) + } + if (url[0] === '/') { + const tryAbsouteUrl = path.posix.join(environment.config.root, url) + return environment.facadeToChunk.get(tryAbsouteUrl) + } +} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/dynamic-import.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/dynamic-import.js index 181d161ee7bb74..f0ba94eed3b989 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/dynamic-import.js +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/dynamic-import.js @@ -2,7 +2,14 @@ import path from 'node:path' import * as staticModule from './simple' import { pathToFileURL } from 'node:url' -export const initialize = async () => { +export const initialize = async (isFullBundle) => { + if (isFullBundle) { + return { + dynamicProcessed: await import('./simple'), + static: staticModule, + } + } + const nameRelative = './simple' const nameAbsolute = '/fixtures/simple' const nameAbsoluteExtension = '/fixtures/simple.js' diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts index 997df1f12095b7..a7862e5c0ef214 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts @@ -1,13 +1,16 @@ import { describe, expect } from 'vitest' -import { createModuleRunnerTester } from './utils' +import { runnerTest as it } from './utils' describe( 'module runner hmr works as expected', async () => { - const it = await createModuleRunnerTester({ - server: { - // override watch options because it's disabled by default - watch: {}, + it.scoped({ + config: { + server: { + // override watch options because it's disabled by default + watch: {}, + hmr: true, + }, }, }) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-no-hmr.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-no-hmr.spec.ts index d4cf03c756c565..06faca338c932f 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-no-hmr.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-no-hmr.spec.ts @@ -1,12 +1,14 @@ import { describe, expect } from 'vitest' -import { createModuleRunnerTester } from './utils' +import { runnerTest as it } from './utils' describe('module runner hmr works as expected', async () => { - const it = await createModuleRunnerTester({ - server: { - // override watch options because it's disabled by default - watch: {}, - hmr: false, + it.scoped({ + config: { + server: { + // override watch options because it's disabled by default + watch: {}, + hmr: false, + }, }, }) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index 5114dab3b593bc..a32cabf059449e 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -2,21 +2,64 @@ import { existsSync, readdirSync } from 'node:fs' import { posix, win32 } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, vi } from 'vitest' -import { isWindows } from '../../../../shared/utils' +import { isWindows, slash } from '../../../../shared/utils' import type { ExternalFetchResult } from '../../../../shared/invokeMethods' -import { createModuleRunnerTester } from './utils' +import { runnerTest as it } from './utils' const _URL = URL -describe('module runner initialization', async () => { - const it = await createModuleRunnerTester({ - resolve: { - external: ['tinyglobby'], +describe.for([ + { + fullBundle: [ + './fixtures/dynamic-import.js', + './fixtures/simple.js', + './fixtures/test.css', + './fixtures/test.module.css', + './fixtures/assets.js', + './fixtures/top-level-object.js', + './fixtures/cyclic2/test9/index.js', + './fixtures/live-binding/test4/index.js', + './fixtures/live-binding/test3/index.js', + './fixtures/live-binding/test2/index.js', + './fixtures/live-binding/test1/index.js', + './fixtures/execution-order-re-export/index.js', + './fixtures/cyclic2/test7/Ion.js', + './fixtures/cyclic2/test6/index.js', + './fixtures/cyclic2/test5/index.js', + './fixtures/cyclic2/test4/index.js', + './fixtures/cyclic2/test3/index.js', + './fixtures/cyclic2/test2/index.js', + './fixtures/cyclic2/test1/index.js', + './fixtures/no-this/importer.js', + './fixtures/native.js', + './fixtures/installed.js', + './fixtures/virtual.js', + './fixtures/cyclic/entry.js', + './fixtures/has-error.js', + './fixtures/basic.js', + './fixtures/simple.js?raw', + './fixtures/simple.js?url', + './fixtures/test.css?inline', + + // TODO: this fails during bundle, not at runtime + // './fixtures/esm-external-non-existing.js', + // './fixtures/cjs-external-non-existing.js', + ], + title: 'full bundle mode', + }, + { fullBundle: [], title: 'dev mode' }, +])('module runner initialization ($title)', async ({ fullBundle }) => { + it.scoped({ + fullBundle, + config: { + resolve: { + external: ['tinyglobby'], + }, }, }) it('correctly runs ssr code', async ({ runner }) => { - const mod = await runner.import('/fixtures/simple.js') + const mod = await runner.import('./fixtures/simple.js') expect(mod.test).toEqual('I am initialized') // loads the same module if id is a file url @@ -30,43 +73,6 @@ describe('module runner initialization', async () => { expect(mod).toBe(mod3) }) - it('can load virtual modules as an entry point', async ({ runner }) => { - const mod = await runner.import('virtual:test') - expect(mod.msg).toBe('virtual') - - // already resolved id works similar to `transformRequest` - expect(await runner.import(`\0virtual:normal`)).toMatchInlineSnapshot(` - { - "default": "ok", - } - `) - - // escaped virtual module id works - expect(await runner.import(`/@id/__x00__virtual:normal`)) - .toMatchInlineSnapshot(` - { - "default": "ok", - } - `) - - // timestamp query works - expect(await runner.import(`virtual:normal?t=${Date.now()}`)) - .toMatchInlineSnapshot(` - { - "default": "ok", - } - `) - - // other arbitrary queries don't work - await expect(() => - runner.import('virtual:normal?abcd=1234'), - ).rejects.toMatchObject({ - message: expect.stringContaining( - 'Failed to load url virtual:normal?abcd=1234', - ), - }) - }) - it('css is loaded correctly', async ({ runner }) => { const css = await runner.import('/fixtures/test.css') expect(css.default).toBe(undefined) @@ -79,17 +85,29 @@ describe('module runner initialization', async () => { }) }) - it('assets are loaded correctly', async ({ runner }) => { + it('assets are loaded correctly', async ({ runner, fullBundle }) => { const assets = await runner.import('/fixtures/assets.js') - expect(assets).toMatchObject({ - mov: '/fixtures/assets/placeholder.mov', - txt: '/fixtures/assets/placeholder.txt', - png: '/fixtures/assets/placeholder.png', - webp: '/fixtures/assets/placeholder.webp', - }) + if (fullBundle.length) { + expect(assets).toMatchObject({ + mov: 'data:video/quicktime;base64,', + txt: 'data:text/plain;base64,', + png: 'data:image/png;base64,', + webp: 'data:image/webp;base64,', + }) + } else { + expect(assets).toMatchObject({ + mov: '/fixtures/assets/placeholder.mov', + txt: '/fixtures/assets/placeholder.txt', + png: '/fixtures/assets/placeholder.png', + webp: '/fixtures/assets/placeholder.webp', + }) + } }) - it('ids with Vite queries are loaded correctly', async ({ runner }) => { + it('ids with Vite queries are loaded correctly', async ({ + runner, + fullBundle, + }) => { const raw = await runner.import('/fixtures/simple.js?raw') expect(raw.default).toMatchInlineSnapshot(` "export const test = 'I am initialized' @@ -98,7 +116,11 @@ describe('module runner initialization', async () => { " `) const url = await runner.import('/fixtures/simple.js?url') - expect(url.default).toMatchInlineSnapshot(`"/fixtures/simple.js"`) + if (fullBundle.length) { + expect(url.default).toMatch('__VITE_ASSET__') + } else { + expect(url.default).toMatchInlineSnapshot(`"/fixtures/simple.js"`) + } const inline = await runner.import('/fixtures/test.css?inline') expect(inline.default).toMatchInlineSnapshot(` ".test { @@ -110,11 +132,16 @@ describe('module runner initialization', async () => { it('modules with query strings are treated as different modules', async ({ runner, + fullBundle, }) => { const modSimple = await runner.import('/fixtures/simple.js') const modUrl = await runner.import('/fixtures/simple.js?url') expect(modSimple).not.toBe(modUrl) - expect(modUrl.default).toBe('/fixtures/simple.js') + if (fullBundle.length) { + expect(modUrl.default).toContain('__VITE_ASSET__') + } else { + expect(modUrl.default).toBe('/fixtures/simple.js') + } }) it('exports is not modifiable', async ({ runner }) => { @@ -147,7 +174,7 @@ describe('module runner initialization', async () => { const s = Symbol() try { await runner.import('/fixtures/has-error.js') - } catch (e) { + } catch (e: any) { expect(e[s]).toBeUndefined() e[s] = true expect(e[s]).toBe(true) @@ -155,56 +182,11 @@ describe('module runner initialization', async () => { try { await runner.import('/fixtures/has-error.js') - } catch (e) { + } catch (e: any) { expect(e[s]).toBe(true) } }) - it('importing external cjs library checks exports', async ({ runner }) => { - await expect(() => runner.import('/fixtures/cjs-external-non-existing.js')) - .rejects.toThrowErrorMatchingInlineSnapshot(` - [SyntaxError: [vite] Named export 'nonExisting' not found. The requested module '@vitejs/cjs-external' is a CommonJS module, which may not support all module.exports as named exports. - CommonJS modules can always be imported via the default export, for example using: - - import pkg from '@vitejs/cjs-external'; - const {nonExisting} = pkg; - ] - `) - // subsequent imports of the same external package should not throw if imports are correct - await expect( - runner.import('/fixtures/cjs-external-existing.js'), - ).resolves.toMatchObject({ - result: 'world', - }) - }) - - it('importing external esm library checks exports', async ({ runner }) => { - await expect(() => - runner.import('/fixtures/esm-external-non-existing.js'), - ).rejects.toThrowErrorMatchingInlineSnapshot( - `[SyntaxError: [vite] The requested module '@vitejs/esm-external' does not provide an export named 'nonExisting']`, - ) - // subsequent imports of the same external package should not throw if imports are correct - await expect( - runner.import('/fixtures/esm-external-existing.js'), - ).resolves.toMatchObject({ - result: 'world', - }) - }) - - it("dynamic import doesn't produce duplicates", async ({ runner }) => { - const mod = await runner.import('/fixtures/dynamic-import.js') - const modules = await mod.initialize() - // toBe checks that objects are actually the same, not just structurally - // using toEqual here would be a mistake because it check the structural difference - expect(modules.static).toBe(modules.dynamicProcessed) - expect(modules.static).toBe(modules.dynamicRelative) - expect(modules.static).toBe(modules.dynamicAbsolute) - expect(modules.static).toBe(modules.dynamicAbsoluteExtension) - expect(modules.static).toBe(modules.dynamicAbsoluteFull) - expect(modules.static).toBe(modules.dynamicFileUrl) - }) - it('correctly imports a virtual module', async ({ runner }) => { const mod = await runner.import('/fixtures/virtual.js') expect(mod.msg0).toBe('virtual0') @@ -226,44 +208,6 @@ describe('module runner initialization', async () => { expect(mod.existsSync).toBe(existsSync) }) - it('correctly resolves module url', async ({ runner, server }) => { - const { meta } = await runner.import('/fixtures/basic') - const basicUrl = new _URL('./fixtures/basic.js', import.meta.url).toString() - expect(meta.url).toBe(basicUrl) - - const filename = meta.filename! - const dirname = meta.dirname! - - if (isWindows) { - const cwd = process.cwd() - const drive = `${cwd[0].toUpperCase()}:\\` - const root = server.config.root.replace(/\\/g, '/') - - expect(filename.startsWith(drive)).toBe(true) - expect(dirname.startsWith(drive)).toBe(true) - - expect(filename).toBe(win32.join(root, '.\\fixtures\\basic.js')) - expect(dirname).toBe(win32.join(root, '.\\fixtures')) - } else { - const root = server.config.root - - expect(posix.join(root, './fixtures/basic.js')).toBe(filename) - expect(posix.join(root, './fixtures')).toBe(dirname) - } - }) - - it(`no maximum call stack error ModuleRunner.isCircularImport`, async ({ - runner, - }) => { - // entry.js ⇔ entry-cyclic.js - // ⇓ - // action.js - const mod = await runner.import('/fixtures/cyclic/entry') - await mod.setupCyclic() - const action = await mod.importAction('/fixtures/cyclic/action') - expect(action).toBeDefined() - }) - it('this of the exported function should be undefined', async ({ runner, }) => { @@ -288,28 +232,23 @@ describe('module runner initialization', async () => { }) }) - it(`cyclic invalid 1`, async ({ runner }) => { + it(`cyclic invalid 1`, async ({ runner, fullBundle }) => { // Node also fails but with a different message // $ node packages/vite/src/node/ssr/runtime/__tests__/fixtures/cyclic2/test5/index.js // ReferenceError: Cannot access 'dep1' before initialization - await expect(() => - runner.import('/fixtures/cyclic2/test5/index.js'), - ).rejects.toMatchInlineSnapshot( - `[TypeError: Cannot read properties of undefined (reading 'ok')]`, - ) - }) - - it(`cyclic invalid 2`, async ({ runner }) => { - // It should be an error but currently `undefined` fallback. - expect( - await runner.import('/fixtures/cyclic2/test6/index.js'), - ).toMatchInlineSnapshot( - ` - { - "dep1": "dep1: dep2: undefined", - } - `, - ) + if (fullBundle.length) { + await expect(() => + runner.import('/fixtures/cyclic2/test5/index.js'), + ).rejects.toMatchInlineSnapshot( + `[ReferenceError: Cannot access 'dep1' before initialization]`, + ) + } else { + await expect(() => + runner.import('/fixtures/cyclic2/test5/index.js'), + ).rejects.toMatchInlineSnapshot( + `[TypeError: Cannot read properties of undefined (reading 'ok')]`, + ) + } }) it(`cyclic with mixed import and re-export`, async ({ runner }) => { @@ -406,13 +345,163 @@ describe('module runner initialization', async () => { }) }) +describe('not supported by bundle mode', () => { + // if bundle throws an error, we should stopn waiting + it('importing external cjs library checks exports', async ({ runner }) => { + await expect(() => runner.import('/fixtures/cjs-external-non-existing.js')) + .rejects.toThrowErrorMatchingInlineSnapshot(` + [SyntaxError: [vite] Named export 'nonExisting' not found. The requested module '@vitejs/cjs-external' is a CommonJS module, which may not support all module.exports as named exports. + CommonJS modules can always be imported via the default export, for example using: + + import pkg from '@vitejs/cjs-external'; + const {nonExisting} = pkg; + ] + `) + // subsequent imports of the same external package should not throw if imports are correct + await expect( + runner.import('/fixtures/cjs-external-existing.js'), + ).resolves.toMatchObject({ + result: 'world', + }) + }) + + it('importing external esm library checks exports', async ({ runner }) => { + await expect(() => + runner.import('/fixtures/esm-external-non-existing.js'), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[SyntaxError: [vite] The requested module '@vitejs/esm-external' does not provide an export named 'nonExisting']`, + ) + // subsequent imports of the same external package should not throw if imports are correct + await expect( + runner.import('/fixtures/esm-external-existing.js'), + ).resolves.toMatchObject({ + result: 'world', + }) + }) + + it("dynamic import doesn't produce duplicates", async ({ runner }) => { + const mod = await runner.import('/fixtures/dynamic-import.js') + const modules = await mod.initialize() + // toBe checks that objects are actually the same, not just structurally + // using toEqual here would be a mistake because it check the structural difference + expect(modules.static).toBe(modules.dynamicProcessed) + expect(modules.static).toBe(modules.dynamicRelative) + expect(modules.static).toBe(modules.dynamicAbsolute) + expect(modules.static).toBe(modules.dynamicAbsoluteExtension) + expect(modules.static).toBe(modules.dynamicAbsoluteFull) + expect(modules.static).toBe(modules.dynamicFileUrl) + }) + + it('can load virtual modules as an entry point', async ({ runner }) => { + const mod = await runner.import('virtual:test') + expect(mod.msg).toBe('virtual') + + // already resolved id works similar to `transformRequest` + expect(await runner.import(`\0virtual:normal`)).toMatchInlineSnapshot(` + { + "default": "ok", + } + `) + + // escaped virtual module id works + expect(await runner.import(`/@id/__x00__virtual:normal`)) + .toMatchInlineSnapshot(` + { + "default": "ok", + } + `) + + // timestamp query works + expect(await runner.import(`virtual:normal?t=${Date.now()}`)) + .toMatchInlineSnapshot(` + { + "default": "ok", + } + `) + + // other arbitrary queries don't work + await expect(() => + runner.import('virtual:normal?abcd=1234'), + ).rejects.toMatchObject({ + message: expect.stringContaining( + 'Failed to load url virtual:normal?abcd=1234', + ), + }) + }) + + it('dynamic imports in FBM', async ({ runner }) => { + const mod = await runner.import('./fixtures/dynamic-import.js') + const modules = await mod.initialize(true) + + expect(modules.static.test).toBeTypeOf('string') + expect(modules.dynamicProcessed.test).toBeTypeOf('string') + expect(modules.dynamicProcessed.test).toBe(modules.static.test) + }) + + // files are virtual, so url is not defined + it('correctly resolves module url', async ({ runner, server }) => { + const { meta } = await runner.import('/fixtures/basic.js') + const basicUrl = new _URL('./fixtures/basic.js', import.meta.url).toString() + expect(meta.url).toBe(basicUrl) + + const filename = meta.filename! + const dirname = meta.dirname! + + if (isWindows) { + const cwd = process.cwd() + const drive = `${cwd[0].toUpperCase()}:\\` + const root = server.config.root.replace(/\\/g, '/') + + expect(filename.startsWith(drive)).toBe(true) + expect(dirname.startsWith(drive)).toBe(true) + + expect(filename).toBe(win32.join(root, '.\\fixtures\\basic.js')) + expect(dirname).toBe(win32.join(root, '.\\fixtures')) + } else { + const root = server.config.root + + expect(posix.join(root, './fixtures/basic.js')).toBe(filename) + expect(posix.join(root, './fixtures')).toBe(dirname) + } + }) + + it(`no maximum call stack error ModuleRunner.isCircularImport`, async ({ + runner, + }) => { + // entry.js ⇔ entry-cyclic.js + // ⇓ + // action.js + const mod = await runner.import('./fixtures/cyclic/entry.js') + await mod.setupCyclic() + const action = await mod.importAction('/fixtures/cyclic/action') + expect(action).toBeDefined() + }) + + // rolldown doesn't support this + // - Cannot access 'dep1' before initialization + it(`cyclic invalid 2`, async ({ runner }) => { + // It should be an error but currently `undefined` fallback. + expect( + await runner.import('/fixtures/cyclic2/test6/index.js'), + ).toMatchInlineSnapshot( + ` + { + "dep1": "dep1: dep2: undefined", + } + `, + ) + }) +}) + describe('optimize-deps', async () => { - const it = await createModuleRunnerTester({ - cacheDir: 'node_modules/.vite-test', - ssr: { - noExternal: true, - optimizeDeps: { - include: ['@vitejs/cjs-external'], + it.scoped({ + config: { + cacheDir: 'node_modules/.vite-test', + ssr: { + noExternal: true, + optimizeDeps: { + include: ['@vitejs/cjs-external'], + }, }, }, }) @@ -424,26 +513,28 @@ describe('optimize-deps', async () => { }) describe('resolveId absolute path entry', async () => { - const it = await createModuleRunnerTester({ - plugins: [ - { - name: 'test-resolevId', - enforce: 'pre', - resolveId(source) { - if ( - source === - posix.join(this.environment.config.root, 'fixtures/basic.js') - ) { - return '\0virtual:basic' - } - }, - load(id) { - if (id === '\0virtual:basic') { - return `export const name = "virtual:basic"` - } + it.scoped({ + config: { + plugins: [ + { + name: 'test-resolevId', + enforce: 'pre', + resolveId(source) { + if ( + source === + posix.join(this.environment.config.root, 'fixtures/basic.js') + ) { + return '\0virtual:basic' + } + }, + load(id) { + if (id === '\0virtual:basic') { + return `export const name = "virtual:basic"` + } + }, }, - }, - ], + ], + }, }) it('ssrLoadModule', async ({ server }) => { @@ -459,36 +550,54 @@ describe('resolveId absolute path entry', async () => { ) expect(mod.name).toMatchInlineSnapshot(`"virtual:basic"`) }) + + describe('in full bundle mode', async () => { + it.scoped({ + fullBundle: [posix.join(slash(import.meta.dirname), 'fixtures/basic.js')], + }) + + it('runner', async ({ runner }) => { + // Unlike with dev mode, the ID is specified in the build options, + // And then we HAVE to use the resolved ID here to get the chunk name. + const mod = await runner.import('\0virtual:basic') + expect(mod.name).toMatchInlineSnapshot(`"virtual:basic"`) + }) + }) }) describe('virtual module hmr', async () => { let state = 'init' - const it = await createModuleRunnerTester({ - plugins: [ - { - name: 'test-resolevId', - enforce: 'pre', - resolveId(source) { - if (source === 'virtual:test') { - return '\0' + source - } - }, - load(id) { - if (id === '\0virtual:test') { - return `export default ${JSON.stringify(state)}` - } - }, + it.scoped({ + config: { + server: { + hmr: true, }, - ], + plugins: [ + { + name: 'test-resolevId', + enforce: 'pre', + resolveId(source) { + if (source === 'virtual:test') { + return '\0' + source + } + }, + load(id) { + if (id === '\0virtual:test') { + return `export default ${JSON.stringify(state)}` + } + }, + }, + ], + }, }) - it('full reload', async ({ server, runner }) => { + it('full reload', async ({ environment, runner }) => { const mod = await runner.import('virtual:test') expect(mod.default).toBe('init') state = 'reloaded' - server.environments.ssr.moduleGraph.invalidateAll() - server.environments.ssr.hot.send({ type: 'full-reload' }) + environment.moduleGraph.invalidateAll() + environment.hot.send({ type: 'full-reload' }) await vi.waitFor(() => { const mod = runner.evaluatedModules.getModuleById('\0virtual:test') expect(mod?.exports.default).toBe('reloaded') @@ -517,11 +626,13 @@ describe('virtual module hmr', async () => { }) describe('invalid package', async () => { - const it = await createModuleRunnerTester({ - environments: { - ssr: { - resolve: { - noExternal: true, + it.scoped({ + config: { + environments: { + ssr: { + resolve: { + noExternal: true, + }, }, }, }, diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts index 6caeea61be17b1..3cd95c7649cee1 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts @@ -4,25 +4,20 @@ import { describe, expect } from 'vitest' import type { ViteDevServer } from '../../..' import type { ModuleRunnerContext } from '../../../../module-runner' import { ESModulesEvaluator } from '../../../../module-runner' -import { - createFixtureEditor, - createModuleRunnerTester, - resolvePath, -} from './utils' +import { createFixtureEditor, runnerTest as it, resolvePath } from './utils' describe('module runner initialization', async () => { - const it = await createModuleRunnerTester( - {}, - { + it.scoped({ + runnerOptions: { sourcemapInterceptor: 'prepareStackTrace', }, - ) + }) const getError = async (cb: () => void): Promise => { try { await cb() expect.unreachable() - } catch (err) { + } catch (err: any) { return err } } @@ -157,13 +152,12 @@ describe('module runner with node:vm executor', async () => { } } - const it = await createModuleRunnerTester( - {}, - { + it.scoped({ + runnerOptions: { sourcemapInterceptor: 'prepareStackTrace', evaluator: new Evaluator(), }, - ) + }) it('should not crash when error stacktrace contains negative column', async ({ runner, diff --git a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts index 8ebe6708224d9c..35a73d37ed0c61 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts @@ -1,51 +1,52 @@ import fs from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import type { TestAPI } from 'vitest' -import { afterEach, beforeEach, onTestFinished, test } from 'vitest' +import { test as base, onTestFinished } from 'vitest' import type { ModuleRunner } from 'vite/module-runner' -import type { ServerModuleRunnerOptions } from '../serverModuleRunner' import type { ViteDevServer } from '../../../server' import type { InlineConfig } from '../../../config' import { createServer } from '../../../server' -import { createServerModuleRunner } from '../serverModuleRunner' -import type { DevEnvironment } from '../../../server/environment' +import type { RunnableDevEnvironment } from '../../../server/environments/runnableEnvironment' +import { + type ServerModuleRunnerOptions, + createServerModuleRunner, +} from '../serverModuleRunner' interface TestClient { + fullBundle: string[] + config: InlineConfig server: ViteDevServer runner: ModuleRunner - environment: DevEnvironment + runnerOptions: ServerModuleRunnerOptions | undefined + environment: RunnableDevEnvironment } -export async function createModuleRunnerTester( - config: InlineConfig = {}, - runnerConfig: ServerModuleRunnerOptions = {}, -): Promise> { - function waitForWatcher(server: ViteDevServer) { - return new Promise((resolve) => { - if ((server.watcher as any)._readyEmitted) { - resolve() - } else { - server.watcher.once('ready', () => resolve()) - } - }) - } - - beforeEach(async (t) => { - // @ts-ignore - globalThis.__HMR__ = {} - - t.server = await createServer({ +export const runnerTest = base.extend({ + // eslint-disable-next-line no-empty-pattern + fullBundle: ({}, use) => use([]), + // eslint-disable-next-line no-empty-pattern + runnerOptions: ({}, use) => use(undefined), + // eslint-disable-next-line no-empty-pattern + config: ({}, use) => use({}), + server: async ({ config, fullBundle }, use) => { + const server = await createServer({ + configFile: false, root: import.meta.dirname, logLevel: 'error', - server: { - middlewareMode: true, - watch: null, - ws: false, - }, ssr: { external: ['@vitejs/cjs-external', '@vitejs/esm-external'], }, + experimental: { + ssrBundledDev: fullBundle.length > 0, + ...config.experimental, + }, + build: { + rolldownOptions: { + input: fullBundle, + ...config.build?.rolldownOptions, + }, + ...config.build, + }, optimizeDeps: { disabled: true, noDiscovery: true, @@ -80,27 +81,42 @@ export async function createModuleRunnerTester( ...(config.plugins ?? []), ], ...config, - }) - t.environment = t.server.environments.ssr - t.runner = createServerModuleRunner(t.environment, { - hmr: { - logger: false, + server: { + middlewareMode: true, + watch: null, + ws: false, + hmr: false, + ...config.server, }, - // don't override by default so Vitest source maps are correct - sourcemapInterceptor: false, - ...runnerConfig, }) if (config.server?.watch) { - await waitForWatcher(t.server) + await waitForWatcher(server) } - }) + await use(server) + await server.close() + }, + environment: async ({ server }, use) => { + await use(server.environments.ssr as RunnableDevEnvironment) + }, + runner: async ({ environment, runnerOptions }, use) => { + if (runnerOptions) { + const runner = createServerModuleRunner(environment, runnerOptions) + await use(runner) + await runner.close() + } else { + await use(environment.runner) + } + }, +}) - afterEach(async (t) => { - await t.runner.close() - await t.server.close() +function waitForWatcher(server: ViteDevServer) { + return new Promise((resolve) => { + if ((server.watcher as any)._readyEmitted) { + resolve() + } else { + server.watcher.once('ready', () => resolve()) + } }) - - return test as TestAPI } type FixtureEditor = { diff --git a/packages/vite/src/node/ssr/runtime/serverModuleRunner.ts b/packages/vite/src/node/ssr/runtime/serverModuleRunner.ts index 71a3610f4b6c0e..ef39a1bcf165b8 100644 --- a/packages/vite/src/node/ssr/runtime/serverModuleRunner.ts +++ b/packages/vite/src/node/ssr/runtime/serverModuleRunner.ts @@ -145,3 +145,33 @@ export function createServerModuleRunner( options.evaluator, ) } + +export interface ServerModuleRunnerFactoryOptions { + runner?: (environment: E, options?: ServerModuleRunnerOptions) => ModuleRunner + runnerOptions?: ServerModuleRunnerOptions +} + +export interface ServerModuleRunnerFactory { + create(): ModuleRunner + get(): ModuleRunner | undefined +} + +export function defineServerModuleRunnerFactory( + environment: E, + options: ServerModuleRunnerFactoryOptions, +): ServerModuleRunnerFactory { + let runner: ModuleRunner | undefined + return { + create() { + if (runner) { + return runner + } + const factory = options.runner || createServerModuleRunner + runner = factory(environment, options.runnerOptions) + return runner + }, + get() { + return runner + }, + } +} diff --git a/vitest.config.ts b/vitest.config.ts index ad54877148376a..7e69b4e036d3f4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ const _dirname = path.dirname(url.fileURLToPath(import.meta.url)) export default defineConfig({ test: { + includeTaskLocation: true, include: ['**/__tests__/**/*.spec.[tj]s'], exclude: [ '**/node_modules/**',