@kadi.build/file-sharing 1.0.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.
@@ -0,0 +1,598 @@
1
+ /**
2
+ * FileSharingServer - Main orchestrating class for @kadi.build/file-sharing
3
+ *
4
+ * Integrates HttpServerProvider, S3Server, TunnelManager, DownloadMonitor,
5
+ * ShutdownManager, MonitoringDashboard, and EventNotifier into a unified
6
+ * file sharing solution.
7
+ *
8
+ * KĀDI is the default tunnel service.
9
+ */
10
+
11
+ import { EventEmitter } from 'events';
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { createFileManager } from '@kadi.build/file-manager';
15
+ import { TunnelManager } from '@kadi.build/tunnel-services';
16
+ import { HttpServerProvider } from './HttpServerProvider.js';
17
+ import { S3Server } from './S3Server.js';
18
+ import { DownloadMonitor } from './DownloadMonitor.js';
19
+ import { ShutdownManager } from './ShutdownManager.js';
20
+ import { MonitoringDashboard } from './MonitoringDashboard.js';
21
+ import { EventNotifier } from './EventNotifier.js';
22
+
23
+ /**
24
+ * Parse a .env file's content and inject into process.env.
25
+ * Existing env vars take priority (are NOT overwritten).
26
+ * @param {string} content raw file content
27
+ */
28
+ function _parseEnvFile(content) {
29
+ for (const line of content.split('\n')) {
30
+ const trimmed = line.trim();
31
+ if (!trimmed || trimmed.startsWith('#')) continue;
32
+ const eqIdx = trimmed.indexOf('=');
33
+ if (eqIdx === -1) continue;
34
+ const key = trimmed.substring(0, eqIdx).trim();
35
+ let val = trimmed.substring(eqIdx + 1).trim();
36
+ // Strip surrounding quotes
37
+ if ((val.startsWith('"') && val.endsWith('"')) ||
38
+ (val.startsWith("'") && val.endsWith("'"))) {
39
+ val = val.slice(1, -1);
40
+ }
41
+ // Real env vars take priority over .env file values
42
+ if (process.env[key] === undefined) {
43
+ process.env[key] = val;
44
+ }
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Walk up from cwd looking for a .env file (supports monorepo layouts
50
+ * where .env lives at the workspace root, not inside packages/*).
51
+ * Stops at the filesystem root.
52
+ */
53
+ function _loadEnvFile() {
54
+ let dir = process.cwd();
55
+ const root = path.parse(dir).root;
56
+
57
+ while (true) {
58
+ try {
59
+ const envPath = path.join(dir, '.env');
60
+ const content = fs.readFileSync(envPath, 'utf8');
61
+ _parseEnvFile(content);
62
+ return; // found and loaded — done
63
+ } catch {
64
+ // not found in this directory — keep climbing
65
+ }
66
+ const parent = path.dirname(dir);
67
+ if (parent === dir || dir === root) break; // reached fs root
68
+ dir = parent;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Load secrets from environment variables and optional .env file.
74
+ * Supports KADI_ prefixed vars and common tunnel/auth env vars.
75
+ *
76
+ * Env vars (all optional — constructor config takes priority):
77
+ * KADI_TUNNEL_TOKEN — KĀDI tunnel auth token
78
+ * KADI_TUNNEL_SERVER — KĀDI broker address (default: broker.kadi.build)
79
+ * KADI_TUNNEL_DOMAIN — KĀDI tunnel domain (default: tunnel.kadi.build)
80
+ * KADI_TUNNEL_PORT — KĀDI frpc port (default: 7000)
81
+ * KADI_TUNNEL_SSH_PORT— KĀDI SSH gateway port (default: 2200)
82
+ * KADI_TUNNEL_MODE — ssh | frpc | auto (default: auto)
83
+ * KADI_AGENT_ID — Agent identifier for proxy naming
84
+ * NGROK_AUTHTOKEN — Ngrok auth token (also: NGROK_AUTH_TOKEN)
85
+ * KADI_S3_ACCESS_KEY — S3 access key (default: minioadmin)
86
+ * KADI_S3_SECRET_KEY — S3 secret key (default: minioadmin)
87
+ * KADI_AUTH_USERNAME — HTTP Basic auth username
88
+ * KADI_AUTH_PASSWORD — HTTP Basic auth password
89
+ * KADI_AUTH_API_KEY — API key for Bearer/X-API-Key auth
90
+ *
91
+ * @returns {object} Secrets object (values are strings or undefined)
92
+ */
93
+ function loadSecrets() {
94
+ // Find and load .env file — walk up parent directories (monorepo support).
95
+ // e.g. packages/file-sharing/ → packages/ → workspace root (where .env lives)
96
+ _loadEnvFile();
97
+
98
+
99
+ return {
100
+ kadiToken: process.env.KADI_TUNNEL_TOKEN,
101
+ kadiServer: process.env.KADI_TUNNEL_SERVER,
102
+ kadiDomain: process.env.KADI_TUNNEL_DOMAIN,
103
+ kadiPort: process.env.KADI_TUNNEL_PORT ? Number(process.env.KADI_TUNNEL_PORT) : undefined,
104
+ kadiSshPort: process.env.KADI_TUNNEL_SSH_PORT ? Number(process.env.KADI_TUNNEL_SSH_PORT) : undefined,
105
+ kadiMode: process.env.KADI_TUNNEL_MODE,
106
+ kadiAgentId: process.env.KADI_AGENT_ID,
107
+ ngrokAuthToken: process.env.NGROK_AUTHTOKEN || process.env.NGROK_AUTH_TOKEN,
108
+ s3AccessKey: process.env.KADI_S3_ACCESS_KEY,
109
+ s3SecretKey: process.env.KADI_S3_SECRET_KEY,
110
+ authUsername: process.env.KADI_AUTH_USERNAME,
111
+ authPassword: process.env.KADI_AUTH_PASSWORD,
112
+ authApiKey: process.env.KADI_AUTH_API_KEY
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Strip `undefined` values from an object so they don't override downstream
118
+ * defaults when spread into a constructor config.
119
+ * @param {object} obj
120
+ * @returns {object}
121
+ */
122
+ function _filterDefined(obj) {
123
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
124
+ }
125
+
126
+ export class FileSharingServer extends EventEmitter {
127
+ constructor(config = {}) {
128
+ super();
129
+
130
+ this.config = {
131
+ staticDir: process.cwd(),
132
+ port: 3000,
133
+ host: '0.0.0.0',
134
+ enableS3: false,
135
+ s3Port: 9000,
136
+ s3Config: {},
137
+ enableDirectoryListing: true,
138
+ cors: true,
139
+ auth: null,
140
+ tunnel: {
141
+ enabled: false,
142
+ service: 'kadi', // KĀDI is the default tunnel service
143
+ options: {},
144
+ autoReconnect: true,
145
+ reconnectDelay: 5000
146
+ },
147
+ monitoring: {
148
+ enabled: true,
149
+ dashboard: false,
150
+ webhooks: []
151
+ },
152
+ shutdown: {
153
+ gracefulTimeout: 30000,
154
+ finishActiveDownloads: true,
155
+ forceKillTimeout: 60000
156
+ },
157
+ ...config
158
+ };
159
+
160
+ // Ensure tunnel defaults are preserved when user provides partial tunnel config
161
+ if (config.tunnel) {
162
+ this.config.tunnel = {
163
+ enabled: false,
164
+ service: 'kadi',
165
+ options: {},
166
+ autoReconnect: true,
167
+ reconnectDelay: 5000,
168
+ ...config.tunnel
169
+ };
170
+ }
171
+
172
+ // ----------------------------------------------------------------
173
+ // Load secrets from env vars / .env (constructor config takes priority)
174
+ // ----------------------------------------------------------------
175
+ const secrets = loadSecrets();
176
+
177
+ // Build auth config: explicit config > env vars > null
178
+ if (!this.config.auth) {
179
+ if (secrets.authApiKey) {
180
+ this.config.auth = { apiKey: secrets.authApiKey };
181
+ } else if (secrets.authUsername && secrets.authPassword) {
182
+ this.config.auth = { username: secrets.authUsername, password: secrets.authPassword };
183
+ }
184
+ }
185
+
186
+ // Initialize components
187
+ this.fileManager = createFileManager();
188
+
189
+ this.httpServer = new HttpServerProvider({
190
+ port: this.config.port,
191
+ host: this.config.host,
192
+ staticDir: this.config.staticDir,
193
+ enableDirectoryListing: this.config.enableDirectoryListing,
194
+ cors: this.config.cors,
195
+ auth: this.config.auth,
196
+ ...(this.config.httpConfig || {})
197
+ });
198
+
199
+ this.s3Server = null;
200
+ if (this.config.enableS3) {
201
+ this.s3Server = new S3Server({
202
+ port: this.config.s3Port,
203
+ host: this.config.host,
204
+ rootDir: this.config.staticDir,
205
+ ...(secrets.s3AccessKey ? { accessKeyId: secrets.s3AccessKey } : {}),
206
+ ...(secrets.s3SecretKey ? { secretAccessKey: secrets.s3SecretKey } : {}),
207
+ ...this.config.s3Config
208
+ });
209
+ }
210
+
211
+ // Build TunnelManager config from env vars + explicit tunnel config.
212
+ // Use || to let explicit config override env, and filter out undefined
213
+ // values so TunnelManager's own defaults are preserved.
214
+ const tunnelCfg = this.config.tunnel || {};
215
+ const tunnelManagerConfig = _filterDefined({
216
+ primaryService: tunnelCfg.service || 'kadi',
217
+ autoFallback: tunnelCfg.autoFallback,
218
+ // KĀDI auth (env vars as fallback)
219
+ kadiToken: tunnelCfg.kadiToken || secrets.kadiToken,
220
+ kadiServer: tunnelCfg.kadiServer || secrets.kadiServer,
221
+ kadiDomain: tunnelCfg.kadiDomain || secrets.kadiDomain,
222
+ kadiPort: tunnelCfg.kadiPort || secrets.kadiPort,
223
+ kadiSshPort: tunnelCfg.kadiSshPort || secrets.kadiSshPort,
224
+ kadiMode: tunnelCfg.kadiMode || secrets.kadiMode,
225
+ kadiAgentId: tunnelCfg.kadiAgentId || secrets.kadiAgentId,
226
+ // Ngrok auth (env vars as fallback)
227
+ ngrokAuthToken: tunnelCfg.ngrokAuthToken || secrets.ngrokAuthToken,
228
+ // Pass through any extra service-specific options
229
+ ...(tunnelCfg.managerOptions || {})
230
+ });
231
+ this.tunnelManager = new TunnelManager(tunnelManagerConfig);
232
+ this.downloadMonitor = new DownloadMonitor();
233
+ this.shutdownManager = new ShutdownManager(this.config.shutdown);
234
+ this.monitoringDashboard = new MonitoringDashboard();
235
+ this.eventNotifier = new EventNotifier();
236
+
237
+ // State
238
+ this.isRunning = false;
239
+ this.tunnel = null;
240
+ this._startTime = null;
241
+
242
+ // Wire up events
243
+ this._setupEventForwarding();
244
+ this._setupShutdownHandlers();
245
+ this._setupWebhooks();
246
+ }
247
+
248
+ /**
249
+ * Start the file sharing server
250
+ * @returns {Promise<object>} Server information
251
+ */
252
+ async start() {
253
+ if (this.isRunning) {
254
+ return this.getInfo();
255
+ }
256
+
257
+ this._startTime = Date.now();
258
+
259
+ // Start HTTP server
260
+ const httpResult = await this.httpServer.start();
261
+ this.emit('http:started', httpResult);
262
+
263
+ // Start S3 server if enabled
264
+ if (this.s3Server) {
265
+ const s3Result = await this.s3Server.start();
266
+ this.emit('s3:started', s3Result);
267
+ }
268
+
269
+ // Create tunnel if enabled
270
+ if (this.config.tunnel.enabled) {
271
+ try {
272
+ await this.enableTunnel(this.config.tunnel);
273
+ } catch (error) {
274
+ this.emit('tunnel:error', error);
275
+ // Don't fail server start if tunnel fails
276
+ }
277
+ }
278
+
279
+ // Start monitoring dashboard if configured
280
+ if (this.config.monitoring.dashboard) {
281
+ await this.monitoringDashboard.start();
282
+ this.monitoringDashboard.setServerInfo(this.getInfo());
283
+ }
284
+
285
+ this.isRunning = true;
286
+ const info = this.getInfo();
287
+ this.emit('started', info);
288
+
289
+ return info;
290
+ }
291
+
292
+ /**
293
+ * Stop the file sharing server
294
+ * Gracefully shuts down all components.
295
+ */
296
+ async stop() {
297
+ if (!this.isRunning) {
298
+ return;
299
+ }
300
+
301
+ this.emit('stopping');
302
+
303
+ // Stop monitoring dashboard
304
+ if (this.monitoringDashboard.isRunning) {
305
+ await this.monitoringDashboard.stop();
306
+ }
307
+
308
+ // Disable tunnel
309
+ if (this.tunnel) {
310
+ try {
311
+ await this.disableTunnel();
312
+ } catch {
313
+ // Best effort tunnel cleanup
314
+ }
315
+ }
316
+
317
+ // Stop servers
318
+ await this.httpServer.stop();
319
+
320
+ if (this.s3Server) {
321
+ await this.s3Server.stop();
322
+ }
323
+
324
+ this.isRunning = false;
325
+ this._startTime = null;
326
+ this.emit('stopped');
327
+ }
328
+
329
+ /**
330
+ * Enable tunnel to expose server publicly
331
+ * @param {object} options - Tunnel options
332
+ * @returns {Promise<object>} Tunnel information
333
+ */
334
+ async enableTunnel(options = {}) {
335
+ if (this.tunnel) {
336
+ await this.disableTunnel();
337
+ }
338
+
339
+ const service = options.service || 'kadi'; // KĀDI is the default
340
+ const port = this.config.port;
341
+
342
+ // TunnelManager.createTunnel(port, options) — port is first arg (number),
343
+ // options.service tells it which provider to use.
344
+ const tunnelOptions = {
345
+ service,
346
+ ...(options.options || {})
347
+ };
348
+
349
+ this.tunnel = await this.tunnelManager.createTunnel(port, tunnelOptions);
350
+ this.emit('tunnel:created', this.tunnel);
351
+
352
+ return this.tunnel;
353
+ }
354
+
355
+ /**
356
+ * Disable active tunnel
357
+ */
358
+ async disableTunnel() {
359
+ if (!this.tunnel) {
360
+ return;
361
+ }
362
+
363
+ await this.tunnelManager.closeTunnel(this.tunnel.id);
364
+ this.tunnel = null;
365
+ this.emit('tunnel:closed');
366
+ }
367
+
368
+ /**
369
+ * Enable S3-compatible API dynamically
370
+ * @param {object} options - S3 server options
371
+ * @returns {Promise<object>} S3 server information
372
+ */
373
+ async enableS3(options = {}) {
374
+ if (this.s3Server) {
375
+ await this.disableS3();
376
+ }
377
+
378
+ const secrets = loadSecrets();
379
+ this.s3Server = new S3Server({
380
+ port: this.config.s3Port,
381
+ host: this.config.host,
382
+ rootDir: this.config.staticDir,
383
+ ...(secrets.s3AccessKey ? { accessKeyId: secrets.s3AccessKey } : {}),
384
+ ...(secrets.s3SecretKey ? { secretAccessKey: secrets.s3SecretKey } : {}),
385
+ ...this.config.s3Config,
386
+ ...options
387
+ });
388
+
389
+ // Forward S3 events
390
+ this.s3Server.on('object:get', (data) => this.emit('s3:get', data));
391
+ this.s3Server.on('object:put', (data) => this.emit('s3:put', data));
392
+ this.s3Server.on('object:delete', (data) => this.emit('s3:delete', data));
393
+ this.s3Server.on('error', (err) => this.emit('error', err));
394
+
395
+ const result = await this.s3Server.start();
396
+ this.emit('s3:started', result);
397
+ return result;
398
+ }
399
+
400
+ /**
401
+ * Disable S3 API
402
+ */
403
+ async disableS3() {
404
+ if (!this.s3Server) return;
405
+
406
+ await this.s3Server.stop();
407
+ this.s3Server = null;
408
+ }
409
+
410
+ /**
411
+ * Get current server information and statistics
412
+ * @returns {object} Server information
413
+ */
414
+ getInfo() {
415
+ return {
416
+ isRunning: this.isRunning,
417
+ localUrl: `http://${this.config.host}:${this.config.port}`,
418
+ publicUrl: this.tunnel?.publicUrl || null,
419
+ s3Endpoint: this.s3Server?.isRunning
420
+ ? `http://${this.config.host}:${this.config.s3Port}`
421
+ : null,
422
+ staticDir: this.config.staticDir,
423
+ stats: this.downloadMonitor.getStats(),
424
+ uptime: this._startTime ? Date.now() - this._startTime : 0,
425
+ tunnelStatus: this.tunnel || null
426
+ };
427
+ }
428
+
429
+ /**
430
+ * Get download statistics
431
+ * @returns {object}
432
+ */
433
+ getStats() {
434
+ return this.downloadMonitor.getStats();
435
+ }
436
+
437
+ /**
438
+ * List files in the served directory
439
+ * @param {string} subPath - Optional subdirectory path
440
+ * @returns {Promise<Array>}
441
+ */
442
+ async listFiles(subPath = '') {
443
+ const { default: path } = await import('path');
444
+ const { default: fsPromises } = await import('fs/promises');
445
+
446
+ const dirPath = path.join(this.config.staticDir, subPath);
447
+ const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
448
+
449
+ return Promise.all(entries.map(async (entry) => {
450
+ const fullPath = path.join(dirPath, entry.name);
451
+ const stats = await fsPromises.stat(fullPath);
452
+ return {
453
+ name: entry.name,
454
+ type: entry.isDirectory() ? 'directory' : 'file',
455
+ size: stats.size,
456
+ modified: stats.mtime.toISOString()
457
+ };
458
+ }));
459
+ }
460
+
461
+ /**
462
+ * Add a webhook for event notifications
463
+ * @param {string} url - Webhook URL
464
+ * @param {string[]} events - Events to subscribe to
465
+ */
466
+ addWebhook(url, events = []) {
467
+ this.eventNotifier.addWebhook(url, events);
468
+ }
469
+
470
+ /**
471
+ * Remove a webhook
472
+ * @param {string} url - Webhook URL to remove
473
+ */
474
+ removeWebhook(url) {
475
+ this.eventNotifier.removeWebhook(url);
476
+ }
477
+
478
+ /** @type {number} */
479
+ get port() {
480
+ return this.config.port;
481
+ }
482
+
483
+ /** @type {string|null} */
484
+ get tunnelUrl() {
485
+ return this.tunnel?.publicUrl || null;
486
+ }
487
+
488
+ /** @type {string|null} */
489
+ get s3Endpoint() {
490
+ return this.s3Server?.isRunning
491
+ ? `http://${this.config.host}:${this.config.s3Port}`
492
+ : null;
493
+ }
494
+
495
+ /** @type {string} */
496
+ get staticDir() {
497
+ return this.config.staticDir;
498
+ }
499
+
500
+ /**
501
+ * Wire up event forwarding between components
502
+ * @private
503
+ */
504
+ _setupEventForwarding() {
505
+ // Forward HTTP server events
506
+ this.httpServer.on('download:start', (data) => {
507
+ this.downloadMonitor.startDownload(
508
+ `http-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
509
+ data
510
+ );
511
+ this.emit('download:start', data);
512
+ });
513
+ this.httpServer.on('download:complete', (data) => {
514
+ this.emit('download:complete', data);
515
+ });
516
+ this.httpServer.on('download:error', (data) => {
517
+ this.emit('download:error', data);
518
+ });
519
+ this.httpServer.on('upload:complete', (data) => {
520
+ this.emit('upload:complete', data);
521
+ });
522
+ this.httpServer.on('error', (err) => {
523
+ this.emit('http:error', err);
524
+ });
525
+
526
+ // Forward S3 events
527
+ if (this.s3Server) {
528
+ this.s3Server.on('object:get', (data) => this.emit('s3:get', data));
529
+ this.s3Server.on('object:put', (data) => this.emit('s3:put', data));
530
+ this.s3Server.on('object:delete', (data) => this.emit('s3:delete', data));
531
+ this.s3Server.on('error', (err) => this.emit('error', err));
532
+ }
533
+
534
+ // Forward download monitor events to dashboard
535
+ this.downloadMonitor.on('download:start', () => {
536
+ this._updateDashboard();
537
+ });
538
+ this.downloadMonitor.on('download:complete', () => {
539
+ this._updateDashboard();
540
+ });
541
+ this.downloadMonitor.on('download:progress', () => {
542
+ this._updateDashboard();
543
+ });
544
+ }
545
+
546
+ /**
547
+ * Register shutdown handlers
548
+ * @private
549
+ */
550
+ _setupShutdownHandlers() {
551
+ this.shutdownManager.register(async () => {
552
+ await this.stop();
553
+ }, 1);
554
+ }
555
+
556
+ /**
557
+ * Setup initial webhooks from config
558
+ * @private
559
+ */
560
+ _setupWebhooks() {
561
+ if (this.config.monitoring?.webhooks) {
562
+ for (const webhook of this.config.monitoring.webhooks) {
563
+ this.eventNotifier.addWebhook(
564
+ webhook.url,
565
+ webhook.events || [],
566
+ webhook.options || {}
567
+ );
568
+ }
569
+ }
570
+
571
+ // Auto-notify on key events
572
+ const eventsToNotify = [
573
+ 'started', 'stopped', 'download:start', 'download:complete',
574
+ 'upload:complete', 's3:put', 's3:delete', 'tunnel:created', 'tunnel:closed'
575
+ ];
576
+
577
+ for (const event of eventsToNotify) {
578
+ this.on(event, (data) => {
579
+ this.eventNotifier.notify(event, data).catch(() => {
580
+ // Silently handle notification failures
581
+ });
582
+ });
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Update the monitoring dashboard with latest stats
588
+ * @private
589
+ */
590
+ _updateDashboard() {
591
+ if (this.monitoringDashboard.isRunning) {
592
+ this.monitoringDashboard.updateStats(this.downloadMonitor.getStats());
593
+ this.monitoringDashboard.setServerInfo(this.getInfo());
594
+ }
595
+ }
596
+ }
597
+
598
+ export default FileSharingServer;