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,183 @@
1
+ /**
2
+ * JSON-RPC 2.0 request router
3
+ */
4
+
5
+ import { AuthenticatedSocket, JSONRPCRequest, ErrorCode, createRPCError, ToolDetectionResult } from '../types.js';
6
+ import { FilesystemService } from '../services/FilesystemService.js';
7
+ import { GitService } from '../services/GitService.js';
8
+ import { TerminalService } from '../services/TerminalService.js';
9
+ import { SearchService } from '../services/SearchService.js';
10
+ import { BrowserProxyService } from '../services/BrowserProxyService.js';
11
+
12
+ export class RPCRouter {
13
+ private static filesystemService: FilesystemService;
14
+ private static gitService: GitService;
15
+ private static searchService: SearchService;
16
+ private static terminalServices: Map<string, TerminalService> = new Map();
17
+ private static currentSockets: Map<string, AuthenticatedSocket> = new Map();
18
+ private static browserProxyService: BrowserProxyService;
19
+ private static rootPath: string;
20
+ private static tools: ToolDetectionResult;
21
+ private static terminalEnabled: boolean;
22
+ private static browserProxyEnabled: boolean;
23
+
24
+ /**
25
+ * Initialize services
26
+ */
27
+ static initialize(rootPath: string, config: any, tools: ToolDetectionResult) {
28
+ this.rootPath = rootPath;
29
+ this.tools = tools;
30
+ this.terminalEnabled = config.terminal?.enabled ?? true;
31
+ this.browserProxyEnabled = config.browserProxy?.enabled ?? true;
32
+ this.filesystemService = new FilesystemService(rootPath, config.filesystem);
33
+ this.gitService = new GitService(rootPath);
34
+ this.browserProxyService = new BrowserProxyService();
35
+
36
+ // Parse maxFileSize from config
37
+ const maxFileSizeBytes = this.parseFileSize(config.filesystem.maxFileSize);
38
+ this.searchService = new SearchService(rootPath, maxFileSizeBytes, 64 * 1024, tools.ripgrep);
39
+ }
40
+
41
+ /**
42
+ * Parse file size string to bytes
43
+ */
44
+ private static parseFileSize(sizeStr: string): number {
45
+ const match = sizeStr.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i);
46
+ if (!match) {
47
+ return 10 * 1024 * 1024; // Default 10MB
48
+ }
49
+
50
+ const value = parseFloat(match[1]);
51
+ const unit = (match[2] || 'B').toUpperCase();
52
+
53
+ const multipliers: { [key: string]: number } = {
54
+ B: 1,
55
+ KB: 1024,
56
+ MB: 1024 * 1024,
57
+ GB: 1024 * 1024 * 1024,
58
+ };
59
+
60
+ return value * multipliers[unit];
61
+ }
62
+
63
+ /**
64
+ * Get or create terminal service for socket
65
+ */
66
+ private static getTerminalService(socket: AuthenticatedSocket): TerminalService {
67
+ const deviceId = socket.data.deviceId;
68
+
69
+ // Update current socket for this deviceId (handles reconnections)
70
+ this.currentSockets.set(deviceId, socket);
71
+
72
+ if (!this.terminalServices.has(deviceId)) {
73
+ // Create new service with getter function that returns current socket
74
+ const getSocket = () => {
75
+ const currentSocket = this.currentSockets.get(deviceId);
76
+ if (!currentSocket) {
77
+ throw new Error(`No active socket for device: ${deviceId}`);
78
+ }
79
+ return currentSocket;
80
+ };
81
+ this.terminalServices.set(deviceId, new TerminalService(getSocket, 10, 10000, this.rootPath));
82
+ }
83
+
84
+ return this.terminalServices.get(deviceId)!;
85
+ }
86
+
87
+ /**
88
+ * Route JSON-RPC request to appropriate service
89
+ */
90
+ static async route(
91
+ message: JSONRPCRequest,
92
+ socket: AuthenticatedSocket
93
+ ): Promise<any> {
94
+ const { method, params } = message;
95
+
96
+ // Parse method prefix (split on first dot only so sub-namespaces like browser.proxy.request are preserved)
97
+ const dotIndex = method.indexOf('.');
98
+ const service = dotIndex !== -1 ? method.slice(0, dotIndex) : method;
99
+ const methodName = dotIndex !== -1 ? method.slice(dotIndex + 1) : '';
100
+
101
+ if (!service || !methodName) {
102
+ throw createRPCError(
103
+ ErrorCode.INVALID_REQUEST,
104
+ `Invalid method format: ${method}`
105
+ );
106
+ }
107
+
108
+ try {
109
+ switch (service) {
110
+ case 'fs':
111
+ return await this.filesystemService.handle(methodName, params, socket);
112
+
113
+ case 'git':
114
+ if (!this.tools.git) {
115
+ throw createRPCError(
116
+ ErrorCode.FEATURE_DISABLED,
117
+ 'Git is not available. Install Git 2.20.0+ to use version control features.'
118
+ );
119
+ }
120
+ return await this.gitService.handle(methodName, params, socket);
121
+
122
+ case 'search':
123
+ // Search is always available, but fast search (ripgrep) may be disabled
124
+ return await this.searchService.handle(methodName, params, socket);
125
+
126
+ case 'terminal':
127
+ if (!this.terminalEnabled) {
128
+ throw createRPCError(
129
+ ErrorCode.FEATURE_DISABLED,
130
+ 'Terminal is disabled in configuration.'
131
+ );
132
+ }
133
+ const terminalService = this.getTerminalService(socket);
134
+ return await terminalService.handle(methodName, params);
135
+
136
+ case 'browser': {
137
+ if (!this.browserProxyEnabled) {
138
+ throw createRPCError(
139
+ ErrorCode.FEATURE_DISABLED,
140
+ 'Browser proxy is disabled in configuration.'
141
+ );
142
+ }
143
+ // methodName is 'proxy.request' — strip the 'proxy.' sub-namespace
144
+ const dotIdx = methodName.indexOf('.');
145
+ const browserMethod = dotIdx !== -1 ? methodName.slice(dotIdx + 1) : methodName;
146
+ return await this.browserProxyService.handle(browserMethod, params, socket);
147
+ }
148
+
149
+ default:
150
+ throw createRPCError(
151
+ ErrorCode.METHOD_NOT_FOUND,
152
+ `Unknown service: ${service}`
153
+ );
154
+ }
155
+ } catch (error: any) {
156
+ // Re-throw if already an RPC error
157
+ if (error.code && error.message) {
158
+ throw error;
159
+ }
160
+
161
+ // Wrap other errors
162
+ console.error(`Error in ${method}:`, error);
163
+ throw createRPCError(
164
+ ErrorCode.INTERNAL_ERROR,
165
+ `Internal error: ${error.message || 'Unknown error'}`,
166
+ { method, originalError: error.toString() }
167
+ );
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Cleanup terminal service for socket
173
+ */
174
+ static cleanupTerminalService(socket: AuthenticatedSocket) {
175
+ const deviceId = socket.data.deviceId;
176
+ const service = this.terminalServices.get(deviceId);
177
+ if (service) {
178
+ service.cleanup();
179
+ this.terminalServices.delete(deviceId);
180
+ this.currentSockets.delete(deviceId);
181
+ }
182
+ }
183
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * BrowserProxyService - Handles browser.proxy.request RPC calls
3
+ *
4
+ * Fetches from localhost:PORT/path and returns the HTTP response
5
+ * back to the editor client, which forwards it to the browser.spck.io SW.
6
+ */
7
+
8
+ import http from 'http';
9
+ import https from 'https';
10
+ import { AuthenticatedSocket, ErrorCode, createRPCError } from '../types.js';
11
+ import { logBrowserProxy } from '../utils/logger.js';
12
+
13
+ const REQUEST_TIMEOUT_MS = 30000;
14
+ const ALLOWED_HOSTS = new Set(['localhost', '127.0.0.1']);
15
+ const ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
16
+ const MAX_RESPONSE_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
17
+
18
+ interface ProxyRequestParams {
19
+ requestId: string;
20
+ url: string;
21
+ method: string;
22
+ headers: Record<string, string>;
23
+ body: number[] | null;
24
+ }
25
+
26
+ interface ProxyResponse {
27
+ requestId: string;
28
+ status: number;
29
+ statusText: string;
30
+ headers: Record<string, string>;
31
+ body: string; // base64-encoded response body
32
+ bodyEncoding: 'base64';
33
+ }
34
+
35
+ export class BrowserProxyService {
36
+ async handle(method: string, params: any, socket: AuthenticatedSocket): Promise<any> {
37
+ switch (method) {
38
+ case 'request':
39
+ return await this.proxyRequest(params, socket.data.deviceId);
40
+ default:
41
+ throw createRPCError(ErrorCode.METHOD_NOT_FOUND, `Method not found: browser.proxy.${method}`);
42
+ }
43
+ }
44
+
45
+ private async proxyRequest(params: ProxyRequestParams, uid: string): Promise<ProxyResponse> {
46
+ const { requestId, url, method, headers, body } = params;
47
+
48
+ // Validate method
49
+ const upperMethod = (method || 'GET').toUpperCase();
50
+ if (!ALLOWED_METHODS.has(upperMethod)) {
51
+ throw createRPCError(ErrorCode.INVALID_PARAMS, `Disallowed HTTP method: ${method}`);
52
+ }
53
+
54
+ // Validate URL — must be localhost only
55
+ let parsed: URL;
56
+ try {
57
+ parsed = new URL(url);
58
+ } catch {
59
+ throw createRPCError(ErrorCode.INVALID_PARAMS, `Invalid URL: ${url}`);
60
+ }
61
+
62
+ if (!ALLOWED_HOSTS.has(parsed.hostname)) {
63
+ throw createRPCError(ErrorCode.PERMISSION_DENIED, `Only localhost requests are allowed`);
64
+ }
65
+
66
+ const port = parseInt(parsed.port || '80', 10);
67
+ if (isNaN(port) || port < 1 || port > 65535) {
68
+ throw createRPCError(ErrorCode.INVALID_PARAMS, `Invalid port: ${parsed.port}`);
69
+ }
70
+
71
+ // Convert body array back to Buffer
72
+ const bodyBuffer = body && body.length > 0 ? Buffer.from(body) : undefined;
73
+
74
+ // Strip hop-by-hop headers that should not be forwarded
75
+ const forwardHeaders: Record<string, string> = {};
76
+ const hopByHop = new Set([
77
+ 'connection', 'keep-alive', 'transfer-encoding', 'te',
78
+ 'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate'
79
+ ]);
80
+ for (const [key, value] of Object.entries(headers || {})) {
81
+ if (!hopByHop.has(key.toLowerCase())) {
82
+ forwardHeaders[key] = value;
83
+ }
84
+ }
85
+ if (bodyBuffer) {
86
+ forwardHeaders['content-length'] = String(bodyBuffer.length);
87
+ }
88
+
89
+ try {
90
+ const response = await this.fetch(parsed, upperMethod, forwardHeaders, bodyBuffer);
91
+ logBrowserProxy(params, uid, true, undefined, {
92
+ status: response.status,
93
+ size: Buffer.byteLength(response.body, 'base64') * 3 / 4 | 0,
94
+ });
95
+ return { requestId, ...response };
96
+ } catch (error: any) {
97
+ logBrowserProxy(params, uid, false, error);
98
+ throw error;
99
+ }
100
+ }
101
+
102
+ private fetch(
103
+ url: URL,
104
+ method: string,
105
+ headers: Record<string, string>,
106
+ body?: Buffer
107
+ ): Promise<Omit<ProxyResponse, 'requestId'>> {
108
+ return new Promise((resolve, reject) => {
109
+ const isHttps = url.protocol === 'https:';
110
+ const lib = isHttps ? https : http;
111
+
112
+ const options: http.RequestOptions = {
113
+ hostname: url.hostname,
114
+ port: url.port || (isHttps ? 443 : 80),
115
+ path: url.pathname + url.search,
116
+ method,
117
+ headers,
118
+ timeout: REQUEST_TIMEOUT_MS,
119
+ };
120
+
121
+ const req = lib.request(options, (res) => {
122
+ const chunks: Buffer[] = [];
123
+ let totalLength = 0;
124
+
125
+ res.on('data', (chunk: Buffer) => {
126
+ totalLength += chunk.length;
127
+ if (totalLength > MAX_RESPONSE_BODY_BYTES) {
128
+ req.destroy();
129
+ reject(createRPCError(ErrorCode.INTERNAL_ERROR, 'Response body too large'));
130
+ return;
131
+ }
132
+ chunks.push(chunk);
133
+ });
134
+
135
+ res.on('end', () => {
136
+ const bodyBuffer = Buffer.concat(chunks);
137
+
138
+ // Collect response headers (flatten multi-value headers)
139
+ const responseHeaders: Record<string, string> = {};
140
+ for (const [key, value] of Object.entries(res.headers)) {
141
+ if (value !== undefined) {
142
+ responseHeaders[key] = Array.isArray(value) ? value.join(', ') : value;
143
+ }
144
+ }
145
+
146
+ resolve({
147
+ status: res.statusCode || 200,
148
+ statusText: res.statusMessage || '',
149
+ headers: responseHeaders,
150
+ body: bodyBuffer.toString('base64'),
151
+ bodyEncoding: 'base64',
152
+ });
153
+ });
154
+
155
+ res.on('error', (err) => {
156
+ reject(createRPCError(ErrorCode.INTERNAL_ERROR, `Response error: ${err.message}`));
157
+ });
158
+ });
159
+
160
+ req.on('timeout', () => {
161
+ req.destroy();
162
+ reject(createRPCError(ErrorCode.OPERATION_TIMEOUT, 'Request to localhost timed out'));
163
+ });
164
+
165
+ req.on('error', (err: NodeJS.ErrnoException) => {
166
+ if (err.code === 'ECONNREFUSED') {
167
+ reject(createRPCError(ErrorCode.INTERNAL_ERROR, `Connection refused on port ${(err as any).port || ''} — is the dev server running?`));
168
+ } else {
169
+ reject(createRPCError(ErrorCode.INTERNAL_ERROR, `Request failed: ${err.message}`));
170
+ }
171
+ });
172
+
173
+ if (body) {
174
+ req.write(body);
175
+ }
176
+ req.end();
177
+ });
178
+ }
179
+ }