@mauroandre/velojs 0.0.21 → 0.0.22

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
@@ -629,10 +629,12 @@ export const stream_deploy = createEventStream<DeployState>({
629
629
  |--------|------|-------------|
630
630
  | `path` | `string` | (Standalone) Explicit URL path |
631
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` |
632
+ | `channel` | `(c) => string \| null \| Promise<...>` | Resolve channel ID. Sync or async. Return `null`/`undefined` to reject (403). Default: `?channel=...` |
633
633
  | `snapshot` | `(channel) => TSnapshot` | Returns current state on connect (state-machine pattern) |
634
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 |
635
+ | `source` | `(emit, { abortSignal }) => Promise<void>` | Stream-wide producer, only runs while subscribed |
636
+ | `perChannelSource` | `(channelKey, emit, { abortSignal }) => Promise<void>` | Per-channel producer. Mutually exclusive with `source` |
637
+ | `bufferSize` | `number` | Max entries kept in snapshot buffer per channel (FIFO). Default `Infinity` |
636
638
  | `retainMs` | `number` | Buffer retention after `close()`. Default `300000` (5 min) |
637
639
  | `heartbeatMs` | `number \| false` | Heartbeat interval. Default `20000` (20s). `false` to disable |
638
640
  | `middlewares` | `MiddlewareHandler[]` | (Standalone) Hono middlewares for the SSE route |
@@ -698,6 +700,53 @@ For interval-based polling sources. Wraps your tick function in a loop that resp
698
700
  poll({ intervalMs: 3000, tick: async (emit) => emit(await collect()) })
699
701
  ```
700
702
 
703
+ ### Per-channel sources
704
+
705
+ Use `perChannelSource` for resources that scale per channel (SSH connections, DB cursors, pub/sub topics). Invoked once per channel on first subscriber, aborted when the last subscriber of that channel leaves.
706
+
707
+ ```typescript
708
+ export const stream_logs = createEventStream<string>({
709
+ channel: (c) => `${c.req.param("worker")}:${c.req.param("container")}`,
710
+ bufferSize: 500,
711
+ perChannelSource: async (key, emit, { abortSignal }) => {
712
+ const conn = await ssh.connect(...);
713
+ const stream = conn.exec(`podman logs -f ${key.split(":")[1]}`);
714
+ stream.on("data", (d) => emit(d.toString(), { snapshot: true }));
715
+ abortSignal.addEventListener("abort", () => { stream.close(); conn.end(); });
716
+ },
717
+ });
718
+ ```
719
+
720
+ Mutually exclusive with `source`.
721
+
722
+ ### Async channel resolver (auth + ownership)
723
+
724
+ The `channel` resolver can be async and reject the connection by returning `null`/`undefined`:
725
+
726
+ ```typescript
727
+ createEventStream({
728
+ channel: async (c) => {
729
+ const user = c.get("user");
730
+ const appId = c.req.query("channel");
731
+ const app = await getApp({ id: appId });
732
+ if (app?.owner !== user.id) return null; // → 403
733
+ return appId;
734
+ },
735
+ });
736
+ ```
737
+
738
+ Combines auth and channel extraction in one place.
739
+
740
+ ### Buffer size limits
741
+
742
+ Cap memory usage of long-running log streams via FIFO ring:
743
+
744
+ ```typescript
745
+ createEventStream<string>({ bufferSize: 500 }); // keep last 500 entries per channel
746
+ ```
747
+
748
+ Only affects emits with `{ snapshot: true }`.
749
+
701
750
  ---
702
751
 
703
752
  ## Hooks
@@ -1143,11 +1192,11 @@ Hooks (`useParams`, `useQuery`, `usePathname`, `Loader`, `useLoader`) access thi
1143
1192
 
1144
1193
  | Import | Contents |
1145
1194
  |--------|----------|
1146
- | `@mauroandre/velojs` | Types (`AppRoutes`, `ActionArgs`, `LoaderArgs`, `Metadata`, `EventStream`, `EventStreamConfig`, `EmitFn`, `EmitOptions`, `SourceFn`), `Scripts`, `Link`, `createEventStream`, `poll`, `defineConfig` |
1195
+ | `@mauroandre/velojs` | Types (`AppRoutes`, `ActionArgs`, `LoaderArgs`, `Metadata`, `EventStream`, `EventStreamConfig`, `EmitFn`, `EmitOptions`, `SourceFn`, `PerChannelSourceFn`, `ChannelResolver`), `Scripts`, `Link`, `createEventStream`, `poll`, `defineConfig` |
1147
1196
  | `@mauroandre/velojs/server` | `startServer`, `createApp`, `addRoutes`, `onServer`, `serverDataStorage` |
1148
1197
  | `@mauroandre/velojs/client` | `startClient` |
1149
1198
  | `@mauroandre/velojs/hooks` | `Loader`, `useLoader`, `useEventStream`, `useParams`, `useQuery`, `useNavigate`, `usePathname`, `touch` |
1150
- | `@mauroandre/velojs/events` | `createEventStream`, `poll`, `EventStream`, `EventStreamConfig`, `EmitFn`, `EmitOptions`, `SourceFn` (also re-exported from root) |
1199
+ | `@mauroandre/velojs/events` | `createEventStream`, `poll`, `EventStream`, `EventStreamConfig`, `EmitFn`, `EmitOptions`, `SourceFn`, `PerChannelSourceFn`, `ChannelResolver` (also re-exported from root) |
1151
1200
  | `@mauroandre/velojs/cookie` | `getCookie`, `setCookie`, `deleteCookie`, `getSignedCookie`, `setSignedCookie` |
1152
1201
  | `@mauroandre/velojs/factory` | `createMiddleware`, `createFactory` |
1153
1202
  | `@mauroandre/velojs/vite` | `veloPlugin` |
package/dist/events.d.ts CHANGED
@@ -66,6 +66,36 @@ export interface EmitOptions {
66
66
  export type SourceFn<TEvent> = (emit: EmitFn<TEvent>, ctx: {
67
67
  abortSignal: AbortSignal;
68
68
  }) => void | Promise<void>;
69
+ /**
70
+ * A per-channel producer function. Invoked when the **first** subscriber connects
71
+ * to a specific channel. Aborted when the **last** subscriber of that channel disconnects.
72
+ *
73
+ * The provided `emit` function is bound to the current channel — call it with just
74
+ * the event value (no need to pass the channel ID again).
75
+ *
76
+ * Example (per-channel SSH log stream):
77
+ * ```typescript
78
+ * perChannelSource: async (channelKey, emit, { abortSignal }) => {
79
+ * const conn = await ssh.connect(...);
80
+ * const stream = conn.exec(`podman logs -f ${channelKey}`);
81
+ * stream.on("data", (d) => emit(d.toString(), { snapshot: true }));
82
+ * abortSignal.addEventListener("abort", () => { stream.close(); conn.end(); });
83
+ * }
84
+ * ```
85
+ *
86
+ * Use this for resources that are **per-channel and expensive** (SSH connections,
87
+ * DB cursors, external pub/sub subscriptions). Mutually exclusive with `source`.
88
+ */
89
+ export type PerChannelSourceFn<TEvent> = (channelKey: string, emit: (event: TEvent, options?: EmitOptions) => void, ctx: {
90
+ abortSignal: AbortSignal;
91
+ }) => void | Promise<void>;
92
+ /**
93
+ * Async-capable channel resolver. Returns a channel ID, or `null`/`undefined`
94
+ * to reject the connection (framework responds 403).
95
+ *
96
+ * Useful for combining channel extraction with authorization in one place.
97
+ */
98
+ export type ChannelResolver = (c: Context) => string | null | undefined | Promise<string | null | undefined>;
69
99
  export interface EventStreamConfig<TEvent, TSnapshot = TEvent> {
70
100
  /**
71
101
  * Explicit URL path for the SSE endpoint (standalone usage).
@@ -79,9 +109,12 @@ export interface EventStreamConfig<TEvent, TSnapshot = TEvent> {
79
109
  * When omitted, defaults to `c.req.query("channel") ?? ""` so client-side
80
110
  * `useEventStream(stream, { channel })` works out of the box.
81
111
  *
112
+ * Can be **async** — return a Promise to perform DB lookups, auth checks, etc.
113
+ * Return `null` or `undefined` to reject the connection (framework responds 403).
114
+ *
82
115
  * Ignored when `broadcast: true` is set.
83
116
  */
84
- channel?: (c: Context) => string;
117
+ channel?: ChannelResolver;
85
118
  /**
86
119
  * If true, this stream has no channels — every emit goes to all subscribers.
87
120
  * - `emit(value, options?)` (no channel arg)
@@ -127,14 +160,37 @@ export interface EventStreamConfig<TEvent, TSnapshot = TEvent> {
127
160
  * When the last subscriber disconnects, the AbortSignal fires.
128
161
  *
129
162
  * Use the `poll` helper for the common interval-based pattern.
163
+ *
164
+ * Mutually exclusive with `perChannelSource`.
130
165
  */
131
166
  source?: SourceFn<TEvent>;
167
+ /**
168
+ * Per-channel producer. Invoked when the **first** subscriber of a channel connects.
169
+ * Aborted when the **last** subscriber of that channel disconnects.
170
+ *
171
+ * Use this for resources that scale per channel (SSH connections, DB cursors,
172
+ * external pub/sub subscriptions). The `emit` provided is bound to the current
173
+ * channel — call it with just the value.
174
+ *
175
+ * Mutually exclusive with `source`.
176
+ */
177
+ perChannelSource?: PerChannelSourceFn<TEvent>;
132
178
  /**
133
179
  * Time (ms) the per-channel snapshot buffer is retained after `close()`.
134
180
  * Reconnecting clients within this window still see the final state.
135
181
  * Default: 5 * 60 * 1000 (5 minutes).
136
182
  */
137
183
  retainMs?: number;
184
+ /**
185
+ * Maximum number of events kept in the per-channel snapshot buffer
186
+ * (only applies to `emit(..., { snapshot: true })`).
187
+ *
188
+ * When the buffer reaches this size, the oldest entries are dropped (FIFO ring).
189
+ * Use this to prevent memory growth in long-running log streams.
190
+ *
191
+ * Default: `Infinity` (no limit, current behavior).
192
+ */
193
+ bufferSize?: number;
138
194
  }
139
195
  export interface EventStream<TEvent, TSnapshot = TEvent> {
140
196
  /**
package/dist/events.js CHANGED
@@ -1,88 +1,112 @@
1
- const T = [];
2
- function m(t) {
3
- for (const r of T) r(t);
4
- T.length = 0;
1
+ const C = [];
2
+ function R(t) {
3
+ for (const a of C) a(t);
4
+ C.length = 0;
5
5
  }
6
- function N(t = {}) {
7
- t.broadcast && t.channel && console.warn(
6
+ function L(t = {}) {
7
+ if (t.broadcast && t.channel && console.warn(
8
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 })
9
+ ), t.source && t.perChannelSource)
10
+ throw new Error(
11
+ "[velojs] createEventStream: `source` and `perChannelSource` are mutually exclusive. Use `source` for stream-wide producers or `perChannelSource` for per-channel resources, not both."
12
+ );
13
+ const a = /* @__PURE__ */ new Map(), s = /* @__PURE__ */ new Map(), l = /* @__PURE__ */ new Set(), d = /* @__PURE__ */ new Map();
14
+ let u = null, p = 0;
15
+ const c = /* @__PURE__ */ new Map(), E = () => {
16
+ t.source && (u || (u = new AbortController(), Promise.resolve(
17
+ t.source(_, { abortSignal: u.signal })
15
18
  ).catch((e) => {
16
19
  console.error("[velojs] event stream source threw:", e);
17
20
  })));
18
- }, w = () => {
19
- A > 0 || f && (f.abort(), f = null);
21
+ }, v = () => {
22
+ p > 0 || u && (u.abort(), u = null);
23
+ }, i = (e) => {
24
+ if (!t.perChannelSource || c.has(e)) return;
25
+ const o = new AbortController();
26
+ c.set(e, o);
27
+ const n = (r, S) => {
28
+ h.emit(e, r, S);
29
+ };
30
+ Promise.resolve(
31
+ t.perChannelSource(e, n, { abortSignal: o.signal })
32
+ ).catch((r) => {
33
+ console.error(
34
+ `[velojs] perChannelSource threw for channel "${e}":`,
35
+ r
36
+ );
37
+ });
38
+ }, f = (e) => {
39
+ const o = c.get(e);
40
+ o && (o.abort(), c.delete(e));
20
41
  };
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)) {
42
+ function _(e, o, n) {
43
+ let r, S, b;
44
+ if (!!t.broadcast ? (r = "", S = e, b = o) : (r = e, S = o, b = n), l.has(r)) {
24
45
  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.`
46
+ `[velojs] emit() called on closed channel "${r}" — ignored. Reopen the channel by emitting to a fresh ID, or remove the close() call.`
26
47
  );
27
48
  return;
28
49
  }
29
- if (h?.snapshot) {
30
- const b = o.get(s) ?? [];
31
- b.push(l), o.set(s, b);
50
+ if (b?.snapshot) {
51
+ const w = s.get(r) ?? [];
52
+ w.push(S);
53
+ const T = t.bufferSize ?? 1 / 0;
54
+ w.length > T && w.splice(0, w.length - T), s.set(r, w);
32
55
  }
33
- const v = r.get(s);
34
- if (v)
35
- for (const b of v) b(l);
56
+ const m = a.get(r);
57
+ if (m)
58
+ for (const w of m) w(S);
36
59
  }
37
- function i(e = "") {
38
- if (a.has(e)) return;
39
- a.add(e);
40
- const c = r.get(e);
41
- if (c)
42
- for (const l of c) l.close();
43
- const n = t.retainMs ?? 3e5, s = u.get(e);
44
- s && clearTimeout(s), u.set(
60
+ function A(e = "") {
61
+ if (l.has(e)) return;
62
+ l.add(e);
63
+ const o = a.get(e);
64
+ if (o)
65
+ for (const S of o) S.close();
66
+ const n = t.retainMs ?? 3e5, r = d.get(e);
67
+ r && clearTimeout(r), d.set(
45
68
  e,
46
69
  setTimeout(() => {
47
- o.delete(e), u.delete(e), a.delete(e);
70
+ s.delete(e), d.delete(e), l.delete(e);
48
71
  }, n)
49
72
  );
50
73
  }
51
- const S = {
52
- emit: E,
53
- close: i,
74
+ const h = {
75
+ emit: _,
76
+ close: A,
54
77
  __isVeloEventStream: !0,
55
78
  __config: t,
56
- __listeners: r,
57
- __buffers: o,
79
+ __listeners: a,
80
+ __buffers: s,
58
81
  __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();
82
+ __onConnect(e, o) {
83
+ let n = a.get(e);
84
+ const r = !n || n.size === 0;
85
+ n || (n = /* @__PURE__ */ new Set(), a.set(e, n)), n.add(o), p++, E(), r && i(e);
62
86
  },
63
- __onDisconnect(e, c) {
64
- const n = r.get(e);
65
- n && (n.delete(c), n.size === 0 && r.delete(e), A--, w());
87
+ __onDisconnect(e, o) {
88
+ const n = a.get(e);
89
+ n && (n.delete(o), n.size === 0 && (a.delete(e), f(e)), p--, v());
66
90
  }
67
91
  };
68
- return t.path && T.push((e) => {
69
- g(e, t.path, S, t.middlewares ?? []);
70
- }), S;
92
+ return t.path && C.push((e) => {
93
+ N(e, t.path, h, t.middlewares ?? []);
94
+ }), h;
71
95
  }
72
- function C(t) {
73
- return async (r, { abortSignal: o }) => {
74
- for (; !o.aborted; ) {
96
+ function M(t) {
97
+ return async (a, { abortSignal: s }) => {
98
+ for (; !s.aborted; ) {
75
99
  try {
76
- await t.tick(r);
77
- } catch (a) {
78
- console.error("[velojs] poll tick threw:", a);
100
+ await t.tick(a);
101
+ } catch (l) {
102
+ console.error("[velojs] poll tick threw:", l);
79
103
  }
80
- await new Promise((a) => {
81
- const u = setTimeout(a, t.intervalMs);
82
- o.addEventListener(
104
+ await new Promise((l) => {
105
+ const d = setTimeout(l, t.intervalMs);
106
+ s.addEventListener(
83
107
  "abort",
84
108
  () => {
85
- clearTimeout(u), a();
109
+ clearTimeout(d), l();
86
110
  },
87
111
  { once: !0 }
88
112
  );
@@ -90,51 +114,67 @@ function C(t) {
90
114
  }
91
115
  };
92
116
  }
93
- function p(t) {
117
+ function g(t) {
94
118
  return t.req.query("channel") ?? "";
95
119
  }
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({
120
+ function N(t, a, s, l = []) {
121
+ const d = async (u) => {
122
+ const { streamSSE: p } = await import("hono/streaming");
123
+ let c;
124
+ if (s.__config.broadcast)
125
+ c = "";
126
+ else {
127
+ const i = s.__config.channel ?? g;
128
+ let f;
129
+ try {
130
+ f = await i(u);
131
+ } catch (_) {
132
+ return console.error("[velojs] channel resolver threw:", _), u.json({ error: "internal" }, 500);
133
+ }
134
+ if (f == null)
135
+ return u.json({ error: "forbidden" }, 403);
136
+ c = f;
137
+ }
138
+ const E = s.__config.heartbeatMs, v = E === !1 ? 0 : E ?? 2e4;
139
+ return p(u, async (i) => {
140
+ const f = s.__buffers.get(c);
141
+ if (f && f.length > 0 && await i.writeSSE({
102
142
  event: "snapshot",
103
- data: JSON.stringify(S)
104
- }), o.__config.snapshot) {
105
- const _ = await o.__config.snapshot(
106
- d === "" ? void 0 : d
143
+ data: JSON.stringify(f)
144
+ }), s.__config.snapshot) {
145
+ const r = await s.__config.snapshot(
146
+ c === "" ? void 0 : c
107
147
  );
108
- _ !== void 0 && await i.writeSSE({
148
+ r !== void 0 && await i.writeSSE({
109
149
  event: "snapshot",
110
- data: JSON.stringify(_)
150
+ data: JSON.stringify(r)
111
151
  });
112
152
  }
113
- let e;
114
- const c = new Promise((_) => {
115
- e = _;
153
+ let _;
154
+ const A = new Promise((r) => {
155
+ _ = r;
116
156
  });
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()));
157
+ let h = !1, e = null;
158
+ const o = () => {
159
+ h || (h = !0, e && clearInterval(e), s.__onDisconnect(c, n), _());
160
+ }, n = ((r) => {
161
+ h || (i.writeSSE({ data: JSON.stringify(r) }).catch(() => {
162
+ }), s.__config.closeOn?.(r) && (i.close(), o()));
123
163
  });
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(() => {
164
+ n.close = () => {
165
+ h || (i.writeSSE({ event: "close", data: "" }).catch(() => {
166
+ }), i.close(), o());
167
+ }, s.__onConnect(c, n), e = v > 0 ? setInterval(() => {
168
+ h || i.writeSSE({ event: "heartbeat", data: "" }).catch(() => {
129
169
  });
130
- }, E) : null, i.onAbort(l), await c;
170
+ }, v) : null, i.onAbort(o), await A;
131
171
  });
132
172
  };
133
- a.length > 0 ? t.on(["GET"], [r], ...a, u) : t.on(["GET"], [r], u);
173
+ l.length > 0 ? t.on(["GET"], [a], ...l, d) : t.on(["GET"], [a], d);
134
174
  }
135
175
  export {
136
- N as createEventStream,
137
- m as flushPendingStreamRoutes,
138
- C as poll,
139
- g as registerStreamHandler
176
+ L as createEventStream,
177
+ R as flushPendingStreamRoutes,
178
+ M as poll,
179
+ N as registerStreamHandler
140
180
  };
package/dist/index.d.ts CHANGED
@@ -3,5 +3,5 @@ export type { VeloConfig } from "./config.js";
3
3
  export { defineConfig } from "./config.js";
4
4
  export { Scripts, Link } from "./components.js";
5
5
  export { createEventStream, poll } from "./events.js";
6
- export type { EventStream, EventStreamConfig, EmitFn, EmitOptions, SourceFn, } from "./events.js";
6
+ export type { EventStream, EventStreamConfig, EmitFn, EmitOptions, SourceFn, PerChannelSourceFn, ChannelResolver, } from "./events.js";
7
7
  export type { Context, MiddlewareHandler, Next } from "hono";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mauroandre/velojs",
3
- "version": "0.0.21",
3
+ "version": "0.0.22",
4
4
  "type": "module",
5
5
  "description": "Fullstack web framework built on Hono + Preact with Vite-powered AST transforms",
6
6
  "keywords": [
@@ -30,7 +30,7 @@
30
30
  ],
31
31
  "scripts": {
32
32
  "build": "vite build --config vite.build.config.ts && tsc --declaration --emitDeclarationOnly --outDir dist --noEmit false --rootDir src",
33
- "typecheck": "tsc --noEmit",
33
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
34
34
  "test": "vitest run",
35
35
  "test:watch": "vitest"
36
36
  },