Implement standalone control server

This commit is contained in:
Max Goodhart
2025-06-14 06:46:27 +00:00
parent ec6b7bd360
commit 9ded048667
29 changed files with 3414 additions and 415 deletions

View File

@@ -0,0 +1,2 @@
node_modules
dist

View 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>

View 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"
}
}

View 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)

View 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"]
}

View 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[]),
],
})