diff --git a/core/src/html-error.ts b/core/src/html-error.ts new file mode 100644 index 00000000000..3eea9ba66b0 --- /dev/null +++ b/core/src/html-error.ts @@ -0,0 +1,23 @@ +import { escape } from "html-escaper"; + +export function generateHtmlError(message: string) { + return ` + + + + +
${escape(message)}
+ +`; +} diff --git a/core/src/index.ts b/core/src/index.ts index 3c2fc207184..a11e73f9cf6 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -201,10 +201,6 @@ export async function createWorkspace({ logger, middlewares, port, - onFileChanged: (absoluteFilePath) => { - const filePath = path.relative(rootDir, absoluteFilePath); - frameworkPlugin.typeAnalyzer.invalidateCachedTypesForFile(filePath); - }, }); await previewer.start(); activePreviewers.add(previewer); diff --git a/core/src/previewer.ts b/core/src/previewer.ts index cbd60d78dd4..4d15203c29c 100644 --- a/core/src/previewer.ts +++ b/core/src/previewer.ts @@ -1,64 +1,21 @@ -import type { PreviewConfig } from "@previewjs/config"; -import { PREVIEW_CONFIG_NAME, readConfig } from "@previewjs/config"; -import type { Reader, ReaderListenerInfo } from "@previewjs/vfs"; +import type { Reader } from "@previewjs/vfs"; import { createFileSystemReader, createStackedReader } from "@previewjs/vfs"; import assertNever from "assert-never"; import axios from "axios"; import express from "express"; -import { escape } from "html-escaper"; import path from "path"; import type { Logger } from "pino"; import { getCacheDir } from "./caching"; -import { FILES_REQUIRING_REDETECTION } from "./crawl-files"; -import { findFiles } from "./find-files"; import type { FrameworkPlugin } from "./plugins/framework"; import { Server } from "./server"; import { ViteManager } from "./vite/vite-manager"; -const POSTCSS_CONFIG_FILE = [ - ".postcssrc", - ".postcssrc.json", - ".postcssrc.yml", - ".postcssrc.js", - ".postcssrc.mjs", - ".postcssrc.cjs", - ".postcssrc.ts", - "postcss.config.js", - "postcss.config.mjs", - "postcss.config.cjs", - "postcss.config.ts", -]; -const GLOBAL_CSS_FILE_NAMES_WITHOUT_EXT = [ - "index", - "global", - "globals", - "style", - "styles", - "app", -]; -const GLOBAL_CSS_EXTS = ["css", "sass", "scss", "less", "styl", "stylus"]; -const GLOBAL_CSS_FILE = GLOBAL_CSS_FILE_NAMES_WITHOUT_EXT.flatMap((fileName) => - GLOBAL_CSS_EXTS.map((ext) => `${fileName}.${ext}`) -); - -const FILES_REQUIRING_RESTART = new Set([ - PREVIEW_CONFIG_NAME, - ...FILES_REQUIRING_REDETECTION, - ...POSTCSS_CONFIG_FILE, - ...GLOBAL_CSS_FILE, - "vite.config.js", - "vite.config.ts", - // TODO: Make plugins contribute files requiring restart to make core agnostic of Svelte config files. - "svelte.config.js", -]); - export class Previewer { private readonly transformingReader: Reader; private appServer: Server | null = null; private viteManager: ViteManager | null = null; private status: PreviewerStatus = { kind: "stopped" }; private disposeObserver: (() => Promise) | null = null; - private config: PreviewConfig | null = null; constructor( private readonly options: { @@ -69,7 +26,6 @@ export class Previewer { frameworkPlugin: FrameworkPlugin; middlewares: express.RequestHandler[]; port: number; - onFileChanged?(absoluteFilePath: string): void; } ) { this.transformingReader = createStackedReader([ @@ -119,38 +75,17 @@ export class Previewer { await this.start(options); break; case "stopped": - await this.startFromStopped(options); + await this.startFromStopped(); break; default: throw assertNever(statusBeforeStart); } } - private async startFromStopped({ - restarting, - }: { restarting?: boolean } = {}) { + private async startFromStopped() { this.status = { kind: "starting", promise: (async () => { - // PostCSS requires the current directory to change because it relies - // on the `import-cwd` package to resolve plugins. - process.chdir(this.options.rootDir); - const configFromProject = await readConfig(this.options.rootDir); - const config = (this.config = { - ...configFromProject, - wrapper: configFromProject.wrapper || { - path: this.options.frameworkPlugin.defaultWrapperPath, - }, - }); - const globalCssAbsoluteFilePaths = await findFiles( - this.options.rootDir, - `**/@(${GLOBAL_CSS_FILE_NAMES_WITHOUT_EXT.join( - "|" - )}).@(${GLOBAL_CSS_EXTS.join("|")})`, - { - maxDepth: 3, - } - ); const router = express.Router(); router.get(/^\/.*:[^/]+\/$/, async (req, res, next) => { if (req.url.includes("?html-proxy")) { @@ -167,42 +102,15 @@ export class Previewer { res.status(404).end(`Uh-Oh! Vite server is not running.`); return; } - try { - res - .status(200) - .set({ "Content-Type": "text/html" }) - .end( - await this.viteManager.loadIndexHtml( - req.originalUrl, - previewableId - ) - ); - } catch (e: any) { - res - .status(500) - .set({ "Content-Type": "text/html" }) - .end( - ` - - - - -
${escape(`${e}` || "An unknown error has occurred")}
- - ` - ); - } + res + .status(200) + .set({ "Content-Type": "text/html" }) + .end( + await this.viteManager.loadIndexHtml( + req.originalUrl, + previewableId + ) + ); }); router.use("/ping", async (req, res) => { res.json( @@ -221,40 +129,28 @@ export class Previewer { }, ], }); - if (!restarting) { - // When we restart, we must not stop the file observer otherwise a crash while restarting - // (e.g. due to an incomplete preview.config.js) would mean that we stop listening altogether, - // and we will never know to restart. - if (this.transformingReader.observe) { - this.disposeObserver = await this.transformingReader.observe( - this.options.rootDir - ); - } - this.transformingReader.listeners.add(this.onFileChangeListener); + if (this.transformingReader.observe) { + this.disposeObserver = await this.transformingReader.observe( + this.options.rootDir + ); } + this.options.logger.debug(`Starting server`); + const server = await this.appServer.start(this.options.port); + this.options.logger.debug(`Starting Vite manager`); this.viteManager = new ViteManager({ rootDir: this.options.rootDir, shadowHtmlFilePath: path.join( this.options.previewDirPath, "index.html" ), - detectedGlobalCssFilePaths: globalCssAbsoluteFilePaths.map( - (absoluteFilePath) => - path.relative(this.options.rootDir, absoluteFilePath) - ), reader: this.transformingReader, cacheDir: path.join(getCacheDir(this.options.rootDir), "vite"), - config, logger: this.options.logger, frameworkPlugin: this.options.frameworkPlugin, + server, + port: this.options.port, }); - this.options.logger.debug(`Starting server`); - const server = await this.appServer.start(this.options.port); - this.options.logger.debug(`Starting Vite manager`); - this.viteManager.start(server, this.options.port).catch((e) => { - this.options.logger.error(`Vite manager failed to start: ${e}`); - this.stop(); - }); + this.viteManager.start(); this.options.logger.debug(`Previewer ready`); this.status = { kind: "started", @@ -278,11 +174,7 @@ export class Previewer { } } - async stop({ - restarting, - }: { - restarting?: boolean; - } = {}) { + async stop() { if (this.status.kind === "starting") { try { await this.status.promise; @@ -298,13 +190,8 @@ export class Previewer { this.status = { kind: "stopping", promise: (async () => { - if (!restarting) { - this.transformingReader.listeners.remove(this.onFileChangeListener); - if (this.disposeObserver) { - await this.disposeObserver(); - this.disposeObserver = null; - } - } + await this.disposeObserver?.(); + this.disposeObserver = null; await this.viteManager?.stop(); this.viteManager = null; await this.appServer?.stop(); @@ -316,45 +203,6 @@ export class Previewer { }; await this.status.promise; } - - private readonly onFileChangeListener = { - onChange: (absoluteFilePath: string, info: ReaderListenerInfo) => { - absoluteFilePath = path.resolve(absoluteFilePath); - if ( - !info.virtual && - this.config?.wrapper && - absoluteFilePath === - path.resolve(this.options.rootDir, this.config.wrapper.path) - ) { - this.viteManager?.triggerFullReload(); - } - if ( - !info.virtual && - FILES_REQUIRING_RESTART.has(path.basename(absoluteFilePath)) - ) { - if (this.status.kind === "starting" || this.status.kind === "started") { - // Packages were updated. Restart. - this.options.logger.info( - "New dependencies were detected. Restarting..." - ); - this.stop({ - restarting: true, - }) - .then(async () => { - await this.start({ restarting: true }); - }) - .catch(this.options.logger.error.bind(this.options.logger)); - } - return; - } - if (info.virtual) { - this.viteManager?.triggerReload(absoluteFilePath); - // this.viteManager?.triggerReload(absoluteFilePath + ".ts"); - } else if (this.options.onFileChanged) { - this.options.onFileChanged(absoluteFilePath); - } - }, - }; } type PreviewerStatus = diff --git a/core/src/vite/vite-manager.ts b/core/src/vite/vite-manager.ts index 6d406e88925..adc78565ccb 100644 --- a/core/src/vite/vite-manager.ts +++ b/core/src/vite/vite-manager.ts @@ -1,9 +1,18 @@ import viteTsconfigPaths from "@fwouts/vite-tsconfig-paths"; import { decodePreviewableId } from "@previewjs/analyzer-api"; -import type { PreviewConfig } from "@previewjs/config"; -import type { Reader } from "@previewjs/vfs"; +import { + PREVIEW_CONFIG_NAME, + readConfig, + type PreviewConfig, +} from "@previewjs/config"; +import type { + Reader, + ReaderListener, + ReaderListenerInfo, +} from "@previewjs/vfs"; import type { Alias } from "@rollup/plugin-alias"; import { polyfillNode } from "esbuild-plugin-polyfill-node"; +import { exclusivePromiseRunner } from "exclusive-promises"; import express from "express"; import fs from "fs-extra"; import type { Server } from "http"; @@ -14,6 +23,9 @@ import type { Tsconfig } from "tsconfig-paths/lib/tsconfig-loader.js"; import { loadTsconfig } from "tsconfig-paths/lib/tsconfig-loader.js"; import * as vite from "vite"; import { searchForWorkspaceRoot } from "vite"; +import { FILES_REQUIRING_REDETECTION } from "../crawl-files"; +import { findFiles } from "../find-files"; +import { generateHtmlError } from "../html-error"; import type { FrameworkPlugin } from "../plugins/framework"; import { cssModulesWithoutSuffixPlugin } from "./plugins/css-modules-without-suffix-plugin"; import { exportToplevelPlugin } from "./plugins/export-toplevel-plugin"; @@ -21,10 +33,86 @@ import { localEval } from "./plugins/local-eval"; import { publicAssetImportPluginPlugin } from "./plugins/public-asset-import-plugin"; import { virtualPlugin } from "./plugins/virtual-plugin"; +const POSTCSS_CONFIG_FILE = [ + ".postcssrc", + ".postcssrc.json", + ".postcssrc.yml", + ".postcssrc.js", + ".postcssrc.mjs", + ".postcssrc.cjs", + ".postcssrc.ts", + "postcss.config.js", + "postcss.config.mjs", + "postcss.config.cjs", + "postcss.config.ts", +]; + +const GLOBAL_CSS_FILE_NAMES_WITHOUT_EXT = [ + "index", + "global", + "globals", + "style", + "styles", + "app", +]; +const GLOBAL_CSS_EXTS = ["css", "sass", "scss", "less", "styl", "stylus"]; + +const GLOBAL_CSS_FILE = GLOBAL_CSS_FILE_NAMES_WITHOUT_EXT.flatMap((fileName) => + GLOBAL_CSS_EXTS.map((ext) => `${fileName}.${ext}`) +); + +const FILES_REQUIRING_VITE_RESTART = new Set([ + PREVIEW_CONFIG_NAME, + ...FILES_REQUIRING_REDETECTION, + ...POSTCSS_CONFIG_FILE, + ...GLOBAL_CSS_FILE, + "vite.config.js", + "vite.config.ts", + // TODO: Make plugins contribute files requiring restart to make core agnostic of Svelte config files. + "svelte.config.js", +]); + +type ViteState = + | { + kind: "starting"; + // Note: This promise is guaranteed not to ever throw. + promise: Promise; + } + | { + kind: "running"; + viteServer: vite.ViteDevServer; + config: PreviewConfig & { detectedGlobalCssFilePaths: string[] }; + } + | { + kind: "error"; + error: string; + }; + +type ViteEndState = Exclude; + +async function endState(state: ViteState | null) { + if (state?.kind === "starting") { + return state.promise; + } else { + return state; + } +} + export class ViteManager { readonly middleware: express.RequestHandler; - private viteStartupPromise: Promise | undefined; - private viteServer?: vite.ViteDevServer; + #state: ViteState | null = null; + #exclusively: (f: () => Promise) => Promise; + #readerListener: ReaderListener = { + onChange: (absoluteFilePath, info) => { + this.onFileChanged(absoluteFilePath, info).catch((e) => + this.options.logger.error(e) + ); + }, + }; + + get state() { + return this.#state; + } constructor( private readonly options: { @@ -32,44 +120,58 @@ export class ViteManager { reader: Reader; rootDir: string; shadowHtmlFilePath: string; - detectedGlobalCssFilePaths: string[]; cacheDir: string; - config: PreviewConfig; frameworkPlugin: FrameworkPlugin; + server: Server; + port: number; } ) { const router = express.Router(); router.use(async (req, res, next) => { + const state = await endState(this.#state); + if (!state || state.kind === "error") { + return next(); + } try { - const viteServer = await this.awaitViteServerReady(); - viteServer.middlewares(req, res, next); + state.viteServer.middlewares(req, res, next); } catch (e) { - this.options.logger.error(`Routing error: ${e}`); - res.status(500).end(`Error: ${e}`); + this.options.logger.error(`Vite middleware error: ${e}`); + res.status(500).end(`Vite middleware error: ${e}`); } }); this.middleware = router; + this.#exclusively = exclusivePromiseRunner(); } - async loadIndexHtml(url: string, id: string) { - const template = await fs.readFile( - this.options.shadowHtmlFilePath, - "utf-8" - ); - const viteServer = await this.awaitViteServerReady(); - const { filePath, name: previewableName } = decodePreviewableId(id); - const componentPath = filePath.replace(/\\/g, "/"); - const wrapper = this.options.config.wrapper; - const wrapperPath = - wrapper && - (await fs.pathExists(path.join(this.options.rootDir, wrapper.path))) - ? wrapper.path.replace(/\\/g, "/") - : null; - return await viteServer.transformIndexHtml( - url, - template.replace( - "", - ` + async loadIndexHtml(url: string, id: string): Promise { + // Note: this must run exclusively from stop() or viteServer.transformIndexHtml() + // could fail with "The server is being restarted or closed. Request is outdated". + return this.#exclusively(async () => { + const state = await endState(this.#state); + if (!state) { + return generateHtmlError(`Vite server is not running`); + } + if (state.kind === "error") { + return generateHtmlError(state.error); + } + const template = await fs.readFile( + this.options.shadowHtmlFilePath, + "utf-8" + ); + const { config, viteServer } = state; + const { filePath, name: previewableName } = decodePreviewableId(id); + const componentPath = filePath.replace(/\\/g, "/"); + const wrapper = config.wrapper; + const wrapperPath = + wrapper && + (await fs.pathExists(path.join(this.options.rootDir, wrapper.path))) + ? wrapper.path.replace(/\\/g, "/") + : null; + return await viteServer.transformIndexHtml( + url, + template.replace( + "", + ` ` - ) - ); - } - - private async awaitViteServerReady() { - await this.viteStartupPromise; - if (!this.viteServer) { - throw new Error(`Vite server is not running.`); - } - return this.viteServer; + ) + ); + }); } - async start(server: Server, port: number) { - let resolveViteStartupPromise!: () => void; - this.viteStartupPromise = new Promise((resolve) => { - resolveViteStartupPromise = resolve; - }); - const tsInferredAlias: Alias[] = []; - // If there is a top-level tsconfig.json, use it to infer aliases. - // While this is also done by vite-tsconfig-paths, it doesn't apply to CSS Modules and so on. - let tsConfig: Tsconfig | null = null; - for (const potentialTsConfigFileName of [ - "tsconfig.json", - "jsconfig.json", - ]) { - const potentialTsConfigFilePath = path.join( + // Note: this is guaranteed not to throw. + async start() { + let setEndState!: (running: ViteEndState) => void; + this.#state = { + kind: "starting", + promise: new Promise((resolve) => { + setEndState = (state: ViteEndState) => { + this.#state = state; + resolve(state); + }; + }), + }; + this.options.reader.listeners.add(this.#readerListener); + try { + // PostCSS requires the current directory to change because it relies + // on the `import-cwd` package to resolve plugins. + process.chdir(this.options.rootDir); + const configFromProject = await readConfig(this.options.rootDir); + const globalCssAbsoluteFilePaths = await findFiles( this.options.rootDir, - potentialTsConfigFileName + `**/@(${GLOBAL_CSS_FILE_NAMES_WITHOUT_EXT.join( + "|" + )}).@(${GLOBAL_CSS_EXTS.join("|")})`, + { + maxDepth: 3, + } ); - if (await fs.pathExists(potentialTsConfigFilePath)) { - tsConfig = loadTsconfig(potentialTsConfigFilePath) || null; - if (tsConfig) { - break; + const config = { + ...configFromProject, + wrapper: configFromProject.wrapper || { + path: this.options.frameworkPlugin.defaultWrapperPath, + }, + detectedGlobalCssFilePaths: globalCssAbsoluteFilePaths.map( + (absoluteFilePath) => + path.relative(this.options.rootDir, absoluteFilePath) + ), + }; + const tsInferredAlias: Alias[] = []; + // If there is a top-level tsconfig.json, use it to infer aliases. + // While this is also done by vite-tsconfig-paths, it doesn't apply to CSS Modules and so on. + let tsConfig: Tsconfig | null = null; + for (const potentialTsConfigFileName of [ + "tsconfig.json", + "jsconfig.json", + ]) { + const potentialTsConfigFilePath = path.join( + this.options.rootDir, + potentialTsConfigFileName + ); + if (await fs.pathExists(potentialTsConfigFilePath)) { + tsConfig = loadTsconfig(potentialTsConfigFilePath) || null; + if (tsConfig) { + break; + } } } - } - this.options.logger.debug( - `Loaded ts/jsconfig: ${JSON.stringify(tsConfig || null, null, 2)}` - ); - const baseUrl = tsConfig?.compilerOptions?.baseUrl || ""; - const tsConfigPaths = tsConfig?.compilerOptions?.paths || {}; - let baseAlias = baseUrl.startsWith("./") ? baseUrl.substring(1) : baseUrl; - if (baseAlias && !baseAlias.endsWith("/")) { - baseAlias += "/"; - } - for (const [match, mapping] of Object.entries(tsConfigPaths)) { - const firstMapping = mapping[0]; - if (!firstMapping) { - continue; + this.options.logger.debug( + `Loaded ts/jsconfig: ${JSON.stringify(tsConfig || null, null, 2)}` + ); + const baseUrl = tsConfig?.compilerOptions?.baseUrl || ""; + const tsConfigPaths = tsConfig?.compilerOptions?.paths || {}; + let baseAlias = baseUrl.startsWith("./") ? baseUrl.substring(1) : baseUrl; + if (baseAlias && !baseAlias.endsWith("/")) { + baseAlias += "/"; } - const matchNoWildcard = match.endsWith("/*") - ? match.slice(0, match.length - 2) - : match; - const firstMappingNoWildcard = firstMapping.endsWith("/*") - ? firstMapping.slice(0, firstMapping.length - 2) - : firstMapping; - tsInferredAlias.push({ - find: matchNoWildcard, - replacement: path.join( - this.options.rootDir, - baseUrl, - firstMappingNoWildcard - ), - }); - } - const existingViteConfig = await vite.loadConfigFromFile( - { - command: "serve", - mode: "development", - }, - undefined, - this.options.rootDir - ); - const defaultLogger = vite.createLogger( - viteLogLevelFromPinoLogger(this.options.logger) - ); - const frameworkPluginViteConfig = this.options.frameworkPlugin.viteConfig( - await flattenPlugins([ - ...(existingViteConfig?.config.plugins || []), - ...(this.options.config.vite?.plugins || []), - ]) - ); - const publicDir = - this.options.config.vite?.publicDir || - existingViteConfig?.config.publicDir || - frameworkPluginViteConfig.publicDir || - this.options.config.publicDir; - const plugins = replaceHandleHotUpdate( - this.options.reader, - await flattenPlugins([ - viteTsconfigPaths({ - root: this.options.rootDir, - }), - virtualPlugin({ - logger: this.options.logger, - reader: this.options.reader, - rootDir: this.options.rootDir, - allowedAbsolutePaths: this.options.config.vite?.server?.fs?.allow || - existingViteConfig?.config.server?.fs?.allow || [ - searchForWorkspaceRoot(this.options.rootDir), - ], - moduleGraph: () => this.viteServer?.moduleGraph || null, - esbuildOptions: frameworkPluginViteConfig.esbuild || {}, - }), - localEval(), - exportToplevelPlugin(), - fakeExportedTypesPlugin({ - readFile: (absoluteFilePath) => - this.options.reader.read(absoluteFilePath).then((entry) => { - if (entry?.kind !== "file") { - return null; + for (const [match, mapping] of Object.entries(tsConfigPaths)) { + const firstMapping = mapping[0]; + if (!firstMapping) { + continue; + } + const matchNoWildcard = match.endsWith("/*") + ? match.slice(0, match.length - 2) + : match; + const firstMappingNoWildcard = firstMapping.endsWith("/*") + ? firstMapping.slice(0, firstMapping.length - 2) + : firstMapping; + tsInferredAlias.push({ + find: matchNoWildcard, + replacement: path.join( + this.options.rootDir, + baseUrl, + firstMappingNoWildcard + ), + }); + } + const existingViteConfig = await vite.loadConfigFromFile( + { + command: "serve", + mode: "development", + }, + undefined, + this.options.rootDir + ); + const defaultLogger = vite.createLogger( + viteLogLevelFromPinoLogger(this.options.logger) + ); + const frameworkPluginViteConfig = this.options.frameworkPlugin.viteConfig( + await flattenPlugins([ + ...(existingViteConfig?.config.plugins || []), + ...(config.vite?.plugins || []), + ]) + ); + const publicDir = + config.vite?.publicDir || + existingViteConfig?.config.publicDir || + frameworkPluginViteConfig.publicDir || + config.publicDir; + const plugins = replaceHandleHotUpdate( + this.options.reader, + await flattenPlugins([ + viteTsconfigPaths({ + root: this.options.rootDir, + }), + virtualPlugin({ + logger: this.options.logger, + reader: this.options.reader, + rootDir: this.options.rootDir, + allowedAbsolutePaths: config.vite?.server?.fs?.allow || + existingViteConfig?.config.server?.fs?.allow || [ + searchForWorkspaceRoot(this.options.rootDir), + ], + moduleGraph: () => { + if (this.#state?.kind === "running") { + return this.#state.viteServer.moduleGraph; } - return entry.read(); - }), - }), - cssModulesWithoutSuffixPlugin(), - publicAssetImportPluginPlugin({ - rootDir: this.options.rootDir, - publicDir, - }), - frameworkPluginViteConfig.plugins, - ]) - ); - this.options.logger.debug(`Creating Vite server`); - const viteServerPromise = vite.createServer({ - ...frameworkPluginViteConfig, - ...existingViteConfig?.config, - ...this.options.config.vite, - configFile: false, - root: this.options.rootDir, - optimizeDeps: { - entries: [], - esbuildOptions: { - // @ts-expect-error incompatible esbuild versions? - plugins: [polyfillNode()], + return null; + }, + esbuildOptions: frameworkPluginViteConfig.esbuild || {}, + }), + localEval(), + exportToplevelPlugin(), + fakeExportedTypesPlugin({ + readFile: (absoluteFilePath) => + this.options.reader.read(absoluteFilePath).then((entry) => { + if (entry?.kind !== "file") { + return null; + } + return entry.read(); + }), + }), + cssModulesWithoutSuffixPlugin(), + publicAssetImportPluginPlugin({ + rootDir: this.options.rootDir, + publicDir, + }), + frameworkPluginViteConfig.plugins, + ]) + ); + this.options.logger.debug(`Creating Vite server`); + const viteServerPromise = vite.createServer({ + ...frameworkPluginViteConfig, + ...existingViteConfig?.config, + ...config.vite, + configFile: false, + root: this.options.rootDir, + optimizeDeps: { + entries: [], + esbuildOptions: { + // @ts-expect-error incompatible esbuild versions? + plugins: [polyfillNode()], + }, }, - }, - server: { - middlewareMode: true, - hmr: { - overlay: false, - server, - clientPort: port, - ...(typeof this.options.config.vite?.server?.hmr === "object" - ? this.options.config.vite?.server?.hmr - : {}), + server: { + middlewareMode: true, + hmr: { + overlay: false, + server: this.options.server, + clientPort: this.options.port, + ...(typeof config.vite?.server?.hmr === "object" + ? config.vite?.server?.hmr + : {}), + }, + fs: { + strict: false, + ...(config.vite?.server?.fs || {}), + }, + ...config.vite?.server, }, - fs: { - strict: false, - ...(this.options.config.vite?.server?.fs || {}), + customLogger: { + info: defaultLogger.info, + warn: defaultLogger.warn, + error: (msg, options) => { + if (!msg.startsWith("\x1B[31mInternal server error")) { + // Note: we only send errors through WebSocket when they're not already sent by Vite automatically. + if (this.#state?.kind === "running") { + this.#state.viteServer.ws.send({ + type: "error", + err: { + message: msg, + stack: "", + }, + }); + } + } + defaultLogger.error(msg, options); + }, + warnOnce: defaultLogger.warnOnce, + clearScreen: () => { + // Do nothing. + }, + hasWarned: defaultLogger.hasWarned, + hasErrorLogged: defaultLogger.hasErrorLogged, }, - ...this.options.config.vite?.server, - }, - customLogger: { - info: defaultLogger.info, - warn: defaultLogger.warn, - error: (msg, options) => { - if (!msg.startsWith("\x1B[31mInternal server error")) { - // Note: we only send errors through WebSocket when they're not already sent by Vite automatically. - this.viteServer?.ws.send({ - type: "error", - err: { - message: msg, - stack: "", - }, - }); - } - defaultLogger.error(msg, options); + clearScreen: false, + cacheDir: + config.vite?.cacheDir || + existingViteConfig?.config.cacheDir || + this.options.cacheDir, + publicDir, + plugins, + define: { + __filename: undefined, + __dirname: undefined, + ...frameworkPluginViteConfig.define, + ...existingViteConfig?.config.define, + ...config.vite?.define, }, - warnOnce: defaultLogger.warnOnce, - clearScreen: () => { - // Do nothing. + resolve: { + ...existingViteConfig?.config.resolve, + ...config.vite?.resolve, + alias: [ + // First defined rules are applied first, therefore highest priority should come first. + ...viteAliasToRollupAliasEntries(config.vite?.resolve?.alias), + ...viteAliasToRollupAliasEntries(config.alias), + ...viteAliasToRollupAliasEntries( + existingViteConfig?.config.resolve?.alias + ), + ...tsInferredAlias, + { + find: /^~(.*)/, + replacement: baseAlias + "$1", + }, + { + find: "@", + replacement: baseAlias, + }, + ...viteAliasToRollupAliasEntries( + frameworkPluginViteConfig.resolve?.alias + ), + ], }, - hasWarned: defaultLogger.hasWarned, - hasErrorLogged: defaultLogger.hasErrorLogged, - }, - clearScreen: false, - cacheDir: - this.options.config.vite?.cacheDir || - existingViteConfig?.config.cacheDir || - this.options.cacheDir, - publicDir, - plugins, - define: { - __filename: undefined, - __dirname: undefined, - ...frameworkPluginViteConfig.define, - ...existingViteConfig?.config.define, - ...this.options.config.vite?.define, - }, - resolve: { - ...existingViteConfig?.config.resolve, - ...this.options.config.vite?.resolve, - alias: [ - // First defined rules are applied first, therefore highest priority should come first. - ...viteAliasToRollupAliasEntries( - this.options.config.vite?.resolve?.alias - ), - ...viteAliasToRollupAliasEntries(this.options.config.alias), - ...viteAliasToRollupAliasEntries( - existingViteConfig?.config.resolve?.alias - ), - ...tsInferredAlias, - { - find: /^~(.*)/, - replacement: baseAlias + "$1", - }, - { - find: "@", - replacement: baseAlias, - }, - ...viteAliasToRollupAliasEntries( - frameworkPluginViteConfig.resolve?.alias - ), - ], - }, - }); - viteServerPromise.catch((e) => { + }); + const viteServer = await viteServerPromise; + setEndState({ + kind: "running", + config, + viteServer, + }); + this.options.logger.debug(`Done starting Vite server`); + } catch (e: any) { this.options.logger.error(`Vite startup error: ${e}`); - resolveViteStartupPromise(); - delete this.viteStartupPromise; - }); - this.viteServer = await viteServerPromise; - delete this.viteStartupPromise; - resolveViteStartupPromise(); - this.options.logger.debug(`Done starting Vite server`); - } - - async stop() { - await this.viteStartupPromise; - if (this.viteServer) { - await this.viteServer.close(); - delete this.viteServer; + setEndState({ + kind: "error", + error: e.stack || e.message, + }); } + return this.#state.promise; } - getConfig() { - return this.options.config; + async stop({ restart }: { restart: boolean } = { restart: false }) { + await this.#exclusively(async () => { + this.options.reader.listeners.remove(this.#readerListener); + const state = await endState(this.#state); + if (state?.kind === "running") { + await state.viteServer.close(); + } + if (restart) { + return this.start(); + } else { + return (this.#state = null); + } + }); } - triggerReload(absoluteFilePath: string) { - (async () => { - const viteServer = this.viteServer; - if (!viteServer) { + private async onFileChanged( + absoluteFilePath: string, + info: ReaderListenerInfo + ) { + const state = await endState(this.state); + if (!state) { + return; + } + if (!info.virtual) { + // Note: this doesn't strictly fit well in ViteManager. It may be better refactored out. + this.options.frameworkPlugin.typeAnalyzer.invalidateCachedTypesForFile( + path.relative(this.options.rootDir, absoluteFilePath) + ); + if (FILES_REQUIRING_VITE_RESTART.has(path.basename(absoluteFilePath))) { + // Packages were updated. Restart. + this.options.logger.info( + "New dependencies were detected. Restarting..." + ); + await this.stop({ restart: true }); return; } + } + if (state.kind !== "running") { + return; + } + const { viteServer, config } = state; + if (info.virtual) { const modules = await viteServer.moduleGraph.getModulesByFile( absoluteFilePath ); @@ -395,14 +557,16 @@ export class ViteManager { for (const onChange of viteServer.watcher.listeners("change")) { onChange(absoluteFilePath); } - })(); - } - - triggerFullReload() { - this.viteServer?.moduleGraph.invalidateAll(); - this.viteServer?.ws.send({ - type: "full-reload", - }); + } else if ( + config.wrapper && + absoluteFilePath === + path.resolve(this.options.rootDir, config.wrapper.path) + ) { + viteServer.moduleGraph.invalidateAll(); + viteServer.ws.send({ + type: "full-reload", + }); + } } } diff --git a/testing/src/file-manager.ts b/testing/src/file-manager.ts index 857a463f47e..c2f781cb098 100644 --- a/testing/src/file-manager.ts +++ b/testing/src/file-manager.ts @@ -7,7 +7,7 @@ import fs from "fs-extra"; import path from "path"; import { duplicateProjectForTesting } from "./test-dir"; -export function prepareFileManager({ +export async function prepareFileManager({ testProjectDirPath, onBeforeFileUpdated = async () => { /* no-op by default */ @@ -21,6 +21,9 @@ export function prepareFileManager({ onAfterFileUpdated?: () => void; }) { const rootDir = duplicateProjectForTesting(testProjectDirPath); + // Introduce an artificial wait to prevent chokidar from mistakenly detecting + // newly created files as "just changed" and emitting events by mistake. + await new Promise((resolve) => setTimeout(resolve, 1000)); const memoryReader = createMemoryReader(); const fsReader = createFileSystemReader({ watch: true,