@pylonsync/react 0.2.4

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.
@@ -0,0 +1,299 @@
1
+ /**
2
+ * useShard — React hook for real-time sharded simulations (games, MMO zones, etc.).
3
+ *
4
+ * Connects to an pylon shard over WebSocket (preferred) or SSE (fallback),
5
+ * receives snapshots as they arrive, and sends inputs upstream.
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * const { snapshot, tick, send, connected, error } = useShard("match1", {
10
+ * subscriberId: "player42",
11
+ * token: authToken,
12
+ * });
13
+ *
14
+ * return (
15
+ * <GameBoard snapshot={snapshot} onMove={(move) => send({ action: "move", move })} />
16
+ * );
17
+ * ```
18
+ */
19
+
20
+ import { useEffect, useRef, useState } from "react";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export interface UseShardOptions {
27
+ /** Subscriber ID (usually the logged-in user ID). Required for multiplayer. */
28
+ subscriberId: string;
29
+ /**
30
+ * Auth token. Sent over the WebSocket as a Sec-WebSocket-Protocol
31
+ * subprotocol header in the form `"bearer.<token>"`. This keeps the token
32
+ * out of URLs — proxy logs, browser devtools network panel, and error
33
+ * telemetry typically record the URL but not the subprotocol value.
34
+ *
35
+ * The pylon shard server reads either the subprotocol header or the
36
+ * legacy `?token=` query param (which is still accepted but deprecated —
37
+ * scheduled for removal in a future release).
38
+ */
39
+ token?: string;
40
+ /** Override the base URL. Defaults to `window.location.host`. */
41
+ baseUrl?: string;
42
+ /** Override the shard WS port (default: HTTP port + 3). */
43
+ wsPort?: number;
44
+ /** Explicit WebSocket URL. Overrides baseUrl/wsPort. */
45
+ wsUrl?: string;
46
+ /** If true, falls back to SSE + HTTP POST if WebSocket fails (default: true). */
47
+ sseFallback?: boolean;
48
+ /** Reconnect on unexpected close (default: true). */
49
+ autoReconnect?: boolean;
50
+ /** Reconnect backoff in ms (default: starts at 500, maxes at 10_000). */
51
+ reconnectBackoffMs?: number;
52
+ }
53
+
54
+ export interface UseShardReturn<TSnapshot = unknown, TInput = unknown> {
55
+ snapshot: TSnapshot | null;
56
+ tick: number;
57
+ connected: boolean;
58
+ error: Error | null;
59
+ /** Send an input to the shard. Returns a client sequence number. */
60
+ send: (input: TInput) => number;
61
+ /** Close the connection early. */
62
+ close: () => void;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Low-level client (no React)
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export interface ShardClient<TSnapshot = unknown, TInput = unknown> {
70
+ onSnapshot: (fn: (snapshot: TSnapshot, tick: number) => void) => void;
71
+ onError: (fn: (err: Error) => void) => void;
72
+ onOpen: (fn: () => void) => void;
73
+ onClose: (fn: () => void) => void;
74
+ send: (input: TInput) => number;
75
+ close: () => void;
76
+ readonly connected: boolean;
77
+ }
78
+
79
+ /**
80
+ * Connect to a shard without React — returns a typed client you can wire
81
+ * into any framework.
82
+ */
83
+ export function connectShard<TSnapshot = unknown, TInput = unknown>(
84
+ shardId: string,
85
+ options: UseShardOptions
86
+ ): ShardClient<TSnapshot, TInput> {
87
+ let ws: WebSocket | null = null;
88
+ let clientSeq = 0;
89
+ let closed = false;
90
+ let connected = false;
91
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
92
+ let backoff = options.reconnectBackoffMs ?? 500;
93
+
94
+ const snapshotHandlers: Array<(s: TSnapshot, t: number) => void> = [];
95
+ const errorHandlers: Array<(e: Error) => void> = [];
96
+ const openHandlers: Array<() => void> = [];
97
+ const closeHandlers: Array<() => void> = [];
98
+
99
+ const dispatchSnapshot = (snapshot: TSnapshot, tick: number) => {
100
+ for (const h of snapshotHandlers) h(snapshot, tick);
101
+ };
102
+ const dispatchError = (err: Error) => {
103
+ for (const h of errorHandlers) h(err);
104
+ };
105
+
106
+ const buildWsUrl = (): string => {
107
+ if (options.wsUrl) return options.wsUrl;
108
+ const host =
109
+ options.baseUrl ||
110
+ (typeof window !== "undefined" ? window.location.hostname : "localhost");
111
+ const port = options.wsPort ?? 4324; // default: pylon HTTP port + 3 (4321 + 3)
112
+ const proto =
113
+ typeof window !== "undefined" && window.location.protocol === "https:"
114
+ ? "wss"
115
+ : "ws";
116
+ // Only shard id + subscriber id land in the URL — these are routing
117
+ // metadata, not credentials.
118
+ const params = new URLSearchParams({
119
+ shard: shardId,
120
+ sid: options.subscriberId,
121
+ });
122
+ return `${proto}://${host}:${port}/?${params.toString()}`;
123
+ };
124
+
125
+ const connect = () => {
126
+ if (closed) return;
127
+ const url = buildWsUrl();
128
+ try {
129
+ // The bearer token rides on the WebSocket subprotocol header so it
130
+ // doesn't get captured by every proxy / devtools pane that logs URLs.
131
+ // Subprotocol values must be a token per RFC 6455; encode the bearer
132
+ // so spaces/punctuation don't break the handshake.
133
+ const protocols = options.token
134
+ ? [`bearer.${encodeURIComponent(options.token)}`]
135
+ : undefined;
136
+ ws = protocols ? new WebSocket(url, protocols) : new WebSocket(url);
137
+ } catch (e) {
138
+ dispatchError(e instanceof Error ? e : new Error(String(e)));
139
+ return;
140
+ }
141
+ ws.binaryType = "arraybuffer";
142
+
143
+ ws.onopen = () => {
144
+ connected = true;
145
+ backoff = options.reconnectBackoffMs ?? 500;
146
+ for (const h of openHandlers) h();
147
+ };
148
+
149
+ ws.onmessage = (event) => {
150
+ // Binary format: 8 bytes (u64 BE) tick + JSON snapshot bytes.
151
+ if (event.data instanceof ArrayBuffer) {
152
+ const view = new DataView(event.data);
153
+ const hi = view.getUint32(0);
154
+ const lo = view.getUint32(4);
155
+ const tick = hi * 0x100000000 + lo;
156
+ const jsonBytes = new Uint8Array(event.data, 8);
157
+ const jsonStr = new TextDecoder().decode(jsonBytes);
158
+ try {
159
+ const snapshot = JSON.parse(jsonStr) as TSnapshot;
160
+ dispatchSnapshot(snapshot, tick);
161
+ } catch (e) {
162
+ dispatchError(
163
+ e instanceof Error ? e : new Error("Failed to parse snapshot")
164
+ );
165
+ }
166
+ } else if (typeof event.data === "string") {
167
+ // Text frame (e.g., JSON fallback format).
168
+ try {
169
+ const wrapped = JSON.parse(event.data) as {
170
+ tick?: number;
171
+ snapshot?: TSnapshot;
172
+ };
173
+ if (typeof wrapped.tick === "number" && wrapped.snapshot !== undefined) {
174
+ dispatchSnapshot(wrapped.snapshot, wrapped.tick);
175
+ }
176
+ } catch (e) {
177
+ dispatchError(
178
+ e instanceof Error ? e : new Error("Failed to parse snapshot")
179
+ );
180
+ }
181
+ }
182
+ };
183
+
184
+ ws.onerror = () => {
185
+ dispatchError(new Error(`WebSocket error connecting to shard ${shardId}`));
186
+ };
187
+
188
+ ws.onclose = () => {
189
+ connected = false;
190
+ for (const h of closeHandlers) h();
191
+ if (closed) return;
192
+ if (options.autoReconnect !== false) {
193
+ reconnectTimer = setTimeout(connect, backoff);
194
+ backoff = Math.min(backoff * 2, 10_000);
195
+ }
196
+ };
197
+ };
198
+
199
+ connect();
200
+
201
+ return {
202
+ get connected() {
203
+ return connected;
204
+ },
205
+ onSnapshot(fn) {
206
+ snapshotHandlers.push(fn);
207
+ },
208
+ onError(fn) {
209
+ errorHandlers.push(fn);
210
+ },
211
+ onOpen(fn) {
212
+ openHandlers.push(fn);
213
+ },
214
+ onClose(fn) {
215
+ closeHandlers.push(fn);
216
+ },
217
+ send(input: TInput): number {
218
+ clientSeq += 1;
219
+ const seq = clientSeq;
220
+ const payload = JSON.stringify({ input, client_seq: seq });
221
+ if (ws && ws.readyState === WebSocket.OPEN) {
222
+ ws.send(payload);
223
+ } else {
224
+ dispatchError(
225
+ new Error("Cannot send: shard connection is not open")
226
+ );
227
+ }
228
+ return seq;
229
+ },
230
+ close() {
231
+ closed = true;
232
+ if (reconnectTimer) clearTimeout(reconnectTimer);
233
+ if (ws) ws.close();
234
+ },
235
+ };
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // React hook
240
+ // ---------------------------------------------------------------------------
241
+
242
+ /**
243
+ * React hook that subscribes to a shard's snapshots and provides a send fn.
244
+ *
245
+ * The hook re-renders when a new snapshot arrives or the connection state
246
+ * changes. The `send` fn is stable across re-renders.
247
+ */
248
+ export function useShard<TSnapshot = unknown, TInput = unknown>(
249
+ shardId: string,
250
+ options: UseShardOptions
251
+ ): UseShardReturn<TSnapshot, TInput> {
252
+ const [snapshot, setSnapshot] = useState<TSnapshot | null>(null);
253
+ const [tick, setTick] = useState<number>(0);
254
+ const [connected, setConnected] = useState<boolean>(false);
255
+ const [error, setError] = useState<Error | null>(null);
256
+
257
+ const clientRef = useRef<ShardClient<TSnapshot, TInput> | null>(null);
258
+
259
+ // Use primitive-value deps so the effect re-runs on identity-impacting
260
+ // changes (token, subscriberId, URL) without re-running on every render
261
+ // just because `options` is a fresh object literal. Previously we
262
+ // excluded `options` entirely, so a user logging out would keep the
263
+ // old socket alive under the old identity until `shardId` changed.
264
+ const token = options.token;
265
+ const subscriberId = options.subscriberId;
266
+ const baseUrl = options.baseUrl;
267
+ const wsUrl = options.wsUrl;
268
+ const wsPort = options.wsPort;
269
+
270
+ useEffect(() => {
271
+ const client = connectShard<TSnapshot, TInput>(shardId, options);
272
+ clientRef.current = client;
273
+
274
+ client.onSnapshot((snap, t) => {
275
+ setSnapshot(snap);
276
+ setTick(t);
277
+ });
278
+ client.onOpen(() => setConnected(true));
279
+ client.onClose(() => setConnected(false));
280
+ client.onError((e) => setError(e));
281
+
282
+ return () => {
283
+ client.close();
284
+ clientRef.current = null;
285
+ };
286
+ // eslint-disable-next-line react-hooks/exhaustive-deps
287
+ }, [shardId, token, subscriberId, baseUrl, wsUrl, wsPort]);
288
+
289
+ const send = (input: TInput): number => {
290
+ if (clientRef.current) return clientRef.current.send(input);
291
+ return 0;
292
+ };
293
+
294
+ const close = () => {
295
+ if (clientRef.current) clientRef.current.close();
296
+ };
297
+
298
+ return { snapshot, tick, connected, error, send, close };
299
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }
7
+