@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 +1 -0
- package/README.md +93 -0
- package/dist/index.js +3 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/relay-display-host.d.ts +51 -0
- package/lib/relay-display-host.d.ts.map +1 -0
- package/package.json +61 -0
- package/src/index.ts +4 -0
- package/src/relay-display-host.ts +155 -0
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
package/lib/index.d.ts
ADDED
|
@@ -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,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
|
+
}
|