gemstack 0.3.6 → 0.4.0

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.
@@ -4,109 +4,535 @@
4
4
  * const off = realtime.subscribe("orders:42", (event) => console.log(event.event, event.data));
5
5
  * off(); // unsubscribe
6
6
  *
7
- * useRealtime("products", (event) => queryClient.invalidateQueries({ queryKey: ["products"] }));
7
+ * await realtime.send("rooms:1", "message.create", { body: "Hi" }); // handled by `receive` in config/channels.rb
8
8
  *
9
- * One EventSource per tab carries every channel; changes to the set of
10
- * channels made in the same tick cause a single reconnect. After a dropped
11
- * connection the browser reconnects with Last-Event-ID and the server replays
12
- * what was missed — or sends `gemstack.gap` (delivered to every handler) when
13
- * it can't, so you can refetch. A refused channel's handlers get `gemstack.denied`
14
- * while the other channels keep working.
9
+ * useRealtime("products", () => queryClient.invalidateQueries({ queryKey: ["products"] }));
10
+ * const people = usePresence("rooms:1"); // [{ id, meta }] — channels declared with presence: true
11
+ * const status = useRealtimeStatus(); // idle | connecting | open | reconnecting | offline
12
+ *
13
+ * One connection per tab to `<api path>/realtime` on the API's origin (the
14
+ * page's own origin unless NEXT_PUBLIC_GEMSTACK_API_URL says otherwise), so
15
+ * the session cookie authenticates it. Two transports, same features:
16
+ *
17
+ * websocket one WebSocket carries subscriptions, events, presence and send()
18
+ * sse an EventSource stream for the subscribed channels (reopened
19
+ * when they change), and send() as a POST
20
+ *
21
+ * NEXT_PUBLIC_GEMSTACK_REALTIME picks one: "auto" (default: WebSocket, then
22
+ * Server-Sent Events if a WebSocket can't be opened — e.g. a proxy that
23
+ * doesn't pass WebSocket upgrades), "websocket" or "sse".
24
+ *
25
+ * It connects when the first channel is subscribed and closes after the last
26
+ * one goes. If it drops, it reconnects with backoff (at once when the browser
27
+ * comes back online) and resubscribes; the server replays what was missed, or
28
+ * sends `gemstack.gap` to the channel's handlers when it can't, so you can
29
+ * refetch. A refused channel's handlers get `gemstack.denied`.
15
30
  */
16
31
  "use client";
17
32
 
18
- import { useEffect, useRef } from "react";
33
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
19
34
  import { apiUrl } from "./client";
20
35
 
21
36
  export type RealtimeEvent<T = unknown> = {
22
37
  id: string | null;
23
- channel: string | null;
38
+ channel: string;
24
39
  event: string;
25
40
  data: T;
26
41
  };
27
42
 
28
43
  export type RealtimeHandler<T = unknown> = (event: RealtimeEvent<T>) => void;
29
- export type RealtimeStatus = "idle" | "connecting" | "open" | "reconnecting";
44
+ export type RealtimeStatus = "idle" | "connecting" | "open" | "reconnecting" | "offline";
45
+ export type RealtimeTransport = "websocket" | "sse";
46
+ export type RealtimeMode = RealtimeTransport | "auto";
47
+ export type PresenceEntry<M = Record<string, unknown>> = { id: string; meta?: M };
30
48
 
49
+ /** Delivered to a channel's handlers when events were missed and can't be replayed: refetch. */
31
50
  export const GAP_EVENT = "gemstack.gap";
32
51
  /** Delivered to a channel's handlers when the server refuses the subscription. */
33
52
  export const DENIED_EVENT = "gemstack.denied";
34
53
 
35
- class RealtimeClient {
36
- private handlers = new Map<string, Set<RealtimeHandler>>();
37
- private source: EventSource | null = null;
38
- private connectedKey = "";
39
- private lastEventId: string | null = null;
40
- private scheduled = false;
41
- private statusListeners = new Set<(status: RealtimeStatus) => void>();
54
+ /** A `realtime.send` the server refused or couldn't handle. */
55
+ export class RealtimeError extends Error {
56
+ readonly code: string;
57
+ constructor(code: string, message: string) {
58
+ super(message);
59
+ this.name = "RealtimeError";
60
+ this.code = code;
61
+ }
62
+ }
63
+
64
+ type ErrorBody = { code: string; message: string };
65
+
66
+ // WebSocket messages.
67
+ type ServerMessage =
68
+ | { type: "welcome"; connection_id: string; heartbeat: number }
69
+ | { type: "subscribed"; channel: string; presence?: PresenceEntry[] }
70
+ | { type: "unsubscribed"; channel: string }
71
+ | { type: "denied"; channel: string; code: string }
72
+ | { type: "event"; id: string; channel: string; event: string; data: unknown }
73
+ | { type: "gap"; channel: string }
74
+ | { type: "presence"; channel: string; event: "join" | "leave"; id: string; meta?: Record<string, unknown> }
75
+ | { type: "reply"; ref: number; ok: boolean; data?: unknown; error?: ErrorBody }
76
+ | { type: "error"; code: string; message: string; ref?: number }
77
+ | { type: "pong" };
78
+
79
+ // Server-Sent Events: the application's events, plus gemstack.* and presence.* ones.
80
+ type StreamMessage = { id: string | null; channel: string | null; event: string; data: unknown };
81
+
82
+ type Outgoing = { type: string; channel?: string; event?: string; data?: unknown; ref?: number; last_id?: string };
83
+ type Pending = { resolve: (data: unknown) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> };
84
+
85
+ const MAX_BACKOFF_MS = 30_000;
86
+ const PING_EVERY_MS = 25_000;
87
+ const WEBSOCKET_OPEN_TIMEOUT_MS = 5_000; // auto: then use Server-Sent Events
88
+ const REOPEN_DELAY_MS = 50; // Server-Sent Events: one reopen for a burst of subscription changes
89
+
90
+ function configuredMode(): RealtimeMode {
91
+ const mode = process.env.NEXT_PUBLIC_GEMSTACK_REALTIME;
92
+ return mode === "websocket" || mode === "sse" ? mode : "auto";
93
+ }
94
+
95
+ function endpoint(): string {
96
+ return apiUrl("/realtime");
97
+ }
98
+
99
+ function crossOrigin(): boolean {
100
+ return /^https?:/i.test(endpoint());
101
+ }
102
+
103
+ function websocketUrl(): string {
104
+ const url = endpoint();
105
+ if (/^https?:/i.test(url)) return url.replace(/^http/i, "ws");
106
+ const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
107
+ return `${scheme}//${window.location.host}${url}`;
108
+ }
109
+
110
+ function browserOffline(): boolean {
111
+ return typeof navigator !== "undefined" && navigator.onLine === false;
112
+ }
113
+
114
+ export class RealtimeClient {
42
115
  status: RealtimeStatus = "idle";
116
+ /** The transport of the open connection. */
117
+ transport: RealtimeTransport | null = null;
118
+ readonly mode: RealtimeMode;
119
+ private socket: WebSocket | null = null;
120
+ private stream: EventSource | null = null;
121
+ private handlers = new Map<string, Set<RealtimeHandler>>();
122
+ private lastIds = new Map<string, string>();
123
+ private lastStreamId: string | null = null;
124
+ private seen = new Map<string, Set<string>>();
125
+ private presence = new Map<string, Map<string, PresenceEntry>>();
126
+ private pending = new Map<number, Pending>();
127
+ private outbox: Outgoing[] = [];
128
+ private listeners = new Set<() => void>();
129
+ private nextRef = 1;
130
+ private attempts = 0;
131
+ private heartbeatMs = 15_000;
132
+ private lastMessageAt = 0;
133
+ private websocketWorks = false; // auto: a WebSocket opened, so a later drop is just a drop
134
+ private websocketFailed = false; // auto: none ever opened, so this page uses Server-Sent Events
135
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
136
+ private reopenTimer: ReturnType<typeof setTimeout> | null = null;
137
+ private openTimer: ReturnType<typeof setTimeout> | null = null;
138
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
139
+ private closeScheduled = false;
140
+
141
+ constructor(mode: RealtimeMode = configuredMode()) {
142
+ this.mode = mode;
143
+ }
43
144
 
44
145
  subscribe<T = unknown>(channel: string, handler: RealtimeHandler<T>): () => void {
45
- const set = this.handlers.get(channel) ?? new Set<RealtimeHandler>();
146
+ let set = this.handlers.get(channel);
147
+ if (!set) {
148
+ set = new Set();
149
+ this.handlers.set(channel, set);
150
+ this.subscriptionsChanged({ type: "subscribe", channel, last_id: this.lastIds.get(channel) });
151
+ }
46
152
  set.add(handler as RealtimeHandler);
47
- this.handlers.set(channel, set);
48
- this.schedule();
153
+ this.ensureConnected();
49
154
  return () => {
50
155
  set.delete(handler as RealtimeHandler);
51
- if (set.size === 0) this.handlers.delete(channel);
52
- this.schedule();
156
+ if (set.size > 0 || this.handlers.get(channel) !== set) return;
157
+ this.handlers.delete(channel);
158
+ this.presence.delete(channel);
159
+ this.lastIds.delete(channel);
160
+ this.seen.delete(channel);
161
+ this.subscriptionsChanged({ type: "unsubscribe", channel });
162
+ this.notify();
163
+ this.scheduleIdleClose();
53
164
  };
54
165
  }
55
166
 
56
- onStatus(listener: (status: RealtimeStatus) => void): () => void {
57
- this.statusListeners.add(listener);
58
- return () => this.statusListeners.delete(listener);
167
+ /** Sends a message to a channel's `receive` handler; resolves with its return value. */
168
+ send<T = unknown>(channel: string, event: string, data?: unknown, { timeout = 10_000 } = {}): Promise<T> {
169
+ const ref = this.nextRef++;
170
+ return new Promise<T>((resolve, reject) => {
171
+ const timer = setTimeout(() => {
172
+ this.pending.delete(ref);
173
+ reject(new RealtimeError("timeout", `no reply to ${event} on ${channel}`));
174
+ }, timeout);
175
+ this.pending.set(ref, { resolve: resolve as (data: unknown) => void, reject, timer });
176
+ const message = { type: "message", channel, event, data, ref };
177
+ if (this.preferred() === "sse") {
178
+ void this.post(message);
179
+ } else {
180
+ this.write(message);
181
+ this.ensureConnected();
182
+ }
183
+ });
59
184
  }
60
185
 
61
- private setStatus(status: RealtimeStatus) {
62
- this.status = status;
63
- this.statusListeners.forEach((listener) => listener(status));
186
+ /** Who's on a presence channel (empty until the subscription is confirmed). */
187
+ presenceOf(channel: string): PresenceEntry[] {
188
+ return [...(this.presence.get(channel)?.values() ?? [])];
189
+ }
190
+
191
+ /** Reconnects now instead of waiting for the backoff (called when the browser comes back online). */
192
+ reconnectNow() {
193
+ if (this.socket || this.stream) return;
194
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
195
+ this.reconnectTimer = null;
196
+ this.attempts = 0;
197
+ this.connect();
64
198
  }
65
199
 
66
- private schedule() {
67
- if (this.scheduled) return;
68
- this.scheduled = true;
69
- queueMicrotask(() => {
70
- this.scheduled = false;
71
- this.connect();
200
+ /** Drops the connection until reconnectNow() (called when the browser goes offline). */
201
+ wentOffline() {
202
+ this.disconnect();
203
+ if (this.wanted()) this.setStatus("offline");
204
+ }
205
+
206
+ /** Calls listener(status) whenever the connection status changes. */
207
+ onStatus(listener: (status: RealtimeStatus) => void): () => void {
208
+ let last = this.status;
209
+ return this.onChange(() => {
210
+ if (this.status !== last) listener((last = this.status));
72
211
  });
73
212
  }
74
213
 
75
- private connect() {
76
- if (typeof window === "undefined" || typeof EventSource === "undefined") return;
77
- const channels = [...this.handlers.keys()].sort();
78
- const key = channels.join(",");
79
- if (key === this.connectedKey && this.source) return;
214
+ /** Re-renders on status and presence changes (for hooks). */
215
+ onChange(listener: () => void): () => void {
216
+ this.listeners.add(listener);
217
+ return () => this.listeners.delete(listener);
218
+ }
219
+
220
+ // ── connection ───────────────────────────────────────────────────────
221
+
222
+ private preferred(): RealtimeTransport {
223
+ if (this.mode === "sse" || typeof WebSocket === "undefined") return "sse";
224
+ if (this.mode === "websocket") return "websocket";
225
+ return this.websocketFailed ? "sse" : "websocket";
226
+ }
80
227
 
81
- this.source?.close();
82
- this.source = null;
83
- this.connectedKey = key;
84
- if (channels.length === 0) return this.setStatus("idle");
228
+ // A stream only carries subscriptions; a WebSocket also carries send().
229
+ private wanted(): boolean {
230
+ return this.handlers.size > 0 || (this.preferred() === "websocket" && this.pending.size > 0);
231
+ }
85
232
 
86
- const query: Record<string, string> = { channels: key };
87
- if (this.lastEventId) query.last_event_id = this.lastEventId;
88
- const source = new EventSource(apiUrl("/realtime", query), { withCredentials: true });
89
- this.source = source;
233
+ private ensureConnected() {
234
+ if (typeof window === "undefined") return;
235
+ if (this.socket || this.stream || this.reconnectTimer || this.status === "offline") return;
236
+ if (this.preferred() === "websocket") return this.connect();
237
+ // Let the components mounting together subscribe first: one stream for all of them.
90
238
  this.setStatus("connecting");
91
- source.onopen = () => this.setStatus("open");
92
- source.onerror = () => this.setStatus("reconnecting"); // the browser retries on its own
93
- source.onmessage = (message) => {
94
- if (message.lastEventId) this.lastEventId = message.lastEventId;
95
- let event: RealtimeEvent;
96
- try {
97
- event = JSON.parse(message.data) as RealtimeEvent;
98
- } catch {
239
+ this.reconnectTimer = setTimeout(() => this.connect(), 0);
240
+ }
241
+
242
+ private connect(reopening = false) {
243
+ this.reconnectTimer = null;
244
+ if (!this.wanted()) return this.setStatus("idle");
245
+ if (browserOffline()) return this.setStatus("offline");
246
+
247
+ if (!reopening) this.setStatus(this.attempts === 0 ? "connecting" : "reconnecting");
248
+ if (this.preferred() === "websocket") this.openSocket();
249
+ else this.openStream();
250
+ }
251
+
252
+ private retry() {
253
+ if (!this.wanted()) return this.setStatus("idle");
254
+ if (browserOffline()) return this.setStatus("offline");
255
+ // Exponential backoff with jitter: 0.5 s, 1 s, 2 s … up to 30 s.
256
+ const delay = Math.min(MAX_BACKOFF_MS, 500 * 2 ** this.attempts) * (0.5 + Math.random() / 2);
257
+ this.attempts++;
258
+ this.setStatus("reconnecting");
259
+ this.reconnectTimer = setTimeout(() => this.connect(), delay);
260
+ }
261
+
262
+ private opened(transport: RealtimeTransport, heartbeat: number) {
263
+ this.attempts = 0;
264
+ this.transport = transport;
265
+ this.heartbeatMs = heartbeat * 1000;
266
+ this.setStatus("open");
267
+ if (this.pingTimer) clearInterval(this.pingTimer);
268
+ this.pingTimer = setInterval(() => this.ping(), PING_EVERY_MS);
269
+ }
270
+
271
+ // A connection that has been silent for three heartbeats is dead: replace it.
272
+ private ping() {
273
+ if (Date.now() - this.lastMessageAt > this.heartbeatMs * 3) {
274
+ if (this.socket) return this.socket.close();
275
+ if (this.stream) return this.streamDropped(this.stream);
276
+ }
277
+ if (this.socket) this.write({ type: "ping" });
278
+ }
279
+
280
+ private disconnect() {
281
+ for (const timer of [this.reconnectTimer, this.reopenTimer, this.openTimer]) if (timer) clearTimeout(timer);
282
+ if (this.pingTimer) clearInterval(this.pingTimer);
283
+ this.reconnectTimer = this.reopenTimer = this.openTimer = this.pingTimer = null;
284
+ const { socket, stream } = this;
285
+ this.socket = null;
286
+ this.stream = null;
287
+ socket?.close(1000);
288
+ stream?.close();
289
+ }
290
+
291
+ private scheduleIdleClose() {
292
+ if (this.closeScheduled) return;
293
+ this.closeScheduled = true;
294
+ setTimeout(() => {
295
+ this.closeScheduled = false;
296
+ if (this.wanted()) return;
297
+ this.outbox = [];
298
+ this.disconnect();
299
+ this.setStatus("idle");
300
+ }, 1000);
301
+ }
302
+
303
+ private subscriptionsChanged(message: Outgoing) {
304
+ if (this.preferred() === "websocket") return this.write(message);
305
+ // The stream's URL lists its channels: reopen it (presence rides out a quick reopen).
306
+ if (!this.stream || this.reopenTimer) return;
307
+ this.reopenTimer = setTimeout(() => {
308
+ this.reopenTimer = null;
309
+ const stream = this.stream;
310
+ if (!stream) return;
311
+ this.stream = null;
312
+ stream.close();
313
+ this.connect(true);
314
+ }, REOPEN_DELAY_MS);
315
+ }
316
+
317
+ // ── WebSocket ────────────────────────────────────────────────────────
318
+
319
+ private openSocket() {
320
+ const socket = new WebSocket(websocketUrl());
321
+ this.socket = socket;
322
+ socket.onmessage = (message) => this.receive(message.data);
323
+ // Either one means it's gone (some runtimes skip `close` after a failed handshake).
324
+ socket.onclose = () => this.socketDropped(socket);
325
+ socket.onerror = () => this.socketDropped(socket);
326
+ if (this.mode === "auto" && !this.websocketWorks) {
327
+ this.openTimer = setTimeout(() => socket.close(), WEBSOCKET_OPEN_TIMEOUT_MS);
328
+ }
329
+ }
330
+
331
+ private socketDropped(socket: WebSocket) {
332
+ if (this.socket !== socket) return;
333
+ this.socket = null;
334
+ if (this.openTimer) clearTimeout(this.openTimer);
335
+ if (this.pingTimer) clearInterval(this.pingTimer);
336
+ this.openTimer = this.pingTimer = null;
337
+ if (this.mode === "auto" && !this.websocketWorks && !browserOffline()) return this.fallBackToStream();
338
+ this.retry();
339
+ }
340
+
341
+ // auto: no WebSocket got through (a proxy in the way), so this page uses
342
+ // Server-Sent Events from now on.
343
+ private fallBackToStream() {
344
+ this.websocketFailed = true;
345
+ console.info("[realtime] WebSocket unavailable; using Server-Sent Events");
346
+ const queued = this.outbox.filter((message) => message.type === "message");
347
+ this.outbox = [];
348
+ queued.forEach((message) => void this.post(message));
349
+ this.connect();
350
+ }
351
+
352
+ private socketOpened(heartbeat: number) {
353
+ if (this.openTimer) clearTimeout(this.openTimer);
354
+ this.openTimer = null;
355
+ this.websocketWorks = true;
356
+ this.opened("websocket", heartbeat);
357
+ // Resubscribe everything (with the last event seen, for replay), then flush what waited.
358
+ const resubscribe = [...this.handlers.keys()].map((channel) => ({
359
+ type: "subscribe",
360
+ channel,
361
+ last_id: this.lastIds.get(channel),
362
+ }));
363
+ // Queued (un)subscribes are superseded by the current subscriptions above.
364
+ const queued = this.outbox.filter((message) => message.type !== "subscribe" && message.type !== "unsubscribe");
365
+ this.outbox = [];
366
+ [...resubscribe, ...queued].forEach((message) => this.socket?.send(JSON.stringify(message)));
367
+ }
368
+
369
+ private write(message: Outgoing) {
370
+ if (this.socket?.readyState === WebSocket.OPEN && this.status === "open") this.socket.send(JSON.stringify(message));
371
+ else if (message.type !== "ping") this.outbox.push(message);
372
+ }
373
+
374
+ private receive(raw: unknown) {
375
+ this.lastMessageAt = Date.now();
376
+ let message: ServerMessage;
377
+ try {
378
+ message = JSON.parse(String(raw)) as ServerMessage;
379
+ } catch {
380
+ return;
381
+ }
382
+ switch (message.type) {
383
+ case "welcome":
384
+ return this.socketOpened(message.heartbeat);
385
+ case "event":
386
+ return this.event(message);
387
+ case "gap":
388
+ return this.emit(message.channel, { id: null, channel: message.channel, event: GAP_EVENT, data: null });
389
+ case "denied":
390
+ return this.emit(message.channel, { id: null, channel: message.channel, event: DENIED_EVENT, data: message.code });
391
+ case "subscribed":
392
+ if (message.presence) this.presenceState(message.channel, message.presence);
99
393
  return;
394
+ case "presence":
395
+ return this.presenceChange(message.channel, message.event, message.id, message.meta);
396
+ case "reply":
397
+ return this.reply(message.ref, message.ok, message.data, message.error);
398
+ case "error":
399
+ if (message.ref !== undefined) this.reply(message.ref, false, undefined, message);
400
+ else console.warn(`[realtime] ${message.code}: ${message.message}`);
401
+ }
402
+ }
403
+
404
+ // ── Server-Sent Events ───────────────────────────────────────────────
405
+
406
+ private openStream() {
407
+ if (typeof EventSource === "undefined") return this.setStatus("idle");
408
+ const query = new URLSearchParams({ channels: [...this.handlers.keys()].join(",") });
409
+ if (this.lastStreamId) query.set("last_event_id", this.lastStreamId);
410
+ const stream = new EventSource(`${endpoint()}?${query}`, { withCredentials: crossOrigin() });
411
+ this.stream = stream;
412
+ stream.onmessage = (message) => this.receiveStream(message.data);
413
+ // Reconnect on this client's schedule and with the current channels, not EventSource's.
414
+ stream.onerror = () => this.streamDropped(stream);
415
+ }
416
+
417
+ private streamDropped(stream: EventSource) {
418
+ stream.close();
419
+ if (this.stream !== stream) return;
420
+ this.stream = null;
421
+ if (this.pingTimer) clearInterval(this.pingTimer);
422
+ this.pingTimer = null;
423
+ this.retry();
424
+ }
425
+
426
+ private receiveStream(raw: string) {
427
+ this.lastMessageAt = Date.now();
428
+ let message: StreamMessage;
429
+ try {
430
+ message = JSON.parse(raw) as StreamMessage;
431
+ } catch {
432
+ return;
433
+ }
434
+ const channel = message.channel ?? "";
435
+ switch (message.event) {
436
+ case "gemstack.welcome":
437
+ return this.opened("sse", (message.data as { heartbeat: number }).heartbeat);
438
+ case "gemstack.ping":
439
+ return;
440
+ case "gemstack.presence":
441
+ return this.presenceState(channel, message.data as PresenceEntry[]);
442
+ case "presence.join":
443
+ case "presence.leave": {
444
+ const entry = message.data as PresenceEntry;
445
+ return this.presenceChange(channel, message.event === "presence.join" ? "join" : "leave", entry.id, entry.meta);
100
446
  }
101
- const targets =
102
- event.event === GAP_EVENT ? [...this.handlers.values()] : [this.handlers.get(event.channel ?? "") ?? new Set()];
103
- targets.forEach((set) => set.forEach((handler) => handler(event)));
104
- };
447
+ case DENIED_EVENT:
448
+ return this.emit(channel, { id: null, channel, event: DENIED_EVENT, data: message.data });
449
+ case GAP_EVENT: // for every channel on the stream
450
+ return this.handlers.forEach((_, name) => this.emit(name, { id: null, channel: name, event: GAP_EVENT, data: null }));
451
+ default:
452
+ if (!message.id || !message.channel) return;
453
+ this.lastStreamId = message.id;
454
+ this.event({ id: message.id, channel: message.channel, event: message.event, data: message.data });
455
+ }
456
+ }
457
+
458
+ private async post(message: Outgoing) {
459
+ const ref = message.ref as number;
460
+ try {
461
+ const response = await fetch(endpoint(), {
462
+ method: "POST",
463
+ headers: { "content-type": "application/json", accept: "application/json" },
464
+ body: JSON.stringify({ channel: message.channel, event: message.event, data: message.data }),
465
+ credentials: crossOrigin() ? "include" : "same-origin",
466
+ });
467
+ const body = (await response.json().catch(() => null)) as { data?: unknown; error?: ErrorBody } | null;
468
+ if (response.ok) this.reply(ref, true, body?.data);
469
+ else this.reply(ref, false, undefined, body?.error ?? { code: "http_error", message: `HTTP ${response.status}` });
470
+ } catch (error) {
471
+ this.reply(ref, false, undefined, { code: "network_error", message: String(error) });
472
+ }
473
+ }
474
+
475
+ // ── both ─────────────────────────────────────────────────────────────
476
+
477
+ private event(message: { id: string; channel: string; event: string; data: unknown }) {
478
+ // A replay can overlap with live delivery right after (re)subscribing.
479
+ const seen = this.seen.get(message.channel) ?? new Set<string>();
480
+ if (seen.has(message.id)) return;
481
+ seen.add(message.id);
482
+ if (seen.size > 200) seen.delete(seen.values().next().value as string);
483
+ this.seen.set(message.channel, seen);
484
+ this.lastIds.set(message.channel, message.id);
485
+ this.emit(message.channel, { id: message.id, channel: message.channel, event: message.event, data: message.data });
486
+ }
487
+
488
+ private presenceState(channel: string, entries: PresenceEntry[]) {
489
+ if (!this.handlers.has(channel)) return;
490
+ this.presence.set(channel, new Map(entries.map((entry) => [entry.id, entry])));
491
+ this.notify();
492
+ }
493
+
494
+ private presenceChange(channel: string, change: "join" | "leave", id: string, meta?: Record<string, unknown>) {
495
+ const entries = this.presence.get(channel);
496
+ if (!entries) return;
497
+ if (change === "join") entries.set(id, { id, meta });
498
+ else entries.delete(id);
499
+ this.presence.set(channel, new Map(entries)); // a new Map, so hooks see a change
500
+ this.notify();
501
+ }
502
+
503
+ private reply(ref: number, ok: boolean, data: unknown, error?: ErrorBody) {
504
+ const pending = this.pending.get(ref);
505
+ if (!pending) return;
506
+ this.pending.delete(ref);
507
+ clearTimeout(pending.timer);
508
+ if (ok) pending.resolve(data);
509
+ else pending.reject(new RealtimeError(error?.code ?? "error", error?.message ?? "the message was refused"));
510
+ this.scheduleIdleClose();
511
+ }
512
+
513
+ private emit(channel: string, event: RealtimeEvent) {
514
+ this.handlers.get(channel)?.forEach((handler) => handler(event));
515
+ }
516
+
517
+ private setStatus(status: RealtimeStatus) {
518
+ if (status !== "open" && !this.socket && !this.stream) this.transport = null;
519
+ if (this.status === status) return;
520
+ this.status = status;
521
+ this.notify();
522
+ }
523
+
524
+ private notify() {
525
+ this.listeners.forEach((listener) => listener());
105
526
  }
106
527
  }
107
528
 
108
529
  export const realtime = new RealtimeClient();
109
530
 
531
+ if (typeof window !== "undefined") {
532
+ window.addEventListener("offline", () => realtime.wentOffline());
533
+ window.addEventListener("online", () => realtime.reconnectNow());
534
+ }
535
+
110
536
  /** Subscribes while the component is mounted. Pass null to skip. */
111
537
  export function useRealtime<T = unknown>(channel: string | null, handler: RealtimeHandler<T>) {
112
538
  const latest = useRef(handler);
@@ -118,3 +544,20 @@ export function useRealtime<T = unknown>(channel: string | null, handler: Realti
118
544
  return realtime.subscribe<T>(channel, (event) => latest.current(event));
119
545
  }, [channel]);
120
546
  }
547
+
548
+ /** Who's on a presence channel; subscribes while mounted. */
549
+ export function usePresence<M = Record<string, unknown>>(channel: string | null): PresenceEntry<M>[] {
550
+ useRealtime(channel, () => {});
551
+ const [, rerender] = useState(0);
552
+ useEffect(() => realtime.onChange(() => rerender((n) => n + 1)), []);
553
+ return channel ? (realtime.presenceOf(channel) as PresenceEntry<M>[]) : [];
554
+ }
555
+
556
+ /** "idle" | "connecting" | "open" | "reconnecting" | "offline" — e.g. to show an offline banner. */
557
+ export function useRealtimeStatus(): RealtimeStatus {
558
+ return useSyncExternalStore(
559
+ (listener) => realtime.onChange(listener),
560
+ () => realtime.status,
561
+ () => "idle",
562
+ );
563
+ }
@@ -52,7 +52,7 @@ export function <%= class_name %>Form({
52
52
  const [formError, setFormError] = useState<string | null>(null);
53
53
  const [pending, setPending] = useState(false);
54
54
 
55
- const set = (key: keyof Values) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
55
+ const set = (key: keyof Values) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
56
56
  const target = event.target;
57
57
  const value = target instanceof HTMLInputElement && target.type === "checkbox" ? target.checked : target.value;
58
58
  // Checkboxes give booleans, other inputs strings — matching each field's type in Values.
@@ -69,7 +69,9 @@ export function <%= class_name %>Form({
69
69
  } catch (error) {
70
70
  if (error instanceof ApiError) {
71
71
  setErrors(error.errors);
72
- setFormError(Object.keys(error.errors).length ? null : error.message);
72
+ // The message lists every error: show it when some aren't next to a field below.
73
+ const fields = Object.keys(error.errors);
74
+ setFormError(fields.length && fields.every((field) => field in values) ? null : error.message);
73
75
  } else {
74
76
  setFormError("Something went wrong. Please try again.");
75
77
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gemstack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.6
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adware Technologies
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - '='
46
46
  - !ruby/object:Gem::Version
47
- version: 0.3.6
47
+ version: 0.4.0
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - '='
53
53
  - !ruby/object:Gem::Version
54
- version: 0.3.6
54
+ version: 0.4.0
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: json
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -401,5 +401,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
401
401
  requirements: []
402
402
  rubygems_version: 4.0.20
403
403
  specification_version: 4
404
- summary: 'GemStack: a fast, modular Ruby API framework for Next.js applications'
404
+ summary: 'GemStack: a fast, modular Ruby web application framework with a Next.js
405
+ frontend'
405
406
  test_files: []