@mauroandre/velojs 0.0.37 → 0.0.38
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 +19 -9
- package/agent/AGENTS.md +143 -0
- package/agent/skills/velojs-actions/SKILL.md +192 -0
- package/agent/skills/velojs-components/SKILL.md +99 -0
- package/agent/skills/velojs-endpoints/SKILL.md +187 -0
- package/agent/skills/velojs-event-streams/SKILL.md +705 -0
- package/agent/skills/velojs-getting-started/SKILL.md +191 -0
- package/agent/skills/velojs-hooks/SKILL.md +103 -0
- package/agent/skills/velojs-link-component/SKILL.md +67 -0
- package/agent/skills/velojs-loaders/SKILL.md +134 -0
- package/agent/skills/velojs-middlewares/SKILL.md +143 -0
- package/agent/skills/velojs-production-deploy/SKILL.md +96 -0
- package/agent/skills/velojs-routes/SKILL.md +209 -0
- package/agent/skills/velojs-scripts-component/SKILL.md +62 -0
- package/agent/skills/velojs-server-api/SKILL.md +155 -0
- package/agent/skills/velojs-sockets/SKILL.md +303 -0
- package/agent/skills/velojs-static-generation/SKILL.md +148 -0
- package/agent/skills/velojs-testing/SKILL.md +472 -0
- package/agent/skills/velojs-type-reference/SKILL.md +142 -0
- package/agent/skills/velojs-vite-plugin/SKILL.md +71 -0
- package/package.json +6 -2
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: velojs-event-streams
|
|
3
|
+
description: "Server-Sent Events via `createEventStream`/`stream_*` and `useEventStream`: live progress, logs, notifications, dashboards — one-way server-to-client realtime. Use when the server must push updates to the browser."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Event Streams
|
|
7
|
+
|
|
8
|
+
Event Streams are how you push data from the server to the client in real time using **Server-Sent Events (SSE)**. They cover live progress updates, notifications, metrics, log streaming, AI token generation, and any scenario where the server needs to notify clients of something happening.
|
|
9
|
+
|
|
10
|
+
VeloJS makes event streams as simple as declaring a function — the framework handles routing, type safety, listener management, snapshots, lifecycle, reconnection, and cleanup for you.
|
|
11
|
+
|
|
12
|
+
## Why Event Streams?
|
|
13
|
+
|
|
14
|
+
In a typical web app, the client asks the server for data. But sometimes you need the **server to tell the client** something happened — without polling. Three options:
|
|
15
|
+
|
|
16
|
+
- **Polling** — wasteful and laggy
|
|
17
|
+
- **WebSocket** — bidirectional, complex setup, proxy issues
|
|
18
|
+
- **Server-Sent Events (SSE)** — one-way over plain HTTP, auto-reconnect built-in, works through any proxy
|
|
19
|
+
|
|
20
|
+
VeloJS picks SSE because it covers ~90% of real-time use cases with minimal complexity. For bidirectional needs (rare — interactive terminals, live cursors), use the [`socket_*` convention](/docs/sockets).
|
|
21
|
+
|
|
22
|
+
## Channels by default
|
|
23
|
+
|
|
24
|
+
Streams are **channel-aware by default**. You don't need to configure anything — `useEventStream(stream, { channel })` on the client and `stream.emit(channel, value)` on the server just work together. The framework reads `?channel=...` from the URL automatically.
|
|
25
|
+
|
|
26
|
+
For streams that should send the same event to everyone (broadcast), opt in with `{ broadcast: true }`.
|
|
27
|
+
|
|
28
|
+
## A channel is untrusted input
|
|
29
|
+
|
|
30
|
+
`useEventStream(stream, { channel })` puts the channel on the query string, so **anyone can send any value**. A resolver that echoes it back subscribes the caller to whatever they ask for — including another customer's data:
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
// ⚠️ only safe when the route's middleware already restricts every subscriber
|
|
34
|
+
// (e.g. an admin-only page). Otherwise this is an IDOR.
|
|
35
|
+
channel: (c) => c.req.query("channel") ?? "",
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Resolve it through an ownership check instead. Returning `null` denies the connection with a 403:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// app/modules/app/app.stream.ts
|
|
42
|
+
export const ownAppChannel = async (c: Context): Promise<string | null> => {
|
|
43
|
+
const appId = c.req.query("channel");
|
|
44
|
+
if (!appId) return null;
|
|
45
|
+
|
|
46
|
+
const user = c.get("user"); // set by the route's middleware
|
|
47
|
+
if (!user?.id) return null;
|
|
48
|
+
|
|
49
|
+
// Dynamic import keeps the service out of the client bundle.
|
|
50
|
+
const { getApp } = await import("./app.service.js");
|
|
51
|
+
const app = await getApp({ id: appId });
|
|
52
|
+
if (!app) return null;
|
|
53
|
+
|
|
54
|
+
if (user.role !== "master" && app.ownerId !== user.id) return null;
|
|
55
|
+
return appId;
|
|
56
|
+
};
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
export const stream_deploy = createEventStream<DeployEvent>({
|
|
61
|
+
channel: ownAppChannel,
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A channel derived from the session needs no check, since the client cannot influence it:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
channel: (c) => c.get("user").id,
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
> `c.req.param()` does **not** work in a `stream_*` resolver. Convention streams are mounted at a static path — `/_event/{moduleId}/{name}` — so the page's `:params` are not in scope and `c.req.param()` is always empty. Send the value as `?channel=` and read it with `c.req.query("channel")`. (Params do work in a [standalone stream](#2-standalone-cross-cutting-streams) given an explicit `path` containing `:segments`.)
|
|
72
|
+
|
|
73
|
+
## The shortest example
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
// app/admin/Provision.tsx
|
|
77
|
+
import { createEventStream } from "@mauroandre/velojs";
|
|
78
|
+
import { useEventStream } from "@mauroandre/velojs/hooks";
|
|
79
|
+
|
|
80
|
+
export const stream_logs = createEventStream<string>();
|
|
81
|
+
|
|
82
|
+
export const Component = () => {
|
|
83
|
+
const { snapshot, data, closed } = useEventStream(stream_logs, {
|
|
84
|
+
channel: sessionId,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const allLines = [...(snapshot.value ?? []), ...(data.value ? [data.value] : [])];
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<pre>
|
|
91
|
+
{allLines.join("\n")}
|
|
92
|
+
{closed.value && "\n[done]"}
|
|
93
|
+
</pre>
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
// app/admin/provision.service.ts
|
|
100
|
+
import { stream_logs } from "./Provision.js";
|
|
101
|
+
|
|
102
|
+
export async function provision(sessionId: string) {
|
|
103
|
+
try {
|
|
104
|
+
stream_logs.emit(sessionId, "Connecting...", { snapshot: true });
|
|
105
|
+
// ... real work
|
|
106
|
+
stream_logs.emit(sessionId, "Worker ready.", { snapshot: true });
|
|
107
|
+
} catch (err) {
|
|
108
|
+
stream_logs.emit(sessionId, `Error: ${err.message}`, { snapshot: true });
|
|
109
|
+
} finally {
|
|
110
|
+
stream_logs.close(sessionId);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
That's it. One line to declare. Three verbs to use (`emit`, `close`, `useEventStream`). The framework handles:
|
|
116
|
+
- Route registration at `/_event/admin/Provision/logs`
|
|
117
|
+
- Channel routing per `sessionId`
|
|
118
|
+
- Internal buffer for `{ snapshot: true }` emits, sent to late subscribers
|
|
119
|
+
- Auto-close signaling
|
|
120
|
+
- Heartbeat to keep proxies happy
|
|
121
|
+
- Cleanup of buffer 5 minutes after `close()`
|
|
122
|
+
|
|
123
|
+
## Two ways to declare a stream
|
|
124
|
+
|
|
125
|
+
### 1. The `stream_*` convention (recommended for most cases)
|
|
126
|
+
|
|
127
|
+
Declare a stream as a named export starting with `stream_` in any page or layout module. The framework registers an SSE route at `/_event/{moduleId}/{name}` automatically.
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// app/admin/Deploy.tsx
|
|
131
|
+
export const stream_progress = createEventStream<DeployState>();
|
|
132
|
+
// → registered at /_event/admin/Deploy/progress
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
This is the same pattern as `loader` and `action_*`. Middlewares from parent route nodes are inherited automatically.
|
|
136
|
+
|
|
137
|
+
**When to use:** logically tied to a specific page or feature.
|
|
138
|
+
|
|
139
|
+
### 2. Standalone (cross-cutting streams)
|
|
140
|
+
|
|
141
|
+
Some streams don't belong to any single page — global metrics, app-wide notifications. Pass an explicit `path`:
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
// app/streams/metrics.ts
|
|
145
|
+
import { createEventStream } from "@mauroandre/velojs";
|
|
146
|
+
import { authMiddleware } from "../middlewares/auth.js";
|
|
147
|
+
|
|
148
|
+
export const containerMetrics = createEventStream<Metric[]>({
|
|
149
|
+
path: "/api/metrics/containers",
|
|
150
|
+
broadcast: true, // every subscriber gets every emit
|
|
151
|
+
middlewares: [authMiddleware], // explicit, since not in a route
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**When to use:** consumed by multiple unrelated pages, doesn't belong to one.
|
|
156
|
+
|
|
157
|
+
## Channel-aware vs broadcast
|
|
158
|
+
|
|
159
|
+
Streams come in two shapes:
|
|
160
|
+
|
|
161
|
+
**Channel-aware (default)** — events are addressed to a specific channel ID. Subscribers only receive events for their channel.
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
export const stream_deploy = createEventStream<DeployState>();
|
|
165
|
+
|
|
166
|
+
stream_deploy.emit("app-123", state); // only listeners with channel="app-123"
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
useEventStream(stream_deploy, { channel: appId }); // subscribes to that channel
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
This is the default because most real-time features are scoped to something — a deploy ID, a user ID, a session ID.
|
|
174
|
+
|
|
175
|
+
**Broadcast** — opt in with `{ broadcast: true }`. Every event reaches every subscriber. No channels.
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
export const stream_metrics = createEventStream<Metric[]>({
|
|
179
|
+
broadcast: true,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
stream_metrics.emit(currentMetrics); // all subscribers receive
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
useEventStream(stream_metrics); // no channel option
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Use broadcast for things like global metrics, system-wide notifications, or live counters that aren't tied to any specific entity.
|
|
190
|
+
|
|
191
|
+
## Three ways to emit events
|
|
192
|
+
|
|
193
|
+
### Reactive — call `emit()` from anywhere (most common)
|
|
194
|
+
|
|
195
|
+
The service does its work and calls `.emit()` whenever something happens:
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
import { stream_progress } from "./Deploy.js";
|
|
199
|
+
|
|
200
|
+
export async function runDeploy(id: string) {
|
|
201
|
+
stream_progress.emit(id, { step: "building", status: "running" }, { snapshot: true });
|
|
202
|
+
// ... build
|
|
203
|
+
stream_progress.emit(id, { step: "uploading", status: "running" }, { snapshot: true });
|
|
204
|
+
// ... upload
|
|
205
|
+
stream_progress.emit(id, { step: "done", status: "success" }, { snapshot: true });
|
|
206
|
+
stream_progress.close(id);
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
This is the right pattern when emission is **caused by application logic** — a deploy starts, a payment confirms, a backup finishes.
|
|
211
|
+
|
|
212
|
+
### Source-driven — let the framework call your producer
|
|
213
|
+
|
|
214
|
+
For streams that **continuously generate data** (polling metrics, listening to a global event bus), pass a `source` function. The framework only invokes it while there are active subscribers.
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
import { createEventStream, poll } from "@mauroandre/velojs";
|
|
218
|
+
|
|
219
|
+
export const stream_workerMetrics = createEventStream<WorkerMetrics[]>({
|
|
220
|
+
source: poll({
|
|
221
|
+
intervalMs: 3000,
|
|
222
|
+
tick: async (emit) => {
|
|
223
|
+
const { collectMetrics } = await import("./metrics.service.js");
|
|
224
|
+
emit(await collectMetrics());
|
|
225
|
+
},
|
|
226
|
+
}),
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
When the first user opens the metrics page, the polling starts. When the last user closes it, polling stops. **Zero CPU when nobody is watching.** No `setInterval` in your code, no manual wiring, no leaks.
|
|
231
|
+
|
|
232
|
+
For event-driven (non-polling) sources, write the function yourself with the `AbortSignal`:
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
export const stream_busEvents = createEventStream<BusEvent>({
|
|
236
|
+
source: async (emit, { abortSignal }) => {
|
|
237
|
+
const handler = (e: BusEvent) => emit(e);
|
|
238
|
+
bus.on("event", handler);
|
|
239
|
+
abortSignal.addEventListener("abort", () => bus.off("event", handler));
|
|
240
|
+
await new Promise((r) => abortSignal.addEventListener("abort", r));
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The `AbortSignal` fires when the last subscriber disconnects. Your code uses it to clean up listeners and exit gracefully.
|
|
246
|
+
|
|
247
|
+
> **Anti-pattern**: ignoring `abortSignal` keeps your producer running forever, leaking resources every time someone opens and closes the page. Only do this if the producer is truly meant to run independently of UI (rare).
|
|
248
|
+
|
|
249
|
+
### Stateful snapshot — for state-machine patterns
|
|
250
|
+
|
|
251
|
+
When **each emit is a complete state** (deploy progress, migration status), use the `snapshot` callback. It returns the current state to send on connect:
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
const activeDeploys = new Map<string, DeployState>();
|
|
255
|
+
|
|
256
|
+
export const stream_deploy = createEventStream<DeployState>({
|
|
257
|
+
snapshot: (channel) => activeDeploys.get(channel ?? ""),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// In service:
|
|
261
|
+
function updateDeploy(id: string, state: DeployState) {
|
|
262
|
+
activeDeploys.set(id, state);
|
|
263
|
+
stream_deploy.emit(id, state); // no { snapshot: true } needed
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Use this when the snapshot type is different from the event type, or when you only care about the **latest** state, not the full history.
|
|
268
|
+
|
|
269
|
+
## Configuration
|
|
270
|
+
|
|
271
|
+
`createEventStream` accepts these options — all optional:
|
|
272
|
+
|
|
273
|
+
| Option | Type | Description |
|
|
274
|
+
|--------|------|-------------|
|
|
275
|
+
| `path` | `string` | (Standalone only) Explicit URL path for the SSE endpoint |
|
|
276
|
+
| `broadcast` | `boolean` | If true, no channels — every emit goes to all subscribers. Default: false (channel-aware) |
|
|
277
|
+
| `channel` | `(c) => string \| null \| Promise<...>` | Resolve channel ID from request. Sync or async. Return `null`/`undefined` to reject (403). Default: `?channel=...` query |
|
|
278
|
+
| `snapshot` | `(channel) => TSnapshot` | Returns the current state on connect (state-machine pattern) |
|
|
279
|
+
| `closeOn` | `(event: TEvent) => boolean` | Closes the SSE connection when this returns true for an emitted event |
|
|
280
|
+
| `source` | `(emit, { abortSignal }) => Promise<void>` | Self-running producer that runs only while subscribed (stream-wide) |
|
|
281
|
+
| `perChannelSource` | `(channelKey, emit, { abortSignal }) => Promise<void>` | Per-channel producer. Mutually exclusive with `source` |
|
|
282
|
+
| `bufferSize` | `number` | Max events kept in the snapshot buffer per channel (FIFO). Default: `Infinity` |
|
|
283
|
+
| `retainMs` | `number` | Time (ms) the snapshot buffer is kept after `close()`. Default: `300000` (5 min) |
|
|
284
|
+
| `heartbeatMs` | `number \| false` | Keep-alive heartbeat interval. Default: `20000` (20s). `false` to disable |
|
|
285
|
+
| `middlewares` | `MiddlewareHandler[]` | (Standalone only) Hono middlewares applied to the SSE route |
|
|
286
|
+
|
|
287
|
+
## Snapshots in depth
|
|
288
|
+
|
|
289
|
+
There are **two snapshot mechanisms** for two different patterns:
|
|
290
|
+
|
|
291
|
+
### Pattern A — Append (use `{ snapshot: true }` per emit)
|
|
292
|
+
|
|
293
|
+
For logs, AI tokens, or anything where each emit is a small piece of a growing whole. The framework keeps a buffer per channel and sends it as a single snapshot to late subscribers.
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
export const stream_logs = createEventStream<string>();
|
|
297
|
+
|
|
298
|
+
stream_logs.emit("session-1", "Line 1", { snapshot: true });
|
|
299
|
+
stream_logs.emit("session-1", "Line 2", { snapshot: true });
|
|
300
|
+
// Late subscriber connects → receives ["Line 1", "Line 2"] as snapshot
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
On the client:
|
|
304
|
+
```typescript
|
|
305
|
+
const { snapshot, data } = useEventStream(stream_logs, { channel: sessionId });
|
|
306
|
+
// snapshot.value: string[] — all buffered lines
|
|
307
|
+
// data.value: string — most recent line received after connect
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Pattern B — Replace (use `snapshot: callback` config)
|
|
311
|
+
|
|
312
|
+
For state-machine patterns where **each emit is the complete current state**. Only the latest matters. The callback returns whatever your application considers "the current state".
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
const deploys = new Map<string, DeployState>();
|
|
316
|
+
|
|
317
|
+
export const stream_deploy = createEventStream<DeployState>({
|
|
318
|
+
snapshot: (id) => deploys.get(id ?? ""),
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Late subscriber connects → snapshot.value = the latest DeployState
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
On the client:
|
|
325
|
+
```typescript
|
|
326
|
+
const { snapshot, data } = useEventStream(stream_deploy, { channel: deployId });
|
|
327
|
+
// snapshot.value: DeployState — the latest snapshot returned by your callback
|
|
328
|
+
// data.value: DeployState — most recent emitted state
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Snapshots survive after close
|
|
332
|
+
|
|
333
|
+
When you call `stream.close(channel)`, the SSE connection closes for current subscribers but the **snapshot buffer persists for `retainMs`** (default 5 minutes). If a user refreshes the page right after a deploy fails, they still see "Deploy failed" — not a blank screen.
|
|
334
|
+
|
|
335
|
+
After `retainMs`, the buffer is cleared. Adjust `retainMs` if you need longer (or shorter) retention.
|
|
336
|
+
|
|
337
|
+
## Closing a stream
|
|
338
|
+
|
|
339
|
+
There are **two ways** to close, for different needs:
|
|
340
|
+
|
|
341
|
+
### `stream.close(channel?)` — imperative
|
|
342
|
+
|
|
343
|
+
Call this when **your application** decides the stream is done. Useful for finally-blocks, manual close after a workflow finishes:
|
|
344
|
+
|
|
345
|
+
```typescript
|
|
346
|
+
try {
|
|
347
|
+
await runDeploy(id);
|
|
348
|
+
} finally {
|
|
349
|
+
stream_deploy.close(id); // always close, success or fail
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
The current subscribers' `closed.value` becomes `true`. Subsequent `emit()` calls to that channel are ignored with a warning.
|
|
354
|
+
|
|
355
|
+
### `closeOn` — declarative
|
|
356
|
+
|
|
357
|
+
Configure a predicate. The framework closes automatically when an event matches:
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
export const stream_deploy = createEventStream<DeployState>({
|
|
361
|
+
closeOn: (state) => state.status === "success" || state.status === "error",
|
|
362
|
+
});
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Use this when the close decision is **a function of the event itself**, not external state.
|
|
366
|
+
|
|
367
|
+
You can use either, both, or neither. They compose cleanly.
|
|
368
|
+
|
|
369
|
+
## The `useEventStream` hook
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
const { data, snapshot, closed, error } = useEventStream(stream, options);
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
| Signal | Type | Description |
|
|
376
|
+
|--------|------|-------------|
|
|
377
|
+
| `data` | `Signal<TEvent \| null>` | Latest event received from the server |
|
|
378
|
+
| `snapshot` | `Signal<TSnapshot \| null>` | Initial state on connect (buffer or callback) |
|
|
379
|
+
| `closed` | `Signal<boolean>` | `true` when server closed the stream (via `close()` or `closeOn`) |
|
|
380
|
+
| `error` | `Signal<Error \| null>` | Set on parse or connection error |
|
|
381
|
+
|
|
382
|
+
Options:
|
|
383
|
+
```typescript
|
|
384
|
+
useEventStream(stream, {
|
|
385
|
+
channel: "app-123", // sent as ?channel=app-123
|
|
386
|
+
enabled: true, // skip the connection when false
|
|
387
|
+
});
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
The hook handles `EventSource` lifecycle automatically: opens on mount, closes on unmount, re-opens with a fresh connection when `channel` changes.
|
|
391
|
+
|
|
392
|
+
## Heartbeat
|
|
393
|
+
|
|
394
|
+
VeloJS sends a `:heartbeat` SSE comment every 20 seconds by default. Without this, idle connections are closed by Cloudflare (~100s), Nginx (~60s), and AWS ALB (~60s) — and the client doesn't even notice.
|
|
395
|
+
|
|
396
|
+
Configure if needed:
|
|
397
|
+
```typescript
|
|
398
|
+
createEventStream({ heartbeatMs: 10000 }); // every 10s
|
|
399
|
+
createEventStream({ heartbeatMs: false }); // disabled
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Heartbeats are invisible to your application — they just keep the pipe open.
|
|
403
|
+
|
|
404
|
+
## Per-channel sources (expensive resources per channel)
|
|
405
|
+
|
|
406
|
+
Sometimes each channel needs its **own** producer — like an SSH connection per `(worker, container)` for log streaming, or a separate DB cursor per channel. Use `perChannelSource`:
|
|
407
|
+
|
|
408
|
+
```typescript
|
|
409
|
+
export const stream_logs = createEventStream<string>({
|
|
410
|
+
channel: (c) => c.req.query("channel") ?? "",
|
|
411
|
+
perChannelSource: async (channelKey, emit, { abortSignal }) => {
|
|
412
|
+
const [worker, container] = channelKey.split(":");
|
|
413
|
+
const conn = await ssh.connect(worker);
|
|
414
|
+
const sshStream = conn.exec(`podman logs -f ${container}`);
|
|
415
|
+
|
|
416
|
+
sshStream.on("data", (d) => emit(d.toString(), { snapshot: true }));
|
|
417
|
+
abortSignal.addEventListener("abort", () => {
|
|
418
|
+
sshStream.close();
|
|
419
|
+
conn.end();
|
|
420
|
+
});
|
|
421
|
+
},
|
|
422
|
+
bufferSize: 500, // ring buffer for log replay
|
|
423
|
+
});
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
The framework guarantees:
|
|
427
|
+
- Invoked **once per channel**, when the first subscriber of that channel connects
|
|
428
|
+
- `emit` is **bound to the current channel** — pass only the value
|
|
429
|
+
- `abortSignal` fires when the **last subscriber of that channel** disconnects
|
|
430
|
+
- Re-invoked with a fresh signal if a new subscriber connects later
|
|
431
|
+
|
|
432
|
+
This means resources scale with **active channels**, not total channels. When nobody is watching a specific log, no SSH connection is open for it.
|
|
433
|
+
|
|
434
|
+
> Mutually exclusive with `source`. `source` is for stream-wide producers (e.g., a global metrics poll); `perChannelSource` is for per-channel producers.
|
|
435
|
+
|
|
436
|
+
## `pipe` — unified vocabulary {#pipe-unified-vocabulary}
|
|
437
|
+
|
|
438
|
+
`pipe` is a **newer per-channel producer** that shares the same vocabulary (`send / keepOpen / abortSignal / close`) used by [`socket_*` handlers](/docs/sockets). Prefer `pipe` for new code — it's a cleaner API and transfers directly to sockets when the same team later needs bidirectional flow.
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
export const stream_logs = createEventStream<string>({
|
|
442
|
+
channel: ownAppChannel,
|
|
443
|
+
pipe: async ({ send, keepOpen, abortSignal, c, params, channel }) => {
|
|
444
|
+
const user = c.get("user");
|
|
445
|
+
const conn = await ssh.connect(channel);
|
|
446
|
+
const sshStream = conn.exec(`podman logs -f ${channel}`);
|
|
447
|
+
|
|
448
|
+
sshStream.on("data", (line) => send(line.toString(), { snapshot: true }));
|
|
449
|
+
abortSignal.addEventListener("abort", () => {
|
|
450
|
+
sshStream.close();
|
|
451
|
+
conn.end();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
keepOpen();
|
|
455
|
+
},
|
|
456
|
+
bufferSize: 500,
|
|
457
|
+
});
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Semantics mirror `perChannelSource`:
|
|
461
|
+
|
|
462
|
+
- Invoked once per channel, when the first subscriber connects.
|
|
463
|
+
- `abortSignal` fires when the last subscriber of that channel disconnects.
|
|
464
|
+
- `c`, `params`, `query` come from the **first** subscriber's request (subsequent subscribers share the same pipe).
|
|
465
|
+
- `channel` is the resolved channel key (empty string for broadcast streams).
|
|
466
|
+
|
|
467
|
+
### `keepOpen` and auto-close
|
|
468
|
+
|
|
469
|
+
If `pipe` returns without calling `keepOpen()`, the framework calls `stream.close(channel)` automatically — so one-shot producers don't leak channel state:
|
|
470
|
+
|
|
471
|
+
```typescript
|
|
472
|
+
pipe: async ({ send }) => {
|
|
473
|
+
send(await fetchSnapshot()); // one message
|
|
474
|
+
// no keepOpen → channel auto-closes
|
|
475
|
+
},
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
For long-lived producers (the common case), call `keepOpen()`.
|
|
479
|
+
|
|
480
|
+
### Coexists with external `stream.emit`
|
|
481
|
+
|
|
482
|
+
`pipe` is the **internal** form of production. External services can still call `stream.emit(channel, value)` — both paths reach the same subscribers:
|
|
483
|
+
|
|
484
|
+
```typescript
|
|
485
|
+
// inside pipe
|
|
486
|
+
send("tick");
|
|
487
|
+
|
|
488
|
+
// from a service elsewhere
|
|
489
|
+
stream_logs.emit("app-42", "notice: restarted", { snapshot: true });
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
### Mutual exclusion
|
|
493
|
+
|
|
494
|
+
`pipe`, `source`, and `perChannelSource` are **mutually exclusive** — pick one. Combining them throws at `createEventStream` time.
|
|
495
|
+
|
|
496
|
+
`perChannelSource` continues to work and isn't deprecated yet — migrate at your pace, or use `perChannelSource` when the `channelKey` argument is more ergonomic than `channel` on the args object.
|
|
497
|
+
|
|
498
|
+
## Async channel resolver (auth + ownership in one place)
|
|
499
|
+
|
|
500
|
+
The `channel` resolver can be **async** and return `null`/`undefined` to reject the connection. This lets you combine channel extraction with authorization in a single function:
|
|
501
|
+
|
|
502
|
+
```typescript
|
|
503
|
+
export const stream_appLogs = createEventStream<string>({
|
|
504
|
+
channel: async (c) => {
|
|
505
|
+
const user = c.get("user");
|
|
506
|
+
const appId = c.req.query("channel");
|
|
507
|
+
const app = await getApp({ id: appId });
|
|
508
|
+
|
|
509
|
+
if (app?.owner !== user.id) return null; // → framework responds 403
|
|
510
|
+
return appId;
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
- Returns a string → connection proceeds with that channel
|
|
516
|
+
- Returns `null` or `undefined` → server responds **403 Forbidden**
|
|
517
|
+
- Throws → server responds **500 Internal Server Error** (logged)
|
|
518
|
+
|
|
519
|
+
Use this instead of composite channels like `${userId}:${appId}` when you want to keep the public channel ID clean.
|
|
520
|
+
|
|
521
|
+
## Buffer size limits
|
|
522
|
+
|
|
523
|
+
For long-running log streams, the snapshot buffer can grow without bound. Use `bufferSize` to cap it (FIFO ring — oldest entries dropped first):
|
|
524
|
+
|
|
525
|
+
```typescript
|
|
526
|
+
export const stream_logs = createEventStream<string>({
|
|
527
|
+
channel: (c) => c.req.query("channel") ?? "",
|
|
528
|
+
bufferSize: 500, // keep only the last 500 lines
|
|
529
|
+
});
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
- Applies **per channel** (each channel has its own ring)
|
|
533
|
+
- Only affects emits with `{ snapshot: true }` — ephemeral emits aren't counted
|
|
534
|
+
- Default: `Infinity` (existing behavior)
|
|
535
|
+
|
|
536
|
+
Snapshot replay on reconnect sends whatever is currently in the buffer.
|
|
537
|
+
|
|
538
|
+
## Authentication and middlewares
|
|
539
|
+
|
|
540
|
+
### Convention `stream_*`
|
|
541
|
+
|
|
542
|
+
Streams declared with the `stream_*` convention **inherit middlewares** from parent route nodes:
|
|
543
|
+
|
|
544
|
+
```typescript
|
|
545
|
+
// app/routes.tsx
|
|
546
|
+
{
|
|
547
|
+
module: AdminLayout,
|
|
548
|
+
middlewares: [authMiddleware],
|
|
549
|
+
children: [
|
|
550
|
+
{ path: "/deploy/:id", module: Deploy }, // stream_progress here is protected
|
|
551
|
+
],
|
|
552
|
+
}
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
No extra wiring. The `stream_progress` declared in `Deploy.tsx` is automatically protected by `authMiddleware`. Unauthenticated clients get 401 before the SSE connection even opens.
|
|
556
|
+
|
|
557
|
+
### Standalone
|
|
558
|
+
|
|
559
|
+
Standalone streams aren't part of the route tree. Pass middlewares directly:
|
|
560
|
+
|
|
561
|
+
```typescript
|
|
562
|
+
export const containerMetrics = createEventStream<Metric[]>({
|
|
563
|
+
path: "/api/metrics/containers",
|
|
564
|
+
middlewares: [authMiddleware], // ← required
|
|
565
|
+
});
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
If the middleware short-circuits with `c.json(..., 401)`, the SSE never starts.
|
|
569
|
+
|
|
570
|
+
## Real-world patterns
|
|
571
|
+
|
|
572
|
+
### Pattern 1 — Replicated state machine
|
|
573
|
+
|
|
574
|
+
Server keeps the authoritative state. Each emit is the complete state.
|
|
575
|
+
|
|
576
|
+
```typescript
|
|
577
|
+
const deploys = new Map<string, DeployState>();
|
|
578
|
+
|
|
579
|
+
export const stream_deploy = createEventStream<DeployState>({
|
|
580
|
+
snapshot: (id) => deploys.get(id ?? ""),
|
|
581
|
+
closeOn: (s) => s.status === "success" || s.status === "error",
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
function updateDeploy(id: string, state: DeployState) {
|
|
585
|
+
deploys.set(id, state);
|
|
586
|
+
stream_deploy.emit(id, state);
|
|
587
|
+
}
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
Use cases: deploy progress, migration status, provisioning workflows.
|
|
591
|
+
|
|
592
|
+
### Pattern 2 — Append-only log
|
|
593
|
+
|
|
594
|
+
Each event is a delta. Snapshot is the accumulated buffer.
|
|
595
|
+
|
|
596
|
+
```typescript
|
|
597
|
+
export const stream_logs = createEventStream<string>();
|
|
598
|
+
|
|
599
|
+
function appendLog(sessionId: string, line: string) {
|
|
600
|
+
stream_logs.emit(sessionId, line, { snapshot: true });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function finishSession(sessionId: string) {
|
|
604
|
+
stream_logs.close(sessionId);
|
|
605
|
+
}
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
Use cases: bootstrap logs, AI token streaming, container logs.
|
|
609
|
+
|
|
610
|
+
### Pattern 3 — Polling broadcast
|
|
611
|
+
|
|
612
|
+
`broadcast: true` + framework-driven `source`. Producer only runs while subscribed.
|
|
613
|
+
|
|
614
|
+
```typescript
|
|
615
|
+
import { poll } from "@mauroandre/velojs";
|
|
616
|
+
|
|
617
|
+
export const stream_metrics = createEventStream<Metric[]>({
|
|
618
|
+
broadcast: true,
|
|
619
|
+
source: poll({
|
|
620
|
+
intervalMs: 3000,
|
|
621
|
+
tick: async (emit) => emit(await collectMetrics()),
|
|
622
|
+
}),
|
|
623
|
+
});
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
Use cases: live metrics, presence indicators.
|
|
627
|
+
|
|
628
|
+
### Pattern 5 — Per-channel resource (SSH log tail, DB cursor, pub/sub topic)
|
|
629
|
+
|
|
630
|
+
Each channel owns an expensive resource. Open on first subscriber, close on last.
|
|
631
|
+
|
|
632
|
+
```typescript
|
|
633
|
+
export const stream_containerLogs = createEventStream<string>({
|
|
634
|
+
channel: (c) => c.req.query("channel") ?? "",
|
|
635
|
+
bufferSize: 500,
|
|
636
|
+
perChannelSource: async (key, emit, { abortSignal }) => {
|
|
637
|
+
const [worker, container] = key.split(":");
|
|
638
|
+
const conn = await ssh.connect(worker);
|
|
639
|
+
const stream = conn.exec(`podman logs -f --tail 100 ${container}`);
|
|
640
|
+
stream.on("data", (d) => emit(d.toString(), { snapshot: true }));
|
|
641
|
+
abortSignal.addEventListener("abort", () => { stream.close(); conn.end(); });
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
Use cases: container logs, journalctl tail, per-resource metrics, Redis pub/sub by topic.
|
|
647
|
+
|
|
648
|
+
### Pattern 4 — Event-driven broadcast
|
|
649
|
+
|
|
650
|
+
Listen to a global bus, emit when something happens.
|
|
651
|
+
|
|
652
|
+
```typescript
|
|
653
|
+
export const stream_notifications = createEventStream<Notification>({
|
|
654
|
+
broadcast: true,
|
|
655
|
+
source: async (emit, { abortSignal }) => {
|
|
656
|
+
const handler = (n: Notification) => emit(n);
|
|
657
|
+
notificationBus.on("new", handler);
|
|
658
|
+
abortSignal.addEventListener("abort", () => notificationBus.off("new", handler));
|
|
659
|
+
await new Promise((r) => abortSignal.addEventListener("abort", r));
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
Use cases: cross-tab notifications, system-wide events.
|
|
665
|
+
|
|
666
|
+
## Frequently asked questions
|
|
667
|
+
|
|
668
|
+
### When does the source function run?
|
|
669
|
+
|
|
670
|
+
Only while there is at least one active SSE subscriber across any channel. As soon as the last subscriber disconnects, the `AbortSignal` fires. As soon as a new subscriber connects, the source is invoked fresh with a new signal.
|
|
671
|
+
|
|
672
|
+
This means **zero CPU when nobody is watching**. You don't need to start/stop your polling job manually.
|
|
673
|
+
|
|
674
|
+
### Can I emit before any subscribers exist?
|
|
675
|
+
|
|
676
|
+
Yes — emits with `{ snapshot: true }` go to the buffer and reach the next subscriber. Emits without that flag are dropped (no listeners, nowhere to deliver). This is intentional: ephemeral events are for current observers only.
|
|
677
|
+
|
|
678
|
+
### What happens if I call `emit` after `close()`?
|
|
679
|
+
|
|
680
|
+
The emit is ignored and a warning is logged. After `retainMs`, the channel becomes "fresh" again — you can use the same channel ID for a new session.
|
|
681
|
+
|
|
682
|
+
### Can I have multiple streams in the same module?
|
|
683
|
+
|
|
684
|
+
Yes. Each `stream_*` export is its own SSE route:
|
|
685
|
+
```typescript
|
|
686
|
+
export const stream_progress = createEventStream<DeployState>();
|
|
687
|
+
export const stream_logs = createEventStream<string>();
|
|
688
|
+
// → /_event/.../progress and /_event/.../logs
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
### Does this work without `useEventStream` (raw EventSource)?
|
|
692
|
+
|
|
693
|
+
Yes. The hook is just a convenience. You can construct the URL (`stream.__path` plus `?channel=...`) and use `new EventSource(url)`. The framework still handles routing and emission.
|
|
694
|
+
|
|
695
|
+
### What about WebSocket?
|
|
696
|
+
|
|
697
|
+
Use [Sockets](/docs/sockets). VeloJS ships a first-class `socket_*` convention with the same middleware inheritance and testing story as streams. Reach for it when the client must **send** as well as receive — interactive terminals, collaborative editing. For server-to-client push (the ~90% case) SSE stays simpler, so prefer a stream. `onServer` remains available if you need to attach a WebSocket library yourself, but it's no longer the recommended path.
|
|
698
|
+
|
|
699
|
+
### How many concurrent subscribers can the server handle?
|
|
700
|
+
|
|
701
|
+
Modern Node.js handles thousands of concurrent SSE connections per process. If you need more, scale horizontally — VeloJS state is per-process (the listener Map and buffers don't sync across instances).
|
|
702
|
+
|
|
703
|
+
### What happens during deploys when the server restarts?
|
|
704
|
+
|
|
705
|
+
The browser's `EventSource` auto-reconnects. With snapshots configured (callback or buffer), the client immediately sees the current state on reconnect, so the user experience is nearly seamless.
|