From af5165ae9c81ce77f36175a026c44d322ac5019f Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 21 Oct 2026 01:34:22 +0100 Subject: [PATCH 1/2] feat(foundry): Hook on/off/emit, single registry, server:start hook Hook API - `Hook.on(name, positions)` registers (merging positions), `Hook.off(name?, pos?)` removes, and `Hook.emit(name, pos, ...args)` calls a position and returns its result (a Promise for async hooks). `emit` is a no-op when nothing is registered, so call sites drop the `if (Hook.has(...))` guard. `Hook.all()` lists hooks. - `get()` only retrieves now. Deprecated aliases keep working: `set` -> `on`, `unset` -> `off`, `getAll` -> `all`, and `get(name, pos, ...args)` (which called the hook) -> `emit`. server:start hook - The Express and H3 drivers emit `server:start` once listening, after the startup banner and any tunnel: `after` always, then `tunneled` only when a tunnel is open. Payloads are `ServerStartInfo` / `TunneledServerStartInfo` from @arkstack/contract (host, port, scheme, secure, browsable url, tunnelUrl). `tunnelUrl` is the URL the tunnel actually opened. A failing listener is logged, never stops the server, and doesn't skip the other position. Fixes - @arkstack/common shipped its own copy of Hook, a second registry, so hooks registered through one import never fired for code reading the other. The Express auth middleware read common's, so `middleware:auth` hooks registered as documented (via foundry) never ran on Express. common now re-exports foundry's Hook and types. Typed hook names must be declared by augmenting HookRegistry on @arkstack/foundry. - `shutdown` checked for a `before` hook but called `after`. - `unset(name)` for an unregistered name cleared every hook. - H3's auth middleware now awaits its hooks, as Express already did. - A failing Express `after` middleware exited the process: Logger.error exits by default, and the post-response handler didn't pass false. --- CHANGELOG.md | 9 ++ docs/architecture/overview.md | 4 +- docs/guide/authentication.md | 4 +- docs/guide/hooks.md | 103 +++++++++++-- packages/common/README.md | 66 +------- packages/common/src/Hook.ts | 111 +------------- packages/common/src/lifecycle.ts | 2 +- packages/common/src/network.ts | 4 +- packages/common/src/types.ts | 42 ++---- packages/common/tests/hook.test.ts | 10 ++ packages/contract/src/Arkstack.ts | 2 +- packages/contract/src/global.d.ts | 23 +-- packages/contract/src/kits.ts | 19 +++ packages/driver-express/src/ExpressDriver.ts | 53 ++++++- packages/driver-express/src/app.d.ts | 11 +- .../driver-express/src/middlewares/auth.ts | 16 +- .../tests/after-middleware.test.ts | 27 ++++ .../tests/server-start-hook.test.ts | 141 ++++++++++++++++++ packages/driver-h3/src/H3Driver.ts | 47 +++++- packages/driver-h3/src/app.d.ts | 11 +- packages/driver-h3/src/middlewares/auth.ts | 6 +- .../driver-h3/tests/server-start-hook.test.ts | 129 ++++++++++++++++ packages/foundry/README.md | 36 +++-- packages/foundry/src/Hook.ts | 97 ++++++++---- packages/foundry/src/types.ts | 4 + packages/foundry/tests/hook.test.ts | 98 ++++++++++++ packages/view/src/View.ts | 2 +- 27 files changed, 766 insertions(+), 311 deletions(-) create mode 100644 packages/driver-express/tests/server-start-hook.test.ts create mode 100644 packages/driver-h3/tests/server-start-hook.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 539c2e6d..99292ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format follows semantic versioning principles. ### Added +- `Hook.emit(name, pos, ...args)` calls a hook position and returns its result (a Promise for async hooks); it is a no-op when nothing is registered, so call sites drop the `if (Hook.has(...))` guard. `Hook.on()`, `Hook.off()` and `Hook.all()` are the new names for `set()`, `unset()` and `getAll()`. +- `server:start` hook, run by the Express and H3 drivers once the server is listening, after the startup banner and any tunnel. `after` always runs and receives `ServerStartInfo` (exported from `@arkstack/contract`): `host`, `port`, `scheme`, `secure`, a browsable `url`, and `tunnelUrl` (`TUNNEL_URL`, or the URL `ark dev -t` opened). `tunneled` runs next, only when a tunnel is open, with `TunneledServerStartInfo` (where `tunnelUrl` is always a string). A failing listener is logged and never stops the server or skips the other position. - `ark queue:work --queue` accepts a comma-separated priority list (`--queue=webhook,ocr,default`) and the `*` wildcard for every queue on the connection (`--queue=*`, or `--queue=webhook,*` to put some first). `Worker.runNextJob()` and `daemon({ queue })` take a string or a list. `QueueContract.popAny()` backs the wildcard: the `database` driver takes the oldest due job across all queues, and the `redis` driver discovers queues with `SCAN` and works them round-robin. A bare `queue:work` still works only the default queue. - `transports.pusher.public_host` / `public_port` / `public_use_tls` (`PUSHER_PUBLIC_*`) set the Pusher-protocol server address handed to browsers when it differs from the one the app uses, such as soketi inside Docker or behind a TLS proxy. Each defaults to its server-side counterpart. - `RealtimeConfig.importModule` loads the transport SDKs (`pusher-js`, `firebase/*`) for every transport and for device tokens, so pages without a bundler can load them from a CDN. Defaults to a dynamic `import()`. The `createPusherTransport`, `createFirebaseTransport`, `createFirebaseDatabaseTransport` and `requestDeviceToken` helpers accept it as an extra argument. @@ -20,6 +22,8 @@ The format follows semantic versioning principles. ### Changed +- `@arkstack/common` re-exports `Hook` and its types from `@arkstack/foundry` instead of shipping its own copy. Declare typed hook names by augmenting `HookRegistry` on `@arkstack/foundry`; an augmentation of `@arkstack/common` no longer has any effect. +- Deprecated `Hook.set()`, `Hook.unset()`, `Hook.getAll()`, and calling a hook through `Hook.get(name, pos, ...args)`; they keep working. `on()` accepts a partial set of positions for typed hooks. - `env()` now reads a value as a number only when the number says exactly what was written. `Number()` accepts hexadecimal, binary and octal literals, exponents, surrounding whitespace, and integers wider than a double can hold, and coercing those loses information silently. `'6379'` is still a number; `'0x1f'`, `'007'`, `'1e5'`, `' 12 '`, `'0b1010'`, `'Infinity'` and `'12345678901234567890'` are now the strings they were written as. ### Docs @@ -28,6 +32,11 @@ The format follows semantic versioning principles. ### Fixed +- `@arkstack/common` and `@arkstack/foundry` each had their own `Hook` registry, so hooks registered through one import never fired for code reading the other. The Express auth middleware read `common`'s, so `middleware:auth` hooks registered as documented (via `foundry`) never ran on Express. +- The `shutdown` hook checked for a `before` position but called `after`; it now runs `before`. +- `Hook.unset(name)` / `remove(name)` with a name that wasn't registered cleared every hook. +- H3's auth middleware now awaits `middleware:auth` hooks, as Express already did. +- A failing Express `after` middleware exited the process: `Logger.error` exits by default, and the post-response handler didn't pass `exit = false`. - `ark queue:work --queue` rejected every value in 0.19.5 ("argument '…' is invalid. Allowed choices are …"): musket reads a trailing `: a, b, c` in an option's description as its allowed choices, and the new `--queue` description had one. The description no longer does, and a test pins that `queue:work` options declare no choices. - `DotPath` (the key type behind `config()` and `configure()`) is now depth-bounded and stops at built-in object types, so configs holding deep or self-referencing library types (nodemailer transports) no longer fail to typecheck with "circularly references itself". The `@ts-expect-error` workarounds in the H3 template and the notifications config are gone. - `build:templates` builds the templates one at a time; run in parallel, each template's prebuild rebuilt the shared packages concurrently and the builds clobbered each other. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index c12b5934..8fb70b34 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -150,12 +150,12 @@ For example, an authenticated route uses driver middleware to validate the beare ### Hooks -`Hook` from `@arkstack/foundry` provides named callback positions. +`Hook` from `@arkstack/foundry` provides named callback positions (`@arkstack/common` re-exports the same registry). Callers fire a position with `Hook.emit(name, pos, ...args)`, which is a no-op when nothing is registered. The auth middleware exposes `middleware:auth`, with a runtime-agnostic context shape: ```ts -Hook.set('middleware:auth', { +Hook.on('middleware:auth', { before: ({ req, res }) => {}, after: ({ req, res }) => {}, error: (error, { req, res }) => {}, diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index fecf58ba..6c6253e7 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -156,7 +156,7 @@ The hook contract is the same across runtime drivers: middleware context is pass ```ts import { Hook } from '@arkstack/foundry' -Hook.set('middleware:auth', { +Hook.on('middleware:auth', { before: ({ req, res }) => { console.log('auth middleware starting') }, @@ -177,7 +177,7 @@ Hook positions receive: - `after({ req, res })` after the token is authorized and `req.user`, `req.authUser`, and `req.authToken` are attached. - `error(error, { req, res })` when authentication throws before the error is passed back to the active runtime. -Calling `Hook.set('middleware:auth', ...)` again merges the supplied positions with existing ones, so packages can add an `error` hook without replacing an existing `before` hook. +Each position is awaited, so it may be async. Calling `Hook.on('middleware:auth', ...)` again merges the supplied positions with existing ones, so packages can add an `error` hook without replacing an existing `before` hook. ## Protect Route Groups diff --git a/docs/guide/hooks.md b/docs/guide/hooks.md index 4c868b46..8d44aa6b 100644 --- a/docs/guide/hooks.md +++ b/docs/guide/hooks.md @@ -2,14 +2,16 @@ Arkstack exposes a small shared hook registry through `@arkstack/foundry`. Hooks are useful when a package or application wants to register callbacks around named extension points without coupling the caller to a concrete implementation. -## Register Hooks +There is one registry per process. `@arkstack/common` re-exports the same `Hook`, so a hook registered through either import is seen by code using the other. -Use `Hook.set(name, positions)` to register callbacks. The built-in positions are `before` and `after`, and applications can use custom position names when a package documents them. +## Listen To Hooks + +Use `Hook.on(name, positions)` to register callbacks. The built-in positions are `before` and `after`, and applications can use custom position names when a package documents them. ```ts import { Hook } from '@arkstack/foundry' -Hook.set('middleware:auth', { +Hook.on('middleware:auth', { before: (context) => { context.logger?.debug('auth middleware starting') }, @@ -19,40 +21,113 @@ Hook.set('middleware:auth', { }) ``` -Calling `set()` again for the same hook name merges positions and only overwrites the positions supplied in the new registration. +Registering the same hook name again merges positions and only overwrites the positions supplied in the new registration. ```ts -Hook.set('middleware:auth', { +Hook.on('middleware:auth', { error: (error) => { console.error(error) }, }) ``` -## Read And Run Hooks +## Emit Hooks -Use `Hook.has()` to check for a hook and `Hook.get()` to retrieve either the full hook object or one position. +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. + +```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. ```ts const beforeAuth = Hook.get('middleware:auth', 'before') -beforeAuth?.({ logger }) +Hook.has('middleware:auth', 'after') // true | false ``` You can also read every registered hook: ```ts -const hooks = Hook.getAll() +const hooks = Hook.all() +``` + +## Stop Listening + +Remove a single position, a whole hook, or every registered hook: + +```ts +Hook.off('middleware:auth', 'before') +Hook.off('middleware:auth') +Hook.clear() // or Hook.off() with no arguments +``` + +Removing a name that isn't registered does nothing. + +## Typed Hooks + +Augment `HookRegistry` on `@arkstack/foundry` to type a hook's positions and arguments. `on()` and `emit()` then check them. + +```ts +declare module '@arkstack/foundry' { + interface HookRegistry { + 'billing:charge': { + after: (amount: number) => void | Promise + } + } +} ``` -## Remove Hooks +Augment `@arkstack/foundry`, not `@arkstack/common`: `common` only re-exports these types, and an augmentation there declares a separate, unused interface. -Unset a single hook, a single position, or every registered hook: +## 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)` | + +### Server start + +Every driver runs `server:start` (`after`) once its server is listening, after the startup banner and once any tunnel is open. It fires in development and production alike. ```ts -Hook.unset('middleware:auth', 'before') -Hook.unset('middleware:auth') -Hook.clear() +import { Hook } from '@arkstack/foundry' + +Hook.on('server:start', { + after: ({ url }) => { + console.log(`ready at ${url}`) + }, +}) ``` +`ServerStartInfo` (exported from `@arkstack/contract`) carries `host`, `port`, `scheme`, `secure`, a browsable local `url` (`localhost` when bound to every interface), and `tunnelUrl`: the `TUNNEL_URL` you set, or the URL `ark dev -t` opened. + +To react only when the server is reachable through a tunnel, use the `tunneled` position. It runs after `after`, only when a tunnel is open, and its `TunneledServerStartInfo` payload guarantees `tunnelUrl` is a string: + +```ts +Hook.on('server:start', { + tunneled: async ({ tunnelUrl }) => { + await registerWebhook(`${tunnelUrl}/api/webhooks`) + }, +}) +``` + +A listener that throws or rejects is logged and never stops the server; a failing `after` doesn't prevent `tunneled` from running. + +## Deprecated Names + +| Deprecated | Use | +| --------------------------------------------- | ------------------------------- | +| `Hook.set()` | `Hook.on()` | +| `Hook.get(name, pos, ...args)` (calling form) | `Hook.emit(name, pos, ...args)` | +| `Hook.unset()` | `Hook.off()` | +| `Hook.getAll()` | `Hook.all()` | + Hooks are process-local. Register them during application boot or test setup so their lifecycle is explicit. diff --git a/packages/common/README.md b/packages/common/README.md index 4d1415c2..d6d5df05 100644 --- a/packages/common/README.md +++ b/packages/common/README.md @@ -439,71 +439,7 @@ RequestException.abortIf(!user.isActive, 'Account is suspended', 403) **`src/Hook.ts`** -A global, named hook registry for extending Arkstack internals without modifying core code. Hooks are keyed by name and support positional slots (`before`, `after`, or any custom string). - -#### `Hook.set(name, hook)` - -Registers a hook. Multiple calls for the same name are merged. - -```ts -import { Hook } from '@arkstack/common' - -Hook.set('request:handle', { - before: (ctx) => console.log('before handler'), - after: (ctx) => console.log('after handler'), -}) -``` - ---- - -#### `Hook.get(name, pos?)` - -Retrieves the full hook object or a specific positional handler. - -```ts -const hook = Hook.get('request:handle') // IHook | undefined -const before = Hook.get('request:handle', 'before') // function | undefined -``` - ---- - -#### `Hook.has(name, pos?)` - -Checks whether a hook (or a specific position within it) exists. - -```ts -Hook.has('request:handle') // true | false -Hook.has('request:handle', 'after') // true | false -``` - ---- - -#### `Hook.unset(name?, pos?)` - -Removes a hook or a single positional handler. If the hook becomes empty after removal, it is deleted entirely. Called with no arguments, it delegates to `Hook.clear()`. - -```ts -Hook.unset('request:handle', 'before') // removes only the 'before' handler -Hook.unset('request:handle') // removes the entire hook -Hook.unset() // clears all hooks -``` - ---- - -#### `Hook.getAll()` - -Returns all registered hooks as a plain record. - -```ts -const hooks = Hook.getAll() -// { 'request:handle': { before: fn, after: fn } } -``` - ---- - -#### `Hook.clear()` - -Clears all registered hooks. +Re-exports `Hook` from `@arkstack/foundry`; both imports are the same process-wide registry. See the [`@arkstack/foundry` README](../foundry/README.md#hook) for the API (`on`, `emit`, `get`, `has`, `off`, `all`, `clear`). Declare typed hook names by augmenting `HookRegistry` on `@arkstack/foundry`. --- diff --git a/packages/common/src/Hook.ts b/packages/common/src/Hook.ts index 25695271..790388de 100644 --- a/packages/common/src/Hook.ts +++ b/packages/common/src/Hook.ts @@ -1,107 +1,4 @@ -import { HookArgs, HookFor, HookName, HookPos, HookPositions, HookRegistry, IHook } from './types' - -export class Hook { - private static hooks = new Map() - - /** - * Hooks define code that should run within defined boundaries to add extra functionalities. - * - * @param name - * @param value - */ - static set(name: N | (string & {}), hook: HookFor) { - const oldhook = this.hooks.get(name) ?? {} - - this.hooks.set(name, { - ...oldhook, - ...hook, - }) - } - - /** - * Check if a hook is defined by name - * - * @param name - */ - static has>(name: N | (string & {}), pos?: P) { - if (pos && this.hooks.has(name)) return Boolean(this.get(name, pos)) - - return this.hooks.has(name) - } - - /** - * Retrieve a defined hook by name - * - * @param name - */ - static get(name: N): HookFor | undefined - /** - * Retrieve a defined hook by name and position - * - * @param name - * @param pos - */ - static get>(name: N, pos: P): HookPos | undefined - /** - * Retrieve a defined hook by name and position the set args for callback - * - * @param name - * @param pos - */ - static get>(name: N, pos: P, ...args: HookArgs): void - static get>( - name: N, - pos?: P, - ...args: HookArgs | any[] - ): HookFor | HookPos | undefined { - const hook = this.hooks.get(name) - - if (!hook) return undefined - if (pos === undefined) return hook as HookFor - - const fn = hook[pos] - if (!fn) return undefined - - if (args.length > 0) return fn(...args) as HookPos - - return fn as HookPos - } - - /** - * Retrieve all defined hooks - * - * @param name - */ - static getAll = () => { - const hooks: Record & HookRegistry = {} - - for (const [name, value] of this.hooks) hooks[name] = value - - return hooks - } - - /** - * Remove the defined hook - * - * @param name - */ - static unset>(name?: N | (string & {}), pos?: P) { - if (name && this.hooks.has(name)) { - if (pos !== undefined) { - const hook = this.get(name) - if (hook?.[pos]) delete hook[pos] - - return Object.keys(hook ?? {}).length < 1 ? void this.hooks.delete(name) : undefined - } - - this.hooks.delete(name) - } else this.clear() - } - - /** - * Clear all the defined hooks - */ - static clear = () => { - this.hooks.clear() - } -} +// `Hook` is a single process-wide registry owned by @arkstack/foundry. This +// package used to ship its own copy (a second, separate registry), so hooks +// registered through one import never reached code reading the other. +export { Hook } from '@arkstack/foundry' diff --git a/packages/common/src/lifecycle.ts b/packages/common/src/lifecycle.ts index 19f5ebc2..259e7011 100644 --- a/packages/common/src/lifecycle.ts +++ b/packages/common/src/lifecycle.ts @@ -5,7 +5,7 @@ export const bindGracefulShutdown = (shutdown: () => Promise | void, defer ;['SIGINT', 'SIGTERM', 'SIGQUIT'].forEach((signal) => { process.on(signal, async () => { - if (Hook.has('shutdown', 'before')) Hook.get('shutdown', 'after', {}) + await Hook.emit('shutdown', 'before', {}) await shutdown() }) }) diff --git a/packages/common/src/network.ts b/packages/common/src/network.ts index 2873c954..13777508 100644 --- a/packages/common/src/network.ts +++ b/packages/common/src/network.ts @@ -24,7 +24,7 @@ export const bootWithDetectedPort = async { const port = await detect(preferredPort) - if (Hook.has('boot', 'before')) Hook.get('boot', 'before', port, app) + await Hook.emit('boot', 'before', port, app) if (app && !globalThis.app) globalThis.app = () => app as never globalThis.env = env @@ -42,7 +42,7 @@ export const bootWithDetectedPort = async await app?.shutdown(), defer) - if (Hook.has('boot', 'after')) Hook.get('boot', 'after', port, app) + await Hook.emit('boot', 'after', port, app) } /** The `view()` global registered by `@arkstack/view`. */ diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 9a656916..e49d8dc2 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -254,36 +254,18 @@ export interface ArkstackErrorPayload { [key: string]: unknown } -// Augmentable Hooks Registry -export interface HookRegistry {} - -type Position = 'before' | 'after' | (string & {}) - -export type IHook = { - [P in Position]?: (...args: any[]) => void -} - -// Derive available hook names -export type HookName = keyof HookRegistry extends never ? string : keyof HookRegistry | (string & {}) - -// Derive the IHook type for a given name -export type HookFor = N extends keyof HookRegistry ? HookRegistry[N] : IHook - -export type HookPos = N extends keyof HookRegistry - ? P extends keyof HookRegistry[N] - ? HookRegistry[N][P] - : (...args: any[]) => void - : (...args: any[]) => void - -export type HookPositions = N extends keyof HookRegistry ? keyof HookRegistry[N] : Position - -export type HookArgs = N extends keyof HookRegistry - ? P extends keyof HookRegistry[N] - ? HookRegistry[N][P] extends (...args: infer A) => any - ? A - : any[] - : any[] - : any[] +// Hook types live in @arkstack/foundry (one registry); re-exported for compatibility. +// Augment `HookRegistry` on '@arkstack/foundry', not here. +export type { + HookArgs, + HookFor, + HookName, + HookPos, + HookPositions, + HookRegistry, + HookResult, + IHook, +} from '@arkstack/foundry' export type Choice = { value: Value diff --git a/packages/common/tests/hook.test.ts b/packages/common/tests/hook.test.ts index bb7b7747..1707778b 100644 --- a/packages/common/tests/hook.test.ts +++ b/packages/common/tests/hook.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Hook as FoundryHook } from '@arkstack/foundry' import { Hook } from '../src/Hook' describe('Hook', () => { @@ -31,4 +32,13 @@ describe('Hook', () => { expect(Hook.get('middleware:auth', 'before')).toBe(nextBefore) expect(Hook.get('middleware:auth', 'after')).toBe(after) }) + + it('is the same registry as @arkstack/foundry (not a separate copy)', () => { + const before = vi.fn() + + expect(Hook).toBe(FoundryHook) + + FoundryHook.on('middleware:auth', { before }) + expect(Hook.get('middleware:auth', 'before')).toBe(before) + }) }) diff --git a/packages/contract/src/Arkstack.ts b/packages/contract/src/Arkstack.ts index b6771bec..7593e2ff 100644 --- a/packages/contract/src/Arkstack.ts +++ b/packages/contract/src/Arkstack.ts @@ -129,6 +129,6 @@ export abstract class Arkstack { return } - if (Hook.has('set:root-dir', 'after')) Hook.get('set:root-dir', 'after', dir) + Hook.emit('set:root-dir', 'after', dir) } } diff --git a/packages/contract/src/global.d.ts b/packages/contract/src/global.d.ts index efae49cc..d1707d12 100644 --- a/packages/contract/src/global.d.ts +++ b/packages/contract/src/global.d.ts @@ -1,4 +1,4 @@ -import { Arkstack } from '.' +import { Arkstack, ServerStartInfo, TunneledServerStartInfo } from '.' declare global { function app(): Arkstack @@ -8,18 +8,23 @@ declare global { } } -declare module '@arkstack/common' { - interface HookRegistry { - 'set:root-dir': { - after: (dir: string) => void - } - } -} - +// `Hook` and its registry live in @arkstack/foundry (@arkstack/common only +// re-exports them), so typed hook names are declared there. declare module '@arkstack/foundry' { interface HookRegistry { 'set:root-dir': { after: (dir: string) => void } + /** + * Fired by every driver once its server is listening (dev and production), + * after the startup banner and once any tunnel is open. + */ + 'server:start': { + after: (info: ServerStartInfo) => void | Promise + /** + * Only when the server is reachable through a tunnel; runs after `after`. + */ + tunneled: (info: TunneledServerStartInfo) => void | Promise + } } } diff --git a/packages/contract/src/kits.ts b/packages/contract/src/kits.ts index 2e11a047..6d1cc104 100644 --- a/packages/contract/src/kits.ts +++ b/packages/contract/src/kits.ts @@ -1,6 +1,25 @@ import { ArkstackMiddlewareConfig } from './http' import { PromiseOrValue } from './Arkstack' +/** + * What a driver passes to the `server:start` hook (`after` position) once its + * server is listening, and any tunnel is open. + */ +export interface ServerStartInfo { + /** The address the server is bound to, e.g. `0.0.0.0`. */ + host: string + port: number + scheme: 'http' | 'https' + secure: boolean + /** A browsable local URL (`localhost` when bound to every interface). */ + url: string + /** The public tunnel URL (`TUNNEL_URL`, or the one `ark dev -t` opened), if any. */ + tunnelUrl?: string +} + +/** What the `server:start` hook's `tunneled` position receives: a tunnel is always open. */ +export type TunneledServerStartInfo = ServerStartInfo & { tunnelUrl: string } + /** * The ArkstackKitDriver class defines the contract for a driver * that can be used with the ArkstackKitContract. diff --git a/packages/driver-express/src/ExpressDriver.ts b/packages/driver-express/src/ExpressDriver.ts index 151d40de..c00d40cc 100644 --- a/packages/driver-express/src/ExpressDriver.ts +++ b/packages/driver-express/src/ExpressDriver.ts @@ -7,11 +7,51 @@ import { defaultErrorHandler } from './error-handler' import { ExpressDriverOptions, Middleware, MiddlewareConfig } from './types' import { resolveMiddleware } from '@arkstack/http' import ngrok from '@ngrok/ngrok' +import { Hook } from '@arkstack/foundry' +import type { ServerStartInfo } from '@arkstack/contract' + +/** + * Run one `server:start` position. A failing listener is logged, never thrown: it + * must not take down a server that is already up, nor skip the next position. + * + * @param position + * @param run + */ +const emitServerStartPosition = async (position: string, run: () => unknown): Promise => { + try { + await run() + } catch (error) { + Logger.error( + `server:start ${position} hook failed: ${error instanceof Error ? error.message : String(error)}`, + false, + ) + } +} + +/** + * Run the `server:start` hook once the server is listening: `after` always, then + * `tunneled` when the server is reachable through a tunnel. + * + * @param info + */ +const emitServerStart = async (info: ServerStartInfo): Promise => { + await emitServerStartPosition('after', () => Hook.emit('server:start', 'after', info)) + + const { tunnelUrl } = info + if (tunnelUrl) { + await emitServerStartPosition('tunneled', () => Hook.emit('server:start', 'tunneled', { ...info, tunnelUrl })) + } +} /** * Build the "Server is running" startup lines, adding a local-network URL when * the server is bound to all interfaces (`0.0.0.0`/`::`) so it is reachable from * other devices. + * + * @param scheme + * @param host + * @param port + * @returns */ const startupLogLines = (scheme: string, host: string, port: number): string[] => { const bindsAll = host === '0.0.0.0' || host === '::' @@ -133,7 +173,7 @@ export class ExpressDriver extends ArkstackKitDriver { // The response is already sent, so `next` must not resume the router. res.once('finish', () => { Promise.resolve(entry(req, res, () => {})).catch((error) => { - Logger.error(error instanceof Error ? error.message : String(error)) + Logger.error(error instanceof Error ? error.message : String(error), false) }) }) next() @@ -180,7 +220,7 @@ export class ExpressDriver extends ArkstackKitDriver { const secure = env('APP_SECURE', false) === true const tunneled = env('TUNNEL', false) const tunnelUrl = env('TUNNEL_URL') - const scheme = secure ? 'https' : 'http' + const scheme: ServerStartInfo['scheme'] = secure ? 'https' : 'http' const onListen = async () => { let log = startupLogLines(scheme, host, port) @@ -227,6 +267,15 @@ export class ExpressDriver extends ArkstackKitDriver { } console.log(log.join('\n')) + + await emitServerStart({ + host, + port, + scheme, + secure, + url: `${scheme}://${host === '0.0.0.0' || host === '::' ? 'localhost' : host}:${port}`, + tunnelUrl: this.tunnel_url || undefined, + }) } if (secure) { diff --git a/packages/driver-express/src/app.d.ts b/packages/driver-express/src/app.d.ts index 21087218..1040898f 100644 --- a/packages/driver-express/src/app.d.ts +++ b/packages/driver-express/src/app.d.ts @@ -1,15 +1,6 @@ import type { Request, Response } from 'express' -declare module '@arkstack/common' { - interface HookRegistry { - 'middleware:auth': { - before: (ctx: { req: Request; res: Response }) => Promise - after: (ctx: { req: Request; res: Response }) => Promise - error: (error: unknown, ctx: { req: Request; res: Response }) => Promise - } - } -} - +// Typed hook names are declared on @arkstack/foundry, which owns `Hook`. declare module '@arkstack/foundry' { interface HookRegistry { 'middleware:auth': { diff --git a/packages/driver-express/src/middlewares/auth.ts b/packages/driver-express/src/middlewares/auth.ts index e81ff0eb..280280d4 100644 --- a/packages/driver-express/src/middlewares/auth.ts +++ b/packages/driver-express/src/middlewares/auth.ts @@ -1,13 +1,12 @@ import type { Handler, NextFunction, Request, Response } from 'express' -import { Hook } from '@arkstack/common' +import { Hook } from '@arkstack/foundry' export const auth: Handler = async (req, res, next) => { const { Auth, AuthenticationException } = await import('@arkstack/auth') try { - if (Hook.has('middleware:auth', 'before')) - await Promise.resolve(Hook.get('middleware:auth', 'before')?.({ req, res })) + await Hook.emit('middleware:auth', 'before', { req, res }) const token = readBearerToken(req.headers.authorization) @@ -27,18 +26,11 @@ export const auth: Handler = async (req, res, next) => { req.session = auth.session() req.authToken = token - if (Hook.has('middleware:auth', 'after')) - await Promise.resolve(Hook.get('middleware:auth', 'after')?.({ req, res })) + await Hook.emit('middleware:auth', 'after', { req, res }) next() } catch (error) { - if (Hook.has('middleware:auth', 'error')) - await Promise.resolve( - Hook.get('middleware:auth', 'error')?.(error, { - req, - res, - }), - ) + await Hook.emit('middleware:auth', 'error', error, { req, res }) next(error) } diff --git a/packages/driver-express/tests/after-middleware.test.ts b/packages/driver-express/tests/after-middleware.test.ts index 0e0c26e4..5b3d816d 100644 --- a/packages/driver-express/tests/after-middleware.test.ts +++ b/packages/driver-express/tests/after-middleware.test.ts @@ -32,4 +32,31 @@ describe('ExpressDriver after middleware', () => { expect(handler).toHaveBeenCalledOnce() expect(fallthrough).not.toHaveBeenCalled() }) + + it('logs a failing after middleware without exiting the process', async () => { + const app = express() + const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + let failed!: () => void + const done = new Promise((resolve) => (failed = resolve)) + const after = vi.fn(async () => { + setTimeout(failed, 0) + throw new Error('after blew up') + }) + + new ExpressDriver({ bindRouter: vi.fn() }).applyMiddleware(app, { + after: [after], + } as never) + app.get('/thing', (_req, res) => res.json({ ok: true })) + + const res = await request(app).get('/thing') + await done + + expect(res.status).toBe(200) + expect(errors.mock.calls.flat().join(' ')).toContain('after blew up') + expect(exit).not.toHaveBeenCalled() + + exit.mockRestore() + errors.mockRestore() + }) }) diff --git a/packages/driver-express/tests/server-start-hook.test.ts b/packages/driver-express/tests/server-start-hook.test.ts new file mode 100644 index 00000000..c6bdf303 --- /dev/null +++ b/packages/driver-express/tests/server-start-hook.test.ts @@ -0,0 +1,141 @@ +import type { ServerStartInfo } from '@arkstack/contract' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { Express } from 'express' +import { ExpressDriver } from '../src/index' +import { Hook } from '@arkstack/foundry' + +vi.mock('@ngrok/ngrok', () => ({ + default: { forward: vi.fn(async () => ({ url: () => 'https://opened.ngrok.app' })) }, +})) + +/** A fake app whose `listen` "binds" immediately, so no real port is opened. */ +const fakeApp = () => ({ listen: vi.fn((_port, _host, onListen: () => void) => onListen()) }) as unknown as Express + +/** Start the driver and resolve with what the `server:start` hook received. */ +const startAndCapture = (port = 4321) => + new Promise((resolve) => { + Hook.on('server:start', { after: (info) => resolve(info) }) + new ExpressDriver({ bindRouter: vi.fn() }).start(fakeApp(), port) + }) + +describe('ExpressDriver server:start hook', () => { + const saved = { ...process.env } + + beforeEach(() => { + Hook.clear() + vi.spyOn(console, 'log').mockImplementation(() => {}) + for (const key of ['APP_HOST', 'HOST', 'APP_SECURE', 'TUNNEL', 'TUNNEL_URL']) delete process.env[key] + }) + + afterEach(() => { + process.env = { ...saved } + vi.restoreAllMocks() + }) + + it('reports the bound address and a browsable local url', async () => { + const info = await startAndCapture(4321) + + expect(info).toEqual({ + host: '0.0.0.0', + port: 4321, + scheme: 'http', + secure: false, + url: 'http://localhost:4321', + tunnelUrl: undefined, + }) + }) + + it('passes the url of a tunnel it opened (not the unset TUNNEL_URL)', async () => { + process.env.TUNNEL = 'true' + + const info = await startAndCapture() + + expect(info.tunnelUrl).toBe('https://opened.ngrok.app') + }) + + it('passes a pre-set TUNNEL_URL through', async () => { + process.env.TUNNEL_URL = 'https://preset.example.com' + + const info = await startAndCapture() + + expect(info.tunnelUrl).toBe('https://preset.example.com') + }) + + it('logs a failing listener instead of crashing the started server', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + let settled!: () => void + const done = new Promise((resolve) => (settled = resolve)) + + Hook.on('server:start', { + after: async () => { + setTimeout(settled, 0) + throw new Error('listener blew up') + }, + }) + + await new ExpressDriver({ bindRouter: vi.fn() }).start(fakeApp(), 4321) + await done + + expect(errors.mock.calls.flat().join(' ')).toContain('listener blew up') + }) + + describe('tunneled position', () => { + /** Start the driver; resolve with the order positions ran and the tunneled payload. */ + const startAndWatch = () => + new Promise<{ order: string[]; tunneled?: ServerStartInfo }>((resolve) => { + const order: string[] = [] + Hook.on('server:start', { + after: () => { + order.push('after') + // No tunnel: `tunneled` never runs, so settle once `after` has. + if (!process.env.TUNNEL && !process.env.TUNNEL_URL) setTimeout(() => resolve({ order }), 0) + }, + tunneled: (info) => { + order.push('tunneled') + resolve({ order, tunneled: info }) + }, + }) + new ExpressDriver({ bindRouter: vi.fn() }).start(fakeApp(), 4321) + }) + + it('runs after `after` with the tunnel it opened', async () => { + process.env.TUNNEL = 'true' + + const { order, tunneled } = await startAndWatch() + + expect(order).toEqual(['after', 'tunneled']) + expect(tunneled?.tunnelUrl).toBe('https://opened.ngrok.app') + }) + + it('runs for a preset TUNNEL_URL', async () => { + process.env.TUNNEL_URL = 'https://preset.example.com' + + expect((await startAndWatch()).tunneled?.tunnelUrl).toBe('https://preset.example.com') + }) + + it('does not run without a tunnel', async () => { + const { order, tunneled } = await startAndWatch() + + expect(order).toEqual(['after']) + expect(tunneled).toBeUndefined() + }) + + it('still runs when `after` throws', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + process.env.TUNNEL = 'true' + + const tunneled = new Promise((resolve) => + Hook.on('server:start', { + after: () => { + throw new Error('after blew up') + }, + tunneled: ({ tunnelUrl }) => resolve(tunnelUrl), + }), + ) + new ExpressDriver({ bindRouter: vi.fn() }).start(fakeApp(), 4321) + + expect(await tunneled).toBe('https://opened.ngrok.app') + }) + }) +}) diff --git a/packages/driver-h3/src/H3Driver.ts b/packages/driver-h3/src/H3Driver.ts index 988c2238..47bd221b 100644 --- a/packages/driver-h3/src/H3Driver.ts +++ b/packages/driver-h3/src/H3Driver.ts @@ -3,11 +3,47 @@ import { H3, serve, toResponse } from 'h3' import { H3DriverOptions, H3Middleware, Middleware, MiddlewareConfig } from './types' import { Logger, devTlsCredentials, env, localNetworkAddress } from '@arkstack/common' +import { Hook } from '@arkstack/foundry' +import type { ServerStartInfo } from '@arkstack/contract' import { defaultErrorHandler } from './error-handler' import ngrok from '@ngrok/ngrok' import { resolveMiddleware } from '@arkstack/http' import { staticAssetHandler } from './middlewares' +/** + * Run one `server:start` position. A failing listener is logged, never thrown: it + * must not take down a server that is already up, nor skip the next position. + * + * @param position + * @param run + */ +const emitServerStartPosition = async (position: string, run: () => unknown): Promise => { + try { + await run() + } catch (error) { + Logger.error( + `server:start ${position} hook failed: ${error instanceof Error ? error.message : String(error)}`, + false, + ) + } +} + +/** + * Run the `server:start` hook once the server is listening: `after` always, then + * `tunneled` when the server is reachable through a tunnel. + * + * @param info + */ +const emitServerStart = async (info: ServerStartInfo): Promise => { + await emitServerStartPosition('after', () => Hook.emit('server:start', 'after', info)) + + const { tunnelUrl } = info + + if (tunnelUrl) { + await emitServerStartPosition('tunneled', () => Hook.emit('server:start', 'tunneled', { ...info, tunnelUrl })) + } +} + /** * Build the "Server is running" startup lines, adding a local-network URL when * the server is bound to all interfaces (`0.0.0.0`/`::`) so it is reachable from @@ -185,7 +221,7 @@ export class H3Driver extends ArkstackKitDriver { const secure = env('APP_SECURE', false) === true const tunneled = env('TUNNEL', false) const tunnelUrl = env('TUNNEL_URL') - const scheme = secure ? 'https' : 'http' + const scheme: ServerStartInfo['scheme'] = secure ? 'https' : 'http' // Dev HTTPS: serve with an in-memory self-signed certificate. const tls = secure ? await devTlsCredentials() : undefined @@ -241,5 +277,14 @@ export class H3Driver extends ArkstackKitDriver { } console.log(log.join('\n')) + + await emitServerStart({ + host, + port, + scheme, + secure, + url: `${scheme}://${host === '0.0.0.0' || host === '::' ? 'localhost' : host}:${port}`, + tunnelUrl: this.tunnel_url || undefined, + }) } } diff --git a/packages/driver-h3/src/app.d.ts b/packages/driver-h3/src/app.d.ts index 04ab8173..74e02421 100644 --- a/packages/driver-h3/src/app.d.ts +++ b/packages/driver-h3/src/app.d.ts @@ -1,15 +1,6 @@ import type { H3Event } from 'h3' -declare module '@arkstack/common' { - interface HookRegistry { - 'middleware:auth': { - before: (event: H3Event) => void - after: (event: H3Event) => void - error: (error: unknown, event: H3Event) => void - } - } -} - +// Typed hook names are declared on @arkstack/foundry, which owns `Hook`. declare module '@arkstack/foundry' { interface HookRegistry { 'middleware:auth': { diff --git a/packages/driver-h3/src/middlewares/auth.ts b/packages/driver-h3/src/middlewares/auth.ts index 0e9ee4a1..648b609a 100644 --- a/packages/driver-h3/src/middlewares/auth.ts +++ b/packages/driver-h3/src/middlewares/auth.ts @@ -19,7 +19,7 @@ export const auth = async (event: H3Event, next: () => unknown | Promise unknown | Promise ({ + default: { forward: vi.fn(async () => ({ url: () => 'https://opened.ngrok.app' })) }, +})) + +// `serve` "binds" immediately, so no real port is opened. +vi.mock('h3', async (original) => ({ + ...(await original()), + serve: vi.fn(() => ({ ready: async () => undefined })), +})) + +/** Start the driver and return what the `server:start` hook received. */ +const startAndCapture = async (port = 4321) => { + let received: ServerStartInfo | undefined + Hook.on('server:start', { after: (info) => void (received = info) }) + + // H3's start() awaits the hook, so it has run once start() resolves. + await new H3Driver({ bindRouter: vi.fn() } as never).start(new H3(), port) + + return received! +} + +describe('H3Driver server:start hook', () => { + const saved = { ...process.env } + + beforeEach(() => { + Hook.clear() + vi.spyOn(console, 'log').mockImplementation(() => {}) + for (const key of ['APP_HOST', 'HOST', 'APP_SECURE', 'TUNNEL', 'TUNNEL_URL']) delete process.env[key] + }) + + afterEach(() => { + process.env = { ...saved } + vi.restoreAllMocks() + }) + + it('reports the bound address and a browsable local url', async () => { + expect(await startAndCapture(4321)).toEqual({ + host: '0.0.0.0', + port: 4321, + scheme: 'http', + secure: false, + url: 'http://localhost:4321', + tunnelUrl: undefined, + }) + }) + + it('passes the url of a tunnel it opened (not the unset TUNNEL_URL)', async () => { + process.env.TUNNEL = 'true' + + expect((await startAndCapture()).tunnelUrl).toBe('https://opened.ngrok.app') + }) + + it('runs the hook after the startup banner', async () => { + const order: string[] = [] + vi.mocked(console.log).mockImplementation(() => void order.push('banner')) + Hook.on('server:start', { after: () => void order.push('hook') }) + + await new H3Driver({ bindRouter: vi.fn() } as never).start(new H3(), 4321) + + expect(order).toEqual(['banner', 'hook']) + }) + + it('logs a failing listener instead of rejecting start()', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + Hook.on('server:start', { + after: () => { + throw new Error('listener blew up') + }, + }) + + await expect(new H3Driver({ bindRouter: vi.fn() } as never).start(new H3(), 4321)).resolves.toBeUndefined() + expect(errors.mock.calls.flat().join(' ')).toContain('listener blew up') + }) + + describe('tunneled position', () => { + /** Start the driver (which awaits every position) and report what ran. */ + const startAndWatch = async () => { + const order: string[] = [] + let tunneled: ServerStartInfo | undefined + Hook.on('server:start', { + after: () => void order.push('after'), + tunneled: (info) => { + order.push('tunneled') + tunneled = info + }, + }) + + await new H3Driver({ bindRouter: vi.fn() } as never).start(new H3(), 4321) + + return { order, tunneled } + } + + it('runs after `after` with the tunnel it opened', async () => { + process.env.TUNNEL = 'true' + + const { order, tunneled } = await startAndWatch() + + expect(order).toEqual(['after', 'tunneled']) + expect(tunneled?.tunnelUrl).toBe('https://opened.ngrok.app') + }) + + it('does not run without a tunnel', async () => { + expect((await startAndWatch()).order).toEqual(['after']) + }) + + it('still runs when `after` throws', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + process.env.TUNNEL_URL = 'https://preset.example.com' + let tunnelUrl: string | undefined + Hook.on('server:start', { + after: () => { + throw new Error('after blew up') + }, + tunneled: (info) => void (tunnelUrl = info.tunnelUrl), + }) + + await new H3Driver({ bindRouter: vi.fn() } as never).start(new H3(), 4321) + + expect(tunnelUrl).toBe('https://preset.example.com') + }) + }) +}) diff --git a/packages/foundry/README.md b/packages/foundry/README.md index 1edb6810..c75a1b23 100644 --- a/packages/foundry/README.md +++ b/packages/foundry/README.md @@ -16,24 +16,32 @@ pnpm add @arkstack/foundry **`src/Hook.ts`** -A global, named hook registry for extending Arkstack internals without modifying core code. Hooks are keyed by name and support positional slots (`before`, `after`, or any custom string). +A global, named hook registry for extending Arkstack internals without modifying core code. Hooks are keyed by name and support positional slots (`before`, `after`, or any custom string). There is one registry per process: `@arkstack/common` re-exports this same `Hook`. -#### `Hook.set(name, hook)` +#### `Hook.on(name, positions)` -Registers a hook. Multiple calls for the same name are merged. +Registers callbacks for a hook's positions. Registering the same name again merges: only the positions supplied are added or replaced. ```ts import { Hook } from '@arkstack/foundry' -Hook.set('request:handle', { +Hook.on('request:handle', { before: (ctx) => console.log('before handler'), after: (ctx) => console.log('after handler'), }) ``` +#### `Hook.emit(name, pos, ...args)` + +Calls a position with the given arguments and returns its result: a Promise for an async hook, so it can be awaited. When nothing is registered there it does nothing and returns `undefined`, so callers need no `has()` check. + +```ts +await Hook.emit('request:handle', 'before', ctx) +``` + #### `Hook.get(name, pos?)` -Retrieves the full hook object or a specific positional handler. +Retrieves the full hook object or one position's callback, without calling it. ```ts const hook = Hook.get('request:handle') // IHook | undefined @@ -49,25 +57,29 @@ Hook.has('request:handle') // true | false Hook.has('request:handle', 'after') // true | false ``` -#### `Hook.unset(name?, pos?)` +#### `Hook.off(name?, pos?)` -Removes a hook or a single positional handler. If the hook becomes empty after removal, it is deleted entirely. Called with no arguments, it delegates to `Hook.clear()`. +Removes a hook or a single position. A hook left with no positions is deleted. Called with no arguments it clears every hook; a name that isn't registered is a no-op. ```ts -Hook.unset('request:handle', 'before') // removes only the 'before' handler -Hook.unset('request:handle') // removes the entire hook -Hook.unset() // clears all hooks +Hook.off('request:handle', 'before') // removes only the 'before' callback +Hook.off('request:handle') // removes the entire hook +Hook.off() // clears all hooks ``` -#### `Hook.getAll()` +#### `Hook.all()` Returns all registered hooks as a plain record. ```ts -const hooks = Hook.getAll() +const hooks = Hook.all() // { 'request:handle': { before: fn, after: fn } } ``` #### `Hook.clear()` Clears all registered hooks. + +#### Deprecated aliases + +`set()` → `on()`, `unset()` → `off()`, `getAll()` → `all()`. `get(name, pos, ...args)` with arguments still calls the hook, but use `emit()`; `get()` should only retrieve. diff --git a/packages/foundry/src/Hook.ts b/packages/foundry/src/Hook.ts index 25695271..9ccf869b 100644 --- a/packages/foundry/src/Hook.ts +++ b/packages/foundry/src/Hook.ts @@ -1,34 +1,66 @@ -import { HookArgs, HookFor, HookName, HookPos, HookPositions, HookRegistry, IHook } from './types' +import { HookArgs, HookFor, HookName, HookPos, HookPositions, HookRegistry, HookResult, IHook } from './types' export class Hook { private static hooks = new Map() /** - * Hooks define code that should run within defined boundaries to add extra functionalities. + * Register callbacks for a hook's positions. Registering the same name again + * merges: only the positions supplied are added or replaced. * * @param name - * @param value + * @param hook */ - static set(name: N | (string & {}), hook: HookFor) { + static on(name: N | (string & {}), hook: Partial>) { const oldhook = this.hooks.get(name) ?? {} this.hooks.set(name, { ...oldhook, - ...hook, + ...(hook as IHook), }) } /** - * Check if a hook is defined by name + * @deprecated Use {@link Hook.on}; `set` merges rather than replaces. * * @param name + * @param hook + */ + static set(name: N | (string & {}), hook: Partial>) { + this.on(name, hook) + } + + /** + * Check if a hook is defined by name, or a position of it. + * + * @param name + * @param pos */ static has>(name: N | (string & {}), pos?: P) { - if (pos && this.hooks.has(name)) return Boolean(this.get(name, pos)) + if (pos && this.hooks.has(name)) return Boolean(this.hooks.get(name)?.[pos as string]) return this.hooks.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. + * + * @param name + * @param pos + * @param args + */ + static emit>( + name: N, + pos: P, + ...args: HookArgs + ): HookResult | undefined { + const fn = this.hooks.get(name)?.[pos as string] + + return fn ? (fn(...args) as HookResult) : undefined + } + /** * Retrieve a defined hook by name * @@ -36,43 +68,38 @@ export class Hook { */ static get(name: N): HookFor | undefined /** - * Retrieve a defined hook by name and position + * Retrieve a defined hook's position by name and position * * @param name * @param pos */ - static get>(name: N, pos: P): HookPos | undefined + static get>(name: N, pos: P): HookPos | undefined /** - * Retrieve a defined hook by name and position the set args for callback + * @deprecated Use {@link Hook.emit} to call a hook; `get` with arguments calls it. * * @param name * @param pos + * @param args */ - static get>(name: N, pos: P, ...args: HookArgs): void + static get>(name: N, pos: P, ...args: HookArgs): void static get>( name: N, pos?: P, - ...args: HookArgs | any[] - ): HookFor | HookPos | undefined { + ...args: HookArgs | any[] + ): HookFor | HookPos | undefined { const hook = this.hooks.get(name) if (!hook) return undefined if (pos === undefined) return hook as HookFor + if (args.length > 0) return void this.emit(name, pos, ...(args as HookArgs)) - const fn = hook[pos] - if (!fn) return undefined - - if (args.length > 0) return fn(...args) as HookPos - - return fn as HookPos + return hook[pos as string] as HookPos | undefined } /** * Retrieve all defined hooks - * - * @param name */ - static getAll = () => { + static all = () => { const hooks: Record & HookRegistry = {} for (const [name, value] of this.hooks) hooks[name] = value @@ -81,21 +108,37 @@ export class Hook { } /** - * Remove the defined hook + * @deprecated Use {@link Hook.all}. + */ + static getAll = () => this.all() + + /** + * Remove a hook, or one position of it. With no name, removes every hook. * * @param name + * @param pos */ - static unset>(name?: N | (string & {}), pos?: P) { + static off>(name?: N | (string & {}), pos?: P) { if (name && this.hooks.has(name)) { if (pos !== undefined) { - const hook = this.get(name) - if (hook?.[pos]) delete hook[pos] + 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 } this.hooks.delete(name) - } else this.clear() + } else if (!name) this.clear() + } + + /** + * @deprecated Use {@link Hook.off}. + * + * @param name + * @param pos + */ + static unset>(name?: N | (string & {}), pos?: P) { + this.off(name, pos) } /** diff --git a/packages/foundry/src/types.ts b/packages/foundry/src/types.ts index 1ab491dd..c4c4f06b 100644 --- a/packages/foundry/src/types.ts +++ b/packages/foundry/src/types.ts @@ -28,3 +28,7 @@ export type HookArgs = N extends keyof HookR : any[] : any[] : any[] + +// What running a hook position returns (a Promise for async hooks) +export type HookResult = + HookPos extends (...args: any[]) => infer R ? R : unknown diff --git a/packages/foundry/tests/hook.test.ts b/packages/foundry/tests/hook.test.ts index bb7b7747..cc97aa16 100644 --- a/packages/foundry/tests/hook.test.ts +++ b/packages/foundry/tests/hook.test.ts @@ -31,4 +31,102 @@ describe('Hook', () => { expect(Hook.get('middleware:auth', 'before')).toBe(nextBefore) expect(Hook.get('middleware:auth', 'after')).toBe(after) }) + + describe('emit', () => { + it('calls the position with its arguments and returns the result', () => { + Hook.on('test:hook', { after: (a: number, b: number) => a + b }) + + expect(Hook.emit('test:hook', 'after', 2, 3)).toBe(5) + }) + + it('calls a zero-argument hook (which get() could never trigger)', () => { + const after = vi.fn() + Hook.on('test:hook', { after }) + + Hook.emit('test:hook', 'after') + + expect(after).toHaveBeenCalledTimes(1) + // get() without arguments still only retrieves it. + expect(Hook.get('test:hook', 'after')).toBe(after) + expect(after).toHaveBeenCalledTimes(1) + }) + + it('returns the promise of an async hook so callers can await it', async () => { + const order: string[] = [] + Hook.on('test:hook', { + after: async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + order.push('hook') + }, + }) + + await Hook.emit('test:hook', 'after') + order.push('caller') + + expect(order).toEqual(['hook', 'caller']) + }) + + it('is a no-op returning undefined when the hook or position is missing', () => { + expect(Hook.emit('test:missing', 'after')).toBeUndefined() + + Hook.on('test:hook', { before: vi.fn() }) + expect(Hook.emit('test:hook', 'after')).toBeUndefined() + }) + }) + + describe('compatibility aliases', () => { + it('set() registers like on()', () => { + const before = vi.fn() + Hook.set('test:hook', { before }) + + expect(Hook.get('test:hook', 'before')).toBe(before) + }) + + it('get() with arguments still calls the hook (deprecated)', () => { + const after = vi.fn() + Hook.on('test:hook', { after }) + + Hook.get('test:hook', 'after', 'value') + + expect(after).toHaveBeenCalledWith('value') + }) + + it('getAll() and unset() mirror all() and off()', () => { + Hook.on('test:hook', { before: vi.fn() }) + expect(Object.keys(Hook.getAll())).toEqual(['test:hook']) + + Hook.unset('test:hook') + expect(Hook.has('test:hook')).toBe(false) + }) + }) + + describe('off', () => { + it('removes one position and drops the hook once it is empty', () => { + Hook.on('test:hook', { before: vi.fn(), after: vi.fn() }) + + Hook.off('test:hook', 'before') + expect(Hook.has('test:hook', 'before')).toBe(false) + expect(Hook.has('test:hook', 'after')).toBe(true) + + Hook.off('test:hook', 'after') + expect(Hook.has('test:hook')).toBe(false) + }) + + it('leaves other hooks alone when the named hook is not registered', () => { + Hook.on('test:keep', { after: vi.fn() }) + + Hook.off('test:missing') + + expect(Hook.has('test:keep')).toBe(true) + }) + + it('clears every hook when called with no name', () => { + Hook.on('test:a', { after: vi.fn() }) + Hook.on('test:b', { after: vi.fn() }) + + Hook.off() + + expect(Object.keys(Hook.all())).toEqual([]) + }) + }) }) diff --git a/packages/view/src/View.ts b/packages/view/src/View.ts index 5aaeebe0..63e66408 100644 --- a/packages/view/src/View.ts +++ b/packages/view/src/View.ts @@ -9,7 +9,7 @@ export class View { private static usesDefaultFactoryRoot = true static { - Hook.set('set:root-dir', { + Hook.on('set:root-dir', { after: () => { if (View.usesDefaultFactoryRoot) View.factory = new ViewFactory() }, From ee31e839aee2233e3ffbc9e122efb2a26b78797c Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 21 Oct 2026 01:34:54 +0100 Subject: [PATCH 2/2] style(catalog): standardize quotes in catalog entries --- create-arkstack/src/catalog.ts | 40 +++++++++++++++++----------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/create-arkstack/src/catalog.ts b/create-arkstack/src/catalog.ts index bb535e95..c6c701f8 100644 --- a/create-arkstack/src/catalog.ts +++ b/create-arkstack/src/catalog.ts @@ -4,26 +4,26 @@ /** The default workspace catalog (`catalog:`). */ export const catalog: Record = { - "@h3ravel/musket": "^2.2.13", - "@h3ravel/shared": "^2.2.7", - "@h3ravel/support": "^2.2.7", - "@h3ravel/collect.js": "^5.4.0", - "@faker-js/faker": "^10.4.0", - "@arkormx/plugin-clear-router": "^0.1.57", - "@resora/plugin-clear-router": "^1.0.72", - "@kanun-hq/plugin-file": "^0.1.12", - "@kanun-hq/plugin-phone": "^0.1.8", - "arkormx": "^2.12.8", - "clear-router": "^2.9.3", - "dotenv": "^17.4.2", - "express": "^5.2.1", - "h3": "^2.0.1-rc.22", - "kysely": "^0.29.2", - "kanun": "^1.2.0", - "parasito": "^0.2.6", - "pg": "^8.22.0", - "resora": "^1.3.34", - "tsdown": "^0.22.14" + '@h3ravel/musket': '^2.2.13', + '@h3ravel/shared': '^2.2.7', + '@h3ravel/support': '^2.2.7', + '@h3ravel/collect.js': '^5.4.0', + '@faker-js/faker': '^10.4.0', + '@arkormx/plugin-clear-router': '^0.1.57', + '@resora/plugin-clear-router': '^1.0.72', + '@kanun-hq/plugin-file': '^0.1.12', + '@kanun-hq/plugin-phone': '^0.1.8', + arkormx: '^2.12.8', + 'clear-router': '^2.9.3', + dotenv: '^17.4.2', + express: '^5.2.1', + h3: '^2.0.1-rc.22', + kysely: '^0.29.2', + kanun: '^1.2.0', + parasito: '^0.2.6', + pg: '^8.22.0', + resora: '^1.3.34', + tsdown: '^0.22.14', } /** Named workspace catalogs (`catalog:`). */