@mauroandre/velojs 0.0.59 → 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 +182 -1
- package/agent/AGENTS.md +84 -0
- package/agent/skills/velojs-actions/SKILL.md +32 -0
- package/agent/skills/velojs-hooks/SKILL.md +26 -0
- package/agent/skills/velojs-live-actions/SKILL.md +211 -0
- package/agent/skills/velojs-telemetry/SKILL.md +6 -1
- package/agent/skills/velojs-type-reference/SKILL.md +1 -0
- package/agent/skills/velojs-vite-plugin/SKILL.md +51 -2
- package/dist/channels.d.ts +117 -11
- package/dist/channels.js +459 -284
- package/dist/config.d.ts +8 -0
- package/dist/hooks.d.ts +2 -0
- package/dist/hooks.js +34 -32
- package/dist/leak-diagnostic.d.ts +77 -0
- package/dist/leak-diagnostic.js +14 -0
- package/dist/live-action.d.ts +86 -0
- package/dist/live-action.js +52 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +165 -143
- package/dist/telemetry-cli.d.ts +1 -0
- package/dist/telemetry-cli.js +116 -113
- package/dist/telemetry.d.ts +3 -3
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +500 -453
- package/dist/testing/subscription.d.ts +7 -0
- package/dist/testing/types.d.ts +43 -2
- package/dist/types.d.ts +12 -0
- package/dist/vite.d.ts +47 -1
- package/dist/vite.js +869 -401
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -170,6 +170,7 @@ veloPlugin({
|
|
|
170
170
|
serverInit: "server.tsx", // default
|
|
171
171
|
clientInit: "client.tsx", // default
|
|
172
172
|
hostname: "127.0.0.1", // bind interface, dev and production (the HOST env wins)
|
|
173
|
+
serverOnly: ["server/**"], // declares server-only modules to the leak diagnostic
|
|
173
174
|
});
|
|
174
175
|
```
|
|
175
176
|
|
|
@@ -872,6 +873,134 @@ surface) without advancing the fake timers through the window.
|
|
|
872
873
|
|
|
873
874
|
---
|
|
874
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
|
+
|
|
875
1004
|
## Telemetry
|
|
876
1005
|
|
|
877
1006
|
The framework records its own bottlenecks — pages, `?_data=1` refetches,
|
|
@@ -1780,13 +1909,15 @@ Callbacks queue until the server starts. If called after startup, executes immed
|
|
|
1780
1909
|
|
|
1781
1910
|
## Vite Plugin Architecture
|
|
1782
1911
|
|
|
1783
|
-
`veloPlugin()` returns
|
|
1912
|
+
`veloPlugin()` returns 8 plugins:
|
|
1784
1913
|
|
|
1785
1914
|
| Plugin | Purpose |
|
|
1786
1915
|
|--------|---------|
|
|
1787
1916
|
| `velo:config` | Build config (client/server modes, aliases, defines) |
|
|
1788
1917
|
| `velo:transform` | AST transforms (metadata injection, action stubs, loader removal) |
|
|
1789
1918
|
| `velo:static-url` | Rewrites CSS `url(/path)` to `url(STATIC_BASE_URL/path)` at build time |
|
|
1919
|
+
| `velo:graph` | Generates `.velojs/graph.json` (route tree + dependency graph) on build and dev |
|
|
1920
|
+
| `velo:leak-diagnostic` | Prints the server→client leak report on build and dev (read-only) |
|
|
1790
1921
|
| `@preact/preset-vite` | Preact JSX support |
|
|
1791
1922
|
| `@hono/vite-dev-server` | Dev server with SSR |
|
|
1792
1923
|
| `velo:ws-bridge` | Exposes Vite's HTTP server for WebSocket handlers in dev mode |
|
|
@@ -1829,6 +1960,55 @@ velojs build
|
|
|
1829
1960
|
|
|
1830
1961
|
When `routes.tsx` changes, the plugin rebuilds the fullPath map and triggers a full page reload (not partial HMR).
|
|
1831
1962
|
|
|
1963
|
+
### Server→Client Leak Diagnostic
|
|
1964
|
+
|
|
1965
|
+
What ships to the browser is checked on every `velojs build` (including `build --static`) and `velojs dev`: the terminal prints a report of every server module the client entry can reach — a leak of the server→client boundary.
|
|
1966
|
+
|
|
1967
|
+
A **server module** is a module that touches a Node builtin in its own code — static import, re-export or `await import()`, by any import path — or that matches a `serverOnly` pattern declared in the plugin config.
|
|
1968
|
+
A **leak** is a server module reachable from what the browser loads, following static and dynamic imports.
|
|
1969
|
+
The analysis reads the code *after* the client transforms — the same ones the build applies (loaders, actions, streams, sockets, middlewares and endpoints stripped, orphaned imports pruned) — so the normal VeloJS shape (a `loader` importing server code) is silent by construction: what the framework removes never travels.
|
|
1970
|
+
|
|
1971
|
+
Each leak in the report brings:
|
|
1972
|
+
|
|
1973
|
+
- the leaked module and what qualifies it as server (the Node builtin touched, or the `serverOnly` pattern matched);
|
|
1974
|
+
- the full import chain from the client entry to it — file and line at every link;
|
|
1975
|
+
- the **entry point** — the import where the chain pulls the first server module, marked `entry point (cut here)`: exactly where the cut happens.
|
|
1976
|
+
|
|
1977
|
+
```text
|
|
1978
|
+
[velojs] server->client leak analysis: 1 leak(s) in what ships to the client.
|
|
1979
|
+
|
|
1980
|
+
1) app/server-side.ts
|
|
1981
|
+
server because: imports Node builtin "node:fs"
|
|
1982
|
+
import chain from the client entry:
|
|
1983
|
+
virtual:velo/client-entry (generated) -> app/routes.tsx
|
|
1984
|
+
app/routes.tsx:3 -> app/pages/Home.tsx
|
|
1985
|
+
app/pages/Home.tsx:6 -> app/server-side.ts <- entry point (cut here)
|
|
1986
|
+
|
|
1987
|
+
The report is read-only: nothing is blocked or rewritten; the cut is yours.
|
|
1988
|
+
```
|
|
1989
|
+
|
|
1990
|
+
Two chains to the same module are two items — every cut point shows up — and the list is stable between runs.
|
|
1991
|
+
When there is nothing to report, one line says so (`0 leaks — what ships to the client is clean.`), so silence is distinguishable from the analysis not running.
|
|
1992
|
+
In dev the report appears when the server comes up (no navigation needed) and is re-emitted whenever a file change shifts what travels to the client — an import added stitches a new path into the next report, an import removed takes the leak out of it.
|
|
1993
|
+
|
|
1994
|
+
The report is the light, never the hand: nothing is blocked, rewritten, stubbed or moved, `await import()` included; the build finishes with success, the dev keeps serving, and the decision to cut any edge is entirely yours.
|
|
1995
|
+
|
|
1996
|
+
#### Declaring server-only code: `serverOnly`
|
|
1997
|
+
|
|
1998
|
+
A module that touches no Node builtin is invisible to the analysis — the framework does not guess your app's domain.
|
|
1999
|
+
Declare it:
|
|
2000
|
+
|
|
2001
|
+
```typescript
|
|
2002
|
+
veloPlugin({
|
|
2003
|
+
serverOnly: ["src/fsm/**", "server/**"],
|
|
2004
|
+
});
|
|
2005
|
+
```
|
|
2006
|
+
|
|
2007
|
+
Patterns are globs (`*`, `**`, `?`) matched against the module's file path relative to the project root, always with `/` as separator.
|
|
2008
|
+
The declaration feeds the diagnostic only — nothing is blocked or altered because of it.
|
|
2009
|
+
|
|
2010
|
+
**Limitation (craved):** server code that touches no Node builtin and matches no `serverOnly` pattern generates no item; that is what the declaration is for.
|
|
2011
|
+
|
|
1832
2012
|
---
|
|
1833
2013
|
|
|
1834
2014
|
## Request Isolation
|
|
@@ -1986,6 +2166,7 @@ interface VeloConfig {
|
|
|
1986
2166
|
clientInit?: string; // default: "client.tsx"
|
|
1987
2167
|
port?: number; // default: 3000; the PORT env wins
|
|
1988
2168
|
hostname?: string; // bind interface, dev and production; the HOST env wins
|
|
2169
|
+
serverOnly?: string[]; // glob patterns declaring server-only modules to the leak diagnostic
|
|
1989
2170
|
}
|
|
1990
2171
|
```
|
|
1991
2172
|
|
package/agent/AGENTS.md
CHANGED
|
@@ -35,10 +35,33 @@ 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.
|
|
41
43
|
|
|
44
|
+
## The server→client leak report
|
|
45
|
+
|
|
46
|
+
`velojs build` (and `build --static`, `velojs dev`) prints a report of every **server module** the browser can reach — a module that touches a Node builtin in its own code (static import, re-export or `await import()`), or that matches a `serverOnly` pattern.
|
|
47
|
+
The analysis reads the code **after** the client transforms (the table above describes what those remove), so a `loader`/`action_*`/`stream_*`/`socket_*`/`middlewares` importing server code where the framework strips it is silent by construction.
|
|
48
|
+
|
|
49
|
+
What the warning means: the module named in the item reaches what ships to the browser; the chain lists every import from the client entry to it (file and line per link); `entry point (cut here)` marks the import where the chain pulls the first server module — that is where the cut happens.
|
|
50
|
+
The report never blocks, rewrites, stubs or moves anything: the build finishes with success, the dev keeps serving, and cutting any edge is always your call.
|
|
51
|
+
Two chains to the same module are two items — every cut point shows up.
|
|
52
|
+
|
|
53
|
+
Code that touches **no** Node builtin is invisible to the analysis — the framework does not guess your app's domain. Declare it:
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
veloPlugin({
|
|
57
|
+
serverOnly: ["src/fsm/**", "server/**"],
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Patterns are globs (`*`, `**`, `?`) matched against the module's file path relative to the project root, always with `/` as separator.
|
|
62
|
+
The declaration feeds the diagnostic only — nothing is blocked or altered because of it.
|
|
63
|
+
A server module with no builtin and no declaration generates no item: that limitation is craved, and the declaration is how you lift it.
|
|
64
|
+
|
|
42
65
|
## Sharing a layout's data with its children
|
|
43
66
|
|
|
44
67
|
`Loader()` is the importable handle for a module's loader data; `useLoader()` is the
|
|
@@ -279,6 +302,66 @@ the module.
|
|
|
279
302
|
Do not turn on a channel where a post-action `refetch()` suffices, and do not
|
|
280
303
|
use `stream_*` for a value that is simply replaced.
|
|
281
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
|
+
|
|
282
365
|
## A stream/socket `channel` is untrusted client input
|
|
283
366
|
|
|
284
367
|
`useEventStream(s, { channel })` and `useSocket(s, { channel })` put the channel on the
|
|
@@ -352,6 +435,7 @@ API from this file — it is deliberately incomplete.
|
|
|
352
435
|
| `<Scripts />`, assets, favicon | `scripts-component` |
|
|
353
436
|
| SSE, live/progress/streaming data | `event-streams` |
|
|
354
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` |
|
|
355
439
|
| WebSockets, bidirectional realtime | `sockets` |
|
|
356
440
|
| `addRoutes`, `onServer`, ports, server lifecycle | `server-api` |
|
|
357
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` |
|