import type { Plugin, ResolvedConfig, ViteDevServer } from "vite"; import MagicString from "magic-string"; import * as path from "node:path"; import * as fs from "node:fs"; import * as process from "node:process"; import { readFile, writeFile } from "node:fs/promises"; import { spawn, exec } from "node:child_process"; import { TLSSocket } from "node:tls"; import { promisify } from "node:util"; const execPromise = promisify(exec); import type * as https from "https"; export interface AspNetCoreHmrPluginOptions { /** The base path for vite when running with HMR. * Must correlate with `ViteServerOptions.PathBase` in aspnetcore. */ base?: string; /** If true (default), will inject https key and cert from `dotnet dev-certs` */ https?: boolean; /** If true (default), most relative imports will be rewritten to include the host * of the underlying Vite dev server, allowing these requests to bypass the * ASP.NET Core Server. This results in a significant performance boost when a debugger is attached to .NET. */ assetBypass?: boolean; /** If true (default), index.html will be written to the output path on disk as it updates. * This mirrors production behavior where index.html will be served from wwwroot. * However, during development, if the port of the HMR server ever changes, the copy on disk will briefly * contain the wrong port on app startup. */ writeIndexHtmlToDisk?: boolean; /** If set, will override the hostname that is injected into index.html * and other assets when `assetBypass` is true. Can be configured to access the * local development app instance from a network computer, e.g. a phone or * tablet for mobile testing. */ host?: string; /** If true (default), some code will be injected during development * that attempts to detect and suggest fixes for common browser configuration issues. */ offerConfigurationSuggestions?: boolean; /** If true (default), will invoke `npm ls` on start to validate that actual installed packages * match the versions defined in package.json. Only supports `npm`. */ checkPackageVersions?: boolean; } /** * A plugin that works with IntelliTect.Coalesce.Vue.ViteDevelopmentServerMiddleware: * - Writes `index.html` to the build output directory during HMR development, * allowing any transformations in HomeController.cs to work identically in both dev and prod. * - Shuts down the HMR server when the parent ASP.NET Core application shuts down, preventing process orphaning. * - Automatically obtains certs from `dotnet-devcerts` and injects them into the Vite configuration. */ export function createAspNetCoreHmrPlugin({ base = "/vite_hmr/", https = true, assetBypass = true, writeIndexHtmlToDisk = true, host: configuredHost, offerConfigurationSuggestions = true, checkPackageVersions = true, }: AspNetCoreHmrPluginOptions = {}) { // We are passed in the PID of the parent .NET process so that when it aborts, // we can shut ourselves down. Otherwise the vite server will end up orphaned. // Technique adopted from https://github.com/dotnet/aspnetcore/blob/v3.0.0/src/Middleware/NodeServices/src/Content/Node/entrypoint-http.js#L369-L395 const parentPid = process.env.ASPNETCORE_VITE_PID; if (!parentPid) return; let certsExportPromise: Promise | undefined; const plugins = [ { name: "coalesce-vite-hmr", async config(config, env) { const server = (config.server ??= {}); // Make sure we can listen on `localhost`, which is what is // expected by ViteDevelopmentServerMiddleware. // Listening on any host also allows for "local remote" development, // e.g. pulling up the app on your phone to work on mobile interactions. server.host ??= "0.0.0.0"; config.base = base; // The development server launched by UseViteDevelopmentServer must be HTTPS // if the aspnetcore server is HTTPs to avoid issues with mixed content: if ( https && //@ts-expect-error boolean check backwards combat with vite<5 server.https != false ) { const httpsOptions = (server.https ??= {}) as https.ServerOptions; const { keyFilePath, certFilePath, certsExportPromise: certsExport, } = await getCertPaths(); certsExportPromise = certsExport; httpsOptions.key = await readFile(keyFilePath); httpsOptions.cert = await readFile(certFilePath); } }, async configureServer(server) { // We are passed in the parent .NET process's PID so that when it aborts, // we can shut ourselves down. Otherwise the vite server will end up orphaned. // Technique adopted from https://github.com/dotnet/aspnetcore/blob/v3.0.0/src/Middleware/NodeServices/src/Content/Node/entrypoint-http.js#L369-L395 setInterval(async function () { let parentExists = true; try { // Sending signal 0 - on all platforms - tests whether the process exists. As long as it doesn't // throw, that means it does exist. process.kill(+parentPid, 0); parentExists = true; } catch (ex) { // If the reason for the error is that we don't have permission to ask about this process, // report that as a separate problem. if ((ex as any).code === "EPERM") { throw new Error( `Attempted to check whether process ${parentPid} was running, but got a permissions error.`, ); } parentExists = false; } if (!parentExists) { try { await server.close(); } finally { process.exit(0); } } }, 1000); server.httpServer!.on("listening", () => { // Write the index.html file once on startup so it can be picked up immediately by aspnetcore. if (writeIndexHtmlToDisk) { writeHtml(server); } // Wait for dotnet dev-certs to finish exporting the ssl cert. // If it did export a new cert, restart the server so it can be used. // We wait for the server to start listening because if we do this too soon, // things blow up a little bit. certsExportPromise?.then((certsWereRegenerated) => { if (certsWereRegenerated) { console.log( "dotnet dev-certs produced a different cert than the previous cached cert. Restarting the vite server...", ); setTimeout(() => { // Wait a bit of time because things explode if we do this too soon after listening starts. server.restart(); }, 1000); } }); }); // Package version checking uses `npm ls` which is npm-specific. if ( checkPackageVersions && process.env.npm_config_user_agent?.startsWith("npm") ) { const packageVersions = (async () => { const packageLock = getNpmDependencies("--package-lock-only"); const nodeModules = getNpmDependencies(""); return [ { description: "package-lock.json", results: await packageLock, resolution: "npm install", }, { description: "node_modules", results: await nodeModules, resolution: "npm ci", }, ]; })().then((data) => { for (const results of data) { const packageProblems: Array<{ packageName: string; requiredVersion: string; actualVersion: string; }> = []; function collectProblems(dependencies: Record) { for (const [packageName, data] of Object.entries( dependencies, )) { if (data.invalid) { const requiredVersion = data.invalid .replace(" from the root project", "") .replace(/"/g, ""); packageProblems.push({ packageName, requiredVersion, actualVersion: data.version, }); } if (data.missing) { const missingVersion = data.required || data.problems ?.find((p: string) => p.startsWith("missing")) ?.match(/@([^\s,]+)/)?.[1] || ""; packageProblems.push({ packageName, requiredVersion: missingVersion, actualVersion: "missing", }); } // Recursively check nested dependencies (e.g., in NPM workspaces) if (data.dependencies) { collectProblems(data.dependencies); } } } collectProblems(results.results?.dependencies ?? {}); if (packageProblems.length) { // Calculate column widths based on actual data const col1Header = "Package Name"; const col2Header = results.description; const col3Header = "package.json"; const col1Width = Math.max( col1Header.length, ...packageProblems.map((p) => p.packageName.length), ); const col2Width = Math.max( col2Header.length, ...packageProblems.map((p) => p.actualVersion.length), ); const col3Width = Math.max( col3Header.length, ...packageProblems.map((p) => p.requiredVersion.length), ); const header = ` ${col1Header.padEnd(col1Width)} ${col2Header.padEnd(col2Width)} ${col3Header}`; const separator = ` ${"-".repeat(col1Width)} ${"-".repeat(col2Width)} ${"-".repeat(col3Width)}`; const rows = packageProblems.map( (p) => ` ${p.packageName.padEnd(col1Width)} ${p.actualVersion.padEnd(col2Width)} ${p.requiredVersion}`, ); const ret = `NPM packages in ${results.description} don't match the versions in package.json.\n\n` + `Stop the application and run '${results.resolution}' in ${path.basename(path.dirname(process.env.npm_package_json!))}\n\n` + header + "\n" + separator + "\n" + rows.join("\n"); console.error(ret); return ret; } } return null; }); server.ws.on("connection", async (data, client) => { const message = await packageVersions; if (message) { server.ws.send({ type: "error", err: { message: "\n" + message, stack: "", plugin: "coalesce-vite-hmr", }, }); } }); } }, async handleHotUpdate(ctx) { if ( writeIndexHtmlToDisk && ctx.server.config.root + "/index.html" == ctx.file ) { // Rewrite the index.html file whenever it changes. writeHtml(ctx.server); } }, }, ]; if (assetBypass) { plugins.push( ...createAssetBypassPlugins( configuredHost, offerConfigurationSuggestions, ), ); } return plugins; } function createAssetBypassPlugins( configuredHost: string | undefined, offerConfigurationSuggestions: boolean, ) { let port: number | undefined; let base: string; let resolvedConfig: ResolvedConfig; const plugins: Plugin[] = []; function getViteOrigin() { const serverConfig = resolvedConfig.server; const host = configuredHost || (typeof serverConfig.host == "string" && serverConfig.host == "0.0.0.0" ? undefined : serverConfig.host) || // Cannot use the network address because it isn't a valid name for dotnet dev-certs' certs. //getNetworkAddresses()[0] || "localhost"; return `http${serverConfig.https ? "s" : ""}://${host}:${port}`; } function transformCode(code: string) { // Search for strings like: // - `"/vite_hmr/..."` (double quote import in js, etc) // - `'/vite_hmr/...'` (single quote import in js, etc) // - `=/vite_hmr/...` (HTML src attibutes) const regex = new RegExp(`(["'=])(${escapeRegex(base)})`, "g"); let s: MagicString | undefined; let match; while ((match = regex.exec(code))) { s ??= new MagicString(code); // Insert the hostname of the vite server at the start of the relative path // so that the request will go directly to the vite server // rather than having to traverse the aspnetcore server proxy first. s.appendLeft(match.index + match[1].length, getViteOrigin()); } return s; } plugins.unshift({ name: "coalesce-vite-hmr-bypass-aspnetcore", enforce: "pre", configResolved(config) { resolvedConfig = config; base = config.base; }, transformIndexHtml(html) { // Transform imports in index.html to go to the HMR server instead of the aspnetcore server. html = transformCode(html)?.toString() || html; return { html, tags: [ ...(offerConfigurationSuggestions ? getConfigurationSuggestionTag( getViteOrigin(), base, path.join(getHtmlTargetDir(resolvedConfig), "index.html"), ) : []), ], }; }, configureServer(server) { // Transform assets (fonts, mainly) to go to the HMR server instead of the aspnetcore server. server.httpServer!.on("listening", () => { port = (server.httpServer!.address() as any)?.port; // Static assets are normally loaded relative to the browser's origin // since they're often originating from things like font rules in