chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Environment variables required by the @sim/realtime (Socket.IO) server.
# These MUST match the corresponding values in apps/sim/.env for auth to work.
# See apps/realtime/src/env.ts for the full zod schema.
# Core
NODE_ENV=development
PORT=3002
# Database — must point at the same Postgres as the main app
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/simstudio
# Auth — shared with apps/sim (Better Auth "Shared Database Session" pattern)
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=your_better_auth_secret_min_32_chars
# Internal RPC — shared with apps/sim
INTERNAL_API_SECRET=your_internal_api_secret_min_32_chars
# Public app URL — used for CORS allow-list and base URL resolution
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Optional: Redis for cross-pod room management
# Leave unset for single-pod / in-memory rooms
# REDIS_URL=redis://localhost:6379
# Optional: extra Socket.IO CORS allow-list (comma-separated)
# ALLOWED_ORIGINS=https://embed.example.com,https://admin.example.com
# Optional: disable auth entirely for trusted private networks
# DISABLE_AUTH=true
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@sim/realtime",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"engines": {
"bun": ">=1.2.13",
"node": ">=20.0.0"
},
"scripts": {
"dev": "SIM_DB_ROLE=realtime DB_APP_NAME=sim-realtime bun --watch src/index.ts",
"start": "SIM_DB_ROLE=realtime DB_APP_NAME=sim-realtime bun src/index.ts",
"type-check": "tsc --noEmit",
"lint": "biome check --write --unsafe .",
"lint:check": "biome check .",
"format": "biome format --write .",
"format:check": "biome format .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@sim/audit": "workspace:*",
"@sim/auth": "workspace:*",
"@sim/db": "workspace:*",
"@sim/logger": "workspace:*",
"@sim/platform-authz": "workspace:*",
"@sim/realtime-protocol": "workspace:*",
"@sim/runtime-secrets": "workspace:*",
"@sim/security": "workspace:*",
"@sim/utils": "workspace:*",
"@sim/workflow-persistence": "workspace:*",
"@sim/workflow-types": "workspace:*",
"@socket.io/redis-adapter": "8.3.0",
"drizzle-orm": "^0.45.2",
"postgres": "^3.4.5",
"redis": "5.10.0",
"socket.io": "^4.8.1",
"zod": "4.3.6"
},
"devDependencies": {
"@sim/testing": "workspace:*",
"@sim/tsconfig": "workspace:*",
"@types/node": "24.2.1",
"socket.io-client": "4.8.1",
"typescript": "^7.0.2",
"vitest": "^4.1.0"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { createVerifyAuth } from '@sim/auth/verify'
import { env } from '@/env'
export const ANONYMOUS_USER_ID = '00000000-0000-0000-0000-000000000000'
export const ANONYMOUS_USER = {
id: ANONYMOUS_USER_ID,
name: 'Anonymous',
email: 'anonymous@localhost',
emailVerified: true,
image: null,
} as const
export const auth = createVerifyAuth({
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
})
+20
View File
@@ -0,0 +1,20 @@
/**
* Container entrypoint. Hydrates `process.env` from the runtime secret before
* loading the Socket.IO server, whose modules (`@/env`, DB preflight) read env
* at import time. See `@sim/runtime-secrets`.
*/
import { loadRuntimeSecrets } from '@sim/runtime-secrets'
await loadRuntimeSecrets()
/**
* Pin this process to the `realtime` DB role — covering both the realtime
* `socketDb` pool and the shared `@sim/db` client used by handlers, preflight,
* and permissions. The role drives the pool-size profile, `application_name`,
* and the role-keyed connection URL, so every realtime connection resolves
* consistently (without it the shared client would default to `web`). Set
* before importing `@/index` so it lands before `@sim/db` reads it at
* module-eval time; `??=` respects an explicit override.
*/
process.env.SIM_DB_ROLE ??= 'realtime'
process.env.DB_APP_NAME ??= 'sim-realtime'
await import('@/index')
+151
View File
@@ -0,0 +1,151 @@
import type { Server as HttpServer } from 'http'
import { createLogger } from '@sim/logger'
import { createAdapter } from '@socket.io/redis-adapter'
import { createClient, type RedisClientType } from 'redis'
import { Server } from 'socket.io'
import { env, getBaseUrl, isProd } from '@/env'
const logger = createLogger('SocketIOConfig')
/** Socket.IO ping timeout - how long to wait for pong before considering connection dead */
const PING_TIMEOUT_MS = 60000
/** Socket.IO ping interval - how often to send ping packets */
const PING_INTERVAL_MS = 25000
/** Maximum HTTP buffer size for Socket.IO messages */
const MAX_HTTP_BUFFER_SIZE = 1e6
let adapterPubClient: RedisClientType | null = null
let adapterSubClient: RedisClientType | null = null
function getAllowedOrigins(): string[] {
const allowedOrigins = [
getBaseUrl(),
'http://localhost:3000',
'http://localhost:3001',
...(env.ALLOWED_ORIGINS?.split(',') || []),
].filter((url): url is string => Boolean(url))
logger.info('Socket.IO CORS configuration:', { allowedOrigins })
return allowedOrigins
}
/**
* Create and configure a Socket.IO server instance.
* If REDIS_URL is configured, adds Redis adapter for cross-pod broadcasting.
*/
export async function createSocketIOServer(httpServer: HttpServer): Promise<Server> {
const allowedOrigins = getAllowedOrigins()
const io = new Server(httpServer, {
cors: {
origin: allowedOrigins,
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Cookie', 'socket.io'],
credentials: true,
},
transports: ['websocket', 'polling'],
allowEIO3: true,
pingTimeout: PING_TIMEOUT_MS,
pingInterval: PING_INTERVAL_MS,
maxHttpBufferSize: MAX_HTTP_BUFFER_SIZE,
cookie: {
name: 'io',
path: '/',
httpOnly: true,
sameSite: 'none',
secure: isProd,
},
})
if (env.REDIS_URL) {
logger.info('Configuring Socket.IO Redis adapter...')
const redisOptions = {
url: env.REDIS_URL,
socket: {
reconnectStrategy: (retries: number) => {
if (retries > 10) {
logger.error('Redis adapter reconnection failed after 10 attempts')
return new Error('Redis adapter reconnection failed')
}
const delay = Math.min(retries * 100, 3000)
logger.warn(`Redis adapter reconnecting in ${delay}ms (attempt ${retries})`)
return delay
},
},
}
// Create separate clients for pub and sub (recommended for reliability)
adapterPubClient = createClient(redisOptions)
adapterSubClient = createClient(redisOptions)
adapterPubClient.on('error', (err) => {
logger.error('Redis adapter pub client error:', err)
})
adapterSubClient.on('error', (err) => {
logger.error('Redis adapter sub client error:', err)
})
adapterPubClient.on('ready', () => {
logger.info('Redis adapter pub client ready')
})
adapterSubClient.on('ready', () => {
logger.info('Redis adapter sub client ready')
})
await Promise.all([adapterPubClient.connect(), adapterSubClient.connect()])
io.adapter(createAdapter(adapterPubClient, adapterSubClient))
logger.info('Socket.IO Redis adapter connected - cross-pod broadcasting enabled')
} else {
logger.warn('REDIS_URL not configured - running in single-pod mode')
}
logger.info('Socket.IO server configured with:', {
allowedOrigins: allowedOrigins.length,
transports: ['websocket', 'polling'],
pingTimeout: PING_TIMEOUT_MS,
pingInterval: PING_INTERVAL_MS,
maxHttpBufferSize: MAX_HTTP_BUFFER_SIZE,
cookieSecure: isProd,
corsCredentials: true,
redisAdapter: !!env.REDIS_URL,
})
return io
}
/**
* Clean up Redis adapter connections.
* Call this during graceful shutdown.
*/
export async function shutdownSocketIOAdapter(): Promise<void> {
const closePromises: Promise<void>[] = []
if (adapterPubClient) {
closePromises.push(
adapterPubClient.quit().then(() => {
logger.info('Redis adapter pub client closed')
adapterPubClient = null
})
)
}
if (adapterSubClient) {
closePromises.push(
adapterSubClient.quit().then(() => {
logger.info('Redis adapter sub client closed')
adapterSubClient = null
})
)
}
if (closePromises.length > 0) {
await Promise.all(closePromises)
logger.info('Socket.IO Redis adapter shutdown complete')
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockLimit } = vi.hoisted(() => ({
mockLimit: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
select: () => ({
from: () => ({
limit: mockLimit,
}),
}),
},
}))
vi.mock('@sim/db/schema', () => ({
workflow: {},
}))
vi.mock('@sim/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}))
vi.mock('@sim/utils/helpers', () => ({
sleep: vi.fn().mockResolvedValue(undefined),
}))
import { sleep } from '@sim/utils/helpers'
import { assertSchemaCompatibility } from '@/database/preflight'
/** Builds a Postgres-shaped error carrying a SQLSTATE `code`, as postgres.js throws. */
function pgError(code: string): Error & { code: string } {
return Object.assign(new Error(`pg error ${code}`), { code })
}
/** Mirrors how drizzle wraps the driver error: the SQLSTATE lives on `cause`, not the outer error. */
function wrappedPgError(code: string): Error {
return new Error('Failed query', { cause: pgError(code) })
}
describe('assertSchemaCompatibility', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('resolves when the representative schema query succeeds', async () => {
mockLimit.mockResolvedValueOnce([])
await expect(assertSchemaCompatibility()).resolves.toBeUndefined()
expect(mockLimit).toHaveBeenCalledTimes(1)
})
it('throws immediately on an undefined-column mismatch without retrying', async () => {
mockLimit.mockRejectedValue(pgError('42703'))
await expect(assertSchemaCompatibility()).rejects.toThrow(/incompatible with the live database/)
expect(mockLimit).toHaveBeenCalledTimes(1)
expect(sleep).not.toHaveBeenCalled()
})
it('throws immediately on an undefined-table mismatch', async () => {
mockLimit.mockRejectedValue(pgError('42P01'))
await expect(assertSchemaCompatibility()).rejects.toThrow(/incompatible with the live database/)
expect(mockLimit).toHaveBeenCalledTimes(1)
})
it('detects a schema mismatch wrapped in error.cause and fails fast', async () => {
mockLimit.mockRejectedValue(wrappedPgError('42703'))
await expect(assertSchemaCompatibility()).rejects.toThrow(/incompatible with the live database/)
expect(mockLimit).toHaveBeenCalledTimes(1)
expect(sleep).not.toHaveBeenCalled()
})
it('retries transient connection errors and resolves once reachable', async () => {
mockLimit
.mockRejectedValueOnce(pgError('ECONNREFUSED'))
.mockRejectedValueOnce(pgError('ECONNREFUSED'))
.mockResolvedValueOnce([])
await expect(assertSchemaCompatibility()).resolves.toBeUndefined()
expect(mockLimit).toHaveBeenCalledTimes(3)
expect(sleep).toHaveBeenCalledTimes(2)
})
it('throws after exhausting retries when the database stays unreachable', async () => {
mockLimit.mockRejectedValue(pgError('ECONNREFUSED'))
await expect(assertSchemaCompatibility()).rejects.toThrow(/database unreachable/)
expect(mockLimit).toHaveBeenCalledTimes(5)
expect(sleep).toHaveBeenCalledTimes(4)
})
})
+98
View File
@@ -0,0 +1,98 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { backoffWithJitter } from '@sim/utils/retry'
const logger = createLogger('SocketPreflight')
/**
* Maximum attempts for the schema canary when the database is merely unreachable.
* Connection-class failures are retried; schema-class failures fail immediately.
*/
const MAX_CONNECT_ATTEMPTS = 5
/**
* Postgres SQLSTATE codes meaning the deployed image's compiled schema disagrees
* with the live database (undefined column, table, or function). These never
* self-heal, so retrying only delays an inevitable startup failure.
*/
const SCHEMA_MISMATCH_CODES = new Set(['42703', '42P01', '42883'])
/**
* Walks the `cause` chain so a SQLSTATE code is found even when drizzle wraps the
* driver error (the code commonly lives on the inner `cause`, not the outer throw).
*/
function isSchemaMismatch(error: unknown): boolean {
const seen = new Set<unknown>()
let current: unknown = error
while (current && typeof current === 'object' && !seen.has(current)) {
seen.add(current)
const code = (current as { code?: unknown }).code
if (typeof code === 'string' && SCHEMA_MISMATCH_CODES.has(code)) {
return true
}
current = (current as { cause?: unknown }).cause
}
return false
}
/**
* Verifies, before the server accepts traffic, that the deployed image's schema
* is compatible with the live database — throwing if it is not.
*
* Every socket is authorized against the `workflow` table through a full-row
* drizzle projection. If the image's compiled schema is ahead of (or behind) the
* database — e.g. a column dropped by a migration the image predates — that query
* fails on every request and silently breaks persistence, yet the process stays
* up and the shallow `/health` probe keeps returning 200. The fleet looks healthy
* while serving nothing.
*
* Running one representative query at startup turns that latent, per-request
* failure into an immediate startup failure: the throw propagates to the server
* entrypoint, the task exits non-zero and never becomes healthy, and the deploy's
* health gate never flips — so CodeDeploy auto-rolls-back instead of shifting
* traffic onto broken tasks.
*
* Deliberately invoked once at startup and never from the per-probe load-balancer
* health check: a deep dependency check on every probe would let a transient
* database blip mass-terminate the whole fleet (cascading failure).
*
* @throws when the schema is incompatible, or the database stays unreachable
* across {@link MAX_CONNECT_ATTEMPTS} attempts.
*/
export async function assertSchemaCompatibility(): Promise<void> {
let lastError: unknown
for (let attempt = 1; attempt <= MAX_CONNECT_ATTEMPTS; attempt++) {
try {
await db.select().from(workflow).limit(1)
logger.info('Schema-compatibility check passed')
return
} catch (error) {
lastError = error
if (isSchemaMismatch(error)) {
throw new Error(
`Deployed image is incompatible with the live database schema: ${getErrorMessage(error)}`
)
}
if (attempt === MAX_CONNECT_ATTEMPTS) {
break
}
const delay = backoffWithJitter(attempt, null)
logger.warn(
`Schema-compatibility check could not reach the database (attempt ${attempt}/${MAX_CONNECT_ATTEMPTS}), retrying in ${Math.round(delay)}ms`,
getErrorMessage(error)
)
await sleep(delay)
}
}
throw new Error(
`Schema-compatibility check failed after ${MAX_CONNECT_ATTEMPTS} attempts — database unreachable: ${getErrorMessage(lastError)}`
)
}
+50
View File
@@ -0,0 +1,50 @@
import { z } from 'zod'
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
DATABASE_URL: z.string().url(),
DATABASE_URL_REALTIME: z.string().url().optional(),
DATABASE_REPLICA_URL_REALTIME: z.string().url().optional(),
REDIS_URL: z.preprocess(
(value) => (typeof value === 'string' && value.trim() === '' ? undefined : value),
z.string().url().optional()
),
BETTER_AUTH_URL: z.string().url(),
BETTER_AUTH_SECRET: z.string().min(32),
INTERNAL_API_SECRET: z.string().min(32),
NEXT_PUBLIC_APP_URL: z.string().url(),
ALLOWED_ORIGINS: z.string().optional(),
PORT: z.coerce.number().int().positive().default(3002),
SIM_DB_ROLE: z.enum(['web', 'trigger', 'realtime']).optional(),
DISABLE_AUTH: z
.string()
.optional()
.transform((value) => value === 'true' || value === '1'),
})
function parseEnv() {
const parsed = EnvSchema.safeParse(process.env)
if (!parsed.success) {
const formatted = z.treeifyError(parsed.error)
throw new Error(`Invalid realtime server environment: ${JSON.stringify(formatted, null, 2)}`)
}
return parsed.data
}
export const env = parseEnv()
export const isProd = env.NODE_ENV === 'production'
export const isDev = env.NODE_ENV === 'development'
export const isTest = env.NODE_ENV === 'test'
let appHostname = ''
try {
appHostname = new URL(env.NEXT_PUBLIC_APP_URL).hostname
} catch {}
export const isHosted = appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai')
export const isAuthDisabled = env.DISABLE_AUTH === true && !isHosted
export function getBaseUrl(): string {
return env.NEXT_PUBLIC_APP_URL
}
+37
View File
@@ -0,0 +1,37 @@
import { createLogger } from '@sim/logger'
import { cleanupPendingSubblocksForSocket } from '@/handlers/subblocks'
import { cleanupPendingVariablesForSocket } from '@/handlers/variables'
import type { AuthenticatedSocket } from '@/middleware/auth'
import type { IRoomManager } from '@/rooms'
const logger = createLogger('ConnectionHandlers')
export function setupConnectionHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) {
socket.on('error', (error) => {
logger.error(`Socket ${socket.id} error:`, error)
})
socket.conn.on('error', (error) => {
logger.error(`Socket ${socket.id} connection error:`, error)
})
socket.on('disconnect', async (reason) => {
try {
// Clean up pending debounce entries for this socket to prevent memory leaks
cleanupPendingSubblocksForSocket(socket.id)
cleanupPendingVariablesForSocket(socket.id)
const workflowIdHint = [...socket.rooms].find((roomId) => roomId !== socket.id)
const workflowId = await roomManager.removeUserFromRoom(socket.id, workflowIdHint)
if (workflowId) {
await roomManager.broadcastPresenceUpdate(workflowId)
logger.info(
`Socket ${socket.id} disconnected from workflow ${workflowId} (reason: ${reason})`
)
}
} catch (error) {
logger.error(`Error handling disconnect for socket ${socket.id}:`, error)
}
})
}
+17
View File
@@ -0,0 +1,17 @@
import { setupConnectionHandlers } from '@/handlers/connection'
import { setupOperationsHandlers } from '@/handlers/operations'
import { setupPresenceHandlers } from '@/handlers/presence'
import { setupSubblocksHandlers } from '@/handlers/subblocks'
import { setupVariablesHandlers } from '@/handlers/variables'
import { setupWorkflowHandlers } from '@/handlers/workflow'
import type { AuthenticatedSocket } from '@/middleware/auth'
import type { IRoomManager } from '@/rooms'
export function setupAllHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) {
setupWorkflowHandlers(socket, roomManager)
setupOperationsHandlers(socket, roomManager)
setupSubblocksHandlers(socket, roomManager)
setupVariablesHandlers(socket, roomManager)
setupPresenceHandlers(socket, roomManager)
setupConnectionHandlers(socket, roomManager)
}
+654
View File
@@ -0,0 +1,654 @@
import { createLogger } from '@sim/logger'
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
import {
BLOCK_OPERATIONS,
BLOCKS_OPERATIONS,
EDGES_OPERATIONS,
OPERATION_TARGETS,
VARIABLE_OPERATIONS,
type VariableOperation,
WORKFLOW_OPERATIONS,
} from '@sim/realtime-protocol/constants'
import { WorkflowOperationSchema } from '@sim/realtime-protocol/schemas'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { ZodError } from 'zod'
import { persistWorkflowOperation } from '@/database/operations'
import type { AuthenticatedSocket } from '@/middleware/auth'
import { checkWorkflowOperationPermission } from '@/middleware/permissions'
import type { IRoomManager, UserSession } from '@/rooms'
const logger = createLogger('OperationsHandlers')
export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) {
socket.on('workflow-operation', async (data) => {
const emitOperationError = (
forbidden: { type: string; message: string; operation?: string; target?: string },
failed?: { error: string; retryable?: boolean }
) => {
socket.emit('operation-forbidden', forbidden)
if (failed && data?.operationId) {
socket.emit('operation-failed', { operationId: data.operationId, ...failed })
}
}
if (!roomManager.isReady()) {
emitOperationError(
{ type: 'ROOM_MANAGER_UNAVAILABLE', message: 'Realtime unavailable' },
{ error: 'Realtime unavailable', retryable: true }
)
return
}
let workflowId: string | null = null
let session: UserSession | null = null
try {
workflowId = await roomManager.getWorkflowIdForSocket(socket.id)
session = await roomManager.getUserSession(socket.id)
} catch (error) {
logger.error('Error loading session for workflow operation:', error)
emitOperationError(
{ type: 'ROOM_MANAGER_UNAVAILABLE', message: 'Realtime unavailable' },
{ error: 'Realtime unavailable', retryable: true }
)
return
}
if (!workflowId || !session) {
emitOperationError(
{ type: 'SESSION_ERROR', message: 'Session expired, please rejoin workflow' },
{ error: 'Session expired' }
)
return
}
let hasRoom = false
try {
hasRoom = await roomManager.hasWorkflowRoom(workflowId)
} catch (error) {
logger.error('Error checking workflow room:', error)
emitOperationError(
{ type: 'ROOM_MANAGER_UNAVAILABLE', message: 'Realtime unavailable' },
{ error: 'Realtime unavailable', retryable: true }
)
return
}
if (!hasRoom) {
emitOperationError(
{ type: 'ROOM_NOT_FOUND', message: 'Workflow room not found' },
{ error: 'Workflow room not found' }
)
return
}
let operationId: string | undefined
try {
const validatedOperation = WorkflowOperationSchema.parse(data)
operationId = validatedOperation.operationId
const { operation, target, payload, timestamp } = validatedOperation
// For position updates, preserve client timestamp to maintain ordering
// For other operations, use server timestamp for consistency
const isPositionUpdate =
operation === BLOCK_OPERATIONS.UPDATE_POSITION && target === OPERATION_TARGETS.BLOCK
const commitPositionUpdate =
isPositionUpdate && 'commit' in payload ? payload.commit === true : false
const operationTimestamp = isPositionUpdate ? timestamp : Date.now()
// Get user presence for permission checking
const users = await roomManager.getWorkflowUsers(workflowId)
const userPresence = users.find((u) => u.socketId === socket.id)
// Skip permission checks for non-committed position updates (broadcasts only, no persistence)
if (isPositionUpdate && !commitPositionUpdate) {
// Update last activity
if (userPresence) {
await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() })
}
} else {
// Check permissions from cached role for all other operations
if (!userPresence) {
logger.warn(`User presence not found for socket ${socket.id}`)
emitOperationError(
{
type: 'SESSION_ERROR',
message: 'User session not found',
operation,
target,
},
{ error: 'User session not found' }
)
return
}
await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() })
// Re-validate the workspace role against the DB (cached per pod for a short
// window) so revoked or downgraded collaborators lose write access live.
const permissionCheck = await checkWorkflowOperationPermission(
session.userId,
workflowId,
operation,
userPresence.role
)
if (!permissionCheck.allowed) {
logger.warn(
`User ${session.userId} (role: ${permissionCheck.role ?? 'none'}) forbidden from ${operation} on ${target}`
)
emitOperationError({
type: 'INSUFFICIENT_PERMISSIONS',
message: `${permissionCheck.reason} on '${target}'`,
operation,
target,
})
return
}
}
try {
await assertWorkflowMutable(workflowId)
} catch (error) {
if (error instanceof WorkflowLockedError) {
emitOperationError(
{
type: 'WORKFLOW_LOCKED',
message: error.message,
operation,
target,
},
{ error: error.message, retryable: false }
)
return
}
throw error
}
// Broadcast first for position updates to minimize latency, then persist
// For other operations, persist first for consistency
if (isPositionUpdate) {
// Broadcast position updates immediately for smooth real-time movement
const broadcastData = {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: {
workflowId,
operationId: generateId(),
isPositionUpdate: true,
},
}
socket.to(workflowId).emit('workflow-operation', broadcastData)
if (!commitPositionUpdate) {
return
}
try {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
await roomManager.updateRoomLastModified(workflowId)
if (operationId) {
socket.emit('operation-confirmed', {
operationId,
serverTimestamp: Date.now(),
})
}
} catch (error) {
logger.error('Failed to persist position update:', error)
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: getErrorMessage(error, 'Database persistence failed'),
retryable: true,
})
}
}
return
}
if (
target === OPERATION_TARGETS.BLOCKS &&
operation === BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS
) {
socket.to(workflowId).emit('workflow-operation', {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: { workflowId, operationId: generateId(), isBatchPositionUpdate: true },
})
try {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
await roomManager.updateRoomLastModified(workflowId)
if (operationId) {
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
}
} catch (error) {
logger.error('Failed to persist batch position update:', error)
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: getErrorMessage(error, 'Database persistence failed'),
retryable: true,
})
}
}
return
}
if (
target === OPERATION_TARGETS.VARIABLE &&
([VARIABLE_OPERATIONS.ADD, VARIABLE_OPERATIONS.REMOVE] as VariableOperation[]).includes(
operation as VariableOperation
)
) {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
await roomManager.updateRoomLastModified(workflowId)
const broadcastData = {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: {
workflowId,
operationId: generateId(),
},
}
socket.to(workflowId).emit('workflow-operation', broadcastData)
if (operationId) {
socket.emit('operation-confirmed', {
operationId,
serverTimestamp: Date.now(),
})
}
return
}
if (
target === OPERATION_TARGETS.WORKFLOW &&
operation === WORKFLOW_OPERATIONS.REPLACE_STATE
) {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
await roomManager.updateRoomLastModified(workflowId)
const broadcastData = {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: {
workflowId,
operationId: generateId(),
},
}
socket.to(workflowId).emit('workflow-operation', broadcastData)
if (operationId) {
socket.emit('operation-confirmed', {
operationId,
serverTimestamp: Date.now(),
})
}
return
}
if (target === OPERATION_TARGETS.BLOCKS && operation === BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS) {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
await roomManager.updateRoomLastModified(workflowId)
socket.to(workflowId).emit('workflow-operation', {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: { workflowId, operationId: generateId() },
})
if (operationId) {
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
}
return
}
if (
target === OPERATION_TARGETS.BLOCKS &&
operation === BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS
) {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
await roomManager.updateRoomLastModified(workflowId)
socket.to(workflowId).emit('workflow-operation', {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: { workflowId, operationId: generateId() },
})
if (operationId) {
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
}
return
}
if (target === OPERATION_TARGETS.EDGES && operation === EDGES_OPERATIONS.BATCH_REMOVE_EDGES) {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
await roomManager.updateRoomLastModified(workflowId)
socket.to(workflowId).emit('workflow-operation', {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: { workflowId, operationId: generateId() },
})
if (operationId) {
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
}
return
}
if (
target === OPERATION_TARGETS.BLOCKS &&
operation === BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED
) {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
await roomManager.updateRoomLastModified(workflowId)
socket.to(workflowId).emit('workflow-operation', {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: { workflowId, operationId: generateId() },
})
if (operationId) {
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
}
return
}
if (
target === OPERATION_TARGETS.BLOCKS &&
operation === BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES
) {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
await roomManager.updateRoomLastModified(workflowId)
socket.to(workflowId).emit('workflow-operation', {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: { workflowId, operationId: generateId() },
})
if (operationId) {
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
}
return
}
if (
target === OPERATION_TARGETS.BLOCKS &&
operation === BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT
) {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
await roomManager.updateRoomLastModified(workflowId)
socket.to(workflowId).emit('workflow-operation', {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: { workflowId, operationId: generateId() },
})
if (operationId) {
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
}
return
}
if (target === OPERATION_TARGETS.EDGES && operation === EDGES_OPERATIONS.BATCH_ADD_EDGES) {
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
await roomManager.updateRoomLastModified(workflowId)
socket.to(workflowId).emit('workflow-operation', {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: { workflowId, operationId: generateId() },
})
if (operationId) {
socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() })
}
return
}
// For non-position operations, persist first then broadcast
await persistWorkflowOperation(workflowId, {
operation,
target,
payload,
timestamp: operationTimestamp,
userId: session.userId,
})
await roomManager.updateRoomLastModified(workflowId)
const broadcastData = {
operation,
target,
payload,
timestamp: operationTimestamp,
senderId: socket.id,
userId: session.userId,
userName: session.userName,
metadata: {
workflowId,
operationId: generateId(),
},
}
socket.to(workflowId).emit('workflow-operation', broadcastData)
if (operationId) {
socket.emit('operation-confirmed', {
operationId,
serverTimestamp: Date.now(),
})
}
} catch (error) {
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: errorMessage,
retryable: !(error instanceof ZodError),
})
}
if (error instanceof ZodError) {
socket.emit('operation-error', {
type: 'VALIDATION_ERROR',
message: 'Invalid operation data',
errors: error.issues,
operation: data.operation,
target: data.target,
})
logger.warn(`Validation error for operation from ${session.userId}:`, error.issues)
} else if (error instanceof Error) {
if (error.message.includes('not found')) {
socket.emit('operation-error', {
type: 'RESOURCE_NOT_FOUND',
message: error.message,
operation: data.operation,
target: data.target,
})
} else if (error.message.includes('duplicate') || error.message.includes('unique')) {
socket.emit('operation-error', {
type: 'DUPLICATE_RESOURCE',
message: 'Resource already exists',
operation: data.operation,
target: data.target,
})
} else {
socket.emit('operation-error', {
type: 'OPERATION_FAILED',
message: error.message,
operation: data.operation,
target: data.target,
})
}
logger.error(
`Operation error for ${session.userId} (${data.operation} on ${data.target}):`,
error
)
} else {
socket.emit('operation-error', {
type: 'UNKNOWN_ERROR',
message: 'An unknown error occurred',
operation: data.operation,
target: data.target,
})
logger.error('Unknown error handling workflow operation:', error)
}
}
})
}
+53
View File
@@ -0,0 +1,53 @@
import { createLogger } from '@sim/logger'
import type { AuthenticatedSocket } from '@/middleware/auth'
import type { IRoomManager } from '@/rooms'
const logger = createLogger('PresenceHandlers')
export function setupPresenceHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) {
socket.on('cursor-update', async ({ cursor }) => {
try {
const workflowId = await roomManager.getWorkflowIdForSocket(socket.id)
const session = await roomManager.getUserSession(socket.id)
if (!workflowId || !session) return
// Update cursor in room state
await roomManager.updateUserActivity(workflowId, socket.id, { cursor })
// Broadcast to other users in the room
socket.to(workflowId).emit('cursor-update', {
socketId: socket.id,
userId: session.userId,
userName: session.userName,
avatarUrl: session.avatarUrl,
cursor,
})
} catch (error) {
logger.error(`Error handling cursor update for socket ${socket.id}:`, error)
}
})
socket.on('selection-update', async ({ selection }) => {
try {
const workflowId = await roomManager.getWorkflowIdForSocket(socket.id)
const session = await roomManager.getUserSession(socket.id)
if (!workflowId || !session) return
// Update selection in room state
await roomManager.updateUserActivity(workflowId, socket.id, { selection })
// Broadcast to other users in the room
socket.to(workflowId).emit('selection-update', {
socketId: socket.id,
userId: session.userId,
userName: session.userName,
avatarUrl: session.avatarUrl,
selection,
})
} catch (error) {
logger.error(`Error handling selection update for socket ${socket.id}:`, error)
}
})
}
+374
View File
@@ -0,0 +1,374 @@
import { db } from '@sim/db'
import { workflow, workflowBlocks } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
import { SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants'
import { getErrorMessage } from '@sim/utils/errors'
import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow'
import { and, eq } from 'drizzle-orm'
import type { AuthenticatedSocket } from '@/middleware/auth'
import { checkWorkflowOperationPermission } from '@/middleware/permissions'
import type { IRoomManager } from '@/rooms'
const logger = createLogger('SubblocksHandlers')
/** Debounce interval for coalescing rapid subblock updates before persisting */
const DEBOUNCE_INTERVAL_MS = 25
type PendingSubblock = {
latest: { blockId: string; subblockId: string; value: any; timestamp: number }
timeout: NodeJS.Timeout
// Map operationId -> socketId to emit confirmations/failures to correct clients
opToSocket: Map<string, string>
}
// Keyed by `${workflowId}:${blockId}:${subblockId}`
const pendingSubblockUpdates = new Map<string, PendingSubblock>()
/**
* Cleans up pending updates for a disconnected socket.
* Removes the socket's operationIds from pending updates to prevent memory leaks.
*/
export function cleanupPendingSubblocksForSocket(socketId: string): void {
for (const [, pending] of pendingSubblockUpdates.entries()) {
// Remove this socket's operation entries
for (const [opId, sid] of pending.opToSocket.entries()) {
if (sid === socketId) {
pending.opToSocket.delete(opId)
}
}
// If no more operations are waiting, the timeout will still fire and flush
// This is fine - the update will still persist, just no confirmation to send
}
}
export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) {
socket.on('subblock-update', async (data) => {
const {
workflowId: payloadWorkflowId,
blockId,
subblockId,
value,
timestamp,
operationId,
} = data
if (!roomManager.isReady()) {
socket.emit('operation-forbidden', {
type: 'ROOM_MANAGER_UNAVAILABLE',
message: 'Realtime unavailable',
})
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: 'Realtime unavailable',
retryable: true,
})
}
return
}
try {
const sessionWorkflowId = await roomManager.getWorkflowIdForSocket(socket.id)
const session = await roomManager.getUserSession(socket.id)
if (!sessionWorkflowId || !session) {
logger.debug(`Ignoring subblock update: socket not connected to any workflow room`, {
socketId: socket.id,
hasWorkflowId: !!sessionWorkflowId,
hasSession: !!session,
})
socket.emit('operation-forbidden', {
type: 'SESSION_ERROR',
message: 'Session expired, please rejoin workflow',
})
if (operationId) {
socket.emit('operation-failed', { operationId, error: 'Session expired' })
}
return
}
const workflowId = payloadWorkflowId || sessionWorkflowId
if (payloadWorkflowId && payloadWorkflowId !== sessionWorkflowId) {
logger.warn('Workflow ID mismatch in subblock update', {
payloadWorkflowId,
sessionWorkflowId,
socketId: socket.id,
})
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: 'Workflow ID mismatch',
retryable: true,
})
}
return
}
const hasRoom = await roomManager.hasWorkflowRoom(workflowId)
if (!hasRoom) {
logger.debug(`Ignoring subblock update: workflow room not found`, {
socketId: socket.id,
workflowId,
blockId,
subblockId,
})
return
}
const users = await roomManager.getWorkflowUsers(workflowId)
const userPresence = users.find((user) => user.socketId === socket.id)
if (!userPresence) {
socket.emit('operation-forbidden', {
type: 'SESSION_ERROR',
message: 'User session not found',
operation: SUBBLOCK_OPERATIONS.UPDATE,
target: 'subblock',
})
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: 'User session not found',
retryable: true,
})
}
return
}
const permissionCheck = await checkWorkflowOperationPermission(
session.userId,
workflowId,
SUBBLOCK_OPERATIONS.UPDATE,
userPresence.role
)
if (!permissionCheck.allowed) {
socket.emit('operation-forbidden', {
type: 'INSUFFICIENT_PERMISSIONS',
message: permissionCheck.reason || 'Insufficient permissions',
operation: SUBBLOCK_OPERATIONS.UPDATE,
target: 'subblock',
})
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: permissionCheck.reason || 'Insufficient permissions',
retryable: false,
})
}
return
}
try {
await assertWorkflowMutable(workflowId)
} catch (error) {
if (error instanceof WorkflowLockedError) {
socket.emit('operation-forbidden', {
type: 'WORKFLOW_LOCKED',
message: error.message,
operation: SUBBLOCK_OPERATIONS.UPDATE,
target: 'subblock',
})
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: error.message,
retryable: false,
})
}
return
}
throw error
}
// Update user activity
await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() })
// Server-side debounce/coalesce by workflowId+blockId+subblockId
const debouncedKey = `${workflowId}:${blockId}:${subblockId}`
const existing = pendingSubblockUpdates.get(debouncedKey)
if (existing) {
clearTimeout(existing.timeout)
existing.latest = { blockId, subblockId, value, timestamp }
if (operationId) existing.opToSocket.set(operationId, socket.id)
existing.timeout = setTimeout(async () => {
await flushSubblockUpdate(workflowId, existing, roomManager)
pendingSubblockUpdates.delete(debouncedKey)
}, DEBOUNCE_INTERVAL_MS)
} else {
const opToSocket = new Map<string, string>()
if (operationId) opToSocket.set(operationId, socket.id)
const timeout = setTimeout(async () => {
const pending = pendingSubblockUpdates.get(debouncedKey)
if (pending) {
await flushSubblockUpdate(workflowId, pending, roomManager)
pendingSubblockUpdates.delete(debouncedKey)
}
}, DEBOUNCE_INTERVAL_MS)
pendingSubblockUpdates.set(debouncedKey, {
latest: { blockId, subblockId, value, timestamp },
timeout,
opToSocket,
})
}
} catch (error) {
logger.error('Error handling subblock update:', error)
const errorMessage = getErrorMessage(error, 'Unknown error')
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: errorMessage,
retryable: true,
})
}
socket.emit('operation-error', {
type: 'SUBBLOCK_UPDATE_FAILED',
message: `Failed to update subblock ${blockId}.${subblockId}: ${errorMessage}`,
operation: 'subblock-update',
target: 'subblock',
})
}
})
}
async function flushSubblockUpdate(
workflowId: string,
pending: PendingSubblock,
roomManager: IRoomManager
) {
const { blockId, subblockId, value, timestamp } = pending.latest
const io = roomManager.io
try {
// Verify workflow still exists
const workflowExists = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (workflowExists.length === 0) {
pending.opToSocket.forEach((socketId, opId) => {
io.to(socketId).emit('operation-failed', {
operationId: opId,
error: 'Workflow not found',
retryable: true,
})
})
return
}
try {
await assertWorkflowMutable(workflowId)
} catch (error) {
if (error instanceof WorkflowLockedError) {
pending.opToSocket.forEach((socketId, opId) => {
io.to(socketId).emit('operation-failed', {
operationId: opId,
error: error.message,
retryable: false,
})
})
return
}
throw error
}
let updateSuccessful = false
let blockLocked = false
await db.transaction(async (tx) => {
const allBlocks = await tx
.select({
id: workflowBlocks.id,
subBlocks: workflowBlocks.subBlocks,
locked: workflowBlocks.locked,
data: workflowBlocks.data,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
type SubblockUpdateBlockRecord = (typeof allBlocks)[number]
const blocksById: Record<string, SubblockUpdateBlockRecord> = Object.fromEntries(
allBlocks.map((block: SubblockUpdateBlockRecord) => [block.id, block])
)
const block = blocksById[blockId]
if (!block) {
return
}
if (isWorkflowBlockProtected(blockId, blocksById)) {
logger.info(
`Skipping subblock update - block ${blockId} is locked or inside a locked container`
)
blockLocked = true
return
}
const subBlocks = (block.subBlocks as any) || {}
if (!subBlocks[subblockId]) {
subBlocks[subblockId] = { id: subblockId, type: 'unknown', value }
} else {
subBlocks[subblockId] = { ...subBlocks[subblockId], value }
}
await tx
.update(workflowBlocks)
.set({ subBlocks, updatedAt: new Date() })
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
updateSuccessful = true
})
if (updateSuccessful) {
// Broadcast to room excluding all senders (works cross-pod via Redis adapter)
const senderSocketIds = [...pending.opToSocket.values()]
const broadcastPayload = {
workflowId,
blockId,
subblockId,
value,
timestamp,
}
if (senderSocketIds.length > 0) {
io.to(workflowId).except(senderSocketIds).emit('subblock-update', broadcastPayload)
} else {
io.to(workflowId).emit('subblock-update', broadcastPayload)
}
// Confirm all coalesced operationIds (io.to(socketId) works cross-pod)
pending.opToSocket.forEach((socketId, opId) => {
io.to(socketId).emit('operation-confirmed', {
operationId: opId,
serverTimestamp: Date.now(),
})
})
} else if (blockLocked) {
pending.opToSocket.forEach((socketId, opId) => {
io.to(socketId).emit('operation-confirmed', {
operationId: opId,
serverTimestamp: Date.now(),
})
})
} else {
pending.opToSocket.forEach((socketId, opId) => {
io.to(socketId).emit('operation-failed', {
operationId: opId,
error: 'Block no longer exists',
retryable: true,
})
})
}
} catch (error) {
logger.error('Error flushing subblock update:', error)
pending.opToSocket.forEach((socketId, opId) => {
io.to(socketId).emit('operation-failed', {
operationId: opId,
error: getErrorMessage(error, 'Unknown error'),
retryable: true,
})
})
}
}
+365
View File
@@ -0,0 +1,365 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
import { VARIABLE_OPERATIONS } from '@sim/realtime-protocol/constants'
import { getErrorMessage } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import type { AuthenticatedSocket } from '@/middleware/auth'
import { checkWorkflowOperationPermission } from '@/middleware/permissions'
import type { IRoomManager } from '@/rooms'
const logger = createLogger('VariablesHandlers')
/** Debounce interval for coalescing rapid variable updates before persisting */
const DEBOUNCE_INTERVAL_MS = 25
type PendingVariable = {
latest: { variableId: string; field: string; value: any; timestamp: number }
timeout: NodeJS.Timeout
opToSocket: Map<string, string>
/** Most recent writer, used as the audit actor when the coalesced update is persisted. */
actorId: string
actorName?: string
}
// Keyed by `${workflowId}:${variableId}:${field}`
const pendingVariableUpdates = new Map<string, PendingVariable>()
/**
* Cleans up pending updates for a disconnected socket.
* Removes the socket's operationIds from pending updates to prevent memory leaks.
*/
export function cleanupPendingVariablesForSocket(socketId: string): void {
for (const [, pending] of pendingVariableUpdates.entries()) {
for (const [opId, sid] of pending.opToSocket.entries()) {
if (sid === socketId) {
pending.opToSocket.delete(opId)
}
}
}
}
export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) {
socket.on('variable-update', async (data) => {
const { workflowId: payloadWorkflowId, variableId, field, value, timestamp, operationId } = data
if (!roomManager.isReady()) {
socket.emit('operation-forbidden', {
type: 'ROOM_MANAGER_UNAVAILABLE',
message: 'Realtime unavailable',
})
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: 'Realtime unavailable',
retryable: true,
})
}
return
}
try {
const sessionWorkflowId = await roomManager.getWorkflowIdForSocket(socket.id)
const session = await roomManager.getUserSession(socket.id)
if (!sessionWorkflowId || !session) {
logger.debug(`Ignoring variable update: socket not connected to any workflow room`, {
socketId: socket.id,
hasWorkflowId: !!sessionWorkflowId,
hasSession: !!session,
})
socket.emit('operation-forbidden', {
type: 'SESSION_ERROR',
message: 'Session expired, please rejoin workflow',
})
if (operationId) {
socket.emit('operation-failed', { operationId, error: 'Session expired' })
}
return
}
const workflowId = payloadWorkflowId || sessionWorkflowId
if (payloadWorkflowId && payloadWorkflowId !== sessionWorkflowId) {
logger.warn('Workflow ID mismatch in variable update', {
payloadWorkflowId,
sessionWorkflowId,
socketId: socket.id,
})
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: 'Workflow ID mismatch',
retryable: true,
})
}
return
}
const hasRoom = await roomManager.hasWorkflowRoom(workflowId)
if (!hasRoom) {
logger.debug(`Ignoring variable update: workflow room not found`, {
socketId: socket.id,
workflowId,
variableId,
field,
})
return
}
const users = await roomManager.getWorkflowUsers(workflowId)
const userPresence = users.find((user) => user.socketId === socket.id)
if (!userPresence) {
socket.emit('operation-forbidden', {
type: 'SESSION_ERROR',
message: 'User session not found',
operation: VARIABLE_OPERATIONS.UPDATE,
target: 'variable',
})
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: 'User session not found',
retryable: true,
})
}
return
}
const permissionCheck = await checkWorkflowOperationPermission(
session.userId,
workflowId,
VARIABLE_OPERATIONS.UPDATE,
userPresence.role
)
if (!permissionCheck.allowed) {
socket.emit('operation-forbidden', {
type: 'INSUFFICIENT_PERMISSIONS',
message: permissionCheck.reason || 'Insufficient permissions',
operation: VARIABLE_OPERATIONS.UPDATE,
target: 'variable',
})
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: permissionCheck.reason || 'Insufficient permissions',
retryable: false,
})
}
return
}
try {
await assertWorkflowMutable(workflowId)
} catch (error) {
if (error instanceof WorkflowLockedError) {
socket.emit('operation-forbidden', {
type: 'WORKFLOW_LOCKED',
message: error.message,
operation: VARIABLE_OPERATIONS.UPDATE,
target: 'variable',
})
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: error.message,
retryable: false,
})
}
return
}
throw error
}
// Update user activity
await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() })
const debouncedKey = `${workflowId}:${variableId}:${field}`
const existing = pendingVariableUpdates.get(debouncedKey)
if (existing) {
clearTimeout(existing.timeout)
existing.latest = { variableId, field, value, timestamp }
existing.actorId = session.userId
existing.actorName = session.userName
if (operationId) existing.opToSocket.set(operationId, socket.id)
existing.timeout = setTimeout(async () => {
await flushVariableUpdate(workflowId, existing, roomManager)
pendingVariableUpdates.delete(debouncedKey)
}, DEBOUNCE_INTERVAL_MS)
} else {
const opToSocket = new Map<string, string>()
if (operationId) opToSocket.set(operationId, socket.id)
const timeout = setTimeout(async () => {
const pending = pendingVariableUpdates.get(debouncedKey)
if (pending) {
await flushVariableUpdate(workflowId, pending, roomManager)
pendingVariableUpdates.delete(debouncedKey)
}
}, DEBOUNCE_INTERVAL_MS)
pendingVariableUpdates.set(debouncedKey, {
latest: { variableId, field, value, timestamp },
timeout,
opToSocket,
actorId: session.userId,
actorName: session.userName,
})
}
} catch (error) {
logger.error('Error handling variable update:', error)
const errorMessage = getErrorMessage(error, 'Unknown error')
if (operationId) {
socket.emit('operation-failed', {
operationId,
error: errorMessage,
retryable: true,
})
}
socket.emit('operation-error', {
type: 'VARIABLE_UPDATE_FAILED',
message: `Failed to update variable ${variableId}.${field}: ${errorMessage}`,
operation: 'variable-update',
target: 'variable',
})
}
})
}
async function flushVariableUpdate(
workflowId: string,
pending: PendingVariable,
roomManager: IRoomManager
) {
const { variableId, field, value, timestamp } = pending.latest
const io = roomManager.io
try {
const workflowExists = await db
.select({
id: workflow.id,
name: workflow.name,
workspaceId: workflow.workspaceId,
})
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (workflowExists.length === 0) {
pending.opToSocket.forEach((socketId, opId) => {
io.to(socketId).emit('operation-failed', {
operationId: opId,
error: 'Workflow not found',
retryable: true,
})
})
return
}
try {
await assertWorkflowMutable(workflowId)
} catch (error) {
if (error instanceof WorkflowLockedError) {
pending.opToSocket.forEach((socketId, opId) => {
io.to(socketId).emit('operation-failed', {
operationId: opId,
error: error.message,
retryable: false,
})
})
return
}
throw error
}
let updateSuccessful = false
await db.transaction(async (tx) => {
const [workflowRecord] = await tx
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
return
}
const variables = (workflowRecord.variables as any) || {}
if (!variables[variableId]) {
return
}
variables[variableId] = {
...variables[variableId],
[field]: value,
}
await tx
.update(workflow)
.set({ variables, updatedAt: new Date() })
.where(eq(workflow.id, workflowId))
updateSuccessful = true
})
if (updateSuccessful) {
const workflowRow = workflowExists[0]
recordAudit({
workspaceId: workflowRow.workspaceId ?? null,
actorId: pending.actorId,
actorName: pending.actorName,
action: AuditAction.WORKFLOW_VARIABLES_UPDATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: workflowRow.name ?? undefined,
description: `Updated workflow variables`,
metadata: { variableId, field },
})
// Broadcast to room excluding all senders (works cross-pod via Redis adapter)
const senderSocketIds = [...pending.opToSocket.values()]
const broadcastPayload = {
workflowId,
variableId,
field,
value,
timestamp,
}
if (senderSocketIds.length > 0) {
io.to(workflowId).except(senderSocketIds).emit('variable-update', broadcastPayload)
} else {
io.to(workflowId).emit('variable-update', broadcastPayload)
}
// Confirm all coalesced operationIds (io.to(socketId) works cross-pod)
pending.opToSocket.forEach((socketId, opId) => {
io.to(socketId).emit('operation-confirmed', {
operationId: opId,
serverTimestamp: Date.now(),
})
})
logger.debug(`Flushed variable update ${workflowId}: ${variableId}.${field}`)
} else {
pending.opToSocket.forEach((socketId, opId) => {
io.to(socketId).emit('operation-failed', {
operationId: opId,
error: 'Variable no longer exists',
retryable: true,
})
})
}
} catch (error) {
logger.error('Error flushing variable update:', error)
pending.opToSocket.forEach((socketId, opId) => {
io.to(socketId).emit('operation-failed', {
operationId: opId,
error: getErrorMessage(error, 'Unknown error'),
retryable: true,
})
})
}
}
+194
View File
@@ -0,0 +1,194 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { IRoomManager } from '@/rooms'
const { mockGetWorkflowState, mockVerifyWorkflowAccess } = vi.hoisted(() => ({
mockGetWorkflowState: vi.fn(),
mockVerifyWorkflowAccess: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: { select: vi.fn() },
user: { image: 'image' },
}))
vi.mock('@/database/operations', () => ({
getWorkflowState: mockGetWorkflowState,
}))
vi.mock('@/middleware/permissions', () => ({
verifyWorkflowAccess: mockVerifyWorkflowAccess,
}))
import { setupWorkflowHandlers } from '@/handlers/workflow'
interface JoinWorkflowPayload {
workflowId: string
tabSessionId?: string
}
function createSocket(overrides?: Partial<Record<string, unknown>>) {
const handlers: Record<string, (payload: JoinWorkflowPayload) => Promise<void> | void> = {}
const socket = {
id: 'socket-1',
userId: 'user-1',
userName: 'Test User',
userImage: 'avatar.png',
on: vi.fn((event: string, handler: (payload: JoinWorkflowPayload) => Promise<void> | void) => {
handlers[event] = handler
}),
emit: vi.fn(),
join: vi.fn(),
leave: vi.fn(),
...overrides,
}
return {
handlers,
socket,
}
}
function createRoomManager(overrides?: Partial<IRoomManager>): IRoomManager {
return {
isReady: vi.fn().mockReturnValue(true),
getWorkflowIdForSocket: vi.fn().mockResolvedValue(null),
removeUserFromRoom: vi.fn().mockResolvedValue(null),
broadcastPresenceUpdate: vi.fn().mockResolvedValue(undefined),
getWorkflowUsers: vi.fn().mockResolvedValue([]),
hasWorkflowRoom: vi.fn().mockResolvedValue(false),
addUserToRoom: vi.fn().mockResolvedValue(undefined),
getUserSession: vi.fn().mockResolvedValue(null),
updateUserActivity: vi.fn().mockResolvedValue(undefined),
updateRoomLastModified: vi.fn().mockResolvedValue(undefined),
emitToWorkflow: vi.fn(),
getUniqueUserCount: vi.fn().mockResolvedValue(1),
getTotalActiveConnections: vi.fn().mockResolvedValue(0),
handleWorkflowDeletion: vi.fn().mockResolvedValue(undefined),
handleWorkflowRevert: vi.fn().mockResolvedValue(undefined),
handleWorkflowUpdate: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
initialize: vi.fn().mockResolvedValue(undefined),
io: {
in: vi.fn().mockReturnValue({
fetchSockets: vi.fn().mockResolvedValue([]),
socketsLeave: vi.fn().mockResolvedValue(undefined),
}),
},
...overrides,
} as unknown as IRoomManager
}
describe('setupWorkflowHandlers', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetWorkflowState.mockResolvedValue({ id: 'workflow-1', state: {} })
mockVerifyWorkflowAccess.mockResolvedValue({ hasAccess: true, role: 'admin' })
})
it('includes workflowId when authentication is missing', async () => {
const { socket, handlers } = createSocket({ userId: undefined, userName: undefined })
const roomManager = createRoomManager()
setupWorkflowHandlers(
socket as unknown as Parameters<typeof setupWorkflowHandlers>[0],
roomManager
)
await handlers['join-workflow']({ workflowId: 'workflow-1', tabSessionId: 'tab-1' })
expect(socket.emit).toHaveBeenCalledWith('join-workflow-error', {
workflowId: 'workflow-1',
error: 'Authentication required',
code: 'AUTHENTICATION_REQUIRED',
retryable: false,
})
})
it('includes workflowId when realtime is unavailable', async () => {
const { socket, handlers } = createSocket()
const roomManager = createRoomManager({
isReady: vi.fn().mockReturnValue(false),
})
setupWorkflowHandlers(
socket as unknown as Parameters<typeof setupWorkflowHandlers>[0],
roomManager
)
await handlers['join-workflow']({ workflowId: 'workflow-1', tabSessionId: 'tab-1' })
expect(socket.emit).toHaveBeenCalledWith('join-workflow-error', {
workflowId: 'workflow-1',
error: 'Realtime unavailable',
code: 'ROOM_MANAGER_UNAVAILABLE',
retryable: true,
})
})
it('includes workflowId when access is denied', async () => {
mockVerifyWorkflowAccess.mockResolvedValue({ hasAccess: false })
const { socket, handlers } = createSocket()
const roomManager = createRoomManager()
setupWorkflowHandlers(
socket as unknown as Parameters<typeof setupWorkflowHandlers>[0],
roomManager
)
await handlers['join-workflow']({ workflowId: 'workflow-1', tabSessionId: 'tab-1' })
expect(socket.emit).toHaveBeenCalledWith('join-workflow-error', {
workflowId: 'workflow-1',
error: 'Access denied to workflow',
code: 'ACCESS_DENIED',
retryable: false,
})
})
it('marks workflow access verification failures as retryable', async () => {
mockVerifyWorkflowAccess.mockRejectedValue(new Error('database unavailable'))
const { socket, handlers } = createSocket()
const roomManager = createRoomManager()
setupWorkflowHandlers(
socket as unknown as Parameters<typeof setupWorkflowHandlers>[0],
roomManager
)
await handlers['join-workflow']({ workflowId: 'workflow-1', tabSessionId: 'tab-1' })
expect(socket.emit).toHaveBeenCalledWith('join-workflow-error', {
workflowId: 'workflow-1',
error: 'Failed to verify workflow access',
code: 'VERIFY_WORKFLOW_ACCESS_FAILED',
retryable: true,
})
})
it('includes workflowId when an unexpected join failure occurs', async () => {
const { socket, handlers } = createSocket()
const roomManager = createRoomManager({
getWorkflowIdForSocket: vi.fn().mockRejectedValue(new Error('boom')),
removeUserFromRoom: vi.fn().mockResolvedValue(null),
})
setupWorkflowHandlers(
socket as unknown as Parameters<typeof setupWorkflowHandlers>[0],
roomManager
)
await handlers['join-workflow']({ workflowId: 'workflow-1', tabSessionId: 'tab-1' })
expect(socket.emit).toHaveBeenCalledWith('join-workflow-error', {
workflowId: 'workflow-1',
error: 'Failed to join workflow',
code: 'JOIN_WORKFLOW_FAILED',
retryable: true,
})
})
})
+227
View File
@@ -0,0 +1,227 @@
import { db, user } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getWorkflowState } from '@/database/operations'
import type { AuthenticatedSocket } from '@/middleware/auth'
import { verifyWorkflowAccess } from '@/middleware/permissions'
import type { IRoomManager, UserPresence } from '@/rooms'
const logger = createLogger('WorkflowHandlers')
export function setupWorkflowHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) {
socket.on('join-workflow', async ({ workflowId, tabSessionId }) => {
try {
const userId = socket.userId
const userName = socket.userName
if (!userId || !userName) {
logger.warn(`Join workflow rejected: Socket ${socket.id} not authenticated`)
socket.emit('join-workflow-error', {
workflowId,
error: 'Authentication required',
code: 'AUTHENTICATION_REQUIRED',
retryable: false,
})
return
}
if (!roomManager.isReady()) {
logger.warn(`Join workflow rejected: Room manager unavailable`)
socket.emit('join-workflow-error', {
workflowId,
error: 'Realtime unavailable',
code: 'ROOM_MANAGER_UNAVAILABLE',
retryable: true,
})
return
}
logger.info(`Join workflow request from ${userId} (${userName}) for workflow ${workflowId}`)
// Verify workflow access
let userRole: string
try {
const accessInfo = await verifyWorkflowAccess(userId, workflowId)
if (!accessInfo.hasAccess) {
logger.warn(`User ${userId} (${userName}) denied access to workflow ${workflowId}`)
socket.emit('join-workflow-error', {
workflowId,
error: 'Access denied to workflow',
code: 'ACCESS_DENIED',
retryable: false,
})
return
}
userRole = accessInfo.role || 'read'
} catch (error) {
logger.warn(`Error verifying workflow access for ${userId}:`, error)
socket.emit('join-workflow-error', {
workflowId,
error: 'Failed to verify workflow access',
code: 'VERIFY_WORKFLOW_ACCESS_FAILED',
retryable: true,
})
return
}
// Leave current room if in one
const currentWorkflowId = await roomManager.getWorkflowIdForSocket(socket.id)
if (currentWorkflowId) {
socket.leave(currentWorkflowId)
await roomManager.removeUserFromRoom(socket.id, currentWorkflowId)
await roomManager.broadcastPresenceUpdate(currentWorkflowId)
}
// Keep this above Redis socket key TTL (1h) so a normal idle user is not evicted too aggressively.
const STALE_THRESHOLD_MS = 75 * 60 * 1000
const now = Date.now()
const existingUsers = await roomManager.getWorkflowUsers(workflowId)
let liveSocketIds = new Set<string>()
let canCheckLiveness = false
try {
const liveSockets = await roomManager.io.in(workflowId).fetchSockets()
liveSocketIds = new Set(liveSockets.map((liveSocket) => liveSocket.id))
canCheckLiveness = true
} catch (error) {
logger.warn(
`Skipping stale cleanup for ${workflowId} due to live socket lookup failure`,
error
)
}
for (const existingUser of existingUsers) {
try {
if (existingUser.socketId === socket.id) {
continue
}
const isSameTab = Boolean(
existingUser.userId === userId &&
tabSessionId &&
existingUser.tabSessionId === tabSessionId
)
if (isSameTab) {
logger.info(
`Cleaning up socket ${existingUser.socketId} for user ${existingUser.userId} (same tab)`
)
await roomManager.removeUserFromRoom(existingUser.socketId, workflowId)
await roomManager.io.in(existingUser.socketId).socketsLeave(workflowId)
continue
}
if (!canCheckLiveness || liveSocketIds.has(existingUser.socketId)) {
continue
}
const isStaleByActivity =
now - (existingUser.lastActivity || existingUser.joinedAt || 0) > STALE_THRESHOLD_MS
if (!isStaleByActivity) {
continue
}
logger.info(
`Cleaning up socket ${existingUser.socketId} for user ${existingUser.userId} (stale activity)`
)
await roomManager.removeUserFromRoom(existingUser.socketId, workflowId)
await roomManager.io.in(existingUser.socketId).socketsLeave(workflowId)
} catch (error) {
logger.warn(`Best-effort cleanup failed for socket ${existingUser.socketId}`, error)
}
}
// Join the new room
socket.join(workflowId)
// Get avatar URL
let avatarUrl = socket.userImage || null
if (!avatarUrl) {
try {
const [userRecord] = await db
.select({ image: user.image })
.from(user)
.where(eq(user.id, userId))
.limit(1)
avatarUrl = userRecord?.image ?? null
} catch (error) {
logger.warn('Failed to load user avatar for presence', { userId, error })
}
}
// Create presence entry
const userPresence: UserPresence = {
userId,
workflowId,
userName,
socketId: socket.id,
tabSessionId,
joinedAt: Date.now(),
lastActivity: Date.now(),
role: userRole,
avatarUrl,
}
// Add user to room
await roomManager.addUserToRoom(workflowId, socket.id, userPresence)
// Get current presence list for the join acknowledgment
const presenceUsers = await roomManager.getWorkflowUsers(workflowId)
// Get workflow state
const workflowState = await getWorkflowState(workflowId)
// Send join success with presence list (client waits for this to confirm join)
socket.emit('join-workflow-success', {
workflowId,
socketId: socket.id,
presenceUsers,
})
// Send workflow state
socket.emit('workflow-state', workflowState)
// Broadcast presence update to all users in the room
await roomManager.broadcastPresenceUpdate(workflowId)
const uniqueUserCount = await roomManager.getUniqueUserCount(workflowId)
logger.info(
`User ${userId} (${userName}) joined workflow ${workflowId}. Room now has ${uniqueUserCount} unique users.`
)
} catch (error) {
logger.error('Error joining workflow:', error)
// Undo socket.join and room manager entry if any operation failed
socket.leave(workflowId)
await roomManager.removeUserFromRoom(socket.id, workflowId)
const isReady = roomManager.isReady()
socket.emit('join-workflow-error', {
workflowId,
error: isReady ? 'Failed to join workflow' : 'Realtime unavailable',
code: isReady ? 'JOIN_WORKFLOW_FAILED' : 'ROOM_MANAGER_UNAVAILABLE',
retryable: true,
})
}
})
socket.on('leave-workflow', async () => {
try {
if (!roomManager.isReady()) {
return
}
const workflowId = await roomManager.getWorkflowIdForSocket(socket.id)
const session = await roomManager.getUserSession(socket.id)
if (workflowId && session) {
socket.leave(workflowId)
await roomManager.removeUserFromRoom(socket.id, workflowId)
await roomManager.broadcastPresenceUpdate(workflowId)
logger.info(`User ${session.userId} (${session.userName}) left workflow ${workflowId}`)
}
} catch (error) {
logger.error('Error leaving workflow:', error)
}
})
}
+453
View File
@@ -0,0 +1,453 @@
/**
* Tests for the socket server index.ts
*
* @vitest-environment node
*/
import { createServer, request as httpRequest } from 'http'
import { createMockLogger } from '@sim/testing'
import { randomInt } from '@sim/utils/random'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { createSocketIOServer } from '@/config/socket'
import { MemoryRoomManager } from '@/rooms'
import { createHttpHandler } from '@/routes/http'
vi.mock('@/auth', () => ({
auth: {
api: {
verifyOneTimeToken: vi.fn(),
},
},
ANONYMOUS_USER_ID: '00000000-0000-0000-0000-000000000000',
ANONYMOUS_USER: {
id: '00000000-0000-0000-0000-000000000000',
name: 'Anonymous',
email: 'anonymous@localhost',
emailVerified: true,
image: null,
},
}))
vi.mock('redis', () => ({
createClient: vi.fn(() => ({
on: vi.fn(),
connect: vi.fn().mockResolvedValue(undefined),
quit: vi.fn().mockResolvedValue(undefined),
duplicate: vi.fn().mockReturnThis(),
})),
}))
vi.mock('@/env', () => ({
env: {
DATABASE_URL: 'postgres://localhost/test',
NODE_ENV: 'test',
REDIS_URL: undefined,
BETTER_AUTH_URL: 'http://localhost:3000',
BETTER_AUTH_SECRET: 'test-better-auth-secret-at-least-32-chars',
INTERNAL_API_SECRET: 'test-internal-api-secret-at-least-32-chars',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
PORT: 3002,
DISABLE_AUTH: false,
},
isProd: false,
isDev: false,
isTest: true,
isHosted: false,
isAuthDisabled: false,
getBaseUrl: () => 'http://localhost:3000',
}))
vi.mock('@/middleware/auth', () => ({
authenticateSocket: vi.fn((socket, next) => {
socket.userId = 'test-user-id'
socket.userName = 'Test User'
socket.userEmail = 'test@example.com'
next()
}),
}))
vi.mock('@/middleware/permissions', () => ({
verifyWorkflowAccess: vi.fn().mockResolvedValue({
hasAccess: true,
role: 'admin',
}),
checkRolePermission: vi.fn().mockReturnValue({
allowed: true,
}),
checkWorkflowOperationPermission: vi.fn().mockResolvedValue({
allowed: true,
role: 'admin',
}),
}))
vi.mock('@/database/operations', () => ({
getWorkflowState: vi.fn().mockResolvedValue({
id: 'test-workflow',
name: 'Test Workflow',
lastModified: Date.now(),
}),
persistWorkflowOperation: vi.fn().mockResolvedValue(undefined),
}))
describe('Socket Server Index Integration', () => {
let httpServer: any
let io: any
let roomManager: MemoryRoomManager
let logger: ReturnType<typeof createMockLogger>
let PORT: number
beforeAll(() => {
logger = createMockLogger()
})
beforeEach(async () => {
PORT = 3333 + randomInt(0, 1000)
httpServer = createServer()
io = await createSocketIOServer(httpServer)
roomManager = new MemoryRoomManager(io)
await roomManager.initialize()
const httpHandler = createHttpHandler(roomManager, logger)
httpServer.on('request', httpHandler)
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Server failed to start on port ${PORT} within 15 seconds`))
}, 15000)
httpServer.listen(PORT, '0.0.0.0', () => {
clearTimeout(timeout)
resolve()
})
httpServer.on('error', (err: any) => {
clearTimeout(timeout)
if (err.code === 'EADDRINUSE') {
PORT = 3333 + randomInt(0, 1000)
httpServer.close(() => {
httpServer.listen(PORT, '0.0.0.0', () => {
resolve()
})
})
} else {
reject(err)
}
})
})
}, 20000)
afterEach(async () => {
if (roomManager) {
await roomManager.shutdown()
}
if (io) {
await new Promise<void>((resolve) => {
io.close(() => resolve())
})
}
if (httpServer) {
await new Promise<void>((resolve) => {
httpServer.close(() => resolve())
})
}
vi.clearAllMocks()
})
describe('HTTP Server Configuration', () => {
it('should create HTTP server successfully', () => {
expect(httpServer).toBeDefined()
expect(httpServer.listening).toBe(true)
})
it('should handle health check endpoint', async () => {
const data = await new Promise<{ status: string; timestamp: number; connections: number }>(
(resolve, reject) => {
const req = httpRequest(
{
hostname: 'localhost',
port: PORT,
path: '/health',
method: 'GET',
},
(res) => {
expect(res.statusCode).toBe(200)
let body = ''
res.on('data', (chunk) => {
body += chunk
})
res.on('end', () => {
try {
resolve(JSON.parse(body))
} catch (e) {
reject(e)
}
})
}
)
req.on('error', reject)
req.end()
}
)
expect(data).toHaveProperty('status', 'ok')
expect(data).toHaveProperty('timestamp')
expect(data).toHaveProperty('connections')
})
})
describe('Socket.IO Server Configuration', () => {
it('should create Socket.IO server with proper configuration', () => {
expect(io).toBeDefined()
expect(io.engine).toBeDefined()
})
it('should have proper CORS configuration', () => {
const corsOptions = io.engine.opts.cors
expect(corsOptions).toBeDefined()
expect(corsOptions.methods).toContain('GET')
expect(corsOptions.methods).toContain('POST')
expect(corsOptions.credentials).toBe(true)
})
it('should have proper transport configuration', () => {
const transports = io.engine.opts.transports
expect(transports).toContain('polling')
expect(transports).toContain('websocket')
})
})
describe('Room Manager Integration', () => {
it('should create room manager successfully', async () => {
expect(roomManager).toBeDefined()
expect(await roomManager.getTotalActiveConnections()).toBe(0)
})
it('should add and get users from workflow rooms', async () => {
const workflowId = 'test-workflow-123'
const socketId = 'test-socket-123'
const presence = {
userId: 'user-123',
workflowId,
userName: 'Test User',
socketId,
joinedAt: Date.now(),
lastActivity: Date.now(),
role: 'admin',
}
await roomManager.addUserToRoom(workflowId, socketId, presence)
expect(await roomManager.hasWorkflowRoom(workflowId)).toBe(true)
const users = await roomManager.getWorkflowUsers(workflowId)
expect(users).toHaveLength(1)
expect(users[0].socketId).toBe(socketId)
})
it('should manage user sessions', async () => {
const socketId = 'test-socket-123'
const workflowId = 'test-workflow-456'
const presence = {
userId: 'user-123',
workflowId,
userName: 'Test User',
socketId,
joinedAt: Date.now(),
lastActivity: Date.now(),
role: 'admin',
}
await roomManager.addUserToRoom(workflowId, socketId, presence)
expect(await roomManager.getWorkflowIdForSocket(socketId)).toBe(workflowId)
const session = await roomManager.getUserSession(socketId)
expect(session).toBeDefined()
expect(session?.userId).toBe('user-123')
})
it('should clean up rooms properly', async () => {
const workflowId = 'test-workflow-789'
const socketId = 'test-socket-789'
const presence = {
userId: 'user-789',
workflowId,
userName: 'Test User',
socketId,
joinedAt: Date.now(),
lastActivity: Date.now(),
role: 'admin',
}
await roomManager.addUserToRoom(workflowId, socketId, presence)
expect(await roomManager.hasWorkflowRoom(workflowId)).toBe(true)
// Remove user
await roomManager.removeUserFromRoom(socketId)
// Room should be cleaned up since it's now empty
expect(await roomManager.hasWorkflowRoom(workflowId)).toBe(false)
expect(await roomManager.getWorkflowIdForSocket(socketId)).toBeNull()
})
})
describe('Module Integration', () => {
it.concurrent('should properly import all extracted modules', async () => {
const { createSocketIOServer } = await import('@/config/socket')
const { createHttpHandler } = await import('@/routes/http')
const { MemoryRoomManager, RedisRoomManager } = await import('@/rooms')
const { authenticateSocket } = await import('@/middleware/auth')
const { verifyWorkflowAccess } = await import('@/middleware/permissions')
const { getWorkflowState } = await import('@/database/operations')
const { WorkflowOperationSchema } = await import('@sim/realtime-protocol/schemas')
expect(createSocketIOServer).toBeTypeOf('function')
expect(createHttpHandler).toBeTypeOf('function')
expect(MemoryRoomManager).toBeTypeOf('function')
expect(RedisRoomManager).toBeTypeOf('function')
expect(authenticateSocket).toBeTypeOf('function')
expect(verifyWorkflowAccess).toBeTypeOf('function')
expect(getWorkflowState).toBeTypeOf('function')
expect(WorkflowOperationSchema).toBeDefined()
})
it.concurrent('should maintain all original functionality after refactoring', async () => {
expect(httpServer).toBeDefined()
expect(io).toBeDefined()
expect(roomManager).toBeDefined()
expect(typeof roomManager.addUserToRoom).toBe('function')
expect(typeof roomManager.removeUserFromRoom).toBe('function')
expect(typeof roomManager.handleWorkflowDeletion).toBe('function')
expect(typeof roomManager.broadcastPresenceUpdate).toBe('function')
})
})
describe('Error Handling', () => {
it('should have global error handlers configured', () => {
expect(typeof process.on).toBe('function')
})
it('should handle server setup', () => {
expect(httpServer).toBeDefined()
expect(io).toBeDefined()
})
})
describe('Authentication Middleware', () => {
it('should apply authentication middleware to Socket.IO', () => {
expect(io._parser).toBeDefined()
})
})
describe('Graceful Shutdown', () => {
it('should have shutdown capability', () => {
expect(typeof httpServer.close).toBe('function')
expect(typeof io.close).toBe('function')
expect(typeof roomManager.shutdown).toBe('function')
})
})
describe('Validation and Utils', () => {
it.concurrent('should validate workflow operations', async () => {
const { WorkflowOperationSchema } = await import('@sim/realtime-protocol/schemas')
const validOperation = {
operation: 'batch-add-blocks',
target: 'blocks',
payload: {
blocks: [
{
id: 'test-block',
type: 'action',
name: 'Test Block',
position: { x: 100, y: 200 },
},
],
edges: [],
loops: {},
parallels: {},
subBlockValues: {},
},
timestamp: Date.now(),
}
expect(() => WorkflowOperationSchema.parse(validOperation)).not.toThrow()
})
it.concurrent('should validate batch-add-blocks with edges', async () => {
const { WorkflowOperationSchema } = await import('@sim/realtime-protocol/schemas')
const validOperationWithEdge = {
operation: 'batch-add-blocks',
target: 'blocks',
payload: {
blocks: [
{
id: 'test-block',
type: 'action',
name: 'Test Block',
position: { x: 100, y: 200 },
},
],
edges: [
{
id: 'auto-edge-123',
source: 'source-block',
target: 'test-block',
sourceHandle: 'output',
targetHandle: 'target',
type: 'workflowEdge',
},
],
loops: {},
parallels: {},
subBlockValues: {},
},
timestamp: Date.now(),
}
expect(() => WorkflowOperationSchema.parse(validOperationWithEdge)).not.toThrow()
})
it.concurrent('should validate edge operations', async () => {
const { WorkflowOperationSchema } = await import('@sim/realtime-protocol/schemas')
const validEdgeOperation = {
operation: 'add',
target: 'edge',
payload: {
id: 'test-edge',
source: 'block-1',
target: 'block-2',
},
timestamp: Date.now(),
}
expect(() => WorkflowOperationSchema.parse(validEdgeOperation)).not.toThrow()
})
it('should validate subflow operations', async () => {
const { WorkflowOperationSchema } = await import('@sim/realtime-protocol/schemas')
const validSubflowOperation = {
operation: 'update',
target: 'subflow',
payload: {
id: 'test-subflow',
type: 'loop',
config: { iterations: 5 },
},
timestamp: Date.now(),
}
expect(() => WorkflowOperationSchema.parse(validSubflowOperation)).not.toThrow()
})
})
})
+139
View File
@@ -0,0 +1,139 @@
import { createServer } from 'http'
import { createLogger } from '@sim/logger'
import type { Server as SocketIOServer } from 'socket.io'
import { createSocketIOServer, shutdownSocketIOAdapter } from '@/config/socket'
import { assertSchemaCompatibility } from '@/database/preflight'
import { env } from '@/env'
import { setupAllHandlers } from '@/handlers'
import { type AuthenticatedSocket, authenticateSocket } from '@/middleware/auth'
import { type IRoomManager, MemoryRoomManager, RedisRoomManager } from '@/rooms'
import { createHttpHandler } from '@/routes/http'
const logger = createLogger('CollaborativeSocketServer')
/** Maximum time to wait for graceful shutdown before forcing exit */
const SHUTDOWN_TIMEOUT_MS = 10000
async function createRoomManager(io: SocketIOServer): Promise<IRoomManager> {
if (env.REDIS_URL) {
logger.info('Initializing Redis-backed RoomManager for multi-pod support')
const manager = new RedisRoomManager(io, env.REDIS_URL)
await manager.initialize()
return manager
}
logger.warn('No REDIS_URL configured - using in-memory RoomManager (single-pod only)')
const manager = new MemoryRoomManager(io)
await manager.initialize()
return manager
}
async function main() {
const httpServer = createServer()
const PORT = env.PORT
logger.info('Starting Socket.IO server...', {
port: PORT,
nodeEnv: env.NODE_ENV,
hasDatabase: !!env.DATABASE_URL,
hasAuth: !!env.BETTER_AUTH_SECRET,
hasRedis: !!env.REDIS_URL,
})
// Register the HTTP handler before Socket.IO attaches: engine.io captures
// pre-existing `request` listeners and forwards only non-`/socket.io/`
// requests to them, making it the single dispatcher for the shared port.
// The handler itself is assigned after the room manager exists, before listen().
// biome-ignore lint/style/useConst: must be declared before the request listener closure; assigned only after the room manager exists
let httpHandler: ReturnType<typeof createHttpHandler> | undefined
httpServer.on('request', (req, res) => httpHandler?.(req, res))
// Create Socket.IO server with Redis adapter if configured
const io = await createSocketIOServer(httpServer)
// Initialize room manager (Redis or in-memory based on config)
const roomManager = await createRoomManager(io)
// Set up authentication middleware
io.use(authenticateSocket)
// Set up HTTP handler for health checks and internal APIs
httpHandler = createHttpHandler(roomManager, logger)
// Global error handlers
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception:', error)
})
process.on('unhandledRejection', (reason, promise) => {
if (reason instanceof Error && reason.message === 'The client is closed') {
logger.warn('Redis client is closed — suppressing unhandled rejection')
return
}
logger.error('Unhandled Rejection at:', promise, 'reason:', reason)
})
httpServer.on('error', (error: NodeJS.ErrnoException) => {
logger.error('HTTP server error:', error)
if (error.code === 'EADDRINUSE' || error.code === 'EACCES') {
process.exit(1)
}
})
io.engine.on('connection_error', (err) => {
logger.error('Socket.IO connection error:', {
req: err.req?.url,
code: err.code,
message: err.message,
context: err.context,
})
})
io.on('connection', (socket: AuthenticatedSocket) => {
logger.info(`New socket connection: ${socket.id}`)
setupAllHandlers(socket, roomManager)
})
await assertSchemaCompatibility()
httpServer.listen(PORT, '0.0.0.0', () => {
logger.info(`Socket.IO server running on port ${PORT}`)
logger.info(`Health check available at: http://localhost:${PORT}/health`)
})
const shutdown = async () => {
logger.info('Shutting down Socket.IO server...')
try {
await roomManager.shutdown()
logger.info('RoomManager shutdown complete')
} catch (error) {
logger.error('Error during RoomManager shutdown:', error)
}
try {
await shutdownSocketIOAdapter()
} catch (error) {
logger.error('Error during Socket.IO adapter shutdown:', error)
}
httpServer.close(() => {
logger.info('Socket.IO server closed')
process.exit(0)
})
setTimeout(() => {
logger.error('Forced shutdown after timeout')
process.exit(1)
}, SHUTDOWN_TIMEOUT_MS)
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
}
// Start the server
main().catch((error) => {
logger.error('Failed to start server:', error)
process.exit(1)
})
+93
View File
@@ -0,0 +1,93 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import type { Socket } from 'socket.io'
import { ANONYMOUS_USER, ANONYMOUS_USER_ID, auth } from '@/auth'
import { isAuthDisabled } from '@/env'
const logger = createLogger('SocketAuth')
/**
* Authenticated socket with user data attached.
*/
export interface AuthenticatedSocket extends Socket {
userId?: string
userName?: string
userEmail?: string
activeOrganizationId?: string
userImage?: string | null
}
/**
* Socket.IO authentication middleware.
* Handles both anonymous mode (DISABLE_AUTH=true) and normal token-based auth.
*/
export async function authenticateSocket(socket: AuthenticatedSocket, next: (err?: Error) => void) {
try {
if (isAuthDisabled) {
socket.userId = ANONYMOUS_USER_ID
socket.userName = ANONYMOUS_USER.name
socket.userEmail = ANONYMOUS_USER.email
socket.userImage = ANONYMOUS_USER.image
logger.debug(`Socket ${socket.id} authenticated as anonymous`)
return next()
}
// Extract authentication data from socket handshake
const token = socket.handshake.auth?.token
const origin = socket.handshake.headers.origin
const referer = socket.handshake.headers.referer
logger.info(`Socket ${socket.id} authentication attempt:`, {
hasToken: !!token,
origin,
referer,
})
if (!token) {
logger.warn(`Socket ${socket.id} rejected: No authentication token found`)
return next(new Error('Authentication required'))
}
// Validate one-time token with Better Auth
try {
logger.debug(`Attempting token validation for socket ${socket.id}`, {
tokenLength: token?.length || 0,
origin,
})
const session = await auth.api.verifyOneTimeToken({
body: {
token,
},
})
if (!session?.user?.id) {
logger.warn(`Socket ${socket.id} rejected: Invalid token - no user found`)
return next(new Error('Invalid session'))
}
// Store user info in socket for later use
socket.userId = session.user.id
socket.userName = session.user.name || session.user.email || 'Unknown User'
socket.userEmail = session.user.email
socket.userImage = session.user.image || null
socket.activeOrganizationId = session.session.activeOrganizationId || undefined
next()
} catch (tokenError) {
const errorMessage = toError(tokenError).message
const errorStack = tokenError instanceof Error ? tokenError.stack : undefined
logger.warn(`Token validation failed for socket ${socket.id}:`, {
error: errorMessage,
stack: errorStack,
origin,
referer,
})
return next(new Error('Token validation failed'))
}
} catch (error) {
logger.error(`Socket authentication error for ${socket.id}:`, error)
next(new Error('Authentication failed'))
}
}
@@ -0,0 +1,416 @@
/**
* Tests for socket server permission middleware.
*
* Tests cover:
* - Role-based operation permissions (admin, write, read)
* - All socket operations
* - Edge cases and invalid inputs
*/
import {
expectPermissionAllowed,
expectPermissionDenied,
ROLE_ALLOWED_OPERATIONS,
SOCKET_OPERATIONS,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockAuthorize } = vi.hoisted(() => ({
mockAuthorize: vi.fn(),
}))
vi.mock('@sim/platform-authz/workflow', () => ({
authorizeWorkflowByWorkspacePermission: mockAuthorize,
}))
import { checkRolePermission, checkWorkflowOperationPermission } from '@/middleware/permissions'
describe('checkRolePermission', () => {
describe('admin role', () => {
it('should allow all operations for admin role', () => {
const operations = SOCKET_OPERATIONS
for (const operation of operations) {
const result = checkRolePermission('admin', operation)
expectPermissionAllowed(result)
}
})
it('should allow batch-add-blocks operation', () => {
const result = checkRolePermission('admin', 'batch-add-blocks')
expectPermissionAllowed(result)
})
it('should allow batch-remove-blocks operation', () => {
const result = checkRolePermission('admin', 'batch-remove-blocks')
expectPermissionAllowed(result)
})
it('should allow update operation', () => {
const result = checkRolePermission('admin', 'update')
expectPermissionAllowed(result)
})
it('should allow batch-update-positions operation', () => {
const result = checkRolePermission('admin', 'batch-update-positions')
expectPermissionAllowed(result)
})
it('should allow replace-state operation', () => {
const result = checkRolePermission('admin', 'replace-state')
expectPermissionAllowed(result)
})
it('should allow subblock-batch-update operation', () => {
const result = checkRolePermission('admin', 'subblock-batch-update')
expectPermissionAllowed(result)
})
})
describe('write role', () => {
it('should allow all operations for write role (same as admin)', () => {
const operations = SOCKET_OPERATIONS
for (const operation of operations) {
const result = checkRolePermission('write', operation)
expectPermissionAllowed(result)
}
})
it('should allow batch-add-blocks operation', () => {
const result = checkRolePermission('write', 'batch-add-blocks')
expectPermissionAllowed(result)
})
it('should allow batch-remove-blocks operation', () => {
const result = checkRolePermission('write', 'batch-remove-blocks')
expectPermissionAllowed(result)
})
it('should allow update-position operation', () => {
const result = checkRolePermission('write', 'update-position')
expectPermissionAllowed(result)
})
it('should allow subblock-batch-update operation', () => {
const result = checkRolePermission('write', 'subblock-batch-update')
expectPermissionAllowed(result)
})
})
describe('read role', () => {
it('should only allow update-position for read role', () => {
const result = checkRolePermission('read', 'update-position')
expectPermissionAllowed(result)
})
it('should deny batch-add-blocks operation for read role', () => {
const result = checkRolePermission('read', 'batch-add-blocks')
expectPermissionDenied(result, 'read')
expectPermissionDenied(result, 'batch-add-blocks')
})
it('should deny batch-remove-blocks operation for read role', () => {
const result = checkRolePermission('read', 'batch-remove-blocks')
expectPermissionDenied(result, 'read')
})
it('should deny update operation for read role', () => {
const result = checkRolePermission('read', 'update')
expectPermissionDenied(result, 'read')
})
it('should allow batch-update-positions operation for read role', () => {
const result = checkRolePermission('read', 'batch-update-positions')
expectPermissionAllowed(result)
})
it('should deny replace-state operation for read role', () => {
const result = checkRolePermission('read', 'replace-state')
expectPermissionDenied(result, 'read')
})
it('should deny subblock-batch-update operation for read role', () => {
const result = checkRolePermission('read', 'subblock-batch-update')
expectPermissionDenied(result, 'read')
})
it('should deny toggle-enabled operation for read role', () => {
const result = checkRolePermission('read', 'toggle-enabled')
expectPermissionDenied(result, 'read')
})
it('should deny all write operations for read role', () => {
const readAllowedOps = ['update-position', 'batch-update-positions']
const writeOperations = SOCKET_OPERATIONS.filter((op) => !readAllowedOps.includes(op))
for (const operation of writeOperations) {
const result = checkRolePermission('read', operation)
expect(result.allowed).toBe(false)
expect(result.reason).toContain('read')
}
})
})
describe('unknown role', () => {
it('should deny all operations for unknown role', () => {
const operations = SOCKET_OPERATIONS
for (const operation of operations) {
const result = checkRolePermission('unknown', operation)
expectPermissionDenied(result)
}
})
it('should deny operations for empty role', () => {
const result = checkRolePermission('', 'batch-add-blocks')
expectPermissionDenied(result)
})
})
describe('unknown operations', () => {
it('should deny unknown operations for admin', () => {
const result = checkRolePermission('admin', 'unknown-operation')
expectPermissionDenied(result, 'admin')
expectPermissionDenied(result, 'unknown-operation')
})
it('should deny unknown operations for write', () => {
const result = checkRolePermission('write', 'unknown-operation')
expectPermissionDenied(result)
})
it('should deny unknown operations for read', () => {
const result = checkRolePermission('read', 'unknown-operation')
expectPermissionDenied(result)
})
it('should deny empty operation', () => {
const result = checkRolePermission('admin', '')
expectPermissionDenied(result)
})
})
describe('permission hierarchy verification', () => {
it('should verify admin has same permissions as write', () => {
const adminOps = ROLE_ALLOWED_OPERATIONS.admin
const writeOps = ROLE_ALLOWED_OPERATIONS.write
// Admin and write should have same operations
expect(adminOps).toEqual(writeOps)
})
it('should verify read is a subset of write permissions', () => {
const readOps = ROLE_ALLOWED_OPERATIONS.read
const writeOps = ROLE_ALLOWED_OPERATIONS.write
for (const op of readOps) {
expect(writeOps).toContain(op)
}
})
it('should verify read has minimal permissions', () => {
const readOps = ROLE_ALLOWED_OPERATIONS.read
expect(readOps).toHaveLength(2)
expect(readOps).toContain('update-position')
expect(readOps).toContain('batch-update-positions')
})
})
describe('specific operations', () => {
const testCases = [
{ operation: 'batch-add-blocks', adminAllowed: true, writeAllowed: true, readAllowed: false },
{
operation: 'batch-remove-blocks',
adminAllowed: true,
writeAllowed: true,
readAllowed: false,
},
{ operation: 'update', adminAllowed: true, writeAllowed: true, readAllowed: false },
{ operation: 'update-position', adminAllowed: true, writeAllowed: true, readAllowed: true },
{ operation: 'update-name', adminAllowed: true, writeAllowed: true, readAllowed: false },
{ operation: 'toggle-enabled', adminAllowed: true, writeAllowed: true, readAllowed: false },
{ operation: 'update-parent', adminAllowed: true, writeAllowed: true, readAllowed: false },
{
operation: 'update-canonical-mode',
adminAllowed: true,
writeAllowed: true,
readAllowed: false,
},
{ operation: 'toggle-handles', adminAllowed: true, writeAllowed: true, readAllowed: false },
{
operation: 'batch-toggle-locked',
adminAllowed: true,
writeAllowed: false, // Admin-only operation
readAllowed: false,
},
{
operation: 'batch-update-positions',
adminAllowed: true,
writeAllowed: true,
readAllowed: true,
},
{ operation: 'replace-state', adminAllowed: true, writeAllowed: true, readAllowed: false },
]
for (const { operation, adminAllowed, writeAllowed, readAllowed } of testCases) {
it(`should ${adminAllowed ? 'allow' : 'deny'} "${operation}" for admin`, () => {
const result = checkRolePermission('admin', operation)
expect(result.allowed).toBe(adminAllowed)
})
it(`should ${writeAllowed ? 'allow' : 'deny'} "${operation}" for write`, () => {
const result = checkRolePermission('write', operation)
expect(result.allowed).toBe(writeAllowed)
})
it(`should ${readAllowed ? 'allow' : 'deny'} "${operation}" for read`, () => {
const result = checkRolePermission('read', operation)
expect(result.allowed).toBe(readAllowed)
})
}
})
describe('reason messages', () => {
it('should include role in denial reason', () => {
const result = checkRolePermission('read', 'batch-add-blocks')
expect(result.reason).toContain("'read'")
})
it('should include operation in denial reason', () => {
const result = checkRolePermission('read', 'batch-add-blocks')
expect(result.reason).toContain("'batch-add-blocks'")
})
it('should have descriptive denial message format', () => {
const result = checkRolePermission('read', 'remove')
expect(result.reason).toMatch(/Role '.*' not permitted to perform '.*'/)
})
})
})
describe('checkWorkflowOperationPermission', () => {
const userId = 'user-1'
let workflowCounter = 0
let workflowId: string
beforeEach(() => {
vi.clearAllMocks()
// Unique workflowId per test so the module-level role cache never leaks across tests
workflowCounter += 1
workflowId = `wf-${workflowCounter}`
})
it('allows a write operation when the user still has write access', async () => {
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'write' })
const result = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
expect(result.allowed).toBe(true)
expect(result.role).toBe('write')
})
it('denies all writes once workspace access has been revoked', async () => {
mockAuthorize.mockResolvedValue({ allowed: false, workspacePermission: null })
const result = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'write')
expect(result.allowed).toBe(false)
expect(result.role).toBeNull()
expect(result.reason).toMatch(/revoked/i)
})
it('denies writes after a downgrade to read but still allows position updates', async () => {
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'read' })
const denied = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'write')
expect(denied.allowed).toBe(false)
expect(denied.role).toBe('read')
const allowed = await checkWorkflowOperationPermission(
userId,
workflowId,
'update-position',
'write'
)
expect(allowed.allowed).toBe(true)
expect(allowed.role).toBe('read')
})
it('caches the role within the TTL to avoid a DB read on every operation', async () => {
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'write' })
await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
expect(mockAuthorize).toHaveBeenCalledTimes(1)
})
it('re-reads the role after the cache TTL expires', async () => {
vi.useFakeTimers()
try {
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'write' })
await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
// Downgraded to read after the first check
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'read' })
vi.advanceTimersByTime(31_000)
const result = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'write')
expect(mockAuthorize).toHaveBeenCalledTimes(2)
expect(result.allowed).toBe(false)
expect(result.role).toBe('read')
} finally {
vi.useRealTimers()
}
})
it('falls back to the join-time role on a transient DB error when nothing is cached yet', async () => {
mockAuthorize.mockRejectedValue(new Error('db unavailable'))
const result = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'write')
expect(result.allowed).toBe(true)
expect(result.role).toBe('write')
})
it('preserves a recorded revocation through a later transient DB error', async () => {
vi.useFakeTimers()
try {
// First check records the revocation (null) in the cache
mockAuthorize.mockResolvedValue({ allowed: false, workspacePermission: null })
const first = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'admin')
expect(first.allowed).toBe(false)
expect(first.role).toBeNull()
// TTL expires, then the DB blips on the next re-validation. The stale join-time
// role ('admin') must NOT resurrect access — the recorded revocation wins.
vi.advanceTimersByTime(31_000)
mockAuthorize.mockRejectedValue(new Error('db unavailable'))
const second = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'admin')
expect(second.allowed).toBe(false)
expect(second.role).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('uses the last cached role (not the join-time role) on a transient DB error', async () => {
vi.useFakeTimers()
try {
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'write' })
await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
vi.advanceTimersByTime(31_000)
mockAuthorize.mockRejectedValue(new Error('db unavailable'))
// fallbackRole is 'read', but the last recorded decision was 'write' — use that
const result = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
expect(result.allowed).toBe(true)
expect(result.role).toBe('write')
} finally {
vi.useRealTimers()
}
})
})
+247
View File
@@ -0,0 +1,247 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import {
BLOCK_OPERATIONS,
BLOCKS_OPERATIONS,
EDGE_OPERATIONS,
EDGES_OPERATIONS,
SUBBLOCK_OPERATIONS,
SUBFLOW_OPERATIONS,
VARIABLE_OPERATIONS,
WORKFLOW_OPERATIONS,
} from '@sim/realtime-protocol/constants'
import { and, eq, isNull } from 'drizzle-orm'
const logger = createLogger('SocketPermissions')
// Admin-only operations (require admin role)
const ADMIN_ONLY_OPERATIONS: string[] = [BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED]
// Write operations (admin and write roles both have these permissions)
const WRITE_OPERATIONS: string[] = [
// Block operations
BLOCK_OPERATIONS.UPDATE_POSITION,
BLOCK_OPERATIONS.UPDATE_NAME,
BLOCK_OPERATIONS.TOGGLE_ENABLED,
BLOCK_OPERATIONS.UPDATE_PARENT,
BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE,
BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE,
BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES,
BLOCK_OPERATIONS.TOGGLE_HANDLES,
// Batch block operations
BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS,
BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS,
BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS,
BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES,
BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT,
// Edge operations
EDGE_OPERATIONS.ADD,
EDGE_OPERATIONS.REMOVE,
// Batch edge operations
EDGES_OPERATIONS.BATCH_ADD_EDGES,
EDGES_OPERATIONS.BATCH_REMOVE_EDGES,
// Subflow operations
SUBFLOW_OPERATIONS.UPDATE,
// Subblock operations
SUBBLOCK_OPERATIONS.UPDATE,
SUBBLOCK_OPERATIONS.BATCH_UPDATE,
// Variable operations
VARIABLE_OPERATIONS.UPDATE,
// Workflow operations
WORKFLOW_OPERATIONS.REPLACE_STATE,
]
// Read role can only update positions (for cursor sync, etc.)
const READ_OPERATIONS: string[] = [
BLOCK_OPERATIONS.UPDATE_POSITION,
BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS,
]
// Define operation permissions based on role
const ROLE_PERMISSIONS: Record<string, string[]> = {
admin: [...ADMIN_ONLY_OPERATIONS, ...WRITE_OPERATIONS],
write: WRITE_OPERATIONS,
read: READ_OPERATIONS,
}
// Check if a role allows a specific operation (no DB query, pure logic)
export function checkRolePermission(
role: string,
operation: string
): { allowed: boolean; reason?: string } {
const allowedOperations = ROLE_PERMISSIONS[role] || []
if (!allowedOperations.includes(operation)) {
return {
allowed: false,
reason: `Role '${role}' not permitted to perform '${operation}'`,
}
}
return { allowed: true }
}
/**
* TTL for the per-pod role cache backing live re-validation of mutating operations.
* Bounds how long a revoked or downgraded collaborator can retain write access on an
* already-connected socket.
*/
const ROLE_REVALIDATION_TTL_MS = 30_000
/** Soft cap on cached entries before an opportunistic purge of expired ones runs. */
const MAX_ROLE_CACHE_ENTRIES = 5_000
interface CachedRole {
/** Authoritative workspace role, or `null` when the user has no access. */
role: string | null
expiresAt: number
}
/**
* Per-pod cache of authoritative workspace roles, keyed by `${userId}:${workflowId}`.
*
* Socket connections are sticky to a single pod, so a socket's mutating operations are
* always gated by the same pod's cache. We rely on TTL expiry (not cross-pod
* invalidation) to bound stale authorization to {@link ROLE_REVALIDATION_TTL_MS}, which
* keeps this correct under a multi-pod deployment without any shared state.
*/
const roleCache = new Map<string, CachedRole>()
function purgeExpiredRoles(now: number): void {
for (const [key, entry] of roleCache) {
if (entry.expiresAt <= now) {
roleCache.delete(key)
}
}
}
/**
* Resolves a user's current workspace role for a workflow, re-reading the `permissions`
* table at most once per {@link ROLE_REVALIDATION_TTL_MS} per pod.
*
* Returns `null` when the user genuinely has no access (removed/revoked). On a transient
* DB failure it reuses the last recorded decision for this (user, workflow) — including a
* previously recorded revocation (`null`) — and only falls back to `fallbackRole` when no
* decision has been recorded yet, so a blip neither blocks legitimate editors nor
* resurrects already-revoked access.
*/
export async function resolveCurrentWorkflowRole(
userId: string,
workflowId: string,
fallbackRole: string
): Promise<string | null> {
const now = Date.now()
const key = `${userId}:${workflowId}`
const cached = roleCache.get(key)
if (cached && cached.expiresAt > now) {
return cached.role
}
try {
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'read',
})
const role = authorization.allowed ? (authorization.workspacePermission ?? null) : null
if (roleCache.size >= MAX_ROLE_CACHE_ENTRIES) {
purgeExpiredRoles(now)
}
roleCache.set(key, { role, expiresAt: now + ROLE_REVALIDATION_TTL_MS })
return role
} catch (error) {
logger.warn(
`Failed to re-validate role for user ${userId} on workflow ${workflowId}; using last known role`,
error
)
// Prefer the last recorded decision — even if expired, and even if it is `null` for an
// already-revoked user — so a recorded revocation survives a transient DB failure
// instead of reverting to the stale join-time role. Only trust `fallbackRole` when
// nothing has been recorded for this (user, workflow) yet.
const lastKnown = roleCache.get(key)
return lastKnown !== undefined ? lastKnown.role : fallbackRole
}
}
/**
* Live permission gate for mutating socket operations. Re-validates the user's workspace
* role against the database (cached per pod for {@link ROLE_REVALIDATION_TTL_MS}) so that
* revoked or downgraded collaborators lose write access on an open connection without
* needing to rejoin the workflow.
*/
export async function checkWorkflowOperationPermission(
userId: string,
workflowId: string,
operation: string,
fallbackRole: string
): Promise<{ allowed: boolean; reason?: string; role: string | null }> {
const role = await resolveCurrentWorkflowRole(userId, workflowId, fallbackRole)
if (!role) {
return {
allowed: false,
reason: 'Access to this workflow has been revoked',
role: null,
}
}
return { ...checkRolePermission(role, operation), role }
}
/**
* Verifies a user's access to a workflow via workspace permissions.
*
* Returns `hasAccess: false` only for genuine denials (workflow missing/archived
* or no workspace permission). Transient failures (DB errors) are rethrown so the
* caller can report them as retryable instead of a permanent access denial.
*/
export async function verifyWorkflowAccess(
userId: string,
workflowId: string
): Promise<{ hasAccess: boolean; role?: string; workspaceId?: string }> {
try {
const workflowData = await db
.select({
workspaceId: workflow.workspaceId,
name: workflow.name,
})
.from(workflow)
.where(and(eq(workflow.id, workflowId), isNull(workflow.archivedAt)))
.limit(1)
if (!workflowData.length) {
logger.warn(`Workflow ${workflowId} not found`)
return { hasAccess: false }
}
const { workspaceId, name: workflowName } = workflowData[0]
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'read',
})
if (!authorization.allowed || !authorization.workspacePermission) {
logger.warn(
`User ${userId} is not permitted to access workflow ${workflowId}: ${authorization.message}`
)
return { hasAccess: false }
}
logger.debug(
`User ${userId} has ${authorization.workspacePermission} access to workflow ${workflowId} (${workflowName}) via workspace ${workspaceId}`
)
return {
hasAccess: true,
role: authorization.workspacePermission,
workspaceId: workspaceId || undefined,
}
} catch (error) {
logger.error(
`Error verifying workflow access for user ${userId}, workflow ${workflowId}:`,
error
)
throw error
}
}
+3
View File
@@ -0,0 +1,3 @@
export { MemoryRoomManager } from '@/rooms/memory-manager'
export { RedisRoomManager } from '@/rooms/redis-manager'
export type { IRoomManager, UserPresence, UserSession, WorkflowRoom } from '@/rooms/types'
+258
View File
@@ -0,0 +1,258 @@
import { createLogger } from '@sim/logger'
import type { Server } from 'socket.io'
import type { IRoomManager, UserPresence, UserSession, WorkflowRoom } from '@/rooms/types'
const logger = createLogger('MemoryRoomManager')
/**
* In-memory room manager for single-pod deployments
* Used as fallback when REDIS_URL is not configured
*/
export class MemoryRoomManager implements IRoomManager {
private workflowRooms = new Map<string, WorkflowRoom>()
private socketToWorkflow = new Map<string, string>()
private userSessions = new Map<string, UserSession>()
private _io: Server
constructor(io: Server) {
this._io = io
}
get io(): Server {
return this._io
}
async initialize(): Promise<void> {
logger.info('MemoryRoomManager initialized (single-pod mode)')
}
isReady(): boolean {
return true
}
async shutdown(): Promise<void> {
this.workflowRooms.clear()
this.socketToWorkflow.clear()
this.userSessions.clear()
logger.info('MemoryRoomManager shutdown complete')
}
async addUserToRoom(workflowId: string, socketId: string, presence: UserPresence): Promise<void> {
// Create room if it doesn't exist
if (!this.workflowRooms.has(workflowId)) {
this.workflowRooms.set(workflowId, {
workflowId,
users: new Map(),
lastModified: Date.now(),
activeConnections: 0,
})
}
const room = this.workflowRooms.get(workflowId)!
room.users.set(socketId, presence)
room.activeConnections++
room.lastModified = Date.now()
// Map socket to workflow
this.socketToWorkflow.set(socketId, workflowId)
// Store session
this.userSessions.set(socketId, {
userId: presence.userId,
userName: presence.userName,
avatarUrl: presence.avatarUrl,
})
logger.debug(`Added user ${presence.userId} to workflow ${workflowId} (socket: ${socketId})`)
}
async removeUserFromRoom(socketId: string, _workflowIdHint?: string): Promise<string | null> {
const workflowId = this.socketToWorkflow.get(socketId)
if (!workflowId) {
return null
}
const room = this.workflowRooms.get(workflowId)
if (room) {
room.users.delete(socketId)
room.activeConnections = Math.max(0, room.activeConnections - 1)
// Clean up empty rooms
if (room.activeConnections === 0) {
this.workflowRooms.delete(workflowId)
logger.info(`Cleaned up empty workflow room: ${workflowId}`)
}
}
this.socketToWorkflow.delete(socketId)
this.userSessions.delete(socketId)
logger.debug(`Removed socket ${socketId} from workflow ${workflowId}`)
return workflowId
}
async getWorkflowIdForSocket(socketId: string): Promise<string | null> {
return this.socketToWorkflow.get(socketId) ?? null
}
async getUserSession(socketId: string): Promise<UserSession | null> {
return this.userSessions.get(socketId) ?? null
}
async getWorkflowUsers(workflowId: string): Promise<UserPresence[]> {
const room = this.workflowRooms.get(workflowId)
if (!room) return []
return Array.from(room.users.values())
}
async hasWorkflowRoom(workflowId: string): Promise<boolean> {
return this.workflowRooms.has(workflowId)
}
async updateUserActivity(
workflowId: string,
socketId: string,
updates: Partial<Pick<UserPresence, 'cursor' | 'selection' | 'lastActivity'>>
): Promise<void> {
const room = this.workflowRooms.get(workflowId)
if (!room) return
const presence = room.users.get(socketId)
if (presence) {
if (updates.cursor !== undefined) presence.cursor = updates.cursor
if (updates.selection !== undefined) presence.selection = updates.selection
presence.lastActivity = updates.lastActivity ?? Date.now()
}
}
async updateRoomLastModified(workflowId: string): Promise<void> {
const room = this.workflowRooms.get(workflowId)
if (room) {
room.lastModified = Date.now()
}
}
async broadcastPresenceUpdate(workflowId: string): Promise<void> {
const users = await this.getWorkflowUsers(workflowId)
this._io.to(workflowId).emit('presence-update', users)
}
emitToWorkflow<T = unknown>(workflowId: string, event: string, payload: T): void {
this._io.to(workflowId).emit(event, payload)
}
async getUniqueUserCount(workflowId: string): Promise<number> {
const room = this.workflowRooms.get(workflowId)
if (!room) return 0
const uniqueUsers = new Set<string>()
room.users.forEach((presence) => {
uniqueUsers.add(presence.userId)
})
return uniqueUsers.size
}
async getTotalActiveConnections(): Promise<number> {
let total = 0
for (const room of this.workflowRooms.values()) {
total += room.activeConnections
}
return total
}
async handleWorkflowDeletion(workflowId: string): Promise<void> {
logger.info(`Handling workflow deletion notification for ${workflowId}`)
const room = this.workflowRooms.get(workflowId)
if (!room) {
logger.debug(`No active room found for deleted workflow ${workflowId}`)
return
}
this._io.to(workflowId).emit('workflow-deleted', {
workflowId,
message: 'This workflow has been deleted',
timestamp: Date.now(),
})
const socketsToDisconnect: string[] = []
room.users.forEach((_presence, socketId) => {
socketsToDisconnect.push(socketId)
})
for (const socketId of socketsToDisconnect) {
const socket = this._io.sockets.sockets.get(socketId)
if (socket) {
socket.leave(workflowId)
logger.debug(`Disconnected socket ${socketId} from deleted workflow ${workflowId}`)
}
await this.removeUserFromRoom(socketId)
}
this.workflowRooms.delete(workflowId)
logger.info(
`Cleaned up workflow room ${workflowId} after deletion (${socketsToDisconnect.length} users disconnected)`
)
}
async handleWorkflowRevert(workflowId: string, timestamp: number): Promise<void> {
logger.info(`Handling workflow revert notification for ${workflowId}`)
const room = this.workflowRooms.get(workflowId)
if (!room) {
logger.debug(`No active room found for reverted workflow ${workflowId}`)
return
}
this._io.to(workflowId).emit('workflow-reverted', {
workflowId,
message: 'Workflow has been reverted to deployed state',
timestamp,
})
room.lastModified = timestamp
logger.info(`Notified ${room.users.size} users about workflow revert: ${workflowId}`)
}
async handleWorkflowUpdate(workflowId: string): Promise<void> {
logger.info(`Handling workflow update notification for ${workflowId}`)
const room = this.workflowRooms.get(workflowId)
if (!room) {
logger.debug(`No active room found for updated workflow ${workflowId}`)
return
}
const timestamp = Date.now()
this._io.to(workflowId).emit('workflow-updated', {
workflowId,
message: 'Workflow has been updated externally',
timestamp,
})
room.lastModified = timestamp
logger.info(`Notified ${room.users.size} users about workflow update: ${workflowId}`)
}
async handleWorkflowDeployed(workflowId: string): Promise<void> {
logger.info(`Handling workflow deployed notification for ${workflowId}`)
const room = this.workflowRooms.get(workflowId)
if (!room) {
logger.debug(`No active room found for deployed workflow ${workflowId}`)
return
}
this._io.to(workflowId).emit('workflow-deployed', {
workflowId,
timestamp: Date.now(),
})
logger.info(`Notified ${room.users.size} users about workflow deployment change: ${workflowId}`)
}
}
+460
View File
@@ -0,0 +1,460 @@
import { createLogger } from '@sim/logger'
import { createClient, type RedisClientType } from 'redis'
import type { Server } from 'socket.io'
import type { IRoomManager, UserPresence, UserSession } from '@/rooms/types'
const logger = createLogger('RedisRoomManager')
const KEYS = {
workflowUsers: (wfId: string) => `workflow:${wfId}:users`,
workflowMeta: (wfId: string) => `workflow:${wfId}:meta`,
socketWorkflow: (socketId: string) => `socket:${socketId}:workflow`,
socketSession: (socketId: string) => `socket:${socketId}:session`,
socketPresenceWorkflow: (socketId: string) => `socket:${socketId}:presence-workflow`,
} as const
const SOCKET_KEY_TTL = 3600
const SOCKET_PRESENCE_WORKFLOW_KEY_TTL = 24 * 60 * 60
/**
* Lua script for atomic user removal from room.
* Returns workflowId if user was removed, null otherwise.
* Handles room cleanup atomically to prevent race conditions.
*/
const REMOVE_USER_SCRIPT = `
local socketWorkflowKey = KEYS[1]
local socketSessionKey = KEYS[2]
local socketPresenceWorkflowKey = KEYS[3]
local workflowUsersPrefix = ARGV[1]
local workflowMetaPrefix = ARGV[2]
local socketId = ARGV[3]
local workflowIdHint = ARGV[4]
local workflowId = redis.call('GET', socketWorkflowKey)
if not workflowId then
workflowId = redis.call('GET', socketPresenceWorkflowKey)
end
if not workflowId and workflowIdHint ~= '' then
workflowId = workflowIdHint
end
if not workflowId then
return nil
end
local workflowUsersKey = workflowUsersPrefix .. workflowId .. ':users'
local workflowMetaKey = workflowMetaPrefix .. workflowId .. ':meta'
redis.call('HDEL', workflowUsersKey, socketId)
redis.call('DEL', socketWorkflowKey, socketSessionKey, socketPresenceWorkflowKey)
local remaining = redis.call('HLEN', workflowUsersKey)
if remaining == 0 then
redis.call('DEL', workflowUsersKey, workflowMetaKey)
end
return workflowId
`
/**
* Lua script for atomic user activity update.
* Performs read-modify-write atomically to prevent lost updates.
* Also refreshes TTL on socket keys to prevent expiry during long sessions.
*/
const UPDATE_ACTIVITY_SCRIPT = `
local workflowUsersKey = KEYS[1]
local socketWorkflowKey = KEYS[2]
local socketSessionKey = KEYS[3]
local socketPresenceWorkflowKey = KEYS[4]
local socketId = ARGV[1]
local cursorJson = ARGV[2]
local selectionJson = ARGV[3]
local lastActivity = ARGV[4]
local ttl = tonumber(ARGV[5])
local presenceWorkflowTtl = tonumber(ARGV[6])
local existingJson = redis.call('HGET', workflowUsersKey, socketId)
if not existingJson then
return 0
end
local existing = cjson.decode(existingJson)
if cursorJson ~= '' then
existing.cursor = cjson.decode(cursorJson)
end
if selectionJson ~= '' then
existing.selection = cjson.decode(selectionJson)
end
existing.lastActivity = tonumber(lastActivity)
redis.call('HSET', workflowUsersKey, socketId, cjson.encode(existing))
redis.call('EXPIRE', socketWorkflowKey, ttl)
redis.call('EXPIRE', socketSessionKey, ttl)
redis.call('EXPIRE', socketPresenceWorkflowKey, presenceWorkflowTtl)
return 1
`
/**
* Redis-backed room manager for multi-pod deployments.
* Uses Lua scripts for atomic operations to prevent race conditions.
*/
export class RedisRoomManager implements IRoomManager {
private redis: RedisClientType
private _io: Server
private isConnected = false
private removeUserScriptSha: string | null = null
private updateActivityScriptSha: string | null = null
constructor(io: Server, redisUrl: string) {
this._io = io
this.redis = createClient({
url: redisUrl,
})
this.redis.on('error', (err) => {
logger.error('Redis client error:', err)
})
this.redis.on('reconnecting', () => {
logger.warn('Redis client reconnecting...')
this.isConnected = false
})
this.redis.on('ready', () => {
logger.info('Redis client ready')
this.isConnected = true
})
this.redis.on('end', () => {
logger.warn('Redis client connection closed')
this.isConnected = false
})
}
get io(): Server {
return this._io
}
isReady(): boolean {
return this.isConnected
}
async initialize(): Promise<void> {
if (this.isConnected) return
try {
await this.redis.connect()
this.isConnected = true
// Pre-load Lua scripts for better performance
this.removeUserScriptSha = await this.redis.scriptLoad(REMOVE_USER_SCRIPT)
this.updateActivityScriptSha = await this.redis.scriptLoad(UPDATE_ACTIVITY_SCRIPT)
logger.info('RedisRoomManager connected to Redis and scripts loaded')
} catch (error) {
logger.error('Failed to connect to Redis:', error)
throw error
}
}
async shutdown(): Promise<void> {
if (!this.isConnected) return
try {
await this.redis.quit()
this.isConnected = false
logger.info('RedisRoomManager disconnected from Redis')
} catch (error) {
logger.error('Error during Redis shutdown:', error)
}
}
async addUserToRoom(workflowId: string, socketId: string, presence: UserPresence): Promise<void> {
try {
const pipeline = this.redis.multi()
pipeline.hSet(KEYS.workflowUsers(workflowId), socketId, JSON.stringify(presence))
pipeline.hSet(KEYS.workflowMeta(workflowId), 'lastModified', Date.now().toString())
pipeline.set(KEYS.socketWorkflow(socketId), workflowId)
pipeline.expire(KEYS.socketWorkflow(socketId), SOCKET_KEY_TTL)
pipeline.set(KEYS.socketPresenceWorkflow(socketId), workflowId)
pipeline.expire(KEYS.socketPresenceWorkflow(socketId), SOCKET_PRESENCE_WORKFLOW_KEY_TTL)
pipeline.hSet(KEYS.socketSession(socketId), {
userId: presence.userId,
userName: presence.userName,
avatarUrl: presence.avatarUrl || '',
})
pipeline.expire(KEYS.socketSession(socketId), SOCKET_KEY_TTL)
const results = await pipeline.exec()
// Check if any command failed
const failed = results.some((result) => result instanceof Error)
if (failed) {
logger.error(`Pipeline partially failed when adding user to room`, { workflowId, socketId })
throw new Error('Failed to store user session data in Redis')
}
logger.debug(`Added user ${presence.userId} to workflow ${workflowId} (socket: ${socketId})`)
} catch (error) {
logger.error(`Failed to add user to room: ${socketId} -> ${workflowId}`, error)
throw error
}
}
async removeUserFromRoom(
socketId: string,
workflowIdHint?: string,
retried = false
): Promise<string | null> {
if (!this.removeUserScriptSha) {
logger.error('removeUserFromRoom called before initialize()')
return null
}
try {
const workflowId = await this.redis.evalSha(this.removeUserScriptSha, {
keys: [
KEYS.socketWorkflow(socketId),
KEYS.socketSession(socketId),
KEYS.socketPresenceWorkflow(socketId),
],
arguments: ['workflow:', 'workflow:', socketId, workflowIdHint ?? ''],
})
if (typeof workflowId === 'string' && workflowId.length > 0) {
logger.debug(`Removed socket ${socketId} from workflow ${workflowId}`)
return workflowId
}
return null
} catch (error) {
if ((error as Error).message?.includes('NOSCRIPT') && !retried) {
logger.warn('Lua script not found, reloading...')
this.removeUserScriptSha = await this.redis.scriptLoad(REMOVE_USER_SCRIPT)
return this.removeUserFromRoom(socketId, workflowIdHint, true)
}
logger.error(`Failed to remove user from room: ${socketId}`, error)
return null
}
}
async getWorkflowIdForSocket(socketId: string): Promise<string | null> {
const workflowId = await this.redis.get(KEYS.socketWorkflow(socketId))
if (workflowId) {
return workflowId
}
return this.redis.get(KEYS.socketPresenceWorkflow(socketId))
}
async getUserSession(socketId: string): Promise<UserSession | null> {
try {
const session = await this.redis.hGetAll(KEYS.socketSession(socketId))
if (!session.userId) {
return null
}
return {
userId: session.userId,
userName: session.userName,
avatarUrl: session.avatarUrl || undefined,
}
} catch (error) {
logger.error(`Failed to get user session for ${socketId}:`, error)
return null
}
}
async getWorkflowUsers(workflowId: string): Promise<UserPresence[]> {
try {
const users = await this.redis.hGetAll(KEYS.workflowUsers(workflowId))
return Object.entries(users)
.map(([socketId, json]) => {
try {
return JSON.parse(json) as UserPresence
} catch {
logger.warn(`Corrupted user data for socket ${socketId}, skipping`)
return null
}
})
.filter((u): u is UserPresence => u !== null)
} catch (error) {
logger.error(`Failed to get workflow users for ${workflowId}:`, error)
return []
}
}
async hasWorkflowRoom(workflowId: string): Promise<boolean> {
const exists = await this.redis.exists(KEYS.workflowUsers(workflowId))
return exists > 0
}
async updateUserActivity(
workflowId: string,
socketId: string,
updates: Partial<Pick<UserPresence, 'cursor' | 'selection' | 'lastActivity'>>,
retried = false
): Promise<void> {
if (!this.updateActivityScriptSha) {
logger.error('updateUserActivity called before initialize()')
return
}
try {
await this.redis.evalSha(this.updateActivityScriptSha, {
keys: [
KEYS.workflowUsers(workflowId),
KEYS.socketWorkflow(socketId),
KEYS.socketSession(socketId),
KEYS.socketPresenceWorkflow(socketId),
],
arguments: [
socketId,
updates.cursor !== undefined ? JSON.stringify(updates.cursor) : '',
updates.selection !== undefined ? JSON.stringify(updates.selection) : '',
(updates.lastActivity ?? Date.now()).toString(),
SOCKET_KEY_TTL.toString(),
SOCKET_PRESENCE_WORKFLOW_KEY_TTL.toString(),
],
})
} catch (error) {
if ((error as Error).message?.includes('NOSCRIPT') && !retried) {
logger.warn('Lua script not found, reloading...')
this.updateActivityScriptSha = await this.redis.scriptLoad(UPDATE_ACTIVITY_SCRIPT)
return this.updateUserActivity(workflowId, socketId, updates, true)
}
logger.error(`Failed to update user activity: ${socketId}`, error)
}
}
async updateRoomLastModified(workflowId: string): Promise<void> {
await this.redis.hSet(KEYS.workflowMeta(workflowId), 'lastModified', Date.now().toString())
}
async broadcastPresenceUpdate(workflowId: string): Promise<void> {
const users = await this.getWorkflowUsers(workflowId)
// io.to() with Redis adapter broadcasts to all pods
this._io.to(workflowId).emit('presence-update', users)
}
emitToWorkflow<T = unknown>(workflowId: string, event: string, payload: T): void {
this._io.to(workflowId).emit(event, payload)
}
async getUniqueUserCount(workflowId: string): Promise<number> {
const users = await this.getWorkflowUsers(workflowId)
const uniqueUserIds = new Set(users.map((u) => u.userId))
return uniqueUserIds.size
}
async getTotalActiveConnections(): Promise<number> {
// This is more complex with Redis - we'd need to scan all workflow:*:users keys
// For now, just count sockets in this server instance
// The true count would require aggregating across all pods
return this._io.sockets.sockets.size
}
async handleWorkflowDeletion(workflowId: string): Promise<void> {
logger.info(`Handling workflow deletion notification for ${workflowId}`)
try {
const users = await this.getWorkflowUsers(workflowId)
if (users.length === 0) {
logger.debug(`No active users found for deleted workflow ${workflowId}`)
return
}
// Notify all clients across all pods via Redis adapter
this._io.to(workflowId).emit('workflow-deleted', {
workflowId,
message: 'This workflow has been deleted',
timestamp: Date.now(),
})
// Use Socket.IO's cross-pod socketsLeave() to remove all sockets from the room
// This works across all pods when using the Redis adapter
await this._io.in(workflowId).socketsLeave(workflowId)
logger.debug(`All sockets left workflow room ${workflowId} via socketsLeave()`)
// Remove all users from Redis state
for (const user of users) {
await this.removeUserFromRoom(user.socketId, workflowId)
}
// Clean up room data
await this.redis.del([KEYS.workflowUsers(workflowId), KEYS.workflowMeta(workflowId)])
logger.info(
`Cleaned up workflow room ${workflowId} after deletion (${users.length} users disconnected)`
)
} catch (error) {
logger.error(`Failed to handle workflow deletion for ${workflowId}:`, error)
}
}
async handleWorkflowRevert(workflowId: string, timestamp: number): Promise<void> {
logger.info(`Handling workflow revert notification for ${workflowId}`)
const hasRoom = await this.hasWorkflowRoom(workflowId)
if (!hasRoom) {
logger.debug(`No active room found for reverted workflow ${workflowId}`)
return
}
this._io.to(workflowId).emit('workflow-reverted', {
workflowId,
message: 'Workflow has been reverted to deployed state',
timestamp,
})
await this.updateRoomLastModified(workflowId)
const userCount = await this.getUniqueUserCount(workflowId)
logger.info(`Notified ${userCount} users about workflow revert: ${workflowId}`)
}
async handleWorkflowUpdate(workflowId: string): Promise<void> {
logger.info(`Handling workflow update notification for ${workflowId}`)
const hasRoom = await this.hasWorkflowRoom(workflowId)
if (!hasRoom) {
logger.debug(`No active room found for updated workflow ${workflowId}`)
return
}
const timestamp = Date.now()
this._io.to(workflowId).emit('workflow-updated', {
workflowId,
message: 'Workflow has been updated externally',
timestamp,
})
await this.updateRoomLastModified(workflowId)
const userCount = await this.getUniqueUserCount(workflowId)
logger.info(`Notified ${userCount} users about workflow update: ${workflowId}`)
}
async handleWorkflowDeployed(workflowId: string): Promise<void> {
logger.info(`Handling workflow deployed notification for ${workflowId}`)
const hasRoom = await this.hasWorkflowRoom(workflowId)
if (!hasRoom) {
logger.debug(`No active room found for deployed workflow ${workflowId}`)
return
}
this._io.to(workflowId).emit('workflow-deployed', {
workflowId,
timestamp: Date.now(),
})
const userCount = await this.getUniqueUserCount(workflowId)
logger.info(`Notified ${userCount} users about workflow deployment change: ${workflowId}`)
}
}
+146
View File
@@ -0,0 +1,146 @@
import type { Server } from 'socket.io'
/**
* User presence data stored in room state
*/
export interface UserPresence {
userId: string
workflowId: string
userName: string
socketId: string
tabSessionId?: string
joinedAt: number
lastActivity: number
role: string
cursor?: { x: number; y: number }
selection?: { type: 'block' | 'edge' | 'none'; id?: string }
avatarUrl?: string | null
}
/**
* User session data (minimal info for quick lookups)
*/
export interface UserSession {
userId: string
userName: string
avatarUrl?: string | null
}
/**
* Workflow room state
*/
export interface WorkflowRoom {
workflowId: string
users: Map<string, UserPresence>
lastModified: number
activeConnections: number
}
/**
* Common interface for room managers (in-memory and Redis)
* All methods that access state are async to support Redis operations
*/
export interface IRoomManager {
readonly io: Server
/**
* Initialize the room manager (connect to Redis, etc.)
*/
initialize(): Promise<void>
/**
* Whether the room manager is ready to serve requests
*/
isReady(): boolean
/**
* Clean shutdown
*/
shutdown(): Promise<void>
/**
* Add a user to a workflow room
*/
addUserToRoom(workflowId: string, socketId: string, presence: UserPresence): Promise<void>
/**
* Remove a user from their current room
* Optional workflowIdHint is used when socket mapping keys are missing/expired.
* Returns the workflowId they were in, or null if not in any room.
*/
removeUserFromRoom(socketId: string, workflowIdHint?: string): Promise<string | null>
/**
* Get the workflow ID for a socket
*/
getWorkflowIdForSocket(socketId: string): Promise<string | null>
/**
* Get user session data for a socket
*/
getUserSession(socketId: string): Promise<UserSession | null>
/**
* Get all users in a workflow room
*/
getWorkflowUsers(workflowId: string): Promise<UserPresence[]>
/**
* Check if a workflow room exists
*/
hasWorkflowRoom(workflowId: string): Promise<boolean>
/**
* Update user activity (cursor, selection, lastActivity)
*/
updateUserActivity(
workflowId: string,
socketId: string,
updates: Partial<Pick<UserPresence, 'cursor' | 'selection' | 'lastActivity'>>
): Promise<void>
/**
* Update room's lastModified timestamp
*/
updateRoomLastModified(workflowId: string): Promise<void>
/**
* Broadcast presence update to all clients in a workflow room
*/
broadcastPresenceUpdate(workflowId: string): Promise<void>
/**
* Emit an event to all clients in a workflow room
*/
emitToWorkflow<T = unknown>(workflowId: string, event: string, payload: T): void
/**
* Get the number of unique users in a workflow room
*/
getUniqueUserCount(workflowId: string): Promise<number>
/**
* Get total active connections across all rooms
*/
getTotalActiveConnections(): Promise<number>
/**
* Handle workflow deletion - notify users and clean up room
*/
handleWorkflowDeletion(workflowId: string): Promise<void>
/**
* Handle workflow revert - notify users
*/
handleWorkflowRevert(workflowId: string, timestamp: number): Promise<void>
/**
* Handle workflow update - notify users
*/
handleWorkflowUpdate(workflowId: string): Promise<void>
/**
* Handle workflow deployment change - notify users to refresh deployment state
*/
handleWorkflowDeployed(workflowId: string): Promise<void>
}
+156
View File
@@ -0,0 +1,156 @@
import type { IncomingMessage, ServerResponse } from 'http'
import { safeCompare } from '@sim/security/compare'
import { env } from '@/env'
import type { IRoomManager } from '@/rooms'
interface Logger {
info: (message: string, ...args: unknown[]) => void
error: (message: string, ...args: unknown[]) => void
debug: (message: string, ...args: unknown[]) => void
warn: (message: string, ...args: unknown[]) => void
}
function checkInternalApiKey(req: IncomingMessage): { success: boolean; error?: string } {
const apiKey = req.headers['x-api-key']
const expectedApiKey = env.INTERNAL_API_SECRET
if (!expectedApiKey) {
return { success: false, error: 'Internal API key not configured' }
}
if (!apiKey) {
return { success: false, error: 'API key required' }
}
const apiKeyStr = Array.isArray(apiKey) ? apiKey[0] : apiKey
if (!apiKeyStr || !safeCompare(apiKeyStr, expectedApiKey)) {
return { success: false, error: 'Invalid API key' }
}
return { success: true }
}
function readRequestBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let body = ''
req.on('data', (chunk) => {
body += chunk.toString()
})
req.on('end', () => resolve(body))
req.on('error', reject)
})
}
function sendSuccess(res: ServerResponse): void {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ success: true }))
}
function sendError(res: ServerResponse, message: string, status = 500): void {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: message }))
}
/**
* Creates an HTTP request handler for the socket server
* @param roomManager - RoomManager instance for managing workflow rooms and state
* @param logger - Logger instance for logging requests and errors
* @returns HTTP request handler function
*/
export function createHttpHandler(roomManager: IRoomManager, logger: Logger) {
return async (req: IncomingMessage, res: ServerResponse) => {
// Health check doesn't require auth
if (req.method === 'GET' && req.url === '/health') {
try {
const connections = await roomManager.getTotalActiveConnections()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
status: 'ok',
timestamp: new Date().toISOString(),
connections,
})
)
} catch (error) {
logger.error('Error in health check:', error)
res.writeHead(503, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'error', message: 'Health check failed' }))
}
return
}
// All POST endpoints require internal API key authentication
if (req.method === 'POST') {
const authResult = checkInternalApiKey(req)
if (!authResult.success) {
res.writeHead(401, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: authResult.error }))
return
}
if (!roomManager.isReady()) {
sendError(res, 'Room manager unavailable', 503)
return
}
}
// Handle workflow deletion notifications from the main API
if (req.method === 'POST' && req.url === '/api/workflow-deleted') {
try {
const body = await readRequestBody(req)
const { workflowId } = JSON.parse(body)
await roomManager.handleWorkflowDeletion(workflowId)
sendSuccess(res)
} catch (error) {
logger.error('Error handling workflow deletion notification:', error)
sendError(res, 'Failed to process deletion notification')
}
return
}
// Handle workflow update notifications from the main API
if (req.method === 'POST' && req.url === '/api/workflow-updated') {
try {
const body = await readRequestBody(req)
const { workflowId } = JSON.parse(body)
await roomManager.handleWorkflowUpdate(workflowId)
sendSuccess(res)
} catch (error) {
logger.error('Error handling workflow update notification:', error)
sendError(res, 'Failed to process update notification')
}
return
}
// Handle workflow deployment change notifications from the main API
if (req.method === 'POST' && req.url === '/api/workflow-deployed') {
try {
const body = await readRequestBody(req)
const { workflowId } = JSON.parse(body)
await roomManager.handleWorkflowDeployed(workflowId)
sendSuccess(res)
} catch (error) {
logger.error('Error handling workflow deployed notification:', error)
sendError(res, 'Failed to process deployment notification')
}
return
}
// Handle workflow revert notifications from the main API
if (req.method === 'POST' && req.url === '/api/workflow-reverted') {
try {
const body = await readRequestBody(req)
const { workflowId, timestamp } = JSON.parse(body)
await roomManager.handleWorkflowRevert(workflowId, timestamp)
sendSuccess(res)
} catch (error) {
logger.error('Error handling workflow revert notification:', error)
sendError(res, 'Failed to process revert notification')
}
return
}
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found' }))
}
}
@@ -0,0 +1,268 @@
import { createServer } from 'http'
import { Server } from 'socket.io'
import { io, type Socket } from 'socket.io-client'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
describe('Socket Server Integration Tests', () => {
let httpServer: any
let socketServer: Server
let clientSocket: Socket
let serverPort: number
beforeAll(async () => {
httpServer = createServer()
socketServer = new Server(httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST'],
},
})
await new Promise<void>((resolve) => {
httpServer.listen(() => {
serverPort = httpServer.address()?.port
resolve()
})
})
socketServer.on('connection', (socket) => {
socket.on('join-workflow', ({ workflowId }) => {
socket.join(workflowId)
socket.emit('joined-workflow', { workflowId })
})
socket.on('workflow-operation', (data) => {
socket.to(data.workflowId || 'test-workflow').emit('workflow-operation', {
...data,
senderId: socket.id,
})
})
})
clientSocket = io(`http://localhost:${serverPort}`, {
transports: ['polling', 'websocket'],
})
await new Promise<void>((resolve) => {
clientSocket.on('connect', () => {
resolve()
})
})
})
afterAll(async () => {
if (clientSocket) {
clientSocket.close()
}
if (socketServer) {
socketServer.close()
}
if (httpServer) {
httpServer.close()
}
})
it('should connect to socket server', () => {
expect(clientSocket.connected).toBe(true)
})
it('should join workflow room', async () => {
const workflowId = 'test-workflow-123'
const joinedPromise = new Promise<void>((resolve) => {
clientSocket.once('joined-workflow', (data) => {
expect(data.workflowId).toBe(workflowId)
resolve()
})
})
clientSocket.emit('join-workflow', { workflowId })
await joinedPromise
})
it('should broadcast workflow operations', async () => {
const workflowId = 'test-workflow-456'
const client2 = io(`http://localhost:${serverPort}`)
await new Promise<void>((resolve) => {
client2.once('connect', resolve)
})
try {
const join1Promise = new Promise<void>((resolve) => {
clientSocket.once('joined-workflow', () => resolve())
})
const join2Promise = new Promise<void>((resolve) => {
client2.once('joined-workflow', () => resolve())
})
clientSocket.emit('join-workflow', { workflowId })
client2.emit('join-workflow', { workflowId })
await Promise.all([join1Promise, join2Promise])
const operationPromise = new Promise<void>((resolve) => {
client2.once('workflow-operation', (data) => {
expect(data.operation).toBe('batch-add-blocks')
expect(data.target).toBe('blocks')
expect(data.payload.blocks[0].id).toBe('block-123')
resolve()
})
})
clientSocket.emit('workflow-operation', {
workflowId,
operation: 'batch-add-blocks',
target: 'blocks',
payload: {
blocks: [
{ id: 'block-123', type: 'action', name: 'Test Block', position: { x: 0, y: 0 } },
],
edges: [],
loops: {},
parallels: {},
subBlockValues: {},
},
timestamp: Date.now(),
})
await operationPromise
} finally {
client2.close()
}
})
it('should handle multiple concurrent connections', async () => {
const numClients = 10
const clients: Socket[] = []
const workflowId = 'stress-test-workflow'
try {
const connectPromises = Array.from({ length: numClients }, () => {
const client = io(`http://localhost:${serverPort}`)
clients.push(client)
return new Promise<void>((resolve) => {
client.once('connect', resolve)
})
})
await Promise.all(connectPromises)
const joinPromises = clients.map((client) => {
return new Promise<void>((resolve) => {
client.once('joined-workflow', () => resolve())
})
})
clients.forEach((client) => {
client.emit('join-workflow', { workflowId })
})
await Promise.all(joinPromises)
let receivedCount = 0
const expectedCount = numClients - 1
const operationPromise = new Promise<void>((resolve) => {
clients.forEach((client, index) => {
if (index === 0) return
client.once('workflow-operation', () => {
receivedCount++
if (receivedCount === expectedCount) {
resolve()
}
})
})
})
clients[0].emit('workflow-operation', {
workflowId,
operation: 'batch-add-blocks',
target: 'blocks',
payload: {
blocks: [
{ id: 'stress-block', type: 'action', name: 'Stress Block', position: { x: 0, y: 0 } },
],
edges: [],
loops: {},
parallels: {},
subBlockValues: {},
},
timestamp: Date.now(),
})
await operationPromise
expect(receivedCount).toBe(expectedCount)
} finally {
clients.forEach((client) => client.close())
}
})
it('should handle rapid operations without loss', async () => {
const workflowId = 'rapid-test-workflow'
const numOperations = 50
const client2 = io(`http://localhost:${serverPort}`)
await new Promise<void>((resolve) => {
client2.once('connect', resolve)
})
try {
const join1Promise = new Promise<void>((resolve) => {
clientSocket.once('joined-workflow', () => resolve())
})
const join2Promise = new Promise<void>((resolve) => {
client2.once('joined-workflow', () => resolve())
})
clientSocket.emit('join-workflow', { workflowId })
client2.emit('join-workflow', { workflowId })
await Promise.all([join1Promise, join2Promise])
let receivedCount = 0
const receivedOperations = new Set<string>()
const operationsPromise = new Promise<void>((resolve) => {
client2.on('workflow-operation', (data) => {
receivedCount++
receivedOperations.add(data.payload.blocks[0].id)
if (receivedCount === numOperations) {
resolve()
}
})
})
for (let i = 0; i < numOperations; i++) {
clientSocket.emit('workflow-operation', {
workflowId,
operation: 'batch-add-blocks',
target: 'blocks',
payload: {
blocks: [
{
id: `rapid-block-${i}`,
type: 'action',
name: `Rapid Block ${i}`,
position: { x: 0, y: 0 },
},
],
edges: [],
loops: {},
parallels: {},
subBlockValues: {},
},
timestamp: Date.now(),
})
}
await operationsPromise
expect(receivedCount).toBe(numOperations)
expect(receivedOperations.size).toBe(numOperations)
} finally {
client2.close()
}
})
})
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "@sim/tsconfig/base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+27
View File
@@ -0,0 +1,27 @@
import path from 'node:path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['**/*.test.{ts,tsx}'],
exclude: ['**/node_modules/**', '**/dist/**'],
setupFiles: ['./vitest.setup.ts'],
pool: 'threads',
testTimeout: 10000,
},
resolve: {
alias: [
{
find: '@sim/db',
replacement: path.resolve(__dirname, '../../packages/db'),
},
{
find: '@sim/logger',
replacement: path.resolve(__dirname, '../../packages/logger/src'),
},
{ find: '@', replacement: path.resolve(__dirname, 'src') },
],
},
})
+6
View File
@@ -0,0 +1,6 @@
process.env.DATABASE_URL ??= 'postgres://localhost/test'
process.env.NODE_ENV ??= 'test'
process.env.BETTER_AUTH_URL ??= 'http://localhost:3000'
process.env.BETTER_AUTH_SECRET ??= 'test-better-auth-secret-at-least-32-chars'
process.env.INTERNAL_API_SECRET ??= 'test-internal-api-secret-at-least-32-chars'
process.env.NEXT_PUBLIC_APP_URL ??= 'http://localhost:3000'