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
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:
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)}`
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@sim/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -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') },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user