@mauroandre/velojs 0.0.56 → 0.0.58

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 CHANGED
@@ -467,6 +467,7 @@ A page value that must **track server state over time** — a total another
467
467
  person's request changes, data a background scheduler mutates — declares the
468
468
  channels that keep it fresh. No extra component, no manual subscription: the
469
469
  module exports `channels` next to its `loader`.
470
+ The one-sentence model: the live loader keeps a page's state replica faithful to the server — the first payload arrives through the usual path (SSR, `__PAGE_DATA__`, the `?_data=1` refetch), and from then on every change is **addressed**, by the app's own rules, to the group of clients it concerns, **by duty or by interest**, each one receiving the state computed for its own scope.
470
471
 
471
472
  ```tsx
472
473
  // app/expenses/Expenses.tsx
@@ -489,9 +490,11 @@ export const Component = () => {
489
490
  // app/channels.ts — the app's channel map: name → partition resolver
490
491
  export const channels = {
491
492
  teamExpenses: {
492
- // ctx: on subscribe, the principal materialized in c.get("user");
493
- // on emit, the object the emitter passed. Returns the partition key.
494
- scope: (ctx) => `team:${ctx.teamId}`,
493
+ // On subscribe the ctx is { user, params }: the principal materialized
494
+ // in c.get("user") and the route params the connection declared. On
495
+ // emit it is the object the producer built, with the fields this scope
496
+ // consumes. Returns the partition key.
497
+ scope: ({ user }) => `team:${user.teamId}`,
495
498
  },
496
499
  };
497
500
  ```
@@ -500,7 +503,7 @@ export const channels = {
500
503
  // anywhere server-side: an action, server.tsx, a scheduler, a webhook
501
504
  import { emit } from "@mauroandre/velojs/server";
502
505
 
503
- await emit("teamExpenses", { teamId: 7 });
506
+ await emit("teamExpenses", { user: { teamId: 7 } });
504
507
  ```
505
508
 
506
509
  ### What happens
@@ -513,12 +516,14 @@ await emit("teamExpenses", { teamId: 7 });
513
516
  channel declared by both a layout and a page produces one connection per
514
517
  module, each feeding its own module's data; a single `emit` reaches both.
515
518
  - Every new connection immediately receives the **current state**, computed by
516
- re-executing the loader with that connection's own principal — so the pane
517
- never shows the SSR value while the server has already moved on.
518
- - `emit(channel, ctx)` resolves the partition with the same `scope`, finds the
519
- connections in that partition and pushes a fresh snapshot to each one — its
520
- loader re-executed with its own principal. Connections of other partitions
521
- receive nothing.
519
+ re-executing the loader with that connection's own principal and route
520
+ params — so the pane never shows the SSR value while the server has already
521
+ moved on.
522
+ - `emit(channel, ctx)` resolves the partition with the same `scope` — the
523
+ producer builds the same `{ user, params }` shape with the fields its scope
524
+ consumes — finds the connections in that partition and pushes a fresh
525
+ snapshot to each one: its loader re-executed with its own principal and route
526
+ params. Connections of other partitions receive nothing.
522
527
  - The snapshot **replaces** the module's loader data; the component re-renders
523
528
  by itself. `useLoader()` and `Loader()` also expose `freshness` — `"live"`,
524
529
  `"stale"` (connection closed/reconnecting/silent), `"error"` (the last
@@ -526,6 +531,81 @@ await emit("teamExpenses", { teamId: 7 });
526
531
  same states keyed by each of the module's channel names. They are data for
527
532
  CSS to react to.
528
533
 
534
+ ### Routes with `:params` — the client declares, the server decides
535
+
536
+ A channel declared in a module whose route has `:params` (`/projeto/:id`, a page
537
+ or a layout) partitions per **resource**, and the loader re-executes with
538
+ `params.id` filled — the connect snapshot and every emit.
539
+
540
+ ```tsx
541
+ // app/projeto/Projeto.tsx — the route /projeto/:id
542
+ export const loader = async ({ params }: LoaderArgs) => ({
543
+ arquivos: await projetoService.arquivos(params.id),
544
+ });
545
+ export const channels = ["projetoArquivos"];
546
+ ```
547
+
548
+ ```ts
549
+ // app/channels.ts
550
+ export const channels = {
551
+ projetoArquivos: {
552
+ scope: ({ user, params }) => {
553
+ if (!params?.id) return null;
554
+ if (user && !podeVer(user, params.id)) return null; // 403
555
+ return `projeto:${params.id}`;
556
+ },
557
+ },
558
+ };
559
+ ```
560
+
561
+ - The client **declares** the address it is already seeing: it extracts the
562
+ params the module's `fullPath` declares, matching as a **prefix** of the
563
+ pathname (a layout `/projeto/:id` covers `/projeto/7/sala` and extracts
564
+ `id=7`; the child's suffix takes no part) and sends them in `?_route=<json>`.
565
+ A catch-all (`/docs/*`) declares no `:` key — the connection travels by
566
+ principal.
567
+ - The server **decides**: it validates the keys against the module's declared
568
+ path — a key the path does not declare rejects the subscription with **400** —
569
+ and the `scope` validates the policy (possession/permission), deriving the
570
+ key. `params` are never an authorization input: they are the declared route,
571
+ validated by shape and then by policy.
572
+ - The internal `_route` key never reaches the loader's `query`: it is transport,
573
+ not page data.
574
+ - The connection is **keyed by the extracted params**, not by the pathname:
575
+ `/projeto/7` → `/projeto/9` closes the connection and opens a new one (with a
576
+ snapshot on connect); `/projeto/7` → `/projeto/7/sala` does not reconnect.
577
+
578
+ ### Group lifecycle: `onGroupOpen` / `onGroupClose`
579
+
580
+ A source with a **cost of life** — a filesystem watcher, a process, a
581
+ subscription to an external service — must not run forever for an empty group.
582
+ The first connection of a group (channel + partition) arms it and the last one
583
+ disarms it, by any cause (disconnect, idle, revalidation):
584
+
585
+ ```ts
586
+ export const channels = {
587
+ projetoArquivos: {
588
+ scope: ({ params }) => (params?.id ? `projeto:${params.id}` : null),
589
+ onGroupOpen: ({ params }) =>
590
+ watchers.armar(params.id, () => emit("projetoArquivos", { params: { id: params.id } })),
591
+ onGroupClose: ({ partition }) => watchers.desarmar(),
592
+ },
593
+ };
594
+ ```
595
+
596
+ - A second connection of the same group fires nothing.
597
+ - Both hooks may be async and are **awaited**: on open, before the connect
598
+ snapshot (the source is armed before the first data arrives); on close, before
599
+ the group is discarded. A hook that throws (or rejects) is logged loudly,
600
+ naming channel and partition, and the group continues — the cold-source
601
+ snapshot still arrives, only the live data waits for the fix.
602
+ - The hook is infrastructure, not policy: it does not receive `user`.
603
+ - The `params` of `onGroupOpen` belong to the connection that opened the group
604
+ and are identical across it **only when the partition derives from them** (a
605
+ watcher per resource). A channel partitioned by **principal**, declared in a
606
+ module with `:params`, mixes resources in one group: do not consume the
607
+ hook's params there.
608
+
529
609
  ### Two ways to emit
530
610
 
531
611
  **Invalidation** — `emit(channel, ctx)` says *this partition changed*, without
@@ -544,10 +624,10 @@ import { emit } from "@mauroandre/velojs/server";
544
624
  import * as Expenses from "../app/expenses/Expenses.js";
545
625
 
546
626
  // invalidation: the runtime re-executes Expenses.loader per connection
547
- await emit("teamExpenses", { teamId: 7 });
627
+ await emit("teamExpenses", { user: { teamId: 7 } });
548
628
 
549
629
  // slice: the value is already in hand — delivered as-is, no re-execution
550
- await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
630
+ await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
551
631
  ```
552
632
 
553
633
  Why the module, and not just the channel name? Because the same channel can be
@@ -602,8 +682,9 @@ key on top of the current value.
602
682
  `createTestApp` registers with the window off and takes the same options in
603
683
  `channelOptions`.
604
684
  - **Partition revalidation.** Before delivering anything, the runtime re-derives
605
- each connection's partition by running the `scope` again with the principal
606
- captured at subscribe. A connection whose partition moved — or whose scope now
685
+ each connection's partition by running the `scope` again with the ctx captured
686
+ at subscribe — the principal and the validated route params of that
687
+ connection. A connection whose partition moved — or whose scope now
607
688
  returns `null` — is removed from the group and closed: the client sees the
608
689
  connection closed (`freshness` goes `"stale"`) and receives none of that
609
690
  emit's data. A `scope` that **throws** on re-derivation is treated the same
@@ -688,12 +769,22 @@ partitions is internal information, and it never ships in a production build.
688
769
 
689
770
  ### The partition contract
690
771
 
691
- On subscribe the `scope` receives `c.get("user")` — the house key, the same one
692
- the stream/socket channel resolvers use (a middleware that materializes the
693
- principal elsewhere should set it in `"user"` too). Without an authenticated
694
- principal it receives `undefined`: a public channel ignores it (`() => "all"`),
695
- an authenticated one returns `null`, which denies the subscription with 403.
696
- The partition **never** derives from the query string or from any client input.
772
+ **Addressed, not broadcast.** A partition is not a filter applied after a broadcast. The `emit` is addressed: the runtime resolves the partition key and writes only to the connections of that group — a client outside the group never hears about the change, not even a byte. Two reasons make a partition matter, and they compose: **by duty** (authorization — the data belongs to that team, that family, that project; others must not see it) and **by interest** (relevance — the tab watching project 9 has nothing to wake up for when project 7 changes). The same `scope` serves both; the app decides which, or both at once.
773
+
774
+ On subscribe the `scope` receives `{ user, params }`. `user` is `c.get("user")`
775
+ — the house key, the same one the stream/socket channel resolvers use (a
776
+ middleware that materializes the principal elsewhere should set it in `"user"`
777
+ too) — and `params` are the route params the connection declared, already
778
+ validated. Without an authenticated principal `user` is `undefined`: a public
779
+ channel ignores it (`() => "all"`), an authenticated one returns `null`, which
780
+ denies the subscription with 403; an app that does not materialize a principal
781
+ at all (a network guard, e.g. loopback) writes `({ params }) => …`. On emit the
782
+ same scope receives the object the producer built, with the fields it consumes
783
+ (`{ params: … }` or `{ user: … }`). A scope that **throws** on subscribe denies
784
+ with **403** and logs loudly, naming the channel.
785
+ The partition **never** derives from the query string or from any other client
786
+ input: `params` are the declared route — validated by shape — not a choice of
787
+ subject.
697
788
 
698
789
  ### State or flow?
699
790
 
@@ -714,6 +805,11 @@ Two questions decide, every time:
714
805
  server and in the build, naming the channel.
715
806
  - `emit()` on a channel with no entry in the map: throws immediately, naming
716
807
  the channel. `emit()` on a valid channel with no subscribers is a no-op.
808
+ - Route params with a key the module's path does **not** declare: the
809
+ subscription is rejected with **400**, naming the key — the client may only
810
+ declare what the route declares.
811
+ - A `scope` that **throws** on subscribe: denied with **403** and logged
812
+ loudly, naming the channel.
717
813
  - `emit(module, channel, …)` on a module that does not declare that channel, or
718
814
  that has no `loader`: throws immediately, naming both. A slice that is not an
719
815
  object throws too.
@@ -739,10 +835,14 @@ const app = await createTestApp({
739
835
 
740
836
  const sub = await app.as({ user: { teamId: 7 } }).channel(Expenses, "teamExpenses");
741
837
  await sub.next({ timeoutMs: 1000 }); // snapshot on connect
742
- await emit("teamExpenses", { teamId: 7 });
838
+ await emit("teamExpenses", { user: { teamId: 7 } });
743
839
  const snapshot = await sub.next({ timeoutMs: 1000 });
744
- await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
840
+ await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
745
841
  const slice = await sub.next({ timeoutMs: 1000 });
842
+
843
+ // A route with `:params`: the connection declares them (validated for real).
844
+ const project = await app.channel(Project, "projetoArquivos", { params: { id: "7" } });
845
+ expect(await project.next({ timeoutMs: 1000 })).toEqual({ id: "7", files: ["a.txt"] });
746
846
  ```
747
847
 
748
848
  `next()` returns the payload exactly as it travelled (a whole snapshot or a raw
@@ -1689,7 +1789,7 @@ await asAlice.subscribe(stream_progress, { channel: appId });
1689
1789
  // Live loader — open a channel with a principal, emit, await the next snapshot
1690
1790
  const live = await app.as({ user: { teamId: 7 } }).channel(Expenses, "teamExpenses");
1691
1791
  await live.next({ timeoutMs: 1000 }); // snapshot on connect
1692
- await emit("teamExpenses", { teamId: 7 });
1792
+ await emit("teamExpenses", { user: { teamId: 7 } });
1693
1793
  const snapshot = await live.next({ timeoutMs: 1000 });
1694
1794
 
1695
1795
  await app.close();
@@ -1705,7 +1805,7 @@ await app.close();
1705
1805
  | `app.action(fn, opts)` | Invoke `action_*` by function reference |
1706
1806
  | `app.loader(fn, opts)` | Invoke `loader` and unwrap response data |
1707
1807
  | `app.subscribe(stream, opts)` | Subscribe to a stream; returns `TestSubscription` with `next/nextN/snapshot/close/closed` |
1708
- | `app.channel(module, name, opts)` | Open a live-loader channel connection; snapshots and slices arrive through `next()` (raw) or `nextEvent()` (`{ type, data }`) |
1808
+ | `app.channel(module, name, opts)` | Open a live-loader channel connection; `opts.params` declares the route params (validated for real — a wrong key rejects with 400); snapshots and slices arrive through `next()` (raw) or `nextEvent()` (`{ type, data }`) |
1709
1809
  | `app.as({ user })` | Sub-client with cookies bound to a user |
1710
1810
  | `app.sessionCookies({ user })` | Build cookies via `getSessionCookie` |
1711
1811
  | `app.mockContext(opts)` | Escape hatch — partial Hono Context for direct invocation |
@@ -1928,7 +2028,7 @@ velojs build
1928
2028
 
1929
2029
  Asset filenames include content hashes for long-term browser caching. The server build reads the Vite manifest to inject the correct filenames into `<Scripts>`.
1930
2030
 
1931
- In production, `velojs start` sets `NODE_ENV=production` automatically and serves static files from `dist/client/`. HTML responses are served with `Cache-Control: no-cache` so browsers always fetch the latest HTML (which references the current hashed assets).
2031
+ In production, `velojs start` sets `NODE_ENV=production` automatically and serves static files from `dist/client/`. Assets carry content hashes and are **public by design**, so the server answers an existing file **before the app's route table** — a page route, an action endpoint or a route middleware (an auth guard redirecting to `/login`, for instance) never sees an asset path. Only the framework's own infra middlewares (trailing-slash normalization, the logger) run ahead of the asset lookup. Paths that are not files fall through to the app routes as usual (SSR pages, actions, streams, sockets, endpoints, the catch-all 404). HTML responses are served with `Cache-Control: no-cache` so browsers always fetch the latest HTML (which references the current hashed assets).
1932
2032
 
1933
2033
  ### Static assets on CDN
1934
2034
 
@@ -1938,7 +2038,7 @@ Set `STATIC_BASE_URL` to serve static assets from a CDN or S3 bucket:
1938
2038
  STATIC_BASE_URL=https://cdn.example.com/assets node dist/server.js
1939
2039
  ```
1940
2040
 
1941
- The `<Scripts />` component and CSS `url()` references will use this prefix automatically.
2041
+ The `<Scripts />` component and CSS `url()` references will use this prefix automatically. When `STATIC_BASE_URL` starts with `http`, the server mounts no local static at all — every path is an app path, and the assets' public-by-design semantics come from the bucket/CDN.
1942
2042
 
1943
2043
  ---
1944
2044
 
package/agent/AGENTS.md CHANGED
@@ -92,9 +92,11 @@ And declare the partition of each channel once, in `app/channels.ts`:
92
92
  // app/channels.ts — the app's channel map: name → partition resolver
93
93
  export const channels = {
94
94
  teamExpenses: {
95
- // ctx: on subscribe, the principal materialized in c.get("user");
96
- // on emit, the object the emitter passed. Returns the partition key.
97
- scope: (ctx) => `team:${ctx.teamId}`,
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}`,
98
100
  },
99
101
  };
100
102
  ```
@@ -103,24 +105,81 @@ Any server-side code signals that the partition changed:
103
105
 
104
106
  ```ts
105
107
  import { emit } from "@mauroandre/velojs/server";
106
- await emit("teamExpenses", { teamId: 7 });
108
+ await emit("teamExpenses", { user: { teamId: 7 } });
107
109
  ```
108
110
 
111
+ An `emit` is addressed to the partition's group — clients outside the group never hear about it (not a broadcast the client filters); partitions exist by duty (authorization) or by interest (relevance).
112
+
109
113
  Or, when the producer already holds the new value, it pushes it as a typed slice:
110
114
 
111
115
  ```ts
112
116
  import * as Expenses from "../app/expenses/Expenses.js";
113
- await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
117
+ await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
114
118
  ```
115
119
 
116
120
  The framework derives `GET /_channel/{moduleId}/{channel}` (same family as
117
121
  `/_action` and `/_event`), inheriting the route node's `middlewares`; after
118
122
  hydration the client opens one connection per (module, channel) of the rendered
119
123
  hierarchy and closes it on unmount. Every new connection receives the **current
120
- state** (the loader re-executed with that connection's own principal), and every
121
- `emit` re-executes the loader per connection in the emitting partition, pushing
122
- a snapshot that replaces the module's data. The component re-renders on its own —
123
- the page has no code for any of it.
124
+ state** (the loader re-executed with that connection's own principal and route
125
+ params), and every `emit` re-executes the loader per connection in the emitting
126
+ partition, pushing a snapshot that replaces the module's data. The component
127
+ re-renders on its own — the page has no code for any of it.
128
+
129
+ ### Routes with `:params` — declare, do not choose
130
+
131
+ A channel declared in a module whose route declares `:params` (`/projeto/:id`, a
132
+ page or a layout) partitions per **resource**, and the loader re-executes with
133
+ `params.id` filled — the connect snapshot and every emit.
134
+
135
+ ```tsx
136
+ // app/projeto/Projeto.tsx — the route /projeto/:id
137
+ export const loader = async ({ params }: LoaderArgs) => ({
138
+ arquivos: await projetoService.arquivos(params.id),
139
+ });
140
+ export const channels = ["projetoArquivos"];
141
+ ```
142
+
143
+ ```ts
144
+ // app/channels.ts
145
+ export const channels = {
146
+ projetoArquivos: {
147
+ scope: ({ user, params }) => {
148
+ if (!params?.id) return null;
149
+ if (user && !podeVer(user, params.id)) return null; // 403
150
+ return `projeto:${params.id}`;
151
+ },
152
+ },
153
+ };
154
+ ```
155
+
156
+ - 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.
157
+ - 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.
158
+ - The internal `_route` key never reaches the loader's `query` — it is transport, not page data.
159
+ - 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.
160
+
161
+ ### Group lifecycle — arming a source with a cost of life
162
+
163
+ A watcher (filesystem, process, an external subscription) must not run forever
164
+ for an empty group. `onGroupOpen` fires when the **first** connection enters a
165
+ group (channel + partition) and `onGroupClose` when the **last** one leaves —
166
+ by any cause (disconnect, idle, revalidation):
167
+
168
+ ```ts
169
+ export const channels = {
170
+ projetoArquivos: {
171
+ scope: ({ params }) => (params?.id ? `projeto:${params.id}` : null),
172
+ onGroupOpen: ({ params }) =>
173
+ watchers.armar(params.id, () => emit("projetoArquivos", { params: { id: params.id } })),
174
+ onGroupClose: ({ partition }) => watchers.desarmar(),
175
+ },
176
+ };
177
+ ```
178
+
179
+ - 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.
180
+ - 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.
181
+ - The hook is infrastructure, not policy: it does not receive `user`.
182
+ - 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.
124
183
 
125
184
  ### Two ways to emit — invalidation or the value itself
126
185
 
@@ -153,19 +212,25 @@ name keeps reaching every module of the channel.
153
212
  - **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.
154
213
  **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.
155
214
  - **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.
156
- - **Partition revalidation.** Before delivering anything, the runtime re-derives each connection's partition by running the `scope` again with the principal captured at subscribe. 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.
215
+ - **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.
157
216
  - **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.
158
217
  **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**.
159
218
  - **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.
160
219
  - **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.
161
220
 
162
221
  **The partition contract is fixed.** On subscribe the scope receives
163
- `c.get("user")` — the house key, the same one used by stream/socket resolvers. If
164
- your middlewares materialize the principal under another key, set it in `"user"`
165
- too. With no authenticated principal the scope receives `undefined`: a public
166
- channel ignores it (`() => "all"`), an authenticated one returns `null`, which
167
- denies the subscription with 403. The partition **never** comes from the query
168
- string or from any client input.
222
+ `{ user, params }`: `user` is `c.get("user")` — the house key, the same one used
223
+ by stream/socket resolvers; if your middlewares materialize the principal under
224
+ another key, set it in `"user"` too — and `params` are the route params the
225
+ connection declared, already validated. With no authenticated principal `user`
226
+ is `undefined`: a public channel ignores it (`() => "all"`), an authenticated one
227
+ returns `null`, which denies the subscription with 403; an app that does not
228
+ materialize a principal at all (a network guard, e.g. loopback) writes
229
+ `({ params }) => …`. On emit the scope receives the object the producer built,
230
+ with the fields it consumes (`{ params: … }` or `{ user: … }`). A scope that
231
+ **throws** on subscribe denies with **403** and logs loudly, naming the channel.
232
+ The partition **never** comes from the query string or from any other client
233
+ input.
169
234
 
170
235
  Same channel declared by a layout and by a page → one connection per module,
171
236
  each feeding its own module's data; a single `emit` by channel name reaches both.
@@ -194,6 +259,10 @@ immediately naming it (`emit` on a valid channel with no subscribers is a no-op)
194
259
  and `emit(module, channel, …)` on a module that does not declare that channel, or
195
260
  that has no `loader`, throws immediately naming both (a slice that is not an
196
261
  object throws too).
262
+ A subscription whose route params carry a key the module's path does not declare
263
+ is rejected with **400**, naming the key — the client may only declare what the
264
+ route declares. A `scope` that **throws** on subscribe denies with **403** and
265
+ logs loudly, naming the channel (it used to answer 500).
197
266
  In `velojs build --static` the channels are **inert** — no server, so the client
198
267
  opens no connection and freshness stays `"live"` — and the build warns, naming
199
268
  the module.
@@ -246,7 +315,7 @@ cannot influence it.
246
315
  | File | Role |
247
316
  |---|---|
248
317
  | `app/routes.tsx` | The route tree. `export default [...] satisfies AppRoutes`, `import * as` for every page/layout |
249
- | `app/channels.ts` | The live-loader channel map: channel name → partition resolver (`scope`). Convention file, imported by the framework |
318
+ | `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 |
250
319
  | `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 |
251
320
  | `app/layouts/*.tsx` | Shared layouts. Long-lived `stream_*` declarations usually live on the layout that spans their pages |
252
321
  | `app/modules/<domain>/` | Server-side logic imported by pages: `*.service.ts`, `*.middleware.ts`, `*.stream.ts`. Not routed |