@couch-kit/display 0.0.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 ADDED
@@ -0,0 +1 @@
1
+ # @couch-kit/display
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @couch-kit/display
2
+
3
+ Browser **display host** for cross-network Couch Kit games. It owns the
4
+ authoritative `GameHostRuntime` — exactly like the React Native
5
+ `GameHostProvider` does on an Android TV — but bridges the runtime to a shared,
6
+ game-agnostic [relay server](https://github.com/faluciano/react-native-couch-kit/tree/main/services/relay)
7
+ instead of a local LAN WebSocket. That lets phones on **different networks** join
8
+ a game hosted in a browser tab.
9
+
10
+ ```
11
+ phone ─┐ ┌─ RelayDisplayHost (owns the runtime)
12
+ phone ─┼─ WebSocket ─▶ relay ◀─┘ browser display tab
13
+ phone ─┘ (you deploy it)
14
+ ```
15
+
16
+ - The display owns the game; the relay only routes opaque envelopes by room code,
17
+ so **one relay deployment serves every game** you build.
18
+ - Framework-agnostic (no React dependency): subscribe with `subscribe` /
19
+ `getState` from any UI, e.g. React's `useSyncExternalStore`.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ bun add @couch-kit/display @couch-kit/core @couch-kit/runtime @couch-kit/client
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```ts
30
+ import { RelayDisplayHost } from "@couch-kit/display";
31
+ import { gameReducer, initialState } from "./shared"; // shared with the controller
32
+
33
+ const display = new RelayDisplayHost({
34
+ url: "wss://your-relay.example.com", // YOUR relay — see note below
35
+ roomId: "ABCD",
36
+ reducer: gameReducer,
37
+ initialState,
38
+ });
39
+
40
+ // Render whenever authoritative state changes.
41
+ display.subscribe(() => render(display.getState()));
42
+ ```
43
+
44
+ Phones connect to the same room with the client's relay transport:
45
+
46
+ ```ts
47
+ import { createRelayTransport } from "@couch-kit/client";
48
+
49
+ useGameClient({
50
+ reducer: gameReducer,
51
+ initialState,
52
+ createTransport: createRelayTransport({
53
+ url: "wss://your-relay.example.com",
54
+ roomId: "ABCD",
55
+ }),
56
+ });
57
+ ```
58
+
59
+ ## API
60
+
61
+ `new RelayDisplayHost(options)` — `options` is the game's
62
+ `GameHostRuntimeConfig` (`reducer`, `initialState`, and the usual runtime knobs)
63
+ plus the relay coordinates:
64
+
65
+ | Option | Description |
66
+ | ----------- | ---------------------------------------------- |
67
+ | `url` | WebSocket URL of **your** relay server |
68
+ | `roomId` | Room code phones use to reach this display |
69
+ | `reducer` | The shared game reducer |
70
+ | `initialState` | The shared initial state |
71
+
72
+ Instance methods:
73
+
74
+ - `getState()` — current authoritative state.
75
+ - `subscribe(listener)` — subscribe to state changes; returns an unsubscribe fn.
76
+ - `dispatch(action)` — dispatch a trusted host-side action.
77
+ - `stop()` — tear down the runtime and close the relay socket.
78
+
79
+ The host maps relay `PEER_JOINED` / `DATA` / `PEER_LEFT` to the runtime's
80
+ `handleConnection` / `handleMessage` / `handleDisconnect`, and implements the
81
+ runtime's transport by wrapping outbound messages in relay `DATA` envelopes
82
+ (unicast when addressed, room broadcast otherwise). Inbound phone messages are
83
+ size-bounded (`DEFAULT_MAX_MESSAGE_BYTES`) before parsing.
84
+
85
+ ## Deploy your own relay — the SDK never points at anyone else's
86
+
87
+ The relay `url` is **required config with no default**. Couch Kit ships the relay
88
+ as *source you deploy yourself*
89
+ ([`services/relay`](https://github.com/faluciano/react-native-couch-kit/tree/main/services/relay)),
90
+ not a hosted service. Every game you build points at the relay **you** deploy;
91
+ nobody consuming this SDK is routed through another developer's infrastructure.
92
+ One relay deployment can serve all of your games — it is game-agnostic and keyed
93
+ only by room code.
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export {
2
+ RelayDisplayHost
3
+ };
package/lib/index.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { RelayDisplayHost, type RelayDisplayHostOptions, } from "./relay-display-host";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAChB,KAAK,uBAAuB,GAC7B,MAAM,sBAAsB,CAAC"}
@@ -0,0 +1,51 @@
1
+ import { type GameHostRuntimeConfig } from "@couch-kit/runtime";
2
+ import type { IGameState, IAction } from "@couch-kit/core";
3
+ /**
4
+ * Options for {@link RelayDisplayHost}.
5
+ *
6
+ * The caller supplies the game's runtime config (reducer + initial state, same
7
+ * object used by the RN-TV host) and the shared relay coordinates; the display
8
+ * host owns the authoritative runtime and the relay socket.
9
+ */
10
+ export interface RelayDisplayHostOptions<S extends IGameState, A extends IAction> extends GameHostRuntimeConfig<S, A> {
11
+ /** WebSocket URL of the shared relay server. */
12
+ url: string;
13
+ /** Room code phones will use to reach this display. */
14
+ roomId: string;
15
+ }
16
+ /**
17
+ * Browser **display host** for the cross-network relay transport.
18
+ *
19
+ * It owns a {@link GameHostRuntime} (the authoritative game) exactly like the
20
+ * React Native `GameHostProvider` does, but bridges the runtime to a shared,
21
+ * game-agnostic relay instead of a local WebSocket server:
22
+ *
23
+ * - Connects to the relay and creates the room.
24
+ * - Maps relay `PEER_JOINED` / `DATA` / `PEER_LEFT` to
25
+ * `runtime.handleConnection` / `handleMessage` / `handleDisconnect`, using the
26
+ * relay-assigned `peerId` as the stable connection id.
27
+ * - Implements {@link GameRuntimeTransport} by wrapping outbound host messages in
28
+ * relay `DATA` envelopes (`to` for unicast, absent for room broadcast).
29
+ *
30
+ * Framework-agnostic (no React): a display UI subscribes via {@link subscribe} /
31
+ * {@link getState} (e.g. React's `useSyncExternalStore`).
32
+ */
33
+ export declare class RelayDisplayHost<S extends IGameState, A extends IAction> {
34
+ private readonly runtime;
35
+ private readonly ws;
36
+ private readonly roomId;
37
+ /** Connected phone connection ids (relay peer ids). */
38
+ private readonly peers;
39
+ constructor(options: RelayDisplayHostOptions<S, A>);
40
+ /** Current authoritative game state. */
41
+ getState: () => S;
42
+ /** Subscribe to state changes (for `useSyncExternalStore` or manual render). */
43
+ subscribe: (listener: () => void) => (() => void);
44
+ /** Dispatch a trusted host-display action. */
45
+ dispatch: (action: A) => void;
46
+ /** Tear down the runtime and relay socket. */
47
+ stop(): void;
48
+ private sendEnvelope;
49
+ private handleRelayMessage;
50
+ }
51
+ //# sourceMappingURL=relay-display-host.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relay-display-host.d.ts","sourceRoot":"","sources":["../src/relay-display-host.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,qBAAqB,EAE3B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,UAAU,EAAE,OAAO,EAAe,MAAM,iBAAiB,CAAC;AAMxE;;;;;;GAMG;AACH,MAAM,WAAW,uBAAuB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,CAC9E,SAAQ,qBAAqB,CAAC,CAAC,EAAE,CAAC,CAAC;IACnC,gDAAgD;IAChD,GAAG,EAAE,MAAM,CAAC;IACZ,uDAAuD;IACvD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,gBAAgB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACnE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAChD,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAY;IAC/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,uDAAuD;IACvD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqB;gBAE/B,OAAO,EAAE,uBAAuB,CAAC,CAAC,EAAE,CAAC,CAAC;IAsClD,wCAAwC;IACxC,QAAQ,QAAO,CAAC,CAA4B;IAE5C,gFAAgF;IAChF,SAAS,GAAI,UAAU,MAAM,IAAI,KAAG,CAAC,MAAM,IAAI,CAAC,CACb;IAEnC,8CAA8C;IAC9C,QAAQ,GAAI,QAAQ,CAAC,KAAG,IAAI,CAAkC;IAE9D,8CAA8C;IAC9C,IAAI,IAAI,IAAI;IAMZ,OAAO,CAAC,YAAY;IAUpB,OAAO,CAAC,kBAAkB;CAqC3B"}
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@couch-kit/display",
3
+ "version": "0.0.0",
4
+ "publishConfig": {
5
+ "access": "public",
6
+ "provenance": true
7
+ },
8
+ "description": "Browser display host for cross-network Couch Kit games — owns the authoritative runtime and bridges it to a game-agnostic relay",
9
+ "license": "MIT",
10
+ "type": "module",
11
+ "engines": {
12
+ "node": ">=18.0.0"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/faluciano/react-native-couch-kit.git",
17
+ "directory": "packages/display"
18
+ },
19
+ "homepage": "https://github.com/faluciano/react-native-couch-kit#readme",
20
+ "keywords": [
21
+ "couch-kit",
22
+ "party-game",
23
+ "browser-display",
24
+ "relay",
25
+ "cross-network",
26
+ "websocket"
27
+ ],
28
+ "main": "./dist/index.js",
29
+ "types": "./lib/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./lib/index.d.ts",
33
+ "import": "./dist/index.js",
34
+ "default": "./dist/index.js"
35
+ }
36
+ },
37
+ "files": [
38
+ "src",
39
+ "dist",
40
+ "lib",
41
+ "README.md",
42
+ "CHANGELOG.md"
43
+ ],
44
+ "sideEffects": false,
45
+ "scripts": {
46
+ "build": "bun build ./src/index.ts --outdir ./dist --target browser --external @couch-kit/core --external @couch-kit/runtime --external @couch-kit/client && tsc -p tsconfig.build.json",
47
+ "prepublishOnly": "bun run build",
48
+ "test": "bun test",
49
+ "lint": "eslint src/",
50
+ "typecheck": "tsc --noEmit",
51
+ "clean": "rm -rf dist lib"
52
+ },
53
+ "dependencies": {
54
+ "@couch-kit/client": "0.9.0",
55
+ "@couch-kit/core": "0.9.3",
56
+ "@couch-kit/runtime": "0.1.0"
57
+ },
58
+ "devDependencies": {
59
+ "typescript": "^6.0.0"
60
+ }
61
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export {
2
+ RelayDisplayHost,
3
+ type RelayDisplayHostOptions,
4
+ } from "./relay-display-host";
@@ -0,0 +1,155 @@
1
+ import {
2
+ GameHostRuntime,
3
+ frameByteLength,
4
+ DEFAULT_MAX_MESSAGE_BYTES,
5
+ type GameHostRuntimeConfig,
6
+ type GameRuntimeTransport,
7
+ } from "@couch-kit/runtime";
8
+ import type { IGameState, IAction, HostMessage } from "@couch-kit/core";
9
+ import {
10
+ RelayMessageTypes,
11
+ type RelayServerMessage,
12
+ } from "@couch-kit/client";
13
+
14
+ /**
15
+ * Options for {@link RelayDisplayHost}.
16
+ *
17
+ * The caller supplies the game's runtime config (reducer + initial state, same
18
+ * object used by the RN-TV host) and the shared relay coordinates; the display
19
+ * host owns the authoritative runtime and the relay socket.
20
+ */
21
+ export interface RelayDisplayHostOptions<S extends IGameState, A extends IAction>
22
+ extends GameHostRuntimeConfig<S, A> {
23
+ /** WebSocket URL of the shared relay server. */
24
+ url: string;
25
+ /** Room code phones will use to reach this display. */
26
+ roomId: string;
27
+ }
28
+
29
+ /**
30
+ * Browser **display host** for the cross-network relay transport.
31
+ *
32
+ * It owns a {@link GameHostRuntime} (the authoritative game) exactly like the
33
+ * React Native `GameHostProvider` does, but bridges the runtime to a shared,
34
+ * game-agnostic relay instead of a local WebSocket server:
35
+ *
36
+ * - Connects to the relay and creates the room.
37
+ * - Maps relay `PEER_JOINED` / `DATA` / `PEER_LEFT` to
38
+ * `runtime.handleConnection` / `handleMessage` / `handleDisconnect`, using the
39
+ * relay-assigned `peerId` as the stable connection id.
40
+ * - Implements {@link GameRuntimeTransport} by wrapping outbound host messages in
41
+ * relay `DATA` envelopes (`to` for unicast, absent for room broadcast).
42
+ *
43
+ * Framework-agnostic (no React): a display UI subscribes via {@link subscribe} /
44
+ * {@link getState} (e.g. React's `useSyncExternalStore`).
45
+ */
46
+ export class RelayDisplayHost<S extends IGameState, A extends IAction> {
47
+ private readonly runtime: GameHostRuntime<S, A>;
48
+ private readonly ws: WebSocket;
49
+ private readonly roomId: string;
50
+ /** Connected phone connection ids (relay peer ids). */
51
+ private readonly peers = new Set<string>();
52
+
53
+ constructor(options: RelayDisplayHostOptions<S, A>) {
54
+ const { url, roomId, ...runtimeConfig } = options;
55
+ this.roomId = roomId;
56
+ this.runtime = new GameHostRuntime<S, A>(runtimeConfig);
57
+ this.ws = new WebSocket(url);
58
+
59
+ this.ws.onopen = () => {
60
+ this.ws.send(
61
+ JSON.stringify({
62
+ type: RelayMessageTypes.CREATE_ROOM,
63
+ roomId: this.roomId,
64
+ }),
65
+ );
66
+ };
67
+
68
+ this.ws.onmessage = (event: MessageEvent) => {
69
+ let msg: RelayServerMessage;
70
+ try {
71
+ msg = JSON.parse(event.data as string) as RelayServerMessage;
72
+ } catch {
73
+ return;
74
+ }
75
+ this.handleRelayMessage(msg);
76
+ };
77
+
78
+ this.ws.onerror = (event) =>
79
+ this.runtime.handleError(
80
+ event instanceof Error ? event : new Error("Relay socket error"),
81
+ );
82
+
83
+ const transport: GameRuntimeTransport = {
84
+ send: (connectionId, message) =>
85
+ this.sendEnvelope(message, connectionId),
86
+ broadcast: (message) => this.sendEnvelope(message),
87
+ };
88
+ this.runtime.setTransport(transport);
89
+ }
90
+
91
+ /** Current authoritative game state. */
92
+ getState = (): S => this.runtime.getState();
93
+
94
+ /** Subscribe to state changes (for `useSyncExternalStore` or manual render). */
95
+ subscribe = (listener: () => void): (() => void) =>
96
+ this.runtime.subscribe(listener);
97
+
98
+ /** Dispatch a trusted host-display action. */
99
+ dispatch = (action: A): void => this.runtime.dispatch(action);
100
+
101
+ /** Tear down the runtime and relay socket. */
102
+ stop(): void {
103
+ this.runtime.setTransport(null);
104
+ this.runtime.stop();
105
+ this.ws.close();
106
+ }
107
+
108
+ private sendEnvelope(message: HostMessage, to?: string): void {
109
+ const envelope: Record<string, unknown> = {
110
+ type: RelayMessageTypes.DATA,
111
+ roomId: this.roomId,
112
+ data: JSON.stringify(message),
113
+ };
114
+ if (to !== undefined) envelope.to = to;
115
+ this.ws.send(JSON.stringify(envelope));
116
+ }
117
+
118
+ private handleRelayMessage(msg: RelayServerMessage): void {
119
+ switch (msg.type) {
120
+ case RelayMessageTypes.PEER_JOINED:
121
+ this.peers.add(msg.peerId);
122
+ this.runtime.handleConnection(msg.peerId);
123
+ break;
124
+ case RelayMessageTypes.PEER_LEFT:
125
+ this.peers.delete(msg.peerId);
126
+ this.runtime.handleDisconnect(msg.peerId);
127
+ break;
128
+ case RelayMessageTypes.DATA: {
129
+ // A game message from a phone. `from` is the phone's connection id.
130
+ if (!msg.from) break;
131
+ // Enforce the same inbound bound as the WebSocket transport before
132
+ // parsing untrusted phone input.
133
+ if (frameByteLength(msg.data) > DEFAULT_MAX_MESSAGE_BYTES) break;
134
+ let parsed: unknown;
135
+ try {
136
+ parsed = JSON.parse(msg.data);
137
+ } catch {
138
+ break;
139
+ }
140
+ this.runtime
141
+ .handleMessage(msg.from, parsed)
142
+ .catch((err) =>
143
+ this.runtime.handleError(
144
+ err instanceof Error ? err : new Error(String(err)),
145
+ ),
146
+ );
147
+ break;
148
+ }
149
+ case RelayMessageTypes.ERROR:
150
+ this.runtime.handleError(new Error(msg.message));
151
+ break;
152
+ // ROOM_CREATED / ROOM_JOINED are acknowledgements; no action needed.
153
+ }
154
+ }
155
+ }