diff --git a/package.json b/package.json index 6791e53b..cac7124f 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "del-cli": "^5.1.0", "eslint": "^8.57.0", "github-label-sync": "^2.3.1", + "hot-hook": "^0.1.9", "husky": "^9.0.11", "np": "^10.0.2", "p-event": "^6.0.1", diff --git a/src/dev_server.ts b/src/dev_server.ts index ac7b18db..bf3c719d 100644 --- a/src/dev_server.ts +++ b/src/dev_server.ts @@ -8,6 +8,7 @@ */ import picomatch from 'picomatch' +import { relative } from 'node:path' import type tsStatic from 'typescript' import prettyHrtime from 'pretty-hrtime' import { type ExecaChildProcess } from 'execa' @@ -101,6 +102,9 @@ export class DevServer { this.#cwd = cwd this.#options = options this.#hooks = new AssemblerHooks(options.hooks) + if (this.#options.hmr) { + this.#options.nodeArgs = this.#options.nodeArgs.concat(['--import=hot-hook/register']) + } this.#isMetaFileWithReloadsEnabled = picomatch( (this.#options.metaFiles || []) @@ -134,6 +138,23 @@ export class DevServer { ) } + /** + * Inspect if child process message is coming from Hot Hook + */ + #isHotHookMessage(message: unknown): message is { + type: string + path: string + paths?: string[] + } { + return ( + message !== null && + typeof message === 'object' && + 'type' in message && + typeof message.type === 'string' && + message.type.startsWith('hot-hook:') + ) + } + /** * Conditionally clear the terminal screen */ @@ -147,6 +168,7 @@ export class DevServer { * Starts the HTTP server */ #startHTTPServer(port: string, mode: 'blocking' | 'nonblocking') { + const hooksArgs = { colors: ui.colors, logger: this.#logger } this.#httpServer = runNode(this.#cwd, { script: this.#scriptFile, env: { PORT: port, ...this.#options.env }, @@ -155,12 +177,30 @@ export class DevServer { }) this.#httpServer.on('message', async (message) => { - void this.#hooks.onHttpServerMessage({ colors: ui.colors, logger: this.#logger }, message, { - restartServer: () => { - this.#restartHTTPServer(port) - }, + this.#hooks.onHttpServerMessage(hooksArgs, message, { + restartServer: () => this.#restartHTTPServer(port), }) + /** + * Handle Hot-Hook messages + */ + if (this.#isHotHookMessage(message)) { + const path = relative(this.#cwd.pathname, message.path || message.paths?.[0]!) + this.#hooks.onSourceFileChanged(hooksArgs, path) + + if (message.type === 'hot-hook:full-reload') { + this.#clearScreen() + this.#logger.log(`${ui.colors.green('full-reload')} ${path}`) + this.#restartHTTPServer(port) + this.#hooks.onDevServerStarted(hooksArgs) + } else if (message.type === 'hot-hook:invalidated') { + this.#logger.log(`${ui.colors.green('invalidated')} ${path}`) + } + } + + /** + * Handle AdonisJS ready message + */ if (this.#isAdonisJSReadyMessage(message)) { const host = message.host === '0.0.0.0' ? '127.0.0.1' : message.host diff --git a/src/types.ts b/src/types.ts index 6f8ea49f..19c36d0d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -84,6 +84,11 @@ export type AssetsBundlerOptions = * server */ export type DevServerOptions = { + /** + * If the dev server should use HMR + */ + hmr?: boolean + /** * Arguments to pass to the "bin/server.js" file * executed a child process diff --git a/tests/dev_server.spec.ts b/tests/dev_server.spec.ts index 53a7827d..3a587ce1 100644 --- a/tests/dev_server.spec.ts +++ b/tests/dev_server.spec.ts @@ -9,9 +9,11 @@ import ts from 'typescript' import { test } from '@japa/runner' -import { DevServer } from '../index.js' +import { cliui } from '@poppinss/cliui' import { setTimeout as sleep } from 'node:timers/promises' +import { DevServer } from '../index.js' + test.group('DevServer', () => { test('start() execute onDevServerStarted hook', async ({ assert, fs, cleanup }) => { assert.plan(1) @@ -110,7 +112,6 @@ test.group('DevServer', () => { include: ['**/*'], exclude: [], }) - await fs.create('index.ts', 'console.log("hey")') await fs.create('bin/server.js', `process.send({ isAdonisJS: true, environment: 'web' })`) await fs.create('.env', 'PORT=3334') @@ -145,7 +146,6 @@ test.group('DevServer', () => { include: ['**/*'], exclude: [], }) - await fs.create('index.ts', 'console.log("hey")') await fs.create( 'bin/server.js', ` @@ -188,7 +188,6 @@ test.group('DevServer', () => { include: ['**/*'], exclude: [], }) - await fs.create('index.ts', 'console.log("hey")') await fs.create( 'bin/server.js', ` @@ -227,4 +226,72 @@ test.group('DevServer', () => { assert.deepEqual(receivedMessages.length, 4) }).timeout(10_000) + + test('should restart server if receive hot-hook message', async ({ assert, fs }) => { + await fs.createJson('tsconfig.json', { include: ['**/*'], exclude: [] }) + await fs.create( + 'bin/server.js', + `process.send({ type: 'hot-hook:full-reload', path: '/foo' });` + ) + await fs.create('.env', 'PORT=3334') + + const { logger } = cliui({ mode: 'raw' }) + const devServer = new DevServer(fs.baseUrl, { + hmr: true, + nodeArgs: [], + scriptArgs: [], + }).setLogger(logger) + + await devServer.start() + await sleep(1000) + await devServer.close() + + const logMessages = logger.getLogs().map(({ message }) => message) + assert.isAtLeast(logMessages.filter((message) => message.includes('full-reload')).length, 1) + }) + + test('trigger onDevServerStarted and onSourceFileChanged when hot-hook message is received', async ({ + assert, + fs, + }) => { + let onDevServerStartedCalled = false + let onSourceFileChangedCalled = false + + await fs.createJson('tsconfig.json', { include: ['**/*'], exclude: [] }) + await fs.create( + 'bin/server.js', + `process.send({ type: 'hot-hook:full-reload', path: '/foo' });` + ) + await fs.create('.env', 'PORT=3334') + + const { logger } = cliui({ mode: 'raw' }) + const devServer = new DevServer(fs.baseUrl, { + hmr: true, + nodeArgs: [], + scriptArgs: [], + hooks: { + onDevServerStarted: [ + async () => ({ + default: () => { + onDevServerStartedCalled = true + }, + }), + ], + onSourceFileChanged: [ + async () => ({ + default: () => { + onSourceFileChangedCalled = true + }, + }), + ], + }, + }).setLogger(logger) + + await devServer.start() + await sleep(1000) + await devServer.close() + + assert.isTrue(onDevServerStartedCalled) + assert.isTrue(onSourceFileChangedCalled) + }) })