From 3ba74367d77ef734a056e16660ce5070edfbc3b3 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 19 Jan 2024 16:46:52 +0100 Subject: [PATCH 01/33] feat: add vite-node into the core --- packages/vite/package.json | 13 +- packages/vite/rollup.config.ts | 8 +- packages/vite/rollup.dts.config.ts | 54 +- packages/vite/src/node/index.ts | 11 + packages/vite/src/node/server/hmr.ts | 76 ++ packages/vite/src/node/server/index.ts | 14 +- .../node/ssr/runtime/__tests__/fixtures/a.ts | 1 + .../node/ssr/runtime/__tests__/fixtures/b.ts | 1 + .../ssr/runtime/__tests__/fixtures/basic.js | 3 + .../node/ssr/runtime/__tests__/fixtures/c.ts | 7 + .../__tests__/fixtures/circular/circular-a.js | 2 + .../__tests__/fixtures/circular/circular-b.js | 2 + .../fixtures/circular/circular-index.js | 8 + .../fixtures/cjs-external-existing.js | 3 + .../fixtures/cjs-external-non-existing.js | 4 + .../__tests__/fixtures/cjs-external/index.cjs | 3 + .../fixtures/cjs-external/package.json | 7 + .../node/ssr/runtime/__tests__/fixtures/d.ts | 7 + .../__tests__/fixtures/dynamic-import.js | 14 + .../fixtures/esm-external-existing.js | 3 + .../fixtures/esm-external-non-existing.js | 4 + .../__tests__/fixtures/esm-external/index.mjs | 1 + .../fixtures/esm-external/package.json | 7 + .../runtime/__tests__/fixtures/has-error.js | 1 + .../ssr/runtime/__tests__/fixtures/hmr.js | 1 + .../runtime/__tests__/fixtures/installed.js | 1 + .../ssr/runtime/__tests__/fixtures/native.js | 3 + .../ssr/runtime/__tests__/fixtures/simple.js | 3 + .../ssr/runtime/__tests__/fixtures/virtual.js | 4 + .../node/ssr/runtime/__tests__/package.json | 10 + .../ssr/runtime/__tests__/server-hmr.spec.ts | 41 + .../runtime/__tests__/server-no-hmr.spec.ts | 20 + .../runtime/__tests__/server-runtime.spec.ts | 142 +++ .../src/node/ssr/runtime/__tests__/utils.ts | 121 ++ .../vite/src/node/ssr/runtime/constants.ts | 6 + .../vite/src/node/ssr/runtime/esmRunner.ts | 121 ++ .../vite/src/node/ssr/runtime/hmrHandler.ts | 115 ++ .../vite/src/node/ssr/runtime/hmrLogger.ts | 8 + packages/vite/src/node/ssr/runtime/index.ts | 24 + .../vite/src/node/ssr/runtime/moduleCache.ts | 132 ++ .../ssr/runtime/node/mainThreadRuntime.ts | 53 + .../ssr/runtime/node/serverHmrConnector.ts | 79 ++ packages/vite/src/node/ssr/runtime/runtime.ts | 329 +++++ packages/vite/src/node/ssr/runtime/types.ts | 119 ++ packages/vite/src/node/ssr/runtime/utils.ts | 262 ++++ packages/vite/src/node/ssr/ssrFetchModule.ts | 157 +++ packages/vite/src/shared/hmr.ts | 11 +- packages/vite/types/hmrPayload.d.ts | 2 + playground/hmr-ssr/__tests__/hmr.spec.ts | 1073 +++++++++++++++++ .../dynamic-imports/deps-all-accepted.ts | 14 + .../dynamic-imports/deps-some-accepted.ts | 14 + .../dynamic-imports/dynamic-imports.ts | 9 + .../accept-exports/dynamic-imports/index.ts | 1 + .../accept-exports/export-from/depA.ts | 1 + .../accept-exports/export-from/export-from.ts | 8 + .../hmr-ssr/accept-exports/export-from/hub.ts | 1 + .../accept-exports/export-from/index.html | 3 + .../accept-exports/main-accepted/callback.ts | 7 + .../accept-exports/main-accepted/dep.ts | 1 + .../accept-exports/main-accepted/index.ts | 1 + .../main-accepted/main-accepted.ts | 7 + .../accept-exports/main-accepted/target.ts | 16 + .../main-non-accepted/default.ts | 11 + .../accept-exports/main-non-accepted/dep.ts | 1 + .../accept-exports/main-non-accepted/index.ts | 1 + .../main-non-accepted/main-non-accepted.ts | 4 + .../accept-exports/main-non-accepted/named.ts | 11 + .../reexports.bak/accept-named.ts | 10 + .../accept-exports/reexports.bak/index.html | 1 + .../accept-exports/reexports.bak/reexports.ts | 5 + .../accept-exports/reexports.bak/source.ts | 2 + .../accept-exports/side-effects/index.ts | 1 + .../side-effects/side-effects.ts | 13 + .../star-imports/deps-all-accepted.ts | 14 + .../star-imports/deps-some-accepted.ts | 14 + .../accept-exports/star-imports/index.ts | 1 + .../star-imports/star-imports.ts | 6 + .../accept-exports/unused-exports/index.html | 1 + .../accept-exports/unused-exports/index.ts | 4 + .../accept-exports/unused-exports/unused.ts | 11 + .../accept-exports/unused-exports/used.ts | 9 + playground/hmr-ssr/circular/index.js | 7 + playground/hmr-ssr/circular/mod-a.js | 5 + playground/hmr-ssr/circular/mod-b.js | 3 + playground/hmr-ssr/circular/mod-c.js | 11 + playground/hmr-ssr/counter/dep.ts | 4 + playground/hmr-ssr/counter/index.ts | 11 + playground/hmr-ssr/customFile.js | 1 + playground/hmr-ssr/event.d.ts | 17 + .../hmr-ssr/file-delete-restore/child.js | 11 + .../hmr-ssr/file-delete-restore/index.js | 4 + .../hmr-ssr/file-delete-restore/parent.js | 12 + .../hmr-ssr/file-delete-restore/runtime.js | 15 + playground/hmr-ssr/hmr.ts | 111 ++ playground/hmr-ssr/hmrDep.js | 14 + playground/hmr-ssr/hmrNestedDep.js | 1 + playground/hmr-ssr/importedVirtual.js | 1 + playground/hmr-ssr/importing-updated/a.js | 9 + playground/hmr-ssr/importing-updated/b.js | 10 + playground/hmr-ssr/importing-updated/index.js | 2 + .../intermediate-file-delete/display.js | 1 + .../hmr-ssr/intermediate-file-delete/index.js | 21 + .../intermediate-file-delete/re-export.js | 1 + playground/hmr-ssr/invalidation/child.js | 9 + playground/hmr-ssr/invalidation/parent.js | 9 + playground/hmr-ssr/logo.svg | 3 + playground/hmr-ssr/missing-import/a.js | 3 + playground/hmr-ssr/missing-import/index.js | 1 + playground/hmr-ssr/missing-import/main.js | 1 + playground/hmr-ssr/modules.d.ts | 3 + playground/hmr-ssr/optional-chaining/child.js | 1 + .../hmr-ssr/optional-chaining/parent.js | 8 + playground/hmr-ssr/package.json | 12 + .../hmr-ssr/self-accept-within-circular/a.js | 5 + .../hmr-ssr/self-accept-within-circular/b.js | 7 + .../hmr-ssr/self-accept-within-circular/c.js | 12 + .../self-accept-within-circular/index.js | 3 + playground/hmr-ssr/soft-invalidation/child.js | 1 + playground/hmr-ssr/soft-invalidation/index.js | 4 + playground/hmr-ssr/vite.config.ts | 68 ++ playground/test-utils.ts | 1 + playground/vitestSetup.ts | 6 +- pnpm-lock.yaml | 19 +- 123 files changed, 3722 insertions(+), 20 deletions(-) create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/a.ts create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/b.ts create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/basic.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/c.ts create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/circular/circular-a.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/circular/circular-b.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/circular/circular-index.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external-existing.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external-non-existing.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external/index.cjs create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/cjs-external/package.json create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/d.ts create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/dynamic-import.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external-existing.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external-non-existing.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external/index.mjs create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/esm-external/package.json create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/has-error.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/hmr.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/installed.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/native.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/simple.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/virtual.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/package.json create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/server-no-hmr.spec.ts create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/utils.ts create mode 100644 packages/vite/src/node/ssr/runtime/constants.ts create mode 100644 packages/vite/src/node/ssr/runtime/esmRunner.ts create mode 100644 packages/vite/src/node/ssr/runtime/hmrHandler.ts create mode 100644 packages/vite/src/node/ssr/runtime/hmrLogger.ts create mode 100644 packages/vite/src/node/ssr/runtime/index.ts create mode 100644 packages/vite/src/node/ssr/runtime/moduleCache.ts create mode 100644 packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts create mode 100644 packages/vite/src/node/ssr/runtime/node/serverHmrConnector.ts create mode 100644 packages/vite/src/node/ssr/runtime/runtime.ts create mode 100644 packages/vite/src/node/ssr/runtime/types.ts create mode 100644 packages/vite/src/node/ssr/runtime/utils.ts create mode 100644 packages/vite/src/node/ssr/ssrFetchModule.ts create mode 100644 playground/hmr-ssr/__tests__/hmr.spec.ts create mode 100644 playground/hmr-ssr/accept-exports/dynamic-imports/deps-all-accepted.ts create mode 100644 playground/hmr-ssr/accept-exports/dynamic-imports/deps-some-accepted.ts create mode 100644 playground/hmr-ssr/accept-exports/dynamic-imports/dynamic-imports.ts create mode 100644 playground/hmr-ssr/accept-exports/dynamic-imports/index.ts create mode 100644 playground/hmr-ssr/accept-exports/export-from/depA.ts create mode 100644 playground/hmr-ssr/accept-exports/export-from/export-from.ts create mode 100644 playground/hmr-ssr/accept-exports/export-from/hub.ts create mode 100644 playground/hmr-ssr/accept-exports/export-from/index.html create mode 100644 playground/hmr-ssr/accept-exports/main-accepted/callback.ts create mode 100644 playground/hmr-ssr/accept-exports/main-accepted/dep.ts create mode 100644 playground/hmr-ssr/accept-exports/main-accepted/index.ts create mode 100644 playground/hmr-ssr/accept-exports/main-accepted/main-accepted.ts create mode 100644 playground/hmr-ssr/accept-exports/main-accepted/target.ts create mode 100644 playground/hmr-ssr/accept-exports/main-non-accepted/default.ts create mode 100644 playground/hmr-ssr/accept-exports/main-non-accepted/dep.ts create mode 100644 playground/hmr-ssr/accept-exports/main-non-accepted/index.ts create mode 100644 playground/hmr-ssr/accept-exports/main-non-accepted/main-non-accepted.ts create mode 100644 playground/hmr-ssr/accept-exports/main-non-accepted/named.ts create mode 100644 playground/hmr-ssr/accept-exports/reexports.bak/accept-named.ts create mode 100644 playground/hmr-ssr/accept-exports/reexports.bak/index.html create mode 100644 playground/hmr-ssr/accept-exports/reexports.bak/reexports.ts create mode 100644 playground/hmr-ssr/accept-exports/reexports.bak/source.ts create mode 100644 playground/hmr-ssr/accept-exports/side-effects/index.ts create mode 100644 playground/hmr-ssr/accept-exports/side-effects/side-effects.ts create mode 100644 playground/hmr-ssr/accept-exports/star-imports/deps-all-accepted.ts create mode 100644 playground/hmr-ssr/accept-exports/star-imports/deps-some-accepted.ts create mode 100644 playground/hmr-ssr/accept-exports/star-imports/index.ts create mode 100644 playground/hmr-ssr/accept-exports/star-imports/star-imports.ts create mode 100644 playground/hmr-ssr/accept-exports/unused-exports/index.html create mode 100644 playground/hmr-ssr/accept-exports/unused-exports/index.ts create mode 100644 playground/hmr-ssr/accept-exports/unused-exports/unused.ts create mode 100644 playground/hmr-ssr/accept-exports/unused-exports/used.ts create mode 100644 playground/hmr-ssr/circular/index.js create mode 100644 playground/hmr-ssr/circular/mod-a.js create mode 100644 playground/hmr-ssr/circular/mod-b.js create mode 100644 playground/hmr-ssr/circular/mod-c.js create mode 100644 playground/hmr-ssr/counter/dep.ts create mode 100644 playground/hmr-ssr/counter/index.ts create mode 100644 playground/hmr-ssr/customFile.js create mode 100644 playground/hmr-ssr/event.d.ts create mode 100644 playground/hmr-ssr/file-delete-restore/child.js create mode 100644 playground/hmr-ssr/file-delete-restore/index.js create mode 100644 playground/hmr-ssr/file-delete-restore/parent.js create mode 100644 playground/hmr-ssr/file-delete-restore/runtime.js create mode 100644 playground/hmr-ssr/hmr.ts create mode 100644 playground/hmr-ssr/hmrDep.js create mode 100644 playground/hmr-ssr/hmrNestedDep.js create mode 100644 playground/hmr-ssr/importedVirtual.js create mode 100644 playground/hmr-ssr/importing-updated/a.js create mode 100644 playground/hmr-ssr/importing-updated/b.js create mode 100644 playground/hmr-ssr/importing-updated/index.js create mode 100644 playground/hmr-ssr/intermediate-file-delete/display.js create mode 100644 playground/hmr-ssr/intermediate-file-delete/index.js create mode 100644 playground/hmr-ssr/intermediate-file-delete/re-export.js create mode 100644 playground/hmr-ssr/invalidation/child.js create mode 100644 playground/hmr-ssr/invalidation/parent.js create mode 100644 playground/hmr-ssr/logo.svg create mode 100644 playground/hmr-ssr/missing-import/a.js create mode 100644 playground/hmr-ssr/missing-import/index.js create mode 100644 playground/hmr-ssr/missing-import/main.js create mode 100644 playground/hmr-ssr/modules.d.ts create mode 100644 playground/hmr-ssr/optional-chaining/child.js create mode 100644 playground/hmr-ssr/optional-chaining/parent.js create mode 100644 playground/hmr-ssr/package.json create mode 100644 playground/hmr-ssr/self-accept-within-circular/a.js create mode 100644 playground/hmr-ssr/self-accept-within-circular/b.js create mode 100644 playground/hmr-ssr/self-accept-within-circular/c.js create mode 100644 playground/hmr-ssr/self-accept-within-circular/index.js create mode 100644 playground/hmr-ssr/soft-invalidation/child.js create mode 100644 playground/hmr-ssr/soft-invalidation/index.js create mode 100644 playground/hmr-ssr/vite.config.ts 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==} From c0de2ffb8817cd7d66593a3e5ddb80ca3abb4dff Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Sat, 20 Jan 2024 18:21:34 +0100 Subject: [PATCH 02/33] fix: normalize entry url before calling fetchModule --- .../runtime/__tests__/server-runtime.spec.ts | 18 +++++++++ packages/vite/src/node/ssr/runtime/runtime.ts | 37 ++++++++++++++++++- packages/vite/src/node/ssr/runtime/utils.ts | 6 +++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index 34d84d8bcee1cd..9e8522a5f97179 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -1,15 +1,33 @@ import { existsSync, readdirSync } from 'node:fs' import { posix, win32 } from 'node:path' +import { fileURLToPath } from 'node:url' import { describe, expect } from 'vitest' import { isWindows } from '../utils' import { createViteRuntimeTester } from './utils' +const _URL = URL + describe('vite-runtime initialization', async () => { const it = await createViteRuntimeTester() it('correctly runs ssr code', async ({ runtime }) => { const mod = await runtime.executeUrl('/fixtures/simple.js') expect(mod.test).toEqual('I am initialized') + + // loads the same module if id is a file url + const fileUrl = new _URL('./fixtures/simple.js', import.meta.url) + const mod2 = await runtime.executeUrl(fileUrl.toString()) + expect(mod).toBe(mod2) + + // loads the same module if id is a file path + const filePath = fileURLToPath(fileUrl) + const mod3 = await runtime.executeUrl(filePath) + expect(mod).toBe(mod3) + }) + + it('can load virtual modules as an entry point', async ({ runtime }) => { + const mod = await runtime.executeEntrypoint('virtual:test') + expect(mod.msg).toBe('virtual') }) it('exports is not modifiable', async ({ runtime }) => { diff --git a/packages/vite/src/node/ssr/runtime/runtime.ts b/packages/vite/src/node/ssr/runtime/runtime.ts index 3d253e6b71be64..11b7e4dfb9f656 100644 --- a/packages/vite/src/node/ssr/runtime/runtime.ts +++ b/packages/vite/src/node/ssr/runtime/runtime.ts @@ -22,6 +22,7 @@ import { posixResolve, toWindowsPath, unwrapId, + wrapId, } from './utils' import { ssrDynamicImportKey, @@ -74,8 +75,9 @@ export class ViteRuntime { * URL to execute. Accepts file path, server path or id relative to the root. */ public async executeUrl(url: string): Promise { + url = this.normalizeEntryUrl(url) const fetchedModule = await this.cachedModule(url) - return await this.cachedRequest(url, fetchedModule, []) + return await this.cachedRequest(url, fetchedModule) } /** @@ -83,6 +85,7 @@ export class ViteRuntime { * In the case of a full reload triggered by HMR, these are the modules that will be reloaded */ public async executeEntrypoint(url: string): Promise { + url = this.normalizeEntryUrl(url) const fetchedModule = await this.cachedModule(url) return await this.cachedRequest(url, fetchedModule, [], { entrypoint: true, @@ -96,6 +99,36 @@ export class ViteRuntime { this.hmrClient?.clear() } + // we don't use moduleCache.normalize because this URL doesn't have to follow the same rules + // this URL is something that user passes down manually, and is later resolved by fetchModule + // moduleCache.normalize is used on resolved "file" property + private normalizeEntryUrl(url: string) { + // expect fetchModule to resolve relative module correctly + if (url[0] === '.') { + return url + } + // file:///C:/root/id.js -> C:/root/id.js + if (url.startsWith('file://')) { + // 8 is the length of "file:///" + url = url.slice(isWindows ? 8 : 7) + } + url = url.replace(/\\/g, '/') + const _root = this.options.root + const root = _root[_root.length - 1] === '/' ? _root : `${_root}/` + // strip root from the URL because fetchModule prefers a public served url path + // packages/vite/src/node/server/moduleGraph.ts:17 + if (url.startsWith(root)) { + // /root/id.js -> /id.js + // C:/root/id.js -> /id.js + // 1 is to keep the leading slash + return url.slice(root.length - 1) + } + // if it's a server url (starts with a slash), keep it, otherwise assume a virtual module + // /id.js -> /id.js + // virtual:custom -> /@id/virtual:custom + return url[0] === '/' ? url : wrapId(url) + } + private processImport( exports: Record, fetchResult: ResolvedResult, @@ -110,7 +143,7 @@ export class ViteRuntime { private async cachedRequest( id: string, fetchedModule: ResolvedResult, - callstack: string[], + callstack: string[] = [], metadata?: SSRImportMetadata, ): Promise { const moduleId = fetchedModule.id diff --git a/packages/vite/src/node/ssr/runtime/utils.ts b/packages/vite/src/node/ssr/runtime/utils.ts index 1cb3f87b5bbaa8..cf2dd29a8a10f5 100644 --- a/packages/vite/src/node/ssr/runtime/utils.ts +++ b/packages/vite/src/node/ssr/runtime/utils.ts @@ -7,6 +7,12 @@ export const isWindows = const NULL_BYTE_PLACEHOLDER = `__x00__` const VALID_ID_PREFIX = `/@id/` +export function wrapId(id: string): string { + return id.startsWith(VALID_ID_PREFIX) + ? id + : VALID_ID_PREFIX + id.replace('\0', NULL_BYTE_PLACEHOLDER) +} + export function unwrapId(id: string): string { return id.startsWith(VALID_ID_PREFIX) ? id.slice(VALID_ID_PREFIX.length).replace(NULL_BYTE_PLACEHOLDER, '\0') From 24238106f3aa462cb823ae3713ba7c51b1b6b0d6 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Sun, 21 Jan 2024 16:58:41 +0100 Subject: [PATCH 03/33] refactor: expose more types, fix caching with the query issue, require onUpdate --- .../ssr/runtime/__tests__/fixtures/assets.js | 4 ++ .../__tests__/fixtures/assets/placeholder.mov | 0 .../__tests__/fixtures/assets/placeholder.png | 0 .../__tests__/fixtures/assets/placeholder.txt | 0 .../fixtures/assets/placeholder.webp | 0 .../ssr/runtime/__tests__/fixtures/test.css | 3 + .../__tests__/fixtures/test.module.css | 3 + .../runtime/__tests__/server-runtime.spec.ts | 55 +++++++++++++++ .../vite/src/node/ssr/runtime/esmRunner.ts | 4 +- packages/vite/src/node/ssr/runtime/index.ts | 5 +- .../ssr/runtime/node/mainThreadRuntime.ts | 9 +-- packages/vite/src/node/ssr/runtime/runtime.ts | 23 ++++--- packages/vite/src/node/ssr/runtime/types.ts | 13 ++-- packages/vite/src/node/ssr/runtime/utils.ts | 69 ++++++++++--------- 14 files changed, 130 insertions(+), 58 deletions(-) create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.mov create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.png create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.txt create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.webp create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/test.css create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/test.module.css diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets.js new file mode 100644 index 00000000000000..01723f914c90b7 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets.js @@ -0,0 +1,4 @@ +export { default as mov } from './assets/placeholder.mov' +export { default as txt } from './assets/placeholder.txt' +export { default as png } from './assets/placeholder.png' +export { default as webp } from './assets/placeholder.webp' diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.mov b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.mov new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.png b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.png new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.txt b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.txt new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.webp b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/assets/placeholder.webp new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/test.css b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/test.css new file mode 100644 index 00000000000000..6446ebfd427495 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/test.css @@ -0,0 +1,3 @@ +.test { + color: red; +} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/test.module.css b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/test.module.css new file mode 100644 index 00000000000000..6446ebfd427495 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/test.module.css @@ -0,0 +1,3 @@ +.test { + color: red; +} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index 9e8522a5f97179..560ae9255e1703 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -30,6 +30,61 @@ describe('vite-runtime initialization', async () => { expect(mod.msg).toBe('virtual') }) + it('css is loaded correctly', async ({ runtime }) => { + const css = await runtime.executeUrl('/fixtures/test.css') + expect(css.default).toMatchInlineSnapshot(` + ".test { + color: red; + } + " + `) + const module = await runtime.executeUrl('/fixtures/test.module.css') + expect(module).toMatchObject({ + default: { + test: expect.stringMatching(/^_test_/), + }, + test: expect.stringMatching(/^_test_/), + }) + }) + + it('assets are loaded correctly', async ({ runtime }) => { + const assets = await runtime.executeUrl('/fixtures/assets.js') + expect(assets).toMatchObject({ + mov: '/fixtures/assets/placeholder.mov', + txt: '/fixtures/assets/placeholder.txt', + png: '/fixtures/assets/placeholder.png', + webp: '/fixtures/assets/placeholder.webp', + }) + }) + + it('ids with Vite queries are loaded correctly', async ({ runtime }) => { + const raw = await runtime.executeUrl('/fixtures/simple.js?raw') + expect(raw.default).toMatchInlineSnapshot(` + "export const test = 'I am initialized' + + import.meta.hot?.accept() + " + `) + const url = await runtime.executeUrl('/fixtures/simple.js?url') + expect(url.default).toMatchInlineSnapshot(`"/fixtures/simple.js"`) + const inline = await runtime.executeUrl('/fixtures/test.css?inline') + expect(inline.default).toMatchInlineSnapshot(` + ".test { + color: red; + } + " + `) + }) + + it('modules with query strings are treated as different modules', async ({ + runtime, + }) => { + const modSimple = await runtime.executeUrl('/fixtures/simple.js') + const modUrl = await runtime.executeUrl('/fixtures/simple.js?url') + expect(modSimple).not.toBe(modUrl) + expect(modUrl.default).toBe('/fixtures/simple.js') + }) + it('exports is not modifiable', async ({ runtime }) => { const mod = await runtime.executeUrl('/fixtures/simple.js') expect(() => { diff --git a/packages/vite/src/node/ssr/runtime/esmRunner.ts b/packages/vite/src/node/ssr/runtime/esmRunner.ts index aa6a567efe609f..f082b4187f39c9 100644 --- a/packages/vite/src/node/ssr/runtime/esmRunner.ts +++ b/packages/vite/src/node/ssr/runtime/esmRunner.ts @@ -18,7 +18,7 @@ const AsyncFunction = async function () {}.constructor as typeof Function export class ESModulesRunner implements ViteModuleRunner { async runViteModule( context: ViteRuntimeModuleContext, - transformed: string, + code: string, ): Promise { // use AsyncFunction instead of vm module to support broader array of environments out of the box const initModule = new AsyncFunction( @@ -28,7 +28,7 @@ export class ESModulesRunner implements ViteModuleRunner { ssrDynamicImportKey, ssrExportAllKey, // source map should already be inlined by Vite - '"use strict";' + transformed, + '"use strict";' + code, ) await initModule( diff --git a/packages/vite/src/node/ssr/runtime/index.ts b/packages/vite/src/node/ssr/runtime/index.ts index 979c00f589bebc..331faefb31dbe0 100644 --- a/packages/vite/src/node/ssr/runtime/index.ts +++ b/packages/vite/src/node/ssr/runtime/index.ts @@ -6,13 +6,16 @@ export { ESModulesRunner } from './esmRunner' export { handleHMRUpdate, createHMRHandler } from './hmrHandler' -export type { HMRLogger } from '../../../shared/hmr' +export type { HMRLogger, HMRConnection } from '../../../shared/hmr' export type { ViteModuleRunner, ViteRuntimeModuleContext, ModuleCache, FetchResult, FetchFunction, + ResolvedResult, + SSRImportMetadata, + ViteRuntimeImportMeta, ViteServerClientOptions, } from './types' export { diff --git a/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts b/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts index 7ba05c3b7618d5..08157c5c0b54da 100644 --- a/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts +++ b/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts @@ -1,7 +1,6 @@ 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' @@ -35,7 +34,7 @@ export async function createViteRuntime( options: MainThreadRuntimeOptions = {}, ): Promise { const hmr = createHMROptions(server, options) - const runtime = new ViteRuntime( + return new ViteRuntime( { ...options, root: server.config.root, @@ -44,10 +43,4 @@ export async function createViteRuntime( }, options.runner || new ESModulesRunner(), ) - - if (hmr) { - hmr.connection.onUpdate(createHMRHandler(runtime)) - } - - return runtime } diff --git a/packages/vite/src/node/ssr/runtime/runtime.ts b/packages/vite/src/node/ssr/runtime/runtime.ts index 11b7e4dfb9f656..b049368f1145a1 100644 --- a/packages/vite/src/node/ssr/runtime/runtime.ts +++ b/packages/vite/src/node/ssr/runtime/runtime.ts @@ -32,6 +32,7 @@ import { ssrModuleExportsKey, } from './constants' import { silentConsole } from './hmrLogger' +import { createHMRHandler } from './hmrHandler' interface ViteRuntimeDebugger { (formatter: unknown, ...args: unknown[]): void @@ -46,7 +47,7 @@ export class ViteRuntime { public hmrClient?: HMRClient public entrypoints = new Set() - private idToFileMap = new Map() + private idToUrlMap = new Map() private envProxy: ImportMetaEnv constructor( @@ -68,6 +69,7 @@ export class ViteRuntime { return this.executeUrl(acceptedPath) }, ) + options.hmr.connection.onUpdate(createHMRHandler(this)) } } @@ -94,7 +96,7 @@ export class ViteRuntime { public clearCache(): void { this.moduleCache.clear() - this.idToFileMap.clear() + this.idToUrlMap.clear() this.entrypoints.clear() this.hmrClient?.clear() } @@ -204,7 +206,7 @@ export class ViteRuntime { id: string, importer?: string, ): Promise { - const normalized = this.idToFileMap.get(id) + const normalized = this.idToUrlMap.get(id) if (normalized) { const mod = this.moduleCache.getByModuleId(normalized) if (mod.meta) { @@ -219,19 +221,22 @@ export class ViteRuntime { // 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 idQuery = id.split('?')[1] + const query = idQuery ? `?${idQuery}` : '' + const fullFile = fetchedModule.file ? `${fetchedModule.file}${query}` : id + const moduleId = this.moduleCache.normalize(fullFile) const mod = this.moduleCache.getByModuleId(moduleId) fetchedModule.id = moduleId mod.meta = fetchedModule - this.idToFileMap.set(id, moduleId) - this.idToFileMap.set(unwrapId(id), moduleId) + this.idToUrlMap.set(id, moduleId) + this.idToUrlMap.set(unwrapId(id), moduleId) return fetchedModule as ResolvedResult } // override is allowed, consider this a public API protected async directRequest( id: string, - { file, externalize, code: transformed, id: moduleId }: ResolvedResult, + { file, externalize, code, id: moduleId }: ResolvedResult, _callstack: string[], metadata?: SSRImportMetadata, ): Promise { @@ -267,7 +272,7 @@ export class ViteRuntime { return exports } - if (transformed == null) { + if (code == null) { const importer = callstack[callstack.length - 2] throw new Error( `[vite-runtime] Failed to load "${id}"${ @@ -330,7 +335,7 @@ export class ViteRuntime { this.debug?.('[vite-runtime] executing', href) - await this.runner.runViteModule(context, transformed, metadata) + await this.runner.runViteModule(context, code, id, metadata) return exports } diff --git a/packages/vite/src/node/ssr/runtime/types.ts b/packages/vite/src/node/ssr/runtime/types.ts index 24e002e84f7f1e..c5465983363d33 100644 --- a/packages/vite/src/node/ssr/runtime/types.ts +++ b/packages/vite/src/node/ssr/runtime/types.ts @@ -1,4 +1,5 @@ import type { ViteHotContext } from 'types/hot' +import type { HMRPayload } from 'types/hmrPayload' import type { HMRConnection, HMRLogger } from '../../../shared/hmr' import type { ModuleCacheMap } from './moduleCache' import type { @@ -22,6 +23,10 @@ export interface DefineImportMetadata { importedNames?: string[] } +export interface HMRRuntimeConnection extends HMRConnection { + onUpdate(callback: (payload: HMRPayload) => void): void +} + export interface SSRImportMetadata extends DefineImportMetadata { isDynamicImport?: boolean entrypoint?: boolean @@ -49,12 +54,10 @@ export interface ViteModuleRunner { runViteModule( context: ViteRuntimeModuleContext, code: string, + id: string, metadata?: SSRImportMetadata, ): Promise - runExternalModule( - filepath: string, - metadata?: SSRImportMetadata, - ): Promise + runExternalModule(file: string, metadata?: SSRImportMetadata): Promise /** * This is called for every "import" (dynamic and static) statement and is not cached */ @@ -102,7 +105,7 @@ export interface ViteServerClientOptions { hmr?: | false | { - connection: HMRConnection + connection: HMRRuntimeConnection logger?: false | HMRLogger } moduleCache?: ModuleCacheMap diff --git a/packages/vite/src/node/ssr/runtime/utils.ts b/packages/vite/src/node/ssr/runtime/utils.ts index cf2dd29a8a10f5..625905a6c9a43c 100644 --- a/packages/vite/src/node/ssr/runtime/utils.ts +++ b/packages/vite/src/node/ssr/runtime/utils.ts @@ -46,41 +46,44 @@ 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' : '' - } + return new Proxy( + {}, + { + 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) + 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 + ) as ImportMetaEnv } export function isPrimitive(value: unknown): boolean { From a91fc5ae684c1ec050cc74150046ca9758677301 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Sun, 21 Jan 2024 17:01:26 +0100 Subject: [PATCH 04/33] chore: export HMRRuntimeConnection --- packages/vite/src/node/ssr/runtime/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vite/src/node/ssr/runtime/index.ts b/packages/vite/src/node/ssr/runtime/index.ts index 331faefb31dbe0..64941a7a7e8dc9 100644 --- a/packages/vite/src/node/ssr/runtime/index.ts +++ b/packages/vite/src/node/ssr/runtime/index.ts @@ -6,7 +6,7 @@ export { ESModulesRunner } from './esmRunner' export { handleHMRUpdate, createHMRHandler } from './hmrHandler' -export type { HMRLogger, HMRConnection } from '../../../shared/hmr' +export type { HMRLogger } from '../../../shared/hmr' export type { ViteModuleRunner, ViteRuntimeModuleContext, @@ -15,6 +15,7 @@ export type { FetchFunction, ResolvedResult, SSRImportMetadata, + HMRRuntimeConnection, ViteRuntimeImportMeta, ViteServerClientOptions, } from './types' From 8f4b6d1eb6dbfedf58a30e8aa7dc61e57fc0777b Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 22 Jan 2024 10:26:53 +0100 Subject: [PATCH 05/33] refactor: remove cleanup function from onUpdate --- .../src/node/ssr/runtime/node/serverHmrConnector.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/vite/src/node/ssr/runtime/node/serverHmrConnector.ts b/packages/vite/src/node/ssr/runtime/node/serverHmrConnector.ts index d72f02c9353106..9a19192a2c0db7 100644 --- a/packages/vite/src/node/ssr/runtime/node/serverHmrConnector.ts +++ b/packages/vite/src/node/ssr/runtime/node/serverHmrConnector.ts @@ -1,10 +1,10 @@ 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' +import type { HMRRuntimeConnection } from '../types' class ServerHMRBroadcasterClient implements HMRBroadcasterClient { constructor(private readonly hmrChannel: ServerHMRChannel) {} @@ -29,7 +29,7 @@ class ServerHMRBroadcasterClient implements HMRBroadcasterClient { } } -export class ServerHMRConnector implements HMRConnection { +export class ServerHMRConnector implements HMRRuntimeConnection { private handlers: ((payload: HMRPayload) => void)[] = [] private hmrChannel: ServerHMRChannel private hmrClient: ServerHMRBroadcasterClient @@ -65,15 +65,9 @@ export class ServerHMRConnector implements HMRConnection { ) } - onUpdate(handler: (payload: HMRPayload) => void): () => void { + 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 - } - } } } From 090246e3bca2435b9ed8ee836100277970dd84c7 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 22 Jan 2024 10:31:30 +0100 Subject: [PATCH 06/33] refactor: remove processImport from the public API --- .../vite/src/node/ssr/runtime/esmRunner.ts | 80 +------------------ packages/vite/src/node/ssr/runtime/runtime.ts | 66 ++++++++++++++- packages/vite/src/node/ssr/runtime/types.ts | 8 -- 3 files changed, 65 insertions(+), 89 deletions(-) diff --git a/packages/vite/src/node/ssr/runtime/esmRunner.ts b/packages/vite/src/node/ssr/runtime/esmRunner.ts index f082b4187f39c9..a9aacd8fbea13f 100644 --- a/packages/vite/src/node/ssr/runtime/esmRunner.ts +++ b/packages/vite/src/node/ssr/runtime/esmRunner.ts @@ -5,12 +5,7 @@ import { ssrImportMetaKey, ssrModuleExportsKey, } from './constants' -import type { - ResolvedResult, - SSRImportMetadata, - ViteModuleRunner, - ViteRuntimeModuleContext, -} from './types' +import type { ViteModuleRunner, ViteRuntimeModuleContext } from './types' // eslint-disable-next-line @typescript-eslint/no-empty-function const AsyncFunction = async function () {}.constructor as typeof Function @@ -45,77 +40,4 @@ export class ESModulesRunner implements ViteModuleRunner { 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/runtime.ts b/packages/vite/src/node/ssr/runtime/runtime.ts index b049368f1145a1..8e85fcb18698ae 100644 --- a/packages/vite/src/node/ssr/runtime/runtime.ts +++ b/packages/vite/src/node/ssr/runtime/runtime.ts @@ -136,10 +136,13 @@ export class ViteRuntime { fetchResult: ResolvedResult, metadata?: SSRImportMetadata, ) { - if (!this.runner.processImport) { + if (!fetchResult.externalize) { return exports } - return this.runner.processImport(exports, fetchResult, metadata) + const { id, type } = fetchResult + if (type === 'builtin') return exports + analyzeImportedModDifference(exports, id, type, metadata) + return proxyGuardOnlyEsm(exports, id, metadata) } private async cachedRequest( @@ -365,3 +368,62 @@ function exportAll(exports: any, sourceModule: any) { } } } + +/** + * 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/types.ts b/packages/vite/src/node/ssr/runtime/types.ts index c5465983363d33..b642e96d4ffc69 100644 --- a/packages/vite/src/node/ssr/runtime/types.ts +++ b/packages/vite/src/node/ssr/runtime/types.ts @@ -58,14 +58,6 @@ export interface ViteModuleRunner { metadata?: SSRImportMetadata, ): Promise runExternalModule(file: 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 { From 95434b4fb036044650c4213e61f1fcf471d7e510 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 22 Jan 2024 12:31:32 +0100 Subject: [PATCH 07/33] test: add test for HMR on files imported with queries --- packages/vite/src/node/ssr/runtime/runtime.ts | 21 ++++++++++++++++++- playground/hmr-ssr/__tests__/hmr.spec.ts | 12 +++++++++++ playground/hmr-ssr/hmr.ts | 1 + playground/hmr-ssr/modules.d.ts | 8 +++++++ playground/hmr-ssr/queries/index.js | 9 ++++++++ playground/hmr-ssr/queries/multi-query.js | 0 playground/hmr-ssr/vite.config.ts | 16 ++++++++++++++ 7 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 playground/hmr-ssr/queries/index.js create mode 100644 playground/hmr-ssr/queries/multi-query.js diff --git a/packages/vite/src/node/ssr/runtime/runtime.ts b/packages/vite/src/node/ssr/runtime/runtime.ts index 8e85fcb18698ae..0a8c50c22cab9a 100644 --- a/packages/vite/src/node/ssr/runtime/runtime.ts +++ b/packages/vite/src/node/ssr/runtime/runtime.ts @@ -48,6 +48,7 @@ export class ViteRuntime { public entrypoints = new Set() private idToUrlMap = new Map() + private fileToIdMap = new Map() private envProxy: ImportMetaEnv constructor( @@ -65,7 +66,9 @@ export class ViteRuntime { options.hmr.connection, ({ acceptedPath, ssrInvalidates }) => { this.moduleCache.delete(acceptedPath) - ssrInvalidates?.forEach((id) => this.moduleCache.delete(id)) + if (ssrInvalidates) { + this.invalidateFiles(ssrInvalidates) + } return this.executeUrl(acceptedPath) }, ) @@ -101,6 +104,15 @@ export class ViteRuntime { this.hmrClient?.clear() } + private invalidateFiles(files: string[]) { + files.forEach((file) => { + const ids = this.fileToIdMap.get(file) + if (ids) { + ids.forEach((id) => this.moduleCache.deleteByModuleId(id)) + } + }) + } + // we don't use moduleCache.normalize because this URL doesn't have to follow the same rules // this URL is something that user passes down manually, and is later resolved by fetchModule // moduleCache.normalize is used on resolved "file" property @@ -231,6 +243,13 @@ export class ViteRuntime { const mod = this.moduleCache.getByModuleId(moduleId) fetchedModule.id = moduleId mod.meta = fetchedModule + + if (fetchedModule.file) { + const fileModules = this.fileToIdMap.get(fetchedModule.file) || [] + fileModules.push(moduleId) + this.fileToIdMap.set(fetchedModule.file, fileModules) + } + this.idToUrlMap.set(id, moduleId) this.idToUrlMap.set(unwrapId(id), moduleId) return fetchedModule as ResolvedResult diff --git a/playground/hmr-ssr/__tests__/hmr.spec.ts b/playground/hmr-ssr/__tests__/hmr.spec.ts index 577d3611396d97..d4b2d65abd2b39 100644 --- a/playground/hmr-ssr/__tests__/hmr.spec.ts +++ b/playground/hmr-ssr/__tests__/hmr.spec.ts @@ -233,6 +233,18 @@ describe('hmr works correctly', () => { await untilUpdated(() => el(), '3') }) + test('queries are correctly resolved', async () => { + const query1 = () => hmr('query1') + const query2 = () => hmr('query2') + + expect(query1()).toBe('query1') + expect(query2()).toBe('query2') + + editFile('queries/multi-query.js', (code) => code + '//comment') + await untilUpdated(() => query1(), '//commentquery1') + await untilUpdated(() => query2(), '//commentquery2') + }) + // TODO // test.skipIf(hasWindowsUnicodeFsBug)('full-reload encodeURI path', async () => { // await page.goto( diff --git a/playground/hmr-ssr/hmr.ts b/playground/hmr-ssr/hmr.ts index ea84a40e44a7b0..cd88dbd47bb6c6 100644 --- a/playground/hmr-ssr/hmr.ts +++ b/playground/hmr-ssr/hmr.ts @@ -6,6 +6,7 @@ import './file-delete-restore' import './optional-chaining/parent' import './intermediate-file-delete' import './circular' +import './queries' import logo from './logo.svg' import { msg as softInvalidationMsg } from './soft-invalidation' diff --git a/playground/hmr-ssr/modules.d.ts b/playground/hmr-ssr/modules.d.ts index 122559a692ef20..815c25568d5119 100644 --- a/playground/hmr-ssr/modules.d.ts +++ b/playground/hmr-ssr/modules.d.ts @@ -1,3 +1,11 @@ declare module 'virtual:file' { export const virtual: string } +declare module '*?query1' { + const string: string + export default string +} +declare module '*?query2' { + const string: string + export default string +} diff --git a/playground/hmr-ssr/queries/index.js b/playground/hmr-ssr/queries/index.js new file mode 100644 index 00000000000000..113eb1a079af40 --- /dev/null +++ b/playground/hmr-ssr/queries/index.js @@ -0,0 +1,9 @@ +import query1 from './multi-query?query1' +import query2 from './multi-query?query2' + +hmr('query1', query1) +hmr('query2', query2) + +function hmr(key, value) { + globalThis.__HMR__[key] = String(value) +} diff --git a/playground/hmr-ssr/queries/multi-query.js b/playground/hmr-ssr/queries/multi-query.js new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/playground/hmr-ssr/vite.config.ts b/playground/hmr-ssr/vite.config.ts index 02465735d16611..5b4a7c17fe27cb 100644 --- a/playground/hmr-ssr/vite.config.ts +++ b/playground/hmr-ssr/vite.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ }, virtualPlugin(), transformCountPlugin(), + queryPlugin(), ], }) @@ -55,6 +56,21 @@ export const virtual = _virtual + '${num}';` } } +function queryPlugin(): Plugin { + return { + name: 'query-resolver', + transform(code, id) { + if (id.includes('?query1')) { + return `export default ${JSON.stringify(code + 'query1')}` + } + + if (id.includes('?query2')) { + return `export default ${JSON.stringify(code + 'query2')}` + } + }, + } +} + function transformCountPlugin(): Plugin { let num = 0 return { From a6fd97b0552c8082e959fc53dd18f51992e988f0 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 22 Jan 2024 16:34:38 +0100 Subject: [PATCH 08/33] feat: support source map --- .../runtime/__tests__/fixtures/has-error.js | 2 +- .../__tests__/fixtures/throws-error-method.ts | 7 + .../runtime/__tests__/server-runtime.spec.ts | 53 ++- .../vite/src/node/ssr/runtime/moduleCache.ts | 24 +- .../ssr/runtime/node/mainThreadRuntime.ts | 8 + packages/vite/src/node/ssr/runtime/runtime.ts | 4 + .../node/ssr/runtime/source-map/decoder.ts | 268 ++++++++++++ .../src/node/ssr/runtime/source-map/index.ts | 25 ++ .../ssr/runtime/source-map/interceptor.ts | 411 ++++++++++++++++++ packages/vite/src/node/ssr/runtime/types.ts | 8 + packages/vite/src/node/ssr/runtime/utils.ts | 5 + packages/vite/src/node/ssr/ssrFetchModule.ts | 1 + 12 files changed, 813 insertions(+), 3 deletions(-) create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixtures/throws-error-method.ts create mode 100644 packages/vite/src/node/ssr/runtime/source-map/decoder.ts create mode 100644 packages/vite/src/node/ssr/runtime/source-map/index.ts create mode 100644 packages/vite/src/node/ssr/runtime/source-map/interceptor.ts 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 index ef6d5d4df85621..77d0c0c87aed71 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/has-error.js +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/has-error.js @@ -1 +1 @@ -throw new Error() +throw new Error('module error') diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/throws-error-method.ts b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/throws-error-method.ts new file mode 100644 index 00000000000000..3f5c23e4c01de4 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/throws-error-method.ts @@ -0,0 +1,7 @@ +interface Foo { + bar: string +} + +export function throwError(foo?: Foo): void { + throw new Error('method error') +} diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index 560ae9255e1703..1ae5d7be6f1490 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -3,7 +3,8 @@ import { posix, win32 } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect } from 'vitest' import { isWindows } from '../utils' -import { createViteRuntimeTester } from './utils' +import type { ViteRuntime } from '../runtime' +import { createViteRuntimeTester, editFile, resolvePath } from './utils' const _URL = URL @@ -99,6 +100,56 @@ describe('vite-runtime initialization', async () => { ) }) + const getError = async (cb: () => void): Promise => { + try { + await cb() + expect.unreachable() + } catch (err) { + return err + } + } + const serializeStack = (runtime: ViteRuntime, err: Error) => { + return err.stack!.split('\n')[1].replace(runtime.options.root, '') + } + + it.only('source maps are correctly applied to stack traces', async ({ + runtime, + server, + }) => { + expect.assertions(3) + const topLevelError = await getError(() => + runtime.executeUrl('/fixtures/has-error.js'), + ) + expect(serializeStack(runtime, topLevelError)).toBe( + ' at /fixtures/has-error.js:2:7', + ) + + const methodError = await getError(async () => { + const mod = await runtime.executeUrl('/fixtures/throws-error-method.ts') + mod.throwError() + }) + expect(serializeStack(runtime, methodError)).toBe( + ' at Module.throwError (/fixtures/throws-error-method.ts:6:9)', + ) + + // simulate HMR + editFile( + resolvePath(import.meta.url, './fixtures/throws-error-method.ts'), + (code) => '\n\n\n\n\n' + code + '\n', + ) + runtime.moduleCache.clear() + server.moduleGraph.invalidateAll() + + const methodErrorNew = await getError(async () => { + const mod = await runtime.executeUrl('/fixtures/throws-error-method.ts') + mod.throwError() + }) + + expect(serializeStack(runtime, methodErrorNew)).toBe( + ' at Module.throwError (/fixtures/throws-error-method.ts:11:9)', + ) + }) + it('throws the same error', async ({ runtime }) => { expect.assertions(3) const s = Symbol() diff --git a/packages/vite/src/node/ssr/runtime/moduleCache.ts b/packages/vite/src/node/ssr/runtime/moduleCache.ts index 82ad78e464672a..84e6c71d05cdb8 100644 --- a/packages/vite/src/node/ssr/runtime/moduleCache.ts +++ b/packages/vite/src/node/ssr/runtime/moduleCache.ts @@ -1,5 +1,14 @@ +import { DecodedMap } from './source-map/decoder' import type { ModuleCache } from './types' -import { isWindows } from './utils' +import { decodeBase64, isWindows } from './utils' + +let SOURCEMAPPING_URL = 'sourceMa' +SOURCEMAPPING_URL += 'ppingURL' + +const VITE_RUNTIME_SOURCEMAPPING_URL = `${SOURCEMAPPING_URL}=data:application/json;charset=utf-8` +const VITE_RUNTIME_SOURCEMAPPING_REGEXP = new RegExp( + `//# ${VITE_RUNTIME_SOURCEMAPPING_URL};base64,(.+)`, +) export class ModuleCacheMap extends Map { private root: string @@ -93,6 +102,19 @@ export class ModuleCacheMap extends Map { } return invalidated } + + getSourceMap(moduleId: string): null | DecodedMap { + const mod = this.get(moduleId) + if (mod.map) return mod.map + if (!mod.meta || !mod.meta.code) return null + const mapString = mod.meta.code.match( + VITE_RUNTIME_SOURCEMAPPING_REGEXP, + )?.[1] + if (!mapString) return null + const baseFile = mod.meta.file || moduleId.split('?')[0] + mod.map = new DecodedMap(JSON.parse(decodeBase64(mapString)), baseFile) + return mod.map + } } function withTrailingSlash(path: string): string { diff --git a/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts b/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts index 08157c5c0b54da..f16d95da9b130c 100644 --- a/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts +++ b/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts @@ -1,3 +1,4 @@ +import { existsSync, readFileSync } from 'node:fs' import type { ViteDevServer } from '../../../index' import { ViteRuntime } from '../runtime' import { ESModulesRunner } from '../esmRunner' @@ -40,6 +41,13 @@ export async function createViteRuntime( root: server.config.root, fetchModule: server.ssrFetchModule, hmr, + sourcemapInterceptor: options.sourcemapInterceptor ?? { + retrieveFile: (id) => { + if (existsSync(id)) { + return readFileSync(id, 'utf-8') + } + }, + }, }, options.runner || new ESModulesRunner(), ) diff --git a/packages/vite/src/node/ssr/runtime/runtime.ts b/packages/vite/src/node/ssr/runtime/runtime.ts index 0a8c50c22cab9a..386c72b463958c 100644 --- a/packages/vite/src/node/ssr/runtime/runtime.ts +++ b/packages/vite/src/node/ssr/runtime/runtime.ts @@ -33,6 +33,7 @@ import { } from './constants' import { silentConsole } from './hmrLogger' import { createHMRHandler } from './hmrHandler' +import { enableSourceMapSupport } from './source-map/index' interface ViteRuntimeDebugger { (formatter: unknown, ...args: unknown[]): void @@ -74,6 +75,9 @@ export class ViteRuntime { ) options.hmr.connection.onUpdate(createHMRHandler(this)) } + if (options.sourcemapInterceptor !== false) { + enableSourceMapSupport(this) + } } /** diff --git a/packages/vite/src/node/ssr/runtime/source-map/decoder.ts b/packages/vite/src/node/ssr/runtime/source-map/decoder.ts new file mode 100644 index 00000000000000..520088823f1fd7 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/source-map/decoder.ts @@ -0,0 +1,268 @@ +import type { Bias, Needle, OriginalMapping } from '@jridgewell/trace-mapping' +import { posixResolve } from '../utils' + +interface SourceMapLike { + version: number + mappings?: string + names?: string[] + sources?: string[] + sourcesContent?: string[] +} + +export class DecodedMap { + _encoded: string + _decoded: undefined | number[][][] + _decodedMemo: Stats + url: string + version: number + names: string[] = [] + resolvedSources: string[] + + constructor( + public map: SourceMapLike, + from: string, + ) { + const { mappings, names, sources } = map + this.version = map.version + this.names = names || [] + this._encoded = mappings || '' + this._decodedMemo = memoizedState() + this.url = from + this.resolvedSources = (sources || []).map((s) => + posixResolve(s || '', from), + ) + } +} + +// This is a copy of all methods that we need for decoding a source map from "@jridgewell/trace-mapping" + +function indexOf(mappings: string, index: number) { + const idx = mappings.indexOf(';', index) + return idx === -1 ? mappings.length : idx +} + +const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' +const charToInt = new Uint8Array(128) // z is 122 in ASCII +for (let i = 0; i < chars.length; i++) { + const c = chars.charCodeAt(i) + charToInt[c] = i +} + +function decodeInteger( + mappings: string, + pos: number, + state: Int32Array, + j: number, +) { + let value = 0 + let shift = 0 + let integer = 0 + do { + const c = mappings.charCodeAt(pos++) + integer = charToInt[c] + value |= (integer & 31) << shift + shift += 5 + } while (integer & 32) + const shouldNegate = value & 1 + value >>>= 1 + if (shouldNegate) { + value = -0x80000000 | -value + } + state[j] += value + return pos +} + +const comma = ','.charCodeAt(0) + +function hasMoreVlq(mappings: string, i: number, length: number) { + if (i >= length) return false + return mappings.charCodeAt(i) !== comma +} + +function decode(mappings: string): number[][][] { + const state = new Int32Array(5) + const decoded: number[][][] = [] + let index = 0 + do { + const semi = indexOf(mappings, index) + const line = [] + let sorted = true + let lastCol = 0 + state[0] = 0 + for (let i = index; i < semi; i++) { + let seg + i = decodeInteger(mappings, i, state, 0) // genColumn + const col = state[0] + if (col < lastCol) sorted = false + lastCol = col + if (hasMoreVlq(mappings, i, semi)) { + i = decodeInteger(mappings, i, state, 1) // sourcesIndex + i = decodeInteger(mappings, i, state, 2) // sourceLine + i = decodeInteger(mappings, i, state, 3) // sourceColumn + if (hasMoreVlq(mappings, i, semi)) { + i = decodeInteger(mappings, i, state, 4) // namesIndex + seg = [col, state[1], state[2], state[3], state[4]] + } else { + seg = [col, state[1], state[2], state[3]] + } + } else { + seg = [col] + } + line.push(seg) + } + if (!sorted) line.sort((a, b) => a[0] - b[0]) + decoded.push(line) + index = semi + 1 + } while (index <= mappings.length) + return decoded +} + +const LINE_GTR_ZERO = '`line` must be greater than 0 (lines start at line 1)' +const COL_GTR_EQ_ZERO = + '`column` must be greater than or equal to 0 (columns start at column 0)' + +const COLUMN = 0 +const SOURCES_INDEX = 1 +const SOURCE_LINE = 2 +const SOURCE_COLUMN = 3 +const NAMES_INDEX = 4 +const LEAST_UPPER_BOUND = -1 +const GREATEST_LOWER_BOUND = 1 + +function OMapping( + source: string | null, + line: number, + column: number, + name: string | null, +): OriginalMapping { + return { source, line, column, name } +} + +function decodedMappings(map: DecodedMap): number[][][] { + return map._decoded || (map._decoded = decode(map._encoded)) +} + +let found = false +function binarySearch( + haystack: number[][], + needle: number, + low: number, + high: number, +) { + while (low <= high) { + const mid = low + ((high - low) >> 1) + const cmp = haystack[mid][COLUMN] - needle + if (cmp === 0) { + found = true + return mid + } + if (cmp < 0) { + low = mid + 1 + } else { + high = mid - 1 + } + } + found = false + return low - 1 +} + +function upperBound(haystack: number[][], needle: number, index: number) { + for (let i = index + 1; i < haystack.length; index = i++) { + if (haystack[i][COLUMN] !== needle) break + } + return index +} +function lowerBound(haystack: number[][], needle: number, index: number) { + for (let i = index - 1; i >= 0; index = i--) { + if (haystack[i][COLUMN] !== needle) break + } + return index +} +interface Stats { + lastKey: number + lastNeedle: number + lastIndex: number +} +function memoizedState(): Stats { + return { + lastKey: -1, + lastNeedle: -1, + lastIndex: -1, + } +} +function memoizedBinarySearch( + haystack: number[][], + needle: number, + state: Stats, + key: number, +) { + const { lastKey, lastNeedle, lastIndex } = state + let low = 0 + let high = haystack.length - 1 + if (key === lastKey) { + if (needle === lastNeedle) { + found = lastIndex !== -1 && haystack[lastIndex][COLUMN] === needle + return lastIndex + } + if (needle >= lastNeedle) { + // lastIndex may be -1 if the previous needle was not found. + low = lastIndex === -1 ? 0 : lastIndex + } else { + high = lastIndex + } + } + state.lastKey = key + state.lastNeedle = needle + return (state.lastIndex = binarySearch(haystack, needle, low, high)) +} + +function traceSegmentInternal( + segments: number[][], + memo: Stats, + line: number, + column: number, + bias: Bias, +) { + let index = memoizedBinarySearch(segments, column, memo, line) + if (found) { + index = (bias === LEAST_UPPER_BOUND ? upperBound : lowerBound)( + segments, + column, + index, + ) + } else if (bias === LEAST_UPPER_BOUND) index++ + if (index === -1 || index === segments.length) return -1 + return index +} + +export function getOriginalPosition( + map: DecodedMap, + { line, column, bias }: Needle, +): OriginalMapping | null { + line-- + if (line < 0) throw new Error(LINE_GTR_ZERO) + if (column < 0) throw new Error(COL_GTR_EQ_ZERO) + map._decodedMemo ??= memoizedState() + const decoded = decodedMappings(map) + // It's common for parent source maps to have pointers to lines that have no + // mapping (like a "//# sourceMappingURL=") at the end of the child file. + if (line >= decoded.length) return null + const segments = decoded[line] + const index = traceSegmentInternal( + segments, + map._decodedMemo, + line, + column, + bias || GREATEST_LOWER_BOUND, + ) + if (index === -1) return null + const segment = segments[index] + if (segment.length === 1) return null + const { names, resolvedSources } = map + return OMapping( + resolvedSources[segment[SOURCES_INDEX]], + segment[SOURCE_LINE] + 1, + segment[SOURCE_COLUMN], + segment.length === 5 ? names[segment[NAMES_INDEX]] : null, + ) +} diff --git a/packages/vite/src/node/ssr/runtime/source-map/index.ts b/packages/vite/src/node/ssr/runtime/source-map/index.ts new file mode 100644 index 00000000000000..810060bdf69bc3 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/source-map/index.ts @@ -0,0 +1,25 @@ +import type { ViteRuntime } from '../runtime' +import { interceptStackTrace } from './interceptor' + +export function enableSourceMapSupport(runtime: ViteRuntime): void { + if (runtime.options.sourcemapInterceptor === 'node') { + if (typeof process === 'undefined') { + throw new TypeError( + `Cannot use "sourcemapInterceptor: 'node'" because global "process" variable is not available.`, + ) + } + if (typeof process.setSourceMapsEnabled !== 'function') { + throw new TypeError( + `Cannot use "sourcemapInterceptor: 'node'" because "process.setSourceMapsEnabled" function is not available. Please use Node >= 16.6.0.`, + ) + } + process.setSourceMapsEnabled(true) + return + } + interceptStackTrace( + runtime, + typeof runtime.options.sourcemapInterceptor === 'object' + ? runtime.options.sourcemapInterceptor + : undefined, + ) +} diff --git a/packages/vite/src/node/ssr/runtime/source-map/interceptor.ts b/packages/vite/src/node/ssr/runtime/source-map/interceptor.ts new file mode 100644 index 00000000000000..61fab334aba5ee --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/source-map/interceptor.ts @@ -0,0 +1,411 @@ +import type { OriginalMapping } from '@jridgewell/trace-mapping' +import type { ViteRuntime } from '../runtime' +import { posixDirname, posixResolve } from '../utils' +import { DecodedMap, getOriginalPosition } from './decoder' + +interface CallSite extends NodeJS.CallSite { + getScriptNameOrSourceURL(): string +} + +interface State { + nextPosition: null | OriginalMapping + curPosition: null | OriginalMapping +} + +interface CachedMapEntry { + url: string | null + map: DecodedMap | null + vite?: boolean +} +const sourceMapCache: Record = {} +const fileContentsCache: Record = {} + +// Support URLs relative to a directory, but be careful about a protocol prefix +function supportRelativeURL(file: string, url: string) { + if (!file) return url + const dir = posixDirname(file.replace(/\\/g, '/')) + const match = /^\w+:\/\/[^/]*/.exec(dir) + let protocol = match ? match[0] : '' + const startPath = dir.slice(protocol.length) + if (protocol && /^\/\w:/.test(startPath)) { + // handle file:///C:/ paths + protocol += '/' + return ( + protocol + + posixResolve(dir.slice(protocol.length), url).replace(/\\/g, '/') + ) + } + return protocol + posixResolve(dir.slice(protocol.length), url) +} + +function getRuntimeSourceMap( + runtime: ViteRuntime, + position: OriginalMapping, +): CachedMapEntry | null { + const sourceMap = runtime.moduleCache.getSourceMap(position.source as string) + if (sourceMap) { + return { + url: position.source, + map: sourceMap, + vite: true, + } + } + return null +} + +function retrieveFile( + path: string, + options: InterceptorOptions, +): string | null | undefined | false { + if (!options.retrieveFile) return null + if (path in fileContentsCache) return fileContentsCache[path] + const content = options.retrieveFile(path) + if (typeof content === 'string') { + fileContentsCache[path] = content + return content + } + return null +} + +function retrieveSourceMapURL(source: string, options: InterceptorOptions) { + // Get the URL of the source map + const fileData = retrieveFile(source, options) + if (!fileData) return null + const re = + /\/\/[@#]\s*sourceMappingURL=([^\s'"]+)\s*$|\/\*[@#]\s*sourceMappingURL=[^\s*'"]+\s*\*\/\s*$/gm + // Keep executing the search to find the *last* sourceMappingURL to avoid + // picking up sourceMappingURLs from comments, strings, etc. + let lastMatch, match + + while ((match = re.exec(fileData))) lastMatch = match + if (!lastMatch) return null + return lastMatch[1] +} + +const reSourceMap = /^data:application\/json[^,]+base64,/ + +function retrieveSourceMap(source: string, options: InterceptorOptions) { + if (options.retrieveSourceMap) { + const urlAndMap = options.retrieveSourceMap(source) + if (urlAndMap) return urlAndMap + } + + let sourceMappingURL = retrieveSourceMapURL(source, options) + if (!sourceMappingURL) return null + + // Read the contents of the source map + let sourceMapData + if (reSourceMap.test(sourceMappingURL)) { + // Support source map URL as a data url + const rawData = sourceMappingURL.slice(sourceMappingURL.indexOf(',') + 1) + sourceMapData = Buffer.from(rawData, 'base64').toString() + sourceMappingURL = source + } else { + // Support source map URLs relative to the source URL + sourceMappingURL = supportRelativeURL(source, sourceMappingURL) + sourceMapData = retrieveFile(sourceMappingURL, options) + } + + if (!sourceMapData) return null + + return { + url: sourceMappingURL, + map: sourceMapData, + } +} + +function mapSourcePosition( + runtime: ViteRuntime, + options: InterceptorOptions, + position: OriginalMapping, +) { + if (!position.source) return position + let sourceMap = getRuntimeSourceMap(runtime, position) + if (!sourceMap) sourceMap = sourceMapCache[position.source] + if (!sourceMap) { + // Call the (overrideable) retrieveSourceMap function to get the source map. + const urlAndMap = retrieveSourceMap(position.source, options) + if (urlAndMap && urlAndMap.map) { + const url = urlAndMap.url + sourceMap = sourceMapCache[position.source] = { + url, + map: new DecodedMap( + typeof urlAndMap.map === 'string' + ? JSON.parse(urlAndMap.map) + : urlAndMap.map, + url, + ), + } + + const contents = sourceMap.map?.map.sourcesContent + // Load all sources stored inline with the source map into the file cache + // to pretend like they are already loaded. They may not exist on disk. + if (sourceMap.map && contents) { + sourceMap.map.resolvedSources.forEach((source, i) => { + const content = contents[i] + if (content && source && url) { + const contentUrl = supportRelativeURL(url, source) + fileContentsCache[contentUrl] = content + } + }) + } + } else { + sourceMap = sourceMapCache[position.source] = { + url: null, + map: null, + } + } + } + + // Resolve the source URL relative to the URL of the source map + if (sourceMap && sourceMap.map && sourceMap.url) { + const originalPosition = getOriginalPosition(sourceMap.map, position) + + // Only return the original position if a matching line was found. If no + // matching line is found then we return position instead, which will cause + // the stack trace to print the path and line for the compiled file. It is + // better to give a precise location in the compiled file than a vague + // location in the original file. + if (originalPosition && originalPosition.source != null) { + originalPosition.source = supportRelativeURL( + sourceMap.url, + originalPosition.source, + ) + if (sourceMap.vite) { + // @ts-expect-error vite is not defined + originalPosition._vite = true + } + return originalPosition + } + } + + return position +} + +// Parses code generated by FormatEvalOrigin(), a function inside V8: +// https://code.google.com/p/v8/source/browse/trunk/src/messages.js +function mapEvalOrigin( + runtime: ViteRuntime, + options: InterceptorOptions, + origin: string, +): string { + // Most eval() calls are in this format + let match = /^eval at ([^(]+) \((.+):(\d+):(\d+)\)$/.exec(origin) + if (match) { + const position = mapSourcePosition(runtime, options, { + name: null, + source: match[2], + line: +match[3], + column: +match[4] - 1, + }) + return `eval at ${match[1]} (${position.source}:${position.line}:${position.column + 1})` + } + + // Parse nested eval() calls using recursion + match = /^eval at ([^(]+) \((.+)\)$/.exec(origin) + if (match) + return `eval at ${match[1]} (${mapEvalOrigin(runtime, options, match[2])})` + + // Make sure we still return useful information if we didn't find anything + return origin +} + +// This is copied almost verbatim from the V8 source code at +// https://code.google.com/p/v8/source/browse/trunk/src/messages.js. The +// implementation of wrapCallSite() used to just forward to the actual source +// code of CallSite.prototype.toString but unfortunately a new release of V8 +// did something to the prototype chain and broke the shim. The only fix I +// could find was copy/paste. +function CallSiteToString(this: CallSite) { + let fileName + let fileLocation = '' + if (this.isNative()) { + fileLocation = 'native' + } else { + fileName = this.getScriptNameOrSourceURL() + if (!fileName && this.isEval()) { + fileLocation = this.getEvalOrigin() as string + fileLocation += ', ' // Expecting source position to follow. + } + + if (fileName) { + fileLocation += fileName + } else { + // Source code does not originate from a file and is not native, but we + // can still get the source position inside the source string, e.g. in + // an eval string. + fileLocation += '' + } + const lineNumber = this.getLineNumber() + if (lineNumber != null) { + fileLocation += `:${lineNumber}` + const columnNumber = this.getColumnNumber() + if (columnNumber) fileLocation += `:${columnNumber}` + } + } + + let line = '' + const functionName = this.getFunctionName() + let addSuffix = true + const isConstructor = this.isConstructor() + const isMethodCall = !(this.isToplevel() || isConstructor) + if (isMethodCall) { + let typeName = this.getTypeName() + // Fixes shim to be backward compatable with Node v0 to v4 + if (typeName === '[object Object]') typeName = 'null' + + const methodName = this.getMethodName() + if (functionName) { + if (typeName && functionName.indexOf(typeName) !== 0) + line += `${typeName}.` + + line += functionName + if ( + methodName && + functionName.indexOf(`.${methodName}`) !== + functionName.length - methodName.length - 1 + ) + line += ` [as ${methodName}]` + } else { + line += `${typeName}.${methodName || ''}` + } + } else if (isConstructor) { + line += `new ${functionName || ''}` + } else if (functionName) { + line += functionName + } else { + line += fileLocation + addSuffix = false + } + if (addSuffix) line += ` (${fileLocation})` + + return line +} + +function cloneCallSite(frame: CallSite) { + const object = {} as CallSite + Object.getOwnPropertyNames(Object.getPrototypeOf(frame)).forEach((name) => { + const key = name as keyof CallSite + // @ts-expect-error difficult to type + object[key] = /^(?:is|get)/.test(name) + ? function () { + return frame[key].call(frame) + } + : frame[key] + }) + object.toString = CallSiteToString + return object +} + +function wrapCallSite( + runtime: ViteRuntime, + options: InterceptorOptions, + frame: CallSite, + state: State, +) { + // provides interface backward compatibility + if (state === undefined) state = { nextPosition: null, curPosition: null } + + if (frame.isNative()) { + state.curPosition = null + return frame + } + + // Most call sites will return the source file from getFileName(), but code + // passed to eval() ending in "//# sourceURL=..." will return the source file + // from getScriptNameOrSourceURL() instead + const source = frame.getFileName() || frame.getScriptNameOrSourceURL() + if (source) { + const line = frame.getLineNumber() as number + let column = (frame.getColumnNumber() as number) - 1 + + // Fix position in Node where some (internal) code is prepended. + // See https://github.com/evanw/node-source-map-support/issues/36 + // Header removed in node at ^10.16 || >=11.11.0 + // v11 is not an LTS candidate, we can just test the one version with it. + // Test node versions for: 10.16-19, 10.20+, 12-19, 20-99, 100+, or 11.11 + const headerLength = 62 + if (line === 1 && column > headerLength && !frame.isEval()) + column -= headerLength + + const position = mapSourcePosition(runtime, options, { + name: null, + source, + line, + column, + }) + state.curPosition = position + frame = cloneCallSite(frame) + const originalFunctionName = frame.getFunctionName + frame.getFunctionName = function () { + const name = (() => { + if (state.nextPosition == null) return originalFunctionName() + + return state.nextPosition.name || originalFunctionName() + })() + return name === 'eval' && '_vite' in position ? null : name + } + frame.getFileName = function () { + return position.source ?? undefined + } + frame.getLineNumber = function () { + return position.line + } + frame.getColumnNumber = function () { + return position.column + 1 + } + frame.getScriptNameOrSourceURL = function () { + return position.source as string + } + return frame + } + + // Code called using eval() needs special handling + let origin = frame.isEval() && frame.getEvalOrigin() + if (origin) { + origin = mapEvalOrigin(runtime, options, origin) + frame = cloneCallSite(frame) + frame.getEvalOrigin = function () { + return origin || undefined + } + return frame + } + + // If we get here then we were unable to change the source position + return frame +} + +function prepareStackTrace( + runtime: ViteRuntime, + options: InterceptorOptions, + error: Error, + stack: CallSite[], +) { + const name = error.name || 'Error' + const message = error.message || '' + const errorString = `${name}: ${message}` + + const state = { nextPosition: null, curPosition: null } + const processedStack = [] + for (let i = stack.length - 1; i >= 0; i--) { + processedStack.push( + `\n at ${wrapCallSite(runtime, options, stack[i], state)}`, + ) + state.nextPosition = state.curPosition + } + state.curPosition = state.nextPosition = null + return errorString + processedStack.reverse().join('') +} + +export interface InterceptorOptions { + retrieveFile?: (path: string) => string | null | undefined | false + retrieveSourceMap?: (path: string) => null | { url: string; map: any } +} + +export function interceptStackTrace( + runtime: ViteRuntime, + options: InterceptorOptions = {}, +): void { + Error.prepareStackTrace = (error, stack) => + prepareStackTrace(runtime, options, error, stack) +} diff --git a/packages/vite/src/node/ssr/runtime/types.ts b/packages/vite/src/node/ssr/runtime/types.ts index b642e96d4ffc69..10902ae6f4a444 100644 --- a/packages/vite/src/node/ssr/runtime/types.ts +++ b/packages/vite/src/node/ssr/runtime/types.ts @@ -9,6 +9,8 @@ import type { ssrImportMetaKey, ssrModuleExportsKey, } from './constants' +import type { DecodedMap } from './source-map/decoder' +import type { InterceptorOptions } from './source-map/interceptor' export interface DefineImportMetadata { /** @@ -65,6 +67,7 @@ export interface ModuleCache { exports?: any evaluated?: boolean resolving?: boolean + map?: DecodedMap meta?: FetchResult /** * Module ids that imports this module @@ -94,6 +97,11 @@ export interface ViteServerClientOptions { root: string fetchModule: FetchFunction environmentVariables?: Record + sourcemapInterceptor?: + | false + | 'node' + | 'prepareStackTrace' + | InterceptorOptions hmr?: | false | { diff --git a/packages/vite/src/node/ssr/runtime/utils.ts b/packages/vite/src/node/ssr/runtime/utils.ts index 625905a6c9a43c..acc22dc5c22beb 100644 --- a/packages/vite/src/node/ssr/runtime/utils.ts +++ b/packages/vite/src/node/ssr/runtime/utils.ts @@ -3,6 +3,11 @@ import type { ImportMetaEnv } from './types' export const isWindows = typeof process !== 'undefined' && process.platform === 'win32' +export const decodeBase64 = + typeof atob !== 'undefined' + ? atob + : (str: string) => Buffer.from(str, 'base64').toString('utf-8') + // currently we copy this from '../../constants' - maybe we can inline it somewhow? const NULL_BYTE_PLACEHOLDER = `__x00__` const VALID_ID_PREFIX = `/@id/` diff --git a/packages/vite/src/node/ssr/ssrFetchModule.ts b/packages/vite/src/node/ssr/ssrFetchModule.ts index 9981becc2647dd..5356df77a858d0 100644 --- a/packages/vite/src/node/ssr/ssrFetchModule.ts +++ b/packages/vite/src/node/ssr/ssrFetchModule.ts @@ -140,6 +140,7 @@ function inlineSourceMap(mod: ModuleNode, result: TransformResult) { // this assumes that "new AsyncFunction" is used to create the module const moduleSourceMap = Object.assign({}, map, { + sourcesContent: undefined, // remove sourcesContent because _we_ don't use it, it also reduces the payload size // currently we need to offset the line // https://github.com/nodejs/node/issues/43047#issuecomment-1180632750 mappings: ';'.repeat(fnDeclarationLineCount) + map.mappings, From 1475aeb50ad5141e524cf24cd7a16cfab41c84f7 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 22 Jan 2024 17:05:19 +0100 Subject: [PATCH 09/33] chore: add check for types chunk --- packages/vite/rollup.dts.config.ts | 9 ++++--- .../node/ssr/runtime/source-map/decoder.ts | 20 +++++++++++--- .../ssr/runtime/source-map/interceptor.ts | 26 +++++++++---------- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/packages/vite/rollup.dts.config.ts b/packages/vite/rollup.dts.config.ts index 42c1d205365a58..6c4b84c6f914e1 100644 --- a/packages/vite/rollup.dts.config.ts +++ b/packages/vite/rollup.dts.config.ts @@ -89,7 +89,10 @@ function patchTypes(): Plugin { } }, renderChunk(code, chunk) { - if (chunk.fileName.startsWith('runtime')) { + if ( + chunk.fileName.startsWith('runtime') || + chunk.fileName.startsWith('types.d-') + ) { validateRuntimeChunk.call(this, chunk) } else { validateChunkImports.call(this, chunk) @@ -110,7 +113,7 @@ function validateRuntimeChunk(this: PluginContext, chunk: RenderedChunk) { if ( !id.startsWith('./') && !id.startsWith('../') && - !id.startsWith('runtime.d') + !id.startsWith('types.d') ) { this.warn(`${chunk.fileName} imports "${id}" which is not allowed`) process.exitCode = 1 @@ -128,7 +131,7 @@ function validateChunkImports(this: PluginContext, chunk: RenderedChunk) { !id.startsWith('./') && !id.startsWith('../') && !id.startsWith('node:') && - !id.startsWith('runtime.d') && + !id.startsWith('types.d') && !deps.includes(id) && !deps.some((name) => id.startsWith(name + '/')) ) { diff --git a/packages/vite/src/node/ssr/runtime/source-map/decoder.ts b/packages/vite/src/node/ssr/runtime/source-map/decoder.ts index 520088823f1fd7..8e4d69fe96eb53 100644 --- a/packages/vite/src/node/ssr/runtime/source-map/decoder.ts +++ b/packages/vite/src/node/ssr/runtime/source-map/decoder.ts @@ -1,4 +1,3 @@ -import type { Bias, Needle, OriginalMapping } from '@jridgewell/trace-mapping' import { posixResolve } from '../utils' interface SourceMapLike { @@ -9,6 +8,21 @@ interface SourceMapLike { sourcesContent?: string[] } +type Bias = typeof LEAST_UPPER_BOUND | typeof GREATEST_LOWER_BOUND + +type OriginalMapping = { + source: string | null + line: number + column: number + name: string | null +} + +type Needle = { + line: number + column: number + bias?: Bias +} + export class DecodedMap { _encoded: string _decoded: undefined | number[][][] @@ -237,7 +251,7 @@ function traceSegmentInternal( export function getOriginalPosition( map: DecodedMap, - { line, column, bias }: Needle, + { line, column }: Needle, ): OriginalMapping | null { line-- if (line < 0) throw new Error(LINE_GTR_ZERO) @@ -253,7 +267,7 @@ export function getOriginalPosition( map._decodedMemo, line, column, - bias || GREATEST_LOWER_BOUND, + GREATEST_LOWER_BOUND, ) if (index === -1) return null const segment = segments[index] diff --git a/packages/vite/src/node/ssr/runtime/source-map/interceptor.ts b/packages/vite/src/node/ssr/runtime/source-map/interceptor.ts index 61fab334aba5ee..7743dab7216c1f 100644 --- a/packages/vite/src/node/ssr/runtime/source-map/interceptor.ts +++ b/packages/vite/src/node/ssr/runtime/source-map/interceptor.ts @@ -3,6 +3,19 @@ import type { ViteRuntime } from '../runtime' import { posixDirname, posixResolve } from '../utils' import { DecodedMap, getOriginalPosition } from './decoder' +export interface InterceptorOptions { + retrieveFile?: (path: string) => string | null | undefined | false + retrieveSourceMap?: (path: string) => null | { url: string; map: any } +} + +export function interceptStackTrace( + runtime: ViteRuntime, + options: InterceptorOptions = {}, +): void { + Error.prepareStackTrace = (error, stack) => + prepareStackTrace(runtime, options, error, stack) +} + interface CallSite extends NodeJS.CallSite { getScriptNameOrSourceURL(): string } @@ -396,16 +409,3 @@ function prepareStackTrace( state.curPosition = state.nextPosition = null return errorString + processedStack.reverse().join('') } - -export interface InterceptorOptions { - retrieveFile?: (path: string) => string | null | undefined | false - retrieveSourceMap?: (path: string) => null | { url: string; map: any } -} - -export function interceptStackTrace( - runtime: ViteRuntime, - options: InterceptorOptions = {}, -): void { - Error.prepareStackTrace = (error, stack) => - prepareStackTrace(runtime, options, error, stack) -} From f83f0e3aee55645a85866513d577924723e3a036 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 22 Jan 2024 17:09:22 +0100 Subject: [PATCH 10/33] test: remove only --- .../vite/src/node/ssr/runtime/__tests__/fixtures/has-error.js | 1 + .../vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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 index 77d0c0c87aed71..807d1c8af46b47 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/has-error.js +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/has-error.js @@ -1 +1,2 @@ +// comment throw new Error('module error') diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index 1ae5d7be6f1490..78c637ae2850e5 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -112,7 +112,7 @@ describe('vite-runtime initialization', async () => { return err.stack!.split('\n')[1].replace(runtime.options.root, '') } - it.only('source maps are correctly applied to stack traces', async ({ + it('source maps are correctly applied to stack traces', async ({ runtime, server, }) => { From 3205c5433f7abbb2f3b1d1cb1d06a0d6484f7453 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 22 Jan 2024 18:38:30 +0100 Subject: [PATCH 11/33] chore: change default to use node interceptor if available --- .../src/node/ssr/runtime/__tests__/utils.ts | 1 + .../ssr/runtime/node/mainThreadRuntime.ts | 32 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts index 20d89a98687437..347d543f162f9b 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts @@ -74,6 +74,7 @@ export async function createViteRuntimeTester( hmr: { logger: false, }, + sourcemapInterceptor: 'prepareStackTrace', }) if (config.server?.watch) { await waitForWatcher(t.server) diff --git a/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts b/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts index f16d95da9b130c..27c6a3fc8d516b 100644 --- a/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts +++ b/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts @@ -30,6 +30,30 @@ function createHMROptions( } } +const prepareStackTrace = { + retrieveFile(id: string) { + if (existsSync(id)) { + return readFileSync(id, 'utf-8') + } + }, +} + +function resolveSourceMapOptions(options: MainThreadRuntimeOptions) { + if (options.sourcemapInterceptor != null) { + if (options.sourcemapInterceptor === 'prepareStackTrace') { + return prepareStackTrace + } + if (typeof options.sourcemapInterceptor === 'object') { + return { ...prepareStackTrace, ...options.sourcemapInterceptor } + } + return options.sourcemapInterceptor + } + if (typeof process !== 'undefined' && 'setSourceMapsEnabled' in process) { + return 'node' + } + return prepareStackTrace +} + export async function createViteRuntime( server: ViteDevServer, options: MainThreadRuntimeOptions = {}, @@ -41,13 +65,7 @@ export async function createViteRuntime( root: server.config.root, fetchModule: server.ssrFetchModule, hmr, - sourcemapInterceptor: options.sourcemapInterceptor ?? { - retrieveFile: (id) => { - if (existsSync(id)) { - return readFileSync(id, 'utf-8') - } - }, - }, + sourcemapInterceptor: resolveSourceMapOptions(options), }, options.runner || new ESModulesRunner(), ) From d8fce47c1803eea1f0d3f0287b76ca46ed587de2 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 25 Jan 2024 18:34:22 +0100 Subject: [PATCH 12/33] refactor: expose "fetchModule" via a "vite" entypoint, improve types --- packages/vite/src/node/index.ts | 3 +- packages/vite/src/node/ssr/fetchModule.ts | 150 ++++++++++++++++ .../vite/src/node/ssr/runtime/moduleCache.ts | 2 +- packages/vite/src/node/ssr/runtime/runtime.ts | 21 ++- .../node/ssr/runtime/source-map/decoder.ts | 28 +-- packages/vite/src/node/ssr/runtime/types.ts | 33 +++- packages/vite/src/node/ssr/ssrFetchModule.ts | 162 ++---------------- 7 files changed, 212 insertions(+), 187 deletions(-) create mode 100644 packages/vite/src/node/ssr/fetchModule.ts diff --git a/packages/vite/src/node/index.ts b/packages/vite/src/node/index.ts index 61d1b1b57207ba..8f71ab1fbf98cc 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -15,7 +15,8 @@ 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 { fetchModule } from './ssr/fetchModule' +export type { FetchModuleOptions } from './ssr/fetchModule' export * from './publicUtils' // additional types diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts new file mode 100644 index 00000000000000..c16ea1371dd036 --- /dev/null +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -0,0 +1,150 @@ +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 +} + +export interface FetchModuleOptions { + inlineSourceMap?: boolean + processSourceMap?>(map: T): T +} + +export async function fetchModule( + server: ViteDevServer, + url: string, + importer?: string, + options: FetchModuleOptions = {}, +): Promise { + // builtins should always be externalized + if (url.startsWith('data:') || isBuiltin(url)) { + return { externalize: url, type: 'builtin' } + } + + if (url[0] !== '.' && url[0] !== '/') { + const { + isProduction, + resolve: { dedupe, preserveSymlinks }, + root, + ssr, + } = server.config + const overrideConditions = ssr.resolve?.externalConditions || [] + + const resolveOptions: NodeImportResolveOptions = { + mainFields: ['main'], + conditions: [], + overrideConditions: [...overrideConditions, 'production', 'development'], + extensions: ['.js', '.cjs', '.json'], + dedupe, + preserveSymlinks, + isBuild: false, + isProduction, + root, + ssrConfig: ssr, + legacyProxySsrExternalModules: + server.config.legacy?.proxySsrExternalModules, + packageCache: server.config.packageCache, + } + + const resolved = tryNodeResolve( + url, + importer, + { ...resolveOptions, tryEsmOnly: true }, + false, + undefined, + true, + ) + if (!resolved) { + const err: any = new Error( + `Cannot find module '${url}' imported from '${importer}'`, + ) + err.code = 'ERR_MODULE_NOT_FOUND' + throw err + } + const file = pathToFileURL(resolved.id).toString() + const type = isFilePathESM(file, server.config.packageCache) + ? 'module' + : 'commonjs' + return { externalize: file, type } + } + + url = unwrapId(url) + + let result = await server.transformRequest(url, { ssr: true }) + + if (!result) { + throw new Error( + `[vite] transform failed for module '${url}'${ + importer ? ` imported from '${importer}'` : '' + }.`, + ) + } + + // module entry should be created by transformRequest + const mod = await server.moduleGraph.getModuleByUrl(url, true) + + if (!mod) { + throw new Error( + `[vite] cannot find module '${url}' ${ + importer ? ` imported from '${importer}'` : '' + }.`, + ) + } + + if (options.inlineSourceMap !== false) { + result = inlineSourceMap(mod, result, options.processSourceMap) + } + + // remove shebang + if (result.code[0] === '#') + result.code = result.code.replace(/^#!.*/, (s) => ' '.repeat(s.length)) + + return { code: result.code, file: mod.file } +} + +let SOURCEMAPPING_URL = 'sourceMa' +SOURCEMAPPING_URL += 'ppingURL' + +const VITE_RUNTIME_SOURCEMAPPING_SOURCE = '//# sourceMappingSource=vite-runtime' +const VITE_RUNTIME_SOURCEMAPPING_URL = `${SOURCEMAPPING_URL}=data:application/json;charset=utf-8` + +function inlineSourceMap( + mod: ModuleNode, + result: TransformResult, + processSourceMap?: FetchModuleOptions['processSourceMap'], +) { + const map = result.map + let code = result.code + + if ( + !map || + !('version' in map) || + code.includes(VITE_RUNTIME_SOURCEMAPPING_SOURCE) + ) + return result + + // to reduce the payload size, we only inline vite node source map, because it's also the only one we use + const OTHER_SOURCE_MAP_REGEXP = new RegExp( + `//# ${SOURCEMAPPING_URL}=data:application/json[^,]+base64,([A-Za-z0-9+/=]+)$`, + 'gm', + ) + while (OTHER_SOURCE_MAP_REGEXP.test(code)) + code = code.replace(OTHER_SOURCE_MAP_REGEXP, '') + + const sourceMap = Buffer.from( + JSON.stringify(processSourceMap?.(map) || map), + 'utf-8', + ).toString('base64') + result.code = `${code.trimEnd()}\n//# sourceURL=${ + mod.id + }\n${VITE_RUNTIME_SOURCEMAPPING_SOURCE}\n//# ${VITE_RUNTIME_SOURCEMAPPING_URL};base64,${sourceMap}\n` + + return result +} diff --git a/packages/vite/src/node/ssr/runtime/moduleCache.ts b/packages/vite/src/node/ssr/runtime/moduleCache.ts index 84e6c71d05cdb8..2d24d0a1d12da6 100644 --- a/packages/vite/src/node/ssr/runtime/moduleCache.ts +++ b/packages/vite/src/node/ssr/runtime/moduleCache.ts @@ -106,7 +106,7 @@ export class ModuleCacheMap extends Map { getSourceMap(moduleId: string): null | DecodedMap { const mod = this.get(moduleId) if (mod.map) return mod.map - if (!mod.meta || !mod.meta.code) return null + if (!mod.meta || !('code' in mod.meta)) return null const mapString = mod.meta.code.match( VITE_RUNTIME_SOURCEMAPPING_REGEXP, )?.[1] diff --git a/packages/vite/src/node/ssr/runtime/runtime.ts b/packages/vite/src/node/ssr/runtime/runtime.ts index 386c72b463958c..28816b6a8f3a68 100644 --- a/packages/vite/src/node/ssr/runtime/runtime.ts +++ b/packages/vite/src/node/ssr/runtime/runtime.ts @@ -152,7 +152,7 @@ export class ViteRuntime { fetchResult: ResolvedResult, metadata?: SSRImportMetadata, ) { - if (!fetchResult.externalize) { + if (!('externalize' in fetchResult)) { return exports } const { id, type } = fetchResult @@ -242,16 +242,17 @@ export class ViteRuntime { // if we used id for that, it's possible to have a duplicated module const idQuery = id.split('?')[1] const query = idQuery ? `?${idQuery}` : '' - const fullFile = fetchedModule.file ? `${fetchedModule.file}${query}` : id + const file = 'file' in fetchedModule ? fetchedModule.file : undefined + const fullFile = file ? `${file}${query}` : id const moduleId = this.moduleCache.normalize(fullFile) const mod = this.moduleCache.getByModuleId(moduleId) - fetchedModule.id = moduleId + ;(fetchedModule as ResolvedResult).id = moduleId mod.meta = fetchedModule - if (fetchedModule.file) { - const fileModules = this.fileToIdMap.get(fetchedModule.file) || [] + if (file) { + const fileModules = this.fileToIdMap.get(file) || [] fileModules.push(moduleId) - this.fileToIdMap.set(fetchedModule.file, fileModules) + this.fileToIdMap.set(file, fileModules) } this.idToUrlMap.set(id, moduleId) @@ -262,10 +263,11 @@ export class ViteRuntime { // override is allowed, consider this a public API protected async directRequest( id: string, - { file, externalize, code, id: moduleId }: ResolvedResult, + fetchResult: ResolvedResult, _callstack: string[], metadata?: SSRImportMetadata, ): Promise { + const moduleId = fetchResult.id const callstack = [..._callstack, moduleId] const mod = this.moduleCache.getByModuleId(moduleId) @@ -291,13 +293,16 @@ export class ViteRuntime { const requestStubs = this.options.requestStubs || {} if (id in requestStubs) return requestStubs[id] - if (externalize) { + if ('externalize' in fetchResult) { + const { externalize } = fetchResult this.debug?.('[vite-runtime] externalizing', externalize) const exports = await this.runner.runExternalModule(externalize, metadata) mod.exports = exports return exports } + const { code, file } = fetchResult + if (code == null) { const importer = callstack[callstack.length - 2] throw new Error( diff --git a/packages/vite/src/node/ssr/runtime/source-map/decoder.ts b/packages/vite/src/node/ssr/runtime/source-map/decoder.ts index 8e4d69fe96eb53..a3abcf9e388f98 100644 --- a/packages/vite/src/node/ssr/runtime/source-map/decoder.ts +++ b/packages/vite/src/node/ssr/runtime/source-map/decoder.ts @@ -8,8 +8,6 @@ interface SourceMapLike { sourcesContent?: string[] } -type Bias = typeof LEAST_UPPER_BOUND | typeof GREATEST_LOWER_BOUND - type OriginalMapping = { source: string | null line: number @@ -20,7 +18,6 @@ type OriginalMapping = { type Needle = { line: number column: number - bias?: Bias } export class DecodedMap { @@ -140,8 +137,6 @@ const SOURCES_INDEX = 1 const SOURCE_LINE = 2 const SOURCE_COLUMN = 3 const NAMES_INDEX = 4 -const LEAST_UPPER_BOUND = -1 -const GREATEST_LOWER_BOUND = 1 function OMapping( source: string | null, @@ -180,12 +175,6 @@ function binarySearch( return low - 1 } -function upperBound(haystack: number[][], needle: number, index: number) { - for (let i = index + 1; i < haystack.length; index = i++) { - if (haystack[i][COLUMN] !== needle) break - } - return index -} function lowerBound(haystack: number[][], needle: number, index: number) { for (let i = index - 1; i >= 0; index = i--) { if (haystack[i][COLUMN] !== needle) break @@ -235,16 +224,11 @@ function traceSegmentInternal( memo: Stats, line: number, column: number, - bias: Bias, ) { let index = memoizedBinarySearch(segments, column, memo, line) if (found) { - index = (bias === LEAST_UPPER_BOUND ? upperBound : lowerBound)( - segments, - column, - index, - ) - } else if (bias === LEAST_UPPER_BOUND) index++ + index = lowerBound(segments, column, index) + } if (index === -1 || index === segments.length) return -1 return index } @@ -262,13 +246,7 @@ export function getOriginalPosition( // mapping (like a "//# sourceMappingURL=") at the end of the child file. if (line >= decoded.length) return null const segments = decoded[line] - const index = traceSegmentInternal( - segments, - map._decodedMemo, - line, - column, - GREATEST_LOWER_BOUND, - ) + const index = traceSegmentInternal(segments, map._decodedMemo, line, column) if (index === -1) return null const segment = segments[index] if (segment.length === 1) return null diff --git a/packages/vite/src/node/ssr/runtime/types.ts b/packages/vite/src/node/ssr/runtime/types.ts index 10902ae6f4a444..dd2bf4231e5461 100644 --- a/packages/vite/src/node/ssr/runtime/types.ts +++ b/packages/vite/src/node/ssr/runtime/types.ts @@ -76,15 +76,36 @@ export interface ModuleCache { imports?: Set } -export interface FetchResult { - id?: string - code?: string - file?: string | null - externalize?: string +export type FetchResult = ExternalFetchResult | ViteFetchResult + +export interface ExternalFetchResult { + /** + * The path to the externalized module starting with file://, + * by default this will be imported via a dynamic "import" + * instead of being transformed by vite and loaded with vite runtime + */ + externalize: string + /** + * Type of the module. Will be used to determine if import statement is correct. + * For example, if Vite needs to throw an error if variable is not actually exported + */ type?: 'module' | 'commonjs' | 'builtin' } -export interface ResolvedResult extends Omit { +export interface ViteFetchResult { + /** + * Code that will be evaluated by vite runtime + * by default this will be wrapped in an async function + */ + code: string + /** + * File path of the module on disk. + * This will be resolved as import.meta.url/filename + */ + file: string | null +} + +export type ResolvedResult = (ExternalFetchResult | ViteFetchResult) & { id: string } diff --git a/packages/vite/src/node/ssr/ssrFetchModule.ts b/packages/vite/src/node/ssr/ssrFetchModule.ts index 5356df77a858d0..27ffe25085305c 100644 --- a/packages/vite/src/node/ssr/ssrFetchModule.ts +++ b/packages/vite/src/node/ssr/ssrFetchModule.ts @@ -1,116 +1,7 @@ -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 { ViteDevServer } from '../server' +import { fetchModule } from './fetchModule' 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 = (() => { @@ -119,40 +10,19 @@ const fnDeclarationLineCount = (() => { 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, { - sourcesContent: undefined, // remove sourcesContent because _we_ don't use it, it also reduces the payload size - // currently we need to offset the line - // https://github.com/nodejs/node/issues/43047#issuecomment-1180632750 - mappings: ';'.repeat(fnDeclarationLineCount) + map.mappings, +export function ssrFetchModule( + server: ViteDevServer, + id: string, + importer?: string, +): Promise { + return fetchModule(server, id, importer, { + processSourceMap(map) { + // this assumes that "new AsyncFunction" is used to create the module + return Object.assign({}, map, { + // currently we need to offset the line + // https://github.com/nodejs/node/issues/43047#issuecomment-1180632750 + mappings: ';'.repeat(fnDeclarationLineCount) + map.mappings, + }) + }, }) - - 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 } From 2949d96331764516f4e3c46b37f6a0ea69cea739 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Jan 2024 11:35:18 +0100 Subject: [PATCH 13/33] fix: allow several runtimes to influece source maps --- packages/vite/src/node/index.ts | 1 + .../runtime/__tests__/server-runtime.spec.ts | 55 +------ .../__tests__/server-source-maps.spec.ts | 62 ++++++++ .../src/node/ssr/runtime/__tests__/utils.ts | 12 +- .../ssr/runtime/node/mainThreadRuntime.ts | 2 +- packages/vite/src/node/ssr/runtime/runtime.ts | 15 +- .../src/node/ssr/runtime/source-map/index.ts | 7 +- .../ssr/runtime/source-map/interceptor.ts | 149 +++++++++++------- 8 files changed, 182 insertions(+), 121 deletions(-) create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts diff --git a/packages/vite/src/node/index.ts b/packages/vite/src/node/index.ts index 8f71ab1fbf98cc..c998cd8b503394 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -129,6 +129,7 @@ export type { } from './server/hmr' export type { FetchFunction } from './ssr/runtime/index' export { createViteRuntime } from './ssr/runtime/node/mainThreadRuntime' +export type { MainThreadRuntimeOptions } from './ssr/runtime/node/mainThreadRuntime' export { ServerHMRConnector } from './ssr/runtime/node/serverHmrConnector' export type { BindCLIShortcutsOptions, CLIShortcut } from './shortcuts' diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index 78c637ae2850e5..5dd45fba53fbcc 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -3,8 +3,7 @@ import { posix, win32 } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect } from 'vitest' import { isWindows } from '../utils' -import type { ViteRuntime } from '../runtime' -import { createViteRuntimeTester, editFile, resolvePath } from './utils' +import { createViteRuntimeTester } from './utils' const _URL = URL @@ -100,56 +99,6 @@ describe('vite-runtime initialization', async () => { ) }) - const getError = async (cb: () => void): Promise => { - try { - await cb() - expect.unreachable() - } catch (err) { - return err - } - } - const serializeStack = (runtime: ViteRuntime, err: Error) => { - return err.stack!.split('\n')[1].replace(runtime.options.root, '') - } - - it('source maps are correctly applied to stack traces', async ({ - runtime, - server, - }) => { - expect.assertions(3) - const topLevelError = await getError(() => - runtime.executeUrl('/fixtures/has-error.js'), - ) - expect(serializeStack(runtime, topLevelError)).toBe( - ' at /fixtures/has-error.js:2:7', - ) - - const methodError = await getError(async () => { - const mod = await runtime.executeUrl('/fixtures/throws-error-method.ts') - mod.throwError() - }) - expect(serializeStack(runtime, methodError)).toBe( - ' at Module.throwError (/fixtures/throws-error-method.ts:6:9)', - ) - - // simulate HMR - editFile( - resolvePath(import.meta.url, './fixtures/throws-error-method.ts'), - (code) => '\n\n\n\n\n' + code + '\n', - ) - runtime.moduleCache.clear() - server.moduleGraph.invalidateAll() - - const methodErrorNew = await getError(async () => { - const mod = await runtime.executeUrl('/fixtures/throws-error-method.ts') - mod.throwError() - }) - - expect(serializeStack(runtime, methodErrorNew)).toBe( - ' at Module.throwError (/fixtures/throws-error-method.ts:11:9)', - ) - }) - it('throws the same error', async ({ runtime }) => { expect.assertions(3) const s = Symbol() @@ -238,8 +187,6 @@ describe('vite-runtime initialization', async () => { 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) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts new file mode 100644 index 00000000000000..15acfaec4990ad --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect } from 'vitest' +import type { ViteRuntime } from '../runtime' +import { createViteRuntimeTester, editFile, resolvePath } from './utils' + +describe('vite-runtime initialization', async () => { + const it = await createViteRuntimeTester( + {}, + { + sourcemapInterceptor: 'prepareStackTrace', + }, + ) + + const getError = async (cb: () => void): Promise => { + try { + await cb() + expect.unreachable() + } catch (err) { + return err + } + } + const serializeStack = (runtime: ViteRuntime, err: Error) => { + return err.stack!.split('\n')[1].replace(runtime.options.root, '') + } + + it('source maps are correctly applied to stack traces', async ({ + runtime, + server, + }) => { + expect.assertions(3) + const topLevelError = await getError(() => + runtime.executeUrl('/fixtures/has-error.js'), + ) + expect(serializeStack(runtime, topLevelError)).toBe( + ' at /fixtures/has-error.js:2:7', + ) + + const methodError = await getError(async () => { + const mod = await runtime.executeUrl('/fixtures/throws-error-method.ts') + mod.throwError() + }) + expect(serializeStack(runtime, methodError)).toBe( + ' at Module.throwError (/fixtures/throws-error-method.ts:6:9)', + ) + + // simulate HMR + editFile( + resolvePath(import.meta.url, './fixtures/throws-error-method.ts'), + (code) => '\n\n\n\n\n' + code + '\n', + ) + runtime.moduleCache.clear() + server.moduleGraph.invalidateAll() + + const methodErrorNew = await getError(async () => { + const mod = await runtime.executeUrl('/fixtures/throws-error-method.ts') + mod.throwError() + }) + + expect(serializeStack(runtime, methodErrorNew)).toBe( + ' at Module.throwError (/fixtures/throws-error-method.ts:11:9)', + ) + }) +}) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts index 347d543f162f9b..a6d7ccb57480b3 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts @@ -3,7 +3,11 @@ 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 type { + InlineConfig, + MainThreadRuntimeOptions, + ViteDevServer, +} from '../../../index' import { createServer } from '../../../index' import type { ViteRuntime } from '../runtime' import { createViteRuntime } from '../node/mainThreadRuntime' @@ -15,6 +19,7 @@ interface TestClient { export async function createViteRuntimeTester( config: InlineConfig = {}, + runtimeConfig: MainThreadRuntimeOptions = {}, ): Promise> { function waitForWatcher(server: ViteDevServer) { return new Promise((resolve) => { @@ -74,7 +79,9 @@ export async function createViteRuntimeTester( hmr: { logger: false, }, - sourcemapInterceptor: 'prepareStackTrace', + // don't override by default so Vitest source maps are correct + sourcemapInterceptor: false, + ...runtimeConfig, }) if (config.server?.watch) { await waitForWatcher(t.server) @@ -82,6 +89,7 @@ export async function createViteRuntimeTester( }) afterEach(async (t) => { + await t.runtime.destroy() await t.server.close() }) diff --git a/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts b/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts index 27c6a3fc8d516b..a9b76c22ad0833 100644 --- a/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts +++ b/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts @@ -6,7 +6,7 @@ import type { ViteModuleRunner, ViteServerClientOptions } from '../types' import type { HMRLogger } from '../../../../shared/hmr' import { ServerHMRConnector } from './serverHmrConnector' -interface MainThreadRuntimeOptions +export interface MainThreadRuntimeOptions extends Omit { hmr?: | false diff --git a/packages/vite/src/node/ssr/runtime/runtime.ts b/packages/vite/src/node/ssr/runtime/runtime.ts index 28816b6a8f3a68..28913144c5f79e 100644 --- a/packages/vite/src/node/ssr/runtime/runtime.ts +++ b/packages/vite/src/node/ssr/runtime/runtime.ts @@ -52,6 +52,9 @@ export class ViteRuntime { private fileToIdMap = new Map() private envProxy: ImportMetaEnv + private _destroyed = false + private _resetSourceMapSupport?: () => void + constructor( public options: ViteServerClientOptions, public runner: ViteModuleRunner, @@ -76,7 +79,7 @@ export class ViteRuntime { options.hmr.connection.onUpdate(createHMRHandler(this)) } if (options.sourcemapInterceptor !== false) { - enableSourceMapSupport(this) + this._resetSourceMapSupport = enableSourceMapSupport(this) } } @@ -108,6 +111,13 @@ export class ViteRuntime { this.hmrClient?.clear() } + public async destroy(): Promise { + this._resetSourceMapSupport?.() + this.clearCache() + this.hmrClient = undefined + this._destroyed = true + } + private invalidateFiles(files: string[]) { files.forEach((file) => { const ids = this.fileToIdMap.get(file) @@ -225,6 +235,9 @@ export class ViteRuntime { id: string, importer?: string, ): Promise { + if (this._destroyed) { + throw new Error(`[vite] Vite runtime has been destroyed.`) + } const normalized = this.idToUrlMap.get(id) if (normalized) { const mod = this.moduleCache.getByModuleId(normalized) diff --git a/packages/vite/src/node/ssr/runtime/source-map/index.ts b/packages/vite/src/node/ssr/runtime/source-map/index.ts index 810060bdf69bc3..8329c27013eb9a 100644 --- a/packages/vite/src/node/ssr/runtime/source-map/index.ts +++ b/packages/vite/src/node/ssr/runtime/source-map/index.ts @@ -1,7 +1,7 @@ import type { ViteRuntime } from '../runtime' import { interceptStackTrace } from './interceptor' -export function enableSourceMapSupport(runtime: ViteRuntime): void { +export function enableSourceMapSupport(runtime: ViteRuntime): () => void { if (runtime.options.sourcemapInterceptor === 'node') { if (typeof process === 'undefined') { throw new TypeError( @@ -13,10 +13,11 @@ export function enableSourceMapSupport(runtime: ViteRuntime): void { `Cannot use "sourcemapInterceptor: 'node'" because "process.setSourceMapsEnabled" function is not available. Please use Node >= 16.6.0.`, ) } + const isEnabledAlready = process.sourceMapsEnabled ?? false process.setSourceMapsEnabled(true) - return + return () => !isEnabledAlready && process.setSourceMapsEnabled(false) } - interceptStackTrace( + return interceptStackTrace( runtime, typeof runtime.options.sourcemapInterceptor === 'object' ? runtime.options.sourcemapInterceptor diff --git a/packages/vite/src/node/ssr/runtime/source-map/interceptor.ts b/packages/vite/src/node/ssr/runtime/source-map/interceptor.ts index 7743dab7216c1f..4a1fe72c3a8326 100644 --- a/packages/vite/src/node/ssr/runtime/source-map/interceptor.ts +++ b/packages/vite/src/node/ssr/runtime/source-map/interceptor.ts @@ -1,19 +1,73 @@ import type { OriginalMapping } from '@jridgewell/trace-mapping' import type { ViteRuntime } from '../runtime' import { posixDirname, posixResolve } from '../utils' +import type { ModuleCacheMap } from '../moduleCache' import { DecodedMap, getOriginalPosition } from './decoder' +interface RetrieveFileHandler { + (path: string): string | null | undefined | false +} + +interface RetrieveSourceMapHandler { + (path: string): null | { url: string; map: any } +} + export interface InterceptorOptions { - retrieveFile?: (path: string) => string | null | undefined | false - retrieveSourceMap?: (path: string) => null | { url: string; map: any } + retrieveFile?: RetrieveFileHandler + retrieveSourceMap?: RetrieveSourceMapHandler +} + +const sourceMapCache: Record = {} +const fileContentsCache: Record = {} + +const moduleGraphs: Set = new Set() +const retrieveFileHandlers = new Set() +const retrieveSourceMapHandlers = new Set() + +const createExecHandlers = any>( + handlers: Set, +) => { + return ((...args: Parameters) => { + for (const handler of handlers) { + const result = handler(...(args as [])) + if (result) return result + } + return null + }) as T +} + +const retrieveFileFromHandlers = createExecHandlers(retrieveFileHandlers) +const retrievSourceMapFromHandlers = createExecHandlers( + retrieveSourceMapHandlers, +) + +let overriden = false +const originalPrepare = Error.prepareStackTrace + +function resetInterceptor(runtime: ViteRuntime, options: InterceptorOptions) { + moduleGraphs.delete(runtime.moduleCache) + if (options.retrieveFile) retrieveFileHandlers.delete(options.retrieveFile) + if (options.retrieveSourceMap) + retrieveSourceMapHandlers.delete(options.retrieveSourceMap) + if (moduleGraphs.size === 0) { + Error.prepareStackTrace = originalPrepare + overriden = false + } } export function interceptStackTrace( runtime: ViteRuntime, options: InterceptorOptions = {}, -): void { - Error.prepareStackTrace = (error, stack) => - prepareStackTrace(runtime, options, error, stack) +): () => void { + if (!overriden) { + Error.prepareStackTrace = prepareStackTrace + overriden = true + } + moduleGraphs.add(runtime.moduleCache) + if (options.retrieveFile) retrieveFileHandlers.add(options.retrieveFile) + if (options.retrieveSourceMap) + retrieveSourceMapHandlers.add(options.retrieveSourceMap) + return () => resetInterceptor(runtime, options) } interface CallSite extends NodeJS.CallSite { @@ -30,8 +84,6 @@ interface CachedMapEntry { map: DecodedMap | null vite?: boolean } -const sourceMapCache: Record = {} -const fileContentsCache: Record = {} // Support URLs relative to a directory, but be careful about a protocol prefix function supportRelativeURL(file: string, url: string) { @@ -51,28 +103,23 @@ function supportRelativeURL(file: string, url: string) { return protocol + posixResolve(dir.slice(protocol.length), url) } -function getRuntimeSourceMap( - runtime: ViteRuntime, - position: OriginalMapping, -): CachedMapEntry | null { - const sourceMap = runtime.moduleCache.getSourceMap(position.source as string) - if (sourceMap) { - return { - url: position.source, - map: sourceMap, - vite: true, +function getRuntimeSourceMap(position: OriginalMapping): CachedMapEntry | null { + for (const moduleCache of moduleGraphs) { + const sourceMap = moduleCache.getSourceMap(position.source as string) + if (sourceMap) { + return { + url: position.source, + map: sourceMap, + vite: true, + } } } return null } -function retrieveFile( - path: string, - options: InterceptorOptions, -): string | null | undefined | false { - if (!options.retrieveFile) return null +function retrieveFile(path: string): string | null | undefined | false { if (path in fileContentsCache) return fileContentsCache[path] - const content = options.retrieveFile(path) + const content = retrieveFileFromHandlers(path) if (typeof content === 'string') { fileContentsCache[path] = content return content @@ -80,9 +127,9 @@ function retrieveFile( return null } -function retrieveSourceMapURL(source: string, options: InterceptorOptions) { +function retrieveSourceMapURL(source: string) { // Get the URL of the source map - const fileData = retrieveFile(source, options) + const fileData = retrieveFile(source) if (!fileData) return null const re = /\/\/[@#]\s*sourceMappingURL=([^\s'"]+)\s*$|\/\*[@#]\s*sourceMappingURL=[^\s*'"]+\s*\*\/\s*$/gm @@ -97,13 +144,11 @@ function retrieveSourceMapURL(source: string, options: InterceptorOptions) { const reSourceMap = /^data:application\/json[^,]+base64,/ -function retrieveSourceMap(source: string, options: InterceptorOptions) { - if (options.retrieveSourceMap) { - const urlAndMap = options.retrieveSourceMap(source) - if (urlAndMap) return urlAndMap - } +function retrieveSourceMap(source: string) { + const urlAndMap = retrievSourceMapFromHandlers(source) + if (urlAndMap) return urlAndMap - let sourceMappingURL = retrieveSourceMapURL(source, options) + let sourceMappingURL = retrieveSourceMapURL(source) if (!sourceMappingURL) return null // Read the contents of the source map @@ -116,7 +161,7 @@ function retrieveSourceMap(source: string, options: InterceptorOptions) { } else { // Support source map URLs relative to the source URL sourceMappingURL = supportRelativeURL(source, sourceMappingURL) - sourceMapData = retrieveFile(sourceMappingURL, options) + sourceMapData = retrieveFile(sourceMappingURL) } if (!sourceMapData) return null @@ -127,17 +172,13 @@ function retrieveSourceMap(source: string, options: InterceptorOptions) { } } -function mapSourcePosition( - runtime: ViteRuntime, - options: InterceptorOptions, - position: OriginalMapping, -) { +function mapSourcePosition(position: OriginalMapping) { if (!position.source) return position - let sourceMap = getRuntimeSourceMap(runtime, position) + let sourceMap = getRuntimeSourceMap(position) if (!sourceMap) sourceMap = sourceMapCache[position.source] if (!sourceMap) { // Call the (overrideable) retrieveSourceMap function to get the source map. - const urlAndMap = retrieveSourceMap(position.source, options) + const urlAndMap = retrieveSourceMap(position.source) if (urlAndMap && urlAndMap.map) { const url = urlAndMap.url sourceMap = sourceMapCache[position.source] = { @@ -197,15 +238,11 @@ function mapSourcePosition( // Parses code generated by FormatEvalOrigin(), a function inside V8: // https://code.google.com/p/v8/source/browse/trunk/src/messages.js -function mapEvalOrigin( - runtime: ViteRuntime, - options: InterceptorOptions, - origin: string, -): string { +function mapEvalOrigin(origin: string): string { // Most eval() calls are in this format let match = /^eval at ([^(]+) \((.+):(\d+):(\d+)\)$/.exec(origin) if (match) { - const position = mapSourcePosition(runtime, options, { + const position = mapSourcePosition({ name: null, source: match[2], line: +match[3], @@ -216,8 +253,7 @@ function mapEvalOrigin( // Parse nested eval() calls using recursion match = /^eval at ([^(]+) \((.+)\)$/.exec(origin) - if (match) - return `eval at ${match[1]} (${mapEvalOrigin(runtime, options, match[2])})` + if (match) return `eval at ${match[1]} (${mapEvalOrigin(match[2])})` // Make sure we still return useful information if we didn't find anything return origin @@ -310,12 +346,7 @@ function cloneCallSite(frame: CallSite) { return object } -function wrapCallSite( - runtime: ViteRuntime, - options: InterceptorOptions, - frame: CallSite, - state: State, -) { +function wrapCallSite(frame: CallSite, state: State) { // provides interface backward compatibility if (state === undefined) state = { nextPosition: null, curPosition: null } @@ -341,7 +372,7 @@ function wrapCallSite( if (line === 1 && column > headerLength && !frame.isEval()) column -= headerLength - const position = mapSourcePosition(runtime, options, { + const position = mapSourcePosition({ name: null, source, line, @@ -376,7 +407,7 @@ function wrapCallSite( // Code called using eval() needs special handling let origin = frame.isEval() && frame.getEvalOrigin() if (origin) { - origin = mapEvalOrigin(runtime, options, origin) + origin = mapEvalOrigin(origin) frame = cloneCallSite(frame) frame.getEvalOrigin = function () { return origin || undefined @@ -389,8 +420,8 @@ function wrapCallSite( } function prepareStackTrace( - runtime: ViteRuntime, - options: InterceptorOptions, + // runtime: ViteRuntime, + // options: InterceptorOptions, error: Error, stack: CallSite[], ) { @@ -401,9 +432,7 @@ function prepareStackTrace( const state = { nextPosition: null, curPosition: null } const processedStack = [] for (let i = stack.length - 1; i >= 0; i--) { - processedStack.push( - `\n at ${wrapCallSite(runtime, options, stack[i], state)}`, - ) + processedStack.push(`\n at ${wrapCallSite(stack[i], state)}`) state.nextPosition = state.curPosition } state.curPosition = state.nextPosition = null From bcfe993b25571665db8ea492134f531669ae8e64 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Jan 2024 11:49:21 +0100 Subject: [PATCH 14/33] fix: externalize network urls by default --- packages/vite/src/node/ssr/fetchModule.ts | 6 +++- packages/vite/src/node/ssr/runtime/types.ts | 2 +- .../ssr-html/__tests__/ssr-html.spec.ts | 32 ++++++++++++++----- playground/ssr-html/test-network-imports.js | 14 +++++--- 4 files changed, 40 insertions(+), 14 deletions(-) diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index c16ea1371dd036..f5123d9e88dcc7 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -3,7 +3,7 @@ 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 { isBuiltin, isExternalUrl, isFilePathESM, unwrapId } from '../utils' import type { FetchResult } from './runtime/types' interface NodeImportResolveOptions @@ -28,6 +28,10 @@ export async function fetchModule( return { externalize: url, type: 'builtin' } } + if (isExternalUrl(url)) { + return { externalize: url, type: 'network' } + } + if (url[0] !== '.' && url[0] !== '/') { const { isProduction, diff --git a/packages/vite/src/node/ssr/runtime/types.ts b/packages/vite/src/node/ssr/runtime/types.ts index dd2bf4231e5461..1e7342ef096e96 100644 --- a/packages/vite/src/node/ssr/runtime/types.ts +++ b/packages/vite/src/node/ssr/runtime/types.ts @@ -89,7 +89,7 @@ export interface ExternalFetchResult { * Type of the module. Will be used to determine if import statement is correct. * For example, if Vite needs to throw an error if variable is not actually exported */ - type?: 'module' | 'commonjs' | 'builtin' + type?: 'module' | 'commonjs' | 'builtin' | 'network' } export interface ViteFetchResult { diff --git a/playground/ssr-html/__tests__/ssr-html.spec.ts b/playground/ssr-html/__tests__/ssr-html.spec.ts index 80fd5726d31411..fa9d12c7f6da41 100644 --- a/playground/ssr-html/__tests__/ssr-html.spec.ts +++ b/playground/ssr-html/__tests__/ssr-html.spec.ts @@ -100,12 +100,28 @@ describe.runIf(isServe)('stacktrace', () => { } }) -test.runIf(isServe)('network-imports', async () => { - await execFileAsync( - 'node', - ['--experimental-network-imports', 'test-network-imports.js'], - { - cwd: fileURLToPath(new URL('..', import.meta.url)), - }, - ) +describe.runIf(isServe)('network-imports', () => { + test('with Vite SSR', async () => { + await execFileAsync( + 'node', + ['--experimental-network-imports', 'test-network-imports.js'], + { + cwd: fileURLToPath(new URL('..', import.meta.url)), + }, + ) + }) + + test('with Vite runtime', async () => { + await execFileAsync( + 'node', + [ + '--experimental-network-imports', + 'test-network-imports.js', + '--runtime', + ], + { + cwd: fileURLToPath(new URL('..', import.meta.url)), + }, + ) + }) }) diff --git a/playground/ssr-html/test-network-imports.js b/playground/ssr-html/test-network-imports.js index ddb80eff7f8ca7..91f84f6a3b3ea3 100644 --- a/playground/ssr-html/test-network-imports.js +++ b/playground/ssr-html/test-network-imports.js @@ -1,8 +1,8 @@ import assert from 'node:assert' import { fileURLToPath } from 'node:url' -import { createServer } from 'vite' +import { createServer, createViteRuntime } from 'vite' -async function runTest() { +async function runTest(useRuntime) { const server = await createServer({ configFile: false, root: fileURLToPath(new URL('.', import.meta.url)), @@ -10,9 +10,15 @@ async function runTest() { middlewareMode: true, }, }) - const mod = await server.ssrLoadModule('/src/network-imports.js') + let mod + if (useRuntime) { + const runtime = await createViteRuntime(server, { hmr: false }) + mod = await runtime.executeUrl('/src/network-imports.js') + } else { + mod = await server.ssrLoadModule('/src/network-imports.js') + } assert.equal(mod.slash('foo\\bar'), 'foo/bar') await server.close() } -runTest() +runTest(process.argv.includes('--runtime')) From aa5354e3d48233b5671e66bfd3e6369a1ca7554e Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Jan 2024 11:49:48 +0100 Subject: [PATCH 15/33] chore: cleanup --- packages/vite/src/node/ssr/runtime/runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vite/src/node/ssr/runtime/runtime.ts b/packages/vite/src/node/ssr/runtime/runtime.ts index 28913144c5f79e..26e173420b0a00 100644 --- a/packages/vite/src/node/ssr/runtime/runtime.ts +++ b/packages/vite/src/node/ssr/runtime/runtime.ts @@ -166,7 +166,7 @@ export class ViteRuntime { return exports } const { id, type } = fetchResult - if (type === 'builtin') return exports + if (type !== 'module' && type !== 'commonjs') return exports analyzeImportedModDifference(exports, id, type, metadata) return proxyGuardOnlyEsm(exports, id, metadata) } From 3f044b021b238d62802426079d2d722dc800a88c Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 29 Jan 2024 12:11:57 +0100 Subject: [PATCH 16/33] chore: cleanup --- playground/test-utils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/playground/test-utils.ts b/playground/test-utils.ts index ff18190bc74249..7e9997a6deff6f 100644 --- a/playground/test-utils.ts +++ b/playground/test-utils.ts @@ -39,7 +39,6 @@ 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, From 0b31ff749fec9f047380d4121843175e467f7c7b Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 30 Jan 2024 16:50:36 +0100 Subject: [PATCH 17/33] refactor: add more jsdocs to vite runtime, add isDestroyed --- .../vite/src/node/ssr/runtime/hmrHandler.ts | 2 +- packages/vite/src/node/ssr/runtime/index.ts | 4 +- .../ssr/runtime/node/mainThreadRuntime.ts | 10 +++- packages/vite/src/node/ssr/runtime/runtime.ts | 33 +++++++++--- packages/vite/src/node/ssr/runtime/types.ts | 50 +++++++++++++++++-- 5 files changed, 82 insertions(+), 17 deletions(-) diff --git a/packages/vite/src/node/ssr/runtime/hmrHandler.ts b/packages/vite/src/node/ssr/runtime/hmrHandler.ts index e46547847ba975..48b27636391562 100644 --- a/packages/vite/src/node/ssr/runtime/hmrHandler.ts +++ b/packages/vite/src/node/ssr/runtime/hmrHandler.ts @@ -15,7 +15,7 @@ export async function handleHMRUpdate( payload: HMRPayload, ): Promise { const hmrClient = runtime.hmrClient - if (!hmrClient) return + if (!hmrClient || runtime.isDestroyed()) return switch (payload.type) { case 'connected': hmrClient.logger.debug(`[vite] connected.`) diff --git a/packages/vite/src/node/ssr/runtime/index.ts b/packages/vite/src/node/ssr/runtime/index.ts index 64941a7a7e8dc9..6887d0c9cb279d 100644 --- a/packages/vite/src/node/ssr/runtime/index.ts +++ b/packages/vite/src/node/ssr/runtime/index.ts @@ -6,7 +6,7 @@ export { ESModulesRunner } from './esmRunner' export { handleHMRUpdate, createHMRHandler } from './hmrHandler' -export type { HMRLogger } from '../../../shared/hmr' +export type { HMRLogger, HMRConnection } from '../../../shared/hmr' export type { ViteModuleRunner, ViteRuntimeModuleContext, @@ -17,7 +17,7 @@ export type { SSRImportMetadata, HMRRuntimeConnection, ViteRuntimeImportMeta, - ViteServerClientOptions, + ViteRuntimeOptions, } from './types' export { ssrDynamicImportKey, diff --git a/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts b/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts index a9b76c22ad0833..ea2f462df6557d 100644 --- a/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts +++ b/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts @@ -2,17 +2,23 @@ import { existsSync, readFileSync } from 'node:fs' import type { ViteDevServer } from '../../../index' import { ViteRuntime } from '../runtime' import { ESModulesRunner } from '../esmRunner' -import type { ViteModuleRunner, ViteServerClientOptions } from '../types' +import type { ViteModuleRunner, ViteRuntimeOptions } from '../types' import type { HMRLogger } from '../../../../shared/hmr' import { ServerHMRConnector } from './serverHmrConnector' export interface MainThreadRuntimeOptions - extends Omit { + extends Omit { + /** + * Disable HMR or configure HMR logger. + */ hmr?: | false | { logger?: false | HMRLogger } + /** + * Provide a custom module runner. This controls how the code is executed. + */ runner?: ViteModuleRunner } diff --git a/packages/vite/src/node/ssr/runtime/runtime.ts b/packages/vite/src/node/ssr/runtime/runtime.ts index 26e173420b0a00..239d143c38a14f 100644 --- a/packages/vite/src/node/ssr/runtime/runtime.ts +++ b/packages/vite/src/node/ssr/runtime/runtime.ts @@ -10,7 +10,7 @@ import type { ViteModuleRunner, ViteRuntimeImportMeta, ViteRuntimeModuleContext, - ViteServerClientOptions, + ViteRuntimeOptions, } from './types' import { cleanUrl, @@ -56,7 +56,7 @@ export class ViteRuntime { private _resetSourceMapSupport?: () => void constructor( - public options: ViteServerClientOptions, + public options: ViteRuntimeOptions, public runner: ViteModuleRunner, private debug?: ViteRuntimeDebugger, ) { @@ -94,7 +94,8 @@ export class ViteRuntime { /** * 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 + * In the case of a full reload triggered by HMR, this is the module that will be reloaded. + * If this method is called multiple times, all entrypoints will be reloaded one at a time. */ public async executeEntrypoint(url: string): Promise { url = this.normalizeEntryUrl(url) @@ -104,6 +105,9 @@ export class ViteRuntime { }) } + /** + * Clear all caches including HMR listeners. + */ public clearCache(): void { this.moduleCache.clear() this.idToUrlMap.clear() @@ -111,6 +115,10 @@ export class ViteRuntime { this.hmrClient?.clear() } + /** + * Clears all caches, removes all HMR listeners, and resets source map support. + * This method doesn't stop the HMR connection. + */ public async destroy(): Promise { this._resetSourceMapSupport?.() this.clearCache() @@ -118,6 +126,13 @@ export class ViteRuntime { this._destroyed = true } + /** + * Returns `true` if the runtime has been destroyed by calling `destroy()` method. + */ + public isDestroyed(): boolean { + return this._destroyed + } + private invalidateFiles(files: string[]) { files.forEach((file) => { const ids = this.fileToIdMap.get(file) @@ -221,7 +236,7 @@ export class ViteRuntime { if (mod.promise) return this.processImport(await mod.promise, fetchedModule, metadata) - const promise = this.directRequest(id, fetchedModule, callstack, metadata) + const promise = this.directRequest(id, fetchedModule, callstack) mod.promise = promise mod.evaluated = false return this.processImport(await promise, fetchedModule, metadata) @@ -278,7 +293,6 @@ export class ViteRuntime { id: string, fetchResult: ResolvedResult, _callstack: string[], - metadata?: SSRImportMetadata, ): Promise { const moduleId = fetchResult.id const callstack = [..._callstack, moduleId] @@ -309,7 +323,7 @@ export class ViteRuntime { if ('externalize' in fetchResult) { const { externalize } = fetchResult this.debug?.('[vite-runtime] externalizing', externalize) - const exports = await this.runner.runExternalModule(externalize, metadata) + const exports = await this.runner.runExternalModule(externalize) mod.exports = exports return exports } @@ -359,8 +373,11 @@ export class ViteRuntime { Object.defineProperty(meta, 'hot', { enumerable: true, get: () => { + if (!this.hmrClient) { + throw new Error(`[vite-runtime] HMR client was destroyed.`) + } this.debug?.('[vite-runtime] creating hmr context for', moduleId) - hotContext ||= new HMRContext(this.hmrClient!, moduleId) + hotContext ||= new HMRContext(this.hmrClient, moduleId) return hotContext }, set: (value) => { @@ -379,7 +396,7 @@ export class ViteRuntime { this.debug?.('[vite-runtime] executing', href) - await this.runner.runViteModule(context, code, id, metadata) + await this.runner.runViteModule(context, code, id) return exports } diff --git a/packages/vite/src/node/ssr/runtime/types.ts b/packages/vite/src/node/ssr/runtime/types.ts index 1e7342ef096e96..6d055f8514d659 100644 --- a/packages/vite/src/node/ssr/runtime/types.ts +++ b/packages/vite/src/node/ssr/runtime/types.ts @@ -26,6 +26,10 @@ export interface DefineImportMetadata { } export interface HMRRuntimeConnection extends HMRConnection { + /** + * Configure how HMR is handled when this connection triggers an update. + * This method expects that connection will start listening for HMR updates and call this callback when it's received. + */ onUpdate(callback: (payload: HMRPayload) => void): void } @@ -53,20 +57,28 @@ export interface ViteRuntimeModuleContext { } export interface ViteModuleRunner { + /** + * Run code that was transformed by Vite. + * @param context Function context + * @param code Transformed code + * @param id ID that was used to fetch the module + */ runViteModule( context: ViteRuntimeModuleContext, code: string, id: string, - metadata?: SSRImportMetadata, ): Promise - runExternalModule(file: string, metadata?: SSRImportMetadata): Promise + /** + * Run externalized module. + * @param file File URL to the external module + */ + runExternalModule(file: string): Promise } export interface ModuleCache { promise?: Promise exports?: any evaluated?: boolean - resolving?: boolean map?: DecodedMap meta?: FetchResult /** @@ -114,22 +126,52 @@ export type FetchFunction = ( importer?: string, ) => Promise -export interface ViteServerClientOptions { +export interface ViteRuntimeOptions { + /** + * Root of the project + */ root: string + /** + * A method to get the information about the module. + * For SSR, Vite exposes `server.ssrFetchModule` function that you can use here. + * For other runtime use cases, Vite also exposes `fetchModule` from its main entry point. + */ fetchModule: FetchFunction + /** + * Custom environment variables available on `import.meta.env`. This doesn't modify the actual `process.env`. + */ environmentVariables?: Record + /** + * Configure how source maps are resolved. Prefers `node` if `process.setSourceMapsEnabled` is available. + * Otherwise it will use `prepareStackTrace` by default. + */ sourcemapInterceptor?: | false | 'node' | 'prepareStackTrace' | InterceptorOptions + /** + * Disable HMR or configure HMR options. + */ hmr?: | false | { + /** + * Configure how HMR communicates between the client and the server. + */ connection: HMRRuntimeConnection + /** + * Configure HMR logger. + */ logger?: false | HMRLogger } + /** + * Custom module cache. If not provided, creates a separate module cache for each ViteRuntime instance. + */ moduleCache?: ModuleCacheMap + /** + * Resolved modules that will be returned instead of executing the code. + */ requestStubs?: Record } From 84283ffa8a35a871929b49fe7773e908d76e87a9 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 30 Jan 2024 16:50:54 +0100 Subject: [PATCH 18/33] docs: add documentation about vite runtime --- docs/.vitepress/config.ts | 4 + docs/guide/api-vite-runtime.md | 241 +++++++++++++++++++++++++++++++++ docs/guide/ssr.md | 14 +- 3 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 docs/guide/api-vite-runtime.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 8d2d685a8409d9..8db949d482cfaa 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -279,6 +279,10 @@ export default defineConfig({ text: 'JavaScript API', link: '/guide/api-javascript', }, + { + text: 'Vite Runtime API', + link: '/guide/api-vite-runtime', + }, { text: 'Config Reference', link: '/config/', diff --git a/docs/guide/api-vite-runtime.md b/docs/guide/api-vite-runtime.md new file mode 100644 index 00000000000000..45241a8fe728e9 --- /dev/null +++ b/docs/guide/api-vite-runtime.md @@ -0,0 +1,241 @@ +# Vite Runtime API + +:::warning Low-level API +This API was introduced in Vite 5.1 as an experimental feature. This is a low-level API meant for library and framework authors. If your goal is to create an application, make sure to check out the higher-level SSR plugins and tools at [Awesome Vite SSR section](https://github.com/vitejs/awesome-vite#ssr) first. +::: + +The "Vite Runtime" is a tool that allows running any code by processing it with Vite plugins first. It is different from `server.ssrLoadModule` because the runtime implementation is decoupled from the server. This allows library and framework authors to implement their own layer of communication between the server and the runtime. + +One of the goals of this feature is to provide a customizable API to process and run the code. Vite provides enough tools to use Vite Runtime out of the box, but users can build upon it if their needs do not align with Vite's built-in implementation. + +All APIs can be imported from `vite/runtime`, unless stated otherwise. + +## `ViteRuntime` + +**Type Signature:** + +```ts +export class ViteRuntime { + constructor( + public options: ViteRuntimeOptions, + public runner: ViteModuleRunner, + private debug?: ViteRuntimeDebugger, + ) {} + /** + * URL to execute. Accepts file path, server path or id relative to the root. + */ + public async executeUrl(url: string): Promise + /** + * Entrypoint URL to execute. Accepts file path, server path or id relative to the root. + * In the case of a full reload triggered by HMR, this is the module that will be reloaded. + * If this method is called multiple times, all entrypoints will be reloaded one at a time. + */ + public async executeEntrypoint(url: string): Promise + /** + * Clear all caches including HMR listeners. + */ + public clearCache(): void + /** + * Clears all caches, removes all HMR listeners, and resets source map support. + * This method doesn't stop the HMR connection. + */ + public async destroy(): Promise + /** + * Returns `true` if the runtime has been destroyed by calling `destroy()` method. + */ + public isDestroyed(): boolean +} +``` + +::: tip Advanced Usage +If you are just migrating from `server.ssrLoadModule` and want to support HMR, consider using [`createViteRuntime`](#createviteruntime) instead. +::: + +The `ViteRuntime` class requires `root` and `fetchModule` options when initiated. Vite exposes `ssrFetchModule` on the [`server`](/guide/api-javascript) instance for an easier integration with Vite SSR. Vite also exports `fetchModule` from its main entry point - it doesn't make any assumptions about how the code is running unlike `ssrFetchModule` that expects the code to run using `new Function`. This can be seen in source maps that these functions return. + +Runner in `ViteRuntime` is responsible for executing the code. Vite exports `ESModulesRunner` out of the box, it uses `new AsyncFunction` to run the code. You can provide your own implementation if your JavaScript runtime doesn't support unsafe evaluation. + +The two main methods that runtime exposes are `executeUrl` and `executeEntrypoint`. The only difference between them is that all modules executed by `executeEntrypoint` will be reexecuted if HMR triggers `full-reload` event. Be aware that Vite Runtime doesn't update `exports` object when this happens (it overrides it), you would need to run `executeUrl` or get the module from `moduleCache` again if you rely on having the latest `exports` object. + +**Example Usage:** + +```js +import { ViteRuntime, ESModulesRunner } from 'vite/runtime' +import { root, fetchModule } from './rpc-implementation.js' + +const runtime = new ViteRuntime( + { + root, + fetchModule, + // you can also provide hmr.connection to support HMR + }, + new ESModulesRunner(), +) + +await runtime.executeEntrypoint('/src/entry-point.js') +``` + +## `ViteRuntimeOptions` + +```ts +export interface ViteRuntimeOptions { + /** + * Root of the project + */ + root: string + /** + * A method to get the information about the module. + * For SSR, Vite exposes `server.ssrFetchModule` function that you can use here. + * For other runtime use cases, Vite also exposes `fetchModule` from its main entry point. + */ + fetchModule: FetchFunction + /** + * Custom environment variables available on `import.meta.env`. This doesn't modify the actual `process.env`. + */ + environmentVariables?: Record + /** + * Configure how source maps are resolved. Prefers `node` if `process.setSourceMapsEnabled` is available. + * Otherwise it will use `prepareStackTrace` by default. + */ + sourcemapInterceptor?: + | false + | 'node' + | 'prepareStackTrace' + | InterceptorOptions + /** + * Disable HMR or configure HMR options. + */ + hmr?: + | false + | { + /** + * Configure how HMR communicates between the client and the server. + */ + connection: HMRRuntimeConnection + /** + * Configure HMR logger. + */ + logger?: false | HMRLogger + } + /** + * Custom module cache. If not provided, creates a separate module cache for each ViteRuntime instance. + */ + moduleCache?: ModuleCacheMap + /** + * Resolved modules that will be returned instead of executing the code. + */ + requestStubs?: Record +} +``` + +## `ViteModuleRunner` + +**Type Signature:** + +```ts +export interface ViteModuleRunner { + /** + * Run code that was transformed by Vite. + * @param context Function context + * @param code Transformed code + * @param id ID that was used to fetch the module + */ + runViteModule( + context: ViteRuntimeModuleContext, + code: string, + id: string, + ): Promise + /** + * Run externalized module. + * @param file File URL to the external module + */ + runExternalModule(file: string): Promise +} +``` + +Vite exports `ESModulesRunner` that implements this interface by default. It uses `new AsyncFunction` to run code, so if the code has inlined source map it should contain an [offset of 2 lines](https://tc39.es/ecma262/#sec-createdynamicfunction) to accommodate for new lines added. This is done automatically by `server.ssrFetchModule`. If your runner implementation doesn't have this contraint, you should use `fetchModule` (exported from `vite`) directly. + +## HMRRuntimeConnection + +**Type Signature:** + +```ts +export interface HMRRuntimeConnection { + /** + * Checked before sending messages to the client. + */ + isReady(): boolean + /** + * Send message to the client. + */ + send(messages: string): void + /** + * Configure how HMR is handled when this connection triggers an update. + * This method expects that connection will start listening for HMR updates and call this callback when it's received. + */ + onUpdate(callback: (payload: HMRPayload) => void): void +} +``` + +This interface defines how HMR communication is established. Vite exports `ServerHMRConnector` from the main entry point to support HMR during Vite SSR. The `isReady` and `send` methods are usually called when the custom event is triggered (like, `import.meta.hot.send("my-event")`). + +`onUpdate` is called only once when the new runtime is initiated. It passed down a method that should be called when connection triggers the HMR event. The implementation depends on the type of connection (as an example, it can be `WebSocket`/`EventEmitter`/`MessageChannel`), but it usually looks something like this: + +```js +function onUpdate(callback) { + this.connection.on('hmr', (event) => callback(event.data)) +} +``` + +The callback is queued and it will wait for the current update to be resolved before processing the next update. Unlike the browser implementation, HMR updates in Vite Runtime wait until all listeners (like, `vite:beforeUpdate`/`vite:beforeFullReload`) are finished before updating the modules. + +## `createViteRuntime` + +**Type Signature:** + +```ts +async function createViteRuntime( + server: ViteDevServer, + options?: MainThreadRuntimeOptions, +): Promise +``` + +**Example Usage:** + +```js +import { createServer } from 'vite' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) + +;(async () => { + const server = await createServer({ + root: __dirname, + }) + await server.listen() + + const runtime = await createViteRuntime(server) + await runtime.executeEntrypoint('/src/entry-point.js') +})() +``` + +This methods serves as an easy replacement for `server.ssrLoadModule`. Unlike `ssrLoadModule`, `createViteRuntime` provides HMR support out of the box. You can pass down [`options`](#mainthreadruntimeoptions) to customize how SSR runtime behaves to suit your needs. + +## `MainThreadRuntimeOptions` + +```ts +export interface MainThreadRuntimeOptions + extends Omit { + /** + * Disable HMR or configure HMR logger. + */ + hmr?: + | false + | { + logger?: false | HMRLogger + } + /** + * Provide a custom module runner. This controls how the code is executed. + */ + runner?: ViteModuleRunner +} +``` diff --git a/docs/guide/ssr.md b/docs/guide/ssr.md index d8159ab8a0c042..f989f7cf392704 100644 --- a/docs/guide/ssr.md +++ b/docs/guide/ssr.md @@ -125,10 +125,16 @@ app.use('*', async (req, res, next) => { // preambles from @vitejs/plugin-react template = await vite.transformIndexHtml(url, template) - // 3. Load the server entry. ssrLoadModule automatically transforms + // 3a. Load the server entry. ssrLoadModule automatically transforms // ESM source code to be usable in Node.js! There is no bundling // required, and provides efficient invalidation similar to HMR. const { render } = await vite.ssrLoadModule('/src/entry-server.js') + // 3b. Since Vite 5.1, you can use createViteRuntime API instead. + // It fully supports HMR and works in a simillar way to ssrLoadModule + // More advanced use case would be creating a runtime in a separate + // thread or even a different machine using ViteRuntime class + const runtime = await vite.createViteRuntime(server) + const { render } = await runtime.executeEntrypoint('/src/entry-server.js') // 4. render the app HTML. This assumes entry-server.js's exported // `render` function calls appropriate framework SSR APIs, @@ -163,7 +169,7 @@ The `dev` script in `package.json` should also be changed to use the server scri To ship an SSR project for production, we need to: 1. Produce a client build as normal; -2. Produce an SSR build, which can be directly loaded via `import()` so that we don't have to go through Vite's `ssrLoadModule`; +2. Produce an SSR build, which can be directly loaded via `import()` so that we don't have to go through Vite's `ssrLoadModule` or `runtime.executeEntrypoint`; Our scripts in `package.json` will look like this: @@ -181,9 +187,9 @@ Note the `--ssr` flag which indicates this is an SSR build. It should also speci Then, in `server.js` we need to add some production specific logic by checking `process.env.NODE_ENV`: -- Instead of reading the root `index.html`, use the `dist/client/index.html` as the template instead, since it contains the correct asset links to the client build. +- Instead of reading the root `index.html`, use the `dist/client/index.html` as the template, since it contains the correct asset links to the client build. -- Instead of `await vite.ssrLoadModule('/src/entry-server.js')`, use `import('./dist/server/entry-server.js')` instead (this file is the result of the SSR build). +- Instead of `await vite.ssrLoadModule('/src/entry-server.js')` or `await runtime.executeEntrypoint('/src/entry-server.js')`, use `import('./dist/server/entry-server.js')` (this file is the result of the SSR build). - Move the creation and all usage of the `vite` dev server behind dev-only conditional branches, then add static file serving middlewares to serve files from `dist/client`. From 0e0263e7ba1e8daef4e4b3cdf36b36f2d3e42640 Mon Sep 17 00:00:00 2001 From: patak Date: Tue, 30 Jan 2024 22:33:40 +0100 Subject: [PATCH 19/33] chore: typo --- docs/guide/api-vite-runtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/api-vite-runtime.md b/docs/guide/api-vite-runtime.md index 45241a8fe728e9..388fef43802fd2 100644 --- a/docs/guide/api-vite-runtime.md +++ b/docs/guide/api-vite-runtime.md @@ -218,7 +218,7 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url)) })() ``` -This methods serves as an easy replacement for `server.ssrLoadModule`. Unlike `ssrLoadModule`, `createViteRuntime` provides HMR support out of the box. You can pass down [`options`](#mainthreadruntimeoptions) to customize how SSR runtime behaves to suit your needs. +This method serves as an easy replacement for `server.ssrLoadModule`. Unlike `ssrLoadModule`, `createViteRuntime` provides HMR support out of the box. You can pass down [`options`](#mainthreadruntimeoptions) to customize how SSR runtime behaves to suit your needs. ## `MainThreadRuntimeOptions` From ff81b02c9427df20a1ba61120cdfaa23130253a1 Mon Sep 17 00:00:00 2001 From: patak Date: Tue, 30 Jan 2024 22:34:00 +0100 Subject: [PATCH 20/33] chore: typo --- docs/guide/api-vite-runtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/api-vite-runtime.md b/docs/guide/api-vite-runtime.md index 388fef43802fd2..137d7dee7b5805 100644 --- a/docs/guide/api-vite-runtime.md +++ b/docs/guide/api-vite-runtime.md @@ -8,7 +8,7 @@ The "Vite Runtime" is a tool that allows running any code by processing it with One of the goals of this feature is to provide a customizable API to process and run the code. Vite provides enough tools to use Vite Runtime out of the box, but users can build upon it if their needs do not align with Vite's built-in implementation. -All APIs can be imported from `vite/runtime`, unless stated otherwise. +All APIs can be imported from `vite/runtime` unless stated otherwise. ## `ViteRuntime` From fb4622788878f8f6b027d5bbe23498427f007a99 Mon Sep 17 00:00:00 2001 From: patak Date: Tue, 30 Jan 2024 22:34:16 +0100 Subject: [PATCH 21/33] chore: typo --- docs/guide/api-vite-runtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/api-vite-runtime.md b/docs/guide/api-vite-runtime.md index 137d7dee7b5805..ae4b53de7585ba 100644 --- a/docs/guide/api-vite-runtime.md +++ b/docs/guide/api-vite-runtime.md @@ -22,7 +22,7 @@ export class ViteRuntime { private debug?: ViteRuntimeDebugger, ) {} /** - * URL to execute. Accepts file path, server path or id relative to the root. + * URL to execute. Accepts file path, server path, or id relative to the root. */ public async executeUrl(url: string): Promise /** From bab1559cdcc5dd3c73958424a27c3fb8e4ab63b5 Mon Sep 17 00:00:00 2001 From: patak Date: Tue, 30 Jan 2024 22:34:40 +0100 Subject: [PATCH 22/33] chore: wording --- docs/guide/api-vite-runtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/api-vite-runtime.md b/docs/guide/api-vite-runtime.md index ae4b53de7585ba..205cd646818c54 100644 --- a/docs/guide/api-vite-runtime.md +++ b/docs/guide/api-vite-runtime.md @@ -51,7 +51,7 @@ export class ViteRuntime { If you are just migrating from `server.ssrLoadModule` and want to support HMR, consider using [`createViteRuntime`](#createviteruntime) instead. ::: -The `ViteRuntime` class requires `root` and `fetchModule` options when initiated. Vite exposes `ssrFetchModule` on the [`server`](/guide/api-javascript) instance for an easier integration with Vite SSR. Vite also exports `fetchModule` from its main entry point - it doesn't make any assumptions about how the code is running unlike `ssrFetchModule` that expects the code to run using `new Function`. This can be seen in source maps that these functions return. +The `ViteRuntime` class requires `root` and `fetchModule` options when initiated. Vite exposes `ssrFetchModule` on the [`server`](/guide/api-javascript) instance for easier integration with Vite SSR. Vite also exports `fetchModule` from its main entry point - it doesn't make any assumptions about how the code is running unlike `ssrFetchModule` that expects the code to run using `new Function`. This can be seen in source maps that these functions return. Runner in `ViteRuntime` is responsible for executing the code. Vite exports `ESModulesRunner` out of the box, it uses `new AsyncFunction` to run the code. You can provide your own implementation if your JavaScript runtime doesn't support unsafe evaluation. From 34f236384dadd3fa8abd1ac74d66691a67461758 Mon Sep 17 00:00:00 2001 From: patak Date: Tue, 30 Jan 2024 22:34:55 +0100 Subject: [PATCH 23/33] chore: wording --- docs/guide/api-vite-runtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/api-vite-runtime.md b/docs/guide/api-vite-runtime.md index 205cd646818c54..bd36966ec8e81a 100644 --- a/docs/guide/api-vite-runtime.md +++ b/docs/guide/api-vite-runtime.md @@ -118,7 +118,7 @@ export interface ViteRuntimeOptions { logger?: false | HMRLogger } /** - * Custom module cache. If not provided, creates a separate module cache for each ViteRuntime instance. + * Custom module cache. If not provided, it creates a separate module cache for each ViteRuntime instance. */ moduleCache?: ModuleCacheMap /** From c58fbe0ed5e2e0262635a5f352dc68adae1e1863 Mon Sep 17 00:00:00 2001 From: patak Date: Tue, 30 Jan 2024 22:35:10 +0100 Subject: [PATCH 24/33] chore: typo --- docs/guide/api-vite-runtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/api-vite-runtime.md b/docs/guide/api-vite-runtime.md index bd36966ec8e81a..2ccb0110316947 100644 --- a/docs/guide/api-vite-runtime.md +++ b/docs/guide/api-vite-runtime.md @@ -153,7 +153,7 @@ export interface ViteModuleRunner { } ``` -Vite exports `ESModulesRunner` that implements this interface by default. It uses `new AsyncFunction` to run code, so if the code has inlined source map it should contain an [offset of 2 lines](https://tc39.es/ecma262/#sec-createdynamicfunction) to accommodate for new lines added. This is done automatically by `server.ssrFetchModule`. If your runner implementation doesn't have this contraint, you should use `fetchModule` (exported from `vite`) directly. +Vite exports `ESModulesRunner` that implements this interface by default. It uses `new AsyncFunction` to run code, so if the code has inlined source map it should contain an [offset of 2 lines](https://tc39.es/ecma262/#sec-createdynamicfunction) to accommodate for new lines added. This is done automatically by `server.ssrFetchModule`. If your runner implementation doesn't have this constraint, you should use `fetchModule` (exported from `vite`) directly. ## HMRRuntimeConnection From cf3815187b0a999a4e357a22a16930421084621c Mon Sep 17 00:00:00 2001 From: patak Date: Tue, 30 Jan 2024 22:35:26 +0100 Subject: [PATCH 25/33] chore: typo --- docs/guide/api-vite-runtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/api-vite-runtime.md b/docs/guide/api-vite-runtime.md index 2ccb0110316947..bdd7beda6b6b21 100644 --- a/docs/guide/api-vite-runtime.md +++ b/docs/guide/api-vite-runtime.md @@ -168,7 +168,7 @@ export interface HMRRuntimeConnection { /** * Send message to the client. */ - send(messages: string): void + send(message: string): void /** * Configure how HMR is handled when this connection triggers an update. * This method expects that connection will start listening for HMR updates and call this callback when it's received. From 9764b51fa623a0dc44b63c97873a86c18efb9e0e Mon Sep 17 00:00:00 2001 From: patak Date: Wed, 31 Jan 2024 06:27:57 +0100 Subject: [PATCH 26/33] chore: warning about potential changes in Vite 5.2 --- docs/guide/api-vite-runtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/api-vite-runtime.md b/docs/guide/api-vite-runtime.md index bdd7beda6b6b21..46c189a71ca17d 100644 --- a/docs/guide/api-vite-runtime.md +++ b/docs/guide/api-vite-runtime.md @@ -1,7 +1,7 @@ # Vite Runtime API :::warning Low-level API -This API was introduced in Vite 5.1 as an experimental feature. This is a low-level API meant for library and framework authors. If your goal is to create an application, make sure to check out the higher-level SSR plugins and tools at [Awesome Vite SSR section](https://github.com/vitejs/awesome-vite#ssr) first. +This API was introduced in Vite 5.1 as an experimental feature. It was a added to gather feedback. There will probably be breaking changes to it in Vite 5.2, so make sure to pin the Vite version to `~5.1.0` when using it. This is a low-level API meant for library and framework authors. If your goal is to create an application, make sure to check out the higher-level SSR plugins and tools at [Awesome Vite SSR section](https://github.com/vitejs/awesome-vite#ssr) first. ::: The "Vite Runtime" is a tool that allows running any code by processing it with Vite plugins first. It is different from `server.ssrLoadModule` because the runtime implementation is decoupled from the server. This allows library and framework authors to implement their own layer of communication between the server and the runtime. From 3e18bb7e2bc56afe392b9cdd5f8706312f93e89b Mon Sep 17 00:00:00 2001 From: patak Date: Wed, 31 Jan 2024 08:33:05 +0100 Subject: [PATCH 27/33] chore: wording --- docs/guide/api-vite-runtime.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/api-vite-runtime.md b/docs/guide/api-vite-runtime.md index 46c189a71ca17d..7cec0c91d6d556 100644 --- a/docs/guide/api-vite-runtime.md +++ b/docs/guide/api-vite-runtime.md @@ -26,9 +26,9 @@ export class ViteRuntime { */ public async executeUrl(url: string): Promise /** - * Entrypoint URL to execute. Accepts file path, server path or id relative to the root. + * Entry point URL to execute. Accepts file path, server path or id relative to the root. * In the case of a full reload triggered by HMR, this is the module that will be reloaded. - * If this method is called multiple times, all entrypoints will be reloaded one at a time. + * If this method is called multiple times, all entry points will be reloaded one at a time. */ public async executeEntrypoint(url: string): Promise /** From 294a50a824012ea4ff973b7580f7d05e73537106 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 31 Jan 2024 08:39:35 +0100 Subject: [PATCH 28/33] test: correct hmr ports --- playground/test-utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/playground/test-utils.ts b/playground/test-utils.ts index 7e9997a6deff6f..bf86291e3e0c72 100644 --- a/playground/test-utils.ts +++ b/playground/test-utils.ts @@ -36,6 +36,8 @@ export const ports = { 'ssr-noexternal': 9603, 'ssr-pug': 9604, 'ssr-webworker': 9605, + 'proxy-bypass': 9606, // not imported but used in `proxy-hmr/vite.config.js` + 'proxy-bypass/non-existent-app': 9607, // not imported but used in `proxy-hmr/other-app/vite.config.js` '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, From fea94b53e79990e1104651d9fb22d6954284b197 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 31 Jan 2024 08:47:18 +0100 Subject: [PATCH 29/33] test: fix ports --- playground/test-utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/playground/test-utils.ts b/playground/test-utils.ts index bf86291e3e0c72..92123f08900896 100644 --- a/playground/test-utils.ts +++ b/playground/test-utils.ts @@ -38,9 +38,9 @@ export const ports = { 'ssr-webworker': 9605, 'proxy-bypass': 9606, // not imported but used in `proxy-hmr/vite.config.js` 'proxy-bypass/non-existent-app': 9607, // not imported but used in `proxy-hmr/other-app/vite.config.js` - '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, + 'proxy-hmr': 9616, // not imported but used in `proxy-hmr/vite.config.js` + 'proxy-hmr/other-app': 9617, // not imported but used in `proxy-hmr/other-app/vite.config.js` + 'ssr-conditions': 9620, 'css/postcss-caching': 5005, 'css/postcss-plugins-different-dir': 5006, 'css/dynamic-import': 5007, From 0573c0d3346c080fc115fba66ffb25480a0c9fa6 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 31 Jan 2024 12:29:08 +0100 Subject: [PATCH 30/33] chore: review feedback --- docs/guide/api-vite-runtime.md | 11 +--- .../vite/src/node/ssr/runtime/hmrHandler.ts | 4 +- packages/vite/src/node/ssr/runtime/index.ts | 2 - packages/vite/src/node/ssr/runtime/runtime.ts | 14 ++--- packages/vite/src/node/ssr/runtime/types.ts | 7 +-- packages/vite/src/node/ssr/runtime/utils.ts | 63 +------------------ playground/hmr-ssr/package.json | 2 +- 7 files changed, 16 insertions(+), 87 deletions(-) diff --git a/docs/guide/api-vite-runtime.md b/docs/guide/api-vite-runtime.md index 7cec0c91d6d556..18a93549b5cfc8 100644 --- a/docs/guide/api-vite-runtime.md +++ b/docs/guide/api-vite-runtime.md @@ -89,13 +89,10 @@ export interface ViteRuntimeOptions { * For other runtime use cases, Vite also exposes `fetchModule` from its main entry point. */ fetchModule: FetchFunction - /** - * Custom environment variables available on `import.meta.env`. This doesn't modify the actual `process.env`. - */ - environmentVariables?: Record /** * Configure how source maps are resolved. Prefers `node` if `process.setSourceMapsEnabled` is available. - * Otherwise it will use `prepareStackTrace` by default. + * Otherwise it will use `prepareStackTrace` by default which overrides `Error.prepareStackTrace` method. + * You can provide an object to configure how file contents and source maps are resolved for files that were not processed by Vite. */ sourcemapInterceptor?: | false @@ -121,10 +118,6 @@ export interface ViteRuntimeOptions { * Custom module cache. If not provided, it creates a separate module cache for each ViteRuntime instance. */ moduleCache?: ModuleCacheMap - /** - * Resolved modules that will be returned instead of executing the code. - */ - requestStubs?: Record } ``` diff --git a/packages/vite/src/node/ssr/runtime/hmrHandler.ts b/packages/vite/src/node/ssr/runtime/hmrHandler.ts index 48b27636391562..413d355c2f51a0 100644 --- a/packages/vite/src/node/ssr/runtime/hmrHandler.ts +++ b/packages/vite/src/node/ssr/runtime/hmrHandler.ts @@ -7,10 +7,10 @@ export function createHMRHandler( runtime: ViteRuntime, ): (payload: HMRPayload) => Promise { const queue = new Queue() - return (payload) => queue.enqueue(() => handleHMRUpdate(runtime, payload)) + return (payload) => queue.enqueue(() => handleHMRPayload(runtime, payload)) } -export async function handleHMRUpdate( +export async function handleHMRPayload( runtime: ViteRuntime, payload: HMRPayload, ): Promise { diff --git a/packages/vite/src/node/ssr/runtime/index.ts b/packages/vite/src/node/ssr/runtime/index.ts index 6887d0c9cb279d..f2b5b83f0fda5d 100644 --- a/packages/vite/src/node/ssr/runtime/index.ts +++ b/packages/vite/src/node/ssr/runtime/index.ts @@ -4,8 +4,6 @@ export { ModuleCacheMap } from './moduleCache' export { ViteRuntime } from './runtime' export { ESModulesRunner } from './esmRunner' -export { handleHMRUpdate, createHMRHandler } from './hmrHandler' - export type { HMRLogger, HMRConnection } from '../../../shared/hmr' export type { ViteModuleRunner, diff --git a/packages/vite/src/node/ssr/runtime/runtime.ts b/packages/vite/src/node/ssr/runtime/runtime.ts index 239d143c38a14f..77b647623d63d8 100644 --- a/packages/vite/src/node/ssr/runtime/runtime.ts +++ b/packages/vite/src/node/ssr/runtime/runtime.ts @@ -3,7 +3,6 @@ import { HMRClient, HMRContext } from '../../../shared/hmr' import { ModuleCacheMap } from './moduleCache' import type { FetchResult, - ImportMetaEnv, ModuleCache, ResolvedResult, SSRImportMetadata, @@ -14,7 +13,6 @@ import type { } from './types' import { cleanUrl, - createImportMetaEnvProxy, isPrimitive, isWindows, posixDirname, @@ -50,7 +48,13 @@ export class ViteRuntime { private idToUrlMap = new Map() private fileToIdMap = new Map() - private envProxy: ImportMetaEnv + private envProxy = new Proxy({} as any, { + get(_, p) { + throw new Error( + `[vite-runtime] Dynamic access of "import.meta.env" is not supported. Please, use "import.meta.env.${String(p)}" instead.`, + ) + }, + }) private _destroyed = false private _resetSourceMapSupport?: () => void @@ -61,7 +65,6 @@ export class ViteRuntime { 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 @@ -317,9 +320,6 @@ export class ViteRuntime { return request(dep, { isDynamicImport: true }) } - const requestStubs = this.options.requestStubs || {} - if (id in requestStubs) return requestStubs[id] - if ('externalize' in fetchResult) { const { externalize } = fetchResult this.debug?.('[vite-runtime] externalizing', externalize) diff --git a/packages/vite/src/node/ssr/runtime/types.ts b/packages/vite/src/node/ssr/runtime/types.ts index 6d055f8514d659..8da395093feead 100644 --- a/packages/vite/src/node/ssr/runtime/types.ts +++ b/packages/vite/src/node/ssr/runtime/types.ts @@ -143,7 +143,8 @@ export interface ViteRuntimeOptions { environmentVariables?: Record /** * Configure how source maps are resolved. Prefers `node` if `process.setSourceMapsEnabled` is available. - * Otherwise it will use `prepareStackTrace` by default. + * Otherwise it will use `prepareStackTrace` by default which overrides `Error.prepareStackTrace` method. + * You can provide an object to configure how file contents and source maps are resolved for files that were not processed by Vite. */ sourcemapInterceptor?: | false @@ -169,10 +170,6 @@ export interface ViteRuntimeOptions { * Custom module cache. If not provided, creates a separate module cache for each ViteRuntime instance. */ moduleCache?: ModuleCacheMap - /** - * Resolved modules that will be returned instead of executing the code. - */ - requestStubs?: Record } export interface ImportMetaEnv { diff --git a/packages/vite/src/node/ssr/runtime/utils.ts b/packages/vite/src/node/ssr/runtime/utils.ts index acc22dc5c22beb..f2eca533b350fe 100644 --- a/packages/vite/src/node/ssr/runtime/utils.ts +++ b/packages/vite/src/node/ssr/runtime/utils.ts @@ -1,5 +1,3 @@ -import type { ImportMetaEnv } from './types' - export const isWindows = typeof process !== 'undefined' && process.platform === 'win32' @@ -29,66 +27,9 @@ export function slash(p: string): string { return p.replace(windowsSlashRE, '/') } -export const queryRE = /\?.*$/s -export const hashRE = /#.*$/s - +const postfixRE = /[?#].*$/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( - {}, - { - 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 + return url.replace(postfixRE, '') } export function isPrimitive(value: unknown): boolean { diff --git a/playground/hmr-ssr/package.json b/playground/hmr-ssr/package.json index 35e0799c262738..52a5646e2da7a4 100644 --- a/playground/hmr-ssr/package.json +++ b/playground/hmr-ssr/package.json @@ -1,5 +1,5 @@ { - "name": "@vitejs/test-hmr", + "name": "@vitejs/test-hmr-ssr", "private": true, "version": "0.0.0", "type": "module", From 4c0d3faeccc2632268fdbc4435528f5720604c83 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 31 Jan 2024 15:58:15 +0100 Subject: [PATCH 31/33] docs: add experimental jsdoc to runtime APIs available from "vite" entry point --- packages/vite/src/node/index.ts | 1 + packages/vite/src/node/server/index.ts | 3 ++- packages/vite/src/node/ssr/fetchModule.ts | 4 ++++ .../vite/src/node/ssr/runtime/node/mainThreadRuntime.ts | 7 +++++++ .../vite/src/node/ssr/runtime/node/serverHmrConnector.ts | 4 ++++ packages/vite/src/node/ssr/runtime/types.ts | 3 +++ 6 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/vite/src/node/index.ts b/packages/vite/src/node/index.ts index c998cd8b503394..61d50e0c6d6853 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -127,6 +127,7 @@ export type { ServerHMRChannel, HMRBroadcasterClient, } from './server/hmr' + export type { FetchFunction } from './ssr/runtime/index' export { createViteRuntime } from './ssr/runtime/node/mainThreadRuntime' export type { MainThreadRuntimeOptions } from './ssr/runtime/node/mainThreadRuntime' diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 7479463273961c..dd13255ad431f0 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -298,7 +298,8 @@ export interface ViteDevServer { opts?: { fixStacktrace?: boolean }, ): Promise> /** - * Fetch information about the module + * Fetch information about the module for Vite SSR runtime. + * @experimental */ ssrFetchModule(id: string, importer?: string): Promise /** diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index f5123d9e88dcc7..e62d5b25d1f2d3 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -17,6 +17,10 @@ export interface FetchModuleOptions { processSourceMap?>(map: T): T } +/** + * Fetch module information for Vite runtime. + * @experimental + */ export async function fetchModule( server: ViteDevServer, url: string, diff --git a/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts b/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts index ea2f462df6557d..9146f42c1f3b21 100644 --- a/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts +++ b/packages/vite/src/node/ssr/runtime/node/mainThreadRuntime.ts @@ -6,6 +6,9 @@ import type { ViteModuleRunner, ViteRuntimeOptions } from '../types' import type { HMRLogger } from '../../../../shared/hmr' import { ServerHMRConnector } from './serverHmrConnector' +/** + * @experimental + */ export interface MainThreadRuntimeOptions extends Omit { /** @@ -60,6 +63,10 @@ function resolveSourceMapOptions(options: MainThreadRuntimeOptions) { return prepareStackTrace } +/** + * Create an instance of the Vite SSR runtime that support HMR. + * @experimental + */ export async function createViteRuntime( server: ViteDevServer, options: MainThreadRuntimeOptions = {}, diff --git a/packages/vite/src/node/ssr/runtime/node/serverHmrConnector.ts b/packages/vite/src/node/ssr/runtime/node/serverHmrConnector.ts index 9a19192a2c0db7..21e0315ad381d5 100644 --- a/packages/vite/src/node/ssr/runtime/node/serverHmrConnector.ts +++ b/packages/vite/src/node/ssr/runtime/node/serverHmrConnector.ts @@ -29,6 +29,10 @@ class ServerHMRBroadcasterClient implements HMRBroadcasterClient { } } +/** + * The connector class to establish HMR communication between the server and the Vite runtime. + * @experimental + */ export class ServerHMRConnector implements HMRRuntimeConnection { private handlers: ((payload: HMRPayload) => void)[] = [] private hmrChannel: ServerHMRChannel diff --git a/packages/vite/src/node/ssr/runtime/types.ts b/packages/vite/src/node/ssr/runtime/types.ts index 8da395093feead..cbdbb17bbe7161 100644 --- a/packages/vite/src/node/ssr/runtime/types.ts +++ b/packages/vite/src/node/ssr/runtime/types.ts @@ -121,6 +121,9 @@ export type ResolvedResult = (ExternalFetchResult | ViteFetchResult) & { id: string } +/** + * @experimental + */ export type FetchFunction = ( id: string, importer?: string, From 1db9c011dada7768fc835f101cf22a42faf5d411 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 1 Feb 2024 09:22:03 +0100 Subject: [PATCH 32/33] refactor: update source map folder name --- packages/vite/src/node/ssr/runtime/moduleCache.ts | 2 +- packages/vite/src/node/ssr/runtime/runtime.ts | 2 +- .../node/ssr/runtime/{source-map => sourcemap}/decoder.ts | 0 .../node/ssr/runtime/{source-map => sourcemap}/index.ts | 0 .../ssr/runtime/{source-map => sourcemap}/interceptor.ts | 7 +------ packages/vite/src/node/ssr/runtime/types.ts | 4 ++-- 6 files changed, 5 insertions(+), 10 deletions(-) rename packages/vite/src/node/ssr/runtime/{source-map => sourcemap}/decoder.ts (100%) rename packages/vite/src/node/ssr/runtime/{source-map => sourcemap}/index.ts (100%) rename packages/vite/src/node/ssr/runtime/{source-map => sourcemap}/interceptor.ts (99%) diff --git a/packages/vite/src/node/ssr/runtime/moduleCache.ts b/packages/vite/src/node/ssr/runtime/moduleCache.ts index 2d24d0a1d12da6..e40d1dd7316960 100644 --- a/packages/vite/src/node/ssr/runtime/moduleCache.ts +++ b/packages/vite/src/node/ssr/runtime/moduleCache.ts @@ -1,4 +1,4 @@ -import { DecodedMap } from './source-map/decoder' +import { DecodedMap } from './sourcemap/decoder' import type { ModuleCache } from './types' import { decodeBase64, isWindows } from './utils' diff --git a/packages/vite/src/node/ssr/runtime/runtime.ts b/packages/vite/src/node/ssr/runtime/runtime.ts index 77b647623d63d8..1c43eb713eea4f 100644 --- a/packages/vite/src/node/ssr/runtime/runtime.ts +++ b/packages/vite/src/node/ssr/runtime/runtime.ts @@ -31,7 +31,7 @@ import { } from './constants' import { silentConsole } from './hmrLogger' import { createHMRHandler } from './hmrHandler' -import { enableSourceMapSupport } from './source-map/index' +import { enableSourceMapSupport } from './sourcemap/index' interface ViteRuntimeDebugger { (formatter: unknown, ...args: unknown[]): void diff --git a/packages/vite/src/node/ssr/runtime/source-map/decoder.ts b/packages/vite/src/node/ssr/runtime/sourcemap/decoder.ts similarity index 100% rename from packages/vite/src/node/ssr/runtime/source-map/decoder.ts rename to packages/vite/src/node/ssr/runtime/sourcemap/decoder.ts diff --git a/packages/vite/src/node/ssr/runtime/source-map/index.ts b/packages/vite/src/node/ssr/runtime/sourcemap/index.ts similarity index 100% rename from packages/vite/src/node/ssr/runtime/source-map/index.ts rename to packages/vite/src/node/ssr/runtime/sourcemap/index.ts diff --git a/packages/vite/src/node/ssr/runtime/source-map/interceptor.ts b/packages/vite/src/node/ssr/runtime/sourcemap/interceptor.ts similarity index 99% rename from packages/vite/src/node/ssr/runtime/source-map/interceptor.ts rename to packages/vite/src/node/ssr/runtime/sourcemap/interceptor.ts index 4a1fe72c3a8326..043287b5839168 100644 --- a/packages/vite/src/node/ssr/runtime/source-map/interceptor.ts +++ b/packages/vite/src/node/ssr/runtime/sourcemap/interceptor.ts @@ -419,12 +419,7 @@ function wrapCallSite(frame: CallSite, state: State) { return frame } -function prepareStackTrace( - // runtime: ViteRuntime, - // options: InterceptorOptions, - error: Error, - stack: CallSite[], -) { +function prepareStackTrace(error: Error, stack: CallSite[]) { const name = error.name || 'Error' const message = error.message || '' const errorString = `${name}: ${message}` diff --git a/packages/vite/src/node/ssr/runtime/types.ts b/packages/vite/src/node/ssr/runtime/types.ts index cbdbb17bbe7161..da4ae441282e53 100644 --- a/packages/vite/src/node/ssr/runtime/types.ts +++ b/packages/vite/src/node/ssr/runtime/types.ts @@ -9,8 +9,8 @@ import type { ssrImportMetaKey, ssrModuleExportsKey, } from './constants' -import type { DecodedMap } from './source-map/decoder' -import type { InterceptorOptions } from './source-map/interceptor' +import type { DecodedMap } from './sourcemap/decoder' +import type { InterceptorOptions } from './sourcemap/interceptor' export interface DefineImportMetadata { /** From f7c3c259b5545d22d7950691dc0832a9c08daa7f Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 1 Feb 2024 09:29:21 +0100 Subject: [PATCH 33/33] chore: add a link to the discussion in runtime docs --- docs/guide/api-vite-runtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/api-vite-runtime.md b/docs/guide/api-vite-runtime.md index 18a93549b5cfc8..5c4ee3f07c3ec5 100644 --- a/docs/guide/api-vite-runtime.md +++ b/docs/guide/api-vite-runtime.md @@ -1,7 +1,7 @@ # Vite Runtime API :::warning Low-level API -This API was introduced in Vite 5.1 as an experimental feature. It was a added to gather feedback. There will probably be breaking changes to it in Vite 5.2, so make sure to pin the Vite version to `~5.1.0` when using it. This is a low-level API meant for library and framework authors. If your goal is to create an application, make sure to check out the higher-level SSR plugins and tools at [Awesome Vite SSR section](https://github.com/vitejs/awesome-vite#ssr) first. +This API was introduced in Vite 5.1 as an experimental feature. It was added to [gather feedback](https://github.com/vitejs/vite/discussions/15774). There will probably be breaking changes to it in Vite 5.2, so make sure to pin the Vite version to `~5.1.0` when using it. This is a low-level API meant for library and framework authors. If your goal is to create an application, make sure to check out the higher-level SSR plugins and tools at [Awesome Vite SSR section](https://github.com/vitejs/awesome-vite#ssr) first. ::: The "Vite Runtime" is a tool that allows running any code by processing it with Vite plugins first. It is different from `server.ssrLoadModule` because the runtime implementation is decoupled from the server. This allows library and framework authors to implement their own layer of communication between the server and the runtime.