@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.
@@ -1,6 +1,6 @@
1
1
  ---
2
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. Use when a page value must track server state over time instead of being fetched once."
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
4
  ---
5
5
 
6
6
  # Live loader
@@ -30,9 +30,11 @@ export const Component = () => {
30
30
  // app/channels.ts — the app's channel map: name → partition resolver
31
31
  export const channels = {
32
32
  teamExpenses: {
33
- // ctx: on subscribe, the principal materialized in c.get("user");
34
- // on emit, the object the emitter passed. Returns the partition key.
35
- scope: (ctx) => `team:${ctx.teamId}`,
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}`,
36
38
  },
37
39
  };
38
40
  ```
@@ -41,7 +43,7 @@ export const channels = {
41
43
  // anywhere server-side: an action, server.tsx, a scheduler, a webhook
42
44
  import { emit } from "@mauroandre/velojs/server";
43
45
 
44
- await emit("teamExpenses", { teamId: 7 });
46
+ await emit("teamExpenses", { user: { teamId: 7 } });
45
47
  ```
46
48
 
47
49
  `export const channels = [...]` is the whole opt-in. A module without it behaves exactly as before — no connection, no cost.
@@ -50,10 +52,69 @@ await emit("teamExpenses", { teamId: 7 });
50
52
 
51
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.
52
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.
53
- - Every new connection immediately receives the **current state**, computed by re-executing the loader with that connection's own principal. 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.
54
- - `emit(channel, ctx)` resolves the partition with the same `scope`, finds the connections of that partition and pushes a fresh snapshot to each — the loader re-executed with each connection's own principal. 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.
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.
55
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.
56
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
+
57
118
  ## Two ways to emit
58
119
 
59
120
  The emit has two modes, and the difference is *who knows the new value*.
@@ -66,10 +127,10 @@ The emit has two modes, and the difference is *who knows the new value*.
66
127
  import * as Expenses from "../app/expenses/Expenses.js";
67
128
 
68
129
  // Invalidation: Expenses.loader runs once per connection of the partition.
69
- await emit("teamExpenses", { teamId: 7 });
130
+ await emit("teamExpenses", { user: { teamId: 7 } });
70
131
 
71
132
  // Slice: nothing runs; the connections merge this payload into their data.
72
- await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
133
+ await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
73
134
  ```
74
135
 
75
136
  The slice is typed as `Partial` of what the module's loader returns:
@@ -84,11 +145,11 @@ export const loader = async ({ c }: LoaderArgs) => {
84
145
  };
85
146
 
86
147
  // OK — known key, right type
87
- await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
148
+ await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
88
149
  // tsc error — the key does not exist in the loader's return
89
- await emit(Expenses, "teamExpenses", { teamId: 7 }, { total: 880 });
150
+ await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { total: 880 });
90
151
  // tsc error — wrong value type
91
- await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: "880" });
152
+ await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: "880" });
92
153
  ```
93
154
 
94
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.
@@ -108,12 +169,12 @@ The client merges every slice into the module's current value, with rules short
108
169
 
109
170
  ```ts
110
171
  // Current data: { teamTotal: 4, expenses: ["mercado", "luz"] }
111
- await emit("teamExpenses", { teamId: 7 }); // whole value
112
- await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 9 });
172
+ await emit("teamExpenses", { user: { teamId: 7 } }); // whole value
173
+ await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 9 });
113
174
  // → { teamTotal: 9, expenses: ["mercado", "luz"] }
114
175
 
115
176
  // Removing "luz" is re-sending the whole list without it
116
- await emit(Expenses, "teamExpenses", { teamId: 7 }, { expenses: ["mercado"] });
177
+ await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { expenses: ["mercado"] });
117
178
  // → { teamTotal: 9, expenses: ["mercado"] }
118
179
  ```
119
180
 
@@ -144,7 +205,7 @@ The window also decides what `await emit(…)` waits for. With the window off (`
144
205
 
145
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 })`.
146
207
 
147
- **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 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 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.
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.
148
209
 
149
210
  ## Transport: heartbeat, idle timeout and the proxy
150
211
 
@@ -223,7 +284,7 @@ On a page with no `channels` the fields exist, the record is empty and the aggre
223
284
 
224
285
  ## The partition contract
225
286
 
226
- On subscribe the `scope` receives `c.get("user")` — the house key, the same one the stream/socket channel resolvers use. A middleware that materializes the principal somewhere else should set it in `"user"` too:
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:
227
288
 
228
289
  ```ts
229
290
  const auth = async (c, next) => {
@@ -234,8 +295,11 @@ const auth = async (c, next) => {
234
295
  };
235
296
  ```
236
297
 
237
- - No authenticated principal → the scope receives `undefined`. A public channel ignores it (`() => "all"`); an authenticated one returns `null`, which denies the subscription with **403**.
238
- - The partition **never** derives from the query string or from any client input. A client cannot choose which subject's data it receives.
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.
239
303
  - No scope at all → the channel is public (`() => "all"`).
240
304
 
241
305
  ## State or flow?
@@ -257,6 +321,8 @@ The failures that would otherwise be silent are explicit:
257
321
 
258
322
  - `channels` in a module **without** a `loader`: error in the dev server and in the build — there is nothing to synchronize.
259
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.
260
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.
261
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.
262
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.
@@ -281,10 +347,10 @@ const app = await createTestApp({
281
347
  const sub = await app.as({ user: { teamId: 7 } }).channel(Expenses, "teamExpenses");
282
348
  expect(await sub.next({ timeoutMs: 1000 })).toEqual({ teamTotal: 4 }); // connect
283
349
 
284
- await emit("teamExpenses", { teamId: 7 });
350
+ await emit("teamExpenses", { user: { teamId: 7 } });
285
351
  expect(await sub.next({ timeoutMs: 1000 })).toEqual({ teamTotal: 9 }); // emit
286
352
 
287
- await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
353
+ await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
288
354
  expect(await sub.next({ timeoutMs: 1000 })).toEqual({ teamTotal: 880 }); // raw slice
289
355
 
290
356
  expect(await sub.nextEvent({ timeoutMs: 1000 })).toEqual({ // discriminated
@@ -298,6 +364,18 @@ await app.close();
298
364
 
299
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.
300
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
+
301
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`:
302
380
 
303
381
  ```ts
@@ -315,8 +393,11 @@ await vi.advanceTimersByTimeAsync(20); // the window closes: one round
315
393
  | Piece | Behavior |
316
394
  |---|---|
317
395
  | `export const channels = ["name"]` | Module opt-in: one internal route and one client connection per name |
318
- | `app/channels.ts` | Channel name → `{ scope }`; imported by the framework |
319
- | `scope(ctx)` | Returns the partition key; `null` denies with 403; omitted → `"all"`. Re-derived on every emit with the principal captured at subscribe |
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 |
320
401
  | `emit(channel, ctx)` | Invalidation: re-executes the loader per connection of the partition and pushes snapshots |
321
402
  | `emit(module, channel, ctx, slice)` | Slice: pushes the typed `Partial` to the connections of that (module, channel) pair — no re-execution |
322
403
  | Merge | Shallow by key, on arrival; key sent replaced whole, absent key never removes, whole lists |
@@ -326,4 +407,4 @@ await vi.advanceTimersByTimeAsync(20); // the window closes: one round
326
407
  | `CHANNEL_SILENCE_MS` | 60000 — client-side silence window; an open connection without deliveries past it is `"stale"` |
327
408
  | `inspectChannels()` | Server-side live report: groups per (moduleId, partition) with connections and last delivery, open coalesce windows, totals |
328
409
  | `GET /_channel-inspect` | Dev-only endpoint answering `inspectChannels()` as JSON; absent in production builds |
329
- | `app.channel(module, name)` | Test subscription; `next()` (raw data) and `nextEvent()` (`{ type, data }`) |
410
+ | `app.channel(module, name, opts)` | Test subscription; `opts.params` declares the route params (validated for real); `next()` (raw data) and `nextEvent()` (`{ type, data }`) |
@@ -11,21 +11,27 @@
11
11
  *
12
12
  * ```ts
13
13
  * export const channels = {
14
- * teamExpenses: { scope: (ctx) => `team:${ctx.teamId}` },
14
+ * teamExpenses: { scope: ({ user }) => `team:${user?.teamId}` },
15
15
  * };
16
16
  * ```
17
17
  *
18
- * On subscribe the resolver receives the materialized principal (`c.get("user")`,
19
- * the house key); without an authenticated principal it receives `undefined`, so
20
- * a public channel ignores it (`() => "all"`) and an authenticated one returns
21
- * `null` (which denies the subscription with 403). The partition NEVER derives
22
- * from the query string or from any client input.
18
+ * On subscribe the resolver receives `{ user, params }`: `user` is the
19
+ * materialized principal (`c.get("user")`, the house key — `undefined` when the
20
+ * app does not materialize one, so a public channel ignores it and an
21
+ * authenticated one returns `null`, which denies the subscription with 403) and
22
+ * `params` are the route params the client declared and the runtime validated
23
+ * against the module's path (empty for a route without `:params`). The
24
+ * partition NEVER derives from the query string or from any other client input;
25
+ * `params` are not input — they are the address the client is already seeing,
26
+ * validated by shape and then by the scope (ownership/permission).
23
27
  *
24
- * `emit(channel, ctx)` resolves the same partition key on the producer side,
25
- * finds the group of connections in that partition and re-executes the loader
26
- * for each connection — with that connection's own principal — pushing a fresh
27
- * snapshot down the wire. Emitting to a channel with no subscribers is a no-op;
28
- * emitting to a channel with no entry in the app map throws immediately.
28
+ * `emit(channel, ctx)` resolves the same partition key on the producer side —
29
+ * the producer builds the same `{ user, params }` shape with the fields its
30
+ * scope consumes — finds the group of connections in that partition and
31
+ * re-executes the loader for each connection, with that connection's own
32
+ * principal and route params, pushing a fresh snapshot down the wire. Emitting
33
+ * to a channel with no subscribers is a no-op; emitting to a channel with no
34
+ * entry in the app map throws immediately.
29
35
  *
30
36
  * `emit(module, channel, ctx, slice)` is the second mode, addressed to the
31
37
  * (module, channel) pair: the producer already holds the value, so it travels
@@ -35,27 +41,76 @@
35
41
  * one channel can be declared by modules with different `Data` (a layout and a
36
42
  * page), and a slice is the shape of exactly one of them.
37
43
  *
44
+ * A channel's group (channel + partition) has a lifecycle: the first connection
45
+ * arms it (`onGroupOpen({ partition, params })`) and the last one disarms it
46
+ * (`onGroupClose({ partition })`), both awaited — that is where a watcher with a
47
+ * cost of life is armed and disarmed, instead of running forever for an empty
48
+ * group.
49
+ *
38
50
  * Every emit logs one line (channel, partition, kind, connections at the
39
51
  * moment of the gesture, timestamp) unless `logEmits: false` is registered,
40
52
  * and invalidations are folded by a coalescing window per (channel, partition)
41
53
  * (`coalesceMs`, default 50, `0` disables). Before delivering anything the
42
- * runtime re-derives each connection's partition with the principal captured
43
- * on subscribe: a connection that moved (or whose scope now returns null) is
54
+ * runtime re-derives each connection's partition with the ctx captured on
55
+ * subscribe: a connection that moved (or whose scope now returns null) is
44
56
  * dropped and closed instead of receiving data of a group it left.
45
57
  */
46
58
  import type { Context, Hono, MiddlewareHandler } from "hono";
47
59
  import type { LoaderArgs, RouteModule } from "./types.js";
48
60
  /** Result of a partition resolver. `null`/`undefined` denies the subscription. */
49
61
  export type ChannelScopeResult = string | null | undefined;
62
+ /**
63
+ * The ctx of a partition resolver — the same shape on both sides.
64
+ *
65
+ * On **subscribe** the runtime builds it: `user` is the materialized principal
66
+ * (`c.get("user")`, the house key — `undefined` when the app does not
67
+ * materialize one) and `params` are the route params the client declared and
68
+ * the runtime validated against the module's path (URL strings; empty for a
69
+ * route without `:params`).
70
+ *
71
+ * On **emit** the producer builds the same object with the fields its scope
72
+ * consumes — `{ params: { id: 7 } }` for a resource channel, `{ user: … }`
73
+ * for a principal one — and the same `scope` runs against it.
74
+ */
75
+ export interface ChannelScopeContext {
76
+ user?: any;
77
+ params?: Record<string, any>;
78
+ }
79
+ /**
80
+ * What `onGroupOpen` receives: the partition key of the group that just got its
81
+ * first connection, and the validated route params of **that** connection.
82
+ *
83
+ * The params are identical across the group only when the partition derives
84
+ * from them (a watcher per resource — the case the hook exists for). A channel
85
+ * partitioned by principal, declared in a module with `:params`, mixes
86
+ * resources in the same group and the hook's params are the first connection's
87
+ * — arbitrary for the rest; such a channel must not consume them here.
88
+ */
89
+ export interface ChannelGroupOpenContext {
90
+ partition: string;
91
+ params: Record<string, string>;
92
+ }
93
+ /** What `onGroupClose` receives: the partition key of the group being torn down. */
94
+ export interface ChannelGroupCloseContext {
95
+ partition: string;
96
+ }
50
97
  /**
51
98
  * One entry of the app's channel map (`app/channels.ts`).
52
99
  *
53
- * `scope` receives the materialized principal on subscribe (`c.get("user")`)
54
- * and the emitter's object on emit. Returning `null` denies with 403.
55
- * Omitted → the channel is public (`() => "all"`).
100
+ * `scope` receives `{ user, params }` on subscribe and the emitter's object on
101
+ * emit; returning `null` denies with 403. Omitted → the channel is public
102
+ * (`() => "all"`).
103
+ *
104
+ * `onGroupOpen`/`onGroupClose` are the group lifecycle — armed by the first
105
+ * connection of a (channel, partition) group, disarmed by the last one — and
106
+ * may be async: the runtime awaits them (the group is only marked open after
107
+ * `onGroupOpen` resolves; only discarded after `onGroupClose` resolves). A hook
108
+ * that throws or rejects is logged loudly and the group continues.
56
109
  */
57
110
  export interface ChannelDefinition {
58
- scope?: (ctx: any) => ChannelScopeResult | Promise<ChannelScopeResult>;
111
+ scope?: (ctx: ChannelScopeContext) => ChannelScopeResult | Promise<ChannelScopeResult>;
112
+ onGroupOpen?: (ctx: ChannelGroupOpenContext) => void | Promise<void>;
113
+ onGroupClose?: (ctx: ChannelGroupCloseContext) => void | Promise<void>;
59
114
  }
60
115
  /** The `app/channels.ts` map: channel name → partition resolver. */
61
116
  export type ChannelMap = Record<string, ChannelDefinition>;
@@ -213,7 +268,7 @@ export interface ChannelInspectReport {
213
268
  * guard) to expose it; the dev server exposes it as `GET /_channel-inspect`.
214
269
  */
215
270
  export declare function inspectChannels(): ChannelInspectReport;
216
- /** Test-only. Drops every live connection and closes every open window. */
271
+ /** Test-only. Drops every live connection, group and open window. */
217
272
  export declare function __resetChannels(): void;
218
273
  /**
219
274
  * Executes the connection's loader with the connection's own principal and
@@ -232,16 +287,20 @@ export declare function pushSnapshot(conn: ChannelConnection): Promise<void>;
232
287
  /**
233
288
  * Signals that a channel's partition changed: every connection in that
234
289
  * partition receives a fresh snapshot (its loader re-executed with the
235
- * connection's own principal). Connections in other partitions receive
236
- * nothing; a channel with no subscribers is a no-op; a channel with no entry
237
- * in the app map throws immediately, naming the channel.
290
+ * connection's own principal and route params). Connections in other
291
+ * partitions receive nothing; a channel with no subscribers is a no-op; a
292
+ * channel with no entry in the app map throws immediately, naming the channel.
293
+ *
294
+ * The ctx is the object the same `scope` receives: the producer builds it with
295
+ * the fields its scope consumes (`{ params: { id: 7 } }` for a resource
296
+ * channel, `{ user: … }` for a principal one).
238
297
  *
239
298
  * ```ts
240
299
  * import { emit } from "@mauroandre/velojs/server";
241
- * await emit("teamExpenses", { teamId: 7 });
300
+ * await emit("teamExpenses", { user: { teamId: 7 } });
242
301
  * ```
243
302
  */
244
- export declare function emit(channel: string, ctx?: unknown): Promise<void>;
303
+ export declare function emit(channel: string, ctx?: ChannelScopeContext): Promise<void>;
245
304
  /**
246
305
  * Slice mode: pushes the value the producer already holds to the connections
247
306
  * of the (module, channel) pair in the emitting partition — **without**
@@ -250,10 +309,10 @@ export declare function emit(channel: string, ctx?: unknown): Promise<void>;
250
309
  *
251
310
  * ```ts
252
311
  * import * as Expenses from "../app/expenses/Expenses.js";
253
- * await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
312
+ * await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
254
313
  * ```
255
314
  */
256
- export declare function emit<M extends ChannelEmitModule>(module: M, channel: string, ctx: unknown, slice: ChannelSlice<M>): Promise<void>;
315
+ export declare function emit<M extends ChannelEmitModule>(module: M, channel: string, ctx: ChannelScopeContext, slice: ChannelSlice<M>): Promise<void>;
257
316
  /**
258
317
  * Serializes the SSE writes of one connection and never lets a failed write
259
318
  * break the chain.
@@ -273,6 +332,27 @@ export type ChannelWritePayload = {
273
332
  event: string;
274
333
  data: string;
275
334
  } | string;
335
+ /** Query key carrying the route params the connection declares. */
336
+ export declare const ROUTE_PARAMS_QUERY = "_route";
337
+ /**
338
+ * The `:param` names a route path declares, in declaration order. A path with
339
+ * a catch-all (`/docs/*`) declares none: the pattern has no key to extract, so
340
+ * the connection travels by principal only.
341
+ */
342
+ export declare function declaredRouteParams(fullPath: string | null | undefined): string[];
343
+ /**
344
+ * Parses and validates the `?_route=` payload of a channel connection against
345
+ * the names the module's route path declares.
346
+ *
347
+ * The client declares the params of the URL it is already seeing; it is not
348
+ * free input: a key the module's path does not declare rejects the
349
+ * subscription with an explicit error, and every value is a URL string. An
350
+ * absent payload is the empty object — a route without `:params` sends none.
351
+ */
352
+ export declare function parseRouteParamsQuery(raw: string | undefined | null, declared: readonly string[]): {
353
+ params: Record<string, string>;
354
+ error: string | null;
355
+ };
276
356
  /**
277
357
  * Registers the internal SSE route of one declared channel:
278
358
  * `GET /_channel/{moduleId}/{channel}`, inheriting the route node's
@@ -282,8 +362,9 @@ export type ChannelWritePayload = {
282
362
  * channel with no entry in `app/channels.ts`, is an explicit error — never a
283
363
  * silent inert route.
284
364
  *
285
- * The principal is captured on the connection for the partition revalidation
286
- * of every emit, and `terminate` lets an emit close a connection whose
287
- * partition no longer holds.
365
+ * The connection carries the client-declared route params (validated here
366
+ * against the module's path), the principal captured for the partition
367
+ * revalidation of every emit, and `terminate` lets an emit close a connection
368
+ * whose partition no longer holds.
288
369
  */
289
370
  export declare function registerChannelRoute(app: Hono, path: string, channel: string, module: RouteModule, moduleId: string, middlewares: MiddlewareHandler[]): void;