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