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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down
40 changes: 20 additions & 20 deletions create-arkstack/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,26 @@

/** The default workspace catalog (`catalog:`). */
export const catalog: Record<string, string> = {
"@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:<name>`). */
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {},
Expand Down
4 changes: 2 additions & 2 deletions docs/guide/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')
},
Expand All @@ -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

Expand Down
103 changes: 89 additions & 14 deletions docs/guide/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')
},
Expand All @@ -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<void>
}
}
}
```

## 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.
66 changes: 1 addition & 65 deletions packages/common/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---

Expand Down
Loading
Loading