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,413 @@
1
+ /**
2
+ * Terminal service - manages PTY sessions with xterm-headless
3
+ */
4
+
5
+ import * as pty from 'node-pty';
6
+ import XtermHeadlessModule from '@xterm/headless';
7
+ import SerializeAddonModule from '@xterm/addon-serialize';
8
+ import defaultShell from 'default-shell';
9
+ import { AuthenticatedSocket, ErrorCode, createRPCError } from '../types.js';
10
+ import { logTerminalRead, logTerminalWrite } from '../utils/logger.js';
11
+
12
+ const { Terminal: XtermHeadless } = XtermHeadlessModule as any;
13
+ const { SerializeAddon } = SerializeAddonModule as any;
14
+
15
+ // Hard limit on terminal buffer size to prevent memory exhaustion (DoS)
16
+ const MAX_BUFFER_LINES_HARD_LIMIT = 50000;
17
+
18
+ interface TerminalSession {
19
+ id: string;
20
+ pty: pty.IPty;
21
+ xterm: any;
22
+ serializeAddon: any;
23
+ cols: number;
24
+ rows: number;
25
+ exited: boolean;
26
+ exitCode?: number;
27
+ uid: string;
28
+ pendingWrites: number; // Track number of pending write operations
29
+ }
30
+
31
+ export class TerminalService {
32
+ private sessions: Map<string, TerminalSession> = new Map();
33
+ private activeTerminalId: string | null = null;
34
+
35
+ constructor(
36
+ private getSocket: () => AuthenticatedSocket,
37
+ private maxTerminals: number = 10,
38
+ private maxBufferedLines: number = 10000,
39
+ private rootPath: string = process.cwd()
40
+ ) {
41
+ // Enforce hard limit on buffer size to prevent memory exhaustion
42
+ if (this.maxBufferedLines > MAX_BUFFER_LINES_HARD_LIMIT) {
43
+ console.warn(
44
+ `Terminal buffer size ${this.maxBufferedLines} exceeds hard limit ${MAX_BUFFER_LINES_HARD_LIMIT}. ` +
45
+ `Capping at ${MAX_BUFFER_LINES_HARD_LIMIT} lines.`
46
+ );
47
+ this.maxBufferedLines = MAX_BUFFER_LINES_HARD_LIMIT;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Get UID from socket with fallback
53
+ */
54
+ private getUid(): string {
55
+ return this.getSocket().data?.uid || 'unknown';
56
+ }
57
+
58
+ /**
59
+ * Handle terminal RPC methods
60
+ */
61
+ async handle(method: string, params: any): Promise<any> {
62
+ const uid = this.getUid();
63
+ let result: any;
64
+ let error: any;
65
+
66
+ // Define read and write operations
67
+ const readOps = ['list', 'refresh'];
68
+ const writeOps = ['create', 'destroy', 'activate', 'send', 'resize'];
69
+
70
+ try {
71
+ switch (method) {
72
+ case 'create':
73
+ result = await this.create(params);
74
+ logTerminalWrite(method, params, uid, true, undefined, { terminalId: result });
75
+ return result;
76
+ case 'destroy':
77
+ result = await this.destroy(params);
78
+ logTerminalWrite(method, params, uid, true);
79
+ return result;
80
+ case 'activate':
81
+ result = await this.activate(params);
82
+ logTerminalWrite(method, params, uid, true);
83
+ return result;
84
+ case 'send':
85
+ result = await this.send(params);
86
+ logTerminalWrite(method, params, uid, true);
87
+ return result;
88
+ case 'resize':
89
+ result = await this.resize(params);
90
+ logTerminalWrite(method, params, uid, true);
91
+ return result;
92
+ case 'refresh':
93
+ result = await this.refresh(params);
94
+ logTerminalRead(method, params, uid, true);
95
+ return result;
96
+ case 'list':
97
+ result = await this.list();
98
+ logTerminalRead(method, params, uid, true, undefined, { count: result.length });
99
+ return result;
100
+ default:
101
+ throw createRPCError(ErrorCode.METHOD_NOT_FOUND, `Method not found: terminal.${method}`);
102
+ }
103
+ } catch (err: any) {
104
+ error = err;
105
+ // Log error based on operation type
106
+ if (readOps.includes(method)) {
107
+ logTerminalRead(method, params, uid, false, error);
108
+ } else if (writeOps.includes(method)) {
109
+ logTerminalWrite(method, params, uid, false, error);
110
+ }
111
+ throw error;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Create new terminal session
117
+ */
118
+ private async create(params: { cols: number; rows: number }): Promise<string> {
119
+ const uid = this.getUid();
120
+
121
+ // Check terminal limit
122
+ const userTerminals = Array.from(this.sessions.values()).filter((s) => s.uid === uid);
123
+
124
+ if (userTerminals.length >= this.maxTerminals) {
125
+ throw createRPCError(
126
+ ErrorCode.TERMINAL_LIMIT_EXCEEDED,
127
+ `Terminal limit exceeded (max ${this.maxTerminals})`
128
+ );
129
+ }
130
+
131
+ const terminalId = this.generateId();
132
+
133
+ // Get default shell for the platform
134
+ const shell = defaultShell;
135
+
136
+ // Spawn PTY process
137
+ let ptyProcess: pty.IPty;
138
+ try {
139
+ ptyProcess = pty.spawn(shell, [], {
140
+ name: 'xterm-256color',
141
+ cols: params.cols || 80,
142
+ rows: params.rows || 24,
143
+ cwd: this.rootPath,
144
+ env: process.env,
145
+ });
146
+ } catch (error: any) {
147
+ // Log full error details server-side for debugging
148
+ console.error('Failed to spawn terminal:', {
149
+ error: error.message,
150
+ shell,
151
+ cwd: this.rootPath,
152
+ stack: error.stack,
153
+ });
154
+
155
+ // Send sanitized error to client (no system paths or stack traces)
156
+ throw createRPCError(
157
+ ErrorCode.INTERNAL_ERROR,
158
+ 'Failed to spawn terminal. Please check that the shell is available and the working directory is accessible.'
159
+ );
160
+ }
161
+
162
+ // Create headless xterm for buffer management
163
+ const xterm = new XtermHeadless({
164
+ cols: params.cols || 80,
165
+ rows: params.rows || 24,
166
+ // Enforce hard limit as defense in depth (should already be capped in constructor)
167
+ scrollback: Math.min(this.maxBufferedLines, MAX_BUFFER_LINES_HARD_LIMIT),
168
+ allowProposedApi: true
169
+ });
170
+
171
+ const serializeAddon = new SerializeAddon();
172
+ xterm.loadAddon(serializeAddon);
173
+
174
+ // Connect PTY to xterm buffer
175
+ ptyProcess.onData((data) => {
176
+ const session = this.sessions.get(terminalId);
177
+ if (session) {
178
+ // Track pending write
179
+ session.pendingWrites++;
180
+
181
+ // Write to xterm with callback
182
+ xterm.write(data, () => {
183
+ // Decrement pending writes when complete
184
+ if (session.pendingWrites > 0) {
185
+ session.pendingWrites--;
186
+ }
187
+ });
188
+ }
189
+
190
+ // If this is the active terminal, stream to client
191
+ if (this.activeTerminalId === terminalId) {
192
+ this.getSocket().emit('rpc', {
193
+ jsonrpc: '2.0',
194
+ method: 'terminal.output',
195
+ params: { terminalId, data },
196
+ });
197
+ }
198
+ });
199
+
200
+ // Handle process exit
201
+ ptyProcess.onExit(({ exitCode }) => {
202
+ const session = this.sessions.get(terminalId);
203
+ if (session) {
204
+ session.exited = true;
205
+ session.exitCode = exitCode;
206
+
207
+ // Notify client
208
+ this.getSocket().emit('rpc', {
209
+ jsonrpc: '2.0',
210
+ method: 'terminal.exited',
211
+ params: { terminalId, exitCode },
212
+ });
213
+ }
214
+ });
215
+
216
+ // Store session
217
+ this.sessions.set(terminalId, {
218
+ id: terminalId,
219
+ pty: ptyProcess,
220
+ xterm,
221
+ serializeAddon,
222
+ cols: params.cols || 80,
223
+ rows: params.rows || 24,
224
+ exited: false,
225
+ uid,
226
+ pendingWrites: 0,
227
+ });
228
+
229
+ // Send initial buffer
230
+ await this.sendBufferRefresh(terminalId);
231
+
232
+ return terminalId;
233
+ }
234
+
235
+ /**
236
+ * Destroy terminal session
237
+ */
238
+ private async destroy(params: { terminalId: string }): Promise<void> {
239
+ const session = this.sessions.get(params.terminalId);
240
+ if (!session) {
241
+ throw createRPCError(ErrorCode.TERMINAL_NOT_FOUND, 'Terminal not found');
242
+ }
243
+
244
+ // Verify ownership
245
+ if (session.uid !== this.getUid()) {
246
+ throw createRPCError(ErrorCode.PERMISSION_DENIED, 'Permission denied');
247
+ }
248
+
249
+ // Kill PTY process if still running
250
+ if (!session.exited) {
251
+ session.pty.kill();
252
+ }
253
+
254
+ // Clean up
255
+ this.sessions.delete(params.terminalId);
256
+
257
+ if (this.activeTerminalId === params.terminalId) {
258
+ this.activeTerminalId = null;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Activate terminal (start streaming)
264
+ */
265
+ private async activate(params: { terminalId: string }): Promise<void> {
266
+ const session = this.sessions.get(params.terminalId);
267
+ if (!session) {
268
+ throw createRPCError(ErrorCode.TERMINAL_NOT_FOUND, 'Terminal not found');
269
+ }
270
+
271
+ // Verify ownership
272
+ if (session.uid !== this.getUid()) {
273
+ throw createRPCError(ErrorCode.PERMISSION_DENIED, 'Permission denied');
274
+ }
275
+
276
+ // Update active terminal
277
+ this.activeTerminalId = params.terminalId;
278
+
279
+ // Send full buffer refresh
280
+ await this.sendBufferRefresh(params.terminalId);
281
+ }
282
+
283
+ /**
284
+ * Send input to terminal
285
+ */
286
+ private async send(params: { terminalId: string; data: string }): Promise<void> {
287
+ const session = this.sessions.get(params.terminalId);
288
+ if (!session) {
289
+ throw createRPCError(ErrorCode.TERMINAL_NOT_FOUND, 'Terminal not found');
290
+ }
291
+
292
+ // Verify ownership
293
+ if (session.uid !== this.getUid()) {
294
+ throw createRPCError(ErrorCode.PERMISSION_DENIED, 'Permission denied');
295
+ }
296
+
297
+ if (session.exited) {
298
+ throw createRPCError(
299
+ ErrorCode.TERMINAL_PROCESS_EXITED,
300
+ 'Terminal process has exited'
301
+ );
302
+ }
303
+
304
+ // Write to PTY
305
+ session.pty.write(params.data);
306
+ }
307
+
308
+ /**
309
+ * Resize terminal
310
+ */
311
+ private async resize(params: { terminalId: string; cols: number; rows: number }): Promise<void> {
312
+ const session = this.sessions.get(params.terminalId);
313
+ if (!session) {
314
+ throw createRPCError(ErrorCode.TERMINAL_NOT_FOUND, 'Terminal not found');
315
+ }
316
+
317
+ // Verify ownership
318
+ if (session.uid !== this.getUid()) {
319
+ throw createRPCError(ErrorCode.PERMISSION_DENIED, 'Permission denied');
320
+ }
321
+
322
+ // Resize PTY
323
+ session.pty.resize(params.cols, params.rows);
324
+
325
+ // Resize xterm buffer
326
+ session.xterm.resize(params.cols, params.rows);
327
+
328
+ // Update session
329
+ session.cols = params.cols;
330
+ session.rows = params.rows;
331
+ }
332
+
333
+ /**
334
+ * Refresh terminal buffer
335
+ */
336
+ private async refresh(params: { terminalId: string }): Promise<void> {
337
+ const session = this.sessions.get(params.terminalId);
338
+ if (!session) {
339
+ throw createRPCError(ErrorCode.TERMINAL_NOT_FOUND, 'Terminal not found');
340
+ }
341
+
342
+ // Verify ownership
343
+ if (session.uid !== this.getUid()) {
344
+ throw createRPCError(ErrorCode.PERMISSION_DENIED, 'Permission denied');
345
+ }
346
+
347
+ await this.sendBufferRefresh(params.terminalId);
348
+ }
349
+
350
+ /**
351
+ * List terminals for current user
352
+ */
353
+ private async list(): Promise<string[]> {
354
+ const uid = this.getUid();
355
+ return Array.from(this.sessions.values())
356
+ .filter((s) => s.uid === uid)
357
+ .map((s) => s.id);
358
+ }
359
+
360
+ /**
361
+ * Send buffer refresh notification
362
+ */
363
+ private async sendBufferRefresh(terminalId: string): Promise<void> {
364
+ const session = this.sessions.get(terminalId);
365
+ if (!session) return;
366
+
367
+ // Wait for pending writes to complete
368
+ const waitForWrites = async () => {
369
+ const maxWaitTime = 5000; // 5 seconds max
370
+ const checkInterval = 10; // Check every 10ms
371
+ let elapsed = 0;
372
+
373
+ while (session.pendingWrites > 0 && elapsed < maxWaitTime) {
374
+ await new Promise(resolve => setTimeout(resolve, checkInterval));
375
+ elapsed += checkInterval;
376
+ }
377
+
378
+ if (elapsed >= maxWaitTime) {
379
+ console.warn(`Timeout waiting for pending writes (${session.pendingWrites} remaining)`);
380
+ }
381
+ };
382
+
383
+ await waitForWrites();
384
+
385
+ // Serialize xterm buffer
386
+ const buffer = session.serializeAddon.serialize();
387
+ // Send to client
388
+ this.getSocket().emit('rpc', {
389
+ jsonrpc: '2.0',
390
+ method: 'terminal.bufferRefresh',
391
+ params: { terminalId, buffer },
392
+ });
393
+ }
394
+
395
+ /**
396
+ * Generate unique terminal ID
397
+ */
398
+ private generateId(): string {
399
+ return `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
400
+ }
401
+
402
+ /**
403
+ * Cleanup all terminals
404
+ */
405
+ cleanup(): void {
406
+ for (const session of this.sessions.values()) {
407
+ if (!session.exited) {
408
+ session.pty.kill();
409
+ }
410
+ }
411
+ this.sessions.clear();
412
+ }
413
+ }
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ /**
3
+ * Tests for BrowserProxyService edge cases
4
+ */
5
+
6
+ import http from 'http';
7
+ import { BrowserProxyService } from '../BrowserProxyService.js';
8
+ import { ErrorCode } from '../../types.js';
9
+
10
+ function makeService() {
11
+ return new BrowserProxyService();
12
+ }
13
+
14
+ function startServer(handler: http.RequestListener): Promise<{ port: number; close: () => Promise<void> }> {
15
+ return new Promise((resolve, reject) => {
16
+ const server = http.createServer(handler);
17
+ server.listen(0, '127.0.0.1', () => {
18
+ const { port } = server.address() as { port: number };
19
+ resolve({ port, close: () => new Promise<void>((res) => server.close(() => res())) });
20
+ });
21
+ server.on('error', reject);
22
+ });
23
+ }
24
+
25
+ function makeParams(overrides: Record<string, any> = {}) {
26
+ return { requestId: 'req-1', url: 'http://localhost:3000/', method: 'GET', headers: {}, body: null, ...overrides };
27
+ }
28
+
29
+ function makeSocket(deviceId = 'test-device-1') {
30
+ return { data: { deviceId, uid: 'test-user' } } as any;
31
+ }
32
+
33
+ describe('BrowserProxyService', () => {
34
+ let service: BrowserProxyService;
35
+ beforeEach(() => { service = makeService(); });
36
+
37
+ it('rejects unknown RPC methods', async () => {
38
+ await expect(service.handle('unknown', makeParams(), makeSocket())).rejects.toMatchObject({ code: ErrorCode.METHOD_NOT_FOUND });
39
+ });
40
+
41
+ it.each([
42
+ ['non-localhost hostname', 'http://example.com/'],
43
+ ['internal IP', 'http://192.168.1.1:3000/'],
44
+ ['0.0.0.0', 'http://0.0.0.0:3000/'],
45
+ ])('rejects %s', async (_label, url) => {
46
+ await expect(service.handle('request', makeParams({ url }), makeSocket())).rejects.toMatchObject({ code: ErrorCode.PERMISSION_DENIED });
47
+ });
48
+
49
+ it.each([
50
+ ['malformed URL', 'not-a-url', ErrorCode.INVALID_PARAMS],
51
+ ['port 0', 'http://localhost:0/', ErrorCode.INVALID_PARAMS],
52
+ ['port > 65535', 'http://localhost:99999/', ErrorCode.INVALID_PARAMS],
53
+ ['TRACE method', 'http://localhost:3000/', ErrorCode.INVALID_PARAMS],
54
+ ])('rejects %s', async (_label, urlOrMethod, code) => {
55
+ const isMethod = _label.includes('method');
56
+ await expect(
57
+ service.handle('request', makeParams(isMethod ? { method: 'TRACE' } : { url: urlOrMethod }), makeSocket())
58
+ ).rejects.toMatchObject({ code });
59
+ });
60
+
61
+ it('forwards POST body bytes and returns base64-encoded response', async () => {
62
+ let receivedBody = '';
63
+ const srv = await startServer((req, res) => {
64
+ req.on('data', (c) => { receivedBody += c.toString(); });
65
+ req.on('end', () => { res.writeHead(200); res.end('pong'); });
66
+ });
67
+ try {
68
+ const result = await service.handle('request', makeParams({
69
+ url: `http://localhost:${srv.port}/`,
70
+ method: 'POST',
71
+ body: Array.from(Buffer.from('ping')),
72
+ }), makeSocket());
73
+ expect(receivedBody).toBe('ping');
74
+ expect(result.bodyEncoding).toBe('base64');
75
+ expect(Buffer.from(result.body, 'base64').toString()).toBe('pong');
76
+ } finally {
77
+ await srv.close();
78
+ }
79
+ });
80
+
81
+ it('correctly encodes binary response body', async () => {
82
+ const binary = Buffer.from([0x00, 0x01, 0xff, 0xfe]);
83
+ const srv = await startServer((_req, res) => { res.writeHead(200); res.end(binary); });
84
+ try {
85
+ const result = await service.handle('request', makeParams({ url: `http://localhost:${srv.port}/` }), makeSocket());
86
+ expect(Buffer.from(result.body, 'base64')).toEqual(binary);
87
+ } finally {
88
+ await srv.close();
89
+ }
90
+ });
91
+
92
+ it('strips hop-by-hop headers and forwards custom headers', async () => {
93
+ let received: http.IncomingHttpHeaders = {};
94
+ const srv = await startServer((req, res) => { received = req.headers; res.writeHead(200); res.end(); });
95
+ try {
96
+ await service.handle('request', makeParams({
97
+ url: `http://localhost:${srv.port}/`,
98
+ headers: { 'transfer-encoding': 'chunked', 'upgrade': 'websocket', 'x-custom': 'yes' },
99
+ }), makeSocket());
100
+ expect(received['transfer-encoding']).toBeUndefined();
101
+ expect(received['upgrade']).toBeUndefined();
102
+ expect(received['x-custom']).toBe('yes');
103
+ } finally {
104
+ await srv.close();
105
+ }
106
+ });
107
+
108
+ it('rejects with INTERNAL_ERROR on ECONNREFUSED', async () => {
109
+ await expect(
110
+ service.handle('request', makeParams({ url: 'http://localhost:19999/' }), makeSocket())
111
+ ).rejects.toMatchObject({ code: ErrorCode.INTERNAL_ERROR });
112
+ });
113
+
114
+ it('rejects with OPERATION_TIMEOUT when server hangs', async () => {
115
+ const srv = await startServer(() => { /* hang */ });
116
+ try {
117
+ vi.spyOn(service as any, 'fetch').mockImplementation((...args: unknown[]) => {
118
+ const url = args[0] as URL;
119
+ return new Promise((_res, reject) => {
120
+ const req = http.request({ hostname: url.hostname, port: url.port, path: '/', timeout: 50 });
121
+ req.on('timeout', () => { req.destroy(); reject({ code: ErrorCode.OPERATION_TIMEOUT, message: 'timed out' }); });
122
+ req.on('error', reject);
123
+ req.end();
124
+ });
125
+ });
126
+ await expect(service.handle('request', makeParams({ url: `http://localhost:${srv.port}/` }), makeSocket()))
127
+ .rejects.toMatchObject({ code: ErrorCode.OPERATION_TIMEOUT });
128
+ } finally {
129
+ vi.restoreAllMocks();
130
+ await srv.close();
131
+ }
132
+ });
133
+
134
+ it('rejects responses exceeding 10 MB', async () => {
135
+ const srv = await startServer((_req, res) => {
136
+ res.writeHead(200);
137
+ const chunk = Buffer.alloc(1024 * 1024);
138
+ let sent = 0;
139
+ const write = () => {
140
+ while (sent < 11) {
141
+ if (!res.write(chunk)) { res.once('drain', write); return; }
142
+ sent++;
143
+ }
144
+ res.end();
145
+ };
146
+ write();
147
+ });
148
+ try {
149
+ await expect(service.handle('request', makeParams({ url: `http://localhost:${srv.port}/` }), makeSocket()))
150
+ .rejects.toMatchObject({ code: ErrorCode.INTERNAL_ERROR });
151
+ } finally {
152
+ await srv.close();
153
+ }
154
+ });
155
+ });