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.
- package/.oxlintrc.json +49 -0
- package/LICENSE +21 -0
- package/README.md +631 -0
- package/bin/cli.js +20 -0
- package/bin/validate-cwd.js +41 -0
- package/dist/config/__tests__/config.test.d.ts +2 -0
- package/dist/config/__tests__/config.test.js +262 -0
- package/dist/config/__tests__/credentials.test.d.ts +2 -0
- package/dist/config/__tests__/credentials.test.js +360 -0
- package/dist/config/config.d.ts +33 -0
- package/dist/config/config.js +185 -0
- package/dist/config/credentials.d.ts +75 -0
- package/dist/config/credentials.js +259 -0
- package/dist/config/server-selection.d.ts +40 -0
- package/dist/config/server-selection.js +130 -0
- package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
- package/dist/connection/__tests__/firebase-auth.test.js +96 -0
- package/dist/connection/__tests__/hmac.test.d.ts +2 -0
- package/dist/connection/__tests__/hmac.test.js +372 -0
- package/dist/connection/auth.d.ts +13 -0
- package/dist/connection/auth.js +91 -0
- package/dist/connection/firebase-auth.d.ts +40 -0
- package/dist/connection/firebase-auth.js +429 -0
- package/dist/connection/hmac.d.ts +24 -0
- package/dist/connection/hmac.js +109 -0
- package/dist/i18n/index.d.ts +25 -0
- package/dist/i18n/index.js +101 -0
- package/dist/i18n/locales/en.json +313 -0
- package/dist/i18n/locales/es.json +302 -0
- package/dist/i18n/locales/fr.json +302 -0
- package/dist/i18n/locales/id.json +302 -0
- package/dist/i18n/locales/ja.json +302 -0
- package/dist/i18n/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/en.json +309 -0
- package/dist/i18n/locales/locales/es.json +302 -0
- package/dist/i18n/locales/locales/fr.json +302 -0
- package/dist/i18n/locales/locales/id.json +302 -0
- package/dist/i18n/locales/locales/ja.json +302 -0
- package/dist/i18n/locales/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/pt.json +302 -0
- package/dist/i18n/locales/locales/zh-Hans.json +302 -0
- package/dist/i18n/locales/pt.json +302 -0
- package/dist/i18n/locales/zh-Hans.json +302 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +493 -0
- package/dist/proxy/ProxyClient.d.ts +125 -0
- package/dist/proxy/ProxyClient.js +781 -0
- package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
- package/dist/proxy/ProxySocketWrapper.js +98 -0
- package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
- package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
- package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
- package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
- package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
- package/dist/proxy/chunking.d.ts +53 -0
- package/dist/proxy/chunking.js +127 -0
- package/dist/proxy/handshake-validation.d.ts +21 -0
- package/dist/proxy/handshake-validation.js +49 -0
- package/dist/rpc/__tests__/router.test.d.ts +2 -0
- package/dist/rpc/__tests__/router.test.js +262 -0
- package/dist/rpc/router.d.ts +37 -0
- package/dist/rpc/router.js +132 -0
- package/dist/services/BrowserProxyService.d.ts +13 -0
- package/dist/services/BrowserProxyService.js +139 -0
- package/dist/services/FilesystemService.d.ts +99 -0
- package/dist/services/FilesystemService.js +742 -0
- package/dist/services/GitService.d.ts +243 -0
- package/dist/services/GitService.js +1439 -0
- package/dist/services/SearchService.d.ts +93 -0
- package/dist/services/SearchService.js +670 -0
- package/dist/services/TerminalService.d.ts +62 -0
- package/dist/services/TerminalService.js +337 -0
- package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
- package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
- package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
- package/dist/services/__tests__/FilesystemService.test.js +609 -0
- package/dist/services/__tests__/GitService.test.d.ts +2 -0
- package/dist/services/__tests__/GitService.test.js +953 -0
- package/dist/services/__tests__/SearchService.test.d.ts +2 -0
- package/dist/services/__tests__/SearchService.test.js +384 -0
- package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
- package/dist/services/__tests__/TerminalService.test.js +513 -0
- package/dist/setup/wizard.d.ts +10 -0
- package/dist/setup/wizard.js +172 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +44 -0
- package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
- package/dist/utils/__tests__/gitignore.test.js +127 -0
- package/dist/utils/gitignore.d.ts +24 -0
- package/dist/utils/gitignore.js +77 -0
- package/dist/utils/logger.d.ts +96 -0
- package/dist/utils/logger.js +456 -0
- package/dist/utils/project-dir.d.ts +51 -0
- package/dist/utils/project-dir.js +191 -0
- package/dist/utils/ripgrep.d.ts +34 -0
- package/dist/utils/ripgrep.js +148 -0
- package/dist/utils/tool-detection.d.ts +17 -0
- package/dist/utils/tool-detection.js +126 -0
- package/dist/watcher/FileWatcher.d.ts +10 -0
- package/dist/watcher/FileWatcher.js +42 -0
- package/package.json +70 -0
- package/src/config/__tests__/config.test.ts +318 -0
- package/src/config/__tests__/credentials.test.ts +494 -0
- package/src/config/config.ts +206 -0
- package/src/config/credentials.ts +302 -0
- package/src/config/server-selection.ts +150 -0
- package/src/connection/__tests__/firebase-auth.test.ts +121 -0
- package/src/connection/__tests__/hmac.test.ts +509 -0
- package/src/connection/auth.ts +140 -0
- package/src/connection/firebase-auth.ts +504 -0
- package/src/connection/hmac.ts +139 -0
- package/src/i18n/index.ts +119 -0
- package/src/i18n/locales/en.json +313 -0
- package/src/i18n/locales/es.json +302 -0
- package/src/i18n/locales/fr.json +302 -0
- package/src/i18n/locales/id.json +302 -0
- package/src/i18n/locales/ja.json +302 -0
- package/src/i18n/locales/ko.json +302 -0
- package/src/i18n/locales/pt.json +302 -0
- package/src/i18n/locales/zh-Hans.json +302 -0
- package/src/index.ts +542 -0
- package/src/proxy/ProxyClient.ts +968 -0
- package/src/proxy/ProxySocketWrapper.ts +113 -0
- package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
- package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
- package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
- package/src/proxy/chunking.ts +162 -0
- package/src/proxy/handshake-validation.ts +64 -0
- package/src/rpc/__tests__/router.test.ts +400 -0
- package/src/rpc/router.ts +183 -0
- package/src/services/BrowserProxyService.ts +179 -0
- package/src/services/FilesystemService.ts +841 -0
- package/src/services/GitService.ts +1639 -0
- package/src/services/SearchService.ts +809 -0
- package/src/services/TerminalService.ts +413 -0
- package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
- package/src/services/__tests__/FilesystemService.test.ts +1002 -0
- package/src/services/__tests__/GitService.test.ts +1552 -0
- package/src/services/__tests__/SearchService.test.ts +484 -0
- package/src/services/__tests__/TerminalService.test.ts +702 -0
- package/src/setup/wizard.ts +242 -0
- package/src/types/fossil-delta.d.ts +4 -0
- package/src/types.ts +287 -0
- package/src/utils/__tests__/gitignore.test.ts +174 -0
- package/src/utils/gitignore.ts +91 -0
- package/src/utils/logger.ts +578 -0
- package/src/utils/project-dir.ts +218 -0
- package/src/utils/ripgrep.ts +180 -0
- package/src/utils/tool-detection.ts +141 -0
- package/src/watcher/FileWatcher.ts +53 -0
- package/tsconfig.json +24 -0
- 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,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
|