diff --git a/package.json b/package.json index 7f3d35a9..e423bc25 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "cross-env": "^10.1.0", "del-cli": "^7.0.0", "eslint": "^9.37.0", - "hot-hook": "^0.4.1-next.0", + "hot-hook": "^0.4.1-next.2", "p-event": "^7.0.0", "prettier": "^3.6.2", "release-it": "^19.0.5", diff --git a/src/dev_server.ts b/src/dev_server.ts index 92b6bd4c..8b4c5b1e 100644 --- a/src/dev_server.ts +++ b/src/dev_server.ts @@ -55,15 +55,6 @@ import { * await devServer.start(ts) */ export class DevServer { - /** - * Pre-allocated info object for hot-hook change events to avoid repeated object creation - */ - static readonly #HOT_HOOK_CHANGE_INFO = { - source: 'hot-hook' as const, - fullReload: false, - hotReloaded: false, - } - /** * Pre-allocated info object for hot-hook full reload events */ @@ -119,6 +110,11 @@ export class DevServer { */ #httpServer?: ResultPromise + /** + * Flag to track if the HTTP server child process is alive + */ + #isHttpServerAlive = false + /** * Keyboard shortcuts manager instance */ @@ -348,6 +344,81 @@ export class DevServer { } } + /** + * Creates our file system watcher + */ + #createWatcher(options?: { poll?: boolean }) { + const watcher = watch({ + usePolling: options?.poll ?? false, + cwd: this.cwdPath, + ignoreInitial: true, + ignored: (file, stats) => { + if (!stats) return false + if (file.includes('inertia') && !file.includes('node_modules')) return false + if (stats.isFile()) return !this.#fileSystem.shouldWatchFile(file) + + return !this.#fileSystem.shouldWatchDirectory(file) + }, + }) + + watcher.on('error', (error: any) => { + this.ui.logger.warning('file system watcher failure') + this.ui.logger.fatal(error as any) + + this.#onError?.(error) + this.#watcher?.close() + }) + + watcher.on('ready', () => { + this.ui.logger.info('watching file system for changes...') + }) + + return watcher + } + + /** + * Handles file change events in HMR mode by forwarding to hot-hook + * or restarting the server if dead. + */ + #handleHmrWatcherEvent(options: { + filePath: string + action: 'add' | 'change' | 'unlink' + displayLabel: 'add' | 'update' | 'delete' + }) { + const relativePath = string.toUnixSlash(options.filePath) + const absolutePath = join(this.cwdPath, relativePath) + + if (this.#isHttpServerAlive === false) { + this.#clearScreen() + this.ui.logger.log(`${this.ui.colors.green(options.displayLabel)} ${relativePath}`) + this.#restartHTTPServer() + return + } + + /** + * For add/unlink, we call the hooks directly since hot-hook ignores files + * not in its dependency tree. This ensures index files are regenerated + * for new/removed files. + */ + if (options.action === 'add') { + this.#hooks.runner('fileAdded').run(relativePath, absolutePath, this) + } else if (options.action === 'unlink') { + this.#hooks.runner('fileRemoved').run(relativePath, absolutePath, this) + } + + /** + * Forward all events to hot-hook so it can: + * - Update its dependency tree (for unlink) + * - Handle HMR for change events on imported files + * - Then we wait for hot-hook to notify us back via IPC message + */ + this.#httpServer?.send({ + type: 'hot-hook:file-changed', + path: absolutePath, + action: options.action, + }) + } + /** * Handles file change events and triggers appropriate server actions * @@ -627,7 +698,7 @@ export class DevServer { await this.#hooks.runner('devServerStarting').run(this) debug('starting http server using "%s" file, options %O', this.scriptFile, this.options) - return new Promise(async (resolve) => { + return new Promise((resolve) => { /** * Creating child process */ @@ -638,6 +709,7 @@ export class DevServer { reject: true, scriptArgs: this.options.scriptArgs, }) + this.#isHttpServerAlive = true this.#httpServer.on('message', async (message) => { if (this.#isAdonisJSReadyMessage(message)) { @@ -651,20 +723,7 @@ export class DevServer { } else if (this.#mode === 'hmr' && this.#isHotHookMessage(message)) { debug('received hot-hook message %O', message) - if (message.type === 'hot-hook:file-changed') { - const absolutePath = message.path ? string.toUnixSlash(message.path) : '' - const relativePath = relative(this.cwdPath, absolutePath) - - if (message.action === 'add') { - this.#hooks.runner('fileAdded').run(relativePath, absolutePath, this) - } else if (message.action === 'change') { - this.#hooks - .runner('fileChanged') - .run(relativePath, absolutePath, DevServer.#HOT_HOOK_CHANGE_INFO, this) - } else if (message.action === 'unlink') { - this.#hooks.runner('fileRemoved').run(relativePath, absolutePath, this) - } - } else if (message.type === 'hot-hook:full-reload') { + if (message.type === 'hot-hook:full-reload') { const absolutePath = message.path ? string.toUnixSlash(message.path) : '' const relativePath = relative(this.cwdPath, absolutePath) @@ -684,6 +743,7 @@ export class DevServer { this.#httpServer .then((result) => { + this.#isHttpServerAlive = false if (!this.#watcher) { this.#onClose?.(result.exitCode!) } else { @@ -691,6 +751,7 @@ export class DevServer { } }) .catch((error) => { + this.#isHttpServerAlive = false if (!this.#watcher) { this.#onError?.(error) } else { @@ -783,19 +844,25 @@ export class DevServer { this.options.nodeArgs.push('--import=hot-hook/register') this.options.env = { ...this.options.env, - HOT_HOOK_INCLUDE: this.#fileSystem.includes.join(','), - HOT_HOOK_IGNORE: this.#fileSystem.excludes - .filter((exclude) => !exclude.includes('inertia')) - .join(','), - HOT_HOOK_RESTART: (this.options.metaFiles ?? []) - .filter(({ reloadServer }) => !!reloadServer) - .map(({ pattern }) => pattern) - .join(','), + HOT_HOOK_WATCH: 'false', } } this.ui.logger.info('starting HTTP server...') await this.#startHTTPServer(this.#stickyPort) + + if (this.#mode !== 'hmr') return + + this.#watcher = this.#createWatcher() + this.#watcher.on('add', (filePath) => { + this.#handleHmrWatcherEvent({ filePath, action: 'add', displayLabel: 'add' }) + }) + this.#watcher.on('change', (filePath) => { + this.#handleHmrWatcherEvent({ filePath, action: 'change', displayLabel: 'update' }) + }) + this.#watcher.on('unlink', (filePath) => { + this.#handleHmrWatcherEvent({ filePath, action: 'unlink', displayLabel: 'delete' }) + }) } /** @@ -822,47 +889,7 @@ export class DevServer { this.ui.logger.info('starting HTTP server...') await this.#startHTTPServer(this.#stickyPort) - /** - * Create watcher - */ - this.#watcher = watch({ - usePolling: options?.poll ?? false, - cwd: this.cwdPath, - ignoreInitial: true, - ignored: (file, stats) => { - if (!stats) { - return false - } - - if (file.includes('inertia') && !file.includes('node_modules')) { - return false - } - - if (stats.isFile()) { - return !this.#fileSystem.shouldWatchFile(file) - } - return !this.#fileSystem.shouldWatchDirectory(file) - }, - }) - - /** - * Notify the watcher is ready - */ - this.#watcher.on('ready', () => { - this.ui.logger.info('watching file system for changes...') - }) - - /** - * Cleanup when watcher dies - */ - this.#watcher.on('error', (error: any) => { - this.ui.logger.warning('file system watcher failure') - this.ui.logger.fatal(error as any) - - this.#onError?.(error) - this.#watcher?.close() - }) - + this.#watcher = this.#createWatcher({ poll: options?.poll }) this.#watcher.on('add', (filePath) => { const relativePath = string.toUnixSlash(filePath) const absolutePath = join(this.cwdPath, relativePath) diff --git a/tests/dev_server.spec.ts b/tests/dev_server.spec.ts index accd6f1f..ac3535c9 100644 --- a/tests/dev_server.spec.ts +++ b/tests/dev_server.spec.ts @@ -539,6 +539,73 @@ test.group('DevServer', () => { assert.lengthOf(indexGenerationLogs, 3) }).timeout(10 * 1000) + test('restart server on file change when child process has crashed in hmr mode', async ({ + fs, + assert, + cleanup, + }) => { + /** + * Create a server that crashes immediately after sending ready message + */ + await fs.createJson('tsconfig.json', { include: ['**/*'], exclude: [] }) + await fs.create( + 'bin/server.ts', + ` + process.send({ isAdonisJS: true, environment: 'web', port: process.env.PORT, host: 'localhost' }) + setTimeout(() => { throw new Error('crash') }, 100) + ` + ) + await fs.create('app/controllers/home_controller.ts', 'export default class {}') + await fs.create('.env', 'PORT=3350') + + const devServer = new DevServer(fs.baseUrl, { + hmr: true, + nodeArgs: [], + scriptArgs: [], + clearScreen: false, + }) + + devServer.ui = cliui() + devServer.ui.switchMode('raw') + + await devServer.start() + cleanup(() => devServer.close()) + + /** + * Wait for the server to crash + */ + await sleep(500) + + /** + * Modify a file, should trigger a restart since the child is dead + */ + await fs.create('app/controllers/home_controller.ts', 'export default class { foo() {} }') + + await sleep(1000) + + const logMessages = devServer.ui.logger.getLogs().map(({ message }) => message) + console.log(logMessages) + + assert.snapshot(logMessages).matchInline(` + [ + "[ blue(info) ] starting server in hmr mode...", + "[ blue(info) ] loading hooks...", + "[ blue(info) ] generating indexes...", + "[ blue(info) ] starting HTTP server...", + "Server address: cyan(http://localhost:3350) + Mode: cyan(hmr) + Press dim(h) to show help", + "[ blue(info) ] watching file system for changes...", + "[ blue(info) ] Underlying HTTP server died. Still watching for changes", + "green(update) app/controllers/home_controller.ts", + "Server address: cyan(http://localhost:3350) + Mode: cyan(hmr) + Press dim(h) to show help", + "[ blue(info) ] Underlying HTTP server died. Still watching for changes", + ] + `) + }).timeout(10 * 1000) + test('define hooks as inline functions', async ({ fs, assert, cleanup }) => { let hooksStack: string[] = []