@mauroandre/velojs 0.0.58 → 0.0.60

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
@@ -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,89 @@ surface) without advancing the fake timers through the window.
872
873
 
873
874
  ---
874
875
 
876
+ ## Telemetry
877
+
878
+ The framework records its own bottlenecks — pages, `?_data=1` refetches,
879
+ actions, endpoints, stream connects, socket setups, channel connects — plus
880
+ every `emit()` gesture that runs outside the request cycle, as JSONL lines with
881
+ duration, status, response bytes and child spans. Collection is transparent to
882
+ the app code: no import, no wrapper, no change in a route module.
883
+
884
+ ### Activation: the destination is the trigger
885
+
886
+ There is no on/off flag. `VELO_TELEMETRY_FILE` and/or `VELO_TELEMETRY_SINK_URL`
887
+ present and non-empty turn the collection on; none of them — absent or empty —
888
+ leaves the whole thing inert (no file, no event, no cost) in any environment,
889
+ dev included. The framework never picks a path: there is no default file, and
890
+ the values expand `~` and `$VAR`/`${VAR}`.
891
+
892
+ | Variable | Default | Description |
893
+ |----------|---------|-------------|
894
+ | `VELO_TELEMETRY_FILE` | — | Destination file (append-only JSONL). Present and non-empty activates the file collection. Expands `~`/`$VAR`; no default path |
895
+ | `VELO_TELEMETRY_SINK_URL` | — | Destination sink. Present and non-empty activates the HTTP collection: one `POST` per event |
896
+ | `VELO_TELEMETRY_MAX_MB` | `10` | Rotation limit of the file, in MB. The only pruning mechanism |
897
+
898
+ ```bash
899
+ # .env — the operator's destination, in production or in dev
900
+ VELO_TELEMETRY_FILE=$DATA_DIR/telemetry.jsonl
901
+ VELO_TELEMETRY_MAX_MB=10
902
+ ```
903
+
904
+ ### The event
905
+
906
+ One JSON object per line. `route` is always the path **pattern** (`/:mes`),
907
+ never the materialized URL; `bytes` is the response size where there is a body
908
+ (pages, data, actions, endpoints) — time and weight travel together. Every line
909
+ carries the trace id and its own timestamp.
910
+
911
+ ```json
912
+ {
913
+ "trace": "m3k9x1-4-f8a2q7",
914
+ "ts": "2026-10-07T18:22:03.482Z",
915
+ "type": "page",
916
+ "route": "/:mes",
917
+ "module": "MesPage",
918
+ "status": "ok",
919
+ "duration": 45.2,
920
+ "bytes": 122880,
921
+ "spans": [
922
+ { "name": "loader:AppLayout", "start": 0.8, "duration": 3.1, "status": "ok" },
923
+ { "name": "loader:MesPage", "start": 1.2, "duration": 43.9, "status": "ok" },
924
+ { "name": "render", "start": 44.3, "duration": 3.5, "status": "ok" }
925
+ ]
926
+ }
927
+ ```
928
+
929
+ Types: `page`, `data`, `action`, `endpoint`, `stream-connect`, `socket-connect`,
930
+ `channel-connect`, `emit`, `delivery`, `close` and `boot`. The `emit` trace has
931
+ no request parent — in the invalidation mode each re-executed loader is a child
932
+ span. A long connection (channel, stream, socket) writes one light `delivery`
933
+ line per frame, with the cumulative ordinal and the frame's bytes, and a final
934
+ `close` line with the total and the lifetime.
935
+
936
+ ### File, boot and rotation
937
+
938
+ The first line of every active destination is the boot record: the app version,
939
+ the environment, the **effective** destination and the rotation limit in force.
940
+ The effective file carries an instance suffix — `telemetry.jsonl` becomes
941
+ `telemetry.8123-host.jsonl` — so replicas pointing the same path write distinct
942
+ files. The file rotates by size (`VELO_TELEMETRY_MAX_MB`, default 10):
943
+ `<effective>.<n>` keeps the past, the effective path keeps the newest lines.
944
+
945
+ ### The CLI
946
+
947
+ ```bash
948
+ velojs telemetry # resolves VELO_TELEMETRY_FILE (env or .env), instance files included
949
+ velojs telemetry --last 60m # the last hour of the file
950
+ velojs telemetry ./t.prod.jsonl
951
+ ```
952
+
953
+ Ranks the JSONL by route, action and channel — count, errors, p50/p95 and
954
+ volume in bytes — and prints the timeline. The full reference lives in
955
+ [site/docs/22-telemetry.md](site/docs/22-telemetry.md).
956
+
957
+ ---
958
+
875
959
  ## Actions
876
960
 
877
961
  Server-side functions callable from the client via RPC.
@@ -1689,18 +1773,23 @@ Callbacks queue until the server starts. If called after startup, executes immed
1689
1773
  | `HOST` | — | Bind interface, in dev and production. Overrides `defineConfig`'s `hostname`; in dev an explicit `--host`/`server.host` still wins |
1690
1774
  | `NODE_ENV` | — | Set automatically by `velojs start`. Enables static file serving |
1691
1775
  | `STATIC_BASE_URL` | `""` | CDN/bucket prefix for static assets |
1776
+ | `VELO_TELEMETRY_FILE` | — | Telemetry destination file — present and non-empty activates the native collection (see [Telemetry](#telemetry)). Expands `~`/`$VAR` |
1777
+ | `VELO_TELEMETRY_SINK_URL` | — | Telemetry sink — present and non-empty also POSTs every event |
1778
+ | `VELO_TELEMETRY_MAX_MB` | `10` | Telemetry file rotation limit, in MB |
1692
1779
 
1693
1780
  ---
1694
1781
 
1695
1782
  ## Vite Plugin Architecture
1696
1783
 
1697
- `veloPlugin()` returns 6 plugins:
1784
+ `veloPlugin()` returns 8 plugins:
1698
1785
 
1699
1786
  | Plugin | Purpose |
1700
1787
  |--------|---------|
1701
1788
  | `velo:config` | Build config (client/server modes, aliases, defines) |
1702
1789
  | `velo:transform` | AST transforms (metadata injection, action stubs, loader removal) |
1703
1790
  | `velo:static-url` | Rewrites CSS `url(/path)` to `url(STATIC_BASE_URL/path)` at build time |
1791
+ | `velo:graph` | Generates `.velojs/graph.json` (route tree + dependency graph) on build and dev |
1792
+ | `velo:leak-diagnostic` | Prints the server→client leak report on build and dev (read-only) |
1704
1793
  | `@preact/preset-vite` | Preact JSX support |
1705
1794
  | `@hono/vite-dev-server` | Dev server with SSR |
1706
1795
  | `velo:ws-bridge` | Exposes Vite's HTTP server for WebSocket handlers in dev mode |
@@ -1743,6 +1832,55 @@ velojs build
1743
1832
 
1744
1833
  When `routes.tsx` changes, the plugin rebuilds the fullPath map and triggers a full page reload (not partial HMR).
1745
1834
 
1835
+ ### Server→Client Leak Diagnostic
1836
+
1837
+ 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.
1838
+
1839
+ 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.
1840
+ A **leak** is a server module reachable from what the browser loads, following static and dynamic imports.
1841
+ 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.
1842
+
1843
+ Each leak in the report brings:
1844
+
1845
+ - the leaked module and what qualifies it as server (the Node builtin touched, or the `serverOnly` pattern matched);
1846
+ - the full import chain from the client entry to it — file and line at every link;
1847
+ - the **entry point** — the import where the chain pulls the first server module, marked `entry point (cut here)`: exactly where the cut happens.
1848
+
1849
+ ```text
1850
+ [velojs] server->client leak analysis: 1 leak(s) in what ships to the client.
1851
+
1852
+ 1) app/server-side.ts
1853
+ server because: imports Node builtin "node:fs"
1854
+ import chain from the client entry:
1855
+ virtual:velo/client-entry (generated) -> app/routes.tsx
1856
+ app/routes.tsx:3 -> app/pages/Home.tsx
1857
+ app/pages/Home.tsx:6 -> app/server-side.ts <- entry point (cut here)
1858
+
1859
+ The report is read-only: nothing is blocked or rewritten; the cut is yours.
1860
+ ```
1861
+
1862
+ Two chains to the same module are two items — every cut point shows up — and the list is stable between runs.
1863
+ 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.
1864
+ 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.
1865
+
1866
+ 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.
1867
+
1868
+ #### Declaring server-only code: `serverOnly`
1869
+
1870
+ A module that touches no Node builtin is invisible to the analysis — the framework does not guess your app's domain.
1871
+ Declare it:
1872
+
1873
+ ```typescript
1874
+ veloPlugin({
1875
+ serverOnly: ["src/fsm/**", "server/**"],
1876
+ });
1877
+ ```
1878
+
1879
+ Patterns are globs (`*`, `**`, `?`) matched against the module's file path relative to the project root, always with `/` as separator.
1880
+ The declaration feeds the diagnostic only — nothing is blocked or altered because of it.
1881
+
1882
+ **Limitation (craved):** server code that touches no Node builtin and matches no `serverOnly` pattern generates no item; that is what the declaration is for.
1883
+
1746
1884
  ---
1747
1885
 
1748
1886
  ## Request Isolation
@@ -1900,6 +2038,7 @@ interface VeloConfig {
1900
2038
  clientInit?: string; // default: "client.tsx"
1901
2039
  port?: number; // default: 3000; the PORT env wins
1902
2040
  hostname?: string; // bind interface, dev and production; the HOST env wins
2041
+ serverOnly?: string[]; // glob patterns declaring server-only modules to the leak diagnostic
1903
2042
  }
1904
2043
  ```
1905
2044
 
package/agent/AGENTS.md CHANGED
@@ -39,6 +39,27 @@ hard constraints, not style.
39
39
 
40
40
  Never pass a module id to `useLoader()` / `Loader()` yourself — the plugin injects it.
41
41
 
42
+ ## The server→client leak report
43
+
44
+ `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.
45
+ 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.
46
+
47
+ 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.
48
+ 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.
49
+ Two chains to the same module are two items — every cut point shows up.
50
+
51
+ Code that touches **no** Node builtin is invisible to the analysis — the framework does not guess your app's domain. Declare it:
52
+
53
+ ```typescript
54
+ veloPlugin({
55
+ serverOnly: ["src/fsm/**", "server/**"],
56
+ });
57
+ ```
58
+
59
+ Patterns are globs (`*`, `**`, `?`) matched against the module's file path relative to the project root, always with `/` as separator.
60
+ The declaration feeds the diagnostic only — nothing is blocked or altered because of it.
61
+ A server module with no builtin and no declaration generates no item: that limitation is craved, and the declaration is how you lift it.
62
+
42
63
  ## Sharing a layout's data with its children
43
64
 
44
65
  `Loader()` is the importable handle for a module's loader data; `useLoader()` is the
@@ -0,0 +1,182 @@
1
+ ---
2
+ name: velojs-telemetry
3
+ description: "Native telemetry: the framework records its own boundaries — the page SSR (with a span per hierarchy loader plus the render), the `?_data=1` refetch, the action, the endpoint, the stream connect, the socket setup, the channel connect and every `emit` — as JSONL lines carrying duration, status, response bytes, child spans and, on long connections, one delivery line per frame plus a closing line. Enable it by destination (`VELO_TELEMETRY_FILE` and/or `VELO_TELEMETRY_SINK_URL`, values expanding `~`/`$VAR`, instance-suffixed file, `VELO_TELEMETRY_MAX_MB` rotation) and rank the file with `velojs telemetry`. Use when a production page is slow, when a response is too fat, or to see which routes, loaders, actions and channels are actually being called."
4
+ ---
5
+
6
+ # Telemetry
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.
9
+
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
+
12
+ ## The destination is the trigger
13
+
14
+ There is no on/off flag. **The destination env is the activation**: `VELO_TELEMETRY_FILE` and/or `VELO_TELEMETRY_SINK_URL`, present and non-empty, turn collection on; none of them — absent or empty — leaves the module completely inert (no file, no event, no cost) in **any environment, dev included**. The dev server never turns telemetry on by itself: an app that wants it in development puts the env in its `.env`, like any other environment.
15
+
16
+ The framework never picks a destination for you: there is no default file path anywhere. The path is the deployment's decision, read from the env. The values expand `~` (home) and `$VAR` / `${VAR}`, so an operator can point at `$ALGO_PRIVADO/telemetry.jsonl`; an undefined reference is kept literally, and the [boot record](#the-boot-record) shows the destination that was actually in force.
17
+
18
+ | Variable | Default | Role |
19
+ |----------|---------|------|
20
+ | `VELO_TELEMETRY_FILE` | — | Destination file. Present and non-empty activates the file collection (append-only JSONL). Expands `~` and `$VAR`. No default path |
21
+ | `VELO_TELEMETRY_SINK_URL` | — | Destination sink. Present and non-empty activates the HTTP collection: one `POST` per event with the JSON line as the body |
22
+ | `VELO_TELEMETRY_MAX_MB` | `10` | Rotation limit of the file, in MB. The only pruning mechanism — there is no retention by days |
23
+
24
+ Both destinations at once feed both; one alone is enough. The values are read from the process environment first and from the project's `.env` as the fallback (an env var exported in the shell wins over the file) — the same precedence the release script uses.
25
+
26
+ ```bash
27
+ # .env — production, or a dev machine that wants the traces
28
+ VELO_TELEMETRY_FILE=$DATA_DIR/telemetry.jsonl
29
+ VELO_TELEMETRY_MAX_MB=10
30
+ # ...or ship them somewhere:
31
+ # VELO_TELEMETRY_SINK_URL=https://collector.example.com/ingest
32
+ ```
33
+
34
+ ## What gets recorded
35
+
36
+ Each execution in one of the framework's bottlenecks produces a trace (type → fields):
37
+
38
+ | Type | Produced by | Carries |
39
+ |------|-------------|---------|
40
+ | `page` | A page request (SSR HTML) | `route` (path pattern), `module` (the page's moduleId), duration, status, `bytes`, one child span per loader of the hierarchy (plus the `render` span) |
41
+ | `data` | A `?_data=1` refetch (SPA navigation) | `route`, `module`, duration, status, `bytes` of the JSON, a span per loader executed |
42
+ | `action` | `/_action/{moduleId}/{name}` | `route`, `module`, `name`, duration, status, `bytes` |
43
+ | `endpoint` | A declarative `{ method, handler }` node | `method`, `route`, duration, status, `bytes` where there is a body |
44
+ | `stream-connect` | A `stream_*` / standalone SSE connect | `route`, `module`, `name`, `channel`, duration, status |
45
+ | `socket-connect` | A `socket_*` upgrade | `route`, `module`, `name`, duration, status |
46
+ | `channel-connect` | A live-loader channel connect | `route`, `module`, `channel`, duration, status, the `snapshot` span of the connect snapshot |
47
+ | `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
+ | `delivery` | Every frame a long connection delivers | `channel`/`route`/`module`, the cumulative ordinal (`deliveries`), `bytes` of the frame |
49
+ | `close` | The end of a long connection | `channel`/`route`/`module`, the total of `deliveries`, the connection's lifetime as `duration` |
50
+
51
+ The emission is **transparent to the app**: no import, no wrapper, no change in a route module. The spans are automatic too — the trace of a page knows the loaders because the framework is the one executing them.
52
+
53
+ ## The event format
54
+
55
+ One JSON object per line. The same envelope for every type:
56
+
57
+ ```json
58
+ {
59
+ "trace": "m3k9x1-4-f8a2q7",
60
+ "ts": "2026-10-07T18:22:03.482Z",
61
+ "type": "page",
62
+ "route": "/:mes",
63
+ "module": "MesPage",
64
+ "status": "ok",
65
+ "duration": 45.2,
66
+ "bytes": 122880,
67
+ "spans": [
68
+ { "name": "loader:AppLayout", "start": 0.8, "duration": 3.1, "status": "ok" },
69
+ { "name": "loader:MesPage", "start": 1.2, "duration": 43.9, "status": "ok" },
70
+ { "name": "render", "start": 44.3, "duration": 3.5, "status": "ok" }
71
+ ]
72
+ }
73
+ ```
74
+
75
+ - **`trace`** identifies the execution (and ties a connection's delivery/close lines to its connect line); **`ts`** is the line's own timestamp — both exist on **every** type, including `delivery`, `close` and `boot`.
76
+ - **`route`** is always the path **pattern** (`/:mes`), never the materialized URL — this is what makes aggregation by route possible. The channel and stream routes travel as their registered paths (`/_channel/{moduleId}/{channel}`, `/_event/{moduleId}/{name}`).
77
+ - **`duration`** is in milliseconds, with decimals; **`status`** is `ok` or `error` (a loader, action or handler that throws). `bytes` is the size of the response body where there is one: pages, data refetches, actions and endpoints. Time and weight travel together, so a slow screen is either a slow server or a fat response — the trace says which.
78
+ - **`spans`** are the children of the trace, when the type has them: `name`, `start` (relative to the trace), `duration` and `status` each. Loader spans are named after the module (`loader:MesPage`); `render` delimits the SSR itself. `total − (loaders + render)` is the gap of the middlewares, which this slice deliberately leaves uninstrumented.
79
+ - **`deliveries`** is the cumulative ordinal of a connection's deliveries. A long connection is a stream of evidence: fifty snapshots on one channel is the symptom of "re-executing too much", and the volume is proportional to what it measures — whoever delivers a lot, records a lot. Rotation limits the rest.
80
+
81
+ ### Delivery and closing lines
82
+
83
+ Long connections (channel, stream and socket) never keep one line growing: each delivered frame is a light line of its own — no spans — carrying the ordinal and the frame's bytes when there are any:
84
+
85
+ ```json
86
+ { "trace": "m3k9x1-4-f8a2q7", "ts": "…", "type": "delivery", "channel": "teamExpenses", "deliveries": 2, "bytes": 31 }
87
+ ```
88
+
89
+ When the connection ends — the client left, the server closed it, the idle timeout elapsed — the final line reports the total and the lifetime:
90
+
91
+ ```json
92
+ { "trace": "m3k9x1-4-f8a2q7", "ts": "…", "type": "close", "channel": "teamExpenses", "deliveries": 2, "status": "ok", "duration": 120000.4 }
93
+ ```
94
+
95
+ Heartbeats are transport, not delivery: a `: ping` comment never counts as a delivery and never moves the idle clock.
96
+
97
+ ### The emit chain
98
+
99
+ The emit trace has **no request parent at all** — that is the point:
100
+
101
+ ```json
102
+ {
103
+ "trace": "m3k9x2-1-b41c9d",
104
+ "ts": "…",
105
+ "type": "emit",
106
+ "channel": "teamExpenses",
107
+ "partition": "team:7",
108
+ "mode": "invalidate",
109
+ "connections": 2,
110
+ "status": "ok",
111
+ "duration": 12.7,
112
+ "spans": [
113
+ { "name": "loader:Expenses", "start": 0.1, "duration": 5.4, "status": "ok" },
114
+ { "name": "loader:Expenses", "start": 0.2, "duration": 6.1, "status": "ok" }
115
+ ]
116
+ }
117
+ ```
118
+
119
+ In the invalidation mode every re-executed loader is a child span — one per connection; a scope that resolves to a partition with no subscribers still produces the trace, with `connections: 0` (an emit that reaches nobody is exactly what you want to see). In the slice mode the producer already holds the value, so the trace carries the connections that received it and no spans. When the coalescing window folds a burst, each gesture that fell inside it gets its trace, carrying the round's spans.
120
+
121
+ ## The boot record
122
+
123
+ The first event of every active destination — the file's first line, the sink's first POST — is the boot record, the cheap "where am I writing and how far can I grow" diagnosis:
124
+
125
+ ```json
126
+ { "trace": "m3k9x0-0-0a1b2c", "ts": "…", "type": "boot", "version": "1.4.2", "env": "production", "file": "/srv/app/logs/telemetry.8123-host.jsonl", "sink": null, "maxMb": 10 }
127
+ ```
128
+
129
+ `version` is the served app's `package.json` version; `env` is `NODE_ENV`; `file` and `sink` are the **effective** destinations (a null one is off), and `maxMb` is the rotation limit in force — the default or the override.
130
+
131
+ ## File, instance and rotation
132
+
133
+ The file destination is append-only JSONL. The **effective name carries an instance suffix** with the pid and the hostname — `telemetry.jsonl` becomes `telemetry.8123-host.jsonl` — so replicas of the same app sharing a volume write different files and a concurrent append never corrupts one another's JSONL.
134
+
135
+ Rotation is by size only (`VELO_TELEMETRY_MAX_MB`, default `10`): when the next line would push the file past the limit, the file is renamed to `<effective>.<n>` and a fresh one takes its place — the effective path always holds the newest lines, and the rotated files stay behind it. There is no retention by days; the limit is the whole pruning policy.
136
+
137
+ ## The sink
138
+
139
+ With `VELO_TELEMETRY_SINK_URL` set (alone or alongside `VELO_TELEMETRY_FILE`), every event also leaves as one `POST` with the JSON line as the body — that is what the consumer's platform ingests. Without the env, nothing leaves the machine. A sink failure — refused connection, non-2xx — is logged and **never** affects the request that produced the event.
140
+
141
+ ## The CLI
142
+
143
+ `velojs telemetry` is the inaugural consumption surface: it ranks the JSONL by route, action and channel — count, errors, p50/p95 and volume in bytes — and prints the timeline. No UI, no server route: the operator's terminal.
144
+
145
+ ```bash
146
+ # The whole file resolved from the env (or the project's .env), instance and
147
+ # rotation files included:
148
+ velojs telemetry
149
+
150
+ # Only the last hour of a long-lived file:
151
+ velojs telemetry --last 60m
152
+
153
+ # A JSONL copied from production:
154
+ velojs telemetry ./telemetry.prod.jsonl
155
+ ```
156
+
157
+ The window is the whole file by default; `--last` accepts `ms`, `s`, `m`, `h` and `d` (`--last 60m`, `--last 2h`, `--last 7d`). Without a path, the destination comes from `VELO_TELEMETRY_FILE` (environment or `.env`), including every file that derives from it — the instance suffixes and the rotated ones. An explicit path reads exactly that file (or a directory of them).
158
+
159
+ ```
160
+ VeloJS telemetry
161
+ files: 1
162
+ /srv/app/logs/telemetry.8123-host.jsonl
163
+ window: last 60m — since 2026-10-07T17:22:03.482Z
164
+ events: 412
165
+
166
+ By route
167
+ key count errors p50 p95 bytes
168
+ /:mes 48 1 45.2ms 88.1ms 5.6MB
169
+ GET /api/health 120 0 1.2ms 3.0ms 12.0KB
170
+
171
+ By action
172
+ key count errors p50 p95 bytes
173
+ Jobs/notify 6 0 12.4ms 30.2ms 1.2KB
174
+
175
+ By channel
176
+ key count errors p50 p95 bytes
177
+ teamExpenses 52 0 12.7ms 20.1ms 1.3KB
178
+ ```
179
+
180
+ ## What is not here yet
181
+
182
+ The visual viewer over the route tree (`velojs telemetry --view`, following the `velojs graph --view` pattern), a manual `trace()` API for app spans, OTLP/OpenTelemetry export, middleware spans, client-side telemetry and the retention/continuous aggregation layer are deliberately out of this slice. The nested format is the one that will receive them without a migration — the model is already parent-and-children.
@@ -139,5 +139,6 @@ interface VeloConfig {
139
139
  clientInit?: string; // default: "client.tsx"
140
140
  port?: number; // default: 3000 (the PORT env always wins)
141
141
  hostname?: string; // bind interface, dev and production; the HOST env wins
142
+ serverOnly?: string[]; // glob patterns declaring server-only modules to the leak diagnostic
142
143
  }
143
144
  ```
@@ -5,9 +5,9 @@ description: "The `veloPlugin` build pipeline: the 9 AST transforms that inject
5
5
 
6
6
  # Vite Plugin
7
7
 
8
- The `veloPlugin()` is the core of VeloJS. It's a single function that returns 6 Vite plugins working together to handle routing, SSR, code transforms, and more.
8
+ The `veloPlugin()` is the core of VeloJS. It's a single function that returns 8 Vite plugins working together to handle routing, SSR, code transforms, and more.
9
9
 
10
- ## The 7 plugins
10
+ ## The 8 plugins
11
11
 
12
12
  | Plugin | What it does |
13
13
  |--------|-------------|
@@ -15,6 +15,7 @@ The `veloPlugin()` is the core of VeloJS. It's a single function that returns 6
15
15
  | `velo:transform` | Performs AST transforms on your code (the most important plugin) |
16
16
  | `velo:static-url` | Rewrites CSS `url(/path)` to use `STATIC_BASE_URL` in production builds |
17
17
  | `velo:graph` | Generates `.velojs/graph.json` (route tree + dependency graph) on build and dev |
18
+ | `velo:leak-diagnostic` | Prints the server→client leak report on build and dev (read-only) |
18
19
  | `@preact/preset-vite` | Adds Preact JSX support |
19
20
  | `@hono/vite-dev-server` | Runs the SSR dev server |
20
21
  | `velo:ws-bridge` | Exposes Vite's HTTP server for WebSocket handlers during development |
@@ -79,6 +80,54 @@ VeloJS uses Vite virtual modules to generate the server and client entry points
79
80
 
80
81
  You never import these directly — they're generated by the plugin.
81
82
 
83
+ ## The server→client leak diagnostic
84
+
85
+ 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.
86
+
87
+ 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.
88
+ A **leak** is a server module reachable from what the browser loads, following static and dynamic imports.
89
+ The analysis reads the code *after* the client transforms above — loaders, actions, streams, sockets, middlewares and endpoints stripped, orphaned imports pruned — never the raw source, so a `loader` importing server code is silent by construction.
90
+
91
+ Each leak in the report brings:
92
+
93
+ - the leaked module and what qualifies it as server (the Node builtin touched, or the `serverOnly` pattern matched);
94
+ - the full import chain from the client entry to it — file and line at every link;
95
+ - the **entry point** — the import where the chain pulls the first server module, marked `entry point (cut here)`: exactly where the cut happens.
96
+
97
+ ```text
98
+ [velojs] server->client leak analysis: 1 leak(s) in what ships to the client.
99
+
100
+ 1) app/server-side.ts
101
+ server because: imports Node builtin "node:fs"
102
+ import chain from the client entry:
103
+ virtual:velo/client-entry (generated) -> app/routes.tsx
104
+ app/routes.tsx:3 -> app/pages/Home.tsx
105
+ app/pages/Home.tsx:6 -> app/server-side.ts <- entry point (cut here)
106
+
107
+ The report is read-only: nothing is blocked or rewritten; the cut is yours.
108
+ ```
109
+
110
+ Two chains to the same module are two items — every cut point shows up — and the list is stable between runs.
111
+ When nothing leaks, one line says so (`0 leaks — what ships to the client is clean.`), so silence is distinguishable from the analysis not running.
112
+ 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.
113
+
114
+ ### Declaring server-only code: `serverOnly`
115
+
116
+ A module that touches no Node builtin is invisible to the analysis — the framework does not guess your app's domain.
117
+ Declare it:
118
+
119
+ ```typescript
120
+ veloPlugin({
121
+ serverOnly: ["src/fsm/**", "server/**"],
122
+ });
123
+ ```
124
+
125
+ Patterns are globs (`*`, `**`, `?`) matched against the module's file path relative to the project root, always with `/` as separator.
126
+ The declaration feeds the diagnostic only — nothing is blocked or altered because of it.
127
+
128
+ **Limitation (craved):** server code that touches no Node builtin and matches no `serverOnly` pattern generates no item; that is what the declaration is for.
129
+ The diagnostic is the light, never the hand: nothing is blocked, rewritten, stubbed or moved, `await import()` included; the build finishes with success and the dev keeps serving.
130
+
82
131
  ## Hot reload
83
132
 
84
133
  When you edit `routes.tsx`, the plugin detects the change, rebuilds the path map, and triggers a full page reload. Other file changes use Vite's standard HMR (hot module replacement).
@@ -57,6 +57,7 @@
57
57
  */
58
58
  import type { Context, Hono, MiddlewareHandler } from "hono";
59
59
  import type { LoaderArgs, RouteModule } from "./types.js";
60
+ import { type TelemetryStatus, type TelemetryTrace } from "./telemetry.js";
60
61
  /** Result of a partition resolver. `null`/`undefined` denies the subscription. */
61
62
  export type ChannelScopeResult = string | null | undefined;
62
63
  /**
@@ -229,6 +230,12 @@ export interface ChannelConnection {
229
230
  * comments never touch it.
230
231
  */
231
232
  lastDeliveryAt?: number | undefined;
233
+ /**
234
+ * The connection's telemetry trace: `channel-connect` with the snapshot
235
+ * span, then one `delivery` line per snapshot/slice and the final `close`
236
+ * line. Absent (no-op) when collection is off.
237
+ */
238
+ telemetry?: TelemetryTrace | undefined;
232
239
  }
233
240
  /** One live group of a channel: the connections of one module in one partition. */
234
241
  export interface ChannelInspectGroup {
@@ -283,7 +290,7 @@ export declare function __resetChannels(): void;
283
290
  * instead of dying silently; a loader that returns a `Response` (a redirect
284
291
  * short-circuit) has no state to send.
285
292
  */
286
- export declare function pushSnapshot(conn: ChannelConnection): Promise<void>;
293
+ export declare function pushSnapshot(conn: ChannelConnection): Promise<TelemetryStatus>;
287
294
  /**
288
295
  * Signals that a channel's partition changed: every connection in that
289
296
  * partition receives a fresh snapshot (its loader re-executed with the