@shaferllc/keel 0.66.0 → 0.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +169 -0
- package/README.md +44 -6
- package/bin/keel-mcp.mjs +9 -0
- package/dist/api/api.config.stub +9 -0
- package/dist/api/config.d.ts +13 -0
- package/dist/api/config.js +14 -0
- package/dist/api/index.d.ts +16 -0
- package/dist/api/index.js +13 -0
- package/dist/api/provider.d.ts +10 -0
- package/dist/api/provider.js +17 -0
- package/dist/api/query.d.ts +35 -0
- package/dist/api/query.js +42 -0
- package/dist/api/resource.d.ts +91 -0
- package/dist/api/resource.js +188 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +8 -2
- package/dist/core/cache.d.ts +81 -5
- package/dist/core/cache.js +188 -23
- package/dist/core/cli/stubs.d.ts +26 -0
- package/dist/core/cli/stubs.js +225 -0
- package/dist/core/console-prompt.d.ts +79 -0
- package/dist/core/console-prompt.js +239 -0
- package/dist/core/console-ui.d.ts +96 -0
- package/dist/core/console-ui.js +187 -0
- package/dist/core/console.d.ts +188 -0
- package/dist/core/console.js +395 -0
- package/dist/core/database.d.ts +70 -1
- package/dist/core/database.js +174 -15
- package/dist/core/env.d.ts +96 -0
- package/dist/core/env.js +140 -0
- package/dist/core/events.d.ts +129 -5
- package/dist/core/events.js +165 -7
- package/dist/core/health.d.ts +141 -0
- package/dist/core/health.js +226 -0
- package/dist/core/helpers.d.ts +9 -3
- package/dist/core/helpers.js +11 -3
- package/dist/core/http/kernel.d.ts +2 -0
- package/dist/core/http/kernel.js +48 -0
- package/dist/core/http/router.d.ts +5 -5
- package/dist/core/http/router.js +5 -5
- package/dist/core/i18n.d.ts +162 -0
- package/dist/core/i18n.js +472 -0
- package/dist/core/index.d.ts +39 -13
- package/dist/core/index.js +19 -6
- package/dist/core/instrumentation.d.ts +113 -0
- package/dist/core/instrumentation.js +52 -0
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +89 -4
- package/dist/core/logger.js +167 -22
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/notification.js +10 -1
- package/dist/core/package.d.ts +120 -0
- package/dist/core/package.js +169 -0
- package/dist/core/pages.d.ts +108 -0
- package/dist/core/pages.js +199 -0
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +325 -14
- package/dist/core/repl.d.ts +33 -0
- package/dist/core/repl.js +88 -0
- package/dist/core/scheduler.js +6 -0
- package/dist/core/social.d.ts +4 -4
- package/dist/core/social.js +4 -4
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +299 -7
- package/dist/core/telemetry.d.ts +208 -0
- package/dist/core/telemetry.js +383 -0
- package/dist/core/template.d.ts +2 -3
- package/dist/core/template.js +2 -3
- package/dist/core/testing.d.ts +170 -1
- package/dist/core/testing.js +504 -2
- package/dist/db/d1.js +13 -0
- package/dist/db/pg.d.ts +13 -0
- package/dist/db/pg.js +46 -4
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/dist/openapi/config.d.ts +28 -0
- package/dist/openapi/config.js +25 -0
- package/dist/openapi/doc.d.ts +40 -0
- package/dist/openapi/doc.js +20 -0
- package/dist/openapi/export.d.ts +8 -0
- package/dist/openapi/export.js +19 -0
- package/dist/openapi/gate.d.ts +15 -0
- package/dist/openapi/gate.js +27 -0
- package/dist/openapi/index.d.ts +19 -0
- package/dist/openapi/index.js +15 -0
- package/dist/openapi/openapi.config.stub +29 -0
- package/dist/openapi/provider.d.ts +18 -0
- package/dist/openapi/provider.js +35 -0
- package/dist/openapi/routes.d.ts +9 -0
- package/dist/openapi/routes.js +23 -0
- package/dist/openapi/spec.d.ts +23 -0
- package/dist/openapi/spec.js +132 -0
- package/dist/openapi/ui.d.ts +8 -0
- package/dist/openapi/ui.js +31 -0
- package/dist/openapi/zod.d.ts +12 -0
- package/dist/openapi/zod.js +46 -0
- package/dist/watch/config.d.ts +33 -0
- package/dist/watch/config.js +38 -0
- package/dist/watch/entry.d.ts +53 -0
- package/dist/watch/entry.js +105 -0
- package/dist/watch/gate.d.ts +20 -0
- package/dist/watch/gate.js +32 -0
- package/dist/watch/index.d.ts +21 -0
- package/dist/watch/index.js +17 -0
- package/dist/watch/migration.d.ts +7 -0
- package/dist/watch/migration.js +20 -0
- package/dist/watch/provider.d.ts +22 -0
- package/dist/watch/provider.js +58 -0
- package/dist/watch/prune.d.ts +11 -0
- package/dist/watch/prune.js +20 -0
- package/dist/watch/recorder.d.ts +24 -0
- package/dist/watch/recorder.js +39 -0
- package/dist/watch/routes.d.ts +13 -0
- package/dist/watch/routes.js +55 -0
- package/dist/watch/store.d.ts +54 -0
- package/dist/watch/store.js +158 -0
- package/dist/watch/ui/dist/watch.css +1 -0
- package/dist/watch/ui/dist/watch.js +555 -0
- package/dist/watch/ui-shell.d.ts +12 -0
- package/dist/watch/ui-shell.js +24 -0
- package/dist/watch/watch.config.stub +47 -0
- package/dist/watch/watchers.d.ts +12 -0
- package/dist/watch/watchers.js +156 -0
- package/docs/ai-manifest.json +3206 -0
- package/docs/ai.md +128 -0
- package/docs/api-resources.md +118 -0
- package/docs/architecture.md +331 -0
- package/docs/authentication.md +453 -0
- package/docs/authorization.md +167 -0
- package/docs/broadcasting.md +137 -0
- package/docs/broker.md +500 -0
- package/docs/cache.md +558 -0
- package/docs/configuration.md +385 -0
- package/docs/console.md +536 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +631 -0
- package/docs/debugging.md +129 -0
- package/docs/decorators.md +127 -0
- package/docs/errors.md +395 -0
- package/docs/events.md +496 -0
- package/docs/examples/architecture-app.ts +27 -0
- package/docs/examples/authentication.ts +61 -0
- package/docs/examples/authorization.ts +79 -0
- package/docs/examples/broadcasting.ts +60 -0
- package/docs/examples/broker-cache-validate.ts +34 -0
- package/docs/examples/broker-fault-tolerance.ts +29 -0
- package/docs/examples/broker-middleware.ts +27 -0
- package/docs/examples/broker.ts +203 -0
- package/docs/examples/cache.ts +222 -0
- package/docs/examples/configuration.ts +121 -0
- package/docs/examples/console.ts +134 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +208 -0
- package/docs/examples/debugging.ts +41 -0
- package/docs/examples/decorators.ts +40 -0
- package/docs/examples/errors.ts +121 -0
- package/docs/examples/events.ts +204 -0
- package/docs/examples/factories.ts +84 -0
- package/docs/examples/hashing.ts +71 -0
- package/docs/examples/health.ts +94 -0
- package/docs/examples/helpers.ts +171 -0
- package/docs/examples/hooks.ts +54 -0
- package/docs/examples/i18n.ts +117 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +166 -0
- package/docs/examples/mail.ts +263 -0
- package/docs/examples/middleware.ts +119 -0
- package/docs/examples/migrations.ts +126 -0
- package/docs/examples/models.ts +239 -0
- package/docs/examples/notification.ts +124 -0
- package/docs/examples/pages.ts +82 -0
- package/docs/examples/providers.ts +123 -0
- package/docs/examples/queues.ts +254 -0
- package/docs/examples/rate-limiting.ts +42 -0
- package/docs/examples/redis.ts +99 -0
- package/docs/examples/request-response.ts +197 -0
- package/docs/examples/routing.ts +186 -0
- package/docs/examples/scheduling.ts +62 -0
- package/docs/examples/sessions.ts +102 -0
- package/docs/examples/static-files.ts +63 -0
- package/docs/examples/storage.ts +132 -0
- package/docs/examples/telemetry.ts +127 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +215 -0
- package/docs/examples/transformer.ts +141 -0
- package/docs/examples/transformers.ts +49 -0
- package/docs/examples/url-builder.ts +86 -0
- package/docs/examples/validation.ts +102 -0
- package/docs/examples/views.tsx +62 -0
- package/docs/examples/vite.ts +106 -0
- package/docs/factories.md +166 -0
- package/docs/getting-started.md +290 -0
- package/docs/hashing.md +259 -0
- package/docs/health.md +225 -0
- package/docs/helpers.md +347 -0
- package/docs/hono.md +186 -0
- package/docs/hooks.md +118 -0
- package/docs/i18n.md +302 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +436 -0
- package/docs/mail.md +751 -0
- package/docs/middleware.md +425 -0
- package/docs/migrations.md +476 -0
- package/docs/models.md +810 -0
- package/docs/notifications.md +474 -0
- package/docs/openapi.md +111 -0
- package/docs/packages.md +118 -0
- package/docs/pages.md +217 -0
- package/docs/providers.md +363 -0
- package/docs/queues.md +679 -0
- package/docs/rate-limiting.md +155 -0
- package/docs/redis.md +178 -0
- package/docs/request-response.md +953 -0
- package/docs/routing.md +804 -0
- package/docs/scheduling.md +110 -0
- package/docs/security.md +85 -0
- package/docs/sessions.md +354 -0
- package/docs/social-auth.md +174 -0
- package/docs/static-files.md +211 -0
- package/docs/storage.md +452 -0
- package/docs/telemetry.md +263 -0
- package/docs/templates.md +314 -0
- package/docs/testing.md +376 -0
- package/docs/transformers.md +381 -0
- package/docs/url-builder.md +295 -0
- package/docs/validation.md +288 -0
- package/docs/views.md +267 -0
- package/docs/vite.md +434 -0
- package/docs/watch.md +118 -0
- package/llms-full.txt +19816 -0
- package/llms.txt +127 -0
- package/package.json +42 -7
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Broadcasting
|
|
2
|
+
|
|
3
|
+
Push events to clients in real time over named **channels**. Like the database
|
|
4
|
+
and mail layers, broadcasting rides a pluggable `Broadcaster`, so the core owns no
|
|
5
|
+
socket — point it at Pusher/Ably (`fetch`), a Cloudflare Durable Object, or the
|
|
6
|
+
built-in `MemoryBroadcaster` for tests and single-instance workers.
|
|
7
|
+
|
|
8
|
+
## Broadcasting an event
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { broadcast } from "@shaferllc/keel/core";
|
|
12
|
+
|
|
13
|
+
await broadcast("orders.42", "status", { state: "shipped" });
|
|
14
|
+
await broadcast(["team.7", "admins"], "deploy", { sha }); // several channels
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`broadcast(channels, event, payload)` hands the event to the registered
|
|
18
|
+
broadcaster. Register one at boot:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { setBroadcaster } from "@shaferllc/keel/core";
|
|
22
|
+
setBroadcaster(pusher(env.PUSHER_KEY, env.PUSHER_SECRET));
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Channel authorization
|
|
26
|
+
|
|
27
|
+
Public channels need nothing. **Private** and **presence** channels are gated:
|
|
28
|
+
register who may subscribe with `channelAuth`, then have your socket endpoint call
|
|
29
|
+
`authorizeChannel`. `{param}` segments are captured from the channel name:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { channelAuth, authorizeChannel } from "@shaferllc/keel/core";
|
|
33
|
+
|
|
34
|
+
// only the order's owner may subscribe:
|
|
35
|
+
channelAuth("orders.{orderId}", (user, params) => user.id === Number(params.orderId));
|
|
36
|
+
|
|
37
|
+
// presence: return member data to join
|
|
38
|
+
channelAuth("presence.room.{room}", (user, params) => ({ id: user.id, name: user.name }));
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
At the subscription endpoint (the URL your client hits to authorize a channel):
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
router.post("/broadcasting/auth", async () => {
|
|
45
|
+
const { channel } = await request.all();
|
|
46
|
+
const ok = await authorizeChannel(channel, await auth().user());
|
|
47
|
+
if (!ok) response.abort("Forbidden", 403);
|
|
48
|
+
return json(ok); // `true`, or member data for presence
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Return `false` to deny, `true` to allow, or an object of **member data** to allow
|
|
53
|
+
*and* join a presence channel. It composes with [`auth()`](./authentication.md)
|
|
54
|
+
and [authorization](./authorization.md).
|
|
55
|
+
|
|
56
|
+
## Same-process fan-out
|
|
57
|
+
|
|
58
|
+
`MemoryBroadcaster` also lets you `subscribe` in-process — useful inside a
|
|
59
|
+
Cloudflare Durable Object (the WebSocket owner) or an SSE loop:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { MemoryBroadcaster } from "@shaferllc/keel/core";
|
|
63
|
+
|
|
64
|
+
const bus = new MemoryBroadcaster();
|
|
65
|
+
setBroadcaster(bus);
|
|
66
|
+
|
|
67
|
+
const off = bus.subscribe("orders.42", (event, payload) => socket.send(JSON.stringify({ event, payload })));
|
|
68
|
+
// … later
|
|
69
|
+
off();
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Writing a driver
|
|
73
|
+
|
|
74
|
+
A broadcaster is one method — `publish`. Here's the shape for a Pusher-style HTTP
|
|
75
|
+
provider over `fetch` (edge-safe):
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import type { Broadcaster } from "@shaferllc/keel/core";
|
|
79
|
+
|
|
80
|
+
const pusher = (url: string, auth: string): Broadcaster => ({
|
|
81
|
+
async publish(channels, event, payload) {
|
|
82
|
+
await fetch(url, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "content-type": "application/json", authorization: auth },
|
|
85
|
+
body: JSON.stringify({ channels, name: event, data: JSON.stringify(payload) }),
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
For Cloudflare, the driver forwards to a Durable Object that owns the WebSockets;
|
|
92
|
+
the DO uses a `MemoryBroadcaster` internally to fan out to its connected sockets.
|
|
93
|
+
|
|
94
|
+
## API reference
|
|
95
|
+
|
|
96
|
+
### `broadcast(channels, event, payload?)`
|
|
97
|
+
|
|
98
|
+
`broadcast(channels: string | string[], event: string, payload?: unknown): Promise<void>`
|
|
99
|
+
|
|
100
|
+
Publish an event to one or more channels via the registered broadcaster.
|
|
101
|
+
|
|
102
|
+
### `setBroadcaster(instance)` / `getBroadcaster()`
|
|
103
|
+
|
|
104
|
+
Register / read the default `Broadcaster`.
|
|
105
|
+
|
|
106
|
+
### `MemoryBroadcaster`
|
|
107
|
+
|
|
108
|
+
`class MemoryBroadcaster implements Broadcaster` — in-process pub/sub; the default.
|
|
109
|
+
`subscribe(channel, cb)` returns an unsubscribe function.
|
|
110
|
+
|
|
111
|
+
### `channelAuth(pattern, authorizer)`
|
|
112
|
+
|
|
113
|
+
`channelAuth(pattern: string, authorizer: (user, params) => boolean | object | Promise<…>): void`
|
|
114
|
+
|
|
115
|
+
Register an authorizer for a channel pattern. `{param}` segments are captured into
|
|
116
|
+
`params`.
|
|
117
|
+
|
|
118
|
+
### `authorizeChannel(channel, user)`
|
|
119
|
+
|
|
120
|
+
`authorizeChannel(channel: string, user: unknown): Promise<boolean | Record<string, unknown>>`
|
|
121
|
+
|
|
122
|
+
Run the first matching rule (a channel with no rule is public → `true`). Returns
|
|
123
|
+
`false` (deny), `true` (allow), or member data (presence).
|
|
124
|
+
|
|
125
|
+
### Interfaces & types
|
|
126
|
+
|
|
127
|
+
#### `Broadcaster`
|
|
128
|
+
|
|
129
|
+
`interface Broadcaster { publish(channels: string[], event: string, payload: unknown): Promise<void> }`
|
|
130
|
+
|
|
131
|
+
#### `ChannelAuthorizer`
|
|
132
|
+
|
|
133
|
+
`type ChannelAuthorizer = (user: unknown, params: Record<string, string>) => boolean | Record<string, unknown> | Promise<…>`
|
|
134
|
+
|
|
135
|
+
#### `Subscriber`
|
|
136
|
+
|
|
137
|
+
`type Subscriber = (event: string, payload: unknown, channel: string) => void`
|
package/docs/broker.md
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
# Service Broker
|
|
2
|
+
|
|
3
|
+
Structure an application as **services** that talk to each other by name instead
|
|
4
|
+
of by import. You register a service — a name plus a bag of `actions` and
|
|
5
|
+
`events` — with a **broker**, then reach it anywhere with
|
|
6
|
+
`broker.call("users.get", { id })` or fan an event out with
|
|
7
|
+
`broker.emit("user.created", user)`. It's a [Moleculer](https://moleculer.services/docs/0.15/broker)-style
|
|
8
|
+
backbone: actions receive a `Context` and can call *other* actions through it, so
|
|
9
|
+
a request threads its `meta` (auth, trace ids) all the way down.
|
|
10
|
+
|
|
11
|
+
Like the queue and Redis layers, clustering lives behind a pluggable seam. The
|
|
12
|
+
default `LocalTransporter` is a single-node no-op, so the core imports no network
|
|
13
|
+
client and runs on Node and the edge. Swap in a real `Transporter` to span
|
|
14
|
+
processes — the `call` / `emit` API never changes.
|
|
15
|
+
|
|
16
|
+
## Defining a service
|
|
17
|
+
|
|
18
|
+
A service is a schema object. Handlers and lifecycle hooks run with `this` bound
|
|
19
|
+
to the live service, so they can reach `this.settings`, `this.metadata`,
|
|
20
|
+
`this.broker`, `this.logger`, and any `methods` you define.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { broker, type Context } from "@shaferllc/keel/core";
|
|
24
|
+
|
|
25
|
+
broker().createService({
|
|
26
|
+
name: "users",
|
|
27
|
+
settings: { defaultRole: "member" },
|
|
28
|
+
metadata: { region: "us-east" }, // descriptive; travels with discovery
|
|
29
|
+
actions: {
|
|
30
|
+
async get(this: any, ctx: Context<{ id: number }>) {
|
|
31
|
+
return { id: ctx.params.id, role: this.settings.defaultRole };
|
|
32
|
+
},
|
|
33
|
+
async create(ctx: Context<{ email: string }>) {
|
|
34
|
+
const user = { id: 1, email: ctx.params.email };
|
|
35
|
+
await ctx.emit("user.created", user); // inherits ctx.meta
|
|
36
|
+
return user;
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
events: {
|
|
40
|
+
"user.created": (ctx: Context) => {
|
|
41
|
+
// ctx.params is the event payload
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Calling actions
|
|
48
|
+
|
|
49
|
+
An action is addressed as `"<service>.<action>"`. Give a service a `version` and
|
|
50
|
+
its actions namespace under a `v`-prefix (`v2.users.get`).
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const user = await broker().call("users.create", { email: "ada@keel.dev" });
|
|
54
|
+
|
|
55
|
+
// pass metadata that flows down through nested ctx.call()s
|
|
56
|
+
await broker().call("users.get", { id: 1 }, { meta: { locale: "en" } });
|
|
57
|
+
|
|
58
|
+
// bound a call with a timeout (ms); rejects with RequestTimeoutError
|
|
59
|
+
await broker().call("reports.build", {}, { timeout: 5000 });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Inside an action, use `ctx.call(...)` rather than `broker().call(...)` — it
|
|
63
|
+
carries the current `meta` (and `requestID`) into the child call automatically.
|
|
64
|
+
|
|
65
|
+
Call several actions at once with `mcall` — pass an array or a keyed map and get
|
|
66
|
+
the same shape back. With `settled: true` you get a per-call
|
|
67
|
+
`{ status, value | reason }` instead of failing on the first rejection.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
const [a, b] = await broker().mcall([
|
|
71
|
+
{ action: "users.get", params: { id: 1 } },
|
|
72
|
+
{ action: "users.get", params: { id: 2 } },
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const { profile, posts } = await broker().mcall({
|
|
76
|
+
profile: { action: "users.get", params: { id: 1 } },
|
|
77
|
+
posts: { action: "posts.byUser", params: { id: 1 } },
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### The call context
|
|
82
|
+
|
|
83
|
+
Every handler receives a `Context`. Beyond `params`, `meta`, and `call`, it
|
|
84
|
+
carries a few request-scoped slots:
|
|
85
|
+
|
|
86
|
+
- **`ctx.meta`** — flows *down* into nested `ctx.call()`s. Put auth, locale, and
|
|
87
|
+
trace data here.
|
|
88
|
+
- **`ctx.headers`** — per-call and **transient**: available to this handler and
|
|
89
|
+
its hooks, but *not* propagated to nested calls.
|
|
90
|
+
- **`ctx.locals`** — scratch space shared between a call's hooks and its handler
|
|
91
|
+
(e.g. a hook looks up the current user, the handler reads it back).
|
|
92
|
+
- **`ctx.requestID`** — one correlation id for the whole request tree; generated
|
|
93
|
+
once and threaded through every nested call. Pass your own to stitch a call
|
|
94
|
+
into an existing trace.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
await broker().call("reports.build", {}, {
|
|
98
|
+
headers: { "x-trace": "abc" },
|
|
99
|
+
requestID: "req-42",
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Each `ctx.call()` builds a child context linked to its parent, so a handler can
|
|
104
|
+
see where it sits in the request tree:
|
|
105
|
+
|
|
106
|
+
- **`ctx.id`** — unique per context; **`ctx.parentID`** — the caller's `id`
|
|
107
|
+
(`null` at the root).
|
|
108
|
+
- **`ctx.level`** — call depth, `1` at the root and `+1` per nested call.
|
|
109
|
+
- **`ctx.caller`** — the full name of the service that invoked this call (`null`
|
|
110
|
+
at the root).
|
|
111
|
+
- **`ctx.action`** — `{ name }` of the running action (absent in event handlers).
|
|
112
|
+
- **`ctx.toJSON()`** — a serializable snapshot (ids, level, caller, name, meta) —
|
|
113
|
+
no functions or live `broker`/`service` refs, so it is safe to log.
|
|
114
|
+
|
|
115
|
+
In an **event** handler the context instead carries **`ctx.eventName`**,
|
|
116
|
+
**`ctx.eventType`** (`"emit"` or `"broadcast"`), and **`ctx.eventGroups`**.
|
|
117
|
+
|
|
118
|
+
## Full action definitions
|
|
119
|
+
|
|
120
|
+
An action is a bare handler by default. Swap in an object to attach per-action
|
|
121
|
+
options — `visibility`, a `timeout`, and `hooks`:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
broker().createService({
|
|
125
|
+
name: "billing",
|
|
126
|
+
actions: {
|
|
127
|
+
// shorthand — a plain handler
|
|
128
|
+
quote: (ctx: Context) => ({ cents: 999 }),
|
|
129
|
+
|
|
130
|
+
// full form
|
|
131
|
+
charge: {
|
|
132
|
+
visibility: "private", // hidden from broker.call — internal only
|
|
133
|
+
timeout: 3000, // per-action; the call option still overrides it
|
|
134
|
+
hooks: {
|
|
135
|
+
before: (ctx) => { /* validate */ },
|
|
136
|
+
after: (ctx, res) => res,
|
|
137
|
+
},
|
|
138
|
+
handler: (ctx: Context<{ cents: number }>) => ctx.params.cents,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Visibility
|
|
145
|
+
|
|
146
|
+
`visibility` controls how far an action reaches:
|
|
147
|
+
|
|
148
|
+
| Value | Reachable via `broker.call` / `ctx.call` | Internally (`this.actions.x`) |
|
|
149
|
+
| ------------------------ | ---------------------------------------- | ----------------------------- |
|
|
150
|
+
| `published` *(default)* | yes | yes |
|
|
151
|
+
| `public` | yes | yes |
|
|
152
|
+
| `protected` | yes (same node) | yes |
|
|
153
|
+
| `private` | **no** — throws `ServiceNotFoundError` | yes |
|
|
154
|
+
|
|
155
|
+
A `private` action is invisible to `call`, but a service can still invoke its
|
|
156
|
+
own private actions through `this.actions.<name>(params)`, which runs the full
|
|
157
|
+
pipeline (hooks and timeout) while skipping the visibility gate.
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
actions: {
|
|
161
|
+
charge: { visibility: "private", handler: (ctx) => /* ... */ },
|
|
162
|
+
checkout(this: any, ctx: Context) {
|
|
163
|
+
return this.actions.charge({ cents: ctx.params.cents }); // ok — internal
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Hooks
|
|
169
|
+
|
|
170
|
+
Hooks wrap action handlers to keep validation, sanitisation, and response
|
|
171
|
+
shaping out of the handler body. Declare them at the **service** level (keyed by
|
|
172
|
+
action name) or inline on a single **action**.
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
broker().createService({
|
|
176
|
+
name: "users",
|
|
177
|
+
hooks: {
|
|
178
|
+
before: {
|
|
179
|
+
"*": (ctx) => { /* runs before every action */ },
|
|
180
|
+
"create|update": (ctx) => { /* pipe list */ },
|
|
181
|
+
remove: (ctx) => { /* exact name */ },
|
|
182
|
+
},
|
|
183
|
+
after: {
|
|
184
|
+
get: (ctx, res) => ({ ...res, fetchedAt: Date.now() }), // must return res
|
|
185
|
+
},
|
|
186
|
+
error: {
|
|
187
|
+
"*": (ctx, err) => { throw err; }, // return a fallback, or re-throw
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
actions: { /* ... */ },
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
- **before** hooks receive `ctx` and may mutate `ctx.params`, `ctx.meta`, and
|
|
195
|
+
`ctx.locals`. Their return value is ignored — they can't skip the handler.
|
|
196
|
+
- **after** hooks receive `(ctx, res)` and **must return** the (possibly
|
|
197
|
+
transformed) response.
|
|
198
|
+
- **error** hooks receive `(ctx, err)`. Return a value to recover, or throw to
|
|
199
|
+
propagate. If several match, each re-throw feeds the next.
|
|
200
|
+
|
|
201
|
+
Keys may be `"*"` (all actions), an exact name, a `"a|b"` pipe list, or a `*`
|
|
202
|
+
glob (`"get*"`). Ordering matches Moleculer — **before** runs service-wildcard →
|
|
203
|
+
service-named → action, and **after**/**error** run in reverse (action →
|
|
204
|
+
service-named → service-wildcard):
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
before: hooks.before["*"] → hooks.before[name] → action.hooks.before → handler
|
|
208
|
+
after: action.hooks.after → hooks.after[name] → hooks.after["*"]
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Mixins
|
|
212
|
+
|
|
213
|
+
`mixins` fold reusable schemas into a service. Every field is merged by type —
|
|
214
|
+
`settings`/`metadata` deep-merge, `actions`/`events`/`methods`/`hooks` merge by
|
|
215
|
+
key, and lifecycle hooks (`created`/`started`/`stopped`/`merged`) *chain* so all
|
|
216
|
+
of them run (mixins first, then the service). **The service's own schema always
|
|
217
|
+
wins on conflict.**
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
const Timestamps = {
|
|
221
|
+
name: "timestamps",
|
|
222
|
+
settings: { softDelete: false },
|
|
223
|
+
methods: { touch(this: any) { /* ... */ } },
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
broker().createService({
|
|
227
|
+
mixins: [Timestamps],
|
|
228
|
+
name: "articles",
|
|
229
|
+
settings: { perPage: 10 }, // → { softDelete: false, perPage: 10 }
|
|
230
|
+
actions: { list: () => [] },
|
|
231
|
+
merged(schema) {
|
|
232
|
+
// fires once, after mixins merge, before the instance is built
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
When several mixins collide, the **first** in the array wins.
|
|
238
|
+
|
|
239
|
+
## Dependencies
|
|
240
|
+
|
|
241
|
+
List services a service needs with `dependencies`. During `broker.start()`, a
|
|
242
|
+
service's `started` hook waits until every dependency is registered. You can also
|
|
243
|
+
await readiness directly with `waitForServices` (from the broker or `this`):
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
broker().createService({
|
|
247
|
+
name: "api",
|
|
248
|
+
dependencies: ["db", "cache"], // started() waits for both
|
|
249
|
+
async started() {
|
|
250
|
+
await this.waitForServices("mailer", 5000); // optional explicit wait (ms)
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Events
|
|
256
|
+
|
|
257
|
+
`emit` sends a **balanced** event — each listening *group* receives it once. In a
|
|
258
|
+
cluster only one instance per group is chosen; locally, with one instance per
|
|
259
|
+
service, that's every listener. `broadcast` always reaches every listener, and
|
|
260
|
+
`broadcastLocal` reaches every listener on this node (identical to `broadcast`
|
|
261
|
+
until a real transporter would otherwise relay across nodes).
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
await broker().emit("user.created", user); // balanced
|
|
265
|
+
await broker().broadcast("cache.flush"); // everyone
|
|
266
|
+
await broker().broadcastLocal("cache.warm"); // everyone on this node
|
|
267
|
+
broker().hasEventListener("user.created"); // boolean
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### Groups
|
|
271
|
+
|
|
272
|
+
Every listener belongs to a **group** — its service name by default, or whatever
|
|
273
|
+
`group` you set on the listener. `emit` delivers to one listener per group; pass
|
|
274
|
+
`groups` to target specific ones:
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
broker().createService({
|
|
278
|
+
name: "mailer",
|
|
279
|
+
events: { "user.created": { group: "notify", handler: (ctx) => {} } },
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await broker().emit("user.created", user, { groups: ["notify"] });
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Patterns
|
|
286
|
+
|
|
287
|
+
Subscription keys may glob: `*` matches one segment (`user.*`), `**` any depth
|
|
288
|
+
(`user.**`), and `?` a single non-dot character (`user.??eated`).
|
|
289
|
+
|
|
290
|
+
### Internal events
|
|
291
|
+
|
|
292
|
+
The broker emits its own lifecycle events, which any service can subscribe to:
|
|
293
|
+
|
|
294
|
+
- **`$broker.started`** / **`$broker.stopped`** — after `start()` / before `stop()`.
|
|
295
|
+
- **`$services.changed`** — when a service is created or destroyed; the payload is
|
|
296
|
+
`{ service }`.
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
broker().createService({
|
|
300
|
+
name: "registry",
|
|
301
|
+
events: {
|
|
302
|
+
"$services.changed": (ctx) => {
|
|
303
|
+
// ctx.params.service changed
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## Lifecycle
|
|
310
|
+
|
|
311
|
+
Nothing runs actions across the network until you `start()`. Hooks fire in order:
|
|
312
|
+
`created` when the service is registered, `started` on `broker.start()`, and
|
|
313
|
+
`stopped` on `broker.stop()` (reverse order).
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
const b = broker();
|
|
317
|
+
b.createService({
|
|
318
|
+
name: "clock",
|
|
319
|
+
async started() {
|
|
320
|
+
this.timer = setInterval(() => this.broker.broadcast("tick"), 1000);
|
|
321
|
+
},
|
|
322
|
+
async stopped() {
|
|
323
|
+
clearInterval(this.timer);
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
await b.start();
|
|
328
|
+
// ...
|
|
329
|
+
await b.stop();
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
## Clustering
|
|
333
|
+
|
|
334
|
+
The default broker is single-node. To span processes, implement `Transporter`
|
|
335
|
+
and pass it in — the broker calls `connect` on start and `disconnect` on stop,
|
|
336
|
+
and a real transporter registers remote services and relays calls/events:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import { Broker, setBroker, type Transporter } from "@shaferllc/keel/core";
|
|
340
|
+
|
|
341
|
+
const nats: Transporter = {
|
|
342
|
+
async connect(broker) {
|
|
343
|
+
/* subscribe, register remote endpoints */
|
|
344
|
+
},
|
|
345
|
+
async disconnect() {
|
|
346
|
+
/* close */
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
setBroker(new Broker({ nodeID: "api-1", transporter: nats, requestTimeout: 10_000 }));
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
`broker()` returns the default instance (a fresh single-node `Broker`);
|
|
354
|
+
`setBroker()` replaces it, exactly as `redis()` / `setRedis()` work.
|
|
355
|
+
|
|
356
|
+
## Middlewares
|
|
357
|
+
|
|
358
|
+
Broker middlewares wrap every action call and tap broker lifecycle — the place
|
|
359
|
+
for cross-cutting concerns (logging, metrics, caching, auth) that apply to all
|
|
360
|
+
services. A middleware's `localAction` receives the next handler and returns a
|
|
361
|
+
replacement, so they compose (the first in the array is the outermost):
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
import { Broker, type BrokerMiddleware } from "@shaferllc/keel/core";
|
|
365
|
+
|
|
366
|
+
const timing: BrokerMiddleware = {
|
|
367
|
+
name: "timing",
|
|
368
|
+
localAction(next, action) {
|
|
369
|
+
return async (ctx) => {
|
|
370
|
+
const start = performance.now();
|
|
371
|
+
try {
|
|
372
|
+
return await next(ctx);
|
|
373
|
+
} finally {
|
|
374
|
+
logger().debug("action", { action, ms: performance.now() - start });
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
},
|
|
378
|
+
started(broker) {
|
|
379
|
+
logger().info("broker up", { nodeID: broker.nodeID });
|
|
380
|
+
},
|
|
381
|
+
stopped() {
|
|
382
|
+
/* flush metrics, close connections */
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const broker = new Broker({ middlewares: [timing] });
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`localAction(next, action)` wraps the handler (action = the full action name);
|
|
390
|
+
`started(broker)` / `stopped(broker)` run during `broker.start()` / `stop()`
|
|
391
|
+
(stopped in reverse order). A middleware that omits `localAction` leaves calls
|
|
392
|
+
untouched — handy for a lifecycle-only middleware.
|
|
393
|
+
|
|
394
|
+
## Fault tolerance
|
|
395
|
+
|
|
396
|
+
A call can be made resilient with per-call options (or broker-wide defaults):
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
// retry up to 3 times, then fall back to a cached value
|
|
400
|
+
await broker.call("orders.get", { id }, {
|
|
401
|
+
retries: 3,
|
|
402
|
+
fallback: { id, status: "unknown" },
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// timeout + a computed fallback
|
|
406
|
+
await broker.call("pricing.quote", cart, {
|
|
407
|
+
timeout: 500,
|
|
408
|
+
fallback: (err: Error) => ({ error: err.message, price: null }),
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const broker = new Broker({ requestTimeout: 1000, retries: 2 }); // defaults for every call
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
- **`retries`** — total attempts are `retries + 1`; the whole call re-runs on
|
|
415
|
+
failure. Defaults to `BrokerOptions.retries`.
|
|
416
|
+
- **`fallback`** — a value, or `(err, ctx) => value`, returned once every attempt
|
|
417
|
+
(and any `error` hooks) has failed — instead of throwing.
|
|
418
|
+
- **`timeout`** — ms before a `RequestTimeoutError` (per call, per action, or the
|
|
419
|
+
broker default).
|
|
420
|
+
|
|
421
|
+
Order: retry → error hooks → fallback → throw.
|
|
422
|
+
|
|
423
|
+
## Registry introspection
|
|
424
|
+
|
|
425
|
+
The broker's registry is queryable:
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
broker.hasAction("users.find"); // boolean (private actions read as absent)
|
|
429
|
+
broker.listActions(); // ["orders.get", "users.find", …] (public, sorted)
|
|
430
|
+
broker.listServices(); // ["orders", "users", …]
|
|
431
|
+
broker.getService("users"); // the Service instance, or undefined
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
## Networking & balancing
|
|
435
|
+
|
|
436
|
+
The broker is **single-node** by default (`LocalTransporter`). Clustering across
|
|
437
|
+
nodes is the `Transporter` seam — implement `Transporter` for NATS, Redis, or TCP
|
|
438
|
+
and pass it as `transporter`. With a single node there's one endpoint per action,
|
|
439
|
+
so cross-node **load balancing** doesn't apply; event **group** balancing (one
|
|
440
|
+
listener per group) works today via `emit(event, payload, { groups })`.
|
|
441
|
+
|
|
442
|
+
## Validating params
|
|
443
|
+
|
|
444
|
+
Give an action a `params` schema and it's validated (and coerced) before the
|
|
445
|
+
handler runs — a bad call rejects with a `ValidationException`, so the handler
|
|
446
|
+
only ever sees valid input:
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
import { z } from "zod";
|
|
450
|
+
|
|
451
|
+
broker.createService({
|
|
452
|
+
name: "users",
|
|
453
|
+
actions: {
|
|
454
|
+
create: {
|
|
455
|
+
params: z.object({ email: z.string().email(), age: z.coerce.number().min(18) }),
|
|
456
|
+
handler: (ctx) => createUser(ctx.params), // params typed + validated
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Any [Zod-style schema](./validation.md) works — the broker bundles no validator.
|
|
463
|
+
|
|
464
|
+
## Caching action results
|
|
465
|
+
|
|
466
|
+
Mark an action `cache` and give the broker a `cacher` (any Keel [`Cache`](./cache.md)
|
|
467
|
+
— memory, or Redis via `redisStore()`), and results are memoized by action + params:
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
import { Cache } from "@shaferllc/keel/core";
|
|
471
|
+
|
|
472
|
+
const broker = new Broker({ cacher: new Cache() });
|
|
473
|
+
|
|
474
|
+
broker.createService({
|
|
475
|
+
name: "stats",
|
|
476
|
+
actions: {
|
|
477
|
+
daily: {
|
|
478
|
+
cache: { ttl: 300, keys: ["day"] }, // 5 min; key on the `day` param only
|
|
479
|
+
handler: (ctx) => computeDaily(ctx.params.day),
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
`cache: true` caches forever keyed on all params; `{ ttl }` sets a TTL (seconds);
|
|
486
|
+
`{ keys }` limits the cache key to those params. With no `cacher`, `cache` is a
|
|
487
|
+
no-op.
|
|
488
|
+
|
|
489
|
+
## Metrics, tracing, errors & runner
|
|
490
|
+
|
|
491
|
+
- **Metrics & tracing** — the [middleware](#middlewares) `localAction` seam is the
|
|
492
|
+
hook: wrap every call to time it, count it, or open a span. Every context
|
|
493
|
+
already carries the trace fields (`ctx.requestID`, `ctx.parentID`, `ctx.level`,
|
|
494
|
+
`ctx.caller`) a span exporter needs.
|
|
495
|
+
- **Errors** — the broker throws typed errors (`ServiceNotFoundError`,
|
|
496
|
+
`RequestTimeoutError`, and `ValidationException` from `params`); define your own
|
|
497
|
+
with [`createError`](./errors.md).
|
|
498
|
+
- **Runner** — no separate runner binary: register services with `createService()`
|
|
499
|
+
(loop over a folder of schemas) and call `broker.start()` from your app's boot
|
|
500
|
+
or a [service provider](./providers.md).
|