Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
173 changes: 100 additions & 73 deletions src/dev_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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<void>(async (resolve) => {
return new Promise<void>((resolve) => {
/**
* Creating child process
*/
Expand All @@ -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)) {
Expand All @@ -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)

Expand All @@ -684,13 +743,15 @@ export class DevServer {

this.#httpServer
.then((result) => {
this.#isHttpServerAlive = false
if (!this.#watcher) {
this.#onClose?.(result.exitCode!)
} else {
this.ui.logger.info('Underlying HTTP server closed. Still watching for changes')
}
})
.catch((error) => {
this.#isHttpServerAlive = false
if (!this.#watcher) {
this.#onError?.(error)
} else {
Expand Down Expand Up @@ -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' })
})
}

/**
Expand All @@ -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)
Expand Down
67 changes: 67 additions & 0 deletions tests/dev_server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []

Expand Down
Loading