@mauroandre/velojs 0.0.18 → 0.0.20
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 +217 -3
- package/dist/events.d.ts +184 -0
- package/dist/events.js +137 -0
- package/dist/hooks.d.ts +31 -0
- package/dist/hooks.js +79 -46
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -5
- package/dist/server.js +124 -94
- package/dist/vite.d.ts +8 -0
- package/dist/vite.js +200 -169
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -501,6 +501,200 @@ export const action_login = async ({ body }: { body: LoginBody }) => {
|
|
|
501
501
|
|
|
502
502
|
**Error handling**: Actions do NOT throw on server errors. They resolve with `{ error: "message" }`. Always check `result.error` explicitly.
|
|
503
503
|
|
|
504
|
+
### Shared actions in Layouts
|
|
505
|
+
|
|
506
|
+
Actions are tied to the module where they're declared — the route is always `/_action/{moduleId}/{actionName}`. Declare an action in a Layout and import it from any child module to share it across multiple pages.
|
|
507
|
+
|
|
508
|
+
```typescript
|
|
509
|
+
// app/admin/Layout.tsx — action declared once in the layout
|
|
510
|
+
import type { ActionArgs } from "@mauroandre/velojs";
|
|
511
|
+
|
|
512
|
+
export const action_logout = async ({ c }: ActionArgs) => {
|
|
513
|
+
const { deleteCookie } = await import("@mauroandre/velojs/cookie");
|
|
514
|
+
deleteCookie(c!, "session");
|
|
515
|
+
return { ok: true };
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
export const Component = ({ children }) => (/* layout with children */);
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
```typescript
|
|
522
|
+
// app/admin/Dashboard.tsx — imports and uses the action from Layout
|
|
523
|
+
import { action_logout } from "./Layout.js";
|
|
524
|
+
|
|
525
|
+
export const Component = () => (
|
|
526
|
+
<button onClick={async () => {
|
|
527
|
+
await action_logout({});
|
|
528
|
+
window.location.href = "/login";
|
|
529
|
+
}}>Logout</button>
|
|
530
|
+
);
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
The client-side transform rewrites the import to fetch `/_action/admin/Layout/logout`, so the action always points to the correct URL regardless of where it's imported. Middlewares on the Layout apply automatically.
|
|
534
|
+
|
|
535
|
+
---
|
|
536
|
+
|
|
537
|
+
## Event Streams
|
|
538
|
+
|
|
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
|
+
|
|
541
|
+
Three verbs: `emit`, `close`, `useEventStream`. The framework handles routing, types, listener management, snapshots, lifecycle, reconnection, and cleanup.
|
|
542
|
+
|
|
543
|
+
### Shortest example
|
|
544
|
+
|
|
545
|
+
```typescript
|
|
546
|
+
// app/admin/Provision.tsx
|
|
547
|
+
import { createEventStream } from "@mauroandre/velojs";
|
|
548
|
+
import { useEventStream } from "@mauroandre/velojs/hooks";
|
|
549
|
+
|
|
550
|
+
export const stream_logs = createEventStream<string>();
|
|
551
|
+
|
|
552
|
+
export const Component = () => {
|
|
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>;
|
|
556
|
+
};
|
|
557
|
+
```
|
|
558
|
+
|
|
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.
|
|
579
|
+
|
|
580
|
+
```typescript
|
|
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.
|
|
585
|
+
|
|
586
|
+
```typescript
|
|
587
|
+
export const containerMetrics = createEventStream<Metric[]>({
|
|
588
|
+
path: "/api/metrics/containers",
|
|
589
|
+
middlewares: [authMiddleware],
|
|
590
|
+
});
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
### Three ways to emit
|
|
594
|
+
|
|
595
|
+
**Reactive** — call `emit()` from anywhere when something happens. Most common:
|
|
596
|
+
|
|
597
|
+
```typescript
|
|
598
|
+
stream_logs.emit(sessionId, "Line", { snapshot: true });
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
**Source-driven** — pass a `source` function. Framework runs it only while subscribed (zero CPU when nobody is watching):
|
|
602
|
+
|
|
603
|
+
```typescript
|
|
604
|
+
import { poll } from "@mauroandre/velojs";
|
|
605
|
+
|
|
606
|
+
export const stream_metrics = createEventStream<Metric[]>({
|
|
607
|
+
source: poll({
|
|
608
|
+
intervalMs: 3000,
|
|
609
|
+
tick: async (emit) => emit(await collectMetrics()),
|
|
610
|
+
}),
|
|
611
|
+
});
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
**Stateful snapshot** — for state-machine patterns where each emit is the complete current state:
|
|
615
|
+
|
|
616
|
+
```typescript
|
|
617
|
+
const deploys = new Map<string, DeployState>();
|
|
618
|
+
|
|
619
|
+
export const stream_deploy = createEventStream<DeployState>({
|
|
620
|
+
snapshot: (id) => deploys.get(id ?? ""),
|
|
621
|
+
});
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
### Configuration
|
|
625
|
+
|
|
626
|
+
| Option | Type | Description |
|
|
627
|
+
|--------|------|-------------|
|
|
628
|
+
| `path` | `string` | (Standalone) Explicit URL path |
|
|
629
|
+
| `channel` | `(c) => string` | Extract channel ID. Default: `?channel=...` query param |
|
|
630
|
+
| `snapshot` | `(channel) => TSnapshot` | Returns current state on connect (state-machine pattern) |
|
|
631
|
+
| `closeOn` | `(event) => boolean` | Closes SSE when matching event is sent (declarative) |
|
|
632
|
+
| `source` | `(emit, { abortSignal }) => Promise<void>` | Self-running producer, only runs while subscribed |
|
|
633
|
+
| `retainMs` | `number` | Buffer retention after `close()`. Default `300000` (5 min) |
|
|
634
|
+
| `heartbeatMs` | `number \| false` | Heartbeat interval. Default `20000` (20s). `false` to disable |
|
|
635
|
+
| `middlewares` | `MiddlewareHandler[]` | (Standalone) Hono middlewares for the SSE route |
|
|
636
|
+
|
|
637
|
+
### Snapshot mechanisms
|
|
638
|
+
|
|
639
|
+
Two snapshot patterns for two cases:
|
|
640
|
+
|
|
641
|
+
**Per-emit `{ snapshot: true }` — append pattern.** Framework keeps a buffer per channel. Late subscribers receive the array.
|
|
642
|
+
|
|
643
|
+
```typescript
|
|
644
|
+
stream_logs.emit(id, "line 1", { snapshot: true });
|
|
645
|
+
stream_logs.emit(id, "line 2", { snapshot: true });
|
|
646
|
+
// Late subscriber → snapshot.value = ["line 1", "line 2"]
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
**`snapshot: callback` config — replace pattern.** You return the latest state from your own data structure.
|
|
650
|
+
|
|
651
|
+
```typescript
|
|
652
|
+
createEventStream({ snapshot: (id) => deploys.get(id ?? "") });
|
|
653
|
+
// Late subscriber → snapshot.value = the latest DeployState
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
Both survive after `close()` for `retainMs` (default 5 min) so refresh-after-finish still shows final state.
|
|
657
|
+
|
|
658
|
+
### Closing
|
|
659
|
+
|
|
660
|
+
Two ways, choose either or both:
|
|
661
|
+
|
|
662
|
+
```typescript
|
|
663
|
+
// Imperative — call from anywhere
|
|
664
|
+
stream.close(channelId);
|
|
665
|
+
|
|
666
|
+
// Declarative — predicate on event
|
|
667
|
+
createEventStream({ closeOn: (s) => s.status === "success" });
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
After close, subsequent `emit()` to that channel is ignored with a warning.
|
|
671
|
+
|
|
672
|
+
### `useEventStream` hook
|
|
673
|
+
|
|
674
|
+
```typescript
|
|
675
|
+
const { data, snapshot, closed, error } = useEventStream(stream, {
|
|
676
|
+
channel: "deploy-123", // optional
|
|
677
|
+
enabled: true, // optional
|
|
678
|
+
});
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
| Signal | Description |
|
|
682
|
+
|--------|-------------|
|
|
683
|
+
| `data` | Latest event received |
|
|
684
|
+
| `snapshot` | Initial state on connect (buffer or callback) |
|
|
685
|
+
| `closed` | `true` when server closed the stream |
|
|
686
|
+
| `error` | Parse or connection error, if any |
|
|
687
|
+
|
|
688
|
+
Lifecycle is automatic: opens on mount, closes on unmount, re-opens fresh when `channel` changes.
|
|
689
|
+
|
|
690
|
+
### `poll` helper
|
|
691
|
+
|
|
692
|
+
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.
|
|
693
|
+
|
|
694
|
+
```typescript
|
|
695
|
+
poll({ intervalMs: 3000, tick: async (emit) => emit(await collect()) })
|
|
696
|
+
```
|
|
697
|
+
|
|
504
698
|
---
|
|
505
699
|
|
|
506
700
|
## Hooks
|
|
@@ -511,6 +705,7 @@ All hooks work in both SSR and client (via AsyncLocalStorage on server, wouter/D
|
|
|
511
705
|
|------|-------------|
|
|
512
706
|
| `useLoader<T>(deps?)` | Loader data with SSR + SPA support. Returns `{ data, loading, refetch }` |
|
|
513
707
|
| `Loader<T>()` | Module-level SSR-only loader. Returns `{ data, loading }` |
|
|
708
|
+
| `useEventStream<T, S>(stream, opts?)` | Subscribe to a server-sent event stream. Returns `{ data, snapshot, closed, error }` |
|
|
514
709
|
| `useParams<T>()` | Route parameters (e.g., `:id`) |
|
|
515
710
|
| `useQuery<T>()` | Query string parameters |
|
|
516
711
|
| `useNavigate()` | Programmatic navigation. Returns `navigate(path)` function |
|
|
@@ -701,7 +896,25 @@ export default [
|
|
|
701
896
|
|
|
702
897
|
### Inheritance
|
|
703
898
|
|
|
704
|
-
Middlewares accumulate from parent to child
|
|
899
|
+
Middlewares accumulate from parent to child and apply to **every nested route**, including:
|
|
900
|
+
|
|
901
|
+
- **Page routes** (GET) — when loading a page
|
|
902
|
+
- **Action routes** (POST `/_action/...`) — when calling a server action
|
|
903
|
+
- **Data fetches** (GET with `?_data=1`) — during SPA navigation
|
|
904
|
+
|
|
905
|
+
In the example above, `/master/workers` runs `authMiddleware` first, then `masterMiddleware`. The same applies when calling `action_*` functions from any page under `MasterLayout` — both middlewares run before the action executes.
|
|
906
|
+
|
|
907
|
+
```typescript
|
|
908
|
+
// This action, defined in app/master/Workers.tsx, is registered as
|
|
909
|
+
// POST /_action/master/Workers/delete
|
|
910
|
+
// with authMiddleware + masterMiddleware applied.
|
|
911
|
+
export const action_delete = async ({ body, c }: ActionArgs<{ id: string }>) => {
|
|
912
|
+
const user = c!.get("user"); // set by authMiddleware
|
|
913
|
+
// ...
|
|
914
|
+
};
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
A middleware on a Layout guards everything underneath it — pages, loaders, and actions — with no extra wiring.
|
|
705
918
|
|
|
706
919
|
### Accessing middleware data in loaders and actions
|
|
707
920
|
|
|
@@ -927,10 +1140,11 @@ Hooks (`useParams`, `useQuery`, `usePathname`, `Loader`, `useLoader`) access thi
|
|
|
927
1140
|
|
|
928
1141
|
| Import | Contents |
|
|
929
1142
|
|--------|----------|
|
|
930
|
-
| `@mauroandre/velojs` | Types (`AppRoutes`, `ActionArgs`, `LoaderArgs`, `Metadata`), `Scripts`, `Link`, `defineConfig` |
|
|
1143
|
+
| `@mauroandre/velojs` | Types (`AppRoutes`, `ActionArgs`, `LoaderArgs`, `Metadata`, `EventStream`, `EventStreamConfig`, `EmitFn`, `EmitOptions`, `SourceFn`), `Scripts`, `Link`, `createEventStream`, `poll`, `defineConfig` |
|
|
931
1144
|
| `@mauroandre/velojs/server` | `startServer`, `createApp`, `addRoutes`, `onServer`, `serverDataStorage` |
|
|
932
1145
|
| `@mauroandre/velojs/client` | `startClient` |
|
|
933
|
-
| `@mauroandre/velojs/hooks` | `Loader`, `useLoader`, `useParams`, `useQuery`, `useNavigate`, `usePathname`, `touch` |
|
|
1146
|
+
| `@mauroandre/velojs/hooks` | `Loader`, `useLoader`, `useEventStream`, `useParams`, `useQuery`, `useNavigate`, `usePathname`, `touch` |
|
|
1147
|
+
| `@mauroandre/velojs/events` | `createEventStream`, `poll`, `EventStream`, `EventStreamConfig`, `EmitFn`, `EmitOptions`, `SourceFn` (also re-exported from root) |
|
|
934
1148
|
| `@mauroandre/velojs/cookie` | `getCookie`, `setCookie`, `deleteCookie`, `getSignedCookie`, `setSignedCookie` |
|
|
935
1149
|
| `@mauroandre/velojs/factory` | `createMiddleware`, `createFactory` |
|
|
936
1150
|
| `@mauroandre/velojs/vite` | `veloPlugin` |
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VeloJS Event Streams
|
|
3
|
+
* Server-Sent Events (SSE) with snapshot, channels, source-driven emission, and auto-close.
|
|
4
|
+
*
|
|
5
|
+
* Two ways to declare:
|
|
6
|
+
*
|
|
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):
|
|
14
|
+
* ```typescript
|
|
15
|
+
* export const metricsStream = createEventStream<Metric[]>({
|
|
16
|
+
* path: "/api/metrics",
|
|
17
|
+
* middlewares: [authMiddleware],
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
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):
|
|
52
|
+
* ```typescript
|
|
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
|
+
* }
|
|
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.
|
|
65
|
+
*/
|
|
66
|
+
export type SourceFn<TEvent> = (emit: EmitFn<TEvent>, ctx: {
|
|
67
|
+
abortSignal: AbortSignal;
|
|
68
|
+
}) => void | Promise<void>;
|
|
69
|
+
export interface EventStreamConfig<TEvent, TSnapshot = TEvent> {
|
|
70
|
+
/**
|
|
71
|
+
* Explicit URL path for the SSE endpoint (standalone usage).
|
|
72
|
+
* When omitted, the path is derived from the module ID at build time
|
|
73
|
+
* (convention `stream_*`).
|
|
74
|
+
*/
|
|
75
|
+
path?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Channel selector — extracts a channel ID from the request.
|
|
78
|
+
* Subscribers only receive events emitted to their channel.
|
|
79
|
+
* When omitted, defaults to `c.req.query("channel") ?? ""` so client-side
|
|
80
|
+
* `useEventStream(stream, { channel })` works out of the box.
|
|
81
|
+
*/
|
|
82
|
+
channel?: (c: Context) => string;
|
|
83
|
+
/**
|
|
84
|
+
* Returns the current state of a channel for snapshot-on-connect.
|
|
85
|
+
* Sent before any new events to bring the client up to date.
|
|
86
|
+
*
|
|
87
|
+
* Use this for **state-machine patterns** where each emit is a complete state
|
|
88
|
+
* (deploy progress, migration status). The callback returns the latest state.
|
|
89
|
+
*
|
|
90
|
+
* For **append-only patterns** (logs, tokens), prefer `emit(..., { snapshot: true })`
|
|
91
|
+
* which uses an internal buffer managed by the framework.
|
|
92
|
+
*/
|
|
93
|
+
snapshot?: (channel: string | undefined) => TSnapshot | undefined | Promise<TSnapshot | undefined>;
|
|
94
|
+
/**
|
|
95
|
+
* Returns true when an event marks the end of the stream for that channel.
|
|
96
|
+
* The server closes the SSE connection after sending such an event.
|
|
97
|
+
*
|
|
98
|
+
* For imperative close from anywhere, use `stream.close(channel)` instead.
|
|
99
|
+
*/
|
|
100
|
+
closeOn?: (event: TEvent) => boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Heartbeat interval in milliseconds. Sends `:heartbeat\n\n` to keep
|
|
103
|
+
* proxies (Cloudflare, Nginx, ALB) from closing idle connections.
|
|
104
|
+
* Default: 20000 (20s). Set to `false` to disable.
|
|
105
|
+
*/
|
|
106
|
+
heartbeatMs?: number | false;
|
|
107
|
+
/**
|
|
108
|
+
* Middlewares to apply to the SSE route (standalone streams only).
|
|
109
|
+
* For `stream_*` convention streams, middlewares are inherited from
|
|
110
|
+
* parent route nodes automatically.
|
|
111
|
+
*/
|
|
112
|
+
middlewares?: MiddlewareHandler[];
|
|
113
|
+
/**
|
|
114
|
+
* Self-running producer that emits values into the stream.
|
|
115
|
+
* The framework only invokes `source` while there are active subscribers.
|
|
116
|
+
* When the last subscriber disconnects, the AbortSignal fires.
|
|
117
|
+
*
|
|
118
|
+
* Use the `poll` helper for the common interval-based pattern.
|
|
119
|
+
*/
|
|
120
|
+
source?: SourceFn<TEvent>;
|
|
121
|
+
/**
|
|
122
|
+
* Time (ms) the per-channel snapshot buffer is retained after `close()`.
|
|
123
|
+
* Reconnecting clients within this window still see the final state.
|
|
124
|
+
* Default: 5 * 60 * 1000 (5 minutes).
|
|
125
|
+
*/
|
|
126
|
+
retainMs?: number;
|
|
127
|
+
}
|
|
128
|
+
export interface EventStream<TEvent, TSnapshot = TEvent> {
|
|
129
|
+
/** Broadcast emit (no channel) */
|
|
130
|
+
emit(event: TEvent, options?: EmitOptions): void;
|
|
131
|
+
/** Channel-targeted emit */
|
|
132
|
+
emit(channel: string, event: TEvent, options?: EmitOptions): void;
|
|
133
|
+
/**
|
|
134
|
+
* Closes the stream (or a specific channel).
|
|
135
|
+
* - Notifies current subscribers so their `useEventStream().closed` becomes true.
|
|
136
|
+
* - Schedules cleanup of the per-channel buffer after `retainMs`.
|
|
137
|
+
* - Idempotent: calling close on an already-closed channel is a no-op.
|
|
138
|
+
*/
|
|
139
|
+
close(channel?: string): void;
|
|
140
|
+
/** Internal: marker for runtime detection */
|
|
141
|
+
readonly __isVeloEventStream: true;
|
|
142
|
+
/** Internal: configuration */
|
|
143
|
+
readonly __config: EventStreamConfig<TEvent, TSnapshot>;
|
|
144
|
+
/** Internal: registered listeners by channel ("" for broadcast) */
|
|
145
|
+
readonly __listeners: Map<string, Set<StreamListener<TEvent>>>;
|
|
146
|
+
/** Internal: per-channel buffer for { snapshot: true } emits */
|
|
147
|
+
readonly __buffers: Map<string, TEvent[]>;
|
|
148
|
+
/** Internal: assigned URL path (set by standalone or by stream_* discovery) */
|
|
149
|
+
__path: string | undefined;
|
|
150
|
+
/** Internal: source lifecycle hook */
|
|
151
|
+
__onConnect(channelKey: string, listener: StreamListener<TEvent>): void;
|
|
152
|
+
/** Internal: source lifecycle hook */
|
|
153
|
+
__onDisconnect(channelKey: string, listener: StreamListener<TEvent>): void;
|
|
154
|
+
}
|
|
155
|
+
/** Internal listener — receives events and a close signal. */
|
|
156
|
+
interface StreamListener<TEvent> {
|
|
157
|
+
(event: TEvent): void;
|
|
158
|
+
close(): void;
|
|
159
|
+
}
|
|
160
|
+
export declare function flushPendingStreamRoutes(app: Hono): void;
|
|
161
|
+
export declare function createEventStream<TEvent, TSnapshot = TEvent>(config?: EventStreamConfig<TEvent, TSnapshot>): EventStream<TEvent, TSnapshot>;
|
|
162
|
+
/**
|
|
163
|
+
* Creates a `source` function that runs `tick` every `intervalMs` while subscribed.
|
|
164
|
+
* The tick function is wrapped in try/catch — errors are logged but don't stop the loop.
|
|
165
|
+
*
|
|
166
|
+
* ```typescript
|
|
167
|
+
* export const stream_metrics = createEventStream<Metric[]>({
|
|
168
|
+
* source: poll({
|
|
169
|
+
* intervalMs: 3000,
|
|
170
|
+
* tick: async (emit) => emit(await collectMetrics()),
|
|
171
|
+
* }),
|
|
172
|
+
* });
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
175
|
+
export declare function poll<TEvent>(opts: {
|
|
176
|
+
intervalMs: number;
|
|
177
|
+
tick: (emit: EmitFn<TEvent>) => void | Promise<void>;
|
|
178
|
+
}): SourceFn<TEvent>;
|
|
179
|
+
/**
|
|
180
|
+
* Registers a GET SSE route for the given stream at the given path.
|
|
181
|
+
* Used both by standalone streams and by stream_* discovery.
|
|
182
|
+
*/
|
|
183
|
+
export declare function registerStreamHandler<TEvent, TSnapshot>(app: Hono, path: string, stream: EventStream<TEvent, TSnapshot>, middlewares?: Array<(c: Context, next: () => Promise<void>) => any>): void;
|
|
184
|
+
export {};
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const p = [];
|
|
2
|
+
function m(t) {
|
|
3
|
+
for (const r of p) r(t);
|
|
4
|
+
p.length = 0;
|
|
5
|
+
}
|
|
6
|
+
function C(t = {}) {
|
|
7
|
+
const r = /* @__PURE__ */ new Map(), n = /* @__PURE__ */ new Map(), a = /* @__PURE__ */ new Set(), _ = /* @__PURE__ */ new Map();
|
|
8
|
+
let u = null, S = 0;
|
|
9
|
+
const b = () => {
|
|
10
|
+
t.source && (u || (u = new AbortController(), Promise.resolve(
|
|
11
|
+
t.source(A, { abortSignal: u.signal })
|
|
12
|
+
).catch((e) => {
|
|
13
|
+
console.error("[velojs] event stream source threw:", e);
|
|
14
|
+
})));
|
|
15
|
+
}, h = () => {
|
|
16
|
+
S > 0 || u && (u.abort(), u = null);
|
|
17
|
+
};
|
|
18
|
+
function A(e, i, s) {
|
|
19
|
+
let o, l, d;
|
|
20
|
+
if (!!t.channel ? (o = e, l = i, d = s) : (o = "", l = e, d = i), a.has(o)) {
|
|
21
|
+
console.warn(
|
|
22
|
+
`[velojs] emit() called on closed channel "${o}" — ignored. Reopen the channel by emitting to a fresh ID, or remove the close() call.`
|
|
23
|
+
);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (d?.snapshot) {
|
|
27
|
+
const T = n.get(o) ?? [];
|
|
28
|
+
T.push(l), n.set(o, T);
|
|
29
|
+
}
|
|
30
|
+
const f = r.get(o);
|
|
31
|
+
if (f)
|
|
32
|
+
for (const T of f) T(l);
|
|
33
|
+
}
|
|
34
|
+
function E(e = "") {
|
|
35
|
+
if (a.has(e)) return;
|
|
36
|
+
a.add(e);
|
|
37
|
+
const i = r.get(e);
|
|
38
|
+
if (i)
|
|
39
|
+
for (const l of i) l.close();
|
|
40
|
+
const s = t.retainMs ?? 3e5, o = _.get(e);
|
|
41
|
+
o && clearTimeout(o), _.set(
|
|
42
|
+
e,
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
n.delete(e), _.delete(e), a.delete(e);
|
|
45
|
+
}, s)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const c = {
|
|
49
|
+
emit: A,
|
|
50
|
+
close: E,
|
|
51
|
+
__isVeloEventStream: !0,
|
|
52
|
+
__config: t,
|
|
53
|
+
__listeners: r,
|
|
54
|
+
__buffers: n,
|
|
55
|
+
__path: t.path,
|
|
56
|
+
__onConnect(e, i) {
|
|
57
|
+
let s = r.get(e);
|
|
58
|
+
s || (s = /* @__PURE__ */ new Set(), r.set(e, s)), s.add(i), S++, b();
|
|
59
|
+
},
|
|
60
|
+
__onDisconnect(e, i) {
|
|
61
|
+
const s = r.get(e);
|
|
62
|
+
s && (s.delete(i), s.size === 0 && r.delete(e), S--, h());
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
return t.path && p.push((e) => {
|
|
66
|
+
v(e, t.path, c, t.middlewares ?? []);
|
|
67
|
+
}), c;
|
|
68
|
+
}
|
|
69
|
+
function N(t) {
|
|
70
|
+
return async (r, { abortSignal: n }) => {
|
|
71
|
+
for (; !n.aborted; ) {
|
|
72
|
+
try {
|
|
73
|
+
await t.tick(r);
|
|
74
|
+
} catch (a) {
|
|
75
|
+
console.error("[velojs] poll tick threw:", a);
|
|
76
|
+
}
|
|
77
|
+
await new Promise((a) => {
|
|
78
|
+
const _ = setTimeout(a, t.intervalMs);
|
|
79
|
+
n.addEventListener(
|
|
80
|
+
"abort",
|
|
81
|
+
() => {
|
|
82
|
+
clearTimeout(_), a();
|
|
83
|
+
},
|
|
84
|
+
{ once: !0 }
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function g(t) {
|
|
91
|
+
return t.req.query("channel") ?? "";
|
|
92
|
+
}
|
|
93
|
+
function v(t, r, n, a = []) {
|
|
94
|
+
const _ = async (u) => {
|
|
95
|
+
const { streamSSE: S } = await import("hono/streaming"), h = (n.__config.channel ?? g)(u), A = n.__config.heartbeatMs, E = A === !1 ? 0 : A ?? 2e4;
|
|
96
|
+
return S(u, async (c) => {
|
|
97
|
+
const e = n.__buffers.get(h);
|
|
98
|
+
if (e && e.length > 0 && await c.writeSSE({
|
|
99
|
+
event: "snapshot",
|
|
100
|
+
data: JSON.stringify(e)
|
|
101
|
+
}), n.__config.snapshot) {
|
|
102
|
+
const f = await n.__config.snapshot(
|
|
103
|
+
h === "" ? void 0 : h
|
|
104
|
+
);
|
|
105
|
+
f !== void 0 && await c.writeSSE({
|
|
106
|
+
event: "snapshot",
|
|
107
|
+
data: JSON.stringify(f)
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
let i;
|
|
111
|
+
const s = new Promise((f) => {
|
|
112
|
+
i = f;
|
|
113
|
+
});
|
|
114
|
+
let o = !1, l = null;
|
|
115
|
+
const d = () => {
|
|
116
|
+
o || (o = !0, l && clearInterval(l), n.__onDisconnect(h, w), i());
|
|
117
|
+
}, w = ((f) => {
|
|
118
|
+
o || (c.writeSSE({ data: JSON.stringify(f) }).catch(() => {
|
|
119
|
+
}), n.__config.closeOn?.(f) && (c.close(), d()));
|
|
120
|
+
});
|
|
121
|
+
w.close = () => {
|
|
122
|
+
o || (c.writeSSE({ event: "close", data: "" }).catch(() => {
|
|
123
|
+
}), c.close(), d());
|
|
124
|
+
}, n.__onConnect(h, w), l = E > 0 ? setInterval(() => {
|
|
125
|
+
o || c.writeSSE({ event: "heartbeat", data: "" }).catch(() => {
|
|
126
|
+
});
|
|
127
|
+
}, E) : null, c.onAbort(d), await s;
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
a.length > 0 ? t.on(["GET"], [r], ...a, _) : t.on(["GET"], [r], _);
|
|
131
|
+
}
|
|
132
|
+
export {
|
|
133
|
+
C as createEventStream,
|
|
134
|
+
m as flushPendingStreamRoutes,
|
|
135
|
+
N as poll,
|
|
136
|
+
v as registerStreamHandler
|
|
137
|
+
};
|
package/dist/hooks.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Signal } from "@preact/signals";
|
|
2
|
+
import type { EventStream } from "./events.js";
|
|
2
3
|
export declare const __veloUpdatePending: Signal<boolean>;
|
|
3
4
|
/**
|
|
4
5
|
* Força o signal a notificar mudanças após mutação de propriedades aninhadas
|
|
@@ -118,3 +119,33 @@ export declare function useQuery<T extends Record<string, string> = Record<strin
|
|
|
118
119
|
* ```
|
|
119
120
|
*/
|
|
120
121
|
export declare function usePathname(): string;
|
|
122
|
+
/**
|
|
123
|
+
* Subscribes to a server event stream (SSE) created with createEventStream.
|
|
124
|
+
*
|
|
125
|
+
* Returns:
|
|
126
|
+
* - `data`: Signal with the latest event received (null until first event)
|
|
127
|
+
* - `snapshot`: Signal with the initial snapshot if the server provided one
|
|
128
|
+
* - `closed`: Signal that becomes true when the stream is closed by the server
|
|
129
|
+
* - `error`: Signal with the connection error, if any
|
|
130
|
+
*
|
|
131
|
+
* Usage:
|
|
132
|
+
* ```tsx
|
|
133
|
+
* // Per-page stream (stream_* convention)
|
|
134
|
+
* const { data, snapshot } = useEventStream(stream_progress, { channel: deployId });
|
|
135
|
+
*
|
|
136
|
+
* // Standalone stream (no channel)
|
|
137
|
+
* const { data } = useEventStream(metricsStream);
|
|
138
|
+
* ```
|
|
139
|
+
*
|
|
140
|
+
* The connection closes automatically when the component unmounts or when
|
|
141
|
+
* the channel changes (re-opens with new channel).
|
|
142
|
+
*/
|
|
143
|
+
export declare function useEventStream<TEvent, TSnapshot = TEvent>(stream: EventStream<TEvent, TSnapshot>, options?: {
|
|
144
|
+
channel?: string;
|
|
145
|
+
enabled?: boolean;
|
|
146
|
+
}): {
|
|
147
|
+
data: Signal<TEvent | null>;
|
|
148
|
+
snapshot: Signal<TSnapshot | null>;
|
|
149
|
+
closed: Signal<boolean>;
|
|
150
|
+
error: Signal<Error | null>;
|
|
151
|
+
};
|