diff --git a/packages/vite/package.json b/packages/vite/package.json index 3281805976adbe..879b323e2af6e6 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..42c1d205365a58 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,35 @@ 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')) { + 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('runtime.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 +128,7 @@ function validateChunkImports(this: PluginContext, chunk: RenderedChunk) { !id.startsWith('./') && !id.startsWith('../') && !id.startsWith('node:') && + !id.startsWith('runtime.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..61d1b1b57207ba 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -15,6 +15,7 @@ export { optimizeDeps } from './optimizer' export { formatPostcssSourceMap, preprocessCSS } from './plugins/css' export { transformWithEsbuild } from './plugins/esbuild' export { buildErrorMessage } from './server/middlewares/error' +export { ssrFetchModule } from './ssr/ssrFetchModule' export * from './publicUtils' // additional types @@ -119,6 +120,16 @@ 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 { 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 fd03199f2e2cda..2399014488fdaa 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' @@ -73,6 +75,7 @@ import { errorMiddleware, prepareError } from './middlewares/error' import type { HMRBroadcaster, HmrOptions } from './hmr' import { createHMRBroadcaster, + createServerHMRChannel, getShortName, handleFileAddUnlink, handleHMRUpdate, @@ -291,6 +294,10 @@ export interface ViteDevServer { url: string, opts?: { fixStacktrace?: boolean }, ): Promise> + /** + * Fetch information about the module + */ + ssrFetchModule(id: string, importer?: string): Promise /** * Returns a fixed version of the given stack */ @@ -410,7 +417,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)) } @@ -493,6 +502,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/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/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..ef6d5d4df85621 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/has-error.js @@ -0,0 +1 @@ +throw new 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/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..34d84d8bcee1cd --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -0,0 +1,142 @@ +import { existsSync, readdirSync } from 'node:fs' +import { posix, win32 } from 'node:path' +import { describe, expect } from 'vitest' +import { isWindows } from '../utils' +import { createViteRuntimeTester } from './utils' + +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') + }) + + 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', + ) + // so it isn't transformed by Vitest + const _URL = URL + 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__/utils.ts b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts new file mode 100644 index 00000000000000..20d89a98687437 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts @@ -0,0 +1,121 @@ +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, 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 = {}, +): 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, + }, + }) + if (config.server?.watch) { + await waitForWatcher(t.server) + } + }) + + afterEach(async (t) => { + 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..aa6a567efe609f --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/esmRunner.ts @@ -0,0 +1,121 @@ +import { + ssrDynamicImportKey, + ssrExportAllKey, + ssrImportKey, + ssrImportMetaKey, + ssrModuleExportsKey, +} from './constants' +import type { + ResolvedResult, + SSRImportMetadata, + 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, + transformed: 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";' + transformed, + ) + + await initModule( + context[ssrModuleExportsKey], + context[ssrImportMetaKey], + context[ssrImportKey], + context[ssrDynamicImportKey], + context[ssrExportAllKey], + ) + + Object.freeze(context[ssrModuleExportsKey]) + } + + runExternalModule(filepath: string): Promise { + return import(filepath) + } + + processImport( + mod: Record, + fetchResult: ResolvedResult, + metadata?: SSRImportMetadata | undefined, + ): Record { + if (!fetchResult.externalize) { + return mod + } + const { id, type } = fetchResult + if (type === 'builtin') return mod + analyzeImportedModDifference(mod, id, type, metadata) + return proxyGuardOnlyEsm(mod, id, metadata) + } +} + +/** + * 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/hmrHandler.ts b/packages/vite/src/node/ssr/runtime/hmrHandler.ts new file mode 100644 index 00000000000000..e46547847ba975 --- /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(() => handleHMRUpdate(runtime, payload)) +} + +export async function handleHMRUpdate( + runtime: ViteRuntime, + payload: HMRPayload, +): Promise { + const hmrClient = runtime.hmrClient + if (!hmrClient) 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..979c00f589bebc --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/index.ts @@ -0,0 +1,24 @@ +// 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 { handleHMRUpdate, createHMRHandler } from './hmrHandler' + +export type { HMRLogger } from '../../../shared/hmr' +export type { + ViteModuleRunner, + ViteRuntimeModuleContext, + ModuleCache, + FetchResult, + FetchFunction, + ViteServerClientOptions, +} 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..82ad78e464672a --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/moduleCache.ts @@ -0,0 +1,132 @@ +import type { ModuleCache } from './types' +import { isWindows } from './utils' + +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 + } +} + +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..7ba05c3b7618d5 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts @@ -0,0 +1,53 @@ +import type { ViteDevServer } from '../../../index' +import { ViteRuntime } from '../runtime' +import { ESModulesRunner } from '../esmRunner' +import { createHMRHandler } from '../hmrHandler' +import type { ViteModuleRunner, ViteServerClientOptions } from '../types' +import type { HMRLogger } from '../../../../shared/hmr' +import { ServerHMRConnector } from './serverHmrConnector' + +interface MainThreadRuntimeOptions + extends Omit { + hmr?: + | false + | { + logger?: false | HMRLogger + } + 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, + } +} + +export async function createViteRuntime( + server: ViteDevServer, + options: MainThreadRuntimeOptions = {}, +): Promise { + const hmr = createHMROptions(server, options) + const runtime = new ViteRuntime( + { + ...options, + root: server.config.root, + fetchModule: server.ssrFetchModule, + hmr, + }, + options.runner || new ESModulesRunner(), + ) + + if (hmr) { + hmr.connection.onUpdate(createHMRHandler(runtime)) + } + + return runtime +} 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..d72f02c9353106 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/node/serverHmrConnector.ts @@ -0,0 +1,79 @@ +import type { CustomPayload, HMRPayload } from 'types/hmrPayload' +import type { HMRConnection } from '../../../../shared/hmr' +import type { ViteDevServer } from '../../../server' +import type { + HMRBroadcasterClient, + ServerHMRChannel, +} from '../../../server/hmr' + +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) + } +} + +export class ServerHMRConnector implements HMRConnection { + 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 + return () => { + this.handlers = this.handlers.filter((cb) => cb !== handler) + if (!this.handlers.length) { + this.connected = false + } + } + } +} 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..3d253e6b71be64 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/runtime.ts @@ -0,0 +1,329 @@ +import type { ViteHotContext } from 'types/hot' +import { HMRClient, HMRContext } from '../../../shared/hmr' +import { ModuleCacheMap } from './moduleCache' +import type { + FetchResult, + ImportMetaEnv, + ModuleCache, + ResolvedResult, + SSRImportMetadata, + ViteModuleRunner, + ViteRuntimeImportMeta, + ViteRuntimeModuleContext, + ViteServerClientOptions, +} from './types' +import { + cleanUrl, + createImportMetaEnvProxy, + isPrimitive, + isWindows, + posixDirname, + posixPathToFileHref, + posixResolve, + toWindowsPath, + unwrapId, +} from './utils' +import { + ssrDynamicImportKey, + ssrExportAllKey, + ssrImportKey, + ssrImportMetaKey, + ssrModuleExportsKey, +} from './constants' +import { silentConsole } from './hmrLogger' + +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 idToFileMap = new Map() + private envProxy: ImportMetaEnv + + constructor( + public options: ViteServerClientOptions, + public runner: ViteModuleRunner, + private debug?: ViteRuntimeDebugger, + ) { + this.moduleCache = options.moduleCache ?? new ModuleCacheMap(options.root) + this.envProxy = createImportMetaEnvProxy(options.environmentVariables) + 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) + ssrInvalidates?.forEach((id) => this.moduleCache.delete(id)) + return this.executeUrl(acceptedPath) + }, + ) + } + } + + /** + * URL to execute. Accepts file path, server path or id relative to the root. + */ + public async executeUrl(url: string): Promise { + 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, these are the modules that will be reloaded + */ + public async executeEntrypoint(url: string): Promise { + const fetchedModule = await this.cachedModule(url) + return await this.cachedRequest(url, fetchedModule, [], { + entrypoint: true, + }) + } + + public clearCache(): void { + this.moduleCache.clear() + this.idToFileMap.clear() + this.entrypoints.clear() + this.hmrClient?.clear() + } + + private processImport( + exports: Record, + fetchResult: ResolvedResult, + metadata?: SSRImportMetadata, + ) { + if (!this.runner.processImport) { + return exports + } + return this.runner.processImport(exports, fetchResult, 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, metadata) + 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 { + const normalized = this.idToFileMap.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 moduleId = this.moduleCache.normalize(fetchedModule.file || id) + const mod = this.moduleCache.getByModuleId(moduleId) + fetchedModule.id = moduleId + mod.meta = fetchedModule + this.idToFileMap.set(id, moduleId) + this.idToFileMap.set(unwrapId(id), moduleId) + return fetchedModule as ResolvedResult + } + + // override is allowed, consider this a public API + protected async directRequest( + id: string, + { file, externalize, code: transformed, id: moduleId }: ResolvedResult, + _callstack: string[], + metadata?: SSRImportMetadata, + ): Promise { + 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 }) + } + + const requestStubs = this.options.requestStubs || {} + if (id in requestStubs) return requestStubs[id] + + if (externalize) { + this.debug?.('[vite-runtime] externalizing', externalize) + const exports = await this.runner.runExternalModule(externalize, metadata) + mod.exports = exports + return exports + } + + if (transformed == 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: () => { + 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, transformed, metadata) + + 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) {} + } + } +} 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..24e002e84f7f1e --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/types.ts @@ -0,0 +1,119 @@ +import type { ViteHotContext } from 'types/hot' +import type { HMRConnection, HMRLogger } from '../../../shared/hmr' +import type { ModuleCacheMap } from './moduleCache' +import type { + ssrDynamicImportKey, + ssrExportAllKey, + ssrImportKey, + ssrImportMetaKey, + ssrModuleExportsKey, +} from './constants' + +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 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 { + runViteModule( + context: ViteRuntimeModuleContext, + code: string, + metadata?: SSRImportMetadata, + ): Promise + runExternalModule( + filepath: string, + metadata?: SSRImportMetadata, + ): Promise + /** + * This is called for every "import" (dynamic and static) statement and is not cached + */ + processImport?( + exports: Record, + fetchResult: ResolvedResult, + metadata?: SSRImportMetadata, + ): Record +} + +export interface ModuleCache { + promise?: Promise + exports?: any + evaluated?: boolean + resolving?: boolean + meta?: FetchResult + /** + * Module ids that imports this module + */ + importers?: Set + imports?: Set +} + +export interface FetchResult { + id?: string + code?: string + file?: string | null + externalize?: string + type?: 'module' | 'commonjs' | 'builtin' +} + +export interface ResolvedResult extends Omit { + id: string +} + +export type FetchFunction = ( + id: string, + importer?: string, +) => Promise + +export interface ViteServerClientOptions { + root: string + fetchModule: FetchFunction + environmentVariables?: Record + hmr?: + | false + | { + connection: HMRConnection + logger?: false | HMRLogger + } + moduleCache?: ModuleCacheMap + requestStubs?: Record +} + +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..1cb3f87b5bbaa8 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/utils.ts @@ -0,0 +1,262 @@ +import type { ImportMetaEnv } from './types' + +export const isWindows = + typeof process !== 'undefined' && process.platform === 'win32' + +// currently we copy this from '../../constants' - maybe we can inline it somewhow? +const NULL_BYTE_PLACEHOLDER = `__x00__` +const VALID_ID_PREFIX = `/@id/` + +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, '/') +} + +export const queryRE = /\?.*$/s +export const hashRE = /#.*$/s + +export function cleanUrl(url: string): string { + return url.replace(hashRE, '').replace(queryRE, '') +} + +const _envShim = Object.create(null) + +const _getEnv = (environmentVariables?: Record) => + globalThis.process?.env || + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore "env" in meta is not typed for SSR code + import.meta.env || + // @ts-expect-error Deno global is not typed + globalThis.Deno?.env.toObject() || + environmentVariables + +export function createImportMetaEnvProxy( + environmentVariables?: Record, +): ImportMetaEnv { + const booleanKeys = ['DEV', 'PROD', 'SSR'] + return new Proxy(process.env, { + get(_, key) { + if (typeof key !== 'string') return undefined + const env = _getEnv(environmentVariables) + if (booleanKeys.includes(key)) return !!env[key] + return env[key] ?? _envShim[key] + }, + has(_, key) { + const env = _getEnv(environmentVariables) + return key in env || key in _envShim + }, + set(_, key, value) { + if (typeof key !== 'string') return true + + if (booleanKeys.includes(key)) { + value = value ? '1' : '' + } + + const env = _getEnv(environmentVariables) || _envShim + env[key] = value + return true + }, + deleteProperty(_, prop) { + if (!prop) { + return false + } + const env = _getEnv(environmentVariables) || _envShim + delete env[prop as any] + return true + }, + ownKeys() { + const env = _getEnv(environmentVariables) || _envShim + return Object.keys(env) + }, + }) as ImportMetaEnv +} + +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..9981becc2647dd --- /dev/null +++ b/packages/vite/src/node/ssr/ssrFetchModule.ts @@ -0,0 +1,157 @@ +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, isFilePathESM, unwrapId } from '../utils' +import type { FetchResult } from './runtime/types' + +interface NodeImportResolveOptions + extends InternalResolveOptionsWithOverrideConditions { + legacyProxySsrExternalModules?: boolean + packageCache?: PackageCache +} + +interface FetchModuleOptions { + /** + * @default true + */ + inlineSourceMap?: + | boolean + | ((mod: ModuleNode, result: TransformResult) => TransformResult) +} + +export async function ssrFetchModule( + server: ViteDevServer, + rawId: string, + importer?: string, + options: FetchModuleOptions = {}, +): Promise { + // builtins should always be externalized + if (rawId.startsWith('data:') || isBuiltin(rawId)) { + return { externalize: rawId, type: 'builtin' } + } + + if (rawId[0] !== '.' && rawId[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( + rawId, + importer, + { ...resolveOptions, tryEsmOnly: true }, + false, + undefined, + true, + ) + if (!resolved) { + const err: any = new Error( + `Cannot find module '${rawId}' imported from '${importer}'`, + ) + err.code = 'ERR_MODULE_NOT_FOUND' + throw err + } + const url = pathToFileURL(resolved.id).toString() + const type = isFilePathESM(url, server.config.packageCache) + ? 'module' + : 'commonjs' + return { externalize: url, type } + } + + const id = unwrapId(rawId) + + const mod = await server.moduleGraph.ensureEntryFromUrl(id, true) + let result = await server.transformRequest(id, { ssr: true }) + + if (!result) { + throw new Error( + `[vite] transform failed for module '${id}'${ + importer ? ` imported from ${importer}` : '' + }.`, + ) + } + + if (typeof options.inlineSourceMap === 'function') { + result = options.inlineSourceMap(mod, result) + } else if (options.inlineSourceMap !== false) { + result = inlineSourceMap(mod, result) + } + + // remove shebang + if (result.code[0] === '#') + result.code = result.code.replace(/^#!.*/, (s) => ' '.repeat(s.length)) + + return { code: result.code, file: mod.file, id } +} + +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` + +// 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 +})() + +function inlineSourceMap(mod: ModuleNode, result: TransformResult) { + 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, '') + + // this assumes that "new AsyncFunction" is used to create the module + const moduleSourceMap = Object.assign({}, map, { + // currently we need to offset the line + // https://github.com/nodejs/node/issues/43047#issuecomment-1180632750 + mappings: ';'.repeat(fnDeclarationLineCount) + map.mappings, + }) + + const sourceMap = Buffer.from( + JSON.stringify(moduleSourceMap), + '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/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..577d3611396d97 --- /dev/null +++ b/playground/hmr-ssr/__tests__/hmr.spec.ts @@ -0,0 +1,1073 @@ +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') + }) + + // 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..ea84a40e44a7b0 --- /dev/null +++ b/playground/hmr-ssr/hmr.ts @@ -0,0 +1,111 @@ +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 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..122559a692ef20 --- /dev/null +++ b/playground/hmr-ssr/modules.d.ts @@ -0,0 +1,3 @@ +declare module 'virtual:file' { + export const virtual: 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..35e0799c262738 --- /dev/null +++ b/playground/hmr-ssr/package.json @@ -0,0 +1,12 @@ +{ + "name": "@vitejs/test-hmr", + "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/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..02465735d16611 --- /dev/null +++ b/playground/hmr-ssr/vite.config.ts @@ -0,0 +1,68 @@ +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(), + ], +}) + +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 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/test-utils.ts b/playground/test-utils.ts index 741a11386078b7..5d8ba21bf05faf 100644 --- a/playground/test-utils.ts +++ b/playground/test-utils.ts @@ -38,6 +38,7 @@ export const ports = { 'proxy-hmr': 9606, // not imported but used in `proxy-hmr/vite.config.js` 'proxy-hmr/other-app': 9607, // not imported but used in `proxy-hmr/other-app/vite.config.js` 'ssr-conditions': 9608, + 'ssr-hmr': 9609, // not imported but used in `ssr-hmr/vite.config.js` 'css/postcss-caching': 5005, 'css/postcss-plugins-different-dir': 5006, 'css/dynamic-import': 5007, 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 66cfa9efcd4f3c..5666a668be1be1 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: {} @@ -9010,7 +9028,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==}