@mauroandre/velojs 0.0.56 → 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 CHANGED
@@ -489,9 +489,11 @@ export const Component = () => {
489
489
  // app/channels.ts — the app's channel map: name → partition resolver
490
490
  export const channels = {
491
491
  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}`,
492
+ // On subscribe the ctx is { user, params }: the principal materialized
493
+ // in c.get("user") and the route params the connection declared. On
494
+ // emit it is the object the producer built, with the fields this scope
495
+ // consumes. Returns the partition key.
496
+ scope: ({ user }) => `team:${user.teamId}`,
495
497
  },
496
498
  };
497
499
  ```
@@ -500,7 +502,7 @@ export const channels = {
500
502
  // anywhere server-side: an action, server.tsx, a scheduler, a webhook
501
503
  import { emit } from "@mauroandre/velojs/server";
502
504
 
503
- await emit("teamExpenses", { teamId: 7 });
505
+ await emit("teamExpenses", { user: { teamId: 7 } });
504
506
  ```
505
507
 
506
508
  ### What happens
@@ -513,12 +515,14 @@ await emit("teamExpenses", { teamId: 7 });
513
515
  channel declared by both a layout and a page produces one connection per
514
516
  module, each feeding its own module's data; a single `emit` reaches both.
515
517
  - 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.
518
+ re-executing the loader with that connection's own principal and route
519
+ params — so the pane never shows the SSR value while the server has already
520
+ moved on.
521
+ - `emit(channel, ctx)` resolves the partition with the same `scope` — the
522
+ producer builds the same `{ user, params }` shape with the fields its scope
523
+ consumes — finds the connections in that partition and pushes a fresh
524
+ snapshot to each one: its loader re-executed with its own principal and route
525
+ params. Connections of other partitions receive nothing.
522
526
  - The snapshot **replaces** the module's loader data; the component re-renders
523
527
  by itself. `useLoader()` and `Loader()` also expose `freshness` — `"live"`,
524
528
  `"stale"` (connection closed/reconnecting/silent), `"error"` (the last
@@ -526,6 +530,81 @@ await emit("teamExpenses", { teamId: 7 });
526
530
  same states keyed by each of the module's channel names. They are data for
527
531
  CSS to react to.
528
532
 
533
+ ### Routes with `:params` — the client declares, the server decides
534
+
535
+ A channel declared in a module whose route has `:params` (`/projeto/:id`, a page
536
+ or a layout) partitions per **resource**, and the loader re-executes with
537
+ `params.id` filled — the connect snapshot and every emit.
538
+
539
+ ```tsx
540
+ // app/projeto/Projeto.tsx — the route /projeto/:id
541
+ export const loader = async ({ params }: LoaderArgs) => ({
542
+ arquivos: await projetoService.arquivos(params.id),
543
+ });
544
+ export const channels = ["projetoArquivos"];
545
+ ```
546
+
547
+ ```ts
548
+ // app/channels.ts
549
+ export const channels = {
550
+ projetoArquivos: {
551
+ scope: ({ user, params }) => {
552
+ if (!params?.id) return null;
553
+ if (user && !podeVer(user, params.id)) return null; // 403
554
+ return `projeto:${params.id}`;
555
+ },
556
+ },
557
+ };
558
+ ```
559
+
560
+ - The client **declares** the address it is already seeing: it extracts the
561
+ params the module's `fullPath` declares, matching as a **prefix** of the
562
+ pathname (a layout `/projeto/:id` covers `/projeto/7/sala` and extracts
563
+ `id=7`; the child's suffix takes no part) and sends them in `?_route=<json>`.
564
+ A catch-all (`/docs/*`) declares no `:` key — the connection travels by
565
+ principal.
566
+ - The server **decides**: it validates the keys against the module's declared
567
+ path — a key the path does not declare rejects the subscription with **400** —
568
+ and the `scope` validates the policy (possession/permission), deriving the
569
+ key. `params` are never an authorization input: they are the declared route,
570
+ validated by shape and then by policy.
571
+ - The internal `_route` key never reaches the loader's `query`: it is transport,
572
+ not page data.
573
+ - The connection is **keyed by the extracted params**, not by the pathname:
574
+ `/projeto/7` → `/projeto/9` closes the connection and opens a new one (with a
575
+ snapshot on connect); `/projeto/7` → `/projeto/7/sala` does not reconnect.
576
+
577
+ ### Group lifecycle: `onGroupOpen` / `onGroupClose`
578
+
579
+ A source with a **cost of life** — a filesystem watcher, a process, a
580
+ subscription to an external service — must not run forever for an empty group.
581
+ The first connection of a group (channel + partition) arms it and the last one
582
+ disarms it, by any cause (disconnect, idle, revalidation):
583
+
584
+ ```ts
585
+ export const channels = {
586
+ projetoArquivos: {
587
+ scope: ({ params }) => (params?.id ? `projeto:${params.id}` : null),
588
+ onGroupOpen: ({ params }) =>
589
+ watchers.armar(params.id, () => emit("projetoArquivos", { params: { id: params.id } })),
590
+ onGroupClose: ({ partition }) => watchers.desarmar(),
591
+ },
592
+ };
593
+ ```
594
+
595
+ - A second connection of the same group fires nothing.
596
+ - Both hooks may be async and are **awaited**: on open, before the connect
597
+ snapshot (the source is armed before the first data arrives); on close, before
598
+ the group is discarded. A hook that throws (or rejects) is logged loudly,
599
+ naming channel and partition, and the group continues — the cold-source
600
+ snapshot still arrives, only the live data waits for the fix.
601
+ - The hook is infrastructure, not policy: it does not receive `user`.
602
+ - The `params` of `onGroupOpen` belong to the connection that opened the group
603
+ and are identical across it **only when the partition derives from them** (a
604
+ watcher per resource). A channel partitioned by **principal**, declared in a
605
+ module with `:params`, mixes resources in one group: do not consume the
606
+ hook's params there.
607
+
529
608
  ### Two ways to emit
530
609
 
531
610
  **Invalidation** — `emit(channel, ctx)` says *this partition changed*, without
@@ -544,10 +623,10 @@ import { emit } from "@mauroandre/velojs/server";
544
623
  import * as Expenses from "../app/expenses/Expenses.js";
545
624
 
546
625
  // invalidation: the runtime re-executes Expenses.loader per connection
547
- await emit("teamExpenses", { teamId: 7 });
626
+ await emit("teamExpenses", { user: { teamId: 7 } });
548
627
 
549
628
  // slice: the value is already in hand — delivered as-is, no re-execution
550
- await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
629
+ await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
551
630
  ```
552
631
 
553
632
  Why the module, and not just the channel name? Because the same channel can be
@@ -602,8 +681,9 @@ key on top of the current value.
602
681
  `createTestApp` registers with the window off and takes the same options in
603
682
  `channelOptions`.
604
683
  - **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
684
+ each connection's partition by running the `scope` again with the ctx captured
685
+ at subscribe — the principal and the validated route params of that
686
+ connection. A connection whose partition moved — or whose scope now
607
687
  returns `null` — is removed from the group and closed: the client sees the
608
688
  connection closed (`freshness` goes `"stale"`) and receives none of that
609
689
  emit's data. A `scope` that **throws** on re-derivation is treated the same
@@ -688,12 +768,20 @@ partitions is internal information, and it never ships in a production build.
688
768
 
689
769
  ### The partition contract
690
770
 
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.
771
+ On subscribe the `scope` receives `{ user, params }`. `user` is `c.get("user")`
772
+ — the house key, the same one the stream/socket channel resolvers use (a
773
+ middleware that materializes the principal elsewhere should set it in `"user"`
774
+ too) — and `params` are the route params the connection declared, already
775
+ validated. Without an authenticated principal `user` is `undefined`: a public
776
+ channel ignores it (`() => "all"`), an authenticated one returns `null`, which
777
+ denies the subscription with 403; an app that does not materialize a principal
778
+ at all (a network guard, e.g. loopback) writes `({ params }) => …`. On emit the
779
+ same scope receives the object the producer built, with the fields it consumes
780
+ (`{ params: … }` or `{ user: … }`). A scope that **throws** on subscribe denies
781
+ with **403** and logs loudly, naming the channel.
782
+ The partition **never** derives from the query string or from any other client
783
+ input: `params` are the declared route — validated by shape — not a choice of
784
+ subject.
697
785
 
698
786
  ### State or flow?
699
787
 
@@ -714,6 +802,11 @@ Two questions decide, every time:
714
802
  server and in the build, naming the channel.
715
803
  - `emit()` on a channel with no entry in the map: throws immediately, naming
716
804
  the channel. `emit()` on a valid channel with no subscribers is a no-op.
805
+ - Route params with a key the module's path does **not** declare: the
806
+ subscription is rejected with **400**, naming the key — the client may only
807
+ declare what the route declares.
808
+ - A `scope` that **throws** on subscribe: denied with **403** and logged
809
+ loudly, naming the channel.
717
810
  - `emit(module, channel, …)` on a module that does not declare that channel, or
718
811
  that has no `loader`: throws immediately, naming both. A slice that is not an
719
812
  object throws too.
@@ -739,10 +832,14 @@ const app = await createTestApp({
739
832
 
740
833
  const sub = await app.as({ user: { teamId: 7 } }).channel(Expenses, "teamExpenses");
741
834
  await sub.next({ timeoutMs: 1000 }); // snapshot on connect
742
- await emit("teamExpenses", { teamId: 7 });
835
+ await emit("teamExpenses", { user: { teamId: 7 } });
743
836
  const snapshot = await sub.next({ timeoutMs: 1000 });
744
- await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
837
+ await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
745
838
  const slice = await sub.next({ timeoutMs: 1000 });
839
+
840
+ // A route with `:params`: the connection declares them (validated for real).
841
+ const project = await app.channel(Project, "projetoArquivos", { params: { id: "7" } });
842
+ expect(await project.next({ timeoutMs: 1000 })).toEqual({ id: "7", files: ["a.txt"] });
746
843
  ```
747
844
 
748
845
  `next()` returns the payload exactly as it travelled (a whole snapshot or a raw
@@ -1689,7 +1786,7 @@ await asAlice.subscribe(stream_progress, { channel: appId });
1689
1786
  // Live loader — open a channel with a principal, emit, await the next snapshot
1690
1787
  const live = await app.as({ user: { teamId: 7 } }).channel(Expenses, "teamExpenses");
1691
1788
  await live.next({ timeoutMs: 1000 }); // snapshot on connect
1692
- await emit("teamExpenses", { teamId: 7 });
1789
+ await emit("teamExpenses", { user: { teamId: 7 } });
1693
1790
  const snapshot = await live.next({ timeoutMs: 1000 });
1694
1791
 
1695
1792
  await app.close();
@@ -1705,7 +1802,7 @@ await app.close();
1705
1802
  | `app.action(fn, opts)` | Invoke `action_*` by function reference |
1706
1803
  | `app.loader(fn, opts)` | Invoke `loader` and unwrap response data |
1707
1804
  | `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 }`) |
1805
+ | `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
1806
  | `app.as({ user })` | Sub-client with cookies bound to a user |
1710
1807
  | `app.sessionCookies({ user })` | Build cookies via `getSessionCookie` |
1711
1808
  | `app.mockContext(opts)` | Escape hatch — partial Hono Context for direct invocation |
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,79 @@ 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
 
109
111
  Or, when the producer already holds the new value, it pushes it as a typed slice:
110
112
 
111
113
  ```ts
112
114
  import * as Expenses from "../app/expenses/Expenses.js";
113
- await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
115
+ await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
114
116
  ```
115
117
 
116
118
  The framework derives `GET /_channel/{moduleId}/{channel}` (same family as
117
119
  `/_action` and `/_event`), inheriting the route node's `middlewares`; after
118
120
  hydration the client opens one connection per (module, channel) of the rendered
119
121
  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.
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.
124
181
 
125
182
  ### Two ways to emit — invalidation or the value itself
126
183
 
@@ -153,19 +210,25 @@ name keeps reaching every module of the channel.
153
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.
154
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.
155
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.
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.
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.
157
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.
158
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**.
159
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.
160
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.
161
218
 
162
219
  **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.
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.
169
232
 
170
233
  Same channel declared by a layout and by a page → one connection per module,
171
234
  each feeding its own module's data; a single `emit` by channel name reaches both.
@@ -194,6 +257,10 @@ immediately naming it (`emit` on a valid channel with no subscribers is a no-op)
194
257
  and `emit(module, channel, …)` on a module that does not declare that channel, or
195
258
  that has no `loader`, throws immediately naming both (a slice that is not an
196
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).
197
264
  In `velojs build --static` the channels are **inert** — no server, so the client
198
265
  opens no connection and freshness stays `"live"` — and the build warns, naming
199
266
  the module.
@@ -246,7 +313,7 @@ cannot influence it.
246
313
  | File | Role |
247
314
  |---|---|
248
315
  | `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 |
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 |
250
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 |
251
318
  | `app/layouts/*.tsx` | Shared layouts. Long-lived `stream_*` declarations usually live on the layout that spans their pages |
252
319
  | `app/modules/<domain>/` | Server-side logic imported by pages: `*.service.ts`, `*.middleware.ts`, `*.stream.ts`. Not routed |