@mauroandre/velojs 0.0.60 → 0.0.61

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
@@ -873,6 +873,134 @@ surface) without advancing the fake timers through the window.
873
873
 
874
874
  ---
875
875
 
876
+ ## Live actions
877
+
878
+ The live loader keeps a **page** value faithful to server state. The newer
879
+ apps are tab UIs (the VSCode shape): the page is the hull and every content
880
+ opens in a tab — client state, not a route — loaded at open time by an action
881
+ with its own arguments. When that content is a value that must **track server
882
+ state over time** (an expense sheet that grows line by line, a card whose
883
+ phase changes on its own), the action declares interest in living: the
884
+ tab-facing twin of the live loader, addressed by the call's own args — the
885
+ same `channels` map, the same `emit()`, zero polling.
886
+
887
+ ```tsx
888
+ // app/expenses/Expenses.tsx — the page module (the tab hull)
889
+ export const action_abrirExpense = async ({ body }: ActionArgs<{ id: number }>) => {
890
+ const { expenseSheet } = await import("./expenses.service.js");
891
+ return expenseSheet(body.id); // a parameterized read
892
+ };
893
+
894
+ // The opt-in: key = action name without `action_`, value = channel names.
895
+ export const liveActions = { abrirExpense: ["expenseSheet"] };
896
+ ```
897
+
898
+ ```tsx
899
+ // the tab component — one per open tab
900
+ import { useLiveAction } from "@mauroandre/velojs/hooks";
901
+ import { action_abrirExpense } from "../expenses/Expenses.js";
902
+
903
+ function ExpenseTab({ id }: { id: number }) {
904
+ const { data, loading, freshness } = useLiveAction(action_abrirExpense, { id });
905
+ return <article class={freshness.value}>{data.value?.total}</article>;
906
+ }
907
+ ```
908
+
909
+ ```ts
910
+ // the same emit() — no second mechanism was born
911
+ import { emit } from "@mauroandre/velojs/server";
912
+
913
+ await emit("expenseSheet", { args: { id: 7 } });
914
+ ```
915
+
916
+ ### What happens
917
+
918
+ - The framework derives `GET /_live/{moduleId}/{actionName}` (the same family
919
+ as `/_action` and `/_channel`), inheriting the route node's `middlewares` —
920
+ the subscription is guarded exactly like the action call and the page are.
921
+ - `useLiveAction(action, args)` opens one SSE connection per subscription with
922
+ the args in `?_args=`. The **connect snapshot is the loading gesture**: the
923
+ action executed once with the declared args — there is no initial POST, and
924
+ the tab never shows a value the server does not stand behind.
925
+ - Every `emit()` in any declared channel re-executes the action with the
926
+ subscription's own args and pushes the fresh value, which **replaces**
927
+ `data` whole (no merge). An action that throws marks `freshness` `"error"`
928
+ and keeps the last valid value — a failure never blanks the tab.
929
+ - The subscription's life is the mount: the tab unmounts and the connection
930
+ closes (nothing arrives afterwards; being the last of the group,
931
+ `onGroupClose` disarms the source). It remounts and is born again with a
932
+ snapshot of the current state — reopening is indistinguishable from never
933
+ having closed. Changing the args reconnects: they are the identity.
934
+ - `freshness` is the subscription's own `"live"` / `"stale"` / `"error"`
935
+ signal (the same 60s silence detector as the live loader's) and never
936
+ touches the page aggregate — a stale tab does not stale the page, and the
937
+ other way around. On SSR the hook does not connect (`data` null,
938
+ `loading` true); in a static build the load really failed: no connection,
939
+ `data` undefined, `freshness` `"error"`.
940
+
941
+ ### The contract and the partition
942
+
943
+ A live action is, by contract, a **parameterized read** — the server
944
+ re-executes it at every signal, so a mutation with a side effect in a live
945
+ action is a usage error (the re-execution would repeat the effect). The
946
+ mutation the user makes inside the tab keeps being a common one-shot action;
947
+ the tab updates through the channel. The re-execution receives the same
948
+ `ActionArgs` of the common call, with `body` carrying the declared args.
949
+
950
+ The scope ctx gains `args`, additively: a subscription resolves its partition
951
+ with `{ user, args }`, a loader connection with `{ user, params }` as before.
952
+ A channel declared by both ends has one scope and one partition — the
953
+ canonical form (carved so no reader invents their own):
954
+
955
+ ```ts
956
+ // app/channels.ts — one emit("expenseSheet", { args: { id: 7 } }) updates
957
+ // the page (/expenses/:id, partitioned by params) and the tab (by args)
958
+ export const channels = {
959
+ expenseSheet: {
960
+ scope: ({ params, args }) => "expense:" + (args?.id ?? params?.id),
961
+ },
962
+ };
963
+ ```
964
+
965
+ `null`, `undefined` or a `throw` from the scope denies the subscription with
966
+ 403, loudly; `onGroupOpen`/`onGroupClose` receive the args of the connection
967
+ that opened the group; and the transport rules are the live loader's,
968
+ inherited (heartbeat comment, idle timeout, coalescing, partition
969
+ revalidation, `no-store`) — no periodic request exists anywhere.
970
+
971
+ ### Guards
972
+
973
+ - `liveActions` naming an action the module does not export, or a channel with
974
+ no entry in `app/channels.ts`: error in the dev server and in the build,
975
+ naming module, action and channel.
976
+ - Serialized args above **4096 bytes**: rejected with **400**, naming the
977
+ action and the limit — the args are the tab's address, not a payload.
978
+ - `velojs build --static` with a module declaring `liveActions`: the build
979
+ warns naming module and action.
980
+
981
+ ### Testing a live action
982
+
983
+ ```ts
984
+ const sub = await app
985
+ .as({ id: 1, teamId: 7 })
986
+ .liveAction(Expenses, "abrirExpense", { args: { id: 7 } });
987
+ expect(await sub.next({ timeoutMs: 1000 })).toEqual({ total: 880 }); // connect
988
+
989
+ await emit("expenseSheet", { args: { id: 7 } });
990
+ expect(await sub.next({ timeoutMs: 1000 })).toEqual({ total: 940 });
991
+
992
+ await sub.close(); // the tab closed: the group empties, onGroupClose fires
993
+ ```
994
+
995
+ `next()` delivers the payloads in arrival order (the connect snapshot first);
996
+ `nextEvent()` discriminates `{ type: "snapshot" | "error", data }`; `status`
997
+ reads the guards (403 denied, 400 oversized args). The subscription and every
998
+ delivery are traced as their own telemetry boundary (`live-action-connect`,
999
+ ranked by `velojs telemetry` in `By live action`), and `inspectChannels()`
1000
+ lists the live subscriptions next to the loader groups.
1001
+
1002
+ ---
1003
+
876
1004
  ## Telemetry
877
1005
 
878
1006
  The framework records its own bottlenecks — pages, `?_data=1` refetches,
package/agent/AGENTS.md CHANGED
@@ -35,6 +35,8 @@ hard constraints, not style.
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
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 |
38
+ | a live action with a side effect (an action listed in `liveActions` that mutates anything) | a **parameterized read** in `liveActions`; the tab's mutations stay one-shot actions | the server re-executes a live action at every `emit` of its channels — **the side effect repeats** (duplicate rows, duplicate emails, double writes) |
39
+ | `export const liveActions = {...}` in a `.jsx` / `.js` page, or any page outside `appDirectory` | `.tsx` inside `app/` | **zero transforms**: the live-action guards never run, so the declaration is inert — no `/_live` route, no connection, no error |
38
40
  | 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 |
39
41
 
40
42
  Never pass a module id to `useLoader()` / `Loader()` yourself — the plugin injects it.
@@ -300,6 +302,66 @@ the module.
300
302
  Do not turn on a channel where a post-action `refetch()` suffices, and do not
301
303
  use `stream_*` for a value that is simply replaced.
302
304
 
305
+ ## A tab value that must follow the server: `liveActions` + `useLiveAction`
306
+
307
+ A tab is not a route: it is client state (persisted, restored on refresh) whose
308
+ content loads at open time from an action with its own args. When that content
309
+ is a value that must track server state over time, the action declares
310
+ interest in living — the tab-facing twin of the live loader, same channels,
311
+ same `emit()`, addressed by the call's own args:
312
+
313
+ ```tsx
314
+ // app/expenses/Expenses.tsx
315
+ export const action_abrirExpense = async ({ body }: ActionArgs<{ id: number }>) => {
316
+ const { expenseSheet } = await import("./expenses.service.js");
317
+ return expenseSheet(body.id); // a parameterized read — ONLY
318
+ };
319
+ export const liveActions = { abrirExpense: ["expenseSheet"] };
320
+ ```
321
+
322
+ ```tsx
323
+ // the tab component — one per open tab
324
+ import { useLiveAction } from "@mauroandre/velojs/hooks";
325
+ import { action_abrirExpense } from "../expenses/Expenses.js";
326
+
327
+ const { data, loading, freshness } = useLiveAction(action_abrirExpense, { id });
328
+ ```
329
+
330
+ ```ts
331
+ // the same emit() — the producer addresses the partition by args
332
+ await emit("expenseSheet", { args: { id: 7 } });
333
+ ```
334
+
335
+ The contract, in short:
336
+
337
+ - A live action is a **parameterized read**. The server re-executes it with the
338
+ subscription's own args at every `emit()` of its declared channels — a
339
+ mutation there repeats its effect on every signal. The tab's mutations stay
340
+ one-shot actions; the tab updates through the channel.
341
+ - The connect snapshot **is** the loading gesture: the action runs once on
342
+ subscribe, there is no initial POST. The re-execution receives the same
343
+ `ActionArgs`, with `body` carrying the declared args.
344
+ - The scope ctx gains `args` (additively): a subscription resolves its
345
+ partition with `{ user, args }`; a loader connection keeps `{ user, params }`.
346
+ A channel declared by both ends has one scope and one partition — the
347
+ canonical form: `scope: ({ params, args }) => "expense:" + (args?.id ?? params?.id)`
348
+ — and one `emit` by name updates the page and the tab, each with its own data.
349
+ - The subscription's life is the mount (unmount closes, remount reborn with
350
+ the current state), the args are the identity (a change reconnects), and
351
+ `freshness` (`"live"`/`"stale"`/`"error"`, same 60s silence detector) belongs
352
+ to the subscription — it never touches the page's aggregate.
353
+ - Serialized args above **4096 bytes** reject the subscription with 400 — the
354
+ args are an address (an id), not a payload. `liveActions` naming an action
355
+ the module does not export, or a channel missing from `app/channels.ts`,
356
+ fails dev and build naming module, action and channel.
357
+ - In `velojs build --static` there is no server to run the action: the build
358
+ warns naming module and action, and the hook returns `data` undefined with
359
+ `freshness` `"error"` — the load really failed (a channel, in contrast,
360
+ stays `"live"`: deliberate).
361
+ - Test it with `app.liveAction(Module, "abrirExpense", { args: { id: 7 } })` —
362
+ `next()` (the connect snapshot first), `nextEvent()` (`snapshot`/`error`),
363
+ `close()`, `status` (403/400 guards), all deterministic like `app.channel`.
364
+
303
365
  ## A stream/socket `channel` is untrusted client input
304
366
 
305
367
  `useEventStream(s, { channel })` and `useSocket(s, { channel })` put the channel on the
@@ -373,6 +435,7 @@ API from this file — it is deliberately incomplete.
373
435
  | `<Scripts />`, assets, favicon | `scripts-component` |
374
436
  | SSE, live/progress/streaming data | `event-streams` |
375
437
  | A page value that must track server state (`loader` + `channels`) | `live-loader` |
438
+ | A tab value that must track server state (`liveActions` + `useLiveAction`) | `live-actions` |
376
439
  | WebSockets, bidirectional realtime | `sockets` |
377
440
  | `addRoutes`, `onServer`, ports, server lifecycle | `server-api` |
378
441
  | The build, transforms, `veloPlugin` options | `vite-plugin` |
@@ -190,3 +190,35 @@ Both imports compile to the **same URL**: `POST /_action/admin/Layout/logout`. M
190
190
  ### Why this works
191
191
 
192
192
  The Vite plugin replaces the action body with a `fetch()` call to its registered URL **during compile time**, using the `moduleId` of the file where the export lives. Imports in other files just reuse that compiled stub — they never change the URL.
193
+
194
+ ## The action that lives — when the portrait ages
195
+
196
+ An action is, by default, a one-shot gesture: the client calls, the server
197
+ executes, the value returns. When the content it carries is a **value that
198
+ tracks server state over time** — the tab of an expense sheet that grows line
199
+ by line, the card whose phase changes on its own — the portrait ages minutes
200
+ after opening. The way out is declaring interest in living ([live
201
+ actions](/docs/23-live-actions), the tab-facing twin of the action):
202
+
203
+ ```tsx
204
+ // app/expenses/Expenses.tsx
205
+ export const action_abrirExpense = async ({ body }: ActionArgs<{ id: number }>) => {
206
+ const { expenseSheet } = await import("./expenses.service.js");
207
+ return expenseSheet(body.id); // a parameterized read — ONLY
208
+ };
209
+ export const liveActions = { abrirExpense: ["expenseSheet"] };
210
+ ```
211
+
212
+ ```tsx
213
+ import { useLiveAction } from "@mauroandre/velojs/hooks";
214
+
215
+ const { data, loading, freshness } = useLiveAction(action_abrirExpense, { id });
216
+ ```
217
+
218
+ The lifecycle changes hands: what loads the tab is no longer the POST call but
219
+ the **subscription** (`GET /_live/{moduleId}/{actionName}?_args=`) — the
220
+ connect snapshot is the action executed once, and every `emit()` of its
221
+ declared channels re-executes it with the same args. The contract is rigid: a
222
+ live action is a **parameterized read** — a side effect in it is a usage error
223
+ (the re-execution would repeat the effect); the mutation the user makes inside
224
+ the tab keeps being a common action, and the tab updates through the channel.
@@ -14,6 +14,7 @@ VeloJS provides a set of hooks that work identically on both server (SSR) and cl
14
14
  | `useLoader<T>(deps?)` | `{ data, loading, refetch }` | Page data with SSR + SPA support |
15
15
  | `Loader<T>()` | `{ data, loading }` | Module-level SSR-only data |
16
16
  | `useEventStream<T, S>(stream, opts?)` | `{ data, snapshot, closed, error }` | Real-time SSE stream subscription |
17
+ | `useLiveAction<T>(action, args)` | `{ data, loading, freshness }` | A live action's value, tracking server state (a tab) |
17
18
  | `useParams<T>()` | `T` | URL parameters (`:id`, etc) |
18
19
  | `useQuery<T>()` | `T` | Query string parameters |
19
20
  | `useNavigate()` | `navigate(path)` | Programmatic navigation |
@@ -83,6 +84,31 @@ const { data, snapshot, closed, error } = useEventStream(stream, {
83
84
 
84
85
  The hook handles `EventSource` lifecycle automatically: opens on mount, closes on unmount, re-opens when `channel` changes. See [Event Streams](/docs/event-streams) for the full guide.
85
86
 
87
+ ## useLiveAction
88
+
89
+ Subscribes to a **live action** — the tab-facing twin of the live loader. The
90
+ hook receives the action itself (its client stub carries the subscription
91
+ address) and the args; the connect snapshot is the whole loading gesture, and
92
+ every `emit()` of the action's declared channels re-executes it server-side
93
+ and replaces `data` whole:
94
+
95
+ ```tsx
96
+ import { useLiveAction } from "@mauroandre/velojs/hooks";
97
+ import { action_abrirExpense } from "../expenses/Expenses.js";
98
+
99
+ function ExpenseTab({ id }: { id: number }) {
100
+ const { data, loading, freshness } = useLiveAction(action_abrirExpense, { id });
101
+ if (loading.value) return <div class="tab-loading" />;
102
+ return <article class={freshness.value}>Total: {data.value?.total}</article>;
103
+ }
104
+ ```
105
+
106
+ The life is the mount (unmount closes the subscription, remount reopens it
107
+ with the current state) and the args are the identity (a change reconnects).
108
+ `freshness` is the subscription's own `"live"` / `"stale"` / `"error"` signal —
109
+ never the page's aggregate. On SSR the hook does not connect (`data` null,
110
+ `loading` true). See [Live actions](/docs/live-actions) for the full contract.
111
+
86
112
  ## touch
87
113
 
88
114
  Preact signals are great for reactivity, but they have a limitation: mutating a nested property (like changing `items[0].checked`) doesn't trigger a signal update. The `touch` function forces the signal to notify its subscribers after a mutation:
@@ -0,0 +1,211 @@
1
+ ---
2
+ name: velojs-live-actions
3
+ description: "Live actions: the tab-facing twin of the live loader. Declare `liveActions = { abrirFicha: [\"fichaProjeto\"] }` next to the action and subscribe with `useLiveAction(action, args)` — the connect snapshot is the loading gesture and every `emit()` of the declared channels re-executes the action with the subscription's own args, replacing the value whole. A live action is a parameterized read (no side effects); the partition derives from `args` in `app/channels.ts`. Use when a tab's content must track server state over time instead of being fetched once at open."
4
+ ---
5
+
6
+ # Live actions
7
+
8
+ The newer apps built on VeloJS are tab UIs (the VSCode shape): the page is the hull — sidebar, tab bar — and every content opens in a tab. A tab is **not a route**: it is client state, persisted in `localStorage` and restored on refresh, and its content loads at open time from an action with its own arguments (the resource id, the file path). The page's loader does not know the tabs — it loads only what belongs to the page itself.
9
+
10
+ When a tab's content is a **value that tracks server state over time** — the expense sheet that grows line by line, the project card whose phase changes without anyone clicking — the one-shot action delivers a portrait that ages minutes after opening, and whoever reads it cannot know they are looking at the past. That is what a live action is for: the action declares interest in **living**, and the tab's value follows the server, addressed by the call's own arguments — one signaling mechanism (`emit()`), zero polling.
11
+
12
+ It is the tab-facing twin of the live loader: the same channels, the same `emit()`, the same partition policy in `app/channels.ts` — addressed to an **action call with args** instead of a **route with params**.
13
+
14
+ ## The three declarations
15
+
16
+ ```tsx
17
+ // app/expenses/Expenses.tsx — the page module (the tab hull)
18
+ import type { ActionArgs } from "@mauroandre/velojs";
19
+
20
+ // A live action is a parameterized read: it returns the tab's content for the
21
+ // args it receives. Mutations inside the tab stay common one-shot actions.
22
+ export const action_abrirExpense = async ({ body }: ActionArgs<{ id: number }>) => {
23
+ const { expenseSheet } = await import("./expenses.service.js");
24
+ return expenseSheet(body.id); // { total: 880, linhas: [...] }
25
+ };
26
+
27
+ // The opt-in: the key is the action name WITHOUT the `action_` prefix, the
28
+ // value is the list of channels — an emit in any one delivers.
29
+ export const liveActions = { abrirExpense: ["expenseSheet"] };
30
+
31
+ export const Component = () => <ExpensesShell />; // sidebar + tab bar
32
+ ```
33
+
34
+ ```tsx
35
+ // the tab component — one per open tab
36
+ import { useLiveAction } from "@mauroandre/velojs/hooks";
37
+ import { action_abrirExpense } from "../expenses/Expenses.js";
38
+
39
+ function ExpenseTab({ id }: { id: number }) {
40
+ const { data, loading, freshness } = useLiveAction(action_abrirExpense, { id });
41
+ if (loading.value) return <div class="tab-loading" />;
42
+ return (
43
+ <article class={`tab ${freshness.value}`}>
44
+ <h3>{data.value?.total}</h3>
45
+ <ul>{data.value?.linhas.map((linha) => <li>{linha}</li>)}</ul>
46
+ </article>
47
+ );
48
+ }
49
+ ```
50
+
51
+ ```ts
52
+ // app/channels.ts — the same map the live loader uses
53
+ export const channels = {
54
+ expenseSheet: {
55
+ scope: ({ user, args }) => {
56
+ if (args?.id == null) return null;
57
+ if (user && !podeVerTime(user, args.id)) return null; // 403
58
+ return `expense:${args.id}`;
59
+ },
60
+ },
61
+ };
62
+ ```
63
+
64
+ ```ts
65
+ // anywhere server-side: the same emit() — no second mechanism was born
66
+ import { emit } from "@mauroandre/velojs/server";
67
+
68
+ await emit("expenseSheet", { args: { id: 7 } });
69
+ ```
70
+
71
+ `export const liveActions = { ... }` is the whole opt-in. A module without it behaves exactly as before — no route, no connection, no cost. A module may declare `loader` + `channels` (the live page) **and** `liveActions` (the live tabs): the two surfaces coexist without interference.
72
+
73
+ ## What happens
74
+
75
+ - The framework derives one internal SSE route per declared action — `GET /_live/{moduleId}/{actionName}`, the same family as `/_action`, `/_event` and `/_channel` — inheriting the `middlewares` of the module's route node: the subscription is guarded exactly like the action call and the page are.
76
+ - `useLiveAction(action, args)` opens **one connection per subscription** with the args serialized in `?_args=`. The connect snapshot — the action executed once with the declared args — **is the loading gesture**: there is no initial POST, the tab never shows a value the server does not stand behind.
77
+ - Every `emit()` in any of the action's declared channels re-executes the action **with the subscription's own args** and pushes the fresh value, which **replaces** `data` whole — the updated payload has the same shape as the original answer, no merge on the client.
78
+ - The subscription's life is the mount: the tab component unmounts (tab closed, page left) and the connection closes — nothing arrives afterwards, and the server holds nothing for whoever left (being the last of the group, `onGroupClose` disarms the source). It remounts (tab reopened, refresh restored the tabs) and the subscription is born again with a snapshot of the current state: reopening is indistinguishable from never having closed.
79
+ - The args are the identity: changing them reconnects — a new subscription with a new snapshot, the twin of the loader connection keyed by its route params.
80
+
81
+ ## The contract of a live action
82
+
83
+ A live action is, by contract, a **parameterized read**. The server re-executes it with the subscription's args at every signal of its channels, and the result replaces the previous value whole — a mutation with a side effect in a live action is a **usage error**: the re-execution would repeat the effect (one more row, one more email). The row is explicit in the agent rules' silent-failure table.
84
+
85
+ The mutation the user makes inside the tab keeps being a **common one-shot action**; the tab updates through the channel. The re-execution receives the same `ActionArgs` as the common call — `body` carries the args declared in the subscription, `c` is the connection's context (the same ruler as the loader re-executed by a channel connection).
86
+
87
+ ## The partition: `args` in the scope
88
+
89
+ The ctx of a channel's `scope` gains `args` — additively. On a live action subscription the scope receives `{ user, args }`; on a loader connection it receives `{ user, params }` as before. On `emit` the producer builds the same shape of always: `emit("expenseSheet", { args: { id: 7 } })` — the public gesture is the existing `emit()`, nothing new.
90
+
91
+ A channel declared by **both** ends has one scope that consumes what each end declares and resolves **one partition**. The canonical form is carved here so no reader invents their own — a channel `expenseSheet` declared by the `loader` of the page `/expenses/:id` and by the tab action `abrirExpense`:
92
+
93
+ ```ts
94
+ // app/channels.ts
95
+ export const channels = {
96
+ expenseSheet: {
97
+ scope: ({ params, args }) => "expense:" + (args?.id ?? params?.id),
98
+ },
99
+ };
100
+ ```
101
+
102
+ The loader connection (partitioned by the route it is seeing) and the tab subscription (partitioned by its own args) fall in the same partition `expense:{id}` — and **one** `emit("expenseSheet", { args: { id: 7 } })` re-executes the loader on the page and the action on the tab, each with its own data.
103
+
104
+ The rest of the partition contract is the live loader's, unchanged: `null`, `undefined` or a `throw` from the scope denies the subscription with 403, loudly; the partition **never** derives from anything the client did not declare. `onGroupOpen`/`onGroupClose` receive the args of the connection that opened the group (`{ partition, params, args }`) — with the same arbitrariness caveat the live loader makes for `params` when the partition does not derive from them.
105
+
106
+ ## `useLiveAction(action, args)`
107
+
108
+ The hook (exported from `@mauroandre/velojs/hooks`, the house of hooks) receives **the action itself** — its client stub, which the Vite plugin marks with the subscription address (`__path`, the socket-stub ruler) — and the args. The types are preserved from the action's own signature: args and return check in the app's typecheck at the same level as the common call.
109
+
110
+ | Field | Behavior |
111
+ |---|---|
112
+ | `data` | Signal with the action's return, replaced whole by every snapshot |
113
+ | `loading` | True until the first snapshot or error of the current subscription |
114
+ | `freshness` | The subscription's own `"live"` / `"stale"` / `"error"` signal |
115
+
116
+ `freshness` carries the same three states and the same silence detector as the live loader's: a connection open with no deliveries for 60s (`CHANNEL_SILENCE_MS`, a runtime constant) is `"stale"` even though `EventSource` still reads `OPEN`; any delivery brings it back. It is **per subscription** and never touches the page aggregate: a stale tab does not stale the page (`useLoader().freshness`), and the other way around.
117
+
118
+ On the server (SSR) the hook does not connect — the tab is client state the server does not know — and returns `data` null with `loading` true. In a static build the load really failed (there is no server to run the action): no connection is opened and the handle is `data` undefined with `freshness` `"error"` — the deliberate contrast with a channel, which stays `"live"` in static because there is nothing to synchronize.
119
+
120
+ An error — the action throwing on the snapshot or on a re-execution — arrives as the error event of the connection: `freshness` goes `"error"`, `loading` ends, and the **last valid value stays** (a failure never blanks the tab). A malformed payload keeps the previous value — the house ruler.
121
+
122
+ ## Transport — the channel ruler, inherited
123
+
124
+ The live connection enters the same groups by (channel, partition) as the loader connections, with every transport rule of the live loader: the heartbeat is an SSE comment (no client event, no delivery), the idle timeout closes a subscription with no deliveries, invalidations coalesce per (channel, partition), the partition is revalidated before every delivery (the scope re-derived with the ctx captured at subscribe — a partition that moves or denies closes the subscription), and the answer carries `Cache-Control: no-store`. **No periodic request exists anywhere**: the update is born of `emit()`, never of a clock.
125
+
126
+ One detail belongs to this surface: the subscription of an action that declares **two** channels enters the groups of both (one SSE stream, N group entries) — an emit in either one delivers, and any cause of death (idle, revalidation in any channel) ends the whole subscription: a tab cannot be half alive. In the inspector it counts once.
127
+
128
+ ## Guards
129
+
130
+ The failures that would otherwise be silent are explicit:
131
+
132
+ - `liveActions` naming an action the module does **not** export: error in the dev server and in the build, naming module and action — never an inert route.
133
+ - A channel with no entry in `app/channels.ts`: error in the dev server and in the build, naming module, action and channel.
134
+ - Serialized args above **4096 bytes**: the subscription is rejected with **400**, naming the action and the limit — the args are the tab's address (an id), not a payload; a bigger read wants a common action call.
135
+ - A malformed `?_args=` payload: rejected with **400**, legibly.
136
+ - A `scope` that returns nothing (or throws): the subscription is denied with **403**, loudly — and the route node's `middlewares` run for real, so a guard middleware denies an unauthenticated subscription exactly as it denies the page.
137
+ - `velojs build --static` with a module declaring `liveActions`: the build warns naming module and action — in static the client opens no `/_live` connection and the hook reports the real failure.
138
+ - A module outside the conventions (`.jsx`/`.js`, or outside `app/`) is the one genuinely silent line: no transform, so the declaration is simply inert (the row exists in the agent rules' table).
139
+
140
+ ## Telemetry and inspection
141
+
142
+ The subscription and every delivery are traced as a boundary of their own — `live-action-connect`, carrying `module` and `name` (never a channel) — with one delivery line per snapshot and a closing line with the total: distinguishable from a loader's `channel-connect` in the JSONL and in `velojs telemetry`, which ranks the live actions (times, bytes, deliveries) in their own `By live action` section. The emit log stays one line per gesture, as always.
143
+
144
+ `inspectChannels()` (and the dev-only `GET /_channel-inspect`) lists the live subscriptions next to the loader groups — each with module, action, declared args and the (channel, partition) groups it holds — and the grand total counts each subscription once.
145
+
146
+ ```ts
147
+ import { inspectChannels } from "@mauroandre/velojs/server";
148
+
149
+ inspectChannels().liveActions;
150
+ // [{
151
+ // moduleId: "expenses/Expenses",
152
+ // action: "abrirExpense",
153
+ // channels: [{ channel: "expenseSheet", partition: "expense:7" }],
154
+ // args: { id: 7 },
155
+ // lastDeliveryAt: "2026-10-12T12:00:01.104Z",
156
+ // }]
157
+ ```
158
+
159
+ ## Testing
160
+
161
+ `app.liveAction(Module, "name", { args })` opens a real subscription — the family of `app.channel(...)`: the middlewares of the route node run for real, cookies come from the options or from `app.as(user)`, and the size guard and the scope run for real too. The toolkit registers the channels with the transport clocks at `0` (deterministic), so `await emit(…)` is the delivery — no sleep, no retry.
162
+
163
+ ```ts
164
+ const app = await createTestApp({
165
+ routes,
166
+ channels, // the map (app/channels.ts)
167
+ getSessionCookie,
168
+ });
169
+
170
+ const sub = await app
171
+ .as({ id: 1, teamId: 7 })
172
+ .liveAction(Expenses, "abrirExpense", { args: { id: 7 } });
173
+ expect(await sub.next({ timeoutMs: 1000 })).toEqual({ total: 880, linhas: ["mercado"] }); // connect
174
+
175
+ await emit("expenseSheet", { args: { id: 7 } });
176
+ expect(await sub.next({ timeoutMs: 1000 })).toEqual({ total: 940, linhas: ["mercado", "luz"] });
177
+
178
+ await sub.close(); // the tab closed: the group empties, onGroupClose fires
179
+ ```
180
+
181
+ `next()` delivers the payloads in arrival order (the connect snapshot first); `nextEvent()` discriminates the arrival — `{ type: "snapshot" | "error", data }` (an error is an arrival for `nextEvent()`, never a payload for `next()`); `close()` ends the subscription, and `status` reads the guards (403 denied, 400 oversized args). The group hooks are observable at the subscription's entrances and exits, and the denial of the scope and the args limit are observable as status.
182
+
183
+ ## State or flow — the same two questions
184
+
185
+ | Question | Answer | Tool |
186
+ |---|---|---|
187
+ | Is this a **value** or a **sequence**? | value — "what is it now?", replacement | `liveActions` + `useLiveAction` (a tab), `loader` + `channels` (the page) |
188
+ | | sequence — "what happened?", accumulation (chat, log, progress) | `stream_*` / `socket_*` |
189
+ | Is the address a **route** or a **call with args**? | the route the user is seeing | `loader` + `channels` |
190
+ | | the tab's own arguments (client state) | `liveActions` + `useLiveAction` |
191
+
192
+ Do not turn on a live action where the value is fetched once and never moves, and do not use it for flow — tokens being born in a chat keep being `stream_*`; a terminal keeps being `socket_*`.
193
+
194
+ ## Reference
195
+
196
+ | Piece | Behavior |
197
+ |---|---|
198
+ | `export const liveActions = { abrir: ["canal"] }` | Module opt-in: key = action name without `action_`, value = channel names (an emit in any one delivers) |
199
+ | `GET /_live/{moduleId}/{actionName}?_args=` | The subscription route — inherits the route node's `middlewares` |
200
+ | `useLiveAction(action, args)` | `{ data, loading, freshness }`; the life is the mount, the args are the identity (a change reconnects) |
201
+ | The connect snapshot | The action executed once with the declared args — the whole loading gesture |
202
+ | The re-execution | `emit()` in a declared channel re-runs the action with the subscription's own args; the value replaces whole |
203
+ | `scope({ user, args })` | The live half of the partition ctx; `null`/`undefined`/throw denies with 403 |
204
+ | Shared channel | `scope: ({ params, args }) => "expense:" + (args?.id ?? params?.id)` — the canonical form for a channel declared by a loader and a live action |
205
+ | `onGroupOpen`/`onGroupClose` | Receive `{ partition, params, args }` — the address of the connection that opened the group |
206
+ | Args limit | Serialized args above 4096 bytes reject the subscription with 400, naming the action |
207
+ | `freshness` | `"live"` / `"stale"` (closed, reconnecting or silent past 60s) / `"error"`, per subscription — never the page aggregate |
208
+ | Static build | No connection; `data` undefined with `freshness` `"error"` — the load really failed (the channel stays `"live"` — deliberate contrast) |
209
+ | Telemetry | `live-action-connect` + one `delivery` line per snapshot + the closing line; `velojs telemetry` ranks them in `By live action` |
210
+ | `inspectChannels().liveActions` | The live subscriptions: module, action, args, groups, last delivery |
211
+ | `app.liveAction(Module, "name", { args })` | Test subscription — `next()`, `nextEvent()`, `close()`, `status` |
@@ -5,7 +5,7 @@ description: "Native telemetry: the framework records its own boundaries — the
5
5
 
6
6
  # Telemetry
7
7
 
8
- The framework knows its own bottlenecks — they are finite: a page SSR (with the loaders of the hierarchy), a `?_data=1` refetch, an action, a declarative endpoint, a stream connect, a socket setup, a channel connect. It also knows the part that runs **outside any request**: when `emit()` invalidates a channel, the loader re-executes on an already-open connection, with no new request involved. Native telemetry turns each of those executions into one JSON line — duration, status, response bytes, child spans — and each long connection into one light line per delivery plus a final line when it closes.
8
+ The framework knows its own bottlenecks — they are finite: a page SSR (with the loaders of the hierarchy), a `?_data=1` refetch, an action, a declarative endpoint, a stream connect, a socket setup, a channel connect, a live action connect. It also knows the part that runs **outside any request**: when `emit()` invalidates a channel, the loader (or the live action) re-executes on an already-open connection, with no new request involved. Native telemetry turns each of those executions into one JSON line — duration, status, response bytes, child spans — and each long connection into one light line per delivery plus a final line when it closes.
9
9
 
10
10
  What this buys you: **which route is slow, how big its response is, and why**. A page trace names every loader that ran and how long each took; an emit trace names the channel, the partition and how many connections re-executed. The file is plain JSONL in an operator-chosen path — no dev overlay, no dashboard inside the app, nothing to attack in production.
11
11
 
@@ -44,6 +44,7 @@ Each execution in one of the framework's bottlenecks produces a trace (type →
44
44
  | `stream-connect` | A `stream_*` / standalone SSE connect | `route`, `module`, `name`, `channel`, duration, status |
45
45
  | `socket-connect` | A `socket_*` upgrade | `route`, `module`, `name`, duration, status |
46
46
  | `channel-connect` | A live-loader channel connect | `route`, `module`, `channel`, duration, status, the `snapshot` span of the connect snapshot |
47
+ | `live-action-connect` | A live action subscription (`useLiveAction`) | `route`, `module`, `name` (never a `channel`), duration, status, the `snapshot` span of the connect snapshot — plus its `delivery`/`close` lines, told from a loader's by the `name` they carry |
47
48
  | `emit` | Every `emit()` gesture | `channel`, `partition`, `mode` (`invalidate` / `slice`), `connections` reached, duration; in the invalidation mode, one child span per loader re-execution per connection |
48
49
  | `delivery` | Every frame a long connection delivers | `channel`/`route`/`module`, the cumulative ordinal (`deliveries`), `bytes` of the frame |
49
50
  | `close` | The end of a long connection | `channel`/`route`/`module`, the total of `deliveries`, the connection's lifetime as `duration` |
@@ -175,6 +176,10 @@ By action
175
176
  By channel
176
177
  key count errors p50 p95 bytes
177
178
  teamExpenses 52 0 12.7ms 20.1ms 1.3KB
179
+
180
+ By live action
181
+ key count errors p50 p95 bytes
182
+ expenses/abrirExpense 18 0 8.9ms 14.0ms 2.4KB
178
183
  ```
179
184
 
180
185
  ## What is not here yet