@mauroandre/velojs 0.0.55 → 0.0.57

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.
@@ -0,0 +1,370 @@
1
+ /**
2
+ * Live loader — server-side runtime (slice 1).
3
+ *
4
+ * A route module opts into state synchronization with a sibling `channels`
5
+ * export (an array of channel names). The framework derives one internal SSE
6
+ * route per declared channel — `GET /_channel/{moduleId}/{channel}`, the same
7
+ * family as `/_action` and `/_event` — which inherits the middlewares of the
8
+ * module's route node and serves the module's loader as a stream of snapshots.
9
+ *
10
+ * The app owns the partition policy in `app/channels.ts`, a name → resolver map:
11
+ *
12
+ * ```ts
13
+ * export const channels = {
14
+ * teamExpenses: { scope: ({ user }) => `team:${user?.teamId}` },
15
+ * };
16
+ * ```
17
+ *
18
+ * On subscribe the resolver receives `{ user, params }`: `user` is the
19
+ * materialized principal (`c.get("user")`, the house key — `undefined` when the
20
+ * app does not materialize one, so a public channel ignores it and an
21
+ * authenticated one returns `null`, which denies the subscription with 403) and
22
+ * `params` are the route params the client declared and the runtime validated
23
+ * against the module's path (empty for a route without `:params`). The
24
+ * partition NEVER derives from the query string or from any other client input;
25
+ * `params` are not input — they are the address the client is already seeing,
26
+ * validated by shape and then by the scope (ownership/permission).
27
+ *
28
+ * `emit(channel, ctx)` resolves the same partition key on the producer side —
29
+ * the producer builds the same `{ user, params }` shape with the fields its
30
+ * scope consumes — finds the group of connections in that partition and
31
+ * re-executes the loader for each connection, with that connection's own
32
+ * principal and route params, pushing a fresh snapshot down the wire. Emitting
33
+ * to a channel with no subscribers is a no-op; emitting to a channel with no
34
+ * entry in the app map throws immediately.
35
+ *
36
+ * `emit(module, channel, ctx, slice)` is the second mode, addressed to the
37
+ * (module, channel) pair: the producer already holds the value, so it travels
38
+ * as a slice — the connections of that pair in the emitting partition merge it
39
+ * shallowly by key, and no loader runs. The slice is typed as a `Partial` of
40
+ * the module's loader return, which is why the module is part of the address:
41
+ * one channel can be declared by modules with different `Data` (a layout and a
42
+ * page), and a slice is the shape of exactly one of them.
43
+ *
44
+ * A channel's group (channel + partition) has a lifecycle: the first connection
45
+ * arms it (`onGroupOpen({ partition, params })`) and the last one disarms it
46
+ * (`onGroupClose({ partition })`), both awaited — that is where a watcher with a
47
+ * cost of life is armed and disarmed, instead of running forever for an empty
48
+ * group.
49
+ *
50
+ * Every emit logs one line (channel, partition, kind, connections at the
51
+ * moment of the gesture, timestamp) unless `logEmits: false` is registered,
52
+ * and invalidations are folded by a coalescing window per (channel, partition)
53
+ * (`coalesceMs`, default 50, `0` disables). Before delivering anything the
54
+ * runtime re-derives each connection's partition with the ctx captured on
55
+ * subscribe: a connection that moved (or whose scope now returns null) is
56
+ * dropped and closed instead of receiving data of a group it left.
57
+ */
58
+ import type { Context, Hono, MiddlewareHandler } from "hono";
59
+ import type { LoaderArgs, RouteModule } from "./types.js";
60
+ /** Result of a partition resolver. `null`/`undefined` denies the subscription. */
61
+ export type ChannelScopeResult = string | null | undefined;
62
+ /**
63
+ * The ctx of a partition resolver — the same shape on both sides.
64
+ *
65
+ * On **subscribe** the runtime builds it: `user` is the materialized principal
66
+ * (`c.get("user")`, the house key — `undefined` when the app does not
67
+ * materialize one) and `params` are the route params the client declared and
68
+ * the runtime validated against the module's path (URL strings; empty for a
69
+ * route without `:params`).
70
+ *
71
+ * On **emit** the producer builds the same object with the fields its scope
72
+ * consumes — `{ params: { id: 7 } }` for a resource channel, `{ user: … }`
73
+ * for a principal one — and the same `scope` runs against it.
74
+ */
75
+ export interface ChannelScopeContext {
76
+ user?: any;
77
+ params?: Record<string, any>;
78
+ }
79
+ /**
80
+ * What `onGroupOpen` receives: the partition key of the group that just got its
81
+ * first connection, and the validated route params of **that** connection.
82
+ *
83
+ * The params are identical across the group only when the partition derives
84
+ * from them (a watcher per resource — the case the hook exists for). A channel
85
+ * partitioned by principal, declared in a module with `:params`, mixes
86
+ * resources in the same group and the hook's params are the first connection's
87
+ * — arbitrary for the rest; such a channel must not consume them here.
88
+ */
89
+ export interface ChannelGroupOpenContext {
90
+ partition: string;
91
+ params: Record<string, string>;
92
+ }
93
+ /** What `onGroupClose` receives: the partition key of the group being torn down. */
94
+ export interface ChannelGroupCloseContext {
95
+ partition: string;
96
+ }
97
+ /**
98
+ * One entry of the app's channel map (`app/channels.ts`).
99
+ *
100
+ * `scope` receives `{ user, params }` on subscribe and the emitter's object on
101
+ * emit; returning `null` denies with 403. Omitted → the channel is public
102
+ * (`() => "all"`).
103
+ *
104
+ * `onGroupOpen`/`onGroupClose` are the group lifecycle — armed by the first
105
+ * connection of a (channel, partition) group, disarmed by the last one — and
106
+ * may be async: the runtime awaits them (the group is only marked open after
107
+ * `onGroupOpen` resolves; only discarded after `onGroupClose` resolves). A hook
108
+ * that throws or rejects is logged loudly and the group continues.
109
+ */
110
+ export interface ChannelDefinition {
111
+ scope?: (ctx: ChannelScopeContext) => ChannelScopeResult | Promise<ChannelScopeResult>;
112
+ onGroupOpen?: (ctx: ChannelGroupOpenContext) => void | Promise<void>;
113
+ onGroupClose?: (ctx: ChannelGroupCloseContext) => void | Promise<void>;
114
+ }
115
+ /** The `app/channels.ts` map: channel name → partition resolver. */
116
+ export type ChannelMap = Record<string, ChannelDefinition>;
117
+ /** SSE event carrying the loader's current value — it replaces the module's data. */
118
+ export declare const SNAPSHOT_EVENT = "snapshot";
119
+ /** SSE event carrying a slice — the client merges it into the module's current value. */
120
+ export declare const SLICE_EVENT = "slice";
121
+ /** SSE event signaling that the loader re-execution failed on the server. */
122
+ export declare const CHANNEL_ERROR_EVENT = "error";
123
+ /** SSE event signaling a deliberate server-side close. */
124
+ export declare const CHANNEL_CLOSE_EVENT = "close";
125
+ /** Default coalescing window, in milliseconds. */
126
+ export declare const DEFAULT_COALESCE_MS = 50;
127
+ /**
128
+ * Default idle timeout of a channel connection, in milliseconds: a connection
129
+ * with no deliveries (snapshot or slice) for this long is closed by the
130
+ * server. The heartbeat does NOT reset it — idle is about data delivery,
131
+ * heartbeat is transport. The close makes a live page cycle
132
+ * (close → EventSource reconnect → snapshot on connect), which repairs the
133
+ * state, and frees an abandoned page's resources for good.
134
+ */
135
+ export declare const DEFAULT_IDLE_MS = 300000;
136
+ /** The two modes of `emit`, as they appear in the emit log. */
137
+ export type ChannelEmitKind = "invalidate" | "slice";
138
+ /**
139
+ * `registerChannels` options.
140
+ *
141
+ * `coalesceMs` is the coalescing window of the invalidation mode, per
142
+ * (channel, partition): every emit inside an open window becomes one
143
+ * re-execution round, fired when the window closes. `0` disables it (immediate
144
+ * — the slice-1 behavior). The default is `DEFAULT_COALESCE_MS` in any
145
+ * registration without options; `createTestApp` registers with `0` for
146
+ * determinism.
147
+ *
148
+ * `logEmits` writes one line per emit gesture (never per consolidated
149
+ * delivery). Default: on.
150
+ *
151
+ * `idleMs` closes a connection that had no deliveries (snapshot or slice) for
152
+ * that long — a silent connection is closed even though its heartbeat keeps
153
+ * flowing; heartbeats never reset the idle clock. Default: `DEFAULT_IDLE_MS`
154
+ * (5 min) in any registration without options; `0` disables it.
155
+ * `createTestApp` registers with `0` for determinism.
156
+ *
157
+ * `heartbeatMs` writes an SSE comment (`: ping`) through the write chain every
158
+ * interval, keeping proxies and the browser from buffering or closing an idle
159
+ * stream. A comment generates no client event and never counts as a delivery
160
+ * (no effect on freshness or idle). Default: `DEFAULT_HEARTBEAT_MS` (20s) in
161
+ * any registration without options; `0` disables it.
162
+ */
163
+ export interface ChannelRegistryOptions {
164
+ coalesceMs?: number;
165
+ logEmits?: boolean;
166
+ idleMs?: number;
167
+ heartbeatMs?: number;
168
+ }
169
+ /**
170
+ * What `emit(module, channel, ctx, slice)` accepts: the imported route module
171
+ * (the same object the route tree uses — the Vite plugin has injected its
172
+ * `metadata`, and its `loader` types the slice).
173
+ */
174
+ export interface ChannelEmitModule {
175
+ metadata?: {
176
+ moduleId?: string;
177
+ } | undefined;
178
+ channels?: readonly string[] | undefined;
179
+ loader?: ((args: LoaderArgs) => unknown) | undefined;
180
+ }
181
+ /**
182
+ * The slice type of a module: a `Partial` of whatever its loader returns.
183
+ * `Partial` (plus `exactOptionalPropertyTypes` on the app's tsconfig) is what
184
+ * makes a wrong key or a wrong value type a compile error instead of a silent
185
+ * no-op at runtime.
186
+ */
187
+ export type ChannelSlice<M> = M extends {
188
+ loader?: (...args: any[]) => infer R;
189
+ } ? Partial<Awaited<R>> : never;
190
+ /**
191
+ * Registers (replaces) the app's channel map and its options. Idempotent.
192
+ *
193
+ * Called without options, the coalescing window is `DEFAULT_COALESCE_MS` — the
194
+ * window matters wherever emits burst (a job writing N rows), not only in
195
+ * production.
196
+ */
197
+ export declare function registerChannels(map: ChannelMap | undefined, options?: ChannelRegistryOptions): void;
198
+ /** The currently registered channel map (read-only use). */
199
+ export declare function getChannelMap(): ChannelMap;
200
+ /** Channel names declared in the app map, in declaration order. */
201
+ export declare function channelNames(): string[];
202
+ /** One live channel connection (an SSE subscription). */
203
+ export interface ChannelConnection {
204
+ channel: string;
205
+ /** Partition key resolved at subscribe time — never from client input. */
206
+ partition: string;
207
+ moduleId: string;
208
+ /**
209
+ * The principal materialized at subscribe (`c.get("user")`), captured for
210
+ * the partition revalidation of every emit — the scope re-runs against it.
211
+ */
212
+ principal: unknown;
213
+ loader: (args: LoaderArgs) => Promise<unknown>;
214
+ /** The subscribing request's context — the loader re-runs against it. */
215
+ c: Context;
216
+ params: Record<string, string>;
217
+ query: Record<string, string>;
218
+ /** Writes one SSE event on this connection. Resolves when it hits the wire. */
219
+ send: (event: string, data: string) => Promise<void>;
220
+ /**
221
+ * Ends this connection: a `close` event on the wire, then the group entry
222
+ * and the stream. Used when the connection's partition no longer holds —
223
+ * or when its idle timeout elapsed.
224
+ */
225
+ terminate: (reason?: string) => Promise<void>;
226
+ /**
227
+ * Epoch ms of this connection's last delivery (snapshot or slice) — the
228
+ * clock the idle timeout runs on and what the inspector reports. Heartbeat
229
+ * comments never touch it.
230
+ */
231
+ lastDeliveryAt?: number | undefined;
232
+ }
233
+ /** One live group of a channel: the connections of one module in one partition. */
234
+ export interface ChannelInspectGroup {
235
+ /** The route module holding these connections (`metadata.moduleId`). */
236
+ moduleId: string;
237
+ /** The partition key resolved at subscribe time. */
238
+ partition: string;
239
+ /** Live connections in this (module, partition) pair. */
240
+ connections: number;
241
+ /** ISO timestamp of the last delivery; `null` when none was delivered yet. */
242
+ lastDeliveryAt: string | null;
243
+ }
244
+ /** One live channel: its groups and its total. */
245
+ export interface ChannelInspectChannel {
246
+ channel: string;
247
+ /** Groups identified by (moduleId, partition) — which page holds what. */
248
+ groups: ChannelInspectGroup[];
249
+ connections: number;
250
+ }
251
+ /** One open coalescing window: the invalidations not yet delivered. */
252
+ export interface ChannelInspectWindow {
253
+ channel: string;
254
+ partition: string;
255
+ }
256
+ /** The full live state of the channel registry, as `inspectChannels()` sees it. */
257
+ export interface ChannelInspectReport {
258
+ channels: ChannelInspectChannel[];
259
+ /** Open coalescing windows, per (channel, partition). */
260
+ openCoalesceWindows: ChannelInspectWindow[];
261
+ totalConnections: number;
262
+ }
263
+ /**
264
+ * Server-side snapshot of the live channels: per channel, the groups
265
+ * identified by (moduleId, partition key), each with its connection count and
266
+ * the timestamp of its last delivery; the coalescing windows still open; and
267
+ * the grand totals. Always available — the app decides how (and with what
268
+ * guard) to expose it; the dev server exposes it as `GET /_channel-inspect`.
269
+ */
270
+ export declare function inspectChannels(): ChannelInspectReport;
271
+ /** Test-only. Drops every live connection, group and open window. */
272
+ export declare function __resetChannels(): void;
273
+ /**
274
+ * Executes the connection's loader with the connection's own principal and
275
+ * pushes the result as a snapshot. Per-connection execution (rather than one
276
+ * execution per partition) is deliberate: every subscriber always receives the
277
+ * snapshot computed with its own scope, which makes the homogeneous-partition
278
+ * rule unnecessary as a security invariant. What a burst of invalidations
279
+ * amortizes is the number of *rounds*, not the executions inside one — a single
280
+ * computation serving the whole partition is a later slice.
281
+ *
282
+ * A loader that throws reports the failure on the connection (`error` event)
283
+ * instead of dying silently; a loader that returns a `Response` (a redirect
284
+ * short-circuit) has no state to send.
285
+ */
286
+ export declare function pushSnapshot(conn: ChannelConnection): Promise<void>;
287
+ /**
288
+ * Signals that a channel's partition changed: every connection in that
289
+ * partition receives a fresh snapshot (its loader re-executed with the
290
+ * connection's own principal and route params). Connections in other
291
+ * partitions receive nothing; a channel with no subscribers is a no-op; a
292
+ * channel with no entry in the app map throws immediately, naming the channel.
293
+ *
294
+ * The ctx is the object the same `scope` receives: the producer builds it with
295
+ * the fields its scope consumes (`{ params: { id: 7 } }` for a resource
296
+ * channel, `{ user: … }` for a principal one).
297
+ *
298
+ * ```ts
299
+ * import { emit } from "@mauroandre/velojs/server";
300
+ * await emit("teamExpenses", { user: { teamId: 7 } });
301
+ * ```
302
+ */
303
+ export declare function emit(channel: string, ctx?: ChannelScopeContext): Promise<void>;
304
+ /**
305
+ * Slice mode: pushes the value the producer already holds to the connections
306
+ * of the (module, channel) pair in the emitting partition — **without**
307
+ * re-executing the loader. The slice is typed as a `Partial` of what the
308
+ * module's loader returns, and the merge on arrival is shallow by key.
309
+ *
310
+ * ```ts
311
+ * import * as Expenses from "../app/expenses/Expenses.js";
312
+ * await emit(Expenses, "teamExpenses", { user: { teamId: 7 } }, { teamTotal: 880 });
313
+ * ```
314
+ */
315
+ export declare function emit<M extends ChannelEmitModule>(module: M, channel: string, ctx: ChannelScopeContext, slice: ChannelSlice<M>): Promise<void>;
316
+ /**
317
+ * Serializes the SSE writes of one connection and never lets a failed write
318
+ * break the chain.
319
+ *
320
+ * A write can fail because the client went away mid-event (tab closed, proxy
321
+ * cut). The connection is cleaned up by the abort handler either way, but the
322
+ * failure is logged instead of swallowed: a snapshot lost in a dead write must
323
+ * not be indistinguishable from an emit that never happened.
324
+ */
325
+ export declare function createChannelWriteChain(channel: string, write: (payload: ChannelWritePayload) => Promise<unknown>): (payload: ChannelWritePayload) => Promise<void>;
326
+ /**
327
+ * What the write chain carries: an SSE event (`{ event, data }`) or a raw
328
+ * chunk — the heartbeat is a comment (`": ping\n\n"`), which is no event at
329
+ * all: no client event fires for it and no delivery clock moves.
330
+ */
331
+ export type ChannelWritePayload = {
332
+ event: string;
333
+ data: string;
334
+ } | string;
335
+ /** Query key carrying the route params the connection declares. */
336
+ export declare const ROUTE_PARAMS_QUERY = "_route";
337
+ /**
338
+ * The `:param` names a route path declares, in declaration order. A path with
339
+ * a catch-all (`/docs/*`) declares none: the pattern has no key to extract, so
340
+ * the connection travels by principal only.
341
+ */
342
+ export declare function declaredRouteParams(fullPath: string | null | undefined): string[];
343
+ /**
344
+ * Parses and validates the `?_route=` payload of a channel connection against
345
+ * the names the module's route path declares.
346
+ *
347
+ * The client declares the params of the URL it is already seeing; it is not
348
+ * free input: a key the module's path does not declare rejects the
349
+ * subscription with an explicit error, and every value is a URL string. An
350
+ * absent payload is the empty object — a route without `:params` sends none.
351
+ */
352
+ export declare function parseRouteParamsQuery(raw: string | undefined | null, declared: readonly string[]): {
353
+ params: Record<string, string>;
354
+ error: string | null;
355
+ };
356
+ /**
357
+ * Registers the internal SSE route of one declared channel:
358
+ * `GET /_channel/{moduleId}/{channel}`, inheriting the route node's
359
+ * middlewares (auth policy is declared once per node).
360
+ *
361
+ * Guards are loud: a module that declares `channels` without a `loader`, or a
362
+ * channel with no entry in `app/channels.ts`, is an explicit error — never a
363
+ * silent inert route.
364
+ *
365
+ * The connection carries the client-declared route params (validated here
366
+ * against the module's path), the principal captured for the partition
367
+ * revalidation of every emit, and `terminate` lets an emit close a connection
368
+ * whose partition no longer holds.
369
+ */
370
+ export declare function registerChannelRoute(app: Hono, path: string, channel: string, module: RouteModule, moduleId: string, middlewares: MiddlewareHandler[]): void;