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:
parent
8e4338bbbe
commit
0346455b3e
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue