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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +3 -3
- data/lib/gemstack/application.rb +4 -0
- data/lib/gemstack/cli/app_generator.rb +9 -4
- data/lib/gemstack/cli/doctor.rb +2 -1
- data/lib/gemstack/cli/migration_changes.rb +3 -2
- data/lib/gemstack/cli/resource_generator.rb +14 -1
- data/lib/gemstack/cli/resource_spec.rb +25 -8
- data/lib/gemstack/cli/template_update.rb +3 -2
- data/lib/gemstack/cli.rb +1 -1
- data/lib/gemstack/contract/builder.rb +14 -2
- data/lib/gemstack/contract/openapi.rb +1 -0
- data/lib/gemstack/contract/typescript.rb +1 -0
- data/lib/gemstack/db/model.rb +85 -5
- data/lib/gemstack/db/testing.rb +2 -1
- data/lib/gemstack/errors.rb +11 -1
- data/lib/gemstack/interlock.rb +8 -0
- data/lib/gemstack/reloader.rb +1 -1
- data/lib/gemstack/schema.rb +3 -1
- data/lib/gemstack/version.rb +1 -1
- data/templates/app/Gemfile.tt +1 -1
- data/templates/app/config/app.rb.tt +2 -1
- data/templates/app/config/database.yml.tt +4 -1
- data/templates/deploy/config/deploy.yml.tt +2 -0
- data/templates/realtime/config/channels.rb +20 -5
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +502 -59
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +4 -2
- metadata +5 -4
|
@@ -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
|
-
*
|
|
7
|
+
* await realtime.send("rooms:1", "message.create", { body: "Hi" }); // handled by `receive` in config/channels.rb
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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.
|
|
48
|
-
this.schedule();
|
|
153
|
+
this.ensureConnected();
|
|
49
154
|
return () => {
|
|
50
155
|
set.delete(handler as RealtimeHandler);
|
|
51
|
-
if (set.size
|
|
52
|
-
this.
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
this.
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
this.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
this.
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
this.
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
404
|
+
summary: 'GemStack: a fast, modular Ruby web application framework with a Next.js
|
|
405
|
+
frontend'
|
|
405
406
|
test_files: []
|