@mauroandre/velojs 0.0.36 → 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.
Files changed (32) hide show
  1. package/README.md +20 -10
  2. package/agent/AGENTS.md +143 -0
  3. package/agent/skills/velojs-actions/SKILL.md +192 -0
  4. package/agent/skills/velojs-components/SKILL.md +99 -0
  5. package/agent/skills/velojs-endpoints/SKILL.md +187 -0
  6. package/agent/skills/velojs-event-streams/SKILL.md +705 -0
  7. package/agent/skills/velojs-getting-started/SKILL.md +191 -0
  8. package/agent/skills/velojs-hooks/SKILL.md +103 -0
  9. package/agent/skills/velojs-link-component/SKILL.md +67 -0
  10. package/agent/skills/velojs-loaders/SKILL.md +134 -0
  11. package/agent/skills/velojs-middlewares/SKILL.md +143 -0
  12. package/agent/skills/velojs-production-deploy/SKILL.md +96 -0
  13. package/agent/skills/velojs-routes/SKILL.md +209 -0
  14. package/agent/skills/velojs-scripts-component/SKILL.md +62 -0
  15. package/agent/skills/velojs-server-api/SKILL.md +155 -0
  16. package/agent/skills/velojs-sockets/SKILL.md +303 -0
  17. package/agent/skills/velojs-static-generation/SKILL.md +148 -0
  18. package/agent/skills/velojs-testing/SKILL.md +472 -0
  19. package/agent/skills/velojs-type-reference/SKILL.md +142 -0
  20. package/agent/skills/velojs-vite-plugin/SKILL.md +71 -0
  21. package/dist/client.js +58 -47
  22. package/dist/components.js +18 -16
  23. package/dist/hooks.d.ts +3 -2
  24. package/dist/hooks.js +107 -110
  25. package/dist/loader-store.d.ts +49 -0
  26. package/dist/loader-store.js +103 -0
  27. package/dist/server.js +11 -11
  28. package/dist/testing/createTestApp.d.ts +1 -1
  29. package/dist/testing/index.d.ts +1 -1
  30. package/dist/testing/types.d.ts +7 -3
  31. package/dist/vite.js +276 -277
  32. 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.