@iksdev/shard-cli 0.1.9 → 0.1.11

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 (3) hide show
  1. package/README.md +4 -4
  2. package/bin/shard.js +104 -116
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -24,7 +24,7 @@ shard --help
24
24
  shard login --server http://localhost:3000 --username admin --password secret
25
25
  ```
26
26
 
27
- 2. Partage local (sans stockage serveur, commande simple)
27
+ 2. Partage relay (sans stockage serveur, commande simple)
28
28
 
29
29
  ```bash
30
30
  shard share ./MonFichier.mp4 --server https://shard-0ow4.onrender.com --limits 0 --temps 0
@@ -41,7 +41,7 @@ shard sync ./MonDossier
41
41
  - `shard login --username <name> --password <pass> [--server <url>]`
42
42
  - `shard whoami [--server <url>]`
43
43
  - `shard sync <folder> [--server <url>] [--dry-run] [--force]`
44
- - `shard share <file> [--server <url>] [--limits <n>] [--temps <jours>] [--port <n>] [--upload]`
44
+ - `shard share <file> [--server <url>] [--limits <n>] [--temps <jours>] [--upload]`
45
45
  - `shard logout`
46
46
  - `shard config show`
47
47
  - `shard config set-server <url>`
@@ -51,6 +51,6 @@ shard sync ./MonDossier
51
51
  - Le CLI stocke la config dans `~/.shard-cli/config.json`.
52
52
  - Le CLI stocke l'etat de sync dans `<ton-dossier>/.shard-sync-state.json`.
53
53
  - Les uploads passent par `POST /api/files/upload` avec token `Bearer`.
54
- - Par défaut `shard share` est en mode local: le fichier reste sur ton PC, le serveur stocke seulement metadata + token.
55
- - Si aucune URL publique n'est fournie, le CLI crée automatiquement un tunnel public (rien à installer en plus).
54
+ - Par défaut `shard share` utilise le mode relay: le fichier reste sur ton PC, le serveur stocke seulement metadata + token.
55
+ - Aucun tunnel externe à installer: le serveur Shard relaie directement le flux via websocket.
56
56
  - Utilise `--upload` pour revenir au mode historique (upload serveur).
package/bin/shard.js CHANGED
@@ -4,9 +4,8 @@ const fs = require('fs');
4
4
  const fsp = require('fs/promises');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
- const http = require('http');
8
7
  const crypto = require('crypto');
9
- const localtunnel = require('localtunnel');
8
+ const { WebSocket } = require('ws');
10
9
  const { Readable } = require('stream');
11
10
  const { pipeline } = require('stream/promises');
12
11
 
@@ -23,7 +22,7 @@ Usage:
23
22
  shard login --username <name> --password <pass> [--server <url>]
24
23
  shard whoami [--server <url>]
25
24
  shard sync <folder> [--server <url>] [--dry-run] [--force] [--once] [--interval-ms <n>]
26
- shard share <file> [--server <url>] [--limits <n>] [--temps <jours>] [--port <n>] [--upload]
25
+ shard share <file> [--server <url>] [--limits <n>] [--temps <jours>] [--upload]
27
26
  shard logout
28
27
  shard config show
29
28
  shard config set-server <url>
@@ -312,79 +311,19 @@ function parseOptionalPositiveInt(raw, flagName) {
312
311
  return n;
313
312
  }
314
313
 
315
- function normalizePublicUrl(input) {
316
- const raw = String(input || '').trim();
317
- if (!raw) return '';
318
- return raw.replace(/\/+$/, '');
314
+ function toWebSocketUrl(serverUrl, token) {
315
+ const url = new URL(serverUrl);
316
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
317
+ url.pathname = '/api/relay/ws';
318
+ url.search = '';
319
+ url.searchParams.set('token', token);
320
+ return url.toString();
319
321
  }
320
322
 
321
- async function startManagedTunnel(port) {
322
- const tunnel = await localtunnel({ port });
323
- const publicUrl = normalizePublicUrl(tunnel?.url || '');
324
- if (!publicUrl) {
325
- throw new Error('Tunnel demarre mais URL publique manquante');
326
- }
327
- return {
328
- publicUrl,
329
- close: async () => {
330
- try {
331
- await tunnel.close();
332
- } catch (_) {
333
- // ignore close errors
334
- }
335
- }
336
- };
337
- }
338
-
339
- function startLocalSingleFileServer({ filePath, fileName, mimeType, port, accessKey }) {
340
- const safeName = encodeURIComponent(fileName);
341
- const routePath = `/download/${accessKey}`;
342
-
343
- const server = http.createServer((req, res) => {
344
- const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
345
-
346
- if (requestUrl.pathname === '/health') {
347
- res.statusCode = 200;
348
- res.setHeader('Content-Type', 'application/json');
349
- res.end(JSON.stringify({ ok: true, mode: 'local-share', file: fileName }));
350
- return;
351
- }
352
-
353
- if (requestUrl.pathname !== routePath) {
354
- res.statusCode = 404;
355
- res.setHeader('Content-Type', 'application/json');
356
- res.end(JSON.stringify({ error: 'Not found' }));
357
- return;
358
- }
359
-
360
- const stream = fs.createReadStream(filePath);
361
- res.statusCode = 200;
362
- res.setHeader('Content-Type', mimeType || 'application/octet-stream');
363
- res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${safeName}`);
364
- stream.on('error', () => {
365
- if (!res.headersSent) {
366
- res.statusCode = 500;
367
- res.end('Read error');
368
- } else {
369
- res.destroy();
370
- }
371
- });
372
- stream.pipe(res);
373
- });
374
-
375
- return new Promise((resolve, reject) => {
376
- server.once('error', reject);
377
- server.listen(port, '0.0.0.0', () => {
378
- server.removeListener('error', reject);
379
- resolve({ server, routePath });
380
- });
381
- });
382
- }
383
-
384
323
  async function shareFile(positionals, flags) {
385
324
  const target = positionals[0];
386
325
  if (!target) {
387
- throw new Error('Usage: shard share <file> [--server <url>] [--limits <n>] [--temps <jours>] [--port <n>] [--upload]');
326
+ throw new Error('Usage: shard share <file> [--server <url>] [--limits <n>] [--temps <jours>] [--upload]');
388
327
  }
389
328
 
390
329
  const absPath = path.resolve(process.cwd(), target);
@@ -414,42 +353,99 @@ async function shareFile(positionals, flags) {
414
353
 
415
354
  const fileName = path.basename(absPath);
416
355
  if (localMode) {
417
- const port = Math.max(parseInt(flags.port || process.env.SHARD_LOCAL_PORT || '8787', 10) || 8787, 1);
418
- const accessKey = crypto.randomBytes(18).toString('base64url');
419
356
  const mimeType = guessMime(absPath);
420
- let localServer = null;
421
- let tunnel = null;
357
+ const relayFileId = crypto.randomBytes(12).toString('base64url');
358
+ const wsUrl = toWebSocketUrl(server, token);
359
+ const relaySocket = new WebSocket(wsUrl);
360
+
361
+ let relayClientId = '';
362
+ let closed = false;
363
+ const closeRelay = () => {
364
+ if (closed) return;
365
+ closed = true;
366
+ try { relaySocket.close(); } catch (_) {}
367
+ };
368
+
369
+ const stopSignals = ['SIGINT', 'SIGTERM'];
370
+ for (const sig of stopSignals) {
371
+ process.on(sig, closeRelay);
372
+ }
373
+
374
+ const openPromise = new Promise((resolve, reject) => {
375
+ const timer = setTimeout(() => {
376
+ reject(new Error('Timeout connexion relay'));
377
+ }, 15000);
378
+
379
+ relaySocket.on('open', () => {
380
+ clearTimeout(timer);
381
+ resolve();
382
+ });
383
+ relaySocket.on('error', (event) => {
384
+ clearTimeout(timer);
385
+ reject(new Error(`Echec connexion relay: ${event?.message || 'ws error'}`));
386
+ });
387
+ });
388
+
422
389
  try {
423
- localServer = await startLocalSingleFileServer({
424
- filePath: absPath,
425
- fileName,
426
- mimeType,
427
- port,
428
- accessKey
390
+ await openPromise;
391
+
392
+ const helloPromise = new Promise((resolve, reject) => {
393
+ const timer = setTimeout(() => reject(new Error('Handshake relay timeout')), 12000);
394
+ relaySocket.on('message', (event) => {
395
+ let msg = null;
396
+ try { msg = JSON.parse(String(event || '')); } catch (_) { return; }
397
+ if (!msg || typeof msg !== 'object') return;
398
+ if (msg.type === 'hello_ack' && msg.relayClientId) {
399
+ clearTimeout(timer);
400
+ relayClientId = String(msg.relayClientId);
401
+ resolve();
402
+ }
403
+ });
429
404
  });
430
- let publicUrl = normalizePublicUrl(flags['public-url'] || process.env.SHARD_PUBLIC_URL);
431
- if (!publicUrl) {
432
- console.log(`Aucune URL publique fournie, creation automatique du tunnel (port ${port})...`);
433
- tunnel = await startManagedTunnel(port);
434
- publicUrl = tunnel.publicUrl;
435
- }
405
+ await helloPromise;
436
406
 
437
- let publicUrlParsed;
438
- try {
439
- publicUrlParsed = new URL(publicUrl);
440
- } catch {
441
- throw new Error(`URL publique invalide: ${publicUrl}`);
442
- }
443
- if (!['http:', 'https:'].includes(publicUrlParsed.protocol)) {
444
- throw new Error('URL publique doit etre en http(s)');
445
- }
407
+ relaySocket.on('message', async (event) => {
408
+ let msg = null;
409
+ try { msg = JSON.parse(String(event || '')); } catch (_) { return; }
410
+ if (!msg || typeof msg !== 'object') return;
411
+ if (msg.type === 'heartbeat_ack') return;
412
+ if (msg.type !== 'stream_request') return;
413
+ if (String(msg.relayFileId || '') !== relayFileId) return;
414
+ const uploadUrl = String(msg.uploadUrl || '');
415
+ if (!uploadUrl) return;
416
+
417
+ try {
418
+ const stream = fs.createReadStream(absPath);
419
+ const response = await fetch(uploadUrl, {
420
+ method: 'POST',
421
+ headers: {
422
+ 'Content-Type': mimeType,
423
+ 'Content-Length': String(st.size)
424
+ },
425
+ body: stream,
426
+ duplex: 'half'
427
+ });
428
+ if (!response.ok) {
429
+ const reason = await response.text().catch(() => '');
430
+ console.error(`Relay upload failed: HTTP ${response.status} ${reason}`.trim());
431
+ }
432
+ } catch (err) {
433
+ console.error(`Relay upload error: ${err.message}`);
434
+ }
435
+ });
436
+
437
+ const heartbeat = setInterval(() => {
438
+ if (relaySocket.readyState === WebSocket.OPEN) {
439
+ relaySocket.send(JSON.stringify({ type: 'heartbeat', ts: Date.now() }));
440
+ }
441
+ }, 15000);
446
442
 
447
- const directDownloadUrl = `${publicUrl}${localServer.routePath}`;
448
443
  const payload = {
449
444
  fileName,
450
445
  fileSize: st.size,
451
446
  mimeType,
452
- downloadUrl: directDownloadUrl
447
+ relayClientId,
448
+ relayFileId
453
449
  };
454
450
  if (limits !== undefined && limits > 0) payload.maxDownloads = limits;
455
451
  if (temps !== undefined && temps > 0) payload.expiresInDays = temps;
@@ -464,35 +460,27 @@ async function shareFile(positionals, flags) {
464
460
  });
465
461
 
466
462
  const share = created?.share || {};
467
- console.log(`Partage local cree pour: ${fileName}`);
463
+ console.log(`Partage relay cree pour: ${fileName}`);
468
464
  if (share.url) console.log(`URL Shard: ${share.url}`);
469
- console.log(`URL publique: ${publicUrl}`);
470
- console.log(`URL agent locale: ${directDownloadUrl}`);
471
465
  if (share.token) console.log(`Token: ${share.token}`);
472
- console.log(`Agent local en ecoute sur: http://0.0.0.0:${port}${localServer.routePath}`);
466
+ console.log(`Relay client id: ${relayClientId}`);
473
467
  console.log(`Limite downloads: ${limits && limits > 0 ? limits : 'illimitee'}`);
474
468
  console.log(`Expiration: ${temps && temps > 0 ? `${temps} jour(s)` : 'aucune'}`);
475
469
  console.log('Laisse cette commande ouverte tant que le partage doit fonctionner.');
476
470
 
477
- const stopSignals = ['SIGINT', 'SIGTERM'];
478
471
  await new Promise((resolve) => {
479
- const stop = () => {
480
- for (const sig of stopSignals) process.off(sig, stop);
481
- if (tunnel && tunnel.close) {
482
- void tunnel.close();
483
- }
484
- localServer.server.close(() => resolve());
485
- };
486
- for (const sig of stopSignals) process.on(sig, stop);
472
+ relaySocket.on('close', () => resolve());
487
473
  });
474
+ clearInterval(heartbeat);
475
+ for (const sig of stopSignals) {
476
+ process.off(sig, closeRelay);
477
+ }
488
478
  return;
489
479
  } catch (error) {
490
- if (tunnel && tunnel.close) {
491
- await tunnel.close();
492
- }
493
- if (localServer && localServer.server) {
494
- await new Promise((resolve) => localServer.server.close(() => resolve()));
480
+ for (const sig of stopSignals) {
481
+ process.off(sig, closeRelay);
495
482
  }
483
+ closeRelay();
496
484
  throw error;
497
485
  }
498
486
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iksdev/shard-cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "CLI pour synchroniser un dossier local avec Shard",
5
5
  "bin": {
6
6
  "shard": "bin/shard.js"
@@ -10,7 +10,7 @@
10
10
  "check": "node --check bin/shard.js"
11
11
  },
12
12
  "dependencies": {
13
- "localtunnel": "^2.0.2"
13
+ "ws": "^8.18.3"
14
14
  },
15
15
  "engines": {
16
16
  "node": ">=20.0.0"