@mauroandre/velojs 0.0.54 → 0.0.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -159,6 +159,8 @@ export const Component = () => {
159
159
  npm run dev # http://localhost:3000
160
160
  ```
161
161
 
162
+ To expose the dev server beyond loopback — a VPS behind a reverse proxy, an app reached by hostname — set `HOST=0.0.0.0`, in the shell or in the project's `.env`; no `--host` flag by hand. The security trade-off: a broad bind in dev (`0.0.0.0`, `::`, or `--host` with no value) turns Vite's Host check off, so a request whose `Host` header carries that domain is served instead of 403'd — the DNS-rebinding protection you get on loopback is consciously given up. Stay on localhost unless you are behind a proxy; a `server.allowedHosts` declared in `vite.config.ts` still pins the allowed list.
163
+
162
164
  ### Configuration
163
165
 
164
166
  ```typescript
@@ -167,6 +169,7 @@ veloPlugin({
167
169
  routesFile: "routes.tsx", // default
168
170
  serverInit: "server.tsx", // default
169
171
  clientInit: "client.tsx", // default
172
+ hostname: "127.0.0.1", // bind interface, dev and production (the HOST env wins)
170
173
  });
171
174
  ```
172
175
 
@@ -458,6 +461,317 @@ SPA navigation:
458
461
 
459
462
  ---
460
463
 
464
+ ## Live Loader
465
+
466
+ A page value that must **track server state over time** — a total another
467
+ person's request changes, data a background scheduler mutates — declares the
468
+ channels that keep it fresh. No extra component, no manual subscription: the
469
+ module exports `channels` next to its `loader`.
470
+
471
+ ```tsx
472
+ // app/expenses/Expenses.tsx
473
+ export const loader = async ({ c }: LoaderArgs) => {
474
+ const user = c.get("user");
475
+ return {
476
+ teamTotal: await expensesService.teamTotal(user.teamId),
477
+ expenses: await expensesService.byTeam(user.teamId),
478
+ };
479
+ };
480
+ export const channels = ["teamExpenses"];
481
+
482
+ export const Component = () => {
483
+ const { data, freshness } = useLoader();
484
+ return <h2 class={freshness.value}>Team: ${data.value?.teamTotal}</h2>;
485
+ };
486
+ ```
487
+
488
+ ```ts
489
+ // app/channels.ts — the app's channel map: name → partition resolver
490
+ export const channels = {
491
+ teamExpenses: {
492
+ // ctx: on subscribe, the principal materialized in c.get("user");
493
+ // on emit, the object the emitter passed. Returns the partition key.
494
+ scope: (ctx) => `team:${ctx.teamId}`,
495
+ },
496
+ };
497
+ ```
498
+
499
+ ```ts
500
+ // anywhere server-side: an action, server.tsx, a scheduler, a webhook
501
+ import { emit } from "@mauroandre/velojs/server";
502
+
503
+ await emit("teamExpenses", { teamId: 7 });
504
+ ```
505
+
506
+ ### What happens
507
+
508
+ - The framework derives one internal SSE route per declared channel —
509
+ `GET /_channel/{moduleId}/{channel}`, the same family as `/_action` and
510
+ `/_event` — inheriting the `middlewares` of the module's route node.
511
+ - After hydration the client opens one connection per (module, channel) of the
512
+ rendered hierarchy (layout and page alike) and closes it on unmount. A
513
+ channel declared by both a layout and a page produces one connection per
514
+ module, each feeding its own module's data; a single `emit` reaches both.
515
+ - Every new connection immediately receives the **current state**, computed by
516
+ re-executing the loader with that connection's own principal — so the pane
517
+ never shows the SSR value while the server has already moved on.
518
+ - `emit(channel, ctx)` resolves the partition with the same `scope`, finds the
519
+ connections in that partition and pushes a fresh snapshot to each one — its
520
+ loader re-executed with its own principal. Connections of other partitions
521
+ receive nothing.
522
+ - The snapshot **replaces** the module's loader data; the component re-renders
523
+ by itself. `useLoader()` and `Loader()` also expose `freshness` — `"live"`,
524
+ `"stale"` (connection closed/reconnecting/silent), `"error"` (the last
525
+ re-execution failed) — aggregated per page, plus `freshnessByChannel`, the
526
+ same states keyed by each of the module's channel names. They are data for
527
+ CSS to react to.
528
+
529
+ ### Two ways to emit
530
+
531
+ **Invalidation** — `emit(channel, ctx)` says *this partition changed*, without
532
+ knowing the shape of the data: the runtime re-executes the loader per connection
533
+ and pushes the snapshot. Use it when only the server knows the new value (a
534
+ background job, another request).
535
+
536
+ **Slice** — `emit(module, channel, ctx, slice)` says *here is the new value*: the
537
+ producer already holds it, so it travels as-is to the connections of that
538
+ (module, channel) pair in the partition, and **no loader runs**. The slice is
539
+ typed as a `Partial` of what that module's loader returns, so `tsc` rejects an
540
+ unknown key or a wrong value type.
541
+
542
+ ```ts
543
+ import { emit } from "@mauroandre/velojs/server";
544
+ import * as Expenses from "../app/expenses/Expenses.js";
545
+
546
+ // invalidation: the runtime re-executes Expenses.loader per connection
547
+ await emit("teamExpenses", { teamId: 7 });
548
+
549
+ // slice: the value is already in hand — delivered as-is, no re-execution
550
+ await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
551
+ ```
552
+
553
+ Why the module, and not just the channel name? Because the same channel can be
554
+ declared by a layout **and** by a page, each with its own loader and its own
555
+ `Data` — a slice is the shape of exactly one of them, and it reaches only that
556
+ module's connections. `emit(channel, ctx)` keeps reaching every module of the
557
+ channel.
558
+
559
+ ### The merge contract
560
+
561
+ - **Snapshot replaces; slice merges.** A snapshot (connect or invalidation) is
562
+ the whole value of the module's data; a slice applies a **shallow** merge by
563
+ key on top of the current value.
564
+ - The key sent is replaced by the **whole value** sent — lists travel whole
565
+ inside their key; there is no list diff. Keys not sent stay intact.
566
+ - **Removal is explicit**: removing an item is re-sending the key with the new
567
+ whole list (without the item). An absent key never means "remove".
568
+ - The merge happens **on arrival**, never at render: slices that land in the
569
+ same frame are applied together before the next render — none is lost — and
570
+ for the same key the last one in arrival order wins. A malformed payload keeps
571
+ the previous value.
572
+ - The runtime merges blindly (it is JavaScript); shape safety comes from the
573
+ emit typing. A key that does not exist in the loader's `Data` only gets in
574
+ through a cast, and that is a usage error.
575
+
576
+ ### Bursts, log and revalidation
577
+
578
+ - **Coalescing.** Invalidation emits are folded per (channel, partition): the
579
+ first opens a window, the emits inside it join, and closing the window fires
580
+ **one** re-execution round per connection — no invalidation is lost. The
581
+ default window is `50ms`; `registerChannels(map, { coalesceMs: 0 })` turns it
582
+ off (immediate), and a slice never waits for it (a slice is pushed directly).
583
+
584
+ The window decides what `await emit(…)` waits for: with it off (`0`, and the
585
+ test toolkit's default) the await resolves after the round was delivered;
586
+ with it open it resolves as soon as the gesture is registered — the log line
587
+ is out, the delivery comes later. Never assert a screen update right after
588
+ `await emit(…)` under the default window: await the arrival (`next()` /
589
+ `nextEvent()`) or wait for the effect.
590
+ - **Emit log.** Every `emit`, in either mode, writes one `console.log` line at
591
+ the moment of the gesture:
592
+
593
+ ```
594
+ [velojs] emit kind=invalidate channel="teamExpenses" partition="team:7" connections=2 at=2026-09-30T12:00:00.000Z
595
+ ```
596
+
597
+ `connections` is the size of the group at that instant — before coalescing and
598
+ before revalidation — and the consolidated delivery writes no second line. An
599
+ emit whose `scope` resolves to nothing logs `partition=null connections=0`:
600
+ reaching nobody is exactly what the log is for.
601
+ `registerChannels(map, { logEmits: false })` silences it. In tests,
602
+ `createTestApp` registers with the window off and takes the same options in
603
+ `channelOptions`.
604
+ - **Partition revalidation.** Before delivering anything, the runtime re-derives
605
+ each connection's partition by running the `scope` again with the principal
606
+ captured at subscribe. A connection whose partition moved — or whose scope now
607
+ returns `null` — is removed from the group and closed: the client sees the
608
+ connection closed (`freshness` goes `"stale"`) and receives none of that
609
+ emit's data. A `scope` that **throws** on re-derivation is treated the same
610
+ way — fail-closed, with the failure logged naming the channel — so a scope
611
+ that does I/O must handle its own failures. Only the partition is revalidated;
612
+ session expiry stays with the normal request pipeline.
613
+
614
+ ### Transport: heartbeat, idle timeout and the proxy
615
+
616
+ An SSE connection crosses infrastructure that was not built for it — proxies,
617
+ load balancers, browsers with sleeping tabs. The channel answer is built for
618
+ that traffic, and its transport has exactly two clocks:
619
+
620
+ - **Heartbeat** — an SSE comment (`: ping`) written through the write chain
621
+ every `heartbeatMs`. It keeps proxies and browsers from buffering or closing an
622
+ idle stream, and it is pure transport: it generates no event in the client and
623
+ does not count as a delivery — it touches neither freshness nor idle. The
624
+ default is `20000` (20s), the same `DEFAULT_HEARTBEAT_MS` the `stream_*` SSE
625
+ surface uses — one transport ruler in the house. `0` disables it.
626
+ - **Idle timeout** — a connection with **no deliveries** (snapshot or slice) for
627
+ `idleMs` is closed by the server. The heartbeat does not reset it: idle is
628
+ about data, heartbeat is transport. A live but quiet page cycles by design —
629
+ close, `EventSource` reconnects by itself, snapshot on connect repairs the
630
+ state — and an abandoned page's resources are freed for good. The default is
631
+ `300000` (5 min) in any registration without options; `0` disables it.
632
+
633
+ ```ts
634
+ // app/channels.ts registration with explicit windows
635
+ registerChannels(channels, { idleMs: 60000, heartbeatMs: 15000 });
636
+ ```
637
+
638
+ The answer also carries `Cache-Control: no-store`: a proxy or the browser must
639
+ never serve a cached channel snapshot — a stale snapshot is exactly the illusion
640
+ the live loader exists to kill.
641
+
642
+ **Behind a reverse proxy (the house recipe, Caddy).** SSE needs the proxy to
643
+ flush immediately and never buffer the response, or the page goes silent even
644
+ with everything alive. In the Caddyfile, the reverse_proxy for the app needs
645
+ `flush_interval -1` (flush as soon as data arrives — a negative value means
646
+ "immediately", which is what SSE requires; a positive interval would batch
647
+ chunks and delay every snapshot). Caddy passes the request through with
648
+ streaming by default and closes dead upstreams on its own probes — the
649
+ heartbeat then does its part at 20s, and the idle timeout closes what nobody is
650
+ reading anymore. If you put another proxy in front (nginx, a cloud LB), the
651
+ same two rules apply: no response buffering, and `X-Accel-Buffering: no` for
652
+ nginx or the equivalent for the LB.
653
+
654
+ ### Inspecting the live channels
655
+
656
+ When the incident happens, the server answers "what is alive?":
657
+
658
+ ```ts
659
+ // anywhere server-side (an action, a guarded endpoint of your own)
660
+ import { inspectChannels } from "@mauroandre/velojs/server";
661
+
662
+ const report = inspectChannels();
663
+ // {
664
+ // channels: [{
665
+ // channel: "teamExpenses",
666
+ // groups: [
667
+ // { moduleId: "expenses/Layout", partition: "team:7",
668
+ // connections: 1, lastDeliveryAt: "2026-09-30T12:00:00.000Z" },
669
+ // { moduleId: "expenses/Expenses", partition: "team:7",
670
+ // connections: 1, lastDeliveryAt: "2026-09-30T12:00:01.104Z" },
671
+ // ],
672
+ // connections: 2,
673
+ // }],
674
+ // openCoalesceWindows: [],
675
+ // totalConnections: 2,
676
+ // }
677
+ ```
678
+
679
+ Per channel, the groups are identified by the pair (moduleId, partition key) —
680
+ in the incident you know which page/module holds every connection — each with
681
+ its connection count and the timestamp of the last delivery; the coalescing
682
+ windows still open; the grand totals. `inspectChannels()` is always available:
683
+ the app decides how (and with what guard) to expose it.
684
+
685
+ In development there is a ready-made lens: `GET /_channel-inspect` answers the
686
+ same JSON in the browser. It exists **only in dev** — the map of channels and
687
+ partitions is internal information, and it never ships in a production build.
688
+
689
+ ### The partition contract
690
+
691
+ On subscribe the `scope` receives `c.get("user")` — the house key, the same one
692
+ the stream/socket channel resolvers use (a middleware that materializes the
693
+ principal elsewhere should set it in `"user"` too). Without an authenticated
694
+ principal it receives `undefined`: a public channel ignores it (`() => "all"`),
695
+ an authenticated one returns `null`, which denies the subscription with 403.
696
+ The partition **never** derives from the query string or from any client input.
697
+
698
+ ### State or flow?
699
+
700
+ Two questions decide, every time:
701
+
702
+ | Question | Answer | Tool |
703
+ |---|---|---|
704
+ | Is this a **value** or a **sequence**? | value — "what is it now?", replacement | `loader` + `channels` |
705
+ | | sequence — "what happened?", accumulation (chat, log, progress) | `stream_*` / `socket_*` |
706
+ | Does the change come from **someone else** or from **me**? | someone else / a background job | `loader` + `channels` |
707
+ | | me, through my own action | `refetch()` after the action |
708
+
709
+ ### Guards
710
+
711
+ - `channels` in a module **without** a `loader`: explicit error in the dev
712
+ server and in the build ("nothing to synchronize").
713
+ - A channel **without an entry** in `app/channels.ts`: explicit error in the dev
714
+ server and in the build, naming the channel.
715
+ - `emit()` on a channel with no entry in the map: throws immediately, naming
716
+ the channel. `emit()` on a valid channel with no subscribers is a no-op.
717
+ - `emit(module, channel, …)` on a module that does not declare that channel, or
718
+ that has no `loader`: throws immediately, naming both. A slice that is not an
719
+ object throws too.
720
+ - `velojs build --static` with a module declaring `channels`: the channels are
721
+ **inert** (no server for SSE) — the client opens no connection, `freshness`
722
+ stays `"live"` and the build warns, naming the module.
723
+ - A module outside the conventions (`.jsx`/`.js`, or outside `app/`) is the one
724
+ genuinely silent line: no transform, so the channel is simply inert.
725
+
726
+ ### Testing a live loader
727
+
728
+ ```ts
729
+ import { createTestApp } from "@mauroandre/velojs/testing";
730
+ import { emit } from "@mauroandre/velojs/server";
731
+ import { channels } from "../app/channels.js";
732
+ import * as Expenses from "../app/expenses/Expenses.js";
733
+
734
+ const app = await createTestApp({
735
+ routes,
736
+ channels,
737
+ getSessionCookie: async ({ user }) => ({ session: await sign(user) }),
738
+ });
739
+
740
+ const sub = await app.as({ user: { teamId: 7 } }).channel(Expenses, "teamExpenses");
741
+ await sub.next({ timeoutMs: 1000 }); // snapshot on connect
742
+ await emit("teamExpenses", { teamId: 7 });
743
+ const snapshot = await sub.next({ timeoutMs: 1000 });
744
+ await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
745
+ const slice = await sub.next({ timeoutMs: 1000 });
746
+ ```
747
+
748
+ `next()` returns the payload exactly as it travelled (a whole snapshot or a raw
749
+ slice); `nextEvent()` returns the same arrivals discriminated —
750
+ `{ type: "snapshot" | "slice", data }`. The toolkit registers the channels with
751
+ the coalescing window off, so `await emit(…)` is the delivery; a test that
752
+ exercises the window passes it in `channelOptions` — a sibling of `channels`,
753
+ which stays the map — and drives it with fake timers:
754
+
755
+ ```ts
756
+ const app = await createTestApp({
757
+ routes,
758
+ channels, // the map
759
+ channelOptions: { coalesceMs: 20 }, // the registration options
760
+ getSessionCookie,
761
+ });
762
+ ```
763
+
764
+ The same `channelOptions` accepts `idleMs` and `heartbeatMs` — both default to
765
+ `0` in the toolkit (deterministic: no connection dies, no comment is written
766
+ unless the test asks for it and advances fake timers through the window it
767
+ meant). `inspectChannels()` is process-wide and sees the test app's real
768
+ channels: subscribe, emit, inspect, close, inspect again — all without network.
769
+ A test that exercises the idle timeout or the heartbeat arms the window
770
+ explicitly and drives it with fake timers; never assert freshness (in either
771
+ surface) without advancing the fake timers through the window.
772
+
773
+ ---
774
+
461
775
  ## Actions
462
776
 
463
777
  Server-side functions callable from the client via RPC.
@@ -1272,6 +1586,7 @@ Callbacks queue until the server starts. If called after startup, executes immed
1272
1586
  | Variable | Default | Description |
1273
1587
  |----------|---------|-------------|
1274
1588
  | `PORT` | `3000` | Server port. Overrides the `port` set in `defineConfig` |
1589
+ | `HOST` | — | Bind interface, in dev and production. Overrides `defineConfig`'s `hostname`; in dev an explicit `--host`/`server.host` still wins |
1275
1590
  | `NODE_ENV` | — | Set automatically by `velojs start`. Enables static file serving |
1276
1591
  | `STATIC_BASE_URL` | `""` | CDN/bucket prefix for static assets |
1277
1592
 
@@ -1371,6 +1686,12 @@ const event = await sub.next({ timeoutMs: 2000 });
1371
1686
  const asAlice = app.as({ user: alice });
1372
1687
  await asAlice.subscribe(stream_progress, { channel: appId });
1373
1688
 
1689
+ // Live loader — open a channel with a principal, emit, await the next snapshot
1690
+ const live = await app.as({ user: { teamId: 7 } }).channel(Expenses, "teamExpenses");
1691
+ await live.next({ timeoutMs: 1000 }); // snapshot on connect
1692
+ await emit("teamExpenses", { teamId: 7 });
1693
+ const snapshot = await live.next({ timeoutMs: 1000 });
1694
+
1374
1695
  await app.close();
1375
1696
  ```
1376
1697
 
@@ -1384,6 +1705,7 @@ await app.close();
1384
1705
  | `app.action(fn, opts)` | Invoke `action_*` by function reference |
1385
1706
  | `app.loader(fn, opts)` | Invoke `loader` and unwrap response data |
1386
1707
  | `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 }`) |
1387
1709
  | `app.as({ user })` | Sub-client with cookies bound to a user |
1388
1710
  | `app.sessionCookies({ user })` | Build cookies via `getSessionCookie` |
1389
1711
  | `app.mockContext(opts)` | Escape hatch — partial Hono Context for direct invocation |
@@ -1419,11 +1741,11 @@ See [Testing docs](https://github.com/mauro-andre/velojs/blob/dev/site/docs/17-t
1419
1741
  | Import | Contents |
1420
1742
  |--------|----------|
1421
1743
  | `@mauroandre/velojs` | Types (`AppRoutes`, `ActionArgs`, `LoaderArgs`, `Metadata`, `EventStream`, `EventStreamConfig`, `EmitFn`, `EmitOptions`, `SourceFn`, `PerChannelSourceFn`, `ChannelResolver`), `Scripts`, `Link`, `createEventStream`, `poll`, `defineConfig` |
1422
- | `@mauroandre/velojs/server` | `startServer`, `createApp`, `addRoutes`, `onServer`, `serverDataStorage` |
1744
+ | `@mauroandre/velojs/server` | `startServer`, `createApp`, `addRoutes`, `onServer`, `serverDataStorage`, `emit`, `registerChannels`, `ChannelDefinition`, `ChannelMap`, `ChannelRegistryOptions` |
1423
1745
  | `@mauroandre/velojs/client` | `startClient` |
1424
- | `@mauroandre/velojs/hooks` | `Loader`, `useLoader`, `useEventStream`, `useParams`, `useQuery`, `useNavigate`, `usePathname`, `touch` |
1746
+ | `@mauroandre/velojs/hooks` | `Loader`, `useLoader`, `useEventStream`, `useParams`, `useQuery`, `useNavigate`, `usePathname`, `touch`, `Freshness` |
1425
1747
  | `@mauroandre/velojs/events` | `createEventStream`, `poll`, `EventStream`, `EventStreamConfig`, `EmitFn`, `EmitOptions`, `SourceFn`, `PerChannelSourceFn`, `ChannelResolver` (also re-exported from root) |
1426
- | `@mauroandre/velojs/testing` | `createTestApp`, `TestApp`, `TestResponse`, `TestSubscription`, `CreateTestAppOptions`, `MockContextOptions` |
1748
+ | `@mauroandre/velojs/testing` | `createTestApp`, `TestApp`, `TestResponse`, `TestSubscription`, `TestChannelSubscription`, `ChannelEvent`, `CreateTestAppOptions`, `MockContextOptions` |
1427
1749
  | `@mauroandre/velojs/cookie` | `getCookie`, `setCookie`, `deleteCookie`, `getSignedCookie`, `setSignedCookie` |
1428
1750
  | `@mauroandre/velojs/factory` | `createMiddleware`, `createFactory` |
1429
1751
  | `@mauroandre/velojs/vite` | `veloPlugin` |
@@ -1456,6 +1778,7 @@ interface Metadata {
1456
1778
  interface RouteModule {
1457
1779
  Component: ComponentType<any>;
1458
1780
  loader?: (args: LoaderArgs) => Promise<any>;
1781
+ channels?: readonly string[]; // live loader: channel names kept fresh
1459
1782
  metadata?: Metadata;
1460
1783
  [key: `action_${string}`]: (args: ActionArgs) => Promise<any>;
1461
1784
  }
@@ -1475,6 +1798,8 @@ interface VeloConfig {
1475
1798
  routesFile?: string; // default: "routes.tsx"
1476
1799
  serverInit?: string; // default: "server.tsx"
1477
1800
  clientInit?: string; // default: "client.tsx"
1801
+ port?: number; // default: 3000; the PORT env wins
1802
+ hostname?: string; // bind interface, dev and production; the HOST env wins
1478
1803
  }
1479
1804
  ```
1480
1805
 
@@ -1529,6 +1854,10 @@ export const staticPaths = async () => {
1529
1854
 
1530
1855
  Routes without `staticPaths` are skipped with a warning.
1531
1856
 
1857
+ ### Live loader in a static build
1858
+
1859
+ A module that declares `channels` keeps rendering, but its channels are **inert** there: there is no server to speak SSE, so the client opens no connection, `freshness` stays `"live"` and the build warns, naming the module. Static output has no live data by construction.
1860
+
1532
1861
  ### Deploying
1533
1862
 
1534
1863
  The `dist/` folder is self-contained. Deploy to any static hosting (Nginx, Cloudflare Pages, S3, etc.). No server required.
package/agent/AGENTS.md CHANGED
@@ -34,6 +34,7 @@ hard constraints, not style.
34
34
  | `useParams()` / `useQuery()` / `usePathname()` inside a `loader` | the loader's own args: `async ({ params, query, c }) => {}` | the async context wraps only rendering; loaders run **before** it → returns `{}` / `"/"` |
35
35
  | `c.req.param(...)` or `params.x` inside `action_*`, `stream_*`, `socket_*` | actions: read it from `body`. streams/sockets: send `?channel=` from the client and read `query.channel` | these register at **static** paths (`/_action/{moduleId}/{name}`, `/_event/…`, `/_socket/…`) — the page's `:params` are not in scope → always `{}` / `undefined` |
36
36
  | a page in `.jsx` / `.js`, or any page outside `appDirectory` | `.tsx` inside `app/` | **zero transforms**: no metadata, no stubs, loader ships to the client |
37
+ | `export const channels = [...]` in a `.jsx` / `.js` page, or any page outside `appDirectory` | `.tsx` inside `app/` | **zero transforms**: the channel guards never run, so the channel is inert — no route, no connection, no error |
37
38
  | a root layout that doesn't render a literal `<head>` | `isRoot` component renders `<html><head>…</head><body>{children}</body></html>` | `__PAGE_DATA__` is injected by replacing `</head>` → **every loader hydrates `null`** and refetches |
38
39
 
39
40
  Never pass a module id to `useLoader()` / `Loader()` yourself — the plugin injects it.
@@ -63,6 +64,152 @@ An entry refreshes on navigation when the params **its own route declares** chan
63
64
  (`/x/:id`). A query string is not declared in `routes.tsx`, so nothing infers it — use
64
65
  `useLoader([query.tab])` when data depends on one.
65
66
 
67
+ ## Keeping a page value faithful: `loader` + `channels`
68
+
69
+ The loader has two faces. The common one fetches once per request (SSR) and
70
+ re-fetches on SPA navigation — that is all a page needs when its data changes
71
+ because of the user themselves or not at all. When a value must **track server
72
+ state over time** (a team's total updated by another person's request, a
73
+ background scheduler mutating data), declare channels next to the loader:
74
+
75
+ ```tsx
76
+ // app/expenses/Expenses.tsx
77
+ export const loader = async ({ c }: LoaderArgs) => {
78
+ const user = c.get("user");
79
+ return { teamTotal: await expensesService.teamTotal(user.teamId) };
80
+ };
81
+ export const channels = ["teamExpenses"];
82
+
83
+ export const Component = () => {
84
+ const { data, freshness } = useLoader();
85
+ return <h2 class={freshness.value}>Team: ${data.value?.teamTotal}</h2>;
86
+ };
87
+ ```
88
+
89
+ And declare the partition of each channel once, in `app/channels.ts`:
90
+
91
+ ```ts
92
+ // app/channels.ts — the app's channel map: name → partition resolver
93
+ export const channels = {
94
+ teamExpenses: {
95
+ // ctx: on subscribe, the principal materialized in c.get("user");
96
+ // on emit, the object the emitter passed. Returns the partition key.
97
+ scope: (ctx) => `team:${ctx.teamId}`,
98
+ },
99
+ };
100
+ ```
101
+
102
+ Any server-side code signals that the partition changed:
103
+
104
+ ```ts
105
+ import { emit } from "@mauroandre/velojs/server";
106
+ await emit("teamExpenses", { teamId: 7 });
107
+ ```
108
+
109
+ Or, when the producer already holds the new value, it pushes it as a typed slice:
110
+
111
+ ```ts
112
+ import * as Expenses from "../app/expenses/Expenses.js";
113
+ await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
114
+ ```
115
+
116
+ The framework derives `GET /_channel/{moduleId}/{channel}` (same family as
117
+ `/_action` and `/_event`), inheriting the route node's `middlewares`; after
118
+ hydration the client opens one connection per (module, channel) of the rendered
119
+ hierarchy and closes it on unmount. Every new connection receives the **current
120
+ state** (the loader re-executed with that connection's own principal), and every
121
+ `emit` re-executes the loader per connection in the emitting partition, pushing
122
+ a snapshot that replaces the module's data. The component re-renders on its own —
123
+ the page has no code for any of it.
124
+
125
+ ### Two ways to emit — invalidation or the value itself
126
+
127
+ | Mode | Call | What the runtime does |
128
+ |---|---|---|
129
+ | Invalidation | `emit(channel, ctx)` | Re-executes the loader **per connection** of the partition and pushes a snapshot. The producer does not know the shape of the data |
130
+ | Slice | `emit(module, channel, ctx, slice)` | Pushes the payload as-is to the connections of that (module, channel) pair in the partition — **no loader runs** |
131
+
132
+ Use invalidation when only the server knows the new value (a background job, a
133
+ request from another user). Use the slice when the producer already has it — a
134
+ webhook that arrived with the payload, a job that just computed the total.
135
+
136
+ The slice is typed `Partial<Awaited<ReturnType<typeof Module.loader>>>`: an
137
+ unknown key or a wrong value type is a `tsc` error, never `any`. The module is
138
+ part of the address because one channel can be declared by modules with
139
+ different `Data` (a layout and a page): a slice is the shape of exactly **one**
140
+ of them, and it reaches only that module's connections. The invalidation mode by
141
+ name keeps reaching every module of the channel.
142
+
143
+ ### The merge contract — snapshot replaces, slice merges
144
+
145
+ - A snapshot (connect or invalidation) is the whole value: it **replaces** the module's data, gone keys included.
146
+ - A slice applies a **shallow** merge by key: the key sent is replaced by the **whole** value sent (a list travels whole inside its key); keys not sent stay intact.
147
+ - **Removal is explicit**: removing an item is re-sending the key with the new whole list (without the item). An absent key never means "remove".
148
+ - The merge happens **on arrival**, never at render: slices that land in the same frame are applied together before the next render — none is lost — and for the same key the last one in arrival order wins. A malformed payload keeps the previous value.
149
+ - No deep patch and no list diff: the semantics stop at one level, by design. The runtime merges blindly (it is JavaScript) — shape safety comes from the emit typing. A key that does not exist in the `Data` only gets in through a cast: a usage error, documented, not a silent failure.
150
+
151
+ ### Bursts, log and revalidation
152
+
153
+ - **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
+ **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
+ - **Emit log.** Every `emit`, in either mode, writes one `console.log` line at the moment of the gesture: `channel`, `partition`, `kind` (`invalidate`/`slice`), `connections` (the size of the group at that instant — before coalescing and before revalidation) and a timestamp. The consolidated delivery writes no second line; an emit whose `scope` resolves to nothing logs `partition=null connections=0`. `registerChannels(map, { logEmits: false })` silences it; `createTestApp` registers with window `0` and takes the same options in `channelOptions` — the map itself stays in `channels`, the options are a sibling key.
156
+ - **Partition revalidation.** Before delivering anything, the runtime re-derives each connection's partition by running the `scope` again with the principal captured at subscribe. A connection whose partition moved — or whose scope now returns `null` — is removed from the group and closed: the client sees the connection closed (`freshness` goes `"stale"`) and receives none of that emit's data. A `scope` that **throws** on re-derivation is treated the same way (fail-closed: a resolver that failed cannot vouch for the partition) and the failure is logged naming the channel — a scope that does I/O must handle its own failures. Only the partition is revalidated; session/cookie expiry stays with the normal request pipeline.
157
+ - **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
+ **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
+ - **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
+ - **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
+
162
+ **The partition contract is fixed.** On subscribe the scope receives
163
+ `c.get("user")` — the house key, the same one used by stream/socket resolvers. If
164
+ your middlewares materialize the principal under another key, set it in `"user"`
165
+ too. With no authenticated principal the scope receives `undefined`: a public
166
+ channel ignores it (`() => "all"`), an authenticated one returns `null`, which
167
+ denies the subscription with 403. The partition **never** comes from the query
168
+ string or from any client input.
169
+
170
+ Same channel declared by a layout and by a page → one connection per module,
171
+ each feeding its own module's data; a single `emit` by channel name reaches both.
172
+
173
+ `useLoader()` and `Loader()` also expose `freshness` — a signal with `"live"`,
174
+ `"stale"` (connection closed, reconnecting **or silent**) and `"error"` (the
175
+ last re-execution failed), aggregated per page — and `freshnessByChannel`, the
176
+ same states keyed by the channel names the module declares: a page with two
177
+ channels, one fallen and one following, shows both states at once. They are
178
+ data for CSS to react to; the framework renders no JSX for them. Without
179
+ `channels`, the record is empty and `freshness` stays `"live"`.
180
+
181
+ The **silence detector** is the client half of the idle timeout: a connection
182
+ open but without deliveries for `CHANNEL_SILENCE_MS` (60s, a runtime constant,
183
+ not a configuration) is marked `"stale"` even with `EventSource` in `OPEN` —
184
+ the tab in the background, the sleeping notebook, the proxy that stopped
185
+ flushing without closing. Any delivery (or a reconnect's snapshot) brings the
186
+ channel back to `"live"`; the heartbeat comment cannot mask the silence because
187
+ it generates no client event. The aggregate folds it by the same rule: a
188
+ silenced channel counts as not-open.
189
+
190
+ Guards are explicit, never silent: `channels` in a module without a `loader`
191
+ ("nothing to synchronize") and a channel with no entry in `app/channels.ts` both
192
+ fail the dev server and the build; `emit()` on an unknown channel throws
193
+ immediately naming it (`emit` on a valid channel with no subscribers is a no-op);
194
+ and `emit(module, channel, …)` on a module that does not declare that channel, or
195
+ that has no `loader`, throws immediately naming both (a slice that is not an
196
+ object throws too).
197
+ In `velojs build --static` the channels are **inert** — no server, so the client
198
+ opens no connection and freshness stays `"live"` — and the build warns, naming
199
+ the module.
200
+
201
+ ### State or flow? Two questions, always the same two
202
+
203
+ | Question | Answer | Tool |
204
+ |---|---|---|
205
+ | Is this a **value** or a **sequence**? | value — "what is it now?", replacement | `loader` + `channels` (live loader) |
206
+ | | sequence — "what happened?", accumulation (chat, log, metric series, progress) | `stream_*` / `socket_*` |
207
+ | Does the change come from **someone else** or from **me**? | from someone else / from a background job | `loader` + `channels` |
208
+ | | from me (my own action) | `refetch()` after the action — no channel |
209
+
210
+ Do not turn on a channel where a post-action `refetch()` suffices, and do not
211
+ use `stream_*` for a value that is simply replaced.
212
+
66
213
  ## A stream/socket `channel` is untrusted client input
67
214
 
68
215
  `useEventStream(s, { channel })` and `useSocket(s, { channel })` put the channel on the
@@ -99,6 +246,7 @@ cannot influence it.
99
246
  | File | Role |
100
247
  |---|---|
101
248
  | `app/routes.tsx` | The route tree. `export default [...] satisfies AppRoutes`, `import * as` for every page/layout |
249
+ | `app/channels.ts` | The live-loader channel map: channel name → partition resolver (`scope`). Convention file, imported by the framework |
102
250
  | `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 |
103
251
  | `app/layouts/*.tsx` | Shared layouts. Long-lived `stream_*` declarations usually live on the layout that spans their pages |
104
252
  | `app/modules/<domain>/` | Server-side logic imported by pages: `*.service.ts`, `*.middleware.ts`, `*.stream.ts`. Not routed |
@@ -134,6 +282,7 @@ API from this file — it is deliberately incomplete.
134
282
  | Navigation, `<Link>` | `link-component` |
135
283
  | `<Scripts />`, assets, favicon | `scripts-component` |
136
284
  | SSE, live/progress/streaming data | `event-streams` |
285
+ | A page value that must track server state (`loader` + `channels`) | `live-loader` |
137
286
  | WebSockets, bidirectional realtime | `sockets` |
138
287
  | `addRoutes`, `onServer`, ports, server lifecycle | `server-api` |
139
288
  | The build, transforms, `veloPlugin` options | `vite-plugin` |
@@ -175,6 +175,8 @@ npm run dev
175
175
 
176
176
  Open `http://localhost:3000` in your browser. Changes to your code will hot-reload instantly.
177
177
 
178
+ The dev server binds loopback by default. To expose it beyond that — a VPS behind a reverse proxy, an app reached by a real hostname — set `HOST=0.0.0.0`, in the shell or in the project's `.env`; no `--host` flag by hand. A broad bind in dev (`0.0.0.0`, `::`, or `--host` with no value) also turns Vite's Host check off, so a request whose `Host` header carries that domain is served instead of 403'd — the DNS-rebinding protection you get on loopback is consciously traded away. A `server.allowedHosts` declared in `vite.config.ts` still pins the allowed list.
179
+
178
180
  ## Configuration
179
181
 
180
182
  The `veloPlugin()` accepts optional configuration: