@couch-kit/client 0.10.1 → 0.12.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/CHANGELOG.md +51 -0
- package/dist/index.js +89 -13
- package/lib/client.d.ts +17 -1
- package/lib/client.d.ts.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/relay-protocol.d.ts +4 -0
- package/lib/relay-protocol.d.ts.map +1 -1
- package/lib/relay-room.d.ts +36 -0
- package/lib/relay-room.d.ts.map +1 -0
- package/lib/relay-transport.d.ts +6 -0
- package/lib/relay-transport.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +31 -4
- package/src/index.ts +1 -0
- package/src/relay-protocol.ts +4 -0
- package/src/relay-room.ts +109 -0
- package/src/relay-transport.ts +11 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# @couch-kit/client
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#155](https://github.com/faluciano/react-native-couch-kit/pull/155) [`4f297a1`](https://github.com/faluciano/react-native-couch-kit/commit/4f297a19c442541703c3bee7bee26354ae3476a4) Thanks [@faluciano](https://github.com/faluciano)! - Hidden information can now be hidden for real: `GameHostRuntimeConfig` takes an
|
|
8
|
+
optional `project(state, playerId)` that narrows the authoritative state to what
|
|
9
|
+
one player may see.
|
|
10
|
+
|
|
11
|
+
Without it the runtime broadcasts the same state to everyone, so hiding a hand
|
|
12
|
+
depends on the client choosing not to look — any player could read opponents'
|
|
13
|
+
cards from devtools. With it, the data never reaches their device: the runtime
|
|
14
|
+
sends each connection its own projection, in `WELCOME`, `RECONNECTED`, and every
|
|
15
|
+
state update.
|
|
16
|
+
|
|
17
|
+
Games with no hidden information omit `project` and are unchanged — state is
|
|
18
|
+
still broadcast in one frame.
|
|
19
|
+
|
|
20
|
+
Because a projected client holds a _view_ rather than the whole state, it cannot
|
|
21
|
+
run the game reducer, so `ClientConfig.reducer` is now optional. Omit it and the
|
|
22
|
+
client renders what the host sends (no optimistic updates); a round trip is
|
|
23
|
+
imperceptible for turn-based games, and it is what makes the guarantee real.
|
|
24
|
+
|
|
25
|
+
## 0.11.0
|
|
26
|
+
|
|
27
|
+
### Minor Changes
|
|
28
|
+
|
|
29
|
+
- [#147](https://github.com/faluciano/react-native-couch-kit/pull/147) [`abf8bbe`](https://github.com/faluciano/react-native-couch-kit/commit/abf8bbe075e8f5eff00ffee7d9131009195c6a0e) Thanks [@faluciano](https://github.com/faluciano)! - Relay clients can now tell players _why_ a join failed, and ask for a room code
|
|
30
|
+
when they don't have one.
|
|
31
|
+
|
|
32
|
+
A hosted controller opened without `?room=` has no LAN host to fall back to, so
|
|
33
|
+
it could only sit on "connecting" forever — and a wrong or expired code looked
|
|
34
|
+
exactly the same. Two additions fix that:
|
|
35
|
+
|
|
36
|
+
- `useGameClient` returns `disconnectReason`, carrying the relay's error code
|
|
37
|
+
(`ROOM_NOT_FOUND`, `ROOM_FULL`, …) for terminal failures. Previously the
|
|
38
|
+
transport collapsed these to an unexplained close.
|
|
39
|
+
- New `useRelayRoom()` tracks the room from `?room=CODE` and lets the app set one
|
|
40
|
+
(updating the URL so reloads and shared links keep it), plus
|
|
41
|
+
`normalizeRoomCode()` and `describeRelayError()` for the entry UI.
|
|
42
|
+
|
|
43
|
+
Games stay in control of rendering; the SDK supplies the state and the wording.
|
|
44
|
+
|
|
45
|
+
### Patch Changes
|
|
46
|
+
|
|
47
|
+
- [#147](https://github.com/faluciano/react-native-couch-kit/pull/147) [`abf8bbe`](https://github.com/faluciano/react-native-couch-kit/commit/abf8bbe075e8f5eff00ffee7d9131009195c6a0e) Thanks [@faluciano](https://github.com/faluciano)! - Add the `RATE_LIMITED` and `SERVER_BUSY` relay error codes, which the relay has
|
|
48
|
+
sent since abuse limits landed but the client's copy of the protocol never knew
|
|
49
|
+
about — a client could receive a code its own types called impossible.
|
|
50
|
+
|
|
51
|
+
A contract test now imports both copies of the wire constants and fails if they
|
|
52
|
+
ever diverge again.
|
|
53
|
+
|
|
3
54
|
## 0.10.1
|
|
4
55
|
|
|
5
56
|
### Patch Changes
|
package/dist/index.js
CHANGED
|
@@ -167,7 +167,8 @@ function interpretHostMessage(msg) {
|
|
|
167
167
|
function useGameClient(config) {
|
|
168
168
|
const [status, setStatus] = useState2("disconnected");
|
|
169
169
|
const [playerId, setPlayerId] = useState2(null);
|
|
170
|
-
const [
|
|
170
|
+
const [disconnectReason, setDisconnectReason] = useState2(null);
|
|
171
|
+
const [state, dispatchLocal] = useReducer(createGameReducer(config.reducer ?? ((current) => current)), config.initialState);
|
|
171
172
|
const socketRef = useRef2(null);
|
|
172
173
|
const reconnectAttempts = useRef2(0);
|
|
173
174
|
const reconnectTimer = useRef2(null);
|
|
@@ -247,8 +248,9 @@ function useGameClient(config) {
|
|
|
247
248
|
}
|
|
248
249
|
}
|
|
249
250
|
};
|
|
250
|
-
transport.onclose = (code) => {
|
|
251
|
+
transport.onclose = (code, reason) => {
|
|
251
252
|
setStatus("disconnected");
|
|
253
|
+
setDisconnectReason(reason ? reason : null);
|
|
252
254
|
configRef.current.onDisconnect?.();
|
|
253
255
|
if (!shouldReconnect({
|
|
254
256
|
intentionalClose: intentionalClose.current,
|
|
@@ -311,6 +313,7 @@ function useGameClient(config) {
|
|
|
311
313
|
status,
|
|
312
314
|
state,
|
|
313
315
|
playerId,
|
|
316
|
+
disconnectReason,
|
|
314
317
|
sendAction,
|
|
315
318
|
getServerTime,
|
|
316
319
|
rtt,
|
|
@@ -335,7 +338,9 @@ var RelayErrorCodes = {
|
|
|
335
338
|
ROOM_FULL: "ROOM_FULL",
|
|
336
339
|
NOT_IN_ROOM: "NOT_IN_ROOM",
|
|
337
340
|
MESSAGE_TOO_LARGE: "MESSAGE_TOO_LARGE",
|
|
338
|
-
MALFORMED: "MALFORMED"
|
|
341
|
+
MALFORMED: "MALFORMED",
|
|
342
|
+
RATE_LIMITED: "RATE_LIMITED",
|
|
343
|
+
SERVER_BUSY: "SERVER_BUSY"
|
|
339
344
|
};
|
|
340
345
|
function relayRoomUrl(url, roomId) {
|
|
341
346
|
const trimmed = url.replace(/\/+$/, "");
|
|
@@ -351,6 +356,7 @@ class RelayClientTransport {
|
|
|
351
356
|
roomId;
|
|
352
357
|
state = TransportReadyState.CONNECTING;
|
|
353
358
|
pendingCloseCode = null;
|
|
359
|
+
pendingCloseReason = null;
|
|
354
360
|
onopen;
|
|
355
361
|
onmessage;
|
|
356
362
|
onclose;
|
|
@@ -376,7 +382,7 @@ class RelayClientTransport {
|
|
|
376
382
|
this.ws.onclose = (event) => {
|
|
377
383
|
this.state = TransportReadyState.CLOSED;
|
|
378
384
|
const code = this.pendingCloseCode ?? event.code;
|
|
379
|
-
this.onclose?.(code, event.reason);
|
|
385
|
+
this.onclose?.(code, this.pendingCloseReason ?? event.reason);
|
|
380
386
|
};
|
|
381
387
|
this.ws.onerror = (event) => this.onerror?.(event);
|
|
382
388
|
}
|
|
@@ -411,6 +417,7 @@ class RelayClientTransport {
|
|
|
411
417
|
break;
|
|
412
418
|
case RelayMessageTypes.ERROR:
|
|
413
419
|
this.pendingCloseCode = POLICY_CLOSE_CODE;
|
|
420
|
+
this.pendingCloseReason = msg.code;
|
|
414
421
|
this.ws.close();
|
|
415
422
|
break;
|
|
416
423
|
}
|
|
@@ -419,8 +426,74 @@ class RelayClientTransport {
|
|
|
419
426
|
function createRelayTransport(options) {
|
|
420
427
|
return () => new RelayClientTransport(options);
|
|
421
428
|
}
|
|
429
|
+
// src/relay-room.ts
|
|
430
|
+
import { useCallback as useCallback3, useState as useState3 } from "react";
|
|
431
|
+
var ROOM_PARAM = "room";
|
|
432
|
+
function normalizeRoomCode(input) {
|
|
433
|
+
return input.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
|
|
434
|
+
}
|
|
435
|
+
function roomFromLocation() {
|
|
436
|
+
if (typeof window === "undefined")
|
|
437
|
+
return null;
|
|
438
|
+
try {
|
|
439
|
+
const raw = new URLSearchParams(window.location.search).get(ROOM_PARAM);
|
|
440
|
+
if (!raw)
|
|
441
|
+
return null;
|
|
442
|
+
const code = normalizeRoomCode(raw);
|
|
443
|
+
return code.length > 0 ? code : null;
|
|
444
|
+
} catch {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function useRelayRoom() {
|
|
449
|
+
const [roomId, setRoom] = useState3(() => roomFromLocation());
|
|
450
|
+
const syncUrl = useCallback3((code) => {
|
|
451
|
+
if (typeof window === "undefined")
|
|
452
|
+
return;
|
|
453
|
+
try {
|
|
454
|
+
const url = new URL(window.location.href);
|
|
455
|
+
if (code)
|
|
456
|
+
url.searchParams.set(ROOM_PARAM, code);
|
|
457
|
+
else
|
|
458
|
+
url.searchParams.delete(ROOM_PARAM);
|
|
459
|
+
window.history.replaceState(null, "", url.toString());
|
|
460
|
+
} catch {}
|
|
461
|
+
}, []);
|
|
462
|
+
const setRoomId = useCallback3((code) => {
|
|
463
|
+
const normalized = normalizeRoomCode(code);
|
|
464
|
+
if (normalized.length === 0)
|
|
465
|
+
return;
|
|
466
|
+
setRoom(normalized);
|
|
467
|
+
syncUrl(normalized);
|
|
468
|
+
}, [syncUrl]);
|
|
469
|
+
const clearRoomId = useCallback3(() => {
|
|
470
|
+
setRoom(null);
|
|
471
|
+
syncUrl(null);
|
|
472
|
+
}, [syncUrl]);
|
|
473
|
+
return { roomId, setRoomId, clearRoomId };
|
|
474
|
+
}
|
|
475
|
+
function describeRelayError(reason) {
|
|
476
|
+
switch (reason) {
|
|
477
|
+
case RelayErrorCodes.ROOM_NOT_FOUND:
|
|
478
|
+
return "That room isn't open. Check the code on the screen.";
|
|
479
|
+
case RelayErrorCodes.ROOM_FULL:
|
|
480
|
+
return "That room is full.";
|
|
481
|
+
case RelayErrorCodes.RATE_LIMITED:
|
|
482
|
+
return "Too many messages — slow down and try again.";
|
|
483
|
+
case RelayErrorCodes.ROOM_EXISTS:
|
|
484
|
+
return "That room is already hosted by another screen.";
|
|
485
|
+
case RelayErrorCodes.SERVER_BUSY:
|
|
486
|
+
return "The relay is busy. Try again in a moment.";
|
|
487
|
+
case RelayErrorCodes.MESSAGE_TOO_LARGE:
|
|
488
|
+
case RelayErrorCodes.MALFORMED:
|
|
489
|
+
case RelayErrorCodes.NOT_IN_ROOM:
|
|
490
|
+
return "The connection was rejected. Try rejoining.";
|
|
491
|
+
default:
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
422
495
|
// src/assets.ts
|
|
423
|
-
import { useState as
|
|
496
|
+
import { useState as useState4, useEffect as useEffect3, useRef as useRef3 } from "react";
|
|
424
497
|
import { MessageTypes as MessageTypes4 } from "@couch-kit/core";
|
|
425
498
|
function arraysEqual(a, b) {
|
|
426
499
|
if (a.length !== b.length)
|
|
@@ -432,9 +505,9 @@ function arraysEqual(a, b) {
|
|
|
432
505
|
return true;
|
|
433
506
|
}
|
|
434
507
|
function usePreload(assets, sendMessage) {
|
|
435
|
-
const [loaded, setLoaded] =
|
|
436
|
-
const [progress, setProgress] =
|
|
437
|
-
const [failedAssets, setFailedAssets] =
|
|
508
|
+
const [loaded, setLoaded] = useState4(false);
|
|
509
|
+
const [progress, setProgress] = useState4(0);
|
|
510
|
+
const [failedAssets, setFailedAssets] = useState4([]);
|
|
438
511
|
const sendMessageRef = useRef3(sendMessage);
|
|
439
512
|
sendMessageRef.current = sendMessage;
|
|
440
513
|
const prevAssets = useRef3(assets);
|
|
@@ -497,19 +570,19 @@ function usePreload(assets, sendMessage) {
|
|
|
497
570
|
return { loaded, progress, failedAssets };
|
|
498
571
|
}
|
|
499
572
|
// src/debug-panel.ts
|
|
500
|
-
import { useCallback as
|
|
573
|
+
import { useCallback as useCallback4, useEffect as useEffect4, useRef as useRef4, useState as useState5 } from "react";
|
|
501
574
|
function useDebugPanel(options) {
|
|
502
575
|
const { enabled, state, status, rtt, maxEntries = 50 } = options;
|
|
503
576
|
const nextIdRef = useRef4(0);
|
|
504
577
|
const prevStateRef = useRef4(null);
|
|
505
|
-
const [actionLog, setActionLog] =
|
|
506
|
-
const [stateHistory, setStateHistory] =
|
|
507
|
-
const clearHistory =
|
|
578
|
+
const [actionLog, setActionLog] = useState5([]);
|
|
579
|
+
const [stateHistory, setStateHistory] = useState5([]);
|
|
580
|
+
const clearHistory = useCallback4(() => {
|
|
508
581
|
setActionLog([]);
|
|
509
582
|
setStateHistory([]);
|
|
510
583
|
nextIdRef.current = 0;
|
|
511
584
|
}, []);
|
|
512
|
-
const logAction =
|
|
585
|
+
const logAction = useCallback4((action, source = "local") => {
|
|
513
586
|
if (!enabled)
|
|
514
587
|
return;
|
|
515
588
|
setActionLog((prev) => {
|
|
@@ -555,6 +628,7 @@ function useDebugPanel(options) {
|
|
|
555
628
|
}
|
|
556
629
|
export {
|
|
557
630
|
useServerTime,
|
|
631
|
+
useRelayRoom,
|
|
558
632
|
usePreload,
|
|
559
633
|
useGameClient,
|
|
560
634
|
useDebugPanel,
|
|
@@ -562,7 +636,9 @@ export {
|
|
|
562
636
|
resolveWebSocketUrl,
|
|
563
637
|
resolveSessionSecret,
|
|
564
638
|
relayRoomUrl,
|
|
639
|
+
normalizeRoomCode,
|
|
565
640
|
interpretHostMessage,
|
|
641
|
+
describeRelayError,
|
|
566
642
|
createWebSocketTransport,
|
|
567
643
|
createRelayTransport,
|
|
568
644
|
computeBackoffDelay,
|
package/lib/client.d.ts
CHANGED
|
@@ -3,7 +3,17 @@ import { type CreateClientTransport } from "./transport";
|
|
|
3
3
|
export interface ClientConfig<S extends IGameState, A extends IAction> {
|
|
4
4
|
url?: string;
|
|
5
5
|
wsPort?: number;
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Applies actions locally for an optimistic update before the host confirms.
|
|
8
|
+
*
|
|
9
|
+
* **Omit it when the host projects state per player** (`project` in
|
|
10
|
+
* `GameHostRuntimeConfig`): the client then holds a *view* rather than the
|
|
11
|
+
* whole state, and the game reducer cannot run over a partial view. Without a
|
|
12
|
+
* reducer the client simply renders what the host sends — a round trip that
|
|
13
|
+
* is imperceptible for turn-based games, and the price of hidden information
|
|
14
|
+
* never reaching the device.
|
|
15
|
+
*/
|
|
16
|
+
reducer?: (state: S, action: A) => S;
|
|
7
17
|
initialState: S;
|
|
8
18
|
name?: string;
|
|
9
19
|
avatar?: string;
|
|
@@ -46,6 +56,12 @@ export declare function useGameClient<S extends IGameState, A extends IAction>(c
|
|
|
46
56
|
status: "connected" | "connecting" | "disconnected" | "error";
|
|
47
57
|
state: S;
|
|
48
58
|
playerId: string | null;
|
|
59
|
+
/**
|
|
60
|
+
* Why the last connection ended, if the transport reported a cause — for
|
|
61
|
+
* the relay, a `RelayErrorCodes` value like `ROOM_NOT_FOUND` or `ROOM_FULL`.
|
|
62
|
+
* `null` when connected or when the cause is unknown.
|
|
63
|
+
*/
|
|
64
|
+
disconnectReason: string | null;
|
|
49
65
|
sendAction: (action: A) => void;
|
|
50
66
|
getServerTime: () => number;
|
|
51
67
|
/** Round-trip time (ms) to the server. Updated periodically via PING/PONG. */
|
package/lib/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAQL,KAAK,UAAU,EACf,KAAK,OAAO,EAEb,MAAM,iBAAiB,CAAC;AASzB,OAAO,EAIL,KAAK,qBAAqB,EAC3B,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACnE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAQL,KAAK,UAAU,EACf,KAAK,OAAO,EAEb,MAAM,iBAAiB,CAAC;AASzB,OAAO,EAIL,KAAK,qBAAqB,EAC3B,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACnE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,CAAC;IACrC,YAAY,EAAE,CAAC,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mEAAmE;IACnE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,qBAAqB,CAAC;CACzC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EACnE,MAAM,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC;;;;IA2OxB;;;;OAIG;;yBAvBmC,CAAC;;IA2BvC,8EAA8E;;IAE9E,0EAA0E;;IAE1E,+EAA+E;;EAGlF"}
|
package/lib/index.d.ts
CHANGED
package/lib/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAClC,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC"}
|
package/lib/relay-protocol.d.ts
CHANGED
|
@@ -38,6 +38,10 @@ export declare const RelayErrorCodes: {
|
|
|
38
38
|
readonly NOT_IN_ROOM: "NOT_IN_ROOM";
|
|
39
39
|
readonly MESSAGE_TOO_LARGE: "MESSAGE_TOO_LARGE";
|
|
40
40
|
readonly MALFORMED: "MALFORMED";
|
|
41
|
+
/** Connection exceeded the relay's per-connection message rate limit. */
|
|
42
|
+
readonly RATE_LIMITED: "RATE_LIMITED";
|
|
43
|
+
/** Relay is at its room capacity. */
|
|
44
|
+
readonly SERVER_BUSY: "SERVER_BUSY";
|
|
41
45
|
};
|
|
42
46
|
export type RelayErrorCode = (typeof RelayErrorCodes)[keyof typeof RelayErrorCodes];
|
|
43
47
|
/** Display → relay: create and host a room. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relay-protocol.d.ts","sourceRoot":"","sources":["../src/relay-protocol.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,+EAA+E;AAC/E,eAAO,MAAM,iBAAiB;IAC5B,8DAA8D;aAC9D,WAAW,EAAE,aAAa;IAC1B,0EAA0E;aAC1E,YAAY,EAAE,cAAc;IAC5B,4CAA4C;aAC5C,SAAS,EAAE,WAAW;IACtB,qEAAqE;aACrE,WAAW,EAAE,aAAa;IAC1B,gDAAgD;aAChD,WAAW,EAAE,aAAa;IAC1B,8CAA8C;aAC9C,SAAS,EAAE,WAAW;IACtB,yEAAyE;aACzE,IAAI,EAAE,MAAM;IACZ,6CAA6C;aAC7C,KAAK,EAAE,OAAO;CACN,CAAC;AAEX,wEAAwE;AACxE,eAAO,MAAM,eAAe;aAC1B,cAAc,EAAE,gBAAgB;aAChC,WAAW,EAAE,aAAa;aAC1B,SAAS,EAAE,WAAW;aACtB,WAAW,EAAE,aAAa;aAC1B,iBAAiB,EAAE,mBAAmB;aACtC,SAAS,EAAE,WAAW;
|
|
1
|
+
{"version":3,"file":"relay-protocol.d.ts","sourceRoot":"","sources":["../src/relay-protocol.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Room-code entry for relay clients.
|
|
3
|
+
*
|
|
4
|
+
* A hosted controller has no LAN host to fall back to: opened without a room
|
|
5
|
+
* code it can only sit there failing. These helpers give it the missing state —
|
|
6
|
+
* "which room am I trying to join, and why did the last attempt fail" — so the
|
|
7
|
+
* app can ask for a code instead of hanging.
|
|
8
|
+
*/
|
|
9
|
+
export interface UseRelayRoomResult {
|
|
10
|
+
/** Room the client should join, or `null` when none has been chosen yet. */
|
|
11
|
+
readonly roomId: string | null;
|
|
12
|
+
/** Choose a room. Canonicalised, and reflected in the URL so reloads keep it. */
|
|
13
|
+
readonly setRoomId: (code: string) => void;
|
|
14
|
+
/** Forget the current room and return to the entry screen. */
|
|
15
|
+
readonly clearRoomId: () => void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Canonical room code: upper-cased and stripped of spacing or punctuation that
|
|
19
|
+
* people add when copying a code off a TV ("ab 12" and "AB-12" are `AB12`).
|
|
20
|
+
*/
|
|
21
|
+
export declare function normalizeRoomCode(input: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Tracks which room this client is joining, seeded from `?room=CODE`.
|
|
24
|
+
*
|
|
25
|
+
* Scanning a QR fills it in; typing a code sets it and updates the URL, so a
|
|
26
|
+
* reload — or a shared link — lands in the same room.
|
|
27
|
+
*/
|
|
28
|
+
export declare function useRelayRoom(): UseRelayRoomResult;
|
|
29
|
+
/**
|
|
30
|
+
* Human-readable explanation for a relay failure, or `null` if the reason is
|
|
31
|
+
* unknown (an ordinary network drop, say, which the client will retry).
|
|
32
|
+
*
|
|
33
|
+
* Pass `disconnectReason` from `useGameClient`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function describeRelayError(reason: string | null): string | null;
|
|
36
|
+
//# sourceMappingURL=relay-room.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay-room.d.ts","sourceRoot":"","sources":["../src/relay-room.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,MAAM,WAAW,kBAAkB;IACjC,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,8DAA8D;IAC9D,QAAQ,CAAC,WAAW,EAAE,MAAM,IAAI,CAAC;CAClC;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEvD;AAcD;;;;;GAKG;AACH,wBAAgB,YAAY,IAAI,kBAAkB,CA+BjD;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAmBvE"}
|
package/lib/relay-transport.d.ts
CHANGED
|
@@ -22,6 +22,12 @@ export declare class RelayClientTransport implements ClientTransport {
|
|
|
22
22
|
private state;
|
|
23
23
|
/** When set, the code reported to `onclose` instead of the raw socket code. */
|
|
24
24
|
private pendingCloseCode;
|
|
25
|
+
/**
|
|
26
|
+
* Relay error code (e.g. `ROOM_NOT_FOUND`) behind a terminal close, reported
|
|
27
|
+
* as the close `reason` so the UI can say what actually went wrong instead of
|
|
28
|
+
* showing an indefinite "connecting".
|
|
29
|
+
*/
|
|
30
|
+
private pendingCloseReason;
|
|
25
31
|
onopen?: () => void;
|
|
26
32
|
onmessage?: (data: string) => void;
|
|
27
33
|
onclose?: (code: number, reason?: string) => void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relay-transport.d.ts","sourceRoot":"","sources":["../src/relay-transport.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC3B,MAAM,aAAa,CAAC;AAOrB,+EAA+E;AAC/E,MAAM,WAAW,qBAAqB;IACpC,0EAA0E;IAC1E,GAAG,EAAE,MAAM,CAAC;IACZ,4DAA4D;IAC5D,MAAM,EAAE,MAAM,CAAC;CAChB;AASD;;;;;;;;;GASG;AACH,qBAAa,oBAAqB,YAAW,eAAe;IAC1D,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAC/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,KAAK,CAA0C;IACvD,+EAA+E;IAC/E,OAAO,CAAC,gBAAgB,CAAuB;
|
|
1
|
+
{"version":3,"file":"relay-transport.d.ts","sourceRoot":"","sources":["../src/relay-transport.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC3B,MAAM,aAAa,CAAC;AAOrB,+EAA+E;AAC/E,MAAM,WAAW,qBAAqB;IACpC,0EAA0E;IAC1E,GAAG,EAAE,MAAM,CAAC;IACZ,4DAA4D;IAC5D,MAAM,EAAE,MAAM,CAAC;CAChB;AASD;;;;;;;;;GASG;AACH,qBAAa,oBAAqB,YAAW,eAAe;IAC1D,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAC/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,KAAK,CAA0C;IACvD,+EAA+E;IAC/E,OAAO,CAAC,gBAAgB,CAAuB;IAC/C;;;;OAIG;IACH,OAAO,CAAC,kBAAkB,CAAuB;IAEjD,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAClD,OAAO,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAEpC,YAAY,OAAO,EAAE,qBAAqB,EAgCzC;IAED,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CASvB;IAED,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAQ1C;IAED,OAAO,CAAC,kBAAkB;CAqB3B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,qBAAqB,GAC7B,qBAAqB,CAEvB"}
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -29,7 +29,17 @@ import {
|
|
|
29
29
|
export interface ClientConfig<S extends IGameState, A extends IAction> {
|
|
30
30
|
url?: string; // Full WebSocket URL (overrides auto-detection)
|
|
31
31
|
wsPort?: number; // WebSocket port (default: auto-detected as HTTP port + 2)
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Applies actions locally for an optimistic update before the host confirms.
|
|
34
|
+
*
|
|
35
|
+
* **Omit it when the host projects state per player** (`project` in
|
|
36
|
+
* `GameHostRuntimeConfig`): the client then holds a *view* rather than the
|
|
37
|
+
* whole state, and the game reducer cannot run over a partial view. Without a
|
|
38
|
+
* reducer the client simply renders what the host sends — a round trip that
|
|
39
|
+
* is imperceptible for turn-based games, and the price of hidden information
|
|
40
|
+
* never reaching the device.
|
|
41
|
+
*/
|
|
42
|
+
reducer?: (state: S, action: A) => S;
|
|
33
43
|
initialState: S;
|
|
34
44
|
name?: string; // Player display name (default: "Player")
|
|
35
45
|
avatar?: string; // Player avatar emoji (default: "\u{1F600}")
|
|
@@ -76,11 +86,18 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
76
86
|
"connecting" | "connected" | "disconnected" | "error"
|
|
77
87
|
>("disconnected");
|
|
78
88
|
const [playerId, setPlayerId] = useState<string | null>(null);
|
|
89
|
+
/**
|
|
90
|
+
* Why the last connection ended, when the transport knows. For the relay this
|
|
91
|
+
* is a {@link RelayErrorCodes} value such as `ROOM_NOT_FOUND`.
|
|
92
|
+
*/
|
|
93
|
+
const [disconnectReason, setDisconnectReason] = useState<string | null>(null);
|
|
79
94
|
|
|
80
95
|
// Local Optimistic State
|
|
81
|
-
// Wrap the user's reducer with createGameReducer to handle HYDRATE automatically
|
|
96
|
+
// Wrap the user's reducer with createGameReducer to handle HYDRATE automatically.
|
|
97
|
+
// With no reducer (server-projected views) the identity function keeps HYDRATE
|
|
98
|
+
// working while local application becomes a no-op.
|
|
82
99
|
const [state, dispatchLocal] = useReducer(
|
|
83
|
-
createGameReducer(config.reducer),
|
|
100
|
+
createGameReducer(config.reducer ?? ((current: S) => current)),
|
|
84
101
|
config.initialState,
|
|
85
102
|
);
|
|
86
103
|
|
|
@@ -195,8 +212,12 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
195
212
|
}
|
|
196
213
|
};
|
|
197
214
|
|
|
198
|
-
transport.onclose = (code) => {
|
|
215
|
+
transport.onclose = (code, reason) => {
|
|
199
216
|
setStatus("disconnected");
|
|
217
|
+
// Terminal room-level failures carry a relay error code (ROOM_NOT_FOUND,
|
|
218
|
+
// ROOM_FULL, …). Surfacing it lets the UI explain the failure rather than
|
|
219
|
+
// sit on "connecting" forever.
|
|
220
|
+
setDisconnectReason(reason ? reason : null);
|
|
200
221
|
configRef.current.onDisconnect?.();
|
|
201
222
|
|
|
202
223
|
// Don't reconnect if the close was intentional or if the server
|
|
@@ -294,6 +315,12 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
294
315
|
status,
|
|
295
316
|
state,
|
|
296
317
|
playerId,
|
|
318
|
+
/**
|
|
319
|
+
* Why the last connection ended, if the transport reported a cause — for
|
|
320
|
+
* the relay, a `RelayErrorCodes` value like `ROOM_NOT_FOUND` or `ROOM_FULL`.
|
|
321
|
+
* `null` when connected or when the cause is unknown.
|
|
322
|
+
*/
|
|
323
|
+
disconnectReason,
|
|
297
324
|
sendAction,
|
|
298
325
|
getServerTime,
|
|
299
326
|
/** Round-trip time (ms) to the server. Updated periodically via PING/PONG. */
|
package/src/index.ts
CHANGED
package/src/relay-protocol.ts
CHANGED
|
@@ -40,6 +40,10 @@ export const RelayErrorCodes = {
|
|
|
40
40
|
NOT_IN_ROOM: "NOT_IN_ROOM",
|
|
41
41
|
MESSAGE_TOO_LARGE: "MESSAGE_TOO_LARGE",
|
|
42
42
|
MALFORMED: "MALFORMED",
|
|
43
|
+
/** Connection exceeded the relay's per-connection message rate limit. */
|
|
44
|
+
RATE_LIMITED: "RATE_LIMITED",
|
|
45
|
+
/** Relay is at its room capacity. */
|
|
46
|
+
SERVER_BUSY: "SERVER_BUSY",
|
|
43
47
|
} as const;
|
|
44
48
|
|
|
45
49
|
export type RelayErrorCode =
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Room-code entry for relay clients.
|
|
3
|
+
*
|
|
4
|
+
* A hosted controller has no LAN host to fall back to: opened without a room
|
|
5
|
+
* code it can only sit there failing. These helpers give it the missing state —
|
|
6
|
+
* "which room am I trying to join, and why did the last attempt fail" — so the
|
|
7
|
+
* app can ask for a code instead of hanging.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useCallback, useState } from "react";
|
|
11
|
+
import { RelayErrorCodes } from "./relay-protocol";
|
|
12
|
+
|
|
13
|
+
/** Query parameter carrying the room code, as produced by display QR codes. */
|
|
14
|
+
const ROOM_PARAM = "room";
|
|
15
|
+
|
|
16
|
+
export interface UseRelayRoomResult {
|
|
17
|
+
/** Room the client should join, or `null` when none has been chosen yet. */
|
|
18
|
+
readonly roomId: string | null;
|
|
19
|
+
/** Choose a room. Canonicalised, and reflected in the URL so reloads keep it. */
|
|
20
|
+
readonly setRoomId: (code: string) => void;
|
|
21
|
+
/** Forget the current room and return to the entry screen. */
|
|
22
|
+
readonly clearRoomId: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Canonical room code: upper-cased and stripped of spacing or punctuation that
|
|
27
|
+
* people add when copying a code off a TV ("ab 12" and "AB-12" are `AB12`).
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeRoomCode(input: string): string {
|
|
30
|
+
return input.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function roomFromLocation(): string | null {
|
|
34
|
+
if (typeof window === "undefined") return null;
|
|
35
|
+
try {
|
|
36
|
+
const raw = new URLSearchParams(window.location.search).get(ROOM_PARAM);
|
|
37
|
+
if (!raw) return null;
|
|
38
|
+
const code = normalizeRoomCode(raw);
|
|
39
|
+
return code.length > 0 ? code : null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Tracks which room this client is joining, seeded from `?room=CODE`.
|
|
47
|
+
*
|
|
48
|
+
* Scanning a QR fills it in; typing a code sets it and updates the URL, so a
|
|
49
|
+
* reload — or a shared link — lands in the same room.
|
|
50
|
+
*/
|
|
51
|
+
export function useRelayRoom(): UseRelayRoomResult {
|
|
52
|
+
const [roomId, setRoom] = useState<string | null>(() => roomFromLocation());
|
|
53
|
+
|
|
54
|
+
const syncUrl = useCallback((code: string | null): void => {
|
|
55
|
+
if (typeof window === "undefined") return;
|
|
56
|
+
try {
|
|
57
|
+
const url = new URL(window.location.href);
|
|
58
|
+
if (code) url.searchParams.set(ROOM_PARAM, code);
|
|
59
|
+
else url.searchParams.delete(ROOM_PARAM);
|
|
60
|
+
window.history.replaceState(null, "", url.toString());
|
|
61
|
+
} catch {
|
|
62
|
+
// A URL we cannot rewrite is not worth failing the join over.
|
|
63
|
+
}
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const setRoomId = useCallback(
|
|
67
|
+
(code: string): void => {
|
|
68
|
+
const normalized = normalizeRoomCode(code);
|
|
69
|
+
if (normalized.length === 0) return;
|
|
70
|
+
setRoom(normalized);
|
|
71
|
+
syncUrl(normalized);
|
|
72
|
+
},
|
|
73
|
+
[syncUrl],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const clearRoomId = useCallback((): void => {
|
|
77
|
+
setRoom(null);
|
|
78
|
+
syncUrl(null);
|
|
79
|
+
}, [syncUrl]);
|
|
80
|
+
|
|
81
|
+
return { roomId, setRoomId, clearRoomId };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Human-readable explanation for a relay failure, or `null` if the reason is
|
|
86
|
+
* unknown (an ordinary network drop, say, which the client will retry).
|
|
87
|
+
*
|
|
88
|
+
* Pass `disconnectReason` from `useGameClient`.
|
|
89
|
+
*/
|
|
90
|
+
export function describeRelayError(reason: string | null): string | null {
|
|
91
|
+
switch (reason) {
|
|
92
|
+
case RelayErrorCodes.ROOM_NOT_FOUND:
|
|
93
|
+
return "That room isn't open. Check the code on the screen.";
|
|
94
|
+
case RelayErrorCodes.ROOM_FULL:
|
|
95
|
+
return "That room is full.";
|
|
96
|
+
case RelayErrorCodes.RATE_LIMITED:
|
|
97
|
+
return "Too many messages — slow down and try again.";
|
|
98
|
+
case RelayErrorCodes.ROOM_EXISTS:
|
|
99
|
+
return "That room is already hosted by another screen.";
|
|
100
|
+
case RelayErrorCodes.SERVER_BUSY:
|
|
101
|
+
return "The relay is busy. Try again in a moment.";
|
|
102
|
+
case RelayErrorCodes.MESSAGE_TOO_LARGE:
|
|
103
|
+
case RelayErrorCodes.MALFORMED:
|
|
104
|
+
case RelayErrorCodes.NOT_IN_ROOM:
|
|
105
|
+
return "The connection was rejected. Try rejoining.";
|
|
106
|
+
default:
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/relay-transport.ts
CHANGED
|
@@ -40,6 +40,12 @@ export class RelayClientTransport implements ClientTransport {
|
|
|
40
40
|
private state: number = TransportReadyState.CONNECTING;
|
|
41
41
|
/** When set, the code reported to `onclose` instead of the raw socket code. */
|
|
42
42
|
private pendingCloseCode: number | null = null;
|
|
43
|
+
/**
|
|
44
|
+
* Relay error code (e.g. `ROOM_NOT_FOUND`) behind a terminal close, reported
|
|
45
|
+
* as the close `reason` so the UI can say what actually went wrong instead of
|
|
46
|
+
* showing an indefinite "connecting".
|
|
47
|
+
*/
|
|
48
|
+
private pendingCloseReason: string | null = null;
|
|
43
49
|
|
|
44
50
|
onopen?: () => void;
|
|
45
51
|
onmessage?: (data: string) => void;
|
|
@@ -74,7 +80,7 @@ export class RelayClientTransport implements ClientTransport {
|
|
|
74
80
|
this.ws.onclose = (event: CloseEvent) => {
|
|
75
81
|
this.state = TransportReadyState.CLOSED;
|
|
76
82
|
const code = this.pendingCloseCode ?? event.code;
|
|
77
|
-
this.onclose?.(code, event.reason);
|
|
83
|
+
this.onclose?.(code, this.pendingCloseReason ?? event.reason);
|
|
78
84
|
};
|
|
79
85
|
|
|
80
86
|
this.ws.onerror = (event) => this.onerror?.(event);
|
|
@@ -116,8 +122,11 @@ export class RelayClientTransport implements ClientTransport {
|
|
|
116
122
|
break;
|
|
117
123
|
case RelayMessageTypes.ERROR:
|
|
118
124
|
// Room-level failures are terminal: report a policy close so the client
|
|
119
|
-
// does not attempt to reconnect, then close the underlying socket.
|
|
125
|
+
// does not attempt to reconnect, then close the underlying socket. The
|
|
126
|
+
// relay's code travels along as the reason — "ROOM_NOT_FOUND" is
|
|
127
|
+
// actionable ("check the code"), a silent hang is not.
|
|
120
128
|
this.pendingCloseCode = POLICY_CLOSE_CODE;
|
|
129
|
+
this.pendingCloseReason = msg.code;
|
|
121
130
|
this.ws.close();
|
|
122
131
|
break;
|
|
123
132
|
// PEER_JOINED / PEER_LEFT / ROOM_CREATED are host-facing; ignored here.
|