From e55eb601afd3c99f16fdf9e51580f71d7ac95324 Mon Sep 17 00:00:00 2001 From: Martin Paucot Date: Mon, 25 Aug 2025 12:00:43 +0200 Subject: [PATCH] demo autoloader hmr --- package.json | 8 +++ providers/girouette_provider.ts | 16 +++++- src/autoloader.ts | 79 +++++++++++++++++++++++++++++ src/girouette.ts | 88 +++++++++++++++++++++++++++++++++ tsconfig.json | 3 +- 5 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 src/autoloader.ts create mode 100644 src/girouette.ts diff --git a/package.json b/package.json index efc50ff..78db3e8 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test": "c8 --include=src --include=providers --reporter=json-summary npm run quick:test && istanbul-badges-readme", "prebuild": "npm run lint && npm run clean", "build": "pnpm clean && tsc && pnpm copy:templates", + "dev": "tsc --watch", "release": "release-it", "version": "npm run build", "prepublishOnly": "npm run build" @@ -56,6 +57,10 @@ "typescript": "^5.4.5" }, "dependencies": { + "emittery": "^1.2.0", + "globby": "^14.1.0", + "hot-hook": "file:../hot-hook/packages/hot_hook", + "@adonisjs/http-server": "file:../http-server", "reflect-metadata": "^0.2.2" }, "peerDependencies": { @@ -68,6 +73,9 @@ "decorators", "routing" ], + "resolutions": { + "@adonisjs/http-server": "file:../http-server" + }, "eslintConfig": { "extends": "@adonisjs/eslint-config/package" }, diff --git a/providers/girouette_provider.ts b/providers/girouette_provider.ts index 333c545..c91a547 100644 --- a/providers/girouette_provider.ts +++ b/providers/girouette_provider.ts @@ -24,6 +24,7 @@ import { } from '../src/constants.js' import { RouteResource } from '@adonisjs/core/http' import { GirouetteConfig } from '../src/types.js' +import { Girouette } from '../src/girouette.js' /** * Represents a route configuration within the Girouette system @@ -82,11 +83,22 @@ export default class GirouetteProvider { this.#controllersPath = path } + async register() { + this.app.container.singleton(Girouette, async (resolver) => { + const [router, logger] = await Promise.all([resolver.make('router'), resolver.make('logger')]) + + return new Girouette(this.app, router, logger, { + controllersPath: this.app.httpControllersPath(), + }) + }) + } + /** * Boot the provider when the application is ready */ async boot() { - // Provider is booted + const girouette = await this.app.container.make(Girouette) + girouette.load() } /** @@ -96,7 +108,7 @@ export default class GirouetteProvider { this.#router = await this.app.container.make('router') this.#logger = await this.app.container.make('logger') this.#config = this.app.config.get('girouette') - await this.#scanControllersDirectory(this.#controllersPath) + // await this.#scanControllersDirectory(this.#controllersPath) } /** diff --git a/src/autoloader.ts b/src/autoloader.ts new file mode 100644 index 0000000..1f062d7 --- /dev/null +++ b/src/autoloader.ts @@ -0,0 +1,79 @@ +import Emittery from 'emittery' +import { globby } from 'globby' +import { hot } from 'hot-hook' + +export type AutoloaderOptions = { + path: string + + /** + * File suffixes for file matching. + * + * @example ['controller'] + */ + suffixes: string[] + + /** + * File extensions to match. + * + * @default ['ts', 'tsx', 'js', 'jsx'] + */ + extensions?: string[] +} + +export type AutoloaderEvents = { + added: { path: string; module: any } + updated: { path: string; module: any } +} + +export class Autoloader extends Emittery { + options: AutoloaderOptions + + cache = new Map() + + constructor(options: AutoloaderOptions) { + super() + this.options = options + } + + /** + * List matching files for autoloading. + */ + async discover() { + const matches = await globby(this.options.path, { + absolute: true, + expandDirectories: { + files: this.options.suffixes.map((suffix) => `*_${suffix}`), + extensions: ['ts', 'tsx', 'js', 'jsx'], + }, + }) + + return matches + } + + /** + * Starts the autoloader. + * It first discover matching files for autoloading and then hook into hot-hook for HMR support. + */ + async autoload() { + const matches = await this.discover() + await Promise.all(matches.map((path) => this.loadModule(path))) + } + + async loadModule(path: string) { + const module = await import(path, import.meta.hot?.boundary) + + if (this.cache.has(path)) { + await this.emit('updated', { path: path, module }) + } else { + await this.emit('added', { path: path, module }) + } + + if (import.meta.hot) { + this.cache.set(path, true) + + hot.dispose(`file://${path}`, () => { + this.loadModule(path) + }) + } + } +} diff --git a/src/girouette.ts b/src/girouette.ts new file mode 100644 index 0000000..a1ab1d2 --- /dev/null +++ b/src/girouette.ts @@ -0,0 +1,88 @@ +import { ApplicationService, HttpRouterService } from '@adonisjs/core/types' +import { Autoloader } from './autoloader.js' +import { Logger } from '@adonisjs/core/logger' +import { REFLECT_ROUTES_KEY } from './constants.js' +import { Route } from '@adonisjs/http-server' + +export type GirouetteOptions = { + controllersPath: string +} + +type ControllerModule = { + controller: FunctionConstructor + path: string +} + +export class Girouette { + #app: ApplicationService + #router: HttpRouterService + #logger: Logger + #autoloader: Autoloader + + cache = new Map() + + constructor( + app: ApplicationService, + router: HttpRouterService, + logger: Logger, + options: GirouetteOptions + ) { + this.#app = app + this.#router = router + this.#logger = logger + this.#autoloader = new Autoloader({ + path: options.controllersPath, + suffixes: ['controller'], + }) + } + + async load() { + this.#autoloader.on('added', ({ module, path }) => { + const { default: Controller } = module + this.updateControllerRoutes(Controller, path) + }) + + this.#autoloader.on('updated', ({ module, path }) => { + const { default: Controller } = module + this.updateControllerRoutes(Controller, path) + }) + + await this.#autoloader.autoload() + } + + updateControllerRoutes(Controller: FunctionConstructor, path: string) { + const routes = this.#createControllerRoutes(Controller) + this.cache.set(path, routes) + this.reload() + } + + reload() { + for (const routes of this.cache.values()) { + for (const route of routes) { + this.#router.pushToRoutes(route) + } + } + + this.#router.commit() + } + + #createControllerRoutes(Controller: FunctionConstructor) { + const meta = Reflect.getMetadata(REFLECT_ROUTES_KEY, Controller) + if (!meta) return [] + + const routes = Object.entries(meta).map(([method, route]) => + this.#createRoute(Controller, method, route) + ) + + return routes + } + + #createRoute(Controller: FunctionConstructor, method: string, route: any) { + return new Route(this.#app, [], { + pattern: route.pattern, + methods: [route.method], + handler: [Controller, method as any], + globalMatchers: {}, + }) + } +} diff --git a/tsconfig.json b/tsconfig.json index ad0cc44..37549f2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "extends": "@adonisjs/tsconfig/tsconfig.package.json", "compilerOptions": { "rootDir": "./", - "outDir": "./build" + "outDir": "./build", + "types": ["hot-hook/import-meta"] } }