mirror of
https://github.com/streamwall/streamwall.git
synced 2026-01-24 22:22:50 -05:00
Implement standalone control server
This commit is contained in:
2
packages/streamwall-control-client/.gitignore
vendored
Normal file
2
packages/streamwall-control-client/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
14
packages/streamwall-control-client/index.html
Normal file
14
packages/streamwall-control-client/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Streamwall Control</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; style-src 'self' 'unsafe-inline'"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<script src="src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
packages/streamwall-control-client/package.json
Normal file
26
packages/streamwall-control-client/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "streamwall-control-client",
|
||||
"version": "1.0.0",
|
||||
"description": "Multiplayer Streamwall: frontend",
|
||||
"main": "src/index.tsx",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"repository": "github:streamwall/streamwall",
|
||||
"author": "Max Goodhart <c@chromakode.com>",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@preact/preset-vite": "^2.10.1",
|
||||
"jsondiffpatch": "^0.7.3",
|
||||
"reconnecting-websocket": "^4.4.0",
|
||||
"typescript": "~4.5.4",
|
||||
"vite": "^5.4.14",
|
||||
"yjs": "^13.6.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.9.3",
|
||||
"@tsconfig/recommended": "^1.0.8",
|
||||
"@tsconfig/vite-react": "^6.3.5"
|
||||
}
|
||||
}
|
||||
152
packages/streamwall-control-client/src/index.tsx
Normal file
152
packages/streamwall-control-client/src/index.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { render } from 'preact'
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
||||
import ReconnectingWebSocket from 'reconnecting-websocket'
|
||||
import {
|
||||
type CollabData,
|
||||
ControlUI,
|
||||
GlobalStyle,
|
||||
type StreamwallConnection,
|
||||
useStreamwallState,
|
||||
useYDoc,
|
||||
} from 'streamwall-control-ui'
|
||||
import {
|
||||
type ControlCommand,
|
||||
stateDiff,
|
||||
type StreamwallState,
|
||||
} from 'streamwall-shared'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
function useStreamwallWebsocketConnection(
|
||||
wsEndpoint: string,
|
||||
): StreamwallConnection {
|
||||
const wsRef = useRef<{
|
||||
ws: ReconnectingWebSocket
|
||||
msgId: number
|
||||
responseMap: Map<number, (msg: object) => void>
|
||||
}>()
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const {
|
||||
docValue: sharedState,
|
||||
doc: stateDoc,
|
||||
setDoc: setStateDoc,
|
||||
} = useYDoc<CollabData>(['views'])
|
||||
const [streamwallState, setStreamwallState] = useState<StreamwallState>()
|
||||
const appState = useStreamwallState(streamwallState)
|
||||
|
||||
useEffect(() => {
|
||||
let lastStateData: StreamwallState | undefined
|
||||
const ws = new ReconnectingWebSocket(wsEndpoint, [], {
|
||||
maxReconnectionDelay: 5000,
|
||||
minReconnectionDelay: 1000 + Math.random() * 500,
|
||||
reconnectionDelayGrowFactor: 1.1,
|
||||
})
|
||||
ws.binaryType = 'arraybuffer'
|
||||
ws.addEventListener('open', () => setIsConnected(true))
|
||||
ws.addEventListener('close', () => {
|
||||
setStateDoc(new Y.Doc())
|
||||
setIsConnected(false)
|
||||
})
|
||||
ws.addEventListener('message', (ev) => {
|
||||
if (ev.data instanceof ArrayBuffer) {
|
||||
return
|
||||
}
|
||||
const msg = JSON.parse(ev.data)
|
||||
if (msg.response && wsRef.current != null) {
|
||||
const { responseMap } = wsRef.current
|
||||
const responseCb = responseMap.get(msg.id)
|
||||
if (responseCb) {
|
||||
responseMap.delete(msg.id)
|
||||
responseCb(msg)
|
||||
}
|
||||
} else if (msg.type === 'state' || msg.type === 'state-delta') {
|
||||
let state: StreamwallState
|
||||
if (msg.type === 'state') {
|
||||
state = msg.state
|
||||
} else {
|
||||
// Clone so updated object triggers React renders
|
||||
state = stateDiff.clone(
|
||||
stateDiff.patch(lastStateData, msg.delta),
|
||||
) as StreamwallState
|
||||
}
|
||||
lastStateData = state
|
||||
setStreamwallState(state)
|
||||
} else {
|
||||
console.warn('unexpected ws message', msg)
|
||||
}
|
||||
})
|
||||
wsRef.current = { ws, msgId: 0, responseMap: new Map() }
|
||||
}, [])
|
||||
|
||||
const send = useCallback(
|
||||
(msg: ControlCommand, cb?: (msg: unknown) => void) => {
|
||||
if (!wsRef.current) {
|
||||
throw new Error('Websocket not initialized')
|
||||
}
|
||||
const { ws, msgId, responseMap } = wsRef.current
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
...msg,
|
||||
id: msgId,
|
||||
}),
|
||||
)
|
||||
if (cb) {
|
||||
responseMap.set(msgId, cb)
|
||||
}
|
||||
wsRef.current.msgId++
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsRef.current) {
|
||||
throw new Error('Websocket not initialized')
|
||||
}
|
||||
const { ws } = wsRef.current
|
||||
|
||||
function sendUpdate(update: Uint8Array, origin: string) {
|
||||
if (origin === 'server') {
|
||||
return
|
||||
}
|
||||
wsRef.current?.ws.send(update)
|
||||
}
|
||||
|
||||
function receiveUpdate(ev: MessageEvent) {
|
||||
if (!(ev.data instanceof ArrayBuffer)) {
|
||||
return
|
||||
}
|
||||
Y.applyUpdate(stateDoc, new Uint8Array(ev.data), 'server')
|
||||
}
|
||||
|
||||
stateDoc.on('update', sendUpdate)
|
||||
ws.addEventListener('message', receiveUpdate)
|
||||
return () => {
|
||||
stateDoc.off('update', sendUpdate)
|
||||
ws.removeEventListener('message', receiveUpdate)
|
||||
}
|
||||
}, [stateDoc])
|
||||
|
||||
return {
|
||||
...appState,
|
||||
isConnected,
|
||||
send,
|
||||
sharedState,
|
||||
stateDoc,
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { BASE_URL } = import.meta.env
|
||||
|
||||
const connection = useStreamwallWebsocketConnection(
|
||||
(BASE_URL === '/' ? `ws://${location.host}` : BASE_URL) + '/client/ws',
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalStyle />
|
||||
<ControlUI connection={connection} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<App />, document.body)
|
||||
13
packages/streamwall-control-client/tsconfig.json
Normal file
13
packages/streamwall-control-client/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": ["@tsconfig/recommended/tsconfig", "@tsconfig/vite-react"],
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"react": ["./node_modules/preact/compat/"],
|
||||
"react-dom": ["./node_modules/preact/compat/"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
25
packages/streamwall-control-client/vite.config.ts
Normal file
25
packages/streamwall-control-client/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import preact from '@preact/preset-vite'
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// https://vitejs.dev/config
|
||||
export default defineConfig({
|
||||
base: process.env.STREAMWALL_CONTROL_URL ?? '/',
|
||||
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
// Necessary for vite to watch the package dir
|
||||
'streamwall-control-ui': resolve(__dirname, '../streamwall-control-ui'),
|
||||
'streamwall-shared': resolve(__dirname, '../streamwall-shared'),
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
// FIXME: working around TS error: "Type 'Plugin<any>' is not assignable to type 'PluginOption'"
|
||||
...(preact() as Plugin[]),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user