first commit
This commit is contained in:
commit
69b647145f
58 files changed
+15796
No files matched your search
@@ -0,0 +1,25 @@
|
||||
import { ADMIN_COOKIE, signAdminToken } from '../../utils/jwt'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{ username?: string; password?: string }>(event)
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
if (!body?.username || !body?.password) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Username and password required' })
|
||||
}
|
||||
|
||||
if (body.username !== config.adminUsername || body.password !== config.adminPassword) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||
}
|
||||
|
||||
const token = await signAdminToken(body.username)
|
||||
setCookie(event, ADMIN_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
})
|
||||
|
||||
return { ok: true, username: body.username }
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ADMIN_COOKIE } from '../../utils/jwt'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
deleteCookie(event, ADMIN_COOKIE, { path: '/' })
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ADMIN_COOKIE, verifyAdminToken } from '../../utils/jwt'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token =
|
||||
getCookie(event, ADMIN_COOKIE) ||
|
||||
getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await verifyAdminToken(token)
|
||||
return { ok: true, username: payload.username }
|
||||
} catch {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { prisma } from '../../utils/db'
|
||||
import { forceCloseRoom } from '../../utils/runtimeRooms'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const roomId = getRouterParam(event, 'roomId')
|
||||
if (!roomId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Room id required' })
|
||||
}
|
||||
|
||||
const existing = await prisma.room.findUnique({ where: { id: roomId } })
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Room not found' })
|
||||
}
|
||||
|
||||
forceCloseRoom(roomId, 'Room deleted by admin')
|
||||
await prisma.room.delete({ where: { id: roomId } })
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { prisma } from '../../utils/db'
|
||||
import { getMemberCount, getStreamCount } from '../../utils/runtimeRooms'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const roomId = getRouterParam(event, 'roomId')
|
||||
if (!roomId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Room id required' })
|
||||
}
|
||||
|
||||
const room = await prisma.room.findUnique({ where: { id: roomId } })
|
||||
if (!room) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Room not found' })
|
||||
}
|
||||
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
memberCount: getMemberCount(room.id),
|
||||
streamCount: getStreamCount(room.id),
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { prisma } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const roomId = getRouterParam(event, 'roomId')
|
||||
if (!roomId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Room id required' })
|
||||
}
|
||||
|
||||
const body = await readBody<{ name?: string; password?: string }>(event)
|
||||
const existing = await prisma.room.findUnique({ where: { id: roomId } })
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Room not found' })
|
||||
}
|
||||
|
||||
const data: { name?: string; passwordHash?: string; pwdVersion?: number } = {}
|
||||
if (body?.name?.trim()) {
|
||||
data.name = body.name.trim()
|
||||
}
|
||||
if (body?.password) {
|
||||
if (body.password.length < 4) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Password must be at least 4 characters' })
|
||||
}
|
||||
data.passwordHash = await bcrypt.hash(body.password, 10)
|
||||
data.pwdVersion = existing.pwdVersion + 1
|
||||
}
|
||||
|
||||
const room = await prisma.room.update({ where: { id: roomId }, data })
|
||||
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
pwdVersion: room.pwdVersion,
|
||||
createdAt: room.createdAt.toISOString(),
|
||||
createdBy: room.createdBy,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { prisma } from '../../../utils/db'
|
||||
import { signRoomAccessToken, verifyRoomAccessToken } from '../../../utils/roomAccess'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const roomId = getRouterParam(event, 'roomId')
|
||||
if (!roomId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Room id required' })
|
||||
}
|
||||
|
||||
const body = await readBody<{
|
||||
password?: string
|
||||
clientId?: string
|
||||
accessToken?: string
|
||||
}>(event)
|
||||
|
||||
if (!body?.clientId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'clientId required' })
|
||||
}
|
||||
|
||||
const room = await prisma.room.findUnique({ where: { id: roomId } })
|
||||
if (!room) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Room not found' })
|
||||
}
|
||||
|
||||
const expected = {
|
||||
roomId: room.id,
|
||||
clientId: body.clientId,
|
||||
pwdVersion: room.pwdVersion,
|
||||
}
|
||||
|
||||
if (body.accessToken) {
|
||||
try {
|
||||
await verifyRoomAccessToken(body.accessToken, expected)
|
||||
const accessToken = await signRoomAccessToken(expected)
|
||||
return {
|
||||
accessToken,
|
||||
room: { id: room.id, name: room.name },
|
||||
}
|
||||
} catch {
|
||||
// fall through to password
|
||||
}
|
||||
}
|
||||
|
||||
if (!body.password) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Password required' })
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(body.password, room.passwordHash)
|
||||
if (!valid) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid password' })
|
||||
}
|
||||
|
||||
const accessToken = await signRoomAccessToken(expected)
|
||||
return {
|
||||
accessToken,
|
||||
room: { id: room.id, name: room.name },
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { prisma } from '../../utils/db'
|
||||
import { getMemberCount, getStreamCount } from '../../utils/runtimeRooms'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const rooms = await prisma.room.findMany({ orderBy: { createdAt: 'desc' } })
|
||||
return rooms.map((room) => ({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
pwdVersion: room.pwdVersion,
|
||||
createdAt: room.createdAt.toISOString(),
|
||||
createdBy: room.createdBy,
|
||||
memberCount: getMemberCount(room.id),
|
||||
streamCount: getStreamCount(room.id),
|
||||
}))
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { prisma } from '../../utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{ name?: string; password?: string }>(event)
|
||||
|
||||
if (!body?.name?.trim() || !body?.password) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Name and password required' })
|
||||
}
|
||||
|
||||
if (body.password.length < 4) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Password must be at least 4 characters' })
|
||||
}
|
||||
|
||||
const id = nanoid(10)
|
||||
const passwordHash = await bcrypt.hash(body.password, 10)
|
||||
const admin = event.context.admin as { username?: string } | undefined
|
||||
|
||||
const room = await prisma.room.create({
|
||||
data: {
|
||||
id,
|
||||
name: body.name.trim(),
|
||||
passwordHash,
|
||||
pwdVersion: 1,
|
||||
createdBy: admin?.username || 'admin',
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
pwdVersion: room.pwdVersion,
|
||||
createdAt: room.createdAt.toISOString(),
|
||||
createdBy: room.createdBy,
|
||||
url: `/room/${room.id}`,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ADMIN_COOKIE, verifyAdminToken } from '../utils/jwt'
|
||||
|
||||
/**
|
||||
* Protects admin room management endpoints.
|
||||
* Public: GET /api/rooms/:roomId (meta), POST /api/rooms/:roomId/access
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const url = getRequestURL(event)
|
||||
const path = url.pathname
|
||||
const method = event.method
|
||||
|
||||
if (!path.startsWith('/api/rooms')) return
|
||||
|
||||
const isAccess = /\/api\/rooms\/[^/]+\/access$/.test(path) && method === 'POST'
|
||||
const isPublicMeta = /\/api\/rooms\/[^/]+$/.test(path) && method === 'GET' && path !== '/api/rooms'
|
||||
if (isAccess || isPublicMeta) return
|
||||
|
||||
const needsAuth =
|
||||
path === '/api/rooms' ||
|
||||
(/\/api\/rooms\/[^/]+$/.test(path) && (method === 'PATCH' || method === 'DELETE'))
|
||||
|
||||
if (!needsAuth) return
|
||||
|
||||
const token =
|
||||
getCookie(event, ADMIN_COOKIE) ||
|
||||
getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
||||
}
|
||||
|
||||
try {
|
||||
event.context.admin = await verifyAdminToken(token)
|
||||
} catch {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { NitroApp } from 'nitropack'
|
||||
import { Server as Engine } from 'engine.io'
|
||||
import { Server } from 'socket.io'
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { prisma } from '../utils/db'
|
||||
import { verifyRoomAccessToken } from '../utils/roomAccess'
|
||||
import { getMaxConcurrentStreams, getMaxRoomMembers } from '../utils/rooms'
|
||||
import {
|
||||
broadcastRoomState,
|
||||
buildRoomState,
|
||||
getOrCreateRuntimeRoom,
|
||||
removeViewerWatches,
|
||||
setIO,
|
||||
stopStreamsByClient,
|
||||
type RoomSession,
|
||||
} from '../utils/runtimeRooms'
|
||||
|
||||
export default defineNitroPlugin((nitroApp: NitroApp) => {
|
||||
const engine = new Engine()
|
||||
const io = new Server({
|
||||
cors: { origin: true, credentials: true },
|
||||
path: '/socket.io/',
|
||||
})
|
||||
io.bind(engine)
|
||||
setIO(io)
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
let currentRoomId: string | null = null
|
||||
let currentClientId: string | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (!currentRoomId || !currentClientId) return
|
||||
const room = getOrCreateRuntimeRoom(currentRoomId)
|
||||
const session = room.sessions.get(currentClientId)
|
||||
if (session && session.socketId === socket.id) {
|
||||
stopStreamsByClient(room, currentClientId)
|
||||
removeViewerWatches(room, currentClientId)
|
||||
room.sessions.delete(currentClientId)
|
||||
io.to(currentRoomId).emit('peer:left', { clientId: currentClientId })
|
||||
broadcastRoomState(currentRoomId)
|
||||
if (room.sessions.size === 0) {
|
||||
// keep empty runtime room until deleted from DB; optional cleanup
|
||||
}
|
||||
}
|
||||
currentRoomId = null
|
||||
currentClientId = null
|
||||
}
|
||||
|
||||
socket.on('room:join', async (payload, ack) => {
|
||||
try {
|
||||
const { roomId, clientId, nickname, accessToken } = payload || {}
|
||||
if (!roomId || !clientId || !nickname?.trim() || !accessToken) {
|
||||
ack?.({ ok: false, error: 'Invalid join payload' })
|
||||
return
|
||||
}
|
||||
|
||||
const dbRoom = await prisma.room.findUnique({ where: { id: roomId } })
|
||||
if (!dbRoom) {
|
||||
ack?.({ ok: false, error: 'Room not found' })
|
||||
return
|
||||
}
|
||||
|
||||
await verifyRoomAccessToken(accessToken, {
|
||||
roomId,
|
||||
clientId,
|
||||
pwdVersion: dbRoom.pwdVersion,
|
||||
})
|
||||
|
||||
if (currentRoomId && currentClientId) {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
const room = getOrCreateRuntimeRoom(roomId)
|
||||
const existing = room.sessions.get(clientId)
|
||||
if (existing && existing.socketId !== socket.id) {
|
||||
const oldSocket = io.sockets.sockets.get(existing.socketId)
|
||||
oldSocket?.emit('room:force-close', { reason: 'Replaced by new session' })
|
||||
oldSocket?.disconnect(true)
|
||||
stopStreamsByClient(room, clientId)
|
||||
removeViewerWatches(room, clientId)
|
||||
room.sessions.delete(clientId)
|
||||
}
|
||||
|
||||
const maxMembers = getMaxRoomMembers()
|
||||
if (!room.sessions.has(clientId) && room.sessions.size >= maxMembers) {
|
||||
ack?.({ ok: false, error: `Room is full (max ${maxMembers})` })
|
||||
return
|
||||
}
|
||||
|
||||
const session: RoomSession = {
|
||||
clientId,
|
||||
socketId: socket.id,
|
||||
nickname: nickname.trim().slice(0, 32),
|
||||
joinedAt: Date.now(),
|
||||
streamId: null,
|
||||
}
|
||||
room.sessions.set(clientId, session)
|
||||
currentRoomId = roomId
|
||||
currentClientId = clientId
|
||||
socket.join(roomId)
|
||||
|
||||
ack?.({ ok: true })
|
||||
socket.emit('room:state', buildRoomState(room))
|
||||
broadcastRoomState(roomId)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Join failed'
|
||||
ack?.({ ok: false, error: message })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('room:leave', () => {
|
||||
socket.leave(currentRoomId || '')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
socket.on('stream:start', (payload, ack) => {
|
||||
if (!currentRoomId || !currentClientId) {
|
||||
ack?.({ ok: false, error: 'Not in a room' })
|
||||
return
|
||||
}
|
||||
const room = getOrCreateRuntimeRoom(currentRoomId)
|
||||
const session = room.sessions.get(currentClientId)
|
||||
if (!session) {
|
||||
ack?.({ ok: false, error: 'Session missing' })
|
||||
return
|
||||
}
|
||||
if (session.streamId) {
|
||||
ack?.({ ok: false, error: 'Already streaming' })
|
||||
return
|
||||
}
|
||||
const maxStreams = getMaxConcurrentStreams()
|
||||
if (room.streams.size >= maxStreams) {
|
||||
ack?.({ ok: false, error: `Max concurrent streams is ${maxStreams}` })
|
||||
return
|
||||
}
|
||||
const streamId = payload?.streamId || `${currentClientId}-${Date.now()}`
|
||||
session.streamId = streamId
|
||||
room.streams.set(streamId, {
|
||||
streamId,
|
||||
broadcasterId: currentClientId,
|
||||
broadcasterNickname: session.nickname,
|
||||
startedAt: Date.now(),
|
||||
})
|
||||
ack?.({ ok: true })
|
||||
broadcastRoomState(currentRoomId)
|
||||
})
|
||||
|
||||
socket.on('stream:stop', (payload) => {
|
||||
if (!currentRoomId || !currentClientId) return
|
||||
const room = getOrCreateRuntimeRoom(currentRoomId)
|
||||
const session = room.sessions.get(currentClientId)
|
||||
const streamId = payload?.streamId || session?.streamId
|
||||
if (!streamId) return
|
||||
room.streams.delete(streamId)
|
||||
if (session?.streamId === streamId) session.streamId = null
|
||||
for (const set of room.watchMap.values()) {
|
||||
set.delete(streamId)
|
||||
}
|
||||
broadcastRoomState(currentRoomId)
|
||||
})
|
||||
|
||||
socket.on('watch:start', (payload) => {
|
||||
if (!currentRoomId || !currentClientId || !payload?.streamId) return
|
||||
const room = getOrCreateRuntimeRoom(currentRoomId)
|
||||
if (!room.streams.has(payload.streamId)) return
|
||||
let set = room.watchMap.get(currentClientId)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
room.watchMap.set(currentClientId, set)
|
||||
}
|
||||
set.add(payload.streamId)
|
||||
broadcastRoomState(currentRoomId)
|
||||
})
|
||||
|
||||
socket.on('watch:stop', (payload) => {
|
||||
if (!currentRoomId || !currentClientId || !payload?.streamId) return
|
||||
const room = getOrCreateRuntimeRoom(currentRoomId)
|
||||
room.watchMap.get(currentClientId)?.delete(payload.streamId)
|
||||
broadcastRoomState(currentRoomId)
|
||||
})
|
||||
|
||||
const relay = (eventName: 'webrtc:offer' | 'webrtc:answer' | 'webrtc:ice') => {
|
||||
socket.on(eventName, (payload) => {
|
||||
if (!currentRoomId || !currentClientId || !payload?.to) return
|
||||
const room = getOrCreateRuntimeRoom(currentRoomId)
|
||||
const target = room.sessions.get(payload.to)
|
||||
if (!target) return
|
||||
io.to(target.socketId).emit(eventName, {
|
||||
...payload,
|
||||
from: currentClientId,
|
||||
})
|
||||
})
|
||||
}
|
||||
relay('webrtc:offer')
|
||||
relay('webrtc:answer')
|
||||
relay('webrtc:ice')
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
cleanup()
|
||||
})
|
||||
})
|
||||
|
||||
nitroApp.router.use(
|
||||
'/socket.io/',
|
||||
defineEventHandler({
|
||||
handler(event) {
|
||||
engine.handleRequest(event.node.req, event.node.res)
|
||||
event._handled = true
|
||||
},
|
||||
websocket: {
|
||||
open(peer) {
|
||||
// @ts-expect-error engine.io peer bridge
|
||||
engine.prepare(peer._internal.nodeReq)
|
||||
// @ts-expect-error engine.io peer bridge
|
||||
engine.onWebSocket(peer._internal.nodeReq, peer._internal.nodeReq.socket, peer.websocket)
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { SignJWT, jwtVerify } from 'jose'
|
||||
|
||||
function getSecret() {
|
||||
const config = useRuntimeConfig()
|
||||
return new TextEncoder().encode(config.jwtSecret)
|
||||
}
|
||||
|
||||
export async function signAdminToken(username: string) {
|
||||
return new SignJWT({ role: 'admin', username })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('7d')
|
||||
.sign(getSecret())
|
||||
}
|
||||
|
||||
export async function verifyAdminToken(token: string) {
|
||||
const { payload } = await jwtVerify(token, getSecret())
|
||||
if (payload.role !== 'admin') {
|
||||
throw createError({ statusCode: 401, statusMessage: 'Invalid admin token' })
|
||||
}
|
||||
return payload as { role: string; username: string }
|
||||
}
|
||||
|
||||
export const ADMIN_COOKIE = 'sejong_admin_token'
|
||||
@@ -0,0 +1,42 @@
|
||||
import { SignJWT, jwtVerify } from 'jose'
|
||||
|
||||
export interface RoomAccessClaims {
|
||||
roomId: string
|
||||
clientId: string
|
||||
pwdVersion: number
|
||||
}
|
||||
|
||||
function getSecret() {
|
||||
const config = useRuntimeConfig()
|
||||
return new TextEncoder().encode(config.jwtSecret)
|
||||
}
|
||||
|
||||
export async function signRoomAccessToken(claims: RoomAccessClaims) {
|
||||
const config = useRuntimeConfig()
|
||||
const days = config.roomAccessTtlDays || 7
|
||||
return new SignJWT({ ...claims, typ: 'room_access' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${days}d`)
|
||||
.sign(getSecret())
|
||||
}
|
||||
|
||||
export async function verifyRoomAccessToken(
|
||||
token: string,
|
||||
expected: { roomId: string; clientId: string; pwdVersion: number },
|
||||
) {
|
||||
const { payload } = await jwtVerify(token, getSecret())
|
||||
if (payload.typ !== 'room_access') {
|
||||
throw new Error('Invalid token type')
|
||||
}
|
||||
if (payload.roomId !== expected.roomId) {
|
||||
throw new Error('Room mismatch')
|
||||
}
|
||||
if (payload.clientId !== expected.clientId) {
|
||||
throw new Error('Client mismatch')
|
||||
}
|
||||
if (Number(payload.pwdVersion) !== expected.pwdVersion) {
|
||||
throw new Error('Password changed')
|
||||
}
|
||||
return payload as unknown as RoomAccessClaims
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function getMaxRoomMembers() {
|
||||
return useRuntimeConfig().maxRoomMembers || 8
|
||||
}
|
||||
|
||||
export function getMaxConcurrentStreams() {
|
||||
return useRuntimeConfig().maxConcurrentStreams || 4
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { ActiveStream, Participant, RoomState, WatchMap } from '~/types/room'
|
||||
import type { Server } from 'socket.io'
|
||||
|
||||
export interface RoomSession {
|
||||
clientId: string
|
||||
socketId: string
|
||||
nickname: string
|
||||
joinedAt: number
|
||||
streamId: string | null
|
||||
}
|
||||
|
||||
export interface RuntimeRoom {
|
||||
roomId: string
|
||||
sessions: Map<string, RoomSession>
|
||||
streams: Map<string, ActiveStream>
|
||||
/** viewerClientId -> Set<streamId> */
|
||||
watchMap: Map<string, Set<string>>
|
||||
}
|
||||
|
||||
const rooms = new Map<string, RuntimeRoom>()
|
||||
let ioRef: Server | null = null
|
||||
|
||||
export function setIO(io: Server) {
|
||||
ioRef = io
|
||||
}
|
||||
|
||||
export function getIO() {
|
||||
return ioRef
|
||||
}
|
||||
|
||||
export function getOrCreateRuntimeRoom(roomId: string): RuntimeRoom {
|
||||
let room = rooms.get(roomId)
|
||||
if (!room) {
|
||||
room = {
|
||||
roomId,
|
||||
sessions: new Map(),
|
||||
streams: new Map(),
|
||||
watchMap: new Map(),
|
||||
}
|
||||
rooms.set(roomId, room)
|
||||
}
|
||||
return room
|
||||
}
|
||||
|
||||
export function getRuntimeRoom(roomId: string) {
|
||||
return rooms.get(roomId)
|
||||
}
|
||||
|
||||
export function deleteRuntimeRoom(roomId: string) {
|
||||
rooms.delete(roomId)
|
||||
}
|
||||
|
||||
export function serializeWatchMap(room: RuntimeRoom): WatchMap {
|
||||
const out: WatchMap = {}
|
||||
for (const [viewerId, streamIds] of room.watchMap) {
|
||||
out[viewerId] = [...streamIds]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function buildRoomState(room: RuntimeRoom): RoomState {
|
||||
const participants: Participant[] = [...room.sessions.values()].map((s) => ({
|
||||
clientId: s.clientId,
|
||||
nickname: s.nickname,
|
||||
joinedAt: s.joinedAt,
|
||||
isStreaming: Boolean(s.streamId),
|
||||
}))
|
||||
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
participants,
|
||||
activeStreams: [...room.streams.values()],
|
||||
watchMap: serializeWatchMap(room),
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastRoomState(roomId: string) {
|
||||
const room = rooms.get(roomId)
|
||||
const io = ioRef
|
||||
if (!room || !io) return
|
||||
io.to(roomId).emit('room:state', buildRoomState(room))
|
||||
}
|
||||
|
||||
export function forceCloseRoom(roomId: string, reason = 'Room deleted') {
|
||||
const io = ioRef
|
||||
if (io) {
|
||||
io.to(roomId).emit('room:force-close', { reason })
|
||||
io.in(roomId).disconnectSockets(true)
|
||||
}
|
||||
deleteRuntimeRoom(roomId)
|
||||
}
|
||||
|
||||
export function removeViewerWatches(room: RuntimeRoom, clientId: string) {
|
||||
room.watchMap.delete(clientId)
|
||||
for (const [, set] of room.watchMap) {
|
||||
// no-op for stream ownership
|
||||
}
|
||||
}
|
||||
|
||||
export function stopStreamsByClient(room: RuntimeRoom, clientId: string) {
|
||||
const session = room.sessions.get(clientId)
|
||||
if (session?.streamId) {
|
||||
room.streams.delete(session.streamId)
|
||||
for (const [, set] of room.watchMap) {
|
||||
set.delete(session.streamId)
|
||||
}
|
||||
session.streamId = null
|
||||
}
|
||||
}
|
||||
|
||||
export function getMemberCount(roomId: string) {
|
||||
return rooms.get(roomId)?.sessions.size ?? 0
|
||||
}
|
||||
|
||||
export function getStreamCount(roomId: string) {
|
||||
return rooms.get(roomId)?.streams.size ?? 0
|
||||
}
|
||||
Reference in new issue
Block a user