spck 0.3.1 → 0.4.0

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.
@@ -55,10 +55,23 @@ export function validateHMAC(message: JSONRPCRequest, signingKey: string): boole
55
55
  // Reconstruct the message that was signed (must match client's _computeHMAC)
56
56
  // Client uses: const { timestamp, hmac, ...rest } = request
57
57
  // So we need to include all fields except timestamp and hmac
58
+ // Strip any Buffer values from params before JSON.stringify,
59
+ // since they serialize inconsistently across environments. Both sides must do this.
60
+ let params = message.params;
61
+ if (params && typeof params === 'object') {
62
+ const cleanParams: any = {};
63
+ for (const [k, v] of Object.entries(params)) {
64
+ if (!Buffer.isBuffer(v)) {
65
+ cleanParams[k] = v;
66
+ }
67
+ }
68
+ params = cleanParams;
69
+ }
70
+
58
71
  const payload: any = {
59
72
  jsonrpc: message.jsonrpc,
60
73
  method: message.method,
61
- params: message.params,
74
+ params,
62
75
  id: message.id,
63
76
  nonce: message.nonce
64
77
  };
@@ -704,11 +704,17 @@ export class ProxyClient {
704
704
  // Route to appropriate service with socket wrapper
705
705
  const result = await RPCRouter.route(message as JSONRPCRequest, connection.socketWrapper as any);
706
706
 
707
+ // If result is an async iterable, stream it as binary chunks (__binaryChunk protocol)
708
+ if (result != null && typeof result[Symbol.asyncIterator] === 'function') {
709
+ await this.sendBinaryChunked(connectionId, message.id ?? null, result);
710
+ return;
711
+ }
712
+
707
713
  // Send response
708
714
  const response: JSONRPCResponse = {
709
715
  jsonrpc: '2.0',
710
716
  result,
711
- id: message.id || null,
717
+ id: message.id ?? null,
712
718
  };
713
719
 
714
720
  this.sendToClient(connectionId, 'rpc', response);
@@ -721,13 +727,59 @@ export class ProxyClient {
721
727
  ErrorCode.INTERNAL_ERROR,
722
728
  error.message || 'Internal error'
723
729
  ),
724
- id: message.id || null,
730
+ id: message.id ?? null,
725
731
  };
726
732
 
727
733
  this.sendToClient(connectionId, 'rpc', response);
728
734
  }
729
735
  }
730
736
 
737
+ /**
738
+ * Stream an async iterable of Buffers to the client using the __binaryChunk protocol.
739
+ * Each chunk is sent as a Socket.IO binary attachment (no base64).
740
+ * The client assembles chunks and resolves the pending RPC request.
741
+ */
742
+ private async sendBinaryChunked(
743
+ connectionId: string,
744
+ requestId: number | string | null,
745
+ iterable: any
746
+ ): Promise<void> {
747
+ if (!this.socket) return;
748
+ const chunkId = crypto.randomBytes(8).toString('hex');
749
+ const total: number = iterable.totalChunks;
750
+ const sizeMB = iterable.size ? `~${Math.round(iterable.size / 1024 / 1024 * 10) / 10}MB` : '?';
751
+ console.log(`📦 Binary streaming: ${total} chunks (${sizeMB}) for request ${requestId}`);
752
+
753
+ try {
754
+ let index = 0;
755
+ for await (const chunk of iterable) {
756
+ // Emit directly (bypass sendToClient/needsChunking) — Socket.IO handles Buffer natively
757
+ this.socket.emit('rpc', {
758
+ connectionId,
759
+ data: {
760
+ __binaryChunk: true,
761
+ chunkId,
762
+ requestId,
763
+ index,
764
+ total,
765
+ data: chunk, // Buffer — Socket.IO sends as binary attachment
766
+ },
767
+ });
768
+ index++;
769
+ }
770
+ } catch (error: any) {
771
+ // Stream error — send a normal RPC error so the client rejects the pending request
772
+ this.socket.emit('rpc', {
773
+ connectionId,
774
+ data: {
775
+ jsonrpc: '2.0',
776
+ id: requestId,
777
+ error: createRPCError(ErrorCode.INTERNAL_ERROR, error.message || 'Binary stream error'),
778
+ },
779
+ });
780
+ }
781
+ }
782
+
731
783
  /**
732
784
  * Send message to client via proxy
733
785
  * Automatically chunks large payloads (>800kB)
@@ -56,6 +56,22 @@ export class FilesystemService {
56
56
  result = await this.readFile(safePath!, params);
57
57
  logFsRead(method, params, deviceId, true, undefined, { size: result.size, encoding: result.encoding });
58
58
  return result;
59
+ case 'stat':
60
+ result = await this.stat(safePath!);
61
+ logFsRead(method, params, deviceId, true, undefined, { size: result.size });
62
+ return result;
63
+ case 'readFileBinary':
64
+ result = await this.readFileBinary(safePath!, params);
65
+ logFsRead(method, params, deviceId, true, undefined,
66
+ result.rangeLength !== undefined
67
+ ? { size: result.size, offset: result.rangeOffset, length: result.rangeLength }
68
+ : { size: result.size, totalChunks: result.totalChunks }
69
+ );
70
+ return result;
71
+ case 'writeBinary':
72
+ result = await this.writeBinary(safePath!, params);
73
+ logFsWrite(method, params, deviceId, true, undefined, { size: result.size });
74
+ return result;
59
75
  case 'write':
60
76
  result = await this.write(safePath!, params);
61
77
  logFsWrite(method, params, deviceId, true, undefined, { size: result.size });
@@ -114,7 +130,7 @@ export class FilesystemService {
114
130
  } catch (err) {
115
131
  error = err;
116
132
  // Determine if this was a read or write operation for logging
117
- const readOps = ['exists', 'readFile', 'getFileHash', 'readdir', 'readdirDeep', 'bulkExists', 'lstat'];
133
+ const readOps = ['exists', 'readFile', 'getFileHash', 'readdir', 'readdirDeep', 'bulkExists', 'lstat', 'stat'];
118
134
  if (readOps.includes(method)) {
119
135
  logFsRead(method, params, deviceId, false, error);
120
136
  } else {
@@ -129,8 +145,11 @@ export class FilesystemService {
129
145
  * Prevents directory traversal and symlink escape attacks
130
146
  */
131
147
  private async validatePath(userPath: string): Promise<string> {
148
+ // Strip query string and fragment (cache-busters sent by the browser)
149
+ const cleanPath = userPath.split('?')[0].split('#')[0];
150
+
132
151
  // Normalize path
133
- const normalized = path.normalize(userPath);
152
+ const normalized = path.normalize(cleanPath);
134
153
 
135
154
  // Prevent directory traversal
136
155
  if (normalized.includes('..')) {
@@ -251,11 +270,132 @@ export class FilesystemService {
251
270
  /**
252
271
  * Read file contents
253
272
  */
273
+ private async stat(safePath: string): Promise<any> {
274
+ try {
275
+ const stats = await fs.stat(safePath);
276
+ return {
277
+ size: stats.size,
278
+ mtime: stats.mtimeMs,
279
+ isFile: stats.isFile(),
280
+ isDirectory: stats.isDirectory(),
281
+ };
282
+ } catch (error: any) {
283
+ if (error.code === 'ENOENT') {
284
+ throw createRPCError(ErrorCode.FILE_NOT_FOUND, `File not found: ${safePath}`);
285
+ }
286
+ throw error;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Binary read — returns an async iterable of Buffer chunks.
292
+ * ProxyClient detects the iterable and streams chunks to the client via __binaryChunk protocol.
293
+ * File handle is opened once and held for the full iteration (atomic w.r.t. the file descriptor).
294
+ */
295
+ private async readFileBinary(safePath: string, params: any): Promise<any> {
296
+ const CHUNK_SIZE = params.chunkSize || 750 * 1024;
297
+
298
+ let stats: any;
299
+ try {
300
+ stats = await fs.stat(safePath);
301
+ } catch (error: any) {
302
+ if (error.code === 'ENOENT') {
303
+ throw createRPCError(ErrorCode.FILE_NOT_FOUND, `File not found: ${safePath}`);
304
+ }
305
+ throw error;
306
+ }
307
+
308
+ const totalSize = stats.size;
309
+
310
+ // Range read: single-chunk iterable for a specific byte range (used by video streaming)
311
+ if (params.offset !== undefined) {
312
+ const offset = params.offset as number;
313
+ const rangeLength = Math.min(
314
+ params.length !== undefined ? (params.length as number) : (totalSize - offset),
315
+ totalSize - offset
316
+ );
317
+ if (offset < 0 || rangeLength < 0) {
318
+ throw createRPCError(ErrorCode.INVALID_PARAMS, 'Invalid range parameters');
319
+ }
320
+ const iterable = {
321
+ totalChunks: 1,
322
+ size: totalSize,
323
+ rangeOffset: offset,
324
+ rangeLength,
325
+ async *[Symbol.asyncIterator]() {
326
+ const fh = await fs.open(safePath, 'r');
327
+ try {
328
+ const buf = Buffer.alloc(rangeLength);
329
+ if (rangeLength > 0) await fh.read(buf, 0, rangeLength, offset);
330
+ yield buf;
331
+ } finally {
332
+ await fh.close();
333
+ }
334
+ }
335
+ };
336
+ return iterable;
337
+ }
338
+
339
+ const totalChunks = Math.max(1, Math.ceil(totalSize / CHUNK_SIZE));
340
+
341
+ // Return an async iterable. ProxyClient will detect [Symbol.asyncIterator] and stream chunks.
342
+ const iterable = {
343
+ totalChunks,
344
+ size: totalSize,
345
+ async *[Symbol.asyncIterator]() {
346
+ const fh = await fs.open(safePath, 'r');
347
+ try {
348
+ for (let i = 0; i < totalChunks; i++) {
349
+ const offset = i * CHUNK_SIZE;
350
+ const length = Math.min(CHUNK_SIZE, totalSize - offset);
351
+ const buf = Buffer.alloc(length);
352
+ if (length > 0) await fh.read(buf, 0, length, offset);
353
+ yield buf;
354
+ }
355
+ } finally {
356
+ await fh.close();
357
+ }
358
+ }
359
+ };
360
+ return iterable;
361
+ }
362
+
363
+ /**
364
+ * Binary write — receives the full buffer as a Socket.IO binary attachment.
365
+ * No accumulation needed; ProxyClient routes directly once Socket.IO reassembles the binary.
366
+ */
367
+ private async writeBinary(safePath: string, params: any): Promise<any> {
368
+ const buffer = Buffer.isBuffer(params.data) ? params.data : Buffer.alloc(0);
369
+ return this.write(safePath, { ...params, contents: buffer, encoding: 'binary' });
370
+ }
371
+
254
372
  private async readFile(safePath: string, params: any): Promise<any> {
255
373
  try {
256
374
  const stats = await fs.stat(safePath);
257
375
 
258
- // Check file size limit
376
+ const encoding = params.encoding || 'utf8';
377
+
378
+ // Range read: bypass maxFileSize limit since we only read a small chunk
379
+ if (params.offset !== undefined) {
380
+ const offset = params.offset as number;
381
+ const length = Math.min(
382
+ params.length !== undefined ? (params.length as number) : (stats.size - offset),
383
+ stats.size - offset
384
+ );
385
+ if (offset < 0 || length <= 0) {
386
+ throw createRPCError(ErrorCode.INVALID_PARAMS, 'Invalid range parameters');
387
+ }
388
+ const fh = await fs.open(safePath, 'r');
389
+ try {
390
+ const buf = Buffer.alloc(length);
391
+ await fh.read(buf, 0, length, offset);
392
+ return { buffer: buf, size: stats.size, offset, length, encoding: 'binary' };
393
+ } finally {
394
+ await fh.close();
395
+ }
396
+ }
397
+
398
+ // Check file size limit for full reads
259
399
  const maxSize = parseFileSize(this.config.maxFileSize);
260
400
  if (stats.size > maxSize) {
261
401
  throw createRPCError(
@@ -265,8 +405,6 @@ export class FilesystemService {
265
405
  );
266
406
  }
267
407
 
268
- const encoding = params.encoding || 'utf8';
269
-
270
408
  if (encoding === 'binary') {
271
409
  // Binary file - return buffer directly in response
272
410
  const buffer = await fs.readFile(safePath);
@@ -325,25 +463,9 @@ export class FilesystemService {
325
463
 
326
464
  // Write file (atomic or regular)
327
465
  if (atomic) {
328
- // Use write-file-atomic for atomic writes
329
- if (encoding === 'binary') {
330
- const buffer = typeof params.contents === 'string'
331
- ? Buffer.from(params.contents, 'base64')
332
- : Buffer.from(params.contents || Buffer.alloc(0));
333
- await writeFileAtomic(safePath, buffer);
334
- } else {
335
- await writeFileAtomic(safePath, params.contents, { encoding });
336
- }
466
+ await writeFileAtomic(safePath, params.contents, { encoding });
337
467
  } else {
338
- // Regular write
339
- if (encoding === 'binary') {
340
- const buffer = typeof params.contents === 'string'
341
- ? Buffer.from(params.contents, 'base64')
342
- : Buffer.from(params.contents || Buffer.alloc(0));
343
- await fs.writeFile(safePath, buffer);
344
- } else {
345
- await fs.writeFile(safePath, params.contents, encoding);
346
- }
468
+ await fs.writeFile(safePath, params.contents, encoding);
347
469
  }
348
470
 
349
471
  // Set executable if requested
@@ -462,6 +584,9 @@ export class FilesystemService {
462
584
  */
463
585
  private async getFileHash(safePath: string): Promise<any> {
464
586
  const hash = await this.getFileHashValue(safePath);
587
+ if (hash === null) {
588
+ return { hash: null, size: 0, mtime: 0 };
589
+ }
465
590
  const stats = await fs.stat(safePath);
466
591
 
467
592
  return {