@pylonsync/react 0.11.6 → 0.13.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,6 +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
29
  export { useSession } from "./useSession";
28
30
  export type { UseSessionReturn, ResolvedSession } from "./useSession";
29
31
  export { useSyncStatus } from "./useSyncStatus";
@@ -0,0 +1,58 @@
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.
15
+ */
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;
@@ -1,3 +1,4 @@
1
+ import { type ShardInputRejection, type ShardPayloadDecoder } from "./shardWire";
1
2
  export interface UseShardOptions {
2
3
  /** Subscriber ID (usually the logged-in user ID). Required for multiplayer. */
3
4
  subscriberId: string;
@@ -12,9 +13,19 @@ export interface UseShardOptions {
12
13
  * scheduled for removal in a future release).
13
14
  */
14
15
  token?: string;
15
- /** Override the base URL. Defaults to `window.location.host`. */
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`. */
16
24
  baseUrl?: string;
17
- /** Override the shard WS port (default: HTTP port + 3). */
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
+ */
18
29
  wsPort?: number;
19
30
  /** Explicit WebSocket URL. Overrides baseUrl/wsPort. */
20
31
  wsUrl?: string;
@@ -24,10 +35,23 @@ export interface UseShardOptions {
24
35
  autoReconnect?: boolean;
25
36
  /** Reconnect backoff in ms (default: starts at 500, maxes at 10_000). */
26
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;
27
43
  }
28
44
  export interface UseShardReturn<TSnapshot = unknown, TInput = unknown> {
29
45
  snapshot: TSnapshot | null;
30
46
  tick: number;
47
+ /**
48
+ * The highest `send()` sequence number the shard has processed (applied
49
+ * or rejected) as of `snapshot`. Drop local predictions up to it and
50
+ * replay the rest on top of `snapshot`.
51
+ */
52
+ ack: number;
53
+ /** The most recent input the shard refused, if any. */
54
+ lastRejection: ShardInputRejection | null;
31
55
  connected: boolean;
32
56
  error: Error | null;
33
57
  /** Send an input to the shard. Returns a client sequence number. */
@@ -36,7 +60,10 @@ export interface UseShardReturn<TSnapshot = unknown, TInput = unknown> {
36
60
  close: () => void;
37
61
  }
38
62
  export interface ShardClient<TSnapshot = unknown, TInput = unknown> {
39
- onSnapshot: (fn: (snapshot: TSnapshot, tick: number) => void) => void;
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;
40
67
  onError: (fn: (err: Error) => void) => void;
41
68
  onOpen: (fn: () => void) => void;
42
69
  onClose: (fn: () => void) => void;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.11.6",
6
+ "version": "0.13.0",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -14,8 +14,9 @@
14
14
  "prepack": "bun run build"
15
15
  },
16
16
  "dependencies": {
17
- "@pylonsync/sdk": "0.11.6",
18
- "@pylonsync/sync": "0.11.6"
17
+ "@msgpack/msgpack": "^3.1.3",
18
+ "@pylonsync/sdk": "0.13.0",
19
+ "@pylonsync/sync": "0.13.0"
19
20
  },
20
21
  "peerDependencies": {
21
22
  "react": ">=19.0.0"
package/src/index.ts CHANGED
@@ -142,6 +142,19 @@ export type {
142
142
  UseShardReturn,
143
143
  ShardClient,
144
144
  } from "./useShard";
145
+ export {
146
+ SHARD_PROTOCOL_VERSION,
147
+ ShardCodec,
148
+ ShardFrameKind,
149
+ parseShardFrame,
150
+ decodeShardPayload,
151
+ encodeShardInput,
152
+ } from "./shardWire";
153
+ export type {
154
+ ShardFrame,
155
+ ShardInputRejection,
156
+ ShardPayloadDecoder,
157
+ } from "./shardWire";
145
158
 
146
159
  // Session hook — server-resolved user + tenant identity
147
160
  export { useSession } from "./useSession";
@@ -0,0 +1,151 @@
1
+ // End-to-end: examples/shard-arena on a real `pylon start`. Its shard logic
2
+ // is Rust compiled to WebAssembly and loaded by the stock binary.
3
+ //
4
+ // Runs only when PYLON_WASM_SHARD_E2E holds the server's host:port
5
+ // (tools/smoke-wasm-shard.sh sets it up).
6
+
7
+ import { afterAll, beforeAll, expect, test } from "bun:test";
8
+
9
+ import { connectShard } from "./useShard";
10
+ import type { ShardInputRejection } from "./shardWire";
11
+
12
+ const host = process.env.PYLON_WASM_SHARD_E2E ?? "";
13
+
14
+ // The test preload installs happy-dom, whose fetch enforces CORS against the
15
+ // page URL. Put the page on the server's origin for these tests.
16
+ const happyDOM = (globalThis as { happyDOM?: { setURL(url: string): void } }).happyDOM;
17
+ let previousUrl = "";
18
+ beforeAll(() => {
19
+ if (!host || !happyDOM) return;
20
+ previousUrl = location.href;
21
+ happyDOM.setURL(`http://${host}/`);
22
+ });
23
+ afterAll(() => {
24
+ if (previousUrl && happyDOM) happyDOM.setURL(previousUrl);
25
+ });
26
+
27
+ interface Player {
28
+ id: string;
29
+ x: number;
30
+ y: number;
31
+ tx: number;
32
+ ty: number;
33
+ hue: number;
34
+ }
35
+
36
+ interface Arena {
37
+ width: number;
38
+ height: number;
39
+ players: Player[];
40
+ }
41
+
42
+ type Input = "join" | { move_to: { x: number; y: number } };
43
+
44
+ interface Join {
45
+ shardId: string;
46
+ subscriberId: string;
47
+ ticket: string;
48
+ }
49
+
50
+ async function guestJoin(): Promise<Join> {
51
+ const guest = await fetch(`http://${host}/api/auth/guest`, { method: "POST" });
52
+ expect(guest.ok).toBe(true);
53
+ const { token } = (await guest.json()) as { token: string };
54
+ const res = await fetch(`http://${host}/api/fn/joinArena`, {
55
+ method: "POST",
56
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
57
+ body: "{}",
58
+ });
59
+ expect(res.status).toBe(200);
60
+ return (await res.json()) as Join;
61
+ }
62
+
63
+ function waitFor<T>(what: string, check: () => T | undefined, ms = 8000): Promise<T> {
64
+ return new Promise((resolve, reject) => {
65
+ const start = Date.now();
66
+ const timer = setInterval(() => {
67
+ const v = check();
68
+ if (v !== undefined) {
69
+ clearInterval(timer);
70
+ resolve(v);
71
+ } else if (Date.now() - start > ms) {
72
+ clearInterval(timer);
73
+ reject(new Error(`timed out waiting for ${what}`));
74
+ }
75
+ }, 20);
76
+ });
77
+ }
78
+
79
+ function open(join: Join, ticket = join.ticket) {
80
+ const client = connectShard<Arena, Input>(join.shardId, {
81
+ subscriberId: join.subscriberId,
82
+ ticket,
83
+ baseUrl: host,
84
+ autoReconnect: false,
85
+ });
86
+ const state: {
87
+ latest?: Arena;
88
+ ack: number;
89
+ rejections: ShardInputRejection[];
90
+ errors: Error[];
91
+ opened: boolean;
92
+ closed: boolean;
93
+ } = { ack: 0, rejections: [], errors: [], opened: false, closed: false };
94
+ client.onSnapshot((snapshot, _tick, ack) => {
95
+ state.latest = snapshot;
96
+ state.ack = ack;
97
+ });
98
+ client.onInputRejected((r) => state.rejections.push(r));
99
+ client.onError((e) => state.errors.push(e));
100
+ client.onOpen(() => (state.opened = true));
101
+ client.onClose(() => (state.closed = true));
102
+ return { client, state };
103
+ }
104
+
105
+ test.skipIf(!host)("two players move in a WebAssembly shard over /shard", async () => {
106
+ const [a, b] = await Promise.all([guestJoin(), guestJoin()]);
107
+ expect(a.shardId).toBe(b.shardId);
108
+ const pa = open(a);
109
+ const pb = open(b);
110
+
111
+ await waitFor("both snapshots", () => (pa.state.latest && pb.state.latest ? true : undefined));
112
+ // MessagePack snapshots decode into objects.
113
+ expect(pa.state.latest!.width).toBe(800);
114
+
115
+ pa.client.send("join");
116
+ pb.client.send("join");
117
+ const target = { x: 700, y: 400 };
118
+ const seq = pa.client.send({ move_to: target });
119
+
120
+ // B sees A arrive at the target: the module's tick moves the dot.
121
+ const seenByB = await waitFor("A at its target, seen by B", () =>
122
+ pb.state.latest?.players.find(
123
+ (p) => p.id === a.subscriberId && p.x === target.x && p.y === target.y,
124
+ ),
125
+ );
126
+ expect(seenByB.tx).toBe(target.x);
127
+ expect(pa.state.ack).toBeGreaterThanOrEqual(seq);
128
+ expect(pb.state.latest!.players.some((p) => p.id === b.subscriberId)).toBe(true);
129
+
130
+ // The module refuses a move outside the arena; the refusal comes back.
131
+ const bad = pa.client.send({ move_to: { x: 5000, y: 0 } });
132
+ const rejection = await waitFor("a rejection", () => pa.state.rejections[0]);
133
+ expect(rejection).toMatchObject({ clientSeq: bad, code: "apply_failed" });
134
+ expect(rejection.message).toContain("outside the arena");
135
+
136
+ expect(pa.state.errors).toEqual([]);
137
+ expect(pb.state.errors).toEqual([]);
138
+ pa.client.close();
139
+ pb.client.close();
140
+ });
141
+
142
+ test.skipIf(!host)("a ticket for another subscriber is refused", async () => {
143
+ const [a, b] = await Promise.all([guestJoin(), guestJoin()]);
144
+ // B's ticket names B; presenting it as A fails the handshake.
145
+ const stolen = open(a, b.ticket);
146
+ await waitFor("the refused connection to close", () =>
147
+ stolen.state.closed || stolen.state.errors.length > 0 ? true : undefined,
148
+ );
149
+ expect(stolen.state.latest).toBeUndefined();
150
+ stolen.client.close();
151
+ });
@@ -0,0 +1,78 @@
1
+ // End-to-end: connectShard against a real MessagePack shard.
2
+ //
3
+ // Runs only when PYLON_SHARD_E2E holds the JSON line printed by
4
+ // `cargo run -p pylon-runtime --example shard_codec_server`
5
+ // (tools/smoke-shard-codecs.sh sets it up).
6
+
7
+ import { expect, test } from "bun:test";
8
+
9
+ import { connectShard } from "./useShard";
10
+ import type { ShardInputRejection } from "./shardWire";
11
+
12
+ const config = process.env.PYLON_SHARD_E2E
13
+ ? (JSON.parse(process.env.PYLON_SHARD_E2E) as {
14
+ port: number;
15
+ shard: string;
16
+ tokens: { ts: string };
17
+ })
18
+ : null;
19
+
20
+ interface Arena {
21
+ players: Array<{ id: string; x: number; y: number }>;
22
+ label: string;
23
+ }
24
+
25
+ function waitFor<T>(what: string, check: () => T | undefined, ms = 5000): Promise<T> {
26
+ return new Promise((resolve, reject) => {
27
+ const start = Date.now();
28
+ const timer = setInterval(() => {
29
+ const v = check();
30
+ if (v !== undefined) {
31
+ clearInterval(timer);
32
+ resolve(v);
33
+ } else if (Date.now() - start > ms) {
34
+ clearInterval(timer);
35
+ reject(new Error(`timed out waiting for ${what}`));
36
+ }
37
+ }, 10);
38
+ });
39
+ }
40
+
41
+ test.skipIf(!config)("MessagePack snapshots, binary inputs, acks, and rejections", async () => {
42
+ const client = connectShard<Arena, { dx: number; dy: number }>(config!.shard, {
43
+ subscriberId: "ts",
44
+ token: config!.tokens.ts,
45
+ baseUrl: "127.0.0.1",
46
+ wsPort: config!.port,
47
+ autoReconnect: false,
48
+ });
49
+ let latest: { snapshot: Arena; ack: number } | undefined;
50
+ const rejections: ShardInputRejection[] = [];
51
+ const errors: Error[] = [];
52
+ client.onSnapshot((snapshot, _tick, ack) => {
53
+ latest = { snapshot, ack };
54
+ });
55
+ client.onInputRejected((r) => rejections.push(r));
56
+ client.onError((e) => errors.push(e));
57
+
58
+ // The first frame decodes from MessagePack into an object.
59
+ await waitFor("a snapshot", () => latest);
60
+ expect(latest!.snapshot.label).toBe("arena");
61
+
62
+ // Inputs now go as binary MessagePack frames (the client learned the codec).
63
+ for (let i = 0; i < 3; i++) client.send({ dx: 1, dy: 2 });
64
+ const moved = await waitFor("ack 3", () => (latest && latest.ack >= 3 ? latest : undefined));
65
+ const me = moved.snapshot.players.find((p) => p.id === "ts");
66
+ expect(me).toEqual({ id: "ts", x: 3, y: 6 });
67
+
68
+ // A move apply_input refuses comes back as a rejection frame.
69
+ const seq = client.send({ dx: -999, dy: 0 });
70
+ const rejection = await waitFor("a rejection", () => rejections[0]);
71
+ expect(rejection).toMatchObject({ clientSeq: seq, code: "apply_failed" });
72
+ await waitFor("ack of the rejected input", () =>
73
+ latest && latest.ack >= seq ? latest : undefined,
74
+ );
75
+
76
+ expect(errors).toEqual([]);
77
+ client.close();
78
+ });
@@ -0,0 +1,72 @@
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
+ });
@@ -0,0 +1,125 @@
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.
15
+ */
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
+ }
package/src/useShard.ts CHANGED
@@ -20,6 +20,16 @@
20
20
  */
21
21
 
22
22
  import { useEffect, useRef, useState } from "react";
23
+ import {
24
+ SHARD_PROTOCOL_VERSION,
25
+ ShardFrameKind,
26
+ decodeShardPayload,
27
+ decodeShardRejection,
28
+ encodeShardInput,
29
+ parseShardFrame,
30
+ type ShardInputRejection,
31
+ type ShardPayloadDecoder,
32
+ } from "./shardWire";
23
33
 
24
34
  // ---------------------------------------------------------------------------
25
35
  // Types
@@ -39,9 +49,19 @@ export interface UseShardOptions {
39
49
  * scheduled for removal in a future release).
40
50
  */
41
51
  token?: string;
42
- /** Override the base URL. Defaults to `window.location.host`. */
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`. */
43
60
  baseUrl?: string;
44
- /** Override the shard WS port (default: HTTP port + 3). */
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
+ */
45
65
  wsPort?: number;
46
66
  /** Explicit WebSocket URL. Overrides baseUrl/wsPort. */
47
67
  wsUrl?: string;
@@ -51,11 +71,24 @@ export interface UseShardOptions {
51
71
  autoReconnect?: boolean;
52
72
  /** Reconnect backoff in ms (default: starts at 500, maxes at 10_000). */
53
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;
54
79
  }
55
80
 
56
81
  export interface UseShardReturn<TSnapshot = unknown, TInput = unknown> {
57
82
  snapshot: TSnapshot | null;
58
83
  tick: number;
84
+ /**
85
+ * The highest `send()` sequence number the shard has processed (applied
86
+ * or rejected) as of `snapshot`. Drop local predictions up to it and
87
+ * replay the rest on top of `snapshot`.
88
+ */
89
+ ack: number;
90
+ /** The most recent input the shard refused, if any. */
91
+ lastRejection: ShardInputRejection | null;
59
92
  connected: boolean;
60
93
  error: Error | null;
61
94
  /** Send an input to the shard. Returns a client sequence number. */
@@ -69,7 +102,10 @@ export interface UseShardReturn<TSnapshot = unknown, TInput = unknown> {
69
102
  // ---------------------------------------------------------------------------
70
103
 
71
104
  export interface ShardClient<TSnapshot = unknown, TInput = unknown> {
72
- onSnapshot: (fn: (snapshot: TSnapshot, tick: number) => void) => void;
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;
73
109
  onError: (fn: (err: Error) => void) => void;
74
110
  onOpen: (fn: () => void) => void;
75
111
  onClose: (fn: () => void) => void;
@@ -92,14 +128,18 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
92
128
  let connected = false;
93
129
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
94
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;
95
134
 
96
- const snapshotHandlers: Array<(s: TSnapshot, t: number) => void> = [];
135
+ const snapshotHandlers: Array<(s: TSnapshot, t: number, ack: number) => void> = [];
136
+ const rejectionHandlers: Array<(r: ShardInputRejection) => void> = [];
97
137
  const errorHandlers: Array<(e: Error) => void> = [];
98
138
  const openHandlers: Array<() => void> = [];
99
139
  const closeHandlers: Array<() => void> = [];
100
140
 
101
- const dispatchSnapshot = (snapshot: TSnapshot, tick: number) => {
102
- for (const h of snapshotHandlers) h(snapshot, tick);
141
+ const dispatchSnapshot = (snapshot: TSnapshot, tick: number, ack: number) => {
142
+ for (const h of snapshotHandlers) h(snapshot, tick, ack);
103
143
  };
104
144
  const dispatchError = (err: Error) => {
105
145
  for (const h of errorHandlers) h(err);
@@ -107,10 +147,6 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
107
147
 
108
148
  const buildWsUrl = (): string => {
109
149
  if (options.wsUrl) return options.wsUrl;
110
- const host =
111
- options.baseUrl ||
112
- (typeof window !== "undefined" ? window.location.hostname : "localhost");
113
- const port = options.wsPort ?? 4324; // default: pylon HTTP port + 3 (4321 + 3)
114
150
  const proto =
115
151
  typeof window !== "undefined" && window.location.protocol === "https:"
116
152
  ? "wss"
@@ -120,8 +156,19 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
120
156
  const params = new URLSearchParams({
121
157
  shard: shardId,
122
158
  sid: options.subscriberId,
159
+ v: String(SHARD_PROTOCOL_VERSION),
123
160
  });
124
- return `${proto}://${host}:${port}/?${params.toString()}`;
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()}`;
125
172
  };
126
173
 
127
174
  const connect = () => {
@@ -132,10 +179,10 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
132
179
  // doesn't get captured by every proxy / devtools pane that logs URLs.
133
180
  // Subprotocol values must be a token per RFC 6455; encode the bearer
134
181
  // so spaces/punctuation don't break the handshake.
135
- const protocols = options.token
136
- ? [`bearer.${encodeURIComponent(options.token)}`]
137
- : undefined;
138
- ws = protocols ? new WebSocket(url, protocols) : new WebSocket(url);
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);
139
186
  } catch (e) {
140
187
  dispatchError(e instanceof Error ? e : new Error(String(e)));
141
188
  return;
@@ -149,37 +196,23 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
149
196
  };
150
197
 
151
198
  ws.onmessage = (event) => {
152
- // Binary format: 8 bytes (u64 BE) tick + JSON snapshot bytes.
153
- if (event.data instanceof ArrayBuffer) {
154
- const view = new DataView(event.data);
155
- const hi = view.getUint32(0);
156
- const lo = view.getUint32(4);
157
- const tick = hi * 0x100000000 + lo;
158
- const jsonBytes = new Uint8Array(event.data, 8);
159
- const jsonStr = new TextDecoder().decode(jsonBytes);
160
- try {
161
- const snapshot = JSON.parse(jsonStr) as TSnapshot;
162
- dispatchSnapshot(snapshot, tick);
163
- } catch (e) {
164
- dispatchError(
165
- e instanceof Error ? e : new Error("Failed to parse snapshot")
166
- );
167
- }
168
- } else if (typeof event.data === "string") {
169
- // Text frame (e.g., JSON fallback format).
170
- try {
171
- const wrapped = JSON.parse(event.data) as {
172
- tick?: number;
173
- snapshot?: TSnapshot;
174
- };
175
- if (typeof wrapped.tick === "number" && wrapped.snapshot !== undefined) {
176
- dispatchSnapshot(wrapped.snapshot, wrapped.tick);
177
- }
178
- } catch (e) {
179
- dispatchError(
180
- e instanceof Error ? e : new Error("Failed to parse snapshot")
181
- );
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);
182
213
  }
214
+ } catch (e) {
215
+ dispatchError(e instanceof Error ? e : new Error("Failed to decode shard frame"));
183
216
  }
184
217
  };
185
218
 
@@ -207,6 +240,9 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
207
240
  onSnapshot(fn) {
208
241
  snapshotHandlers.push(fn);
209
242
  },
243
+ onInputRejected(fn) {
244
+ rejectionHandlers.push(fn);
245
+ },
210
246
  onError(fn) {
211
247
  errorHandlers.push(fn);
212
248
  },
@@ -219,7 +255,7 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
219
255
  send(input: TInput): number {
220
256
  clientSeq += 1;
221
257
  const seq = clientSeq;
222
- const payload = JSON.stringify({ input, client_seq: seq });
258
+ const payload = encodeShardInput(codec, input, seq);
223
259
  if (ws && ws.readyState === WebSocket.OPEN) {
224
260
  ws.send(payload);
225
261
  } else {
@@ -253,6 +289,8 @@ export function useShard<TSnapshot = unknown, TInput = unknown>(
253
289
  ): UseShardReturn<TSnapshot, TInput> {
254
290
  const [snapshot, setSnapshot] = useState<TSnapshot | null>(null);
255
291
  const [tick, setTick] = useState<number>(0);
292
+ const [ack, setAck] = useState<number>(0);
293
+ const [lastRejection, setLastRejection] = useState<ShardInputRejection | null>(null);
256
294
  const [connected, setConnected] = useState<boolean>(false);
257
295
  const [error, setError] = useState<Error | null>(null);
258
296
 
@@ -264,6 +302,7 @@ export function useShard<TSnapshot = unknown, TInput = unknown>(
264
302
  // excluded `options` entirely, so a user logging out would keep the
265
303
  // old socket alive under the old identity until `shardId` changed.
266
304
  const token = options.token;
305
+ const ticket = options.ticket;
267
306
  const subscriberId = options.subscriberId;
268
307
  const baseUrl = options.baseUrl;
269
308
  const wsUrl = options.wsUrl;
@@ -273,10 +312,12 @@ export function useShard<TSnapshot = unknown, TInput = unknown>(
273
312
  const client = connectShard<TSnapshot, TInput>(shardId, options);
274
313
  clientRef.current = client;
275
314
 
276
- client.onSnapshot((snap, t) => {
315
+ client.onSnapshot((snap, t, a) => {
277
316
  setSnapshot(snap);
278
317
  setTick(t);
318
+ setAck(a);
279
319
  });
320
+ client.onInputRejected((r) => setLastRejection(r));
280
321
  client.onOpen(() => setConnected(true));
281
322
  client.onClose(() => setConnected(false));
282
323
  client.onError((e) => setError(e));
@@ -286,7 +327,7 @@ export function useShard<TSnapshot = unknown, TInput = unknown>(
286
327
  clientRef.current = null;
287
328
  };
288
329
  // eslint-disable-next-line react-hooks/exhaustive-deps
289
- }, [shardId, token, subscriberId, baseUrl, wsUrl, wsPort]);
330
+ }, [shardId, token, ticket, subscriberId, baseUrl, wsUrl, wsPort]);
290
331
 
291
332
  const send = (input: TInput): number => {
292
333
  if (clientRef.current) return clientRef.current.send(input);
@@ -297,5 +338,5 @@ export function useShard<TSnapshot = unknown, TInput = unknown>(
297
338
  if (clientRef.current) clientRef.current.close();
298
339
  };
299
340
 
300
- return { snapshot, tick, connected, error, send, close };
341
+ return { snapshot, tick, ack, lastRejection, connected, error, send, close };
301
342
  }
@@ -0,0 +1,57 @@
1
+ import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
2
+
3
+ import { connectShard } from "./useShard";
4
+
5
+ // Record the URL and subprotocols connectShard opens, without a network.
6
+ const opened: Array<{ url: string; protocols?: string[] }> = [];
7
+ class FakeWebSocket {
8
+ static OPEN = 1;
9
+ readyState = 0;
10
+ binaryType = "blob";
11
+ onopen: unknown = null;
12
+ onmessage: unknown = null;
13
+ onerror: unknown = null;
14
+ onclose: unknown = null;
15
+ constructor(url: string, protocols?: string[]) {
16
+ opened.push({ url, protocols });
17
+ }
18
+ close() {}
19
+ send() {}
20
+ }
21
+ const realWebSocket = globalThis.WebSocket;
22
+ beforeAll(() => {
23
+ globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
24
+ });
25
+ afterAll(() => {
26
+ globalThis.WebSocket = realWebSocket;
27
+ });
28
+ afterEach(() => {
29
+ opened.length = 0;
30
+ });
31
+
32
+ test("the default URL is /shard on the server origin", () => {
33
+ connectShard("zone-3", { subscriberId: "p1", baseUrl: "game.example.com", autoReconnect: false }).close();
34
+ expect(opened[0].url).toBe("ws://game.example.com/shard?shard=zone-3&sid=p1&v=2");
35
+ });
36
+
37
+ test("wsPort selects the dedicated shard port", () => {
38
+ connectShard("zone-3", {
39
+ subscriberId: "p1",
40
+ baseUrl: "game.example.com:4321",
41
+ wsPort: 4324,
42
+ autoReconnect: false,
43
+ }).close();
44
+ expect(opened[0].url).toBe("ws://game.example.com:4324/?shard=zone-3&sid=p1&v=2");
45
+ });
46
+
47
+ test("the token and ticket ride as subprotocols", () => {
48
+ connectShard("zone-3", {
49
+ subscriberId: "c 12",
50
+ baseUrl: "h",
51
+ token: "t/1",
52
+ ticket: "v1.a.b",
53
+ autoReconnect: false,
54
+ }).close();
55
+ expect(opened[0].protocols).toEqual(["bearer.t%2F1", "ticket.v1.a.b"]);
56
+ expect(opened[0].url).toContain("sid=c+12");
57
+ });