@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/agent/AGENTS.md CHANGED
@@ -34,6 +34,7 @@ hard constraints, not style.
34
34
  | `useParams()` / `useQuery()` / `usePathname()` inside a `loader` | the loader's own args: `async ({ params, query, c }) => {}` | the async context wraps only rendering; loaders run **before** it → returns `{}` / `"/"` |
35
35
  | `c.req.param(...)` or `params.x` inside `action_*`, `stream_*`, `socket_*` | actions: read it from `body`. streams/sockets: send `?channel=` from the client and read `query.channel` | these register at **static** paths (`/_action/{moduleId}/{name}`, `/_event/…`, `/_socket/…`) — the page's `:params` are not in scope → always `{}` / `undefined` |
36
36
  | a page in `.jsx` / `.js`, or any page outside `appDirectory` | `.tsx` inside `app/` | **zero transforms**: no metadata, no stubs, loader ships to the client |
37
+ | `export const channels = [...]` in a `.jsx` / `.js` page, or any page outside `appDirectory` | `.tsx` inside `app/` | **zero transforms**: the channel guards never run, so the channel is inert — no route, no connection, no error |
37
38
  | a root layout that doesn't render a literal `<head>` | `isRoot` component renders `<html><head>…</head><body>{children}</body></html>` | `__PAGE_DATA__` is injected by replacing `</head>` → **every loader hydrates `null`** and refetches |
38
39
 
39
40
  Never pass a module id to `useLoader()` / `Loader()` yourself — the plugin injects it.
@@ -63,6 +64,219 @@ An entry refreshes on navigation when the params **its own route declares** chan
63
64
  (`/x/:id`). A query string is not declared in `routes.tsx`, so nothing infers it — use
64
65
  `useLoader([query.tab])` when data depends on one.
65
66
 
67
+ ## Keeping a page value faithful: `loader` + `channels`
68
+
69
+ The loader has two faces. The common one fetches once per request (SSR) and
70
+ re-fetches on SPA navigation — that is all a page needs when its data changes
71
+ because of the user themselves or not at all. When a value must **track server
72
+ state over time** (a team's total updated by another person's request, a
73
+ background scheduler mutating data), declare channels next to the loader:
74
+
75
+ ```tsx
76
+ // app/expenses/Expenses.tsx
77
+ export const loader = async ({ c }: LoaderArgs) => {
78
+ const user = c.get("user");
79
+ return { teamTotal: await expensesService.teamTotal(user.teamId) };
80
+ };
81
+ export const channels = ["teamExpenses"];
82
+
83
+ export const Component = () => {
84
+ const { data, freshness } = useLoader();
85
+ return <h2 class={freshness.value}>Team: ${data.value?.teamTotal}</h2>;
86
+ };
87
+ ```
88
+
89
+ And declare the partition of each channel once, in `app/channels.ts`:
90
+
91
+ ```ts
92
+ // app/channels.ts — the app's channel map: name → partition resolver
93
+ export const channels = {
94
+ teamExpenses: {
95
+ // On subscribe the ctx is { user, params }: the principal materialized
96
+ // in c.get("user") and the route params the connection declared. On
97
+ // emit it is the object the producer built, with the fields this scope
98
+ // consumes. Returns the partition key.
99
+ scope: ({ user }) => `team:${user.teamId}`,
100
+ },
101
+ };
102
+ ```
103
+
104
+ Any server-side code signals that the partition changed:
105
+
106
+ ```ts
107
+ import { emit } from "@mauroandre/velojs/server";
108
+ await emit("teamExpenses", { user: { teamId: 7 } });
109
+ ```
110
+
111
+ Or, when the producer already holds the new value, it pushes it as a typed slice:
112
+
113
+ ```ts
114
+ import * as Expenses from "../app/expenses/Expenses.js";
115
+ await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
116
+ ```
117
+
118
+ The framework derives `GET /_channel/{moduleId}/{channel}` (same family as
119
+ `/_action` and `/_event`), inheriting the route node's `middlewares`; after
120
+ hydration the client opens one connection per (module, channel) of the rendered
121
+ hierarchy and closes it on unmount. Every new connection receives the **current
122
+ state** (the loader re-executed with that connection's own principal and route
123
+ params), and every `emit` re-executes the loader per connection in the emitting
124
+ partition, pushing a snapshot that replaces the module's data. The component
125
+ re-renders on its own — the page has no code for any of it.
126
+
127
+ ### Routes with `:params` — declare, do not choose
128
+
129
+ A channel declared in a module whose route declares `:params` (`/projeto/:id`, a
130
+ page or a layout) partitions per **resource**, and the loader re-executes with
131
+ `params.id` filled — the connect snapshot and every emit.
132
+
133
+ ```tsx
134
+ // app/projeto/Projeto.tsx — the route /projeto/:id
135
+ export const loader = async ({ params }: LoaderArgs) => ({
136
+ arquivos: await projetoService.arquivos(params.id),
137
+ });
138
+ export const channels = ["projetoArquivos"];
139
+ ```
140
+
141
+ ```ts
142
+ // app/channels.ts
143
+ export const channels = {
144
+ projetoArquivos: {
145
+ scope: ({ user, params }) => {
146
+ if (!params?.id) return null;
147
+ if (user && !podeVer(user, params.id)) return null; // 403
148
+ return `projeto:${params.id}`;
149
+ },
150
+ },
151
+ };
152
+ ```
153
+
154
+ - The client **declares** the address it is already seeing: it extracts the params the module's `fullPath` declares, matching as a **prefix** of the pathname (a layout `/projeto/:id` covers `/projeto/7/sala` and extracts `id=7`; the child's suffix takes no part), and sends them in `?_route=<json>`. A catch-all (`/docs/*`) declares no `:` key — the connection travels by principal.
155
+ - The server **decides**: it validates the keys against the module's declared path (a key the path does not declare rejects the subscription with **400**) and the `scope` validates the policy — possession/permission — deriving the key. `params` are never an authorization input: they are not free input, they are the declared route, validated by shape and then by policy.
156
+ - The internal `_route` key never reaches the loader's `query` — it is transport, not page data.
157
+ - The connection is **keyed by the extracted params**, not by the pathname: `/projeto/7` → `/projeto/9` closes the connection and opens a new one (snapshot on connect); `/projeto/7` → `/projeto/7/sala` does not reconnect. Do not expect a refetch on a pathname change that does not move the params.
158
+
159
+ ### Group lifecycle — arming a source with a cost of life
160
+
161
+ A watcher (filesystem, process, an external subscription) must not run forever
162
+ for an empty group. `onGroupOpen` fires when the **first** connection enters a
163
+ group (channel + partition) and `onGroupClose` when the **last** one leaves —
164
+ by any cause (disconnect, idle, revalidation):
165
+
166
+ ```ts
167
+ export const channels = {
168
+ projetoArquivos: {
169
+ scope: ({ params }) => (params?.id ? `projeto:${params.id}` : null),
170
+ onGroupOpen: ({ params }) =>
171
+ watchers.armar(params.id, () => emit("projetoArquivos", { params: { id: params.id } })),
172
+ onGroupClose: ({ partition }) => watchers.desarmar(),
173
+ },
174
+ };
175
+ ```
176
+
177
+ - A second connection of the same group fires nothing. One that arrives while the close hook is still running joins the surviving group without arming it: the runtime re-arms the group when the disarm finishes — a live group is never left without its source.
178
+ - Both hooks may be async and are **awaited**: on open, before the connect snapshot; on close, before the group is discarded. A hook that throws (or rejects) is logged loudly, naming channel and partition, and the group continues — the cold-source snapshot still arrives; only the live data waits for the fix.
179
+ - The hook is infrastructure, not policy: it does not receive `user`.
180
+ - The `params` of `onGroupOpen` belong to 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 one group: do not consume the hook's params there — they are arbitrary for the rest of the group.
181
+
182
+ ### Two ways to emit — invalidation or the value itself
183
+
184
+ | Mode | Call | What the runtime does |
185
+ |---|---|---|
186
+ | Invalidation | `emit(channel, ctx)` | Re-executes the loader **per connection** of the partition and pushes a snapshot. The producer does not know the shape of the data |
187
+ | Slice | `emit(module, channel, ctx, slice)` | Pushes the payload as-is to the connections of that (module, channel) pair in the partition — **no loader runs** |
188
+
189
+ Use invalidation when only the server knows the new value (a background job, a
190
+ request from another user). Use the slice when the producer already has it — a
191
+ webhook that arrived with the payload, a job that just computed the total.
192
+
193
+ The slice is typed `Partial<Awaited<ReturnType<typeof Module.loader>>>`: an
194
+ unknown key or a wrong value type is a `tsc` error, never `any`. The module is
195
+ part of the address because one channel can be declared by modules with
196
+ different `Data` (a layout and a page): a slice is the shape of exactly **one**
197
+ of them, and it reaches only that module's connections. The invalidation mode by
198
+ name keeps reaching every module of the channel.
199
+
200
+ ### The merge contract — snapshot replaces, slice merges
201
+
202
+ - A snapshot (connect or invalidation) is the whole value: it **replaces** the module's data, gone keys included.
203
+ - A slice applies a **shallow** merge by key: the key sent is replaced by the **whole** value sent (a list travels whole inside its key); keys not sent stay intact.
204
+ - **Removal is explicit**: removing an item is re-sending the key with the new whole list (without the item). An absent key never means "remove".
205
+ - The merge happens **on arrival**, never at render: slices that land in the same frame are applied together before the next render — none is lost — and for the same key the last one in arrival order wins. A malformed payload keeps the previous value.
206
+ - No deep patch and no list diff: the semantics stop at one level, by design. The runtime merges blindly (it is JavaScript) — shape safety comes from the emit typing. A key that does not exist in the `Data` only gets in through a cast: a usage error, documented, not a silent failure.
207
+
208
+ ### Bursts, log and revalidation
209
+
210
+ - **Coalescing.** Invalidation emits fold per (channel, partition): the first opens a window, the emits inside it join it (the deadline does not move), and closing the window fires **one** re-execution round per connection — no invalidation is lost. Default `50ms` in any registration without options; `registerChannels(map, { coalesceMs: 0 })` disables it (immediate). A slice never waits for a window: it is pushed directly.
211
+ **The window changes what `await emit(…)` means.** With the window off (`0`, and the test toolkit's default) the await resolves after the round was delivered; with a window open it resolves as soon as the gesture is registered — the log line is already out, the delivery comes later. Never assert a screen update right after `await emit(…)` under the default window: assert the arrival (`next()`/`nextEvent()`) or wait for the effect.
212
+ - **Emit log.** Every `emit`, in either mode, writes one `console.log` line at the moment of the gesture: `channel`, `partition`, `kind` (`invalidate`/`slice`), `connections` (the size of the group at that instant — before coalescing and before revalidation) and a timestamp. The consolidated delivery writes no second line; an emit whose `scope` resolves to nothing logs `partition=null connections=0`. `registerChannels(map, { logEmits: false })` silences it; `createTestApp` registers with window `0` and takes the same options in `channelOptions` — the map itself stays in `channels`, the options are a sibling key.
213
+ - **Partition revalidation.** Before delivering anything, 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 (`freshness` goes `"stale"`) and receives none of that emit's data. A `scope` that **throws** on re-derivation is treated the same way (fail-closed: a resolver that failed cannot vouch for the partition) and the failure is logged naming the channel — a scope that does I/O must handle its own failures. Only the partition is revalidated; session/cookie expiry stays with the normal request pipeline.
214
+ - **Heartbeat and idle timeout — the transport clocks.** Every channel answer carries `Cache-Control: no-store` (a proxy or the browser must never serve a cached snapshot) and a heartbeat: an SSE comment (`: ping`) written through the write chain every `heartbeatMs` — default `20000`, the same ruler the `stream_*` SSE surface uses; `0` disables it. A comment generates no client event and does not count as a delivery. A connection with **no deliveries** (snapshot or slice) for `idleMs` — default `300000` (5 min); `0` disables — is closed by the server, and the heartbeat does **not** reset the idle clock: idle is about data, heartbeat is transport.
215
+ **Practical consequences.** A live but quiet page cycles by design: the server closes it, the `EventSource` reconnects by itself and the snapshot on connect repairs the state — do not build anything against that cycle, and do not interpret a periodic reconnect of a quiet page as a bug. In tests, `createTestApp` defaults both clocks to `0` (deterministic); a test exercising them passes `idleMs`/`heartbeatMs` in `channelOptions` and drives them with fake timers — **never assert freshness without advancing the fake timers through the window**.
216
+ - **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 with everything alive. In the Caddyfile: `flush_interval -1` on the app's `reverse_proxy` (negative value = flush as data arrives; a positive interval would batch chunks and delay every snapshot). For nginx in front: `X-Accel-Buffering: no`. The heartbeat keeps the proxy from killing the idle stream; the idle timeout closes what nobody reads anymore.
217
+ - **Inspector — asking the server what is alive.** `inspectChannels()` (from `@mauroandre/velojs/server`) returns the live state: per channel, the groups identified by the pair (moduleId, partition key) — which page holds every connection — each with its connection count and the timestamp of its last delivery; the coalescing windows still open; the grand totals. Always available; expose it with whatever guard the app judges. In development only, `GET /_channel-inspect` answers the same JSON in the browser — it never ships in a production build (the map of channels and partitions is internal information); a production app that wants something similar exposes `inspectChannels()` itself.
218
+
219
+ **The partition contract is fixed.** On subscribe the scope receives
220
+ `{ user, params }`: `user` is `c.get("user")` — the house key, the same one used
221
+ by stream/socket resolvers; if your middlewares materialize the principal under
222
+ another key, set it in `"user"` too — and `params` are the route params the
223
+ connection declared, already validated. With no authenticated principal `user`
224
+ is `undefined`: a public channel ignores it (`() => "all"`), an authenticated one
225
+ returns `null`, which denies the subscription with 403; an app that does not
226
+ materialize a principal at all (a network guard, e.g. loopback) writes
227
+ `({ params }) => …`. On emit the scope receives the object the producer built,
228
+ with the fields it consumes (`{ params: … }` or `{ user: … }`). A scope that
229
+ **throws** on subscribe denies with **403** and logs loudly, naming the channel.
230
+ The partition **never** comes from the query string or from any other client
231
+ input.
232
+
233
+ Same channel declared by a layout and by a page → one connection per module,
234
+ each feeding its own module's data; a single `emit` by channel name reaches both.
235
+
236
+ `useLoader()` and `Loader()` also expose `freshness` — a signal with `"live"`,
237
+ `"stale"` (connection closed, reconnecting **or silent**) and `"error"` (the
238
+ last re-execution failed), aggregated per page — and `freshnessByChannel`, the
239
+ same states keyed by the channel names the module declares: a page with two
240
+ channels, one fallen and one following, shows both states at once. They are
241
+ data for CSS to react to; the framework renders no JSX for them. Without
242
+ `channels`, the record is empty and `freshness` stays `"live"`.
243
+
244
+ The **silence detector** is the client half of the idle timeout: a connection
245
+ open but without deliveries for `CHANNEL_SILENCE_MS` (60s, a runtime constant,
246
+ not a configuration) is marked `"stale"` even with `EventSource` in `OPEN` —
247
+ the tab in the background, the sleeping notebook, the proxy that stopped
248
+ flushing without closing. Any delivery (or a reconnect's snapshot) brings the
249
+ channel back to `"live"`; the heartbeat comment cannot mask the silence because
250
+ it generates no client event. The aggregate folds it by the same rule: a
251
+ silenced channel counts as not-open.
252
+
253
+ Guards are explicit, never silent: `channels` in a module without a `loader`
254
+ ("nothing to synchronize") and a channel with no entry in `app/channels.ts` both
255
+ fail the dev server and the build; `emit()` on an unknown channel throws
256
+ immediately naming it (`emit` on a valid channel with no subscribers is a no-op);
257
+ and `emit(module, channel, …)` on a module that does not declare that channel, or
258
+ that has no `loader`, throws immediately naming both (a slice that is not an
259
+ object throws too).
260
+ A subscription whose route params carry a key the module's path does not declare
261
+ is rejected with **400**, naming the key — the client may only declare what the
262
+ route declares. A `scope` that **throws** on subscribe denies with **403** and
263
+ logs loudly, naming the channel (it used to answer 500).
264
+ In `velojs build --static` the channels are **inert** — no server, so the client
265
+ opens no connection and freshness stays `"live"` — and the build warns, naming
266
+ the module.
267
+
268
+ ### State or flow? Two questions, always the same two
269
+
270
+ | Question | Answer | Tool |
271
+ |---|---|---|
272
+ | Is this a **value** or a **sequence**? | value — "what is it now?", replacement | `loader` + `channels` (live loader) |
273
+ | | sequence — "what happened?", accumulation (chat, log, metric series, progress) | `stream_*` / `socket_*` |
274
+ | Does the change come from **someone else** or from **me**? | from someone else / from a background job | `loader` + `channels` |
275
+ | | from me (my own action) | `refetch()` after the action — no channel |
276
+
277
+ Do not turn on a channel where a post-action `refetch()` suffices, and do not
278
+ use `stream_*` for a value that is simply replaced.
279
+
66
280
  ## A stream/socket `channel` is untrusted client input
67
281
 
68
282
  `useEventStream(s, { channel })` and `useSocket(s, { channel })` put the channel on the
@@ -99,6 +313,7 @@ cannot influence it.
99
313
  | File | Role |
100
314
  |---|---|
101
315
  | `app/routes.tsx` | The route tree. `export default [...] satisfies AppRoutes`, `import * as` for every page/layout |
316
+ | `app/channels.ts` | The live-loader channel map: channel name → `{ scope, onGroupOpen, onGroupClose }` — the partition resolver and the group lifecycle. Convention file, imported by the framework |
102
317
  | `app/<domain>/Page.tsx` | One module per route: `Component`, plus optional `loader`, `action_*`, `stream_*`, `socket_*`, `metadata`. Group by domain (`app/auth/`, `app/admin/`), not in a flat `pages/` folder |
103
318
  | `app/layouts/*.tsx` | Shared layouts. Long-lived `stream_*` declarations usually live on the layout that spans their pages |
104
319
  | `app/modules/<domain>/` | Server-side logic imported by pages: `*.service.ts`, `*.middleware.ts`, `*.stream.ts`. Not routed |
@@ -134,6 +349,7 @@ API from this file — it is deliberately incomplete.
134
349
  | Navigation, `<Link>` | `link-component` |
135
350
  | `<Scripts />`, assets, favicon | `scripts-component` |
136
351
  | SSE, live/progress/streaming data | `event-streams` |
352
+ | A page value that must track server state (`loader` + `channels`) | `live-loader` |
137
353
  | WebSockets, bidirectional realtime | `sockets` |
138
354
  | `addRoutes`, `onServer`, ports, server lifecycle | `server-api` |
139
355
  | The build, transforms, `veloPlugin` options | `vite-plugin` |