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,