feat(backend): add SIMPLE_STREAMING WebRTC control-path streaming
This commit is contained in:
@@ -4,7 +4,9 @@ import { Server as SocketIOServer } from 'socket.io';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { db } from '../db/client';
|
||||
import { deviceCommands, devices } from '../db/schema';
|
||||
import { simpleStreamingEnabled } from '../media/config';
|
||||
import { deviceCommands, devices, streamSessions } from '../db/schema';
|
||||
import { canRelayWebrtcSignal } from '../streaming/simple';
|
||||
import { hasRequiredTables } from '../utils/db-schema';
|
||||
import { verifyDeviceToken } from '../utils/device-token';
|
||||
|
||||
@@ -26,13 +28,6 @@ const webrtcSignalSchema = z.object({
|
||||
data: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
});
|
||||
|
||||
const streamFrameSchema = z.object({
|
||||
toDeviceId: z.string().uuid(),
|
||||
streamSessionId: z.string().uuid(),
|
||||
frame: z.string().min(32),
|
||||
capturedAt: z.string().optional(),
|
||||
});
|
||||
|
||||
const roomForDevice = (deviceId: string): string => `device:${deviceId}`;
|
||||
|
||||
let io: SocketIOServer | null = null;
|
||||
@@ -112,6 +107,18 @@ export const dispatchCommandById = async (commandId: string): Promise<void> => {
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (simpleStreamingEnabled && command.commandType === 'start_stream') {
|
||||
await db
|
||||
.update(deviceCommands)
|
||||
.set({
|
||||
status: 'failed',
|
||||
updatedAt: now,
|
||||
error: 'start_stream command delivery disabled by SIMPLE_STREAMING',
|
||||
})
|
||||
.where(eq(deviceCommands.id, command.id));
|
||||
return;
|
||||
}
|
||||
|
||||
const delivered = emitCommand({
|
||||
id: command.id,
|
||||
sourceDeviceId: command.sourceDeviceId,
|
||||
@@ -144,6 +151,19 @@ const retryPendingCommands = async () => {
|
||||
|
||||
for (const command of pending) {
|
||||
const now = new Date();
|
||||
|
||||
if (simpleStreamingEnabled && command.commandType === 'start_stream') {
|
||||
await db
|
||||
.update(deviceCommands)
|
||||
.set({
|
||||
status: 'failed',
|
||||
updatedAt: now,
|
||||
error: 'start_stream retries disabled by SIMPLE_STREAMING',
|
||||
})
|
||||
.where(eq(deviceCommands.id, command.id));
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextRetryCount = command.retryCount + 1;
|
||||
|
||||
if (nextRetryCount > MAX_RETRIES) {
|
||||
@@ -240,7 +260,6 @@ export const setupRealtimeGateway = (server: HttpServer): SocketIOServer => {
|
||||
io.on('connection', async (socket) => {
|
||||
const auth = socket.data.deviceAuth as { userId: string; deviceId: string; role: 'camera' | 'client' };
|
||||
const deviceRoom = roomForDevice(auth.deviceId);
|
||||
const verifiedRelayTargets = new Set<string>();
|
||||
|
||||
socket.join(deviceRoom);
|
||||
await markDevicePresence(auth.deviceId, 'online');
|
||||
@@ -312,15 +331,27 @@ export const setupRealtimeGateway = (server: HttpServer): SocketIOServer => {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetDevice = await db.query.devices.findFirst({
|
||||
where: and(eq(devices.id, parsed.data.toDeviceId), eq(devices.userId, auth.userId)),
|
||||
const session = await db.query.streamSessions.findFirst({
|
||||
where: and(eq(streamSessions.id, parsed.data.streamSessionId), eq(streamSessions.ownerUserId, auth.userId)),
|
||||
});
|
||||
|
||||
if (!targetDevice) {
|
||||
socket.emit('error:webrtc_signal', { message: 'Target device not found for this account' });
|
||||
if (!session) {
|
||||
socket.emit('error:webrtc_signal', { message: 'Stream session not found for this account' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canRelayWebrtcSignal(session, auth.deviceId, parsed.data.toDeviceId)) {
|
||||
socket.emit('error:webrtc_signal', { message: 'Signal target is not a participant in this stream session' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.info('[stream.signal]', {
|
||||
streamSessionId: parsed.data.streamSessionId,
|
||||
fromDeviceId: auth.deviceId,
|
||||
toDeviceId: parsed.data.toDeviceId,
|
||||
signalType: parsed.data.signalType,
|
||||
});
|
||||
|
||||
io?.to(roomForDevice(parsed.data.toDeviceId)).emit('webrtc:signal', {
|
||||
fromDeviceId: auth.deviceId,
|
||||
streamSessionId: parsed.data.streamSessionId,
|
||||
@@ -329,38 +360,6 @@ export const setupRealtimeGateway = (server: HttpServer): SocketIOServer => {
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('stream:frame', async (input) => {
|
||||
const parsed = streamFrameSchema.safeParse(input);
|
||||
|
||||
if (!parsed.success) {
|
||||
socket.emit('error:stream_frame', {
|
||||
message: 'Invalid stream frame payload',
|
||||
errors: parsed.error.flatten(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!verifiedRelayTargets.has(parsed.data.toDeviceId)) {
|
||||
const targetDevice = await db.query.devices.findFirst({
|
||||
where: and(eq(devices.id, parsed.data.toDeviceId), eq(devices.userId, auth.userId)),
|
||||
});
|
||||
|
||||
if (!targetDevice) {
|
||||
socket.emit('error:stream_frame', { message: 'Target device not found for this account' });
|
||||
return;
|
||||
}
|
||||
|
||||
verifiedRelayTargets.add(parsed.data.toDeviceId);
|
||||
}
|
||||
|
||||
io?.to(roomForDevice(parsed.data.toDeviceId)).emit('stream:frame', {
|
||||
fromDeviceId: auth.deviceId,
|
||||
streamSessionId: parsed.data.streamSessionId,
|
||||
frame: parsed.data.frame,
|
||||
capturedAt: parsed.data.capturedAt ?? new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('disconnect', async () => {
|
||||
// Small delay allows fast reconnects to reuse presence without flapping.
|
||||
setTimeout(async () => {
|
||||
|
||||
Reference in New Issue
Block a user