@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 +2 -0
- package/dist/shardWire.d.ts +58 -0
- package/dist/useShard.d.ts +30 -3
- package/package.json +4 -3
- package/src/index.ts +13 -0
- package/src/shard-wasm.e2e.test.ts +151 -0
- package/src/shard.e2e.test.ts +78 -0
- package/src/shardWire.test.ts +72 -0
- package/src/shardWire.ts +125 -0
- package/src/useShard.ts +90 -49
- package/src/useShard.url.test.ts +57 -0
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;
|
package/dist/useShard.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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
|
-
"@
|
|
18
|
-
"@pylonsync/
|
|
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
|
+
});
|
package/src/shardWire.ts
ADDED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
dispatchSnapshot(snapshot, tick);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
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 =
|
|
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
|
+
});
|