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,53 @@
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
+ export interface ChunkMetadata {
8
+ type: 'chunk';
9
+ chunkId: string;
10
+ index: number;
11
+ total: number;
12
+ originalEvent: string;
13
+ data: any;
14
+ }
15
+ export interface ChunkedMessage {
16
+ needsChunking: boolean;
17
+ chunks?: ChunkMetadata[];
18
+ originalMessage?: any;
19
+ }
20
+ /**
21
+ * Check if a message needs chunking
22
+ */
23
+ export declare function needsChunking(data: any): boolean;
24
+ /**
25
+ * Chunk a large message into smaller pieces
26
+ */
27
+ export declare function chunkMessage(event: string, data: any): ChunkMetadata[];
28
+ /**
29
+ * Chunk reassembler (for client side)
30
+ */
31
+ export declare class ChunkReassembler {
32
+ private chunks;
33
+ private totalChunks;
34
+ private originalEvents;
35
+ /**
36
+ * Add a chunk and check if message is complete
37
+ * @returns Reassembled data if complete, null otherwise
38
+ */
39
+ addChunk(chunk: ChunkMetadata): {
40
+ complete: boolean;
41
+ event?: string;
42
+ data?: any;
43
+ };
44
+ /**
45
+ * Reassemble chunks into original message
46
+ */
47
+ private reassemble;
48
+ /**
49
+ * Clean up stale chunks (older than 30 seconds)
50
+ */
51
+ cleanup(_maxAge?: number): void;
52
+ }
53
+ //# sourceMappingURL=chunking.d.ts.map
@@ -0,0 +1,127 @@
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
+ const CHUNK_SIZE = 800 * 1024; // 800kB
8
+ const CHUNK_THRESHOLD = 800 * 1024; // Start chunking at 800kB
9
+ /**
10
+ * Check if a message needs chunking
11
+ */
12
+ export function needsChunking(data) {
13
+ const size = estimateSize(data);
14
+ return size > CHUNK_THRESHOLD;
15
+ }
16
+ /**
17
+ * Chunk a large message into smaller pieces
18
+ */
19
+ export function chunkMessage(event, data) {
20
+ // Serialize the data
21
+ const serialized = JSON.stringify(data);
22
+ const totalBytes = Buffer.byteLength(serialized, 'utf8');
23
+ // Calculate number of chunks needed
24
+ const numChunks = Math.ceil(totalBytes / CHUNK_SIZE);
25
+ // Generate unique chunk ID
26
+ const chunkId = generateChunkId();
27
+ // Split into chunks
28
+ const chunks = [];
29
+ let offset = 0;
30
+ for (let i = 0; i < numChunks; i++) {
31
+ const chunkData = serialized.slice(offset, offset + CHUNK_SIZE);
32
+ offset += CHUNK_SIZE;
33
+ chunks.push({
34
+ type: 'chunk',
35
+ chunkId,
36
+ index: i,
37
+ total: numChunks,
38
+ originalEvent: event,
39
+ data: chunkData,
40
+ });
41
+ }
42
+ return chunks;
43
+ }
44
+ /**
45
+ * Estimate size of an object in bytes
46
+ */
47
+ function estimateSize(obj) {
48
+ const serialized = JSON.stringify(obj);
49
+ return Buffer.byteLength(serialized, 'utf8');
50
+ }
51
+ /**
52
+ * Generate unique chunk ID
53
+ */
54
+ function generateChunkId() {
55
+ return `chunk_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
56
+ }
57
+ /**
58
+ * Chunk reassembler (for client side)
59
+ */
60
+ export class ChunkReassembler {
61
+ constructor() {
62
+ this.chunks = new Map();
63
+ this.totalChunks = new Map();
64
+ this.originalEvents = new Map();
65
+ }
66
+ /**
67
+ * Add a chunk and check if message is complete
68
+ * @returns Reassembled data if complete, null otherwise
69
+ */
70
+ addChunk(chunk) {
71
+ const { chunkId, index, total, originalEvent, data } = chunk;
72
+ // Initialize chunk storage for this chunkId
73
+ if (!this.chunks.has(chunkId)) {
74
+ this.chunks.set(chunkId, new Map());
75
+ this.totalChunks.set(chunkId, total);
76
+ this.originalEvents.set(chunkId, originalEvent);
77
+ }
78
+ // Store this chunk
79
+ const chunkMap = this.chunks.get(chunkId);
80
+ chunkMap.set(index, data);
81
+ // Check if we have all chunks
82
+ const expectedTotal = this.totalChunks.get(chunkId);
83
+ if (chunkMap.size === expectedTotal) {
84
+ // Reassemble message
85
+ const reassembled = this.reassemble(chunkId);
86
+ // Clean up
87
+ this.chunks.delete(chunkId);
88
+ this.totalChunks.delete(chunkId);
89
+ this.originalEvents.delete(chunkId);
90
+ return {
91
+ complete: true,
92
+ event: originalEvent,
93
+ data: reassembled,
94
+ };
95
+ }
96
+ return { complete: false };
97
+ }
98
+ /**
99
+ * Reassemble chunks into original message
100
+ */
101
+ reassemble(chunkId) {
102
+ const chunkMap = this.chunks.get(chunkId);
103
+ const total = this.totalChunks.get(chunkId);
104
+ // Concatenate chunks in order
105
+ let reassembled = '';
106
+ for (let i = 0; i < total; i++) {
107
+ const chunk = chunkMap.get(i);
108
+ if (!chunk) {
109
+ throw new Error(`Missing chunk ${i} for chunkId ${chunkId}`);
110
+ }
111
+ reassembled += chunk;
112
+ }
113
+ // Parse back to object
114
+ return JSON.parse(reassembled);
115
+ }
116
+ /**
117
+ * Clean up stale chunks (older than 30 seconds)
118
+ */
119
+ cleanup(_maxAge = 30000) {
120
+ // Note: This is a simple implementation that clears all chunks
121
+ // A more sophisticated version could track timestamps
122
+ this.chunks.clear();
123
+ this.totalChunks.clear();
124
+ this.originalEvents.clear();
125
+ }
126
+ }
127
+ //# sourceMappingURL=chunking.js.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Handshake validation utilities
3
+ * Provides timestamp validation for replay attack prevention
4
+ */
5
+ /**
6
+ * Validate timestamp from client handshake message
7
+ * Prevents replay attacks by ensuring messages are recent
8
+ *
9
+ * @param timestamp - Unix timestamp in milliseconds from client message
10
+ * @param options - Validation options
11
+ * @returns Validation result with error message if invalid
12
+ */
13
+ export declare function validateHandshakeTimestamp(timestamp: number, options?: {
14
+ maxAge?: number;
15
+ clockSkewTolerance?: number;
16
+ now?: number;
17
+ }): {
18
+ valid: boolean;
19
+ error?: string;
20
+ };
21
+ //# sourceMappingURL=handshake-validation.d.ts.map
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Handshake validation utilities
3
+ * Provides timestamp validation for replay attack prevention
4
+ */
5
+ /**
6
+ * Validate timestamp from client handshake message
7
+ * Prevents replay attacks by ensuring messages are recent
8
+ *
9
+ * @param timestamp - Unix timestamp in milliseconds from client message
10
+ * @param options - Validation options
11
+ * @returns Validation result with error message if invalid
12
+ */
13
+ export function validateHandshakeTimestamp(timestamp, options = {}) {
14
+ const { maxAge = 60 * 1000, // 1 minute default
15
+ clockSkewTolerance = 60 * 1000, // 1 minute default
16
+ now = Date.now(), } = options;
17
+ // Validate timestamp is a number
18
+ if (typeof timestamp !== 'number' || !isFinite(timestamp)) {
19
+ return {
20
+ valid: false,
21
+ error: 'Invalid timestamp format',
22
+ };
23
+ }
24
+ // Validate timestamp is positive
25
+ if (timestamp <= 0) {
26
+ return {
27
+ valid: false,
28
+ error: 'Timestamp must be positive',
29
+ };
30
+ }
31
+ // Calculate age
32
+ const age = now - timestamp;
33
+ // Check if message is too old
34
+ if (age > maxAge) {
35
+ return {
36
+ valid: false,
37
+ error: `Message too old (age: ${Math.floor(age / 1000)}s, max: ${Math.floor(maxAge / 1000)}s)`,
38
+ };
39
+ }
40
+ // Check if timestamp is too far in the future (clock skew)
41
+ if (age < -clockSkewTolerance) {
42
+ return {
43
+ valid: false,
44
+ error: `Timestamp too far in future (skew: ${Math.floor(-age / 1000)}s, max: ${Math.floor(clockSkewTolerance / 1000)}s)`,
45
+ };
46
+ }
47
+ return { valid: true };
48
+ }
49
+ //# sourceMappingURL=handshake-validation.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=router.test.d.ts.map
@@ -0,0 +1,262 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ /**
3
+ * Tests for JSON-RPC Router
4
+ */
5
+ // Mock xterm headless modules BEFORE importing TerminalService
6
+ vi.mock('@xterm/headless', () => ({
7
+ default: { Terminal: vi.fn() },
8
+ Terminal: vi.fn(),
9
+ }));
10
+ vi.mock('@xterm/addon-serialize', () => ({
11
+ default: { SerializeAddon: vi.fn() },
12
+ SerializeAddon: vi.fn(),
13
+ }));
14
+ import * as crypto from 'crypto';
15
+ import { RPCRouter } from '../router.js';
16
+ import { ErrorCode, createRPCError } from '../../types.js';
17
+ import { FilesystemService } from '../../services/FilesystemService.js';
18
+ import { GitService } from '../../services/GitService.js';
19
+ import { TerminalService } from '../../services/TerminalService.js';
20
+ /**
21
+ * Helper function to create a valid JSONRPCRequest with HMAC and nonce
22
+ */
23
+ function createRequest(method, params, id) {
24
+ const timestamp = Date.now();
25
+ const nonce = crypto.randomBytes(16).toString('hex');
26
+ const signingKey = 'test-key';
27
+ const payload = {
28
+ jsonrpc: '2.0',
29
+ method,
30
+ params,
31
+ id,
32
+ nonce,
33
+ };
34
+ const messageToSign = timestamp + JSON.stringify(payload);
35
+ const hmac = crypto
36
+ .createHmac('sha256', signingKey)
37
+ .update(messageToSign)
38
+ .digest('hex');
39
+ return {
40
+ ...payload,
41
+ timestamp,
42
+ hmac,
43
+ nonce,
44
+ };
45
+ }
46
+ // Mock services
47
+ vi.mock('../../services/FilesystemService', () => {
48
+ const MockFS = vi.fn();
49
+ MockFS.prototype.handle = vi.fn();
50
+ return { FilesystemService: MockFS };
51
+ });
52
+ vi.mock('../../services/GitService', () => {
53
+ const MockGit = vi.fn();
54
+ MockGit.prototype.handle = vi.fn();
55
+ return { GitService: MockGit };
56
+ });
57
+ vi.mock('../../services/TerminalService', () => {
58
+ const MockTerm = vi.fn();
59
+ MockTerm.prototype.handle = vi.fn();
60
+ MockTerm.prototype.cleanup = vi.fn();
61
+ return { TerminalService: MockTerm };
62
+ });
63
+ const MockFilesystemService = FilesystemService;
64
+ const MockGitService = GitService;
65
+ const MockTerminalService = TerminalService;
66
+ describe('RPCRouter', () => {
67
+ let mockSocket;
68
+ let mockFsHandle;
69
+ let mockGitHandle;
70
+ let mockTerminalHandle;
71
+ beforeEach(() => {
72
+ mockSocket = {
73
+ id: 'test-socket-id',
74
+ data: {
75
+ uid: 'test-user-123',
76
+ },
77
+ emit: vi.fn(),
78
+ on: vi.fn(),
79
+ off: vi.fn(),
80
+ broadcast: {
81
+ emit: vi.fn(),
82
+ },
83
+ };
84
+ // Clear all mocks
85
+ vi.clearAllMocks();
86
+ // Clear router state between tests
87
+ RPCRouter.terminalServices = new Map();
88
+ RPCRouter.currentSockets = new Map();
89
+ // Set up default mock implementations
90
+ mockFsHandle = vi.fn().mockResolvedValue({ success: true });
91
+ mockGitHandle = vi.fn().mockResolvedValue({ success: true });
92
+ mockTerminalHandle = vi.fn().mockResolvedValue('term-123');
93
+ // Mock the service prototypes
94
+ MockFilesystemService.prototype.handle = mockFsHandle;
95
+ MockGitService.prototype.handle = mockGitHandle;
96
+ // Create mock instance for terminal service that will be returned by constructor
97
+ // Use a single tracked cleanup function across all instances
98
+ const mockCleanupFn = vi.fn();
99
+ MockTerminalService.mockImplementation(() => ({
100
+ handle: mockTerminalHandle,
101
+ cleanup: mockCleanupFn,
102
+ }));
103
+ // Initialize router
104
+ RPCRouter.initialize('/test/root', {
105
+ filesystem: { maxFileSize: '100MB', watchIgnorePatterns: [] },
106
+ }, {
107
+ git: true,
108
+ ripgrep: true,
109
+ });
110
+ });
111
+ describe('Route Method Parsing', () => {
112
+ it('should parse method correctly for fs service', async () => {
113
+ await RPCRouter.route(createRequest('fs.readFile', { path: '/test.txt' }, 1), mockSocket);
114
+ expect(mockFsHandle).toHaveBeenCalledWith('readFile', { path: '/test.txt' }, mockSocket);
115
+ });
116
+ it('should parse method correctly for git service', async () => {
117
+ await RPCRouter.route(createRequest('git.log', { dir: '/project' }, 2), mockSocket);
118
+ expect(mockGitHandle).toHaveBeenCalledWith('log', { dir: '/project' }, mockSocket);
119
+ });
120
+ it('should parse method correctly for terminal service', async () => {
121
+ await RPCRouter.route(createRequest('terminal.create', { cols: 80, rows: 24 }, 3), mockSocket);
122
+ expect(mockTerminalHandle).toHaveBeenCalledWith('create', { cols: 80, rows: 24 });
123
+ });
124
+ it('should throw error for invalid method format', async () => {
125
+ await expect(RPCRouter.route(createRequest('invalidmethod', {}, 4), mockSocket)).rejects.toMatchObject({
126
+ code: ErrorCode.INVALID_REQUEST,
127
+ message: expect.stringContaining('Invalid method format'),
128
+ });
129
+ });
130
+ it('should throw error for unknown service', async () => {
131
+ await expect(RPCRouter.route(createRequest('unknown.method', {}, 5), mockSocket)).rejects.toMatchObject({
132
+ code: ErrorCode.METHOD_NOT_FOUND,
133
+ message: expect.stringContaining('Unknown service'),
134
+ });
135
+ });
136
+ });
137
+ describe('Service Routing', () => {
138
+ it('should route to FilesystemService', async () => {
139
+ const mockResult = { content: 'file contents' };
140
+ mockFsHandle.mockResolvedValueOnce(mockResult);
141
+ const result = await RPCRouter.route(createRequest('fs.readFile', { path: '/test.txt', encoding: 'utf8' }, 10), mockSocket);
142
+ expect(result).toEqual(mockResult);
143
+ expect(mockFsHandle).toHaveBeenCalledTimes(1);
144
+ });
145
+ it('should route to GitService', async () => {
146
+ const mockResult = { oid: 'abc123', commit: {} };
147
+ mockGitHandle.mockResolvedValueOnce(mockResult);
148
+ const result = await RPCRouter.route(createRequest('git.readCommit', { dir: '/project', oid: 'abc123' }, 11), mockSocket);
149
+ expect(result).toEqual(mockResult);
150
+ expect(mockGitHandle).toHaveBeenCalledTimes(1);
151
+ });
152
+ it('should route to TerminalService', async () => {
153
+ const result = await RPCRouter.route(createRequest('terminal.create', { cols: 120, rows: 30 }, 12), mockSocket);
154
+ // Should return the default mock result
155
+ expect(result).toEqual('term-123');
156
+ expect(mockTerminalHandle).toHaveBeenCalledTimes(1);
157
+ });
158
+ it('should create separate terminal services per device', async () => {
159
+ const socket1 = { ...mockSocket, id: 'socket-1', data: { uid: 'user-1', deviceId: 'device-1' } };
160
+ const socket2 = { ...mockSocket, id: 'socket-2', data: { uid: 'user-2', deviceId: 'device-2' } };
161
+ const result1 = await RPCRouter.route(createRequest('terminal.create', {}, 1), socket1);
162
+ const result2 = await RPCRouter.route(createRequest('terminal.create', {}, 2), socket2);
163
+ // Both should succeed (indicating separate services were created)
164
+ expect(result1).toBeTruthy();
165
+ expect(result2).toBeTruthy();
166
+ expect(mockTerminalHandle).toHaveBeenCalledTimes(2);
167
+ });
168
+ it('should reuse terminal service for same device', async () => {
169
+ const socket1 = { ...mockSocket, id: 'socket-1', data: { uid: 'user-same', deviceId: 'device-same' } };
170
+ const socket2 = { ...mockSocket, id: 'socket-2', data: { uid: 'user-same', deviceId: 'device-same' } };
171
+ const result1 = await RPCRouter.route(createRequest('terminal.create', {}, 1), socket1);
172
+ const result2 = await RPCRouter.route(createRequest('terminal.create', {}, 2), socket2);
173
+ // Both should succeed using the same service instance
174
+ expect(result1).toBeTruthy();
175
+ expect(result2).toBeTruthy();
176
+ expect(mockTerminalHandle).toHaveBeenCalledTimes(2);
177
+ });
178
+ });
179
+ describe('Error Handling', () => {
180
+ it('should re-throw RPC errors from services', async () => {
181
+ const rpcError = createRPCError(ErrorCode.FILE_NOT_FOUND, 'File not found');
182
+ mockFsHandle.mockRejectedValueOnce(rpcError);
183
+ await expect(RPCRouter.route(createRequest('fs.readFile', { path: '/missing.txt' }, 20), mockSocket)).rejects.toMatchObject({
184
+ code: ErrorCode.FILE_NOT_FOUND,
185
+ message: 'File not found',
186
+ });
187
+ });
188
+ it('should wrap non-RPC errors in internal error', async () => {
189
+ const genericError = new Error('Something went wrong');
190
+ mockFsHandle.mockRejectedValueOnce(genericError);
191
+ await expect(RPCRouter.route(createRequest('fs.readFile', { path: '/test.txt' }, 21), mockSocket)).rejects.toMatchObject({
192
+ code: ErrorCode.INTERNAL_ERROR,
193
+ message: expect.stringContaining('Internal error'),
194
+ });
195
+ });
196
+ it('should include method name in wrapped errors', async () => {
197
+ const genericError = new Error('Unexpected error');
198
+ mockGitHandle.mockRejectedValueOnce(genericError);
199
+ await expect(RPCRouter.route(createRequest('git.commit', { dir: '/project', message: 'test' }, 22), mockSocket)).rejects.toMatchObject({
200
+ code: ErrorCode.INTERNAL_ERROR,
201
+ data: {
202
+ method: 'git.commit',
203
+ originalError: expect.any(String),
204
+ },
205
+ });
206
+ });
207
+ });
208
+ describe('Terminal Service Cleanup', () => {
209
+ it('should cleanup terminal service on disconnect', async () => {
210
+ // Setup a mock cleanup function that will be tracked
211
+ const mockCleanup = vi.fn();
212
+ // Create a new mock instance with cleanup
213
+ const mockServiceWithCleanup = {
214
+ handle: mockTerminalHandle,
215
+ cleanup: mockCleanup,
216
+ };
217
+ // Override the mock to return our instance
218
+ MockTerminalService.mockImplementationOnce(() => mockServiceWithCleanup);
219
+ // Create a terminal service - this will instantiate the mock
220
+ await RPCRouter.route(createRequest('terminal.create', {}, 1), mockSocket);
221
+ // Now cleanup - this should call the cleanup method
222
+ RPCRouter.cleanupTerminalService(mockSocket);
223
+ expect(mockCleanup).toHaveBeenCalledTimes(1);
224
+ });
225
+ it('should handle cleanup when no terminal service exists', () => {
226
+ const otherSocket = { ...mockSocket, data: { uid: 'different-user', deviceId: 'different-device' } };
227
+ expect(() => {
228
+ RPCRouter.cleanupTerminalService(otherSocket);
229
+ }).not.toThrow();
230
+ });
231
+ });
232
+ describe('Edge Cases', () => {
233
+ it('should handle method with multiple dots', async () => {
234
+ // Method "fs.some.deep.method" splits into service="fs", methodName="some.deep.method"
235
+ // Only the first dot is used to separate service from method; sub-namespaces are preserved
236
+ const result = await RPCRouter.route(createRequest('fs.some.deep.method', {}, 30), mockSocket);
237
+ // Should route to fs service with the full sub-method "some.deep.method"
238
+ expect(mockFsHandle).toHaveBeenCalledWith('some.deep.method', {}, mockSocket);
239
+ expect(result).toEqual({ success: true });
240
+ });
241
+ it('should handle empty method name', async () => {
242
+ await expect(RPCRouter.route(createRequest('', {}, 31), mockSocket)).rejects.toMatchObject({
243
+ code: ErrorCode.INVALID_REQUEST,
244
+ });
245
+ });
246
+ it('should handle method with only service name', async () => {
247
+ await expect(RPCRouter.route(createRequest('fs.', {}, 32), mockSocket)).rejects.toMatchObject({
248
+ code: ErrorCode.INVALID_REQUEST,
249
+ });
250
+ });
251
+ it('should handle undefined params', async () => {
252
+ const result = await RPCRouter.route(createRequest('fs.stat', undefined, 33), mockSocket);
253
+ expect(mockFsHandle).toHaveBeenCalledWith('stat', undefined, mockSocket);
254
+ expect(result).toEqual({ success: true });
255
+ });
256
+ it('should handle requests with no id', async () => {
257
+ const result = await RPCRouter.route(createRequest('fs.stat', { path: '/test.txt' }), mockSocket);
258
+ expect(result).toEqual({ success: true });
259
+ });
260
+ });
261
+ });
262
+ //# sourceMappingURL=router.test.js.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * JSON-RPC 2.0 request router
3
+ */
4
+ import { AuthenticatedSocket, JSONRPCRequest, ToolDetectionResult } from '../types.js';
5
+ export declare class RPCRouter {
6
+ private static filesystemService;
7
+ private static gitService;
8
+ private static searchService;
9
+ private static terminalServices;
10
+ private static currentSockets;
11
+ private static browserProxyService;
12
+ private static rootPath;
13
+ private static tools;
14
+ private static terminalEnabled;
15
+ private static browserProxyEnabled;
16
+ /**
17
+ * Initialize services
18
+ */
19
+ static initialize(rootPath: string, config: any, tools: ToolDetectionResult): void;
20
+ /**
21
+ * Parse file size string to bytes
22
+ */
23
+ private static parseFileSize;
24
+ /**
25
+ * Get or create terminal service for socket
26
+ */
27
+ private static getTerminalService;
28
+ /**
29
+ * Route JSON-RPC request to appropriate service
30
+ */
31
+ static route(message: JSONRPCRequest, socket: AuthenticatedSocket): Promise<any>;
32
+ /**
33
+ * Cleanup terminal service for socket
34
+ */
35
+ static cleanupTerminalService(socket: AuthenticatedSocket): void;
36
+ }
37
+ //# sourceMappingURL=router.d.ts.map