@mauroandre/velojs 0.0.55 → 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 CHANGED
@@ -461,6 +461,414 @@ SPA navigation:
461
461
 
462
462
  ---
463
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
+ // 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}`,
497
+ },
498
+ };
499
+ ```
500
+
501
+ ```ts
502
+ // anywhere server-side: an action, server.tsx, a scheduler, a webhook
503
+ import { emit } from "@mauroandre/velojs/server";
504
+
505
+ await emit("teamExpenses", { user: { teamId: 7 } });
506
+ ```
507
+
508
+ ### What happens
509
+
510
+ - The framework derives one internal SSE route per declared channel —
511
+ `GET /_channel/{moduleId}/{channel}`, the same family as `/_action` and
512
+ `/_event` — inheriting the `middlewares` of the module's route node.
513
+ - After hydration the client opens one connection per (module, channel) of the
514
+ rendered hierarchy (layout and page alike) and closes it on unmount. A
515
+ channel declared by both a layout and a page produces one connection per
516
+ module, each feeding its own module's data; a single `emit` reaches both.
517
+ - Every new connection immediately receives the **current state**, computed by
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.
526
+ - The snapshot **replaces** the module's loader data; the component re-renders
527
+ by itself. `useLoader()` and `Loader()` also expose `freshness` — `"live"`,
528
+ `"stale"` (connection closed/reconnecting/silent), `"error"` (the last
529
+ re-execution failed) — aggregated per page, plus `freshnessByChannel`, the
530
+ same states keyed by each of the module's channel names. They are data for
531
+ CSS to react to.
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
+
608
+ ### Two ways to emit
609
+
610
+ **Invalidation** — `emit(channel, ctx)` says *this partition changed*, without
611
+ knowing the shape of the data: the runtime re-executes the loader per connection
612
+ and pushes the snapshot. Use it when only the server knows the new value (a
613
+ background job, another request).
614
+
615
+ **Slice** — `emit(module, channel, ctx, slice)` says *here is the new value*: the
616
+ producer already holds it, so it travels as-is to the connections of that
617
+ (module, channel) pair in the partition, and **no loader runs**. The slice is
618
+ typed as a `Partial` of what that module's loader returns, so `tsc` rejects an
619
+ unknown key or a wrong value type.
620
+
621
+ ```ts
622
+ import { emit } from "@mauroandre/velojs/server";
623
+ import * as Expenses from "../app/expenses/Expenses.js";
624
+
625
+ // invalidation: the runtime re-executes Expenses.loader per connection
626
+ await emit("teamExpenses", { user: { teamId: 7 } });
627
+
628
+ // slice: the value is already in hand — delivered as-is, no re-execution
629
+ await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
630
+ ```
631
+
632
+ Why the module, and not just the channel name? Because the same channel can be
633
+ declared by a layout **and** by a page, each with its own loader and its own
634
+ `Data` — a slice is the shape of exactly one of them, and it reaches only that
635
+ module's connections. `emit(channel, ctx)` keeps reaching every module of the
636
+ channel.
637
+
638
+ ### The merge contract
639
+
640
+ - **Snapshot replaces; slice merges.** A snapshot (connect or invalidation) is
641
+ the whole value of the module's data; a slice applies a **shallow** merge by
642
+ key on top of the current value.
643
+ - The key sent is replaced by the **whole value** sent — lists travel whole
644
+ inside their key; there is no list diff. Keys not sent stay intact.
645
+ - **Removal is explicit**: removing an item is re-sending the key with the new
646
+ whole list (without the item). An absent key never means "remove".
647
+ - The merge happens **on arrival**, never at render: slices that land in the
648
+ same frame are applied together before the next render — none is lost — and
649
+ for the same key the last one in arrival order wins. A malformed payload keeps
650
+ the previous value.
651
+ - The runtime merges blindly (it is JavaScript); shape safety comes from the
652
+ emit typing. A key that does not exist in the loader's `Data` only gets in
653
+ through a cast, and that is a usage error.
654
+
655
+ ### Bursts, log and revalidation
656
+
657
+ - **Coalescing.** Invalidation emits are folded per (channel, partition): the
658
+ first opens a window, the emits inside it join, and closing the window fires
659
+ **one** re-execution round per connection — no invalidation is lost. The
660
+ default window is `50ms`; `registerChannels(map, { coalesceMs: 0 })` turns it
661
+ off (immediate), and a slice never waits for it (a slice is pushed directly).
662
+
663
+ The window decides what `await emit(…)` waits for: with it off (`0`, and the
664
+ test toolkit's default) the await resolves after the round was delivered;
665
+ with it open it resolves as soon as the gesture is registered — the log line
666
+ is out, the delivery comes later. Never assert a screen update right after
667
+ `await emit(…)` under the default window: await the arrival (`next()` /
668
+ `nextEvent()`) or wait for the effect.
669
+ - **Emit log.** Every `emit`, in either mode, writes one `console.log` line at
670
+ the moment of the gesture:
671
+
672
+ ```
673
+ [velojs] emit kind=invalidate channel="teamExpenses" partition="team:7" connections=2 at=2026-09-30T12:00:00.000Z
674
+ ```
675
+
676
+ `connections` is the size of the group at that instant — before coalescing and
677
+ before revalidation — and the consolidated delivery writes no second line. An
678
+ emit whose `scope` resolves to nothing logs `partition=null connections=0`:
679
+ reaching nobody is exactly what the log is for.
680
+ `registerChannels(map, { logEmits: false })` silences it. In tests,
681
+ `createTestApp` registers with the window off and takes the same options in
682
+ `channelOptions`.
683
+ - **Partition revalidation.** Before delivering anything, the runtime re-derives
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
687
+ returns `null` — is removed from the group and closed: the client sees the
688
+ connection closed (`freshness` goes `"stale"`) and receives none of that
689
+ emit's data. A `scope` that **throws** on re-derivation is treated the same
690
+ way — fail-closed, with the failure logged naming the channel — so a scope
691
+ that does I/O must handle its own failures. Only the partition is revalidated;
692
+ session expiry stays with the normal request pipeline.
693
+
694
+ ### Transport: heartbeat, idle timeout and the proxy
695
+
696
+ An SSE connection crosses infrastructure that was not built for it — proxies,
697
+ load balancers, browsers with sleeping tabs. The channel answer is built for
698
+ that traffic, and its transport has exactly two clocks:
699
+
700
+ - **Heartbeat** — an SSE comment (`: ping`) written through the write chain
701
+ every `heartbeatMs`. It keeps proxies and browsers from buffering or closing an
702
+ idle stream, and it is pure transport: it generates no event in the client and
703
+ does not count as a delivery — it touches neither freshness nor idle. The
704
+ default is `20000` (20s), the same `DEFAULT_HEARTBEAT_MS` the `stream_*` SSE
705
+ surface uses — one transport ruler in the house. `0` disables it.
706
+ - **Idle timeout** — a connection with **no deliveries** (snapshot or slice) for
707
+ `idleMs` is closed by the server. The heartbeat does not reset it: idle is
708
+ about data, heartbeat is transport. A live but quiet page cycles by design —
709
+ close, `EventSource` reconnects by itself, snapshot on connect repairs the
710
+ state — and an abandoned page's resources are freed for good. The default is
711
+ `300000` (5 min) in any registration without options; `0` disables it.
712
+
713
+ ```ts
714
+ // app/channels.ts registration with explicit windows
715
+ registerChannels(channels, { idleMs: 60000, heartbeatMs: 15000 });
716
+ ```
717
+
718
+ The answer also carries `Cache-Control: no-store`: a proxy or the browser must
719
+ never serve a cached channel snapshot — a stale snapshot is exactly the illusion
720
+ the live loader exists to kill.
721
+
722
+ **Behind a reverse proxy (the house recipe, Caddy).** SSE needs the proxy to
723
+ flush immediately and never buffer the response, or the page goes silent even
724
+ with everything alive. In the Caddyfile, the reverse_proxy for the app needs
725
+ `flush_interval -1` (flush as soon as data arrives — a negative value means
726
+ "immediately", which is what SSE requires; a positive interval would batch
727
+ chunks and delay every snapshot). Caddy passes the request through with
728
+ streaming by default and closes dead upstreams on its own probes — the
729
+ heartbeat then does its part at 20s, and the idle timeout closes what nobody is
730
+ reading anymore. If you put another proxy in front (nginx, a cloud LB), the
731
+ same two rules apply: no response buffering, and `X-Accel-Buffering: no` for
732
+ nginx or the equivalent for the LB.
733
+
734
+ ### Inspecting the live channels
735
+
736
+ When the incident happens, the server answers "what is alive?":
737
+
738
+ ```ts
739
+ // anywhere server-side (an action, a guarded endpoint of your own)
740
+ import { inspectChannels } from "@mauroandre/velojs/server";
741
+
742
+ const report = inspectChannels();
743
+ // {
744
+ // channels: [{
745
+ // channel: "teamExpenses",
746
+ // groups: [
747
+ // { moduleId: "expenses/Layout", partition: "team:7",
748
+ // connections: 1, lastDeliveryAt: "2026-09-30T12:00:00.000Z" },
749
+ // { moduleId: "expenses/Expenses", partition: "team:7",
750
+ // connections: 1, lastDeliveryAt: "2026-09-30T12:00:01.104Z" },
751
+ // ],
752
+ // connections: 2,
753
+ // }],
754
+ // openCoalesceWindows: [],
755
+ // totalConnections: 2,
756
+ // }
757
+ ```
758
+
759
+ Per channel, the groups are identified by the pair (moduleId, partition key) —
760
+ in the incident you know which page/module holds every connection — each with
761
+ its connection count and the timestamp of the last delivery; the coalescing
762
+ windows still open; the grand totals. `inspectChannels()` is always available:
763
+ the app decides how (and with what guard) to expose it.
764
+
765
+ In development there is a ready-made lens: `GET /_channel-inspect` answers the
766
+ same JSON in the browser. It exists **only in dev** — the map of channels and
767
+ partitions is internal information, and it never ships in a production build.
768
+
769
+ ### The partition contract
770
+
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.
785
+
786
+ ### State or flow?
787
+
788
+ Two questions decide, every time:
789
+
790
+ | Question | Answer | Tool |
791
+ |---|---|---|
792
+ | Is this a **value** or a **sequence**? | value — "what is it now?", replacement | `loader` + `channels` |
793
+ | | sequence — "what happened?", accumulation (chat, log, progress) | `stream_*` / `socket_*` |
794
+ | Does the change come from **someone else** or from **me**? | someone else / a background job | `loader` + `channels` |
795
+ | | me, through my own action | `refetch()` after the action |
796
+
797
+ ### Guards
798
+
799
+ - `channels` in a module **without** a `loader`: explicit error in the dev
800
+ server and in the build ("nothing to synchronize").
801
+ - A channel **without an entry** in `app/channels.ts`: explicit error in the dev
802
+ server and in the build, naming the channel.
803
+ - `emit()` on a channel with no entry in the map: throws immediately, naming
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.
810
+ - `emit(module, channel, …)` on a module that does not declare that channel, or
811
+ that has no `loader`: throws immediately, naming both. A slice that is not an
812
+ object throws too.
813
+ - `velojs build --static` with a module declaring `channels`: the channels are
814
+ **inert** (no server for SSE) — the client opens no connection, `freshness`
815
+ stays `"live"` and the build warns, naming the module.
816
+ - A module outside the conventions (`.jsx`/`.js`, or outside `app/`) is the one
817
+ genuinely silent line: no transform, so the channel is simply inert.
818
+
819
+ ### Testing a live loader
820
+
821
+ ```ts
822
+ import { createTestApp } from "@mauroandre/velojs/testing";
823
+ import { emit } from "@mauroandre/velojs/server";
824
+ import { channels } from "../app/channels.js";
825
+ import * as Expenses from "../app/expenses/Expenses.js";
826
+
827
+ const app = await createTestApp({
828
+ routes,
829
+ channels,
830
+ getSessionCookie: async ({ user }) => ({ session: await sign(user) }),
831
+ });
832
+
833
+ const sub = await app.as({ user: { teamId: 7 } }).channel(Expenses, "teamExpenses");
834
+ await sub.next({ timeoutMs: 1000 }); // snapshot on connect
835
+ await emit("teamExpenses", { user: { teamId: 7 } });
836
+ const snapshot = await sub.next({ timeoutMs: 1000 });
837
+ await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
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"] });
843
+ ```
844
+
845
+ `next()` returns the payload exactly as it travelled (a whole snapshot or a raw
846
+ slice); `nextEvent()` returns the same arrivals discriminated —
847
+ `{ type: "snapshot" | "slice", data }`. The toolkit registers the channels with
848
+ the coalescing window off, so `await emit(…)` is the delivery; a test that
849
+ exercises the window passes it in `channelOptions` — a sibling of `channels`,
850
+ which stays the map — and drives it with fake timers:
851
+
852
+ ```ts
853
+ const app = await createTestApp({
854
+ routes,
855
+ channels, // the map
856
+ channelOptions: { coalesceMs: 20 }, // the registration options
857
+ getSessionCookie,
858
+ });
859
+ ```
860
+
861
+ The same `channelOptions` accepts `idleMs` and `heartbeatMs` — both default to
862
+ `0` in the toolkit (deterministic: no connection dies, no comment is written
863
+ unless the test asks for it and advances fake timers through the window it
864
+ meant). `inspectChannels()` is process-wide and sees the test app's real
865
+ channels: subscribe, emit, inspect, close, inspect again — all without network.
866
+ A test that exercises the idle timeout or the heartbeat arms the window
867
+ explicitly and drives it with fake timers; never assert freshness (in either
868
+ surface) without advancing the fake timers through the window.
869
+
870
+ ---
871
+
464
872
  ## Actions
465
873
 
466
874
  Server-side functions callable from the client via RPC.
@@ -1375,6 +1783,12 @@ const event = await sub.next({ timeoutMs: 2000 });
1375
1783
  const asAlice = app.as({ user: alice });
1376
1784
  await asAlice.subscribe(stream_progress, { channel: appId });
1377
1785
 
1786
+ // Live loader — open a channel with a principal, emit, await the next snapshot
1787
+ const live = await app.as({ user: { teamId: 7 } }).channel(Expenses, "teamExpenses");
1788
+ await live.next({ timeoutMs: 1000 }); // snapshot on connect
1789
+ await emit("teamExpenses", { user: { teamId: 7 } });
1790
+ const snapshot = await live.next({ timeoutMs: 1000 });
1791
+
1378
1792
  await app.close();
1379
1793
  ```
1380
1794
 
@@ -1388,6 +1802,7 @@ await app.close();
1388
1802
  | `app.action(fn, opts)` | Invoke `action_*` by function reference |
1389
1803
  | `app.loader(fn, opts)` | Invoke `loader` and unwrap response data |
1390
1804
  | `app.subscribe(stream, opts)` | Subscribe to a stream; returns `TestSubscription` with `next/nextN/snapshot/close/closed` |
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 }`) |
1391
1806
  | `app.as({ user })` | Sub-client with cookies bound to a user |
1392
1807
  | `app.sessionCookies({ user })` | Build cookies via `getSessionCookie` |
1393
1808
  | `app.mockContext(opts)` | Escape hatch — partial Hono Context for direct invocation |
@@ -1423,11 +1838,11 @@ See [Testing docs](https://github.com/mauro-andre/velojs/blob/dev/site/docs/17-t
1423
1838
  | Import | Contents |
1424
1839
  |--------|----------|
1425
1840
  | `@mauroandre/velojs` | Types (`AppRoutes`, `ActionArgs`, `LoaderArgs`, `Metadata`, `EventStream`, `EventStreamConfig`, `EmitFn`, `EmitOptions`, `SourceFn`, `PerChannelSourceFn`, `ChannelResolver`), `Scripts`, `Link`, `createEventStream`, `poll`, `defineConfig` |
1426
- | `@mauroandre/velojs/server` | `startServer`, `createApp`, `addRoutes`, `onServer`, `serverDataStorage` |
1841
+ | `@mauroandre/velojs/server` | `startServer`, `createApp`, `addRoutes`, `onServer`, `serverDataStorage`, `emit`, `registerChannels`, `ChannelDefinition`, `ChannelMap`, `ChannelRegistryOptions` |
1427
1842
  | `@mauroandre/velojs/client` | `startClient` |
1428
- | `@mauroandre/velojs/hooks` | `Loader`, `useLoader`, `useEventStream`, `useParams`, `useQuery`, `useNavigate`, `usePathname`, `touch` |
1843
+ | `@mauroandre/velojs/hooks` | `Loader`, `useLoader`, `useEventStream`, `useParams`, `useQuery`, `useNavigate`, `usePathname`, `touch`, `Freshness` |
1429
1844
  | `@mauroandre/velojs/events` | `createEventStream`, `poll`, `EventStream`, `EventStreamConfig`, `EmitFn`, `EmitOptions`, `SourceFn`, `PerChannelSourceFn`, `ChannelResolver` (also re-exported from root) |
1430
- | `@mauroandre/velojs/testing` | `createTestApp`, `TestApp`, `TestResponse`, `TestSubscription`, `CreateTestAppOptions`, `MockContextOptions` |
1845
+ | `@mauroandre/velojs/testing` | `createTestApp`, `TestApp`, `TestResponse`, `TestSubscription`, `TestChannelSubscription`, `ChannelEvent`, `CreateTestAppOptions`, `MockContextOptions` |
1431
1846
  | `@mauroandre/velojs/cookie` | `getCookie`, `setCookie`, `deleteCookie`, `getSignedCookie`, `setSignedCookie` |
1432
1847
  | `@mauroandre/velojs/factory` | `createMiddleware`, `createFactory` |
1433
1848
  | `@mauroandre/velojs/vite` | `veloPlugin` |
@@ -1460,6 +1875,7 @@ interface Metadata {
1460
1875
  interface RouteModule {
1461
1876
  Component: ComponentType<any>;
1462
1877
  loader?: (args: LoaderArgs) => Promise<any>;
1878
+ channels?: readonly string[]; // live loader: channel names kept fresh
1463
1879
  metadata?: Metadata;
1464
1880
  [key: `action_${string}`]: (args: ActionArgs) => Promise<any>;
1465
1881
  }
@@ -1535,6 +1951,10 @@ export const staticPaths = async () => {
1535
1951
 
1536
1952
  Routes without `staticPaths` are skipped with a warning.
1537
1953
 
1954
+ ### Live loader in a static build
1955
+
1956
+ 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.
1957
+
1538
1958
  ### Deploying
1539
1959
 
1540
1960
  The `dist/` folder is self-contained. Deploy to any static hosting (Nginx, Cloudflare Pages, S3, etc.). No server required.