@couch-kit/host 0.3.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/README.md +147 -0
- package/package.json +48 -0
- package/src/declarations.d.ts +6 -0
- package/src/event-emitter.ts +69 -0
- package/src/events.d.ts +20 -0
- package/src/index.tsx +4 -0
- package/src/network.ts +25 -0
- package/src/provider.tsx +187 -0
- package/src/server.ts +70 -0
- package/src/websocket.ts +382 -0
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# @couch-kit/host
|
|
2
|
+
|
|
3
|
+
The server-side library for React Native TV applications. This package turns your TV app into a local game server.
|
|
4
|
+
|
|
5
|
+
> **Looking for a working example?** Check out the [Buzz](https://github.com/faluciano/buzz-tv-party-game) starter project for a complete host setup including asset extraction, QR code display, and player tracking.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Dual-Port Architecture:**
|
|
10
|
+
- **Port 8080:** Static File Server (serves the web controller).
|
|
11
|
+
- **Port 8082:** WebSocket Game Server (handles real-time logic).
|
|
12
|
+
- **Session Recovery:** Tracks user secrets to support reconnection (handling page refreshes).
|
|
13
|
+
- **Large Message Support:** Capable of sending game states larger than 64KB (64-bit frame lengths).
|
|
14
|
+
- **Smart Network Discovery:** Uses the device IPv4 address for LAN URLs.
|
|
15
|
+
- **Game Loop:** Manages the canonical `IGameState` using a reducer.
|
|
16
|
+
- **Dev Mode:** Supports hot-reloading the web controller during development.
|
|
17
|
+
- **Debug Mode:** Optional logging for troubleshooting connection issues.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bun add @couch-kit/host
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
> **Note:** This library includes native dependencies (`react-native-tcp-socket`, `react-native-fs`, etc.). React Native's autolinking will handle the setup for Android. Ensure your `android/build.gradle` is configured correctly if you encounter build issues.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
## API
|
|
30
|
+
|
|
31
|
+
### `<GameHostProvider config={...}>`
|
|
32
|
+
|
|
33
|
+
Config:
|
|
34
|
+
|
|
35
|
+
- `initialState`: initial host state
|
|
36
|
+
- `reducer`: `(state, action) => state` (shared reducer)
|
|
37
|
+
- `port?`: HTTP static server port (default `8080`)
|
|
38
|
+
- `wsPort?`: WebSocket game server port (default `8082`)
|
|
39
|
+
- `staticDir?`: absolute path to the directory of static files to serve. On Android, APK assets live inside a zip archive and cannot be served directly — use this to point to a writable filesystem path where you've extracted the `www/` assets at runtime. Defaults to `${RNFS.MainBundlePath}/www`.
|
|
40
|
+
- `devMode?`: if true, do not start the TV static file server; instead point phones at `devServerUrl`
|
|
41
|
+
- `devServerUrl?`: URL of your laptop dev server (e.g. `http://192.168.1.50:5173`)
|
|
42
|
+
- `debug?`: enable verbose logs
|
|
43
|
+
|
|
44
|
+
### `useGameHost()`
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
|
|
48
|
+
- `state`: canonical host state
|
|
49
|
+
- `dispatch(action)`: dispatches an action into your reducer
|
|
50
|
+
- `serverUrl`: HTTP URL phones should open (or `devServerUrl` in dev mode)
|
|
51
|
+
- `serverError`: static server error (if startup fails)
|
|
52
|
+
|
|
53
|
+
## System Actions (Important)
|
|
54
|
+
|
|
55
|
+
The host will dispatch a few **system action types** into your reducer. Treat these as reserved:
|
|
56
|
+
|
|
57
|
+
- `PLAYER_JOINED`: payload `{ id: string, name: string, avatar?: string, secret?: string }`
|
|
58
|
+
- `PLAYER_LEFT`: payload `{ playerId: string }`
|
|
59
|
+
|
|
60
|
+
If you want to track players in `state.players`, handle these action types in your reducer. The `secret` field can be used to identify returning players.
|
|
61
|
+
|
|
62
|
+
### 1. Configure the Provider
|
|
63
|
+
|
|
64
|
+
Wrap your root component (or the game screen) with `GameHostProvider`.
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
import { GameHostProvider } from "@couch-kit/host";
|
|
68
|
+
import { gameReducer, initialState } from "./shared/gameLogic";
|
|
69
|
+
|
|
70
|
+
export default function App() {
|
|
71
|
+
return (
|
|
72
|
+
<GameHostProvider
|
|
73
|
+
config={{
|
|
74
|
+
reducer: gameReducer,
|
|
75
|
+
initialState: initialState,
|
|
76
|
+
port: 8080, // Optional: HTTP port (default 8080)
|
|
77
|
+
wsPort: 8082, // Optional: WebSocket port (default 8082)
|
|
78
|
+
debug: true, // Optional: Enable detailed logs
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<GameScreen />
|
|
82
|
+
</GameHostProvider>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 2. Access State & Actions
|
|
88
|
+
|
|
89
|
+
Use the `useGameHost` hook to access the game state, dispatch actions, and get the server URL (for QR codes).
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
import { useGameHost } from "@couch-kit/host";
|
|
93
|
+
import QRCode from "react-native-qrcode-svg";
|
|
94
|
+
import { View, Text, Button } from "react-native";
|
|
95
|
+
|
|
96
|
+
function GameScreen() {
|
|
97
|
+
const { state, dispatch, serverUrl } = useGameHost();
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
|
|
101
|
+
{state.status === "lobby" && (
|
|
102
|
+
<>
|
|
103
|
+
<Text style={{ fontSize: 24, marginBottom: 20 }}>Join the Game!</Text>
|
|
104
|
+
{serverUrl && <QRCode value={serverUrl} size={200} />}
|
|
105
|
+
<Text style={{ marginTop: 20 }}>Scan to connect</Text>
|
|
106
|
+
<Text>Players Connected: {Object.keys(state.players).length}</Text>
|
|
107
|
+
|
|
108
|
+
<Button
|
|
109
|
+
title="Start Game"
|
|
110
|
+
onPress={() => dispatch({ type: "START_GAME" })}
|
|
111
|
+
/>
|
|
112
|
+
</>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{state.status === "playing" && (
|
|
116
|
+
<Text style={{ fontSize: 40 }}>Current Score: {state.score}</Text>
|
|
117
|
+
)}
|
|
118
|
+
</View>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Development Mode
|
|
124
|
+
|
|
125
|
+
To iterate on your web controller without rebuilding the Android app constantly:
|
|
126
|
+
|
|
127
|
+
1. Start your web project locally (`vite dev` usually runs on `localhost:5173`).
|
|
128
|
+
2. Configure the Host to point to your laptop:
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
<GameHostProvider
|
|
132
|
+
config={{
|
|
133
|
+
devMode: true,
|
|
134
|
+
devServerUrl: 'http://192.168.1.50:5173' // Your laptop's IP
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The TV will now tell phones to load the controller from your laptop.
|
|
140
|
+
|
|
141
|
+
Important: when the controller is served from the laptop, the client-side hook cannot infer the TV WebSocket host from `window.location.hostname`. In dev mode, pass `url: "ws://TV_IP:8082"` to `useGameClient()`.
|
|
142
|
+
|
|
143
|
+
## Bundling / Assets
|
|
144
|
+
|
|
145
|
+
In production, the host serves static controller assets from `${RNFS.MainBundlePath}/www`.
|
|
146
|
+
|
|
147
|
+
The CLI `couch-kit bundle` copies your web build output into `android/app/src/main/assets/www` (default). Ensure your app packaging makes those assets available under the expected `www` folder.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@couch-kit/host",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"main": "src/index.tsx",
|
|
5
|
+
"react-native": "src/index.tsx",
|
|
6
|
+
"source": "src/index.tsx",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"lib",
|
|
10
|
+
"android",
|
|
11
|
+
"ios",
|
|
12
|
+
"cpp",
|
|
13
|
+
"*.podspec",
|
|
14
|
+
"!lib/typescript/example",
|
|
15
|
+
"!android/build",
|
|
16
|
+
"!ios/build",
|
|
17
|
+
"!**/__tests__",
|
|
18
|
+
"!**/__fixtures__",
|
|
19
|
+
"!**/__mocks__"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "jest --passWithNoTests",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"lint": "eslint \"**/*.{js,ts,tsx}\"",
|
|
25
|
+
"clean": "del-cli lib"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@couch-kit/core": "0.2.0",
|
|
29
|
+
"js-sha1": "^0.7.0",
|
|
30
|
+
"react-native-fs": "^2.20.0",
|
|
31
|
+
"react-native-network-info": "^5.2.1",
|
|
32
|
+
"react-native-nitro-http-server": "^1.5.4",
|
|
33
|
+
"react-native-nitro-modules": "^0.33.2",
|
|
34
|
+
"react-native-tcp-socket": "^6.0.6"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/react": "~17.0.21",
|
|
38
|
+
"@types/react-native": "0.70.0",
|
|
39
|
+
"react": "18.2.0",
|
|
40
|
+
"react-native": "0.72.6",
|
|
41
|
+
"del-cli": "^5.1.0",
|
|
42
|
+
"jest": "^29.7.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"react": "*",
|
|
46
|
+
"react-native": "*"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight EventEmitter implementation for cross-platform compatibility.
|
|
3
|
+
* Works in browser, React Native, and Node.js environments.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
type Listener = (...args: any[]) => void;
|
|
8
|
+
|
|
9
|
+
export class EventEmitter {
|
|
10
|
+
private listeners: Map<string, Listener[]> = new Map();
|
|
11
|
+
|
|
12
|
+
on(event: string, listener: Listener): this {
|
|
13
|
+
if (!this.listeners.has(event)) {
|
|
14
|
+
this.listeners.set(event, []);
|
|
15
|
+
}
|
|
16
|
+
this.listeners.get(event)!.push(listener);
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
once(event: string, listener: Listener): this {
|
|
21
|
+
const onceWrapper: Listener = (...args: any[]) => {
|
|
22
|
+
this.off(event, onceWrapper);
|
|
23
|
+
listener(...args);
|
|
24
|
+
};
|
|
25
|
+
return this.on(event, onceWrapper);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
off(event: string, listener: Listener): this {
|
|
29
|
+
const listeners = this.listeners.get(event);
|
|
30
|
+
if (listeners) {
|
|
31
|
+
const index = listeners.indexOf(listener);
|
|
32
|
+
if (index !== -1) {
|
|
33
|
+
listeners.splice(index, 1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
emit(event: string, ...args: any[]): boolean {
|
|
40
|
+
const listeners = this.listeners.get(event);
|
|
41
|
+
if (!listeners || listeners.length === 0) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Create a copy to avoid issues if listeners are added/removed during emit
|
|
46
|
+
const listenersCopy = [...listeners];
|
|
47
|
+
for (const listener of listenersCopy) {
|
|
48
|
+
try {
|
|
49
|
+
listener(...args);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error(`Error in event listener for "${event}":`, error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
removeAllListeners(event?: string): this {
|
|
58
|
+
if (event) {
|
|
59
|
+
this.listeners.delete(event);
|
|
60
|
+
} else {
|
|
61
|
+
this.listeners.clear();
|
|
62
|
+
}
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
listenerCount(event: string): number {
|
|
67
|
+
return this.listeners.get(event)?.length ?? 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/events.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { EventEmitter } from "./event-emitter";
|
|
2
|
+
|
|
3
|
+
// Since the EventEmitter type definition might not perfectly match the Node.js one in RN environment,
|
|
4
|
+
// we'll declare the interface we expect.
|
|
5
|
+
export interface GameWebSocketServer extends EventEmitter {
|
|
6
|
+
on(event: "connection", listener: (socketId: string) => void): this;
|
|
7
|
+
on(
|
|
8
|
+
event: "message",
|
|
9
|
+
listener: (socketId: string, message: unknown) => void,
|
|
10
|
+
): this;
|
|
11
|
+
on(event: "disconnect", listener: (socketId: string) => void): this;
|
|
12
|
+
on(event: "listening", listener: (port: number) => void): this;
|
|
13
|
+
on(event: "error", listener: (error: Error) => void): this;
|
|
14
|
+
|
|
15
|
+
emit(event: "connection", socketId: string): boolean;
|
|
16
|
+
emit(event: "message", socketId: string, message: unknown): boolean;
|
|
17
|
+
emit(event: "disconnect", socketId: string): boolean;
|
|
18
|
+
emit(event: "listening", port: number): boolean;
|
|
19
|
+
emit(event: "error", error: Error): boolean;
|
|
20
|
+
}
|
package/src/index.tsx
ADDED
package/src/network.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NetworkInfo } from "react-native-network-info";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Smart IP Discovery
|
|
5
|
+
* Prioritizes interfaces that are likely to be the main network (WiFi/Ethernet)
|
|
6
|
+
* over internal/virtual interfaces.
|
|
7
|
+
*/
|
|
8
|
+
export async function getBestIpAddress(): Promise<string | null> {
|
|
9
|
+
try {
|
|
10
|
+
// 1. Try to get the standard IP address (usually WiFi)
|
|
11
|
+
const ip = await NetworkInfo.getIPV4Address();
|
|
12
|
+
|
|
13
|
+
if (ip && ip !== "0.0.0.0" && ip !== "127.0.0.1") {
|
|
14
|
+
return ip;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Fallback logic could go here (e.g., iterating interfaces if exposed by a native module)
|
|
18
|
+
// For now, react-native-network-info is the standard abstraction.
|
|
19
|
+
|
|
20
|
+
return null;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
console.warn("[CouchKit] Failed to get IP address:", error);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useReducer,
|
|
6
|
+
useRef,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { GameWebSocketServer } from "./websocket";
|
|
9
|
+
import { useStaticServer } from "./server";
|
|
10
|
+
import {
|
|
11
|
+
MessageTypes,
|
|
12
|
+
type IGameState,
|
|
13
|
+
type IAction,
|
|
14
|
+
type ClientMessage,
|
|
15
|
+
} from "@couch-kit/core";
|
|
16
|
+
|
|
17
|
+
interface GameHostConfig<S extends IGameState, A extends IAction> {
|
|
18
|
+
initialState: S;
|
|
19
|
+
reducer: (state: S, action: A) => S;
|
|
20
|
+
port?: number; // Static server port (default 8080)
|
|
21
|
+
wsPort?: number; // WebSocket port (default: HTTP port + 2, i.e. 8082)
|
|
22
|
+
devMode?: boolean;
|
|
23
|
+
devServerUrl?: string;
|
|
24
|
+
staticDir?: string; // Override the default www directory path (required on Android)
|
|
25
|
+
debug?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface GameHostContextValue<S extends IGameState, A extends IAction> {
|
|
29
|
+
state: S;
|
|
30
|
+
dispatch: (action: A) => void;
|
|
31
|
+
serverUrl: string | null;
|
|
32
|
+
serverError: Error | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Create Context with 'any' fallback because Context generics are tricky in React
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
const GameHostContext = createContext<GameHostContextValue<any, any> | null>(
|
|
38
|
+
null,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
42
|
+
children,
|
|
43
|
+
config,
|
|
44
|
+
}: {
|
|
45
|
+
children: React.ReactNode;
|
|
46
|
+
config: GameHostConfig<S, A>;
|
|
47
|
+
}) {
|
|
48
|
+
const [state, dispatch] = useReducer(config.reducer, config.initialState);
|
|
49
|
+
|
|
50
|
+
// Keep a ref to state so we can access it inside callbacks/effects that don't depend on it
|
|
51
|
+
const stateRef = useRef(state);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
stateRef.current = state;
|
|
54
|
+
}, [state]);
|
|
55
|
+
|
|
56
|
+
// 1. Start Static File Server (Port 8080)
|
|
57
|
+
const { url: serverUrl, error: serverError } = useStaticServer({
|
|
58
|
+
port: config.port || 8080,
|
|
59
|
+
devMode: config.devMode,
|
|
60
|
+
devServerUrl: config.devServerUrl,
|
|
61
|
+
staticDir: config.staticDir,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// 2. Start WebSocket Server (Convention: HTTP port + 2, avoids Metro on 8081)
|
|
65
|
+
const wsServer = useRef<GameWebSocketServer | null>(null);
|
|
66
|
+
|
|
67
|
+
// Track active sessions: secret -> playerId
|
|
68
|
+
const sessions = useRef<Map<string, string>>(new Map());
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const httpPort = config.port || 8080;
|
|
72
|
+
const port = config.wsPort || httpPort + 2;
|
|
73
|
+
const server = new GameWebSocketServer({ port, debug: config.debug });
|
|
74
|
+
|
|
75
|
+
server.start();
|
|
76
|
+
wsServer.current = server;
|
|
77
|
+
|
|
78
|
+
server.on("listening", (p) => {
|
|
79
|
+
if (config.debug)
|
|
80
|
+
console.log(`[GameHost] WebSocket listening on port ${p}`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
server.on("connection", (socketId) => {
|
|
84
|
+
if (config.debug) console.log(`[GameHost] Client connected: ${socketId}`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
server.on("message", (socketId, message: ClientMessage) => {
|
|
88
|
+
if (config.debug)
|
|
89
|
+
console.log(`[GameHost] Msg from ${socketId}:`, message);
|
|
90
|
+
|
|
91
|
+
switch (message.type) {
|
|
92
|
+
case MessageTypes.JOIN: {
|
|
93
|
+
// Check for existing session
|
|
94
|
+
const { secret, ...payload } = message.payload;
|
|
95
|
+
|
|
96
|
+
if (secret) {
|
|
97
|
+
// Update the session map with the new socket ID for this secret
|
|
98
|
+
sessions.current.set(secret, socketId);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
dispatch({
|
|
102
|
+
type: "PLAYER_JOINED",
|
|
103
|
+
payload: { id: socketId, secret, ...payload },
|
|
104
|
+
} as unknown as A);
|
|
105
|
+
|
|
106
|
+
server.send(socketId, {
|
|
107
|
+
type: MessageTypes.WELCOME,
|
|
108
|
+
payload: {
|
|
109
|
+
playerId: socketId,
|
|
110
|
+
state: stateRef.current,
|
|
111
|
+
serverTime: Date.now(),
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
case MessageTypes.ACTION:
|
|
118
|
+
dispatch(message.payload as A);
|
|
119
|
+
break;
|
|
120
|
+
|
|
121
|
+
case MessageTypes.PING:
|
|
122
|
+
server.send(socketId, {
|
|
123
|
+
type: MessageTypes.PONG,
|
|
124
|
+
payload: {
|
|
125
|
+
id: message.payload.id,
|
|
126
|
+
origTimestamp: message.payload.timestamp,
|
|
127
|
+
serverTime: Date.now(),
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
server.on("disconnect", (socketId) => {
|
|
135
|
+
if (config.debug)
|
|
136
|
+
console.log(`[GameHost] Client disconnected: ${socketId}`);
|
|
137
|
+
|
|
138
|
+
// We do NOT remove the session from the map here,
|
|
139
|
+
// allowing them to reconnect later with the same secret.
|
|
140
|
+
|
|
141
|
+
dispatch({
|
|
142
|
+
type: "PLAYER_LEFT",
|
|
143
|
+
payload: { playerId: socketId },
|
|
144
|
+
} as unknown as A);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return () => {
|
|
148
|
+
server.stop();
|
|
149
|
+
};
|
|
150
|
+
}, []); // Run once on mount
|
|
151
|
+
|
|
152
|
+
// 3. Broadcast State Updates
|
|
153
|
+
// Whenever React state changes, send it to all clients
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (wsServer.current) {
|
|
156
|
+
// Optimization: In the future, send deltas or only send if changed significantly
|
|
157
|
+
wsServer.current.broadcast({
|
|
158
|
+
type: MessageTypes.STATE_UPDATE,
|
|
159
|
+
payload: {
|
|
160
|
+
newState: state,
|
|
161
|
+
timestamp: Date.now(),
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}, [state]);
|
|
166
|
+
|
|
167
|
+
// Keep stateRef in sync inside this effect too just in case (redundant but safe)
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
stateRef.current = state;
|
|
170
|
+
}, [state]);
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<GameHostContext.Provider
|
|
174
|
+
value={{ state, dispatch, serverUrl, serverError }}
|
|
175
|
+
>
|
|
176
|
+
{children}
|
|
177
|
+
</GameHostContext.Provider>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function useGameHost<S extends IGameState, A extends IAction>() {
|
|
182
|
+
const context = useContext(GameHostContext);
|
|
183
|
+
if (!context) {
|
|
184
|
+
throw new Error("useGameHost must be used within a GameHostProvider");
|
|
185
|
+
}
|
|
186
|
+
return context as GameHostContextValue<S, A>;
|
|
187
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { StaticServer } from "react-native-nitro-http-server";
|
|
3
|
+
import RNFS from "react-native-fs";
|
|
4
|
+
import { getBestIpAddress } from "./network";
|
|
5
|
+
|
|
6
|
+
interface CouchKitHostConfig {
|
|
7
|
+
port?: number;
|
|
8
|
+
devMode?: boolean;
|
|
9
|
+
devServerUrl?: string; // e.g. "http://localhost:5173"
|
|
10
|
+
staticDir?: string; // Override the default www directory path (required on Android)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const useStaticServer = (config: CouchKitHostConfig) => {
|
|
14
|
+
const [url, setUrl] = useState<string | null>(null);
|
|
15
|
+
const [error, setError] = useState<Error | null>(null);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let server: StaticServer | null = null;
|
|
19
|
+
|
|
20
|
+
const startServer = async () => {
|
|
21
|
+
// In Dev Mode, we don't start the static server.
|
|
22
|
+
// We just resolve the IP so the host knows where it is.
|
|
23
|
+
if (config.devMode && config.devServerUrl) {
|
|
24
|
+
const ip = await getBestIpAddress();
|
|
25
|
+
if (ip) {
|
|
26
|
+
// In dev mode, the URL is the laptop's dev server,
|
|
27
|
+
// but we might need the TV's IP for the WebSocket connection later.
|
|
28
|
+
setUrl(config.devServerUrl);
|
|
29
|
+
} else {
|
|
30
|
+
setError(new Error("Could not detect TV IP address"));
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Production Mode: Serve assets from bundle
|
|
36
|
+
try {
|
|
37
|
+
// Use staticDir if provided (required on Android where MainBundlePath is undefined),
|
|
38
|
+
// otherwise fall back to iOS MainBundlePath
|
|
39
|
+
const path = config.staticDir || `${RNFS.MainBundlePath}/www`;
|
|
40
|
+
const port = config.port || 8080;
|
|
41
|
+
|
|
42
|
+
server = new StaticServer();
|
|
43
|
+
|
|
44
|
+
// Use '0.0.0.0' to bind to all interfaces (local network)
|
|
45
|
+
await server.start(port, path, "0.0.0.0");
|
|
46
|
+
|
|
47
|
+
// We prefer the actual IP over "localhost" returned by some libs
|
|
48
|
+
const ip = await getBestIpAddress();
|
|
49
|
+
if (ip) {
|
|
50
|
+
setUrl(`http://${ip}:${port}`);
|
|
51
|
+
} else {
|
|
52
|
+
// Fallback if we can't detect IP (though HttpServer doesn't return the URL directly like the old lib)
|
|
53
|
+
setUrl(`http://localhost:${port}`);
|
|
54
|
+
}
|
|
55
|
+
} catch (e) {
|
|
56
|
+
setError(e as Error);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
startServer();
|
|
61
|
+
|
|
62
|
+
return () => {
|
|
63
|
+
if (server) {
|
|
64
|
+
server.stop();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}, [config.port, config.devMode, config.devServerUrl, config.staticDir]);
|
|
68
|
+
|
|
69
|
+
return { url, error };
|
|
70
|
+
};
|
package/src/websocket.ts
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight WebSocket Server Implementation
|
|
3
|
+
* Built on top of react-native-tcp-socket
|
|
4
|
+
*
|
|
5
|
+
* Supports: text frames, close frames, ping/pong, multi-frame TCP packets,
|
|
6
|
+
* and robust buffer management per RFC 6455.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import TcpSocket from "react-native-tcp-socket";
|
|
10
|
+
import { EventEmitter } from "./event-emitter";
|
|
11
|
+
import { Buffer } from "buffer";
|
|
12
|
+
import { sha1 } from "js-sha1";
|
|
13
|
+
|
|
14
|
+
interface WebSocketConfig {
|
|
15
|
+
port: number;
|
|
16
|
+
debug?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// WebSocket opcodes (RFC 6455 Section 5.2)
|
|
20
|
+
const OPCODE = {
|
|
21
|
+
CONTINUATION: 0x0,
|
|
22
|
+
TEXT: 0x1,
|
|
23
|
+
BINARY: 0x2,
|
|
24
|
+
CLOSE: 0x8,
|
|
25
|
+
PING: 0x9,
|
|
26
|
+
PONG: 0xa,
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
// Simple WebSocket Frame Parser/Builder
|
|
30
|
+
const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
31
|
+
|
|
32
|
+
interface DecodedFrame {
|
|
33
|
+
opcode: number;
|
|
34
|
+
payload: Buffer;
|
|
35
|
+
bytesConsumed: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class GameWebSocketServer extends EventEmitter {
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
+
private server: any;
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
private clients: Map<string, any>;
|
|
43
|
+
private port: number;
|
|
44
|
+
private debug: boolean;
|
|
45
|
+
|
|
46
|
+
constructor(config: WebSocketConfig) {
|
|
47
|
+
super();
|
|
48
|
+
this.port = config.port;
|
|
49
|
+
this.debug = !!config.debug;
|
|
50
|
+
this.clients = new Map();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private log(...args: unknown[]) {
|
|
54
|
+
if (this.debug) {
|
|
55
|
+
console.log(...args);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private generateSocketId(): string {
|
|
60
|
+
// Generate a 21-character base36 ID for negligible collision probability
|
|
61
|
+
const a = Math.random().toString(36).substring(2, 15); // 13 chars
|
|
62
|
+
const b = Math.random().toString(36).substring(2, 10); // 8 chars
|
|
63
|
+
return a + b;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
67
|
+
public start() {
|
|
68
|
+
this.log(`[WebSocket] Starting server on port ${this.port}...`);
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
70
|
+
this.server = TcpSocket.createServer((socket: any) => {
|
|
71
|
+
this.log(
|
|
72
|
+
`[WebSocket] New connection from ${socket.address?.()?.address}`,
|
|
73
|
+
);
|
|
74
|
+
let buffer: Buffer = Buffer.alloc(0);
|
|
75
|
+
|
|
76
|
+
socket.on("data", (data: Buffer | string) => {
|
|
77
|
+
this.log(
|
|
78
|
+
`[WebSocket] Received data chunk: ${typeof data === "string" ? data.length : data.length} bytes`,
|
|
79
|
+
);
|
|
80
|
+
// Concatenate new data
|
|
81
|
+
buffer = Buffer.concat([
|
|
82
|
+
buffer,
|
|
83
|
+
typeof data === "string" ? Buffer.from(data) : data,
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
// Handshake not yet performed?
|
|
87
|
+
if (!socket.isHandshakeComplete) {
|
|
88
|
+
const header = buffer.toString("utf8");
|
|
89
|
+
const endOfHeader = header.indexOf("\r\n\r\n");
|
|
90
|
+
if (endOfHeader !== -1) {
|
|
91
|
+
this.handleHandshake(socket, header);
|
|
92
|
+
// Retain any bytes after the handshake (could be the first WS frame)
|
|
93
|
+
const headerByteLength = Buffer.byteLength(
|
|
94
|
+
header.substring(0, endOfHeader + 4),
|
|
95
|
+
"utf8",
|
|
96
|
+
);
|
|
97
|
+
buffer = buffer.slice(headerByteLength);
|
|
98
|
+
socket.isHandshakeComplete = true;
|
|
99
|
+
// Fall through to process any remaining frames below
|
|
100
|
+
} else {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Process all complete frames in the buffer
|
|
106
|
+
this.processFrames(socket, buffer, (remaining) => {
|
|
107
|
+
buffer = remaining;
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
socket.on("error", (error: Error) => {
|
|
112
|
+
this.emit("error", error);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
socket.on("close", () => {
|
|
116
|
+
if (socket.id) {
|
|
117
|
+
this.clients.delete(socket.id);
|
|
118
|
+
this.emit("disconnect", socket.id);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
this.server.listen({ port: this.port, host: "0.0.0.0" }, () => {
|
|
124
|
+
this.log(`[WebSocket] Server listening on 0.0.0.0:${this.port}`);
|
|
125
|
+
this.emit("listening", this.port);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
130
|
+
private processFrames(
|
|
131
|
+
socket: any,
|
|
132
|
+
buffer: Buffer,
|
|
133
|
+
setBuffer: (b: Buffer) => void,
|
|
134
|
+
) {
|
|
135
|
+
while (buffer.length > 0) {
|
|
136
|
+
const frame = this.decodeFrame(buffer);
|
|
137
|
+
if (!frame) {
|
|
138
|
+
// Incomplete frame -- keep buffer, wait for more data
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Advance buffer past the consumed frame
|
|
143
|
+
buffer = buffer.slice(frame.bytesConsumed);
|
|
144
|
+
|
|
145
|
+
// Handle frame by opcode
|
|
146
|
+
switch (frame.opcode) {
|
|
147
|
+
case OPCODE.TEXT: {
|
|
148
|
+
try {
|
|
149
|
+
const message = JSON.parse(frame.payload.toString("utf8"));
|
|
150
|
+
this.emit("message", socket.id, message);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
// Corrupt JSON in a complete frame -- discard this frame, continue processing
|
|
153
|
+
this.log(
|
|
154
|
+
`[WebSocket] Invalid JSON from ${socket.id}, discarding frame`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
case OPCODE.CLOSE: {
|
|
161
|
+
this.log(`[WebSocket] Close frame from ${socket.id}`);
|
|
162
|
+
// Send close frame back (RFC 6455 Section 5.5.1)
|
|
163
|
+
const closeFrame = Buffer.alloc(2);
|
|
164
|
+
closeFrame[0] = 0x88; // FIN + Close opcode
|
|
165
|
+
closeFrame[1] = 0x00; // No payload
|
|
166
|
+
try {
|
|
167
|
+
socket.write(closeFrame);
|
|
168
|
+
} catch {
|
|
169
|
+
// Socket may already be closing
|
|
170
|
+
}
|
|
171
|
+
socket.destroy();
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
case OPCODE.PING: {
|
|
176
|
+
this.log(`[WebSocket] Ping from ${socket.id}`);
|
|
177
|
+
// Respond with pong containing the same payload (RFC 6455 Section 5.5.3)
|
|
178
|
+
const pongFrame = this.encodeControlFrame(OPCODE.PONG, frame.payload);
|
|
179
|
+
try {
|
|
180
|
+
socket.write(pongFrame);
|
|
181
|
+
} catch {
|
|
182
|
+
// Socket may be closing
|
|
183
|
+
}
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case OPCODE.PONG: {
|
|
188
|
+
// Unsolicited pong -- safe to ignore (RFC 6455 Section 5.5.3)
|
|
189
|
+
this.log(`[WebSocket] Pong from ${socket.id}`);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
case OPCODE.BINARY: {
|
|
194
|
+
// Binary frames not supported -- log and discard
|
|
195
|
+
this.log(
|
|
196
|
+
`[WebSocket] Binary frame from ${socket.id}, not supported -- discarding`,
|
|
197
|
+
);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
default: {
|
|
202
|
+
this.log(
|
|
203
|
+
`[WebSocket] Unknown opcode 0x${frame.opcode.toString(16)} from ${socket.id}, discarding`,
|
|
204
|
+
);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// If socket was destroyed (e.g., close frame), stop processing
|
|
210
|
+
if (socket.destroyed) break;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
setBuffer(buffer);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
public stop() {
|
|
217
|
+
if (this.server) {
|
|
218
|
+
this.server.close();
|
|
219
|
+
this.clients.forEach((socket) => socket.destroy());
|
|
220
|
+
this.clients.clear();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
public send(socketId: string, data: unknown) {
|
|
225
|
+
const socket = this.clients.get(socketId);
|
|
226
|
+
if (socket) {
|
|
227
|
+
const frame = this.encodeFrame(JSON.stringify(data));
|
|
228
|
+
socket.write(frame);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
public broadcast(data: unknown, excludeId?: string) {
|
|
233
|
+
const frame = this.encodeFrame(JSON.stringify(data));
|
|
234
|
+
this.clients.forEach((socket, id) => {
|
|
235
|
+
if (id !== excludeId) {
|
|
236
|
+
socket.write(frame);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// --- Private Helpers ---
|
|
242
|
+
|
|
243
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
244
|
+
private handleHandshake(socket: any, header: string) {
|
|
245
|
+
this.log("[WebSocket] Handshake request header:", JSON.stringify(header));
|
|
246
|
+
const keyMatch = header.match(/Sec-WebSocket-Key: (.+)/);
|
|
247
|
+
if (!keyMatch) {
|
|
248
|
+
console.error("[WebSocket] Handshake failed: No Sec-WebSocket-Key found");
|
|
249
|
+
socket.destroy();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const key = keyMatch[1].trim();
|
|
254
|
+
this.log("[WebSocket] Client Key:", key);
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const acceptKey = this.generateAcceptKey(key);
|
|
258
|
+
this.log("[WebSocket] Generated Accept Key:", acceptKey);
|
|
259
|
+
|
|
260
|
+
const response = [
|
|
261
|
+
"HTTP/1.1 101 Switching Protocols",
|
|
262
|
+
"Upgrade: websocket",
|
|
263
|
+
"Connection: Upgrade",
|
|
264
|
+
`Sec-WebSocket-Accept: ${acceptKey}`,
|
|
265
|
+
"\r\n",
|
|
266
|
+
].join("\r\n");
|
|
267
|
+
|
|
268
|
+
this.log(
|
|
269
|
+
"[WebSocket] Sending Handshake Response:",
|
|
270
|
+
JSON.stringify(response),
|
|
271
|
+
);
|
|
272
|
+
socket.write(response);
|
|
273
|
+
|
|
274
|
+
// Assign ID and store
|
|
275
|
+
socket.id = this.generateSocketId();
|
|
276
|
+
this.clients.set(socket.id, socket);
|
|
277
|
+
this.emit("connection", socket.id);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
console.error("[WebSocket] Handshake error:", error);
|
|
280
|
+
socket.destroy();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private generateAcceptKey(key: string): string {
|
|
285
|
+
const input = key + GUID;
|
|
286
|
+
const hash = sha1(input);
|
|
287
|
+
this.log(`[WebSocket] SHA1 Input: ${input}`);
|
|
288
|
+
this.log(`[WebSocket] SHA1 Hash (hex): ${hash}`);
|
|
289
|
+
return Buffer.from(hash, "hex").toString("base64");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private decodeFrame(buffer: Buffer): DecodedFrame | null {
|
|
293
|
+
// Need at least 2 bytes for the header
|
|
294
|
+
if (buffer.length < 2) return null;
|
|
295
|
+
|
|
296
|
+
const firstByte = buffer[0];
|
|
297
|
+
const opcode = firstByte & 0x0f;
|
|
298
|
+
|
|
299
|
+
const secondByte = buffer[1];
|
|
300
|
+
const isMasked = (secondByte & 0x80) !== 0;
|
|
301
|
+
let payloadLength = secondByte & 0x7f;
|
|
302
|
+
let headerLength = 2;
|
|
303
|
+
|
|
304
|
+
if (payloadLength === 126) {
|
|
305
|
+
if (buffer.length < 4) return null; // Need 2 more bytes for extended length
|
|
306
|
+
payloadLength = buffer.readUInt16BE(2);
|
|
307
|
+
headerLength = 4;
|
|
308
|
+
} else if (payloadLength === 127) {
|
|
309
|
+
if (buffer.length < 10) return null; // Need 8 more bytes for extended length
|
|
310
|
+
// Read 64-bit length. For safety, only use the lower 32 bits.
|
|
311
|
+
const highBits = buffer.readUInt32BE(2);
|
|
312
|
+
if (highBits > 0) {
|
|
313
|
+
// Payload > 4GB -- reject
|
|
314
|
+
throw new Error("Frame payload too large");
|
|
315
|
+
}
|
|
316
|
+
payloadLength = buffer.readUInt32BE(6);
|
|
317
|
+
headerLength = 10;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const maskLength = isMasked ? 4 : 0;
|
|
321
|
+
const totalFrameLength = headerLength + maskLength + payloadLength;
|
|
322
|
+
|
|
323
|
+
// Check if we have the complete frame
|
|
324
|
+
if (buffer.length < totalFrameLength) return null;
|
|
325
|
+
|
|
326
|
+
let payload: Buffer;
|
|
327
|
+
if (isMasked) {
|
|
328
|
+
const mask = buffer.slice(headerLength, headerLength + 4);
|
|
329
|
+
const maskedPayload = buffer.slice(
|
|
330
|
+
headerLength + 4,
|
|
331
|
+
headerLength + 4 + payloadLength,
|
|
332
|
+
);
|
|
333
|
+
payload = Buffer.alloc(payloadLength);
|
|
334
|
+
for (let i = 0; i < payloadLength; i++) {
|
|
335
|
+
payload[i] = maskedPayload[i] ^ mask[i % 4];
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
payload = Buffer.from(
|
|
339
|
+
buffer.slice(headerLength, headerLength + payloadLength),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { opcode, payload, bytesConsumed: totalFrameLength };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private encodeFrame(data: string): Buffer {
|
|
347
|
+
// Server -> Client frames are NOT masked (text frame)
|
|
348
|
+
return this.buildFrame(OPCODE.TEXT, Buffer.from(data));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private encodeControlFrame(opcode: number, payload: Buffer): Buffer {
|
|
352
|
+
return this.buildFrame(opcode, payload);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private buildFrame(opcode: number, payload: Buffer): Buffer {
|
|
356
|
+
let headerLength = 2;
|
|
357
|
+
|
|
358
|
+
if (payload.length > 65535) {
|
|
359
|
+
headerLength = 10; // 2 header + 8 length
|
|
360
|
+
} else if (payload.length > 125) {
|
|
361
|
+
headerLength = 4; // 2 header + 2 length
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const frame = Buffer.alloc(headerLength + payload.length);
|
|
365
|
+
frame[0] = 0x80 | opcode; // FIN bit set + opcode
|
|
366
|
+
|
|
367
|
+
if (payload.length > 65535) {
|
|
368
|
+
frame[1] = 127;
|
|
369
|
+
// Write 64-bit integer (max safe integer in JS is 2^53, so high 32 bits are 0)
|
|
370
|
+
frame.writeUInt32BE(0, 2);
|
|
371
|
+
frame.writeUInt32BE(payload.length, 6);
|
|
372
|
+
} else if (payload.length > 125) {
|
|
373
|
+
frame[1] = 126;
|
|
374
|
+
frame.writeUInt16BE(payload.length, 2);
|
|
375
|
+
} else {
|
|
376
|
+
frame[1] = payload.length;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
payload.copy(frame, headerLength);
|
|
380
|
+
return frame;
|
|
381
|
+
}
|
|
382
|
+
}
|