diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 8d2d685a8409d9..8db949d482cfaa 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -279,6 +279,10 @@ export default defineConfig({ text: 'JavaScript API', link: '/guide/api-javascript', }, + { + text: 'Vite Runtime API', + link: '/guide/api-vite-runtime', + }, { text: 'Config Reference', link: '/config/', diff --git a/docs/guide/api-vite-runtime.md b/docs/guide/api-vite-runtime.md new file mode 100644 index 00000000000000..5c4ee3f07c3ec5 --- /dev/null +++ b/docs/guide/api-vite-runtime.md @@ -0,0 +1,234 @@ +# Vite Runtime API + +:::warning Low-level API +This API was introduced in Vite 5.1 as an experimental feature. It was added to [gather feedback](https://github.com/vitejs/vite/discussions/15774). There will probably be breaking changes to it in Vite 5.2, so make sure to pin the Vite version to `~5.1.0` when using it. This is a low-level API meant for library and framework authors. If your goal is to create an application, make sure to check out the higher-level SSR plugins and tools at [Awesome Vite SSR section](https://github.com/vitejs/awesome-vite#ssr) first. +::: + +The "Vite Runtime" is a tool that allows running any code by processing it with Vite plugins first. It is different from `server.ssrLoadModule` because the runtime implementation is decoupled from the server. This allows library and framework authors to implement their own layer of communication between the server and the runtime. + +One of the goals of this feature is to provide a customizable API to process and run the code. Vite provides enough tools to use Vite Runtime out of the box, but users can build upon it if their needs do not align with Vite's built-in implementation. + +All APIs can be imported from `vite/runtime` unless stated otherwise. + +## `ViteRuntime` + +**Type Signature:** + +```ts +export class ViteRuntime { + constructor( + public options: ViteRuntimeOptions, + public runner: ViteModuleRunner, + private debug?: ViteRuntimeDebugger, + ) {} + /** + * URL to execute. Accepts file path, server path, or id relative to the root. + */ + public async executeUrl(url: string): Promise + /** + * Entry point URL to execute. Accepts file path, server path or id relative to the root. + * In the case of a full reload triggered by HMR, this is the module that will be reloaded. + * If this method is called multiple times, all entry points will be reloaded one at a time. + */ + public async executeEntrypoint(url: string): Promise + /** + * Clear all caches including HMR listeners. + */ + public clearCache(): void + /** + * Clears all caches, removes all HMR listeners, and resets source map support. + * This method doesn't stop the HMR connection. + */ + public async destroy(): Promise + /** + * Returns `true` if the runtime has been destroyed by calling `destroy()` method. + */ + public isDestroyed(): boolean +} +``` + +::: tip Advanced Usage +If you are just migrating from `server.ssrLoadModule` and want to support HMR, consider using [`createViteRuntime`](#createviteruntime) instead. +::: + +The `ViteRuntime` class requires `root` and `fetchModule` options when initiated. Vite exposes `ssrFetchModule` on the [`server`](/guide/api-javascript) instance for easier integration with Vite SSR. Vite also exports `fetchModule` from its main entry point - it doesn't make any assumptions about how the code is running unlike `ssrFetchModule` that expects the code to run using `new Function`. This can be seen in source maps that these functions return. + +Runner in `ViteRuntime` is responsible for executing the code. Vite exports `ESModulesRunner` out of the box, it uses `new AsyncFunction` to run the code. You can provide your own implementation if your JavaScript runtime doesn't support unsafe evaluation. + +The two main methods that runtime exposes are `executeUrl` and `executeEntrypoint`. The only difference between them is that all modules executed by `executeEntrypoint` will be reexecuted if HMR triggers `full-reload` event. Be aware that Vite Runtime doesn't update `exports` object when this happens (it overrides it), you would need to run `executeUrl` or get the module from `moduleCache` again if you rely on having the latest `exports` object. + +**Example Usage:** + +```js +import { ViteRuntime, ESModulesRunner } from 'vite/runtime' +import { root, fetchModule } from './rpc-implementation.js' + +const runtime = new ViteRuntime( + { + root, + fetchModule, + // you can also provide hmr.connection to support HMR + }, + new ESModulesRunner(), +) + +await runtime.executeEntrypoint('/src/entry-point.js') +``` + +## `ViteRuntimeOptions` + +```ts +export interface ViteRuntimeOptions { + /** + * Root of the project + */ + root: string + /** + * A method to get the information about the module. + * For SSR, Vite exposes `server.ssrFetchModule` function that you can use here. + * For other runtime use cases, Vite also exposes `fetchModule` from its main entry point. + */ + fetchModule: FetchFunction + /** + * Configure how source maps are resolved. Prefers `node` if `process.setSourceMapsEnabled` is available. + * Otherwise it will use `prepareStackTrace` by default which overrides `Error.prepareStackTrace` method. + * You can provide an object to configure how file contents and source maps are resolved for files that were not processed by Vite. + */ + sourcemapInterceptor?: + | false + | 'node' + | 'prepareStackTrace' + | InterceptorOptions + /** + * Disable HMR or configure HMR options. + */ + hmr?: + | false + | { + /** + * Configure how HMR communicates between the client and the server. + */ + connection: HMRRuntimeConnection + /** + * Configure HMR logger. + */ + logger?: false | HMRLogger + } + /** + * Custom module cache. If not provided, it creates a separate module cache for each ViteRuntime instance. + */ + moduleCache?: ModuleCacheMap +} +``` + +## `ViteModuleRunner` + +**Type Signature:** + +```ts +export interface ViteModuleRunner { + /** + * Run code that was transformed by Vite. + * @param context Function context + * @param code Transformed code + * @param id ID that was used to fetch the module + */ + runViteModule( + context: ViteRuntimeModuleContext, + code: string, + id: string, + ): Promise + /** + * Run externalized module. + * @param file File URL to the external module + */ + runExternalModule(file: string): Promise +} +``` + +Vite exports `ESModulesRunner` that implements this interface by default. It uses `new AsyncFunction` to run code, so if the code has inlined source map it should contain an [offset of 2 lines](https://tc39.es/ecma262/#sec-createdynamicfunction) to accommodate for new lines added. This is done automatically by `server.ssrFetchModule`. If your runner implementation doesn't have this constraint, you should use `fetchModule` (exported from `vite`) directly. + +## HMRRuntimeConnection + +**Type Signature:** + +```ts +export interface HMRRuntimeConnection { + /** + * Checked before sending messages to the client. + */ + isReady(): boolean + /** + * Send message to the client. + */ + send(message: string): void + /** + * Configure how HMR is handled when this connection triggers an update. + * This method expects that connection will start listening for HMR updates and call this callback when it's received. + */ + onUpdate(callback: (payload: HMRPayload) => void): void +} +``` + +This interface defines how HMR communication is established. Vite exports `ServerHMRConnector` from the main entry point to support HMR during Vite SSR. The `isReady` and `send` methods are usually called when the custom event is triggered (like, `import.meta.hot.send("my-event")`). + +`onUpdate` is called only once when the new runtime is initiated. It passed down a method that should be called when connection triggers the HMR event. The implementation depends on the type of connection (as an example, it can be `WebSocket`/`EventEmitter`/`MessageChannel`), but it usually looks something like this: + +```js +function onUpdate(callback) { + this.connection.on('hmr', (event) => callback(event.data)) +} +``` + +The callback is queued and it will wait for the current update to be resolved before processing the next update. Unlike the browser implementation, HMR updates in Vite Runtime wait until all listeners (like, `vite:beforeUpdate`/`vite:beforeFullReload`) are finished before updating the modules. + +## `createViteRuntime` + +**Type Signature:** + +```ts +async function createViteRuntime( + server: ViteDevServer, + options?: MainThreadRuntimeOptions, +): Promise +``` + +**Example Usage:** + +```js +import { createServer } from 'vite' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) + +;(async () => { + const server = await createServer({ + root: __dirname, + }) + await server.listen() + + const runtime = await createViteRuntime(server) + await runtime.executeEntrypoint('/src/entry-point.js') +})() +``` + +This method serves as an easy replacement for `server.ssrLoadModule`. Unlike `ssrLoadModule`, `createViteRuntime` provides HMR support out of the box. You can pass down [`options`](#mainthreadruntimeoptions) to customize how SSR runtime behaves to suit your needs. + +## `MainThreadRuntimeOptions` + +```ts +export interface MainThreadRuntimeOptions + extends Omit { + /** + * Disable HMR or configure HMR logger. + */ + hmr?: + | false + | { + logger?: false | HMRLogger + } + /** + * Provide a custom module runner. This controls how the code is executed. + */ + runner?: ViteModuleRunner +} +``` diff --git a/docs/guide/ssr.md b/docs/guide/ssr.md index d8159ab8a0c042..f989f7cf392704 100644 --- a/docs/guide/ssr.md +++ b/docs/guide/ssr.md @@ -125,10 +125,16 @@ app.use('*', async (req, res, next) => { // preambles from @vitejs/plugin-react template = await vite.transformIndexHtml(url, template) - // 3. Load the server entry. ssrLoadModule automatically transforms + // 3a. Load the server entry. ssrLoadModule automatically transforms // ESM source code to be usable in Node.js! There is no bundling // required, and provides efficient invalidation similar to HMR. const { render } = await vite.ssrLoadModule('/src/entry-server.js') + // 3b. Since Vite 5.1, you can use createViteRuntime API instead. + // It fully supports HMR and works in a simillar way to ssrLoadModule + // More advanced use case would be creating a runtime in a separate + // thread or even a different machine using ViteRuntime class + const runtime = await vite.createViteRuntime(server) + const { render } = await runtime.executeEntrypoint('/src/entry-server.js') // 4. render the app HTML. This assumes entry-server.js's exported // `render` function calls appropriate framework SSR APIs, @@ -163,7 +169,7 @@ The `dev` script in `package.json` should also be changed to use the server scri To ship an SSR project for production, we need to: 1. Produce a client build as normal; -2. Produce an SSR build, which can be directly loaded via `import()` so that we don't have to go through Vite's `ssrLoadModule`; +2. Produce an SSR build, which can be directly loaded via `import()` so that we don't have to go through Vite's `ssrLoadModule` or `runtime.executeEntrypoint`; Our scripts in `package.json` will look like this: @@ -181,9 +187,9 @@ Note the `--ssr` flag which indicates this is an SSR build. It should also speci Then, in `server.js` we need to add some production specific logic by checking `process.env.NODE_ENV`: -- Instead of reading the root `index.html`, use the `dist/client/index.html` as the template instead, since it contains the correct asset links to the client build. +- Instead of reading the root `index.html`, use the `dist/client/index.html` as the template, since it contains the correct asset links to the client build. -- Instead of `await vite.ssrLoadModule('/src/entry-server.js')`, use `import('./dist/server/entry-server.js')` instead (this file is the result of the SSR build). +- Instead of `await vite.ssrLoadModule('/src/entry-server.js')` or `await runtime.executeEntrypoint('/src/entry-server.js')`, use `import('./dist/server/entry-server.js')` (this file is the result of the SSR build). - Move the creation and all usage of the `vite` dev server behind dev-only conditional branches, then add static file serving middlewares to serve files from `dist/client`. diff --git a/packages/vite/package.json b/packages/vite/package.json index 4bc4f0fa26b84a..95630a9766b103 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -32,12 +32,23 @@ "./client": { "types": "./client.d.ts" }, + "./runtime": { + "types": "./dist/node/runtime.d.ts", + "import": "./dist/node/runtime.js" + }, "./dist/client/*": "./dist/client/*", "./types/*": { "types": "./types/*" }, "./package.json": "./package.json" }, + "typesVersions": { + "*": { + "runtime": [ + "dist/node/runtime.d.ts" + ] + } + }, "files": [ "bin", "dist", @@ -64,7 +75,7 @@ "build": "rimraf dist && run-s build-bundle build-types", "build-bundle": "rollup --config rollup.config.ts --configPlugin typescript", "build-types": "run-s build-types-temp build-types-roll build-types-check", - "build-types-temp": "tsc --emitDeclarationOnly --outDir temp/node -p src/node", + "build-types-temp": "tsc --emitDeclarationOnly --outDir temp -p src/node", "build-types-roll": "rollup --config rollup.dts.config.ts --configPlugin typescript && rimraf temp", "build-types-check": "tsc --project tsconfig.check.json", "typecheck": "tsc --noEmit", diff --git a/packages/vite/rollup.config.ts b/packages/vite/rollup.config.ts index 8b125cbd0556ec..d86a27ff6745c8 100644 --- a/packages/vite/rollup.config.ts +++ b/packages/vite/rollup.config.ts @@ -153,6 +153,7 @@ function createNodeConfig(isProduction: boolean) { index: path.resolve(__dirname, 'src/node/index.ts'), cli: path.resolve(__dirname, 'src/node/cli.ts'), constants: path.resolve(__dirname, 'src/node/constants.ts'), + runtime: path.resolve(__dirname, 'src/node/ssr/runtime/index.ts'), }, output: { ...sharedNodeOptions.output, @@ -299,7 +300,12 @@ const __require = require; name: 'cjs-chunk-patch', renderChunk(code, chunk) { if (!chunk.fileName.includes('chunks/dep-')) return - + // don't patch runtime utils chunk because it should stay lightweight and we know it doesn't use require + if ( + chunk.name === 'utils' && + chunk.moduleIds.some((id) => id.endsWith('/ssr/runtime/utils.ts')) + ) + return const match = code.match(/^(?:import[\s\S]*?;\s*)+/) const index = match ? match.index! + match[0].length : 0 const s = new MagicString(code) diff --git a/packages/vite/rollup.dts.config.ts b/packages/vite/rollup.dts.config.ts index 7eb0663271711f..6c4b84c6f914e1 100644 --- a/packages/vite/rollup.dts.config.ts +++ b/packages/vite/rollup.dts.config.ts @@ -13,19 +13,24 @@ const pkg = JSON.parse( readFileSync(new URL('./package.json', import.meta.url)).toString(), ) +const external = [ + /^node:*/, + 'rollup/parseAst', + ...Object.keys(pkg.dependencies), + // lightningcss types are bundled + ...Object.keys(pkg.devDependencies).filter((d) => d !== 'lightningcss'), +] + export default defineConfig({ - input: './temp/node/index.d.ts', + input: { + index: './temp/node/index.d.ts', + runtime: './temp/node/ssr/runtime/index.d.ts', + }, output: { - file: './dist/node/index.d.ts', - format: 'es', + dir: './dist/node', + format: 'esm', }, - external: [ - /^node:*/, - 'rollup/parseAst', - ...Object.keys(pkg.dependencies), - // lightningcss types are bundled - ...Object.keys(pkg.devDependencies).filter((d) => d !== 'lightningcss'), - ], + external, plugins: [patchTypes(), dts({ respectExternal: true })], }) @@ -84,15 +89,38 @@ function patchTypes(): Plugin { } }, renderChunk(code, chunk) { - validateChunkImports.call(this, chunk) - code = replaceConfusingTypeNames.call(this, code, chunk) - code = stripInternalTypes.call(this, code, chunk) - code = cleanUnnecessaryComments(code) + if ( + chunk.fileName.startsWith('runtime') || + chunk.fileName.startsWith('types.d-') + ) { + validateRuntimeChunk.call(this, chunk) + } else { + validateChunkImports.call(this, chunk) + code = replaceConfusingTypeNames.call(this, code, chunk) + code = stripInternalTypes.call(this, code, chunk) + code = cleanUnnecessaryComments(code) + } return code }, } } +/** + * Runtime chunk should only import local dependencies to stay lightweight + */ +function validateRuntimeChunk(this: PluginContext, chunk: RenderedChunk) { + for (const id of chunk.imports) { + if ( + !id.startsWith('./') && + !id.startsWith('../') && + !id.startsWith('types.d') + ) { + this.warn(`${chunk.fileName} imports "${id}" which is not allowed`) + process.exitCode = 1 + } + } +} + /** * Validate that chunk imports do not import dev deps */ @@ -103,6 +131,7 @@ function validateChunkImports(this: PluginContext, chunk: RenderedChunk) { !id.startsWith('./') && !id.startsWith('../') && !id.startsWith('node:') && + !id.startsWith('types.d') && !deps.includes(id) && !deps.some((name) => id.startsWith(name + '/')) ) { diff --git a/packages/vite/src/node/index.ts b/packages/vite/src/node/index.ts index 0c94465e1690be..61d50e0c6d6853 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -15,6 +15,8 @@ export { optimizeDeps } from './optimizer' export { formatPostcssSourceMap, preprocessCSS } from './plugins/css' export { transformWithEsbuild } from './plugins/esbuild' export { buildErrorMessage } from './server/middlewares/error' +export { fetchModule } from './ssr/fetchModule' +export type { FetchModuleOptions } from './ssr/fetchModule' export * from './publicUtils' // additional types @@ -119,6 +121,18 @@ export type { } from './server/transformRequest' export type { HmrOptions, HmrContext } from './server/hmr' +export type { + HMRBroadcaster, + HMRChannel, + ServerHMRChannel, + HMRBroadcasterClient, +} from './server/hmr' + +export type { FetchFunction } from './ssr/runtime/index' +export { createViteRuntime } from './ssr/runtime/node/mainThreadRuntime' +export type { MainThreadRuntimeOptions } from './ssr/runtime/node/mainThreadRuntime' +export { ServerHMRConnector } from './ssr/runtime/node/serverHmrConnector' + export type { BindCLIShortcutsOptions, CLIShortcut } from './shortcuts' export type { diff --git a/packages/vite/src/node/server/hmr.ts b/packages/vite/src/node/server/hmr.ts index a0ee622c34f26e..a87d902ae0a35d 100644 --- a/packages/vite/src/node/server/hmr.ts +++ b/packages/vite/src/node/server/hmr.ts @@ -1,6 +1,7 @@ import fsp from 'node:fs/promises' import path from 'node:path' import type { Server } from 'node:http' +import { EventEmitter } from 'node:events' import colors from 'picocolors' import type { CustomPayload, HMRPayload, Update } from 'types/hmrPayload' import type { RollupError } from 'rollup' @@ -252,6 +253,9 @@ export function updateModules( ? isExplicitImportRequired(acceptedVia.url) : false, isWithinCircularImport, + // browser modules are invalidated by changing ?t= query, + // but in ssr we control the module system, so we can directly remove them form cache + ssrInvalidates: getSSRInvalidatedImporters(acceptedVia), }), ), ) @@ -288,6 +292,32 @@ export function updateModules( }) } +function populateSSRImporters( + module: ModuleNode, + timestamp: number, + seen: Set, +) { + module.ssrImportedModules.forEach((importer) => { + if (seen.has(importer)) { + return + } + if ( + importer.lastHMRTimestamp === timestamp || + importer.lastInvalidationTimestamp === timestamp + ) { + seen.add(importer) + populateSSRImporters(importer, timestamp, seen) + } + }) + return seen +} + +function getSSRInvalidatedImporters(module: ModuleNode) { + return [ + ...populateSSRImporters(module, module.lastHMRTimestamp, new Set()), + ].map((m) => m.file!) +} + export async function handleFileAddUnlink( file: string, server: ViteDevServer, @@ -751,3 +781,49 @@ export function createHMRBroadcaster(): HMRBroadcaster { } return broadcaster } + +export interface ServerHMRChannel extends HMRChannel { + api: { + innerEmitter: EventEmitter + outsideEmitter: EventEmitter + } +} + +export function createServerHMRChannel(): ServerHMRChannel { + const innerEmitter = new EventEmitter() + const outsideEmitter = new EventEmitter() + + return { + name: 'ssr', + send(...args: any[]) { + let payload: HMRPayload + if (typeof args[0] === 'string') { + payload = { + type: 'custom', + event: args[0], + data: args[1], + } + } else { + payload = args[0] + } + outsideEmitter.emit('send', payload) + }, + off(event, listener: () => void) { + innerEmitter.off(event, listener) + }, + on: ((event: string, listener: () => unknown) => { + innerEmitter.on(event, listener) + }) as ServerHMRChannel['on'], + close() { + innerEmitter.removeAllListeners() + outsideEmitter.removeAllListeners() + }, + listen() { + innerEmitter.emit('connection') + }, + api: { + innerEmitter, + outsideEmitter, + }, + } +} diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 7f8c08e2f3df11..dd13255ad431f0 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -48,6 +48,8 @@ import { printServerUrls } from '../logger' import { createNoopWatcher, resolveChokidarOptions } from '../watch' import { initPublicFiles } from '../publicDir' import { getEnvFilesForMode } from '../env' +import type { FetchResult } from '../ssr/runtime/types' +import { ssrFetchModule } from '../ssr/ssrFetchModule' import type { PluginContainer } from './pluginContainer' import { ERR_CLOSED_SERVER, createPluginContainer } from './pluginContainer' import type { WebSocketServer } from './ws' @@ -76,6 +78,7 @@ import { errorMiddleware, prepareError } from './middlewares/error' import type { HMRBroadcaster, HmrOptions } from './hmr' import { createHMRBroadcaster, + createServerHMRChannel, getShortName, handleFileAddUnlink, handleHMRUpdate, @@ -294,6 +297,11 @@ export interface ViteDevServer { url: string, opts?: { fixStacktrace?: boolean }, ): Promise> + /** + * Fetch information about the module for Vite SSR runtime. + * @experimental + */ + ssrFetchModule(id: string, importer?: string): Promise /** * Returns a fixed version of the given stack */ @@ -413,7 +421,9 @@ export async function _createServer( : await resolveHttpServer(serverConfig, middlewares, httpsOptions) const ws = createWebSocketServer(httpServer, config, httpsOptions) - const hot = createHMRBroadcaster().addChannel(ws) + const hot = createHMRBroadcaster() + .addChannel(ws) + .addChannel(createServerHMRChannel()) if (typeof config.server.hmr === 'object' && config.server.hmr.channels) { config.server.hmr.channels.forEach((channel) => hot.addChannel(channel)) } @@ -496,6 +506,9 @@ export async function _createServer( opts?.fixStacktrace, ) }, + async ssrFetchModule(url: string, importer?: string) { + return ssrFetchModule(server, url, importer) + }, ssrFixStacktrace(e) { ssrFixStacktrace(e, moduleGraph) }, diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts new file mode 100644 index 00000000000000..e62d5b25d1f2d3 --- /dev/null +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -0,0 +1,158 @@ +import { pathToFileURL } from 'node:url' +import type { ModuleNode, TransformResult, ViteDevServer } from '..' +import type { PackageCache } from '../packages' +import type { InternalResolveOptionsWithOverrideConditions } from '../plugins/resolve' +import { tryNodeResolve } from '../plugins/resolve' +import { isBuiltin, isExternalUrl, isFilePathESM, unwrapId } from '../utils' +import type { FetchResult } from './runtime/types' + +interface NodeImportResolveOptions + extends InternalResolveOptionsWithOverrideConditions { + legacyProxySsrExternalModules?: boolean + packageCache?: PackageCache +} + +export interface FetchModuleOptions { + inlineSourceMap?: boolean + processSourceMap?>(map: T): T +} + +/** + * Fetch module information for Vite runtime. + * @experimental + */ +export async function fetchModule( + server: ViteDevServer, + url: string, + importer?: string, + options: FetchModuleOptions = {}, +): Promise { + // builtins should always be externalized + if (url.startsWith('data:') || isBuiltin(url)) { + return { externalize: url, type: 'builtin' } + } + + if (isExternalUrl(url)) { + return { externalize: url, type: 'network' } + } + + if (url[0] !== '.' && url[0] !== '/') { + const { + isProduction, + resolve: { dedupe, preserveSymlinks }, + root, + ssr, + } = server.config + const overrideConditions = ssr.resolve?.externalConditions || [] + + const resolveOptions: NodeImportResolveOptions = { + mainFields: ['main'], + conditions: [], + overrideConditions: [...overrideConditions, 'production', 'development'], + extensions: ['.js', '.cjs', '.json'], + dedupe, + preserveSymlinks, + isBuild: false, + isProduction, + root, + ssrConfig: ssr, + legacyProxySsrExternalModules: + server.config.legacy?.proxySsrExternalModules, + packageCache: server.config.packageCache, + } + + const resolved = tryNodeResolve( + url, + importer, + { ...resolveOptions, tryEsmOnly: true }, + false, + undefined, + true, + ) + if (!resolved) { + const err: any = new Error( + `Cannot find module '${url}' imported from '${importer}'`, + ) + err.code = 'ERR_MODULE_NOT_FOUND' + throw err + } + const file = pathToFileURL(resolved.id).toString() + const type = isFilePathESM(file, server.config.packageCache) + ? 'module' + : 'commonjs' + return { externalize: file, type } + } + + url = unwrapId(url) + + let result = await server.transformRequest(url, { ssr: true }) + + if (!result) { + throw new Error( + `[vite] transform failed for module '${url}'${ + importer ? ` imported from '${importer}'` : '' + }.`, + ) + } + + // module entry should be created by transformRequest + const mod = await server.moduleGraph.getModuleByUrl(url, true) + + if (!mod) { + throw new Error( + `[vite] cannot find module '${url}' ${ + importer ? ` imported from '${importer}'` : '' + }.`, + ) + } + + if (options.inlineSourceMap !== false) { + result = inlineSourceMap(mod, result, options.processSourceMap) + } + + // remove shebang + if (result.code[0] === '#') + result.code = result.code.replace(/^#!.*/, (s) => ' '.repeat(s.length)) + + return { code: result.code, file: mod.file } +} + +let SOURCEMAPPING_URL = 'sourceMa' +SOURCEMAPPING_URL += 'ppingURL' + +const VITE_RUNTIME_SOURCEMAPPING_SOURCE = '//# sourceMappingSource=vite-runtime' +const VITE_RUNTIME_SOURCEMAPPING_URL = `${SOURCEMAPPING_URL}=data:application/json;charset=utf-8` + +function inlineSourceMap( + mod: ModuleNode, + result: TransformResult, + processSourceMap?: FetchModuleOptions['processSourceMap'], +) { + const map = result.map + let code = result.code + + if ( + !map || + !('version' in map) || + code.includes(VITE_RUNTIME_SOURCEMAPPING_SOURCE) + ) + return result + + // to reduce the payload size, we only inline vite node source map, because it's also the only one we use + const OTHER_SOURCE_MAP_REGEXP = new RegExp( + `//# ${SOURCEMAPPING_URL}=data:application/json[^,]+base64,([A-Za-z0-9+/=]+)$`, + 'gm', + ) + while (OTHER_SOURCE_MAP_REGEXP.test(code)) + code = code.replace(OTHER_SOURCE_MAP_REGEXP, '') + + const sourceMap = Buffer.from( + JSON.stringify(processSourceMap?.(map) || map), + 'utf-8', + ).toString('base64') + result.code = `${code.trimEnd()}\n//# sourceURL=${ + mod.id + }\n${VITE_RUNTIME_SOURCEMAPPING_SOURCE}\n//# ${VITE_RUNTIME_SOURCEMAPPING_URL};base64,${sourceMap}\n` + + return result +} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/a.ts b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/a.ts new file mode 100644 index 00000000000000..804f1b9068a547 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/a.ts @@ -0,0 +1 @@ +export const a = 'a' diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets.js new file mode 100644 index 00000000000000..01723f914c90b7 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets.js @@ -0,0 +1,4 @@ +export { default as mov } from './assets/placeholder.mov' +export { default as txt } from './assets/placeholder.txt' +export { default as png } from './assets/placeholder.png' +export { default as webp } from './assets/placeholder.webp' diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.mov b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.mov new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.png b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.png new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.txt b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.txt new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.webp b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.webp new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/b.ts b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/b.ts new file mode 100644 index 00000000000000..b426ac09186e95 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/b.ts @@ -0,0 +1 @@ +export const b = 'b' diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/basic.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/basic.js new file mode 100644 index 00000000000000..777fa9d3ecf08f --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/basic.js @@ -0,0 +1,3 @@ +export const name = 'basic' + +export const meta = import.meta diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/c.ts b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/c.ts new file mode 100644 index 00000000000000..d21d1b6f71e82a --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/c.ts @@ -0,0 +1,7 @@ +/* eslint-disable no-console */ + +export { a as c } from './a' + +import.meta.hot?.accept(() => { + console.log('accept c') +}) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/circular/circular-a.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/circular/circular-a.js new file mode 100644 index 00000000000000..44793c4db2b0cd --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/circular/circular-a.js @@ -0,0 +1,2 @@ +export { b } from './circular-b' +export const a = 'a' diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/circular/circular-b.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/circular/circular-b.js new file mode 100644 index 00000000000000..9cf9aedeb4c413 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/circular/circular-b.js @@ -0,0 +1,2 @@ +export { a } from './circular-a' +export const b = 'b' diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/circular/circular-index.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/circular/circular-index.js new file mode 100644 index 00000000000000..9fdf137a639c8b --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/circular/circular-index.js @@ -0,0 +1,8 @@ +export { a } from './circular-a' +export { b } from './circular-b' + +// since there is no .accept, it does full reload +import.meta.hot.on('vite:beforeFullReload', () => { + // eslint-disable-next-line no-console + console.log('full reload') +}) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external-existing.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external-existing.js new file mode 100644 index 00000000000000..30b10ff64f05c3 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external-existing.js @@ -0,0 +1,3 @@ +import { hello } from '@vitejs/cjs-external' + +export const result = hello() diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external-non-existing.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external-non-existing.js new file mode 100644 index 00000000000000..2b67706ca1dcfb --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external-non-existing.js @@ -0,0 +1,4 @@ +import { nonExisting } from '@vitejs/cjs-external' + +// eslint-disable-next-line no-console +console.log(nonExisting) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external/index.cjs b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external/index.cjs new file mode 100644 index 00000000000000..84baa79971ff25 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external/index.cjs @@ -0,0 +1,3 @@ +module.exports = { + hello: () => 'world', +} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external/package.json b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external/package.json new file mode 100644 index 00000000000000..2629ebdb4fee41 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external/package.json @@ -0,0 +1,7 @@ +{ + "name": "@vitejs/cjs-external", + "private": true, + "version": "0.0.0", + "type": "commonjs", + "main": "index.cjs" +} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/d.ts b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/d.ts new file mode 100644 index 00000000000000..d85309b8e7e7cb --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/d.ts @@ -0,0 +1,7 @@ +/* eslint-disable no-console */ + +export { c as d } from './c' + +import.meta.hot?.accept(() => { + console.log('accept d') +}) 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 new file mode 100644 index 00000000000000..b46e31ccb40e2e --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/dynamic-import.js @@ -0,0 +1,14 @@ +import * as staticModule from './basic' + +export const initialize = async () => { + const nameRelative = './basic' + const nameAbsolute = '/fixtures/basic' + const nameAbsoluteExtension = '/fixtures/basic.js' + return { + dynamicProcessed: await import('./basic'), + dynamicRelative: await import(nameRelative), + dynamicAbsolute: await import(nameAbsolute), + dynamicAbsoluteExtension: await import(nameAbsoluteExtension), + static: staticModule, + } +} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external-existing.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external-existing.js new file mode 100644 index 00000000000000..89749bccc2ee7b --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external-existing.js @@ -0,0 +1,3 @@ +import { hello } from '@vitejs/esm-external' + +export const result = hello() diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external-non-existing.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external-non-existing.js new file mode 100644 index 00000000000000..7a1d8a07ebc60a --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external-non-existing.js @@ -0,0 +1,4 @@ +import { nonExisting } from '@vitejs/esm-external' + +// eslint-disable-next-line no-console +console.log(nonExisting) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external/index.mjs b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external/index.mjs new file mode 100644 index 00000000000000..42f8d6ae1c7e73 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external/index.mjs @@ -0,0 +1 @@ +export const hello = () => 'world' diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external/package.json b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external/package.json new file mode 100644 index 00000000000000..ddce13efff9d3e --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external/package.json @@ -0,0 +1,7 @@ +{ + "name": "@vitejs/esm-external", + "private": true, + "type": "module", + "version": "0.0.0", + "main": "index.mjs" +} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/has-error.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/has-error.js new file mode 100644 index 00000000000000..807d1c8af46b47 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/has-error.js @@ -0,0 +1,2 @@ +// comment +throw new Error('module error') diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/hmr.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/hmr.js new file mode 100644 index 00000000000000..817e8e946aec6f --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/hmr.js @@ -0,0 +1 @@ +export const hmr = import.meta.hot diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/installed.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/installed.js new file mode 100644 index 00000000000000..bd693c45a4e26d --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/installed.js @@ -0,0 +1 @@ +export * from 'tinyspy' diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/native.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/native.js new file mode 100644 index 00000000000000..b1f9ea4df7b9ae --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/native.js @@ -0,0 +1,3 @@ +export { existsSync } from 'node:fs' +// eslint-disable-next-line i/no-nodejs-modules -- testing that importing without node prefix works +export { readdirSync } from 'fs' diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/simple.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/simple.js new file mode 100644 index 00000000000000..a1d9deff4c396b --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/simple.js @@ -0,0 +1,3 @@ +export const test = 'I am initialized' + +import.meta.hot?.accept() diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/test.css b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/test.css new file mode 100644 index 00000000000000..6446ebfd427495 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/test.css @@ -0,0 +1,3 @@ +.test { + color: red; +} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/test.module.css b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/test.module.css new file mode 100644 index 00000000000000..6446ebfd427495 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/test.module.css @@ -0,0 +1,3 @@ +.test { + color: red; +} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/throws-error-method.ts b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/throws-error-method.ts new file mode 100644 index 00000000000000..3f5c23e4c01de4 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/throws-error-method.ts @@ -0,0 +1,7 @@ +interface Foo { + bar: string +} + +export function throwError(foo?: Foo): void { + throw new Error('method error') +} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/virtual.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/virtual.js new file mode 100644 index 00000000000000..cda3c077b24c05 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/virtual.js @@ -0,0 +1,4 @@ +import { msg as msg0 } from 'virtual0:test' +import { msg } from 'virtual:test' + +export { msg0, msg } diff --git a/packages/vite/src/node/ssr/runtime/__tests__/package.json b/packages/vite/src/node/ssr/runtime/__tests__/package.json new file mode 100644 index 00000000000000..89fe86abc39d19 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/package.json @@ -0,0 +1,10 @@ +{ + "name": "@vitejs/unit-runtime", + "private": true, + "version": "0.0.0", + "dependencies": { + "@vitejs/cjs-external": "link:./fixtures/cjs-external", + "@vitejs/esm-external": "link:./fixtures/esm-external", + "tinyspy": "2.2.0" + } +} 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 new file mode 100644 index 00000000000000..ccc822f543cefc --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect } from 'vitest' +import { createViteRuntimeTester } from './utils' + +describe( + 'vite-runtime hmr works as expected', + async () => { + const it = await createViteRuntimeTester({ + server: { + // override watch options because it's disabled by default + watch: {}, + }, + }) + + it('hmr options are defined', async ({ runtime }) => { + expect(runtime.hmrClient).toBeDefined() + + const mod = await runtime.executeUrl('/fixtures/hmr.js') + expect(mod).toHaveProperty('hmr') + expect(mod.hmr).toHaveProperty('accept') + }) + + it('correctly populates hmr client', async ({ runtime }) => { + const mod = await runtime.executeUrl('/fixtures/d') + expect(mod.d).toBe('a') + + const fixtureC = '/fixtures/c.ts' + const fixtureD = '/fixtures/d.ts' + + expect(runtime.hmrClient!.hotModulesMap.size).toBe(2) + expect(runtime.hmrClient!.dataMap.size).toBe(2) + expect(runtime.hmrClient!.ctxToListenersMap.size).toBe(2) + + for (const fixture of [fixtureC, fixtureD]) { + expect(runtime.hmrClient!.hotModulesMap.has(fixture)).toBe(true) + expect(runtime.hmrClient!.dataMap.has(fixture)).toBe(true) + expect(runtime.hmrClient!.ctxToListenersMap.has(fixture)).toBe(true) + } + }) + }, + process.env.CI ? 50_00 : 5_000, +) 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 new file mode 100644 index 00000000000000..ea2816756c927f --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-no-hmr.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect } from 'vitest' +import { createViteRuntimeTester } from './utils' + +describe('vite-runtime hmr works as expected', async () => { + const it = await createViteRuntimeTester({ + server: { + // override watch options because it's disabled by default + watch: {}, + hmr: false, + }, + }) + + it("hmr client is not defined if it's disabled", async ({ runtime }) => { + expect(runtime.hmrClient).toBeUndefined() + + const mod = await runtime.executeUrl('/fixtures/hmr.js') + expect(mod).toHaveProperty('hmr') + expect(mod.hmr).toBeUndefined() + }) +}) 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 new file mode 100644 index 00000000000000..5dd45fba53fbcc --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -0,0 +1,213 @@ +import { existsSync, readdirSync } from 'node:fs' +import { posix, win32 } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect } from 'vitest' +import { isWindows } from '../utils' +import { createViteRuntimeTester } from './utils' + +const _URL = URL + +describe('vite-runtime initialization', async () => { + const it = await createViteRuntimeTester() + + it('correctly runs ssr code', async ({ runtime }) => { + const mod = await runtime.executeUrl('/fixtures/simple.js') + expect(mod.test).toEqual('I am initialized') + + // loads the same module if id is a file url + const fileUrl = new _URL('./fixtures/simple.js', import.meta.url) + const mod2 = await runtime.executeUrl(fileUrl.toString()) + expect(mod).toBe(mod2) + + // loads the same module if id is a file path + const filePath = fileURLToPath(fileUrl) + const mod3 = await runtime.executeUrl(filePath) + expect(mod).toBe(mod3) + }) + + it('can load virtual modules as an entry point', async ({ runtime }) => { + const mod = await runtime.executeEntrypoint('virtual:test') + expect(mod.msg).toBe('virtual') + }) + + it('css is loaded correctly', async ({ runtime }) => { + const css = await runtime.executeUrl('/fixtures/test.css') + expect(css.default).toMatchInlineSnapshot(` + ".test { + color: red; + } + " + `) + const module = await runtime.executeUrl('/fixtures/test.module.css') + expect(module).toMatchObject({ + default: { + test: expect.stringMatching(/^_test_/), + }, + test: expect.stringMatching(/^_test_/), + }) + }) + + it('assets are loaded correctly', async ({ runtime }) => { + const assets = await runtime.executeUrl('/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', + }) + }) + + it('ids with Vite queries are loaded correctly', async ({ runtime }) => { + const raw = await runtime.executeUrl('/fixtures/simple.js?raw') + expect(raw.default).toMatchInlineSnapshot(` + "export const test = 'I am initialized' + + import.meta.hot?.accept() + " + `) + const url = await runtime.executeUrl('/fixtures/simple.js?url') + expect(url.default).toMatchInlineSnapshot(`"/fixtures/simple.js"`) + const inline = await runtime.executeUrl('/fixtures/test.css?inline') + expect(inline.default).toMatchInlineSnapshot(` + ".test { + color: red; + } + " + `) + }) + + it('modules with query strings are treated as different modules', async ({ + runtime, + }) => { + const modSimple = await runtime.executeUrl('/fixtures/simple.js') + const modUrl = await runtime.executeUrl('/fixtures/simple.js?url') + expect(modSimple).not.toBe(modUrl) + expect(modUrl.default).toBe('/fixtures/simple.js') + }) + + it('exports is not modifiable', async ({ runtime }) => { + const mod = await runtime.executeUrl('/fixtures/simple.js') + expect(() => { + mod.test = 'I am modified' + }).toThrowErrorMatchingInlineSnapshot( + `[TypeError: Cannot set property test of [object Module] which has only a getter]`, + ) + expect(() => { + mod.other = 'I am added' + }).toThrowErrorMatchingInlineSnapshot( + `[TypeError: Cannot add property other, object is not extensible]`, + ) + }) + + it('throws the same error', async ({ runtime }) => { + expect.assertions(3) + const s = Symbol() + try { + await runtime.executeUrl('/fixtures/has-error.js') + } catch (e) { + expect(e[s]).toBeUndefined() + e[s] = true + expect(e[s]).toBe(true) + } + + try { + await runtime.executeUrl('/fixtures/has-error.js') + } catch (e) { + expect(e[s]).toBe(true) + } + }) + + it('importing external cjs library checks exports', async ({ runtime }) => { + await expect(() => + runtime.executeUrl('/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( + runtime.executeUrl('/fixtures/cjs-external-existing.js'), + ).resolves.toMatchObject({ + result: 'world', + }) + }) + + it('importing external esm library checks exports', async ({ runtime }) => { + await expect(() => + runtime.executeUrl('/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( + runtime.executeUrl('/fixtures/esm-external-existing.js'), + ).resolves.toMatchObject({ + result: 'world', + }) + }) + + it("dynamic import doesn't produce duplicates", async ({ runtime }) => { + const mod = await runtime.executeUrl('/fixtures/dynamic-import.js') + const modules = await mod.initialize() + // toBe checks that objects are actually the same, not just structually + // using toEqual here would be a mistake because it chesk 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) + }) + + it('correctly imports a virtual module', async ({ runtime }) => { + const mod = await runtime.executeUrl('/fixtures/virtual.js') + expect(mod.msg0).toBe('virtual0') + expect(mod.msg).toBe('virtual') + }) + + it('importing package from node_modules', async ({ runtime }) => { + const mod = (await runtime.executeUrl( + '/fixtures/installed.js', + )) as typeof import('tinyspy') + const fn = mod.spy() + fn() + expect(fn.called).toBe(true) + }) + + it('importing native node package', async ({ runtime }) => { + const mod = await runtime.executeUrl('/fixtures/native.js') + expect(mod.readdirSync).toBe(readdirSync) + expect(mod.existsSync).toBe(existsSync) + }) + + it('correctly resolves module url', async ({ runtime, server }) => { + const { meta } = + await runtime.executeUrl( + '/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) + } + }) +}) 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 new file mode 100644 index 00000000000000..15acfaec4990ad --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect } from 'vitest' +import type { ViteRuntime } from '../runtime' +import { createViteRuntimeTester, editFile, resolvePath } from './utils' + +describe('vite-runtime initialization', async () => { + const it = await createViteRuntimeTester( + {}, + { + sourcemapInterceptor: 'prepareStackTrace', + }, + ) + + const getError = async (cb: () => void): Promise => { + try { + await cb() + expect.unreachable() + } catch (err) { + return err + } + } + const serializeStack = (runtime: ViteRuntime, err: Error) => { + return err.stack!.split('\n')[1].replace(runtime.options.root, '') + } + + it('source maps are correctly applied to stack traces', async ({ + runtime, + server, + }) => { + expect.assertions(3) + const topLevelError = await getError(() => + runtime.executeUrl('/fixtures/has-error.js'), + ) + expect(serializeStack(runtime, topLevelError)).toBe( + ' at /fixtures/has-error.js:2:7', + ) + + const methodError = await getError(async () => { + const mod = await runtime.executeUrl('/fixtures/throws-error-method.ts') + mod.throwError() + }) + expect(serializeStack(runtime, methodError)).toBe( + ' at Module.throwError (/fixtures/throws-error-method.ts:6:9)', + ) + + // simulate HMR + editFile( + resolvePath(import.meta.url, './fixtures/throws-error-method.ts'), + (code) => '\n\n\n\n\n' + code + '\n', + ) + runtime.moduleCache.clear() + server.moduleGraph.invalidateAll() + + const methodErrorNew = await getError(async () => { + const mod = await runtime.executeUrl('/fixtures/throws-error-method.ts') + mod.throwError() + }) + + expect(serializeStack(runtime, methodErrorNew)).toBe( + ' at Module.throwError (/fixtures/throws-error-method.ts:11:9)', + ) + }) +}) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts new file mode 100644 index 00000000000000..a6d7ccb57480b3 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts @@ -0,0 +1,130 @@ +import fs from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { TestAPI } from 'vitest' +import { afterEach, beforeEach, test } from 'vitest' +import type { + InlineConfig, + MainThreadRuntimeOptions, + ViteDevServer, +} from '../../../index' +import { createServer } from '../../../index' +import type { ViteRuntime } from '../runtime' +import { createViteRuntime } from '../node/mainThreadRuntime' + +interface TestClient { + server: ViteDevServer + runtime: ViteRuntime +} + +export async function createViteRuntimeTester( + config: InlineConfig = {}, + runtimeConfig: MainThreadRuntimeOptions = {}, +): 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) => { + globalThis.__HMR__ = {} + + t.server = await createServer({ + root: __dirname, + logLevel: 'error', + server: { + middlewareMode: true, + watch: null, + hmr: { + port: 9609, + }, + }, + ssr: { + external: ['@vitejs/cjs-external', '@vitejs/esm-external'], + }, + optimizeDeps: { + disabled: true, + noDiscovery: true, + include: [], + }, + plugins: [ + { + name: 'vite-plugin-virtual', + resolveId(id) { + if (id === 'virtual0:test') { + return `\0virtual:test` + } + if (id === 'virtual:test') { + return 'virtual:test' + } + }, + load(id) { + if (id === `\0virtual:test`) { + return `export const msg = 'virtual0'` + } + if (id === `virtual:test`) { + return `export const msg = 'virtual'` + } + }, + }, + ], + ...config, + }) + t.runtime = await createViteRuntime(t.server, { + hmr: { + logger: false, + }, + // don't override by default so Vitest source maps are correct + sourcemapInterceptor: false, + ...runtimeConfig, + }) + if (config.server?.watch) { + await waitForWatcher(t.server) + } + }) + + afterEach(async (t) => { + await t.runtime.destroy() + await t.server.close() + }) + + return test as TestAPI +} + +const originalFiles = new Map() +const createdFiles = new Set() +afterEach(() => { + originalFiles.forEach((content, file) => { + fs.writeFileSync(file, content, 'utf-8') + }) + createdFiles.forEach((file) => { + if (fs.existsSync(file)) fs.unlinkSync(file) + }) + originalFiles.clear() + createdFiles.clear() +}) + +export function createFile(file: string, content: string): void { + createdFiles.add(file) + fs.mkdirSync(dirname(file), { recursive: true }) + fs.writeFileSync(file, content, 'utf-8') +} + +export function editFile( + file: string, + callback: (content: string) => string, +): void { + const content = fs.readFileSync(file, 'utf-8') + if (!originalFiles.has(file)) originalFiles.set(file, content) + fs.writeFileSync(file, callback(content), 'utf-8') +} + +export function resolvePath(baseUrl: string, path: string): string { + const filename = fileURLToPath(baseUrl) + return resolve(dirname(filename), path).replace(/\\/g, '/') +} diff --git a/packages/vite/src/node/ssr/runtime/constants.ts b/packages/vite/src/node/ssr/runtime/constants.ts new file mode 100644 index 00000000000000..9c0f1cb8944395 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/constants.ts @@ -0,0 +1,6 @@ +// they are exported from ssrTransform plugin, but we can't import from there for performance reasons +export const ssrModuleExportsKey = `__vite_ssr_exports__` +export const ssrImportKey = `__vite_ssr_import__` +export const ssrDynamicImportKey = `__vite_ssr_dynamic_import__` +export const ssrExportAllKey = `__vite_ssr_exportAll__` +export const ssrImportMetaKey = `__vite_ssr_import_meta__` diff --git a/packages/vite/src/node/ssr/runtime/esmRunner.ts b/packages/vite/src/node/ssr/runtime/esmRunner.ts new file mode 100644 index 00000000000000..a9aacd8fbea13f --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/esmRunner.ts @@ -0,0 +1,43 @@ +import { + ssrDynamicImportKey, + ssrExportAllKey, + ssrImportKey, + ssrImportMetaKey, + ssrModuleExportsKey, +} from './constants' +import type { ViteModuleRunner, ViteRuntimeModuleContext } from './types' + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const AsyncFunction = async function () {}.constructor as typeof Function + +export class ESModulesRunner implements ViteModuleRunner { + async runViteModule( + context: ViteRuntimeModuleContext, + code: string, + ): Promise { + // use AsyncFunction instead of vm module to support broader array of environments out of the box + const initModule = new AsyncFunction( + ssrModuleExportsKey, + ssrImportMetaKey, + ssrImportKey, + ssrDynamicImportKey, + ssrExportAllKey, + // source map should already be inlined by Vite + '"use strict";' + code, + ) + + await initModule( + context[ssrModuleExportsKey], + context[ssrImportMetaKey], + context[ssrImportKey], + context[ssrDynamicImportKey], + context[ssrExportAllKey], + ) + + Object.freeze(context[ssrModuleExportsKey]) + } + + runExternalModule(filepath: string): Promise { + return import(filepath) + } +} diff --git a/packages/vite/src/node/ssr/runtime/hmrHandler.ts b/packages/vite/src/node/ssr/runtime/hmrHandler.ts new file mode 100644 index 00000000000000..413d355c2f51a0 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/hmrHandler.ts @@ -0,0 +1,115 @@ +import type { HMRPayload } from 'types/hmrPayload' +import type { ViteRuntime } from './runtime' +import { unwrapId } from './utils' + +// updates to HMR should go one after another. It is possible to trigger another update during the invalidation for example. +export function createHMRHandler( + runtime: ViteRuntime, +): (payload: HMRPayload) => Promise { + const queue = new Queue() + return (payload) => queue.enqueue(() => handleHMRPayload(runtime, payload)) +} + +export async function handleHMRPayload( + runtime: ViteRuntime, + payload: HMRPayload, +): Promise { + const hmrClient = runtime.hmrClient + if (!hmrClient || runtime.isDestroyed()) return + switch (payload.type) { + case 'connected': + hmrClient.logger.debug(`[vite] connected.`) + hmrClient.messenger.flush() + break + case 'update': + await hmrClient.notifyListeners('vite:beforeUpdate', payload) + await Promise.all( + payload.updates.map(async (update): Promise => { + if (update.type === 'js-update') { + // runtime always caches modules by their full path without /@id/ prefix + update.acceptedPath = unwrapId(update.acceptedPath) + update.path = unwrapId(update.path) + return hmrClient.queueUpdate(update) + } + + hmrClient.logger.error( + '[vite] css hmr is not supported in runtime mode.', + ) + }), + ) + await hmrClient.notifyListeners('vite:afterUpdate', payload) + break + case 'custom': { + await hmrClient.notifyListeners(payload.event, payload.data) + break + } + case 'full-reload': + hmrClient.logger.debug(`[vite] program reload`) + await hmrClient.notifyListeners('vite:beforeFullReload', payload) + Array.from(runtime.moduleCache.keys()).forEach((id) => { + if (!id.includes('node_modules')) { + runtime.moduleCache.deleteByModuleId(id) + } + }) + for (const id of runtime.entrypoints) { + await runtime.executeUrl(id) + } + break + case 'prune': + await hmrClient.notifyListeners('vite:beforePrune', payload) + hmrClient.prunePaths(payload.paths) + break + case 'error': { + await hmrClient.notifyListeners('vite:error', payload) + const err = payload.err + hmrClient.logger.error( + `[vite] Internal Server Error\n${err.message}\n${err.stack}`, + ) + break + } + default: { + const check: never = payload + return check + } + } +} + +class Queue { + private queue: { + promise: () => Promise + resolve: (value?: unknown) => void + reject: (err?: unknown) => void + }[] = [] + private pending = false + + enqueue(promise: () => Promise) { + return new Promise((resolve, reject) => { + this.queue.push({ + promise, + resolve, + reject, + }) + this.dequeue() + }) + } + + dequeue() { + if (this.pending) { + return false + } + const item = this.queue.shift() + if (!item) { + return false + } + this.pending = true + item + .promise() + .then(item.resolve) + .catch(item.reject) + .finally(() => { + this.pending = false + this.dequeue() + }) + return true + } +} diff --git a/packages/vite/src/node/ssr/runtime/hmrLogger.ts b/packages/vite/src/node/ssr/runtime/hmrLogger.ts new file mode 100644 index 00000000000000..4fc83dba7a4a6a --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/hmrLogger.ts @@ -0,0 +1,8 @@ +import type { HMRLogger } from '../../../shared/hmr' + +const noop = (): void => {} + +export const silentConsole: HMRLogger = { + debug: noop, + error: noop, +} diff --git a/packages/vite/src/node/ssr/runtime/index.ts b/packages/vite/src/node/ssr/runtime/index.ts new file mode 100644 index 00000000000000..f2b5b83f0fda5d --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/index.ts @@ -0,0 +1,26 @@ +// this file should re-export only things that don't rely on Node.js or other runtime features + +export { ModuleCacheMap } from './moduleCache' +export { ViteRuntime } from './runtime' +export { ESModulesRunner } from './esmRunner' + +export type { HMRLogger, HMRConnection } from '../../../shared/hmr' +export type { + ViteModuleRunner, + ViteRuntimeModuleContext, + ModuleCache, + FetchResult, + FetchFunction, + ResolvedResult, + SSRImportMetadata, + HMRRuntimeConnection, + ViteRuntimeImportMeta, + ViteRuntimeOptions, +} from './types' +export { + ssrDynamicImportKey, + ssrExportAllKey, + ssrImportKey, + ssrImportMetaKey, + ssrModuleExportsKey, +} from './constants' diff --git a/packages/vite/src/node/ssr/runtime/moduleCache.ts b/packages/vite/src/node/ssr/runtime/moduleCache.ts new file mode 100644 index 00000000000000..e40d1dd7316960 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/moduleCache.ts @@ -0,0 +1,154 @@ +import { DecodedMap } from './sourcemap/decoder' +import type { ModuleCache } from './types' +import { decodeBase64, isWindows } from './utils' + +let SOURCEMAPPING_URL = 'sourceMa' +SOURCEMAPPING_URL += 'ppingURL' + +const VITE_RUNTIME_SOURCEMAPPING_URL = `${SOURCEMAPPING_URL}=data:application/json;charset=utf-8` +const VITE_RUNTIME_SOURCEMAPPING_REGEXP = new RegExp( + `//# ${VITE_RUNTIME_SOURCEMAPPING_URL};base64,(.+)`, +) + +export class ModuleCacheMap extends Map { + private root: string + + constructor(root: string, entries?: [string, ModuleCache][]) { + super(entries) + this.root = withTrailingSlash(root) + } + + normalize(fsPath: string): string { + return normalizeModuleId(fsPath, this.root) + } + + /** + * Assign partial data to the map + */ + update(fsPath: string, mod: ModuleCache): this { + fsPath = this.normalize(fsPath) + if (!super.has(fsPath)) this.setByModuleId(fsPath, mod) + else Object.assign(super.get(fsPath) as ModuleCache, mod) + return this + } + + setByModuleId(modulePath: string, mod: ModuleCache): this { + return super.set(modulePath, mod) + } + + override set(fsPath: string, mod: ModuleCache): this { + return this.setByModuleId(this.normalize(fsPath), mod) + } + + getByModuleId(modulePath: string): ModuleCache { + if (!super.has(modulePath)) this.setByModuleId(modulePath, {}) + + const mod = super.get(modulePath)! + if (!mod.imports) { + Object.assign(mod, { + imports: new Set(), + importers: new Set(), + }) + } + return mod as ModuleCache + } + + override get(fsPath: string): ModuleCache { + return this.getByModuleId(this.normalize(fsPath)) + } + + deleteByModuleId(modulePath: string): boolean { + return super.delete(modulePath) + } + + override delete(fsPath: string): boolean { + return this.deleteByModuleId(this.normalize(fsPath)) + } + + /** + * Invalidate modules that dependent on the given modules, up to the main entry + */ + invalidateDepTree( + ids: string[] | Set, + invalidated = new Set(), + ): Set { + for (const _id of ids) { + const id = this.normalize(_id) + if (invalidated.has(id)) continue + invalidated.add(id) + const mod = super.get(id) + if (mod?.importers) this.invalidateDepTree(mod.importers, invalidated) + super.delete(id) + } + return invalidated + } + + /** + * Invalidate dependency modules of the given modules, down to the bottom-level dependencies + */ + invalidateSubDepTree( + ids: string[] | Set, + invalidated = new Set(), + ): Set { + for (const _id of ids) { + const id = this.normalize(_id) + if (invalidated.has(id)) continue + invalidated.add(id) + const subIds = Array.from(super.entries()) + .filter(([, mod]) => mod.importers?.has(id)) + .map(([key]) => key) + subIds.length && this.invalidateSubDepTree(subIds, invalidated) + super.delete(id) + } + return invalidated + } + + getSourceMap(moduleId: string): null | DecodedMap { + const mod = this.get(moduleId) + if (mod.map) return mod.map + if (!mod.meta || !('code' in mod.meta)) return null + const mapString = mod.meta.code.match( + VITE_RUNTIME_SOURCEMAPPING_REGEXP, + )?.[1] + if (!mapString) return null + const baseFile = mod.meta.file || moduleId.split('?')[0] + mod.map = new DecodedMap(JSON.parse(decodeBase64(mapString)), baseFile) + return mod.map + } +} + +function withTrailingSlash(path: string): string { + if (path[path.length - 1] !== '/') { + return `${path}/` + } + return path +} + +// unique id that is not available as "$bare_import" like "test" +const prefixedBuiltins = new Set(['node:test']) + +// transform file url to id +// virtual:custom -> virtual:custom +// \0custom -> \0custom +// /root/id -> /id +// /root/id.js -> /id.js +// C:/root/id.js -> /id.js +// C:\root\id.js -> /id.js +function normalizeModuleId(file: string, root: string): string { + if (prefixedBuiltins.has(file)) return file + + // unix style, but Windows path still starts with the drive letter to check the root + let unixFile = file + .replace(/\\/g, '/') + .replace(/^\/@fs\//, isWindows ? '' : '/') + .replace(/^node:/, '') + .replace(/^\/+/, '/') + + if (unixFile.startsWith(root)) { + // keep slash + unixFile = unixFile.slice(root.length - 1) + } + + // if it's not in the root, keep it as a path, not a URL + return unixFile.replace(/^file:\//, '/') +} diff --git a/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts b/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts new file mode 100644 index 00000000000000..9146f42c1f3b21 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts @@ -0,0 +1,85 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { ViteDevServer } from '../../../index' +import { ViteRuntime } from '../runtime' +import { ESModulesRunner } from '../esmRunner' +import type { ViteModuleRunner, ViteRuntimeOptions } from '../types' +import type { HMRLogger } from '../../../../shared/hmr' +import { ServerHMRConnector } from './serverHmrConnector' + +/** + * @experimental + */ +export interface MainThreadRuntimeOptions + extends Omit { + /** + * Disable HMR or configure HMR logger. + */ + hmr?: + | false + | { + logger?: false | HMRLogger + } + /** + * Provide a custom module runner. This controls how the code is executed. + */ + runner?: ViteModuleRunner +} + +function createHMROptions( + server: ViteDevServer, + options: MainThreadRuntimeOptions, +) { + if (server.config.server.hmr === false || options.hmr === false) { + return false + } + const connection = new ServerHMRConnector(server) + return { + connection, + logger: options.hmr?.logger, + } +} + +const prepareStackTrace = { + retrieveFile(id: string) { + if (existsSync(id)) { + return readFileSync(id, 'utf-8') + } + }, +} + +function resolveSourceMapOptions(options: MainThreadRuntimeOptions) { + if (options.sourcemapInterceptor != null) { + if (options.sourcemapInterceptor === 'prepareStackTrace') { + return prepareStackTrace + } + if (typeof options.sourcemapInterceptor === 'object') { + return { ...prepareStackTrace, ...options.sourcemapInterceptor } + } + return options.sourcemapInterceptor + } + if (typeof process !== 'undefined' && 'setSourceMapsEnabled' in process) { + return 'node' + } + return prepareStackTrace +} + +/** + * Create an instance of the Vite SSR runtime that support HMR. + * @experimental + */ +export async function createViteRuntime( + server: ViteDevServer, + options: MainThreadRuntimeOptions = {}, +): Promise { + const hmr = createHMROptions(server, options) + return new ViteRuntime( + { + ...options, + root: server.config.root, + fetchModule: server.ssrFetchModule, + hmr, + sourcemapInterceptor: resolveSourceMapOptions(options), + }, + options.runner || new ESModulesRunner(), + ) +} diff --git a/packages/vite/src/node/ssr/runtime/node/serverHmrConnector.ts b/packages/vite/src/node/ssr/runtime/node/serverHmrConnector.ts new file mode 100644 index 00000000000000..21e0315ad381d5 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/node/serverHmrConnector.ts @@ -0,0 +1,77 @@ +import type { CustomPayload, HMRPayload } from 'types/hmrPayload' +import type { ViteDevServer } from '../../../server' +import type { + HMRBroadcasterClient, + ServerHMRChannel, +} from '../../../server/hmr' +import type { HMRRuntimeConnection } from '../types' + +class ServerHMRBroadcasterClient implements HMRBroadcasterClient { + constructor(private readonly hmrChannel: ServerHMRChannel) {} + + send(...args: any[]) { + let payload: HMRPayload + if (typeof args[0] === 'string') { + payload = { + type: 'custom', + event: args[0], + data: args[1], + } + } else { + payload = args[0] + } + if (payload.type !== 'custom') { + throw new Error( + 'Cannot send non-custom events from the client to the server.', + ) + } + this.hmrChannel.send(payload) + } +} + +/** + * The connector class to establish HMR communication between the server and the Vite runtime. + * @experimental + */ +export class ServerHMRConnector implements HMRRuntimeConnection { + private handlers: ((payload: HMRPayload) => void)[] = [] + private hmrChannel: ServerHMRChannel + private hmrClient: ServerHMRBroadcasterClient + + private connected = false + + constructor(server: ViteDevServer) { + const hmrChannel = server.hot?.channels.find( + (c) => c.name === 'ssr', + ) as ServerHMRChannel + if (!hmrChannel) { + throw new Error( + "Your version of Vite doesn't support HMR during SSR. Please, use Vite 5.1 or higher.", + ) + } + this.hmrClient = new ServerHMRBroadcasterClient(hmrChannel) + hmrChannel.api.outsideEmitter.on('send', (payload: HMRPayload) => { + this.handlers.forEach((listener) => listener(payload)) + }) + this.hmrChannel = hmrChannel + } + + isReady(): boolean { + return this.connected + } + + send(message: string): void { + const payload = JSON.parse(message) as CustomPayload + this.hmrChannel.api.innerEmitter.emit( + payload.event, + payload.data, + this.hmrClient, + ) + } + + onUpdate(handler: (payload: HMRPayload) => void): void { + this.handlers.push(handler) + handler({ type: 'connected' }) + this.connected = true + } +} diff --git a/packages/vite/src/node/ssr/runtime/runtime.ts b/packages/vite/src/node/ssr/runtime/runtime.ts new file mode 100644 index 00000000000000..1c43eb713eea4f --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/runtime.ts @@ -0,0 +1,487 @@ +import type { ViteHotContext } from 'types/hot' +import { HMRClient, HMRContext } from '../../../shared/hmr' +import { ModuleCacheMap } from './moduleCache' +import type { + FetchResult, + ModuleCache, + ResolvedResult, + SSRImportMetadata, + ViteModuleRunner, + ViteRuntimeImportMeta, + ViteRuntimeModuleContext, + ViteRuntimeOptions, +} from './types' +import { + cleanUrl, + isPrimitive, + isWindows, + posixDirname, + posixPathToFileHref, + posixResolve, + toWindowsPath, + unwrapId, + wrapId, +} from './utils' +import { + ssrDynamicImportKey, + ssrExportAllKey, + ssrImportKey, + ssrImportMetaKey, + ssrModuleExportsKey, +} from './constants' +import { silentConsole } from './hmrLogger' +import { createHMRHandler } from './hmrHandler' +import { enableSourceMapSupport } from './sourcemap/index' + +interface ViteRuntimeDebugger { + (formatter: unknown, ...args: unknown[]): void +} + +export class ViteRuntime { + /** + * Holds the cache of modules + * Keys of the map are ids + */ + public moduleCache: ModuleCacheMap + public hmrClient?: HMRClient + public entrypoints = new Set() + + private idToUrlMap = new Map() + private fileToIdMap = new Map() + private envProxy = new Proxy({} as any, { + get(_, p) { + throw new Error( + `[vite-runtime] Dynamic access of "import.meta.env" is not supported. Please, use "import.meta.env.${String(p)}" instead.`, + ) + }, + }) + + private _destroyed = false + private _resetSourceMapSupport?: () => void + + constructor( + public options: ViteRuntimeOptions, + public runner: ViteModuleRunner, + private debug?: ViteRuntimeDebugger, + ) { + this.moduleCache = options.moduleCache ?? new ModuleCacheMap(options.root) + if (typeof options.hmr === 'object') { + this.hmrClient = new HMRClient( + options.hmr.logger === false + ? silentConsole + : options.hmr.logger || console, + options.hmr.connection, + ({ acceptedPath, ssrInvalidates }) => { + this.moduleCache.delete(acceptedPath) + if (ssrInvalidates) { + this.invalidateFiles(ssrInvalidates) + } + return this.executeUrl(acceptedPath) + }, + ) + options.hmr.connection.onUpdate(createHMRHandler(this)) + } + if (options.sourcemapInterceptor !== false) { + this._resetSourceMapSupport = enableSourceMapSupport(this) + } + } + + /** + * URL to execute. Accepts file path, server path or id relative to the root. + */ + public async executeUrl(url: string): Promise { + url = this.normalizeEntryUrl(url) + const fetchedModule = await this.cachedModule(url) + return await this.cachedRequest(url, fetchedModule) + } + + /** + * Entrypoint URL to execute. Accepts file path, server path or id relative to the root. + * In the case of a full reload triggered by HMR, this is the module that will be reloaded. + * If this method is called multiple times, all entrypoints will be reloaded one at a time. + */ + public async executeEntrypoint(url: string): Promise { + url = this.normalizeEntryUrl(url) + const fetchedModule = await this.cachedModule(url) + return await this.cachedRequest(url, fetchedModule, [], { + entrypoint: true, + }) + } + + /** + * Clear all caches including HMR listeners. + */ + public clearCache(): void { + this.moduleCache.clear() + this.idToUrlMap.clear() + this.entrypoints.clear() + this.hmrClient?.clear() + } + + /** + * Clears all caches, removes all HMR listeners, and resets source map support. + * This method doesn't stop the HMR connection. + */ + public async destroy(): Promise { + this._resetSourceMapSupport?.() + this.clearCache() + this.hmrClient = undefined + this._destroyed = true + } + + /** + * Returns `true` if the runtime has been destroyed by calling `destroy()` method. + */ + public isDestroyed(): boolean { + return this._destroyed + } + + private invalidateFiles(files: string[]) { + files.forEach((file) => { + const ids = this.fileToIdMap.get(file) + if (ids) { + ids.forEach((id) => this.moduleCache.deleteByModuleId(id)) + } + }) + } + + // we don't use moduleCache.normalize because this URL doesn't have to follow the same rules + // this URL is something that user passes down manually, and is later resolved by fetchModule + // moduleCache.normalize is used on resolved "file" property + private normalizeEntryUrl(url: string) { + // expect fetchModule to resolve relative module correctly + if (url[0] === '.') { + return url + } + // file:///C:/root/id.js -> C:/root/id.js + if (url.startsWith('file://')) { + // 8 is the length of "file:///" + url = url.slice(isWindows ? 8 : 7) + } + url = url.replace(/\\/g, '/') + const _root = this.options.root + const root = _root[_root.length - 1] === '/' ? _root : `${_root}/` + // strip root from the URL because fetchModule prefers a public served url path + // packages/vite/src/node/server/moduleGraph.ts:17 + if (url.startsWith(root)) { + // /root/id.js -> /id.js + // C:/root/id.js -> /id.js + // 1 is to keep the leading slash + return url.slice(root.length - 1) + } + // if it's a server url (starts with a slash), keep it, otherwise assume a virtual module + // /id.js -> /id.js + // virtual:custom -> /@id/virtual:custom + return url[0] === '/' ? url : wrapId(url) + } + + private processImport( + exports: Record, + fetchResult: ResolvedResult, + metadata?: SSRImportMetadata, + ) { + if (!('externalize' in fetchResult)) { + return exports + } + const { id, type } = fetchResult + if (type !== 'module' && type !== 'commonjs') return exports + analyzeImportedModDifference(exports, id, type, metadata) + return proxyGuardOnlyEsm(exports, id, metadata) + } + + private async cachedRequest( + id: string, + fetchedModule: ResolvedResult, + callstack: string[] = [], + metadata?: SSRImportMetadata, + ): Promise { + const moduleId = fetchedModule.id + + if (metadata?.entrypoint) { + this.entrypoints.add(moduleId) + } + + const mod = this.moduleCache.getByModuleId(moduleId) + + const { imports, importers } = mod as Required + + const importee = callstack[callstack.length - 1] + + if (importee) importers.add(importee) + + // check circular dependency + if ( + callstack.includes(moduleId) || + Array.from(imports.values()).some((i) => importers.has(i)) + ) { + if (mod.exports) + return this.processImport(mod.exports, fetchedModule, metadata) + } + + const getStack = () => + `stack:\n${[...callstack, moduleId] + .reverse() + .map((p) => ` - ${p}`) + .join('\n')}` + + let debugTimer: any + if (this.debug) + debugTimer = setTimeout( + () => + this.debug!( + `[vite-runtime] module ${moduleId} takes over 2s to load.\n${getStack()}`, + ), + 2000, + ) + + try { + // cached module + if (mod.promise) + return this.processImport(await mod.promise, fetchedModule, metadata) + + const promise = this.directRequest(id, fetchedModule, callstack) + mod.promise = promise + mod.evaluated = false + return this.processImport(await promise, fetchedModule, metadata) + } finally { + mod.evaluated = true + if (debugTimer) clearTimeout(debugTimer) + } + } + + private async cachedModule( + id: string, + importer?: string, + ): Promise { + if (this._destroyed) { + throw new Error(`[vite] Vite runtime has been destroyed.`) + } + const normalized = this.idToUrlMap.get(id) + if (normalized) { + const mod = this.moduleCache.getByModuleId(normalized) + if (mod.meta) { + return mod.meta as ResolvedResult + } + } + this.debug?.('[vite-runtime] fetching', id) + // fast return for established externalized patterns + const fetchedModule = id.startsWith('data:') + ? ({ externalize: id, type: 'builtin' } as FetchResult) + : await this.options.fetchModule(id, importer) + // base moduleId on "file" and not on id + // if `import(variable)` is called it's possible that it doesn't have an extension for example + // if we used id for that, it's possible to have a duplicated module + const idQuery = id.split('?')[1] + const query = idQuery ? `?${idQuery}` : '' + const file = 'file' in fetchedModule ? fetchedModule.file : undefined + const fullFile = file ? `${file}${query}` : id + const moduleId = this.moduleCache.normalize(fullFile) + const mod = this.moduleCache.getByModuleId(moduleId) + ;(fetchedModule as ResolvedResult).id = moduleId + mod.meta = fetchedModule + + if (file) { + const fileModules = this.fileToIdMap.get(file) || [] + fileModules.push(moduleId) + this.fileToIdMap.set(file, fileModules) + } + + this.idToUrlMap.set(id, moduleId) + this.idToUrlMap.set(unwrapId(id), moduleId) + return fetchedModule as ResolvedResult + } + + // override is allowed, consider this a public API + protected async directRequest( + id: string, + fetchResult: ResolvedResult, + _callstack: string[], + ): Promise { + const moduleId = fetchResult.id + const callstack = [..._callstack, moduleId] + + const mod = this.moduleCache.getByModuleId(moduleId) + + const request = async (dep: string, metadata?: SSRImportMetadata) => { + const fetchedModule = await this.cachedModule(dep, moduleId) + const depMod = this.moduleCache.getByModuleId(fetchedModule.id) + depMod.importers!.add(moduleId) + mod.imports!.add(fetchedModule.id) + + return this.cachedRequest(dep, fetchedModule, callstack, metadata) + } + + const dynamicRequest = async (dep: string) => { + // it's possible to provide an object with toString() method inside import() + dep = String(dep) + if (dep[0] === '.') { + dep = posixResolve(posixDirname(id), dep) + } + return request(dep, { isDynamicImport: true }) + } + + if ('externalize' in fetchResult) { + const { externalize } = fetchResult + this.debug?.('[vite-runtime] externalizing', externalize) + const exports = await this.runner.runExternalModule(externalize) + mod.exports = exports + return exports + } + + const { code, file } = fetchResult + + if (code == null) { + const importer = callstack[callstack.length - 2] + throw new Error( + `[vite-runtime] Failed to load "${id}"${ + importer ? ` imported from ${importer}` : '' + }`, + ) + } + + const modulePath = cleanUrl(file || moduleId) + // disambiguate the `:/` on windows: see nodejs/node#31710 + const href = posixPathToFileHref(modulePath) + const filename = modulePath + const dirname = posixDirname(modulePath) + const meta: ViteRuntimeImportMeta = { + filename: isWindows ? toWindowsPath(filename) : filename, + dirname: isWindows ? toWindowsPath(dirname) : dirname, + url: href, + env: this.envProxy, + resolve(id, parent) { + throw new Error( + '[vite-runtime] "import.meta.resolve" is not supported.', + ) + }, + // should be replaced during transformation + glob() { + throw new Error('[vite-runtime] "import.meta.glob" is not supported.') + }, + } + const exports = Object.create(null) + Object.defineProperty(exports, Symbol.toStringTag, { + value: 'Module', + enumerable: false, + configurable: false, + }) + + mod.exports = exports + + let hotContext: ViteHotContext | undefined + if (this.hmrClient) { + Object.defineProperty(meta, 'hot', { + enumerable: true, + get: () => { + if (!this.hmrClient) { + throw new Error(`[vite-runtime] HMR client was destroyed.`) + } + this.debug?.('[vite-runtime] creating hmr context for', moduleId) + hotContext ||= new HMRContext(this.hmrClient, moduleId) + return hotContext + }, + set: (value) => { + hotContext = value + }, + }) + } + + const context: ViteRuntimeModuleContext = { + [ssrImportKey]: request, + [ssrDynamicImportKey]: dynamicRequest, + [ssrModuleExportsKey]: exports, + [ssrExportAllKey]: (obj: any) => exportAll(exports, obj), + [ssrImportMetaKey]: meta, + } + + this.debug?.('[vite-runtime] executing', href) + + await this.runner.runViteModule(context, code, id) + + return exports + } +} + +function exportAll(exports: any, sourceModule: any) { + // when a module exports itself it causes + // call stack error + if (exports === sourceModule) return + + if ( + isPrimitive(sourceModule) || + Array.isArray(sourceModule) || + sourceModule instanceof Promise + ) + return + + for (const key in sourceModule) { + if (key !== 'default' && key !== '__esModule') { + try { + Object.defineProperty(exports, key, { + enumerable: true, + configurable: true, + get: () => sourceModule[key], + }) + } catch (_err) {} + } + } +} + +/** + * Vite converts `import { } from 'foo'` to `const _ = __vite_ssr_import__('foo')`. + * Top-level imports and dynamic imports work slightly differently in Node.js. + * This function normalizes the differences so it matches prod behaviour. + */ +function analyzeImportedModDifference( + mod: any, + rawId: string, + moduleType: string | undefined, + metadata?: SSRImportMetadata, +) { + // No normalization needed if the user already dynamic imports this module + if (metadata?.isDynamicImport) return + // If file path is ESM, everything should be fine + if (moduleType === 'module') return + + // For non-ESM, named imports is done via static analysis with cjs-module-lexer in Node.js. + // If the user named imports a specifier that can't be analyzed, error. + if (metadata?.importedNames?.length) { + const missingBindings = metadata.importedNames.filter((s) => !(s in mod)) + if (missingBindings.length) { + const lastBinding = missingBindings[missingBindings.length - 1] + // Copied from Node.js + throw new SyntaxError(`\ +[vite] Named export '${lastBinding}' not found. The requested module '${rawId}' 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 '${rawId}'; +const {${missingBindings.join(', ')}} = pkg; +`) + } + } +} + +/** + * Guard invalid named exports only, similar to how Node.js errors for top-level imports. + * But since we transform as dynamic imports, we need to emulate the error manually. + */ +function proxyGuardOnlyEsm( + mod: any, + rawId: string, + metadata?: SSRImportMetadata, +) { + // If the module doesn't import anything explicitly, e.g. `import 'foo'` or + // `import * as foo from 'foo'`, we can skip the proxy guard. + if (!metadata?.importedNames?.length) return mod + + return new Proxy(mod, { + get(mod, prop) { + if (prop !== 'then' && !(prop in mod)) { + throw new SyntaxError( + `[vite] The requested module '${rawId}' does not provide an export named '${prop.toString()}'`, + ) + } + return mod[prop] + }, + }) +} diff --git a/packages/vite/src/node/ssr/runtime/sourcemap/decoder.ts b/packages/vite/src/node/ssr/runtime/sourcemap/decoder.ts new file mode 100644 index 00000000000000..a3abcf9e388f98 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/sourcemap/decoder.ts @@ -0,0 +1,260 @@ +import { posixResolve } from '../utils' + +interface SourceMapLike { + version: number + mappings?: string + names?: string[] + sources?: string[] + sourcesContent?: string[] +} + +type OriginalMapping = { + source: string | null + line: number + column: number + name: string | null +} + +type Needle = { + line: number + column: number +} + +export class DecodedMap { + _encoded: string + _decoded: undefined | number[][][] + _decodedMemo: Stats + url: string + version: number + names: string[] = [] + resolvedSources: string[] + + constructor( + public map: SourceMapLike, + from: string, + ) { + const { mappings, names, sources } = map + this.version = map.version + this.names = names || [] + this._encoded = mappings || '' + this._decodedMemo = memoizedState() + this.url = from + this.resolvedSources = (sources || []).map((s) => + posixResolve(s || '', from), + ) + } +} + +// This is a copy of all methods that we need for decoding a source map from "@jridgewell/trace-mapping" + +function indexOf(mappings: string, index: number) { + const idx = mappings.indexOf(';', index) + return idx === -1 ? mappings.length : idx +} + +const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' +const charToInt = new Uint8Array(128) // z is 122 in ASCII +for (let i = 0; i < chars.length; i++) { + const c = chars.charCodeAt(i) + charToInt[c] = i +} + +function decodeInteger( + mappings: string, + pos: number, + state: Int32Array, + j: number, +) { + let value = 0 + let shift = 0 + let integer = 0 + do { + const c = mappings.charCodeAt(pos++) + integer = charToInt[c] + value |= (integer & 31) << shift + shift += 5 + } while (integer & 32) + const shouldNegate = value & 1 + value >>>= 1 + if (shouldNegate) { + value = -0x80000000 | -value + } + state[j] += value + return pos +} + +const comma = ','.charCodeAt(0) + +function hasMoreVlq(mappings: string, i: number, length: number) { + if (i >= length) return false + return mappings.charCodeAt(i) !== comma +} + +function decode(mappings: string): number[][][] { + const state = new Int32Array(5) + const decoded: number[][][] = [] + let index = 0 + do { + const semi = indexOf(mappings, index) + const line = [] + let sorted = true + let lastCol = 0 + state[0] = 0 + for (let i = index; i < semi; i++) { + let seg + i = decodeInteger(mappings, i, state, 0) // genColumn + const col = state[0] + if (col < lastCol) sorted = false + lastCol = col + if (hasMoreVlq(mappings, i, semi)) { + i = decodeInteger(mappings, i, state, 1) // sourcesIndex + i = decodeInteger(mappings, i, state, 2) // sourceLine + i = decodeInteger(mappings, i, state, 3) // sourceColumn + if (hasMoreVlq(mappings, i, semi)) { + i = decodeInteger(mappings, i, state, 4) // namesIndex + seg = [col, state[1], state[2], state[3], state[4]] + } else { + seg = [col, state[1], state[2], state[3]] + } + } else { + seg = [col] + } + line.push(seg) + } + if (!sorted) line.sort((a, b) => a[0] - b[0]) + decoded.push(line) + index = semi + 1 + } while (index <= mappings.length) + return decoded +} + +const LINE_GTR_ZERO = '`line` must be greater than 0 (lines start at line 1)' +const COL_GTR_EQ_ZERO = + '`column` must be greater than or equal to 0 (columns start at column 0)' + +const COLUMN = 0 +const SOURCES_INDEX = 1 +const SOURCE_LINE = 2 +const SOURCE_COLUMN = 3 +const NAMES_INDEX = 4 + +function OMapping( + source: string | null, + line: number, + column: number, + name: string | null, +): OriginalMapping { + return { source, line, column, name } +} + +function decodedMappings(map: DecodedMap): number[][][] { + return map._decoded || (map._decoded = decode(map._encoded)) +} + +let found = false +function binarySearch( + haystack: number[][], + needle: number, + low: number, + high: number, +) { + while (low <= high) { + const mid = low + ((high - low) >> 1) + const cmp = haystack[mid][COLUMN] - needle + if (cmp === 0) { + found = true + return mid + } + if (cmp < 0) { + low = mid + 1 + } else { + high = mid - 1 + } + } + found = false + return low - 1 +} + +function lowerBound(haystack: number[][], needle: number, index: number) { + for (let i = index - 1; i >= 0; index = i--) { + if (haystack[i][COLUMN] !== needle) break + } + return index +} +interface Stats { + lastKey: number + lastNeedle: number + lastIndex: number +} +function memoizedState(): Stats { + return { + lastKey: -1, + lastNeedle: -1, + lastIndex: -1, + } +} +function memoizedBinarySearch( + haystack: number[][], + needle: number, + state: Stats, + key: number, +) { + const { lastKey, lastNeedle, lastIndex } = state + let low = 0 + let high = haystack.length - 1 + if (key === lastKey) { + if (needle === lastNeedle) { + found = lastIndex !== -1 && haystack[lastIndex][COLUMN] === needle + return lastIndex + } + if (needle >= lastNeedle) { + // lastIndex may be -1 if the previous needle was not found. + low = lastIndex === -1 ? 0 : lastIndex + } else { + high = lastIndex + } + } + state.lastKey = key + state.lastNeedle = needle + return (state.lastIndex = binarySearch(haystack, needle, low, high)) +} + +function traceSegmentInternal( + segments: number[][], + memo: Stats, + line: number, + column: number, +) { + let index = memoizedBinarySearch(segments, column, memo, line) + if (found) { + index = lowerBound(segments, column, index) + } + if (index === -1 || index === segments.length) return -1 + return index +} + +export function getOriginalPosition( + map: DecodedMap, + { line, column }: Needle, +): OriginalMapping | null { + line-- + if (line < 0) throw new Error(LINE_GTR_ZERO) + if (column < 0) throw new Error(COL_GTR_EQ_ZERO) + map._decodedMemo ??= memoizedState() + const decoded = decodedMappings(map) + // It's common for parent source maps to have pointers to lines that have no + // mapping (like a "//# sourceMappingURL=") at the end of the child file. + if (line >= decoded.length) return null + const segments = decoded[line] + const index = traceSegmentInternal(segments, map._decodedMemo, line, column) + if (index === -1) return null + const segment = segments[index] + if (segment.length === 1) return null + const { names, resolvedSources } = map + return OMapping( + resolvedSources[segment[SOURCES_INDEX]], + segment[SOURCE_LINE] + 1, + segment[SOURCE_COLUMN], + segment.length === 5 ? names[segment[NAMES_INDEX]] : null, + ) +} diff --git a/packages/vite/src/node/ssr/runtime/sourcemap/index.ts b/packages/vite/src/node/ssr/runtime/sourcemap/index.ts new file mode 100644 index 00000000000000..8329c27013eb9a --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/sourcemap/index.ts @@ -0,0 +1,26 @@ +import type { ViteRuntime } from '../runtime' +import { interceptStackTrace } from './interceptor' + +export function enableSourceMapSupport(runtime: ViteRuntime): () => void { + if (runtime.options.sourcemapInterceptor === 'node') { + if (typeof process === 'undefined') { + throw new TypeError( + `Cannot use "sourcemapInterceptor: 'node'" because global "process" variable is not available.`, + ) + } + if (typeof process.setSourceMapsEnabled !== 'function') { + throw new TypeError( + `Cannot use "sourcemapInterceptor: 'node'" because "process.setSourceMapsEnabled" function is not available. Please use Node >= 16.6.0.`, + ) + } + const isEnabledAlready = process.sourceMapsEnabled ?? false + process.setSourceMapsEnabled(true) + return () => !isEnabledAlready && process.setSourceMapsEnabled(false) + } + return interceptStackTrace( + runtime, + typeof runtime.options.sourcemapInterceptor === 'object' + ? runtime.options.sourcemapInterceptor + : undefined, + ) +} diff --git a/packages/vite/src/node/ssr/runtime/sourcemap/interceptor.ts b/packages/vite/src/node/ssr/runtime/sourcemap/interceptor.ts new file mode 100644 index 00000000000000..043287b5839168 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/sourcemap/interceptor.ts @@ -0,0 +1,435 @@ +import type { OriginalMapping } from '@jridgewell/trace-mapping' +import type { ViteRuntime } from '../runtime' +import { posixDirname, posixResolve } from '../utils' +import type { ModuleCacheMap } from '../moduleCache' +import { DecodedMap, getOriginalPosition } from './decoder' + +interface RetrieveFileHandler { + (path: string): string | null | undefined | false +} + +interface RetrieveSourceMapHandler { + (path: string): null | { url: string; map: any } +} + +export interface InterceptorOptions { + retrieveFile?: RetrieveFileHandler + retrieveSourceMap?: RetrieveSourceMapHandler +} + +const sourceMapCache: Record = {} +const fileContentsCache: Record = {} + +const moduleGraphs: Set = new Set() +const retrieveFileHandlers = new Set() +const retrieveSourceMapHandlers = new Set() + +const createExecHandlers = any>( + handlers: Set, +) => { + return ((...args: Parameters) => { + for (const handler of handlers) { + const result = handler(...(args as [])) + if (result) return result + } + return null + }) as T +} + +const retrieveFileFromHandlers = createExecHandlers(retrieveFileHandlers) +const retrievSourceMapFromHandlers = createExecHandlers( + retrieveSourceMapHandlers, +) + +let overriden = false +const originalPrepare = Error.prepareStackTrace + +function resetInterceptor(runtime: ViteRuntime, options: InterceptorOptions) { + moduleGraphs.delete(runtime.moduleCache) + if (options.retrieveFile) retrieveFileHandlers.delete(options.retrieveFile) + if (options.retrieveSourceMap) + retrieveSourceMapHandlers.delete(options.retrieveSourceMap) + if (moduleGraphs.size === 0) { + Error.prepareStackTrace = originalPrepare + overriden = false + } +} + +export function interceptStackTrace( + runtime: ViteRuntime, + options: InterceptorOptions = {}, +): () => void { + if (!overriden) { + Error.prepareStackTrace = prepareStackTrace + overriden = true + } + moduleGraphs.add(runtime.moduleCache) + if (options.retrieveFile) retrieveFileHandlers.add(options.retrieveFile) + if (options.retrieveSourceMap) + retrieveSourceMapHandlers.add(options.retrieveSourceMap) + return () => resetInterceptor(runtime, options) +} + +interface CallSite extends NodeJS.CallSite { + getScriptNameOrSourceURL(): string +} + +interface State { + nextPosition: null | OriginalMapping + curPosition: null | OriginalMapping +} + +interface CachedMapEntry { + url: string | null + map: DecodedMap | null + vite?: boolean +} + +// Support URLs relative to a directory, but be careful about a protocol prefix +function supportRelativeURL(file: string, url: string) { + if (!file) return url + const dir = posixDirname(file.replace(/\\/g, '/')) + const match = /^\w+:\/\/[^/]*/.exec(dir) + let protocol = match ? match[0] : '' + const startPath = dir.slice(protocol.length) + if (protocol && /^\/\w:/.test(startPath)) { + // handle file:///C:/ paths + protocol += '/' + return ( + protocol + + posixResolve(dir.slice(protocol.length), url).replace(/\\/g, '/') + ) + } + return protocol + posixResolve(dir.slice(protocol.length), url) +} + +function getRuntimeSourceMap(position: OriginalMapping): CachedMapEntry | null { + for (const moduleCache of moduleGraphs) { + const sourceMap = moduleCache.getSourceMap(position.source as string) + if (sourceMap) { + return { + url: position.source, + map: sourceMap, + vite: true, + } + } + } + return null +} + +function retrieveFile(path: string): string | null | undefined | false { + if (path in fileContentsCache) return fileContentsCache[path] + const content = retrieveFileFromHandlers(path) + if (typeof content === 'string') { + fileContentsCache[path] = content + return content + } + return null +} + +function retrieveSourceMapURL(source: string) { + // Get the URL of the source map + const fileData = retrieveFile(source) + if (!fileData) return null + const re = + /\/\/[@#]\s*sourceMappingURL=([^\s'"]+)\s*$|\/\*[@#]\s*sourceMappingURL=[^\s*'"]+\s*\*\/\s*$/gm + // Keep executing the search to find the *last* sourceMappingURL to avoid + // picking up sourceMappingURLs from comments, strings, etc. + let lastMatch, match + + while ((match = re.exec(fileData))) lastMatch = match + if (!lastMatch) return null + return lastMatch[1] +} + +const reSourceMap = /^data:application\/json[^,]+base64,/ + +function retrieveSourceMap(source: string) { + const urlAndMap = retrievSourceMapFromHandlers(source) + if (urlAndMap) return urlAndMap + + let sourceMappingURL = retrieveSourceMapURL(source) + if (!sourceMappingURL) return null + + // Read the contents of the source map + let sourceMapData + if (reSourceMap.test(sourceMappingURL)) { + // Support source map URL as a data url + const rawData = sourceMappingURL.slice(sourceMappingURL.indexOf(',') + 1) + sourceMapData = Buffer.from(rawData, 'base64').toString() + sourceMappingURL = source + } else { + // Support source map URLs relative to the source URL + sourceMappingURL = supportRelativeURL(source, sourceMappingURL) + sourceMapData = retrieveFile(sourceMappingURL) + } + + if (!sourceMapData) return null + + return { + url: sourceMappingURL, + map: sourceMapData, + } +} + +function mapSourcePosition(position: OriginalMapping) { + if (!position.source) return position + let sourceMap = getRuntimeSourceMap(position) + if (!sourceMap) sourceMap = sourceMapCache[position.source] + if (!sourceMap) { + // Call the (overrideable) retrieveSourceMap function to get the source map. + const urlAndMap = retrieveSourceMap(position.source) + if (urlAndMap && urlAndMap.map) { + const url = urlAndMap.url + sourceMap = sourceMapCache[position.source] = { + url, + map: new DecodedMap( + typeof urlAndMap.map === 'string' + ? JSON.parse(urlAndMap.map) + : urlAndMap.map, + url, + ), + } + + const contents = sourceMap.map?.map.sourcesContent + // Load all sources stored inline with the source map into the file cache + // to pretend like they are already loaded. They may not exist on disk. + if (sourceMap.map && contents) { + sourceMap.map.resolvedSources.forEach((source, i) => { + const content = contents[i] + if (content && source && url) { + const contentUrl = supportRelativeURL(url, source) + fileContentsCache[contentUrl] = content + } + }) + } + } else { + sourceMap = sourceMapCache[position.source] = { + url: null, + map: null, + } + } + } + + // Resolve the source URL relative to the URL of the source map + if (sourceMap && sourceMap.map && sourceMap.url) { + const originalPosition = getOriginalPosition(sourceMap.map, position) + + // Only return the original position if a matching line was found. If no + // matching line is found then we return position instead, which will cause + // the stack trace to print the path and line for the compiled file. It is + // better to give a precise location in the compiled file than a vague + // location in the original file. + if (originalPosition && originalPosition.source != null) { + originalPosition.source = supportRelativeURL( + sourceMap.url, + originalPosition.source, + ) + if (sourceMap.vite) { + // @ts-expect-error vite is not defined + originalPosition._vite = true + } + return originalPosition + } + } + + return position +} + +// Parses code generated by FormatEvalOrigin(), a function inside V8: +// https://code.google.com/p/v8/source/browse/trunk/src/messages.js +function mapEvalOrigin(origin: string): string { + // Most eval() calls are in this format + let match = /^eval at ([^(]+) \((.+):(\d+):(\d+)\)$/.exec(origin) + if (match) { + const position = mapSourcePosition({ + name: null, + source: match[2], + line: +match[3], + column: +match[4] - 1, + }) + return `eval at ${match[1]} (${position.source}:${position.line}:${position.column + 1})` + } + + // Parse nested eval() calls using recursion + match = /^eval at ([^(]+) \((.+)\)$/.exec(origin) + if (match) return `eval at ${match[1]} (${mapEvalOrigin(match[2])})` + + // Make sure we still return useful information if we didn't find anything + return origin +} + +// This is copied almost verbatim from the V8 source code at +// https://code.google.com/p/v8/source/browse/trunk/src/messages.js. The +// implementation of wrapCallSite() used to just forward to the actual source +// code of CallSite.prototype.toString but unfortunately a new release of V8 +// did something to the prototype chain and broke the shim. The only fix I +// could find was copy/paste. +function CallSiteToString(this: CallSite) { + let fileName + let fileLocation = '' + if (this.isNative()) { + fileLocation = 'native' + } else { + fileName = this.getScriptNameOrSourceURL() + if (!fileName && this.isEval()) { + fileLocation = this.getEvalOrigin() as string + fileLocation += ', ' // Expecting source position to follow. + } + + if (fileName) { + fileLocation += fileName + } else { + // Source code does not originate from a file and is not native, but we + // can still get the source position inside the source string, e.g. in + // an eval string. + fileLocation += '' + } + const lineNumber = this.getLineNumber() + if (lineNumber != null) { + fileLocation += `:${lineNumber}` + const columnNumber = this.getColumnNumber() + if (columnNumber) fileLocation += `:${columnNumber}` + } + } + + let line = '' + const functionName = this.getFunctionName() + let addSuffix = true + const isConstructor = this.isConstructor() + const isMethodCall = !(this.isToplevel() || isConstructor) + if (isMethodCall) { + let typeName = this.getTypeName() + // Fixes shim to be backward compatable with Node v0 to v4 + if (typeName === '[object Object]') typeName = 'null' + + const methodName = this.getMethodName() + if (functionName) { + if (typeName && functionName.indexOf(typeName) !== 0) + line += `${typeName}.` + + line += functionName + if ( + methodName && + functionName.indexOf(`.${methodName}`) !== + functionName.length - methodName.length - 1 + ) + line += ` [as ${methodName}]` + } else { + line += `${typeName}.${methodName || ''}` + } + } else if (isConstructor) { + line += `new ${functionName || ''}` + } else if (functionName) { + line += functionName + } else { + line += fileLocation + addSuffix = false + } + if (addSuffix) line += ` (${fileLocation})` + + return line +} + +function cloneCallSite(frame: CallSite) { + const object = {} as CallSite + Object.getOwnPropertyNames(Object.getPrototypeOf(frame)).forEach((name) => { + const key = name as keyof CallSite + // @ts-expect-error difficult to type + object[key] = /^(?:is|get)/.test(name) + ? function () { + return frame[key].call(frame) + } + : frame[key] + }) + object.toString = CallSiteToString + return object +} + +function wrapCallSite(frame: CallSite, state: State) { + // provides interface backward compatibility + if (state === undefined) state = { nextPosition: null, curPosition: null } + + if (frame.isNative()) { + state.curPosition = null + return frame + } + + // Most call sites will return the source file from getFileName(), but code + // passed to eval() ending in "//# sourceURL=..." will return the source file + // from getScriptNameOrSourceURL() instead + const source = frame.getFileName() || frame.getScriptNameOrSourceURL() + if (source) { + const line = frame.getLineNumber() as number + let column = (frame.getColumnNumber() as number) - 1 + + // Fix position in Node where some (internal) code is prepended. + // See https://github.com/evanw/node-source-map-support/issues/36 + // Header removed in node at ^10.16 || >=11.11.0 + // v11 is not an LTS candidate, we can just test the one version with it. + // Test node versions for: 10.16-19, 10.20+, 12-19, 20-99, 100+, or 11.11 + const headerLength = 62 + if (line === 1 && column > headerLength && !frame.isEval()) + column -= headerLength + + const position = mapSourcePosition({ + name: null, + source, + line, + column, + }) + state.curPosition = position + frame = cloneCallSite(frame) + const originalFunctionName = frame.getFunctionName + frame.getFunctionName = function () { + const name = (() => { + if (state.nextPosition == null) return originalFunctionName() + + return state.nextPosition.name || originalFunctionName() + })() + return name === 'eval' && '_vite' in position ? null : name + } + frame.getFileName = function () { + return position.source ?? undefined + } + frame.getLineNumber = function () { + return position.line + } + frame.getColumnNumber = function () { + return position.column + 1 + } + frame.getScriptNameOrSourceURL = function () { + return position.source as string + } + return frame + } + + // Code called using eval() needs special handling + let origin = frame.isEval() && frame.getEvalOrigin() + if (origin) { + origin = mapEvalOrigin(origin) + frame = cloneCallSite(frame) + frame.getEvalOrigin = function () { + return origin || undefined + } + return frame + } + + // If we get here then we were unable to change the source position + return frame +} + +function prepareStackTrace(error: Error, stack: CallSite[]) { + const name = error.name || 'Error' + const message = error.message || '' + const errorString = `${name}: ${message}` + + const state = { nextPosition: null, curPosition: null } + const processedStack = [] + for (let i = stack.length - 1; i >= 0; i--) { + processedStack.push(`\n at ${wrapCallSite(stack[i], state)}`) + state.nextPosition = state.curPosition + } + state.curPosition = state.nextPosition = null + return errorString + processedStack.reverse().join('') +} diff --git a/packages/vite/src/node/ssr/runtime/types.ts b/packages/vite/src/node/ssr/runtime/types.ts new file mode 100644 index 00000000000000..da4ae441282e53 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/types.ts @@ -0,0 +1,185 @@ +import type { ViteHotContext } from 'types/hot' +import type { HMRPayload } from 'types/hmrPayload' +import type { HMRConnection, HMRLogger } from '../../../shared/hmr' +import type { ModuleCacheMap } from './moduleCache' +import type { + ssrDynamicImportKey, + ssrExportAllKey, + ssrImportKey, + ssrImportMetaKey, + ssrModuleExportsKey, +} from './constants' +import type { DecodedMap } from './sourcemap/decoder' +import type { InterceptorOptions } from './sourcemap/interceptor' + +export interface DefineImportMetadata { + /** + * Imported names before being transformed to `ssrImportKey` + * + * import foo, { bar as baz, qux } from 'hello' + * => ['default', 'bar', 'qux'] + * + * import * as namespace from 'world + * => undefined + */ + importedNames?: string[] +} + +export interface HMRRuntimeConnection extends HMRConnection { + /** + * Configure how HMR is handled when this connection triggers an update. + * This method expects that connection will start listening for HMR updates and call this callback when it's received. + */ + onUpdate(callback: (payload: HMRPayload) => void): void +} + +export interface SSRImportMetadata extends DefineImportMetadata { + isDynamicImport?: boolean + entrypoint?: boolean +} + +export interface ViteRuntimeImportMeta extends ImportMeta { + url: string + env: ImportMetaEnv + hot?: ViteHotContext + [key: string]: any +} + +export interface ViteRuntimeModuleContext { + [ssrModuleExportsKey]: Record + [ssrImportKey]: (id: string, metadata?: DefineImportMetadata) => Promise + [ssrDynamicImportKey]: ( + id: string, + options?: ImportCallOptions, + ) => Promise + [ssrExportAllKey]: (obj: any) => void + [ssrImportMetaKey]: ViteRuntimeImportMeta +} + +export interface ViteModuleRunner { + /** + * Run code that was transformed by Vite. + * @param context Function context + * @param code Transformed code + * @param id ID that was used to fetch the module + */ + runViteModule( + context: ViteRuntimeModuleContext, + code: string, + id: string, + ): Promise + /** + * Run externalized module. + * @param file File URL to the external module + */ + runExternalModule(file: string): Promise +} + +export interface ModuleCache { + promise?: Promise + exports?: any + evaluated?: boolean + map?: DecodedMap + meta?: FetchResult + /** + * Module ids that imports this module + */ + importers?: Set + imports?: Set +} + +export type FetchResult = ExternalFetchResult | ViteFetchResult + +export interface ExternalFetchResult { + /** + * The path to the externalized module starting with file://, + * by default this will be imported via a dynamic "import" + * instead of being transformed by vite and loaded with vite runtime + */ + externalize: string + /** + * Type of the module. Will be used to determine if import statement is correct. + * For example, if Vite needs to throw an error if variable is not actually exported + */ + type?: 'module' | 'commonjs' | 'builtin' | 'network' +} + +export interface ViteFetchResult { + /** + * Code that will be evaluated by vite runtime + * by default this will be wrapped in an async function + */ + code: string + /** + * File path of the module on disk. + * This will be resolved as import.meta.url/filename + */ + file: string | null +} + +export type ResolvedResult = (ExternalFetchResult | ViteFetchResult) & { + id: string +} + +/** + * @experimental + */ +export type FetchFunction = ( + id: string, + importer?: string, +) => Promise + +export interface ViteRuntimeOptions { + /** + * Root of the project + */ + root: string + /** + * A method to get the information about the module. + * For SSR, Vite exposes `server.ssrFetchModule` function that you can use here. + * For other runtime use cases, Vite also exposes `fetchModule` from its main entry point. + */ + fetchModule: FetchFunction + /** + * Custom environment variables available on `import.meta.env`. This doesn't modify the actual `process.env`. + */ + environmentVariables?: Record + /** + * Configure how source maps are resolved. Prefers `node` if `process.setSourceMapsEnabled` is available. + * Otherwise it will use `prepareStackTrace` by default which overrides `Error.prepareStackTrace` method. + * You can provide an object to configure how file contents and source maps are resolved for files that were not processed by Vite. + */ + sourcemapInterceptor?: + | false + | 'node' + | 'prepareStackTrace' + | InterceptorOptions + /** + * Disable HMR or configure HMR options. + */ + hmr?: + | false + | { + /** + * Configure how HMR communicates between the client and the server. + */ + connection: HMRRuntimeConnection + /** + * Configure HMR logger. + */ + logger?: false | HMRLogger + } + /** + * Custom module cache. If not provided, creates a separate module cache for each ViteRuntime instance. + */ + moduleCache?: ModuleCacheMap +} + +export interface ImportMetaEnv { + [key: string]: any + BASE_URL: string + MODE: string + DEV: boolean + PROD: boolean + SSR: boolean +} diff --git a/packages/vite/src/node/ssr/runtime/utils.ts b/packages/vite/src/node/ssr/runtime/utils.ts new file mode 100644 index 00000000000000..f2eca533b350fe --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/utils.ts @@ -0,0 +1,217 @@ +export const isWindows = + typeof process !== 'undefined' && process.platform === 'win32' + +export const decodeBase64 = + typeof atob !== 'undefined' + ? atob + : (str: string) => Buffer.from(str, 'base64').toString('utf-8') + +// currently we copy this from '../../constants' - maybe we can inline it somewhow? +const NULL_BYTE_PLACEHOLDER = `__x00__` +const VALID_ID_PREFIX = `/@id/` + +export function wrapId(id: string): string { + return id.startsWith(VALID_ID_PREFIX) + ? id + : VALID_ID_PREFIX + id.replace('\0', NULL_BYTE_PLACEHOLDER) +} + +export function unwrapId(id: string): string { + return id.startsWith(VALID_ID_PREFIX) + ? id.slice(VALID_ID_PREFIX.length).replace(NULL_BYTE_PLACEHOLDER, '\0') + : id +} + +const windowsSlashRE = /\\/g +export function slash(p: string): string { + return p.replace(windowsSlashRE, '/') +} + +const postfixRE = /[?#].*$/s +export function cleanUrl(url: string): string { + return url.replace(postfixRE, '') +} + +export function isPrimitive(value: unknown): boolean { + return !value || (typeof value !== 'object' && typeof value !== 'function') +} + +const CHAR_FORWARD_SLASH = 47 +const CHAR_BACKWARD_SLASH = 92 + +const percentRegEx = /%/g +const backslashRegEx = /\\/g +const newlineRegEx = /\n/g +const carriageReturnRegEx = /\r/g +const tabRegEx = /\t/g +const questionRegex = /\?/g +const hashRegex = /#/g + +function encodePathChars(filepath: string) { + if (filepath.indexOf('%') !== -1) + filepath = filepath.replace(percentRegEx, '%25') + // In posix, backslash is a valid character in paths: + if (!isWindows && filepath.indexOf('\\') !== -1) + filepath = filepath.replace(backslashRegEx, '%5C') + if (filepath.indexOf('\n') !== -1) + filepath = filepath.replace(newlineRegEx, '%0A') + if (filepath.indexOf('\r') !== -1) + filepath = filepath.replace(carriageReturnRegEx, '%0D') + if (filepath.indexOf('\t') !== -1) + filepath = filepath.replace(tabRegEx, '%09') + return filepath +} + +export function posixPathToFileHref(posixPath: string): string { + let resolved = posixResolve(posixPath) + // path.resolve strips trailing slashes so we must add them back + const filePathLast = posixPath.charCodeAt(posixPath.length - 1) + if ( + (filePathLast === CHAR_FORWARD_SLASH || + (isWindows && filePathLast === CHAR_BACKWARD_SLASH)) && + resolved[resolved.length - 1] !== '/' + ) + resolved += '/' + + // Call encodePathChars first to avoid encoding % again for ? and #. + resolved = encodePathChars(resolved) + + // Question and hash character should be included in pathname. + // Therefore, encoding is required to eliminate parsing them in different states. + // This is done as an optimization to not creating a URL instance and + // later triggering pathname setter, which impacts performance + if (resolved.indexOf('?') !== -1) + resolved = resolved.replace(questionRegex, '%3F') + if (resolved.indexOf('#') !== -1) + resolved = resolved.replace(hashRegex, '%23') + return new URL(`file://${resolved}`).href +} + +export function posixDirname(filepath: string): string { + const normalizedPath = filepath.endsWith('/') + ? filepath.substring(0, filepath.length - 1) + : filepath + return normalizedPath.substring(0, normalizedPath.lastIndexOf('/')) || '/' +} + +export function toWindowsPath(path: string): string { + return path.replace(/\//g, '\\') +} + +// inlined from pathe to support environments without access to node:path +function cwd(): string { + if (typeof process !== 'undefined' && typeof process.cwd === 'function') { + return slash(process.cwd()) + } + return '/' +} + +export function posixResolve(...segments: string[]): string { + // Normalize windows arguments + segments = segments.map((argument) => slash(argument)) + + let resolvedPath = '' + let resolvedAbsolute = false + + for ( + let index = segments.length - 1; + index >= -1 && !resolvedAbsolute; + index-- + ) { + const path = index >= 0 ? segments[index] : cwd() + + // Skip empty entries + if (!path || path.length === 0) { + continue + } + + resolvedPath = `${path}/${resolvedPath}` + resolvedAbsolute = isAbsolute(path) + } + + // At this point the path should be resolved to a full absolute path, but + // handle relative paths to be safe (might happen when process.cwd() fails) + + // Normalize the path + resolvedPath = normalizeString(resolvedPath, !resolvedAbsolute) + + if (resolvedAbsolute && !isAbsolute(resolvedPath)) { + return `/${resolvedPath}` + } + + return resolvedPath.length > 0 ? resolvedPath : '.' +} + +const _IS_ABSOLUTE_RE = /^[/\\](?![/\\])|^[/\\]{2}(?!\.)|^[A-Za-z]:[/\\]/ + +function isAbsolute(p: string): boolean { + return _IS_ABSOLUTE_RE.test(p) +} + +// Resolves . and .. elements in a path with directory names +export function normalizeString(path: string, allowAboveRoot: boolean): string { + let res = '' + let lastSegmentLength = 0 + let lastSlash = -1 + let dots = 0 + let char: string | null = null + for (let index = 0; index <= path.length; ++index) { + if (index < path.length) { + char = path[index] + } else if (char === '/') { + break + } else { + char = '/' + } + if (char === '/') { + if (lastSlash === index - 1 || dots === 1) { + // NOOP + } else if (dots === 2) { + if ( + res.length < 2 || + lastSegmentLength !== 2 || + res[res.length - 1] !== '.' || + res[res.length - 2] !== '.' + ) { + if (res.length > 2) { + const lastSlashIndex = res.lastIndexOf('/') + if (lastSlashIndex === -1) { + res = '' + lastSegmentLength = 0 + } else { + res = res.slice(0, lastSlashIndex) + lastSegmentLength = res.length - 1 - res.lastIndexOf('/') + } + lastSlash = index + dots = 0 + continue + } else if (res.length > 0) { + res = '' + lastSegmentLength = 0 + lastSlash = index + dots = 0 + continue + } + } + if (allowAboveRoot) { + res += res.length > 0 ? '/..' : '..' + lastSegmentLength = 2 + } + } else { + if (res.length > 0) { + res += `/${path.slice(lastSlash + 1, index)}` + } else { + res = path.slice(lastSlash + 1, index) + } + lastSegmentLength = index - lastSlash - 1 + } + lastSlash = index + dots = 0 + } else if (char === '.' && dots !== -1) { + ++dots + } else { + dots = -1 + } + } + return res +} diff --git a/packages/vite/src/node/ssr/ssrFetchModule.ts b/packages/vite/src/node/ssr/ssrFetchModule.ts new file mode 100644 index 00000000000000..27ffe25085305c --- /dev/null +++ b/packages/vite/src/node/ssr/ssrFetchModule.ts @@ -0,0 +1,28 @@ +import type { ViteDevServer } from '../server' +import { fetchModule } from './fetchModule' +import type { FetchResult } from './runtime/types' + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const AsyncFunction = async function () {}.constructor as typeof Function +const fnDeclarationLineCount = (() => { + const body = '/*code*/' + const source = new AsyncFunction('a', 'b', body).toString() + return source.slice(0, source.indexOf(body)).split('\n').length - 1 +})() + +export function ssrFetchModule( + server: ViteDevServer, + id: string, + importer?: string, +): Promise { + return fetchModule(server, id, importer, { + processSourceMap(map) { + // this assumes that "new AsyncFunction" is used to create the module + return Object.assign({}, map, { + // currently we need to offset the line + // https://github.com/nodejs/node/issues/43047#issuecomment-1180632750 + mappings: ';'.repeat(fnDeclarationLineCount) + map.mappings, + }) + }, + }) +} diff --git a/packages/vite/src/shared/hmr.ts b/packages/vite/src/shared/hmr.ts index f313a51a6a2ea6..05f2f742c4f247 100644 --- a/packages/vite/src/shared/hmr.ts +++ b/packages/vite/src/shared/hmr.ts @@ -220,6 +220,15 @@ export class HMRClient { } } + public clear(): void { + this.hotModulesMap.clear() + this.disposeMap.clear() + this.pruneMap.clear() + this.dataMap.clear() + this.customListenersMap.clear() + this.ctxToListenersMap.clear() + } + // After an HMR update, some modules are no longer imported on the page // but they may have left behind side effects that need to be cleaned up // (.e.g style injections) @@ -264,7 +273,7 @@ export class HMRClient { } } - public async fetchUpdate(update: Update): Promise<(() => void) | undefined> { + private async fetchUpdate(update: Update): Promise<(() => void) | undefined> { const { path, acceptedPath } = update const mod = this.hotModulesMap.get(path) if (!mod) { diff --git a/packages/vite/types/hmrPayload.d.ts b/packages/vite/types/hmrPayload.d.ts index f3ca652e4acd7b..275fbfc9ec744b 100644 --- a/packages/vite/types/hmrPayload.d.ts +++ b/packages/vite/types/hmrPayload.d.ts @@ -24,6 +24,8 @@ export interface Update { explicitImportRequired?: boolean /** @internal */ isWithinCircularImport?: boolean + /** @internal */ + ssrInvalidates?: string[] } export interface PrunePayload { diff --git a/playground/hmr-ssr/__tests__/hmr.spec.ts b/playground/hmr-ssr/__tests__/hmr.spec.ts new file mode 100644 index 00000000000000..d4b2d65abd2b39 --- /dev/null +++ b/playground/hmr-ssr/__tests__/hmr.spec.ts @@ -0,0 +1,1085 @@ +import fs from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, posix, resolve } from 'node:path' +import EventEmitter from 'node:events' +import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest' +import type { InlineConfig, Logger, ViteDevServer } from 'vite' +import { createServer, createViteRuntime } from 'vite' +import type { ViteRuntime } from 'vite/runtime' +import type { RollupError } from 'rollup' +import { page, promiseWithResolvers, slash, untilUpdated } from '~utils' + +let server: ViteDevServer +const clientLogs: string[] = [] +const serverLogs: string[] = [] +let runtime: ViteRuntime + +const logsEmitter = new EventEmitter() + +const originalFiles = new Map() +const createdFiles = new Set() +const deletedFiles = new Map() +afterAll(async () => { + await server.close() + + originalFiles.forEach((content, file) => { + fs.writeFileSync(file, content, 'utf-8') + }) + createdFiles.forEach((file) => { + if (fs.existsSync(file)) fs.unlinkSync(file) + }) + deletedFiles.forEach((file) => { + fs.writeFileSync(file, deletedFiles.get(file)!, 'utf-8') + }) + originalFiles.clear() + createdFiles.clear() + deletedFiles.clear() +}) + +const hmr = (key: string) => (globalThis.__HMR__[key] as string) || '' + +const updated = (file: string, via?: string) => { + if (via) { + return `[vite] hot updated: ${file} via ${via}` + } + return `[vite] hot updated: ${file}` +} + +describe('hmr works correctly', () => { + beforeAll(async () => { + await setupViteRuntime('/hmr.ts') + }) + + test('should connect', async () => { + expect(clientLogs).toContain('[vite] connected.') + }) + + test('self accept', async () => { + const el = () => hmr('.app') + await untilConsoleLogAfter( + () => + editFile('hmr.ts', (code) => + code.replace('const foo = 1', 'const foo = 2'), + ), + [ + '>>> vite:beforeUpdate -- update', + 'foo was: 1', + '(self-accepting 1) foo is now: 2', + '(self-accepting 2) foo is now: 2', + updated('/hmr.ts'), + '>>> vite:afterUpdate -- update', + ], + true, + ) + await untilUpdated(() => el(), '2') + + await untilConsoleLogAfter( + () => + editFile('hmr.ts', (code) => + code.replace('const foo = 2', 'const foo = 3'), + ), + [ + '>>> vite:beforeUpdate -- update', + 'foo was: 2', + '(self-accepting 1) foo is now: 3', + '(self-accepting 2) foo is now: 3', + updated('/hmr.ts'), + '>>> vite:afterUpdate -- update', + ], + true, + ) + await untilUpdated(() => el(), '3') + }) + + test('accept dep', async () => { + const el = () => hmr('.dep') + await untilConsoleLogAfter( + () => + editFile('hmrDep.js', (code) => + code.replace('const foo = 1', 'const foo = 2'), + ), + [ + '>>> vite:beforeUpdate -- update', + '(dep) foo was: 1', + '(dep) foo from dispose: 1', + '(single dep) foo is now: 2', + '(single dep) nested foo is now: 1', + '(multi deps) foo is now: 2', + '(multi deps) nested foo is now: 1', + updated('/hmrDep.js', '/hmr.ts'), + '>>> vite:afterUpdate -- update', + ], + true, + ) + await untilUpdated(() => el(), '2') + + await untilConsoleLogAfter( + () => + editFile('hmrDep.js', (code) => + code.replace('const foo = 2', 'const foo = 3'), + ), + [ + '>>> vite:beforeUpdate -- update', + '(dep) foo was: 2', + '(dep) foo from dispose: 2', + '(single dep) foo is now: 3', + '(single dep) nested foo is now: 1', + '(multi deps) foo is now: 3', + '(multi deps) nested foo is now: 1', + updated('/hmrDep.js', '/hmr.ts'), + '>>> vite:afterUpdate -- update', + ], + true, + ) + await untilUpdated(() => el(), '3') + }) + + test('nested dep propagation', async () => { + const el = () => hmr('.nested') + await untilConsoleLogAfter( + () => + editFile('hmrNestedDep.js', (code) => + code.replace('const foo = 1', 'const foo = 2'), + ), + [ + '>>> vite:beforeUpdate -- update', + '(dep) foo was: 3', + '(dep) foo from dispose: 3', + '(single dep) foo is now: 3', + '(single dep) nested foo is now: 2', + '(multi deps) foo is now: 3', + '(multi deps) nested foo is now: 2', + updated('/hmrDep.js', '/hmr.ts'), + '>>> vite:afterUpdate -- update', + ], + true, + ) + await untilUpdated(() => el(), '2') + + await untilConsoleLogAfter( + () => + editFile('hmrNestedDep.js', (code) => + code.replace('const foo = 2', 'const foo = 3'), + ), + [ + '>>> vite:beforeUpdate -- update', + '(dep) foo was: 3', + '(dep) foo from dispose: 3', + '(single dep) foo is now: 3', + '(single dep) nested foo is now: 3', + '(multi deps) foo is now: 3', + '(multi deps) nested foo is now: 3', + updated('/hmrDep.js', '/hmr.ts'), + '>>> vite:afterUpdate -- update', + ], + true, + ) + await untilUpdated(() => el(), '3') + }) + + test('invalidate', async () => { + const el = () => hmr('.invalidation') + await untilConsoleLogAfter( + () => + editFile('invalidation/child.js', (code) => + code.replace('child', 'child updated'), + ), + [ + '>>> vite:beforeUpdate -- update', + `>>> vite:invalidate -- /invalidation/child.js`, + '[vite] invalidate /invalidation/child.js', + updated('/invalidation/child.js'), + '>>> vite:afterUpdate -- update', + '>>> vite:beforeUpdate -- update', + '(invalidation) parent is executing', + updated('/invalidation/parent.js'), + '>>> vite:afterUpdate -- update', + ], + true, + ) + await untilUpdated(() => el(), 'child updated') + }) + + test('soft invalidate', async () => { + const el = () => hmr('.soft-invalidation') + expect(el()).toBe( + 'soft-invalidation/index.js is transformed 1 times. child is bar', + ) + editFile('soft-invalidation/child.js', (code) => + code.replace('bar', 'updated'), + ) + await untilUpdated( + () => el(), + 'soft-invalidation/index.js is transformed 1 times. child is updated', + ) + }) + + test('plugin hmr handler + custom event', async () => { + const el = () => hmr('.custom') + editFile('customFile.js', (code) => code.replace('custom', 'edited')) + await untilUpdated(() => el(), 'edited') + }) + + test('plugin hmr remove custom events', async () => { + const el = () => hmr('.toRemove') + editFile('customFile.js', (code) => code.replace('custom', 'edited')) + await untilUpdated(() => el(), 'edited') + editFile('customFile.js', (code) => code.replace('edited', 'custom')) + await untilUpdated(() => el(), 'edited') + }) + + test('plugin client-server communication', async () => { + const el = () => hmr('.custom-communication') + await untilUpdated(() => el(), '3') + }) + + test('queries are correctly resolved', async () => { + const query1 = () => hmr('query1') + const query2 = () => hmr('query2') + + expect(query1()).toBe('query1') + expect(query2()).toBe('query2') + + editFile('queries/multi-query.js', (code) => code + '//comment') + await untilUpdated(() => query1(), '//commentquery1') + await untilUpdated(() => query2(), '//commentquery2') + }) + + // TODO + // test.skipIf(hasWindowsUnicodeFsBug)('full-reload encodeURI path', async () => { + // await page.goto( + // viteTestUrl + '/unicode-path/中文-にほんご-한글-🌕🌖🌗/index.html', + // ) + // const el = () => hmr('#app') + // expect(await el()).toBe('title') + // editFile('unicode-path/中文-にほんご-한글-🌕🌖🌗/index.html', (code) => + // code.replace('title', 'title2'), + // ) + // await page.waitForEvent('load') + // await untilUpdated(async () => el(), 'title2') + // }) + + // TODO: css is not supported in SSR (yet?) + // test('CSS update preserves query params', async () => { + // await page.goto(viteTestUrl) + + // editFile('global.css', (code) => code.replace('white', 'tomato')) + + // const elprev = () => hmr('.css-prev') + // const elpost = () => hmr('.css-post') + // await untilUpdated(() => elprev(), 'param=required') + // await untilUpdated(() => elpost(), 'param=required') + // const textprev = elprev() + // const textpost = elpost() + // expect(textprev).not.toBe(textpost) + // expect(textprev).not.toMatch('direct') + // expect(textpost).not.toMatch('direct') + // }) + + // test('it swaps out link tags', async () => { + // await page.goto(viteTestUrl) + + // editFile('global.css', (code) => code.replace('white', 'tomato')) + + // let el = () => hmr('.link-tag-added') + // await untilUpdated(() => el(), 'yes') + + // el = () => hmr('.link-tag-removed') + // await untilUpdated(() => el(), 'yes') + + // expect((await page.$$('link')).length).toBe(1) + // }) + + // #2255 + test('importing reloaded', async () => { + const outputEle = () => hmr('.importing-reloaded') + + await untilUpdated(outputEle, ['a.js: a0', 'b.js: b0,a0'].join('
')) + + editFile('importing-updated/a.js', (code) => code.replace("'a0'", "'a1'")) + await untilUpdated( + outputEle, + ['a.js: a0', 'b.js: b0,a0', 'a.js: a1'].join('
'), + ) + + editFile('importing-updated/b.js', (code) => + code.replace('`b0,${a}`', '`b1,${a}`'), + ) + // note that "a.js: a1" should not happen twice after "b.js: b0,a0'" + await untilUpdated( + outputEle, + ['a.js: a0', 'b.js: b0,a0', 'a.js: a1', 'b.js: b1,a1'].join('
'), + ) + }) +}) + +describe('acceptExports', () => { + const HOT_UPDATED = /hot updated/ + const CONNECTED = /connected/ + const PROGRAM_RELOAD = /program reload/ + + const baseDir = 'accept-exports' + + describe('when all used exports are accepted', () => { + const testDir = baseDir + '/main-accepted' + + const fileName = 'target.ts' + const file = `${testDir}/${fileName}` + const url = `/${file}` + + let dep = 'dep0' + + beforeAll(async () => { + await untilConsoleLogAfter( + () => setupViteRuntime(`/${testDir}/index`), + [CONNECTED, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<<<<< A0 B0 D0 ; ${dep}`) + expect(logs).toContain('>>>>>> A0 D0') + }, + ) + }) + + test('the callback is called with the new version the module', async () => { + const callbackFile = `${testDir}/callback.ts` + const callbackUrl = `/${callbackFile}` + + await untilConsoleLogAfter( + () => { + editFile(callbackFile, (code) => + code + .replace("x = 'X'", "x = 'Y'") + .replace('reloaded >>>', 'reloaded (2) >>>'), + ) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual([ + 'reloaded >>> Y', + `[vite] hot updated: ${callbackUrl}`, + ]) + }, + ) + + await untilConsoleLogAfter( + () => { + editFile(callbackFile, (code) => code.replace("x = 'Y'", "x = 'Z'")) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual([ + 'reloaded (2) >>> Z', + `[vite] hot updated: ${callbackUrl}`, + ]) + }, + ) + }) + + test('stops HMR bubble on dependency change', async () => { + const depFileName = 'dep.ts' + const depFile = `${testDir}/${depFileName}` + + await untilConsoleLogAfter( + () => { + editFile(depFile, (code) => code.replace('dep0', (dep = 'dep1'))) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual([ + `<<<<<< A0 B0 D0 ; ${dep}`, + `[vite] hot updated: ${url}`, + ]) + }, + ) + }) + + test('accepts itself and refreshes on change', async () => { + await untilConsoleLogAfter( + () => { + editFile(file, (code) => code.replace(/(\b[A-Z])0/g, '$11')) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual([ + `<<<<<< A1 B1 D1 ; ${dep}`, + `[vite] hot updated: ${url}`, + ]) + }, + ) + }) + + test('accepts itself and refreshes on 2nd change', async () => { + await untilConsoleLogAfter( + () => { + editFile(file, (code) => + code + .replace(/(\b[A-Z])1/g, '$12') + .replace( + "acceptExports(['a', 'default']", + "acceptExports(['b', 'default']", + ), + ) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual([ + `<<<<<< A2 B2 D2 ; ${dep}`, + `[vite] hot updated: ${url}`, + ]) + }, + ) + }) + + test('does not accept itself anymore after acceptedExports change', async () => { + await untilConsoleLogAfter( + async () => { + editFile(file, (code) => code.replace(/(\b[A-Z])2/g, '$13')) + }, + [PROGRAM_RELOAD, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<<<<< A3 B3 D3 ; ${dep}`) + expect(logs).toContain('>>>>>> A3 D3') + }, + ) + }) + }) + + describe('when some used exports are not accepted', () => { + const testDir = baseDir + '/main-non-accepted' + + const namedFileName = 'named.ts' + const namedFile = `${testDir}/${namedFileName}` + const defaultFileName = 'default.ts' + const defaultFile = `${testDir}/${defaultFileName}` + const depFileName = 'dep.ts' + const depFile = `${testDir}/${depFileName}` + + const a = 'A0' + let dep = 'dep0' + + beforeAll(async () => { + await untilConsoleLogAfter( + () => setupViteRuntime(`/${testDir}/index`), + [CONNECTED, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<< named: ${a} ; ${dep}`) + expect(logs).toContain(`<<< default: def0`) + expect(logs).toContain(`>>>>>> ${a} def0`) + }, + ) + }) + + test('does not stop the HMR bubble on change to dep', async () => { + await untilConsoleLogAfter( + async () => { + editFile(depFile, (code) => code.replace('dep0', (dep = 'dep1'))) + }, + [PROGRAM_RELOAD, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<< named: ${a} ; ${dep}`) + }, + ) + }) + + describe('does not stop the HMR bubble on change to self', () => { + test('with named exports', async () => { + await untilConsoleLogAfter( + async () => { + editFile(namedFile, (code) => code.replace(a, 'A1')) + }, + [PROGRAM_RELOAD, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<< named: A1 ; ${dep}`) + }, + ) + }) + + test('with default export', async () => { + await untilConsoleLogAfter( + async () => { + editFile(defaultFile, (code) => code.replace('def0', 'def1')) + }, + [PROGRAM_RELOAD, />>>>>>/], + (logs) => { + expect(logs).toContain(`<<< default: def1`) + }, + ) + }) + }) + }) + + test('accepts itself when imported for side effects only (no bindings imported)', async () => { + const testDir = baseDir + '/side-effects' + const file = 'side-effects.ts' + + await untilConsoleLogAfter( + () => setupViteRuntime(`/${testDir}/index`), + [CONNECTED, />>>/], + (logs) => { + expect(logs).toContain('>>> side FX') + }, + ) + + await untilConsoleLogAfter( + () => { + editFile(`${testDir}/${file}`, (code) => + code.replace('>>> side FX', '>>> side FX !!'), + ) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual(['>>> side FX !!', updated(`/${testDir}/${file}`)]) + }, + ) + }) + + describe('acceptExports([])', () => { + const testDir = baseDir + '/unused-exports' + + test('accepts itself if no exports are imported', async () => { + const fileName = 'unused.ts' + const file = `${testDir}/${fileName}` + const url = '/' + file + + await untilConsoleLogAfter( + () => setupViteRuntime(`/${testDir}/index`), + [CONNECTED, '-- unused --'], + (logs) => { + expect(logs).toContain('-- unused --') + }, + ) + + await untilConsoleLogAfter( + () => { + editFile(file, (code) => code.replace('-- unused --', '-> unused <-')) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual(['-> unused <-', updated(url)]) + }, + ) + }) + + test("doesn't accept itself if any of its exports is imported", async () => { + const fileName = 'used.ts' + const file = `${testDir}/${fileName}` + + await untilConsoleLogAfter( + () => setupViteRuntime(`/${testDir}/index`), + [CONNECTED, '-- used --', 'used:foo0'], + (logs) => { + expect(logs).toContain('-- used --') + expect(logs).toContain('used:foo0') + }, + ) + + await untilConsoleLogAfter( + async () => { + editFile(file, (code) => + code.replace('foo0', 'foo1').replace('-- used --', '-> used <-'), + ) + }, + [PROGRAM_RELOAD, /used:foo/], + (logs) => { + expect(logs).toContain('-> used <-') + expect(logs).toContain('used:foo1') + }, + ) + }) + }) + + describe('indiscriminate imports: import *', () => { + const testStarExports = (testDirName: string) => { + const testDir = `${baseDir}/${testDirName}` + + test('accepts itself if all its exports are accepted', async () => { + const fileName = 'deps-all-accepted.ts' + const file = `${testDir}/${fileName}` + const url = '/' + file + + await untilConsoleLogAfter( + () => setupViteRuntime(`/${testDir}/index`), + [CONNECTED, '>>> ready <<<'], + (logs) => { + expect(logs).toContain('loaded:all:a0b0c0default0') + expect(logs).toContain('all >>>>>> a0, b0, c0') + }, + ) + + await untilConsoleLogAfter( + () => { + editFile(file, (code) => code.replace(/([abc])0/g, '$11')) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual(['all >>>>>> a1, b1, c1', updated(url)]) + }, + ) + + await untilConsoleLogAfter( + () => { + editFile(file, (code) => code.replace(/([abc])1/g, '$12')) + }, + HOT_UPDATED, + (logs) => { + expect(logs).toEqual(['all >>>>>> a2, b2, c2', updated(url)]) + }, + ) + }) + + test("doesn't accept itself if one export is not accepted", async () => { + const fileName = 'deps-some-accepted.ts' + const file = `${testDir}/${fileName}` + + await untilConsoleLogAfter( + () => setupViteRuntime(`/${testDir}/index`), + [CONNECTED, '>>> ready <<<'], + (logs) => { + expect(logs).toContain('loaded:some:a0b0c0default0') + expect(logs).toContain('some >>>>>> a0, b0, c0') + }, + ) + + await untilConsoleLogAfter( + async () => { + editFile(file, (code) => code.replace(/([abc])0/g, '$11')) + }, + [PROGRAM_RELOAD, '>>> ready <<<'], + (logs) => { + expect(logs).toContain('loaded:some:a1b1c1default0') + expect(logs).toContain('some >>>>>> a1, b1, c1') + }, + ) + }) + } + + describe('import * from ...', () => testStarExports('star-imports')) + + describe('dynamic import(...)', () => testStarExports('dynamic-imports')) + }) +}) + +test('handle virtual module updates', async () => { + await setupViteRuntime('/hmr.ts') + const el = () => hmr('.virtual') + expect(el()).toBe('[success]0') + editFile('importedVirtual.js', (code) => code.replace('[success]', '[wow]')) + await untilUpdated(el, '[wow]') +}) + +test('invalidate virtual module', async () => { + await setupViteRuntime('/hmr.ts') + const el = () => hmr('.virtual') + expect(el()).toBe('[wow]0') + globalThis.__HMR__['virtual:increment']() + await untilUpdated(el, '[wow]1') +}) + +test.todo('should hmr when file is deleted and restored', async () => { + await setupViteRuntime('/hmr.ts') + + const parentFile = 'file-delete-restore/parent.js' + const childFile = 'file-delete-restore/child.js' + + await untilUpdated(() => hmr('.file-delete-restore'), 'parent:child') + + editFile(childFile, (code) => + code.replace("value = 'child'", "value = 'child1'"), + ) + await untilUpdated(() => hmr('.file-delete-restore'), 'parent:child1') + + editFile(parentFile, (code) => + code.replace( + "export { value as childValue } from './child'", + "export const childValue = 'not-child'", + ), + ) + removeFile(childFile) + await untilUpdated(() => hmr('.file-delete-restore'), 'parent:not-child') + + createFile( + childFile, + ` +import { rerender } from './runtime' + +export const value = 'child' + +if (import.meta.hot) { + import.meta.hot.accept((newMod) => { + if (!newMod) return + + rerender({ child: newMod.value }) + }) +} +`, + ) + editFile(parentFile, (code) => + code.replace( + "export const childValue = 'not-child'", + "export { value as childValue } from './child'", + ), + ) + await untilUpdated(() => hmr('.file-delete-restore'), 'parent:child') +}) + +test.todo('delete file should not break hmr', async () => { + // await page.goto(viteTestUrl) + + await untilUpdated( + () => page.textContent('.intermediate-file-delete-display'), + 'count is 1', + ) + + // add state + await page.click('.intermediate-file-delete-increment') + await untilUpdated( + () => page.textContent('.intermediate-file-delete-display'), + 'count is 2', + ) + + // update import, hmr works + editFile('intermediate-file-delete/index.js', (code) => + code.replace("from './re-export.js'", "from './display.js'"), + ) + editFile('intermediate-file-delete/display.js', (code) => + code.replace('count is ${count}', 'count is ${count}!'), + ) + await untilUpdated( + () => page.textContent('.intermediate-file-delete-display'), + 'count is 2!', + ) + + // remove unused file, page reload because it's considered entry point now + removeFile('intermediate-file-delete/re-export.js') + await untilUpdated( + () => page.textContent('.intermediate-file-delete-display'), + 'count is 1!', + ) + + // re-add state + await page.click('.intermediate-file-delete-increment') + await untilUpdated( + () => page.textContent('.intermediate-file-delete-display'), + 'count is 2!', + ) + + // hmr works after file deletion + editFile('intermediate-file-delete/display.js', (code) => + code.replace('count is ${count}!', 'count is ${count}'), + ) + await untilUpdated( + () => page.textContent('.intermediate-file-delete-display'), + 'count is 2', + ) +}) + +test('import.meta.hot?.accept', async () => { + await setupViteRuntime('/hmr.ts') + await untilConsoleLogAfter( + () => + editFile('optional-chaining/child.js', (code) => + code.replace('const foo = 1', 'const foo = 2'), + ), + '(optional-chaining) child update', + ) + await untilUpdated(() => hmr('.optional-chaining')?.toString(), '2') +}) + +test('hmr works for self-accepted module within circular imported files', async () => { + await setupViteRuntime('/self-accept-within-circular/index') + const el = () => hmr('.self-accept-within-circular') + expect(el()).toBe('c') + editFile('self-accept-within-circular/c.js', (code) => + code.replace(`export const c = 'c'`, `export const c = 'cc'`), + ) + await untilUpdated(() => el(), 'cc') + await vi.waitFor(() => { + expect(serverLogs.length).greaterThanOrEqual(1) + // Should still keep hmr update, but it'll error on the browser-side and will refresh itself. + // Match on full log not possible because of color markers + expect(serverLogs.at(-1)!).toContain('hmr update') + }) +}) + +test('hmr should not reload if no accepted within circular imported files', async () => { + await setupViteRuntime('/circular/index') + const el = () => hmr('.circular') + expect(el()).toBe( + // tests in the browser check that there is an error, but vite runtime just returns undefined in those cases + 'mod-a -> mod-b -> mod-c -> undefined (expected no error)', + ) + editFile('circular/mod-b.js', (code) => + code.replace(`mod-b ->`, `mod-b (edited) ->`), + ) + await untilUpdated( + () => el(), + 'mod-a -> mod-b (edited) -> mod-c -> undefined (expected no error)', + ) +}) + +test('assets HMR', async () => { + await setupViteRuntime('/hmr.ts') + const el = () => hmr('#logo') + await untilConsoleLogAfter( + () => + editFile('logo.svg', (code) => + code.replace('height="30px"', 'height="40px"'), + ), + /Logo updated/, + ) + await vi.waitUntil(() => el().includes('logo.svg?t=')) +}) + +export function createFile(file: string, content: string): void { + const filepath = resolvePath(import.meta.url, '..', file) + createdFiles.add(filepath) + fs.mkdirSync(dirname(filepath), { recursive: true }) + fs.writeFileSync(filepath, content, 'utf-8') +} + +export function removeFile(file: string): void { + const filepath = resolvePath('..', file) + deletedFiles.set(filepath, fs.readFileSync(filepath, 'utf-8')) + fs.unlinkSync(filepath) +} + +export function editFile( + file: string, + callback: (content: string) => string, +): void { + const filepath = resolvePath('..', file) + const content = fs.readFileSync(filepath, 'utf-8') + if (!originalFiles.has(filepath)) originalFiles.set(filepath, content) + fs.writeFileSync(filepath, callback(content), 'utf-8') +} + +export function resolvePath(...segments: string[]): string { + const filename = fileURLToPath(import.meta.url) + return resolve(dirname(filename), ...segments).replace(/\\/g, '/') +} + +type UntilBrowserLogAfterCallback = (logs: string[]) => PromiseLike | void + +export async function untilConsoleLogAfter( + operation: () => any, + target: string | RegExp | Array, + expectOrder?: boolean, + callback?: UntilBrowserLogAfterCallback, +): Promise +export async function untilConsoleLogAfter( + operation: () => any, + target: string | RegExp | Array, + callback?: UntilBrowserLogAfterCallback, +): Promise +export async function untilConsoleLogAfter( + operation: () => any, + target: string | RegExp | Array, + arg3?: boolean | UntilBrowserLogAfterCallback, + arg4?: UntilBrowserLogAfterCallback, +): Promise { + const expectOrder = typeof arg3 === 'boolean' ? arg3 : false + const callback = typeof arg3 === 'boolean' ? arg4 : arg3 + + const promise = untilConsoleLog(target, expectOrder) + await operation() + const logs = await promise + if (callback) { + await callback(logs) + } + return logs +} + +async function untilConsoleLog( + target?: string | RegExp | Array, + expectOrder = true, +): Promise { + const { promise, resolve, reject } = promiseWithResolvers() + + const logsMessages = [] + + try { + const isMatch = (matcher: string | RegExp) => (text: string) => + typeof matcher === 'string' ? text === matcher : matcher.test(text) + + let processMsg: (text: string) => boolean + + if (!target) { + processMsg = () => true + } else if (Array.isArray(target)) { + if (expectOrder) { + const remainingTargets = [...target] + processMsg = (text: string) => { + const nextTarget = remainingTargets.shift() + expect(text).toMatch(nextTarget) + return remainingTargets.length === 0 + } + } else { + const remainingMatchers = target.map(isMatch) + processMsg = (text: string) => { + const nextIndex = remainingMatchers.findIndex((matcher) => + matcher(text), + ) + if (nextIndex >= 0) { + remainingMatchers.splice(nextIndex, 1) + } + return remainingMatchers.length === 0 + } + } + } else { + processMsg = isMatch(target) + } + + const handleMsg = (text: string) => { + try { + text = text.replace(/\n$/, '') + logsMessages.push(text) + const done = processMsg(text) + if (done) { + resolve() + logsEmitter.off('log', handleMsg) + } + } catch (err) { + reject(err) + logsEmitter.off('log', handleMsg) + } + } + + logsEmitter.on('log', handleMsg) + } catch (err) { + reject(err) + } + + await promise + + return logsMessages +} + +function isWatched(server: ViteDevServer, watchedFile: string) { + const watched = server.watcher.getWatched() + for (const [dir, files] of Object.entries(watched)) { + const unixDir = slash(dir) + for (const file of files) { + const filePath = posix.join(unixDir, file) + if (filePath.includes(watchedFile)) { + return true + } + } + } + return false +} + +function waitForWatcher(server: ViteDevServer, watched: string) { + return new Promise((resolve) => { + function checkWatched() { + if (isWatched(server, watched)) { + resolve() + } else { + setTimeout(checkWatched, 20) + } + } + checkWatched() + }) +} + +function createInMemoryLogger(logs: string[]) { + const loggedErrors = new WeakSet() + const warnedMessages = new Set() + + const logger: Logger = { + hasWarned: false, + hasErrorLogged: (err) => loggedErrors.has(err), + clearScreen: () => {}, + info(msg) { + logs.push(msg) + }, + warn(msg) { + logs.push(msg) + logger.hasWarned = true + }, + warnOnce(msg) { + if (warnedMessages.has(msg)) return + logs.push(msg) + logger.hasWarned = true + warnedMessages.add(msg) + }, + error(msg, opts) { + logs.push(msg) + if (opts?.error) { + loggedErrors.add(opts.error) + } + }, + } + + return logger +} + +async function setupViteRuntime( + entrypoint: string, + serverOptions: InlineConfig = {}, +) { + if (server) { + await server.close() + clientLogs.length = 0 + serverLogs.length = 0 + runtime.clearCache() + } + + globalThis.__HMR__ = {} as any + + const root = resolvePath('..') + server = await createServer({ + configFile: resolvePath('../vite.config.ts'), + root, + customLogger: createInMemoryLogger(serverLogs), + server: { + middlewareMode: true, + watch: { + // During tests we edit the files too fast and sometimes chokidar + // misses change events, so enforce polling for consistency + usePolling: true, + interval: 100, + }, + hmr: { + port: 9609, + }, + preTransformRequests: false, + }, + optimizeDeps: { + disabled: true, + noDiscovery: true, + include: [], + }, + ...serverOptions, + }) + + const logger = new HMRMockLogger() + // @ts-expect-error not typed for HMR + globalThis.log = (...msg) => logger.debug(...msg) + + runtime = await createViteRuntime(server, { + hmr: { + logger, + }, + }) + + await waitForWatcher(server, entrypoint) + + await runtime.executeEntrypoint(entrypoint) + + return { + runtime, + server, + } +} + +class HMRMockLogger { + debug(...msg: unknown[]) { + const log = msg.join(' ') + clientLogs.push(log) + logsEmitter.emit('log', log) + } + error(msg: string) { + clientLogs.push(msg) + logsEmitter.emit('log', msg) + } +} diff --git a/playground/hmr-ssr/accept-exports/dynamic-imports/deps-all-accepted.ts b/playground/hmr-ssr/accept-exports/dynamic-imports/deps-all-accepted.ts new file mode 100644 index 00000000000000..bf935ebc878609 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/dynamic-imports/deps-all-accepted.ts @@ -0,0 +1,14 @@ +export const a = 'a0' + +export const b = 'b0' + +const aliased = 'c0' +export { aliased as c } + +export default 'default0' + +log(`all >>>>>> ${a}, ${b}, ${aliased}`) + +if (import.meta.hot) { + import.meta.hot.acceptExports(['a', 'b', 'c', 'default']) +} diff --git a/playground/hmr-ssr/accept-exports/dynamic-imports/deps-some-accepted.ts b/playground/hmr-ssr/accept-exports/dynamic-imports/deps-some-accepted.ts new file mode 100644 index 00000000000000..04469868392dc3 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/dynamic-imports/deps-some-accepted.ts @@ -0,0 +1,14 @@ +export const a = 'a0' + +export const b = 'b0' + +const aliased = 'c0' +export { aliased as c } + +export default 'default0' + +log(`some >>>>>> ${a}, ${b}, ${aliased}`) + +if (import.meta.hot) { + import.meta.hot.acceptExports(['a', 'b', 'default']) +} diff --git a/playground/hmr-ssr/accept-exports/dynamic-imports/dynamic-imports.ts b/playground/hmr-ssr/accept-exports/dynamic-imports/dynamic-imports.ts new file mode 100644 index 00000000000000..a721c318f2ac6b --- /dev/null +++ b/playground/hmr-ssr/accept-exports/dynamic-imports/dynamic-imports.ts @@ -0,0 +1,9 @@ +Promise.all([import('./deps-all-accepted'), import('./deps-some-accepted')]) + .then(([all, some]) => { + log('loaded:all:' + all.a + all.b + all.c + all.default) + log('loaded:some:' + some.a + some.b + some.c + some.default) + log('>>> ready <<<') + }) + .catch((err) => { + log(err) + }) diff --git a/playground/hmr-ssr/accept-exports/dynamic-imports/index.ts b/playground/hmr-ssr/accept-exports/dynamic-imports/index.ts new file mode 100644 index 00000000000000..3e6d5d54db881e --- /dev/null +++ b/playground/hmr-ssr/accept-exports/dynamic-imports/index.ts @@ -0,0 +1 @@ +import './dynamic-imports.ts' diff --git a/playground/hmr-ssr/accept-exports/export-from/depA.ts b/playground/hmr-ssr/accept-exports/export-from/depA.ts new file mode 100644 index 00000000000000..e2eda670ed0097 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/export-from/depA.ts @@ -0,0 +1 @@ +export const a = 'Ax' diff --git a/playground/hmr-ssr/accept-exports/export-from/export-from.ts b/playground/hmr-ssr/accept-exports/export-from/export-from.ts new file mode 100644 index 00000000000000..49cc19fc3e9f86 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/export-from/export-from.ts @@ -0,0 +1,8 @@ +import { a } from './hub' + +log(a) + +if (import.meta.hot) { + import.meta.hot.accept() +} else { +} diff --git a/playground/hmr-ssr/accept-exports/export-from/hub.ts b/playground/hmr-ssr/accept-exports/export-from/hub.ts new file mode 100644 index 00000000000000..5bd0dc05608909 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/export-from/hub.ts @@ -0,0 +1 @@ +export * from './depA' diff --git a/playground/hmr-ssr/accept-exports/export-from/index.html b/playground/hmr-ssr/accept-exports/export-from/index.html new file mode 100644 index 00000000000000..0dde1345f085e2 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/export-from/index.html @@ -0,0 +1,3 @@ + + +
diff --git a/playground/hmr-ssr/accept-exports/main-accepted/callback.ts b/playground/hmr-ssr/accept-exports/main-accepted/callback.ts new file mode 100644 index 00000000000000..8dc4c42a24db99 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/main-accepted/callback.ts @@ -0,0 +1,7 @@ +export const x = 'X' + +if (import.meta.hot) { + import.meta.hot.acceptExports(['x'], (m) => { + log(`reloaded >>> ${m.x}`) + }) +} diff --git a/playground/hmr-ssr/accept-exports/main-accepted/dep.ts b/playground/hmr-ssr/accept-exports/main-accepted/dep.ts new file mode 100644 index 00000000000000..b9f67fd33a75f8 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/main-accepted/dep.ts @@ -0,0 +1 @@ +export default 'dep0' diff --git a/playground/hmr-ssr/accept-exports/main-accepted/index.ts b/playground/hmr-ssr/accept-exports/main-accepted/index.ts new file mode 100644 index 00000000000000..2e798337101607 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/main-accepted/index.ts @@ -0,0 +1 @@ +import './main-accepted' diff --git a/playground/hmr-ssr/accept-exports/main-accepted/main-accepted.ts b/playground/hmr-ssr/accept-exports/main-accepted/main-accepted.ts new file mode 100644 index 00000000000000..74afdbfa7e378c --- /dev/null +++ b/playground/hmr-ssr/accept-exports/main-accepted/main-accepted.ts @@ -0,0 +1,7 @@ +import def, { a } from './target' +import { x } from './callback' + +// we don't want to pollute other checks' logs... +if (0 > 1) log(x) + +log(`>>>>>> ${a} ${def}`) diff --git a/playground/hmr-ssr/accept-exports/main-accepted/target.ts b/playground/hmr-ssr/accept-exports/main-accepted/target.ts new file mode 100644 index 00000000000000..c4826524c3c83d --- /dev/null +++ b/playground/hmr-ssr/accept-exports/main-accepted/target.ts @@ -0,0 +1,16 @@ +import dep from './dep' + +export const a = 'A0' + +const bValue = 'B0' +export { bValue as b } + +const def = 'D0' + +export default def + +log(`<<<<<< ${a} ${bValue} ${def} ; ${dep}`) + +if (import.meta.hot) { + import.meta.hot.acceptExports(['a', 'default']) +} diff --git a/playground/hmr-ssr/accept-exports/main-non-accepted/default.ts b/playground/hmr-ssr/accept-exports/main-non-accepted/default.ts new file mode 100644 index 00000000000000..6ffaecaf43c588 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/main-non-accepted/default.ts @@ -0,0 +1,11 @@ +export const x = 'y' + +const def = 'def0' + +export default def + +log(`<<< default: ${def}`) + +if (import.meta.hot) { + import.meta.hot.acceptExports(['x']) +} diff --git a/playground/hmr-ssr/accept-exports/main-non-accepted/dep.ts b/playground/hmr-ssr/accept-exports/main-non-accepted/dep.ts new file mode 100644 index 00000000000000..b9f67fd33a75f8 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/main-non-accepted/dep.ts @@ -0,0 +1 @@ +export default 'dep0' diff --git a/playground/hmr-ssr/accept-exports/main-non-accepted/index.ts b/playground/hmr-ssr/accept-exports/main-non-accepted/index.ts new file mode 100644 index 00000000000000..3841d7997c4c26 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/main-non-accepted/index.ts @@ -0,0 +1 @@ +import './main-non-accepted.ts' diff --git a/playground/hmr-ssr/accept-exports/main-non-accepted/main-non-accepted.ts b/playground/hmr-ssr/accept-exports/main-non-accepted/main-non-accepted.ts new file mode 100644 index 00000000000000..a159ced50a7f50 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/main-non-accepted/main-non-accepted.ts @@ -0,0 +1,4 @@ +import { a } from './named' +import def from './default' + +log(`>>>>>> ${a} ${def}`) diff --git a/playground/hmr-ssr/accept-exports/main-non-accepted/named.ts b/playground/hmr-ssr/accept-exports/main-non-accepted/named.ts new file mode 100644 index 00000000000000..435d3c8cb50ae8 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/main-non-accepted/named.ts @@ -0,0 +1,11 @@ +import dep from './dep' + +export const a = 'A0' + +export const b = 'B0' + +log(`<<< named: ${a} ; ${dep}`) + +if (import.meta.hot) { + import.meta.hot.acceptExports(['b']) +} diff --git a/playground/hmr-ssr/accept-exports/reexports.bak/accept-named.ts b/playground/hmr-ssr/accept-exports/reexports.bak/accept-named.ts new file mode 100644 index 00000000000000..1c45a7c358452e --- /dev/null +++ b/playground/hmr-ssr/accept-exports/reexports.bak/accept-named.ts @@ -0,0 +1,10 @@ +export { a, b } from './source' + +if (import.meta.hot) { + // import.meta.hot.accept('./source', (m) => { + // log(`accept-named reexport:${m.a},${m.b}`) + // }) + import.meta.hot.acceptExports('a', (m) => { + log(`accept-named reexport:${m.a},${m.b}`) + }) +} diff --git a/playground/hmr-ssr/accept-exports/reexports.bak/index.html b/playground/hmr-ssr/accept-exports/reexports.bak/index.html new file mode 100644 index 00000000000000..241054bca8256f --- /dev/null +++ b/playground/hmr-ssr/accept-exports/reexports.bak/index.html @@ -0,0 +1 @@ + diff --git a/playground/hmr-ssr/accept-exports/reexports.bak/reexports.ts b/playground/hmr-ssr/accept-exports/reexports.bak/reexports.ts new file mode 100644 index 00000000000000..659901c42c7149 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/reexports.bak/reexports.ts @@ -0,0 +1,5 @@ +import { a } from './accept-named' + +log('accept-named:' + a) + +log('>>> ready') diff --git a/playground/hmr-ssr/accept-exports/reexports.bak/source.ts b/playground/hmr-ssr/accept-exports/reexports.bak/source.ts new file mode 100644 index 00000000000000..7f736004a8e9fa --- /dev/null +++ b/playground/hmr-ssr/accept-exports/reexports.bak/source.ts @@ -0,0 +1,2 @@ +export const a = 'a0' +export const b = 'b0' diff --git a/playground/hmr-ssr/accept-exports/side-effects/index.ts b/playground/hmr-ssr/accept-exports/side-effects/index.ts new file mode 100644 index 00000000000000..8a44ded37ba337 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/side-effects/index.ts @@ -0,0 +1 @@ +import './side-effects.ts' diff --git a/playground/hmr-ssr/accept-exports/side-effects/side-effects.ts b/playground/hmr-ssr/accept-exports/side-effects/side-effects.ts new file mode 100644 index 00000000000000..f4abb02fb2b47e --- /dev/null +++ b/playground/hmr-ssr/accept-exports/side-effects/side-effects.ts @@ -0,0 +1,13 @@ +export const x = 'x' + +export const y = 'y' + +export default 'z' + +log('>>> side FX') + +globalThis.__HMR__['.app'] = 'hey' + +if (import.meta.hot) { + import.meta.hot.acceptExports(['default']) +} diff --git a/playground/hmr-ssr/accept-exports/star-imports/deps-all-accepted.ts b/playground/hmr-ssr/accept-exports/star-imports/deps-all-accepted.ts new file mode 100644 index 00000000000000..bf935ebc878609 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/star-imports/deps-all-accepted.ts @@ -0,0 +1,14 @@ +export const a = 'a0' + +export const b = 'b0' + +const aliased = 'c0' +export { aliased as c } + +export default 'default0' + +log(`all >>>>>> ${a}, ${b}, ${aliased}`) + +if (import.meta.hot) { + import.meta.hot.acceptExports(['a', 'b', 'c', 'default']) +} diff --git a/playground/hmr-ssr/accept-exports/star-imports/deps-some-accepted.ts b/playground/hmr-ssr/accept-exports/star-imports/deps-some-accepted.ts new file mode 100644 index 00000000000000..04469868392dc3 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/star-imports/deps-some-accepted.ts @@ -0,0 +1,14 @@ +export const a = 'a0' + +export const b = 'b0' + +const aliased = 'c0' +export { aliased as c } + +export default 'default0' + +log(`some >>>>>> ${a}, ${b}, ${aliased}`) + +if (import.meta.hot) { + import.meta.hot.acceptExports(['a', 'b', 'default']) +} diff --git a/playground/hmr-ssr/accept-exports/star-imports/index.ts b/playground/hmr-ssr/accept-exports/star-imports/index.ts new file mode 100644 index 00000000000000..d98700b239a3df --- /dev/null +++ b/playground/hmr-ssr/accept-exports/star-imports/index.ts @@ -0,0 +1 @@ +import './star-imports.ts' diff --git a/playground/hmr-ssr/accept-exports/star-imports/star-imports.ts b/playground/hmr-ssr/accept-exports/star-imports/star-imports.ts new file mode 100644 index 00000000000000..228622f9ab85b3 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/star-imports/star-imports.ts @@ -0,0 +1,6 @@ +import * as all from './deps-all-accepted' +import * as some from './deps-some-accepted' + +log('loaded:all:' + all.a + all.b + all.c + all.default) +log('loaded:some:' + some.a + some.b + some.c + some.default) +log('>>> ready <<<') diff --git a/playground/hmr-ssr/accept-exports/unused-exports/index.html b/playground/hmr-ssr/accept-exports/unused-exports/index.html new file mode 100644 index 00000000000000..8998d3ce4581ee --- /dev/null +++ b/playground/hmr-ssr/accept-exports/unused-exports/index.html @@ -0,0 +1 @@ + diff --git a/playground/hmr-ssr/accept-exports/unused-exports/index.ts b/playground/hmr-ssr/accept-exports/unused-exports/index.ts new file mode 100644 index 00000000000000..ffd430893843fd --- /dev/null +++ b/playground/hmr-ssr/accept-exports/unused-exports/index.ts @@ -0,0 +1,4 @@ +import './unused' +import { foo } from './used' + +log('used:' + foo) diff --git a/playground/hmr-ssr/accept-exports/unused-exports/unused.ts b/playground/hmr-ssr/accept-exports/unused-exports/unused.ts new file mode 100644 index 00000000000000..1462ed6101bba6 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/unused-exports/unused.ts @@ -0,0 +1,11 @@ +export const x = 'x' + +export const y = 'y' + +export default 'z' + +log('-- unused --') + +if (import.meta.hot) { + import.meta.hot.acceptExports([]) +} diff --git a/playground/hmr-ssr/accept-exports/unused-exports/used.ts b/playground/hmr-ssr/accept-exports/unused-exports/used.ts new file mode 100644 index 00000000000000..a4a093f726e325 --- /dev/null +++ b/playground/hmr-ssr/accept-exports/unused-exports/used.ts @@ -0,0 +1,9 @@ +export const foo = 'foo0' + +export const bar = 'bar0' + +log('-- used --') + +if (import.meta.hot) { + import.meta.hot.acceptExports([]) +} diff --git a/playground/hmr-ssr/circular/index.js b/playground/hmr-ssr/circular/index.js new file mode 100644 index 00000000000000..a78188ea88f93c --- /dev/null +++ b/playground/hmr-ssr/circular/index.js @@ -0,0 +1,7 @@ +import { msg } from './mod-a' + +globalThis.__HMR__['.circular'] = msg + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/playground/hmr-ssr/circular/mod-a.js b/playground/hmr-ssr/circular/mod-a.js new file mode 100644 index 00000000000000..def8466da2e489 --- /dev/null +++ b/playground/hmr-ssr/circular/mod-a.js @@ -0,0 +1,5 @@ +export const value = 'mod-a' + +import { value as _value } from './mod-b' + +export const msg = `mod-a -> ${_value}` diff --git a/playground/hmr-ssr/circular/mod-b.js b/playground/hmr-ssr/circular/mod-b.js new file mode 100644 index 00000000000000..fe0125f33787b7 --- /dev/null +++ b/playground/hmr-ssr/circular/mod-b.js @@ -0,0 +1,3 @@ +import { value as _value } from './mod-c' + +export const value = `mod-b -> ${_value}` diff --git a/playground/hmr-ssr/circular/mod-c.js b/playground/hmr-ssr/circular/mod-c.js new file mode 100644 index 00000000000000..4f9de5b0efcc29 --- /dev/null +++ b/playground/hmr-ssr/circular/mod-c.js @@ -0,0 +1,11 @@ +import { value as _value } from './mod-a' + +// Should error as `_value` is not defined yet within the circular imports +let __value +try { + __value = `${_value} (expected no error)` +} catch { + __value = 'mod-a (unexpected error)' +} + +export const value = `mod-c -> ${__value}` diff --git a/playground/hmr-ssr/counter/dep.ts b/playground/hmr-ssr/counter/dep.ts new file mode 100644 index 00000000000000..e15e77f4e4743f --- /dev/null +++ b/playground/hmr-ssr/counter/dep.ts @@ -0,0 +1,4 @@ +// This file is never loaded +if (import.meta.hot) { + import.meta.hot.accept(() => {}) +} diff --git a/playground/hmr-ssr/counter/index.ts b/playground/hmr-ssr/counter/index.ts new file mode 100644 index 00000000000000..66edcdbe737ed1 --- /dev/null +++ b/playground/hmr-ssr/counter/index.ts @@ -0,0 +1,11 @@ +let count = 0 +export function increment() { + count++ +} +export function getCount() { + return count +} +// @ts-expect-error not used but this is to test that it works +function neverCalled() { + import('./dep') +} diff --git a/playground/hmr-ssr/customFile.js b/playground/hmr-ssr/customFile.js new file mode 100644 index 00000000000000..7c9069974578e0 --- /dev/null +++ b/playground/hmr-ssr/customFile.js @@ -0,0 +1 @@ +export const msg = 'custom' diff --git a/playground/hmr-ssr/event.d.ts b/playground/hmr-ssr/event.d.ts new file mode 100644 index 00000000000000..1920d1e7aff076 --- /dev/null +++ b/playground/hmr-ssr/event.d.ts @@ -0,0 +1,17 @@ +import 'vite/types/customEvent' + +declare module 'vite/types/customEvent' { + interface CustomEventMap { + 'custom:foo': { msg: string } + 'custom:remote-add': { a: number; b: number } + 'custom:remote-add-result': { result: string } + } +} + +declare global { + let log: (...msg: unknown[]) => void + let logger: { + error: (msg: string | Error) => void + debug: (...msg: unknown[]) => void + } +} diff --git a/playground/hmr-ssr/file-delete-restore/child.js b/playground/hmr-ssr/file-delete-restore/child.js new file mode 100644 index 00000000000000..704c7d8c7e98cc --- /dev/null +++ b/playground/hmr-ssr/file-delete-restore/child.js @@ -0,0 +1,11 @@ +import { rerender } from './runtime' + +export const value = 'child' + +if (import.meta.hot) { + import.meta.hot.accept((newMod) => { + if (!newMod) return + + rerender({ child: newMod.value }) + }) +} diff --git a/playground/hmr-ssr/file-delete-restore/index.js b/playground/hmr-ssr/file-delete-restore/index.js new file mode 100644 index 00000000000000..fa4908a32662ac --- /dev/null +++ b/playground/hmr-ssr/file-delete-restore/index.js @@ -0,0 +1,4 @@ +import { render } from './runtime' +import { childValue, parentValue } from './parent' + +render({ parent: parentValue, child: childValue }) diff --git a/playground/hmr-ssr/file-delete-restore/parent.js b/playground/hmr-ssr/file-delete-restore/parent.js new file mode 100644 index 00000000000000..050bfa6d49b4c0 --- /dev/null +++ b/playground/hmr-ssr/file-delete-restore/parent.js @@ -0,0 +1,12 @@ +import { rerender } from './runtime' + +export const parentValue = 'parent' +export { value as childValue } from './child' + +if (import.meta.hot) { + import.meta.hot.accept((newMod) => { + if (!newMod) return + + rerender({ child: newMod.childValue, parent: newMod.parentValue }) + }) +} diff --git a/playground/hmr-ssr/file-delete-restore/runtime.js b/playground/hmr-ssr/file-delete-restore/runtime.js new file mode 100644 index 00000000000000..a3383fcf8ed777 --- /dev/null +++ b/playground/hmr-ssr/file-delete-restore/runtime.js @@ -0,0 +1,15 @@ +let state = {} + +export const render = (newState) => { + state = newState + apply() +} + +export const rerender = (updates) => { + state = { ...state, ...updates } + apply() +} + +const apply = () => { + globalThis.__HMR__['.file-delete-restore'] = Object.values(state).join(':') +} diff --git a/playground/hmr-ssr/hmr.ts b/playground/hmr-ssr/hmr.ts new file mode 100644 index 00000000000000..cd88dbd47bb6c6 --- /dev/null +++ b/playground/hmr-ssr/hmr.ts @@ -0,0 +1,112 @@ +import { virtual } from 'virtual:file' +import { foo as depFoo, nestedFoo } from './hmrDep' +import './importing-updated' +import './invalidation/parent' +import './file-delete-restore' +import './optional-chaining/parent' +import './intermediate-file-delete' +import './circular' +import './queries' +import logo from './logo.svg' +import { msg as softInvalidationMsg } from './soft-invalidation' + +export const foo = 1 +text('.app', foo) +text('.dep', depFoo) +text('.nested', nestedFoo) +text('.virtual', virtual) +text('.soft-invalidation', softInvalidationMsg) +setLogo(logo) + +globalThis.__HMR__['virtual:increment'] = () => { + if (import.meta.hot) { + import.meta.hot.send('virtual:increment') + } +} + +if (import.meta.hot) { + import.meta.hot.accept(({ foo }) => { + log('(self-accepting 1) foo is now:', foo) + }) + + import.meta.hot.accept(({ foo }) => { + log('(self-accepting 2) foo is now:', foo) + }) + + const handleDep = (type, newFoo, newNestedFoo) => { + log(`(${type}) foo is now: ${newFoo}`) + log(`(${type}) nested foo is now: ${newNestedFoo}`) + text('.dep', newFoo) + text('.nested', newNestedFoo) + } + + import.meta.hot.accept('./logo.svg', (newUrl) => { + setLogo(newUrl.default) + log('Logo updated', newUrl.default) + }) + + import.meta.hot.accept('./hmrDep', ({ foo, nestedFoo }) => { + handleDep('single dep', foo, nestedFoo) + }) + + import.meta.hot.accept(['./hmrDep'], ([{ foo, nestedFoo }]) => { + handleDep('multi deps', foo, nestedFoo) + }) + + import.meta.hot.dispose(() => { + log(`foo was:`, foo) + }) + + import.meta.hot.on('vite:afterUpdate', (event) => { + log(`>>> vite:afterUpdate -- ${event.type}`) + }) + + import.meta.hot.on('vite:beforeUpdate', (event) => { + log(`>>> vite:beforeUpdate -- ${event.type}`) + + const cssUpdate = event.updates.find( + (update) => + update.type === 'css-update' && update.path.includes('global.css'), + ) + if (cssUpdate) { + log('CSS updates are not supported in SSR') + } + }) + + import.meta.hot.on('vite:error', (event) => { + log(`>>> vite:error -- ${event.err.message}`) + }) + + import.meta.hot.on('vite:invalidate', ({ path }) => { + log(`>>> vite:invalidate -- ${path}`) + }) + + import.meta.hot.on('custom:foo', ({ msg }) => { + text('.custom', msg) + }) + + import.meta.hot.on('custom:remove', removeCb) + + // send custom event to server to calculate 1 + 2 + import.meta.hot.send('custom:remote-add', { a: 1, b: 2 }) + import.meta.hot.on('custom:remote-add-result', ({ result }) => { + text('.custom-communication', result) + }) +} + +function text(el, text) { + hmr(el, text) +} + +function setLogo(src) { + hmr('#logo', src) +} + +function removeCb({ msg }) { + text('.toRemove', msg) + import.meta.hot.off('custom:remove', removeCb) +} + +function hmr(key: string, value: unknown) { + ;(globalThis.__HMR__ as any)[key] = String(value) +} diff --git a/playground/hmr-ssr/hmrDep.js b/playground/hmr-ssr/hmrDep.js new file mode 100644 index 00000000000000..c4c434146afc41 --- /dev/null +++ b/playground/hmr-ssr/hmrDep.js @@ -0,0 +1,14 @@ +export const foo = 1 +export { foo as nestedFoo } from './hmrNestedDep' + +if (import.meta.hot) { + const data = import.meta.hot.data + if ('fromDispose' in data) { + log(`(dep) foo from dispose: ${data.fromDispose}`) + } + + import.meta.hot.dispose((data) => { + log(`(dep) foo was: ${foo}`) + data.fromDispose = foo + }) +} diff --git a/playground/hmr-ssr/hmrNestedDep.js b/playground/hmr-ssr/hmrNestedDep.js new file mode 100644 index 00000000000000..766766a6260612 --- /dev/null +++ b/playground/hmr-ssr/hmrNestedDep.js @@ -0,0 +1 @@ +export const foo = 1 diff --git a/playground/hmr-ssr/importedVirtual.js b/playground/hmr-ssr/importedVirtual.js new file mode 100644 index 00000000000000..8b0b417bc3113d --- /dev/null +++ b/playground/hmr-ssr/importedVirtual.js @@ -0,0 +1 @@ +export const virtual = '[success]' diff --git a/playground/hmr-ssr/importing-updated/a.js b/playground/hmr-ssr/importing-updated/a.js new file mode 100644 index 00000000000000..e52ef8d3dce2d7 --- /dev/null +++ b/playground/hmr-ssr/importing-updated/a.js @@ -0,0 +1,9 @@ +const val = 'a0' +globalThis.__HMR__['.importing-reloaded'] ??= '' +globalThis.__HMR__['.importing-reloaded'] += `a.js: ${val}
` + +export default val + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/playground/hmr-ssr/importing-updated/b.js b/playground/hmr-ssr/importing-updated/b.js new file mode 100644 index 00000000000000..d309a396a3c56d --- /dev/null +++ b/playground/hmr-ssr/importing-updated/b.js @@ -0,0 +1,10 @@ +import a from './a.js' + +const val = `b0,${a}` + +globalThis.__HMR__['.importing-reloaded'] ??= '' +globalThis.__HMR__['.importing-reloaded'] += `b.js: ${val}
` + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/playground/hmr-ssr/importing-updated/index.js b/playground/hmr-ssr/importing-updated/index.js new file mode 100644 index 00000000000000..0cc74268d385de --- /dev/null +++ b/playground/hmr-ssr/importing-updated/index.js @@ -0,0 +1,2 @@ +import './a' +import './b' diff --git a/playground/hmr-ssr/intermediate-file-delete/display.js b/playground/hmr-ssr/intermediate-file-delete/display.js new file mode 100644 index 00000000000000..3ab1936b0c9009 --- /dev/null +++ b/playground/hmr-ssr/intermediate-file-delete/display.js @@ -0,0 +1 @@ +export const displayCount = (count) => `count is ${count}` diff --git a/playground/hmr-ssr/intermediate-file-delete/index.js b/playground/hmr-ssr/intermediate-file-delete/index.js new file mode 100644 index 00000000000000..30435b7606e273 --- /dev/null +++ b/playground/hmr-ssr/intermediate-file-delete/index.js @@ -0,0 +1,21 @@ +import { displayCount } from './re-export.js' + +const incrementValue = () => + globalThis.__HMR__['.intermediate-file-delete-increment'] + +const render = () => { + globalThis.__HMR__['.intermediate-file-delete-display'] = displayCount( + Number(incrementValue()), + ) +} + +render() + +globalThis.__HMR__['.delete-intermediate-file'] = () => { + globalThis.__HMR__['.intermediate-file-delete-increment'] = `${ + Number(incrementValue()) + 1 + }` + render() +} + +if (import.meta.hot) import.meta.hot.accept() diff --git a/playground/hmr-ssr/intermediate-file-delete/re-export.js b/playground/hmr-ssr/intermediate-file-delete/re-export.js new file mode 100644 index 00000000000000..b2dade525c0675 --- /dev/null +++ b/playground/hmr-ssr/intermediate-file-delete/re-export.js @@ -0,0 +1 @@ +export * from './display.js' diff --git a/playground/hmr-ssr/invalidation/child.js b/playground/hmr-ssr/invalidation/child.js new file mode 100644 index 00000000000000..b424e2f83c3233 --- /dev/null +++ b/playground/hmr-ssr/invalidation/child.js @@ -0,0 +1,9 @@ +if (import.meta.hot) { + // Need to accept, to register a callback for HMR + import.meta.hot.accept(() => { + // Trigger HMR in importers + import.meta.hot.invalidate() + }) +} + +export const value = 'child' diff --git a/playground/hmr-ssr/invalidation/parent.js b/playground/hmr-ssr/invalidation/parent.js new file mode 100644 index 00000000000000..80f80e58348da8 --- /dev/null +++ b/playground/hmr-ssr/invalidation/parent.js @@ -0,0 +1,9 @@ +import { value } from './child' + +if (import.meta.hot) { + import.meta.hot.accept() +} + +log('(invalidation) parent is executing') + +globalThis.__HMR__['.invalidation'] = value diff --git a/playground/hmr-ssr/logo.svg b/playground/hmr-ssr/logo.svg new file mode 100644 index 00000000000000..a85344da4790b2 --- /dev/null +++ b/playground/hmr-ssr/logo.svg @@ -0,0 +1,3 @@ + + Vite + diff --git a/playground/hmr-ssr/missing-import/a.js b/playground/hmr-ssr/missing-import/a.js new file mode 100644 index 00000000000000..fff5559cec149d --- /dev/null +++ b/playground/hmr-ssr/missing-import/a.js @@ -0,0 +1,3 @@ +import 'missing-modules' + +log('missing test') diff --git a/playground/hmr-ssr/missing-import/index.js b/playground/hmr-ssr/missing-import/index.js new file mode 100644 index 00000000000000..5ad5ba12cc8619 --- /dev/null +++ b/playground/hmr-ssr/missing-import/index.js @@ -0,0 +1 @@ +import './main.js' diff --git a/playground/hmr-ssr/missing-import/main.js b/playground/hmr-ssr/missing-import/main.js new file mode 100644 index 00000000000000..999801e4dd1061 --- /dev/null +++ b/playground/hmr-ssr/missing-import/main.js @@ -0,0 +1 @@ +import './a.js' diff --git a/playground/hmr-ssr/modules.d.ts b/playground/hmr-ssr/modules.d.ts new file mode 100644 index 00000000000000..815c25568d5119 --- /dev/null +++ b/playground/hmr-ssr/modules.d.ts @@ -0,0 +1,11 @@ +declare module 'virtual:file' { + export const virtual: string +} +declare module '*?query1' { + const string: string + export default string +} +declare module '*?query2' { + const string: string + export default string +} diff --git a/playground/hmr-ssr/optional-chaining/child.js b/playground/hmr-ssr/optional-chaining/child.js new file mode 100644 index 00000000000000..766766a6260612 --- /dev/null +++ b/playground/hmr-ssr/optional-chaining/child.js @@ -0,0 +1 @@ +export const foo = 1 diff --git a/playground/hmr-ssr/optional-chaining/parent.js b/playground/hmr-ssr/optional-chaining/parent.js new file mode 100644 index 00000000000000..c4d9468bf67907 --- /dev/null +++ b/playground/hmr-ssr/optional-chaining/parent.js @@ -0,0 +1,8 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { foo } from './child' + +import.meta.hot?.accept('./child', ({ foo }) => { + log('(optional-chaining) child update') + globalThis.__HMR__['.optional-chaining'] = foo +}) diff --git a/playground/hmr-ssr/package.json b/playground/hmr-ssr/package.json new file mode 100644 index 00000000000000..52a5646e2da7a4 --- /dev/null +++ b/playground/hmr-ssr/package.json @@ -0,0 +1,12 @@ +{ + "name": "@vitejs/test-hmr-ssr", + "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-ssr/queries/index.js b/playground/hmr-ssr/queries/index.js new file mode 100644 index 00000000000000..113eb1a079af40 --- /dev/null +++ b/playground/hmr-ssr/queries/index.js @@ -0,0 +1,9 @@ +import query1 from './multi-query?query1' +import query2 from './multi-query?query2' + +hmr('query1', query1) +hmr('query2', query2) + +function hmr(key, value) { + globalThis.__HMR__[key] = String(value) +} diff --git a/playground/hmr-ssr/queries/multi-query.js b/playground/hmr-ssr/queries/multi-query.js new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/playground/hmr-ssr/self-accept-within-circular/a.js b/playground/hmr-ssr/self-accept-within-circular/a.js new file mode 100644 index 00000000000000..a559b739d9f253 --- /dev/null +++ b/playground/hmr-ssr/self-accept-within-circular/a.js @@ -0,0 +1,5 @@ +import { b } from './b' + +export const a = { + b, +} diff --git a/playground/hmr-ssr/self-accept-within-circular/b.js b/playground/hmr-ssr/self-accept-within-circular/b.js new file mode 100644 index 00000000000000..4f5a135418728c --- /dev/null +++ b/playground/hmr-ssr/self-accept-within-circular/b.js @@ -0,0 +1,7 @@ +import { c } from './c' + +const b = { + c, +} + +export { b } diff --git a/playground/hmr-ssr/self-accept-within-circular/c.js b/playground/hmr-ssr/self-accept-within-circular/c.js new file mode 100644 index 00000000000000..47b6d494969917 --- /dev/null +++ b/playground/hmr-ssr/self-accept-within-circular/c.js @@ -0,0 +1,12 @@ +import './b' + +export const c = 'c' + +function render(content) { + globalThis.__HMR__['.self-accept-within-circular'] = content +} +render(c) + +import.meta.hot?.accept((nextExports) => { + render(nextExports.c) +}) diff --git a/playground/hmr-ssr/self-accept-within-circular/index.js b/playground/hmr-ssr/self-accept-within-circular/index.js new file mode 100644 index 00000000000000..d826a1226a5e66 --- /dev/null +++ b/playground/hmr-ssr/self-accept-within-circular/index.js @@ -0,0 +1,3 @@ +import { a } from './a' + +log(a) diff --git a/playground/hmr-ssr/soft-invalidation/child.js b/playground/hmr-ssr/soft-invalidation/child.js new file mode 100644 index 00000000000000..21ec276fc7f825 --- /dev/null +++ b/playground/hmr-ssr/soft-invalidation/child.js @@ -0,0 +1 @@ +export const foo = 'bar' diff --git a/playground/hmr-ssr/soft-invalidation/index.js b/playground/hmr-ssr/soft-invalidation/index.js new file mode 100644 index 00000000000000..f236a2579b0c24 --- /dev/null +++ b/playground/hmr-ssr/soft-invalidation/index.js @@ -0,0 +1,4 @@ +import { foo } from './child' + +// @ts-expect-error global +export const msg = `soft-invalidation/index.js is transformed ${__TRANSFORM_COUNT__} times. child is ${foo}` diff --git a/playground/hmr-ssr/vite.config.ts b/playground/hmr-ssr/vite.config.ts new file mode 100644 index 00000000000000..5b4a7c17fe27cb --- /dev/null +++ b/playground/hmr-ssr/vite.config.ts @@ -0,0 +1,84 @@ +import { defineConfig } from 'vite' +import type { Plugin } from 'vite' + +export default defineConfig({ + experimental: { + hmrPartialAccept: true, + }, + plugins: [ + { + name: 'mock-custom', + async handleHotUpdate({ file, read, server }) { + if (file.endsWith('customFile.js')) { + const content = await read() + const msg = content.match(/export const msg = '(\w+)'/)[1] + server.hot.send('custom:foo', { msg }) + server.hot.send('custom:remove', { msg }) + } + }, + configureServer(server) { + server.hot.on('custom:remote-add', ({ a, b }, client) => { + client.send('custom:remote-add-result', { result: a + b }) + }) + }, + }, + virtualPlugin(), + transformCountPlugin(), + queryPlugin(), + ], +}) + +function virtualPlugin(): Plugin { + let num = 0 + return { + name: 'virtual-file', + resolveId(id, importer) { + if (id === 'virtual:file' || id === '\0virtual:file') { + return '\0virtual:file' + } + }, + load(id) { + if (id === '\0virtual:file') { + return `\ +import { virtual as _virtual } from "/importedVirtual.js"; +export const virtual = _virtual + '${num}';` + } + }, + configureServer(server) { + server.hot.on('virtual:increment', async () => { + const mod = await server.moduleGraph.getModuleByUrl('\0virtual:file') + if (mod) { + num++ + server.reloadModule(mod) + } + }) + }, + } +} + +function queryPlugin(): Plugin { + return { + name: 'query-resolver', + transform(code, id) { + if (id.includes('?query1')) { + return `export default ${JSON.stringify(code + 'query1')}` + } + + if (id.includes('?query2')) { + return `export default ${JSON.stringify(code + 'query2')}` + } + }, + } +} + +function transformCountPlugin(): Plugin { + let num = 0 + return { + name: 'transform-count', + transform(code) { + if (code.includes('__TRANSFORM_COUNT__')) { + return code.replace('__TRANSFORM_COUNT__', String(++num)) + } + }, + } +} diff --git a/playground/ssr-html/__tests__/ssr-html.spec.ts b/playground/ssr-html/__tests__/ssr-html.spec.ts index 80fd5726d31411..fa9d12c7f6da41 100644 --- a/playground/ssr-html/__tests__/ssr-html.spec.ts +++ b/playground/ssr-html/__tests__/ssr-html.spec.ts @@ -100,12 +100,28 @@ describe.runIf(isServe)('stacktrace', () => { } }) -test.runIf(isServe)('network-imports', async () => { - await execFileAsync( - 'node', - ['--experimental-network-imports', 'test-network-imports.js'], - { - cwd: fileURLToPath(new URL('..', import.meta.url)), - }, - ) +describe.runIf(isServe)('network-imports', () => { + test('with Vite SSR', async () => { + await execFileAsync( + 'node', + ['--experimental-network-imports', 'test-network-imports.js'], + { + cwd: fileURLToPath(new URL('..', import.meta.url)), + }, + ) + }) + + test('with Vite runtime', async () => { + await execFileAsync( + 'node', + [ + '--experimental-network-imports', + 'test-network-imports.js', + '--runtime', + ], + { + cwd: fileURLToPath(new URL('..', import.meta.url)), + }, + ) + }) }) diff --git a/playground/ssr-html/test-network-imports.js b/playground/ssr-html/test-network-imports.js index ddb80eff7f8ca7..91f84f6a3b3ea3 100644 --- a/playground/ssr-html/test-network-imports.js +++ b/playground/ssr-html/test-network-imports.js @@ -1,8 +1,8 @@ import assert from 'node:assert' import { fileURLToPath } from 'node:url' -import { createServer } from 'vite' +import { createServer, createViteRuntime } from 'vite' -async function runTest() { +async function runTest(useRuntime) { const server = await createServer({ configFile: false, root: fileURLToPath(new URL('.', import.meta.url)), @@ -10,9 +10,15 @@ async function runTest() { middlewareMode: true, }, }) - const mod = await server.ssrLoadModule('/src/network-imports.js') + let mod + if (useRuntime) { + const runtime = await createViteRuntime(server, { hmr: false }) + mod = await runtime.executeUrl('/src/network-imports.js') + } else { + mod = await server.ssrLoadModule('/src/network-imports.js') + } assert.equal(mod.slash('foo\\bar'), 'foo/bar') await server.close() } -runTest() +runTest(process.argv.includes('--runtime')) diff --git a/playground/test-utils.ts b/playground/test-utils.ts index 85f52a450f11ff..92123f08900896 100644 --- a/playground/test-utils.ts +++ b/playground/test-utils.ts @@ -32,6 +32,7 @@ export const ports = { ssr: 9600, 'ssr-deps': 9601, 'ssr-html': 9602, + 'ssr-hmr': 9609, // not imported but used in `ssr-hmr/vite.config.js` 'ssr-noexternal': 9603, 'ssr-pug': 9604, 'ssr-webworker': 9605, diff --git a/playground/vitestSetup.ts b/playground/vitestSetup.ts index cb4ab8f125a9df..ff2303dc498569 100644 --- a/playground/vitestSetup.ts +++ b/playground/vitestSetup.ts @@ -82,7 +82,11 @@ export function setViteUrl(url: string): void { beforeAll(async (s) => { const suite = s as File // skip browser setup for non-playground tests - if (!suite.filepath.includes('playground')) { + // TODO: ssr playground? + if ( + !suite.filepath.includes('playground') || + suite.filepath.includes('hmr-ssr') + ) { return } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ce74d1543bcd6..a7150128fce65f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -444,6 +444,22 @@ importers: packages/vite/src/node/ssr/__tests__/fixtures/cjs-ssr-dep: {} + packages/vite/src/node/ssr/runtime/__tests__: + dependencies: + '@vitejs/cjs-external': + specifier: link:./fixtures/cjs-external + version: link:fixtures/cjs-external + '@vitejs/esm-external': + specifier: link:./fixtures/esm-external + version: link:fixtures/esm-external + tinyspy: + specifier: 2.2.0 + version: 2.2.0 + + packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external: {} + + packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external: {} + playground: devDependencies: convert-source-map: @@ -714,6 +730,8 @@ importers: playground/hmr: {} + playground/hmr-ssr: {} + playground/html: {} playground/html/side-effects: {} @@ -8998,7 +9016,6 @@ packages: /tinyspy@2.2.0: resolution: {integrity: sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==} engines: {node: '>=14.0.0'} - dev: true /to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}