@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.
@@ -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
@@ -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).
@@ -7,6 +7,14 @@
7
7
  * family as `/_action` and `/_event` — which inherits the middlewares of the
8
8
  * module's route node and serves the module's loader as a stream of snapshots.
9
9
  *
10
+ * The tab-facing twin is the live action: a module declares
11
+ * `liveActions = { actionName: [channel, …] }` and the framework derives
12
+ * `GET /_live/{moduleId}/{actionName}` — a subscription whose args travel in
13
+ * `?_args=` and whose source is the action itself (a parameterized read,
14
+ * re-executed with those args). The subscription enters the SAME groups by
15
+ * (channel, partition): one `emit` by name wakes the loader connections and
16
+ * the live subscriptions of the partition, each re-executing its own source.
17
+ *
10
18
  * The app owns the partition policy in `app/channels.ts`, a name → resolver map:
11
19
  *
12
20
  * ```ts
@@ -15,7 +23,8 @@
15
23
  * };
16
24
  * ```
17
25
  *
18
- * On subscribe the resolver receives `{ user, params }`: `user` is the
26
+ * On subscribe the resolver receives `{ user, params }` (a loader connection)
27
+ * or `{ user, args }` (a live action subscription): `user` is the
19
28
  * materialized principal (`c.get("user")`, the house key — `undefined` when the
20
29
  * app does not materialize one, so a public channel ignores it and an
21
30
  * authenticated one returns `null`, which denies the subscription with 403) and
@@ -56,7 +65,7 @@
56
65
  * dropped and closed instead of receiving data of a group it left.
57
66
  */
58
67
  import type { Context, Hono, MiddlewareHandler } from "hono";
59
- import type { LoaderArgs, RouteModule } from "./types.js";
68
+ import type { ActionArgs, LoaderArgs, RouteModule } from "./types.js";
60
69
  import { type TelemetryStatus, type TelemetryTrace } from "./telemetry.js";
61
70
  /** Result of a partition resolver. `null`/`undefined` denies the subscription. */
62
71
  export type ChannelScopeResult = string | null | undefined;
@@ -76,6 +85,12 @@ export type ChannelScopeResult = string | null | undefined;
76
85
  export interface ChannelScopeContext {
77
86
  user?: any;
78
87
  params?: Record<string, any>;
88
+ /**
89
+ * The declared arguments of a **live action** subscription (`kind: "live"`).
90
+ * The tab-facing twin of `params`: the address the subscription itself
91
+ * declared, not something the route declares.
92
+ */
93
+ args?: Record<string, any> | undefined;
79
94
  }
80
95
  /**
81
96
  * What `onGroupOpen` receives: the partition key of the group that just got its
@@ -86,14 +101,27 @@ export interface ChannelScopeContext {
86
101
  * partitioned by principal, declared in a module with `:params`, mixes
87
102
  * resources in the same group and the hook's params are the first connection's
88
103
  * — arbitrary for the rest; such a channel must not consume them here.
104
+ *
105
+ * `args` is the live-action half of the same address: when the connection that
106
+ * opened the group is a live action subscription, they are the args that
107
+ * subscription declared (with the same arbitrariness caveat when the partition
108
+ * does not derive from them).
89
109
  */
90
110
  export interface ChannelGroupOpenContext {
91
111
  partition: string;
92
112
  params: Record<string, string>;
113
+ /** The declared args of the connection that opened the group (live actions). */
114
+ args?: Record<string, unknown> | undefined;
93
115
  }
94
- /** What `onGroupClose` receives: the partition key of the group being torn down. */
116
+ /**
117
+ * What `onGroupClose` receives: the partition key of the group being torn
118
+ * down and, when the group was opened by a live action subscription, the args
119
+ * of that connection — the same fields `onGroupOpen` saw.
120
+ */
95
121
  export interface ChannelGroupCloseContext {
96
122
  partition: string;
123
+ /** The declared args of the connection that opened the group (live actions). */
124
+ args?: Record<string, unknown> | undefined;
97
125
  }
98
126
  /**
99
127
  * One entry of the app's channel map (`app/channels.ts`).
@@ -206,13 +234,31 @@ export interface ChannelConnection {
206
234
  /** Partition key resolved at subscribe time — never from client input. */
207
235
  partition: string;
208
236
  moduleId: string;
237
+ /**
238
+ * Which surface the connection feeds: `"loader"` (the page's live-loader
239
+ * connection — the default) or `"live"` (a live action subscription, the
240
+ * tab-facing twin).
241
+ */
242
+ kind?: "loader" | "live";
209
243
  /**
210
244
  * The principal materialized at subscribe (`c.get("user")`), captured for
211
245
  * the partition revalidation of every emit — the scope re-runs against it.
212
246
  */
213
247
  principal: unknown;
214
- loader: (args: LoaderArgs) => Promise<unknown>;
215
- /** The subscribing request's context — the loader re-runs against it. */
248
+ /** The module's loader — the data source of a `"loader"` connection. */
249
+ loader?: (args: LoaderArgs) => Promise<unknown>;
250
+ /** The live action — the data source of a `"live"` connection. */
251
+ action?: (args: ActionArgs<any>) => Promise<unknown>;
252
+ /** The action name without the `action_` prefix (`"live"` connections). */
253
+ actionName?: string | undefined;
254
+ /** The subscription's declared args (`"live"` connections) — the tab's address. */
255
+ args?: Record<string, unknown> | undefined;
256
+ /**
257
+ * Identifies one live action subscription across the N group entries it
258
+ * holds (one per declared channel) — the inspector's key.
259
+ */
260
+ liveId?: string | undefined;
261
+ /** The subscribing request's context — the loader/action re-runs against it. */
216
262
  c: Context;
217
263
  params: Record<string, string>;
218
264
  query: Record<string, string>;
@@ -260,25 +306,47 @@ export interface ChannelInspectWindow {
260
306
  channel: string;
261
307
  partition: string;
262
308
  }
309
+ /** One live action subscription, as the inspector reports it. */
310
+ export interface ChannelInspectLiveAction {
311
+ /** The route module holding the action (`metadata.moduleId`). */
312
+ moduleId: string;
313
+ /** The action name, without the `action_` prefix. */
314
+ action: string;
315
+ /** Every (channel, partition) group the subscription entered. */
316
+ channels: {
317
+ channel: string;
318
+ partition: string;
319
+ }[];
320
+ /** The args the subscription declared — its address. */
321
+ args: Record<string, unknown> | null;
322
+ /** ISO timestamp of the subscription's last delivery; `null` when none yet. */
323
+ lastDeliveryAt: string | null;
324
+ }
263
325
  /** The full live state of the channel registry, as `inspectChannels()` sees it. */
264
326
  export interface ChannelInspectReport {
265
327
  channels: ChannelInspectChannel[];
266
328
  /** Open coalescing windows, per (channel, partition). */
267
329
  openCoalesceWindows: ChannelInspectWindow[];
330
+ /** The live action subscriptions, per (moduleId, action, args). */
331
+ liveActions: ChannelInspectLiveAction[];
268
332
  totalConnections: number;
269
333
  }
270
334
  /**
271
335
  * Server-side snapshot of the live channels: per channel, the groups
272
336
  * identified by (moduleId, partition key), each with its connection count and
273
- * the timestamp of its last delivery; the coalescing windows still open; and
274
- * the grand totals. Always available — the app decides how (and with what
275
- * guard) to expose it; the dev server exposes it as `GET /_channel-inspect`.
337
+ * the timestamp of its last delivery; the coalescing windows still open; the
338
+ * live action subscriptions (module, action, args and the groups they hold);
339
+ * and the grand totals — where each live subscription counts once, however
340
+ * many channel groups it entered. Always available — the app decides how (and
341
+ * with what guard) to expose it; the dev server exposes it as
342
+ * `GET /_channel-inspect`.
276
343
  */
277
344
  export declare function inspectChannels(): ChannelInspectReport;
278
345
  /** Test-only. Drops every live connection, group and open window. */
279
346
  export declare function __resetChannels(): void;
280
347
  /**
281
- * Executes the connection's loader with the connection's own principal and
348
+ * Executes the connection's data source (the loader of a page connection, the
349
+ * action of a live subscription) with the connection's own principal and
282
350
  * pushes the result as a snapshot. Per-connection execution (rather than one
283
351
  * execution per partition) is deliberate: every subscriber always receives the
284
352
  * snapshot computed with its own scope, which makes the homogeneous-partition
@@ -286,8 +354,8 @@ export declare function __resetChannels(): void;
286
354
  * amortizes is the number of *rounds*, not the executions inside one — a single
287
355
  * computation serving the whole partition is a later slice.
288
356
  *
289
- * A loader that throws reports the failure on the connection (`error` event)
290
- * instead of dying silently; a loader that returns a `Response` (a redirect
357
+ * A source that throws reports the failure on the connection (`error` event)
358
+ * instead of dying silently; a source that returns a `Response` (a redirect
291
359
  * short-circuit) has no state to send.
292
360
  */
293
361
  export declare function pushSnapshot(conn: ChannelConnection): Promise<TelemetryStatus>;
@@ -375,3 +443,41 @@ export declare function parseRouteParamsQuery(raw: string | undefined | null, de
375
443
  * whose partition no longer holds.
376
444
  */
377
445
  export declare function registerChannelRoute(app: Hono, path: string, channel: string, module: RouteModule, moduleId: string, middlewares: MiddlewareHandler[]): void;
446
+ /** Query key carrying the subscription's declared args. */
447
+ export declare const LIVE_ARGS_QUERY = "_args";
448
+ /**
449
+ * The serialized-args ceiling of a live action subscription, in bytes. Args
450
+ * travel on the URL — the ceiling keeps the address an address, not a payload;
451
+ * a bigger read wants an action call plus channels on the page.
452
+ */
453
+ export declare const LIVE_ARGS_MAX_BYTES = 4096;
454
+ /**
455
+ * Parses and validates the `?_args=` payload of a live action subscription.
456
+ *
457
+ * The client declares the args of the tab it opened; they are the subscription's
458
+ * identity (a change reconnects) and the `body` of every re-execution. An
459
+ * absent payload is the empty object — a live action may take no arguments.
460
+ */
461
+ export declare function parseLiveArgsQuery(raw: string | undefined | null): {
462
+ args: Record<string, unknown>;
463
+ error: string | null;
464
+ };
465
+ /**
466
+ * Registers the internal SSE route of one declared live action:
467
+ * `GET /_live/{moduleId}/{actionName}`, inheriting the route node's
468
+ * middlewares — the subscription is guarded exactly like the action call and
469
+ * the page are.
470
+ *
471
+ * The action runs once on subscribe (the snapshot of connection is the whole
472
+ * loading gesture — no initial POST exists) and once per emit in any of the
473
+ * declared channels, always with the args declared in the subscription as
474
+ * `body`. The subscription enters the groups of every declared channel: an
475
+ * emit in any one delivers.
476
+ *
477
+ * Guards are loud: an action that does not exist in the module, or a channel
478
+ * with no entry in `app/channels.ts`, is an explicit error — never a silent
479
+ * inert route. Args above `LIVE_ARGS_MAX_BYTES` reject the subscription with
480
+ * 400, naming the action and the limit; a scope that resolves to nothing (or
481
+ * throws) denies it with 403.
482
+ */
483
+ export declare function registerLiveActionRoute(app: Hono, path: string, actionName: string, channels: readonly string[], module: RouteModule, moduleId: string, middlewares: MiddlewareHandler[]): void;