spck 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/.oxlintrc.json +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +631 -0
  4. package/bin/cli.js +20 -0
  5. package/bin/validate-cwd.js +41 -0
  6. package/dist/config/__tests__/config.test.d.ts +2 -0
  7. package/dist/config/__tests__/config.test.js +262 -0
  8. package/dist/config/__tests__/credentials.test.d.ts +2 -0
  9. package/dist/config/__tests__/credentials.test.js +360 -0
  10. package/dist/config/config.d.ts +33 -0
  11. package/dist/config/config.js +185 -0
  12. package/dist/config/credentials.d.ts +75 -0
  13. package/dist/config/credentials.js +259 -0
  14. package/dist/config/server-selection.d.ts +40 -0
  15. package/dist/config/server-selection.js +130 -0
  16. package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
  17. package/dist/connection/__tests__/firebase-auth.test.js +96 -0
  18. package/dist/connection/__tests__/hmac.test.d.ts +2 -0
  19. package/dist/connection/__tests__/hmac.test.js +372 -0
  20. package/dist/connection/auth.d.ts +13 -0
  21. package/dist/connection/auth.js +91 -0
  22. package/dist/connection/firebase-auth.d.ts +40 -0
  23. package/dist/connection/firebase-auth.js +429 -0
  24. package/dist/connection/hmac.d.ts +24 -0
  25. package/dist/connection/hmac.js +109 -0
  26. package/dist/i18n/index.d.ts +25 -0
  27. package/dist/i18n/index.js +101 -0
  28. package/dist/i18n/locales/en.json +313 -0
  29. package/dist/i18n/locales/es.json +302 -0
  30. package/dist/i18n/locales/fr.json +302 -0
  31. package/dist/i18n/locales/id.json +302 -0
  32. package/dist/i18n/locales/ja.json +302 -0
  33. package/dist/i18n/locales/ko.json +302 -0
  34. package/dist/i18n/locales/locales/en.json +309 -0
  35. package/dist/i18n/locales/locales/es.json +302 -0
  36. package/dist/i18n/locales/locales/fr.json +302 -0
  37. package/dist/i18n/locales/locales/id.json +302 -0
  38. package/dist/i18n/locales/locales/ja.json +302 -0
  39. package/dist/i18n/locales/locales/ko.json +302 -0
  40. package/dist/i18n/locales/locales/pt.json +302 -0
  41. package/dist/i18n/locales/locales/zh-Hans.json +302 -0
  42. package/dist/i18n/locales/pt.json +302 -0
  43. package/dist/i18n/locales/zh-Hans.json +302 -0
  44. package/dist/index.d.ts +25 -0
  45. package/dist/index.js +493 -0
  46. package/dist/proxy/ProxyClient.d.ts +125 -0
  47. package/dist/proxy/ProxyClient.js +781 -0
  48. package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
  49. package/dist/proxy/ProxySocketWrapper.js +98 -0
  50. package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
  51. package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
  52. package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
  53. package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
  54. package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
  55. package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
  56. package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
  57. package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
  58. package/dist/proxy/chunking.d.ts +53 -0
  59. package/dist/proxy/chunking.js +127 -0
  60. package/dist/proxy/handshake-validation.d.ts +21 -0
  61. package/dist/proxy/handshake-validation.js +49 -0
  62. package/dist/rpc/__tests__/router.test.d.ts +2 -0
  63. package/dist/rpc/__tests__/router.test.js +262 -0
  64. package/dist/rpc/router.d.ts +37 -0
  65. package/dist/rpc/router.js +132 -0
  66. package/dist/services/BrowserProxyService.d.ts +13 -0
  67. package/dist/services/BrowserProxyService.js +139 -0
  68. package/dist/services/FilesystemService.d.ts +99 -0
  69. package/dist/services/FilesystemService.js +742 -0
  70. package/dist/services/GitService.d.ts +243 -0
  71. package/dist/services/GitService.js +1439 -0
  72. package/dist/services/SearchService.d.ts +93 -0
  73. package/dist/services/SearchService.js +670 -0
  74. package/dist/services/TerminalService.d.ts +62 -0
  75. package/dist/services/TerminalService.js +337 -0
  76. package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
  77. package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
  78. package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
  79. package/dist/services/__tests__/FilesystemService.test.js +609 -0
  80. package/dist/services/__tests__/GitService.test.d.ts +2 -0
  81. package/dist/services/__tests__/GitService.test.js +953 -0
  82. package/dist/services/__tests__/SearchService.test.d.ts +2 -0
  83. package/dist/services/__tests__/SearchService.test.js +384 -0
  84. package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
  85. package/dist/services/__tests__/TerminalService.test.js +513 -0
  86. package/dist/setup/wizard.d.ts +10 -0
  87. package/dist/setup/wizard.js +172 -0
  88. package/dist/types.d.ts +196 -0
  89. package/dist/types.js +44 -0
  90. package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
  91. package/dist/utils/__tests__/gitignore.test.js +127 -0
  92. package/dist/utils/gitignore.d.ts +24 -0
  93. package/dist/utils/gitignore.js +77 -0
  94. package/dist/utils/logger.d.ts +96 -0
  95. package/dist/utils/logger.js +456 -0
  96. package/dist/utils/project-dir.d.ts +51 -0
  97. package/dist/utils/project-dir.js +191 -0
  98. package/dist/utils/ripgrep.d.ts +34 -0
  99. package/dist/utils/ripgrep.js +148 -0
  100. package/dist/utils/tool-detection.d.ts +17 -0
  101. package/dist/utils/tool-detection.js +126 -0
  102. package/dist/watcher/FileWatcher.d.ts +10 -0
  103. package/dist/watcher/FileWatcher.js +42 -0
  104. package/package.json +70 -0
  105. package/src/config/__tests__/config.test.ts +318 -0
  106. package/src/config/__tests__/credentials.test.ts +494 -0
  107. package/src/config/config.ts +206 -0
  108. package/src/config/credentials.ts +302 -0
  109. package/src/config/server-selection.ts +150 -0
  110. package/src/connection/__tests__/firebase-auth.test.ts +121 -0
  111. package/src/connection/__tests__/hmac.test.ts +509 -0
  112. package/src/connection/auth.ts +140 -0
  113. package/src/connection/firebase-auth.ts +504 -0
  114. package/src/connection/hmac.ts +139 -0
  115. package/src/i18n/index.ts +119 -0
  116. package/src/i18n/locales/en.json +313 -0
  117. package/src/i18n/locales/es.json +302 -0
  118. package/src/i18n/locales/fr.json +302 -0
  119. package/src/i18n/locales/id.json +302 -0
  120. package/src/i18n/locales/ja.json +302 -0
  121. package/src/i18n/locales/ko.json +302 -0
  122. package/src/i18n/locales/pt.json +302 -0
  123. package/src/i18n/locales/zh-Hans.json +302 -0
  124. package/src/index.ts +542 -0
  125. package/src/proxy/ProxyClient.ts +968 -0
  126. package/src/proxy/ProxySocketWrapper.ts +113 -0
  127. package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
  128. package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
  129. package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
  130. package/src/proxy/chunking.ts +162 -0
  131. package/src/proxy/handshake-validation.ts +64 -0
  132. package/src/rpc/__tests__/router.test.ts +400 -0
  133. package/src/rpc/router.ts +183 -0
  134. package/src/services/BrowserProxyService.ts +179 -0
  135. package/src/services/FilesystemService.ts +841 -0
  136. package/src/services/GitService.ts +1639 -0
  137. package/src/services/SearchService.ts +809 -0
  138. package/src/services/TerminalService.ts +413 -0
  139. package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
  140. package/src/services/__tests__/FilesystemService.test.ts +1002 -0
  141. package/src/services/__tests__/GitService.test.ts +1552 -0
  142. package/src/services/__tests__/SearchService.test.ts +484 -0
  143. package/src/services/__tests__/TerminalService.test.ts +702 -0
  144. package/src/setup/wizard.ts +242 -0
  145. package/src/types/fossil-delta.d.ts +4 -0
  146. package/src/types.ts +287 -0
  147. package/src/utils/__tests__/gitignore.test.ts +174 -0
  148. package/src/utils/gitignore.ts +91 -0
  149. package/src/utils/logger.ts +578 -0
  150. package/src/utils/project-dir.ts +218 -0
  151. package/src/utils/ripgrep.ts +180 -0
  152. package/src/utils/tool-detection.ts +141 -0
  153. package/src/watcher/FileWatcher.ts +53 -0
  154. package/tsconfig.json +24 -0
  155. package/vitest.config.ts +19 -0
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Message Chunking Utility
3
+ *
4
+ * Handles chunking of large payloads to avoid Socket.IO buffer limits
5
+ * Chunks messages over 800kB into smaller pieces with reassembly metadata
6
+ */
7
+
8
+ const CHUNK_SIZE = 800 * 1024; // 800kB
9
+ const CHUNK_THRESHOLD = 800 * 1024; // Start chunking at 800kB
10
+
11
+ export interface ChunkMetadata {
12
+ type: 'chunk';
13
+ chunkId: string;
14
+ index: number;
15
+ total: number;
16
+ originalEvent: string;
17
+ data: any;
18
+ }
19
+
20
+ export interface ChunkedMessage {
21
+ needsChunking: boolean;
22
+ chunks?: ChunkMetadata[];
23
+ originalMessage?: any;
24
+ }
25
+
26
+ /**
27
+ * Check if a message needs chunking
28
+ */
29
+ export function needsChunking(data: any): boolean {
30
+ const size = estimateSize(data);
31
+ return size > CHUNK_THRESHOLD;
32
+ }
33
+
34
+ /**
35
+ * Chunk a large message into smaller pieces
36
+ */
37
+ export function chunkMessage(event: string, data: any): ChunkMetadata[] {
38
+ // Serialize the data
39
+ const serialized = JSON.stringify(data);
40
+ const totalBytes = Buffer.byteLength(serialized, 'utf8');
41
+
42
+ // Calculate number of chunks needed
43
+ const numChunks = Math.ceil(totalBytes / CHUNK_SIZE);
44
+
45
+ // Generate unique chunk ID
46
+ const chunkId = generateChunkId();
47
+
48
+ // Split into chunks
49
+ const chunks: ChunkMetadata[] = [];
50
+ let offset = 0;
51
+
52
+ for (let i = 0; i < numChunks; i++) {
53
+ const chunkData = serialized.slice(offset, offset + CHUNK_SIZE);
54
+ offset += CHUNK_SIZE;
55
+
56
+ chunks.push({
57
+ type: 'chunk',
58
+ chunkId,
59
+ index: i,
60
+ total: numChunks,
61
+ originalEvent: event,
62
+ data: chunkData,
63
+ });
64
+ }
65
+
66
+ return chunks;
67
+ }
68
+
69
+ /**
70
+ * Estimate size of an object in bytes
71
+ */
72
+ function estimateSize(obj: any): number {
73
+ const serialized = JSON.stringify(obj);
74
+ return Buffer.byteLength(serialized, 'utf8');
75
+ }
76
+
77
+ /**
78
+ * Generate unique chunk ID
79
+ */
80
+ function generateChunkId(): string {
81
+ return `chunk_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
82
+ }
83
+
84
+ /**
85
+ * Chunk reassembler (for client side)
86
+ */
87
+ export class ChunkReassembler {
88
+ private chunks: Map<string, Map<number, string>> = new Map();
89
+ private totalChunks: Map<string, number> = new Map();
90
+ private originalEvents: Map<string, string> = new Map();
91
+
92
+ /**
93
+ * Add a chunk and check if message is complete
94
+ * @returns Reassembled data if complete, null otherwise
95
+ */
96
+ addChunk(chunk: ChunkMetadata): { complete: boolean; event?: string; data?: any } {
97
+ const { chunkId, index, total, originalEvent, data } = chunk;
98
+
99
+ // Initialize chunk storage for this chunkId
100
+ if (!this.chunks.has(chunkId)) {
101
+ this.chunks.set(chunkId, new Map());
102
+ this.totalChunks.set(chunkId, total);
103
+ this.originalEvents.set(chunkId, originalEvent);
104
+ }
105
+
106
+ // Store this chunk
107
+ const chunkMap = this.chunks.get(chunkId)!;
108
+ chunkMap.set(index, data);
109
+
110
+ // Check if we have all chunks
111
+ const expectedTotal = this.totalChunks.get(chunkId)!;
112
+ if (chunkMap.size === expectedTotal) {
113
+ // Reassemble message
114
+ const reassembled = this.reassemble(chunkId);
115
+
116
+ // Clean up
117
+ this.chunks.delete(chunkId);
118
+ this.totalChunks.delete(chunkId);
119
+ this.originalEvents.delete(chunkId);
120
+
121
+ return {
122
+ complete: true,
123
+ event: originalEvent,
124
+ data: reassembled,
125
+ };
126
+ }
127
+
128
+ return { complete: false };
129
+ }
130
+
131
+ /**
132
+ * Reassemble chunks into original message
133
+ */
134
+ private reassemble(chunkId: string): any {
135
+ const chunkMap = this.chunks.get(chunkId)!;
136
+ const total = this.totalChunks.get(chunkId)!;
137
+
138
+ // Concatenate chunks in order
139
+ let reassembled = '';
140
+ for (let i = 0; i < total; i++) {
141
+ const chunk = chunkMap.get(i);
142
+ if (!chunk) {
143
+ throw new Error(`Missing chunk ${i} for chunkId ${chunkId}`);
144
+ }
145
+ reassembled += chunk;
146
+ }
147
+
148
+ // Parse back to object
149
+ return JSON.parse(reassembled);
150
+ }
151
+
152
+ /**
153
+ * Clean up stale chunks (older than 30 seconds)
154
+ */
155
+ cleanup(_maxAge: number = 30000): void {
156
+ // Note: This is a simple implementation that clears all chunks
157
+ // A more sophisticated version could track timestamps
158
+ this.chunks.clear();
159
+ this.totalChunks.clear();
160
+ this.originalEvents.clear();
161
+ }
162
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Handshake validation utilities
3
+ * Provides timestamp validation for replay attack prevention
4
+ */
5
+
6
+ /**
7
+ * Validate timestamp from client handshake message
8
+ * Prevents replay attacks by ensuring messages are recent
9
+ *
10
+ * @param timestamp - Unix timestamp in milliseconds from client message
11
+ * @param options - Validation options
12
+ * @returns Validation result with error message if invalid
13
+ */
14
+ export function validateHandshakeTimestamp(
15
+ timestamp: number,
16
+ options: {
17
+ maxAge?: number; // Maximum age in milliseconds (default: 1 minute)
18
+ clockSkewTolerance?: number; // Future tolerance in milliseconds (default: 1 minute)
19
+ now?: number; // Current time for testing (default: Date.now())
20
+ } = {}
21
+ ): { valid: boolean; error?: string } {
22
+ const {
23
+ maxAge = 60 * 1000, // 1 minute default
24
+ clockSkewTolerance = 60 * 1000, // 1 minute default
25
+ now = Date.now(),
26
+ } = options;
27
+
28
+ // Validate timestamp is a number
29
+ if (typeof timestamp !== 'number' || !isFinite(timestamp)) {
30
+ return {
31
+ valid: false,
32
+ error: 'Invalid timestamp format',
33
+ };
34
+ }
35
+
36
+ // Validate timestamp is positive
37
+ if (timestamp <= 0) {
38
+ return {
39
+ valid: false,
40
+ error: 'Timestamp must be positive',
41
+ };
42
+ }
43
+
44
+ // Calculate age
45
+ const age = now - timestamp;
46
+
47
+ // Check if message is too old
48
+ if (age > maxAge) {
49
+ return {
50
+ valid: false,
51
+ error: `Message too old (age: ${Math.floor(age / 1000)}s, max: ${Math.floor(maxAge / 1000)}s)`,
52
+ };
53
+ }
54
+
55
+ // Check if timestamp is too far in the future (clock skew)
56
+ if (age < -clockSkewTolerance) {
57
+ return {
58
+ valid: false,
59
+ error: `Timestamp too far in future (skew: ${Math.floor(-age / 1000)}s, max: ${Math.floor(clockSkewTolerance / 1000)}s)`,
60
+ };
61
+ }
62
+
63
+ return { valid: true };
64
+ }
@@ -0,0 +1,400 @@
1
+ import { describe, it, expect, beforeEach, vi, type Mock, type MockedClass } from 'vitest';
2
+ /**
3
+ * Tests for JSON-RPC Router
4
+ */
5
+
6
+ // Mock xterm headless modules BEFORE importing TerminalService
7
+ vi.mock('@xterm/headless', () => ({
8
+ default: { Terminal: vi.fn() },
9
+ Terminal: vi.fn(),
10
+ }));
11
+ vi.mock('@xterm/addon-serialize', () => ({
12
+ default: { SerializeAddon: vi.fn() },
13
+ SerializeAddon: vi.fn(),
14
+ }));
15
+
16
+ import * as crypto from 'crypto';
17
+ import { RPCRouter } from '../router.js';
18
+ import { ErrorCode, createRPCError, JSONRPCRequest } from '../../types.js';
19
+ import { FilesystemService } from '../../services/FilesystemService.js';
20
+ import { GitService } from '../../services/GitService.js';
21
+ import { TerminalService } from '../../services/TerminalService.js';
22
+
23
+ /**
24
+ * Helper function to create a valid JSONRPCRequest with HMAC and nonce
25
+ */
26
+ function createRequest(method: string, params?: any, id?: number | string): JSONRPCRequest {
27
+ const timestamp = Date.now();
28
+ const nonce = crypto.randomBytes(16).toString('hex');
29
+ const signingKey = 'test-key';
30
+
31
+ const payload = {
32
+ jsonrpc: '2.0' as const,
33
+ method,
34
+ params,
35
+ id,
36
+ nonce,
37
+ };
38
+
39
+ const messageToSign = timestamp + JSON.stringify(payload);
40
+ const hmac = crypto
41
+ .createHmac('sha256', signingKey)
42
+ .update(messageToSign)
43
+ .digest('hex');
44
+
45
+ return {
46
+ ...payload,
47
+ timestamp,
48
+ hmac,
49
+ nonce,
50
+ };
51
+ }
52
+
53
+ // Mock services
54
+ vi.mock('../../services/FilesystemService', () => {
55
+ const MockFS = vi.fn();
56
+ MockFS.prototype.handle = vi.fn();
57
+ return { FilesystemService: MockFS };
58
+ });
59
+ vi.mock('../../services/GitService', () => {
60
+ const MockGit = vi.fn();
61
+ MockGit.prototype.handle = vi.fn();
62
+ return { GitService: MockGit };
63
+ });
64
+ vi.mock('../../services/TerminalService', () => {
65
+ const MockTerm = vi.fn();
66
+ MockTerm.prototype.handle = vi.fn();
67
+ MockTerm.prototype.cleanup = vi.fn();
68
+ return { TerminalService: MockTerm };
69
+ });
70
+
71
+ const MockFilesystemService = FilesystemService as MockedClass<typeof FilesystemService>;
72
+ const MockGitService = GitService as MockedClass<typeof GitService>;
73
+ const MockTerminalService = TerminalService as MockedClass<typeof TerminalService>;
74
+
75
+ describe('RPCRouter', () => {
76
+ let mockSocket: any;
77
+ let mockFsHandle: Mock;
78
+ let mockGitHandle: Mock;
79
+ let mockTerminalHandle: Mock;
80
+
81
+ beforeEach(() => {
82
+ mockSocket = {
83
+ id: 'test-socket-id',
84
+ data: {
85
+ uid: 'test-user-123',
86
+ },
87
+ emit: vi.fn(),
88
+ on: vi.fn(),
89
+ off: vi.fn(),
90
+ broadcast: {
91
+ emit: vi.fn(),
92
+ },
93
+ };
94
+
95
+ // Clear all mocks
96
+ vi.clearAllMocks();
97
+
98
+ // Clear router state between tests
99
+ (RPCRouter as any).terminalServices = new Map();
100
+ (RPCRouter as any).currentSockets = new Map();
101
+
102
+ // Set up default mock implementations
103
+ mockFsHandle = vi.fn().mockResolvedValue({ success: true });
104
+ mockGitHandle = vi.fn().mockResolvedValue({ success: true });
105
+ mockTerminalHandle = vi.fn().mockResolvedValue('term-123');
106
+
107
+ // Mock the service prototypes
108
+ MockFilesystemService.prototype.handle = mockFsHandle;
109
+ MockGitService.prototype.handle = mockGitHandle;
110
+
111
+ // Create mock instance for terminal service that will be returned by constructor
112
+ // Use a single tracked cleanup function across all instances
113
+ const mockCleanupFn = vi.fn();
114
+ MockTerminalService.mockImplementation(() => ({
115
+ handle: mockTerminalHandle,
116
+ cleanup: mockCleanupFn,
117
+ }) as any);
118
+
119
+ // Initialize router
120
+ RPCRouter.initialize('/test/root', {
121
+ filesystem: { maxFileSize: '100MB', watchIgnorePatterns: [] },
122
+ }, {
123
+ git: true,
124
+ ripgrep: true,
125
+ });
126
+ });
127
+
128
+ describe('Route Method Parsing', () => {
129
+ it('should parse method correctly for fs service', async () => {
130
+ await RPCRouter.route(
131
+ createRequest('fs.readFile', { path: '/test.txt' }, 1),
132
+ mockSocket
133
+ );
134
+
135
+ expect(mockFsHandle).toHaveBeenCalledWith('readFile', { path: '/test.txt' }, mockSocket);
136
+ });
137
+
138
+ it('should parse method correctly for git service', async () => {
139
+ await RPCRouter.route(
140
+ createRequest('git.log', { dir: '/project' }, 2),
141
+ mockSocket
142
+ );
143
+
144
+ expect(mockGitHandle).toHaveBeenCalledWith('log', { dir: '/project' }, mockSocket);
145
+ });
146
+
147
+ it('should parse method correctly for terminal service', async () => {
148
+ await RPCRouter.route(
149
+ createRequest('terminal.create', { cols: 80, rows: 24 }, 3),
150
+ mockSocket
151
+ );
152
+
153
+ expect(mockTerminalHandle).toHaveBeenCalledWith('create', { cols: 80, rows: 24 });
154
+ });
155
+
156
+ it('should throw error for invalid method format', async () => {
157
+ await expect(
158
+ RPCRouter.route(
159
+ createRequest('invalidmethod', {}, 4),
160
+ mockSocket
161
+ )
162
+ ).rejects.toMatchObject({
163
+ code: ErrorCode.INVALID_REQUEST,
164
+ message: expect.stringContaining('Invalid method format'),
165
+ });
166
+ });
167
+
168
+ it('should throw error for unknown service', async () => {
169
+ await expect(
170
+ RPCRouter.route(
171
+ createRequest('unknown.method', {}, 5),
172
+ mockSocket
173
+ )
174
+ ).rejects.toMatchObject({
175
+ code: ErrorCode.METHOD_NOT_FOUND,
176
+ message: expect.stringContaining('Unknown service'),
177
+ });
178
+ });
179
+ });
180
+
181
+ describe('Service Routing', () => {
182
+ it('should route to FilesystemService', async () => {
183
+ const mockResult = { content: 'file contents' };
184
+ mockFsHandle.mockResolvedValueOnce(mockResult);
185
+
186
+ const result = await RPCRouter.route(
187
+ createRequest('fs.readFile', { path: '/test.txt', encoding: 'utf8' }, 10),
188
+ mockSocket
189
+ );
190
+
191
+ expect(result).toEqual(mockResult);
192
+ expect(mockFsHandle).toHaveBeenCalledTimes(1);
193
+ });
194
+
195
+ it('should route to GitService', async () => {
196
+ const mockResult = { oid: 'abc123', commit: {} };
197
+ mockGitHandle.mockResolvedValueOnce(mockResult);
198
+
199
+ const result = await RPCRouter.route(
200
+ createRequest('git.readCommit', { dir: '/project', oid: 'abc123' }, 11),
201
+ mockSocket
202
+ );
203
+
204
+ expect(result).toEqual(mockResult);
205
+ expect(mockGitHandle).toHaveBeenCalledTimes(1);
206
+ });
207
+
208
+ it('should route to TerminalService', async () => {
209
+ const result = await RPCRouter.route(
210
+ createRequest('terminal.create', { cols: 120, rows: 30 }, 12),
211
+ mockSocket
212
+ );
213
+
214
+ // Should return the default mock result
215
+ expect(result).toEqual('term-123');
216
+ expect(mockTerminalHandle).toHaveBeenCalledTimes(1);
217
+ });
218
+
219
+ it('should create separate terminal services per device', async () => {
220
+ const socket1 = { ...mockSocket, id: 'socket-1', data: { uid: 'user-1', deviceId: 'device-1' } };
221
+ const socket2 = { ...mockSocket, id: 'socket-2', data: { uid: 'user-2', deviceId: 'device-2' } };
222
+
223
+ const result1 = await RPCRouter.route(
224
+ createRequest('terminal.create', {}, 1),
225
+ socket1
226
+ );
227
+
228
+ const result2 = await RPCRouter.route(
229
+ createRequest('terminal.create', {}, 2),
230
+ socket2
231
+ );
232
+
233
+ // Both should succeed (indicating separate services were created)
234
+ expect(result1).toBeTruthy();
235
+ expect(result2).toBeTruthy();
236
+ expect(mockTerminalHandle).toHaveBeenCalledTimes(2);
237
+ });
238
+
239
+ it('should reuse terminal service for same device', async () => {
240
+ const socket1 = { ...mockSocket, id: 'socket-1', data: { uid: 'user-same', deviceId: 'device-same' } };
241
+ const socket2 = { ...mockSocket, id: 'socket-2', data: { uid: 'user-same', deviceId: 'device-same' } };
242
+
243
+ const result1 = await RPCRouter.route(
244
+ createRequest('terminal.create', {}, 1),
245
+ socket1
246
+ );
247
+
248
+ const result2 = await RPCRouter.route(
249
+ createRequest('terminal.create', {}, 2),
250
+ socket2
251
+ );
252
+
253
+ // Both should succeed using the same service instance
254
+ expect(result1).toBeTruthy();
255
+ expect(result2).toBeTruthy();
256
+ expect(mockTerminalHandle).toHaveBeenCalledTimes(2);
257
+ });
258
+ });
259
+
260
+ describe('Error Handling', () => {
261
+ it('should re-throw RPC errors from services', async () => {
262
+ const rpcError = createRPCError(ErrorCode.FILE_NOT_FOUND, 'File not found');
263
+ mockFsHandle.mockRejectedValueOnce(rpcError);
264
+
265
+ await expect(
266
+ RPCRouter.route(
267
+ createRequest('fs.readFile', { path: '/missing.txt' }, 20),
268
+ mockSocket
269
+ )
270
+ ).rejects.toMatchObject({
271
+ code: ErrorCode.FILE_NOT_FOUND,
272
+ message: 'File not found',
273
+ });
274
+ });
275
+
276
+ it('should wrap non-RPC errors in internal error', async () => {
277
+ const genericError = new Error('Something went wrong');
278
+ mockFsHandle.mockRejectedValueOnce(genericError);
279
+
280
+ await expect(
281
+ RPCRouter.route(
282
+ createRequest('fs.readFile', { path: '/test.txt' }, 21),
283
+ mockSocket
284
+ )
285
+ ).rejects.toMatchObject({
286
+ code: ErrorCode.INTERNAL_ERROR,
287
+ message: expect.stringContaining('Internal error'),
288
+ });
289
+ });
290
+
291
+ it('should include method name in wrapped errors', async () => {
292
+ const genericError = new Error('Unexpected error');
293
+ mockGitHandle.mockRejectedValueOnce(genericError);
294
+
295
+ await expect(
296
+ RPCRouter.route(
297
+ createRequest('git.commit', { dir: '/project', message: 'test' }, 22),
298
+ mockSocket
299
+ )
300
+ ).rejects.toMatchObject({
301
+ code: ErrorCode.INTERNAL_ERROR,
302
+ data: {
303
+ method: 'git.commit',
304
+ originalError: expect.any(String),
305
+ },
306
+ });
307
+ });
308
+ });
309
+
310
+ describe('Terminal Service Cleanup', () => {
311
+ it('should cleanup terminal service on disconnect', async () => {
312
+ // Setup a mock cleanup function that will be tracked
313
+ const mockCleanup = vi.fn();
314
+
315
+ // Create a new mock instance with cleanup
316
+ const mockServiceWithCleanup = {
317
+ handle: mockTerminalHandle,
318
+ cleanup: mockCleanup,
319
+ };
320
+
321
+ // Override the mock to return our instance
322
+ MockTerminalService.mockImplementationOnce(() => mockServiceWithCleanup as any);
323
+
324
+ // Create a terminal service - this will instantiate the mock
325
+ await RPCRouter.route(
326
+ createRequest('terminal.create', {}, 1),
327
+ mockSocket
328
+ );
329
+
330
+ // Now cleanup - this should call the cleanup method
331
+ RPCRouter.cleanupTerminalService(mockSocket);
332
+
333
+ expect(mockCleanup).toHaveBeenCalledTimes(1);
334
+ });
335
+
336
+ it('should handle cleanup when no terminal service exists', () => {
337
+ const otherSocket = { ...mockSocket, data: { uid: 'different-user', deviceId: 'different-device' } };
338
+
339
+ expect(() => {
340
+ RPCRouter.cleanupTerminalService(otherSocket);
341
+ }).not.toThrow();
342
+ });
343
+ });
344
+
345
+ describe('Edge Cases', () => {
346
+ it('should handle method with multiple dots', async () => {
347
+ // Method "fs.some.deep.method" splits into service="fs", methodName="some.deep.method"
348
+ // Only the first dot is used to separate service from method; sub-namespaces are preserved
349
+ const result = await RPCRouter.route(
350
+ createRequest('fs.some.deep.method', {}, 30),
351
+ mockSocket
352
+ );
353
+
354
+ // Should route to fs service with the full sub-method "some.deep.method"
355
+ expect(mockFsHandle).toHaveBeenCalledWith('some.deep.method', {}, mockSocket);
356
+ expect(result).toEqual({ success: true });
357
+ });
358
+
359
+ it('should handle empty method name', async () => {
360
+ await expect(
361
+ RPCRouter.route(
362
+ createRequest('', {}, 31),
363
+ mockSocket
364
+ )
365
+ ).rejects.toMatchObject({
366
+ code: ErrorCode.INVALID_REQUEST,
367
+ });
368
+ });
369
+
370
+ it('should handle method with only service name', async () => {
371
+ await expect(
372
+ RPCRouter.route(
373
+ createRequest('fs.', {}, 32),
374
+ mockSocket
375
+ )
376
+ ).rejects.toMatchObject({
377
+ code: ErrorCode.INVALID_REQUEST,
378
+ });
379
+ });
380
+
381
+ it('should handle undefined params', async () => {
382
+ const result = await RPCRouter.route(
383
+ createRequest('fs.stat', undefined, 33),
384
+ mockSocket
385
+ );
386
+
387
+ expect(mockFsHandle).toHaveBeenCalledWith('stat', undefined, mockSocket);
388
+ expect(result).toEqual({ success: true });
389
+ });
390
+
391
+ it('should handle requests with no id', async () => {
392
+ const result = await RPCRouter.route(
393
+ createRequest('fs.stat', { path: '/test.txt' }),
394
+ mockSocket
395
+ );
396
+
397
+ expect(result).toEqual({ success: true });
398
+ });
399
+ });
400
+ });