@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 +121 -24
- package/agent/AGENTS.md +84 -17
- package/agent/skills/velojs-live-loader/SKILL.md +105 -24
- package/dist/channels.d.ts +109 -28
- package/dist/channels.js +285 -192
- package/dist/client.js +8 -7
- package/dist/live-loader.d.ts +40 -7
- package/dist/live-loader.js +91 -81
- package/dist/server.d.ts +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +231 -228
- package/dist/testing/types.d.ts +21 -4
- package/package.json +1 -1
|
@@ -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
|
|
34
|
-
//
|
|
35
|
-
|
|
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
|
|
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 });
|
|
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
|
|
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 `
|
|
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
|
-
|
|
238
|
-
|
|
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)` |
|
|
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 }`) |
|
package/dist/channels.d.ts
CHANGED
|
@@ -11,21 +11,27 @@
|
|
|
11
11
|
*
|
|
12
12
|
* ```ts
|
|
13
13
|
* export const channels = {
|
|
14
|
-
* teamExpenses: { scope: (
|
|
14
|
+
* teamExpenses: { scope: ({ user }) => `team:${user?.teamId}` },
|
|
15
15
|
* };
|
|
16
16
|
* ```
|
|
17
17
|
*
|
|
18
|
-
* On subscribe the resolver receives
|
|
19
|
-
*
|
|
20
|
-
* a public channel ignores it
|
|
21
|
-
* `null
|
|
22
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
43
|
-
*
|
|
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
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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:
|
|
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
|
|
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
|
|
236
|
-
* nothing; a channel with no subscribers is a no-op; a
|
|
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?:
|
|
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:
|
|
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
|
|
286
|
-
*
|
|
287
|
-
*
|
|
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;
|