@pylonsync/realtime 0.18.1 → 0.19.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/dist/connection.d.ts +32 -4
- package/dist/game.d.ts +6 -1
- package/dist/replication.d.ts +72 -1
- package/dist/wire.d.ts +89 -1
- package/package.json +1 -1
- package/src/connection-webtransport.test.ts +624 -0
- package/src/connection.test.ts +48 -1
- package/src/connection.ts +701 -94
- package/src/game.ts +16 -2
- package/src/replication.fixtures.json +2416 -0
- package/src/replication.test.ts +46 -0
- package/src/replication.ts +172 -2
- package/src/wire.test.ts +17 -0
- package/src/wire.ts +221 -1
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { connectShard } from "./connection";
|
|
4
|
+
import fixtures from "./replication.fixtures.json";
|
|
5
|
+
import type { ReplicationSummary } from "./replication";
|
|
6
|
+
|
|
7
|
+
/** A WebTransport session the test plays the server of. */
|
|
8
|
+
class FakeWebTransport {
|
|
9
|
+
static sessions: FakeWebTransport[] = [];
|
|
10
|
+
/** Set to make the next session's `ready` reject, or never settle. */
|
|
11
|
+
static nextReady: "open" | "fail" | "hang" = "open";
|
|
12
|
+
|
|
13
|
+
readonly ready: Promise<void>;
|
|
14
|
+
readonly closed: Promise<{ closeCode?: number; reason?: string }>;
|
|
15
|
+
/** Messages the client wrote on its stream, split at the length prefixes. */
|
|
16
|
+
readonly streamIn: Uint8Array[] = [];
|
|
17
|
+
/** Datagrams the client sent. */
|
|
18
|
+
readonly datagramsIn: Uint8Array[] = [];
|
|
19
|
+
clientClose: { closeCode?: number; reason?: string } | null = null;
|
|
20
|
+
private toClient!: ReadableStreamDefaultController<Uint8Array>;
|
|
21
|
+
private datagramsToClient!: ReadableStreamDefaultController<Uint8Array>;
|
|
22
|
+
private settleClosed!: (info: { closeCode?: number; reason?: string }) => void;
|
|
23
|
+
private streamBytes: number[] = [];
|
|
24
|
+
|
|
25
|
+
readonly datagrams: {
|
|
26
|
+
readable: ReadableStream<Uint8Array>;
|
|
27
|
+
createWritable: () => WritableStream<Uint8Array>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
readonly url: string,
|
|
32
|
+
readonly options: {
|
|
33
|
+
serverCertificateHashes?: Array<{ algorithm: string; value: Uint8Array }>;
|
|
34
|
+
requireUnreliable?: boolean;
|
|
35
|
+
},
|
|
36
|
+
) {
|
|
37
|
+
FakeWebTransport.sessions.push(this);
|
|
38
|
+
const mode = FakeWebTransport.nextReady;
|
|
39
|
+
FakeWebTransport.nextReady = "open";
|
|
40
|
+
let failReady: (e: Error) => void = () => {};
|
|
41
|
+
this.ready = new Promise((resolve, reject) => {
|
|
42
|
+
failReady = reject;
|
|
43
|
+
if (mode === "open") resolve();
|
|
44
|
+
if (mode === "fail") reject(new Error("QUIC handshake failed"));
|
|
45
|
+
});
|
|
46
|
+
this.closed = new Promise((resolve, reject) => {
|
|
47
|
+
this.settleClosed = resolve;
|
|
48
|
+
if (mode === "fail") this.ready.catch(reject);
|
|
49
|
+
});
|
|
50
|
+
void failReady;
|
|
51
|
+
this.datagrams = {
|
|
52
|
+
readable: new ReadableStream({ start: (c) => void (this.datagramsToClient = c) }),
|
|
53
|
+
createWritable: () =>
|
|
54
|
+
new WritableStream({ write: (chunk) => void this.datagramsIn.push(chunk) }),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async createBidirectionalStream() {
|
|
59
|
+
return {
|
|
60
|
+
readable: new ReadableStream<Uint8Array>({ start: (c) => void (this.toClient = c) }),
|
|
61
|
+
writable: new WritableStream<Uint8Array>({
|
|
62
|
+
write: (chunk) => {
|
|
63
|
+
this.streamBytes.push(...chunk);
|
|
64
|
+
while (this.streamBytes.length >= 4) {
|
|
65
|
+
const len = new DataView(Uint8Array.from(this.streamBytes.slice(0, 4)).buffer).getUint32(0);
|
|
66
|
+
if (this.streamBytes.length < 4 + len) break;
|
|
67
|
+
this.streamIn.push(Uint8Array.from(this.streamBytes.slice(4, 4 + len)));
|
|
68
|
+
this.streamBytes = this.streamBytes.slice(4 + len);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
close(info?: { closeCode?: number; reason?: string }) {
|
|
76
|
+
this.clientClose = info ?? {};
|
|
77
|
+
this.settleClosed({ closeCode: info?.closeCode ?? 0, reason: info?.reason ?? "" });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The hello the client sent. */
|
|
81
|
+
get hello(): Record<string, string> {
|
|
82
|
+
return JSON.parse(new TextDecoder().decode(this.streamIn[0]));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Send frames on the stream, each length-prefixed, in one chunk per `split` bytes. */
|
|
86
|
+
sendFrames(frames: Uint8Array[], split = 5) {
|
|
87
|
+
const all: number[] = [];
|
|
88
|
+
for (const f of frames) {
|
|
89
|
+
const len = new Uint8Array(4);
|
|
90
|
+
new DataView(len.buffer).setUint32(0, f.length);
|
|
91
|
+
all.push(...len, ...f);
|
|
92
|
+
}
|
|
93
|
+
for (let i = 0; i < all.length; i += split) {
|
|
94
|
+
this.toClient.enqueue(Uint8Array.from(all.slice(i, i + split)));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
sendDatagram(d: Uint8Array) {
|
|
99
|
+
this.datagramsToClient.enqueue(d);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
serverClose(closeCode: number, reason: string) {
|
|
103
|
+
this.settleClosed({ closeCode, reason });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const sockets: FakeWebSocket[] = [];
|
|
108
|
+
class FakeWebSocket {
|
|
109
|
+
static OPEN = 1;
|
|
110
|
+
readyState = 0;
|
|
111
|
+
binaryType = "blob";
|
|
112
|
+
onopen: (() => void) | null = null;
|
|
113
|
+
onmessage: ((e: { data: ArrayBuffer }) => void) | null = null;
|
|
114
|
+
onerror: (() => void) | null = null;
|
|
115
|
+
onclose: ((e?: { code: number; reason: string }) => void) | null = null;
|
|
116
|
+
constructor(
|
|
117
|
+
readonly url: string,
|
|
118
|
+
readonly protocols?: string[],
|
|
119
|
+
) {
|
|
120
|
+
sockets.push(this);
|
|
121
|
+
}
|
|
122
|
+
close() {
|
|
123
|
+
this.readyState = 3;
|
|
124
|
+
this.onclose?.();
|
|
125
|
+
}
|
|
126
|
+
send() {}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const HASH = new Uint8Array(32).fill(7);
|
|
130
|
+
let infoStatus = 200;
|
|
131
|
+
// "hang": the endpoint request never answers (until it is aborted).
|
|
132
|
+
let infoMode: "answer" | "hang" = "answer";
|
|
133
|
+
const fetched: string[] = [];
|
|
134
|
+
const aborted: string[] = [];
|
|
135
|
+
|
|
136
|
+
const realWebSocket = globalThis.WebSocket;
|
|
137
|
+
const realFetch = globalThis.fetch;
|
|
138
|
+
const g = globalThis as { WebTransport?: unknown };
|
|
139
|
+
beforeAll(() => {
|
|
140
|
+
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
|
141
|
+
globalThis.fetch = (async (url: string, init?: { signal?: AbortSignal }) => {
|
|
142
|
+
fetched.push(String(url));
|
|
143
|
+
if (infoMode === "hang") {
|
|
144
|
+
return new Promise((_, reject) => {
|
|
145
|
+
init?.signal?.addEventListener("abort", () => {
|
|
146
|
+
aborted.push(String(url));
|
|
147
|
+
reject(new Error("aborted"));
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (infoStatus !== 200) return new Response("{}", { status: infoStatus });
|
|
152
|
+
return new Response(
|
|
153
|
+
JSON.stringify({ url: "https://wt.example:443/shard", certHashes: [btoa(String.fromCharCode(...HASH))] }),
|
|
154
|
+
);
|
|
155
|
+
}) as unknown as typeof fetch;
|
|
156
|
+
});
|
|
157
|
+
afterAll(() => {
|
|
158
|
+
globalThis.WebSocket = realWebSocket;
|
|
159
|
+
globalThis.fetch = realFetch;
|
|
160
|
+
delete g.WebTransport;
|
|
161
|
+
});
|
|
162
|
+
beforeEach(() => {
|
|
163
|
+
g.WebTransport = FakeWebTransport;
|
|
164
|
+
FakeWebTransport.sessions.length = 0;
|
|
165
|
+
sockets.length = 0;
|
|
166
|
+
fetched.length = 0;
|
|
167
|
+
aborted.length = 0;
|
|
168
|
+
infoStatus = 200;
|
|
169
|
+
infoMode = "answer";
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
async function until(what: string, cond: () => boolean) {
|
|
173
|
+
for (let i = 0; i < 200; i++) {
|
|
174
|
+
if (cond()) return;
|
|
175
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`timed out waiting for ${what}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function bytes(hex: string): Uint8Array {
|
|
181
|
+
const out = new Uint8Array(hex.length / 2);
|
|
182
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A version 2 frame: kind, codec, tick, ack, payload. */
|
|
187
|
+
function frame(kind: number, codec: number, tick: number, ack: number, payload: Uint8Array): Uint8Array {
|
|
188
|
+
const f = new Uint8Array(18 + payload.length);
|
|
189
|
+
const v = new DataView(f.buffer);
|
|
190
|
+
f[0] = kind;
|
|
191
|
+
f[1] = codec;
|
|
192
|
+
v.setUint32(6, tick);
|
|
193
|
+
v.setUint32(14, ack);
|
|
194
|
+
f.set(payload, 18);
|
|
195
|
+
return f;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const json = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));
|
|
199
|
+
|
|
200
|
+
// Rust-encoded: a full frame with entities 1, 2, 3, then a datagram for
|
|
201
|
+
// frame 2 (tick 3, ack 30) and one for frame 1 (tick 2, ack 20).
|
|
202
|
+
const fullFrame = bytes(fixtures.datagrams[0].stream as string);
|
|
203
|
+
const datagram2 = fixtures.datagrams[1] as { datagram: string; tick: number; updated: number[] };
|
|
204
|
+
const datagram1 = fixtures.datagrams[2] as { datagram: string; tick: number };
|
|
205
|
+
|
|
206
|
+
test("a WebTransport session: hello, stream frames, datagrams with acks, and inputs", async () => {
|
|
207
|
+
const updates: Array<[ReplicationSummary, number, number]> = [];
|
|
208
|
+
const client = connectShard("field", {
|
|
209
|
+
subscriberId: "p1",
|
|
210
|
+
baseUrl: "app.example",
|
|
211
|
+
ticket: "t1",
|
|
212
|
+
transport: "webtransport",
|
|
213
|
+
});
|
|
214
|
+
client.onReplication((_, summary, tick, ack) => updates.push([summary, tick, ack]));
|
|
215
|
+
await until("the session", () => client.connected);
|
|
216
|
+
expect(client.transport).toBe("webtransport");
|
|
217
|
+
expect(fetched).toEqual(["http://app.example/_pylon/shard/webtransport"]);
|
|
218
|
+
|
|
219
|
+
const wt = FakeWebTransport.sessions[0];
|
|
220
|
+
expect(wt.url).toBe("https://wt.example:443/shard");
|
|
221
|
+
expect(wt.options.requireUnreliable).toBe(true);
|
|
222
|
+
expect(wt.options.serverCertificateHashes).toEqual([{ algorithm: "sha-256", value: HASH }]);
|
|
223
|
+
await until("the hello", () => wt.streamIn.length === 1);
|
|
224
|
+
expect(wt.hello).toEqual({ shard: "field", sid: "p1", ticket: "t1" });
|
|
225
|
+
|
|
226
|
+
// The baseline on the stream, split across chunks.
|
|
227
|
+
wt.sendFrames([frame(3, 4, 1, 0, fullFrame)], 3);
|
|
228
|
+
await until("the full frame", () => client.entities.size === 3);
|
|
229
|
+
expect(updates[0][0].full).toBe(true);
|
|
230
|
+
expect(updates[0][1]).toBe(1);
|
|
231
|
+
|
|
232
|
+
// An update datagram: applied, then acked with the frames applied.
|
|
233
|
+
wt.sendDatagram(bytes(datagram2.datagram));
|
|
234
|
+
await until("the ack", () => wt.datagramsIn.length === 1);
|
|
235
|
+
expect([...wt.datagramsIn[0]]).toEqual([1, 1, 2, 1]);
|
|
236
|
+
expect(updates[1][0]).toEqual({ full: false, spawned: [], updated: datagram2.updated, despawned: [] });
|
|
237
|
+
expect(updates[1][1]).toBe(datagram2.tick);
|
|
238
|
+
expect(client.tick).toBe(3);
|
|
239
|
+
expect(client.ack).toBe(30);
|
|
240
|
+
expect(client.entities.get(1)?.qx).toBe(175);
|
|
241
|
+
|
|
242
|
+
// An older tick's datagram, late: dropped unacked, as if lost.
|
|
243
|
+
wt.sendDatagram(bytes(datagram1.datagram));
|
|
244
|
+
wt.sendFrames([frame(1, 0, 4, 30, json({ n: 1 }))]);
|
|
245
|
+
await until("the snapshot", () => client.tick === 4);
|
|
246
|
+
expect(wt.datagramsIn.length).toBe(1);
|
|
247
|
+
expect(updates.length).toBe(2);
|
|
248
|
+
|
|
249
|
+
// Inputs go on the stream after a type byte: JSON until the shard's
|
|
250
|
+
// codec is known (the snapshot said JSON).
|
|
251
|
+
expect(client.send({ move: 1 })).toBe(1);
|
|
252
|
+
await until("the input", () => wt.streamIn.length === 2);
|
|
253
|
+
expect(wt.streamIn[1][0]).toBe(2);
|
|
254
|
+
expect(JSON.parse(new TextDecoder().decode(wt.streamIn[1].subarray(1)))).toEqual({
|
|
255
|
+
input: { move: 1 },
|
|
256
|
+
client_seq: 1,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
client.close();
|
|
260
|
+
await until("the close", () => wt.clientClose !== null);
|
|
261
|
+
expect(wt.clientClose).toEqual({ closeCode: 0, reason: "" });
|
|
262
|
+
expect(client.connected).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("a WebTransport refusal of fixed credentials stops the client", async () => {
|
|
266
|
+
const errors: string[] = [];
|
|
267
|
+
const client = connectShard("field", {
|
|
268
|
+
subscriberId: "p1",
|
|
269
|
+
baseUrl: "h",
|
|
270
|
+
ticket: "expired",
|
|
271
|
+
transport: "webtransport",
|
|
272
|
+
reconnectBackoffMs: 1,
|
|
273
|
+
});
|
|
274
|
+
client.onError((e) => errors.push(e.message));
|
|
275
|
+
await until("the session", () => client.connected);
|
|
276
|
+
FakeWebTransport.sessions[0].serverClose(1, "unauthorized: ticket expired");
|
|
277
|
+
await until("the refusal", () => errors.length === 1);
|
|
278
|
+
expect(errors[0]).toContain("refused the connection (unauthorized: ticket expired)");
|
|
279
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
280
|
+
expect(FakeWebTransport.sessions.length).toBe(1);
|
|
281
|
+
client.close();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("a transfer over WebTransport reconnects with the ticket it carries", async () => {
|
|
285
|
+
const client = connectShard("west", {
|
|
286
|
+
subscriberId: "p1",
|
|
287
|
+
baseUrl: "h",
|
|
288
|
+
ticket: "t-west",
|
|
289
|
+
transport: "webtransport",
|
|
290
|
+
});
|
|
291
|
+
await until("the session", () => client.connected);
|
|
292
|
+
const first = FakeWebTransport.sessions[0];
|
|
293
|
+
first.sendFrames([frame(4, 0, 9, 0, json({ shard: "east", ticket: "t-east" }))]);
|
|
294
|
+
await until("the transfer", () => client.shardId === "east");
|
|
295
|
+
first.serverClose(0, "moved to shard east");
|
|
296
|
+
await until("the second session", () => FakeWebTransport.sessions.length === 2 && client.connected);
|
|
297
|
+
const second = FakeWebTransport.sessions[1];
|
|
298
|
+
await until("the hello", () => second.streamIn.length === 1);
|
|
299
|
+
expect(second.hello).toEqual({ shard: "east", sid: "p1", ticket: "t-east" });
|
|
300
|
+
client.close();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("auto uses a WebSocket, for good, when the app does not serve WebTransport", async () => {
|
|
304
|
+
infoStatus = 404;
|
|
305
|
+
const client = connectShard("field", {
|
|
306
|
+
subscriberId: "p1",
|
|
307
|
+
baseUrl: "h",
|
|
308
|
+
transport: "auto",
|
|
309
|
+
reconnectBackoffMs: 1,
|
|
310
|
+
});
|
|
311
|
+
await until("the socket", () => sockets.length === 1);
|
|
312
|
+
expect(FakeWebTransport.sessions.length).toBe(0);
|
|
313
|
+
sockets[0].readyState = 1;
|
|
314
|
+
sockets[0].onopen?.();
|
|
315
|
+
expect(client.transport).toBe("websocket");
|
|
316
|
+
sockets[0].close();
|
|
317
|
+
await until("the reconnect", () => sockets.length === 2);
|
|
318
|
+
expect(fetched.length).toBe(1);
|
|
319
|
+
client.close();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("auto falls back to a WebSocket when the session fails or does not open in time", async () => {
|
|
323
|
+
FakeWebTransport.nextReady = "fail";
|
|
324
|
+
const a = connectShard("field", { subscriberId: "p1", baseUrl: "h", transport: "auto" });
|
|
325
|
+
await until("the socket", () => sockets.length === 1);
|
|
326
|
+
expect(FakeWebTransport.sessions.length).toBe(1);
|
|
327
|
+
a.close();
|
|
328
|
+
|
|
329
|
+
FakeWebTransport.nextReady = "hang";
|
|
330
|
+
const b = connectShard("field", {
|
|
331
|
+
subscriberId: "p1",
|
|
332
|
+
baseUrl: "h",
|
|
333
|
+
transport: "auto",
|
|
334
|
+
webTransportTimeoutMs: 20,
|
|
335
|
+
});
|
|
336
|
+
await until("the socket", () => sockets.length === 2);
|
|
337
|
+
expect(FakeWebTransport.sessions[1].clientClose).not.toBeNull();
|
|
338
|
+
b.close();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("auto goes straight to a WebSocket without the browser API", () => {
|
|
342
|
+
delete g.WebTransport;
|
|
343
|
+
const client = connectShard("field", { subscriberId: "p1", baseUrl: "h", transport: "auto" });
|
|
344
|
+
expect(sockets.length).toBe(1);
|
|
345
|
+
expect(fetched.length).toBe(0);
|
|
346
|
+
client.close();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("a forced WebTransport stops with an error when the app does not serve it", async () => {
|
|
350
|
+
infoStatus = 404;
|
|
351
|
+
const errors: string[] = [];
|
|
352
|
+
const client = connectShard("field", {
|
|
353
|
+
subscriberId: "p1",
|
|
354
|
+
baseUrl: "h",
|
|
355
|
+
transport: "webtransport",
|
|
356
|
+
reconnectBackoffMs: 1,
|
|
357
|
+
});
|
|
358
|
+
client.onError((e) => errors.push(e.message));
|
|
359
|
+
await until("the error", () => errors.length === 1);
|
|
360
|
+
expect(errors[0]).toContain("the app does not serve WebTransport");
|
|
361
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
362
|
+
expect(fetched.length).toBe(1);
|
|
363
|
+
expect(sockets.length).toBe(0);
|
|
364
|
+
client.close();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("a reconnect over WebTransport starts the table over, so updates apply", async () => {
|
|
368
|
+
const client = connectShard("field", {
|
|
369
|
+
subscriberId: "p1",
|
|
370
|
+
baseUrl: "h",
|
|
371
|
+
ticket: "t1",
|
|
372
|
+
transport: "webtransport",
|
|
373
|
+
reconnectBackoffMs: 1,
|
|
374
|
+
});
|
|
375
|
+
let fulls = 0;
|
|
376
|
+
client.onReplication((_, summary) => {
|
|
377
|
+
if (summary.full) fulls += 1;
|
|
378
|
+
});
|
|
379
|
+
await until("the session", () => client.connected);
|
|
380
|
+
const first = FakeWebTransport.sessions[0];
|
|
381
|
+
first.sendFrames([frame(3, 4, 1, 0, fullFrame)]);
|
|
382
|
+
await until("the full frame", () => fulls === 1);
|
|
383
|
+
first.sendDatagram(bytes(datagram2.datagram));
|
|
384
|
+
await until("the update", () => client.entities.get(1)?.qx === 175);
|
|
385
|
+
|
|
386
|
+
// The server drops the client; the new subscription starts from scratch
|
|
387
|
+
// and sends the same frames (its ticks and datagram numbers restart).
|
|
388
|
+
first.serverClose(3, "client too slow");
|
|
389
|
+
await until("the second session", () => FakeWebTransport.sessions.length === 2 && client.connected);
|
|
390
|
+
const second = FakeWebTransport.sessions[1];
|
|
391
|
+
second.sendFrames([frame(3, 4, 1, 0, fullFrame)]);
|
|
392
|
+
await until("the full frame", () => fulls === 2);
|
|
393
|
+
expect(client.entities.get(1)?.qx).toBe(100);
|
|
394
|
+
second.sendDatagram(bytes(datagram2.datagram));
|
|
395
|
+
await until("the update", () => client.entities.get(1)?.qx === 175);
|
|
396
|
+
client.close();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("auto falls back to a WebSocket when the endpoint request stalls", async () => {
|
|
400
|
+
infoMode = "hang";
|
|
401
|
+
const client = connectShard("field", {
|
|
402
|
+
subscriberId: "p1",
|
|
403
|
+
baseUrl: "h",
|
|
404
|
+
transport: "auto",
|
|
405
|
+
webTransportTimeoutMs: 20,
|
|
406
|
+
});
|
|
407
|
+
await until("the socket", () => sockets.length === 1);
|
|
408
|
+
expect(aborted.length).toBe(1);
|
|
409
|
+
expect(FakeWebTransport.sessions.length).toBe(0);
|
|
410
|
+
client.close();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("closing the client stops the endpoint request", async () => {
|
|
414
|
+
infoMode = "hang";
|
|
415
|
+
const client = connectShard("field", { subscriberId: "p1", baseUrl: "h", transport: "webtransport" });
|
|
416
|
+
await until("the request", () => fetched.length === 1);
|
|
417
|
+
client.close();
|
|
418
|
+
await until("the abort", () => aborted.length === 1);
|
|
419
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
420
|
+
expect(sockets.length).toBe(0);
|
|
421
|
+
expect(FakeWebTransport.sessions.length).toBe(0);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const varint = (v: number): number[] => {
|
|
425
|
+
const out: number[] = [];
|
|
426
|
+
while (v >= 0x80) {
|
|
427
|
+
out.push((v % 0x80) | 0x80);
|
|
428
|
+
v = Math.floor(v / 0x80);
|
|
429
|
+
}
|
|
430
|
+
out.push(v);
|
|
431
|
+
return out;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* A datagram for the fixture's full frame (precision 0.01, entities 1-3
|
|
436
|
+
* spawned at tick 1): each update sets x.
|
|
437
|
+
*/
|
|
438
|
+
function dg(h: { frame: number; tick: number; ack: number; streamTick?: number; parts: number }, xs: Array<[number, number]>) {
|
|
439
|
+
const precision = new Uint8Array(new Float32Array([0.01]).buffer);
|
|
440
|
+
const out = [
|
|
441
|
+
2,
|
|
442
|
+
...varint(h.frame),
|
|
443
|
+
...varint(h.tick),
|
|
444
|
+
...varint(h.ack),
|
|
445
|
+
...varint(h.streamTick ?? 1),
|
|
446
|
+
...precision,
|
|
447
|
+
...varint(h.parts),
|
|
448
|
+
...varint(xs.length),
|
|
449
|
+
];
|
|
450
|
+
let last: number | null = null;
|
|
451
|
+
for (const [id, x] of xs) {
|
|
452
|
+
out.push(...varint(last === null ? id : id - last), 1, 0, 1, ...varint(x * 2));
|
|
453
|
+
last = id;
|
|
454
|
+
}
|
|
455
|
+
return Uint8Array.from(out);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function openWithBaseline() {
|
|
459
|
+
const acks: number[] = [];
|
|
460
|
+
const client = connectShard("field", {
|
|
461
|
+
subscriberId: "p1",
|
|
462
|
+
baseUrl: "h",
|
|
463
|
+
ticket: "t1",
|
|
464
|
+
transport: "webtransport",
|
|
465
|
+
});
|
|
466
|
+
client.onReplication((_, summary, _tick, ack) => {
|
|
467
|
+
if (!summary.full) acks.push(ack);
|
|
468
|
+
});
|
|
469
|
+
await until("the session", () => client.connected);
|
|
470
|
+
const wt = FakeWebTransport.sessions[FakeWebTransport.sessions.length - 1];
|
|
471
|
+
wt.sendFrames([frame(3, 4, 1, 0, fullFrame)]);
|
|
472
|
+
await until("the full frame", () => client.entities.size === 3);
|
|
473
|
+
return { client, wt, acks };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const settleSoon = () => new Promise((r) => setTimeout(r, 20));
|
|
477
|
+
|
|
478
|
+
test("a tick's datagrams and its ack apply together, once the tick is whole", async () => {
|
|
479
|
+
const { client, wt, acks } = await openWithBaseline();
|
|
480
|
+
|
|
481
|
+
// Tick 2 in two parts: the first alone changes nothing yet.
|
|
482
|
+
wt.sendDatagram(dg({ frame: 1, tick: 2, ack: 5, parts: 2 }, [[1, 500]]));
|
|
483
|
+
await settleSoon();
|
|
484
|
+
expect(client.entities.get(1)?.qx).toBe(100);
|
|
485
|
+
expect(client.ack).toBe(0);
|
|
486
|
+
expect(wt.datagramsIn.length).toBe(0);
|
|
487
|
+
|
|
488
|
+
wt.sendDatagram(dg({ frame: 2, tick: 2, ack: 5, parts: 2 }, [[2, 600]]));
|
|
489
|
+
await until("tick 2", () => client.ack === 5);
|
|
490
|
+
expect(client.entities.get(1)?.qx).toBe(500);
|
|
491
|
+
expect(client.entities.get(2)?.qx).toBe(600);
|
|
492
|
+
expect(acks).toEqual([5]);
|
|
493
|
+
expect([...wt.datagramsIn[0]]).toEqual([1, 2, 1, 1, 2, 1]);
|
|
494
|
+
|
|
495
|
+
// Tick 3 loses a part; tick 4 is whole, so tick 3's part applies first.
|
|
496
|
+
wt.sendDatagram(dg({ frame: 3, tick: 3, ack: 6, parts: 2 }, [[1, 700]]));
|
|
497
|
+
wt.sendDatagram(dg({ frame: 5, tick: 4, ack: 7, parts: 1 }, [[3, 800]]));
|
|
498
|
+
await until("tick 4", () => client.ack === 7);
|
|
499
|
+
expect(client.entities.get(1)?.qx).toBe(700);
|
|
500
|
+
expect(client.entities.get(3)?.qx).toBe(800);
|
|
501
|
+
expect(acks).toEqual([5, 7]);
|
|
502
|
+
|
|
503
|
+
// The lost part turns up late: dropped, not acked.
|
|
504
|
+
const acked = wt.datagramsIn.length;
|
|
505
|
+
wt.sendDatagram(dg({ frame: 4, tick: 3, ack: 6, parts: 2 }, [[2, 900]]));
|
|
506
|
+
await settleSoon();
|
|
507
|
+
expect(client.entities.get(2)?.qx).toBe(600);
|
|
508
|
+
expect(wt.datagramsIn.length).toBe(acked);
|
|
509
|
+
client.close();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("a tick waits for the stream frames sent by then", async () => {
|
|
513
|
+
const { client, wt } = await openWithBaseline();
|
|
514
|
+
wt.sendDatagram(dg({ frame: 1, tick: 5, ack: 9, streamTick: 5, parts: 1 }, [[1, 300]]));
|
|
515
|
+
await settleSoon();
|
|
516
|
+
expect(client.ack).toBe(0);
|
|
517
|
+
// Tick 5's stream frame: nothing spawned or despawned, one empty update list.
|
|
518
|
+
wt.sendFrames([frame(3, 4, 5, 9, Uint8Array.from([1, 0, ...new Uint8Array(new Float32Array([0.01]).buffer), 0, 0, 0]))]);
|
|
519
|
+
await until("tick 5", () => client.ack === 9);
|
|
520
|
+
expect(client.entities.get(1)?.qx).toBe(300);
|
|
521
|
+
client.close();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("a closing frame refuses fixed credentials, though the browser gets no code", async () => {
|
|
525
|
+
const errors: string[] = [];
|
|
526
|
+
const client = connectShard("field", {
|
|
527
|
+
subscriberId: "p1",
|
|
528
|
+
baseUrl: "h",
|
|
529
|
+
ticket: "expired",
|
|
530
|
+
transport: "webtransport",
|
|
531
|
+
reconnectBackoffMs: 1,
|
|
532
|
+
});
|
|
533
|
+
client.onError((e) => errors.push(e.message));
|
|
534
|
+
await until("the session", () => client.connected);
|
|
535
|
+
const wt = FakeWebTransport.sessions[0];
|
|
536
|
+
wt.sendFrames([frame(6, 0, 0, 0, json({ code: 1, reason: "unauthorized: ticket expired" }))]);
|
|
537
|
+
await until("the refusal", () => errors.length === 1);
|
|
538
|
+
expect(wt.clientClose).not.toBeNull();
|
|
539
|
+
expect(errors[0]).toContain("refused the connection (unauthorized: ticket expired)");
|
|
540
|
+
await settleSoon();
|
|
541
|
+
expect(FakeWebTransport.sessions.length).toBe(1);
|
|
542
|
+
client.close();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("a stream frame waits for its tick's datagrams, and the handlers get both with the tick's ack", async () => {
|
|
546
|
+
const { client, wt } = await openWithBaseline();
|
|
547
|
+
const seen: Array<{ spawned: number[]; updated: number[]; ack: number }> = [];
|
|
548
|
+
client.onReplication((_, summary, _tick, ack) => {
|
|
549
|
+
if (!summary.full) seen.push({ spawned: summary.spawned, updated: summary.updated, ack });
|
|
550
|
+
});
|
|
551
|
+
// Tick 2's stream frame: entity 4 spawns at x = 50 (q 5000).
|
|
552
|
+
const precision = [...new Uint8Array(new Float32Array([0.01]).buffer)];
|
|
553
|
+
const spawn = Uint8Array.from([1, 0, ...precision, 0, 1, 4, ...varint(10000), 0, 0, 0, 0]);
|
|
554
|
+
wt.sendFrames([frame(3, 4, 2, 3, spawn)]);
|
|
555
|
+
await settleSoon();
|
|
556
|
+
expect(client.entities.get(4)).toBeUndefined();
|
|
557
|
+
expect(seen).toEqual([]);
|
|
558
|
+
|
|
559
|
+
wt.sendDatagram(dg({ frame: 1, tick: 2, ack: 3, streamTick: 2, parts: 1 }, [[1, 150]]));
|
|
560
|
+
await until("tick 2", () => seen.length === 1);
|
|
561
|
+
expect(client.entities.get(4)?.qx).toBe(5000);
|
|
562
|
+
expect(seen[0]).toEqual({ spawned: [4], updated: [1], ack: 3 });
|
|
563
|
+
// Acked with the stream tick the table had then.
|
|
564
|
+
expect([...wt.datagramsIn[0]]).toEqual([1, 1, 1, 2]);
|
|
565
|
+
client.close();
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("a session whose datagrams stop arriving closes", async () => {
|
|
569
|
+
const errors: string[] = [];
|
|
570
|
+
const { client, wt } = await openWithBaseline();
|
|
571
|
+
client.onError((e) => errors.push(e.message));
|
|
572
|
+
// Every tick misses a part, so none is ever whole.
|
|
573
|
+
for (let tick = 2; tick <= 103; tick++) {
|
|
574
|
+
wt.sendDatagram(dg({ frame: tick, tick, ack: 0, parts: 2 }, [[1, tick]]));
|
|
575
|
+
}
|
|
576
|
+
await until("the close", () => wt.clientClose !== null);
|
|
577
|
+
expect(errors).toContain("WebTransport to shard field: datagrams stopped arriving");
|
|
578
|
+
client.close();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("a session with no whole tick for 5 s closes, even when nothing arrives", async () => {
|
|
582
|
+
let clock = 1000;
|
|
583
|
+
const errors: string[] = [];
|
|
584
|
+
const client = connectShard("field", {
|
|
585
|
+
subscriberId: "p1",
|
|
586
|
+
baseUrl: "h",
|
|
587
|
+
ticket: "t1",
|
|
588
|
+
transport: "webtransport",
|
|
589
|
+
now: () => clock,
|
|
590
|
+
});
|
|
591
|
+
client.onError((e) => errors.push(e.message));
|
|
592
|
+
await until("the session", () => client.connected);
|
|
593
|
+
const wt = FakeWebTransport.sessions[FakeWebTransport.sessions.length - 1];
|
|
594
|
+
wt.sendFrames([frame(3, 4, 1, 0, fullFrame)]);
|
|
595
|
+
await until("the full frame", () => client.entities.size === 3);
|
|
596
|
+
// Silence: no stream frame, no datagram. The watchdog runs each second.
|
|
597
|
+
clock += 4000;
|
|
598
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
599
|
+
expect(wt.clientClose).toBeNull();
|
|
600
|
+
clock += 2000;
|
|
601
|
+
await until("the close", () => wt.clientClose !== null);
|
|
602
|
+
expect(errors).toContain("WebTransport to shard field: datagrams stopped arriving");
|
|
603
|
+
client.close();
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("a snapshot shard over WebTransport is not taken for a stall", async () => {
|
|
607
|
+
let clock = 1000;
|
|
608
|
+
const client = connectShard("field", {
|
|
609
|
+
subscriberId: "p1",
|
|
610
|
+
baseUrl: "h",
|
|
611
|
+
ticket: "t1",
|
|
612
|
+
transport: "webtransport",
|
|
613
|
+
now: () => clock,
|
|
614
|
+
});
|
|
615
|
+
await until("the session", () => client.connected);
|
|
616
|
+
const wt = FakeWebTransport.sessions[FakeWebTransport.sessions.length - 1];
|
|
617
|
+
wt.sendFrames([frame(1, 0, 1, 0, json({ n: 1 }))]);
|
|
618
|
+
await until("the snapshot", () => client.tick === 1);
|
|
619
|
+
clock += 10_000;
|
|
620
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
621
|
+
expect(wt.clientClose).toBeNull();
|
|
622
|
+
expect(client.connected).toBe(true);
|
|
623
|
+
client.close();
|
|
624
|
+
});
|
package/src/connection.test.ts
CHANGED
|
@@ -10,7 +10,7 @@ class FakeWebSocket {
|
|
|
10
10
|
onopen: (() => void) | null = null;
|
|
11
11
|
onmessage: ((e: { data: ArrayBuffer }) => void) | null = null;
|
|
12
12
|
onerror: (() => void) | null = null;
|
|
13
|
-
onclose: (() => void) | null = null;
|
|
13
|
+
onclose: ((e?: { code: number; reason: string }) => void) | null = null;
|
|
14
14
|
constructor(
|
|
15
15
|
readonly url: string,
|
|
16
16
|
readonly protocols?: string[],
|
|
@@ -21,6 +21,11 @@ class FakeWebSocket {
|
|
|
21
21
|
this.readyState = 3;
|
|
22
22
|
this.onclose?.();
|
|
23
23
|
}
|
|
24
|
+
/** The server closes the socket with a code and reason. */
|
|
25
|
+
closeFromServer(code: number, reason: string) {
|
|
26
|
+
this.readyState = 3;
|
|
27
|
+
this.onclose?.({ code, reason });
|
|
28
|
+
}
|
|
24
29
|
send() {}
|
|
25
30
|
}
|
|
26
31
|
|
|
@@ -292,3 +297,45 @@ test("the same shard on another machine: reconnect at once, no transfer reported
|
|
|
292
297
|
expect(client.shardId).toBe("west");
|
|
293
298
|
client.close();
|
|
294
299
|
});
|
|
300
|
+
|
|
301
|
+
test("a refused fixed ticket stops reconnecting with an error", async () => {
|
|
302
|
+
const errors: string[] = [];
|
|
303
|
+
const before = sockets.length;
|
|
304
|
+
const client = connectShard("zone", { subscriberId: "p1", baseUrl: "h", ticket: "stale" });
|
|
305
|
+
client.onError((e) => errors.push(e.message));
|
|
306
|
+
sockets[before].closeFromServer(1008, "unauthorized: shard ticket signature does not verify");
|
|
307
|
+
await settle();
|
|
308
|
+
await settle();
|
|
309
|
+
expect(sockets.length).toBe(before + 1);
|
|
310
|
+
expect(errors).toEqual([
|
|
311
|
+
"shard zone refused the connection (unauthorized: shard ticket signature does not verify); pass a ticket function to get new tickets",
|
|
312
|
+
]);
|
|
313
|
+
client.close();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("a refused ticket from a ticket function reconnects with a new ticket", async () => {
|
|
317
|
+
let n = 0;
|
|
318
|
+
const before = sockets.length;
|
|
319
|
+
const client = connectShard("zone", {
|
|
320
|
+
subscriberId: "p1",
|
|
321
|
+
baseUrl: "h",
|
|
322
|
+
ticket: () => `t${++n}`,
|
|
323
|
+
});
|
|
324
|
+
await settle();
|
|
325
|
+
sockets[before].closeFromServer(1008, "unauthorized: shard ticket expired");
|
|
326
|
+
await settle();
|
|
327
|
+
await settle();
|
|
328
|
+
expect(sockets.length).toBe(before + 2);
|
|
329
|
+
expect(sockets[before + 1].protocols).toEqual(["ticket.t2"]);
|
|
330
|
+
client.close();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("a shard that is not found yet keeps retrying with a fixed ticket", async () => {
|
|
334
|
+
const before = sockets.length;
|
|
335
|
+
const client = connectShard("zone", { subscriberId: "p1", baseUrl: "h", ticket: "fixed" });
|
|
336
|
+
sockets[before].closeFromServer(1008, 'shard "zone" not found');
|
|
337
|
+
await settle();
|
|
338
|
+
await settle();
|
|
339
|
+
expect(sockets.length).toBe(before + 2);
|
|
340
|
+
client.close();
|
|
341
|
+
});
|