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
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/express-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 25 additions & 10 deletions docs/guide/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,31 @@ 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 })
```

## 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')
Expand All @@ -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')
Expand Down Expand Up @@ -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

Expand Down
133 changes: 133 additions & 0 deletions docs/guide/plugins.md
Original file line number Diff line number Diff line change
@@ -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<HealthPlugin>('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 <name>`.
14 changes: 14 additions & 0 deletions docs/guide/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions docs/more/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/common/src/Plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ArkstackPlugin, Plugins } from '@arkstack/foundry'
1 change: 1 addition & 0 deletions packages/common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
12 changes: 11 additions & 1 deletion packages/console-slim/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -94,6 +103,7 @@ export const runConsoleKernel = async (options: RunConsoleOptions = {}) => {
DevCommand,
BuildCommand,
MakeCommand,
...(Plugins.commands() as Array<typeof Command>),
...userCommands,
],
discoveryPaths: [join(Arkstack.rootDir(), 'node_modules', '@arkstack/*', 'dist', 'commands', '*.js')],
Expand Down
2 changes: 2 additions & 0 deletions packages/console/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
initializeGlobalContext,
loadPrototypes,
outputDir,
Plugins,
rebuildOutput,
} from '@arkstack/common'
import { existsSync, realpathSync } from 'node:fs'
Expand Down Expand Up @@ -116,6 +117,7 @@ export const runConsoleKernel = async (options: RunConsoleOptions = {}) => {
MakeCommand,
KeyGenerateCommand,
PublishCommand,
...(Plugins.commands() as Array<typeof Command>),
...userCommands,
],
discoveryPaths: [join(Arkstack.rootDir(), 'node_modules', '@arkstack/*', 'dist', 'commands', '*.js')],
Expand Down
14 changes: 13 additions & 1 deletion packages/contract/src/Arkstack.ts
Original file line number Diff line number Diff line change
@@ -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> = T | Promise<T>

Expand Down Expand Up @@ -41,6 +41,18 @@ export abstract class Arkstack<TApp, TRoutes = unknown, THandler = unknown> {
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.
*
Expand Down
13 changes: 12 additions & 1 deletion packages/contract/src/global.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Arkstack, ServerStartInfo, TunneledServerStartInfo } from '.'
import { Arkstack, DriverPrepareContext, ServerStartInfo, TunneledServerStartInfo } from '.'

declare global {
function app(): Arkstack<never, never>
Expand All @@ -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<void>
/** After the app middleware, just before routes are bound. */
router: (context: DriverPrepareContext) => void | Promise<void>
/** After routes and the error handler are in place. */
after: (context: DriverPrepareContext) => void | Promise<void>
}
/**
* Fired by every driver once its server is listening (dev and production),
* after the startup banner and once any tunnel is open.
Expand Down
Loading
Loading