@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.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- 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.
|