@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.
- package/package.json +24 -0
- package/src/db.ts +218 -0
- package/src/hooks.ts +922 -0
- package/src/index.ts +610 -0
- package/src/typed.ts +149 -0
- package/src/useRoom.ts +231 -0
- package/src/useSession.ts +71 -0
- package/src/useShard.ts +299 -0
- package/tsconfig.json +7 -0
package/src/useShard.ts
ADDED
|
@@ -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
|
+
}
|