@mauroandre/velojs 0.0.53 → 0.0.54
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 +21 -1
- package/agent/skills/velojs-event-streams/SKILL.md +28 -0
- package/agent/skills/velojs-sockets/SKILL.md +16 -0
- package/dist/hooks.d.ts +12 -0
- package/dist/hooks.js +22 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -731,7 +731,24 @@ const { data, snapshot, closed, error } = useEventStream(stream, {
|
|
|
731
731
|
| `closed` | `true` when server closed the stream |
|
|
732
732
|
| `error` | Parse or connection error, if any |
|
|
733
733
|
|
|
734
|
-
Lifecycle is automatic: opens on mount, closes on unmount, re-opens
|
|
734
|
+
Lifecycle is automatic: opens on mount, closes on unmount, re-opens when `channel` changes.
|
|
735
|
+
|
|
736
|
+
**Reconnect is stale-while-revalidate.** When the connection re-opens — `channel`, `stream` or `enabled` changed — `data` and `snapshot` keep their previous values until the first event of the new connection overwrites them, the same choice the loader store makes (a refresh must not blank the screen). A channel switch therefore never paints an empty state; only `closed` and `error` reset, because a re-open is a fresh attempt. First mount still starts at `null` and fills in when the snapshot arrives. An app that wants to blank the view on a new channel can clear the signals itself when the channel changes.
|
|
737
|
+
|
|
738
|
+
**Channel coming from loader data?** Pass `enabled` so the hook does not connect before the channel exists — the first render of a SPA navigation has `data = null`, and connecting with `channel: undefined` reaches the server without `?channel=`, which a resolver answers with `null` → 403 → a flash of `closed`:
|
|
739
|
+
|
|
740
|
+
```tsx
|
|
741
|
+
const { data } = useLoader<{ id: string }>();
|
|
742
|
+
const channel = data.value?.id;
|
|
743
|
+
|
|
744
|
+
// no connection until the loader resolves the channel
|
|
745
|
+
const { snapshot, closed } = useEventStream(stream_logs, {
|
|
746
|
+
// spread only when it exists — `exactOptionalPropertyTypes` (the repo's own
|
|
747
|
+
// tsconfig) rejects an explicit `undefined` in an optional property
|
|
748
|
+
...(channel != null && { channel }),
|
|
749
|
+
enabled: channel != null,
|
|
750
|
+
});
|
|
751
|
+
```
|
|
735
752
|
|
|
736
753
|
### `poll` helper
|
|
737
754
|
|
|
@@ -853,6 +870,9 @@ const { send, status, lastMessage, close } = useSocket(socket_terminal, {
|
|
|
853
870
|
```
|
|
854
871
|
|
|
855
872
|
- `status: Signal<"connecting" | "open" | "closed">` — reactive.
|
|
873
|
+
- `lastMessage: Signal<string | Uint8Array | null>` — last frame received.
|
|
874
|
+
- **Reconnect preserves `lastMessage`** — changing `stub.__path`, `channel` or `enabled` opens a new socket, but the previous frame stays until the first frame of the new one arrives (stale-while-revalidate, same as `useEventStream`); `status` restarts at `"connecting"` and `error` resets. Swapping the stub object with the same `__path` does not reconnect. Clear `lastMessage` yourself in `onOpen` if a channel switch must blank the view.
|
|
875
|
+
- **Channel coming from loader data?** Pass `enabled: channel != null` so the socket only opens once the channel resolves — same reason as `useEventStream`.
|
|
856
876
|
- **No auto-reconnect** — sockets are usually stateful (pty sessions, collaborative state); blind reconnect loses state silently. Re-mount or toggle `enabled` to reconnect.
|
|
857
877
|
|
|
858
878
|
### Testing
|
|
@@ -389,6 +389,34 @@ useEventStream(stream, {
|
|
|
389
389
|
|
|
390
390
|
The hook handles `EventSource` lifecycle automatically: opens on mount, closes on unmount, re-opens with a fresh connection when `channel` changes.
|
|
391
391
|
|
|
392
|
+
### Reconnect keeps the last value (stale-while-revalidate)
|
|
393
|
+
|
|
394
|
+
When the connection re-opens — `channel`, `stream` or `enabled` changed — `data` and `snapshot` keep their previous values until the first event of the new connection overwrites them. It is the same choice the loader store makes: a refresh must not blank the screen. Without it, a channel switch (navigating from `/deploys/1` to `/deploys/2` with the stream in a layout that survives the navigation) would paint an empty state for a full round-trip before the new snapshot arrives.
|
|
395
|
+
|
|
396
|
+
`closed` and `error` do reset on a re-open: a reconnect is a fresh attempt, not a closed stream. First mount still starts at `null` and fills in when the snapshot arrives — seed it from loader data if that first frame matters to your UI.
|
|
397
|
+
|
|
398
|
+
If a channel switch must blank the view, clear the signals yourself in an effect keyed on the channel.
|
|
399
|
+
|
|
400
|
+
### Don't connect before the channel exists
|
|
401
|
+
|
|
402
|
+
When `channel` derives from loader data, the first render of a SPA navigation has it `undefined` (the loader fetch is post-mount). The hook would then connect **without** `?channel=`, and a resolver that returns `null`/`undefined` for a missing channel answers **403** — the client sees `closed = true` and paints a broken state for the milliseconds until the loader resolves and the effect reconnects.
|
|
403
|
+
|
|
404
|
+
Pass `enabled` to gate the connection on the channel:
|
|
405
|
+
|
|
406
|
+
```tsx
|
|
407
|
+
const { data } = useLoader<{ id: string }>();
|
|
408
|
+
const channel = data.value?.id;
|
|
409
|
+
|
|
410
|
+
const { snapshot, closed } = useEventStream(stream_logs, {
|
|
411
|
+
// spread only when it exists — `exactOptionalPropertyTypes` (the repo's own
|
|
412
|
+
// tsconfig) rejects an explicit `undefined` in an optional property
|
|
413
|
+
...(channel != null && { channel }),
|
|
414
|
+
enabled: channel != null, // no connection until the loader resolves the id
|
|
415
|
+
});
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
That flash is a documentation-level concern, not a hook one: the hook cannot distinguish "channel still loading" from "stream has no channel". `enabled` is the switch that expresses the difference.
|
|
419
|
+
|
|
392
420
|
## Heartbeat
|
|
393
421
|
|
|
394
422
|
VeloJS sends a `:heartbeat` SSE comment every 20 seconds by default. Without this, idle connections are closed by Cloudflare (~100s), Nginx (~60s), and AWS ALB (~60s) — and the client doesn't even notice.
|
|
@@ -206,6 +206,22 @@ const { send, status, lastMessage, error, close } = useSocket(stub, {
|
|
|
206
206
|
- `send(msg)` — queues messages sent while `connecting`; drops messages sent while `closed`.
|
|
207
207
|
- `close(code?, reason?)` — client-initiated disconnect.
|
|
208
208
|
|
|
209
|
+
**Reconnect keeps `lastMessage` (stale-while-revalidate).** Changing `stub.__path`, `channel` or `enabled` opens a fresh socket, but the previous frame stays in `lastMessage` until the first frame of the new socket arrives — switching from a worker terminal to another one does not paint a blank view for a round-trip. It is the same choice the loader store and `useEventStream` make. `status` restarts at `"connecting"` and `error` resets, because a reconnect is a fresh attempt; a new stub object with the same `__path` is not a reconnect at all. If a channel switch must blank the view, clear `lastMessage` in `onOpen`.
|
|
210
|
+
|
|
211
|
+
**Channel coming from loader data?** The first render of a SPA navigation has no channel yet (the loader fetch is post-mount), and connecting then reaches the server without `?channel=` — for a guarded socket that is an error, not a connection. Pass `enabled: channel != null` to wait for the loader:
|
|
212
|
+
|
|
213
|
+
```tsx
|
|
214
|
+
const { data } = useLoader<{ workerId: string }>();
|
|
215
|
+
const channel = data.value?.workerId;
|
|
216
|
+
|
|
217
|
+
const { send, status, lastMessage } = useSocket(socket_terminal, {
|
|
218
|
+
// spread only when it exists — `exactOptionalPropertyTypes` (the repo's own
|
|
219
|
+
// tsconfig) rejects an explicit `undefined` in an optional property
|
|
220
|
+
...(channel != null && { channel }),
|
|
221
|
+
enabled: channel != null, // no socket until the loader resolves the id
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
209
225
|
**No auto-reconnect.** If the server closes the socket (network drop, server restart), `status` goes to `"closed"` and stays there. The caller decides whether/when to reconnect — typically by toggling `enabled` or re-mounting the component. This is intentional: sockets are usually stateful (pty session, collaborative cursor) and a blind reconnect would silently lose state.
|
|
210
226
|
|
|
211
227
|
## Testing
|
package/dist/hooks.d.ts
CHANGED
|
@@ -166,6 +166,13 @@ export declare function usePathname(): string;
|
|
|
166
166
|
*
|
|
167
167
|
* The connection closes automatically when the component unmounts or when
|
|
168
168
|
* the channel changes (re-opens with new channel).
|
|
169
|
+
*
|
|
170
|
+
* Stale-while-revalidate: on a re-open (new `channel`/`stream`/`enabled`) the
|
|
171
|
+
* previous `data`/`snapshot` stay in place until the first event of the new
|
|
172
|
+
* connection overwrites them — the same choice the loader store makes. Only
|
|
173
|
+
* `closed`/`error` reset, because a re-open is a fresh attempt. To connect
|
|
174
|
+
* only once a channel derived from loader data has resolved, pass
|
|
175
|
+
* `enabled: channel != null`.
|
|
169
176
|
*/
|
|
170
177
|
export declare function useEventStream<TEvent, TSnapshot = TEvent>(stream: EventStream<TEvent, TSnapshot>, options?: {
|
|
171
178
|
channel?: string;
|
|
@@ -218,6 +225,11 @@ export interface UseSocketApi {
|
|
|
218
225
|
* becomes `"closed"` and stays that way — user flow decides whether to
|
|
219
226
|
* reconnect (typically by re-mounting or toggling `enabled`).
|
|
220
227
|
*
|
|
228
|
+
* Stale-while-revalidate: on reconnect (new `stub.__path`/`channel`/`enabled`)
|
|
229
|
+
* `lastMessage` keeps its previous value until the first frame of the new
|
|
230
|
+
* socket arrives; `status`/`error` restart as a fresh attempt
|
|
231
|
+
* (`"connecting"` → `"open"`).
|
|
232
|
+
*
|
|
221
233
|
* ```tsx
|
|
222
234
|
* const { send, status, lastMessage } = useSocket(socket_terminal, {
|
|
223
235
|
* channel: workerId,
|
package/dist/hooks.js
CHANGED
|
@@ -45,10 +45,10 @@ function B(e, n) {
|
|
|
45
45
|
if (!t)
|
|
46
46
|
return { data: u(null), loading: u(!1), refetch: () => {
|
|
47
47
|
} };
|
|
48
|
-
const o = N(t), a = m(t),
|
|
48
|
+
const o = N(t), a = m(t), l = () => D(t), d = S(!0);
|
|
49
49
|
return E(() => {
|
|
50
|
-
d.current && (d.current = !1, o.value !== null) ||
|
|
51
|
-
}, r ?? []), { data: o, loading: a, refetch:
|
|
50
|
+
d.current && (d.current = !1, o.value !== null) || l();
|
|
51
|
+
}, r ?? []), { data: o, loading: a, refetch: l };
|
|
52
52
|
}
|
|
53
53
|
function J() {
|
|
54
54
|
const [, e] = b();
|
|
@@ -69,15 +69,15 @@ function x() {
|
|
|
69
69
|
return typeof window > "u" ? globalThis.__veloServerData?.getStore?.()?.__pathname ?? "/" : (b(), window.location.pathname);
|
|
70
70
|
}
|
|
71
71
|
function G(e, n = {}) {
|
|
72
|
-
const t = u(null), r = u(null), o = u(!1), a = u(null), { channel:
|
|
72
|
+
const t = u(null), r = u(null), o = u(!1), a = u(null), { channel: l, enabled: d = !0 } = n;
|
|
73
73
|
return E(() => {
|
|
74
74
|
if (typeof window > "u" || !d) return;
|
|
75
75
|
if (!e.__path) {
|
|
76
76
|
console.warn("[velojs] useEventStream: stream has no __path");
|
|
77
77
|
return;
|
|
78
78
|
}
|
|
79
|
-
|
|
80
|
-
const g =
|
|
79
|
+
o.value = !1, a.value = null;
|
|
80
|
+
const g = l ? `${e.__path}?channel=${encodeURIComponent(l)}` : e.__path, s = new EventSource(g);
|
|
81
81
|
return s.addEventListener("snapshot", (h) => {
|
|
82
82
|
try {
|
|
83
83
|
r.value = JSON.parse(h.data);
|
|
@@ -98,41 +98,41 @@ function G(e, n = {}) {
|
|
|
98
98
|
}), () => {
|
|
99
99
|
s.close();
|
|
100
100
|
};
|
|
101
|
-
}, [e,
|
|
101
|
+
}, [e, l, d]), { data: t, snapshot: r, closed: o, error: a };
|
|
102
102
|
}
|
|
103
103
|
function F(e, n = {}) {
|
|
104
|
-
const t = u("connecting"), r = u(null), o = u(null), a = S(null),
|
|
104
|
+
const t = u("connecting"), r = u(null), o = u(null), a = S(null), l = S([]), { channel: d, onMessage: g, onOpen: s, onClose: h, onError: y, enabled: L = !0 } = n;
|
|
105
105
|
return E(() => {
|
|
106
106
|
if (typeof window > "u" || !L) return;
|
|
107
107
|
if (!e.__path) {
|
|
108
108
|
console.warn("[velojs] useSocket: stub has no __path");
|
|
109
109
|
return;
|
|
110
110
|
}
|
|
111
|
-
t.value = "connecting",
|
|
111
|
+
t.value = "connecting", o.value = null;
|
|
112
112
|
const v = `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}`, i = d ? `${v}${e.__path}?channel=${encodeURIComponent(d)}` : `${v}${e.__path}`, f = new WebSocket(i);
|
|
113
113
|
return f.binaryType = "arraybuffer", a.current = f, f.addEventListener("open", () => {
|
|
114
114
|
t.value = "open";
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
for (const _ of
|
|
115
|
+
const c = l.current;
|
|
116
|
+
l.current = [];
|
|
117
|
+
for (const _ of c)
|
|
118
118
|
try {
|
|
119
119
|
f.send(_);
|
|
120
120
|
} catch {
|
|
121
121
|
}
|
|
122
122
|
s?.();
|
|
123
|
-
}), f.addEventListener("message", (
|
|
123
|
+
}), f.addEventListener("message", (c) => {
|
|
124
124
|
let _;
|
|
125
|
-
if (typeof
|
|
126
|
-
_ =
|
|
127
|
-
else if (
|
|
128
|
-
_ = new Uint8Array(
|
|
125
|
+
if (typeof c.data == "string")
|
|
126
|
+
_ = c.data;
|
|
127
|
+
else if (c.data instanceof ArrayBuffer)
|
|
128
|
+
_ = new Uint8Array(c.data);
|
|
129
129
|
else
|
|
130
130
|
return;
|
|
131
131
|
r.value = _, g?.(_);
|
|
132
|
-
}), f.addEventListener("close", (
|
|
133
|
-
t.value = "closed", a.current = null, h?.(
|
|
134
|
-
}), f.addEventListener("error", (
|
|
135
|
-
o.value =
|
|
132
|
+
}), f.addEventListener("close", (c) => {
|
|
133
|
+
t.value = "closed", a.current = null, h?.(c);
|
|
134
|
+
}), f.addEventListener("error", (c) => {
|
|
135
|
+
o.value = c, y?.(c);
|
|
136
136
|
}), () => {
|
|
137
137
|
a.current = null;
|
|
138
138
|
try {
|
|
@@ -149,7 +149,7 @@ function F(e, n = {}) {
|
|
|
149
149
|
i.send(v);
|
|
150
150
|
} catch {
|
|
151
151
|
}
|
|
152
|
-
else i && i.readyState === WebSocket.CONNECTING &&
|
|
152
|
+
else i && i.readyState === WebSocket.CONNECTING && l.current.push(v);
|
|
153
153
|
}, close: (p, v) => {
|
|
154
154
|
const i = a.current;
|
|
155
155
|
if (i)
|