@mauroandre/velojs 0.0.19 → 0.0.21

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 CHANGED
@@ -536,57 +536,91 @@ The client-side transform rewrites the import to fetch `/_action/admin/Layout/lo
536
536
 
537
537
  ## Event Streams
538
538
 
539
- Push real-time updates from server to client via Server-Sent Events (SSE). Useful for live progress, notifications, metrics, log streaming, AI tokens, and any server → client push scenario.
539
+ Push real-time updates from server to client via Server-Sent Events (SSE). Live progress, notifications, metrics, log streaming, AI tokens — anything server → client.
540
540
 
541
- ### `stream_*` convention
541
+ Three verbs: `emit`, `close`, `useEventStream`. The framework handles routing, types, listener management, snapshots, lifecycle, reconnection, and cleanup.
542
542
 
543
- Declare a stream alongside `loader` / `action_*` in any page or layout module. The framework registers an SSE route at `/_event/{moduleId}/{name}` automatically:
543
+ ### Shortest example
544
544
 
545
545
  ```typescript
546
- // app/admin/Deploy.tsx
546
+ // app/admin/Provision.tsx
547
547
  import { createEventStream } from "@mauroandre/velojs";
548
- import { useEventStream, useParams } from "@mauroandre/velojs/hooks";
548
+ import { useEventStream } from "@mauroandre/velojs/hooks";
549
549
 
550
- type DeployState = { step: string; status: "running" | "success" | "error" };
551
-
552
- const activeDeploys = new Map<string, DeployState>();
553
-
554
- export const stream_progress = createEventStream<DeployState>({
555
- channel: (c) => c.req.query("channel") ?? "",
556
- snapshot: (channel) => activeDeploys.get(channel ?? ""),
557
- closeOn: (s) => s.status === "success" || s.status === "error",
558
- });
550
+ export const stream_logs = createEventStream<string>();
559
551
 
560
552
  export const Component = () => {
561
- const params = useParams<{ id: string }>();
562
- const { data, snapshot, closed } = useEventStream(stream_progress, {
563
- channel: params.id,
564
- });
565
-
566
- const state = data.value ?? snapshot.value;
567
- return <div>{state?.step ?? "waiting..."}</div>;
553
+ const { snapshot, data, closed } = useEventStream(stream_logs, { channel: sessionId });
554
+ const lines = [...(snapshot.value ?? []), ...(data.value ? [data.value] : [])];
555
+ return <pre>{lines.join("\n")}{closed.value && "\n[done]"}</pre>;
568
556
  };
569
557
  ```
570
558
 
571
- Emit from any service that imports the stream:
559
+ ```typescript
560
+ // app/admin/provision.service.ts
561
+ import { stream_logs } from "./Provision.js";
562
+
563
+ export async function provision(sessionId: string) {
564
+ try {
565
+ stream_logs.emit(sessionId, "Connecting...", { snapshot: true });
566
+ // ... real work
567
+ stream_logs.emit(sessionId, "Worker ready.", { snapshot: true });
568
+ } finally {
569
+ stream_logs.close(sessionId);
570
+ }
571
+ }
572
+ ```
573
+
574
+ One line to declare, three verbs to use. Route at `/_event/admin/Provision/logs` is registered automatically. Middlewares inherited from parent route nodes.
575
+
576
+ ### Two ways to declare
577
+
578
+ **Convention `stream_*`** (recommended) — for streams logically tied to a page/layout. Path derived from module ID, middlewares inherited.
572
579
 
573
580
  ```typescript
574
- import { stream_progress } from "./Deploy.js";
581
+ export const stream_progress = createEventStream<DeployState>();
582
+ ```
583
+
584
+ **Standalone** — for cross-cutting streams (global metrics, notifications). Pass `path` and (if needed) `middlewares` explicitly. Use `broadcast: true` for streams without channels.
575
585
 
576
- stream_progress.emit("app-123", { step: "building", status: "running" });
586
+ ```typescript
587
+ export const containerMetrics = createEventStream<Metric[]>({
588
+ path: "/api/metrics/containers",
589
+ broadcast: true,
590
+ middlewares: [authMiddleware],
591
+ });
577
592
  ```
578
593
 
579
- ### Standalone streams
594
+ ### Three ways to emit
580
595
 
581
- For cross-cutting streams (global metrics, notifications) consumed by many pages, use an explicit `path`:
596
+ **Reactive** — call `emit()` from anywhere when something happens. Most common:
582
597
 
583
598
  ```typescript
584
- // app/streams/metrics.ts
585
- export const metricsStream = createEventStream<Metric[]>({
586
- path: "/api/metrics/containers",
599
+ stream_logs.emit(sessionId, "Line", { snapshot: true });
600
+ ```
601
+
602
+ **Source-driven** — pass a `source` function. Framework runs it only while subscribed (zero CPU when nobody is watching):
603
+
604
+ ```typescript
605
+ import { poll } from "@mauroandre/velojs";
606
+
607
+ export const stream_metrics = createEventStream<Metric[]>({
608
+ broadcast: true,
609
+ source: poll({
610
+ intervalMs: 3000,
611
+ tick: async (emit) => emit(await collectMetrics()),
612
+ }),
587
613
  });
614
+ ```
588
615
 
589
- setInterval(() => metricsStream.emit(collectMetrics()), 2000);
616
+ **Stateful snapshot** — for state-machine patterns where each emit is the complete current state:
617
+
618
+ ```typescript
619
+ const deploys = new Map<string, DeployState>();
620
+
621
+ export const stream_deploy = createEventStream<DeployState>({
622
+ snapshot: (id) => deploys.get(id ?? ""),
623
+ });
590
624
  ```
591
625
 
592
626
  ### Configuration
@@ -594,25 +628,50 @@ setInterval(() => metricsStream.emit(collectMetrics()), 2000);
594
628
  | Option | Type | Description |
595
629
  |--------|------|-------------|
596
630
  | `path` | `string` | (Standalone) Explicit URL path |
597
- | `channel` | `(c: Context) => string` | Extract channel ID from request — subscribers only get matching events |
598
- | `snapshot` | `(channel) => TSnapshot \| undefined` | Returns current state on connect (and reconnect) |
599
- | `closeOn` | `(event: TEvent) => boolean` | Closes the SSE connection when matching event is sent |
600
- | `heartbeatMs` | `number \| false` | Keep-alive heartbeat interval, default `20000` (20s). `false` to disable |
601
- | `middlewares` | `MiddlewareHandler[]` | (Standalone) Hono middlewares applied to the SSE route |
631
+ | `broadcast` | `boolean` | If true, every emit goes to all subscribers (no channels). Default: false |
632
+ | `channel` | `(c) => string` | Extract channel ID. Default: `?channel=...` query param. Ignored when `broadcast: true` |
633
+ | `snapshot` | `(channel) => TSnapshot` | Returns current state on connect (state-machine pattern) |
634
+ | `closeOn` | `(event) => boolean` | Closes SSE when matching event is sent (declarative) |
635
+ | `source` | `(emit, { abortSignal }) => Promise<void>` | Self-running producer, only runs while subscribed |
636
+ | `retainMs` | `number` | Buffer retention after `close()`. Default `300000` (5 min) |
637
+ | `heartbeatMs` | `number \| false` | Heartbeat interval. Default `20000` (20s). `false` to disable |
638
+ | `middlewares` | `MiddlewareHandler[]` | (Standalone) Hono middlewares for the SSE route |
602
639
 
603
- ### Middleware inheritance
640
+ ### Snapshot mechanisms
604
641
 
605
- Streams declared with `stream_*` automatically inherit middlewares from parent route nodes — same as pages and actions. A stream under an `AdminLayout` with `authMiddleware` is protected without extra wiring.
642
+ Two snapshot patterns for two cases:
606
643
 
607
- For standalone streams, pass middlewares explicitly:
644
+ **Per-emit `{ snapshot: true }` — append pattern.** Framework keeps a buffer per channel. Late subscribers receive the array.
608
645
 
609
646
  ```typescript
610
- export const metricsStream = createEventStream<Metric[]>({
611
- path: "/api/metrics",
612
- middlewares: [authMiddleware],
613
- });
647
+ stream_logs.emit(id, "line 1", { snapshot: true });
648
+ stream_logs.emit(id, "line 2", { snapshot: true });
649
+ // Late subscriber → snapshot.value = ["line 1", "line 2"]
614
650
  ```
615
651
 
652
+ **`snapshot: callback` config — replace pattern.** You return the latest state from your own data structure.
653
+
654
+ ```typescript
655
+ createEventStream({ snapshot: (id) => deploys.get(id ?? "") });
656
+ // Late subscriber → snapshot.value = the latest DeployState
657
+ ```
658
+
659
+ Both survive after `close()` for `retainMs` (default 5 min) so refresh-after-finish still shows final state.
660
+
661
+ ### Closing
662
+
663
+ Two ways, choose either or both:
664
+
665
+ ```typescript
666
+ // Imperative — call from anywhere
667
+ stream.close(channelId);
668
+
669
+ // Declarative — predicate on event
670
+ createEventStream({ closeOn: (s) => s.status === "success" });
671
+ ```
672
+
673
+ After close, subsequent `emit()` to that channel is ignored with a warning.
674
+
616
675
  ### `useEventStream` hook
617
676
 
618
677
  ```typescript
@@ -624,12 +683,20 @@ const { data, snapshot, closed, error } = useEventStream(stream, {
624
683
 
625
684
  | Signal | Description |
626
685
  |--------|-------------|
627
- | `data` | Latest event received from the server |
628
- | `snapshot` | Initial state on connect (if `snapshot` is configured) |
629
- | `closed` | `true` when server closed the stream (`closeOn` matched) |
686
+ | `data` | Latest event received |
687
+ | `snapshot` | Initial state on connect (buffer or callback) |
688
+ | `closed` | `true` when server closed the stream |
630
689
  | `error` | Parse or connection error, if any |
631
690
 
632
- The hook handles `EventSource` lifecycle automatically: opens on mount, closes on unmount, re-opens when `channel` changes.
691
+ Lifecycle is automatic: opens on mount, closes on unmount, re-opens fresh when `channel` changes.
692
+
693
+ ### `poll` helper
694
+
695
+ For interval-based polling sources. Wraps your tick function in a loop that respects the `AbortSignal`. Errors in tick are logged but don't stop the loop.
696
+
697
+ ```typescript
698
+ poll({ intervalMs: 3000, tick: async (emit) => emit(await collect()) })
699
+ ```
633
700
 
634
701
  ---
635
702
 
@@ -1076,11 +1143,11 @@ Hooks (`useParams`, `useQuery`, `usePathname`, `Loader`, `useLoader`) access thi
1076
1143
 
1077
1144
  | Import | Contents |
1078
1145
  |--------|----------|
1079
- | `@mauroandre/velojs` | Types (`AppRoutes`, `ActionArgs`, `LoaderArgs`, `Metadata`, `EventStream`, `EventStreamConfig`), `Scripts`, `Link`, `createEventStream`, `defineConfig` |
1146
+ | `@mauroandre/velojs` | Types (`AppRoutes`, `ActionArgs`, `LoaderArgs`, `Metadata`, `EventStream`, `EventStreamConfig`, `EmitFn`, `EmitOptions`, `SourceFn`), `Scripts`, `Link`, `createEventStream`, `poll`, `defineConfig` |
1080
1147
  | `@mauroandre/velojs/server` | `startServer`, `createApp`, `addRoutes`, `onServer`, `serverDataStorage` |
1081
1148
  | `@mauroandre/velojs/client` | `startClient` |
1082
1149
  | `@mauroandre/velojs/hooks` | `Loader`, `useLoader`, `useEventStream`, `useParams`, `useQuery`, `useNavigate`, `usePathname`, `touch` |
1083
- | `@mauroandre/velojs/events` | `createEventStream`, `EventStream`, `EventStreamConfig` (also re-exported from root) |
1150
+ | `@mauroandre/velojs/events` | `createEventStream`, `poll`, `EventStream`, `EventStreamConfig`, `EmitFn`, `EmitOptions`, `SourceFn` (also re-exported from root) |
1084
1151
  | `@mauroandre/velojs/cookie` | `getCookie`, `setCookie`, `deleteCookie`, `getSignedCookie`, `setSignedCookie` |
1085
1152
  | `@mauroandre/velojs/factory` | `createMiddleware`, `createFactory` |
1086
1153
  | `@mauroandre/velojs/vite` | `veloPlugin` |
package/dist/events.d.ts CHANGED
@@ -1,28 +1,71 @@
1
1
  /**
2
2
  * VeloJS Event Streams
3
- * Server-Sent Events (SSE) with snapshot, channels, and auto-close.
3
+ * Server-Sent Events (SSE) with snapshot, channels, source-driven emission, and auto-close.
4
4
  *
5
- * Two ways to use:
5
+ * Two ways to declare:
6
6
  *
7
- * 1. Standalone (cross-cutting streams like global metrics):
7
+ * 1. Convention `stream_*` (per-page or per-layout, route registered automatically):
8
+ * ```typescript
9
+ * // app/admin/Deploy.tsx
10
+ * export const stream_progress = createEventStream<DeployState>();
11
+ * ```
12
+ *
13
+ * 2. Standalone (cross-cutting streams like global metrics):
8
14
  * ```typescript
9
15
  * export const metricsStream = createEventStream<Metric[]>({
10
16
  * path: "/api/metrics",
17
+ * middlewares: [authMiddleware],
11
18
  * });
12
- * metricsStream.emit(currentMetrics);
13
19
  * ```
14
20
  *
15
- * 2. Convention `stream_*` (per-page streams, route registered automatically):
21
+ * Three ways to emit (server-side):
22
+ *
23
+ * - Reactive (call from anywhere when something happens):
24
+ * `stream.emit(channelId, value, { snapshot: true })`
25
+ * - Source-driven (framework runs your producer only when subscribed):
26
+ * `createEventStream({ source: poll({ intervalMs, tick }) })`
27
+ * - Stateful snapshot via callback (for state-machine patterns):
28
+ * `createEventStream({ snapshot: (channelId) => activeStates.get(channelId) })`
29
+ */
30
+ import type { Context, Hono, MiddlewareHandler } from "hono";
31
+ /** Function passed to `source` for emitting values. */
32
+ export type EmitFn<TEvent> = {
33
+ /** Broadcast emit (no channel) */
34
+ (event: TEvent, options?: EmitOptions): void;
35
+ /** Channel-targeted emit */
36
+ (channel: string, event: TEvent, options?: EmitOptions): void;
37
+ };
38
+ /** Options accepted by `emit()` per call. */
39
+ export interface EmitOptions {
40
+ /**
41
+ * If true, the value is appended to the stream's internal buffer for that channel.
42
+ * Late subscribers receive the full buffer as a snapshot on connect.
43
+ * Default: false (event is broadcast-only, not retained).
44
+ */
45
+ snapshot?: boolean;
46
+ }
47
+ /**
48
+ * A producer function that runs **only when there are active subscribers**.
49
+ * The framework passes an `AbortSignal` that fires when the last subscriber disconnects.
50
+ *
51
+ * Example (event-driven):
16
52
  * ```typescript
17
- * // app/admin/Deploy.tsx
18
- * export const stream_progress = createEventStream<DeployState>({
19
- * channel: (c) => c.req.param("id"),
20
- * snapshot: (id) => activeDeploys.get(id),
21
- * closeOn: (s) => s.status === "success" || s.status === "error",
22
- * });
53
+ * source: async (emit, { abortSignal }) => {
54
+ * const handler = (e) => emit(e);
55
+ * bus.on("event", handler);
56
+ * abortSignal.addEventListener("abort", () => bus.off("event", handler));
57
+ * await new Promise(r => abortSignal.addEventListener("abort", r));
58
+ * }
23
59
  * ```
60
+ *
61
+ * For polling, use the `poll` helper instead of writing the loop yourself.
62
+ *
63
+ * **Anti-pattern**: ignoring `abortSignal` keeps the source running forever (and leaks
64
+ * resources). Only do this if the producer is meant to run independently of UI.
24
65
  */
25
- import type { Context, Hono, MiddlewareHandler } from "hono";
66
+ export type SourceFn<TEvent> = (emit: EmitFn<TEvent>, ctx: {
67
+ abortSignal: AbortSignal;
68
+ }) => void | Promise<void>;
26
69
  export interface EventStreamConfig<TEvent, TSnapshot = TEvent> {
27
70
  /**
28
71
  * Explicit URL path for the SSE endpoint (standalone usage).
@@ -33,19 +76,37 @@ export interface EventStreamConfig<TEvent, TSnapshot = TEvent> {
33
76
  /**
34
77
  * Channel selector — extracts a channel ID from the request.
35
78
  * Subscribers only receive events emitted to their channel.
36
- * When omitted, the stream is a broadcast (all subscribers receive all events).
79
+ * When omitted, defaults to `c.req.query("channel") ?? ""` so client-side
80
+ * `useEventStream(stream, { channel })` works out of the box.
81
+ *
82
+ * Ignored when `broadcast: true` is set.
37
83
  */
38
84
  channel?: (c: Context) => string;
85
+ /**
86
+ * If true, this stream has no channels — every emit goes to all subscribers.
87
+ * - `emit(value, options?)` (no channel arg)
88
+ * - `useEventStream(stream)` (no channel option)
89
+ * - Server uses a single broadcast bucket regardless of `?channel=` query.
90
+ *
91
+ * Default: false (channel-aware — `emit(channel, value, options?)`).
92
+ */
93
+ broadcast?: boolean;
39
94
  /**
40
95
  * Returns the current state of a channel for snapshot-on-connect.
41
96
  * Sent before any new events to bring the client up to date.
42
- * Must remain available even after the stream "closes" — clients reconnecting
43
- * after a terminal event still need to see the final state.
97
+ *
98
+ * Use this for **state-machine patterns** where each emit is a complete state
99
+ * (deploy progress, migration status). The callback returns the latest state.
100
+ *
101
+ * For **append-only patterns** (logs, tokens), prefer `emit(..., { snapshot: true })`
102
+ * which uses an internal buffer managed by the framework.
44
103
  */
45
104
  snapshot?: (channel: string | undefined) => TSnapshot | undefined | Promise<TSnapshot | undefined>;
46
105
  /**
47
106
  * Returns true when an event marks the end of the stream for that channel.
48
107
  * The server closes the SSE connection after sending such an event.
108
+ *
109
+ * For imperative close from anywhere, use `stream.close(channel)` instead.
49
110
  */
50
111
  closeOn?: (event: TEvent) => boolean;
51
112
  /**
@@ -60,32 +121,78 @@ export interface EventStreamConfig<TEvent, TSnapshot = TEvent> {
60
121
  * parent route nodes automatically.
61
122
  */
62
123
  middlewares?: MiddlewareHandler[];
124
+ /**
125
+ * Self-running producer that emits values into the stream.
126
+ * The framework only invokes `source` while there are active subscribers.
127
+ * When the last subscriber disconnects, the AbortSignal fires.
128
+ *
129
+ * Use the `poll` helper for the common interval-based pattern.
130
+ */
131
+ source?: SourceFn<TEvent>;
132
+ /**
133
+ * Time (ms) the per-channel snapshot buffer is retained after `close()`.
134
+ * Reconnecting clients within this window still see the final state.
135
+ * Default: 5 * 60 * 1000 (5 minutes).
136
+ */
137
+ retainMs?: number;
63
138
  }
64
139
  export interface EventStream<TEvent, TSnapshot = TEvent> {
65
- /** Broadcast emit (no channel) */
66
- emit(event: TEvent): void;
67
- /** Channel-targeted emit */
68
- emit(channel: string, event: TEvent): void;
140
+ /**
141
+ * Emit an event.
142
+ * - For channel-aware streams (default): `emit(channel, value, options?)`
143
+ * - For broadcast streams (`config.broadcast: true`): `emit(value, options?)`
144
+ */
145
+ emit(event: TEvent, options?: EmitOptions): void;
146
+ emit(channel: string, event: TEvent, options?: EmitOptions): void;
147
+ /**
148
+ * Closes the stream (or a specific channel).
149
+ * - Notifies current subscribers so their `useEventStream().closed` becomes true.
150
+ * - Schedules cleanup of the per-channel buffer after `retainMs`.
151
+ * - Idempotent: calling close on an already-closed channel is a no-op.
152
+ */
153
+ close(channel?: string): void;
69
154
  /** Internal: marker for runtime detection */
70
155
  readonly __isVeloEventStream: true;
71
156
  /** Internal: configuration */
72
157
  readonly __config: EventStreamConfig<TEvent, TSnapshot>;
73
158
  /** Internal: registered listeners by channel ("" for broadcast) */
74
- readonly __listeners: Map<string, Set<(event: TEvent) => void>>;
159
+ readonly __listeners: Map<string, Set<StreamListener<TEvent>>>;
160
+ /** Internal: per-channel buffer for { snapshot: true } emits */
161
+ readonly __buffers: Map<string, TEvent[]>;
75
162
  /** Internal: assigned URL path (set by standalone or by stream_* discovery) */
76
163
  __path: string | undefined;
164
+ /** Internal: source lifecycle hook */
165
+ __onConnect(channelKey: string, listener: StreamListener<TEvent>): void;
166
+ /** Internal: source lifecycle hook */
167
+ __onDisconnect(channelKey: string, listener: StreamListener<TEvent>): void;
168
+ }
169
+ /** Internal listener — receives events and a close signal. */
170
+ interface StreamListener<TEvent> {
171
+ (event: TEvent): void;
172
+ close(): void;
77
173
  }
78
- /**
79
- * Returns and clears the queue of pending standalone streams.
80
- * Called by createApp to register their routes.
81
- */
82
174
  export declare function flushPendingStreamRoutes(app: Hono): void;
175
+ export declare function createEventStream<TEvent, TSnapshot = TEvent>(config?: EventStreamConfig<TEvent, TSnapshot>): EventStream<TEvent, TSnapshot>;
83
176
  /**
84
- * Creates a typed event stream with optional channel, snapshot, and close-on-event.
177
+ * Creates a `source` function that runs `tick` every `intervalMs` while subscribed.
178
+ * The tick function is wrapped in try/catch — errors are logged but don't stop the loop.
179
+ *
180
+ * ```typescript
181
+ * export const stream_metrics = createEventStream<Metric[]>({
182
+ * source: poll({
183
+ * intervalMs: 3000,
184
+ * tick: async (emit) => emit(await collectMetrics()),
185
+ * }),
186
+ * });
187
+ * ```
85
188
  */
86
- export declare function createEventStream<TEvent, TSnapshot = TEvent>(config?: EventStreamConfig<TEvent, TSnapshot>): EventStream<TEvent, TSnapshot>;
189
+ export declare function poll<TEvent>(opts: {
190
+ intervalMs: number;
191
+ tick: (emit: EmitFn<TEvent>) => void | Promise<void>;
192
+ }): SourceFn<TEvent>;
87
193
  /**
88
194
  * Registers a GET SSE route for the given stream at the given path.
89
195
  * Used both by standalone streams and by stream_* discovery.
90
196
  */
91
197
  export declare function registerStreamHandler<TEvent, TSnapshot>(app: Hono, path: string, stream: EventStream<TEvent, TSnapshot>, middlewares?: Array<(c: Context, next: () => Promise<void>) => any>): void;
198
+ export {};
package/dist/events.js CHANGED
@@ -1,62 +1,140 @@
1
- const A = [];
2
- function w(t) {
3
- for (const s of A) s(t);
4
- A.length = 0;
1
+ const T = [];
2
+ function m(t) {
3
+ for (const r of T) r(t);
4
+ T.length = 0;
5
5
  }
6
6
  function N(t = {}) {
7
- const s = /* @__PURE__ */ new Map();
8
- function e(a, f) {
9
- let l, n;
10
- arguments.length === 1 ? (l = "", n = a) : (l = a, n = f);
11
- const c = s.get(l);
7
+ t.broadcast && t.channel && console.warn(
8
+ "[velojs] createEventStream: `broadcast: true` ignores `channel`. Remove one of them — broadcast streams have no channels."
9
+ );
10
+ const r = /* @__PURE__ */ new Map(), o = /* @__PURE__ */ new Map(), a = /* @__PURE__ */ new Set(), u = /* @__PURE__ */ new Map();
11
+ let f = null, A = 0;
12
+ const d = () => {
13
+ t.source && (f || (f = new AbortController(), Promise.resolve(
14
+ t.source(E, { abortSignal: f.signal })
15
+ ).catch((e) => {
16
+ console.error("[velojs] event stream source threw:", e);
17
+ })));
18
+ }, w = () => {
19
+ A > 0 || f && (f.abort(), f = null);
20
+ };
21
+ function E(e, c, n) {
22
+ let s, l, h;
23
+ if (!!t.broadcast ? (s = "", l = e, h = c) : (s = e, l = c, h = n), a.has(s)) {
24
+ console.warn(
25
+ `[velojs] emit() called on closed channel "${s}" — ignored. Reopen the channel by emitting to a fresh ID, or remove the close() call.`
26
+ );
27
+ return;
28
+ }
29
+ if (h?.snapshot) {
30
+ const b = o.get(s) ?? [];
31
+ b.push(l), o.set(s, b);
32
+ }
33
+ const v = r.get(s);
34
+ if (v)
35
+ for (const b of v) b(l);
36
+ }
37
+ function i(e = "") {
38
+ if (a.has(e)) return;
39
+ a.add(e);
40
+ const c = r.get(e);
12
41
  if (c)
13
- for (const h of c) h(n);
42
+ for (const l of c) l.close();
43
+ const n = t.retainMs ?? 3e5, s = u.get(e);
44
+ s && clearTimeout(s), u.set(
45
+ e,
46
+ setTimeout(() => {
47
+ o.delete(e), u.delete(e), a.delete(e);
48
+ }, n)
49
+ );
14
50
  }
15
- const r = {
16
- emit: e,
51
+ const S = {
52
+ emit: E,
53
+ close: i,
17
54
  __isVeloEventStream: !0,
18
55
  __config: t,
19
- __listeners: s,
20
- __path: t.path
56
+ __listeners: r,
57
+ __buffers: o,
58
+ __path: t.path,
59
+ __onConnect(e, c) {
60
+ let n = r.get(e);
61
+ n || (n = /* @__PURE__ */ new Set(), r.set(e, n)), n.add(c), A++, d();
62
+ },
63
+ __onDisconnect(e, c) {
64
+ const n = r.get(e);
65
+ n && (n.delete(c), n.size === 0 && r.delete(e), A--, w());
66
+ }
21
67
  };
22
- return t.path && A.push((a) => {
23
- p(a, t.path, r, t.middlewares ?? []);
24
- }), r;
68
+ return t.path && T.push((e) => {
69
+ g(e, t.path, S, t.middlewares ?? []);
70
+ }), S;
25
71
  }
26
- function p(t, s, e, r = []) {
27
- const a = async (f) => {
28
- const { streamSSE: l } = await import("hono/streaming"), n = e.__config.channel ? e.__config.channel(f) : "", c = e.__config.heartbeatMs, h = c === !1 ? 0 : c ?? 2e4;
29
- return l(f, async (_) => {
30
- if (e.__config.snapshot) {
31
- const o = await e.__config.snapshot(
32
- n === "" ? void 0 : n
72
+ function C(t) {
73
+ return async (r, { abortSignal: o }) => {
74
+ for (; !o.aborted; ) {
75
+ try {
76
+ await t.tick(r);
77
+ } catch (a) {
78
+ console.error("[velojs] poll tick threw:", a);
79
+ }
80
+ await new Promise((a) => {
81
+ const u = setTimeout(a, t.intervalMs);
82
+ o.addEventListener(
83
+ "abort",
84
+ () => {
85
+ clearTimeout(u), a();
86
+ },
87
+ { once: !0 }
33
88
  );
34
- o !== void 0 && await _.writeSSE({
89
+ });
90
+ }
91
+ };
92
+ }
93
+ function p(t) {
94
+ return t.req.query("channel") ?? "";
95
+ }
96
+ function g(t, r, o, a = []) {
97
+ const u = async (f) => {
98
+ const { streamSSE: A } = await import("hono/streaming"), d = o.__config.broadcast ? "" : (o.__config.channel ?? p)(f), w = o.__config.heartbeatMs, E = w === !1 ? 0 : w ?? 2e4;
99
+ return A(f, async (i) => {
100
+ const S = o.__buffers.get(d);
101
+ if (S && S.length > 0 && await i.writeSSE({
102
+ event: "snapshot",
103
+ data: JSON.stringify(S)
104
+ }), o.__config.snapshot) {
105
+ const _ = await o.__config.snapshot(
106
+ d === "" ? void 0 : d
107
+ );
108
+ _ !== void 0 && await i.writeSSE({
35
109
  event: "snapshot",
36
- data: JSON.stringify(o)
110
+ data: JSON.stringify(_)
37
111
  });
38
112
  }
39
- let E;
40
- const T = new Promise((o) => {
41
- E = o;
113
+ let e;
114
+ const c = new Promise((_) => {
115
+ e = _;
116
+ });
117
+ let n = !1, s = null;
118
+ const l = () => {
119
+ n || (n = !0, s && clearInterval(s), o.__onDisconnect(d, h), e());
120
+ }, h = ((_) => {
121
+ n || (i.writeSSE({ data: JSON.stringify(_) }).catch(() => {
122
+ }), o.__config.closeOn?.(_) && (i.close(), l()));
42
123
  });
43
- let S = !1, u = null, i;
44
- const d = () => {
45
- S || (S = !0, u && clearInterval(u), i?.delete(g), i?.size === 0 && e.__listeners.delete(n), E());
46
- }, g = (o) => {
47
- S || (_.writeSSE({ data: JSON.stringify(o) }).catch(() => {
48
- }), e.__config.closeOn?.(o) && (_.close(), d()));
49
- };
50
- i = e.__listeners.get(n), i || (i = /* @__PURE__ */ new Set(), e.__listeners.set(n, i)), i.add(g), u = h > 0 ? setInterval(() => {
51
- S || _.writeSSE({ event: "heartbeat", data: "" }).catch(() => {
124
+ h.close = () => {
125
+ n || (i.writeSSE({ event: "close", data: "" }).catch(() => {
126
+ }), i.close(), l());
127
+ }, o.__onConnect(d, h), s = E > 0 ? setInterval(() => {
128
+ n || i.writeSSE({ event: "heartbeat", data: "" }).catch(() => {
52
129
  });
53
- }, h) : null, _.onAbort(d), await T;
130
+ }, E) : null, i.onAbort(l), await c;
54
131
  });
55
132
  };
56
- r.length > 0 ? t.on(["GET"], [s], ...r, a) : t.on(["GET"], [s], a);
133
+ a.length > 0 ? t.on(["GET"], [r], ...a, u) : t.on(["GET"], [r], u);
57
134
  }
58
135
  export {
59
136
  N as createEventStream,
60
- w as flushPendingStreamRoutes,
61
- p as registerStreamHandler
137
+ m as flushPendingStreamRoutes,
138
+ C as poll,
139
+ g as registerStreamHandler
62
140
  };
package/dist/hooks.js CHANGED
@@ -13,42 +13,42 @@ function m(e) {
13
13
  return e ? globalThis.__veloServerData?.getStore?.()?.[e] ?? null : null;
14
14
  }
15
15
  }, loading: o };
16
- const t = window.__PAGE_DATA__, n = e && t?.[e] ? t[e] : null;
17
- return { data: v(n), loading: o };
16
+ const t = window.__PAGE_DATA__, r = e && t?.[e] ? t[e] : null;
17
+ return { data: v(r), loading: o };
18
18
  }
19
19
  function A(e, o) {
20
- let t, n;
21
- Array.isArray(e) ? n = e : (t = e, n = o);
22
- let r = null;
20
+ let t, r;
21
+ Array.isArray(e) ? r = e : (t = e, r = o);
22
+ let n = null;
23
23
  if (typeof window > "u" && t) {
24
- const l = globalThis.__veloServerData?.getStore?.();
25
- l?.[t] && (r = l[t]);
24
+ const i = globalThis.__veloServerData?.getStore?.();
25
+ i?.[t] && (n = i[t]);
26
26
  }
27
27
  if (typeof window < "u" && t) {
28
28
  const a = window.__PAGE_DATA__;
29
- a?.[t] && (r = a[t], delete a[t]);
29
+ a?.[t] && (n = a[t], delete a[t]);
30
30
  }
31
- const u = c(r), s = c(!1), _ = () => {
31
+ const u = c(n), s = c(!1), _ = () => {
32
32
  if (typeof window > "u" || !t) return;
33
33
  const a = window.location.pathname;
34
34
  s.value = !0;
35
35
  const f = typeof __VELO_STATIC__ < "u" && __VELO_STATIC__ ? `${a === "/" ? "" : a}/index.json` : (() => {
36
- const i = new URLSearchParams(window.location.search);
37
- return i.set("_data", "1"), `${a}?${i.toString()}`;
36
+ const l = new URLSearchParams(window.location.search);
37
+ return l.set("_data", "1"), `${a}?${l.toString()}`;
38
38
  })();
39
- fetch(f, { cache: "no-cache" }).then((i) => i.json()).then((i) => {
40
- typeof __VELO_BUILD_HASH__ < "u" && i.__buildHash && i.__buildHash !== __VELO_BUILD_HASH__ && (S.value = !0), u.value = i[t], s.value = !1;
39
+ fetch(f, { cache: "no-cache" }).then((l) => l.json()).then((l) => {
40
+ typeof __VELO_BUILD_HASH__ < "u" && l.__buildHash && l.__buildHash !== __VELO_BUILD_HASH__ && (S.value = !0), u.value = l[t], s.value = !1;
41
41
  }).catch(() => {
42
42
  s.value = !1;
43
43
  });
44
- }, d = p(r !== null);
44
+ }, d = p(n !== null);
45
45
  return h(() => {
46
46
  if (d.current) {
47
47
  d.current = !1;
48
48
  return;
49
49
  }
50
50
  _();
51
- }, n ?? []), { data: u, loading: s, refetch: _ };
51
+ }, r ?? []), { data: u, loading: s, refetch: _ };
52
52
  }
53
53
  function P() {
54
54
  const [, e] = w();
@@ -64,42 +64,44 @@ function b() {
64
64
  if (e?.__query)
65
65
  return e.__query;
66
66
  const o = new URLSearchParams(window.location.search), t = {};
67
- return o.forEach((n, r) => {
68
- t[r] = n;
67
+ return o.forEach((r, n) => {
68
+ t[n] = r;
69
69
  }), t;
70
70
  }
71
71
  function H() {
72
72
  return typeof window > "u" ? globalThis.__veloServerData?.getStore?.()?.__pathname ?? "/" : (w(), window.location.pathname);
73
73
  }
74
74
  function U(e, o = {}) {
75
- const t = c(null), n = c(null), r = c(!1), u = c(null), { channel: s, enabled: _ = !0 } = o;
75
+ const t = c(null), r = c(null), n = c(!1), u = c(null), { channel: s, enabled: _ = !0 } = o;
76
76
  return h(() => {
77
77
  if (typeof window > "u" || !_) return;
78
78
  if (!e.__path) {
79
79
  console.warn("[velojs] useEventStream: stream has no __path");
80
80
  return;
81
81
  }
82
- t.value = null, n.value = null, r.value = !1, u.value = null;
82
+ t.value = null, r.value = null, n.value = !1, u.value = null;
83
83
  const d = s ? `${e.__path}?channel=${encodeURIComponent(s)}` : e.__path, a = new EventSource(d);
84
- return a.addEventListener("snapshot", (l) => {
84
+ return a.addEventListener("snapshot", (i) => {
85
85
  try {
86
- n.value = JSON.parse(l.data);
86
+ r.value = JSON.parse(i.data);
87
87
  } catch (f) {
88
88
  u.value = f;
89
89
  }
90
- }), a.addEventListener("message", (l) => {
90
+ }), a.addEventListener("message", (i) => {
91
91
  try {
92
- t.value = JSON.parse(l.data);
92
+ t.value = JSON.parse(i.data);
93
93
  } catch (f) {
94
94
  u.value = f;
95
95
  }
96
96
  }), a.addEventListener("heartbeat", () => {
97
+ }), a.addEventListener("close", () => {
98
+ n.value = !0, a.close();
97
99
  }), a.addEventListener("error", () => {
98
- a.readyState === EventSource.CLOSED && (r.value = !0);
100
+ a.readyState === EventSource.CLOSED && (n.value = !0);
99
101
  }), () => {
100
102
  a.close();
101
103
  };
102
- }, [e, s, _]), { data: t, snapshot: n, closed: r, error: u };
104
+ }, [e, s, _]), { data: t, snapshot: r, closed: n, error: u };
103
105
  }
104
106
  export {
105
107
  m as Loader,
package/dist/index.d.ts CHANGED
@@ -2,6 +2,6 @@ export type { AppRoutes, RouteNode, RouteModule, LoaderArgs, ActionArgs, Metadat
2
2
  export type { VeloConfig } from "./config.js";
3
3
  export { defineConfig } from "./config.js";
4
4
  export { Scripts, Link } from "./components.js";
5
- export { createEventStream } from "./events.js";
6
- export type { EventStream, EventStreamConfig } from "./events.js";
5
+ export { createEventStream, poll } from "./events.js";
6
+ export type { EventStream, EventStreamConfig, EmitFn, EmitOptions, SourceFn, } from "./events.js";
7
7
  export type { Context, MiddlewareHandler, Next } from "hono";
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { defineConfig as o } from "./config.js";
2
- import { Link as f, Scripts as i } from "./components.js";
3
- import { createEventStream as n } from "./events.js";
2
+ import { Link as f, Scripts as p } from "./components.js";
3
+ import { createEventStream as m, poll as n } from "./events.js";
4
4
  export {
5
5
  f as Link,
6
- i as Scripts,
7
- n as createEventStream,
8
- o as defineConfig
6
+ p as Scripts,
7
+ m as createEventStream,
8
+ o as defineConfig,
9
+ n as poll
9
10
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mauroandre/velojs",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "type": "module",
5
5
  "description": "Fullstack web framework built on Hono + Preact with Vite-powered AST transforms",
6
6
  "keywords": [