@mauroandre/velojs 0.0.55 → 0.0.57
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/README.md +423 -3
- package/agent/AGENTS.md +216 -0
- package/agent/skills/velojs-live-loader/SKILL.md +410 -0
- package/dist/channels.d.ts +370 -0
- package/dist/channels.js +376 -0
- package/dist/client.js +56 -45
- package/dist/components.js +7 -6
- package/dist/events.d.ts +6 -0
- package/dist/events.js +1 -0
- package/dist/hooks.d.ts +26 -21
- package/dist/hooks.js +117 -98
- package/dist/live-loader.d.ts +120 -0
- package/dist/live-loader.js +141 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +144 -118
- package/dist/testing/index.d.ts +5 -1
- package/dist/testing/index.js +495 -412
- package/dist/testing/subscription.d.ts +18 -2
- package/dist/testing/types.d.ts +85 -0
- package/dist/types.d.ts +9 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.js +431 -340
- package/package.json +1 -1
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: velojs-live-loader
|
|
3
|
+
description: "Keep a loader value faithful to server state in real time: declare `channels` next to the loader, map each channel to a partition resolver in `app/channels.ts`, and signal changes server-side with `emit()` — by name (the runtime re-executes the loader) or with a typed `Partial` slice when the producer already holds the value. Covers routes with `:params` (the client declares the address it is seeing, the server validates it) and the group lifecycle (`onGroupOpen`/`onGroupClose`) for sources with a cost of life. Use when a page value must track server state over time instead of being fetched once."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Live loader
|
|
7
|
+
|
|
8
|
+
A `loader` fetches a page's data once per request and re-fetches it on SPA navigation. That is everything a page needs when its data changes because of the user themselves — or not at all. A **value that must track server state over time** (a team total another person's request changes, data a background scheduler mutates) needs the loader to stay faithful. That is the live loader: the same `loader`, the same `useLoader()` read, plus a sibling `channels` export.
|
|
9
|
+
|
|
10
|
+
## The three declarations
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
// app/expenses/Expenses.tsx — the page module
|
|
14
|
+
export const loader = async ({ c }: LoaderArgs) => {
|
|
15
|
+
const user = c.get("user");
|
|
16
|
+
return {
|
|
17
|
+
teamTotal: await expensesService.teamTotal(user.teamId),
|
|
18
|
+
expenses: await expensesService.byTeam(user.teamId),
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
export const channels = ["teamExpenses"];
|
|
22
|
+
|
|
23
|
+
export const Component = () => {
|
|
24
|
+
const { data, freshness } = useLoader();
|
|
25
|
+
return <h2 class={freshness.value}>Team: ${data.value?.teamTotal}</h2>;
|
|
26
|
+
};
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// app/channels.ts — the app's channel map: name → partition resolver
|
|
31
|
+
export const channels = {
|
|
32
|
+
teamExpenses: {
|
|
33
|
+
// On subscribe the ctx is { user, params }: the principal materialized
|
|
34
|
+
// in c.get("user") and the route params the connection declared.
|
|
35
|
+
// On emit it is the object the producer built, with the fields this
|
|
36
|
+
// scope consumes. Returns the partition key.
|
|
37
|
+
scope: ({ user }) => `team:${user.teamId}`,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// anywhere server-side: an action, server.tsx, a scheduler, a webhook
|
|
44
|
+
import { emit } from "@mauroandre/velojs/server";
|
|
45
|
+
|
|
46
|
+
await emit("teamExpenses", { user: { teamId: 7 } });
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`export const channels = [...]` is the whole opt-in. A module without it behaves exactly as before — no connection, no cost.
|
|
50
|
+
|
|
51
|
+
## What happens
|
|
52
|
+
|
|
53
|
+
- The framework derives one internal SSE route per declared channel — `GET /_channel/{moduleId}/{channel}`, the same family as `/_action` and `/_event` — inheriting the `middlewares` of the module's route node, so `requireAuth`/`requireMaster` guard the subscription exactly as they guard the page.
|
|
54
|
+
- After hydration the client opens one connection per (module, channel) of the rendered hierarchy — layouts and pages alike — and closes it on unmount (SPA navigation, page close). A channel declared by both a layout and a page produces one connection per module, each feeding its own module's data; a single `emit` by channel name reaches both.
|
|
55
|
+
- Every new connection immediately receives the **current state**, computed by re-executing the loader with that connection's own principal and route params. The pane never shows the SSR value while the server has already moved on, and a reconnect is a new connection with a new snapshot — no extra refetch.
|
|
56
|
+
- `emit(channel, ctx)` resolves the partition with the same `scope` — the producer builds the same `{ user, params }` shape with the fields its scope consumes — finds the connections of that partition and pushes a fresh snapshot to each, the loader re-executed with each connection's own principal and route params. Connections of other partitions receive nothing. A channel with no subscribers is a no-op; a channel with no entry in `app/channels.ts` throws immediately, naming it.
|
|
57
|
+
- The snapshot **replaces** the module's loader data, so the component reading `data.value` re-renders on its own. No subscription code, no merge, no manual refresh.
|
|
58
|
+
|
|
59
|
+
## Routes with `:params`
|
|
60
|
+
|
|
61
|
+
A channel declared in a module whose route declares `:params` — a page `/projeto/:id`, a layout — partitions per **resource**. The address travels with the connection, but it is not the client choosing its scope: the client **declares** the route it is already seeing (the same information `?_data=1` sends on every SPA navigation), and the server **decides** — validates the shape against the module's path and the policy in the `scope`.
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
// app/projeto/Projeto.tsx — the route /projeto/:id
|
|
65
|
+
export const loader = async ({ params }: LoaderArgs) => ({
|
|
66
|
+
arquivos: await projetoService.arquivos(params.id), // params.id arrives, always
|
|
67
|
+
});
|
|
68
|
+
export const channels = ["projetoArquivos"];
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// app/channels.ts
|
|
73
|
+
export const channels = {
|
|
74
|
+
projetoArquivos: {
|
|
75
|
+
// On subscribe the scope receives { user, params }: the resource is
|
|
76
|
+
// validated against the principal (ownership) and the key derived
|
|
77
|
+
// from it.
|
|
78
|
+
scope: ({ user, params }) => {
|
|
79
|
+
if (!params?.id) return null;
|
|
80
|
+
if (user && !podeVer(user, params.id)) return null; // 403
|
|
81
|
+
return `projeto:${params.id}`;
|
|
82
|
+
},
|
|
83
|
+
// The emit side is trusted server code: it builds the same shape with
|
|
84
|
+
// the fields the scope consumes — for a resource channel, the params
|
|
85
|
+
// are the whole address (see `emit("projetoArquivos", { params: … })`
|
|
86
|
+
// in the group lifecycle example below).
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- **Extraction (client).** The connection of a module extracts the params its route declares, matching the module's `fullPath` as a **prefix** of the pathname: a layout declared at `/projeto/:id` covers `/projeto/7/sala` and extracts `id=7` — the child's suffix takes no part. A path with a catch-all (`/docs/*`) declares no `:` key: the connection travels by principal only. The params go serialized in one query param, `?_route=<json>`.
|
|
92
|
+
- **Validation (server).** The runtime checks the received keys against the `:params` of the module's declared path. A key the path does not declare rejects the subscription with **400**, naming what is wrong — a declaration is not free input. Values are always URL strings.
|
|
93
|
+
- **Policy (scope).** The partition resolves with the validated params: it is the **relevance** of the group (only the connections of that resource wake up on an emit) with the **authorization** validated on entry and re-derived on every emit, now with the connection's stored params.
|
|
94
|
+
- **The loader re-executes** with `{ params, query, c }` of the connection — `params.id` filled exactly as on the rendered page. The internal `_route` key is transport, never data: the query the loader sees excludes it.
|
|
95
|
+
- **The connection is keyed by the extracted params**, not by the pathname. `/projeto/7` → `/projeto/9` closes the old connection and opens a new one (with a snapshot on connect); `/projeto/7` → `/projeto/7/sala` does **not** reconnect — the child's suffix is not part of the connection's identity.
|
|
96
|
+
|
|
97
|
+
## Group lifecycle: `onGroupOpen` / `onGroupClose`
|
|
98
|
+
|
|
99
|
+
A source with a **cost of life** — a filesystem watcher, a process, a subscription to an external service — must not run forever for an empty group. The channel definition declares when it should live:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
export const channels = {
|
|
103
|
+
projetoArquivos: {
|
|
104
|
+
scope: ({ params }) => (params?.id ? `projeto:${params.id}` : null),
|
|
105
|
+
onGroupOpen: ({ params }) =>
|
|
106
|
+
watchers.armar(params.id, () => emit("projetoArquivos", { params: { id: params.id } })),
|
|
107
|
+
onGroupClose: ({ partition }) => watchers.desarmar(),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- The **first** connection of a group (channel + partition) fires `onGroupOpen({ partition, params })` and the **last** one fires `onGroupClose({ partition })` — by any cause: disconnect, idle timeout, partition revalidation. A second connection of the same group fires nothing; one that arrives while the close hook is still running joins the surviving group without opening it — the runtime re-arms the group when the disarm finishes, so a live group is never left without its source.
|
|
113
|
+
- Both hooks may be async and are **awaited**: on open, before the connect snapshot (the source is armed before the first data arrives); on close, before the group is discarded (the watcher is disarmed before the group stops existing).
|
|
114
|
+
- A hook that throws (or rejects) is logged loudly, naming channel and partition, and the group continues: the connect snapshot still delivers data from the cold source, the live data simply does not arrive until it is fixed — and the log is what says so.
|
|
115
|
+
- The `params` of `onGroupOpen` are the declared params of the connection that opened the group. They are identical across the group only when the partition derives from them (a watcher per resource). A channel partitioned by **principal**, declared in a module with `:params`, mixes resources in the same group: the hook's params are the first connection's and are arbitrary for the rest — such a channel must not consume them.
|
|
116
|
+
- The hook is infrastructure, not policy: it does not receive `user`.
|
|
117
|
+
|
|
118
|
+
## Two ways to emit
|
|
119
|
+
|
|
120
|
+
The emit has two modes, and the difference is *who knows the new value*.
|
|
121
|
+
|
|
122
|
+
**Invalidation** — `emit(channel, ctx)`. The producer knows that something changed, not what. The runtime re-executes the loader of every connection in the partition and pushes a fresh snapshot. This is the mode that keeps the producer decoupled from the shape of the data, and the one to reach for from a scheduler, a webhook handler that only got an id, or another user's request.
|
|
123
|
+
|
|
124
|
+
**Slice** — `emit(module, channel, ctx, slice)`. The producer already holds the new value (the webhook arrived with the payload, the job just computed the total). The slice travels to the connections of that (module, channel) pair in the partition exactly as it is, and **no loader runs**.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import * as Expenses from "../app/expenses/Expenses.js";
|
|
128
|
+
|
|
129
|
+
// Invalidation: Expenses.loader runs once per connection of the partition.
|
|
130
|
+
await emit("teamExpenses", { user: { teamId: 7 } });
|
|
131
|
+
|
|
132
|
+
// Slice: nothing runs; the connections merge this payload into their data.
|
|
133
|
+
await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The slice is typed as `Partial` of what the module's loader returns:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
export const loader = async ({ c }: LoaderArgs) => {
|
|
140
|
+
const user = c.get("user");
|
|
141
|
+
return {
|
|
142
|
+
teamTotal: await expensesService.teamTotal(user.teamId),
|
|
143
|
+
expenses: await expensesService.byTeam(user.teamId),
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// OK — known key, right type
|
|
148
|
+
await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
|
|
149
|
+
// tsc error — the key does not exist in the loader's return
|
|
150
|
+
await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { total: 880 });
|
|
151
|
+
// tsc error — wrong value type
|
|
152
|
+
await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: "880" });
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Why does the module take part in the address? Because the same channel can be declared by a layout **and** by a page, and each has its own loader and its own data shape — a slice is the shape of one of them. The runtime delivers the slice only to the connections of the pair `(module, channel)`; `emit(channel, ctx)` by name keeps reaching every module that declares the channel.
|
|
156
|
+
|
|
157
|
+
## The merge
|
|
158
|
+
|
|
159
|
+
The client merges every slice into the module's current value, with rules short enough to memorize:
|
|
160
|
+
|
|
161
|
+
| Rule | Meaning |
|
|
162
|
+
|---|---|
|
|
163
|
+
| A snapshot **replaces**, a slice **merges** | The connect snapshot and the invalidation snapshot are the whole value; a slice is applied on top of the current one |
|
|
164
|
+
| The key sent is replaced **whole** | Sending `teamTotal` replaces the entire `teamTotal`; lists travel whole inside their key |
|
|
165
|
+
| Keys not sent stay **intact** | A slice with only `teamTotal` leaves `expenses` exactly as it was |
|
|
166
|
+
| An absent key **never removes** | Removal is explicit: re-send the key with the new whole list, without the item |
|
|
167
|
+
| The merge happens **on arrival** | Slices that arrive in the same frame are applied together before the next render; for the same key, the last one in arrival order wins |
|
|
168
|
+
| A malformed payload **keeps the previous value** | A slice that fails to parse never blanks the screen |
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
// Current data: { teamTotal: 4, expenses: ["mercado", "luz"] }
|
|
172
|
+
await emit("teamExpenses", { user: { teamId: 7 } }); // whole value
|
|
173
|
+
await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 9 });
|
|
174
|
+
// → { teamTotal: 9, expenses: ["mercado", "luz"] }
|
|
175
|
+
|
|
176
|
+
// Removing "luz" is re-sending the whole list without it
|
|
177
|
+
await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { expenses: ["mercado"] });
|
|
178
|
+
// → { teamTotal: 9, expenses: ["mercado"] }
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
There is no deep patch and no list diff: the semantics stop at one level, by design. The runtime merges blindly — it is JavaScript — so the shape safety comes from the emit typing; a key that does not exist in the loader's data only gets in through a cast, and that is a usage error, not a feature.
|
|
182
|
+
|
|
183
|
+
## Bursts: coalescing, log and revalidation
|
|
184
|
+
|
|
185
|
+
Three behaviours make the emit usable on a busy server.
|
|
186
|
+
|
|
187
|
+
**Coalescing.** Invalidation emits are folded per (channel, partition). The first opens a window, the emits inside it join, and closing the window fires **one** re-execution round per connection — the consolidated round is the effect of all of them, so no invalidation is lost. A job writing N rows in sequence therefore costs one round, not N.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// Default: 50ms window, in any registration without options.
|
|
191
|
+
// 0 turns it off (immediate, the behavior of the first slice).
|
|
192
|
+
registerChannels(channels, { coalesceMs: 0 });
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A slice never waits for the window: it is a direct push, with no re-execution to amortize. Bursts of slices are absorbed by the merge accumulator on the client.
|
|
196
|
+
|
|
197
|
+
The window also decides what `await emit(…)` waits for. With the window off (`0`) the await resolves after the round was delivered; with a window open (the default `50ms`) it resolves as soon as the gesture is registered — the log line is already out and the delivery comes later. So never assert a screen update right after `await emit(…)` under the default window: await the arrival (`next()` / `nextEvent()`) or wait for the effect.
|
|
198
|
+
|
|
199
|
+
**Emit log.** Every emit, in either mode, writes one line the moment it happens — so an incident ("the screen did not update") has a starting point: who emitted, to which partition, how many connections were in it.
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
[velojs] emit kind=invalidate channel="teamExpenses" partition="team:7" connections=2 at=2026-09-30T12:00:00.000Z
|
|
203
|
+
[velojs] emit kind=slice channel="teamExpenses" partition="team:7" connections=2 at=2026-09-30T12:00:01.104Z
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`connections` is the size of the group **at the instant of the gesture** — before coalescing and before revalidation — and the consolidated delivery writes no second line. An emit whose `scope` resolves to nothing logs `partition=null connections=0`: reaching nobody is exactly what the log is for. The log is off with `registerChannels(channels, { logEmits: false })`.
|
|
207
|
+
|
|
208
|
+
**Partition revalidation.** Before delivering anything (a re-executed snapshot or a slice), the runtime re-derives each connection's partition by running the `scope` again with the ctx captured at subscribe — the principal and the validated route params of that connection. A connection whose partition moved — or whose scope now returns `null` — is removed from the group and closed: the client sees the connection closed and `freshness` goes `"stale"`, no data of a group it left. A `scope` that **throws** on re-derivation is treated the same way — fail-closed, with the failure logged naming the channel — so a scope that talks to an external service must handle its own failures. Only the partition is revalidated; an expired session is covered by the normal request pipeline on the next access.
|
|
209
|
+
|
|
210
|
+
## Transport: heartbeat, idle timeout and the proxy
|
|
211
|
+
|
|
212
|
+
An SSE connection crosses infrastructure that was not built for it — proxies, load balancers, browsers with sleeping tabs. The channel answer is built for that traffic, and its transport has exactly two clocks:
|
|
213
|
+
|
|
214
|
+
- **Heartbeat** — an SSE comment (`: ping`) written through the write chain every `heartbeatMs`. It keeps proxies and browsers from buffering or closing an idle stream, and it is pure transport: it generates no event in the client and does not count as a delivery — it touches neither freshness nor idle. The default is `20000` (20s), the same `DEFAULT_HEARTBEAT_MS` the `stream_*` SSE surface uses — one transport ruler in the house. `0` disables it.
|
|
215
|
+
- **Idle timeout** — a connection with **no deliveries** (snapshot or slice) for `idleMs` is closed by the server, with the same closing gesture as the partition revalidation (close event waiting for the write chain, group cleanup). The heartbeat does **not** reset it: idle is about data, heartbeat is transport. A live but quiet page cycles by design — close, the `EventSource` reconnects by itself, and the snapshot on connect repairs the state — while an abandoned page's resources are freed for good. The default is `300000` (5 min) in any registration without options; `0` disables it.
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
// Registration with explicit windows (app/server.tsx or wherever the app wires)
|
|
219
|
+
registerChannels(channels, { idleMs: 60000, heartbeatMs: 15000 });
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The SSE answer also carries `Cache-Control: no-store`: a proxy or the browser must never serve a cached channel snapshot — a stale snapshot is exactly the illusion the live loader exists to kill.
|
|
223
|
+
|
|
224
|
+
**Behind a reverse proxy (the house recipe, Caddy).** SSE needs the proxy to flush immediately and never buffer the response, or the page goes silent even with everything alive on both ends. In the Caddyfile, the app's `reverse_proxy` needs `flush_interval -1` — a negative value means "flush as soon as data arrives", which is what SSE requires; a positive interval would batch chunks and delay every snapshot. Caddy streams by default and probes dead upstreams on its own — the heartbeat then does its part every 20s, and the idle timeout closes what nobody is reading anymore. Another proxy in front (nginx, a cloud load balancer) follows the same two rules: no response buffering, and `X-Accel-Buffering: no` for nginx or the equivalent for the LB.
|
|
225
|
+
|
|
226
|
+
## Inspecting the live channels
|
|
227
|
+
|
|
228
|
+
When the incident happens, the server answers "what is alive?":
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
// anywhere server-side (an action, a guarded endpoint of your own)
|
|
232
|
+
import { inspectChannels } from "@mauroandre/velojs/server";
|
|
233
|
+
|
|
234
|
+
const report = inspectChannels();
|
|
235
|
+
// {
|
|
236
|
+
// channels: [{
|
|
237
|
+
// channel: "teamExpenses",
|
|
238
|
+
// groups: [
|
|
239
|
+
// { moduleId: "expenses/Layout", partition: "team:7",
|
|
240
|
+
// connections: 1, lastDeliveryAt: "2026-09-30T12:00:00.000Z" },
|
|
241
|
+
// { moduleId: "expenses/Expenses", partition: "team:7",
|
|
242
|
+
// connections: 1, lastDeliveryAt: "2026-09-30T12:00:01.104Z" },
|
|
243
|
+
// ],
|
|
244
|
+
// connections: 2,
|
|
245
|
+
// }],
|
|
246
|
+
// openCoalesceWindows: [],
|
|
247
|
+
// totalConnections: 2,
|
|
248
|
+
// }
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Per channel, the groups are identified by the pair (moduleId, partition key) — in the incident you know which page/module holds every connection — each with its connection count and the timestamp of its last delivery; the coalescing windows still open; and the grand totals. `inspectChannels()` is always available: the app decides how (and with what guard) to expose it.
|
|
252
|
+
|
|
253
|
+
In development there is a ready-made lens: `GET /_channel-inspect` answers the same JSON in the browser. It exists **only in dev** — the map of channels and partitions is internal information, and it never ships in a production build.
|
|
254
|
+
|
|
255
|
+
## Freshness
|
|
256
|
+
|
|
257
|
+
`useLoader()` and `Loader()` expose `freshness` — a signal with three values:
|
|
258
|
+
|
|
259
|
+
| Value | Meaning |
|
|
260
|
+
|---|---|
|
|
261
|
+
| `"live"` | connected and delivering |
|
|
262
|
+
| `"stale"` | the connection closed, is reconnecting, or is **silent** — open past 60s with no delivery |
|
|
263
|
+
| `"error"` | the last loader re-execution failed (the server reported it) |
|
|
264
|
+
|
|
265
|
+
It is aggregated per page and is **data for CSS to react to** — the framework renders no JSX of its own:
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
const { data, freshness } = useLoader();
|
|
269
|
+
return <h2 class={freshness.value}>Team: ${data.value?.teamTotal}</h2>;
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The same handles expose `freshnessByChannel` — a signal record keyed by the channel names the module declares, with the same three values per channel. A page with two channels, one fallen and one following, shows both states at once:
|
|
273
|
+
|
|
274
|
+
```tsx
|
|
275
|
+
const { freshness, freshnessByChannel } = useLoader();
|
|
276
|
+
// freshnessByChannel.value → { teamExpenses: "stale", precos: "live" }
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
The aggregate folds the per-channel states by one rule: any channel in error errors the page; a silenced or closed channel counts as not-open — the page is stale only when nothing is open and delivering.
|
|
280
|
+
|
|
281
|
+
The **silence detector** is the client half of the idle timeout: a connection that stays without deliveries for `CHANNEL_SILENCE_MS` (60s, a runtime constant — not a configuration) is marked stale even though `EventSource` still reads `OPEN`. That is the tab in the background, the sleeping notebook, the proxy that stopped flushing without closing the connection. Any delivery — or a reconnect's snapshot — brings the channel back to `"live"`. The server's heartbeat comment generates no client event, so it cannot mask the silence. Never assert freshness in a test without advancing the fake timers through the window.
|
|
282
|
+
|
|
283
|
+
On a page with no `channels` the fields exist, the record is empty and the aggregate stays `"live"` for its whole life.
|
|
284
|
+
|
|
285
|
+
## The partition contract
|
|
286
|
+
|
|
287
|
+
On subscribe the `scope` receives `{ user, params }`. `user` is whatever a middleware materialized under the house key `"user"` — the same key the stream/socket channel resolvers use; a middleware that materializes the principal somewhere else should set it in `"user"` too:
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
const auth = async (c, next) => {
|
|
291
|
+
const user = await currentUser(c);
|
|
292
|
+
c.set("user", user); // the live loader reads this key
|
|
293
|
+
c.set("currentUser", user); // the app's own key, if it has one
|
|
294
|
+
await next();
|
|
295
|
+
};
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`params` are the route params the connection declared, already validated against the module's path. On emit the same scope receives the object the producer built, with the fields it consumes (`{ params: { id: 7 } }` for a resource channel, `{ user: … }` for a principal one).
|
|
299
|
+
|
|
300
|
+
- No authenticated principal → `user` is `undefined`. A public channel ignores it (`() => "all"`); an authenticated one returns `null`, which denies the subscription with **403**. An app that does not materialize a principal at all (the border is a network guard, e.g. loopback) writes `({ params }) => …`.
|
|
301
|
+
- **Declare, not choose.** `params` are not an authorization input: they are the address the client is already seeing, validated by shape on the server and by policy in the `scope`. The partition **never** derives from the query string or from any other client input.
|
|
302
|
+
- A scope that **throws** on subscribe denies with **403** and logs loudly, naming the channel — fail-closed, never silent. A scope that resolves to `null` denies the same way.
|
|
303
|
+
- No scope at all → the channel is public (`() => "all"`).
|
|
304
|
+
|
|
305
|
+
## State or flow?
|
|
306
|
+
|
|
307
|
+
Two questions decide, and they are always the same two:
|
|
308
|
+
|
|
309
|
+
| Question | Answer | Tool |
|
|
310
|
+
|---|---|---|
|
|
311
|
+
| Is this a **value** or a **sequence**? | value — "what is it now?", replacement | `loader` + `channels` |
|
|
312
|
+
| | sequence — "what happened?", accumulation (chat, log, a metric series, one-off progress) | `stream_*` / `socket_*` |
|
|
313
|
+
| Does the change come from **someone else** or from **me**? | someone else, or a background job | `loader` + `channels` |
|
|
314
|
+
| | me, through my own action | `refetch()` after the action |
|
|
315
|
+
|
|
316
|
+
Do not turn on a channel where a post-action `refetch()` suffices.
|
|
317
|
+
|
|
318
|
+
## Guards
|
|
319
|
+
|
|
320
|
+
The failures that would otherwise be silent are explicit:
|
|
321
|
+
|
|
322
|
+
- `channels` in a module **without** a `loader`: error in the dev server and in the build — there is nothing to synchronize.
|
|
323
|
+
- A channel **without an entry** in `app/channels.ts`: error in the dev server and in the build, naming the channel.
|
|
324
|
+
- Route params with a key the module's path does **not** declare: the subscription is rejected with **400**, naming the key — the client may only declare what the route declares.
|
|
325
|
+
- A `scope` that **throws** on subscribe: denied with **403** and logged loudly, naming the channel.
|
|
326
|
+
- `emit()` on a channel with no entry in the map: throws immediately, naming it (a typo or a rename would otherwise be a no-op). `emit()` on a valid channel with no subscribers is a no-op, no error.
|
|
327
|
+
- `emit(module, channel, …)` on a module that does **not declare** that channel, or that has **no loader**: throws immediately, naming both. A slice that is not an object throws too.
|
|
328
|
+
- `velojs build --static` with a module declaring `channels`: the channels are **inert** — no server for SSE, so the client opens no connection, `freshness` stays `"live"` and the build warns, naming the module.
|
|
329
|
+
- A module outside the conventions (`.jsx`/`.js`, or outside `app/`) is the one genuinely silent line: no transform, so the channel is simply inert.
|
|
330
|
+
|
|
331
|
+
## Testing
|
|
332
|
+
|
|
333
|
+
`createTestApp({ channels })` wires the same map the app uses, and `app.channel(module, name)` opens a real connection — either with cookies or through `app.as(user)`, so the route's middlewares run for real. Snapshots and slices arrive through `next()`; no sleep, no retry, no timing assertion.
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
import { createTestApp } from "@mauroandre/velojs/testing";
|
|
337
|
+
import { emit } from "@mauroandre/velojs/server";
|
|
338
|
+
import { channels } from "../app/channels.js";
|
|
339
|
+
import * as Expenses from "../app/expenses/Expenses.js";
|
|
340
|
+
|
|
341
|
+
const app = await createTestApp({
|
|
342
|
+
routes,
|
|
343
|
+
channels,
|
|
344
|
+
getSessionCookie: async ({ user }) => ({ session: await sign(user) }),
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const sub = await app.as({ user: { teamId: 7 } }).channel(Expenses, "teamExpenses");
|
|
348
|
+
expect(await sub.next({ timeoutMs: 1000 })).toEqual({ teamTotal: 4 }); // connect
|
|
349
|
+
|
|
350
|
+
await emit("teamExpenses", { user: { teamId: 7 } });
|
|
351
|
+
expect(await sub.next({ timeoutMs: 1000 })).toEqual({ teamTotal: 9 }); // emit
|
|
352
|
+
|
|
353
|
+
await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
|
|
354
|
+
expect(await sub.next({ timeoutMs: 1000 })).toEqual({ teamTotal: 880 }); // raw slice
|
|
355
|
+
|
|
356
|
+
expect(await sub.nextEvent({ timeoutMs: 1000 })).toEqual({ // discriminated
|
|
357
|
+
type: "slice",
|
|
358
|
+
data: { teamTotal: 880 },
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await sub.close();
|
|
362
|
+
await app.close();
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
The connection only exists after the connect snapshot, so awaiting the first `next()` before emitting is what makes the test deterministic. `next()` returns the payload exactly as it travelled on the wire (a whole snapshot or a raw slice) — the same contract as the first slice; `nextEvent()` returns the same arrivals discriminated as `{ type: "snapshot" | "slice", data }`, for the tests that care which mode delivered the update. Use one or the other: each walks its own cursor over the arrivals.
|
|
366
|
+
|
|
367
|
+
A route with `:params` declares them in the connection options; the server-side validation runs for real, so a key the module's path does not declare rejects the subscription at `status === 400`:
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
const project = await app.channel(Project, "projetoArquivos", { params: { id: "7" } });
|
|
371
|
+
expect(await project.next({ timeoutMs: 1000 })).toEqual({ id: "7", files: ["a.txt"] });
|
|
372
|
+
|
|
373
|
+
const forged = await app.channel(Project, "projetoArquivos", { params: { id: "7", outro: "9" } });
|
|
374
|
+
expect(forged.status).toBe(400);
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
The group hooks are testable the same way: `onGroupOpen`/`onGroupClose` fire as connections enter and leave the group (first/last), including the exits by idle and by revalidation.
|
|
378
|
+
|
|
379
|
+
The toolkit registers the channels with the coalescing window off (`coalesceMs: 0`), so `await emit(…)` is the delivery. The same determinism holds for the transport clocks: `idleMs` and `heartbeatMs` default to `0` in the toolkit — no connection dies and no comment is written unless the test asks for it. A test that exercises any of the three passes it explicitly and drives it with fake timers. Note where the options go: `channels` is the map, and the registration options are a **sibling** key, `channelOptions`:
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
const app = await createTestApp({
|
|
383
|
+
routes,
|
|
384
|
+
channels, // the map (app/channels.ts)
|
|
385
|
+
channelOptions: { coalesceMs: 20, logEmits: false }, // the options
|
|
386
|
+
getSessionCookie,
|
|
387
|
+
});
|
|
388
|
+
await vi.advanceTimersByTimeAsync(20); // the window closes: one round
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
## Reference
|
|
392
|
+
|
|
393
|
+
| Piece | Behavior |
|
|
394
|
+
|---|---|
|
|
395
|
+
| `export const channels = ["name"]` | Module opt-in: one internal route and one client connection per name |
|
|
396
|
+
| `app/channels.ts` | Channel name → `{ scope, onGroupOpen, onGroupClose }`; imported by the framework |
|
|
397
|
+
| `scope(ctx)` | `{ user, params }` on subscribe, the producer's object on emit; returns the partition key; `null` denies with 403; omitted → `"all"`. Re-derived on every emit with the ctx captured at subscribe |
|
|
398
|
+
| Route params | The client extracts the params the module's path declares (prefix match of `fullPath`) and sends them in `?_route=`; the server validates the keys (400 on an undeclared one) and the connection is keyed by them — moving to another resource reconnects |
|
|
399
|
+
| `onGroupOpen({ partition, params })` | Fires on the first connection of a (channel, partition) group; awaited before the connect snapshot; a throw is logged and the group continues |
|
|
400
|
+
| `onGroupClose({ partition })` | Fires on the last connection out of the group, by any cause (disconnect, idle, revalidation); awaited before the group is discarded |
|
|
401
|
+
| `emit(channel, ctx)` | Invalidation: re-executes the loader per connection of the partition and pushes snapshots |
|
|
402
|
+
| `emit(module, channel, ctx, slice)` | Slice: pushes the typed `Partial` to the connections of that (module, channel) pair — no re-execution |
|
|
403
|
+
| Merge | Shallow by key, on arrival; key sent replaced whole, absent key never removes, whole lists |
|
|
404
|
+
| `registerChannels(map, opts)` | `coalesceMs` (default 50, `0` off), `logEmits` (default on), `idleMs` (default 300000, `0` off) and `heartbeatMs` (default 20000, `0` off); in the toolkit the map stays in `channels` and these go in `channelOptions` — all four default `0` there |
|
|
405
|
+
| `freshness` | `"live"` / `"stale"` / `"error"`, aggregated per page |
|
|
406
|
+
| `freshnessByChannel` | Same states keyed by the module's channel names; empty record without `channels` |
|
|
407
|
+
| `CHANNEL_SILENCE_MS` | 60000 — client-side silence window; an open connection without deliveries past it is `"stale"` |
|
|
408
|
+
| `inspectChannels()` | Server-side live report: groups per (moduleId, partition) with connections and last delivery, open coalesce windows, totals |
|
|
409
|
+
| `GET /_channel-inspect` | Dev-only endpoint answering `inspectChannels()` as JSON; absent in production builds |
|
|
410
|
+
| `app.channel(module, name, opts)` | Test subscription; `opts.params` declares the route params (validated for real); `next()` (raw data) and `nextEvent()` (`{ type, data }`) |
|