@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
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
|
|
493
|
-
//
|
|
494
|
-
|
|
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
|
|
517
|
-
never shows the SSR value while the server has already
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
|
606
|
-
|
|
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")`
|
|
692
|
-
the stream/socket channel resolvers use (a
|
|
693
|
-
principal elsewhere should set it in `"user"`
|
|
694
|
-
|
|
695
|
-
an authenticated
|
|
696
|
-
|
|
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
|
|
96
|
-
//
|
|
97
|
-
|
|
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
|
|
121
|
-
`emit` re-executes the loader per connection in the emitting
|
|
122
|
-
a snapshot that replaces the module's data. The component
|
|
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
|
|
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
|
|
164
|
-
your middlewares materialize the principal under
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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 |
|