@mauroandre/velojs 0.0.54 → 0.0.56
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 +332 -3
- package/agent/AGENTS.md +149 -0
- package/agent/skills/velojs-getting-started/SKILL.md +2 -0
- package/agent/skills/velojs-live-loader/SKILL.md +329 -0
- package/agent/skills/velojs-production-deploy/SKILL.md +6 -0
- package/agent/skills/velojs-server-api/SKILL.md +1 -0
- package/agent/skills/velojs-type-reference/SKILL.md +1 -0
- package/dist/channels.d.ts +289 -0
- package/dist/channels.js +283 -0
- package/dist/client.js +55 -45
- package/dist/components.js +7 -6
- package/dist/config.d.ts +5 -3
- package/dist/events.d.ts +6 -0
- package/dist/events.js +1 -0
- package/dist/hooks.d.ts +26 -21
- package/dist/hooks.js +117 -98
- package/dist/live-loader.d.ts +87 -0
- package/dist/live-loader.js +131 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +144 -118
- package/dist/testing/index.d.ts +5 -1
- package/dist/testing/index.js +507 -427
- package/dist/testing/subscription.d.ts +18 -2
- package/dist/testing/types.d.ts +68 -0
- package/dist/types.d.ts +9 -0
- package/dist/vite.d.ts +16 -1
- package/dist/vite.js +458 -359
- package/package.json +1 -1
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: velojs-live-loader
|
|
3
|
+
description: "Keep a loader value faithful to server state in real time: declare `channels` next to the loader, map each channel to a partition resolver in `app/channels.ts`, and signal changes server-side with `emit()` — by name (the runtime re-executes the loader) or with a typed `Partial` slice when the producer already holds the value. Use when a page value must track server state over time instead of being fetched once."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Live loader
|
|
7
|
+
|
|
8
|
+
A `loader` fetches a page's data once per request and re-fetches it on SPA navigation. That is everything a page needs when its data changes because of the user themselves — or not at all. A **value that must track server state over time** (a team total another person's request changes, data a background scheduler mutates) needs the loader to stay faithful. That is the live loader: the same `loader`, the same `useLoader()` read, plus a sibling `channels` export.
|
|
9
|
+
|
|
10
|
+
## The three declarations
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
// app/expenses/Expenses.tsx — the page module
|
|
14
|
+
export const loader = async ({ c }: LoaderArgs) => {
|
|
15
|
+
const user = c.get("user");
|
|
16
|
+
return {
|
|
17
|
+
teamTotal: await expensesService.teamTotal(user.teamId),
|
|
18
|
+
expenses: await expensesService.byTeam(user.teamId),
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
export const channels = ["teamExpenses"];
|
|
22
|
+
|
|
23
|
+
export const Component = () => {
|
|
24
|
+
const { data, freshness } = useLoader();
|
|
25
|
+
return <h2 class={freshness.value}>Team: ${data.value?.teamTotal}</h2>;
|
|
26
|
+
};
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// app/channels.ts — the app's channel map: name → partition resolver
|
|
31
|
+
export const channels = {
|
|
32
|
+
teamExpenses: {
|
|
33
|
+
// ctx: on subscribe, the principal materialized in c.get("user");
|
|
34
|
+
// on emit, the object the emitter passed. Returns the partition key.
|
|
35
|
+
scope: (ctx) => `team:${ctx.teamId}`,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// anywhere server-side: an action, server.tsx, a scheduler, a webhook
|
|
42
|
+
import { emit } from "@mauroandre/velojs/server";
|
|
43
|
+
|
|
44
|
+
await emit("teamExpenses", { teamId: 7 });
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`export const channels = [...]` is the whole opt-in. A module without it behaves exactly as before — no connection, no cost.
|
|
48
|
+
|
|
49
|
+
## What happens
|
|
50
|
+
|
|
51
|
+
- The framework derives one internal SSE route per declared channel — `GET /_channel/{moduleId}/{channel}`, the same family as `/_action` and `/_event` — inheriting the `middlewares` of the module's route node, so `requireAuth`/`requireMaster` guard the subscription exactly as they guard the page.
|
|
52
|
+
- After hydration the client opens one connection per (module, channel) of the rendered hierarchy — layouts and pages alike — and closes it on unmount (SPA navigation, page close). A channel declared by both a layout and a page produces one connection per module, each feeding its own module's data; a single `emit` by channel name reaches both.
|
|
53
|
+
- Every new connection immediately receives the **current state**, computed by re-executing the loader with that connection's own principal. The pane never shows the SSR value while the server has already moved on, and a reconnect is a new connection with a new snapshot — no extra refetch.
|
|
54
|
+
- `emit(channel, ctx)` resolves the partition with the same `scope`, finds the connections of that partition and pushes a fresh snapshot to each — the loader re-executed with each connection's own principal. Connections of other partitions receive nothing. A channel with no subscribers is a no-op; a channel with no entry in `app/channels.ts` throws immediately, naming it.
|
|
55
|
+
- The snapshot **replaces** the module's loader data, so the component reading `data.value` re-renders on its own. No subscription code, no merge, no manual refresh.
|
|
56
|
+
|
|
57
|
+
## Two ways to emit
|
|
58
|
+
|
|
59
|
+
The emit has two modes, and the difference is *who knows the new value*.
|
|
60
|
+
|
|
61
|
+
**Invalidation** — `emit(channel, ctx)`. The producer knows that something changed, not what. The runtime re-executes the loader of every connection in the partition and pushes a fresh snapshot. This is the mode that keeps the producer decoupled from the shape of the data, and the one to reach for from a scheduler, a webhook handler that only got an id, or another user's request.
|
|
62
|
+
|
|
63
|
+
**Slice** — `emit(module, channel, ctx, slice)`. The producer already holds the new value (the webhook arrived with the payload, the job just computed the total). The slice travels to the connections of that (module, channel) pair in the partition exactly as it is, and **no loader runs**.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import * as Expenses from "../app/expenses/Expenses.js";
|
|
67
|
+
|
|
68
|
+
// Invalidation: Expenses.loader runs once per connection of the partition.
|
|
69
|
+
await emit("teamExpenses", { teamId: 7 });
|
|
70
|
+
|
|
71
|
+
// Slice: nothing runs; the connections merge this payload into their data.
|
|
72
|
+
await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The slice is typed as `Partial` of what the module's loader returns:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
export const loader = async ({ c }: LoaderArgs) => {
|
|
79
|
+
const user = c.get("user");
|
|
80
|
+
return {
|
|
81
|
+
teamTotal: await expensesService.teamTotal(user.teamId),
|
|
82
|
+
expenses: await expensesService.byTeam(user.teamId),
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// OK — known key, right type
|
|
87
|
+
await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
|
|
88
|
+
// tsc error — the key does not exist in the loader's return
|
|
89
|
+
await emit(Expenses, "teamExpenses", { teamId: 7 }, { total: 880 });
|
|
90
|
+
// tsc error — wrong value type
|
|
91
|
+
await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: "880" });
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Why does the module take part in the address? Because the same channel can be declared by a layout **and** by a page, and each has its own loader and its own data shape — a slice is the shape of one of them. The runtime delivers the slice only to the connections of the pair `(module, channel)`; `emit(channel, ctx)` by name keeps reaching every module that declares the channel.
|
|
95
|
+
|
|
96
|
+
## The merge
|
|
97
|
+
|
|
98
|
+
The client merges every slice into the module's current value, with rules short enough to memorize:
|
|
99
|
+
|
|
100
|
+
| Rule | Meaning |
|
|
101
|
+
|---|---|
|
|
102
|
+
| A snapshot **replaces**, a slice **merges** | The connect snapshot and the invalidation snapshot are the whole value; a slice is applied on top of the current one |
|
|
103
|
+
| The key sent is replaced **whole** | Sending `teamTotal` replaces the entire `teamTotal`; lists travel whole inside their key |
|
|
104
|
+
| Keys not sent stay **intact** | A slice with only `teamTotal` leaves `expenses` exactly as it was |
|
|
105
|
+
| An absent key **never removes** | Removal is explicit: re-send the key with the new whole list, without the item |
|
|
106
|
+
| The merge happens **on arrival** | Slices that arrive in the same frame are applied together before the next render; for the same key, the last one in arrival order wins |
|
|
107
|
+
| A malformed payload **keeps the previous value** | A slice that fails to parse never blanks the screen |
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
// Current data: { teamTotal: 4, expenses: ["mercado", "luz"] }
|
|
111
|
+
await emit("teamExpenses", { teamId: 7 }); // whole value
|
|
112
|
+
await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 9 });
|
|
113
|
+
// → { teamTotal: 9, expenses: ["mercado", "luz"] }
|
|
114
|
+
|
|
115
|
+
// Removing "luz" is re-sending the whole list without it
|
|
116
|
+
await emit(Expenses, "teamExpenses", { teamId: 7 }, { expenses: ["mercado"] });
|
|
117
|
+
// → { teamTotal: 9, expenses: ["mercado"] }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
There is no deep patch and no list diff: the semantics stop at one level, by design. The runtime merges blindly — it is JavaScript — so the shape safety comes from the emit typing; a key that does not exist in the loader's data only gets in through a cast, and that is a usage error, not a feature.
|
|
121
|
+
|
|
122
|
+
## Bursts: coalescing, log and revalidation
|
|
123
|
+
|
|
124
|
+
Three behaviours make the emit usable on a busy server.
|
|
125
|
+
|
|
126
|
+
**Coalescing.** Invalidation emits are folded per (channel, partition). The first opens a window, the emits inside it join, and closing the window fires **one** re-execution round per connection — the consolidated round is the effect of all of them, so no invalidation is lost. A job writing N rows in sequence therefore costs one round, not N.
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
// Default: 50ms window, in any registration without options.
|
|
130
|
+
// 0 turns it off (immediate, the behavior of the first slice).
|
|
131
|
+
registerChannels(channels, { coalesceMs: 0 });
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
A slice never waits for the window: it is a direct push, with no re-execution to amortize. Bursts of slices are absorbed by the merge accumulator on the client.
|
|
135
|
+
|
|
136
|
+
The window also decides what `await emit(…)` waits for. With the window off (`0`) the await resolves after the round was delivered; with a window open (the default `50ms`) it resolves as soon as the gesture is registered — the log line is already out and the delivery comes later. So never assert a screen update right after `await emit(…)` under the default window: await the arrival (`next()` / `nextEvent()`) or wait for the effect.
|
|
137
|
+
|
|
138
|
+
**Emit log.** Every emit, in either mode, writes one line the moment it happens — so an incident ("the screen did not update") has a starting point: who emitted, to which partition, how many connections were in it.
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
[velojs] emit kind=invalidate channel="teamExpenses" partition="team:7" connections=2 at=2026-09-30T12:00:00.000Z
|
|
142
|
+
[velojs] emit kind=slice channel="teamExpenses" partition="team:7" connections=2 at=2026-09-30T12:00:01.104Z
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`connections` is the size of the group **at the instant of the gesture** — before coalescing and before revalidation — and the consolidated delivery writes no second line. An emit whose `scope` resolves to nothing logs `partition=null connections=0`: reaching nobody is exactly what the log is for. The log is off with `registerChannels(channels, { logEmits: false })`.
|
|
146
|
+
|
|
147
|
+
**Partition revalidation.** Before delivering anything (a re-executed snapshot or a slice), the runtime re-derives each connection's partition by running the `scope` again with the principal captured at subscribe. A connection whose partition moved — or whose scope now returns `null` — is removed from the group and closed: the client sees the connection closed and `freshness` goes `"stale"`, no data of a group it left. A `scope` that **throws** on re-derivation is treated the same way — fail-closed, with the failure logged naming the channel — so a scope that talks to an external service must handle its own failures. Only the partition is revalidated; an expired session is covered by the normal request pipeline on the next access.
|
|
148
|
+
|
|
149
|
+
## Transport: heartbeat, idle timeout and the proxy
|
|
150
|
+
|
|
151
|
+
An SSE connection crosses infrastructure that was not built for it — proxies, load balancers, browsers with sleeping tabs. The channel answer is built for that traffic, and its transport has exactly two clocks:
|
|
152
|
+
|
|
153
|
+
- **Heartbeat** — an SSE comment (`: ping`) written through the write chain every `heartbeatMs`. It keeps proxies and browsers from buffering or closing an idle stream, and it is pure transport: it generates no event in the client and does not count as a delivery — it touches neither freshness nor idle. The default is `20000` (20s), the same `DEFAULT_HEARTBEAT_MS` the `stream_*` SSE surface uses — one transport ruler in the house. `0` disables it.
|
|
154
|
+
- **Idle timeout** — a connection with **no deliveries** (snapshot or slice) for `idleMs` is closed by the server, with the same closing gesture as the partition revalidation (close event waiting for the write chain, group cleanup). The heartbeat does **not** reset it: idle is about data, heartbeat is transport. A live but quiet page cycles by design — close, the `EventSource` reconnects by itself, and the snapshot on connect repairs the state — while an abandoned page's resources are freed for good. The default is `300000` (5 min) in any registration without options; `0` disables it.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
// Registration with explicit windows (app/server.tsx or wherever the app wires)
|
|
158
|
+
registerChannels(channels, { idleMs: 60000, heartbeatMs: 15000 });
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The SSE answer also carries `Cache-Control: no-store`: a proxy or the browser must never serve a cached channel snapshot — a stale snapshot is exactly the illusion the live loader exists to kill.
|
|
162
|
+
|
|
163
|
+
**Behind a reverse proxy (the house recipe, Caddy).** SSE needs the proxy to flush immediately and never buffer the response, or the page goes silent even with everything alive on both ends. In the Caddyfile, the app's `reverse_proxy` needs `flush_interval -1` — a negative value means "flush as soon as data arrives", which is what SSE requires; a positive interval would batch chunks and delay every snapshot. Caddy streams by default and probes dead upstreams on its own — the heartbeat then does its part every 20s, and the idle timeout closes what nobody is reading anymore. Another proxy in front (nginx, a cloud load balancer) follows the same two rules: no response buffering, and `X-Accel-Buffering: no` for nginx or the equivalent for the LB.
|
|
164
|
+
|
|
165
|
+
## Inspecting the live channels
|
|
166
|
+
|
|
167
|
+
When the incident happens, the server answers "what is alive?":
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// anywhere server-side (an action, a guarded endpoint of your own)
|
|
171
|
+
import { inspectChannels } from "@mauroandre/velojs/server";
|
|
172
|
+
|
|
173
|
+
const report = inspectChannels();
|
|
174
|
+
// {
|
|
175
|
+
// channels: [{
|
|
176
|
+
// channel: "teamExpenses",
|
|
177
|
+
// groups: [
|
|
178
|
+
// { moduleId: "expenses/Layout", partition: "team:7",
|
|
179
|
+
// connections: 1, lastDeliveryAt: "2026-09-30T12:00:00.000Z" },
|
|
180
|
+
// { moduleId: "expenses/Expenses", partition: "team:7",
|
|
181
|
+
// connections: 1, lastDeliveryAt: "2026-09-30T12:00:01.104Z" },
|
|
182
|
+
// ],
|
|
183
|
+
// connections: 2,
|
|
184
|
+
// }],
|
|
185
|
+
// openCoalesceWindows: [],
|
|
186
|
+
// totalConnections: 2,
|
|
187
|
+
// }
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Per channel, the groups are identified by the pair (moduleId, partition key) — in the incident you know which page/module holds every connection — each with its connection count and the timestamp of its last delivery; the coalescing windows still open; and the grand totals. `inspectChannels()` is always available: the app decides how (and with what guard) to expose it.
|
|
191
|
+
|
|
192
|
+
In development there is a ready-made lens: `GET /_channel-inspect` answers the same JSON in the browser. It exists **only in dev** — the map of channels and partitions is internal information, and it never ships in a production build.
|
|
193
|
+
|
|
194
|
+
## Freshness
|
|
195
|
+
|
|
196
|
+
`useLoader()` and `Loader()` expose `freshness` — a signal with three values:
|
|
197
|
+
|
|
198
|
+
| Value | Meaning |
|
|
199
|
+
|---|---|
|
|
200
|
+
| `"live"` | connected and delivering |
|
|
201
|
+
| `"stale"` | the connection closed, is reconnecting, or is **silent** — open past 60s with no delivery |
|
|
202
|
+
| `"error"` | the last loader re-execution failed (the server reported it) |
|
|
203
|
+
|
|
204
|
+
It is aggregated per page and is **data for CSS to react to** — the framework renders no JSX of its own:
|
|
205
|
+
|
|
206
|
+
```tsx
|
|
207
|
+
const { data, freshness } = useLoader();
|
|
208
|
+
return <h2 class={freshness.value}>Team: ${data.value?.teamTotal}</h2>;
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The same handles expose `freshnessByChannel` — a signal record keyed by the channel names the module declares, with the same three values per channel. A page with two channels, one fallen and one following, shows both states at once:
|
|
212
|
+
|
|
213
|
+
```tsx
|
|
214
|
+
const { freshness, freshnessByChannel } = useLoader();
|
|
215
|
+
// freshnessByChannel.value → { teamExpenses: "stale", precos: "live" }
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The aggregate folds the per-channel states by one rule: any channel in error errors the page; a silenced or closed channel counts as not-open — the page is stale only when nothing is open and delivering.
|
|
219
|
+
|
|
220
|
+
The **silence detector** is the client half of the idle timeout: a connection that stays without deliveries for `CHANNEL_SILENCE_MS` (60s, a runtime constant — not a configuration) is marked stale even though `EventSource` still reads `OPEN`. That is the tab in the background, the sleeping notebook, the proxy that stopped flushing without closing the connection. Any delivery — or a reconnect's snapshot — brings the channel back to `"live"`. The server's heartbeat comment generates no client event, so it cannot mask the silence. Never assert freshness in a test without advancing the fake timers through the window.
|
|
221
|
+
|
|
222
|
+
On a page with no `channels` the fields exist, the record is empty and the aggregate stays `"live"` for its whole life.
|
|
223
|
+
|
|
224
|
+
## The partition contract
|
|
225
|
+
|
|
226
|
+
On subscribe the `scope` receives `c.get("user")` — the house key, the same one the stream/socket channel resolvers use. A middleware that materializes the principal somewhere else should set it in `"user"` too:
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
const auth = async (c, next) => {
|
|
230
|
+
const user = await currentUser(c);
|
|
231
|
+
c.set("user", user); // the live loader reads this key
|
|
232
|
+
c.set("currentUser", user); // the app's own key, if it has one
|
|
233
|
+
await next();
|
|
234
|
+
};
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
- No authenticated principal → the scope receives `undefined`. A public channel ignores it (`() => "all"`); an authenticated one returns `null`, which denies the subscription with **403**.
|
|
238
|
+
- The partition **never** derives from the query string or from any client input. A client cannot choose which subject's data it receives.
|
|
239
|
+
- No scope at all → the channel is public (`() => "all"`).
|
|
240
|
+
|
|
241
|
+
## State or flow?
|
|
242
|
+
|
|
243
|
+
Two questions decide, and they are always the same two:
|
|
244
|
+
|
|
245
|
+
| Question | Answer | Tool |
|
|
246
|
+
|---|---|---|
|
|
247
|
+
| Is this a **value** or a **sequence**? | value — "what is it now?", replacement | `loader` + `channels` |
|
|
248
|
+
| | sequence — "what happened?", accumulation (chat, log, a metric series, one-off progress) | `stream_*` / `socket_*` |
|
|
249
|
+
| Does the change come from **someone else** or from **me**? | someone else, or a background job | `loader` + `channels` |
|
|
250
|
+
| | me, through my own action | `refetch()` after the action |
|
|
251
|
+
|
|
252
|
+
Do not turn on a channel where a post-action `refetch()` suffices.
|
|
253
|
+
|
|
254
|
+
## Guards
|
|
255
|
+
|
|
256
|
+
The failures that would otherwise be silent are explicit:
|
|
257
|
+
|
|
258
|
+
- `channels` in a module **without** a `loader`: error in the dev server and in the build — there is nothing to synchronize.
|
|
259
|
+
- A channel **without an entry** in `app/channels.ts`: error in the dev server and in the build, naming the channel.
|
|
260
|
+
- `emit()` on a channel with no entry in the map: throws immediately, naming it (a typo or a rename would otherwise be a no-op). `emit()` on a valid channel with no subscribers is a no-op, no error.
|
|
261
|
+
- `emit(module, channel, …)` on a module that does **not declare** that channel, or that has **no loader**: throws immediately, naming both. A slice that is not an object throws too.
|
|
262
|
+
- `velojs build --static` with a module declaring `channels`: the channels are **inert** — no server for SSE, so the client opens no connection, `freshness` stays `"live"` and the build warns, naming the module.
|
|
263
|
+
- A module outside the conventions (`.jsx`/`.js`, or outside `app/`) is the one genuinely silent line: no transform, so the channel is simply inert.
|
|
264
|
+
|
|
265
|
+
## Testing
|
|
266
|
+
|
|
267
|
+
`createTestApp({ channels })` wires the same map the app uses, and `app.channel(module, name)` opens a real connection — either with cookies or through `app.as(user)`, so the route's middlewares run for real. Snapshots and slices arrive through `next()`; no sleep, no retry, no timing assertion.
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
import { createTestApp } from "@mauroandre/velojs/testing";
|
|
271
|
+
import { emit } from "@mauroandre/velojs/server";
|
|
272
|
+
import { channels } from "../app/channels.js";
|
|
273
|
+
import * as Expenses from "../app/expenses/Expenses.js";
|
|
274
|
+
|
|
275
|
+
const app = await createTestApp({
|
|
276
|
+
routes,
|
|
277
|
+
channels,
|
|
278
|
+
getSessionCookie: async ({ user }) => ({ session: await sign(user) }),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const sub = await app.as({ user: { teamId: 7 } }).channel(Expenses, "teamExpenses");
|
|
282
|
+
expect(await sub.next({ timeoutMs: 1000 })).toEqual({ teamTotal: 4 }); // connect
|
|
283
|
+
|
|
284
|
+
await emit("teamExpenses", { teamId: 7 });
|
|
285
|
+
expect(await sub.next({ timeoutMs: 1000 })).toEqual({ teamTotal: 9 }); // emit
|
|
286
|
+
|
|
287
|
+
await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
|
|
288
|
+
expect(await sub.next({ timeoutMs: 1000 })).toEqual({ teamTotal: 880 }); // raw slice
|
|
289
|
+
|
|
290
|
+
expect(await sub.nextEvent({ timeoutMs: 1000 })).toEqual({ // discriminated
|
|
291
|
+
type: "slice",
|
|
292
|
+
data: { teamTotal: 880 },
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await sub.close();
|
|
296
|
+
await app.close();
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
The connection only exists after the connect snapshot, so awaiting the first `next()` before emitting is what makes the test deterministic. `next()` returns the payload exactly as it travelled on the wire (a whole snapshot or a raw slice) — the same contract as the first slice; `nextEvent()` returns the same arrivals discriminated as `{ type: "snapshot" | "slice", data }`, for the tests that care which mode delivered the update. Use one or the other: each walks its own cursor over the arrivals.
|
|
300
|
+
|
|
301
|
+
The toolkit registers the channels with the coalescing window off (`coalesceMs: 0`), so `await emit(…)` is the delivery. The same determinism holds for the transport clocks: `idleMs` and `heartbeatMs` default to `0` in the toolkit — no connection dies and no comment is written unless the test asks for it. A test that exercises any of the three passes it explicitly and drives it with fake timers. Note where the options go: `channels` is the map, and the registration options are a **sibling** key, `channelOptions`:
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
const app = await createTestApp({
|
|
305
|
+
routes,
|
|
306
|
+
channels, // the map (app/channels.ts)
|
|
307
|
+
channelOptions: { coalesceMs: 20, logEmits: false }, // the options
|
|
308
|
+
getSessionCookie,
|
|
309
|
+
});
|
|
310
|
+
await vi.advanceTimersByTimeAsync(20); // the window closes: one round
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## Reference
|
|
314
|
+
|
|
315
|
+
| Piece | Behavior |
|
|
316
|
+
|---|---|
|
|
317
|
+
| `export const channels = ["name"]` | Module opt-in: one internal route and one client connection per name |
|
|
318
|
+
| `app/channels.ts` | Channel name → `{ scope }`; imported by the framework |
|
|
319
|
+
| `scope(ctx)` | Returns the partition key; `null` denies with 403; omitted → `"all"`. Re-derived on every emit with the principal captured at subscribe |
|
|
320
|
+
| `emit(channel, ctx)` | Invalidation: re-executes the loader per connection of the partition and pushes snapshots |
|
|
321
|
+
| `emit(module, channel, ctx, slice)` | Slice: pushes the typed `Partial` to the connections of that (module, channel) pair — no re-execution |
|
|
322
|
+
| Merge | Shallow by key, on arrival; key sent replaced whole, absent key never removes, whole lists |
|
|
323
|
+
| `registerChannels(map, opts)` | `coalesceMs` (default 50, `0` off), `logEmits` (default on), `idleMs` (default 300000, `0` off) and `heartbeatMs` (default 20000, `0` off); in the toolkit the map stays in `channels` and these go in `channelOptions` — all four default `0` there |
|
|
324
|
+
| `freshness` | `"live"` / `"stale"` / `"error"`, aggregated per page |
|
|
325
|
+
| `freshnessByChannel` | Same states keyed by the module's channel names; empty record without `channels` |
|
|
326
|
+
| `CHANNEL_SILENCE_MS` | 60000 — client-side silence window; an open connection without deliveries past it is `"stale"` |
|
|
327
|
+
| `inspectChannels()` | Server-side live report: groups per (moduleId, partition) with connections and last delivery, open coalesce windows, totals |
|
|
328
|
+
| `GET /_channel-inspect` | Dev-only endpoint answering `inspectChannels()` as JSON; absent in production builds |
|
|
329
|
+
| `app.channel(module, name)` | Test subscription; `next()` (raw data) and `nextEvent()` (`{ type, data }`) |
|
|
@@ -79,6 +79,12 @@ By default `velojs start` binds to **all interfaces** (`::`) — the norm for No
|
|
|
79
79
|
2. The `hostname` field in `defineConfig`.
|
|
80
80
|
3. The Node default (all interfaces).
|
|
81
81
|
|
|
82
|
+
The same declaration covers the dev server, with one extra step at the top: `velojs dev --host <value>` (or a `server.host` in `vite.config.ts`) wins, then `HOST`, then `hostname`, then Vite's default (loopback). `HOST=0.0.0.0` in the shell or in the project's `.env` therefore lifts `velojs dev` beyond loopback with no flag by hand.
|
|
83
|
+
|
|
84
|
+
### Dev behind a reverse proxy: the Host check
|
|
85
|
+
|
|
86
|
+
When a dev server binds broad it sits behind the proxy (or is reached by hostname), and requests arrive carrying the proxy's domain in the `Host` header. Vite's DNS-rebinding protection answers `403 Blocked request. This host is not allowed.` for any host outside its allow list — so the framework opens it whenever the declared bind is broad (`0.0.0.0`, `::`, or `--host` with no value): any `Host` is served. That is a conscious trade-off — the guard exists for loopback development, and leaving loopback means giving it up. If the project declared `server.allowedHosts`, that list wins and stays restrictive even on a broad bind.
|
|
87
|
+
|
|
82
88
|
### Secure bind for local/sensitive apps
|
|
83
89
|
|
|
84
90
|
If your app is **local and single-user** — a cockpit with a terminal, file access, real user data — it must listen on loopback only. An all-interfaces bind exposes that surface to the whole network without any error: the developer finds out by scanning, not by the app telling them.
|
|
@@ -151,5 +151,6 @@ Callbacks registered with `onServer` queue until the server starts. If called af
|
|
|
151
151
|
| Variable | Default | Description |
|
|
152
152
|
|----------|---------|-------------|
|
|
153
153
|
| `PORT` | `3000` | The port the server listens on. Overrides `defineConfig`'s `port`. |
|
|
154
|
+
| `HOST` | — | The bind interface, in dev and production. Overrides `defineConfig`'s `hostname`; in dev an explicit `--host`/`server.host` still wins. |
|
|
154
155
|
| `NODE_ENV` | — | Set automatically by `velojs start` to `production` |
|
|
155
156
|
| `STATIC_BASE_URL` | `""` | CDN/bucket prefix for static assets |
|
|
@@ -138,5 +138,6 @@ interface VeloConfig {
|
|
|
138
138
|
serverInit?: string; // default: "server.tsx"
|
|
139
139
|
clientInit?: string; // default: "client.tsx"
|
|
140
140
|
port?: number; // default: 3000 (the PORT env always wins)
|
|
141
|
+
hostname?: string; // bind interface, dev and production; the HOST env wins
|
|
141
142
|
}
|
|
142
143
|
```
|
|
@@ -0,0 +1,289 @@
|
|
|
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: (ctx) => `team:${ctx.teamId}` },
|
|
15
|
+
* };
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* On subscribe the resolver receives the materialized principal (`c.get("user")`,
|
|
19
|
+
* the house key); without an authenticated principal it receives `undefined`, so
|
|
20
|
+
* a public channel ignores it (`() => "all"`) and an authenticated one returns
|
|
21
|
+
* `null` (which denies the subscription with 403). The partition NEVER derives
|
|
22
|
+
* from the query string or from any client input.
|
|
23
|
+
*
|
|
24
|
+
* `emit(channel, ctx)` resolves the same partition key on the producer side,
|
|
25
|
+
* finds the group of connections in that partition and re-executes the loader
|
|
26
|
+
* for each connection — with that connection's own principal — pushing a fresh
|
|
27
|
+
* snapshot down the wire. Emitting to a channel with no subscribers is a no-op;
|
|
28
|
+
* emitting to a channel with no entry in the app map throws immediately.
|
|
29
|
+
*
|
|
30
|
+
* `emit(module, channel, ctx, slice)` is the second mode, addressed to the
|
|
31
|
+
* (module, channel) pair: the producer already holds the value, so it travels
|
|
32
|
+
* as a slice — the connections of that pair in the emitting partition merge it
|
|
33
|
+
* shallowly by key, and no loader runs. The slice is typed as a `Partial` of
|
|
34
|
+
* the module's loader return, which is why the module is part of the address:
|
|
35
|
+
* one channel can be declared by modules with different `Data` (a layout and a
|
|
36
|
+
* page), and a slice is the shape of exactly one of them.
|
|
37
|
+
*
|
|
38
|
+
* Every emit logs one line (channel, partition, kind, connections at the
|
|
39
|
+
* moment of the gesture, timestamp) unless `logEmits: false` is registered,
|
|
40
|
+
* and invalidations are folded by a coalescing window per (channel, partition)
|
|
41
|
+
* (`coalesceMs`, default 50, `0` disables). Before delivering anything the
|
|
42
|
+
* runtime re-derives each connection's partition with the principal captured
|
|
43
|
+
* on subscribe: a connection that moved (or whose scope now returns null) is
|
|
44
|
+
* dropped and closed instead of receiving data of a group it left.
|
|
45
|
+
*/
|
|
46
|
+
import type { Context, Hono, MiddlewareHandler } from "hono";
|
|
47
|
+
import type { LoaderArgs, RouteModule } from "./types.js";
|
|
48
|
+
/** Result of a partition resolver. `null`/`undefined` denies the subscription. */
|
|
49
|
+
export type ChannelScopeResult = string | null | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* One entry of the app's channel map (`app/channels.ts`).
|
|
52
|
+
*
|
|
53
|
+
* `scope` receives the materialized principal on subscribe (`c.get("user")`)
|
|
54
|
+
* and the emitter's object on emit. Returning `null` denies with 403.
|
|
55
|
+
* Omitted → the channel is public (`() => "all"`).
|
|
56
|
+
*/
|
|
57
|
+
export interface ChannelDefinition {
|
|
58
|
+
scope?: (ctx: any) => ChannelScopeResult | Promise<ChannelScopeResult>;
|
|
59
|
+
}
|
|
60
|
+
/** The `app/channels.ts` map: channel name → partition resolver. */
|
|
61
|
+
export type ChannelMap = Record<string, ChannelDefinition>;
|
|
62
|
+
/** SSE event carrying the loader's current value — it replaces the module's data. */
|
|
63
|
+
export declare const SNAPSHOT_EVENT = "snapshot";
|
|
64
|
+
/** SSE event carrying a slice — the client merges it into the module's current value. */
|
|
65
|
+
export declare const SLICE_EVENT = "slice";
|
|
66
|
+
/** SSE event signaling that the loader re-execution failed on the server. */
|
|
67
|
+
export declare const CHANNEL_ERROR_EVENT = "error";
|
|
68
|
+
/** SSE event signaling a deliberate server-side close. */
|
|
69
|
+
export declare const CHANNEL_CLOSE_EVENT = "close";
|
|
70
|
+
/** Default coalescing window, in milliseconds. */
|
|
71
|
+
export declare const DEFAULT_COALESCE_MS = 50;
|
|
72
|
+
/**
|
|
73
|
+
* Default idle timeout of a channel connection, in milliseconds: a connection
|
|
74
|
+
* with no deliveries (snapshot or slice) for this long is closed by the
|
|
75
|
+
* server. The heartbeat does NOT reset it — idle is about data delivery,
|
|
76
|
+
* heartbeat is transport. The close makes a live page cycle
|
|
77
|
+
* (close → EventSource reconnect → snapshot on connect), which repairs the
|
|
78
|
+
* state, and frees an abandoned page's resources for good.
|
|
79
|
+
*/
|
|
80
|
+
export declare const DEFAULT_IDLE_MS = 300000;
|
|
81
|
+
/** The two modes of `emit`, as they appear in the emit log. */
|
|
82
|
+
export type ChannelEmitKind = "invalidate" | "slice";
|
|
83
|
+
/**
|
|
84
|
+
* `registerChannels` options.
|
|
85
|
+
*
|
|
86
|
+
* `coalesceMs` is the coalescing window of the invalidation mode, per
|
|
87
|
+
* (channel, partition): every emit inside an open window becomes one
|
|
88
|
+
* re-execution round, fired when the window closes. `0` disables it (immediate
|
|
89
|
+
* — the slice-1 behavior). The default is `DEFAULT_COALESCE_MS` in any
|
|
90
|
+
* registration without options; `createTestApp` registers with `0` for
|
|
91
|
+
* determinism.
|
|
92
|
+
*
|
|
93
|
+
* `logEmits` writes one line per emit gesture (never per consolidated
|
|
94
|
+
* delivery). Default: on.
|
|
95
|
+
*
|
|
96
|
+
* `idleMs` closes a connection that had no deliveries (snapshot or slice) for
|
|
97
|
+
* that long — a silent connection is closed even though its heartbeat keeps
|
|
98
|
+
* flowing; heartbeats never reset the idle clock. Default: `DEFAULT_IDLE_MS`
|
|
99
|
+
* (5 min) in any registration without options; `0` disables it.
|
|
100
|
+
* `createTestApp` registers with `0` for determinism.
|
|
101
|
+
*
|
|
102
|
+
* `heartbeatMs` writes an SSE comment (`: ping`) through the write chain every
|
|
103
|
+
* interval, keeping proxies and the browser from buffering or closing an idle
|
|
104
|
+
* stream. A comment generates no client event and never counts as a delivery
|
|
105
|
+
* (no effect on freshness or idle). Default: `DEFAULT_HEARTBEAT_MS` (20s) in
|
|
106
|
+
* any registration without options; `0` disables it.
|
|
107
|
+
*/
|
|
108
|
+
export interface ChannelRegistryOptions {
|
|
109
|
+
coalesceMs?: number;
|
|
110
|
+
logEmits?: boolean;
|
|
111
|
+
idleMs?: number;
|
|
112
|
+
heartbeatMs?: number;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* What `emit(module, channel, ctx, slice)` accepts: the imported route module
|
|
116
|
+
* (the same object the route tree uses — the Vite plugin has injected its
|
|
117
|
+
* `metadata`, and its `loader` types the slice).
|
|
118
|
+
*/
|
|
119
|
+
export interface ChannelEmitModule {
|
|
120
|
+
metadata?: {
|
|
121
|
+
moduleId?: string;
|
|
122
|
+
} | undefined;
|
|
123
|
+
channels?: readonly string[] | undefined;
|
|
124
|
+
loader?: ((args: LoaderArgs) => unknown) | undefined;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The slice type of a module: a `Partial` of whatever its loader returns.
|
|
128
|
+
* `Partial` (plus `exactOptionalPropertyTypes` on the app's tsconfig) is what
|
|
129
|
+
* makes a wrong key or a wrong value type a compile error instead of a silent
|
|
130
|
+
* no-op at runtime.
|
|
131
|
+
*/
|
|
132
|
+
export type ChannelSlice<M> = M extends {
|
|
133
|
+
loader?: (...args: any[]) => infer R;
|
|
134
|
+
} ? Partial<Awaited<R>> : never;
|
|
135
|
+
/**
|
|
136
|
+
* Registers (replaces) the app's channel map and its options. Idempotent.
|
|
137
|
+
*
|
|
138
|
+
* Called without options, the coalescing window is `DEFAULT_COALESCE_MS` — the
|
|
139
|
+
* window matters wherever emits burst (a job writing N rows), not only in
|
|
140
|
+
* production.
|
|
141
|
+
*/
|
|
142
|
+
export declare function registerChannels(map: ChannelMap | undefined, options?: ChannelRegistryOptions): void;
|
|
143
|
+
/** The currently registered channel map (read-only use). */
|
|
144
|
+
export declare function getChannelMap(): ChannelMap;
|
|
145
|
+
/** Channel names declared in the app map, in declaration order. */
|
|
146
|
+
export declare function channelNames(): string[];
|
|
147
|
+
/** One live channel connection (an SSE subscription). */
|
|
148
|
+
export interface ChannelConnection {
|
|
149
|
+
channel: string;
|
|
150
|
+
/** Partition key resolved at subscribe time — never from client input. */
|
|
151
|
+
partition: string;
|
|
152
|
+
moduleId: string;
|
|
153
|
+
/**
|
|
154
|
+
* The principal materialized at subscribe (`c.get("user")`), captured for
|
|
155
|
+
* the partition revalidation of every emit — the scope re-runs against it.
|
|
156
|
+
*/
|
|
157
|
+
principal: unknown;
|
|
158
|
+
loader: (args: LoaderArgs) => Promise<unknown>;
|
|
159
|
+
/** The subscribing request's context — the loader re-runs against it. */
|
|
160
|
+
c: Context;
|
|
161
|
+
params: Record<string, string>;
|
|
162
|
+
query: Record<string, string>;
|
|
163
|
+
/** Writes one SSE event on this connection. Resolves when it hits the wire. */
|
|
164
|
+
send: (event: string, data: string) => Promise<void>;
|
|
165
|
+
/**
|
|
166
|
+
* Ends this connection: a `close` event on the wire, then the group entry
|
|
167
|
+
* and the stream. Used when the connection's partition no longer holds —
|
|
168
|
+
* or when its idle timeout elapsed.
|
|
169
|
+
*/
|
|
170
|
+
terminate: (reason?: string) => Promise<void>;
|
|
171
|
+
/**
|
|
172
|
+
* Epoch ms of this connection's last delivery (snapshot or slice) — the
|
|
173
|
+
* clock the idle timeout runs on and what the inspector reports. Heartbeat
|
|
174
|
+
* comments never touch it.
|
|
175
|
+
*/
|
|
176
|
+
lastDeliveryAt?: number | undefined;
|
|
177
|
+
}
|
|
178
|
+
/** One live group of a channel: the connections of one module in one partition. */
|
|
179
|
+
export interface ChannelInspectGroup {
|
|
180
|
+
/** The route module holding these connections (`metadata.moduleId`). */
|
|
181
|
+
moduleId: string;
|
|
182
|
+
/** The partition key resolved at subscribe time. */
|
|
183
|
+
partition: string;
|
|
184
|
+
/** Live connections in this (module, partition) pair. */
|
|
185
|
+
connections: number;
|
|
186
|
+
/** ISO timestamp of the last delivery; `null` when none was delivered yet. */
|
|
187
|
+
lastDeliveryAt: string | null;
|
|
188
|
+
}
|
|
189
|
+
/** One live channel: its groups and its total. */
|
|
190
|
+
export interface ChannelInspectChannel {
|
|
191
|
+
channel: string;
|
|
192
|
+
/** Groups identified by (moduleId, partition) — which page holds what. */
|
|
193
|
+
groups: ChannelInspectGroup[];
|
|
194
|
+
connections: number;
|
|
195
|
+
}
|
|
196
|
+
/** One open coalescing window: the invalidations not yet delivered. */
|
|
197
|
+
export interface ChannelInspectWindow {
|
|
198
|
+
channel: string;
|
|
199
|
+
partition: string;
|
|
200
|
+
}
|
|
201
|
+
/** The full live state of the channel registry, as `inspectChannels()` sees it. */
|
|
202
|
+
export interface ChannelInspectReport {
|
|
203
|
+
channels: ChannelInspectChannel[];
|
|
204
|
+
/** Open coalescing windows, per (channel, partition). */
|
|
205
|
+
openCoalesceWindows: ChannelInspectWindow[];
|
|
206
|
+
totalConnections: number;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Server-side snapshot of the live channels: per channel, the groups
|
|
210
|
+
* identified by (moduleId, partition key), each with its connection count and
|
|
211
|
+
* the timestamp of its last delivery; the coalescing windows still open; and
|
|
212
|
+
* the grand totals. Always available — the app decides how (and with what
|
|
213
|
+
* guard) to expose it; the dev server exposes it as `GET /_channel-inspect`.
|
|
214
|
+
*/
|
|
215
|
+
export declare function inspectChannels(): ChannelInspectReport;
|
|
216
|
+
/** Test-only. Drops every live connection and closes every open window. */
|
|
217
|
+
export declare function __resetChannels(): void;
|
|
218
|
+
/**
|
|
219
|
+
* Executes the connection's loader with the connection's own principal and
|
|
220
|
+
* pushes the result as a snapshot. Per-connection execution (rather than one
|
|
221
|
+
* execution per partition) is deliberate: every subscriber always receives the
|
|
222
|
+
* snapshot computed with its own scope, which makes the homogeneous-partition
|
|
223
|
+
* rule unnecessary as a security invariant. What a burst of invalidations
|
|
224
|
+
* amortizes is the number of *rounds*, not the executions inside one — a single
|
|
225
|
+
* computation serving the whole partition is a later slice.
|
|
226
|
+
*
|
|
227
|
+
* A loader that throws reports the failure on the connection (`error` event)
|
|
228
|
+
* instead of dying silently; a loader that returns a `Response` (a redirect
|
|
229
|
+
* short-circuit) has no state to send.
|
|
230
|
+
*/
|
|
231
|
+
export declare function pushSnapshot(conn: ChannelConnection): Promise<void>;
|
|
232
|
+
/**
|
|
233
|
+
* Signals that a channel's partition changed: every connection in that
|
|
234
|
+
* partition receives a fresh snapshot (its loader re-executed with the
|
|
235
|
+
* connection's own principal). Connections in other partitions receive
|
|
236
|
+
* nothing; a channel with no subscribers is a no-op; a channel with no entry
|
|
237
|
+
* in the app map throws immediately, naming the channel.
|
|
238
|
+
*
|
|
239
|
+
* ```ts
|
|
240
|
+
* import { emit } from "@mauroandre/velojs/server";
|
|
241
|
+
* await emit("teamExpenses", { teamId: 7 });
|
|
242
|
+
* ```
|
|
243
|
+
*/
|
|
244
|
+
export declare function emit(channel: string, ctx?: unknown): Promise<void>;
|
|
245
|
+
/**
|
|
246
|
+
* Slice mode: pushes the value the producer already holds to the connections
|
|
247
|
+
* of the (module, channel) pair in the emitting partition — **without**
|
|
248
|
+
* re-executing the loader. The slice is typed as a `Partial` of what the
|
|
249
|
+
* module's loader returns, and the merge on arrival is shallow by key.
|
|
250
|
+
*
|
|
251
|
+
* ```ts
|
|
252
|
+
* import * as Expenses from "../app/expenses/Expenses.js";
|
|
253
|
+
* await emit(Expenses, "teamExpenses", { teamId: 7 }, { teamTotal: 880 });
|
|
254
|
+
* ```
|
|
255
|
+
*/
|
|
256
|
+
export declare function emit<M extends ChannelEmitModule>(module: M, channel: string, ctx: unknown, slice: ChannelSlice<M>): Promise<void>;
|
|
257
|
+
/**
|
|
258
|
+
* Serializes the SSE writes of one connection and never lets a failed write
|
|
259
|
+
* break the chain.
|
|
260
|
+
*
|
|
261
|
+
* A write can fail because the client went away mid-event (tab closed, proxy
|
|
262
|
+
* cut). The connection is cleaned up by the abort handler either way, but the
|
|
263
|
+
* failure is logged instead of swallowed: a snapshot lost in a dead write must
|
|
264
|
+
* not be indistinguishable from an emit that never happened.
|
|
265
|
+
*/
|
|
266
|
+
export declare function createChannelWriteChain(channel: string, write: (payload: ChannelWritePayload) => Promise<unknown>): (payload: ChannelWritePayload) => Promise<void>;
|
|
267
|
+
/**
|
|
268
|
+
* What the write chain carries: an SSE event (`{ event, data }`) or a raw
|
|
269
|
+
* chunk — the heartbeat is a comment (`": ping\n\n"`), which is no event at
|
|
270
|
+
* all: no client event fires for it and no delivery clock moves.
|
|
271
|
+
*/
|
|
272
|
+
export type ChannelWritePayload = {
|
|
273
|
+
event: string;
|
|
274
|
+
data: string;
|
|
275
|
+
} | string;
|
|
276
|
+
/**
|
|
277
|
+
* Registers the internal SSE route of one declared channel:
|
|
278
|
+
* `GET /_channel/{moduleId}/{channel}`, inheriting the route node's
|
|
279
|
+
* middlewares (auth policy is declared once per node).
|
|
280
|
+
*
|
|
281
|
+
* Guards are loud: a module that declares `channels` without a `loader`, or a
|
|
282
|
+
* channel with no entry in `app/channels.ts`, is an explicit error — never a
|
|
283
|
+
* silent inert route.
|
|
284
|
+
*
|
|
285
|
+
* The principal is captured on the connection for the partition revalidation
|
|
286
|
+
* of every emit, and `terminate` lets an emit close a connection whose
|
|
287
|
+
* partition no longer holds.
|
|
288
|
+
*/
|
|
289
|
+
export declare function registerChannelRoute(app: Hono, path: string, channel: string, module: RouteModule, moduleId: string, middlewares: MiddlewareHandler[]): void;
|