@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 +2 -2
- package/dist/shardWire.d.ts +3 -56
- package/dist/ssr.d.ts +15 -0
- package/dist/useShard.d.ts +16 -59
- package/package.json +4 -4
- package/src/index.ts +17 -0
- package/src/shard-replication.e2e.test.ts +76 -0
- package/src/shard-wasm.e2e.test.ts +9 -6
- package/src/shardWire.ts +3 -123
- package/src/ssr.ts +15 -0
- package/src/useShard.backoff.test.ts +93 -0
- package/src/useShard.ts +60 -230
- package/src/shardWire.test.ts +0 -72
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";
|
package/dist/shardWire.d.ts
CHANGED
|
@@ -1,58 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The shard wire protocol
|
|
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
|
|
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" }`). */
|
package/dist/useShard.d.ts
CHANGED
|
@@ -1,45 +1,9 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
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.
|
|
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
|
-
"@
|
|
18
|
-
"@pylonsync/sdk": "0.
|
|
19
|
-
"@pylonsync/sync": "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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
7
|
-
*
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
encodeShardInput,
|
|
29
|
-
parseShardFrame,
|
|
26
|
+
connectShard,
|
|
27
|
+
type EntityTable,
|
|
28
|
+
type ShardClient,
|
|
29
|
+
type ShardConnectOptions,
|
|
30
30
|
type ShardInputRejection,
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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
|
}
|
package/src/shardWire.test.ts
DELETED
|
@@ -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
|
-
});
|