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:
1
packages/streamwall-control-server/.gitignore
vendored
Normal file
1
packages/streamwall-control-server/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
storage.json
|
||||
31
packages/streamwall-control-server/package.json
Normal file
31
packages/streamwall-control-server/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "streamwall-control-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Multiplayer Streamwall: backend",
|
||||
"main": "src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx ./src/index.ts"
|
||||
},
|
||||
"repository": "github:streamwall/streamwall",
|
||||
"author": "Max Goodhart <c@chromakode.com>",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@fastify/websocket": "^11.1.0",
|
||||
"base-x": "^5.0.1",
|
||||
"fastify": "^5.4.0",
|
||||
"jsondiffpatch": "^0.7.3",
|
||||
"lowdb": "^7.0.1",
|
||||
"tsx": "^4.20.2",
|
||||
"typescript": "~4.5.4",
|
||||
"ws": "^8.18.2",
|
||||
"yjs": "^13.6.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node-ts": "^23.6.1",
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@tsconfig/recommended": "^1.0.8"
|
||||
}
|
||||
}
|
||||
196
packages/streamwall-control-server/src/auth.ts
Normal file
196
packages/streamwall-control-server/src/auth.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import baseX from 'base-x'
|
||||
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'crypto'
|
||||
import EventEmitter from 'events'
|
||||
import {
|
||||
type AuthTokenInfo,
|
||||
type StreamwallRole,
|
||||
type StreamwallState,
|
||||
validRolesSet,
|
||||
} from 'streamwall-shared'
|
||||
import { promisify } from 'util'
|
||||
import type { StoredData } from './storage.ts'
|
||||
|
||||
export interface AuthToken extends AuthTokenInfo {
|
||||
tokenHash: string
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
invites: AuthTokenInfo[]
|
||||
sessions: AuthTokenInfo[]
|
||||
}
|
||||
|
||||
interface AuthEvents {
|
||||
state: [AuthState]
|
||||
}
|
||||
|
||||
const scrypt = promisify(scryptCb)
|
||||
|
||||
const base62 = baseX(
|
||||
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
)
|
||||
|
||||
function rand62(len: number) {
|
||||
return base62.encode(randomBytes(len))
|
||||
}
|
||||
|
||||
async function hashToken62(token: string, salt: string) {
|
||||
const hashBuffer = await scrypt(token, salt, 24)
|
||||
return base62.encode(hashBuffer as Buffer)
|
||||
}
|
||||
|
||||
// Wrapper for state data to facilitate role-scoped data access.
|
||||
export class StateWrapper extends EventEmitter {
|
||||
_value: StreamwallState
|
||||
|
||||
constructor(value: StreamwallState) {
|
||||
super()
|
||||
this._value = value
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return '<state data>'
|
||||
}
|
||||
|
||||
view(role: StreamwallRole) {
|
||||
const { config, auth, streams, customStreams, views, streamdelay } =
|
||||
this._value
|
||||
|
||||
const state: StreamwallState = {
|
||||
identity: {
|
||||
role,
|
||||
},
|
||||
config,
|
||||
streams,
|
||||
customStreams,
|
||||
views,
|
||||
streamdelay,
|
||||
}
|
||||
if (role === 'admin') {
|
||||
state.auth = auth
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
update(value: StreamwallState) {
|
||||
this._value = { ...this._value, ...value }
|
||||
this.emit('state', this)
|
||||
}
|
||||
|
||||
// Unprivileged getter
|
||||
get info() {
|
||||
return this.view('monitor')
|
||||
}
|
||||
}
|
||||
|
||||
export class Auth extends EventEmitter<AuthEvents> {
|
||||
salt: string
|
||||
tokensById: Map<string, AuthToken>
|
||||
|
||||
constructor({ salt, tokens = [] }: Partial<StoredData['auth']> = {}) {
|
||||
super()
|
||||
this.salt = salt ?? rand62(24)
|
||||
this.tokensById = new Map()
|
||||
for (const token of tokens) {
|
||||
this.tokensById.set(token.tokenId, token)
|
||||
}
|
||||
}
|
||||
|
||||
getStoredData() {
|
||||
return {
|
||||
salt: this.salt,
|
||||
tokens: [...this.tokensById.values()],
|
||||
}
|
||||
}
|
||||
|
||||
getState() {
|
||||
const toTokenInfo = ({ tokenId, name, kind, role }: AuthTokenInfo) => ({
|
||||
tokenId,
|
||||
name,
|
||||
kind,
|
||||
role,
|
||||
})
|
||||
return {
|
||||
invites: this.tokensById
|
||||
.values()
|
||||
.filter((t) => t.kind === 'invite')
|
||||
.map(toTokenInfo)
|
||||
.toArray(),
|
||||
sessions: this.tokensById
|
||||
.values()
|
||||
.filter((t) => t.kind === 'session')
|
||||
.map(toTokenInfo)
|
||||
.toArray(),
|
||||
}
|
||||
}
|
||||
|
||||
emitState() {
|
||||
this.emit('state', this.getState())
|
||||
}
|
||||
|
||||
async validateToken(
|
||||
id: string,
|
||||
secret: string,
|
||||
): Promise<AuthTokenInfo | null> {
|
||||
const tokenHash = await hashToken62(secret, this.salt)
|
||||
const tokenData = this.tokensById.get(id)
|
||||
|
||||
if (!tokenData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const providedTokenHashBuf = Buffer.from(tokenHash)
|
||||
const expectedTokenHashBuf = Buffer.from(tokenData.tokenHash)
|
||||
const isTokenMatch = timingSafeEqual(
|
||||
providedTokenHashBuf,
|
||||
expectedTokenHashBuf,
|
||||
)
|
||||
if (!isTokenMatch) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
tokenId: tokenData.tokenId,
|
||||
kind: tokenData.kind,
|
||||
role: tokenData.role,
|
||||
name: tokenData.name,
|
||||
}
|
||||
}
|
||||
|
||||
async createToken({ kind, role, name }: Omit<AuthTokenInfo, 'tokenId'>) {
|
||||
if (!validRolesSet.has(role)) {
|
||||
throw new Error(`invalid role: ${role}`)
|
||||
}
|
||||
|
||||
let tokenId = rand62(8)
|
||||
while (this.tokensById.has(tokenId)) {
|
||||
// Regenerate in case of an id collision
|
||||
tokenId = rand62(8)
|
||||
}
|
||||
|
||||
const secret = rand62(24)
|
||||
const tokenHash = await hashToken62(secret, this.salt)
|
||||
const tokenData = {
|
||||
tokenId,
|
||||
tokenHash,
|
||||
kind,
|
||||
role,
|
||||
name,
|
||||
}
|
||||
this.tokensById.set(tokenId, tokenData)
|
||||
this.emitState()
|
||||
|
||||
console.log(`Created ${kind} token:`, { tokenId, role, name })
|
||||
|
||||
return { tokenId, secret }
|
||||
}
|
||||
|
||||
deleteToken(tokenId: string) {
|
||||
const tokenData = this.tokensById.get(tokenId)
|
||||
if (!tokenData) {
|
||||
return
|
||||
}
|
||||
this.tokensById.delete(tokenData.tokenId)
|
||||
this.emitState()
|
||||
}
|
||||
}
|
||||
486
packages/streamwall-control-server/src/index.ts
Normal file
486
packages/streamwall-control-server/src/index.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import fastifyCookie from '@fastify/cookie'
|
||||
import fastifyStatic from '@fastify/static'
|
||||
import fastifyWebsocket from '@fastify/websocket'
|
||||
import Fastify from 'fastify'
|
||||
import process from 'node:process'
|
||||
import WebSocket from 'ws'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import path from 'node:path'
|
||||
import {
|
||||
type AuthTokenInfo,
|
||||
type ControlCommandMessage,
|
||||
type ControlUpdateMessage,
|
||||
roleCan,
|
||||
stateDiff,
|
||||
type StreamwallRole,
|
||||
} from 'streamwall-shared'
|
||||
import { Auth, StateWrapper } from './auth.ts'
|
||||
import { loadStorage, type StorageDB } from './storage.ts'
|
||||
|
||||
export const SESSION_COOKIE_NAME = 's'
|
||||
|
||||
interface Client {
|
||||
ws: WebSocket
|
||||
lastStateSent: any
|
||||
identity: AuthTokenInfo
|
||||
}
|
||||
|
||||
interface StreamwallConnection {
|
||||
ws: WebSocket
|
||||
clientState: StateWrapper
|
||||
stateDoc: Y.Doc
|
||||
}
|
||||
|
||||
interface AppOptions {
|
||||
baseURL: string
|
||||
clientStaticPath: string
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
identity?: AuthTokenInfo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to immediately watch for and queue incoming websocket messages.
|
||||
* This is useful for async validation of the connection before handling messages,
|
||||
* because awaiting before adding a message event listener can drop messages.
|
||||
*/
|
||||
function queueWebSocketMessages(ws: WebSocket) {
|
||||
let queue: WebSocket.Data[] = []
|
||||
let messageHandler: ((rawData: WebSocket.Data) => void) | null = null
|
||||
|
||||
const processQueue = () => {
|
||||
if (messageHandler !== null) {
|
||||
let queuedData
|
||||
while ((queuedData = queue.shift())) {
|
||||
messageHandler(queuedData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setMessageHandler = (handler: typeof messageHandler) => {
|
||||
messageHandler = handler
|
||||
processQueue()
|
||||
}
|
||||
|
||||
ws.on('message', (rawData) => {
|
||||
queue.push(rawData)
|
||||
processQueue()
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
queue = []
|
||||
messageHandler = null
|
||||
})
|
||||
|
||||
return setMessageHandler
|
||||
}
|
||||
|
||||
async function initApp({ baseURL, clientStaticPath }: AppOptions) {
|
||||
const expectedOrigin = new URL(baseURL).origin
|
||||
const clients = new Map<string, Client>()
|
||||
const isSecure = baseURL.startsWith('https')
|
||||
|
||||
let currentStreamwallWs: WebSocket | null = null
|
||||
let currentStreamwallConn: StreamwallConnection | null = null
|
||||
|
||||
const db = await loadStorage()
|
||||
const auth = new Auth(db.data.auth)
|
||||
|
||||
const app = Fastify()
|
||||
|
||||
await app.register(fastifyCookie)
|
||||
await app.register(fastifyWebsocket, {
|
||||
errorHandler: (err) => {
|
||||
console.warn('Error handling socket request', err)
|
||||
},
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string }; Querystring: { token?: string } }>(
|
||||
'/invite/:id',
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const { token } = request.query
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
return reply.code(403).send()
|
||||
}
|
||||
|
||||
const tokenInfo = await auth.validateToken(id, token)
|
||||
if (!tokenInfo || tokenInfo.kind !== 'invite') {
|
||||
return reply.code(403).send()
|
||||
}
|
||||
|
||||
const sessionToken = await auth.createToken({
|
||||
kind: 'session',
|
||||
name: tokenInfo.name,
|
||||
role: tokenInfo.role,
|
||||
})
|
||||
|
||||
reply.setCookie(
|
||||
SESSION_COOKIE_NAME,
|
||||
`${sessionToken.tokenId}:${sessionToken.secret}`,
|
||||
{
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
maxAge: 1 * 365 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
)
|
||||
|
||||
await auth.deleteToken(tokenInfo.tokenId)
|
||||
return reply.redirect('/')
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Params: { id: string }; Querystring: { token?: string } }>(
|
||||
'/streamwall/:id/ws',
|
||||
{ websocket: true },
|
||||
async (ws, request) => {
|
||||
ws.binaryType = 'arraybuffer'
|
||||
const handleMessage = queueWebSocketMessages(ws)
|
||||
|
||||
const { id } = request.params
|
||||
const { token } = request.query
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
ws.send(JSON.stringify({ error: 'unauthorized' }))
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
const tokenInfo = await auth.validateToken(id, token)
|
||||
if (!tokenInfo || tokenInfo.kind !== 'streamwall') {
|
||||
ws.send(JSON.stringify({ error: 'unauthorized' }))
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (currentStreamwallWs != null) {
|
||||
ws.send(JSON.stringify({ error: 'streamwall already connected' }))
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
currentStreamwallWs = ws
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
ws.ping()
|
||||
}, 5 * 1000)
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('Streamwall disconnected')
|
||||
currentStreamwallWs = null
|
||||
currentStreamwallConn = null
|
||||
clearInterval(pingInterval)
|
||||
|
||||
for (const client of clients.values()) {
|
||||
client.ws.close()
|
||||
}
|
||||
})
|
||||
|
||||
let clientState: StateWrapper | null = null
|
||||
const stateDoc = new Y.Doc()
|
||||
|
||||
console.log('Streamwall connecting from', request.ip, tokenInfo)
|
||||
|
||||
handleMessage((rawData) => {
|
||||
if (rawData instanceof ArrayBuffer) {
|
||||
Y.applyUpdate(stateDoc, new Uint8Array(rawData))
|
||||
return
|
||||
}
|
||||
|
||||
let msg: ControlUpdateMessage
|
||||
|
||||
try {
|
||||
msg = JSON.parse(rawData.toString())
|
||||
} catch (err) {
|
||||
console.warn('Received unexpected ws data: ', rawData.length, 'bytes')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (msg.type === 'state') {
|
||||
if (clientState === null) {
|
||||
clientState = new StateWrapper(msg.state)
|
||||
currentStreamwallConn = {
|
||||
ws,
|
||||
clientState,
|
||||
stateDoc,
|
||||
}
|
||||
|
||||
console.log('Streamwall connected from', request.ip, tokenInfo)
|
||||
} else {
|
||||
clientState.update(msg.state)
|
||||
}
|
||||
|
||||
for (const client of clients.values()) {
|
||||
try {
|
||||
if (client.ws.readyState !== WebSocket.OPEN) {
|
||||
continue
|
||||
}
|
||||
const stateView = clientState.view(client.identity.role)
|
||||
const delta = stateDiff.diff(client.lastStateSent, stateView)
|
||||
if (!delta) {
|
||||
continue
|
||||
}
|
||||
client.ws.send(JSON.stringify({ type: 'state-delta', delta }))
|
||||
client.lastStateSent = stateView
|
||||
} catch (err) {
|
||||
console.error('failed to send client state delta', client)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to handle ws message:', rawData, err)
|
||||
}
|
||||
})
|
||||
|
||||
stateDoc.on('update', (update, origin) => {
|
||||
try {
|
||||
ws.send(update)
|
||||
} catch (err) {
|
||||
console.error('Failed to send Streamwall doc update')
|
||||
}
|
||||
for (const client of clients.values()) {
|
||||
if (client.identity.tokenId === origin) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
client.ws.send(update)
|
||||
} catch (err) {
|
||||
console.error('Failed to send client doc update:', client)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// Authenticated client routes
|
||||
app.register(async function (fastify) {
|
||||
fastify.addHook('preHandler', async (request) => {
|
||||
const sessionCookie = request.cookies[SESSION_COOKIE_NAME]
|
||||
if (sessionCookie) {
|
||||
const [tokenId, tokenSecret] = sessionCookie.split(':', 2)
|
||||
const tokenInfo = await auth.validateToken(tokenId, tokenSecret)
|
||||
if (tokenInfo && tokenInfo.kind === 'session') {
|
||||
request.identity = tokenInfo
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Serve frontend assets
|
||||
await fastify.register(fastifyStatic, {
|
||||
root: clientStaticPath,
|
||||
})
|
||||
|
||||
// Client WebSocket connection
|
||||
fastify.get('/client/ws', { websocket: true }, async (ws, request) => {
|
||||
ws.binaryType = 'arraybuffer'
|
||||
const handleMessage = queueWebSocketMessages(ws)
|
||||
|
||||
const { identity } = request
|
||||
|
||||
if (request.headers.origin !== expectedOrigin || !identity) {
|
||||
ws.send(JSON.stringify({ error: 'unauthorized' }))
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
const streamwallConn = currentStreamwallConn
|
||||
if (!streamwallConn) {
|
||||
ws.send(JSON.stringify({ error: 'streamwall disconnected' }))
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
const client: Client = {
|
||||
ws,
|
||||
lastStateSent: null,
|
||||
identity,
|
||||
}
|
||||
clients.set(identity.tokenId, client)
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
ws.ping()
|
||||
}, 20 * 1000)
|
||||
|
||||
ws.on('close', () => {
|
||||
clients.delete(identity.tokenId)
|
||||
clearInterval(pingInterval)
|
||||
|
||||
console.log('Client disconnected from', request.ip, client.identity)
|
||||
})
|
||||
|
||||
console.log('Client connected from', request.ip, client.identity)
|
||||
|
||||
handleMessage(async (rawData) => {
|
||||
let msg: ControlCommandMessage
|
||||
const respond = (responseData: any) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
...responseData,
|
||||
response: true,
|
||||
id: msg && msg.id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (!currentStreamwallConn) {
|
||||
respond({ error: 'streamwall disconnected' })
|
||||
return
|
||||
}
|
||||
|
||||
if (rawData instanceof ArrayBuffer) {
|
||||
if (!roleCan(identity.role, 'mutate-state-doc')) {
|
||||
console.warn(
|
||||
`Unauthorized attempt to edit state doc by "${identity.name}"`,
|
||||
)
|
||||
respond({ error: 'unauthorized' })
|
||||
return
|
||||
}
|
||||
Y.applyUpdate(
|
||||
streamwallConn.stateDoc,
|
||||
new Uint8Array(rawData),
|
||||
identity.tokenId,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
msg = JSON.parse(rawData.toString())
|
||||
} catch (err) {
|
||||
console.warn('Received unexpected ws data: ', rawData.length, 'bytes')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!roleCan(identity.role, msg.type)) {
|
||||
console.warn(
|
||||
`Unauthorized attempt to "${msg.type}" by "${identity.name}"`,
|
||||
)
|
||||
respond({ error: 'unauthorized' })
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === 'create-invite') {
|
||||
console.debug('Creating invite for role:', msg.role)
|
||||
const { secret } = await auth.createToken({
|
||||
kind: 'invite',
|
||||
role: msg.role as StreamwallRole,
|
||||
name: msg.name,
|
||||
})
|
||||
respond({ name: msg.name, secret })
|
||||
} else if (msg.type === 'delete-token') {
|
||||
console.debug('Deleting token:', msg.tokenId)
|
||||
auth.deleteToken(msg.tokenId)
|
||||
} else {
|
||||
streamwallConn.ws.send(
|
||||
JSON.stringify({ ...msg, clientId: identity.tokenId }),
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to handle ws message:', rawData, err)
|
||||
}
|
||||
})
|
||||
|
||||
const state = streamwallConn.clientState.view(identity.role)
|
||||
ws.send(JSON.stringify({ type: 'state', state }))
|
||||
ws.send(Y.encodeStateAsUpdate(streamwallConn.stateDoc))
|
||||
client.lastStateSent = state
|
||||
})
|
||||
})
|
||||
|
||||
auth.on('state', (state) => {
|
||||
db.update((data) => {
|
||||
data.auth = auth.getStoredData()
|
||||
})
|
||||
|
||||
const tokenIds = new Set(state.sessions.map((t) => t.tokenId))
|
||||
for (const client of clients.values()) {
|
||||
if (!tokenIds.has(client.identity.tokenId)) {
|
||||
client.ws.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { app, db, auth }
|
||||
}
|
||||
|
||||
async function initialInviteCodes({
|
||||
db,
|
||||
auth,
|
||||
baseURL,
|
||||
}: {
|
||||
db: StorageDB
|
||||
auth: Auth
|
||||
baseURL: string
|
||||
}) {
|
||||
// Create a token for streamwall uplink (if not existing):
|
||||
let streamwallToken = db.data.streamwallToken
|
||||
if (!streamwallToken) {
|
||||
streamwallToken = await auth.createToken({
|
||||
kind: 'streamwall',
|
||||
role: 'admin',
|
||||
name: 'Streamwall',
|
||||
})
|
||||
db.update((data) => {
|
||||
data.streamwallToken = streamwallToken
|
||||
})
|
||||
}
|
||||
|
||||
// Invalidate any existing admin invites and create a new one:
|
||||
for (const adminToken of auth
|
||||
.getState()
|
||||
.invites.filter(({ role }) => role === 'admin')) {
|
||||
auth.deleteToken(adminToken.tokenId)
|
||||
}
|
||||
const adminToken = await auth.createToken({
|
||||
kind: 'invite',
|
||||
role: 'admin',
|
||||
name: 'Server admin',
|
||||
})
|
||||
|
||||
console.log(
|
||||
'🔌 Streamwall endpoint:',
|
||||
`${baseURL.replace(/^http/, 'ws')}/streamwall/${streamwallToken.tokenId}/ws?token=${streamwallToken.secret}`,
|
||||
)
|
||||
console.log(
|
||||
'🔑 Admin invite:',
|
||||
`${baseURL}/invite/${adminToken.tokenId}?token=${adminToken.secret}`,
|
||||
)
|
||||
}
|
||||
|
||||
export default async function runServer({
|
||||
baseURL,
|
||||
clientStaticPath,
|
||||
}: AppOptions) {
|
||||
const url = new URL(baseURL)
|
||||
const { hostname } = url
|
||||
const port = url.port !== '' ? Number(url.port) : 80
|
||||
|
||||
console.debug('Initializing web server:', { hostname, port })
|
||||
const { app, db, auth } = await initApp({
|
||||
baseURL,
|
||||
clientStaticPath,
|
||||
})
|
||||
|
||||
await initialInviteCodes({ db, auth, baseURL })
|
||||
|
||||
await app.listen({ port, host: hostname })
|
||||
|
||||
return { server: app.server }
|
||||
}
|
||||
|
||||
runServer({
|
||||
baseURL: process.env.STREAMWALL_CONTROL_URL ?? 'http://localhost:3000',
|
||||
clientStaticPath:
|
||||
process.env.STREAMWALL_CONTROL_STATIC ??
|
||||
path.join(import.meta.dirname, '../../streamwall-control-client/dist'),
|
||||
})
|
||||
31
packages/streamwall-control-server/src/storage.ts
Normal file
31
packages/streamwall-control-server/src/storage.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Low } from 'lowdb'
|
||||
import { JSONFilePreset } from 'lowdb/node'
|
||||
import process from 'node:process'
|
||||
import type { AuthToken } from './auth.ts'
|
||||
|
||||
export interface StoredData {
|
||||
auth: {
|
||||
salt: string | null
|
||||
tokens: AuthToken[]
|
||||
}
|
||||
streamwallToken: null | {
|
||||
tokenId: string
|
||||
secret: string
|
||||
}
|
||||
}
|
||||
|
||||
const defaultData: StoredData = {
|
||||
auth: {
|
||||
salt: null,
|
||||
tokens: [],
|
||||
},
|
||||
streamwallToken: null,
|
||||
}
|
||||
|
||||
export type StorageDB = Low<StoredData>
|
||||
|
||||
export async function loadStorage() {
|
||||
const dbPath = process.env.DB_PATH || 'storage.json'
|
||||
const db = await JSONFilePreset<StoredData>(dbPath, defaultData)
|
||||
return db
|
||||
}
|
||||
8
packages/streamwall-control-server/tsconfig.json
Normal file
8
packages/streamwall-control-server/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": [
|
||||
"@tsconfig/recommended/tsconfig",
|
||||
"@tsconfig/node22/tsconfig",
|
||||
"@tsconfig/node-ts/tsconfig"
|
||||
],
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user