@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,420 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Flow Events & Broadcasting
|
|
3
|
+
description: Component events, browser events, and multiplayer state shared over WebSockets.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Events & Broadcasting
|
|
7
|
+
|
|
8
|
+
Flow components communicate by dispatching named events. Listeners registered with `@on` are notified and can refresh their own state. Events can be targeted at all components, a specific class, or just the sender. The same mechanism extends to real-time server broadcasts over WebSockets.
|
|
9
|
+
|
|
10
|
+
## Dispatching events from the server
|
|
11
|
+
|
|
12
|
+
Call `this.dispatch()` inside any `@expose`d action. The event is delivered to all matching `@on` listeners on the page after the action completes:
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
@expose async save(): Promise<void> {
|
|
16
|
+
const post = await Post.create({
|
|
17
|
+
title: this.title,
|
|
18
|
+
body: this.body,
|
|
19
|
+
userId: this.currentUserId,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Notify every component on the page
|
|
23
|
+
this.dispatch("post-created", { id: post.id, title: post.title });
|
|
24
|
+
this.redirect(`/posts/${post.id}`);
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The second argument is the payload — any JSON-serialisable value. Omit it for events that carry no data:
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
this.dispatch("cart-cleared");
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Targeting specific components
|
|
35
|
+
|
|
36
|
+
By default, `dispatch()` notifies every `@on` listener on the page. Use `dispatchTo` or `dispatchSelf` to narrow the target:
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// Default: all @on("post-created") listeners on the page
|
|
40
|
+
this.dispatch("post-created", { id });
|
|
41
|
+
|
|
42
|
+
// Only the PostList component class
|
|
43
|
+
this.dispatchTo("PostList", "post-created", { id });
|
|
44
|
+
|
|
45
|
+
// Only this component instance (self-notification)
|
|
46
|
+
this.dispatchSelf("refresh");
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`dispatchTo` matches by class name (the `name` property of the class), not by file path or import. If you have two mounted `PostList` instances on the page, both receive the event — targeting narrows by _class_, not by _instance_.
|
|
50
|
+
|
|
51
|
+
## Listening for events
|
|
52
|
+
|
|
53
|
+
Register a method as an event listener with `@on`. The method is **implicitly exposed** — no `@expose` needed:
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { on } from "@zerotal/flow";
|
|
57
|
+
|
|
58
|
+
export class PostList extends Component {
|
|
59
|
+
@locked posts: Post[] = [];
|
|
60
|
+
|
|
61
|
+
override async onMount() {
|
|
62
|
+
this.posts = await Post.query().orderBy("created_at", "desc").limit(20).get();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// This fires whenever any component dispatches "post-created":
|
|
66
|
+
@on("post-created")
|
|
67
|
+
async handlePostCreated(data: { id: number; title: string }): Promise<void> {
|
|
68
|
+
// Prepend the new post without a full reload
|
|
69
|
+
const newPost = await Post.findOrFail(data.id);
|
|
70
|
+
this.posts = [newPost, ...this.posts.slice(0, 19)];
|
|
71
|
+
this.flash(`"${data.title}" was published.`, "success");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Listen for a deletion too
|
|
75
|
+
@on("post-deleted")
|
|
76
|
+
async handlePostDeleted(data: { id: number }): Promise<void> {
|
|
77
|
+
this.posts = this.posts.filter((p) => p.id !== data.id);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
override async render() {
|
|
81
|
+
return (
|
|
82
|
+
<ul>
|
|
83
|
+
{this.posts.map((p) => (
|
|
84
|
+
<li key={String(p.id)}>{p.title}</li>
|
|
85
|
+
))}
|
|
86
|
+
</ul>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A component can have as many `@on` listeners as it needs. Each fires independently in the order events are dispatched.
|
|
93
|
+
|
|
94
|
+
## Multiple listeners for the same event
|
|
95
|
+
|
|
96
|
+
Several components on the same page can all listen for the same event. Each component is updated independently — Flow sends a separate patch frame to each listener:
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
// PostList.tsx
|
|
100
|
+
@on("post-created")
|
|
101
|
+
async onPostCreated(data: { id: number }) {
|
|
102
|
+
this.posts = await Post.query().orderBy("created_at", "desc").limit(10).get();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// PostCount.tsx (different component, same page)
|
|
106
|
+
@on("post-created")
|
|
107
|
+
async onPostCreated() {
|
|
108
|
+
this.count++;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ActivityFeed.tsx (different component, same page)
|
|
112
|
+
@on("post-created")
|
|
113
|
+
async onPostCreated(data: { id: number; title: string }) {
|
|
114
|
+
this.activities.unshift({ type: "post", title: data.title, at: new Date() });
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
All three components update after a single `this.dispatch("post-created", ...)` call.
|
|
119
|
+
|
|
120
|
+
## Dispatching from the browser
|
|
121
|
+
|
|
122
|
+
The same `dispatch`, `dispatchTo`, and `dispatchSelf` methods work inside **client expressions** — no server round-trip needed to start the dispatch. The `@on` listeners still run server-side when they're notified:
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
{
|
|
126
|
+
/* Notify all listeners without a preceding server action */
|
|
127
|
+
}
|
|
128
|
+
<button onClick={() => this.dispatch("sidebar-opened")}>Open sidebar</button>;
|
|
129
|
+
|
|
130
|
+
{
|
|
131
|
+
/* Notify only the Sidebar component */
|
|
132
|
+
}
|
|
133
|
+
<button onClick={() => this.dispatchTo("Sidebar", "refresh")}>Refresh sidebar</button>;
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
/* Self-reset */
|
|
137
|
+
}
|
|
138
|
+
<button onClick={() => this.dispatchSelf("reset")}>Reset</button>;
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
This pattern is useful when the dispatch itself doesn't require a server round-trip, but the listener's response does.
|
|
142
|
+
|
|
143
|
+
## Type-safe events
|
|
144
|
+
|
|
145
|
+
Events are string-keyed at runtime, but their payloads can be type-checked end-to-end — no codegen. Declare a contract by augmenting the `FlowEvents` interface (one `.d.ts` in your app), mapping each event name to its payload type:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// app/flow-events.d.ts
|
|
149
|
+
import "@zerotal/flow";
|
|
150
|
+
|
|
151
|
+
declare module "@zerotal/flow" {
|
|
152
|
+
interface FlowEvents {
|
|
153
|
+
"post-created": { id: number; title: string };
|
|
154
|
+
"cart-cleared": void; // no payload
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Every `dispatch` / `dispatchTo` / `dispatchSelf` site is now checked against it — in server actions **and** client expressions, since they call the same typed method:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
this.dispatch("post-created", { id: post.id, title: post.title }); // ✓
|
|
163
|
+
this.dispatch("post-created", { id: post.id }); // ✗ missing `title`
|
|
164
|
+
this.dispatch("post-created"); // ✗ payload required
|
|
165
|
+
this.dispatch("cart-cleared"); // ✓ void → no payload
|
|
166
|
+
this.dispatch("cart-cleared", { anything: 1 }); // ✗ void takes no payload
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
On the listener side, `@on` autocompletes to the known event names, and you annotate the handler's parameter with `EventPayload<K>` to type the payload against the same contract:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import { on } from "@zerotal/flow";
|
|
173
|
+
import type { EventPayload } from "@zerotal/flow";
|
|
174
|
+
|
|
175
|
+
@on("post-created")
|
|
176
|
+
async onPostCreated(data: EventPayload<"post-created">) {
|
|
177
|
+
this.latest = await Post.findOrFail(data.id); // data is { id: number; title: string }
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Adoption is gradual and non-breaking: any event name **not** in the contract stays untyped (a plain optional-payload call), so existing events and `@on("echo:…")` broadcasts keep compiling — you type the ones you care about, when you care about them.
|
|
182
|
+
|
|
183
|
+
**Runtime guard (optional).** The types cover your own dispatch sites at compile time. For a payload that arrives from an untrusted source — a client-originated dispatch — register a runtime guard; a violating payload then throws from `dispatch` instead of reaching listeners:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { registerFlowEvent } from "@zerotal/flow";
|
|
187
|
+
|
|
188
|
+
registerFlowEvent(
|
|
189
|
+
"post-created",
|
|
190
|
+
(p): p is { id: number; title: string } => typeof (p as { id?: unknown })?.id === "number",
|
|
191
|
+
);
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Native window events
|
|
195
|
+
|
|
196
|
+
Every dispatched event is also emitted as a native `flow:<name>` event on `window`. Alpine and plain JavaScript can listen:
|
|
197
|
+
|
|
198
|
+
```html
|
|
199
|
+
<!-- Alpine listener -->
|
|
200
|
+
<div x-on:flow:post-created.window="latestTitle = $event.detail.title">
|
|
201
|
+
Latest: <span x-text="latestTitle"></span>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<!-- Vanilla JS -->
|
|
205
|
+
<script>
|
|
206
|
+
window.addEventListener("flow:post-created", (e) => {
|
|
207
|
+
console.log("Post created:", e.detail);
|
|
208
|
+
});
|
|
209
|
+
</script>
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The `detail` property of the event contains the payload passed to `dispatch()`. This is useful when you need to react in JavaScript code outside the Flow component tree.
|
|
213
|
+
|
|
214
|
+
## Calling the parent directly
|
|
215
|
+
|
|
216
|
+
When a child component just needs to invoke a parent action, use `$flow.parent` instead of events — it's more direct and avoids polluting the global event space:
|
|
217
|
+
|
|
218
|
+
```tsx
|
|
219
|
+
{
|
|
220
|
+
/* In the child component's template: */
|
|
221
|
+
}
|
|
222
|
+
<button onClick={() => $flow.parent.showCreateForm()}>New post</button>;
|
|
223
|
+
|
|
224
|
+
{
|
|
225
|
+
/* Read the parent's exposed state: */
|
|
226
|
+
}
|
|
227
|
+
<span x-text="$flow.parent.title" />;
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`$flow.parent.method(args)` dispatches the action to the nearest ancestor component in the DOM tree. It resolves to the live client parent state; from a server action, use events instead.
|
|
231
|
+
|
|
232
|
+
## Real-time broadcasting
|
|
233
|
+
|
|
234
|
+
Listen for server-broadcast events over WebSockets with `@on("echo:…")`. When a matching broadcast arrives, the listener method runs server-side exactly like any other action — the component re-renders live.
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
export class OrderDashboard extends Component {
|
|
238
|
+
@locked orderCount: number = 0;
|
|
239
|
+
@locked recentOrders: Order[] = [];
|
|
240
|
+
|
|
241
|
+
override async onMount() {
|
|
242
|
+
this.orderCount = await Order.count();
|
|
243
|
+
this.recentOrders = await Order.query().orderBy("created_at", "desc").limit(5).get();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
@on("echo:orders,OrderPlaced")
|
|
247
|
+
async onOrderPlaced(payload: { id: number; total: number }): Promise<void> {
|
|
248
|
+
this.orderCount++;
|
|
249
|
+
const order = await Order.findOrFail(payload.id);
|
|
250
|
+
this.recentOrders = [order, ...this.recentOrders.slice(0, 4)];
|
|
251
|
+
this.flash(`New order — $${payload.total}`, "success");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
@on("echo-private:orders.${this.branchId},OrderCancelled")
|
|
255
|
+
async onOrderCancelled(payload: { id: number }): Promise<void> {
|
|
256
|
+
this.recentOrders = this.recentOrders.filter((o) => o.id !== payload.id);
|
|
257
|
+
this.orderCount = Math.max(0, this.orderCount - 1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
override async render() {
|
|
261
|
+
return (
|
|
262
|
+
<div>
|
|
263
|
+
<h2>Orders today: {this.orderCount}</h2>
|
|
264
|
+
<ul>
|
|
265
|
+
{this.recentOrders.map((o) => (
|
|
266
|
+
<li key={String(o.id)}>#{o.id} — ${o.total}</li>
|
|
267
|
+
))}
|
|
268
|
+
</ul>
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Channel name formats
|
|
276
|
+
|
|
277
|
+
| Format | Channel type |
|
|
278
|
+
| ---------------------------------- | --------------------------------------- |
|
|
279
|
+
| `echo:channel,Event` | Public channel |
|
|
280
|
+
| `echo-private:channel,Event` | Private channel (requires auth) |
|
|
281
|
+
| `echo-presence:room,joining` | Presence channel — member joined |
|
|
282
|
+
| `echo-presence:room,leaving` | Presence channel — member left |
|
|
283
|
+
| `echo-presence:room,here` | Presence channel — initial member list |
|
|
284
|
+
| `echo:teams.1.threads,MessageSent` | Dot-separated dynamic/nested channel |
|
|
285
|
+
| `echo:scores,.score.submitted` | Custom `broadcastAs` name (leading dot) |
|
|
286
|
+
|
|
287
|
+
The part before the comma is the channel name; the part after is the event name. For presence channels, `joining`, `leaving`, and `here` are the built-in presence event names.
|
|
288
|
+
|
|
289
|
+
### Requirements
|
|
290
|
+
|
|
291
|
+
Broadcasting requires a global `window.Echo` client configured by your application — the first-party `@zerotal/client` `Socket`, or any compatible realtime client. Flow subscribes through it on component mount and unsubscribes on teardown.
|
|
292
|
+
|
|
293
|
+
If `window.Echo` is not present, all `echo:` listeners are silently inert — no errors, no subscriptions attempted.
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
// In your frontend bootstrap (app.ts or similar):
|
|
297
|
+
import { Socket } from "@zerotal/client";
|
|
298
|
+
|
|
299
|
+
// The first-party Socket speaks Zerotal's native broadcast protocol and is a
|
|
300
|
+
// drop-in for `window.Echo` — no external client library or Pusher credentials.
|
|
301
|
+
window.Echo = new Socket();
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Presence — who's here (multiplayer)
|
|
305
|
+
|
|
306
|
+
`@presence` binds a property to a broadcast **presence channel** and keeps it filled with the live member list — the framework joins the channel, seeds the list, and refreshes it as people join and leave. No event classes, no manual `@on` wiring:
|
|
307
|
+
|
|
308
|
+
```tsx
|
|
309
|
+
import { Component, presence } from "@zerotal/flow";
|
|
310
|
+
import type { PresenceMember } from "@zerotal/flow";
|
|
311
|
+
|
|
312
|
+
export class Board extends Component {
|
|
313
|
+
@locked boardId = "";
|
|
314
|
+
// Static channel, or a resolver for a dynamic room:
|
|
315
|
+
@presence((self) => `board.${self.boardId}`) who: PresenceMember[] = [];
|
|
316
|
+
|
|
317
|
+
override async render() {
|
|
318
|
+
return (
|
|
319
|
+
<div>
|
|
320
|
+
{this.who.map((m) => (
|
|
321
|
+
<Avatar key={String(m.id)} name={m.name} />
|
|
322
|
+
))}
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
The channel is resolved on the server from the component (so it can't be forged from the client) and carried, signed, in the snapshot. `who` is server-controlled (like `@locked`): it lives in the snapshot and the client can't write it. Authorize the channel — and shape the member data — in `routes/channels.ts`:
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
Broadcast.channel("board.[boardId]", (user, boardId) =>
|
|
333
|
+
user.canView(boardId) ? { id: user.id, name: user.name } : null,
|
|
334
|
+
);
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
**Cursors & typing indicators (ephemeral state).** For high-frequency state that should never hit the server or the database — a cursor position, "is typing" — use `$flow.whisper(event, data)` to broadcast to the other members of the component's presence channel, and `$flow.onWhisper(event, cb)` to receive:
|
|
338
|
+
|
|
339
|
+
```tsx
|
|
340
|
+
<div
|
|
341
|
+
onPointerMove={(e) => $flow.whisper("cursor", { x: e.clientX, y: e.clientY })}
|
|
342
|
+
x-init="$flow.onWhisper('cursor', (p) => renderPeerCursor(p))"
|
|
343
|
+
/>
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Whispers are client-only (they ride the presence channel directly), so they're instant and don't count as component round-trips.
|
|
347
|
+
|
|
348
|
+
Like all `echo:` features, presence needs a `window.Echo` client configured (above). Without it, `@presence` props stay empty and whispers are inert — no errors.
|
|
349
|
+
|
|
350
|
+
## Shared state — everyone converges (multiplayer)
|
|
351
|
+
|
|
352
|
+
Where `@presence` answers _who's here_, `@shared` answers _what do we all see_. It binds a property to convergent, **server-authoritative** state on a channel: mutate it in an action and the framework writes it to a per-channel **room store** and broadcasts to the channel, so every other subscriber re-reads and converges. No store to wire, no events, no dispatch:
|
|
353
|
+
|
|
354
|
+
```tsx
|
|
355
|
+
import { Component, presence, shared, expose } from "@zerotal/flow";
|
|
356
|
+
|
|
357
|
+
export class Board extends Component {
|
|
358
|
+
@locked boardId = "";
|
|
359
|
+
// The same channel can carry both who's-here and shared state.
|
|
360
|
+
@presence((self) => `board.${self.boardId}`) who: PresenceMember[] = [];
|
|
361
|
+
@shared((self) => `board.${self.boardId}`) cards: Card[] = [];
|
|
362
|
+
|
|
363
|
+
@expose addCard(card: Card): void {
|
|
364
|
+
this.cards.push(card); // that's it — written to the room store + broadcast to the channel
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
The mental model: a `@shared` prop is a **cache of a server-side room value**, not a per-connection snapshot field. Before every action the prop is refilled from the room store (read-latest), so your action operates on the converged value; after the action, any `@shared` prop it changed is written back and broadcast. Other windows receive the broadcast and re-read — last-write-wins, server-authoritative.
|
|
370
|
+
|
|
371
|
+
Like `@presence`, the channel is resolved on the server (signed in the snapshot, unforgeable) and the prop is server-controlled (`@locked`): clients render it but change it only through `@expose` actions. Authorize the channel in `routes/channels.ts` exactly as for presence.
|
|
372
|
+
|
|
373
|
+
Broadcasting is an **optional peer**. With `window.Echo` and `BroadcastProvider` configured, changes fan out to every open window; without them, `@shared` still converges within a single window's own round-trips, because the room store is server-side either way. For multi-instance deployments, swap the in-process store for a shared backend with `setSharedStore(store)` (any `{ get, set, has }`), e.g. Redis-backed — the convergence logic is unchanged.
|
|
374
|
+
|
|
375
|
+
> v1 semantics are last-write-wins and server-authoritative; `@shared` props should hold plain, serializable data (arrays/objects), like snapshot state generally. The originating window also receives its own change broadcast as an idempotent no-op re-read (self-exclusion is a planned refinement).
|
|
376
|
+
|
|
377
|
+
## The refresh method
|
|
378
|
+
|
|
379
|
+
`this.refresh()` re-renders the component and, crucially, re-runs `onMount()` — allowing you to reload data without a full page navigation.
|
|
380
|
+
|
|
381
|
+
### In a server action
|
|
382
|
+
|
|
383
|
+
Calling `this.refresh()` inside an action inserts `onMount()` back into the WebSocket round-trip cycle:
|
|
384
|
+
|
|
385
|
+
```typescript
|
|
386
|
+
@expose async syncOrders(): Promise<void> {
|
|
387
|
+
await OrderSync.run();
|
|
388
|
+
this.refresh(); // onMount() re-runs → this.orders is freshly loaded
|
|
389
|
+
this.flash("Orders synced.");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
@expose async deletePost(id: number): Promise<void> {
|
|
393
|
+
await Post.where("id", id).delete();
|
|
394
|
+
this.refresh(); // reload the posts list
|
|
395
|
+
this.flash("Post deleted.");
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### In a client expression
|
|
400
|
+
|
|
401
|
+
In the browser, `this.refresh()` sends a lightweight re-render request with no data change — useful for polling or a manual reload button:
|
|
402
|
+
|
|
403
|
+
```tsx
|
|
404
|
+
{
|
|
405
|
+
/* Manual reload button */
|
|
406
|
+
}
|
|
407
|
+
<button onClick={() => this.refresh()}>Reload</button>;
|
|
408
|
+
|
|
409
|
+
{
|
|
410
|
+
/* Auto-poll every 30 seconds */
|
|
411
|
+
}
|
|
412
|
+
<div poll={{ every: "30s", action: this.refresh }}>{/* content refreshes automatically */}</div>;
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
`this.refresh()` inside a `poll` attribute triggers `onMount()` on each poll tick, so the component always shows fresh data without any page navigation.
|
|
416
|
+
|
|
417
|
+
## Next steps
|
|
418
|
+
|
|
419
|
+
- [Flow overview](/docs/flow) — the guide's front page and the rest of the sections.
|
|
420
|
+
- [Reference](/docs/flow/references) — every decorator, prop, and directive in one table.
|