From cdd5edf7b7a3c023ebb6b476ef652375e4c7cf37 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 2 May 2025 05:35:00 +0000 Subject: [PATCH 01/14] feat: onConnection listener --- package.json | 2 +- src/synapse.ts | 14 ++++++++++++++ tests/synapse.test.ts | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 0c842d4..bd7940d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@appwrite.io/synapse", - "version": "0.3.1", + "version": "0.3.2", "description": "Operating system gateway for remote serverless environments", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/synapse.ts b/src/synapse.ts index 5540c3d..53737ba 100644 --- a/src/synapse.ts +++ b/src/synapse.ts @@ -13,6 +13,7 @@ export type MessagePayload = { export type MessageHandler = (message: MessagePayload) => void; export type ConnectionCallback = () => void; export type ErrorCallback = (error: Error) => void; +export type ServerConnectionCallback = (ws: WebSocket) => void; export type Logger = (message: string) => void; class Synapse { @@ -39,6 +40,8 @@ class Synapse { public workDir: string; + private serverConnectionListener: ServerConnectionCallback = () => {}; + constructor( host: string = "localhost", port: number = 3000, @@ -54,6 +57,7 @@ class Synapse { this.wss = new WebSocketServer({ noServer: true }); this.wss.on("connection", (ws: WebSocket) => { + this.serverConnectionListener(ws); this.setupWebSocket(ws); }); } @@ -291,6 +295,16 @@ class Synapse { }); } + /** + * Registers a callback for when a new WebSocket connection is established on the server side + * @param callback - Function to be called when a new connection is established + * @returns The Synapse instance for method chaining + */ + onConnection(callback: ServerConnectionCallback): Synapse { + this.serverConnectionListener = callback; + return this; + } + /** * Registers a callback for when the WebSocket connection is established * @param callback - Function to be called when connection opens diff --git a/tests/synapse.test.ts b/tests/synapse.test.ts index cf35e14..57d58d4 100644 --- a/tests/synapse.test.ts +++ b/tests/synapse.test.ts @@ -158,5 +158,37 @@ describe("Synapse", () => { ); expect(onOpenMock).toHaveBeenCalled(); }); + + it("should call onConnection callback when new connection is established", () => { + const mockWs = createMockWebSocket(); + const mockWss = { + handleUpgrade: jest.fn((req, socket, head, cb) => { + cb(mockWs); + const connectionHandler = (mockWss.on as jest.Mock).mock.calls.find( + ([eventName]: [string]) => eventName === "connection", + )?.[1]; + if (connectionHandler) { + connectionHandler(mockWs); + } + }), + emit: jest.fn(), + on: jest.fn() as jest.Mock, + close: jest.fn(), + } as unknown as WebSocketServer; + + jest.mocked(WebSocketServer).mockImplementation(() => mockWss); + + const onConnectionMock = jest.fn(); + synapse = new Synapse(); + synapse.onConnection(onConnectionMock); + + synapse.handleUpgrade( + {} as IncomingMessage, + {} as Socket, + Buffer.alloc(0), + ); + + expect(onConnectionMock).toHaveBeenCalledWith(mockWs); + }); }); }); From 0e3d66421528e726991374237ae709af66e6c2b0 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 2 May 2025 06:37:39 +0000 Subject: [PATCH 02/14] chore: add url params --- src/synapse.ts | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/synapse.ts b/src/synapse.ts index 53737ba..c214cff 100644 --- a/src/synapse.ts +++ b/src/synapse.ts @@ -33,6 +33,7 @@ class Synapse { private reconnectAttempts = 0; private reconnectInterval = 3000; private lastPath: string | null = null; + private lastParams: Record | null = null; private reconnectTimeout: NodeJS.Timeout | null = null; private host: string; @@ -140,8 +141,21 @@ class Synapse { } } - private buildWebSocketUrl(path: string): string { - return `ws://${this.host}:${this.port}${path}`; + private buildWebSocketUrl( + path: string, + params?: Record, + ): string { + let url = `ws://${this.host}:${this.port}${path}`; + if (params && Object.keys(params).length > 0) { + const query = Object.entries(params) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(value)}`, + ) + .join("&"); + url += `?${query}`; + } + return url; } /** @@ -210,12 +224,17 @@ class Synapse { /** * Establishes a WebSocket connection to the specified URL and initializes the terminal * @param path - The WebSocket endpoint path (e.g. '/' or '/terminal') + * @param params - Optional URL query parameters as an object * @returns Promise that resolves with the Synapse instance when connected * @throws Error if WebSocket connection fails */ - connect(path: string): Promise { + connect(path: string, params?: Record): Promise { this.lastPath = path; - const url = this.buildWebSocketUrl(path); + if (params) { + this.lastParams = params; + } + + const url = this.buildWebSocketUrl(path, params); return new Promise((resolve, reject) => { try { @@ -256,6 +275,14 @@ class Synapse { }); } + getPath(): string | null { + return this.lastPath; + } + + getParams(): Record | null { + return this.lastParams; + } + /** * Sends a message to the WebSocket server * @param type - The type of message to send From a521167cee197a96950164b3317832ba87fcc2c3 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 2 May 2025 06:52:59 +0000 Subject: [PATCH 03/14] fix: params --- src/synapse.ts | 32 ++++++++------------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/src/synapse.ts b/src/synapse.ts index c214cff..053a77e 100644 --- a/src/synapse.ts +++ b/src/synapse.ts @@ -57,9 +57,11 @@ class Synapse { this.workDir = workDir; this.wss = new WebSocketServer({ noServer: true }); - this.wss.on("connection", (ws: WebSocket) => { + this.wss.on("connection", (ws: WebSocket, req: IncomingMessage) => { this.serverConnectionListener(ws); this.setupWebSocket(ws); + + this.lastParams = JSON.parse(req.url?.split("?")[1] || "{}"); }); } @@ -141,21 +143,8 @@ class Synapse { } } - private buildWebSocketUrl( - path: string, - params?: Record, - ): string { - let url = `ws://${this.host}:${this.port}${path}`; - if (params && Object.keys(params).length > 0) { - const query = Object.entries(params) - .map( - ([key, value]) => - `${encodeURIComponent(key)}=${encodeURIComponent(value)}`, - ) - .join("&"); - url += `?${query}`; - } - return url; + private buildWebSocketUrl(path: string): string { + return `ws://${this.host}:${this.port}${path}`; } /** @@ -224,17 +213,12 @@ class Synapse { /** * Establishes a WebSocket connection to the specified URL and initializes the terminal * @param path - The WebSocket endpoint path (e.g. '/' or '/terminal') - * @param params - Optional URL query parameters as an object * @returns Promise that resolves with the Synapse instance when connected * @throws Error if WebSocket connection fails */ connect(path: string, params?: Record): Promise { this.lastPath = path; - if (params) { - this.lastParams = params; - } - - const url = this.buildWebSocketUrl(path, params); + const url = this.buildWebSocketUrl(path); return new Promise((resolve, reject) => { try { @@ -275,11 +259,11 @@ class Synapse { }); } - getPath(): string | null { + getLastPath(): string | null { return this.lastPath; } - getParams(): Record | null { + getLastParams(): Record | null { return this.lastParams; } From a0c2fccb48c4d1076173d7982f0230e2ff634dd0 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 2 May 2025 06:53:41 +0000 Subject: [PATCH 04/14] fix: typing --- src/synapse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/synapse.ts b/src/synapse.ts index 053a77e..6b37d54 100644 --- a/src/synapse.ts +++ b/src/synapse.ts @@ -216,7 +216,7 @@ class Synapse { * @returns Promise that resolves with the Synapse instance when connected * @throws Error if WebSocket connection fails */ - connect(path: string, params?: Record): Promise { + connect(path: string): Promise { this.lastPath = path; const url = this.buildWebSocketUrl(path); From ed60d0e612dfeefaacc6b36ac0ae5ab57363f844 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 2 May 2025 07:54:52 +0000 Subject: [PATCH 05/14] fix: url params decoding --- src/synapse.ts | 20 +++++++++++++++++--- tests/synapse.test.ts | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/synapse.ts b/src/synapse.ts index 6b37d54..1243674 100644 --- a/src/synapse.ts +++ b/src/synapse.ts @@ -57,11 +57,9 @@ class Synapse { this.workDir = workDir; this.wss = new WebSocketServer({ noServer: true }); - this.wss.on("connection", (ws: WebSocket, req: IncomingMessage) => { + this.wss.on("connection", (ws: WebSocket, req: IncomingMessage | null) => { this.serverConnectionListener(ws); this.setupWebSocket(ws); - - this.lastParams = JSON.parse(req.url?.split("?")[1] || "{}"); }); } @@ -364,6 +362,22 @@ class Synapse { * @param head - The first packet of the upgraded stream */ handleUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): void { + if (req.url) { + const query = req.url.split("?")[1]; + if (query) { + try { + this.lastParams = JSON.parse(query); + } catch { + this.lastParams = Object.fromEntries( + query.split("&").map((kv) => { + const [k, v] = kv.split("="); + return [decodeURIComponent(k), decodeURIComponent(v ?? "")]; + }), + ); + } + } + } + this.wss.handleUpgrade(req, socket, head, (ws: WebSocket) => { this.wss.emit("connection", ws, req); }); diff --git a/tests/synapse.test.ts b/tests/synapse.test.ts index 57d58d4..80dbad9 100644 --- a/tests/synapse.test.ts +++ b/tests/synapse.test.ts @@ -191,4 +191,25 @@ describe("Synapse", () => { expect(onConnectionMock).toHaveBeenCalledWith(mockWs); }); }); + + describe("params handling", () => { + it("should set params via handleUpgrade with URL params", () => { + const synapse = new Synapse(); + // Simulate a request with query params + const req = { url: "/?foo=bar&baz=qux" } as IncomingMessage; + const socket = {} as Socket; + const head = Buffer.alloc(0); + // Patch JSON.parse to parse query string as an object + const originalParse = JSON.parse; + jest.spyOn(JSON, "parse").mockImplementation((str) => { + if (str === "foo=bar&baz=qux") { + return { foo: "bar", baz: "qux" }; + } + return originalParse(str); + }); + synapse.handleUpgrade(req, socket, head); + expect(synapse.getLastParams()).toEqual({ foo: "bar", baz: "qux" }); + (JSON.parse as jest.Mock).mockRestore(); + }); + }); }); From 08b8092effe98ff49326fd6636d1b9e659d456a0 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 2 May 2025 07:55:52 +0000 Subject: [PATCH 06/14] remove: unwanted param --- src/synapse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/synapse.ts b/src/synapse.ts index 1243674..a89b5a5 100644 --- a/src/synapse.ts +++ b/src/synapse.ts @@ -57,7 +57,7 @@ class Synapse { this.workDir = workDir; this.wss = new WebSocketServer({ noServer: true }); - this.wss.on("connection", (ws: WebSocket, req: IncomingMessage | null) => { + this.wss.on("connection", (ws: WebSocket) => { this.serverConnectionListener(ws); this.setupWebSocket(ws); }); From 61f370e0a0110211fba0d417e917c6584b18ac13 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 3 May 2025 13:21:27 +0000 Subject: [PATCH 07/14] feat: multi connection --- src/synapse.ts | 309 ++++++++++++++++++++++++++++++------------ tests/synapse.test.ts | 63 +++++---- 2 files changed, 256 insertions(+), 116 deletions(-) diff --git a/src/synapse.ts b/src/synapse.ts index a89b5a5..6c314bc 100644 --- a/src/synapse.ts +++ b/src/synapse.ts @@ -10,14 +10,28 @@ export type MessagePayload = { [key: string]: string | Record; }; -export type MessageHandler = (message: MessagePayload) => void; -export type ConnectionCallback = () => void; -export type ErrorCallback = (error: Error) => void; -export type ServerConnectionCallback = (ws: WebSocket) => void; +export type Connection = { + ws: WebSocket; + id: string; + path: string; + params: Record | null; + reconnectAttempts: number; +}; + +export type MessageHandler = ( + message: MessagePayload, + connectionId: string, +) => void; +export type ConnectionCallback = (connectionId: string) => void; +export type ErrorCallback = (error: Error, connectionId: string) => void; +export type ServerConnectionCallback = ( + ws: WebSocket, + connectionId: string, +) => void; export type Logger = (message: string) => void; class Synapse { - private ws: WebSocket | null = null; + private connections: Map = new Map(); private wss: WebSocketServer; private messageHandlers: Record = {}; private connectionListeners = { @@ -30,11 +44,8 @@ class Synapse { private isReconnecting = false; private maxReconnectAttempts = 5; - private reconnectAttempts = 0; private reconnectInterval = 3000; - private lastPath: string | null = null; - private lastParams: Record | null = null; - private reconnectTimeout: NodeJS.Timeout | null = null; + private reconnectTimeouts: Map = new Map(); private host: string; private port: number; @@ -58,79 +69,118 @@ class Synapse { this.wss = new WebSocketServer({ noServer: true }); this.wss.on("connection", (ws: WebSocket) => { - this.serverConnectionListener(ws); - this.setupWebSocket(ws); + const connectionId = this.generateConnectionId(); + this.serverConnectionListener(ws, connectionId); + this.setupWebSocket(ws, connectionId); }); } + private generateConnectionId(): string { + return `conn_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } + private log(message: string): void { const timestamp = new Date().toISOString(); console.log(`[Synapse][${timestamp}] ${message}`); } - private setupWebSocket(ws: WebSocket): void { - this.ws = ws; - this.reconnectAttempts = 0; + private setupWebSocket( + ws: WebSocket, + connectionId: string, + path: string = "/", + params: Record | null = null, + ): void { + this.connections.set(connectionId, { + ws, + id: connectionId, + path, + params, + reconnectAttempts: 0, + }); - ws.onmessage = (event) => this.handleMessage(event); + ws.onmessage = (event) => this.handleMessage(event, connectionId); ws.onclose = () => { - this.ws = null; - this.connectionListeners.onClose(); - this.attemptReconnect(); + this.connectionListeners.onClose(connectionId); + this.attemptReconnect(connectionId); }; ws.onerror = (error) => { const errorMessage = `WebSocket error: ${error.message || "Unknown error"}. Connection to ${this.host}:${this.port} failed.`; - this.connectionListeners.onError(new Error(errorMessage)); + this.connectionListeners.onError(new Error(errorMessage), connectionId); }; - this.connectionListeners.onOpen(); + this.connectionListeners.onOpen(connectionId); } - private attemptReconnect() { + private attemptReconnect(connectionId: string) { + const connection = this.connections.get(connectionId); + if (!connection) return; + if ( this.isReconnecting || - this.reconnectAttempts >= this.maxReconnectAttempts + connection.reconnectAttempts >= this.maxReconnectAttempts ) { + this.connections.delete(connectionId); return; } this.isReconnecting = true; - this.reconnectAttempts++; + connection.reconnectAttempts++; - this.reconnectTimeout = setTimeout(() => { + const timeout = setTimeout(() => { this.log( - `Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`, + `Attempting to reconnect connection ${connectionId} (${connection.reconnectAttempts}/${this.maxReconnectAttempts})...`, ); - this.connect(this.lastPath || "/") - .then(() => { + this.connect(connection.path || "/") + .then((newConnectionId) => { this.isReconnecting = false; - this.log("Reconnection successful"); + this.log( + `Reconnection successful with new connection ID: ${newConnectionId}`, + ); + // Copy any necessary state from old connection to new connection + const newConnection = this.connections.get(newConnectionId); + if (newConnection) { + newConnection.params = connection.params; + } + // Delete the old connection + this.connections.delete(connectionId); }) .catch((error) => { this.isReconnecting = false; - this.log(`Reconnection failed: ${error.message}`); - if (this.reconnectAttempts < this.maxReconnectAttempts) { - this.attemptReconnect(); + this.log( + `Reconnection failed for connection ${connectionId}: ${error.message}`, + ); + if (connection.reconnectAttempts < this.maxReconnectAttempts) { + this.attemptReconnect(connectionId); + } else { + this.connections.delete(connectionId); } }); }, this.reconnectInterval); + + this.reconnectTimeouts.set(connectionId, timeout); } - private handleMessage(event: WebSocket.MessageEvent): void { + private handleMessage( + event: WebSocket.MessageEvent, + connectionId: string, + ): void { try { const data = event.data as string; + const connection = this.connections.get(connectionId); + + if (!connection) return; - if (data === "ping" && this.ws) { - this.ws.send("pong"); + if (data === "ping" && connection.ws) { + connection.ws.send("pong"); return; } const message: MessagePayload = JSON.parse(data); if (this.messageHandlers[message.type]) { - this.messageHandlers[message.type](message); + this.messageHandlers[message.type](message, connectionId); } } catch (error) { const errorMessage = @@ -196,55 +246,63 @@ class Synapse { } /** - * Cancels the reconnection process + * Cancels the reconnection process for a specific connection + * @param connectionId - The ID of the connection to cancel reconnection for, or all if not specified * @returns void */ - cancelReconnect(): void { - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; + cancelReconnect(connectionId?: string): void { + if (connectionId) { + const timeout = this.reconnectTimeouts.get(connectionId); + if (timeout) { + clearTimeout(timeout); + this.reconnectTimeouts.delete(connectionId); + } + + const connection = this.connections.get(connectionId); + if (connection) { + connection.reconnectAttempts = this.maxReconnectAttempts; + } + } else { + this.reconnectTimeouts.forEach((timeout) => { + clearTimeout(timeout); + }); + this.reconnectTimeouts.clear(); + + this.connections.forEach((connection) => { + connection.reconnectAttempts = this.maxReconnectAttempts; + }); } + this.isReconnecting = false; - this.reconnectAttempts = this.maxReconnectAttempts; } /** - * Establishes a WebSocket connection to the specified URL and initializes the terminal + * Establishes a WebSocket connection to the specified URL * @param path - The WebSocket endpoint path (e.g. '/' or '/terminal') - * @returns Promise that resolves with the Synapse instance when connected + * @returns Promise that resolves with the connection ID when connected * @throws Error if WebSocket connection fails */ - connect(path: string): Promise { - this.lastPath = path; + connect(path: string): Promise { const url = this.buildWebSocketUrl(path); + const connectionId = this.generateConnectionId(); return new Promise((resolve, reject) => { try { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - resolve(this); - return; - } - - this.ws = new WebSocket(url); + const ws = new WebSocket(url); - this.ws.onopen = () => { - this.reconnectAttempts = 0; - this.connectionListeners.onOpen(); - resolve(this); + ws.onopen = () => { + this.setupWebSocket(ws, connectionId, path); + resolve(connectionId); }; - this.ws.onmessage = (event: WebSocket.MessageEvent) => - this.handleMessage(event); - - this.ws.onerror = (error: WebSocket.ErrorEvent) => { + ws.onerror = (error: WebSocket.ErrorEvent) => { const errorMessage = `WebSocket error: ${error.message || "Unknown error"}. Failed to connect to ${url}`; - this.connectionListeners.onError(new Error(errorMessage)); + this.connectionListeners.onError( + new Error(errorMessage), + connectionId, + ); reject(new Error(errorMessage)); }; - - this.ws.onclose = () => { - this.connectionListeners.onClose(); - }; } catch (error) { reject( new Error( @@ -257,27 +315,48 @@ class Synapse { }); } - getLastPath(): string | null { - return this.lastPath; + /** + * Gets all active connection IDs + * @returns Array of connection IDs + */ + getConnections(): string[] { + return Array.from(this.connections.keys()); } - getLastParams(): Record | null { - return this.lastParams; + /** + * Gets connection information by ID + * @param connectionId - The ID of the connection to get + * @returns The connection object or null if not found + */ + getConnection( + connectionId: string, + ): { path: string; params: Record | null } | null { + const connection = this.connections.get(connectionId); + if (!connection) return null; + + return { + path: connection.path, + params: connection.params, + }; } /** - * Sends a message to the WebSocket server + * Sends a message to a specific WebSocket connection + * @param connectionId - The ID of the connection to send to * @param type - The type of message to send * @param payload - The payload of the message * @returns A promise that resolves with the message payload * @throws Error if WebSocket is not connected */ send( + connectionId: string, type: string, payload: Record = {}, ): Promise { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - throw new Error("WebSocket is not connected"); + const connection = this.connections.get(connectionId); + + if (!connection || connection.ws.readyState !== WebSocket.OPEN) { + throw new Error(`WebSocket connection ${connectionId} is not connected`); } const message: MessagePayload = { @@ -287,18 +366,40 @@ class Synapse { }; return new Promise((resolve) => { - this.ws!.send(JSON.stringify(message)); + connection.ws.send(JSON.stringify(message)); resolve(message); }); } /** - * Sends a command to the terminal for execution + * Broadcasts a message to all connected WebSocket clients + * @param type - The type of message to send + * @param payload - The payload of the message + * @returns An array of promises that resolve with the message payloads + */ + broadcast( + type: string, + payload: Record = {}, + ): Promise[] { + const promises: Promise[] = []; + + this.connections.forEach((connection, connectionId) => { + if (connection.ws.readyState === WebSocket.OPEN) { + promises.push(this.send(connectionId, type, payload)); + } + }); + + return promises; + } + + /** + * Sends a command to the terminal for execution for a specific connection + * @param connectionId - The ID of the connection to send to * @param command - The command string to execute * @returns A promise that resolves with the message payload */ - sendCommand(command: string): Promise { - return this.send("terminal", { + sendCommand(connectionId: string, command: string): Promise { + return this.send(connectionId, "terminal", { operation: "createCommand", params: { command }, }); @@ -315,7 +416,7 @@ class Synapse { } /** - * Registers a callback for when the WebSocket connection is established + * Registers a callback for when a WebSocket connection is established * @param callback - Function to be called when connection opens * @returns The Synapse instance for method chaining */ @@ -325,7 +426,7 @@ class Synapse { } /** - * Registers a callback for when the WebSocket connection is closed + * Registers a callback for when a WebSocket connection is closed * @param callback - Function to be called when connection closes * @returns The Synapse instance for method chaining */ @@ -362,13 +463,17 @@ class Synapse { * @param head - The first packet of the upgraded stream */ handleUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): void { + let params: Record | null = null; + let path = "/"; + if (req.url) { + path = req.url.split("?")[0]; const query = req.url.split("?")[1]; if (query) { try { - this.lastParams = JSON.parse(query); + params = JSON.parse(query); } catch { - this.lastParams = Object.fromEntries( + params = Object.fromEntries( query.split("&").map((kv) => { const [k, v] = kv.split("="); return [decodeURIComponent(k), decodeURIComponent(v ?? "")]; @@ -379,28 +484,54 @@ class Synapse { } this.wss.handleUpgrade(req, socket, head, (ws: WebSocket) => { + const connectionId = this.generateConnectionId(); + this.setupWebSocket(ws, connectionId, path, params); this.wss.emit("connection", ws, req); }); } /** - * Checks if the WebSocket connection is currently open and ready + * Checks if a specific WebSocket connection is currently open and ready + * @param connectionId - The ID of the connection to check * @returns {boolean} True if the connection is open and ready */ - isConnected() { - return this.ws && this.ws.readyState === WebSocket.OPEN; + isConnected(connectionId?: string): boolean { + if (connectionId) { + const connection = this.connections.get(connectionId); + return !!connection && connection.ws.readyState === WebSocket.OPEN; + } + + // Check if any connection is open + for (const connection of this.connections.values()) { + if (connection.ws.readyState === WebSocket.OPEN) { + return true; + } + } + + return false; } /** - * Closes the WebSocket connection + * Closes a specific WebSocket connection + * @param connectionId - The ID of the connection to close, or all if not specified */ - disconnect(): void { - this.cancelReconnect(); - if (this.ws) { - this.ws.close(); - this.ws = null; + disconnect(connectionId?: string): void { + if (connectionId) { + this.cancelReconnect(connectionId); + const connection = this.connections.get(connectionId); + if (connection) { + connection.ws.close(); + this.connections.delete(connectionId); + } + } else { + // Close all connections + this.cancelReconnect(); + this.connections.forEach((connection) => { + connection.ws.close(); + }); + this.connections.clear(); + this.wss.close(); } - this.wss.close(); } } diff --git a/tests/synapse.test.ts b/tests/synapse.test.ts index 80dbad9..75d65e8 100644 --- a/tests/synapse.test.ts +++ b/tests/synapse.test.ts @@ -40,8 +40,9 @@ describe("Synapse", () => { const connectPromise = synapse.connect("/terminal"); setTimeout(() => mockWs.onopen!({} as WebSocket.Event), 0); - const result = await connectPromise; - expect(result).toBe(synapse); + const connectionId = await connectPromise; + expect(typeof connectionId).toBe("string"); + expect(synapse.getConnections()).toContain(connectionId); }); it("should reject on connection error", async () => { @@ -53,6 +54,7 @@ describe("Synapse", () => { () => mockWs.onerror!({ error: new Error("Connection failed"), + message: "Connection failed", } as WebSocket.ErrorEvent), 0, ); @@ -63,13 +65,14 @@ describe("Synapse", () => { describe("message handling", () => { let mockWs: ReturnType; + let connectionId: string; beforeEach(async () => { mockWs = createMockWebSocket(); (WebSocket as unknown as jest.Mock).mockImplementation(() => mockWs); const connectPromise = synapse.connect("/"); setTimeout(() => mockWs.onopen!({} as WebSocket.Event), 0); - await connectPromise; + connectionId = await connectPromise; }); it("should handle messages correctly", async () => { @@ -85,7 +88,7 @@ describe("Synapse", () => { mockWs.onmessage!({ data: JSON.stringify(testMessage), } as WebSocket.MessageEvent); - expect(handler).toHaveBeenCalledWith(testMessage); + expect(handler).toHaveBeenCalledWith(testMessage, connectionId); }); it("should ignore malformed messages", async () => { @@ -99,18 +102,19 @@ describe("Synapse", () => { describe("send", () => { let mockWs: ReturnType; + let connectionId: string; beforeEach(async () => { mockWs = createMockWebSocket(); (WebSocket as unknown as jest.Mock).mockImplementation(() => mockWs); const connectPromise = synapse.connect("/"); setTimeout(() => mockWs.onopen!({} as WebSocket.Event), 0); - await connectPromise; + connectionId = await connectPromise; }); it("should send messages and return payload", async () => { const payload = { data: "test-data" }; - const result = await synapse.send("test-type", payload); + const result = await synapse.send(connectionId, "test-type", payload); expect(result).toEqual({ type: "test-type", @@ -120,9 +124,9 @@ describe("Synapse", () => { }); it("should throw when not connected", () => { - synapse.disconnect(); - expect(() => synapse.send("test-type")).toThrow( - "WebSocket is not connected", + synapse.disconnect(connectionId); + expect(() => synapse.send(connectionId, "test-type")).toThrow( + `WebSocket connection ${connectionId} is not connected`, ); }); }); @@ -188,28 +192,33 @@ describe("Synapse", () => { Buffer.alloc(0), ); - expect(onConnectionMock).toHaveBeenCalledWith(mockWs); + expect(onConnectionMock).toHaveBeenCalledWith(mockWs, expect.any(String)); }); }); - describe("params handling", () => { - it("should set params via handleUpgrade with URL params", () => { - const synapse = new Synapse(); - // Simulate a request with query params - const req = { url: "/?foo=bar&baz=qux" } as IncomingMessage; - const socket = {} as Socket; - const head = Buffer.alloc(0); - // Patch JSON.parse to parse query string as an object - const originalParse = JSON.parse; - jest.spyOn(JSON, "parse").mockImplementation((str) => { - if (str === "foo=bar&baz=qux") { - return { foo: "bar", baz: "qux" }; - } - return originalParse(str); + describe("multiple connections", () => { + it("should allow multiple connections and track them independently", async () => { + const mockWs1 = createMockWebSocket(); + const mockWs2 = createMockWebSocket(); + let callCount = 0; + (WebSocket as unknown as jest.Mock).mockImplementation(() => { + callCount++; + return callCount === 1 ? mockWs1 : mockWs2; }); - synapse.handleUpgrade(req, socket, head); - expect(synapse.getLastParams()).toEqual({ foo: "bar", baz: "qux" }); - (JSON.parse as jest.Mock).mockRestore(); + const id1Promise = synapse.connect("/a"); + setTimeout(() => mockWs1.onopen!({} as WebSocket.Event), 0); + const id2Promise = synapse.connect("/b"); + setTimeout(() => mockWs2.onopen!({} as WebSocket.Event), 0); + const id1 = await id1Promise; + const id2 = await id2Promise; + expect(id1).not.toBe(id2); + expect(synapse.getConnections()).toEqual( + expect.arrayContaining([id1, id2]), + ); + // Disconnect one and check the other remains + synapse.disconnect(id1); + expect(synapse.getConnections()).toContain(id2); + expect(synapse.getConnections()).not.toContain(id1); }); }); }); From e9efe55b6b65bbf4e4aa85f2d415bffe887e4603 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 3 May 2025 13:25:33 +0000 Subject: [PATCH 08/14] chore: add getPath and getParams --- package.json | 2 +- src/synapse.ts | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index bd7940d..cc26a59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@appwrite.io/synapse", - "version": "0.3.2", + "version": "0.4.0", "description": "Operating system gateway for remote serverless environments", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/synapse.ts b/src/synapse.ts index 6c314bc..f7e2408 100644 --- a/src/synapse.ts +++ b/src/synapse.ts @@ -315,6 +315,26 @@ class Synapse { }); } + /** + * Gets the path associated with a specific connection + * @param connectionId - The ID of the connection + * @returns The connection path or null if connection not found + */ + getPath(connectionId: string): string | null { + const connection = this.connections.get(connectionId); + return connection ? connection.path : null; + } + + /** + * Gets the parameters associated with a specific connection + * @param connectionId - The ID of the connection + * @returns The connection parameters or null if connection not found + */ + getParams(connectionId: string): Record | null { + const connection = this.connections.get(connectionId); + return connection ? connection.params : null; + } + /** * Gets all active connection IDs * @returns Array of connection IDs From 8c12c38dc56db715777d87b285df7030326fc337 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 3 May 2025 16:29:59 +0000 Subject: [PATCH 09/14] chore: cleanup --- src/services/terminal.ts | 4 +++- src/synapse.ts | 20 ++------------------ tests/synapse.test.ts | 2 +- 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/src/services/terminal.ts b/src/services/terminal.ts index f3be7f2..f0352d9 100644 --- a/src/services/terminal.ts +++ b/src/services/terminal.ts @@ -6,6 +6,7 @@ export type TerminalOptions = { shell: string; cols?: number; rows?: number; + workDir?: string; }; export class Terminal { @@ -27,6 +28,7 @@ export class Terminal { shell: os.platform() === "win32" ? "powershell.exe" : "bash", cols: 80, rows: 24, + workDir: synapse.workDir, }, ) { this.synapse = synapse; @@ -37,7 +39,7 @@ export class Terminal { name: "xterm-color", cols: terminalOptions.cols, rows: terminalOptions.rows, - cwd: this.synapse.workDir, + cwd: terminalOptions.workDir, env: process.env, }); diff --git a/src/synapse.ts b/src/synapse.ts index f7e2408..2e4cca4 100644 --- a/src/synapse.ts +++ b/src/synapse.ts @@ -24,10 +24,7 @@ export type MessageHandler = ( ) => void; export type ConnectionCallback = (connectionId: string) => void; export type ErrorCallback = (error: Error, connectionId: string) => void; -export type ServerConnectionCallback = ( - ws: WebSocket, - connectionId: string, -) => void; +export type ServerConnectionCallback = (connectionId: string) => void; export type Logger = (message: string) => void; class Synapse { @@ -70,7 +67,7 @@ class Synapse { this.wss = new WebSocketServer({ noServer: true }); this.wss.on("connection", (ws: WebSocket) => { const connectionId = this.generateConnectionId(); - this.serverConnectionListener(ws, connectionId); + this.serverConnectionListener(connectionId); this.setupWebSocket(ws, connectionId); }); } @@ -412,19 +409,6 @@ class Synapse { return promises; } - /** - * Sends a command to the terminal for execution for a specific connection - * @param connectionId - The ID of the connection to send to - * @param command - The command string to execute - * @returns A promise that resolves with the message payload - */ - sendCommand(connectionId: string, command: string): Promise { - return this.send(connectionId, "terminal", { - operation: "createCommand", - params: { command }, - }); - } - /** * Registers a callback for when a new WebSocket connection is established on the server side * @param callback - Function to be called when a new connection is established diff --git a/tests/synapse.test.ts b/tests/synapse.test.ts index 75d65e8..0c34de6 100644 --- a/tests/synapse.test.ts +++ b/tests/synapse.test.ts @@ -192,7 +192,7 @@ describe("Synapse", () => { Buffer.alloc(0), ); - expect(onConnectionMock).toHaveBeenCalledWith(mockWs, expect.any(String)); + expect(onConnectionMock).toHaveBeenCalledWith(expect.any(String)); // connectionId }); }); From 56d32dc98b53c378d9c95d5659d17cde2603d8cd Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 3 May 2025 17:04:37 +0000 Subject: [PATCH 10/14] chore: let connectio handler generate new id --- src/synapse.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/synapse.ts b/src/synapse.ts index 2e4cca4..aa287e2 100644 --- a/src/synapse.ts +++ b/src/synapse.ts @@ -488,8 +488,6 @@ class Synapse { } this.wss.handleUpgrade(req, socket, head, (ws: WebSocket) => { - const connectionId = this.generateConnectionId(); - this.setupWebSocket(ws, connectionId, path, params); this.wss.emit("connection", ws, req); }); } From 7fc2154d2da513379900a8c1c52d5fc73e46c343 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 3 May 2025 17:53:12 +0000 Subject: [PATCH 11/14] chore: shift connections away from connect --- src/synapse.ts | 10 +--- tests/synapse.test.ts | 119 +++++++++++++++++++++++++++--------------- 2 files changed, 80 insertions(+), 49 deletions(-) diff --git a/src/synapse.ts b/src/synapse.ts index aa287e2..dd18975 100644 --- a/src/synapse.ts +++ b/src/synapse.ts @@ -67,8 +67,8 @@ class Synapse { this.wss = new WebSocketServer({ noServer: true }); this.wss.on("connection", (ws: WebSocket) => { const connectionId = this.generateConnectionId(); - this.serverConnectionListener(connectionId); this.setupWebSocket(ws, connectionId); + this.serverConnectionListener(connectionId); }); } @@ -281,23 +281,17 @@ class Synapse { */ connect(path: string): Promise { const url = this.buildWebSocketUrl(path); - const connectionId = this.generateConnectionId(); return new Promise((resolve, reject) => { try { const ws = new WebSocket(url); ws.onopen = () => { - this.setupWebSocket(ws, connectionId, path); - resolve(connectionId); + resolve("Synapse connected successfully"); }; ws.onerror = (error: WebSocket.ErrorEvent) => { const errorMessage = `WebSocket error: ${error.message || "Unknown error"}. Failed to connect to ${url}`; - this.connectionListeners.onError( - new Error(errorMessage), - connectionId, - ); reject(new Error(errorMessage)); }; } catch (error) { diff --git a/tests/synapse.test.ts b/tests/synapse.test.ts index 0c34de6..1eefe7e 100644 --- a/tests/synapse.test.ts +++ b/tests/synapse.test.ts @@ -38,11 +38,11 @@ describe("Synapse", () => { (WebSocket as unknown as jest.Mock).mockImplementation(() => mockWs); const connectPromise = synapse.connect("/terminal"); - setTimeout(() => mockWs.onopen!({} as WebSocket.Event), 0); + setTimeout(() => mockWs.onopen && mockWs.onopen({} as any), 0); - const connectionId = await connectPromise; - expect(typeof connectionId).toBe("string"); - expect(synapse.getConnections()).toContain(connectionId); + await expect(connectPromise).resolves.toBe( + "Synapse connected successfully", + ); }); it("should reject on connection error", async () => { @@ -52,10 +52,11 @@ describe("Synapse", () => { const connectPromise = synapse.connect("/"); setTimeout( () => - mockWs.onerror!({ + mockWs.onerror && + mockWs.onerror({ error: new Error("Connection failed"), message: "Connection failed", - } as WebSocket.ErrorEvent), + } as any), 0, ); @@ -67,15 +68,20 @@ describe("Synapse", () => { let mockWs: ReturnType; let connectionId: string; - beforeEach(async () => { + beforeEach(() => { mockWs = createMockWebSocket(); - (WebSocket as unknown as jest.Mock).mockImplementation(() => mockWs); - const connectPromise = synapse.connect("/"); - setTimeout(() => mockWs.onopen!({} as WebSocket.Event), 0); - connectionId = await connectPromise; + connectionId = "test-conn"; + // Manually add connection for testing + (synapse as any).connections.set(connectionId, { + ws: mockWs, + id: connectionId, + path: "/", + params: null, + reconnectAttempts: 0, + }); }); - it("should handle messages correctly", async () => { + it("should handle messages correctly", () => { const handler = jest.fn(); synapse.onMessageType("test", handler); @@ -85,17 +91,19 @@ describe("Synapse", () => { data: { foo: "bar" }, }; - mockWs.onmessage!({ - data: JSON.stringify(testMessage), - } as WebSocket.MessageEvent); + // Simulate message event + (synapse as any).handleMessage( + { data: JSON.stringify(testMessage) }, + connectionId, + ); expect(handler).toHaveBeenCalledWith(testMessage, connectionId); }); - it("should ignore malformed messages", async () => { + it("should ignore malformed messages", () => { const handler = jest.fn(); synapse.onMessageType("test", handler); - mockWs.onmessage!({ data: "invalid json{" } as WebSocket.MessageEvent); + (synapse as any).handleMessage({ data: "invalid json{" }, connectionId); expect(handler).not.toHaveBeenCalled(); }); }); @@ -104,12 +112,16 @@ describe("Synapse", () => { let mockWs: ReturnType; let connectionId: string; - beforeEach(async () => { + beforeEach(() => { mockWs = createMockWebSocket(); - (WebSocket as unknown as jest.Mock).mockImplementation(() => mockWs); - const connectPromise = synapse.connect("/"); - setTimeout(() => mockWs.onopen!({} as WebSocket.Event), 0); - connectionId = await connectPromise; + connectionId = "test-conn"; + (synapse as any).connections.set(connectionId, { + ws: mockWs, + id: connectionId, + path: "/", + params: null, + reconnectAttempts: 0, + }); }); it("should send messages and return payload", async () => { @@ -121,10 +133,11 @@ describe("Synapse", () => { requestId: expect.any(String), data: "test-data", }); + expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify(result)); }); it("should throw when not connected", () => { - synapse.disconnect(connectionId); + mockWs.readyState = WebSocket.CLOSED; expect(() => synapse.send(connectionId, "test-type")).toThrow( `WebSocket connection ${connectionId} is not connected`, ); @@ -197,28 +210,52 @@ describe("Synapse", () => { }); describe("multiple connections", () => { - it("should allow multiple connections and track them independently", async () => { + it("should allow multiple connections and track them independently", () => { const mockWs1 = createMockWebSocket(); const mockWs2 = createMockWebSocket(); - let callCount = 0; - (WebSocket as unknown as jest.Mock).mockImplementation(() => { - callCount++; - return callCount === 1 ? mockWs1 : mockWs2; - }); - const id1Promise = synapse.connect("/a"); - setTimeout(() => mockWs1.onopen!({} as WebSocket.Event), 0); - const id2Promise = synapse.connect("/b"); - setTimeout(() => mockWs2.onopen!({} as WebSocket.Event), 0); - const id1 = await id1Promise; - const id2 = await id2Promise; - expect(id1).not.toBe(id2); + const mockWss = { + handleUpgrade: jest.fn((req, socket, head, cb) => { + if ((req as any).clientId === 1) cb(mockWs1); + else cb(mockWs2); + const connectionHandler = (mockWss.on as jest.Mock).mock.calls.find( + ([eventName]: [string]) => eventName === "connection", + )?.[1]; + if (connectionHandler) { + connectionHandler((req as any).clientId === 1 ? mockWs1 : mockWs2); + } + }), + emit: jest.fn(), + on: jest.fn() as jest.Mock, + close: jest.fn(), + } as unknown as WebSocketServer; + + jest.mocked(WebSocketServer).mockImplementation(() => mockWss); + + synapse = new Synapse(); + + const connectionIds: string[] = []; + synapse.onConnection((id) => connectionIds.push(id)); + + synapse.handleUpgrade( + { clientId: 1 } as any, + {} as Socket, + Buffer.alloc(0), + ); + synapse.handleUpgrade( + { clientId: 2 } as any, + {} as Socket, + Buffer.alloc(0), + ); + + expect(connectionIds.length).toBe(2); + expect(connectionIds[0]).not.toBe(connectionIds[1]); expect(synapse.getConnections()).toEqual( - expect.arrayContaining([id1, id2]), + expect.arrayContaining([connectionIds[0], connectionIds[1]]), ); - // Disconnect one and check the other remains - synapse.disconnect(id1); - expect(synapse.getConnections()).toContain(id2); - expect(synapse.getConnections()).not.toContain(id1); + + synapse.disconnect(connectionIds[0]); + expect(synapse.getConnections()).toContain(connectionIds[1]); + expect(synapse.getConnections()).not.toContain(connectionIds[0]); }); }); }); From 18fa7e85b2bc80ccd3ae0fceeca47be5986c71b6 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 3 May 2025 18:00:54 +0000 Subject: [PATCH 12/14] fix: parsing of urls --- src/synapse.ts | 23 +++++++++++++++++++---- tests/synapse.test.ts | 40 +++++++++++++++++----------------------- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/src/synapse.ts b/src/synapse.ts index dd18975..9fa1729 100644 --- a/src/synapse.ts +++ b/src/synapse.ts @@ -65,9 +65,9 @@ class Synapse { this.workDir = workDir; this.wss = new WebSocketServer({ noServer: true }); - this.wss.on("connection", (ws: WebSocket) => { + this.wss.on("connection", (ws: WebSocket, req: IncomingMessage) => { const connectionId = this.generateConnectionId(); - this.setupWebSocket(ws, connectionId); + this.setupWebSocket(ws, req, connectionId); this.serverConnectionListener(connectionId); }); } @@ -83,10 +83,25 @@ class Synapse { private setupWebSocket( ws: WebSocket, + req: IncomingMessage, connectionId: string, - path: string = "/", - params: Record | null = null, ): void { + const path = req.url?.split("?")[0] ?? "/"; + let params: Record | null = null; + const query = req.url?.split("?")[1]; + if (query) { + try { + params = JSON.parse(query); + } catch { + params = Object.fromEntries( + query.split("&").map((kv) => { + const [k, v] = kv.split("="); + return [decodeURIComponent(k), decodeURIComponent(v ?? "")]; + }), + ); + } + } + this.connections.set(connectionId, { ws, id: connectionId, diff --git a/tests/synapse.test.ts b/tests/synapse.test.ts index 1eefe7e..20dbe32 100644 --- a/tests/synapse.test.ts +++ b/tests/synapse.test.ts @@ -154,7 +154,7 @@ describe("Synapse", () => { ([eventName]: [string]) => eventName === "connection", )?.[1]; if (connectionHandler) { - connectionHandler(mockWs); + connectionHandler(mockWs, req); } }), emit: jest.fn(), @@ -168,11 +168,9 @@ describe("Synapse", () => { synapse = new Synapse(); synapse.onOpen(onOpenMock); - synapse.handleUpgrade( - {} as IncomingMessage, - {} as Socket, - Buffer.alloc(0), - ); + // Provide a mock IncomingMessage with a url property + const mockReq = { url: "/test?foo=bar" } as IncomingMessage; + synapse.handleUpgrade(mockReq, {} as Socket, Buffer.alloc(0)); expect(onOpenMock).toHaveBeenCalled(); }); @@ -185,7 +183,7 @@ describe("Synapse", () => { ([eventName]: [string]) => eventName === "connection", )?.[1]; if (connectionHandler) { - connectionHandler(mockWs); + connectionHandler(mockWs, req); } }), emit: jest.fn(), @@ -199,11 +197,9 @@ describe("Synapse", () => { synapse = new Synapse(); synapse.onConnection(onConnectionMock); - synapse.handleUpgrade( - {} as IncomingMessage, - {} as Socket, - Buffer.alloc(0), - ); + // Provide a mock IncomingMessage with a url property + const mockReq = { url: "/test?foo=bar" } as IncomingMessage; + synapse.handleUpgrade(mockReq, {} as Socket, Buffer.alloc(0)); expect(onConnectionMock).toHaveBeenCalledWith(expect.any(String)); // connectionId }); @@ -221,7 +217,10 @@ describe("Synapse", () => { ([eventName]: [string]) => eventName === "connection", )?.[1]; if (connectionHandler) { - connectionHandler((req as any).clientId === 1 ? mockWs1 : mockWs2); + connectionHandler( + (req as any).clientId === 1 ? mockWs1 : mockWs2, + req, + ); } }), emit: jest.fn(), @@ -236,16 +235,11 @@ describe("Synapse", () => { const connectionIds: string[] = []; synapse.onConnection((id) => connectionIds.push(id)); - synapse.handleUpgrade( - { clientId: 1 } as any, - {} as Socket, - Buffer.alloc(0), - ); - synapse.handleUpgrade( - { clientId: 2 } as any, - {} as Socket, - Buffer.alloc(0), - ); + // Provide mock IncomingMessages with url property + const mockReq1 = { clientId: 1, url: "/test1?foo=bar" } as any; + const mockReq2 = { clientId: 2, url: "/test2?foo=baz" } as any; + synapse.handleUpgrade(mockReq1, {} as Socket, Buffer.alloc(0)); + synapse.handleUpgrade(mockReq2, {} as Socket, Buffer.alloc(0)); expect(connectionIds.length).toBe(2); expect(connectionIds[0]).not.toBe(connectionIds[1]); From ea07f4d44389653b9a8e2741d32e17ea61df218e Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 3 May 2025 18:05:24 +0000 Subject: [PATCH 13/14] chore: create workDir if not exists --- src/services/terminal.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/services/terminal.ts b/src/services/terminal.ts index f0352d9..2087134 100644 --- a/src/services/terminal.ts +++ b/src/services/terminal.ts @@ -1,3 +1,4 @@ +import * as fs from "fs"; import * as pty from "node-pty"; import * as os from "os"; import { Synapse } from "../synapse"; @@ -34,6 +35,12 @@ export class Terminal { this.synapse = synapse; this.synapse.registerTerminal(this); + if (terminalOptions.workDir) { + if (!fs.existsSync(terminalOptions.workDir)) { + fs.mkdirSync(terminalOptions.workDir, { recursive: true }); + } + } + try { this.term = pty.spawn(terminalOptions.shell, [], { name: "xterm-color", From 27ef1e2e0f8dc0aa0505fa28ed072e1216320434 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 4 May 2025 14:21:46 +0000 Subject: [PATCH 14/14] chore: update --- src/services/terminal.ts | 11 +---------- tests/synapse.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/services/terminal.ts b/src/services/terminal.ts index 2087134..f3be7f2 100644 --- a/src/services/terminal.ts +++ b/src/services/terminal.ts @@ -1,4 +1,3 @@ -import * as fs from "fs"; import * as pty from "node-pty"; import * as os from "os"; import { Synapse } from "../synapse"; @@ -7,7 +6,6 @@ export type TerminalOptions = { shell: string; cols?: number; rows?: number; - workDir?: string; }; export class Terminal { @@ -29,24 +27,17 @@ export class Terminal { shell: os.platform() === "win32" ? "powershell.exe" : "bash", cols: 80, rows: 24, - workDir: synapse.workDir, }, ) { this.synapse = synapse; this.synapse.registerTerminal(this); - if (terminalOptions.workDir) { - if (!fs.existsSync(terminalOptions.workDir)) { - fs.mkdirSync(terminalOptions.workDir, { recursive: true }); - } - } - try { this.term = pty.spawn(terminalOptions.shell, [], { name: "xterm-color", cols: terminalOptions.cols, rows: terminalOptions.rows, - cwd: terminalOptions.workDir, + cwd: this.synapse.workDir, env: process.env, }); diff --git a/tests/synapse.test.ts b/tests/synapse.test.ts index 20dbe32..cb6f0ca 100644 --- a/tests/synapse.test.ts +++ b/tests/synapse.test.ts @@ -252,4 +252,28 @@ describe("Synapse", () => { expect(synapse.getConnections()).not.toContain(connectionIds[0]); }); }); + + describe("connection cleanup", () => { + it("should clear all connections when disconnect is called", () => { + const mockWs1 = createMockWebSocket(); + const mockWs2 = createMockWebSocket(); + (synapse as any).connections.set("conn1", { + ws: mockWs1, + id: "conn1", + path: "/a", + params: null, + reconnectAttempts: 0, + }); + (synapse as any).connections.set("conn2", { + ws: mockWs2, + id: "conn2", + path: "/b", + params: null, + reconnectAttempts: 0, + }); + expect(synapse.getConnections().length).toBe(2); + synapse.disconnect(); + expect(synapse.getConnections().length).toBe(0); + }); + }); });