@iksdev/shard-cli 0.1.9 → 0.1.10

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 +103 -116
  3. package/package.json +1 -4
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,7 @@ 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');
10
8
  const { Readable } = require('stream');
11
9
  const { pipeline } = require('stream/promises');
12
10
 
@@ -23,7 +21,7 @@ Usage:
23
21
  shard login --username <name> --password <pass> [--server <url>]
24
22
  shard whoami [--server <url>]
25
23
  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]
24
+ shard share <file> [--server <url>] [--limits <n>] [--temps <jours>] [--upload]
27
25
  shard logout
28
26
  shard config show
29
27
  shard config set-server <url>
@@ -312,79 +310,19 @@ function parseOptionalPositiveInt(raw, flagName) {
312
310
  return n;
313
311
  }
314
312
 
315
- function normalizePublicUrl(input) {
316
- const raw = String(input || '').trim();
317
- if (!raw) return '';
318
- return raw.replace(/\/+$/, '');
313
+ function toWebSocketUrl(serverUrl, token) {
314
+ const url = new URL(serverUrl);
315
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
316
+ url.pathname = '/api/relay/ws';
317
+ url.search = '';
318
+ url.searchParams.set('token', token);
319
+ return url.toString();
319
320
  }
320
321
 
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
322
  async function shareFile(positionals, flags) {
385
323
  const target = positionals[0];
386
324
  if (!target) {
387
- throw new Error('Usage: shard share <file> [--server <url>] [--limits <n>] [--temps <jours>] [--port <n>] [--upload]');
325
+ throw new Error('Usage: shard share <file> [--server <url>] [--limits <n>] [--temps <jours>] [--upload]');
388
326
  }
389
327
 
390
328
  const absPath = path.resolve(process.cwd(), target);
@@ -414,42 +352,99 @@ async function shareFile(positionals, flags) {
414
352
 
415
353
  const fileName = path.basename(absPath);
416
354
  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
355
  const mimeType = guessMime(absPath);
420
- let localServer = null;
421
- let tunnel = null;
356
+ const relayFileId = crypto.randomBytes(12).toString('base64url');
357
+ const wsUrl = toWebSocketUrl(server, token);
358
+ const relaySocket = new WebSocket(wsUrl);
359
+
360
+ let relayClientId = '';
361
+ let closed = false;
362
+ const closeRelay = () => {
363
+ if (closed) return;
364
+ closed = true;
365
+ try { relaySocket.close(); } catch (_) {}
366
+ };
367
+
368
+ const stopSignals = ['SIGINT', 'SIGTERM'];
369
+ for (const sig of stopSignals) {
370
+ process.on(sig, closeRelay);
371
+ }
372
+
373
+ const openPromise = new Promise((resolve, reject) => {
374
+ const timer = setTimeout(() => {
375
+ reject(new Error('Timeout connexion relay'));
376
+ }, 15000);
377
+
378
+ relaySocket.addEventListener('open', () => {
379
+ clearTimeout(timer);
380
+ resolve();
381
+ });
382
+ relaySocket.addEventListener('error', (event) => {
383
+ clearTimeout(timer);
384
+ reject(new Error(`Echec connexion relay: ${event?.message || 'ws error'}`));
385
+ });
386
+ });
387
+
422
388
  try {
423
- localServer = await startLocalSingleFileServer({
424
- filePath: absPath,
425
- fileName,
426
- mimeType,
427
- port,
428
- accessKey
389
+ await openPromise;
390
+
391
+ const helloPromise = new Promise((resolve, reject) => {
392
+ const timer = setTimeout(() => reject(new Error('Handshake relay timeout')), 12000);
393
+ relaySocket.addEventListener('message', (event) => {
394
+ let msg = null;
395
+ try { msg = JSON.parse(String(event.data || '')); } catch (_) { return; }
396
+ if (!msg || typeof msg !== 'object') return;
397
+ if (msg.type === 'hello_ack' && msg.relayClientId) {
398
+ clearTimeout(timer);
399
+ relayClientId = String(msg.relayClientId);
400
+ resolve();
401
+ }
402
+ });
429
403
  });
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
- }
404
+ await helloPromise;
436
405
 
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
- }
406
+ relaySocket.addEventListener('message', async (event) => {
407
+ let msg = null;
408
+ try { msg = JSON.parse(String(event.data || '')); } catch (_) { return; }
409
+ if (!msg || typeof msg !== 'object') return;
410
+ if (msg.type === 'heartbeat_ack') return;
411
+ if (msg.type !== 'stream_request') return;
412
+ if (String(msg.relayFileId || '') !== relayFileId) return;
413
+ const uploadUrl = String(msg.uploadUrl || '');
414
+ if (!uploadUrl) return;
415
+
416
+ try {
417
+ const stream = fs.createReadStream(absPath);
418
+ const response = await fetch(uploadUrl, {
419
+ method: 'POST',
420
+ headers: {
421
+ 'Content-Type': mimeType,
422
+ 'Content-Length': String(st.size)
423
+ },
424
+ body: stream,
425
+ duplex: 'half'
426
+ });
427
+ if (!response.ok) {
428
+ const reason = await response.text().catch(() => '');
429
+ console.error(`Relay upload failed: HTTP ${response.status} ${reason}`.trim());
430
+ }
431
+ } catch (err) {
432
+ console.error(`Relay upload error: ${err.message}`);
433
+ }
434
+ });
435
+
436
+ const heartbeat = setInterval(() => {
437
+ if (relaySocket.readyState === WebSocket.OPEN) {
438
+ relaySocket.send(JSON.stringify({ type: 'heartbeat', ts: Date.now() }));
439
+ }
440
+ }, 15000);
446
441
 
447
- const directDownloadUrl = `${publicUrl}${localServer.routePath}`;
448
442
  const payload = {
449
443
  fileName,
450
444
  fileSize: st.size,
451
445
  mimeType,
452
- downloadUrl: directDownloadUrl
446
+ relayClientId,
447
+ relayFileId
453
448
  };
454
449
  if (limits !== undefined && limits > 0) payload.maxDownloads = limits;
455
450
  if (temps !== undefined && temps > 0) payload.expiresInDays = temps;
@@ -464,35 +459,27 @@ async function shareFile(positionals, flags) {
464
459
  });
465
460
 
466
461
  const share = created?.share || {};
467
- console.log(`Partage local cree pour: ${fileName}`);
462
+ console.log(`Partage relay cree pour: ${fileName}`);
468
463
  if (share.url) console.log(`URL Shard: ${share.url}`);
469
- console.log(`URL publique: ${publicUrl}`);
470
- console.log(`URL agent locale: ${directDownloadUrl}`);
471
464
  if (share.token) console.log(`Token: ${share.token}`);
472
- console.log(`Agent local en ecoute sur: http://0.0.0.0:${port}${localServer.routePath}`);
465
+ console.log(`Relay client id: ${relayClientId}`);
473
466
  console.log(`Limite downloads: ${limits && limits > 0 ? limits : 'illimitee'}`);
474
467
  console.log(`Expiration: ${temps && temps > 0 ? `${temps} jour(s)` : 'aucune'}`);
475
468
  console.log('Laisse cette commande ouverte tant que le partage doit fonctionner.');
476
469
 
477
- const stopSignals = ['SIGINT', 'SIGTERM'];
478
470
  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);
471
+ relaySocket.addEventListener('close', () => resolve());
487
472
  });
473
+ clearInterval(heartbeat);
474
+ for (const sig of stopSignals) {
475
+ process.off(sig, closeRelay);
476
+ }
488
477
  return;
489
478
  } 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()));
479
+ for (const sig of stopSignals) {
480
+ process.off(sig, closeRelay);
495
481
  }
482
+ closeRelay();
496
483
  throw error;
497
484
  }
498
485
  }
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.10",
4
4
  "description": "CLI pour synchroniser un dossier local avec Shard",
5
5
  "bin": {
6
6
  "shard": "bin/shard.js"
@@ -9,9 +9,6 @@
9
9
  "scripts": {
10
10
  "check": "node --check bin/shard.js"
11
11
  },
12
- "dependencies": {
13
- "localtunnel": "^2.0.2"
14
- },
15
12
  "engines": {
16
13
  "node": ">=20.0.0"
17
14
  },