diff --git a/package.json b/package.json index 4e8c6c5b..40afcd4a 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "quick:test": "cross-env NODE_DEBUG=adonisjs:assembler node --enable-source-maps --loader=ts-node/esm bin/test.ts" }, "devDependencies": { - "@adonisjs/application": "^8.0.2", + "@adonisjs/application": "8.1.0", "@adonisjs/eslint-config": "^1.2.1", "@adonisjs/prettier-config": "^1.2.1", "@adonisjs/tsconfig": "^1.2.1", @@ -66,6 +66,8 @@ "@antfu/install-pkg": "^0.3.1", "@poppinss/chokidar-ts": "^4.1.3", "@poppinss/cliui": "^6.3.0", + "@poppinss/hooks": "^7.2.2", + "@poppinss/utils": "^6.7.2", "cpy": "^11.0.0", "dedent": "^1.5.1", "execa": "^8.0.1", diff --git a/src/bundler.ts b/src/bundler.ts index 57cb92eb..0ca7178f 100644 --- a/src/bundler.ts +++ b/src/bundler.ts @@ -16,6 +16,7 @@ import { join, relative } from 'node:path' import { cliui, type Logger } from '@poppinss/cliui' import { detectPackageManager } from '@antfu/install-pkg' +import { AssemblerHooks } from './hooks.js' import type { BundlerOptions } from './types.js' import { run, parseConfig, copyFiles } from './helpers.js' @@ -62,6 +63,7 @@ export class Bundler { #cwdPath: string #ts: typeof tsStatic #logger = ui.logger + #hooks: AssemblerHooks #options: BundlerOptions /** @@ -76,6 +78,7 @@ export class Bundler { this.#cwdPath = fileURLToPath(this.#cwd) this.#ts = ts this.#options = options + this.#hooks = new AssemblerHooks(options.hooks) } /** @@ -197,6 +200,8 @@ export class Bundler { * Bundles the application to be run in production */ async bundle(stopOnError: boolean = true, client?: SupportedPackageManager): Promise { + await this.#hooks.registerBuildHooks() + /** * Step 1: Parse config file to get the build output directory */ @@ -220,7 +225,12 @@ export class Bundler { } /** - * Step 4: Build typescript source code + * Step 4: Execute build starting hook + */ + await this.#hooks.onBuildStarting({ colors: ui.colors, logger: this.#logger }) + + /** + * Step 5: Build typescript source code */ this.#logger.info('compiling typescript source', { suffix: 'tsc' }) const buildCompleted = await this.#runTsc(outDir) @@ -251,7 +261,7 @@ export class Bundler { } /** - * Step 5: Copy meta files to the build directory + * Step 6: Copy meta files to the build directory */ const pkgManager = await this.#getPackageManager(client) const pkgFiles = pkgManager ? ['package.json', pkgManager.lockFile] : ['package.json'] @@ -261,6 +271,11 @@ export class Bundler { this.#logger.success('build completed') this.#logger.log('') + /** + * Step 7: Execute build completed hook + */ + await this.#hooks.onBuildCompleted({ colors: ui.colors, logger: this.#logger }) + /** * Next steps */ diff --git a/src/dev_server.ts b/src/dev_server.ts index 51ee93bf..e5955d74 100644 --- a/src/dev_server.ts +++ b/src/dev_server.ts @@ -14,6 +14,7 @@ import { type ExecaChildProcess } from 'execa' import { cliui, type Logger } from '@poppinss/cliui' import type { Watcher } from '@poppinss/chokidar-ts' +import { AssemblerHooks } from './hooks.js' import type { DevServerOptions } from './types.js' import { AssetsDevServer } from './assets_dev_server.js' import { getPort, isDotEnvFile, runNode, watch } from './helpers.js' @@ -84,6 +85,11 @@ export class DevServer { */ #assetsServer?: AssetsDevServer + /** + * Hooks to execute custom actions during the dev server lifecycle + */ + #hooks: AssemblerHooks + /** * Getting reference to colors library from logger */ @@ -94,6 +100,7 @@ export class DevServer { constructor(cwd: URL, options: DevServerOptions) { this.#cwd = cwd this.#options = options + this.#hooks = new AssemblerHooks(options.hooks) this.#isMetaFileWithReloadsEnabled = picomatch( (this.#options.metaFiles || []) @@ -147,7 +154,7 @@ export class DevServer { scriptArgs: this.#options.scriptArgs, }) - this.#httpServer.on('message', (message) => { + this.#httpServer.on('message', async (message) => { if (this.#isAdonisJSReadyMessage(message)) { const host = message.host === '0.0.0.0' ? '127.0.0.1' : message.host @@ -167,6 +174,8 @@ export class DevServer { } displayMessage.render() + + await this.#hooks.onDevServerStarted({ colors: ui.colors, logger: this.#logger }) } }) @@ -241,6 +250,8 @@ export class DevServer { * Handles TypeScript source file change */ #handleSourceFileChange(action: string, port: string, relativePath: string) { + void this.#hooks.onSourceFileChanged({ colors: ui.colors, logger: this.#logger }, relativePath) + this.#clearScreen() this.#logger.log(`${this.#colors.green(action)} ${relativePath}`) this.#restartHTTPServer(port) diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 00000000..a8b89c9e --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,92 @@ +import { + AssemblerHookNode, + SourceFileChangedHookHandler, + AssemblerHookHandler, + RcFile, +} from '@adonisjs/application/types' +import { RuntimeException } from '@poppinss/utils' +import Hooks from '@poppinss/hooks' + +export class AssemblerHooks { + #config: RcFile['unstable_assembler'] + + #hooks = new Hooks<{ + onDevServerStarted: [Parameters, []] + onSourceFileChanged: [Parameters, []] + onBuildStarting: [Parameters, []] + onBuildCompleted: [Parameters, []] + }>() + + constructor(config: RcFile['unstable_assembler']) { + this.#config = config + } + + /** + * Resolve the hook by importing the file and returning the default export + */ + async #resolveHookNode(node: AssemblerHookNode) { + const exports = await node() + + if (!exports.default) { + throw new RuntimeException('Assembler hook must be defined using the default export') + } + + return exports.default + } + + /** + * Resolve hooks needed for dev-time and register them to the Hooks instance + */ + async registerDevServerHooks() { + await Promise.all([ + this.#config?.onDevServerStarted?.map(async (node) => + this.#hooks.add('onDevServerStarted', await this.#resolveHookNode(node)) + ), + this.#config?.onSourceFileChanged?.map(async (node) => + this.#hooks.add('onSourceFileChanged', await this.#resolveHookNode(node)) + ), + ]) + } + + /** + * Resolve hooks needed for build-time and register them to the Hooks instance + */ + async registerBuildHooks() { + await Promise.all([ + this.#config?.onBuildStarting?.map(async (node) => + this.#hooks.add('onBuildStarting', await this.#resolveHookNode(node)) + ), + this.#config?.onBuildCompleted?.map(async (node) => + this.#hooks.add('onBuildCompleted', await this.#resolveHookNode(node)) + ), + ]) + } + + /** + * When the dev server is started + */ + async onDevServerStarted(...args: Parameters) { + await this.#hooks.runner('onDevServerStarted').run(...args) + } + + /** + * When a source file changes + */ + async onSourceFileChanged(...args: Parameters) { + await this.#hooks.runner('onSourceFileChanged').run(...args) + } + + /** + * When the build process is starting + */ + async onBuildStarting(...args: Parameters) { + await this.#hooks.runner('onBuildStarting').run(...args) + } + + /** + * When the build process is completed + */ + async onBuildCompleted(...args: Parameters) { + await this.#hooks.runner('onBuildCompleted').run(...args) + } +} diff --git a/src/types.ts b/src/types.ts index 9004a1e1..fbce209b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,8 @@ * file that was distributed with this source code. */ +import type { RcFile } from '@adonisjs/application/types' + /** * Options needed to run a script file */ @@ -114,6 +116,13 @@ export type DevServerOptions = { * Assets bundler options to start its dev server */ assets?: AssetsBundlerOptions + /** + * Hooks to execute at different stages + */ + hooks?: Pick< + NonNullable, + 'onDevServerStarted' | 'onSourceFileChanged' + > } /** @@ -206,6 +215,11 @@ export type BundlerOptions = { * for assets */ assets?: AssetsBundlerOptions + + /** + * Hooks to execute at different stages + */ + hooks?: Pick, 'onBuildCompleted' | 'onBuildStarting'> } /** diff --git a/tests/bundler.spec.ts b/tests/bundler.spec.ts index 689f8601..4bde3b94 100644 --- a/tests/bundler.spec.ts +++ b/tests/bundler.spec.ts @@ -154,4 +154,39 @@ test.group('Bundler', () => { const aceFile = await fs.contents('./build/ace.js') assert.notInclude(aceFile, 'ts-node') }) + + test('execute hooks', async ({ assert, fs }) => { + assert.plan(2) + + await Promise.all([ + fs.create( + 'tsconfig.json', + JSON.stringify({ compilerOptions: { outDir: 'build', skipLibCheck: true } }) + ), + fs.create('adonisrc.ts', 'export default { hooks: { onBuildStarting: [() => {}] } }'), + fs.create('package.json', '{}'), + fs.create('package-lock.json', '{}'), + ]) + + const bundler = new Bundler(fs.baseUrl, ts, { + hooks: { + onBuildStarting: [ + async () => ({ + default: () => { + assert.isTrue(true) + }, + }), + ], + onBuildCompleted: [ + async () => ({ + default: () => { + assert.isTrue(true) + }, + }), + ], + }, + }) + + await bundler.bundle() + }) })