@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 +423 -3
- package/agent/AGENTS.md +216 -0
- package/agent/skills/velojs-live-loader/SKILL.md +410 -0
- package/dist/channels.d.ts +370 -0
- package/dist/channels.js +376 -0
- package/dist/client.js +56 -45
- package/dist/components.js +7 -6
- package/dist/events.d.ts +6 -0
- package/dist/events.js +1 -0
- package/dist/hooks.d.ts +26 -21
- package/dist/hooks.js +117 -98
- package/dist/live-loader.d.ts +120 -0
- package/dist/live-loader.js +141 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +144 -118
- package/dist/testing/index.d.ts +5 -1
- package/dist/testing/index.js +495 -412
- package/dist/testing/subscription.d.ts +18 -2
- package/dist/testing/types.d.ts +85 -0
- package/dist/types.d.ts +9 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.js +431 -340
- package/package.json +1 -1
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.
|