@mauroandre/velojs 0.0.21 → 0.0.23
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 +108 -4
- package/dist/app-context.d.ts +38 -0
- package/dist/app-context.js +43 -0
- package/dist/events.d.ts +61 -1
- package/dist/events.js +147 -88
- package/dist/index.d.ts +1 -1
- package/dist/server.js +96 -95
- package/dist/testing/conventions.d.ts +25 -0
- package/dist/testing/createTestApp.d.ts +23 -0
- package/dist/testing/index.d.ts +20 -0
- package/dist/testing/index.js +488 -0
- package/dist/testing/internal.d.ts +30 -0
- package/dist/testing/mockContext.d.ts +10 -0
- package/dist/testing/subscription.d.ts +11 -0
- package/dist/testing/types.d.ts +125 -0
- package/package.json +6 -2
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
|
|
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>` |
|
|
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
|
|
@@ -1139,15 +1188,70 @@ Hooks (`useParams`, `useQuery`, `usePathname`, `Loader`, `useLoader`) access thi
|
|
|
1139
1188
|
|
|
1140
1189
|
---
|
|
1141
1190
|
|
|
1191
|
+
## Testing
|
|
1192
|
+
|
|
1193
|
+
VeloJS ships a backend testing toolkit at `@mauroandre/velojs/testing`. Spin up the app in memory, fire HTTP requests against the registered handlers, subscribe to event streams. No socket, no browser, no fragile mocks of framework internals.
|
|
1194
|
+
|
|
1195
|
+
```typescript
|
|
1196
|
+
import { createTestApp } from "@mauroandre/velojs/testing";
|
|
1197
|
+
import { routes } from "../app/routes.js";
|
|
1198
|
+
import { stream_progress } from "../app/Deploy.js";
|
|
1199
|
+
import { action_startDeploy } from "../app/Deploy.js";
|
|
1200
|
+
|
|
1201
|
+
const app = await createTestApp({
|
|
1202
|
+
routes,
|
|
1203
|
+
bootstrap: async () => { await connect(process.env.MONGO_URI!); },
|
|
1204
|
+
getSessionCookie: async ({ user }) => ({ session: await sign(user) }),
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
// HTTP
|
|
1208
|
+
const res = await app.get("/api/health");
|
|
1209
|
+
|
|
1210
|
+
// Convention helpers — pass the function, framework resolves the URL
|
|
1211
|
+
const data = await app.loader(homeLoader, { params: { id: "abc" } });
|
|
1212
|
+
await app.action(action_startDeploy, { body: { appId } });
|
|
1213
|
+
|
|
1214
|
+
// Streams — TestSubscription with snapshot, next(), close, etc
|
|
1215
|
+
const sub = await app.subscribe(stream_progress, { channel: appId });
|
|
1216
|
+
expect(sub.status).toBe(200);
|
|
1217
|
+
const event = await sub.next({ timeoutMs: 2000 });
|
|
1218
|
+
|
|
1219
|
+
// Auth — sub-client with cookies bound automatically
|
|
1220
|
+
const asAlice = app.as({ user: alice });
|
|
1221
|
+
await asAlice.subscribe(stream_progress, { channel: appId });
|
|
1222
|
+
|
|
1223
|
+
await app.close();
|
|
1224
|
+
```
|
|
1225
|
+
|
|
1226
|
+
**Prerequisite:** `vitest.config.ts` must include `veloPlugin()` so action/loader/stream metadata is injected.
|
|
1227
|
+
|
|
1228
|
+
| API | Purpose |
|
|
1229
|
+
|-----|---------|
|
|
1230
|
+
| `createTestApp(options)` | Build isolated app with bootstrap + auth callback |
|
|
1231
|
+
| `app.get/post/put/patch/delete` | HTTP requests (cookies, headers, query, JSON/FormData body) |
|
|
1232
|
+
| `app.action(fn, opts)` | Invoke `action_*` by function reference |
|
|
1233
|
+
| `app.loader(fn, opts)` | Invoke `loader` and unwrap response data |
|
|
1234
|
+
| `app.subscribe(stream, opts)` | Subscribe to a stream; returns `TestSubscription` with `next/nextN/snapshot/close/closed` |
|
|
1235
|
+
| `app.as({ user })` | Sub-client with cookies bound to a user |
|
|
1236
|
+
| `app.sessionCookies({ user })` | Build cookies via `getSessionCookie` |
|
|
1237
|
+
| `app.mockContext(opts)` | Escape hatch — partial Hono Context for direct invocation |
|
|
1238
|
+
| `app.reset()` | Clear stream buffers/listeners between tests |
|
|
1239
|
+
| `app.close()` | Tear down everything (zero open handles guaranteed) |
|
|
1240
|
+
|
|
1241
|
+
See [Testing docs](https://github.com/mauro-andre/velojs/blob/dev/site/docs/16-testing.md) for the full guide with patterns, isolation, and FAQ.
|
|
1242
|
+
|
|
1243
|
+
---
|
|
1244
|
+
|
|
1142
1245
|
## Subpath Exports
|
|
1143
1246
|
|
|
1144
1247
|
| Import | Contents |
|
|
1145
1248
|
|--------|----------|
|
|
1146
|
-
| `@mauroandre/velojs` | Types (`AppRoutes`, `ActionArgs`, `LoaderArgs`, `Metadata`, `EventStream`, `EventStreamConfig`, `EmitFn`, `EmitOptions`, `SourceFn`), `Scripts`, `Link`, `createEventStream`, `poll`, `defineConfig` |
|
|
1249
|
+
| `@mauroandre/velojs` | Types (`AppRoutes`, `ActionArgs`, `LoaderArgs`, `Metadata`, `EventStream`, `EventStreamConfig`, `EmitFn`, `EmitOptions`, `SourceFn`, `PerChannelSourceFn`, `ChannelResolver`), `Scripts`, `Link`, `createEventStream`, `poll`, `defineConfig` |
|
|
1147
1250
|
| `@mauroandre/velojs/server` | `startServer`, `createApp`, `addRoutes`, `onServer`, `serverDataStorage` |
|
|
1148
1251
|
| `@mauroandre/velojs/client` | `startClient` |
|
|
1149
1252
|
| `@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) |
|
|
1253
|
+
| `@mauroandre/velojs/events` | `createEventStream`, `poll`, `EventStream`, `EventStreamConfig`, `EmitFn`, `EmitOptions`, `SourceFn`, `PerChannelSourceFn`, `ChannelResolver` (also re-exported from root) |
|
|
1254
|
+
| `@mauroandre/velojs/testing` | `createTestApp`, `TestApp`, `TestResponse`, `TestSubscription`, `CreateTestAppOptions`, `MockContextOptions` |
|
|
1151
1255
|
| `@mauroandre/velojs/cookie` | `getCookie`, `setCookie`, `deleteCookie`, `getSignedCookie`, `setSignedCookie` |
|
|
1152
1256
|
| `@mauroandre/velojs/factory` | `createMiddleware`, `createFactory` |
|
|
1153
1257
|
| `@mauroandre/velojs/vite` | `veloPlugin` |
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App context — scoped via a swappable global slot so multiple test apps
|
|
3
|
+
* can coexist without polluting each other's pending route registrations.
|
|
4
|
+
*
|
|
5
|
+
* Production runtime uses a single default context implicitly. Tests create
|
|
6
|
+
* isolated contexts via `withAppContext()` which swaps the slot for the
|
|
7
|
+
* duration of the call.
|
|
8
|
+
*
|
|
9
|
+
* NOTE: this module is client-safe (no `node:async_hooks` import). Vitest runs
|
|
10
|
+
* each test file in its own worker, so a simple swappable slot is sufficient
|
|
11
|
+
* for parallelism between files. Within a single file, tests run sequentially
|
|
12
|
+
* unless `describe.concurrent`/`it.concurrent` is used.
|
|
13
|
+
*/
|
|
14
|
+
import type { Hono } from "hono";
|
|
15
|
+
type RouteRegisterFn = (app: Hono) => void | Promise<void>;
|
|
16
|
+
type StreamRouteRegisterFn = (app: Hono) => void;
|
|
17
|
+
type ServerCallbackFn = (server: any) => void;
|
|
18
|
+
export interface AppContext {
|
|
19
|
+
pendingRoutes: RouteRegisterFn[];
|
|
20
|
+
pendingStreamRoutes: StreamRouteRegisterFn[];
|
|
21
|
+
serverCallbacks: ServerCallbackFn[];
|
|
22
|
+
disposers: Set<() => void>;
|
|
23
|
+
}
|
|
24
|
+
export declare function getAppContext(): AppContext;
|
|
25
|
+
/**
|
|
26
|
+
* Run a function within an isolated app context (for tests).
|
|
27
|
+
* Restores the previous context after the function resolves/rejects.
|
|
28
|
+
*/
|
|
29
|
+
export declare function withAppContext<T>(ctx: AppContext, fn: () => T | Promise<T>): Promise<T>;
|
|
30
|
+
/**
|
|
31
|
+
* Create a new isolated context. Inherits a SNAPSHOT of the default context
|
|
32
|
+
* so any addRoutes/onServer/createEventStream calls that happened at top-level
|
|
33
|
+
* import time are captured.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createIsolatedContext(): AppContext;
|
|
36
|
+
export declare function registerDisposer(dispose: () => void): void;
|
|
37
|
+
export declare function unregisterDisposer(dispose: () => void): void;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
function o() {
|
|
2
|
+
return {
|
|
3
|
+
pendingRoutes: [],
|
|
4
|
+
pendingStreamRoutes: [],
|
|
5
|
+
serverCallbacks: [],
|
|
6
|
+
disposers: /* @__PURE__ */ new Set()
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
const n = o();
|
|
10
|
+
let e = n;
|
|
11
|
+
function i() {
|
|
12
|
+
return e;
|
|
13
|
+
}
|
|
14
|
+
async function p(t, s) {
|
|
15
|
+
const r = e;
|
|
16
|
+
e = t;
|
|
17
|
+
try {
|
|
18
|
+
return await s();
|
|
19
|
+
} finally {
|
|
20
|
+
e = r;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function u() {
|
|
24
|
+
return {
|
|
25
|
+
pendingRoutes: [...n.pendingRoutes],
|
|
26
|
+
pendingStreamRoutes: [...n.pendingStreamRoutes],
|
|
27
|
+
serverCallbacks: [...n.serverCallbacks],
|
|
28
|
+
disposers: /* @__PURE__ */ new Set()
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function a(t) {
|
|
32
|
+
e.disposers.add(t);
|
|
33
|
+
}
|
|
34
|
+
function d(t) {
|
|
35
|
+
e.disposers.delete(t);
|
|
36
|
+
}
|
|
37
|
+
export {
|
|
38
|
+
u as createIsolatedContext,
|
|
39
|
+
i as getAppContext,
|
|
40
|
+
a as registerDisposer,
|
|
41
|
+
d as unregisterDisposer,
|
|
42
|
+
p as withAppContext
|
|
43
|
+
};
|
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?:
|
|
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
|
/**
|
|
@@ -165,6 +221,8 @@ export interface EventStream<TEvent, TSnapshot = TEvent> {
|
|
|
165
221
|
__onConnect(channelKey: string, listener: StreamListener<TEvent>): void;
|
|
166
222
|
/** Internal: source lifecycle hook */
|
|
167
223
|
__onDisconnect(channelKey: string, listener: StreamListener<TEvent>): void;
|
|
224
|
+
/** @internal Test-only: reset all transient state (buffers, listeners, controllers, timers). */
|
|
225
|
+
__reset(): void;
|
|
168
226
|
}
|
|
169
227
|
/** Internal listener — receives events and a close signal. */
|
|
170
228
|
interface StreamListener<TEvent> {
|
|
@@ -172,6 +230,8 @@ interface StreamListener<TEvent> {
|
|
|
172
230
|
close(): void;
|
|
173
231
|
}
|
|
174
232
|
export declare function flushPendingStreamRoutes(app: Hono): void;
|
|
233
|
+
/** @internal Used by `@mauroandre/velojs/testing` to reset all streams. */
|
|
234
|
+
export declare function getRegisteredStreams(): ReadonlySet<EventStream<unknown, unknown>>;
|
|
175
235
|
export declare function createEventStream<TEvent, TSnapshot = TEvent>(config?: EventStreamConfig<TEvent, TSnapshot>): EventStream<TEvent, TSnapshot>;
|
|
176
236
|
/**
|
|
177
237
|
* Creates a `source` function that runs `tick` every `intervalMs` while subscribed.
|
package/dist/events.js
CHANGED
|
@@ -1,88 +1,130 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { getAppContext as A } from "./app-context.js";
|
|
2
|
+
const M = 2e4, R = 300 * 1e3, v = "";
|
|
3
|
+
function x(t) {
|
|
4
|
+
const o = A().pendingStreamRoutes.splice(0);
|
|
5
|
+
for (const c of o) c(t);
|
|
5
6
|
}
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
const I = /* @__PURE__ */ new Set();
|
|
8
|
+
function y() {
|
|
9
|
+
return I;
|
|
10
|
+
}
|
|
11
|
+
function L(t = {}) {
|
|
12
|
+
if (t.broadcast && t.channel && console.warn(
|
|
8
13
|
"[velojs] createEventStream: `broadcast: true` ignores `channel`. Remove one of them — broadcast streams have no channels."
|
|
9
|
-
)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
), t.source && t.perChannelSource)
|
|
15
|
+
throw new Error(
|
|
16
|
+
"[velojs] createEventStream: `source` and `perChannelSource` are mutually exclusive. Use `source` for stream-wide producers or `perChannelSource` for per-channel resources, not both."
|
|
17
|
+
);
|
|
18
|
+
const a = /* @__PURE__ */ new Map(), o = /* @__PURE__ */ new Map(), c = /* @__PURE__ */ new Set(), f = /* @__PURE__ */ new Map();
|
|
19
|
+
let l = null, w = 0;
|
|
20
|
+
const i = /* @__PURE__ */ new Map(), b = () => {
|
|
21
|
+
t.source && (l || (l = new AbortController(), Promise.resolve(
|
|
22
|
+
t.source(p, { abortSignal: l.signal })
|
|
15
23
|
).catch((e) => {
|
|
16
24
|
console.error("[velojs] event stream source threw:", e);
|
|
17
25
|
})));
|
|
18
|
-
},
|
|
19
|
-
|
|
26
|
+
}, m = () => {
|
|
27
|
+
w > 0 || l && (l.abort(), l = null);
|
|
28
|
+
}, u = (e) => {
|
|
29
|
+
if (!t.perChannelSource || i.has(e)) return;
|
|
30
|
+
const n = new AbortController();
|
|
31
|
+
i.set(e, n);
|
|
32
|
+
const s = (r, S) => {
|
|
33
|
+
h.emit(e, r, S);
|
|
34
|
+
};
|
|
35
|
+
Promise.resolve(
|
|
36
|
+
t.perChannelSource(e, s, { abortSignal: n.signal })
|
|
37
|
+
).catch((r) => {
|
|
38
|
+
console.error(
|
|
39
|
+
`[velojs] perChannelSource threw for channel "${e}":`,
|
|
40
|
+
r
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
}, d = (e) => {
|
|
44
|
+
const n = i.get(e);
|
|
45
|
+
n && (n.abort(), i.delete(e));
|
|
20
46
|
};
|
|
21
|
-
function
|
|
22
|
-
let
|
|
23
|
-
if (!!t.broadcast ? (
|
|
47
|
+
function p(e, n, s) {
|
|
48
|
+
let r, S, E;
|
|
49
|
+
if (!!t.broadcast ? (r = v, S = e, E = n) : (r = e, S = n, E = s), c.has(r)) {
|
|
24
50
|
console.warn(
|
|
25
|
-
`[velojs] emit() called on closed channel "${
|
|
51
|
+
`[velojs] emit() called on closed channel "${r}" — ignored. Reopen the channel by emitting to a fresh ID, or remove the close() call.`
|
|
26
52
|
);
|
|
27
53
|
return;
|
|
28
54
|
}
|
|
29
|
-
if (
|
|
30
|
-
const
|
|
31
|
-
|
|
55
|
+
if (E?.snapshot) {
|
|
56
|
+
const _ = o.get(r) ?? [];
|
|
57
|
+
_.push(S);
|
|
58
|
+
const T = t.bufferSize ?? 1 / 0;
|
|
59
|
+
_.length > T && _.splice(0, _.length - T), o.set(r, _);
|
|
32
60
|
}
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
for (const
|
|
61
|
+
const C = a.get(r);
|
|
62
|
+
if (C)
|
|
63
|
+
for (const _ of C) _(S);
|
|
36
64
|
}
|
|
37
|
-
function
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
42
|
-
for (const
|
|
43
|
-
const
|
|
44
|
-
|
|
65
|
+
function g(e = v) {
|
|
66
|
+
if (c.has(e)) return;
|
|
67
|
+
c.add(e);
|
|
68
|
+
const n = a.get(e);
|
|
69
|
+
if (n)
|
|
70
|
+
for (const S of n) S.close();
|
|
71
|
+
const s = t.retainMs ?? R, r = f.get(e);
|
|
72
|
+
r && clearTimeout(r), f.set(
|
|
45
73
|
e,
|
|
46
74
|
setTimeout(() => {
|
|
47
|
-
o.delete(e),
|
|
48
|
-
},
|
|
75
|
+
o.delete(e), f.delete(e), c.delete(e);
|
|
76
|
+
}, s)
|
|
49
77
|
);
|
|
50
78
|
}
|
|
51
|
-
const
|
|
52
|
-
emit:
|
|
53
|
-
close:
|
|
79
|
+
const h = {
|
|
80
|
+
emit: p,
|
|
81
|
+
close: g,
|
|
54
82
|
__isVeloEventStream: !0,
|
|
55
83
|
__config: t,
|
|
56
|
-
__listeners:
|
|
84
|
+
__listeners: a,
|
|
57
85
|
__buffers: o,
|
|
58
86
|
__path: t.path,
|
|
59
|
-
__onConnect(e,
|
|
60
|
-
let
|
|
61
|
-
|
|
87
|
+
__onConnect(e, n) {
|
|
88
|
+
let s = a.get(e);
|
|
89
|
+
const r = !s || s.size === 0;
|
|
90
|
+
s || (s = /* @__PURE__ */ new Set(), a.set(e, s)), s.add(n), w++, b(), r && u(e);
|
|
62
91
|
},
|
|
63
|
-
__onDisconnect(e,
|
|
64
|
-
const
|
|
65
|
-
|
|
92
|
+
__onDisconnect(e, n) {
|
|
93
|
+
const s = a.get(e);
|
|
94
|
+
s && (s.delete(n), s.size === 0 && (a.delete(e), d(e)), w--, m());
|
|
95
|
+
},
|
|
96
|
+
__reset() {
|
|
97
|
+
for (const e of a.values())
|
|
98
|
+
for (const n of e)
|
|
99
|
+
try {
|
|
100
|
+
n.close();
|
|
101
|
+
} catch {
|
|
102
|
+
}
|
|
103
|
+
a.clear(), o.clear(), c.clear();
|
|
104
|
+
for (const e of f.values()) clearTimeout(e);
|
|
105
|
+
f.clear(), l && (l.abort(), l = null);
|
|
106
|
+
for (const e of i.values()) e.abort();
|
|
107
|
+
i.clear(), w = 0;
|
|
66
108
|
}
|
|
67
109
|
};
|
|
68
|
-
return t.path &&
|
|
69
|
-
|
|
70
|
-
}),
|
|
110
|
+
return t.path && A().pendingStreamRoutes.push((e) => {
|
|
111
|
+
D(e, t.path, h, t.middlewares ?? []);
|
|
112
|
+
}), I.add(h), h;
|
|
71
113
|
}
|
|
72
|
-
function
|
|
73
|
-
return async (
|
|
114
|
+
function B(t) {
|
|
115
|
+
return async (a, { abortSignal: o }) => {
|
|
74
116
|
for (; !o.aborted; ) {
|
|
75
117
|
try {
|
|
76
|
-
await t.tick(
|
|
77
|
-
} catch (
|
|
78
|
-
console.error("[velojs] poll tick threw:",
|
|
118
|
+
await t.tick(a);
|
|
119
|
+
} catch (c) {
|
|
120
|
+
console.error("[velojs] poll tick threw:", c);
|
|
79
121
|
}
|
|
80
|
-
await new Promise((
|
|
81
|
-
const
|
|
122
|
+
await new Promise((c) => {
|
|
123
|
+
const f = setTimeout(c, t.intervalMs);
|
|
82
124
|
o.addEventListener(
|
|
83
125
|
"abort",
|
|
84
126
|
() => {
|
|
85
|
-
clearTimeout(
|
|
127
|
+
clearTimeout(f), c();
|
|
86
128
|
},
|
|
87
129
|
{ once: !0 }
|
|
88
130
|
);
|
|
@@ -90,51 +132,68 @@ function C(t) {
|
|
|
90
132
|
}
|
|
91
133
|
};
|
|
92
134
|
}
|
|
93
|
-
function
|
|
94
|
-
return t.req.query("channel") ??
|
|
135
|
+
function j(t) {
|
|
136
|
+
return t.req.query("channel") ?? v;
|
|
95
137
|
}
|
|
96
|
-
function
|
|
97
|
-
const
|
|
98
|
-
const { streamSSE:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
138
|
+
function D(t, a, o, c = []) {
|
|
139
|
+
const f = async (l) => {
|
|
140
|
+
const { streamSSE: w } = await import("hono/streaming");
|
|
141
|
+
let i;
|
|
142
|
+
if (o.__config.broadcast)
|
|
143
|
+
i = v;
|
|
144
|
+
else {
|
|
145
|
+
const u = o.__config.channel ?? j;
|
|
146
|
+
let d;
|
|
147
|
+
try {
|
|
148
|
+
d = await u(l);
|
|
149
|
+
} catch (p) {
|
|
150
|
+
return console.error("[velojs] channel resolver threw:", p), l.json({ error: "internal" }, 500);
|
|
151
|
+
}
|
|
152
|
+
if (d == null)
|
|
153
|
+
return l.json({ error: "forbidden" }, 403);
|
|
154
|
+
i = d;
|
|
155
|
+
}
|
|
156
|
+
const b = o.__config.heartbeatMs, m = b === !1 ? 0 : b ?? M;
|
|
157
|
+
return w(l, async (u) => {
|
|
158
|
+
const d = o.__buffers.get(i);
|
|
159
|
+
if (d && d.length > 0 && await u.writeSSE({
|
|
102
160
|
event: "snapshot",
|
|
103
|
-
data: JSON.stringify(
|
|
161
|
+
data: JSON.stringify(d)
|
|
104
162
|
}), o.__config.snapshot) {
|
|
105
|
-
const
|
|
106
|
-
|
|
163
|
+
const r = await o.__config.snapshot(
|
|
164
|
+
i === v ? void 0 : i
|
|
107
165
|
);
|
|
108
|
-
|
|
166
|
+
r !== void 0 && await u.writeSSE({
|
|
109
167
|
event: "snapshot",
|
|
110
|
-
data: JSON.stringify(
|
|
168
|
+
data: JSON.stringify(r)
|
|
111
169
|
});
|
|
112
170
|
}
|
|
113
|
-
let
|
|
114
|
-
const
|
|
115
|
-
|
|
171
|
+
let p;
|
|
172
|
+
const g = new Promise((r) => {
|
|
173
|
+
p = r;
|
|
116
174
|
});
|
|
117
|
-
let
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
},
|
|
121
|
-
|
|
122
|
-
}), o.__config.closeOn?.(
|
|
175
|
+
let h = !1, e = null;
|
|
176
|
+
const n = () => {
|
|
177
|
+
h || (h = !0, e && clearInterval(e), o.__onDisconnect(i, s), p());
|
|
178
|
+
}, s = ((r) => {
|
|
179
|
+
h || (u.writeSSE({ data: JSON.stringify(r) }).catch(() => {
|
|
180
|
+
}), o.__config.closeOn?.(r) && (u.close(), n()));
|
|
123
181
|
});
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}),
|
|
127
|
-
}, o.__onConnect(
|
|
128
|
-
|
|
182
|
+
s.close = () => {
|
|
183
|
+
h || (u.writeSSE({ event: "close", data: "" }).catch(() => {
|
|
184
|
+
}), u.close(), n());
|
|
185
|
+
}, o.__onConnect(i, s), e = m > 0 ? setInterval(() => {
|
|
186
|
+
h || u.writeSSE({ event: "heartbeat", data: "" }).catch(() => {
|
|
129
187
|
});
|
|
130
|
-
},
|
|
188
|
+
}, m) : null, u.onAbort(n), await g;
|
|
131
189
|
});
|
|
132
190
|
};
|
|
133
|
-
|
|
191
|
+
c.length > 0 ? t.on(["GET"], [a], ...c, f) : t.on(["GET"], [a], f);
|
|
134
192
|
}
|
|
135
193
|
export {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
194
|
+
L as createEventStream,
|
|
195
|
+
x as flushPendingStreamRoutes,
|
|
196
|
+
y as getRegisteredStreams,
|
|
197
|
+
B as poll,
|
|
198
|
+
D as registerStreamHandler
|
|
140
199
|
};
|
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";
|