@pylonsync/react 0.13.0 → 0.15.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.
package/dist/index.d.ts CHANGED
@@ -24,8 +24,8 @@ export type { RoomPeer, RoomSnapshot, UseRoomOptions, UseRoomReturn, } from "./u
24
24
  export type { RoomMessage } from "@pylonsync/sync";
25
25
  export { useShard, connectShard } from "./useShard";
26
26
  export type { UseShardOptions, UseShardReturn, ShardClient, } from "./useShard";
27
- export { SHARD_PROTOCOL_VERSION, ShardCodec, ShardFrameKind, parseShardFrame, decodeShardPayload, encodeShardInput, } from "./shardWire";
28
- export type { ShardFrame, ShardInputRejection, ShardPayloadDecoder, } from "./shardWire";
27
+ export { SHARD_PROTOCOL_VERSION, ShardCodec, ShardFrameKind, parseShardFrame, decodeShardPayload, encodeShardInput, EntityTable, ReplicationError, connectShardGame, ShardClock, EntityInterpolator, Predictor, } from "./shardWire";
28
+ export type { ShardFrame, ShardInputRejection, ShardPayloadDecoder, ReplicatedEntity, ReplicationSummary, ShardConnectOptions, ShardGame, ShardGameOptions, InterpolatedEntity, InterpolationOptions, EntitySample, ShardClockOptions, PredictorOptions, } from "./shardWire";
29
29
  export { useSession } from "./useSession";
30
30
  export type { UseSessionReturn, ResolvedSession } from "./useSession";
31
31
  export { useSyncStatus } from "./useSyncStatus";
@@ -1,58 +1,5 @@
1
1
  /**
2
- * The shard wire protocol, version 2 (see `pylon_realtime::wire` in Rust).
3
- *
4
- * A client asks for it with `?v=2` on the shard WebSocket URL. Each server
5
- * message is a binary frame with an 18-byte header:
6
- *
7
- * 0 1 frame kind: 1 snapshot, 2 input rejected
8
- * 1 1 codec: 0 JSON, 1 MessagePack, 2 bincode, 3 custom
9
- * 2 8 tick (u64 big-endian)
10
- * 10 8 ack: highest client_seq the shard processed for this subscriber (0 = none)
11
- * 18 .. payload in the codec
12
- *
13
- * Inputs go up as `{ input, client_seq }`: JSON in a text frame, or the
14
- * shard's codec in a binary frame.
2
+ * The shard wire protocol now lives in `@pylonsync/realtime`, the client
3
+ * pieces with no framework. Re-exported here for existing imports.
15
4
  */
16
- export declare const SHARD_PROTOCOL_VERSION = 2;
17
- export declare const SHARD_HEADER_LEN = 18;
18
- export declare const ShardFrameKind: {
19
- readonly Snapshot: 1;
20
- readonly InputRejected: 2;
21
- };
22
- export declare const ShardCodec: {
23
- readonly Json: 0;
24
- readonly MessagePack: 1;
25
- readonly Bincode: 2;
26
- readonly Custom: 3;
27
- };
28
- export interface ShardFrame {
29
- kind: number;
30
- codec: number;
31
- tick: number;
32
- ack: number;
33
- payload: Uint8Array;
34
- }
35
- /** Why an input did not take effect. */
36
- export interface ShardInputRejection {
37
- clientSeq: number | null;
38
- /** `unauthorized`, `rate_limited`, `queue_full`, `invalid`, `stopped`, or `apply_failed`. */
39
- code: string;
40
- message: string;
41
- }
42
- /**
43
- * Decode a payload in a codec the JS client does not know (bincode, or a
44
- * game's own codec `3`).
45
- */
46
- export type ShardPayloadDecoder = (payload: Uint8Array, codec: number) => unknown;
47
- /** Split a version 2 frame into its header fields and payload. */
48
- export declare function parseShardFrame(data: ArrayBuffer): ShardFrame;
49
- /** Decode a payload. JSON and MessagePack are built in. */
50
- export declare function decodeShardPayload(codec: number, payload: Uint8Array, custom?: ShardPayloadDecoder): unknown;
51
- /** The payload of an input-rejected frame, with camelCase fields. */
52
- export declare function decodeShardRejection(codec: number, payload: Uint8Array, custom?: ShardPayloadDecoder): ShardInputRejection;
53
- /**
54
- * Encode an input envelope. MessagePack shards get a binary frame; every
55
- * other codec (and a connection that has not seen a frame yet) gets JSON
56
- * text, which the server always accepts.
57
- */
58
- export declare function encodeShardInput(codec: number | null, input: unknown, clientSeq: number): string | Uint8Array;
5
+ export * from "@pylonsync/realtime";
package/dist/ssr.d.ts CHANGED
@@ -176,6 +176,21 @@ export interface PageProps<TParams extends Record<string, string> = Record<strin
176
176
  * existing pages keep working; it will be removed in a later release.
177
177
  */
178
178
  url: string;
179
+ /**
180
+ * The host this request was made to, lowercased (e.g. `feedback.acme.com`),
181
+ * when the server trusts it: the app's own URL (`PYLON_PUBLIC_URL`,
182
+ * `PYLON_CANONICAL_HOST`), a `PYLON_TRUSTED_HOSTS` entry, a ready platform
183
+ * domain attached with `ctx.domains`, or loopback in dev (with its port).
184
+ * Any other `Host` header reads as `""`, so a forged header cannot pick
185
+ * what the page renders.
186
+ *
187
+ * It is the same value as the host part of the SSR cache key, so a page
188
+ * may render differently per host (one app serving each customer on their
189
+ * own domain) and stay cacheable: each host gets its own entry. It is also
190
+ * sent to the browser, so hydration and client navigation see the same
191
+ * value the server rendered with.
192
+ */
193
+ host: string;
179
194
  /** Dynamic-segment matches keyed by name (e.g. `{ slug: "hello-world" }`). */
180
195
  params: TParams;
181
196
  /** Parsed query string (e.g. `?start=10` → `{ start: "10" }`). */
@@ -1,45 +1,9 @@
1
- import { type ShardInputRejection, type ShardPayloadDecoder } from "./shardWire";
2
- export interface UseShardOptions {
3
- /** Subscriber ID (usually the logged-in user ID). Required for multiplayer. */
4
- subscriberId: string;
5
- /**
6
- * Auth token. Sent over the WebSocket as a Sec-WebSocket-Protocol
7
- * subprotocol header in the form `"bearer.<token>"`. This keeps the token
8
- * out of URLs — proxy logs, browser devtools network panel, and error
9
- * telemetry typically record the URL but not the subprotocol value.
10
- *
11
- * The pylon shard server reads either the subprotocol header or the
12
- * legacy `?token=` query param (which is still accepted but deprecated —
13
- * scheduled for removal in a future release).
14
- */
15
- token?: string;
16
- /**
17
- * Shard ticket from a server function (`ctx.shards.ticket(...)`). Sent as
18
- * a `ticket.<ticket>` WebSocket subprotocol. The shard checks it names
19
- * this shard and `subscriberId`, and passes its claims to the game's
20
- * authorization hooks.
21
- */
22
- ticket?: string;
23
- /** Host (and port) of the Pylon server. Defaults to `window.location.host`. */
24
- baseUrl?: string;
25
- /**
26
- * Connect to the dedicated shard port instead of `/shard` on the main
27
- * port (the dedicated port is the HTTP port + 3, e.g. 4324).
28
- */
29
- wsPort?: number;
30
- /** Explicit WebSocket URL. Overrides baseUrl/wsPort. */
31
- wsUrl?: string;
32
- /** If true, falls back to SSE + HTTP POST if WebSocket fails (default: true). */
1
+ import { connectShard, type EntityTable, type ShardClient, type ShardConnectOptions, type ShardInputRejection } from "@pylonsync/realtime";
2
+ export { connectShard };
3
+ export type { ShardClient };
4
+ export interface UseShardOptions extends ShardConnectOptions {
5
+ /** Unused: a shard connection is always a WebSocket. */
33
6
  sseFallback?: boolean;
34
- /** Reconnect on unexpected close (default: true). */
35
- autoReconnect?: boolean;
36
- /** Reconnect backoff in ms (default: starts at 500, maxes at 10_000). */
37
- reconnectBackoffMs?: number;
38
- /**
39
- * Decoder for a payload codec the client does not know: bincode (`2`) or
40
- * a game's own codec (`3`). JSON and MessagePack are built in.
41
- */
42
- decode?: ShardPayloadDecoder;
43
7
  }
44
8
  export interface UseShardReturn<TSnapshot = unknown, TInput = unknown> {
45
9
  snapshot: TSnapshot | null;
@@ -54,28 +18,21 @@ export interface UseShardReturn<TSnapshot = unknown, TInput = unknown> {
54
18
  lastRejection: ShardInputRejection | null;
55
19
  connected: boolean;
56
20
  error: Error | null;
57
- /** Send an input to the shard. Returns a client sequence number. */
21
+ /** Send an input to the shard. Returns a client sequence number, or 0
22
+ * when the connection is not open. */
58
23
  send: (input: TInput) => number;
59
24
  /** Close the connection early. */
60
25
  close: () => void;
26
+ /**
27
+ * For a shard that replicates entities: the entities this client has
28
+ * been sent, updated in place. The hook re-renders once per applied frame
29
+ * (`entitiesVersion` changes). A render loop should use
30
+ * `connectShardGame` from `@pylonsync/realtime` instead, which draws
31
+ * entities between frames without re-rendering.
32
+ */
33
+ entities: EntityTable | null;
34
+ entitiesVersion: number;
61
35
  }
62
- export interface ShardClient<TSnapshot = unknown, TInput = unknown> {
63
- /** `ack` is the highest `send()` sequence number the shard has processed. */
64
- onSnapshot: (fn: (snapshot: TSnapshot, tick: number, ack: number) => void) => void;
65
- /** Called when the shard refuses an input (see `ShardInputRejection.code`). */
66
- onInputRejected: (fn: (rejection: ShardInputRejection) => void) => void;
67
- onError: (fn: (err: Error) => void) => void;
68
- onOpen: (fn: () => void) => void;
69
- onClose: (fn: () => void) => void;
70
- send: (input: TInput) => number;
71
- close: () => void;
72
- readonly connected: boolean;
73
- }
74
- /**
75
- * Connect to a shard without React — returns a typed client you can wire
76
- * into any framework.
77
- */
78
- export declare function connectShard<TSnapshot = unknown, TInput = unknown>(shardId: string, options: UseShardOptions): ShardClient<TSnapshot, TInput>;
79
36
  /**
80
37
  * React hook that subscribes to a shard's snapshots and provides a send fn.
81
38
  *
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.13.0",
6
+ "version": "0.15.0",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -14,9 +14,9 @@
14
14
  "prepack": "bun run build"
15
15
  },
16
16
  "dependencies": {
17
- "@msgpack/msgpack": "^3.1.3",
18
- "@pylonsync/sdk": "0.13.0",
19
- "@pylonsync/sync": "0.13.0"
17
+ "@pylonsync/realtime": "0.15.0",
18
+ "@pylonsync/sdk": "0.15.0",
19
+ "@pylonsync/sync": "0.15.0"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "react": ">=19.0.0"
package/src/index.ts CHANGED
@@ -149,11 +149,28 @@ export {
149
149
  parseShardFrame,
150
150
  decodeShardPayload,
151
151
  encodeShardInput,
152
+ EntityTable,
153
+ ReplicationError,
154
+ // Render-loop client (no React): see `@pylonsync/realtime`.
155
+ connectShardGame,
156
+ ShardClock,
157
+ EntityInterpolator,
158
+ Predictor,
152
159
  } from "./shardWire";
153
160
  export type {
154
161
  ShardFrame,
155
162
  ShardInputRejection,
156
163
  ShardPayloadDecoder,
164
+ ReplicatedEntity,
165
+ ReplicationSummary,
166
+ ShardConnectOptions,
167
+ ShardGame,
168
+ ShardGameOptions,
169
+ InterpolatedEntity,
170
+ InterpolationOptions,
171
+ EntitySample,
172
+ ShardClockOptions,
173
+ PredictorOptions,
157
174
  } from "./shardWire";
158
175
 
159
176
  // Session hook — server-resolved user + tenant identity
@@ -0,0 +1,76 @@
1
+ // End-to-end: connectShard against a replicating shard. The client's entity
2
+ // table follows the server over a real WebSocket.
3
+ //
4
+ // Runs only when PYLON_SHARD_REPLICATION_E2E holds the JSON line printed by
5
+ // `cargo run -p pylon-runtime --example shard_replication_server`
6
+ // (tools/smoke-shard-codecs.sh sets it up).
7
+
8
+ import { expect, test } from "bun:test";
9
+
10
+ import { connectShard } from "./useShard";
11
+
12
+ const config = process.env.PYLON_SHARD_REPLICATION_E2E
13
+ ? (JSON.parse(process.env.PYLON_SHARD_REPLICATION_E2E) as {
14
+ port: number;
15
+ shard: string;
16
+ tokens: { ts: string };
17
+ })
18
+ : null;
19
+
20
+ function waitFor<T>(what: string, check: () => T | undefined, ms = 5000): Promise<T> {
21
+ return new Promise((resolve, reject) => {
22
+ const start = Date.now();
23
+ const timer = setInterval(() => {
24
+ const v = check();
25
+ if (v !== undefined) {
26
+ clearInterval(timer);
27
+ resolve(v);
28
+ } else if (Date.now() - start > ms) {
29
+ clearInterval(timer);
30
+ reject(new Error(`timed out waiting for ${what}`));
31
+ }
32
+ }, 10);
33
+ });
34
+ }
35
+
36
+ test.skipIf(!config)("the entity table follows spawns, moves, components, and despawns", async () => {
37
+ const client = connectShard<unknown, [number, number]>(config!.shard, {
38
+ subscriberId: "ts",
39
+ token: config!.tokens.ts,
40
+ baseUrl: "127.0.0.1",
41
+ wsPort: config!.port,
42
+ autoReconnect: false,
43
+ });
44
+ const errors: Error[] = [];
45
+ client.onError((e) => errors.push(e));
46
+ let frames = 0;
47
+ let sawFull = false;
48
+ client.onReplication((_table, summary) => {
49
+ frames++;
50
+ sawFull ||= summary.full;
51
+ });
52
+
53
+ // The first frame is a full baseline with every visible unit.
54
+ await waitFor("the baseline", () => (client.entities.size === 9 ? true : undefined));
55
+ expect(sawFull).toBe(true);
56
+ // The stealthed unit 0 never reaches this subscriber.
57
+ expect(client.entities.get(0)).toBeUndefined();
58
+
59
+ // Units move each tick: positions change and stay near their circle.
60
+ const before = client.entities.get(3)!.x;
61
+ await waitFor("a move", () => (client.entities.get(3)!.x !== before ? true : undefined));
62
+ const u3 = client.entities.get(3)!;
63
+ expect(Math.abs(u3.x - 15)).toBeLessThanOrEqual(1.01);
64
+
65
+ // A component change arrives.
66
+ client.send([4, 42]);
67
+ await waitFor("hp 42", () => (client.entities.get(4)?.components.get(1)?.[0] === 42 ? true : undefined));
68
+
69
+ // A despawn arrives.
70
+ client.send([5, 0]);
71
+ await waitFor("unit 5 gone", () => (client.entities.get(5) === undefined ? true : undefined));
72
+ expect(client.entities.size).toBe(8);
73
+ expect(frames).toBeGreaterThan(3);
74
+ expect(errors).toEqual([]);
75
+ client.close();
76
+ });
@@ -1,15 +1,18 @@
1
1
  // End-to-end: examples/shard-arena on a real `pylon start`. Its shard logic
2
2
  // is Rust compiled to WebAssembly and loaded by the stock binary.
3
3
  //
4
- // Runs only when PYLON_WASM_SHARD_E2E holds the server's host:port
5
- // (tools/smoke-wasm-shard.sh sets it up).
4
+ // Runs only when PYLON_WASM_SHARD_E2E holds the server's host:port or its
5
+ // origin (tools/smoke-wasm-shard.sh sets it up; an https:// origin tests a
6
+ // deployed app over wss://).
6
7
 
7
8
  import { afterAll, beforeAll, expect, test } from "bun:test";
8
9
 
9
10
  import { connectShard } from "./useShard";
10
11
  import type { ShardInputRejection } from "./shardWire";
11
12
 
12
- const host = process.env.PYLON_WASM_SHARD_E2E ?? "";
13
+ const target = process.env.PYLON_WASM_SHARD_E2E ?? "";
14
+ const origin = target.includes("://") ? target.replace(/\/$/, "") : `http://${target}`;
15
+ const host = target ? new URL(origin).host : "";
13
16
 
14
17
  // The test preload installs happy-dom, whose fetch enforces CORS against the
15
18
  // page URL. Put the page on the server's origin for these tests.
@@ -18,7 +21,7 @@ let previousUrl = "";
18
21
  beforeAll(() => {
19
22
  if (!host || !happyDOM) return;
20
23
  previousUrl = location.href;
21
- happyDOM.setURL(`http://${host}/`);
24
+ happyDOM.setURL(`${origin}/`);
22
25
  });
23
26
  afterAll(() => {
24
27
  if (previousUrl && happyDOM) happyDOM.setURL(previousUrl);
@@ -48,10 +51,10 @@ interface Join {
48
51
  }
49
52
 
50
53
  async function guestJoin(): Promise<Join> {
51
- const guest = await fetch(`http://${host}/api/auth/guest`, { method: "POST" });
54
+ const guest = await fetch(`${origin}/api/auth/guest`, { method: "POST" });
52
55
  expect(guest.ok).toBe(true);
53
56
  const { token } = (await guest.json()) as { token: string };
54
- const res = await fetch(`http://${host}/api/fn/joinArena`, {
57
+ const res = await fetch(`${origin}/api/fn/joinArena`, {
55
58
  method: "POST",
56
59
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
57
60
  body: "{}",
package/src/shardWire.ts CHANGED
@@ -1,125 +1,5 @@
1
1
  /**
2
- * The shard wire protocol, version 2 (see `pylon_realtime::wire` in Rust).
3
- *
4
- * A client asks for it with `?v=2` on the shard WebSocket URL. Each server
5
- * message is a binary frame with an 18-byte header:
6
- *
7
- * 0 1 frame kind: 1 snapshot, 2 input rejected
8
- * 1 1 codec: 0 JSON, 1 MessagePack, 2 bincode, 3 custom
9
- * 2 8 tick (u64 big-endian)
10
- * 10 8 ack: highest client_seq the shard processed for this subscriber (0 = none)
11
- * 18 .. payload in the codec
12
- *
13
- * Inputs go up as `{ input, client_seq }`: JSON in a text frame, or the
14
- * shard's codec in a binary frame.
2
+ * The shard wire protocol now lives in `@pylonsync/realtime`, the client
3
+ * pieces with no framework. Re-exported here for existing imports.
15
4
  */
16
-
17
- import { decode as msgpackDecode, encode as msgpackEncode } from "@msgpack/msgpack";
18
-
19
- export const SHARD_PROTOCOL_VERSION = 2;
20
- export const SHARD_HEADER_LEN = 18;
21
-
22
- export const ShardFrameKind = {
23
- Snapshot: 1,
24
- InputRejected: 2,
25
- } as const;
26
-
27
- export const ShardCodec = {
28
- Json: 0,
29
- MessagePack: 1,
30
- Bincode: 2,
31
- Custom: 3,
32
- } as const;
33
-
34
- export interface ShardFrame {
35
- kind: number;
36
- codec: number;
37
- tick: number;
38
- ack: number;
39
- payload: Uint8Array;
40
- }
41
-
42
- /** Why an input did not take effect. */
43
- export interface ShardInputRejection {
44
- clientSeq: number | null;
45
- /** `unauthorized`, `rate_limited`, `queue_full`, `invalid`, `stopped`, or `apply_failed`. */
46
- code: string;
47
- message: string;
48
- }
49
-
50
- /**
51
- * Decode a payload in a codec the JS client does not know (bincode, or a
52
- * game's own codec `3`).
53
- */
54
- export type ShardPayloadDecoder = (payload: Uint8Array, codec: number) => unknown;
55
-
56
- function readU64(view: DataView, offset: number): number {
57
- return view.getUint32(offset) * 0x1_0000_0000 + view.getUint32(offset + 4);
58
- }
59
-
60
- /** Split a version 2 frame into its header fields and payload. */
61
- export function parseShardFrame(data: ArrayBuffer): ShardFrame {
62
- if (data.byteLength < SHARD_HEADER_LEN) {
63
- throw new Error(`shard frame too short (${data.byteLength} bytes)`);
64
- }
65
- const view = new DataView(data);
66
- return {
67
- kind: view.getUint8(0),
68
- codec: view.getUint8(1),
69
- tick: readU64(view, 2),
70
- ack: readU64(view, 10),
71
- payload: new Uint8Array(data, SHARD_HEADER_LEN),
72
- };
73
- }
74
-
75
- /** Decode a payload. JSON and MessagePack are built in. */
76
- export function decodeShardPayload(
77
- codec: number,
78
- payload: Uint8Array,
79
- custom?: ShardPayloadDecoder,
80
- ): unknown {
81
- switch (codec) {
82
- case ShardCodec.Json:
83
- return JSON.parse(new TextDecoder().decode(payload));
84
- case ShardCodec.MessagePack:
85
- return msgpackDecode(payload);
86
- default:
87
- if (custom) return custom(payload, codec);
88
- throw new Error(
89
- `shard codec ${codec} needs a custom decoder (pass \`decode\` to connectShard)`,
90
- );
91
- }
92
- }
93
-
94
- /** The payload of an input-rejected frame, with camelCase fields. */
95
- export function decodeShardRejection(
96
- codec: number,
97
- payload: Uint8Array,
98
- custom?: ShardPayloadDecoder,
99
- ): ShardInputRejection {
100
- const raw = decodeShardPayload(codec, payload, custom) as {
101
- client_seq?: number | null;
102
- code?: string;
103
- message?: string;
104
- };
105
- return {
106
- clientSeq: typeof raw.client_seq === "number" ? raw.client_seq : null,
107
- code: String(raw.code ?? "invalid"),
108
- message: String(raw.message ?? ""),
109
- };
110
- }
111
-
112
- /**
113
- * Encode an input envelope. MessagePack shards get a binary frame; every
114
- * other codec (and a connection that has not seen a frame yet) gets JSON
115
- * text, which the server always accepts.
116
- */
117
- export function encodeShardInput(
118
- codec: number | null,
119
- input: unknown,
120
- clientSeq: number,
121
- ): string | Uint8Array {
122
- const envelope = { input, client_seq: clientSeq };
123
- if (codec === ShardCodec.MessagePack) return msgpackEncode(envelope);
124
- return JSON.stringify(envelope);
125
- }
5
+ export * from "@pylonsync/realtime";
package/src/ssr.ts CHANGED
@@ -207,6 +207,21 @@ export interface PageProps<
207
207
  * existing pages keep working; it will be removed in a later release.
208
208
  */
209
209
  url: string;
210
+ /**
211
+ * The host this request was made to, lowercased (e.g. `feedback.acme.com`),
212
+ * when the server trusts it: the app's own URL (`PYLON_PUBLIC_URL`,
213
+ * `PYLON_CANONICAL_HOST`), a `PYLON_TRUSTED_HOSTS` entry, a ready platform
214
+ * domain attached with `ctx.domains`, or loopback in dev (with its port).
215
+ * Any other `Host` header reads as `""`, so a forged header cannot pick
216
+ * what the page renders.
217
+ *
218
+ * It is the same value as the host part of the SSR cache key, so a page
219
+ * may render differently per host (one app serving each customer on their
220
+ * own domain) and stay cacheable: each host gets its own entry. It is also
221
+ * sent to the browser, so hydration and client navigation see the same
222
+ * value the server rendered with.
223
+ */
224
+ host: string;
210
225
  /** Dynamic-segment matches keyed by name (e.g. `{ slug: "hello-world" }`). */
211
226
  params: TParams;
212
227
  /** Parsed query string (e.g. `?start=10` → `{ start: "10" }`). */
@@ -0,0 +1,93 @@
1
+ import { afterAll, beforeAll, expect, test } from "bun:test";
2
+
3
+ import { SHARD_HEADER_LEN, ShardCodec, ShardFrameKind } from "@pylonsync/realtime";
4
+ import { connectShard } from "./useShard";
5
+
6
+ // A server that opens the socket and sends a replication frame the client
7
+ // can never apply (version 2). Each close schedules a reconnect; the
8
+ // delays must grow instead of resetting on every open.
9
+ const sockets: FakeWebSocket[] = [];
10
+ class FakeWebSocket {
11
+ static OPEN = 1;
12
+ readyState = 0;
13
+ binaryType = "blob";
14
+ onopen: (() => void) | null = null;
15
+ onmessage: ((e: { data: ArrayBuffer }) => void) | null = null;
16
+ onerror: (() => void) | null = null;
17
+ onclose: (() => void) | null = null;
18
+ constructor() {
19
+ sockets.push(this);
20
+ }
21
+ close() {
22
+ this.readyState = 3;
23
+ this.onclose?.();
24
+ }
25
+ send() {}
26
+ }
27
+
28
+ function replicationFrame(version: number): ArrayBuffer {
29
+ const buf = new ArrayBuffer(SHARD_HEADER_LEN + 9);
30
+ const view = new DataView(buf);
31
+ view.setUint8(0, ShardFrameKind.Replication);
32
+ view.setUint8(1, ShardCodec.Replication);
33
+ // FULL, precision 0.01, no despawns, spawns, or updates.
34
+ new Uint8Array(buf, SHARD_HEADER_LEN).set([version, 1, 0x0a, 0xd7, 0x23, 0x3c, 0, 0, 0]);
35
+ return buf;
36
+ }
37
+
38
+ /** Version 2: the client can never apply it. */
39
+ const badReplicationFrame = () => replicationFrame(2);
40
+
41
+ const realWebSocket = globalThis.WebSocket;
42
+ const realSetTimeout = globalThis.setTimeout;
43
+ const delays: number[] = [];
44
+ beforeAll(() => {
45
+ globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
46
+ // Record reconnect delays and run the reconnect at once.
47
+ globalThis.setTimeout = ((fn: () => void, ms: number) => {
48
+ delays.push(ms);
49
+ queueMicrotask(fn);
50
+ return 0;
51
+ }) as unknown as typeof setTimeout;
52
+ });
53
+ afterAll(() => {
54
+ globalThis.WebSocket = realWebSocket;
55
+ globalThis.setTimeout = realSetTimeout;
56
+ });
57
+
58
+ test("a frame that always fails backs off instead of reconnecting at the shortest delay", async () => {
59
+ const errors: Error[] = [];
60
+ const client = connectShard("zone", { subscriberId: "p1", baseUrl: "h" });
61
+ client.onError((e) => errors.push(e));
62
+ for (let i = 0; i < 5; i++) {
63
+ const ws = sockets[i];
64
+ ws.readyState = 1;
65
+ ws.onopen?.();
66
+ ws.onmessage?.({ data: badReplicationFrame() });
67
+ await new Promise((r) => realSetTimeout(r, 0));
68
+ }
69
+ client.close();
70
+ expect(delays.slice(0, 5)).toEqual([500, 1000, 2000, 4000, 8000]);
71
+ expect(errors.some((e) => e.message.includes("version 2"))).toBe(true);
72
+ });
73
+
74
+ test("a frame that applies resets the backoff", async () => {
75
+ sockets.length = 0;
76
+ delays.length = 0;
77
+ const client = connectShard("zone", { subscriberId: "p1", baseUrl: "h" });
78
+ for (let i = 0; i < 3; i++) {
79
+ const ws = sockets[i];
80
+ ws.readyState = 1;
81
+ ws.onopen?.();
82
+ ws.onmessage?.({ data: badReplicationFrame() });
83
+ await new Promise((r) => realSetTimeout(r, 0));
84
+ }
85
+ const ws = sockets[3];
86
+ ws.readyState = 1;
87
+ ws.onopen?.();
88
+ ws.onmessage?.({ data: replicationFrame(1) });
89
+ ws.close();
90
+ await new Promise((r) => realSetTimeout(r, 0));
91
+ client.close();
92
+ expect(delays).toEqual([500, 1000, 2000, 500]);
93
+ });
package/src/useShard.ts CHANGED
@@ -3,8 +3,10 @@
3
3
  /**
4
4
  * useShard — React hook for real-time sharded simulations (games, MMO zones, etc.).
5
5
  *
6
- * Connects to an pylon shard over WebSocket (preferred) or SSE (fallback),
7
- * receives snapshots as they arrive, and sends inputs upstream.
6
+ * Connects to a Pylon shard over WebSocket, receives snapshots as they
7
+ * arrive, and sends inputs upstream. It re-renders on every frame, so it
8
+ * suits small, turn-based, or UI-only use; a game's render loop should use
9
+ * `connectShardGame` from `@pylonsync/realtime`.
8
10
  *
9
11
  * @example
10
12
  * ```tsx
@@ -21,61 +23,25 @@
21
23
 
22
24
  import { useEffect, useRef, useState } from "react";
23
25
  import {
24
- SHARD_PROTOCOL_VERSION,
25
- ShardFrameKind,
26
- decodeShardPayload,
27
- decodeShardRejection,
28
- encodeShardInput,
29
- parseShardFrame,
26
+ connectShard,
27
+ type EntityTable,
28
+ type ShardClient,
29
+ type ShardConnectOptions,
30
30
  type ShardInputRejection,
31
- type ShardPayloadDecoder,
32
- } from "./shardWire";
31
+ } from "@pylonsync/realtime";
32
+
33
+ // The connection itself lives in `@pylonsync/realtime`; these names stay
34
+ // exported from here for existing imports.
35
+ export { connectShard };
36
+ export type { ShardClient };
33
37
 
34
38
  // ---------------------------------------------------------------------------
35
39
  // Types
36
40
  // ---------------------------------------------------------------------------
37
41
 
38
- export interface UseShardOptions {
39
- /** Subscriber ID (usually the logged-in user ID). Required for multiplayer. */
40
- subscriberId: string;
41
- /**
42
- * Auth token. Sent over the WebSocket as a Sec-WebSocket-Protocol
43
- * subprotocol header in the form `"bearer.<token>"`. This keeps the token
44
- * out of URLs — proxy logs, browser devtools network panel, and error
45
- * telemetry typically record the URL but not the subprotocol value.
46
- *
47
- * The pylon shard server reads either the subprotocol header or the
48
- * legacy `?token=` query param (which is still accepted but deprecated —
49
- * scheduled for removal in a future release).
50
- */
51
- token?: string;
52
- /**
53
- * Shard ticket from a server function (`ctx.shards.ticket(...)`). Sent as
54
- * a `ticket.<ticket>` WebSocket subprotocol. The shard checks it names
55
- * this shard and `subscriberId`, and passes its claims to the game's
56
- * authorization hooks.
57
- */
58
- ticket?: string;
59
- /** Host (and port) of the Pylon server. Defaults to `window.location.host`. */
60
- baseUrl?: string;
61
- /**
62
- * Connect to the dedicated shard port instead of `/shard` on the main
63
- * port (the dedicated port is the HTTP port + 3, e.g. 4324).
64
- */
65
- wsPort?: number;
66
- /** Explicit WebSocket URL. Overrides baseUrl/wsPort. */
67
- wsUrl?: string;
68
- /** If true, falls back to SSE + HTTP POST if WebSocket fails (default: true). */
42
+ export interface UseShardOptions extends ShardConnectOptions {
43
+ /** Unused: a shard connection is always a WebSocket. */
69
44
  sseFallback?: boolean;
70
- /** Reconnect on unexpected close (default: true). */
71
- autoReconnect?: boolean;
72
- /** Reconnect backoff in ms (default: starts at 500, maxes at 10_000). */
73
- reconnectBackoffMs?: number;
74
- /**
75
- * Decoder for a payload codec the client does not know: bincode (`2`) or
76
- * a game's own codec (`3`). JSON and MessagePack are built in.
77
- */
78
- decode?: ShardPayloadDecoder;
79
45
  }
80
46
 
81
47
  export interface UseShardReturn<TSnapshot = unknown, TInput = unknown> {
@@ -91,186 +57,20 @@ export interface UseShardReturn<TSnapshot = unknown, TInput = unknown> {
91
57
  lastRejection: ShardInputRejection | null;
92
58
  connected: boolean;
93
59
  error: Error | null;
94
- /** Send an input to the shard. Returns a client sequence number. */
60
+ /** Send an input to the shard. Returns a client sequence number, or 0
61
+ * when the connection is not open. */
95
62
  send: (input: TInput) => number;
96
63
  /** Close the connection early. */
97
64
  close: () => void;
98
- }
99
-
100
- // ---------------------------------------------------------------------------
101
- // Low-level client (no React)
102
- // ---------------------------------------------------------------------------
103
-
104
- export interface ShardClient<TSnapshot = unknown, TInput = unknown> {
105
- /** `ack` is the highest `send()` sequence number the shard has processed. */
106
- onSnapshot: (fn: (snapshot: TSnapshot, tick: number, ack: number) => void) => void;
107
- /** Called when the shard refuses an input (see `ShardInputRejection.code`). */
108
- onInputRejected: (fn: (rejection: ShardInputRejection) => void) => void;
109
- onError: (fn: (err: Error) => void) => void;
110
- onOpen: (fn: () => void) => void;
111
- onClose: (fn: () => void) => void;
112
- send: (input: TInput) => number;
113
- close: () => void;
114
- readonly connected: boolean;
115
- }
116
-
117
- /**
118
- * Connect to a shard without React — returns a typed client you can wire
119
- * into any framework.
120
- */
121
- export function connectShard<TSnapshot = unknown, TInput = unknown>(
122
- shardId: string,
123
- options: UseShardOptions
124
- ): ShardClient<TSnapshot, TInput> {
125
- let ws: WebSocket | null = null;
126
- let clientSeq = 0;
127
- let closed = false;
128
- let connected = false;
129
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
130
- let backoff = options.reconnectBackoffMs ?? 500;
131
- // The shard's codec, learned from the first frame. Until then inputs go
132
- // as JSON text, which every shard accepts.
133
- let codec: number | null = null;
134
-
135
- const snapshotHandlers: Array<(s: TSnapshot, t: number, ack: number) => void> = [];
136
- const rejectionHandlers: Array<(r: ShardInputRejection) => void> = [];
137
- const errorHandlers: Array<(e: Error) => void> = [];
138
- const openHandlers: Array<() => void> = [];
139
- const closeHandlers: Array<() => void> = [];
140
-
141
- const dispatchSnapshot = (snapshot: TSnapshot, tick: number, ack: number) => {
142
- for (const h of snapshotHandlers) h(snapshot, tick, ack);
143
- };
144
- const dispatchError = (err: Error) => {
145
- for (const h of errorHandlers) h(err);
146
- };
147
-
148
- const buildWsUrl = (): string => {
149
- if (options.wsUrl) return options.wsUrl;
150
- const proto =
151
- typeof window !== "undefined" && window.location.protocol === "https:"
152
- ? "wss"
153
- : "ws";
154
- // Only shard id + subscriber id land in the URL — these are routing
155
- // metadata, not credentials.
156
- const params = new URLSearchParams({
157
- shard: shardId,
158
- sid: options.subscriberId,
159
- v: String(SHARD_PROTOCOL_VERSION),
160
- });
161
- if (options.wsPort !== undefined) {
162
- const hostname =
163
- (options.baseUrl ?? (typeof window !== "undefined" ? window.location.hostname : "localhost"))
164
- .replace(/:\d+$/, "");
165
- return `${proto}://${hostname}:${options.wsPort}/?${params.toString()}`;
166
- }
167
- // Default: `/shard` on the page's own origin, which any proxy that
168
- // forwards WebSocket upgrades on 443 already reaches.
169
- const host =
170
- options.baseUrl || (typeof window !== "undefined" ? window.location.host : "localhost:4321");
171
- return `${proto}://${host}/shard?${params.toString()}`;
172
- };
173
-
174
- const connect = () => {
175
- if (closed) return;
176
- const url = buildWsUrl();
177
- try {
178
- // The bearer token rides on the WebSocket subprotocol header so it
179
- // doesn't get captured by every proxy / devtools pane that logs URLs.
180
- // Subprotocol values must be a token per RFC 6455; encode the bearer
181
- // so spaces/punctuation don't break the handshake.
182
- const protocols: string[] = [];
183
- if (options.token) protocols.push(`bearer.${encodeURIComponent(options.token)}`);
184
- if (options.ticket) protocols.push(`ticket.${encodeURIComponent(options.ticket)}`);
185
- ws = protocols.length ? new WebSocket(url, protocols) : new WebSocket(url);
186
- } catch (e) {
187
- dispatchError(e instanceof Error ? e : new Error(String(e)));
188
- return;
189
- }
190
- ws.binaryType = "arraybuffer";
191
-
192
- ws.onopen = () => {
193
- connected = true;
194
- backoff = options.reconnectBackoffMs ?? 500;
195
- for (const h of openHandlers) h();
196
- };
197
-
198
- ws.onmessage = (event) => {
199
- if (!(event.data instanceof ArrayBuffer)) return;
200
- try {
201
- const frame = parseShardFrame(event.data);
202
- codec = frame.codec;
203
- if (frame.kind === ShardFrameKind.Snapshot) {
204
- const snapshot = decodeShardPayload(
205
- frame.codec,
206
- frame.payload,
207
- options.decode,
208
- ) as TSnapshot;
209
- dispatchSnapshot(snapshot, frame.tick, frame.ack);
210
- } else if (frame.kind === ShardFrameKind.InputRejected) {
211
- const rejection = decodeShardRejection(frame.codec, frame.payload, options.decode);
212
- for (const h of rejectionHandlers) h(rejection);
213
- }
214
- } catch (e) {
215
- dispatchError(e instanceof Error ? e : new Error("Failed to decode shard frame"));
216
- }
217
- };
218
-
219
- ws.onerror = () => {
220
- dispatchError(new Error(`WebSocket error connecting to shard ${shardId}`));
221
- };
222
-
223
- ws.onclose = () => {
224
- connected = false;
225
- for (const h of closeHandlers) h();
226
- if (closed) return;
227
- if (options.autoReconnect !== false) {
228
- reconnectTimer = setTimeout(connect, backoff);
229
- backoff = Math.min(backoff * 2, 10_000);
230
- }
231
- };
232
- };
233
-
234
- connect();
235
-
236
- return {
237
- get connected() {
238
- return connected;
239
- },
240
- onSnapshot(fn) {
241
- snapshotHandlers.push(fn);
242
- },
243
- onInputRejected(fn) {
244
- rejectionHandlers.push(fn);
245
- },
246
- onError(fn) {
247
- errorHandlers.push(fn);
248
- },
249
- onOpen(fn) {
250
- openHandlers.push(fn);
251
- },
252
- onClose(fn) {
253
- closeHandlers.push(fn);
254
- },
255
- send(input: TInput): number {
256
- clientSeq += 1;
257
- const seq = clientSeq;
258
- const payload = encodeShardInput(codec, input, seq);
259
- if (ws && ws.readyState === WebSocket.OPEN) {
260
- ws.send(payload);
261
- } else {
262
- dispatchError(
263
- new Error("Cannot send: shard connection is not open")
264
- );
265
- }
266
- return seq;
267
- },
268
- close() {
269
- closed = true;
270
- if (reconnectTimer) clearTimeout(reconnectTimer);
271
- if (ws) ws.close();
272
- },
273
- };
65
+ /**
66
+ * For a shard that replicates entities: the entities this client has
67
+ * been sent, updated in place. The hook re-renders once per applied frame
68
+ * (`entitiesVersion` changes). A render loop should use
69
+ * `connectShardGame` from `@pylonsync/realtime` instead, which draws
70
+ * entities between frames without re-rendering.
71
+ */
72
+ entities: EntityTable | null;
73
+ entitiesVersion: number;
274
74
  }
275
75
 
276
76
  // ---------------------------------------------------------------------------
@@ -293,6 +93,7 @@ export function useShard<TSnapshot = unknown, TInput = unknown>(
293
93
  const [lastRejection, setLastRejection] = useState<ShardInputRejection | null>(null);
294
94
  const [connected, setConnected] = useState<boolean>(false);
295
95
  const [error, setError] = useState<Error | null>(null);
96
+ const [entitiesVersion, setEntitiesVersion] = useState<number>(0);
296
97
 
297
98
  const clientRef = useRef<ShardClient<TSnapshot, TInput> | null>(null);
298
99
 
@@ -302,14 +103,27 @@ export function useShard<TSnapshot = unknown, TInput = unknown>(
302
103
  // excluded `options` entirely, so a user logging out would keep the
303
104
  // old socket alive under the old identity until `shardId` changed.
304
105
  const token = options.token;
305
- const ticket = options.ticket;
106
+ // A ticket function is called on each connection attempt; a new function
107
+ // on each render must not reconnect, so the effect reads the latest one.
108
+ const ticketRef = useRef(options.ticket);
109
+ ticketRef.current = options.ticket;
110
+ const ticket = typeof options.ticket === "function" ? "function" : options.ticket;
306
111
  const subscriberId = options.subscriberId;
307
112
  const baseUrl = options.baseUrl;
308
113
  const wsUrl = options.wsUrl;
309
114
  const wsPort = options.wsPort;
310
115
 
311
116
  useEffect(() => {
312
- const client = connectShard<TSnapshot, TInput>(shardId, options);
117
+ const client = connectShard<TSnapshot, TInput>(shardId, {
118
+ ...options,
119
+ ticket:
120
+ typeof options.ticket === "function"
121
+ ? () => {
122
+ const current = ticketRef.current;
123
+ return typeof current === "function" ? current() : (current ?? "");
124
+ }
125
+ : options.ticket,
126
+ });
313
127
  clientRef.current = client;
314
128
 
315
129
  client.onSnapshot((snap, t, a) => {
@@ -318,6 +132,11 @@ export function useShard<TSnapshot = unknown, TInput = unknown>(
318
132
  setAck(a);
319
133
  });
320
134
  client.onInputRejected((r) => setLastRejection(r));
135
+ client.onReplication((_table, _summary, t, a) => {
136
+ setTick(t);
137
+ setAck(a);
138
+ setEntitiesVersion((v) => v + 1);
139
+ });
321
140
  client.onOpen(() => setConnected(true));
322
141
  client.onClose(() => setConnected(false));
323
142
  client.onError((e) => setError(e));
@@ -338,5 +157,16 @@ export function useShard<TSnapshot = unknown, TInput = unknown>(
338
157
  if (clientRef.current) clientRef.current.close();
339
158
  };
340
159
 
341
- return { snapshot, tick, ack, lastRejection, connected, error, send, close };
160
+ return {
161
+ snapshot,
162
+ tick,
163
+ ack,
164
+ lastRejection,
165
+ connected,
166
+ error,
167
+ send,
168
+ close,
169
+ entities: clientRef.current?.entities ?? null,
170
+ entitiesVersion,
171
+ };
342
172
  }
@@ -1,72 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { encode as msgpackEncode, decode as msgpackDecode } from "@msgpack/msgpack";
3
-
4
- import {
5
- SHARD_HEADER_LEN,
6
- ShardCodec,
7
- ShardFrameKind,
8
- decodeShardPayload,
9
- decodeShardRejection,
10
- encodeShardInput,
11
- parseShardFrame,
12
- } from "./shardWire";
13
-
14
- function frame(kind: number, codec: number, tick: number, ack: number, payload: Uint8Array) {
15
- const buf = new ArrayBuffer(SHARD_HEADER_LEN + payload.length);
16
- const view = new DataView(buf);
17
- view.setUint8(0, kind);
18
- view.setUint8(1, codec);
19
- view.setUint32(2, Math.floor(tick / 0x1_0000_0000));
20
- view.setUint32(6, tick >>> 0);
21
- view.setUint32(10, Math.floor(ack / 0x1_0000_0000));
22
- view.setUint32(14, ack >>> 0);
23
- new Uint8Array(buf, SHARD_HEADER_LEN).set(payload);
24
- return buf;
25
- }
26
-
27
- describe("parseShardFrame", () => {
28
- test("reads kind, codec, tick, ack, and payload", () => {
29
- const f = parseShardFrame(
30
- frame(ShardFrameKind.Snapshot, ShardCodec.Json, 2 ** 33 + 5, 7, new TextEncoder().encode("42")),
31
- );
32
- expect(f.kind).toBe(1);
33
- expect(f.codec).toBe(0);
34
- expect(f.tick).toBe(2 ** 33 + 5);
35
- expect(f.ack).toBe(7);
36
- expect(decodeShardPayload(f.codec, f.payload)).toBe(42);
37
- });
38
-
39
- test("rejects a frame shorter than the header", () => {
40
- expect(() => parseShardFrame(new ArrayBuffer(10))).toThrow("too short");
41
- });
42
- });
43
-
44
- test("MessagePack payloads decode to objects", () => {
45
- const payload = msgpackEncode({ players: [{ id: "a", x: 1.5 }], tick: 3 });
46
- const f = parseShardFrame(frame(ShardFrameKind.Snapshot, ShardCodec.MessagePack, 3, 0, payload));
47
- expect(decodeShardPayload(f.codec, f.payload)).toEqual({ players: [{ id: "a", x: 1.5 }], tick: 3 });
48
- });
49
-
50
- test("rejection frames decode with camelCase fields", () => {
51
- const payload = msgpackEncode({ client_seq: 9, code: "rate_limited", message: "slow down" });
52
- const f = parseShardFrame(frame(ShardFrameKind.InputRejected, ShardCodec.MessagePack, 1, 8, payload));
53
- expect(decodeShardRejection(f.codec, f.payload)).toEqual({
54
- clientSeq: 9,
55
- code: "rate_limited",
56
- message: "slow down",
57
- });
58
- });
59
-
60
- test("an unknown codec needs a custom decoder", () => {
61
- const bytes = new Uint8Array([1, 2, 3]);
62
- expect(() => decodeShardPayload(ShardCodec.Custom, bytes)).toThrow("custom decoder");
63
- expect(decodeShardPayload(ShardCodec.Custom, bytes, (p) => p.length)).toBe(3);
64
- });
65
-
66
- test("inputs are MessagePack for MessagePack shards and JSON otherwise", () => {
67
- const bin = encodeShardInput(ShardCodec.MessagePack, { move: "n" }, 4);
68
- expect(bin).toBeInstanceOf(Uint8Array);
69
- expect(msgpackDecode(bin as Uint8Array)).toEqual({ input: { move: "n" }, client_seq: 4 });
70
- expect(encodeShardInput(null, 5, 1)).toBe('{"input":5,"client_seq":1}');
71
- expect(encodeShardInput(ShardCodec.Json, 5, 2)).toBe('{"input":5,"client_seq":2}');
72
- });