@zerotal/arch 1.7.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.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
@@ -0,0 +1,170 @@
1
+ ---
2
+ title: Broadcasting Channels
3
+ description: Public, private, presence, and typed channels — and authorizing access to them.
4
+ ---
5
+
6
+ # Channels
7
+
8
+ `broadcastOn()` returns a channel name (or an array of them). Pick the channel type with one of
9
+ three helpers — they prefix the name so the server and client agree on it:
10
+
11
+ ```ts
12
+ // in an event's broadcastOn()
13
+ import { channel, privateChannel, presenceChannel, isPrivateChannel } from "@zerotal/broadcasting";
14
+
15
+ channel("posts"); // public — anyone may subscribe; name: "posts"
16
+ privateChannel("orders.42"); // private — requires authorization; name: "private-orders.42"
17
+ presenceChannel("chat.room1"); // presence — private + member tracking; name: "presence-chat.room1"
18
+ ```
19
+
20
+ | Helper | Channel kind | Authorization | Name prefix |
21
+ | ----------------------- | ------------ | -------------------- | ----------- |
22
+ | `channel(name)` | Public | None | _(none)_ |
23
+ | `privateChannel(name)` | Private | Boolean callback | `private-` |
24
+ | `presenceChannel(name)` | Presence | Member-data callback | `presence-` |
25
+
26
+ The helpers are idempotent — `privateChannel("private-orders.42")` returns the name unchanged, so
27
+ it's safe to wrap an already-prefixed channel. `isPrivateChannel(name)` is a guard that's `true`
28
+ for both `private-` and `presence-` names.
29
+
30
+ Private and presence channels are authorized in
31
+ [`routes/channels.ts`](#authorizing-channels); public channels need no auth.
32
+
33
+ ## Authorizing channels
34
+
35
+ Private and presence channels require server-side authorization before a client can subscribe.
36
+ Define the rules in `routes/channels.ts` with `Broadcast.channel(pattern, callback)` — the
37
+ broadcasting analogue of `routes/web.ts`. It's loaded automatically at boot.
38
+
39
+ Patterns use the file-routing **`[param]` placeholder syntax**. Each `[param]` matches one channel
40
+ segment and is passed to the callback positionally after the authenticated user.
41
+
42
+ ```ts
43
+ // routes/channels.ts
44
+ import { Broadcast } from "@zerotal/broadcasting";
45
+ import { Order } from "../app/models/Order.ts";
46
+ import type { User } from "../app/models/User.ts";
47
+
48
+ // Private channel — return a boolean.
49
+ Broadcast.channel("orders.[orderId]", async (user: User, orderId: string) => {
50
+ return user.id === (await Order.findOrNew(orderId)).userId;
51
+ });
52
+
53
+ // Presence channel — return member data to authorize + publish presence, or null to deny.
54
+ Broadcast.channel("chat.[roomId]", (user: User, roomId: string) => {
55
+ if (!user.canJoin(roomId)) return null;
56
+ return { id: user.id, name: user.name };
57
+ });
58
+ ```
59
+
60
+ - **The authenticated user** is whatever your auth middleware put on the request (`http.user`);
61
+ guests are denied automatically.
62
+ - **Return value is the signal**: `boolean` for private channels; a member-data object for
63
+ presence channels (`false`/`null` to deny).
64
+ - **No matching rule = denied.** A private/presence channel with no registered pattern is rejected.
65
+ - Patterns are registered **without** the `private-`/`presence-` prefix (it's stripped before
66
+ matching), so one `orders.[orderId]` rule covers both private and presence variants.
67
+
68
+ > **Danger** — The authorization callback is the only thing between a client and another user's
69
+ > data. Return `true`/member data only after you've confirmed the authenticated user may access
70
+ > that exact channel — never trust the channel name alone.
71
+
72
+ Authorization is enforced on the `POST /broadcasting/auth` path. List every registered rule with:
73
+
74
+ ```bash
75
+ # in your project root
76
+ bun zt channel:list
77
+ ```
78
+
79
+ ### Scaffolding
80
+
81
+ ```bash
82
+ # in your project root
83
+ # Add a private channel rule to routes/channels.ts (creates the file if missing).
84
+ bun zt make:channel Order # -> orders.[id]
85
+ bun zt make:channel orders.[orderId] # -> orders.[orderId]
86
+ bun zt make:channel chat.[roomId] -p # -p / --presence: presence-channel stub
87
+
88
+ # Generate a broadcastable event class (extends BroadcastingEvent).
89
+ bun zt make:event OrderShipped --broadcast # or -b
90
+ ```
91
+
92
+ `make:channel` appends a `Broadcast.channel(...)` block (private by default, presence with
93
+ `--presence`); a bare model name like `Order` expands to `orders.[id]`. `make:event --broadcast`
94
+ writes an `app/events/*.ts` with `broadcastOn()`/`broadcastWith()` stubbed.
95
+
96
+ ## Presence channels
97
+
98
+ Presence channels track who is currently subscribed, enabling "who's online" lists. The
99
+ authorization rule for a presence channel returns the **member data** instead of a boolean:
100
+
101
+ ```ts
102
+ // routes/channels.ts
103
+ Broadcast.channel("chat.[roomId]", (user: User, roomId: string) => {
104
+ if (!user.canJoin(Number(roomId))) return null;
105
+ return { id: user.id, name: user.name, avatar: user.avatar ?? null };
106
+ });
107
+ ```
108
+
109
+ Read the current members anywhere with `Broadcast.getMembers("presence-chat.room1")`.
110
+
111
+ ### Presence wire events
112
+
113
+ The server emits these protocol events on a presence channel. Names are shown as they appear on
114
+ the wire; a Pusher-protocol client surfaces them to your code under the `pusher:` prefix
115
+ (e.g. `pusher:subscription_succeeded`).
116
+
117
+ | Wire event (pusher driver) | Payload | Description |
118
+ | ---------------------------------------- | ------------------------ | --------------------------------------------------- |
119
+ | `pusher_internal:subscription_succeeded` | `{ presence }` | Sent to the joining client with all current members |
120
+ | `pusher_internal:member_added` | `{ user_id, user_info }` | Broadcast to other members when someone joins |
121
+ | `pusher_internal:member_removed` | `{ user_id }` | Broadcast to other members when someone leaves |
122
+
123
+ > **Note** — The native `ws`/`redis` drivers use the equivalent
124
+ > `subscription_succeeded` / `presence:member_added` / `presence:member_removed` events; the
125
+ > first-party `Socket` client maps them to Echo's `here`/`joining`/`leaving` callbacks for you.
126
+
127
+ ## Typed channels
128
+
129
+ For end-to-end type safety, declare a channel map and use `TypedBroadcastManager`.
130
+ Keys are channel patterns (with optional `[param]` placeholders); values map event
131
+ names to their payload types. The compiler then enforces that every `to()` /
132
+ `toChannel()` call uses a valid channel, a known event name, and a matching payload.
133
+
134
+ ```ts
135
+ // app/broadcasting/channels.ts
136
+ import { TypedBroadcastManager } from "@zerotal/broadcasting";
137
+ import type { BroadcastChannelMap } from "@zerotal/broadcasting";
138
+
139
+ export interface Channels extends BroadcastChannelMap {
140
+ posts: {
141
+ PostCreated: { id: number; title: string };
142
+ PostDeleted: { id: number };
143
+ };
144
+ "private-orders.[orderId]": {
145
+ OrderShipped: { orderId: number; trackingCode: string };
146
+ OrderCancelled: { orderId: number; reason: string };
147
+ };
148
+ }
149
+
150
+ const manager = new TypedBroadcastManager<Channels>();
151
+
152
+ // Static channel — event name + payload are checked against the map
153
+ manager.to("posts", "PostCreated", { id: 1, title: "Hello" });
154
+
155
+ // Parameterised channel — pass the pattern, its params, then event + payload
156
+ manager.toChannel("private-orders.[orderId]", { orderId: 42 }, "OrderShipped", {
157
+ orderId: 42,
158
+ trackingCode: "UPS-123",
159
+ });
160
+ ```
161
+
162
+ A wrong event name, a missing payload field, or an unknown channel is a
163
+ **compile-time error** — nothing reaches the wire. Passing a parameter that the
164
+ pattern doesn't declare (or omitting one) throws `MissingChannelParameterError` at
165
+ runtime.
166
+
167
+ ## Next steps
168
+
169
+ - [Broadcasting overview](/docs/broadcasting) — the guide's front page and the rest of the sections.
170
+ - [Reference](/docs/broadcasting/references) — the full API surface in one table.
@@ -0,0 +1,108 @@
1
+ ---
2
+ title: Broadcasting on the Client
3
+ description: Subscribe from the browser and react to events as they arrive.
4
+ ---
5
+
6
+ # Client-side
7
+
8
+ ## First-party Socket
9
+
10
+ `@zerotal/client` ships a small, dependency-free `Socket` that speaks the native broadcast
11
+ protocol and exposes a familiar realtime-client API — no external client library, and it works
12
+ with the lightweight `ws` and `redis` drivers (no Pusher credentials needed). It's also a drop-in
13
+ for `window.Echo`, so Flow's [`@on('echo:…')`](/docs/flow/events) listeners work against it.
14
+
15
+ ```ts
16
+ // in your client code
17
+ import { Socket } from "@zerotal/client";
18
+
19
+ const socket = new Socket(); // ws(s)://<host>/app/ws (matches the `path` config)
20
+
21
+ // Public channel
22
+ socket.channel("posts").listen("PostPublished", (e) => {
23
+ console.log("New post:", e.title);
24
+ });
25
+
26
+ // Private channel (the `private-` prefix is added for you; the WS connection carries the user)
27
+ socket.private(`orders.${id}`).listen("OrderUpdated", (e) => render(e.order));
28
+
29
+ // Presence channel — who's online
30
+ socket
31
+ .presence(`chat.${roomId}`)
32
+ .here((members) => setOnline(members))
33
+ .joining((m) => addOnline(m))
34
+ .leaving((m) => removeOnline(m))
35
+ .listen("Message", (e) => append(e));
36
+
37
+ // Use it as Echo for Flow @on('echo:…') listeners:
38
+ window.Echo = socket;
39
+ ```
40
+
41
+ Private and presence channels are authorized with a **per-subscription HMAC signature** (the same
42
+ model as Pusher): the client POSTs `{ socket_id, channel_name }` to `authEndpoint` (default
43
+ `/broadcasting/auth`), the server runs the [`routes/channels.ts`](/docs/broadcasting/channels)
44
+ rules and signs the result with the app's `APP_KEY`, and the client echoes the signature in its
45
+ `subscribe`. It's automatically re-fetched on reconnect (the signature is socket-bound). Pass CSRF
46
+ or other headers via `auth.headers`, or set `authEndpoint: false` to skip the fetch and rely on
47
+ connection-level authorization instead:
48
+
49
+ ```ts
50
+ // in your client code
51
+ const socket = new Socket({
52
+ auth: { headers: { "X-CSRF-TOKEN": csrf } }, // sent on POST /broadcasting/auth
53
+ });
54
+ ```
55
+
56
+ Connection state is observable via `socket.on("connected" | "disconnected" | "reconnecting" |
57
+ "error", cb)`; the client auto-reconnects and re-subscribes. Use `socket.socketId()` as the
58
+ `X-Socket-ID` header on your HTTP requests so server `toOthers()` broadcasts skip the originating
59
+ client.
60
+
61
+ ## Pusher-protocol clients
62
+
63
+ The server is also Pusher-protocol compatible, so the reference
64
+ [pusher-js](https://github.com/pusher/pusher-js) client works unchanged:
65
+
66
+ ```ts
67
+ // in your client code
68
+ import Pusher from "pusher-js";
69
+
70
+ const pusher = new Pusher("my-app-key", {
71
+ wsHost: window.location.hostname,
72
+ wsPort: 3000,
73
+ wssPort: 3000,
74
+ forceTLS: false,
75
+ disableStats: true,
76
+ enabledTransports: ["ws"],
77
+ // Private/presence channel auth — matches POST /broadcasting/auth
78
+ authEndpoint: "/broadcasting/auth",
79
+ auth: {
80
+ headers: {
81
+ "X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]')?.content ?? "",
82
+ },
83
+ },
84
+ });
85
+
86
+ // Subscribe to a public channel
87
+ const postsChannel = pusher.subscribe("posts");
88
+ postsChannel.bind("PostPublished", (data: { title: string; slug: string }) => {
89
+ console.log("New post:", data.title);
90
+ });
91
+
92
+ // Subscribe to a private channel
93
+ const ordersChannel = pusher.subscribe("private-orders.42");
94
+ ordersChannel.bind("OrderUpdated", (data) => {
95
+ console.log("Order updated:", data);
96
+ });
97
+
98
+ // Subscribe to a presence channel
99
+ const chatChannel = pusher.subscribe("presence-chat.room1");
100
+ chatChannel.bind("pusher:subscription_succeeded", (members) => {
101
+ console.log("Online members:", members);
102
+ });
103
+ ```
104
+
105
+ ## Next steps
106
+
107
+ - [Broadcasting overview](/docs/broadcasting) — the guide's front page and the rest of the sections.
108
+ - [Reference](/docs/broadcasting/references) — the full API surface in one table.
@@ -0,0 +1,190 @@
1
+ ---
2
+ title: Broadcasting Events
3
+ description: Write a broadcast event, dispatch it, and broadcast model changes automatically.
4
+ ---
5
+
6
+ # Writing broadcast events
7
+
8
+ You only have to implement `broadcastOn()`; the rest default sensibly.
9
+
10
+ ```ts
11
+ // app/events/OrderShipmentStatusUpdated.ts
12
+ import { BroadcastingEvent, privateChannel } from "@zerotal/broadcasting";
13
+ import type { Order } from "../models/Order.ts";
14
+
15
+ export class OrderShipmentStatusUpdated extends BroadcastingEvent {
16
+ constructor(public readonly order: Order) {
17
+ super();
18
+ }
19
+
20
+ broadcastOn() {
21
+ return privateChannel(`orders.${this.order.id}`);
22
+ }
23
+
24
+ // Optional — all default sensibly:
25
+ broadcastAs() {
26
+ return "OrderShipmentStatusUpdated"; // default: the class name
27
+ }
28
+ broadcastWith() {
29
+ return { id: this.order.id, status: this.order.status }; // default: the event's own props
30
+ }
31
+ broadcastWhen() {
32
+ return this.order.total > 100; // default: true
33
+ }
34
+ }
35
+ ```
36
+
37
+ | Method | Required | Default | Description |
38
+ | ----------------- | -------- | --------------------- | ----------------------------------------------- |
39
+ | `broadcastOn()` | Yes | — | Channel name(s). Use the channel helpers. |
40
+ | `broadcastAs()` | No | the class name | The wire event name clients listen for. |
41
+ | `broadcastWith()` | No | the event's own props | The payload object. |
42
+ | `broadcastWhen()` | No | `true` | Gate — broadcast only when this returns `true`. |
43
+
44
+ > **Note** — The lower-level `BroadcastEvent` _interface_ (`broadcastOn`/`broadcastAs?`/`broadcastWith?`)
45
+ > is also exported if you'd rather implement it structurally; `BroadcastingEvent` implements it.
46
+
47
+ ## Dispatching events
48
+
49
+ Three ways to broadcast, in order of ergonomics:
50
+
51
+ ```ts
52
+ // in a controller
53
+ import { broadcast, Broadcast } from "@zerotal/broadcasting";
54
+
55
+ // 1. Static dispatch — constructs the event, runs any app-event listeners, AND broadcasts:
56
+ OrderShipmentStatusUpdated.dispatch(order);
57
+
58
+ // 2. The broadcast() helper — fluent, for excluding the current socket:
59
+ broadcast(new OrderShipmentStatusUpdated(order)).toOthers();
60
+ await broadcast(new OrderShipmentStatusUpdated(order)); // awaitable; sends automatically
61
+
62
+ // 3. The Broadcast facade — explicit send:
63
+ Broadcast.send(new OrderShipmentStatusUpdated(order));
64
+ ```
65
+
66
+ `broadcast(event).toOthers()` excludes the connection that triggered the request (read from the
67
+ `X-Socket-ID` header your Echo client sends), so the user who just made an optimistic UI update
68
+ doesn't receive a duplicate.
69
+
70
+ `broadcast()` returns a `PendingBroadcast` — a thenable that sends itself on the next
71
+ microtask, or immediately when awaited. That is what lets `.toOthers()` configure it
72
+ first without you having to remember a `.send()`: both lines above deliver, one
73
+ fire-and-forget and one awaited. Hold the value only if you want to configure it across
74
+ several statements; otherwise treat `broadcast(...)` as the whole call.
75
+
76
+ A broadcastable event emitted on the [event bus](/docs/events) is broadcast automatically:
77
+
78
+ ```ts
79
+ // in a controller
80
+ import { Events } from "zerotal";
81
+
82
+ Events.emit(new OrderShipmentStatusUpdated(order)); // runs listeners AND broadcasts
83
+ ```
84
+
85
+ Broadcast to multiple channels by returning an array from `broadcastOn()`:
86
+
87
+ ```ts
88
+ // in an event's broadcastOn()
89
+ broadcastOn() {
90
+ return [privateChannel(`orders.${this.order.id}`), privateChannel(`users.${this.order.userId}`)];
91
+ }
92
+ ```
93
+
94
+ ### Anonymous broadcasts
95
+
96
+ When a full event class is overkill, broadcast inline:
97
+
98
+ ```ts
99
+ // in a controller
100
+ Broadcast.on(`orders.${order.id}`).as("OrderPlaced").with(order).toOthers().send();
101
+ Broadcast.private(`orders.${order.id}`).as("OrderPlaced").with({ id: order.id }).send();
102
+ Broadcast.presence(`chat.${room.id}`).with({ userId: user.id }).send();
103
+
104
+ // Or the lowest-level form — push a raw event straight to a channel:
105
+ Broadcast.to("posts", "PostViewed", { id: post.id, viewedAt: Date.now() });
106
+ ```
107
+
108
+ ### Reading presence members
109
+
110
+ Get the members currently subscribed to a presence channel (real driver only —
111
+ returns `[]` under the fake/null driver):
112
+
113
+ ```ts
114
+ // in a controller
115
+ const members = Broadcast.getMembers("presence-chat.room1");
116
+ // → [{ id, info }, …]
117
+ ```
118
+
119
+ ## Broadcasting model changes
120
+
121
+ Broadcast a model's lifecycle changes by mapping them to a `BroadcastingEvent` through the ORM's
122
+ `dispatchesEvents`. When the model fires the event on the [event bus](/docs/events), it is
123
+ broadcast automatically (see [dispatching](#dispatching-events)) — no manual broadcast call.
124
+
125
+ ```ts
126
+ // app/events/PostCreated.ts
127
+ import { BroadcastingEvent, privateChannel } from "@zerotal/broadcasting";
128
+ import type { Post } from "../models/Post.ts";
129
+
130
+ export class PostCreated extends BroadcastingEvent {
131
+ constructor(public readonly post: Post) {
132
+ super();
133
+ }
134
+ broadcastOn() {
135
+ return privateChannel(`posts.${this.post.id}`);
136
+ }
137
+ broadcastWith() {
138
+ return { id: this.post.id, title: this.post.title };
139
+ }
140
+ }
141
+ ```
142
+
143
+ ```ts
144
+ // app/models/Post.ts
145
+ @table("posts")
146
+ export class Post extends Model {
147
+ static dispatchesEvents = { created: PostCreated, updated: PostUpdated };
148
+ }
149
+ ```
150
+
151
+ Now `await Post.create({ … })` fires `PostCreated`, which broadcasts to `private-posts.[id]`.
152
+ See [ORM Lifecycle & Events](/docs/orm/lifecycle).
153
+
154
+ ### Shortcut: broadcastsModelEvents
155
+
156
+ When you only need to broadcast the change (no custom event class), `broadcastsModelEvents()`
157
+ wires `created`/`updated`/`deleted` for you. It generates the `BroadcastingEvent`s and populates
158
+ `dispatchesEvents` — the same bridge, less boilerplate. Call it once, below the model:
159
+
160
+ ```ts
161
+ // app/models/Order.ts
162
+ import { Model, column, table } from "@zerotal/orm";
163
+ import { broadcastsModelEvents, privateChannel } from "@zerotal/broadcasting";
164
+
165
+ @table("orders")
166
+ export class Order extends Model {
167
+ @column() id!: number;
168
+ @column() status!: string;
169
+ }
170
+
171
+ broadcastsModelEvents(Order, {
172
+ channels: (order) => privateChannel(`orders.${order.id}`),
173
+ // events: ["created", "updated", "deleted"], // default
174
+ // as: (modelName, event) => `${modelName}${event}`, // wire name; default "OrderUpdated"
175
+ // with: (order) => ({ id: order.id, status: order.status }), // payload; default { order }
176
+ });
177
+ ```
178
+
179
+ The wire event name defaults to `${ModelName}${Event}` (e.g. `OrderUpdated`) and the payload to
180
+ `{ order }` (the model under its camel-cased name). On the client:
181
+
182
+ ```ts
183
+ // in your client code
184
+ Echo.private(`orders.${id}`).listen("OrderUpdated", (e) => render(e.order));
185
+ ```
186
+
187
+ ## Next steps
188
+
189
+ - [Broadcasting overview](/docs/broadcasting) — the guide's front page and the rest of the sections.
190
+ - [Reference](/docs/broadcasting/references) — the full API surface in one table.
@@ -0,0 +1,163 @@
1
+ ---
2
+ title: Broadcasting
3
+ description: Push server-side events to subscribed clients in real time over WebSockets, with Pusher-protocol compatibility built in.
4
+ ---
5
+
6
+ # Broadcasting
7
+
8
+ Real-time WebSocket broadcasting with a Pusher-compatible server built into the Zerotal
9
+ application process — no separate service required. Define a `BroadcastingEvent`, dispatch it,
10
+ and every subscribed client receives the payload over a live WebSocket connection. Any
11
+ Pusher-protocol client works unchanged.
12
+
13
+ ## Getting Started
14
+
15
+ ```bash
16
+ # in your project root
17
+ bun add @zerotal/broadcasting
18
+ ```
19
+
20
+ ## Register the provider
21
+
22
+ Add `BroadcastProvider` to the providers array in `bootstrap/providers.ts`:
23
+
24
+ ```ts
25
+ // bootstrap/providers.ts
26
+ import { BroadcastProvider } from "@zerotal/broadcasting";
27
+
28
+ const providers = [
29
+ // …your other providers
30
+ BroadcastProvider,
31
+ ];
32
+
33
+ export default providers;
34
+ ```
35
+
36
+ Registering the provider switches on the following (only the hooks it actually uses, in
37
+ lifecycle order):
38
+
39
+ - `onRegister` — reads `config/broadcasting.ts`, builds the configured driver's
40
+ `BroadcastManager`, binds it to the container as `broadcast`, wires the auto-broadcast hook so
41
+ any `BroadcastingEvent` emitted on the [event bus](/docs/events) is broadcast, attaches the
42
+ WebSocket handlers via `app.withWebSocket()`, and registers the `POST /broadcasting/auth`
43
+ channel-auth route (for the `ws`, `redis`, and `pusher` drivers).
44
+ - `onBooting` — boots the Redis driver (when active) and side-effect-imports `routes/channels.ts`
45
+ so its authorization rules are registered before the first auth request.
46
+ - `onBooted` — registers the `channel:list` and `make:channel` CLI commands.
47
+ - `onStopping` — stops the Redis driver, so nothing leaks between boots or test suites.
48
+
49
+ The provider exposes one HTTP route and one WebSocket upgrade path:
50
+
51
+ - `POST /broadcasting/auth` — the private/presence channel auth endpoint (a real `Router` route).
52
+ - The WebSocket upgrade is served at the configured `path` (default `/app/ws`) via Bun's
53
+ WebSocket handler — it is _not_ a separate `Router` route. Pusher/Echo clients connect to
54
+ `ws://host/app/{appKey}`.
55
+
56
+ ## Configuration
57
+
58
+ Create `config/broadcasting.ts` with the `BroadcastConfig()` helper so every field stays
59
+ type-checked while literal values stay inferred. All options below are shown with their defaults;
60
+ `redis` and `pusher` are only required for their respective drivers.
61
+
62
+ ```ts
63
+ // config/broadcasting.ts
64
+ import { BroadcastConfig } from "@zerotal/broadcasting";
65
+ import { env } from "zerotal";
66
+
67
+ export default BroadcastConfig({
68
+ path: "/app/ws", // WebSocket upgrade path
69
+ driver: "null", // 'null' | 'ws' | 'redis' | 'pusher'
70
+
71
+ // Required when driver is 'redis':
72
+ redis: { url: env("REDIS_URL", "redis://localhost:6379") },
73
+
74
+ // Required when driver is 'pusher':
75
+ pusher: {
76
+ appKey: env("PUSHER_APP_KEY", ""),
77
+ appSecret: env("PUSHER_APP_SECRET", ""),
78
+ },
79
+ });
80
+ ```
81
+
82
+ | Field | Required | Default | Description |
83
+ | -------- | --------------- | ----------- | -------------------------------------------------------------------------- |
84
+ | `path` | no | `"/app/ws"` | WebSocket upgrade path. Pusher clients connect to `/app/{appKey}` instead. |
85
+ | `driver` | no | `"null"` | `'null'` \| `'ws'` \| `'redis'` \| `'pusher'` — how broadcasts are sent. |
86
+ | `redis` | with `'redis'` | — | `{ url }` Redis connection. Falls back to `redis://localhost:6379`. |
87
+ | `pusher` | with `'pusher'` | — | `{ appKey, appSecret }` Pusher/Reverb credentials. |
88
+
89
+ ### Which driver should I use?
90
+
91
+ The active driver decides _how_ a broadcast is delivered. Switch drivers per environment via the
92
+ `driver` key — your event classes never change.
93
+
94
+ | Driver | Class | Delivery | Use for |
95
+ | -------- | ---------------------- | ---------------------------------------------- | ------------------------------------------------ |
96
+ | `null` | — | Discards everything (default) | Local dev / tests where you don't need real WS |
97
+ | `ws` | — | In-process WebSocket, Zerotal native protocol | Single-server deployments |
98
+ | `redis` | `RedisBroadcastDriver` | Redis Pub/Sub fan-out, Zerotal native protocol | Horizontally-scaled deployments (many instances) |
99
+ | `pusher` | `PusherCompatManager` | Pusher-compatible wire protocol | Existing Pusher-protocol clients |
100
+
101
+ You select a driver through the `driver` key rather than constructing one. The classes
102
+ are exported for the cases that need the instance itself — a test asserting on
103
+ fan-out, or a custom driver wrapping one rather than reimplementing it.
104
+
105
+ > **Tip** — Start on `null` in tests and local dev; switch to `ws` for a single server, `redis`
106
+ > once you run more than one instance, and `pusher` only when you must speak the Pusher wire
107
+ > protocol to existing clients.
108
+
109
+ ## Basic usage
110
+
111
+ Import the surface you need from `@zerotal/broadcasting`:
112
+
113
+ ```ts
114
+ // in a controller or event file
115
+ import {
116
+ Broadcast,
117
+ BroadcastingEvent,
118
+ broadcast,
119
+ channel,
120
+ privateChannel,
121
+ presenceChannel,
122
+ } from "@zerotal/broadcasting";
123
+ ```
124
+
125
+ Extend `BroadcastingEvent`, implement `broadcastOn()`, and dispatch — every subscribed client on
126
+ that channel receives the payload:
127
+
128
+ ```ts
129
+ // app/events/OrderShipmentStatusUpdated.ts
130
+ import { BroadcastingEvent, privateChannel } from "@zerotal/broadcasting";
131
+ import type { Order } from "../models/Order.ts";
132
+
133
+ export class OrderShipmentStatusUpdated extends BroadcastingEvent {
134
+ constructor(public readonly order: Order) {
135
+ super();
136
+ }
137
+
138
+ broadcastOn() {
139
+ return privateChannel(`orders.${this.order.id}`);
140
+ }
141
+ }
142
+
143
+ // elsewhere — construct, run listeners, and broadcast in one call:
144
+ OrderShipmentStatusUpdated.dispatch(order);
145
+ ```
146
+
147
+ ## The rest of the guide
148
+
149
+ | Page | What it covers |
150
+ | -------------------------------------------------- | -------------------------------------------------------------------------------- |
151
+ | [Events](/docs/broadcasting/events) | Write a broadcast event, dispatch it, and broadcast model changes automatically. |
152
+ | [Channels](/docs/broadcasting/channels) | Public, private, presence, and typed channels — and authorizing access to them. |
153
+ | [on the Client](/docs/broadcasting/client) | Subscribe from the browser and react to events as they arrive. |
154
+ | [Testing Broadcasting](/docs/broadcasting/testing) | Fake the broadcaster and assert on what would have been sent. |
155
+ | [References](/docs/broadcasting/references) | The Broadcast facade, channel APIs, errors, and commands. |
156
+
157
+ ## Next steps
158
+
159
+ - [Events](/docs/events) — the in-process event bus; emitting a `BroadcastingEvent` broadcasts it.
160
+ - [Notifications](/docs/notifications) — real-time notifications over the `'broadcast'` channel.
161
+ - [Authentication](/docs/authentication) — how `http.user` is populated for the
162
+ `routes/channels.ts` authorization callbacks.
163
+ - [ORM Lifecycle](/docs/orm/lifecycle) — the `dispatchesEvents` bridge that auto-broadcasts model changes.