diff --git a/src/node/cli.ts b/src/node/cli.ts index 5a385b5f..276910f6 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -1,6 +1,6 @@ import minimist from 'minimist' import c from 'picocolors' -import { createLogger } from 'vite' +import { createLogger, type ViteDevServer } from 'vite' import { build, @@ -14,8 +14,11 @@ import { clearCache } from './markdownToVue' import { bindShortcuts } from './shortcuts' import { logVersion } from './utils/logVersion' +const CLOSE_TIMEOUT = 10000 + const argv: any = minimist(process.argv.slice(2)) +// minimist keeps `--flag=true` as the string 'true' Object.keys(argv).forEach((key) => { if (argv[key] === 'true') { argv[key] = true @@ -24,16 +27,33 @@ Object.keys(argv).forEach((key) => { } }) +// vitepress [command] [root] const command = argv._[0] const root = argv._[command ? 1 : 0] if (root) { argv.root = root } -let restartPromise: Promise | undefined - if (!command || command === 'dev') { + runDev(root, argv).catch( + logErrorAndExit.bind(null, `failed to start server. error:`) + ) +} else if (command === 'init') { + createLogger().info('', { clear: true }) + init(argv.root) +} else if (command === 'build') { + build(root, argv).catch(logErrorAndExit.bind(null, `build error:`)) +} else if (command === 'serve' || command === 'preview') { + serve(argv).catch( + logErrorAndExit.bind(null, `failed to start server. error:`) + ) +} else { + logErrorAndExit(`unknown command "${command}".`) +} + +async function runDev(root: string, argv: any) { if (argv.force) { + // vite moved --force under optimizeDeps delete argv.force argv.optimizeDeps = { force: true } } @@ -41,48 +61,98 @@ if (!command || command === 'dev') { let config = await resolveConfig(root, argv).catch( logErrorAndExit.bind(null, `failed to resolve config. error:`) ) - const createDevServer = async (isRestart = true) => { - const server = await createServer(root, argv, restartServer, config) - function restartServer() { - if (!restartPromise) { - restartPromise = (async () => { - try { - config = await resolveConfig(root, argv) - } catch (err: any) { - logError(`failed to resolve config. error:`, err) - return - } - disposeMdItInstance() - clearCache() - await server.close() - await createDevServer() - })().finally(() => { - restartPromise = undefined - }) - } - return restartPromise - } + let server: ViteDevServer + let restartPromise: Promise | undefined + + async function startServer(isRestart = true) { + server = await createServer(root, argv, restartServer, config) + // isRestart keeps vite from reopening the browser await server.listen(undefined, isRestart) logVersion(server.config.logger) server.printUrls() bindShortcuts(server, restartServer) } - createDevServer(false).catch( - logErrorAndExit.bind(null, `failed to start server. error:`) - ) -} else if (command === 'init') { - createLogger().info('', { clear: true }) - init(argv.root) -} else { - if (command === 'build') { - build(root, argv).catch(logErrorAndExit.bind(null, `build error:`)) - } else if (command === 'serve' || command === 'preview') { - serve(argv).catch( - logErrorAndExit.bind(null, `failed to start server. error:`) - ) - } else { - logErrorAndExit(`unknown command "${command}".`) + + // vite's close waits for in-flight transform requests, which never settle + // once the plugin container and dep optimizer are torn down under them. the + // port, the watcher and the ws server are released well before that, so stop + // waiting and let the restart go on + async function closeServer() { + let timer: ReturnType | undefined + const closed = await Promise.race([ + server.close().then( + () => true, + (err: any) => { + logError(`failed to close server. error:`, err) + return true + } + ), + new Promise((resolve) => { + timer = setTimeout(resolve, CLOSE_TIMEOUT, false) + }) + ]) + clearTimeout(timer) + if (!closed) { + createLogger().warn( + c.yellow( + `server didn't close in ${CLOSE_TIMEOUT / 1000}s, restarting anyway` + ) + ) + } + } + + // the config watcher and the r shortcut can both ask at once + function restartServer() { + if (!restartPromise) { + // between the two servers nothing references the event loop, so a stall + // anywhere in a restart would drain node into a silent exit(0) + const keepAlive = setInterval(() => {}, 1 << 30) + restartPromise = restart().finally(() => { + clearInterval(keepAlive) + restartPromise = undefined + }) + } + return restartPromise } + + async function restart() { + const prevConfig = config + try { + config = await resolveConfig(root, argv) + } catch (err: any) { + logError(`failed to resolve config. error:`, err) + return + } + + disposeMdItInstance() + clearCache() + await closeServer() + + try { + await startServer() + } catch (err: any) { + logError(`failed to restart server. error:`, err) + // the old server is already closed, so bailing out here leaves the + // session with no server and no watcher — come back up on the last + // known good config so a fix can trigger a fresh restart + config = prevConfig + // the failed attempt may have memoized a half-configured renderer + disposeMdItInstance() + clearCache() + createLogger().warn(c.yellow(`falling back to the previous config`)) + await startServer().catch( + logErrorAndExit.bind(null, `failed to restore server. error:`) + ) + } + } + + // a stray unhandled rejection (from a user config, a theme, or a plugin) + // must not take down a long-lived dev session + process.on('unhandledRejection', (err) => { + logError(`unhandled rejection:`, err) + }) + + await startServer(false) } function logErrorAndExit(message: string, err?: any): never {