@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 +332 -3
- package/agent/AGENTS.md +149 -0
- package/agent/skills/velojs-getting-started/SKILL.md +2 -0
- package/agent/skills/velojs-live-loader/SKILL.md +329 -0
- package/agent/skills/velojs-production-deploy/SKILL.md +6 -0
- package/agent/skills/velojs-server-api/SKILL.md +1 -0
- package/agent/skills/velojs-type-reference/SKILL.md +1 -0
- package/dist/channels.d.ts +289 -0
- package/dist/channels.js +283 -0
- package/dist/client.js +55 -45
- package/dist/components.js +7 -6
- package/dist/config.d.ts +5 -3
- 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 +87 -0
- package/dist/live-loader.js +131 -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 +507 -427
- package/dist/testing/subscription.d.ts +18 -2
- package/dist/testing/types.d.ts +68 -0
- package/dist/types.d.ts +9 -0
- package/dist/vite.d.ts +16 -1
- package/dist/vite.js +458 -359
- package/package.json +1 -1
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:
|