UNPKG

@shelltender/server

Version:

Server-side terminal session management for Shelltender

1 lines 201 kB
{"version":3,"sources":["../src/index.ts","../src/SessionManager.ts","../src/RestrictedShell.ts","../src/BufferManager.ts","../src/SessionStore.ts","../src/WebSocketServer.ts","../src/admin/AdminSessionProxy.ts","../src/events/EventManager.ts","../src/patterns/PatternMatcher.ts","../src/patterns/RegexMatcher.ts","../src/patterns/StringMatcher.ts","../src/patterns/AnsiMatcher.ts","../src/patterns/CustomMatcher.ts","../src/patterns/PatternMatcherFactory.ts","../src/patterns/CommonPatterns.ts","../src/patterns/AgenticCodingPatterns.ts","../src/TerminalDataPipeline.ts","../src/integration/PipelineIntegration.ts","../src/createServer.ts","../src/routes/admin.ts"],"sourcesContent":["// Export all server components\nexport { SessionManager } from './SessionManager.js';\nexport type { SessionMetadata } from './SessionManager.js';\nexport { BufferManager } from './BufferManager.js';\nexport { SessionStore } from './SessionStore.js';\nexport { RestrictedShell } from './RestrictedShell.js';\nexport { WebSocketServer } from './WebSocketServer.js';\nexport { EventManager } from './events/EventManager.js';\n\n// Export pipeline components\nexport { TerminalDataPipeline, CommonProcessors, CommonFilters } from './TerminalDataPipeline.js';\nexport { PipelineIntegration } from './integration/PipelineIntegration.js';\n\n// Export convenience functions\nexport { \n createShelltender, \n createShelltenderServer, \n startShelltender,\n detectEnvironment,\n validateConfiguration\n} from './createServer.js';\nexport type { ShelltenderConfig, ShelltenderInstance } from './createServer.js';\n\n// Export interfaces\nexport type { ISessionManager, IDataEmitter } from './interfaces/ISessionManager.js';\n\n// Export pattern matchers\nexport * from './patterns/index.js';\n\n// Export types that are specific to the server\nexport type { StoredSession } from './SessionStore.js';\n\n// Export admin components\nexport { AdminSessionProxy } from './admin/AdminSessionProxy.js';","import * as pty from 'node-pty';\nimport { v4 as uuidv4 } from 'uuid';\nimport { EventEmitter } from 'events';\nimport { TerminalSession, SessionOptions } from '@shelltender/core';\nimport { SessionStore } from './SessionStore.js';\nimport { RestrictedShell } from './RestrictedShell.js';\nimport { ISessionManager } from './interfaces/ISessionManager.js';\n\ninterface PtyProcess {\n pty: pty.IPty;\n session: TerminalSession;\n clients: Set<string>;\n}\n\n// Simple metadata without user tracking\nexport interface SessionMetadata {\n id: string;\n command: string;\n args: string[];\n createdAt: Date;\n isActive: boolean;\n}\n\nexport class SessionManager extends EventEmitter implements ISessionManager {\n private sessions: Map<string, PtyProcess> = new Map();\n private sessionStore: SessionStore;\n private restoredSessions: Set<string> = new Set();\n\n constructor(sessionStore: SessionStore) {\n super();\n this.sessionStore = sessionStore;\n this.setMaxListeners(100);\n this.restoreSessions();\n }\n\n private async restoreSessions(): Promise<void> {\n const savedSessions = await this.sessionStore.loadAllSessions();\n \n for (const [sessionId, storedSession] of savedSessions) {\n try {\n // Create a new PTY process\n const env = {\n ...process.env,\n LANG: 'en_US.UTF-8',\n LC_ALL: 'en_US.UTF-8',\n LC_CTYPE: 'en_US.UTF-8',\n TERM: 'xterm-256color',\n } as { [key: string]: string };\n\n const ptyProcess = pty.spawn('/bin/bash', [], {\n name: 'xterm-256color',\n cols: storedSession.session.cols,\n rows: storedSession.session.rows,\n cwd: storedSession.cwd || process.env.HOME,\n env,\n });\n\n // Update session with new creation time but preserve original ID\n const session: TerminalSession = {\n ...storedSession.session,\n id: sessionId,\n lastAccessedAt: new Date(),\n };\n\n this.sessions.set(sessionId, {\n pty: ptyProcess,\n session,\n clients: new Set(),\n });\n\n // Set up PTY handlers first\n this.setupPtyHandlers(sessionId, ptyProcess);\n \n // Mark this as a restored session AFTER handlers are set up\n this.restoredSessions.add(sessionId);\n \n // Emit the saved buffer data if any\n if (storedSession.buffer) {\n this.emit('data', sessionId, storedSession.buffer, { source: 'restored' });\n }\n\n } catch (error) {\n console.error(`Failed to restore session ${sessionId}:`, error);\n await this.sessionStore.deleteSession(sessionId);\n }\n }\n }\n\n private setupPtyHandlers(sessionId: string, ptyProcess: pty.IPty): void {\n let saveTimer: NodeJS.Timeout | null = null;\n let hasReceivedOutput = false;\n \n ptyProcess.onData((data: string) => {\n // Emit data event for observers\n this.emit('data', sessionId, data, { source: 'pty' });\n \n // For restored sessions, only start saving after we receive new output\n // This prevents re-saving the same buffer that was just restored\n if (this.restoredSessions.has(sessionId) && !hasReceivedOutput) {\n hasReceivedOutput = true;\n this.restoredSessions.delete(sessionId); // No longer need to track\n }\n });\n\n ptyProcess.onExit(() => {\n // Emit session end event\n this.emit('sessionEnd', sessionId);\n \n this.sessions.delete(sessionId);\n this.restoredSessions.delete(sessionId);\n this.sessionStore.deleteSession(sessionId);\n });\n }\n\n createSession(options: SessionOptions = {}): TerminalSession {\n const cols = options.cols || 80;\n const rows = options.rows || 24;\n const sessionId = options.id || uuidv4();\n const session: TerminalSession = {\n id: sessionId,\n createdAt: new Date(),\n lastAccessedAt: new Date(),\n cols,\n rows,\n command: options.command,\n args: options.args,\n locked: options.locked,\n };\n\n // Set up shell command and environment\n let command = options.command || process.env.SHELL || '/bin/sh'; // Better default\n let args = options.args || [];\n let cwd = options.cwd || process.cwd(); // Use cwd not HOME\n \n // Ensure UTF-8 locale\n let env = {\n ...process.env,\n ...options.env,\n LANG: 'en_US.UTF-8',\n LC_ALL: 'en_US.UTF-8',\n LC_CTYPE: 'en_US.UTF-8',\n TERM: 'xterm-256color',\n } as { [key: string]: string };\n\n // Apply restrictions if specified\n if (options.restrictToPath || options.blockedCommands || options.readOnlyMode) {\n const restrictedShell = new RestrictedShell(options);\n const shellConfig = restrictedShell.getShellCommand();\n command = shellConfig.command;\n args = shellConfig.args;\n env = { ...env, ...shellConfig.env };\n }\n\n\n let ptyProcess;\n try {\n ptyProcess = pty.spawn(command, args, {\n name: 'xterm-256color',\n cols,\n rows,\n cwd,\n env,\n });\n } catch (error) {\n // Provide better error context\n const errorMessage = `Failed to create PTY session: ${error instanceof Error ? error.message : String(error)}`;\n const debugInfo = {\n command,\n args,\n cwd,\n cols,\n rows,\n platform: process.platform,\n shell: command,\n error: error instanceof Error ? error.message : String(error),\n stack: error instanceof Error ? error.stack : undefined\n };\n console.error(errorMessage, debugInfo);\n \n // Check common issues\n if (error instanceof Error && error.message.includes('ENOENT')) {\n throw new Error(`Shell not found: ${command}. Try using /bin/sh or install ${command}`);\n }\n \n throw new Error(`${errorMessage} (command: ${command}, cwd: ${cwd})`);\n }\n\n this.sessions.set(sessionId, {\n pty: ptyProcess,\n session,\n clients: new Set(),\n });\n\n // Set up PTY handlers\n this.setupPtyHandlers(sessionId, ptyProcess);\n \n // Save the new session with the actual cwd\n this.sessionStore.saveSession(sessionId, session, '', cwd);\n\n return session;\n }\n\n getSession(sessionId: string): TerminalSession | null {\n const processInfo = this.sessions.get(sessionId);\n if (processInfo) {\n processInfo.session.lastAccessedAt = new Date();\n return processInfo.session;\n }\n return null;\n }\n\n writeToSession(sessionId: string, data: string): boolean {\n const processInfo = this.sessions.get(sessionId);\n if (processInfo) {\n try {\n processInfo.pty.write(data);\n return true;\n } catch (error) {\n return false;\n }\n }\n return false;\n }\n\n // Send a command to a session (adds newline automatically)\n sendCommand(sessionId: string, command: string): boolean {\n return this.writeToSession(sessionId, command + '\\n');\n }\n\n // Send raw input without modification\n sendRawInput(sessionId: string, data: string): boolean {\n return this.writeToSession(sessionId, data);\n }\n\n // Send special keys\n sendKey(sessionId: string, key: 'ctrl-c' | 'ctrl-d' | 'ctrl-z' | 'ctrl-r' | 'tab' | 'escape' | 'up' | 'down' | 'left' | 'right'): boolean {\n const keyMap: Record<string, string> = {\n 'ctrl-c': '\\x03',\n 'ctrl-d': '\\x04',\n 'ctrl-z': '\\x1a',\n 'ctrl-r': '\\x12',\n 'tab': '\\t',\n 'escape': '\\x1b',\n 'up': '\\x1b[A',\n 'down': '\\x1b[B',\n 'left': '\\x1b[D',\n 'right': '\\x1b[C'\n };\n \n const sequence = keyMap[key];\n if (!sequence) return false;\n \n return this.writeToSession(sessionId, sequence);\n }\n\n resizeSession(sessionId: string, cols: number, rows: number): boolean {\n const processInfo = this.sessions.get(sessionId);\n if (processInfo) {\n processInfo.pty.resize(cols, rows);\n processInfo.session.cols = cols;\n processInfo.session.rows = rows;\n return true;\n }\n return false;\n }\n\n addClient(sessionId: string, clientId: string): void {\n const processInfo = this.sessions.get(sessionId);\n if (processInfo) {\n processInfo.clients.add(clientId);\n }\n }\n\n removeClient(sessionId: string, clientId: string): void {\n const processInfo = this.sessions.get(sessionId);\n if (processInfo) {\n processInfo.clients.delete(clientId);\n }\n }\n\n getAllSessions(): TerminalSession[] {\n return Array.from(this.sessions.values()).map(p => p.session);\n }\n\n killSession(sessionId: string): boolean {\n const processInfo = this.sessions.get(sessionId);\n if (processInfo) {\n // Kill the PTY process\n processInfo.pty.kill();\n \n // Remove from sessions map\n this.sessions.delete(sessionId);\n \n // Emit session end event\n this.emit('sessionEnd', sessionId);\n \n // Delete from store\n this.sessionStore.deleteSession(sessionId);\n \n return true;\n }\n return false;\n }\n\n // Implement IDataEmitter interface methods\n onData(callback: (sessionId: string, data: string, metadata?: any) => void): () => void {\n this.on('data', callback);\n return () => this.off('data', callback);\n }\n\n onSessionEnd(callback: (sessionId: string) => void): () => void {\n this.on('sessionEnd', callback);\n return () => this.off('sessionEnd', callback);\n }\n\n getActiveSessionIds(): string[] {\n return Array.from(this.sessions.keys());\n }\n\n getSessionMetadata(sessionId: string): SessionMetadata | null {\n const processInfo = this.sessions.get(sessionId);\n if (!processInfo) return null;\n \n return {\n id: sessionId,\n command: processInfo.session.command || '/bin/bash',\n args: processInfo.session.args || [],\n createdAt: processInfo.session.createdAt,\n isActive: true\n };\n }\n\n getAllSessionMetadata(): SessionMetadata[] {\n return Array.from(this.sessions.keys())\n .map(id => this.getSessionMetadata(id))\n .filter(Boolean) as SessionMetadata[];\n }\n}","import * as path from 'path';\nimport * as fs from 'fs';\nimport { SessionOptions } from '@shelltender/core';\n\nexport class RestrictedShell {\n private restrictedPath: string;\n private blockedCommands: Set<string>;\n private allowUpward: boolean;\n\n constructor(private options: SessionOptions) {\n this.restrictedPath = options.restrictToPath \n ? path.resolve(options.restrictToPath) \n : options.cwd || process.env.HOME || '/';\n \n this.allowUpward = options.allowUpwardNavigation ?? !options.restrictToPath;\n \n this.blockedCommands = new Set(options.blockedCommands || [\n 'sudo', 'su', 'chmod', 'chown', 'mount', 'umount'\n ]);\n \n if (options.readOnlyMode) {\n this.addReadOnlyRestrictions();\n }\n }\n\n private addReadOnlyRestrictions(): void {\n const writeCommands = [\n 'rm', 'rmdir', 'mv', 'cp', 'mkdir', 'touch', 'dd',\n 'nano', 'vim', 'vi', 'emacs', '>', '>>'\n ];\n writeCommands.forEach(cmd => this.blockedCommands.add(cmd));\n }\n\n // Create initialization script for the shell\n getInitScript(): string {\n const scripts: string[] = [];\n \n // Set up restricted PATH if needed\n if (this.options.restrictToPath) {\n scripts.push(`\n # Restrict navigation\n export RESTRICTED_PATH=\"${this.restrictedPath}\"\n \n # Override cd command\n cd() {\n local target=\"$1\"\n if [ -z \"$target\" ]; then\n target=\"$RESTRICTED_PATH\"\n fi\n \n # Resolve the absolute path\n local abs_path=$(realpath -m \"$target\" 2>/dev/null || echo \"$target\")\n \n # Check if path is within restricted area\n if [[ ! \"$abs_path\" =~ ^\"$RESTRICTED_PATH\" ]]; then\n echo \"Access denied: Cannot navigate outside restricted area\" >&2\n return 1\n fi\n \n # Use builtin cd\n builtin cd \"$target\"\n }\n \n # Override pwd to show relative path\n pwd() {\n local current=$(builtin pwd)\n if [[ \"$current\" =~ ^\"$RESTRICTED_PATH\" ]]; then\n echo \"\\${current#\\$RESTRICTED_PATH}\" | sed 's/^$/\\\\//'\n else\n echo \"/\"\n fi\n }\n `);\n }\n \n // Block specific commands\n if (this.blockedCommands.size > 0) {\n for (const cmd of this.blockedCommands) {\n scripts.push(`\n ${cmd}() {\n echo \"Command '${cmd}' is not allowed in this session\" >&2\n return 1\n }\n `);\n }\n }\n \n // Set readonly mode\n if (this.options.readOnlyMode) {\n scripts.push(`\n # Redirect write operations\n set -o noclobber # Prevent overwriting files\n \n # Make common directories read-only\n alias rm='echo \"Write operations are disabled\" >&2; false'\n alias touch='echo \"Write operations are disabled\" >&2; false'\n `);\n }\n \n // Prevent history file writing in restricted mode\n if (this.options.restrictToPath || this.options.readOnlyMode) {\n scripts.push(`\n unset HISTFILE\n export HISTSIZE=0\n `);\n }\n \n return scripts.join('\\n');\n }\n \n // Validate a command before execution\n validateCommand(command: string): { allowed: boolean; reason?: string } {\n const parts = command.trim().split(/\\s+/);\n const cmd = parts[0];\n \n // Check blocked commands\n if (this.blockedCommands.has(cmd)) {\n return { \n allowed: false, \n reason: `Command '${cmd}' is not allowed in this session` \n };\n }\n \n // Check for path traversal attempts\n if (this.options.restrictToPath && !this.allowUpward) {\n if (command.includes('../') || command.includes('..\\\\')) {\n return { \n allowed: false, \n reason: 'Path traversal is not allowed' \n };\n }\n }\n \n // Check for absolute paths outside restricted area\n if (this.options.restrictToPath) {\n const absolutePathRegex = /\\/[^\\s]+/g;\n const matches = command.match(absolutePathRegex) || [];\n \n for (const match of matches) {\n const absPath = path.resolve(match);\n if (!absPath.startsWith(this.restrictedPath)) {\n return { \n allowed: false, \n reason: `Access to path '${match}' is not allowed` \n };\n }\n }\n }\n \n return { allowed: true };\n }\n \n // Get the shell command and args\n getShellCommand(): { command: string; args: string[]; env: Record<string, string> } {\n const initScript = this.getInitScript();\n const tempInitFile = `/tmp/.terminal_init_${Date.now()}.sh`;\n \n // We'll write the init script and source it\n fs.writeFileSync(tempInitFile, initScript);\n \n return {\n command: this.options.command || '/bin/bash',\n args: [\n '--rcfile', tempInitFile,\n ...(this.options.args || [])\n ],\n env: {\n ...this.options.env,\n ...(this.options.restrictToPath && {\n PS1: '[Restricted] \\\\w\\\\$ '\n })\n }\n };\n }\n}","import { EventManager } from './events/EventManager.js';\n\ninterface BufferEntry {\n sequence: number;\n data: string;\n timestamp: number;\n}\n\ninterface SessionBuffer {\n entries: BufferEntry[];\n nextSequence: number;\n totalSize: number;\n}\n\nexport class BufferManager {\n private buffers: Map<string, string> = new Map();\n private sequencedBuffers: Map<string, SessionBuffer> = new Map();\n private maxBufferSize: number;\n private eventManager?: EventManager;\n\n constructor(maxBufferSize: number = 100000) {\n this.maxBufferSize = maxBufferSize;\n }\n\n /**\n * Set the event manager for pattern matching\n */\n setEventManager(eventManager: EventManager): void {\n this.eventManager = eventManager;\n }\n\n addToBuffer(sessionId: string, data: string): number {\n // Legacy buffer management\n if (!this.buffers.has(sessionId)) {\n this.buffers.set(sessionId, '');\n }\n\n let buffer = this.buffers.get(sessionId)!;\n buffer += data;\n\n // Trim buffer if it exceeds max size (keep last N characters)\n if (buffer.length > this.maxBufferSize) {\n buffer = buffer.slice(buffer.length - this.maxBufferSize);\n }\n\n this.buffers.set(sessionId, buffer);\n\n // New sequenced buffer management\n if (!this.sequencedBuffers.has(sessionId)) {\n this.sequencedBuffers.set(sessionId, {\n entries: [],\n nextSequence: 0,\n totalSize: 0\n });\n }\n\n const sessionBuffer = this.sequencedBuffers.get(sessionId)!;\n const sequence = sessionBuffer.nextSequence++;\n \n sessionBuffer.entries.push({\n sequence,\n data,\n timestamp: Date.now()\n });\n \n sessionBuffer.totalSize += data.length;\n \n // Trim sequenced buffer if needed\n while (sessionBuffer.totalSize > this.maxBufferSize && sessionBuffer.entries.length > 0) {\n const removed = sessionBuffer.entries.shift()!;\n sessionBuffer.totalSize -= removed.data.length;\n }\n\n // Process events if event manager is set\n if (this.eventManager) {\n // Use setImmediate to avoid blocking terminal output\n setImmediate(() => {\n this.eventManager!.processData(sessionId, data, buffer);\n });\n }\n\n return sequence;\n }\n\n getBuffer(sessionId: string): string {\n return this.buffers.get(sessionId) || '';\n }\n\n getBufferWithSequence(sessionId: string): { data: string; lastSequence: number } {\n const buffer = this.sequencedBuffers.get(sessionId);\n if (!buffer || buffer.entries.length === 0) {\n return { data: '', lastSequence: -1 };\n }\n\n const data = buffer.entries.map(e => e.data).join('');\n const lastSequence = buffer.entries[buffer.entries.length - 1].sequence;\n return { data, lastSequence };\n }\n\n getIncrementalData(sessionId: string, fromSequence: number): { data: string; lastSequence: number } {\n const buffer = this.sequencedBuffers.get(sessionId);\n if (!buffer || buffer.entries.length === 0) {\n return { data: '', lastSequence: fromSequence };\n }\n\n const newEntries = buffer.entries.filter(e => e.sequence > fromSequence);\n if (newEntries.length === 0) {\n return { data: '', lastSequence: fromSequence };\n }\n\n const data = newEntries.map(e => e.data).join('');\n const lastSequence = newEntries[newEntries.length - 1].sequence;\n return { data, lastSequence };\n }\n\n clearBuffer(sessionId: string): void {\n this.buffers.delete(sessionId);\n this.sequencedBuffers.delete(sessionId);\n }\n\n getAllSessions(): string[] {\n return Array.from(this.buffers.keys());\n }\n}","import fs from 'fs/promises';\nimport path from 'path';\nimport { TerminalSession, PatternConfig } from '@shelltender/core';\n\nexport interface StoredSession {\n session: TerminalSession;\n buffer: string;\n cwd?: string;\n env?: Record<string, string>;\n patterns?: PatternConfig[];\n}\n\nexport class SessionStore {\n private storePath: string;\n private initialized = false;\n private initPromise: Promise<void> | null = null;\n\n constructor(storePath: string = '.sessions') {\n this.storePath = storePath;\n }\n\n async initialize(): Promise<void> {\n if (this.initialized) return;\n if (this.initPromise) return this.initPromise;\n\n this.initPromise = this.ensureStoreExists();\n await this.initPromise;\n this.initialized = true;\n }\n\n private async ensureStoreExists(): Promise<void> {\n try {\n await fs.mkdir(this.storePath, { recursive: true });\n } catch (error) {\n console.error('Error creating session store directory:', error);\n throw error; // Re-throw to prevent silent failures\n }\n }\n\n async saveSession(sessionId: string, session: TerminalSession, buffer: string, cwd?: string): Promise<void> {\n await this.initialize();\n try {\n const sessionData: StoredSession = {\n session,\n buffer,\n cwd,\n env: {\n TERM: process.env.TERM || 'xterm-256color',\n LANG: process.env.LANG || 'en_US.UTF-8',\n }\n };\n\n const filePath = path.join(this.storePath, `${sessionId}.json`);\n await fs.writeFile(filePath, JSON.stringify(sessionData, null, 2), 'utf-8');\n } catch (error) {\n console.error(`Error saving session ${sessionId}:`, error);\n }\n }\n\n async loadSession(sessionId: string): Promise<StoredSession | null> {\n await this.initialize();\n try {\n const filePath = path.join(this.storePath, `${sessionId}.json`);\n const data = await fs.readFile(filePath, 'utf-8');\n return JSON.parse(data);\n } catch (error) {\n return null;\n }\n }\n\n async loadAllSessions(): Promise<Map<string, StoredSession>> {\n await this.initialize();\n const sessions = new Map<string, StoredSession>();\n \n try {\n const files = await fs.readdir(this.storePath);\n \n for (const file of files) {\n if (file.endsWith('.json')) {\n const sessionId = file.replace('.json', '');\n const session = await this.loadSession(sessionId);\n \n if (session) {\n sessions.set(sessionId, session);\n }\n }\n }\n } catch (error) {\n console.error('Error loading sessions:', error);\n }\n\n return sessions;\n }\n\n async deleteSession(sessionId: string): Promise<void> {\n await this.initialize();\n try {\n const filePath = path.join(this.storePath, `${sessionId}.json`);\n await fs.unlink(filePath);\n } catch (error) {\n // Ignore if file doesn't exist\n }\n }\n\n async deleteAllSessions(): Promise<void> {\n await this.initialize();\n try {\n await fs.rm(this.storePath, { recursive: true, force: true });\n } catch (error) {\n // Ignore if directory doesn't exist\n }\n }\n\n async updateSessionBuffer(sessionId: string, buffer: string): Promise<void> {\n await this.initialize();\n try {\n const filePath = path.join(this.storePath, `${sessionId}.json`);\n const data = await fs.readFile(filePath, 'utf-8');\n const storedSession: StoredSession = JSON.parse(data);\n \n // Only update if buffer has actually changed to avoid unnecessary writes\n if (storedSession.buffer !== buffer) {\n storedSession.buffer = buffer;\n await fs.writeFile(filePath, JSON.stringify(storedSession, null, 2), 'utf-8');\n }\n } catch (error) {\n // Session might not exist yet, ignore\n }\n }\n\n async saveSessionPatterns(sessionId: string, patterns: PatternConfig[]): Promise<void> {\n await this.initialize();\n try {\n const filePath = path.join(this.storePath, `${sessionId}.json`);\n const data = await fs.readFile(filePath, 'utf-8');\n const storedSession: StoredSession = JSON.parse(data);\n \n storedSession.patterns = patterns;\n await fs.writeFile(filePath, JSON.stringify(storedSession, null, 2), 'utf-8');\n } catch (error) {\n console.error(`Error saving patterns for session ${sessionId}:`, error);\n }\n }\n\n async getSessionPatterns(sessionId: string): Promise<PatternConfig[]> {\n await this.initialize();\n try {\n const session = await this.loadSession(sessionId);\n return session?.patterns || [];\n } catch (error) {\n return [];\n }\n }\n}","import { WebSocketServer as WSServer } from 'ws';\nimport { Server as HTTPServer } from 'http';\nimport { Server as HTTPSServer } from 'https';\nimport { SessionManager } from './SessionManager.js';\nimport { BufferManager } from './BufferManager.js';\nimport { SessionStore } from './SessionStore.js';\nimport { EventManager } from './events/EventManager.js';\nimport { AdminSessionProxy } from './admin/AdminSessionProxy.js';\nimport { \n TerminalData, \n WebSocketMessage,\n RegisterPatternMessage,\n UnregisterPatternMessage,\n SubscribeEventsMessage,\n UnsubscribeEventsMessage,\n TerminalEventMessage,\n AdminWebSocketMessage\n} from '@shelltender/core';\n\nexport interface WebSocketServerOptions {\n port?: number;\n server?: HTTPServer | HTTPSServer;\n noServer?: boolean;\n host?: string;\n path?: string;\n perMessageDeflate?: boolean | object;\n maxPayload?: number;\n clientTracking?: boolean;\n}\n\ninterface ClientState {\n clientId: string;\n sessionIds: Set<string>;\n lastReceivedSequence: Map<string, number>;\n connectionTime: number;\n isIncrementalClient: boolean;\n}\n\nexport class WebSocketServer {\n private wss: WSServer;\n private sessionManager: SessionManager;\n private bufferManager: BufferManager;\n private sessionStore?: SessionStore;\n private eventManager?: EventManager;\n private clients: Map<string, any> = new Map();\n private clientStates: Map<string, ClientState> = new Map();\n private clientPatterns = new Map<string, Set<string>>();\n private clientEventSubscriptions = new Map<string, Set<string>>();\n private monitorClients = new Set<string>();\n private adminProxy: AdminSessionProxy;\n private adminClients: Map<string, Set<any>> = new Map();\n\n private constructor(\n wss: WSServer,\n sessionManager: SessionManager, \n bufferManager: BufferManager, \n eventManager?: EventManager,\n sessionStore?: SessionStore\n ) {\n this.wss = wss;\n this.sessionManager = sessionManager;\n this.bufferManager = bufferManager;\n this.eventManager = eventManager;\n this.sessionStore = sessionStore;\n this.adminProxy = new AdminSessionProxy(this.sessionManager);\n\n // Set up event system if available\n if (this.eventManager) {\n this.setupEventSystem();\n }\n\n this.setupWebSocketHandlers();\n }\n\n static create(\n config: number | WebSocketServerOptions,\n sessionManager: SessionManager,\n bufferManager: BufferManager,\n eventManager?: EventManager,\n sessionStore?: SessionStore\n ): WebSocketServer {\n let wss: WSServer;\n\n if (typeof config === 'number') {\n // Legacy: port number only\n wss = new WSServer({ port: config, host: '0.0.0.0' });\n } else {\n const { server, port, noServer, path, ...wsOptions } = config;\n \n if (server && path) {\n // When both server and path are provided, use noServer mode and handle upgrade manually\n wss = new WSServer({ noServer: true, ...wsOptions });\n \n // Set up the upgrade handler on the HTTP server\n server.on('upgrade', function upgrade(request, socket, head) {\n // Handle socket errors\n socket.on('error', (err) => {\n console.error('Socket error:', err);\n });\n \n const pathname = new URL(request.url || '', `http://${request.headers.host}`).pathname;\n \n if (pathname === path) {\n wss.handleUpgrade(request, socket, head, function done(ws) {\n wss.emit('connection', ws, request);\n });\n } else {\n // Destroy the socket if the path doesn't match\n socket.destroy();\n }\n });\n } else if (server) {\n // Attach to existing HTTP/HTTPS server (all paths)\n wss = new WSServer({ server, ...wsOptions });\n } else if (noServer) {\n // No server mode for manual upgrade handling\n wss = new WSServer({ noServer: true, ...wsOptions });\n } else if (port !== undefined) {\n // Standalone server with options\n wss = new WSServer({ \n port, \n host: wsOptions.host || '0.0.0.0',\n ...wsOptions \n });\n } else {\n throw new Error('WebSocketServer requires either port, server, or noServer option');\n }\n }\n\n return new WebSocketServer(wss, sessionManager, bufferManager, eventManager, sessionStore);\n }\n\n private setupWebSocketHandlers(): void {\n this.wss.on('connection', (ws) => {\n const clientId = Math.random().toString(36).substring(7);\n this.clients.set(clientId, ws);\n \n // Initialize client state\n this.clientStates.set(clientId, {\n clientId,\n sessionIds: new Set(),\n lastReceivedSequence: new Map(),\n connectionTime: Date.now(),\n isIncrementalClient: false\n });\n\n ws.on('message', (message: string) => {\n try {\n const data: WebSocketMessage = JSON.parse(message);\n this.handleMessage(clientId, ws, data);\n } catch (error) {\n // Error parsing message\n ws.send(JSON.stringify({ type: 'error', data: 'Invalid message format' }));\n }\n });\n\n ws.on('close', () => {\n // Remove client from all subscribed sessions\n const clientState = this.clientStates.get(clientId);\n if (clientState) {\n clientState.sessionIds.forEach(sessionId => {\n this.sessionManager.removeClient(sessionId, clientId);\n });\n }\n \n // Clean up event subscriptions and patterns\n if (this.eventManager) {\n const patterns = this.clientPatterns.get(clientId);\n if (patterns) {\n for (const patternId of patterns) {\n this.eventManager.unregisterPattern(patternId).catch(err => {\n // Error unregistering pattern\n });\n }\n }\n }\n \n // Clean up admin client connections\n this.adminClients.forEach((adminSet, sessionId) => {\n adminSet.delete(ws);\n if (adminSet.size === 0) {\n this.adminClients.delete(sessionId);\n }\n });\n \n this.clients.delete(clientId);\n this.clientStates.delete(clientId);\n this.clientPatterns.delete(clientId);\n this.clientEventSubscriptions.delete(clientId);\n this.monitorClients.delete(clientId);\n });\n\n ws.on('error', (error) => {\n // WebSocket error occurred\n });\n });\n }\n\n private handleMessage(clientId: string, ws: any, data: WebSocketMessage): void {\n // Check if it's an admin message first\n if (data.type.startsWith('admin-')) {\n this.handleAdminMessage(clientId, ws, data as AdminWebSocketMessage);\n return;\n }\n\n const handlers: Record<string, (clientId: string, ws: any, data: any) => void> = {\n 'create': this.handleCreateSession.bind(this),\n 'connect': this.handleConnectSession.bind(this),\n 'input': this.handleSessionInput.bind(this),\n 'resize': this.handleSessionResize.bind(this),\n 'disconnect': this.handleSessionDisconnect.bind(this),\n 'register-pattern': this.handleRegisterPattern.bind(this),\n 'unregister-pattern': this.handleUnregisterPattern.bind(this),\n 'subscribe-events': this.handleSubscribeEvents.bind(this),\n 'unsubscribe-events': this.handleUnsubscribeEvents.bind(this),\n 'monitor-all': this.handleMonitorAll.bind(this),\n };\n\n const handler = handlers[data.type];\n if (handler) {\n handler(clientId, ws, data);\n } else {\n ws.send(JSON.stringify({\n type: 'error',\n data: `Unknown message type: ${data.type}`,\n }));\n }\n }\n\n private handleCreateSession(clientId: string, ws: any, data: any): void {\n try {\n const options = data.options || {};\n if (data.cols) options.cols = data.cols;\n if (data.rows) options.rows = data.rows;\n \n // Handle sessionId from either location\n const requestedSessionId = data.sessionId || (data.options && data.options.id);\n if (requestedSessionId) {\n options.id = requestedSessionId;\n \n // Check if session already exists\n const existingSession = this.sessionManager.getSession(requestedSessionId);\n if (existingSession) {\n // Session already exists, just connect to it\n this.sessionManager.addClient(requestedSessionId, clientId);\n \n // Add session to client's subscribed sessions\n const clientState = this.clientStates.get(clientId);\n if (clientState) {\n clientState.sessionIds.add(requestedSessionId);\n }\n \n const response = {\n type: 'created',\n sessionId: requestedSessionId,\n session: existingSession,\n };\n ws.send(JSON.stringify(response));\n return;\n }\n }\n \n const session = this.sessionManager.createSession(options);\n \n this.sessionManager.addClient(session.id, clientId);\n \n // Add session to client's subscribed sessions\n const clientState = this.clientStates.get(clientId);\n if (clientState) {\n clientState.sessionIds.add(session.id);\n }\n \n const response = {\n type: 'created',\n sessionId: session.id,\n session,\n };\n ws.send(JSON.stringify(response));\n } catch (error) {\n console.error('[WebSocketServer] Error creating session:', error);\n ws.send(JSON.stringify({\n type: 'error',\n data: error instanceof Error ? error.message : 'Failed to create session',\n requestId: data.requestId\n }));\n }\n }\n\n private handleConnectSession(clientId: string, ws: any, data: any): void {\n if (!data.sessionId) {\n ws.send(JSON.stringify({\n type: 'error',\n data: 'Session ID required',\n }));\n return;\n }\n\n const session = this.sessionManager.getSession(data.sessionId);\n if (session) {\n this.sessionManager.addClient(data.sessionId, clientId);\n \n const clientState = this.clientStates.get(clientId);\n if (!clientState) {\n ws.send(JSON.stringify({\n type: 'error',\n data: 'Client state not found',\n }));\n return;\n }\n\n // Add session to client's subscribed sessions\n clientState.sessionIds.add(data.sessionId);\n \n // Check if client wants incremental updates\n const useIncremental = data.useIncrementalUpdates === true || data.incremental === true;\n clientState.isIncrementalClient = useIncremental;\n\n let response: any = {\n type: 'connect',\n sessionId: data.sessionId,\n session,\n };\n\n if (useIncremental && data.lastSequence !== undefined) {\n // Client supports incremental updates and has a sequence\n const lastClientSequence = data.lastSequence;\n const { data: incrementalData, lastSequence } = this.bufferManager.getIncrementalData(\n data.sessionId, \n lastClientSequence\n );\n \n if (incrementalData) {\n response.incrementalData = incrementalData;\n response.fromSequence = lastClientSequence;\n response.lastSequence = lastSequence;\n } else {\n // No new data\n response.lastSequence = lastClientSequence;\n }\n \n clientState.lastReceivedSequence.set(data.sessionId, lastSequence);\n } else {\n // Legacy behavior or first connection\n const { data: scrollback, lastSequence } = this.bufferManager.getBufferWithSequence(data.sessionId);\n response.scrollback = scrollback;\n response.lastSequence = lastSequence;\n \n if (useIncremental) {\n clientState.lastReceivedSequence.set(data.sessionId, lastSequence);\n }\n }\n \n ws.send(JSON.stringify(response));\n } else {\n ws.send(JSON.stringify({\n type: 'error',\n data: 'Session not found',\n }));\n }\n }\n\n private handleSessionInput(clientId: string, ws: any, data: any): void {\n if (!data.sessionId || !data.data) {\n ws.send(JSON.stringify({\n type: 'error',\n data: 'Session ID and data required',\n }));\n return;\n }\n\n const success = this.sessionManager.writeToSession(data.sessionId, data.data);\n if (!success) {\n ws.send(JSON.stringify({\n type: 'error',\n data: 'Failed to write to session - session may be disconnected',\n sessionId: data.sessionId\n }));\n }\n }\n\n private handleSessionResize(clientId: string, ws: any, data: any): void {\n if (!data.sessionId || !data.cols || !data.rows) {\n ws.send(JSON.stringify({\n type: 'error',\n data: 'Session ID, cols, and rows required',\n }));\n return;\n }\n\n this.sessionManager.resizeSession(data.sessionId, data.cols, data.rows);\n this.broadcastToSession(data.sessionId, {\n type: 'resize',\n sessionId: data.sessionId,\n cols: data.cols,\n rows: data.rows,\n });\n }\n\n private handleSessionDisconnect(clientId: string, ws: any, data: any): void {\n if (data.sessionId) {\n this.sessionManager.removeClient(data.sessionId, clientId);\n \n // Remove session from client's subscribed sessions\n const clientState = this.clientStates.get(clientId);\n if (clientState) {\n clientState.sessionIds.delete(data.sessionId);\n clientState.lastReceivedSequence.delete(data.sessionId);\n }\n }\n }\n\n private async handleAdminMessage(\n clientId: string, \n ws: any, \n message: AdminWebSocketMessage\n ): Promise<void> {\n try {\n switch (message.type) {\n case 'admin-list-sessions':\n const sessions = this.sessionManager.getAllSessionMetadata();\n ws.send(JSON.stringify({ \n type: 'admin-sessions-list', \n sessions \n }));\n break;\n \n case 'admin-attach':\n if (!message.sessionId) return;\n \n await this.adminProxy.attachToSession(message.sessionId, message.mode);\n \n // Track this admin client\n if (!this.adminClients.has(message.sessionId)) {\n this.adminClients.set(message.sessionId, new Set());\n }\n this.adminClients.get(message.sessionId)!.add(ws);\n \n // Send current buffer\n const buffer = this.bufferManager.getBuffer(message.sessionId);\n ws.send(JSON.stringify({\n type: 'buffer',\n sessionId: message.sessionId,\n data: buffer\n }));\n break;\n \n case 'admin-detach':\n if (!message.sessionId) return;\n \n await this.adminProxy.detachFromSession(message.sessionId);\n this.adminClients.get(message.sessionId)?.delete(ws);\n break;\n \n case 'admin-input':\n if (!message.sessionId || !message.data) return;\n \n await this.adminProxy.writeToSession(message.sessionId, message.data);\n break;\n }\n } catch (error: any) {\n ws.send(JSON.stringify({\n type: 'error',\n message: error.message\n }));\n }\n }\n\n public broadcastToSession(sessionId: string, data: any): void {\n // Send to clients connected to this session\n this.clients.forEach((ws, clientId) => {\n const clientState = this.clientStates.get(clientId);\n if (clientState && clientState.sessionIds.has(sessionId) && ws.readyState === ws.OPEN) {\n // Update client's sequence tracking if this is output data with a sequence\n if (data.type === 'output' && data.sequence !== undefined) {\n if (clientState.isIncrementalClient) {\n clientState.lastReceivedSequence.set(sessionId, data.sequence);\n }\n }\n ws.send(JSON.stringify(data));\n }\n });\n\n // Also send to monitor clients with session information\n if (data.type === 'output' && this.monitorClients.size > 0) {\n const monitorMessage = {\n type: 'session-output',\n sessionId,\n data: data.data,\n timestamp: new Date().toISOString()\n };\n\n this.monitorClients.forEach(monitorId => {\n const ws = this.clients.get(monitorId);\n if (ws && ws.readyState === ws.OPEN) {\n ws.send(JSON.stringify(monitorMessage));\n }\n });\n }\n\n // Send to admin viewers\n const adminViewers = this.adminClients.get(sessionId);\n if (adminViewers && adminViewers.size > 0) {\n const adminData = JSON.stringify(data);\n adminViewers.forEach(ws => {\n if (ws.readyState === ws.OPEN) {\n ws.send(adminData);\n }\n });\n }\n }\n\n private setupEventSystem(): void {\n if (!this.eventManager) return;\n\n // Listen for terminal events and broadcast to subscribed clients\n this.eventManager.on('terminal-event', (event) => {\n const message: TerminalEventMessage = {\n type: 'terminal-event',\n event\n };\n\n // Broadcast to clients subscribed to this event type\n this.clients.forEach((ws, clientId) => {\n const subscriptions = this.clientEventSubscriptions.get(clientId);\n if (subscriptions && subscriptions.has(event.type) && ws.readyState === ws.OPEN) {\n ws.send(JSON.stringify(message));\n }\n });\n });\n }\n\n private async handleRegisterPattern(clientId: string, ws: any, message: RegisterPatternMessage): Promise<void> {\n if (!this.eventManager) {\n ws.send(JSON.stringify({ \n type: 'error', \n data: 'Event system not enabled',\n requestId: message.requestId \n }));\n return;\n }\n\n try {\n const patternId = await this.eventManager.registerPattern(message.sessionId, message.config);\n \n // Track which client registered which patterns\n if (!this.clientPatterns.has(clientId)) {\n this.clientPatterns.set(clientId, new Set());\n }\n this.clientPatterns.get(clientId)!.add(patternId);\n\n ws.send(JSON.stringify({\n type: 'pattern-registered',\n patternId,\n requestId: message.requestId\n }));\n } catch (error) {\n ws.send(JSON.stringify({\n type: 'error',\n data: error instanceof Error ? error.message : 'Unknown error',\n requestId: message.requestId\n }));\n }\n }\n\n private async handleUnregisterPattern(clientId: string, ws: any, message: UnregisterPatternMessage): Promise<void> {\n if (!this.eventManager) {\n ws.send(JSON.stringify({ \n type: 'error', \n data: 'Event system not enabled',\n requestId: message.requestId \n }));\n return;\n }\n\n try {\n await this.eventManager.unregisterPattern(message.patternId);\n \n // Remove from client's pattern tracking\n const patterns = this.clientPatterns.get(clientId);\n if (patterns) {\n patterns.delete(message.patternId);\n }\n\n ws.send(JSON.stringify({\n type: 'pattern-unregistered',\n patternId: message.patternId,\n requestId: message.requestId\n }));\n } catch (error) {\n ws.send(JSON.stringify({\n type: 'error',\n data: error instanceof Error ? error.message : 'Unknown error',\n requestId: message.requestId\n }));\n }\n }\n\n private handleSubscribeEvents(clientId: string, ws: any, message: SubscribeEventsMessage): void {\n if (!this.clientEventSubscriptions.has(clientId)) {\n this.clientEventSubscriptions.set(clientId, new Set());\n }\n\n const subscriptions = this.clientEventSubscriptions.get(clientId)!;\n for (const eventType of message.eventTypes) {\n subscriptions.add(eventType);\n }\n\n ws.send(JSON.stringify({\n type: 'subscribed',\n eventTypes: message.eventTypes\n }));\n }\n\n private handleUnsubscribeEvents(clientId: string, ws: any, message: UnsubscribeEventsMessage): void {\n const subscriptions = this.clientEventSubscriptions.get(clientId);\n if (subscriptions) {\n for (const eventType of message.eventTypes) {\n subscriptions.delete(eventType);\n }\n }\n\n ws.send(JSON.stringify({\n type: 'unsubscribed',\n eventTypes: message.eventTypes\n }));\n }\n\n private handleMonitorAll(clientId: string, ws: any, data: any): void {\n // Check authentication\n const authKey = data.authKey || data.auth;\n const expectedAuthKey = process.env.SHELLTENDER_MONITOR_AUTH_KEY || 'default-monitor-key';\n \n if (authKey !== expectedAuthKey) {\n ws.send(JSON.stringify({\n type: 'error',\n data: 'Invalid authentication key for monitor mode',\n requestId: data.requestId\n }));\n return;\n }\n\n // Add to monitor clients\n this.monitorClients.add(clientId);\n ws.isMonitor = true;\n\n ws.send(JSON.stringify({\n type: 'monitor-mode-enabled',\n message: 'Successfully enabled monitor mode. You will receive all terminal output.',\n sessionCount: this.sessionManager.getAllSessions().length\n }));\n }\n\n // Get number of clients connected to a specific session\n public getSessionClientCount(sessionId: string): number {\n let count = 0;\n this.clients.forEach((ws, clientId) => {\n const clientState = this.clientStates.get(clientId);\n if (clientState && clientState.sessionIds.has(sessionId) && ws.readyState === ws.OPEN) {\n count++;\n }\n });\n return count;\n }\n\n // Get all client connections grouped by session\n public getClientsBySession(): Map<string, number> {\n const sessionClients = new Map<string, number>();\n this.clients.forEach((ws, clientId) => {\n const clientState = this.clientStates.get(clientId);\n if (clientState && ws.readyState === ws.OPEN) {\n // Count each session the client is subscribed to\n clientState.sessionIds.forEach(sessionId => {\n sessionClients.set(sessionId, (sessionClients.get(sessionId) || 0) + 1);\n });\n }\n });\n return sessionClients;\n }\n}","import { EventEmitter } from 'events';\nimport { SessionManager } from '../SessionManager';\n\ninterface AdminSessionHandle {\n sessionId: string;\n mode: 'read-only' | 'interactive';\n attachedAt: Date;\n}\n\nexport class AdminSessionProxy extends EventEmitter {\n private attachedSessions: Map<string, AdminSessionHandle> = new Map();\n \n constructor(private sessionManager: SessionManager) {\n super();\n }\n\n async attachToSession(sessionId: string, mode: 'read-only' | 'interactive' = 'read-only'): Promise<void> {\n const session = this.sessionManager.getSession(sessionId);\n if (!session) {\n throw new Error(`Session ${sessionId} not found`);\n }\n\n this.attachedSessions.set(sessionId, {\n sessionId,\n mode,\n attachedAt: new Date()\n });\n\n this.emit('attached', { sessionId, mode });\n }\n\n async detachFromSession(sessionId: string): Promise<void> {\n this.attachedSessions.delete(sessionId);\n this.emit('detached', { sessionId });\n }\n\n async writeToSession(sessionId: string, data: string): Promise<void> {\n const handle = this.attachedSessions.get(sessionId);\n if (!handle || handle.mode !== 'interactive') {\n throw new Error('Session not in interactive mode');\n }\n\n this.sessionManager.writeToSession(sessionId, data);\n }\n \n getAttachedSessions(): AdminSessionHandle[] {\n return Array.from(this.attachedSessions.values())