Skip to content

Commit 9ded048

Browse files
committed
Implement standalone control server
1 parent ec6b7bd commit 9ded048

File tree

29 files changed

+4119
-1120
lines changed

29 files changed

+4119
-1120
lines changed

package-lock.json

Lines changed: 2943 additions & 819 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
{
22
"name": "streamwall",
3+
"scripts": {
4+
"app:start": "npm -w packages/streamwall start",
5+
"server:start": "npm -w packages/streamwall-control-client run build && npm -w packages/streamwall-control-server start"
6+
},
37
"workspaces": [
48
"packages/streamwall",
59
"packages/streamwall-shared",
10+
"packages/streamwall-control-client",
11+
"packages/streamwall-control-server",
612
"packages/streamwall-control-ui"
713
],
814
"devDependencies": {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules
2+
dist
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Streamwall Control</title>
6+
<meta
7+
http-equiv="Content-Security-Policy"
8+
content="default-src 'self'; style-src 'self' 'unsafe-inline'"
9+
/>
10+
</head>
11+
<body>
12+
<script src="src/index.tsx" type="module"></script>
13+
</body>
14+
</html>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "streamwall-control-client",
3+
"version": "1.0.0",
4+
"description": "Multiplayer Streamwall: frontend",
5+
"main": "src/index.tsx",
6+
"type": "module",
7+
"scripts": {
8+
"build": "vite build"
9+
},
10+
"repository": "github:streamwall/streamwall",
11+
"author": "Max Goodhart <[email protected]>",
12+
"license": "MIT",
13+
"dependencies": {
14+
"@preact/preset-vite": "^2.10.1",
15+
"jsondiffpatch": "^0.7.3",
16+
"reconnecting-websocket": "^4.4.0",
17+
"typescript": "~4.5.4",
18+
"vite": "^5.4.14",
19+
"yjs": "^13.6.21"
20+
},
21+
"devDependencies": {
22+
"@preact/preset-vite": "^2.9.3",
23+
"@tsconfig/recommended": "^1.0.8",
24+
"@tsconfig/vite-react": "^6.3.5"
25+
}
26+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { render } from 'preact'
2+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
3+
import ReconnectingWebSocket from 'reconnecting-websocket'
4+
import {
5+
type CollabData,
6+
ControlUI,
7+
GlobalStyle,
8+
type StreamwallConnection,
9+
useStreamwallState,
10+
useYDoc,
11+
} from 'streamwall-control-ui'
12+
import {
13+
type ControlCommand,
14+
stateDiff,
15+
type StreamwallState,
16+
} from 'streamwall-shared'
17+
import * as Y from 'yjs'
18+
19+
function useStreamwallWebsocketConnection(
20+
wsEndpoint: string,
21+
): StreamwallConnection {
22+
const wsRef = useRef<{
23+
ws: ReconnectingWebSocket
24+
msgId: number
25+
responseMap: Map<number, (msg: object) => void>
26+
}>()
27+
const [isConnected, setIsConnected] = useState(false)
28+
const {
29+
docValue: sharedState,
30+
doc: stateDoc,
31+
setDoc: setStateDoc,
32+
} = useYDoc<CollabData>(['views'])
33+
const [streamwallState, setStreamwallState] = useState<StreamwallState>()
34+
const appState = useStreamwallState(streamwallState)
35+
36+
useEffect(() => {
37+
let lastStateData: StreamwallState | undefined
38+
const ws = new ReconnectingWebSocket(wsEndpoint, [], {
39+
maxReconnectionDelay: 5000,
40+
minReconnectionDelay: 1000 + Math.random() * 500,
41+
reconnectionDelayGrowFactor: 1.1,
42+
})
43+
ws.binaryType = 'arraybuffer'
44+
ws.addEventListener('open', () => setIsConnected(true))
45+
ws.addEventListener('close', () => {
46+
setStateDoc(new Y.Doc())
47+
setIsConnected(false)
48+
})
49+
ws.addEventListener('message', (ev) => {
50+
if (ev.data instanceof ArrayBuffer) {
51+
return
52+
}
53+
const msg = JSON.parse(ev.data)
54+
if (msg.response && wsRef.current != null) {
55+
const { responseMap } = wsRef.current
56+
const responseCb = responseMap.get(msg.id)
57+
if (responseCb) {
58+
responseMap.delete(msg.id)
59+
responseCb(msg)
60+
}
61+
} else if (msg.type === 'state' || msg.type === 'state-delta') {
62+
let state: StreamwallState
63+
if (msg.type === 'state') {
64+
state = msg.state
65+
} else {
66+
// Clone so updated object triggers React renders
67+
state = stateDiff.clone(
68+
stateDiff.patch(lastStateData, msg.delta),
69+
) as StreamwallState
70+
}
71+
lastStateData = state
72+
setStreamwallState(state)
73+
} else {
74+
console.warn('unexpected ws message', msg)
75+
}
76+
})
77+
wsRef.current = { ws, msgId: 0, responseMap: new Map() }
78+
}, [])
79+
80+
const send = useCallback(
81+
(msg: ControlCommand, cb?: (msg: unknown) => void) => {
82+
if (!wsRef.current) {
83+
throw new Error('Websocket not initialized')
84+
}
85+
const { ws, msgId, responseMap } = wsRef.current
86+
ws.send(
87+
JSON.stringify({
88+
...msg,
89+
id: msgId,
90+
}),
91+
)
92+
if (cb) {
93+
responseMap.set(msgId, cb)
94+
}
95+
wsRef.current.msgId++
96+
},
97+
[],
98+
)
99+
100+
useEffect(() => {
101+
if (!wsRef.current) {
102+
throw new Error('Websocket not initialized')
103+
}
104+
const { ws } = wsRef.current
105+
106+
function sendUpdate(update: Uint8Array, origin: string) {
107+
if (origin === 'server') {
108+
return
109+
}
110+
wsRef.current?.ws.send(update)
111+
}
112+
113+
function receiveUpdate(ev: MessageEvent) {
114+
if (!(ev.data instanceof ArrayBuffer)) {
115+
return
116+
}
117+
Y.applyUpdate(stateDoc, new Uint8Array(ev.data), 'server')
118+
}
119+
120+
stateDoc.on('update', sendUpdate)
121+
ws.addEventListener('message', receiveUpdate)
122+
return () => {
123+
stateDoc.off('update', sendUpdate)
124+
ws.removeEventListener('message', receiveUpdate)
125+
}
126+
}, [stateDoc])
127+
128+
return {
129+
...appState,
130+
isConnected,
131+
send,
132+
sharedState,
133+
stateDoc,
134+
}
135+
}
136+
137+
function App() {
138+
const { BASE_URL } = import.meta.env
139+
140+
const connection = useStreamwallWebsocketConnection(
141+
(BASE_URL === '/' ? `ws://${location.host}` : BASE_URL) + '/client/ws',
142+
)
143+
144+
return (
145+
<>
146+
<GlobalStyle />
147+
<ControlUI connection={connection} />
148+
</>
149+
)
150+
}
151+
152+
render(<App />, document.body)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"extends": ["@tsconfig/recommended/tsconfig", "@tsconfig/vite-react"],
3+
"compilerOptions": {
4+
"jsx": "react-jsx",
5+
"jsxImportSource": "preact",
6+
"types": ["vite/client"],
7+
"paths": {
8+
"react": ["./node_modules/preact/compat/"],
9+
"react-dom": ["./node_modules/preact/compat/"]
10+
}
11+
},
12+
"include": ["src"]
13+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import preact from '@preact/preset-vite'
2+
import { resolve } from 'path'
3+
import { defineConfig } from 'vite'
4+
5+
// https://vitejs.dev/config
6+
export default defineConfig({
7+
base: process.env.STREAMWALL_CONTROL_URL ?? '/',
8+
9+
build: {
10+
sourcemap: true,
11+
},
12+
13+
resolve: {
14+
alias: {
15+
// Necessary for vite to watch the package dir
16+
'streamwall-control-ui': resolve(__dirname, '../streamwall-control-ui'),
17+
'streamwall-shared': resolve(__dirname, '../streamwall-shared'),
18+
},
19+
},
20+
21+
plugins: [
22+
// FIXME: working around TS error: "Type 'Plugin<any>' is not assignable to type 'PluginOption'"
23+
...(preact() as Plugin[]),
24+
],
25+
})
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
storage.json
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "streamwall-control-server",
3+
"version": "1.0.0",
4+
"description": "Multiplayer Streamwall: backend",
5+
"main": "src/index.ts",
6+
"type": "module",
7+
"scripts": {
8+
"start": "tsx ./src/index.ts"
9+
},
10+
"repository": "github:streamwall/streamwall",
11+
"author": "Max Goodhart <[email protected]>",
12+
"license": "MIT",
13+
"dependencies": {
14+
"@fastify/cookie": "^11.0.2",
15+
"@fastify/static": "^8.2.0",
16+
"@fastify/websocket": "^11.1.0",
17+
"base-x": "^5.0.1",
18+
"fastify": "^5.4.0",
19+
"jsondiffpatch": "^0.7.3",
20+
"lowdb": "^7.0.1",
21+
"tsx": "^4.20.2",
22+
"typescript": "~4.5.4",
23+
"ws": "^8.18.2",
24+
"yjs": "^13.6.21"
25+
},
26+
"devDependencies": {
27+
"@tsconfig/node-ts": "^23.6.1",
28+
"@tsconfig/node22": "^22.0.2",
29+
"@tsconfig/recommended": "^1.0.8"
30+
}
31+
}

0 commit comments

Comments
 (0)