feat(remote-camera): add Redis for cross-instance session sharing

Production blue/green deployment caused remote camera to fail because
desktop and phone could hit different instances with separate in-memory
session storage and Socket.IO rooms.

Changes:
- Add Redis service to docker-compose (production only)
- Create Redis client utility with optional connection
- Update session manager to use Redis when REDIS_URL is set
- Add Socket.IO Redis adapter for cross-instance room broadcasts
- Convert session manager functions to async
- Update tests for async functions

In development (no REDIS_URL), falls back to in-memory storage.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-15 11:18:22 -06:00
parent 8e4338bbbe
commit 0346455b3e
10 changed files with 525 additions and 179 deletions

View File

@ -51,6 +51,7 @@
"@react-spring/web": "^10.0.3",
"@react-three/drei": "^9.117.0",
"@react-three/fiber": "^8.17.0",
"@socket.io/redis-adapter": "^8.3.0",
"@soroban/abacus-react": "workspace:*",
"@soroban/core": "workspace:*",
"@soroban/llm-client": "workspace:*",
@ -81,6 +82,7 @@
"fluent-ffmpeg": "^2.1.3",
"framer-motion": "^12.23.26",
"gray-matter": "^4.0.3",
"ioredis": "^5.9.2",
"jose": "^6.1.0",
"js-aruco2": "^2.0.0",
"js-yaml": "^4.1.0",

View File

@ -10,11 +10,11 @@ import {
*/
export async function POST() {
try {
const session = createRemoteCameraSession()
const session = await createRemoteCameraSession()
return NextResponse.json({
sessionId: session.id,
expiresAt: session.expiresAt.toISOString(),
expiresAt: session.expiresAt,
})
} catch (error) {
console.error('Failed to create remote camera session:', error)
@ -35,7 +35,7 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Session ID required' }, { status: 400 })
}
const session = getRemoteCameraSession(sessionId)
const session = await getRemoteCameraSession(sessionId)
if (!session) {
return NextResponse.json({ error: 'Session not found or expired' }, { status: 404 })
@ -44,7 +44,7 @@ export async function GET(request: Request) {
return NextResponse.json({
sessionId: session.id,
phoneConnected: session.phoneConnected,
expiresAt: session.expiresAt.toISOString(),
expiresAt: session.expiresAt,
})
} catch (error) {
console.error('Failed to get remote camera session:', error)

103
apps/web/src/lib/redis.ts Normal file
View File

@ -0,0 +1,103 @@
/**
* Redis client utility
*
* Provides optional Redis connectivity - uses Redis when REDIS_URL is set,
* otherwise returns null so callers can fall back to in-memory storage.
*
* This allows the app to work without Redis in development while using
* Redis in production for cross-instance state sharing.
*/
import Redis from 'ioredis'
// Singleton Redis client
let redisClient: Redis | null = null
let redisInitialized = false
/**
* Get the Redis client, or null if Redis is not configured
*
* Returns the same instance on subsequent calls (singleton pattern).
* Returns null if REDIS_URL is not set in environment.
*/
export function getRedisClient(): Redis | null {
if (redisInitialized) {
return redisClient
}
redisInitialized = true
const redisUrl = process.env.REDIS_URL
if (!redisUrl) {
console.log('[Redis] REDIS_URL not set, using in-memory storage')
return null
}
try {
redisClient = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
retryStrategy(times) {
if (times > 3) {
console.error('[Redis] Max retries reached, giving up')
return null
}
const delay = Math.min(times * 200, 2000)
console.log(`[Redis] Retrying connection in ${delay}ms (attempt ${times})`)
return delay
},
lazyConnect: false,
})
redisClient.on('connect', () => {
console.log('[Redis] Connected successfully')
})
redisClient.on('error', (err) => {
console.error('[Redis] Connection error:', err.message)
})
redisClient.on('close', () => {
console.log('[Redis] Connection closed')
})
return redisClient
} catch (err) {
console.error('[Redis] Failed to create client:', err)
return null
}
}
/**
* Check if Redis is available
*/
export function isRedisAvailable(): boolean {
const client = getRedisClient()
return client !== null && client.status === 'ready'
}
/**
* Create a new Redis client for pub/sub (Socket.IO adapter needs separate clients)
*
* Returns null if REDIS_URL is not set.
*/
export function createRedisClient(): Redis | null {
const redisUrl = process.env.REDIS_URL
if (!redisUrl) {
return null
}
try {
return new Redis(redisUrl, {
maxRetriesPerRequest: 3,
retryStrategy(times) {
if (times > 3) {
return null
}
return Math.min(times * 200, 2000)
},
})
} catch (err) {
console.error('[Redis] Failed to create pub/sub client:', err)
return null
}
}

View File

@ -35,109 +35,109 @@ describe('Remote Camera Session Manager', () => {
})
describe('createRemoteCameraSession', () => {
it('should create a new session with unique ID', () => {
const session = createRemoteCameraSession()
it('should create a new session with unique ID', async () => {
const session = await createRemoteCameraSession()
expect(session.id).toBeDefined()
expect(session.id.length).toBeGreaterThan(0)
expect(session.phoneConnected).toBe(false)
})
it('should set correct timestamps on creation', () => {
it('should set correct timestamps on creation', async () => {
const now = new Date()
vi.setSystemTime(now)
const session = createRemoteCameraSession()
const session = await createRemoteCameraSession()
expect(session.createdAt.getTime()).toBe(now.getTime())
expect(session.lastActivityAt.getTime()).toBe(now.getTime())
expect(new Date(session.createdAt).getTime()).toBe(now.getTime())
expect(new Date(session.lastActivityAt).getTime()).toBe(now.getTime())
// TTL should be 60 minutes
expect(session.expiresAt.getTime()).toBe(now.getTime() + 60 * 60 * 1000)
expect(new Date(session.expiresAt).getTime()).toBe(now.getTime() + 60 * 60 * 1000)
})
it('should create multiple sessions with unique IDs', () => {
const session1 = createRemoteCameraSession()
const session2 = createRemoteCameraSession()
it('should create multiple sessions with unique IDs', async () => {
const session1 = await createRemoteCameraSession()
const session2 = await createRemoteCameraSession()
expect(session1.id).not.toBe(session2.id)
expect(getSessionCount()).toBe(2)
expect(await getSessionCount()).toBe(2)
})
})
describe('getRemoteCameraSession', () => {
it('should retrieve an existing session', () => {
const created = createRemoteCameraSession()
const retrieved = getRemoteCameraSession(created.id)
it('should retrieve an existing session', async () => {
const created = await createRemoteCameraSession()
const retrieved = await getRemoteCameraSession(created.id)
expect(retrieved).not.toBeNull()
expect(retrieved?.id).toBe(created.id)
})
it('should return null for non-existent session', () => {
const session = getRemoteCameraSession('non-existent-id')
it('should return null for non-existent session', async () => {
const session = await getRemoteCameraSession('non-existent-id')
expect(session).toBeNull()
})
it('should return null for expired session', () => {
const session = createRemoteCameraSession()
it('should return null for expired session', async () => {
const session = await createRemoteCameraSession()
const sessionId = session.id
// Advance time past expiration (61 minutes)
vi.setSystemTime(new Date(Date.now() + 61 * 60 * 1000))
const retrieved = getRemoteCameraSession(sessionId)
const retrieved = await getRemoteCameraSession(sessionId)
expect(retrieved).toBeNull()
})
})
describe('getOrCreateSession', () => {
it('should create new session with provided ID if not exists', () => {
it('should create new session with provided ID if not exists', async () => {
const customId = 'my-custom-session-id'
const session = getOrCreateSession(customId)
const session = await getOrCreateSession(customId)
expect(session.id).toBe(customId)
expect(session.phoneConnected).toBe(false)
})
it('should return existing session if not expired', () => {
it('should return existing session if not expired', async () => {
const customId = 'existing-session'
const original = getOrCreateSession(customId)
const original = await getOrCreateSession(customId)
// Mark phone connected to verify we get same session
markPhoneConnected(customId)
await markPhoneConnected(customId)
const retrieved = getOrCreateSession(customId)
const retrieved = await getOrCreateSession(customId)
expect(retrieved.id).toBe(original.id)
expect(retrieved.phoneConnected).toBe(true)
})
it('should renew TTL when accessing existing session', () => {
it('should renew TTL when accessing existing session', async () => {
const now = new Date()
vi.setSystemTime(now)
const customId = 'session-to-renew'
const original = getOrCreateSession(customId)
const originalExpiry = original.expiresAt.getTime()
const original = await getOrCreateSession(customId)
const originalExpiry = new Date(original.expiresAt).getTime()
// Advance time by 30 minutes
vi.setSystemTime(new Date(now.getTime() + 30 * 60 * 1000))
const retrieved = getOrCreateSession(customId)
const retrieved = await getOrCreateSession(customId)
// Expiry should be extended from current time
expect(retrieved.expiresAt.getTime()).toBeGreaterThan(originalExpiry)
expect(new Date(retrieved.expiresAt).getTime()).toBeGreaterThan(originalExpiry)
})
it('should create new session if existing one expired', () => {
it('should create new session if existing one expired', async () => {
const customId = 'expired-session'
const original = getOrCreateSession(customId)
markPhoneConnected(customId) // Mark to distinguish
await getOrCreateSession(customId)
await markPhoneConnected(customId) // Mark to distinguish
// Advance time past expiration
vi.setSystemTime(new Date(Date.now() + 61 * 60 * 1000))
const newSession = getOrCreateSession(customId)
const newSession = await getOrCreateSession(customId)
// Should be a fresh session (not phone connected)
expect(newSession.id).toBe(customId)
@ -146,42 +146,42 @@ describe('Remote Camera Session Manager', () => {
})
describe('renewSessionTTL', () => {
it('should extend session expiration time', () => {
it('should extend session expiration time', async () => {
const now = new Date()
vi.setSystemTime(now)
const session = createRemoteCameraSession()
const originalExpiry = session.expiresAt.getTime()
const session = await createRemoteCameraSession()
const originalExpiry = new Date(session.expiresAt).getTime()
// Advance time by 30 minutes
vi.setSystemTime(new Date(now.getTime() + 30 * 60 * 1000))
const renewed = renewSessionTTL(session.id)
const renewed = await renewSessionTTL(session.id)
expect(renewed).toBe(true)
const updatedSession = getRemoteCameraSession(session.id)
expect(updatedSession?.expiresAt.getTime()).toBeGreaterThan(originalExpiry)
const updatedSession = await getRemoteCameraSession(session.id)
expect(new Date(updatedSession!.expiresAt).getTime()).toBeGreaterThan(originalExpiry)
})
it('should update lastActivityAt', () => {
it('should update lastActivityAt', async () => {
const now = new Date()
vi.setSystemTime(now)
const session = createRemoteCameraSession()
const session = await createRemoteCameraSession()
// Advance time
const later = new Date(now.getTime() + 10 * 60 * 1000)
vi.setSystemTime(later)
renewSessionTTL(session.id)
await renewSessionTTL(session.id)
const updatedSession = getRemoteCameraSession(session.id)
expect(updatedSession?.lastActivityAt.getTime()).toBe(later.getTime())
const updatedSession = await getRemoteCameraSession(session.id)
expect(new Date(updatedSession!.lastActivityAt).getTime()).toBe(later.getTime())
})
it('should return false for non-existent session', () => {
const result = renewSessionTTL('non-existent')
it('should return false for non-existent session', async () => {
const result = await renewSessionTTL('non-existent')
expect(result).toBe(false)
})
})
@ -196,133 +196,133 @@ describe('Remote Camera Session Manager', () => {
},
}
it('should store calibration data', () => {
const session = createRemoteCameraSession()
it('should store calibration data', async () => {
const session = await createRemoteCameraSession()
const result = setSessionCalibration(session.id, testCalibration)
const result = await setSessionCalibration(session.id, testCalibration)
expect(result).toBe(true)
})
it('should retrieve calibration data', () => {
const session = createRemoteCameraSession()
setSessionCalibration(session.id, testCalibration)
it('should retrieve calibration data', async () => {
const session = await createRemoteCameraSession()
await setSessionCalibration(session.id, testCalibration)
const retrieved = getSessionCalibration(session.id)
const retrieved = await getSessionCalibration(session.id)
expect(retrieved).toEqual(testCalibration)
})
it('should return null for session without calibration', () => {
const session = createRemoteCameraSession()
it('should return null for session without calibration', async () => {
const session = await createRemoteCameraSession()
const calibration = getSessionCalibration(session.id)
const calibration = await getSessionCalibration(session.id)
expect(calibration).toBeNull()
})
it('should return null for non-existent session', () => {
const calibration = getSessionCalibration('non-existent')
it('should return null for non-existent session', async () => {
const calibration = await getSessionCalibration('non-existent')
expect(calibration).toBeNull()
})
it('should renew TTL when setting calibration', () => {
it('should renew TTL when setting calibration', async () => {
const now = new Date()
vi.setSystemTime(now)
const session = createRemoteCameraSession()
const originalExpiry = session.expiresAt.getTime()
const session = await createRemoteCameraSession()
const originalExpiry = new Date(session.expiresAt).getTime()
// Advance time
vi.setSystemTime(new Date(now.getTime() + 30 * 60 * 1000))
setSessionCalibration(session.id, testCalibration)
await setSessionCalibration(session.id, testCalibration)
const updatedSession = getRemoteCameraSession(session.id)
expect(updatedSession?.expiresAt.getTime()).toBeGreaterThan(originalExpiry)
const updatedSession = await getRemoteCameraSession(session.id)
expect(new Date(updatedSession!.expiresAt).getTime()).toBeGreaterThan(originalExpiry)
})
it('should persist calibration across session retrievals', () => {
it('should persist calibration across session retrievals', async () => {
const customId = 'calibrated-session'
const session = getOrCreateSession(customId)
setSessionCalibration(session.id, testCalibration)
const session = await getOrCreateSession(customId)
await setSessionCalibration(session.id, testCalibration)
// Simulate reconnection by getting session again
const reconnected = getOrCreateSession(customId)
const reconnected = await getOrCreateSession(customId)
expect(reconnected.calibration).toEqual(testCalibration)
})
})
describe('phone connection state', () => {
it('should mark phone as connected', () => {
const session = createRemoteCameraSession()
it('should mark phone as connected', async () => {
const session = await createRemoteCameraSession()
const result = markPhoneConnected(session.id)
const result = await markPhoneConnected(session.id)
expect(result).toBe(true)
const updated = getRemoteCameraSession(session.id)
const updated = await getRemoteCameraSession(session.id)
expect(updated?.phoneConnected).toBe(true)
})
it('should mark phone as disconnected', () => {
const session = createRemoteCameraSession()
markPhoneConnected(session.id)
it('should mark phone as disconnected', async () => {
const session = await createRemoteCameraSession()
await markPhoneConnected(session.id)
const result = markPhoneDisconnected(session.id)
const result = await markPhoneDisconnected(session.id)
expect(result).toBe(true)
const updated = getRemoteCameraSession(session.id)
const updated = await getRemoteCameraSession(session.id)
expect(updated?.phoneConnected).toBe(false)
})
it('should extend TTL when phone connects', () => {
it('should extend TTL when phone connects', async () => {
const now = new Date()
vi.setSystemTime(now)
const session = createRemoteCameraSession()
const session = await createRemoteCameraSession()
// Advance time
vi.setSystemTime(new Date(now.getTime() + 30 * 60 * 1000))
markPhoneConnected(session.id)
await markPhoneConnected(session.id)
const updated = getRemoteCameraSession(session.id)
const updated = await getRemoteCameraSession(session.id)
// Expiry should be 60 mins from now (not from creation)
expect(updated?.expiresAt.getTime()).toBeGreaterThan(now.getTime() + 60 * 60 * 1000)
expect(new Date(updated!.expiresAt).getTime()).toBeGreaterThan(now.getTime() + 60 * 60 * 1000)
})
it('should return false for non-existent session', () => {
expect(markPhoneConnected('non-existent')).toBe(false)
expect(markPhoneDisconnected('non-existent')).toBe(false)
it('should return false for non-existent session', async () => {
expect(await markPhoneConnected('non-existent')).toBe(false)
expect(await markPhoneDisconnected('non-existent')).toBe(false)
})
})
describe('deleteRemoteCameraSession', () => {
it('should delete existing session', () => {
const session = createRemoteCameraSession()
it('should delete existing session', async () => {
const session = await createRemoteCameraSession()
const result = deleteRemoteCameraSession(session.id)
const result = await deleteRemoteCameraSession(session.id)
expect(result).toBe(true)
expect(getRemoteCameraSession(session.id)).toBeNull()
expect(await getRemoteCameraSession(session.id)).toBeNull()
})
it('should return false for non-existent session', () => {
const result = deleteRemoteCameraSession('non-existent')
it('should return false for non-existent session', async () => {
const result = await deleteRemoteCameraSession('non-existent')
expect(result).toBe(false)
})
})
describe('session count', () => {
it('should track total sessions', () => {
expect(getSessionCount()).toBe(0)
it('should track total sessions', async () => {
expect(await getSessionCount()).toBe(0)
createRemoteCameraSession()
expect(getSessionCount()).toBe(1)
await createRemoteCameraSession()
expect(await getSessionCount()).toBe(1)
createRemoteCameraSession()
expect(getSessionCount()).toBe(2)
await createRemoteCameraSession()
expect(await getSessionCount()).toBe(2)
})
})
})

View File

@ -1,18 +1,19 @@
/**
* Remote Camera Session Manager
*
* Manages in-memory sessions for phone-to-desktop camera streaming.
* Manages sessions for phone-to-desktop camera streaming.
* Uses Redis when REDIS_URL is set (production), falls back to in-memory (dev).
* Sessions have a 60-minute TTL but are renewed on activity.
* Sessions persist across page reloads via session ID stored client-side.
*/
import { createId } from '@paralleldrive/cuid2'
import { getRedisClient } from '../redis'
export interface RemoteCameraSession {
id: string
createdAt: Date
expiresAt: Date
lastActivityAt: Date
createdAt: string // ISO string for JSON serialization
expiresAt: string
lastActivityAt: string
phoneConnected: boolean
/** Calibration data sent from desktop (persists for reconnects) */
calibration?: {
@ -22,44 +23,71 @@ export interface RemoteCameraSession {
bottomLeft: { x: number; y: number }
bottomRight: { x: number; y: number }
}
columnCount?: number
}
}
// In-memory session storage
// In-memory session storage (fallback when Redis not available)
// Using globalThis to persist across hot reloads in development
declare global {
// eslint-disable-next-line no-var
var __remoteCameraSessions: Map<string, RemoteCameraSession> | undefined
// eslint-disable-next-line no-var
var __remoteCameraCleanupStarted: boolean | undefined
}
const SESSION_TTL_MS = 60 * 60 * 1000 // 60 minutes
const SESSION_TTL_SECONDS = 60 * 60 // 60 minutes (for Redis)
const CLEANUP_INTERVAL_MS = 60 * 1000 // 1 minute
const REDIS_KEY_PREFIX = 'remote-camera:session:'
function getSessions(): Map<string, RemoteCameraSession> {
function getMemorySessions(): Map<string, RemoteCameraSession> {
if (!globalThis.__remoteCameraSessions) {
globalThis.__remoteCameraSessions = new Map()
// Start cleanup interval
setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS)
// Start cleanup interval only once
if (!globalThis.__remoteCameraCleanupStarted) {
globalThis.__remoteCameraCleanupStarted = true
setInterval(cleanupExpiredMemorySessions, CLEANUP_INTERVAL_MS)
}
}
return globalThis.__remoteCameraSessions
}
function cleanupExpiredMemorySessions(): void {
const sessions = getMemorySessions()
const now = new Date().toISOString()
for (const [id, session] of sessions) {
if (now > session.expiresAt) {
sessions.delete(id)
}
}
}
/**
* Create a new remote camera session
*/
export function createRemoteCameraSession(): RemoteCameraSession {
const sessions = getSessions()
export async function createRemoteCameraSession(): Promise<RemoteCameraSession> {
const now = new Date()
const expiresAt = new Date(now.getTime() + SESSION_TTL_MS)
const session: RemoteCameraSession = {
id: createId(),
createdAt: now,
expiresAt: new Date(now.getTime() + SESSION_TTL_MS),
lastActivityAt: now,
createdAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
lastActivityAt: now.toISOString(),
phoneConnected: false,
}
sessions.set(session.id, session)
const redis = getRedisClient()
if (redis) {
await redis.setex(REDIS_KEY_PREFIX + session.id, SESSION_TTL_SECONDS, JSON.stringify(session))
console.log(`[SessionManager] Created session ${session.id} in Redis`)
} else {
getMemorySessions().set(session.id, session)
console.log(`[SessionManager] Created session ${session.id} in memory`)
}
return session
}
@ -68,75 +96,107 @@ export function createRemoteCameraSession(): RemoteCameraSession {
* If the session exists and isn't expired, returns it (renewed)
* If the session doesn't exist, creates a new one with the given ID
*/
export function getOrCreateSession(sessionId: string): RemoteCameraSession {
const sessions = getSessions()
const existing = sessions.get(sessionId)
const now = new Date()
export async function getOrCreateSession(sessionId: string): Promise<RemoteCameraSession> {
const existing = await getRemoteCameraSession(sessionId)
if (existing && now <= existing.expiresAt) {
if (existing) {
// Renew TTL on access
existing.expiresAt = new Date(now.getTime() + SESSION_TTL_MS)
existing.lastActivityAt = now
await renewSessionTTL(sessionId)
return existing
}
// Create new session with provided ID
const now = new Date()
const expiresAt = new Date(now.getTime() + SESSION_TTL_MS)
const session: RemoteCameraSession = {
id: sessionId,
createdAt: now,
expiresAt: new Date(now.getTime() + SESSION_TTL_MS),
lastActivityAt: now,
createdAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
lastActivityAt: now.toISOString(),
phoneConnected: false,
}
sessions.set(session.id, session)
const redis = getRedisClient()
if (redis) {
await redis.setex(REDIS_KEY_PREFIX + session.id, SESSION_TTL_SECONDS, JSON.stringify(session))
} else {
getMemorySessions().set(session.id, session)
}
return session
}
/**
* Renew session TTL (call on activity to keep session alive)
*/
export function renewSessionTTL(sessionId: string): boolean {
const sessions = getSessions()
const session = sessions.get(sessionId)
export async function renewSessionTTL(sessionId: string): Promise<boolean> {
const redis = getRedisClient()
if (redis) {
const data = await redis.get(REDIS_KEY_PREFIX + sessionId)
if (!data) return false
const session: RemoteCameraSession = JSON.parse(data)
const now = new Date()
session.expiresAt = new Date(now.getTime() + SESSION_TTL_MS).toISOString()
session.lastActivityAt = now.toISOString()
await redis.setex(REDIS_KEY_PREFIX + sessionId, SESSION_TTL_SECONDS, JSON.stringify(session))
return true
}
const sessions = getMemorySessions()
const session = sessions.get(sessionId)
if (!session) return false
const now = new Date()
session.expiresAt = new Date(now.getTime() + SESSION_TTL_MS)
session.lastActivityAt = now
session.expiresAt = new Date(now.getTime() + SESSION_TTL_MS).toISOString()
session.lastActivityAt = now.toISOString()
return true
}
/**
* Store calibration data in session (persists for reconnects)
*/
export function setSessionCalibration(
export async function setSessionCalibration(
sessionId: string,
calibration: RemoteCameraSession['calibration']
): boolean {
const sessions = getSessions()
const session = sessions.get(sessionId)
): Promise<boolean> {
const redis = getRedisClient()
if (redis) {
const data = await redis.get(REDIS_KEY_PREFIX + sessionId)
if (!data) return false
const session: RemoteCameraSession = JSON.parse(data)
session.calibration = calibration
const now = new Date()
session.expiresAt = new Date(now.getTime() + SESSION_TTL_MS).toISOString()
session.lastActivityAt = now.toISOString()
await redis.setex(REDIS_KEY_PREFIX + sessionId, SESSION_TTL_SECONDS, JSON.stringify(session))
return true
}
const sessions = getMemorySessions()
const session = sessions.get(sessionId)
if (!session) return false
session.calibration = calibration
// Also renew TTL
const now = new Date()
session.expiresAt = new Date(now.getTime() + SESSION_TTL_MS)
session.lastActivityAt = now
session.expiresAt = new Date(now.getTime() + SESSION_TTL_MS).toISOString()
session.lastActivityAt = now.toISOString()
return true
}
/**
* Get calibration data from session
*/
export function getSessionCalibration(
export async function getSessionCalibration(
sessionId: string
): RemoteCameraSession['calibration'] | null {
const sessions = getSessions()
const session = sessions.get(sessionId)
): Promise<RemoteCameraSession['calibration'] | null> {
const session = await getRemoteCameraSession(sessionId)
if (!session) return null
return session.calibration || null
}
@ -144,14 +204,32 @@ export function getSessionCalibration(
/**
* Get a session by ID
*/
export function getRemoteCameraSession(sessionId: string): RemoteCameraSession | null {
const sessions = getSessions()
const session = sessions.get(sessionId)
export async function getRemoteCameraSession(
sessionId: string
): Promise<RemoteCameraSession | null> {
const redis = getRedisClient()
if (redis) {
const data = await redis.get(REDIS_KEY_PREFIX + sessionId)
if (!data) return null
const session: RemoteCameraSession = JSON.parse(data)
// Check if expired (Redis TTL should handle this, but double-check)
if (new Date().toISOString() > session.expiresAt) {
await redis.del(REDIS_KEY_PREFIX + sessionId)
return null
}
return session
}
const sessions = getMemorySessions()
const session = sessions.get(sessionId)
if (!session) return null
// Check if expired
if (new Date() > session.expiresAt) {
if (new Date().toISOString() > session.expiresAt) {
sessions.delete(sessionId)
return null
}
@ -162,25 +240,49 @@ export function getRemoteCameraSession(sessionId: string): RemoteCameraSession |
/**
* Mark a session as having a connected phone
*/
export function markPhoneConnected(sessionId: string): boolean {
const sessions = getSessions()
const session = sessions.get(sessionId)
export async function markPhoneConnected(sessionId: string): Promise<boolean> {
const redis = getRedisClient()
if (redis) {
const data = await redis.get(REDIS_KEY_PREFIX + sessionId)
if (!data) return false
const session: RemoteCameraSession = JSON.parse(data)
session.phoneConnected = true
session.expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString()
await redis.setex(REDIS_KEY_PREFIX + sessionId, SESSION_TTL_SECONDS, JSON.stringify(session))
return true
}
const sessions = getMemorySessions()
const session = sessions.get(sessionId)
if (!session) return false
session.phoneConnected = true
// Extend TTL when phone connects
session.expiresAt = new Date(Date.now() + SESSION_TTL_MS)
session.expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString()
return true
}
/**
* Mark a session as having the phone disconnected
*/
export function markPhoneDisconnected(sessionId: string): boolean {
const sessions = getSessions()
const session = sessions.get(sessionId)
export async function markPhoneDisconnected(sessionId: string): Promise<boolean> {
const redis = getRedisClient()
if (redis) {
const data = await redis.get(REDIS_KEY_PREFIX + sessionId)
if (!data) return false
const session: RemoteCameraSession = JSON.parse(data)
session.phoneConnected = false
await redis.setex(REDIS_KEY_PREFIX + sessionId, SESSION_TTL_SECONDS, JSON.stringify(session))
return true
}
const sessions = getMemorySessions()
const session = sessions.get(sessionId)
if (!session) return false
session.phoneConnected = false
@ -190,28 +292,27 @@ export function markPhoneDisconnected(sessionId: string): boolean {
/**
* Delete a session
*/
export function deleteRemoteCameraSession(sessionId: string): boolean {
const sessions = getSessions()
return sessions.delete(sessionId)
}
export async function deleteRemoteCameraSession(sessionId: string): Promise<boolean> {
const redis = getRedisClient()
/**
* Clean up expired sessions
*/
function cleanupExpiredSessions(): void {
const sessions = getSessions()
const now = new Date()
for (const [id, session] of sessions) {
if (now > session.expiresAt) {
sessions.delete(id)
}
if (redis) {
const result = await redis.del(REDIS_KEY_PREFIX + sessionId)
return result > 0
}
return getMemorySessions().delete(sessionId)
}
/**
* Get session count (for debugging)
*/
export function getSessionCount(): number {
return getSessions().size
export async function getSessionCount(): Promise<number> {
const redis = getRedisClient()
if (redis) {
const keys = await redis.keys(REDIS_KEY_PREFIX + '*')
return keys.length
}
return getMemorySessions().size
}

View File

@ -1,6 +1,7 @@
import type { Server as HTTPServer } from 'http'
import { Server as SocketIOServer } from 'socket.io'
import type { Server as SocketIOServerType } from 'socket.io'
import { createAdapter } from '@socket.io/redis-adapter'
import {
applyGameMove,
createArcadeSession,
@ -23,6 +24,7 @@ import {
markPhoneConnected,
markPhoneDisconnected,
} from './lib/remote-camera/session-manager'
import { createRedisClient } from './lib/redis'
import { VisionRecorder, type VisionFrame, type PracticeStateInput } from './lib/vision/recording'
// Throttle map for DVR buffer info emissions (sessionId -> last emit timestamp)
@ -336,6 +338,17 @@ export function initializeSocketServer(httpServer: HTTPServer) {
},
})
// Set up Redis adapter for cross-instance Socket.IO broadcasts (production only)
// In dev without Redis, Socket.IO works normally on single instance
const pubClient = createRedisClient()
if (pubClient) {
const subClient = pubClient.duplicate()
io.adapter(createAdapter(pubClient, subClient))
console.log('[Socket.IO] Redis adapter configured for cross-instance communication')
} else {
console.log('[Socket.IO] No Redis available, using default in-memory adapter (single instance)')
}
// Initialize Yjs server over Socket.IO
initializeYjsServer(io)
@ -1366,7 +1379,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// Remote Camera: Phone joins a remote camera session
socket.on('remote-camera:join', async ({ sessionId }: { sessionId: string }) => {
try {
const session = getRemoteCameraSession(sessionId)
const session = await getRemoteCameraSession(sessionId)
if (!session) {
socket.emit('remote-camera:error', {
error: 'Invalid or expired session',
@ -1375,7 +1388,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
}
// Mark phone as connected
markPhoneConnected(sessionId)
await markPhoneConnected(sessionId)
// Join the session room
await socket.join(`remote-camera:${sessionId}`)
@ -1400,7 +1413,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// Remote Camera: Desktop subscribes to receive frames
socket.on('remote-camera:subscribe', async ({ sessionId }: { sessionId: string }) => {
try {
const session = getRemoteCameraSession(sessionId)
const session = await getRemoteCameraSession(sessionId)
if (!session) {
socket.emit('remote-camera:error', {
error: 'Invalid or expired session',
@ -1573,7 +1586,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// If this was the phone, mark as disconnected
if (socket.data.remoteCameraSessionId === sessionId) {
markPhoneDisconnected(sessionId)
await markPhoneDisconnected(sessionId)
socket.data.remoteCameraSessionId = undefined
console.log(`📱 Phone left remote camera session: ${sessionId}`)
@ -1592,7 +1605,8 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// Handle remote camera cleanup on disconnect
const remoteCameraSessionId = socket.data.remoteCameraSessionId as string | undefined
if (remoteCameraSessionId) {
markPhoneDisconnected(remoteCameraSessionId)
// Fire-and-forget async cleanup (don't block disconnect handling)
void markPhoneDisconnected(remoteCameraSessionId)
io!.to(`remote-camera:${remoteCameraSessionId}`).emit('remote-camera:disconnected', {
phoneConnected: false,
})

View File

@ -5,12 +5,16 @@ services:
restart: unless-stopped
env_file:
- .env
environment:
- REDIS_URL=redis://redis:6379
volumes:
- ./public:/app/public
- ./data:/app/apps/web/data
- ./uploads:/app/uploads
networks:
- webgateway
depends_on:
- redis
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 10s

View File

@ -5,12 +5,16 @@ services:
restart: unless-stopped
env_file:
- .env
environment:
- REDIS_URL=redis://redis:6379
volumes:
- ./public:/app/public
- ./data:/app/apps/web/data
- ./uploads:/app/uploads
networks:
- webgateway
depends_on:
- redis
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 10s

View File

@ -13,12 +13,16 @@ x-app: &app
restart: unless-stopped
env_file:
- .env
environment:
- REDIS_URL=redis://redis:6379
volumes:
- ./public:/app/public
- ./data:/app/apps/web/data
- ./uploads:/app/uploads
networks:
- webgateway
depends_on:
- redis
healthcheck:
test:
[
@ -76,6 +80,21 @@ services:
docker-compose-watcher.dir: /volume1/homes/antialias/projects/abaci.one
docker-compose-watcher.file: docker-compose.green.yaml
redis:
image: redis:7-alpine
container_name: abaci-redis
restart: unless-stopped
volumes:
- redis-data:/data
networks:
- webgateway
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
ddns-updater:
image: qmcgaw/ddns-updater:latest
container_name: ddns-updater
@ -89,6 +108,9 @@ services:
networks:
- webgateway
volumes:
redis-data:
networks:
webgateway:
external: true

View File

@ -129,6 +129,9 @@ importers:
'@react-three/fiber':
specifier: ^8.17.0
version: 8.18.0(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(three@0.169.0)
'@socket.io/redis-adapter':
specifier: ^8.3.0
version: 8.3.0(socket.io-adapter@2.5.5)
'@soroban/abacus-react':
specifier: workspace:*
version: link:../../packages/abacus-react
@ -219,6 +222,9 @@ importers:
gray-matter:
specifier: ^4.0.3
version: 4.0.3
ioredis:
specifier: ^5.9.2
version: 5.9.2
jose:
specifier: ^6.1.0
version: 6.1.0
@ -2353,6 +2359,9 @@ packages:
cpu: [x64]
os: [win32]
'@ioredis/commands@1.5.0':
resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
@ -3812,6 +3821,12 @@ packages:
'@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
'@socket.io/redis-adapter@8.3.0':
resolution: {integrity: sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==}
engines: {node: '>=10.0.0'}
peerDependencies:
socket.io-adapter: ^2.5.4
'@storybook/addon-actions@7.6.20':
resolution: {integrity: sha512-c/GkEQ2U9BC/Ew/IMdh+zvsh4N6y6n7Zsn2GIhJgcu9YEAa5aF2a9/pNgEGBMOABH959XE8DAOMERw/5qiLR8g==}
@ -5587,6 +5602,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
code-block-writer@13.0.3:
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
@ -6022,6 +6041,10 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@ -7355,6 +7378,10 @@ packages:
iobuffer@5.4.0:
resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
ioredis@5.9.2:
resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==}
engines: {node: '>=12.22.0'}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
@ -7976,9 +8003,15 @@ packages:
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
lodash.escaperegexp@4.1.2:
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
@ -8602,6 +8635,9 @@ packages:
resolution: {integrity: sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w==}
engines: {node: '>=14.16'}
notepack.io@3.0.1:
resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==}
npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
@ -9548,6 +9584,14 @@ packages:
redeyed@2.1.1:
resolution: {integrity: sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==}
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
redis-parser@3.0.0:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@ -10051,6 +10095,9 @@ packages:
stackframe@1.3.4:
resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
stats-gl@2.4.2:
resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==}
peerDependencies:
@ -10705,6 +10752,10 @@ packages:
engines: {node: '>=0.8.0'}
hasBin: true
uid2@1.0.0:
resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==}
engines: {node: '>= 4.0.0'}
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@ -12869,6 +12920,8 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
'@ioredis/commands@1.5.0': {}
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.0':
@ -14567,6 +14620,15 @@ snapshots:
'@socket.io/component-emitter@3.1.2': {}
'@socket.io/redis-adapter@8.3.0(socket.io-adapter@2.5.5)':
dependencies:
debug: 4.3.7
notepack.io: 3.0.1
socket.io-adapter: 2.5.5
uid2: 1.0.0
transitivePeerDependencies:
- supports-color
'@storybook/addon-actions@7.6.20':
dependencies:
'@storybook/core-events': 7.6.20
@ -17132,6 +17194,8 @@ snapshots:
clone@1.0.4: {}
cluster-key-slot@1.1.2: {}
code-block-writer@13.0.3: {}
color-convert@1.9.3:
@ -17585,6 +17649,8 @@ snapshots:
delayed-stream@1.0.0: {}
denque@2.1.0: {}
depd@2.0.0: {}
deprecation@2.3.1: {}
@ -19271,6 +19337,20 @@ snapshots:
iobuffer@5.4.0: {}
ioredis@5.9.2:
dependencies:
'@ioredis/commands': 1.5.0
cluster-key-slot: 1.1.2
debug: 4.4.3(supports-color@8.1.1)
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
ipaddr.js@1.9.1: {}
is-absolute-url@3.0.3: {}
@ -19937,8 +20017,12 @@ snapshots:
lodash.debounce@4.0.8: {}
lodash.defaults@4.2.0: {}
lodash.escaperegexp@4.1.2: {}
lodash.isarguments@3.1.0: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
@ -20728,6 +20812,8 @@ snapshots:
normalize-url@8.1.0: {}
notepack.io@3.0.1: {}
npm-run-path@4.0.1:
dependencies:
path-key: 3.1.1
@ -21651,6 +21737,12 @@ snapshots:
dependencies:
esprima: 4.0.1
redis-errors@1.2.0: {}
redis-parser@3.0.0:
dependencies:
redis-errors: 1.2.0
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@ -22348,6 +22440,8 @@ snapshots:
stackframe@1.3.4: {}
standard-as-callback@2.1.0: {}
stats-gl@2.4.2(@types/three@0.181.0)(three@0.169.0):
dependencies:
'@types/three': 0.181.0
@ -23031,6 +23125,8 @@ snapshots:
uglify-js@3.19.3:
optional: true
uid2@1.0.0: {}
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4