@pylonsync/realtime 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.15.0",
6
+ "version": "0.16.1",
7
7
  "description": "Pylon realtime shard client pieces with no framework: the wire protocol, codecs, and the entity replication decoder and table.",
8
8
  "type": "module",
9
9
  "main": "./src/index.ts",
@@ -0,0 +1,115 @@
1
+ // End-to-end: examples/shard-arena on two machines (two `pylon start`
2
+ // processes on one Postgres). A shard created on machine B is reached by a
3
+ // client that connects to machine A. Killing B starts the shard again on A
4
+ // from its saved state, and the client, reconnecting, finds its player where
5
+ // it was.
6
+ //
7
+ // Runs only when PYLON_SHARD_CLUSTER_E2E is "<host:port of A>,<host:port of
8
+ // B>" and PYLON_SHARD_CLUSTER_KILL_B is B's process id
9
+ // (tools/smoke-shard-cluster.sh sets them).
10
+
11
+ import { expect, test } from "bun:test";
12
+
13
+ import { connectShard } from "./connection";
14
+
15
+ const [hostA, hostB] = (process.env.PYLON_SHARD_CLUSTER_E2E ?? "").split(",");
16
+ const pidB = Number(process.env.PYLON_SHARD_CLUSTER_KILL_B ?? 0);
17
+
18
+ interface Player {
19
+ id: string;
20
+ x: number;
21
+ y: number;
22
+ }
23
+ interface Arena {
24
+ players: Player[];
25
+ }
26
+ interface Join {
27
+ shardId: string;
28
+ subscriberId: string;
29
+ ticket: string;
30
+ machine?: string;
31
+ }
32
+
33
+ async function guest(host: string): Promise<string> {
34
+ const res = await fetch(`http://${host}/api/auth/guest`, { method: "POST" });
35
+ expect(res.ok).toBe(true);
36
+ return ((await res.json()) as { token: string }).token;
37
+ }
38
+
39
+ async function join(host: string, token: string, args: Record<string, string>): Promise<Join> {
40
+ const res = await fetch(`http://${host}/api/fn/joinArena`, {
41
+ method: "POST",
42
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
43
+ body: JSON.stringify(args),
44
+ });
45
+ const text = await res.text();
46
+ if (res.status !== 200) throw new Error(`joinArena on ${host}: ${res.status} ${text}`);
47
+ return JSON.parse(text) as Join;
48
+ }
49
+
50
+ function waitFor<T>(what: string, check: () => T | undefined, ms: number): Promise<T> {
51
+ return new Promise((resolve, reject) => {
52
+ const start = Date.now();
53
+ const timer = setInterval(() => {
54
+ const v = check();
55
+ if (v !== undefined) {
56
+ clearInterval(timer);
57
+ resolve(v);
58
+ } else if (Date.now() - start > ms) {
59
+ clearInterval(timer);
60
+ reject(new Error(`timed out waiting for ${what}`));
61
+ }
62
+ }, 25);
63
+ });
64
+ }
65
+
66
+ test.skipIf(!hostA || !hostB || !pidB)(
67
+ "a shard on machine B is reached through A, and starts again on A when B dies",
68
+ async () => {
69
+ const arena = `cluster-${Math.random().toString(36).slice(2, 8)}`;
70
+ const token = await guest(hostA);
71
+ // Created through A, placed on B.
72
+ const first = await join(hostA, token, { arena, machine: "b" });
73
+ expect(first.machine).toBe("b");
74
+
75
+ let latest: Arena | undefined;
76
+ let opens = 0;
77
+ const errors: string[] = [];
78
+ const client = connectShard<Arena, unknown>(arena, {
79
+ subscriberId: first.subscriberId,
80
+ baseUrl: hostA,
81
+ reconnectBackoffMs: 250,
82
+ // Each connection attempt gets a new ticket from A.
83
+ ticket: async () => (await join(hostA, token, { arena })).ticket,
84
+ });
85
+ client.onSnapshot((snap) => (latest = snap));
86
+ client.onOpen(() => opens++);
87
+ client.onError((e) => errors.push(e.message));
88
+
89
+ await waitFor("a snapshot through A", () => latest, 10_000);
90
+ client.send("join");
91
+ client.send({ move_to: { x: 700, y: 400 } });
92
+ const me = () => latest?.players.find((p) => p.id === first.subscriberId);
93
+ await waitFor("the player at its target", () => (me()?.x === 700 && me()?.y === 400 ? true : undefined), 10_000);
94
+
95
+ // The directory still says B runs it.
96
+ const onB = await join(hostB, await guest(hostB), { arena });
97
+ expect(onB.machine).toBe("b");
98
+
99
+ // Let B save the arena (PYLON_SHARD_SAVE_SECS=1), then kill it.
100
+ await new Promise((r) => setTimeout(r, 2500));
101
+ process.kill(pidB, "SIGKILL");
102
+ latest = undefined;
103
+
104
+ // A notices B is dead (no heartbeat for 10 s), starts the arena from
105
+ // its saved state, and the client reconnects to it.
106
+ await waitFor("the arena back on A", () => latest, 40_000);
107
+ const back = me();
108
+ expect(back).toMatchObject({ x: 700, y: 400 });
109
+ expect(opens).toBeGreaterThanOrEqual(2);
110
+ const again = await join(hostA, token, { arena });
111
+ expect(again.machine).toBe("a");
112
+ client.close();
113
+ },
114
+ 90_000,
115
+ );