@zerotal/arch 1.7.0 → 1.7.2
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 +57 -0
- package/docs/broadcasting/channels.md +1 -1
- package/docs/broadcasting/client.md +17 -4
- package/docs/broadcasting/events.md +2 -2
- package/docs/broadcasting/index.md +1 -1
- package/docs/changelog.md +46 -0
- package/docs/commands.md +21 -12
- package/docs/flow/components.md +23 -0
- package/docs/flow/decorators.md +17 -5
- package/docs/flow/events.md +51 -17
- package/docs/flow/index.md +8 -1
- package/docs/flow/layouts.md +16 -0
- package/docs/flow/performance.md +27 -1
- package/docs/flow/references.md +15 -0
- package/docs/flow/testing.md +32 -0
- package/docs/getting-started.md +25 -0
- package/docs/i18n.md +248 -145
- package/docs/notifications.md +1 -1
- package/docs/queue.md +11 -3
- package/docs/routing.md +78 -7
- package/docs/seeding.md +7 -0
- package/package.json +3 -3
- package/src/install/guidelines.ts +5 -1
- package/src/install/markers.ts +11 -2
- package/src/install/mcpConfig.ts +14 -2
- package/src/probe/topics.ts +19 -4
- package/src/tools/apiSurface.ts +115 -20
- package/src/tools/baselines.ts +33 -4
- package/src/tools/logs.ts +77 -7
- package/src/tools/searchDocs.ts +196 -29
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,63 @@ All notable changes to this package are documented here. The format is
|
|
|
7
7
|
**Maturity: beta.** The API is close to final and breaking changes are rare, called out
|
|
8
8
|
here with migration steps — but a minor release may still contain one.
|
|
9
9
|
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
## [1.7.1] — 2026-08-16
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
Faults that only appear once the surface is installed into a real app, found by installing
|
|
17
|
+
it into this repo's own `apps/docs` and calling every tool.
|
|
18
|
+
|
|
19
|
+
- **`search_docs` ranked a generated page above the right answer.** Scoring was raw term
|
|
20
|
+
frequency weighted by field, with no length normalisation — so `components.md`, one
|
|
21
|
+
generated page covering 53 components and long enough to mention nearly everything, came
|
|
22
|
+
first for both "send an email" and "how do I write a test for a controller". Now BM25 over
|
|
23
|
+
a small inverted index built when the corpus is read: length normalisation, inverse
|
|
24
|
+
document frequency and term saturation, with title/description/heading matches scored as
|
|
25
|
+
separate fields _outside_ the saturation, since folded in BM25 flattens a title hit to
|
|
26
|
+
about twice a passing mention. Plus light stemming, so "test" meets "testing", and
|
|
27
|
+
hyphenated terms indexed whole and in parts, so "soft deletes" finds `soft-delete`.
|
|
28
|
+
|
|
29
|
+
Measured on fourteen questions an agent would actually ask, top-1 relevance went from
|
|
30
|
+
roughly three in ten to twelve in fourteen. The remaining two return related pages rather
|
|
31
|
+
than the best one; tuning further against a list that size fits the list, not the corpus.
|
|
32
|
+
|
|
33
|
+
- **`last_error` dropped the error.** The framework logs an exception's class in `error`,
|
|
34
|
+
its trace in `stack` and the request it belongs to in `requestId`. The parser named the
|
|
35
|
+
six fields it knew about and discarded the rest, so the tool whose entire job is saying
|
|
36
|
+
_why_ something failed returned the generic `"Unhandled error"` that wraps the real one —
|
|
37
|
+
a line that says nothing. Entries now carry every field the logger wrote, and
|
|
38
|
+
`last_error` renders the exception, the request id and the trace. `logs` shows the first
|
|
39
|
+
two but not the trace: a stack on each of two hundred entries buries the sequence the
|
|
40
|
+
caller asked for.
|
|
41
|
+
|
|
42
|
+
- **`baselines` reported a ceiling smaller than the command's count, without saying so.**
|
|
43
|
+
The cast baseline ratchets per file and exempts designated boundary modules, so it reads
|
|
44
|
+
455 where `cast:check` prints 466. A reader comparing the two saw debt that had appeared
|
|
45
|
+
between them. The reading now carries a `note` naming the exempt modules.
|
|
46
|
+
|
|
47
|
+
- **Packages in a workspace were invisible.** `node_modules/@zerotal/*` is a symlink in
|
|
48
|
+
every workspace — this monorepo, `bun link`, any app developed against a checkout — and
|
|
49
|
+
`Bun.Glob` will not descend into one, `followSymlinks` or not. `installedPackages()`
|
|
50
|
+
returned nothing for an app with seventeen packages, so `app_info` reported an empty list
|
|
51
|
+
and `arch:install` wrote generic guidance with none of the per-package sections that are
|
|
52
|
+
the reason it is composed rather than canned. Listed with `readdir` now, which sees the
|
|
53
|
+
link, and read through `Bun.file`, which follows it.
|
|
54
|
+
|
|
55
|
+
- **The generated Markdown failed the formatter.** `arch:install` wrote files that did not
|
|
56
|
+
pass the `prettier --check .` the project it had just installed into already runs. The
|
|
57
|
+
markers now sit on their own lines with blank lines around them — which is correctness
|
|
58
|
+
before it is formatting, since Markdown parses text pressed against an HTML comment as
|
|
59
|
+
part of that raw-HTML block.
|
|
60
|
+
|
|
61
|
+
- **`arch:update` fought the formatter over `.mcp.json`.** `JSON.stringify(…, 2)` expands a
|
|
62
|
+
one-element array where a formatter collapses it, so the command rewrote a file it had no
|
|
63
|
+
change to make to, and the next `prettier --write` put it back. Idempotence is now
|
|
64
|
+
measured on the parsed data: when the config already says what it should, the file is
|
|
65
|
+
returned exactly as it was found, in whatever shape its owner keeps it.
|
|
66
|
+
|
|
10
67
|
## [1.7.0] — 2026-08-16
|
|
11
68
|
|
|
12
69
|
### Added
|
|
@@ -122,7 +122,7 @@ the wire; a Pusher-protocol client surfaces them to your code under the `pusher:
|
|
|
122
122
|
|
|
123
123
|
> **Note** — The native `ws`/`redis` drivers use the equivalent
|
|
124
124
|
> `subscription_succeeded` / `presence:member_added` / `presence:member_removed` events; the
|
|
125
|
-
> first-party `Socket` client maps them to
|
|
125
|
+
> first-party `Socket` client maps them to Socket's `here`/`joining`/`leaving` callbacks for you.
|
|
126
126
|
|
|
127
127
|
## Typed channels
|
|
128
128
|
|
|
@@ -9,8 +9,20 @@ description: Subscribe from the browser and react to events as they arrive.
|
|
|
9
9
|
|
|
10
10
|
`@zerotal/client` ships a small, dependency-free `Socket` that speaks the native broadcast
|
|
11
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).
|
|
13
|
-
|
|
12
|
+
with the lightweight `ws` and `redis` drivers (no Pusher credentials needed).
|
|
13
|
+
|
|
14
|
+
**Flow apps need none of this.** The client is bundled into `/__flow/runtime.js` and created the
|
|
15
|
+
first time a page declares a [`@on('socket:…')`](/docs/flow/events) listener, so those listeners
|
|
16
|
+
are live with no script of your own. Read on only if you want a configured client — a different
|
|
17
|
+
host, your own auth endpoint — or if you are subscribing from code that is not a Flow component.
|
|
18
|
+
|
|
19
|
+
> **The package root is fine to import in browser code.** It used to be a bundle error: the root
|
|
20
|
+
> also exports `ClientProvider`, which reaches the CLI commands and `await import("bun")`, and a
|
|
21
|
+
> browser bundler rejects that during resolution — before tree-shaking can discard the half you
|
|
22
|
+
> did not want. `@zerotal/client` now resolves to a browser-safe entry under the `browser`
|
|
23
|
+
> condition, so a bundler gets `Socket`, `ApiClient` and `CircuitBreaker` and none of the
|
|
24
|
+
> server-side exports. `@zerotal/client/Socket` still works and is still the leanest import if
|
|
25
|
+
> `Socket` is all you need.
|
|
14
26
|
|
|
15
27
|
```ts
|
|
16
28
|
// in your client code
|
|
@@ -34,8 +46,9 @@ socket
|
|
|
34
46
|
.leaving((m) => removeOnline(m))
|
|
35
47
|
.listen("Message", (e) => append(e));
|
|
36
48
|
|
|
37
|
-
//
|
|
38
|
-
|
|
49
|
+
// Only if you need Flow's listeners to use *this* client rather than the bundled
|
|
50
|
+
// one — assign before the runtime loads and it is used as-is:
|
|
51
|
+
window.Socket = socket;
|
|
39
52
|
```
|
|
40
53
|
|
|
41
54
|
Private and presence channels are authorized with a **per-subscription HMAC signature** (the same
|
|
@@ -64,7 +64,7 @@ Broadcast.send(new OrderShipmentStatusUpdated(order));
|
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
`broadcast(event).toOthers()` excludes the connection that triggered the request (read from the
|
|
67
|
-
`X-Socket-ID` header your
|
|
67
|
+
`X-Socket-ID` header your Socket client sends), so the user who just made an optimistic UI update
|
|
68
68
|
doesn't receive a duplicate.
|
|
69
69
|
|
|
70
70
|
`broadcast()` returns a `PendingBroadcast` — a thenable that sends itself on the next
|
|
@@ -181,7 +181,7 @@ The wire event name defaults to `${ModelName}${Event}` (e.g. `OrderUpdated`) and
|
|
|
181
181
|
|
|
182
182
|
```ts
|
|
183
183
|
// in your client code
|
|
184
|
-
|
|
184
|
+
Socket.private(`orders.${id}`).listen("OrderUpdated", (e) => render(e.order));
|
|
185
185
|
```
|
|
186
186
|
|
|
187
187
|
## Next steps
|
|
@@ -50,7 +50,7 @@ The provider exposes one HTTP route and one WebSocket upgrade path:
|
|
|
50
50
|
|
|
51
51
|
- `POST /broadcasting/auth` — the private/presence channel auth endpoint (a real `Router` route).
|
|
52
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/
|
|
53
|
+
WebSocket handler — it is _not_ a separate `Router` route. Pusher/Socket clients connect to
|
|
54
54
|
`ws://host/app/{appKey}`.
|
|
55
55
|
|
|
56
56
|
## Configuration
|
package/docs/changelog.md
CHANGED
|
@@ -24,6 +24,52 @@ Each version lists changes under three headings:
|
|
|
24
24
|
Patch and minor releases are backward compatible. Before taking a **major** release,
|
|
25
25
|
read its section here and apply each migration note.
|
|
26
26
|
|
|
27
|
+
## 1.7.2 — 2026-08-18
|
|
28
|
+
|
|
29
|
+
Realtime that works without being wired up, and three ways a socket could go quiet without
|
|
30
|
+
saying so.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **BREAKING — Flow's `@on` broadcast listeners use a `socket:` prefix.** `echo:`,
|
|
35
|
+
`echo-private:` and `echo-presence:` are now `socket:`, `socket-private:` and
|
|
36
|
+
`socket-presence:`; the browser global is `window.Socket`, not `window.Echo`. There is no
|
|
37
|
+
alias — an unrenamed listener never matches, and never subscribes.
|
|
38
|
+
|
|
39
|
+
```diff
|
|
40
|
+
- @on("echo-private:issues.5,CommentPosted")
|
|
41
|
+
+ @on("socket-private:issues.5,CommentPosted")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Shipped in a patch release deliberately, on the judgement that the old prefix has no
|
|
45
|
+
meaningful use in the wild. If you are on it, the upgrade is a find-and-replace of `echo:`
|
|
46
|
+
→ `socket:` in your `@on` listeners and `window.Echo` → `window.Socket` in any client code.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **Flow bundles the socket client into its runtime.** A page that declares a `socket:`
|
|
51
|
+
listener is live with no script of your own. Flow apps own no bundle entry, so the contract
|
|
52
|
+
used to be "publish `window.Socket` yourself" — and when you didn't, the listeners were
|
|
53
|
+
*silently inert*: no error, no warning, no subscription, so a live feature with no script
|
|
54
|
+
looked exactly like a live feature that was never written. An app that needs a configured
|
|
55
|
+
client still assigns `window.Socket` before the runtime loads and that one is used as-is; a
|
|
56
|
+
page with no listeners opens no connection at all.
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- **A patch no longer writes back into a file input.** A file input's `value` belongs to the
|
|
61
|
+
user agent, and assigning anything but `""` throws `InvalidStateError`. The write was legal
|
|
62
|
+
while the bound property was empty and threw on the very patch carrying an upload's result
|
|
63
|
+
— and the throw escaped the frame handler, so the DOM never updated *and* the action's ack
|
|
64
|
+
never resolved. Since frames are chained per component, every later action queued behind a
|
|
65
|
+
promise that would never settle: the page rendered correctly and ignored every click for
|
|
66
|
+
the rest of its life.
|
|
67
|
+
|
|
68
|
+
- **WebSocket connections get an explicit 120s `idleTimeout`.** Bun closes an idle socket
|
|
69
|
+
after 10 seconds; the client pings every 30. A connection that was merely quiet got cut
|
|
70
|
+
before it had reason to speak, taking its channel subscriptions with it — so anyone who had
|
|
71
|
+
been reading a page for more than ten seconds silently stopped receiving broadcasts.
|
|
72
|
+
|
|
27
73
|
## 1.7.0 — 2026-08-16
|
|
28
74
|
|
|
29
75
|
The agent surface, a DevTools panel that shows the framework and not just the last request,
|
package/docs/commands.md
CHANGED
|
@@ -210,23 +210,32 @@ running, restarting, or has given up:
|
|
|
210
210
|
─────────────────────────────────────────────────
|
|
211
211
|
GET / 200 4ms
|
|
212
212
|
GET /posts 200 11ms
|
|
213
|
-
1-9 tab · ←/→ cycle · r restart · c clear · / search · t time · s stream · q quit
|
|
213
|
+
1-9 tab · ←/→ cycle · ↑/↓ scroll · r restart · c clear · / search · t time · s stream · q quit
|
|
214
214
|
```
|
|
215
215
|
|
|
216
|
-
| Key | Does
|
|
217
|
-
| ------------- |
|
|
218
|
-
| `1`–`9` | Select that tab
|
|
219
|
-
| `←` `→` `Tab` | Cycle through tabs
|
|
220
|
-
|
|
|
221
|
-
| `
|
|
222
|
-
|
|
|
223
|
-
| `
|
|
224
|
-
| `
|
|
225
|
-
|
|
|
226
|
-
| `
|
|
216
|
+
| Key | Does |
|
|
217
|
+
| ------------- | ----------------------------------------------------------------- |
|
|
218
|
+
| `1`–`9` | Select that tab |
|
|
219
|
+
| `←` `→` `Tab` | Cycle through tabs |
|
|
220
|
+
| `↑` `↓` | Scroll the focused tab a line at a time — as does the mouse wheel |
|
|
221
|
+
| `PgUp` `PgDn` | Scroll it a screen at a time |
|
|
222
|
+
| `Home` `End` | Jump to the oldest line, or back to the newest |
|
|
223
|
+
| `r` | Restart the focused process |
|
|
224
|
+
| `c` | Clear the focused tab's output |
|
|
225
|
+
| `/` | Search within the focused tab (`Enter` keeps it, `Esc` drops it) |
|
|
226
|
+
| `t` | Toggle per-line timestamps |
|
|
227
|
+
| `s` | Switch to stream mode |
|
|
228
|
+
| `q` | Quit — stops every process and restores your shell |
|
|
227
229
|
|
|
228
230
|
Scrollback belongs to the deck rather than to your terminal, which is what makes
|
|
229
231
|
per-tab history and search possible. It keeps the last 5,000 lines per process.
|
|
232
|
+
Your terminal's own scrollbar does nothing while the deck is up — it has no
|
|
233
|
+
history to move, because the deck holds it all. Use the keys above (or the
|
|
234
|
+
wheel, which the terminal sends the deck as `↑`/`↓`).
|
|
235
|
+
|
|
236
|
+
Scrolling up parks the view where you left it: the process behind the tab keeps
|
|
237
|
+
printing, but what you stopped to read stays on screen until you scroll back
|
|
238
|
+
down to the newest line.
|
|
230
239
|
|
|
231
240
|
**A process that dies never takes the server with it.** It restarts on its own —
|
|
232
241
|
three times, backing off between attempts — and if it still will not start, that
|
package/docs/flow/components.md
CHANGED
|
@@ -29,6 +29,13 @@ import { Link } from "@zerotal/flow";
|
|
|
29
29
|
Posts
|
|
30
30
|
</Link>;
|
|
31
31
|
|
|
32
|
+
{
|
|
33
|
+
/* Prefetch on pointer-down instead — for a row in a long list */
|
|
34
|
+
}
|
|
35
|
+
<Link href="/posts/42" down>
|
|
36
|
+
One post
|
|
37
|
+
</Link>;
|
|
38
|
+
|
|
32
39
|
{
|
|
33
40
|
/* Disable automatic data-current (e.g. always-active home links) */
|
|
34
41
|
}
|
|
@@ -37,6 +44,14 @@ import { Link } from "@zerotal/flow";
|
|
|
37
44
|
</Link>;
|
|
38
45
|
```
|
|
39
46
|
|
|
47
|
+
Choose between `hover` and `down` by how many of the link there are. `hover` prefetches after a
|
|
48
|
+
short dwell, which is free speed on a handful of stable links — a navigation rail, a breadcrumb.
|
|
49
|
+
On a dense list it inverts: the pointer crosses every row between where it is and where it is
|
|
50
|
+
going, so scrolling a hundred-row table asks the server for a hundred pages nobody chose. `down`
|
|
51
|
+
fires on `pointerdown` instead — once, on the link the reader has committed to, and still ahead
|
|
52
|
+
of the click by however long the button is held. Both may be set; the target is fetched once and
|
|
53
|
+
cached either way.
|
|
54
|
+
|
|
40
55
|
`data-current` matches by **prefix** — a link to `/posts` stays active on `/posts/42` — which is what you want for a section parent. For an index link that should be active only on its own exact URL (an "Overview" tab that shouldn't light up on the section's sub-pages), add `exact`:
|
|
41
56
|
|
|
42
57
|
```tsx
|
|
@@ -456,6 +471,14 @@ import { Tabs } from "@zerotal/flow";
|
|
|
456
471
|
|
|
457
472
|
`<Tabs>` emits `role="tablist"` / `"tab"` / `"tabpanel"` with roving arrow-key navigation.
|
|
458
473
|
|
|
474
|
+
> **Note** — selection is client-only and not addressable. The active tab lives in Alpine state,
|
|
475
|
+
> always starts on the first item, and there is no prop to bind or read it, so `?tab=settings`
|
|
476
|
+
> cannot be made to work and the back button does not step between panels. The tab strip's own
|
|
477
|
+
> classes are fixed (`border-gray-800`, `text-white`, `border-indigo-500`); only the outer wrapper
|
|
478
|
+
> takes `class`, so on a light surface the selected tab is white on white. For a tab set that has
|
|
479
|
+
> to be linkable or has to match a design system, drive the panels from a `@url` property and
|
|
480
|
+
> write the strip out — it is a dozen lines, and you keep both.
|
|
481
|
+
|
|
459
482
|
## File upload component
|
|
460
483
|
|
|
461
484
|
### FileUpload + FileUploads mixin
|
package/docs/flow/decorators.md
CHANGED
|
@@ -133,9 +133,13 @@ You can also pass rules directly to `this.validate()` — they override the deco
|
|
|
133
133
|
|
|
134
134
|
### Real-time validation
|
|
135
135
|
|
|
136
|
-
When a `@validate` field is bound
|
|
136
|
+
When a `@validate` field is bound `live` (or `blur`), each change is validated on
|
|
137
137
|
the server as it arrives. The field's error appears (and clears)
|
|
138
|
-
as the user edits, with no action call and without affecting any other field
|
|
138
|
+
as the user edits, with no action call and without affecting any other field.
|
|
139
|
+
|
|
140
|
+
> **Note** — `live` is the prop you write; `flow:model.live` is what it compiles to. The compiled
|
|
141
|
+
> form is not writable in TSX: the `.` in an attribute name is a parse error (`TS1003`), so
|
|
142
|
+
> copying it out of the emitted HTML into a component will not build.
|
|
139
143
|
|
|
140
144
|
```tsx
|
|
141
145
|
@expose @validate((rule) => rule.required().email()) email = "";
|
|
@@ -143,7 +147,7 @@ as the user edits, with no action call and without affecting any other field:
|
|
|
143
147
|
async render() {
|
|
144
148
|
return (
|
|
145
149
|
<div>
|
|
146
|
-
<input type="email" value={this.email}
|
|
150
|
+
<input type="email" value={this.email} live />
|
|
147
151
|
<span error={this.errors.email} />
|
|
148
152
|
</div>
|
|
149
153
|
);
|
|
@@ -289,11 +293,18 @@ async refreshUser(data: { userId: number }): Promise<void> {
|
|
|
289
293
|
}
|
|
290
294
|
|
|
291
295
|
// Listen for real-time WebSocket broadcasts (see Events doc for channel formats)
|
|
292
|
-
@on("
|
|
296
|
+
@on("socket:orders,OrderPlaced")
|
|
293
297
|
async onOrderPlaced(payload: { id: number }): Promise<void> {
|
|
294
298
|
this.orderCount++;
|
|
295
299
|
this.flash("New order received!", "success");
|
|
296
300
|
}
|
|
301
|
+
|
|
302
|
+
// A channel naming a record needs a resolver — the string form is read off the
|
|
303
|
+
// class, so `"socket:orders.${this.id},…"` would subscribe to that literal text.
|
|
304
|
+
@on((self) => `socket-private:orders.${self.orderId},OrderCancelled`)
|
|
305
|
+
async onOrderCancelled(payload: { id: number }): Promise<void> {
|
|
306
|
+
this.orderCount--;
|
|
307
|
+
}
|
|
297
308
|
```
|
|
298
309
|
|
|
299
310
|
See [Events & Broadcasting](/docs/flow/events) for dispatch methods, targeting, broadcasting, and native event integration.
|
|
@@ -385,7 +396,8 @@ See [Layouts & Composition](/docs/flow/layouts#two-way-props) for the full `@mod
|
|
|
385
396
|
| `@expose method` | Callable from the browser via WebSocket |
|
|
386
397
|
| `@expose @renderless method` | Callable from browser; skips re-render cycle |
|
|
387
398
|
| `@on("event") method` | Listens for cross-component events (auto-exposed) |
|
|
388
|
-
| `@on("
|
|
399
|
+
| `@on("socket:channel,Event") method` | Listens for real-time server broadcasts |
|
|
400
|
+
| `@on((self) => "socket:…") method` | Same, with the channel resolved per instance (record ids) |
|
|
389
401
|
| `@reactive prop` | Child prop; parent re-pushes on change, child re-renders |
|
|
390
402
|
| `@modelable prop` | Two-way child prop; writes from child flow back to parent |
|
|
391
403
|
|
package/docs/flow/events.md
CHANGED
|
@@ -178,7 +178,7 @@ async onPostCreated(data: EventPayload<"post-created">) {
|
|
|
178
178
|
}
|
|
179
179
|
```
|
|
180
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("
|
|
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("socket:…")` broadcasts keep compiling — you type the ones you care about, when you care about them.
|
|
182
182
|
|
|
183
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
184
|
|
|
@@ -231,7 +231,9 @@ When a child component just needs to invoke a parent action, use `$flow.parent`
|
|
|
231
231
|
|
|
232
232
|
## Real-time broadcasting
|
|
233
233
|
|
|
234
|
-
Listen for server-broadcast events over WebSockets with `@on("
|
|
234
|
+
Listen for server-broadcast events over WebSockets with `@on("socket:…")`. When a matching broadcast arrives, the listener method runs server-side exactly like any other action — the component re-renders live.
|
|
235
|
+
|
|
236
|
+
The socket client is bundled into the Flow runtime and created the first time a page declares one of these listeners, so there is no script to add and nothing to publish on `window`. An app that needs a configured client — a different host, its own auth endpoint — assigns `window.Socket` before the runtime loads and that one is used instead. Pages with no such listener open no broadcast connection at all.
|
|
235
237
|
|
|
236
238
|
```typescript
|
|
237
239
|
export class OrderDashboard extends Component {
|
|
@@ -243,7 +245,7 @@ export class OrderDashboard extends Component {
|
|
|
243
245
|
this.recentOrders = await Order.query().orderBy("created_at", "desc").limit(5).get();
|
|
244
246
|
}
|
|
245
247
|
|
|
246
|
-
@on("
|
|
248
|
+
@on("socket:orders,OrderPlaced")
|
|
247
249
|
async onOrderPlaced(payload: { id: number; total: number }): Promise<void> {
|
|
248
250
|
this.orderCount++;
|
|
249
251
|
const order = await Order.findOrFail(payload.id);
|
|
@@ -251,7 +253,7 @@ export class OrderDashboard extends Component {
|
|
|
251
253
|
this.flash(`New order — $${payload.total}`, "success");
|
|
252
254
|
}
|
|
253
255
|
|
|
254
|
-
@on(
|
|
256
|
+
@on((self) => `socket-private:orders.${self.branchId},OrderCancelled`)
|
|
255
257
|
async onOrderCancelled(payload: { id: number }): Promise<void> {
|
|
256
258
|
this.recentOrders = this.recentOrders.filter((o) => o.id !== payload.id);
|
|
257
259
|
this.orderCount = Math.max(0, this.orderCount - 1);
|
|
@@ -276,29 +278,61 @@ export class OrderDashboard extends Component {
|
|
|
276
278
|
|
|
277
279
|
| Format | Channel type |
|
|
278
280
|
| ---------------------------------- | --------------------------------------- |
|
|
279
|
-
| `
|
|
280
|
-
| `
|
|
281
|
-
| `
|
|
282
|
-
| `
|
|
283
|
-
| `
|
|
284
|
-
| `
|
|
285
|
-
| `
|
|
281
|
+
| `socket:channel,Event` | Public channel |
|
|
282
|
+
| `socket-private:channel,Event` | Private channel (requires auth) |
|
|
283
|
+
| `socket-presence:room,joining` | Presence channel — member joined |
|
|
284
|
+
| `socket-presence:room,leaving` | Presence channel — member left |
|
|
285
|
+
| `socket-presence:room,here` | Presence channel — initial member list |
|
|
286
|
+
| `socket:teams.1.threads,MessageSent` | Dot-separated dynamic/nested channel |
|
|
287
|
+
| `socket:scores,.score.submitted` | Custom `broadcastAs` name (leading dot) |
|
|
286
288
|
|
|
287
289
|
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
290
|
|
|
291
|
+
### Per-instance channels
|
|
292
|
+
|
|
293
|
+
A channel that names a record — `issues.417`, `orders.8` — cannot be written as a string. The
|
|
294
|
+
decorator's argument is read off the **class**, before any instance exists, so a template literal
|
|
295
|
+
inside a plain string is not interpolated: `@on("socket-private:issues.${this.issueId},CommentPosted")`
|
|
296
|
+
subscribes to a channel whose name contains those characters, and receives nothing.
|
|
297
|
+
|
|
298
|
+
Pass a resolver instead. It is called with the component when the snapshot is built, exactly as
|
|
299
|
+
[`@presence`](#presence--whos-here-multiplayer) and [`@shared`](#shared-state--everyone-converges-multiplayer)
|
|
300
|
+
resolve theirs:
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
export class IssuePage extends Component {
|
|
304
|
+
@locked issue!: Issue;
|
|
305
|
+
@locked comments: Comment[] = [];
|
|
306
|
+
|
|
307
|
+
@on((self) => `socket-private:issues.${self.issue.id},CommentPosted`)
|
|
308
|
+
async onCommentPosted(payload: { comment: Comment }): Promise<void> {
|
|
309
|
+
this.comments = [...this.comments, payload.comment];
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
The resolver runs once per render, after `onMount()`, so it can read anything the component has
|
|
315
|
+
loaded. If it throws — a field it reads is still null, say — that one listener is dropped and the
|
|
316
|
+
page renders without it, rather than the render failing.
|
|
317
|
+
|
|
318
|
+
Resolve the *narrowest* channel the reader is entitled to. A static `issues` channel with an
|
|
319
|
+
`if (payload.issueId !== this.issue.id) return` in the handler looks equivalent and is not: the
|
|
320
|
+
broadcast still reaches every subscriber's browser, so every reader receives every issue's
|
|
321
|
+
comment bodies and discards them after the fact.
|
|
322
|
+
|
|
289
323
|
### Requirements
|
|
290
324
|
|
|
291
|
-
Broadcasting requires a global `window.
|
|
325
|
+
Broadcasting requires a global `window.Socket` 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
326
|
|
|
293
|
-
If `window.
|
|
327
|
+
If `window.Socket` is not present, all `socket:` listeners are silently inert — no errors, no subscriptions attempted.
|
|
294
328
|
|
|
295
329
|
```typescript
|
|
296
330
|
// In your frontend bootstrap (app.ts or similar):
|
|
297
331
|
import { Socket } from "@zerotal/client";
|
|
298
332
|
|
|
299
333
|
// The first-party Socket speaks Zerotal's native broadcast protocol and is a
|
|
300
|
-
// drop-in for `window.
|
|
301
|
-
window.
|
|
334
|
+
// drop-in for `window.Socket` — no external client library or Pusher credentials.
|
|
335
|
+
window.Socket = new Socket();
|
|
302
336
|
```
|
|
303
337
|
|
|
304
338
|
## Presence — who's here (multiplayer)
|
|
@@ -345,7 +379,7 @@ Broadcast.channel("board.[boardId]", (user, boardId) =>
|
|
|
345
379
|
|
|
346
380
|
Whispers are client-only (they ride the presence channel directly), so they're instant and don't count as component round-trips.
|
|
347
381
|
|
|
348
|
-
Like all `
|
|
382
|
+
Like all `socket:` features, presence needs a `window.Socket` client configured (above). Without it, `@presence` props stay empty and whispers are inert — no errors.
|
|
349
383
|
|
|
350
384
|
## Shared state — everyone converges (multiplayer)
|
|
351
385
|
|
|
@@ -370,7 +404,7 @@ The mental model: a `@shared` prop is a **cache of a server-side room value**, n
|
|
|
370
404
|
|
|
371
405
|
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
406
|
|
|
373
|
-
Broadcasting is an **optional peer**. With `window.
|
|
407
|
+
Broadcasting is an **optional peer**. With `window.Socket` 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
408
|
|
|
375
409
|
> 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
410
|
|
package/docs/flow/index.md
CHANGED
|
@@ -135,6 +135,13 @@ component in the project is covered — the Flow scaffold already writes this:
|
|
|
135
135
|
> runtime need a `/** @jsxImportSource … */` comment of their own — which is why
|
|
136
136
|
> `make:flow` writes one into every class it generates.
|
|
137
137
|
|
|
138
|
+
> **Note** — Flow components and `zerotal/view` components do not interoperate, and the failure
|
|
139
|
+
> is a type error rather than a wrong render. The two JSX runtimes produce different element
|
|
140
|
+
> types: a view `FC` returns `SafeHtml` (`{ value }`) and Flow's JSX expects `HtmlNode`
|
|
141
|
+
> (`{ html }`), so using one inside the other is `TS2786: 'Box' cannot be used as a JSX
|
|
142
|
+
> component`. A shared component library has to target one runtime; share class-name constants
|
|
143
|
+
> or plain strings across the two instead of components.
|
|
144
|
+
|
|
138
145
|
### Your first component
|
|
139
146
|
|
|
140
147
|
```tsx
|
|
@@ -563,6 +570,6 @@ Flow is a large surface. Each section below is its own page.
|
|
|
563
570
|
- [Validator](/docs/validator) — the full rule chain behind `@validate` and `this.validate()`.
|
|
564
571
|
- [Middleware](/docs/middleware) — write the guards you attach to Flow routes.
|
|
565
572
|
- [Session](/docs/session) — the store behind `@session` and `SessionMiddleware`.
|
|
566
|
-
- [Broadcasting](/docs/broadcasting) — drive `@on("
|
|
573
|
+
- [Broadcasting](/docs/broadcasting) — drive `@on("socket:…")` real-time updates from the server.
|
|
567
574
|
- [Storage](/docs/storage) — configure the disks that file uploads write to.
|
|
568
575
|
- [Testing](/docs/testing/index) — patterns for the `FlowTest` harness and the rest of the suite.
|
package/docs/flow/layouts.md
CHANGED
|
@@ -143,6 +143,22 @@ override async render() {
|
|
|
143
143
|
}
|
|
144
144
|
```
|
|
145
145
|
|
|
146
|
+
### The document's `lang` is fixed
|
|
147
|
+
|
|
148
|
+
Flow assembles the outer document itself and emits `<html lang="en">`, and a `Layout` has no hook to
|
|
149
|
+
change it. `head` injects into `<head>`; the `<html>` attributes are not reachable from a layout.
|
|
150
|
+
|
|
151
|
+
For a localised app, put `lang` (and any locale-dependent class) on the layout's own wrapper element.
|
|
152
|
+
Both are valid on a `div` and apply to every descendant, so the *content* is correctly marked up —
|
|
153
|
+
but the document still declares English to anything reading the root element, which is wrong for a
|
|
154
|
+
screen reader announcing the page in the wrong voice:
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
override render(slot: HtmlNode) {
|
|
158
|
+
return <div lang={activeLocale()}>{slot}</div>;
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
146
162
|
## Sections
|
|
147
163
|
|
|
148
164
|
A layout owns regions a page cannot reach. When a page needs to put something _there_ — a toolbar
|
package/docs/flow/performance.md
CHANGED
|
@@ -33,11 +33,37 @@ It's a graceful degrade, not a mode you configure. WebSocket reconnection keeps
|
|
|
33
33
|
|
|
34
34
|
- Each action is a request/response, with no server-pushed frames — so `@task`
|
|
35
35
|
streaming arrives as one batched update rather than token by token.
|
|
36
|
-
- Real-time `@on("
|
|
36
|
+
- Real-time `@on("socket:…")`, `@presence`, and `@shared` broadcasts are not
|
|
37
37
|
delivered, because those ride the separate broadcasting socket.
|
|
38
38
|
|
|
39
39
|
Everything driven by your own actions still works. Nothing is sent over HTTP until the socket has actually failed; the happy path is unchanged.
|
|
40
40
|
|
|
41
|
+
## What forces the runtime fallback
|
|
42
|
+
|
|
43
|
+
Every page is AOT-compiled at boot where it can be. A page the compiler cannot handle still works —
|
|
44
|
+
it renders through the standard runtime instead — and Flow logs a count at startup:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
N of M Flow pages (75%) render through the runtime fallback instead of compiled output.
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Set `ZT_FLOW_COMPILE_LOG=1` to print what blocks each page. The common causes:
|
|
51
|
+
|
|
52
|
+
| Blocker | Fix |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `render()` with more than one `return` | Build the branches into a variable and return once |
|
|
55
|
+
| A function call in a text child — including **`__()`** | See below |
|
|
56
|
+
| An imported child component in `render()` (`<Header/>`) | Inline it, or accept the fallback |
|
|
57
|
+
| `class={someLocalConst}` or a numeric-literal attribute (`rows={3}`) | Use a literal string |
|
|
58
|
+
|
|
59
|
+
**`__()` is the one that matters most.** A translated template is a function call in a text child,
|
|
60
|
+
so a page that translates a single string falls off the fast path — which in an app where `__()`
|
|
61
|
+
is the house style means every page. The cost is normally just speed, but it is not only speed
|
|
62
|
+
under [`cspSafe`](/docs/flow/components#csp-safe-mode): there, every page **must** compile or the
|
|
63
|
+
build fails, so an app using `__()` in its templates cannot run in CSP-safe mode today. If you need
|
|
64
|
+
`cspSafe`, keep translation out of the template — resolve strings in the action or `onMount()` into
|
|
65
|
+
`@locked` properties and render those.
|
|
66
|
+
|
|
41
67
|
## Interaction polish
|
|
42
68
|
|
|
43
69
|
The perceived speed of a server-driven app comes from three things: never showing
|
package/docs/flow/references.md
CHANGED
|
@@ -87,6 +87,7 @@ Visual loading indicators (`showOnLoading`, `hideOnLoading`, `loadingClass`) wai
|
|
|
87
87
|
| ----------------- | --------------------------------------------------------- | ----------------------------------- |
|
|
88
88
|
| `navigate` | SPA navigation to `href`, layout stays mounted | `flow:navigate` |
|
|
89
89
|
| `navigate hover` | Prefetch page on hover (~60ms debounce) | `flow:navigate flow:navigate.hover` |
|
|
90
|
+
| `navigate down` | Prefetch page on pointer-down (no dwell; dense lists) | `flow:navigate flow:navigate.down` |
|
|
90
91
|
| `current={false}` | Disable automatic `data-current` attribute | — |
|
|
91
92
|
| `exact` | `data-current` only on an exact URL match (not sub-pages) | `flow:current.exact` |
|
|
92
93
|
|
|
@@ -177,6 +178,20 @@ override async render() {
|
|
|
177
178
|
|
|
178
179
|
> A dynamic `sortItem={String(it.id)}` inside a `.map()` renders through the standard runtime (not the AOT fast path) — the drag behaviour is identical either way.
|
|
179
180
|
|
|
181
|
+
> **The payload does not say which container took the drop.** The client reads `flow:sort` off the
|
|
182
|
+
> container a child was dropped **into** and calls it `(key, index)` — so the destination is
|
|
183
|
+
> encoded in *which method runs*, and nowhere else. For a single sortable list that is invisible.
|
|
184
|
+
> For dragging **between** containers under one `sortGroup` it means one action per container:
|
|
185
|
+
>
|
|
186
|
+
> ```tsx
|
|
187
|
+
> <ul onSort={this.dropInTodo} sortGroup="tasks">…</ul>
|
|
188
|
+
> <ul onSort={this.dropInDone} sortGroup="tasks">…</ul>
|
|
189
|
+
> ```
|
|
190
|
+
>
|
|
191
|
+
> An arrow (`onSort={(k, i) => this.move("todo", k, i)}`) cannot stand in, because the attribute's
|
|
192
|
+
> value is used as a method *name* rather than evaluated. `onSort` accepts the name as a string,
|
|
193
|
+
> so the handlers can come from a lookup table keyed by column, but they must be declared members.
|
|
194
|
+
|
|
180
195
|
### DOM utilities
|
|
181
196
|
|
|
182
197
|
| You write | Behaviour | Compiles to |
|
package/docs/flow/testing.md
CHANGED
|
@@ -177,6 +177,38 @@ expect(page.user?.email).toBe("alice@example.com");
|
|
|
177
177
|
expect(page.totalRevenue).toBe(450.0);
|
|
178
178
|
```
|
|
179
179
|
|
|
180
|
+
## No request scope
|
|
181
|
+
|
|
182
|
+
`FlowTest` drives the server-side pipeline but does **not** open a request context. There is no
|
|
183
|
+
`RequestContext.run` inside it, so anything reaching for the request throws rather than
|
|
184
|
+
returning empty:
|
|
185
|
+
|
|
186
|
+
- `Auth.user()` → `E_UNAUTHORIZED`
|
|
187
|
+
- `Auth.attempt()` / `Auth.login()` → `E_CONTEXT_OUTSIDE_REQUEST`
|
|
188
|
+
- request-scoped pagination, and any facade that reads `RequestContext`
|
|
189
|
+
|
|
190
|
+
That covers most actions on any page behind a sign-in, so open the scope yourself. `HttpContext.fake()`
|
|
191
|
+
rather than an object literal cast to the type — it carries a real `Request`, which matters as soon
|
|
192
|
+
as anything downstream reads a header (an audited model records the actor's IP, for one):
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
import { RequestContext, HttpContext } from "@zerotal/core";
|
|
196
|
+
|
|
197
|
+
function asUser<T>(user: User | null, fn: () => Promise<T>): Promise<T> {
|
|
198
|
+
const ctx = HttpContext.fake("http://localhost/");
|
|
199
|
+
if (user) ctx.user = user;
|
|
200
|
+
return RequestContext.run(ctx, fn);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
await asUser(alice, async () => {
|
|
204
|
+
const t = await FlowTest.mount(IssuePage, { issue });
|
|
205
|
+
await t.call("postComment");
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`app.actingAs()` does not help here: it encodes a session cookie for `app.get()`, and `FlowTest`
|
|
210
|
+
never makes a request to send it on.
|
|
211
|
+
|
|
180
212
|
## Testing with a database
|
|
181
213
|
|
|
182
214
|
`FlowTest` does not set up or tear down a database — use your test suite's standard database helpers. With Bun, wrap tests in a transaction that rolls back after each test for full isolation:
|