diff --git a/README.md b/README.md index 63af2577..65a9253b 100644 --- a/README.md +++ b/README.md @@ -219,10 +219,7 @@ npm start Next: -- Plugin and extension model for drivers and the console - End-to-end tests for commands and scaffolding -- Package versioning and release guidance -- Upgrade guides between template versions Later: diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 4976cd34..57100194 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -192,6 +192,7 @@ export default defineConfig({ { text: 'Views', link: '/guide/views' }, { text: 'Inertia', link: '/guide/inertia' }, { text: 'Hooks', link: '/guide/hooks' }, + { text: 'Plugins', link: '/guide/plugins' }, { text: 'HTTP', link: '/guide/http' }, { text: 'Deployment', link: '/guide/deployment' }, { text: 'Upgrading', link: '/guide/upgrading' }, diff --git a/docs/guide/express-runtime.md b/docs/guide/express-runtime.md index ac132b88..5ad6c987 100644 --- a/docs/guide/express-runtime.md +++ b/docs/guide/express-runtime.md @@ -157,7 +157,7 @@ The server binds to `0.0.0.0` by default so it is reachable on all network inter ## Notes -- `app.boot(port)` mounts public assets, binds router, applies middleware, registers error handling, starts the server, and attaches graceful shutdown. +- `app.boot(port)` calls `driver.prepare()` to mount public assets, apply middleware, bind the router and register error handling, then starts the server and attaches graceful shutdown. [Plugins](/guide/plugins) can join in between those steps. - Static asset mounting happens before configured middleware is applied. - For middleware layering and recommended usage, see [Middleware Guide](/guide/middleware). - Use the router contract (`getRouter`) for runtime-agnostic behavior where possible. diff --git a/docs/guide/hooks.md b/docs/guide/hooks.md index 8d44aa6b..5f4bd716 100644 --- a/docs/guide/hooks.md +++ b/docs/guide/hooks.md @@ -31,9 +31,23 @@ Hook.on('middleware:auth', { }) ``` +## Listen Alongside Others + +`Hook.on()` keeps one callback per position, so a second registration replaces the first. When several packages need the same position, use `Hook.listen()`. It adds callbacks without replacing any, and returns a function that removes them. + +```ts +const stop = Hook.listen('server:start', { + after: ({ url }) => console.log(`ready at ${url}`), +}) + +stop() +``` + +[Plugins](./plugins) add their hooks this way. + ## Emit Hooks -Use `Hook.emit(name, position, ...args)` to fire a position. Unlike Node's `EventEmitter.emit()`, which returns a boolean, it returns the callback's result: a Promise for an async hook, so it can be awaited. When nothing is registered there it does nothing and returns `undefined`, so call sites need no `has()` check. +Use `Hook.emit(name, position, ...args)` to fire a position. It runs the `on()` callback first, then the `listen()` callbacks in the order they were added, each one after the previous has settled. Unlike Node's `EventEmitter.emit()`, which returns a boolean, it returns the first callback's result: a Promise when any callback is async, so it can be awaited. When nothing is registered there it does nothing and returns `undefined`, so call sites need no `has()` check. ```ts await Hook.emit('middleware:auth', 'before', { req, res }) @@ -41,7 +55,7 @@ await Hook.emit('middleware:auth', 'before', { req, res }) ## Read Hooks -`Hook.get()` retrieves the full hook object or one position's callback without calling it, and `Hook.has()` checks for one. +`Hook.get()` retrieves the `on()` hook object or one of its positions without calling it. `Hook.has()` checks for any callback, including `listen()` ones. ```ts const beforeAuth = Hook.get('middleware:auth', 'before') @@ -57,7 +71,7 @@ const hooks = Hook.all() ## Stop Listening -Remove a single position, a whole hook, or every registered hook: +Remove a single position, a whole hook, or every registered hook. These remove `listen()` callbacks too: ```ts Hook.off('middleware:auth', 'before') @@ -85,13 +99,14 @@ Augment `@arkstack/foundry`, not `@arkstack/common`: `common` only re-exports th ## Built-in Hooks -| Hook | Positions | Arguments | -| ----------------- | -------------------------- | ------------------------------------------- | -| `server:start` | `after`, `tunneled` | `ServerStartInfo` (see below) | -| `middleware:auth` | `before`, `after`, `error` | see [Authentication](./authentication) | -| `boot` | `before`, `after` | `(port, app)` | -| `shutdown` | `before` | `({})`, on `SIGINT` / `SIGTERM` / `SIGQUIT` | -| `set:root-dir` | `after` | `(dir)` | +| Hook | Positions | Arguments | +| ----------------- | --------------------------- | ---------------------------------------------------------------- | +| `server:start` | `after`, `tunneled` | `ServerStartInfo` (see below) | +| `driver:prepare` | `before`, `router`, `after` | `({ app, driver })`, see [Plugins](./plugins#driver-integration) | +| `middleware:auth` | `before`, `after`, `error` | see [Authentication](./authentication) | +| `boot` | `before`, `after` | `(port, app)` | +| `shutdown` | `before` | `({})`, on `SIGINT` / `SIGTERM` / `SIGQUIT` | +| `set:root-dir` | `after` | `(dir)` | ### Server start diff --git a/docs/guide/plugins.md b/docs/guide/plugins.md new file mode 100644 index 00000000..9e727c38 --- /dev/null +++ b/docs/guide/plugins.md @@ -0,0 +1,133 @@ +# Plugins + +A plugin is a package, or a module in your app, that extends Arkstack without editing the app's core files. It can: + +- hook into the driver while the app is set up, to add runtime middleware, routes or framework plugins +- listen to any other hook, such as `server:start` or `shutdown` +- add commands to `ark` +- say which drivers it works with + +Plugins are built on the [hook registry](./hooks). A plugin's hooks are added with `Hook.listen()`, so they run next to your app's own `Hook.on()` callbacks and next to other plugins, instead of replacing them. + +## Write a Plugin + +Extend `ArkstackPlugin` from `@arkstack/foundry` (also exported by `@arkstack/common`). Only `name` is required. + +```ts +import { ArkstackPlugin, type HookMap } from '@arkstack/foundry' +import type { Express } from 'express' +import { HealthCommand } from './HealthCommand' + +export class HealthPlugin extends ArkstackPlugin { + readonly name = 'health' + readonly drivers = ['express'] + + hooks(): HookMap { + return { + 'driver:prepare': { + router: ({ app }) => { + ;(app as Express).get('/health', (_req, res) => { + res.json({ ok: true }) + }) + }, + }, + 'server:start': { + after: ({ url }) => console.log(`health check at ${url}/health`), + }, + } + } + + commands() { + return [HealthCommand] + } + + register() { + // Runs once, when the plugin is installed + } +} +``` + +| Member | Purpose | +| ------------ | --------------------------------------------------------------------------------------------- | +| `name` | Unique name. Installing a second plugin with the same name does nothing. | +| `drivers` | Driver names the plugin works with (`express`, `h3`, `fastify`). Empty means every driver. | +| `hooks()` | Hook listeners keyed by hook name. Annotate it as `HookMap` to type them from `HookRegistry`. | +| `commands()` | Musket command classes added to `ark`. | +| `register()` | Runs once at install time, for setup such as `Publisher.publishes()` or `Validator.use()`. | + +## Install a Plugin + +Install plugins in `src/core/bootstrap.ts`, so both the HTTP server and the console see them: + +```ts +import { HealthPlugin } from 'src/plugins/HealthPlugin' + +export const app = new Application(expressApp).use(new HealthPlugin()) +``` + +`app.use()` forwards to the `Plugins` registry, which you can also use directly: + +```ts +import { Plugins } from '@arkstack/foundry' + +Plugins.use(new HealthPlugin(), new AuditPlugin()) + +Plugins.has('health') // true +Plugins.get('health') +Plugins.all() +Plugins.remove('health') // uninstalls it and removes its hook listeners +``` + +## Driver Integration + +`driver.prepare()` sets the runtime app up for serving. It runs the `driver:prepare` hook between its steps: + +| Position | Runs | Use it to | +| -------- | ------------------------------------------------------ | ------------------------------------------------ | +| `before` | before public assets and the app middleware | add early middleware or register runtime plugins | +| `router` | after the app middleware, just before routes are bound | add routes that need the app middleware | +| `after` | after routes and the error handler | inspect or finish the configured app | + +Each position receives `{ app, driver }`. `app` is the runtime app (an Express app, an H3 app or a Fastify instance), and `driver.name` tells you which one. Cast `app` to the runtime type your plugin supports, and list that runtime in `drivers`. + +Before any step runs, `prepare()` checks every installed plugin against the current driver and throws if one does not support it. That way an Express-only plugin fails at boot on an H3 app, not on the first request. + +On Fastify, register Fastify plugins (`app.register(...)`) in the `before` position. Fastify fixes a route's hooks when the route is built, so plugins registered after routes do not apply to them. + +The templates call `prepare()` from `Application.boot()`: + +```ts +public async boot(port: number, defer = false) { + // Load public assets, middleware, routes and the error handler + await this.driver.prepare(this.app, { + publicPath: path.join(Arkstack.rootDir(), 'public'), + middleware: config('middleware') as never, + }) + + // Start the server + if (defer !== true) { + await this.driver.start(this.app, port) + } +} +``` + +If your `src/core/app.ts` still calls `mountPublicAssets`, `applyMiddleware`, `bindRouter` and `registerErrorHandler` one by one, replace those four calls with `prepare()` as above. Until then, plugin commands and other hooks work, but the `driver:prepare` hook never fires. + +## Console Integration + +`ark` loads `src/core/bootstrap.ts` before it registers commands, so the commands of every installed plugin are available next to the built-in ones: + +```ts +import { Command } from '@h3ravel/musket' + +export class HealthCommand extends Command { + protected signature = 'health:check' + protected description = 'Check that the app answers on /health' + + async handle() { + // ... + } +} +``` + +A plugin package that publishes config or stubs can register them in `register()` with `Publisher.publishes()`, and users copy them with `ark publish --package `. diff --git a/docs/guide/upgrading.md b/docs/guide/upgrading.md index 71970702..65ea658b 100644 --- a/docs/guide/upgrading.md +++ b/docs/guide/upgrading.md @@ -76,6 +76,20 @@ A few things to keep in mind when reading the diff: These notes cover what an existing application has to do. New features that need no change are in the [changelog](/more/changelog). +### Unreleased + +Plugins need no change to install or to add `ark` commands. To let them hook into the driver setup through `driver:prepare`, replace the four driver calls in `boot()` in `src/core/app.ts` with `driver.prepare()`: + +```ts +// Load public assets, middleware, routes and the error handler +await this.driver.prepare(this.app, { + publicPath: path.join(Arkstack.rootDir(), 'public'), + middleware: config('middleware') as never, +}) +``` + +On H3 the template passes `publicPath: 'public'`. On Fastify, keep the call inside the `if (!this.prepared)` guard. See [Plugins](/guide/plugins). + ### 0.21 to 0.22 No changes are needed for Express and H3 apps. diff --git a/docs/more/roadmap.md b/docs/more/roadmap.md index d60f4366..cebf9b9c 100644 --- a/docs/more/roadmap.md +++ b/docs/more/roadmap.md @@ -28,6 +28,10 @@ This roadmap tracks Arkstack progress and upcoming priorities. - Removes `src/routes/api.ts` - Removes Prisma and database runtime/dependencies - ✅ Root-level test coverage for shared architecture and command surface +- ✅ Plugin model for driver and console integration + - `ArkstackPlugin` and the `Plugins` registry in `@arkstack/foundry` + - `driver.prepare()` and the `driver:prepare` hook + - Plugin commands in `ark` - ✅ [Package versioning and release workflow guidance](/more/releases) - ✅ [Upgrade guides between template versions](/guide/upgrading) @@ -37,8 +41,7 @@ This roadmap tracks Arkstack progress and upcoming priorities. ## Next Priorities -1. 🔲 Add a plugin/extension model for driver and console integration -2. 🔲 Ship richer end-to-end command and scaffolding tests +1. 🔲 Ship richer end-to-end command and scaffolding tests ## Future diff --git a/packages/common/src/Plugins.ts b/packages/common/src/Plugins.ts new file mode 100644 index 00000000..f28b0da2 --- /dev/null +++ b/packages/common/src/Plugins.ts @@ -0,0 +1 @@ +export { ArkstackPlugin, Plugins } from '@arkstack/foundry' diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index eced4893..8d5e0e28 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -9,6 +9,7 @@ export * from './ErrorHandler' export * from './Publisher' export * from './Logger' export * from './Hook' +export * from './Plugins' export * from './types' export * from './utils/encryption' export * from './utils/hash' diff --git a/packages/console-slim/src/index.ts b/packages/console-slim/src/index.ts index 4c16d157..390bc85f 100644 --- a/packages/console-slim/src/index.ts +++ b/packages/console-slim/src/index.ts @@ -1,6 +1,15 @@ #!/usr/bin/env node -import { config, discoverCommands, env, importFile, loadPrototypes, outputDir, rebuildOutput } from '@arkstack/common' +import { + Plugins, + config, + discoverCommands, + env, + importFile, + loadPrototypes, + outputDir, + rebuildOutput, +} from '@arkstack/common' import { existsSync, realpathSync } from 'node:fs' import { fileURLToPath, pathToFileURL } from 'node:url' import path, { join } from 'node:path' @@ -94,6 +103,7 @@ export const runConsoleKernel = async (options: RunConsoleOptions = {}) => { DevCommand, BuildCommand, MakeCommand, + ...(Plugins.commands() as Array), ...userCommands, ], discoveryPaths: [join(Arkstack.rootDir(), 'node_modules', '@arkstack/*', 'dist', 'commands', '*.js')], diff --git a/packages/console/src/index.ts b/packages/console/src/index.ts index 8421c2f1..9e59c896 100644 --- a/packages/console/src/index.ts +++ b/packages/console/src/index.ts @@ -11,6 +11,7 @@ import { initializeGlobalContext, loadPrototypes, outputDir, + Plugins, rebuildOutput, } from '@arkstack/common' import { existsSync, realpathSync } from 'node:fs' @@ -116,6 +117,7 @@ export const runConsoleKernel = async (options: RunConsoleOptions = {}) => { MakeCommand, KeyGenerateCommand, PublishCommand, + ...(Plugins.commands() as Array), ...userCommands, ], discoveryPaths: [join(Arkstack.rootDir(), 'node_modules', '@arkstack/*', 'dist', 'commands', '*.js')], diff --git a/packages/contract/src/Arkstack.ts b/packages/contract/src/Arkstack.ts index 7593e2ff..31cc4f86 100644 --- a/packages/contract/src/Arkstack.ts +++ b/packages/contract/src/Arkstack.ts @@ -1,6 +1,6 @@ import { ArkstackKitDriver } from './kits' import { ArkstackRouterContract } from './routing' -import { Hook } from '@arkstack/foundry' +import { ArkstackPlugin, Hook, Plugins } from '@arkstack/foundry' export type PromiseOrValue = T | Promise @@ -41,6 +41,18 @@ export abstract class Arkstack { return Arkstack.app as TApp } + /** + * Installs plugins for this app. See {@link Plugins.use}. + * + * @param plugins + * @returns + */ + use(...plugins: ArkstackPlugin[]): this { + Plugins.use(...plugins) + + return this + } + /** * Gets the ArkstackKitDriver instance used by the application. * diff --git a/packages/contract/src/global.d.ts b/packages/contract/src/global.d.ts index d1707d12..3c95b226 100644 --- a/packages/contract/src/global.d.ts +++ b/packages/contract/src/global.d.ts @@ -1,4 +1,4 @@ -import { Arkstack, ServerStartInfo, TunneledServerStartInfo } from '.' +import { Arkstack, DriverPrepareContext, ServerStartInfo, TunneledServerStartInfo } from '.' declare global { function app(): Arkstack @@ -15,6 +15,17 @@ declare module '@arkstack/foundry' { 'set:root-dir': { after: (dir: string) => void } + /** + * Fired by `driver.prepare()` while the runtime app is set up for serving. + */ + 'driver:prepare': { + /** Before public assets and middleware are applied. */ + before: (context: DriverPrepareContext) => void | Promise + /** After the app middleware, just before routes are bound. */ + router: (context: DriverPrepareContext) => void | Promise + /** After routes and the error handler are in place. */ + after: (context: DriverPrepareContext) => void | Promise + } /** * Fired by every driver once its server is listening (dev and production), * after the startup banner and once any tunnel is open. diff --git a/packages/contract/src/kits.ts b/packages/contract/src/kits.ts index 6d1cc104..3359af6d 100644 --- a/packages/contract/src/kits.ts +++ b/packages/contract/src/kits.ts @@ -1,3 +1,5 @@ +import { Hook, Plugins } from '@arkstack/foundry' + import { ArkstackMiddlewareConfig } from './http' import { PromiseOrValue } from './Arkstack' @@ -20,6 +22,25 @@ export interface ServerStartInfo { /** What the `server:start` hook's `tunneled` position receives: a tunnel is always open. */ export type TunneledServerStartInfo = ServerStartInfo & { tunnelUrl: string } +/** + * What {@link ArkstackKitDriver.prepare} sets up on the runtime app. + */ +export interface DriverPrepareOptions { + /** The directory static assets are served from. Skipped when not set. */ + publicPath?: string + /** The app middleware, usually `config('middleware')`. Skipped when not set. */ + middleware?: ArkstackMiddlewareConfig | TMiddleware +} + +/** + * What each position of the `driver:prepare` hook receives. + */ +export interface DriverPrepareContext { + /** The runtime app, such as an Express app or a Fastify instance. */ + app: TApp + driver: ArkstackKitDriver +} + /** * The ArkstackKitDriver class defines the contract for a driver * that can be used with the ArkstackKitContract. @@ -35,6 +56,31 @@ export abstract class ArkstackKitDriver { return } abstract start(app: TApp, port: number): PromiseOrValue + + /** + * Sets the app up for serving: public assets, middleware, routes, then the + * error handler. Plugins join in through the `driver:prepare` hook, which + * runs `before` all of it, `router` just before routes are bound, and `after` + * the error handler. + * + * @param app + * @param options + */ + async prepare(app: TApp, options: DriverPrepareOptions = {}): Promise { + Plugins.assertSupports(this.name) + + const context: DriverPrepareContext = { app, driver: this } + + await Hook.emit('driver:prepare', 'before', context) + + if (options.publicPath) await this.mountPublicAssets(app, options.publicPath) + if (options.middleware) await this.applyMiddleware(app, options.middleware as TMiddleware) + + await Hook.emit('driver:prepare', 'router', context) + await this.bindRouter(app) + await this.registerErrorHandler(app) + await Hook.emit('driver:prepare', 'after', context) + } } /** diff --git a/packages/contract/tests/prepare.test.ts b/packages/contract/tests/prepare.test.ts new file mode 100644 index 00000000..7e9954ee --- /dev/null +++ b/packages/contract/tests/prepare.test.ts @@ -0,0 +1,91 @@ +import { ArkstackPlugin, Hook, HookMap, Plugins } from '@arkstack/foundry' +import { beforeEach, describe, expect, it } from 'vitest' + +import { ArkstackKitDriver } from '../src/kits' + +class RecordingDriver extends ArkstackKitDriver { + readonly name = 'recording' + + createApp() { + return [] + } + + mountPublicAssets(app: string[], publicPath: string) { + app.push(`assets:${publicPath}`) + } + + bindRouter(app: string[]) { + app.push('router') + } + + applyMiddleware(app: string[], _middleware: unknown) { + app.push('middleware') + } + + registerErrorHandler(app: string[]) { + app.push('errors') + } + + start() {} +} + +class StepPlugin extends ArkstackPlugin { + readonly name = 'steps' + + hooks(): HookMap { + return { + 'driver:prepare': { + before: ({ app }) => void (app as string[]).push('plugin:before'), + router: ({ app }) => void (app as string[]).push('plugin:router'), + after: ({ app }) => void (app as string[]).push('plugin:after'), + }, + } + } +} + +class OtherDriverPlugin extends ArkstackPlugin { + readonly name = 'other-driver' + readonly drivers = ['fastify'] +} + +describe('ArkstackKitDriver.prepare', () => { + beforeEach(() => { + Plugins.clear() + Hook.clear() + }) + + it('sets the app up in order and runs plugin hooks between the steps', async () => { + const driver = new RecordingDriver() + const app = driver.createApp() + + Plugins.use(new StepPlugin()) + await driver.prepare(app, { publicPath: 'public', middleware: 'cors' }) + + expect(app).toEqual([ + 'plugin:before', + 'assets:public', + 'middleware', + 'plugin:router', + 'router', + 'errors', + 'plugin:after', + ]) + }) + + it('skips assets and middleware that are not given', async () => { + const driver = new RecordingDriver() + const app = driver.createApp() + + await driver.prepare(app) + + expect(app).toEqual(['router', 'errors']) + }) + + it('refuses to prepare when a plugin does not support the driver', async () => { + const driver = new RecordingDriver() + + Plugins.use(new OtherDriverPlugin()) + + await expect(driver.prepare(driver.createApp())).rejects.toThrow('"recording" driver') + }) +}) diff --git a/packages/driver-express/tests/plugins.test.ts b/packages/driver-express/tests/plugins.test.ts new file mode 100644 index 00000000..420aac16 --- /dev/null +++ b/packages/driver-express/tests/plugins.test.ts @@ -0,0 +1,58 @@ +import { ArkstackPlugin, Hook, Plugins } from '@arkstack/foundry' +import { beforeEach, describe, expect, it } from 'vitest' +import express, { type Express } from 'express' + +import { ExpressDriver } from '../src/index' +import request from 'parasito' + +class HealthPlugin extends ArkstackPlugin { + readonly name = 'health' + readonly drivers = ['express'] + + hooks() { + return { + 'driver:prepare': { + before: ({ app }: { app: unknown }) => { + ;(app as Express).use((_req, res, next) => { + res.setHeader('x-health', 'on') + next() + }) + }, + router: ({ app }: { app: unknown }) => { + ;(app as Express).get('/health', (_req, res) => { + res.json({ ok: true }) + }) + }, + }, + } + } +} + +describe('ExpressDriver plugins', () => { + beforeEach(() => { + Plugins.clear() + Hook.clear() + }) + + it('lets a plugin add middleware and routes while the app is prepared', async () => { + const driver = new ExpressDriver({ + bindRouter: (app) => { + app.get('/', (_req, res) => { + res.send('home') + }) + }, + }) + const app = express() + + Plugins.use(new HealthPlugin()) + await driver.prepare(app) + + const health = await request(app).get('/health') + const home = await request(app).get('/') + + expect(health.status).toBe(200) + expect(health.body).toEqual({ ok: true }) + expect(health.headers.get('x-health')).toBe('on') + expect(home.text).toBe('home') + }) +}) diff --git a/packages/foundry/src/ArkstackPlugin.ts b/packages/foundry/src/ArkstackPlugin.ts new file mode 100644 index 00000000..ec5cabe2 --- /dev/null +++ b/packages/foundry/src/ArkstackPlugin.ts @@ -0,0 +1,43 @@ +import { CommandConstructor, HookMap } from './types' + +/** + * Base class for packages that extend an Arkstack app. A plugin adds hook + * listeners and console commands, and can be limited to some drivers. + * Install it with `Plugins.use()` or `app.use()`. + */ +export abstract class ArkstackPlugin { + abstract readonly name: string + + /** + * Driver names the plugin works with, such as `express`. Empty means every driver. + */ + readonly drivers: string[] = [] + + /** + * Hook listeners added when the plugin is installed. + */ + hooks(): HookMap { + return {} + } + + /** + * Console command classes added to `ark`. + */ + commands(): CommandConstructor[] { + return [] + } + + /** + * Runs once when the plugin is installed. + */ + register(): void {} + + /** + * Whether the plugin works with the named driver. + * + * @param driver + */ + supports(driver: string): boolean { + return this.drivers.length < 1 || this.drivers.includes(driver) + } +} diff --git a/packages/foundry/src/Hook.ts b/packages/foundry/src/Hook.ts index 9ccf869b..647cb8f1 100644 --- a/packages/foundry/src/Hook.ts +++ b/packages/foundry/src/Hook.ts @@ -2,6 +2,7 @@ import { HookArgs, HookFor, HookName, HookPos, HookPositions, HookRegistry, Hook export class Hook { private static hooks = new Map() + private static listeners = new Map() /** * Register callbacks for a hook's positions. Registering the same name again @@ -19,6 +20,27 @@ export class Hook { }) } + /** + * Add callbacks for a hook's positions alongside any others already there. + * Unlike {@link Hook.on}, nothing is replaced, so several packages can listen + * to the same position. Returns a function that removes these callbacks. + * + * @param name + * @param hook + */ + static listen(name: N | (string & {}), hook: Partial>): () => void { + const listener = { ...(hook as IHook) } + + this.listeners.set(name, [...(this.listeners.get(name) ?? []), listener]) + + return () => { + const remaining = (this.listeners.get(name) ?? []).filter((entry) => entry !== listener) + + if (remaining.length > 0) this.listeners.set(name, remaining) + else this.listeners.delete(name) + } + } + /** * @deprecated Use {@link Hook.on}; `set` merges rather than replaces. * @@ -36,16 +58,17 @@ export class Hook { * @param pos */ static has>(name: N | (string & {}), pos?: P) { - if (pos && this.hooks.has(name)) return Boolean(this.hooks.get(name)?.[pos as string]) + if (pos) return this.callbacks(name, pos as string).length > 0 - return this.hooks.has(name) + return this.hooks.has(name) || this.listeners.has(name) } /** - * Run a hook position with the given arguments and return its result (a - * Promise for an async hook, so it can be awaited). A no-op returning - * `undefined` when nothing is registered there, so call sites need no - * {@link Hook.has} check. + * Run a hook position with the given arguments: the {@link Hook.on} callback + * first, then every {@link Hook.listen} callback in the order they were added. + * Returns the first callback's result, as a Promise when any callback is + * async, so it can be awaited. A no-op returning `undefined` when nothing is + * registered there, so call sites need no {@link Hook.has} check. * * @param name * @param pos @@ -56,9 +79,18 @@ export class Hook { pos: P, ...args: HookArgs ): HookResult | undefined { - const fn = this.hooks.get(name)?.[pos as string] + const [first, ...rest] = this.callbacks(name, pos as string) + + if (!first) return undefined + + const result = first(...args) + let pending: unknown = result - return fn ? (fn(...args) as HookResult) : undefined + for (const fn of rest) { + pending = pending instanceof Promise ? pending.then(() => fn(...args)) : fn(...args) + } + + return (pending instanceof Promise ? pending.then(() => result) : result) as HookResult } /** @@ -113,22 +145,38 @@ export class Hook { static getAll = () => this.all() /** - * Remove a hook, or one position of it. With no name, removes every hook. + * Remove a hook, or one position of it, including its {@link Hook.listen} + * callbacks. With no name, removes every hook. * * @param name * @param pos */ static off>(name?: N | (string & {}), pos?: P) { - if (name && this.hooks.has(name)) { - if (pos !== undefined) { - const hook = this.hooks.get(name) - if (hook?.[pos as string]) delete hook[pos as string] - - return Object.keys(hook ?? {}).length < 1 ? void this.hooks.delete(name) : undefined - } + if (!name) return this.clear() + if (pos === undefined) { this.hooks.delete(name) - } else if (!name) this.clear() + this.listeners.delete(name) + + return + } + + const hook = this.hooks.get(name) + + if (hook) { + delete hook[pos as string] + + if (Object.keys(hook).length < 1) this.hooks.delete(name) + } + + const listeners = (this.listeners.get(name) ?? []).filter((listener) => { + delete listener[pos as string] + + return Object.keys(listener).length > 0 + }) + + if (listeners.length > 0) this.listeners.set(name, listeners) + else this.listeners.delete(name) } /** @@ -146,5 +194,18 @@ export class Hook { */ static clear = () => { this.hooks.clear() + this.listeners.clear() + } + + /** + * Every callback registered for a position, in the order {@link Hook.emit} runs them. + * + * @param name + * @param pos + */ + private static callbacks(name: string, pos: string) { + return [this.hooks.get(name), ...(this.listeners.get(name) ?? [])] + .map((hook) => hook?.[pos]) + .filter((fn): fn is (...args: any[]) => unknown => typeof fn === 'function') } } diff --git a/packages/foundry/src/Plugins.ts b/packages/foundry/src/Plugins.ts new file mode 100644 index 00000000..1d822937 --- /dev/null +++ b/packages/foundry/src/Plugins.ts @@ -0,0 +1,100 @@ +import { ArkstackPlugin } from './ArkstackPlugin' +import { CommandConstructor } from './types' +import { Hook } from './Hook' + +interface InstalledPlugin { + plugin: ArkstackPlugin + dispose: Array<() => void> +} + +/** + * The process-wide registry of installed {@link ArkstackPlugin}s. Plugin hooks + * are added with {@link Hook.listen}, so they run alongside the app's own. + */ +export class Plugins { + private static installed = new Map() + + /** + * Install plugins. A plugin whose name is already installed is skipped, so + * importing the bootstrap file twice does not register it twice. + * + * @param plugins + */ + static use(...plugins: ArkstackPlugin[]): void { + for (const plugin of plugins) { + if (this.installed.has(plugin.name)) continue + + plugin.register() + + const dispose = Object.entries(plugin.hooks()) + .filter(([, hook]) => hook !== undefined) + .map(([name, hook]) => Hook.listen(name, hook!)) + + this.installed.set(plugin.name, { plugin, dispose }) + } + } + + /** + * Check if a plugin is installed. + * + * @param name + */ + static has(name: string): boolean { + return this.installed.has(name) + } + + /** + * Retrieve an installed plugin by name. + * + * @param name + */ + static get(name: string): T | undefined { + return this.installed.get(name)?.plugin as T | undefined + } + + /** + * Retrieve every installed plugin, in install order. + */ + static all(): ArkstackPlugin[] { + return [...this.installed.values()].map(({ plugin }) => plugin) + } + + /** + * The console commands of every installed plugin. + */ + static commands(): CommandConstructor[] { + return this.all().flatMap((plugin) => plugin.commands()) + } + + /** + * Throw when an installed plugin does not work with the named driver. + * + * @param driver + */ + static assertSupports(driver: string): void { + const unsupported = this.all().filter((plugin) => !plugin.supports(driver)) + + if (unsupported.length < 1) return + + const names = unsupported.map((plugin) => `${plugin.name} (${plugin.drivers.join(', ')})`).join('; ') + + throw new Error(`These plugins do not support the "${driver}" driver: ${names}`) + } + + /** + * Uninstall a plugin and remove its hook listeners. + * + * @param name + */ + static remove(name: string): void { + this.installed.get(name)?.dispose.forEach((dispose) => dispose()) + this.installed.delete(name) + } + + /** + * Uninstall every plugin. + */ + static clear(): void { + for (const name of [...this.installed.keys()]) this.remove(name) + } +} diff --git a/packages/foundry/src/index.ts b/packages/foundry/src/index.ts index 618bd668..9e9e60c8 100644 --- a/packages/foundry/src/index.ts +++ b/packages/foundry/src/index.ts @@ -1,2 +1,4 @@ +export * from './ArkstackPlugin' export * from './Hook' +export * from './Plugins' export * from './types' diff --git a/packages/foundry/src/types.ts b/packages/foundry/src/types.ts index c4c4f06b..53a7e708 100644 --- a/packages/foundry/src/types.ts +++ b/packages/foundry/src/types.ts @@ -32,3 +32,11 @@ export type HookArgs = N extends keyof HookR // What running a hook position returns (a Promise for async hooks) export type HookResult = HookPos extends (...args: any[]) => infer R ? R : unknown + +// Hook callbacks keyed by hook name, as a plugin declares them +export type HookMap = { [N in keyof HookRegistry]?: Partial } & { + [name: string]: IHook | undefined +} + +// A console command class; the console kernel decides its exact shape +export type CommandConstructor = abstract new (...args: any[]) => unknown diff --git a/packages/foundry/tests/hook.test.ts b/packages/foundry/tests/hook.test.ts index cc97aa16..5748cc54 100644 --- a/packages/foundry/tests/hook.test.ts +++ b/packages/foundry/tests/hook.test.ts @@ -129,4 +129,73 @@ describe('Hook', () => { expect(Object.keys(Hook.all())).toEqual([]) }) }) + describe('listen', () => { + it('runs listeners after the on() callback, keeping its result', () => { + const calls: string[] = [] + + Hook.on('test:hook', { after: () => (calls.push('on'), 'primary') }) + Hook.listen('test:hook', { after: () => calls.push('first') }) + Hook.listen('test:hook', { after: () => calls.push('second') }) + + expect(Hook.emit('test:hook', 'after')).toBe('primary') + expect(calls).toEqual(['on', 'first', 'second']) + }) + + it('is not replaced by a later on() registration', () => { + const listener = vi.fn() + + Hook.listen('test:hook', { after: listener }) + Hook.on('test:hook', { after: vi.fn() }) + Hook.emit('test:hook', 'after') + + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('awaits async callbacks in order', async () => { + const calls: string[] = [] + + Hook.listen('test:hook', { + after: async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + calls.push('slow') + }, + }) + Hook.listen('test:hook', { after: () => calls.push('fast') }) + + await Hook.emit('test:hook', 'after') + + expect(calls).toEqual(['slow', 'fast']) + }) + + it('returns a function that removes the listener', () => { + const listener = vi.fn() + const dispose = Hook.listen('test:hook', { after: listener }) + + dispose() + Hook.emit('test:hook', 'after') + + expect(listener).not.toHaveBeenCalled() + expect(Hook.has('test:hook')).toBe(false) + }) + + it('counts for has() and is removed by off()', () => { + const listener = vi.fn() + const other = vi.fn() + + Hook.listen('test:hook', { after: listener, before: other }) + + expect(Hook.has('test:hook', 'after')).toBe(true) + + Hook.off('test:hook', 'after') + Hook.emit('test:hook', 'after') + Hook.emit('test:hook', 'before') + + expect(listener).not.toHaveBeenCalled() + expect(other).toHaveBeenCalledTimes(1) + + Hook.off('test:hook') + + expect(Hook.has('test:hook')).toBe(false) + }) + }) }) diff --git a/packages/foundry/tests/plugins.test.ts b/packages/foundry/tests/plugins.test.ts new file mode 100644 index 00000000..9f9cc871 --- /dev/null +++ b/packages/foundry/tests/plugins.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ArkstackPlugin } from '../src/ArkstackPlugin' +import { Hook } from '../src/Hook' +import { Plugins } from '../src/Plugins' + +class StatusCommand {} + +class AuditPlugin extends ArkstackPlugin { + readonly name = 'audit' + readonly registered = vi.fn() + readonly started = vi.fn() + + hooks() { + return { + 'test:start': { after: this.started }, + } + } + + commands() { + return [StatusCommand] + } + + register() { + this.registered() + } +} + +class ExpressOnlyPlugin extends ArkstackPlugin { + readonly name = 'express-only' + readonly drivers = ['express'] +} + +describe('Plugins', () => { + beforeEach(() => { + Plugins.clear() + Hook.clear() + }) + + it('registers the plugin and its hook listeners once', () => { + const plugin = new AuditPlugin() + + Plugins.use(plugin, new AuditPlugin()) + Hook.emit('test:start', 'after', 'ok') + + expect(plugin.registered).toHaveBeenCalledTimes(1) + expect(plugin.started).toHaveBeenCalledWith('ok') + expect(Plugins.all()).toEqual([plugin]) + expect(Plugins.get('audit')).toBe(plugin) + }) + + it('keeps plugin listeners next to the app own hook', () => { + const plugin = new AuditPlugin() + const own = vi.fn() + + Plugins.use(plugin) + Hook.on('test:start', { after: own }) + Hook.emit('test:start', 'after') + + expect(own).toHaveBeenCalledTimes(1) + expect(plugin.started).toHaveBeenCalledTimes(1) + }) + + it('collects console commands from every plugin', () => { + Plugins.use(new AuditPlugin(), new ExpressOnlyPlugin()) + + expect(Plugins.commands()).toEqual([StatusCommand]) + }) + + it('removes a plugin and its listeners', () => { + const plugin = new AuditPlugin() + + Plugins.use(plugin) + Plugins.remove('audit') + Hook.emit('test:start', 'after') + + expect(Plugins.has('audit')).toBe(false) + expect(plugin.started).not.toHaveBeenCalled() + }) + + it('rejects a driver a plugin does not support', () => { + Plugins.use(new ExpressOnlyPlugin()) + + expect(() => Plugins.assertSupports('express')).not.toThrow() + expect(() => Plugins.assertSupports('h3')).toThrow('express-only (express)') + }) +}) diff --git a/templates/express-inertia/src/core/app.ts b/templates/express-inertia/src/core/app.ts index 8129b712..4b8b48b1 100644 --- a/templates/express-inertia/src/core/app.ts +++ b/templates/express-inertia/src/core/app.ts @@ -46,17 +46,11 @@ export default class Application extends Arkstack { * @param defer Set to true to skip server startup */ public async boot(port: number, defer = false) { - // Load public assets - await this.driver.mountPublicAssets(this.app, path.join(Arkstack.rootDir(), 'public')) - - // Apply all middleware - await this.driver.applyMiddleware(this.app, config('middleware') as any) - - // Bind the router - await this.driver.bindRouter(this.app) - - // Error Handler - await this.driver.registerErrorHandler?.(this.app) + // Load public assets, middleware, routes and the error handler + await this.driver.prepare(this.app, { + publicPath: path.join(Arkstack.rootDir(), 'public'), + middleware: config('middleware') as any, + }) // Start the server if (defer !== true) { diff --git a/templates/express/src/core/app.ts b/templates/express/src/core/app.ts index 8129b712..4b8b48b1 100644 --- a/templates/express/src/core/app.ts +++ b/templates/express/src/core/app.ts @@ -46,17 +46,11 @@ export default class Application extends Arkstack { * @param defer Set to true to skip server startup */ public async boot(port: number, defer = false) { - // Load public assets - await this.driver.mountPublicAssets(this.app, path.join(Arkstack.rootDir(), 'public')) - - // Apply all middleware - await this.driver.applyMiddleware(this.app, config('middleware') as any) - - // Bind the router - await this.driver.bindRouter(this.app) - - // Error Handler - await this.driver.registerErrorHandler?.(this.app) + // Load public assets, middleware, routes and the error handler + await this.driver.prepare(this.app, { + publicPath: path.join(Arkstack.rootDir(), 'public'), + middleware: config('middleware') as any, + }) // Start the server if (defer !== true) { diff --git a/templates/fastify/src/core/app.ts b/templates/fastify/src/core/app.ts index 939e4fef..1cadf712 100644 --- a/templates/fastify/src/core/app.ts +++ b/templates/fastify/src/core/app.ts @@ -54,17 +54,11 @@ export default class Application extends Arkstack { * @param defer Set to true to skip server startup */ public async boot(port: number, defer = false) { - // Load public assets - await this.driver.mountPublicAssets(this.app, 'public') - - // Apply all middleware - await this.driver.applyMiddleware(this.app, config('middleware') as never) - - // Bind the router - await this.driver.bindRouter(this.app) + // Load public assets, middleware, routes and the error handler + await this.driver.prepare(this.app, { + publicPath: 'public', + middleware: config('middleware') as never, + }) // Start the server if (defer !== true) {