@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 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 fresh when `channel` changes.
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), c = () => D(t), d = S(!0);
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) || c();
51
- }, r ?? []), { data: o, loading: a, refetch: c };
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: c, enabled: d = !0 } = n;
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
- t.value = null, r.value = null, o.value = !1, a.value = null;
80
- const g = c ? `${e.__path}?channel=${encodeURIComponent(c)}` : e.__path, s = new EventSource(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, c, d]), { data: t, snapshot: r, closed: o, error: a };
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), c = S([]), { channel: d, onMessage: g, onOpen: s, onClose: h, onError: y, enabled: L = !0 } = n;
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", r.value = null, o.value = null;
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 l = c.current;
116
- c.current = [];
117
- for (const _ of l)
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", (l) => {
123
+ }), f.addEventListener("message", (c) => {
124
124
  let _;
125
- if (typeof l.data == "string")
126
- _ = l.data;
127
- else if (l.data instanceof ArrayBuffer)
128
- _ = new Uint8Array(l.data);
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", (l) => {
133
- t.value = "closed", a.current = null, h?.(l);
134
- }), f.addEventListener("error", (l) => {
135
- o.value = l, y?.(l);
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 && c.current.push(v);
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mauroandre/velojs",
3
- "version": "0.0.53",
3
+ "version": "0.0.54",
4
4
  "type": "module",
5
5
  "description": "Fullstack web framework built on Hono + Preact with Vite-powered AST transforms",
6
6
  "keywords": [