nport 2.0.1 → 2.0.3

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/analytics.js +259 -0
  2. package/index.js +66 -9
  3. package/package.json +2 -1
package/analytics.js ADDED
@@ -0,0 +1,259 @@
1
+ // ============================================================================
2
+ // Firebase Analytics for CLI
3
+ // Using Google Analytics 4 Measurement Protocol
4
+ // ============================================================================
5
+
6
+ import axios from "axios";
7
+ import { createHash, randomUUID } from "crypto";
8
+ import os from "os";
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import { fileURLToPath } from "url";
12
+
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+
16
+ // Firebase/GA4 Configuration (from website/home.html)
17
+ // Full Firebase config for reference (if needed for future features)
18
+ const FIREBASE_WEB_CONFIG = {
19
+ apiKey: "AIzaSyArRxHZJUt4o2RxiLqX1yDSkuUd6ZFy45I",
20
+ authDomain: "nport-link.firebaseapp.com",
21
+ projectId: "nport-link",
22
+ storageBucket: "nport-link.firebasestorage.app",
23
+ messagingSenderId: "515584605320",
24
+ appId: "1:515584605320:web:88daabc8d77146c6e7f33d",
25
+ measurementId: "G-8MYXZL6PGD"
26
+ };
27
+
28
+ // Analytics-specific config (for GA4 Measurement Protocol)
29
+ const FIREBASE_CONFIG = {
30
+ measurementId: FIREBASE_WEB_CONFIG.measurementId,
31
+ apiSecret: process.env.NPORT_ANALYTICS_SECRET || "YOUR_API_SECRET_HERE", // Get from Firebase Console
32
+ };
33
+
34
+ // Analytics Configuration
35
+ const ANALYTICS_CONFIG = {
36
+ enabled: true, // Can be disabled by environment variable
37
+ debug: process.env.NPORT_DEBUG === "true",
38
+ timeout: 2000, // Don't block CLI for too long
39
+ userIdFile: path.join(os.homedir(), ".nport-analytics"),
40
+ };
41
+
42
+ // ============================================================================
43
+ // Analytics Manager
44
+ // ============================================================================
45
+
46
+ class AnalyticsManager {
47
+ constructor() {
48
+ this.userId = null;
49
+ this.sessionId = null;
50
+ this.disabled = false;
51
+
52
+ // Disable analytics if environment variable is set
53
+ if (process.env.NPORT_ANALYTICS === "false") {
54
+ this.disabled = true;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Initialize analytics - must be called before tracking
60
+ */
61
+ async initialize() {
62
+ if (this.disabled) return;
63
+
64
+ // Check if API secret is configured
65
+ if (!FIREBASE_CONFIG.apiSecret || FIREBASE_CONFIG.apiSecret === "YOUR_API_SECRET_HERE") {
66
+ if (ANALYTICS_CONFIG.debug) {
67
+ console.warn("[Analytics] API secret not configured. Analytics disabled.");
68
+ console.warn("[Analytics] Set NPORT_ANALYTICS_SECRET environment variable.");
69
+ }
70
+ this.disabled = true;
71
+ return;
72
+ }
73
+
74
+ try {
75
+ this.userId = await this.getUserId();
76
+ this.sessionId = this.generateSessionId();
77
+
78
+ if (ANALYTICS_CONFIG.debug) {
79
+ console.log("[Analytics] Initialized successfully");
80
+ console.log("[Analytics] User ID:", this.userId.substring(0, 8) + "...");
81
+ }
82
+ } catch (error) {
83
+ if (ANALYTICS_CONFIG.debug) {
84
+ console.error("[Analytics] Initialization failed:", error.message);
85
+ }
86
+ this.disabled = true;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Get or create a persistent user ID
92
+ */
93
+ async getUserId() {
94
+ try {
95
+ // Try to read existing user ID
96
+ if (fs.existsSync(ANALYTICS_CONFIG.userIdFile)) {
97
+ const userId = fs.readFileSync(ANALYTICS_CONFIG.userIdFile, "utf8").trim();
98
+ if (userId) return userId;
99
+ }
100
+
101
+ // Generate new anonymous user ID
102
+ const userId = this.generateAnonymousId();
103
+
104
+ // Save for future use
105
+ fs.writeFileSync(ANALYTICS_CONFIG.userIdFile, userId, "utf8");
106
+
107
+ return userId;
108
+ } catch (error) {
109
+ // If file operations fail, use session-based ID
110
+ return this.generateAnonymousId();
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Generate anonymous user ID based on machine characteristics
116
+ */
117
+ generateAnonymousId() {
118
+ const machineId = [
119
+ os.hostname(),
120
+ os.platform(),
121
+ os.arch(),
122
+ os.homedir(),
123
+ ].join("-");
124
+
125
+ return createHash("sha256").update(machineId).digest("hex").substring(0, 32);
126
+ }
127
+
128
+ /**
129
+ * Generate session ID
130
+ */
131
+ generateSessionId() {
132
+ return randomUUID();
133
+ }
134
+
135
+ /**
136
+ * Track an event
137
+ */
138
+ async trackEvent(eventName, params = {}) {
139
+ if (this.disabled || !ANALYTICS_CONFIG.enabled) return;
140
+
141
+ try {
142
+ const payload = this.buildPayload(eventName, params);
143
+
144
+ // Send to GA4 Measurement Protocol (non-blocking)
145
+ axios.post(
146
+ `https://www.google-analytics.com/mp/collect?measurement_id=${FIREBASE_CONFIG.measurementId}&api_secret=${FIREBASE_CONFIG.apiSecret}`,
147
+ payload,
148
+ {
149
+ timeout: ANALYTICS_CONFIG.timeout,
150
+ headers: { "Content-Type": "application/json" },
151
+ }
152
+ ).catch((error) => {
153
+ // Silently fail - don't interrupt CLI operations
154
+ if (ANALYTICS_CONFIG.debug) {
155
+ console.error("[Analytics] Failed to send event:", error.message);
156
+ }
157
+ });
158
+
159
+ if (ANALYTICS_CONFIG.debug) {
160
+ console.log("[Analytics] Event tracked:", eventName, params);
161
+ }
162
+ } catch (error) {
163
+ // Silently fail
164
+ if (ANALYTICS_CONFIG.debug) {
165
+ console.error("[Analytics] Error tracking event:", error.message);
166
+ }
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Build GA4 Measurement Protocol payload
172
+ */
173
+ buildPayload(eventName, params) {
174
+ return {
175
+ client_id: this.userId,
176
+ events: [
177
+ {
178
+ name: eventName,
179
+ params: {
180
+ session_id: this.sessionId,
181
+ engagement_time_msec: "100",
182
+ ...this.getSystemInfo(),
183
+ ...params,
184
+ },
185
+ },
186
+ ],
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Get system information for context
192
+ */
193
+ getSystemInfo() {
194
+ return {
195
+ os_platform: os.platform(),
196
+ os_version: os.release(),
197
+ os_arch: os.arch(),
198
+ node_version: process.version,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Track CLI start
204
+ */
205
+ async trackCliStart(port, subdomain, version) {
206
+ await this.trackEvent("cli_start", {
207
+ port: String(port),
208
+ has_custom_subdomain: subdomain && !subdomain.startsWith("user-"),
209
+ cli_version: version,
210
+ });
211
+ }
212
+
213
+ /**
214
+ * Track tunnel creation
215
+ */
216
+ async trackTunnelCreated(subdomain, port) {
217
+ await this.trackEvent("tunnel_created", {
218
+ subdomain_type: subdomain.startsWith("user-") ? "random" : "custom",
219
+ port: String(port),
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Track tunnel error
225
+ */
226
+ async trackTunnelError(errorType, errorMessage) {
227
+ await this.trackEvent("tunnel_error", {
228
+ error_type: errorType,
229
+ error_message: errorMessage.substring(0, 100), // Limit length
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Track tunnel shutdown
235
+ */
236
+ async trackTunnelShutdown(reason, durationSeconds) {
237
+ await this.trackEvent("tunnel_shutdown", {
238
+ shutdown_reason: reason, // "manual", "timeout", "error"
239
+ duration_seconds: String(Math.floor(durationSeconds)),
240
+ });
241
+ }
242
+
243
+ /**
244
+ * Track CLI update notification shown
245
+ */
246
+ async trackUpdateAvailable(currentVersion, latestVersion) {
247
+ await this.trackEvent("update_available", {
248
+ current_version: currentVersion,
249
+ latest_version: latestVersion,
250
+ });
251
+ }
252
+ }
253
+
254
+ // ============================================================================
255
+ // Export singleton instance
256
+ // ============================================================================
257
+
258
+ export const analytics = new AnalyticsManager();
259
+
package/index.js CHANGED
@@ -8,6 +8,7 @@ import fs from "fs";
8
8
  import path from "path";
9
9
  import { fileURLToPath } from "url";
10
10
  import { createRequire } from "module";
11
+ import { analytics } from "./analytics.js";
11
12
 
12
13
  // ============================================================================
13
14
  // Module Setup & Constants
@@ -79,12 +80,16 @@ class TunnelState {
79
80
  this.tunnelProcess = null;
80
81
  this.timeoutId = null;
81
82
  this.connectionCount = 0;
83
+ this.startTime = null;
82
84
  }
83
85
 
84
86
  setTunnel(tunnelId, subdomain, port) {
85
87
  this.tunnelId = tunnelId;
86
88
  this.subdomain = subdomain;
87
89
  this.port = port;
90
+ if (!this.startTime) {
91
+ this.startTime = Date.now();
92
+ }
88
93
  }
89
94
 
90
95
  setProcess(process) {
@@ -115,6 +120,11 @@ class TunnelState {
115
120
  return this.tunnelProcess && !this.tunnelProcess.killed;
116
121
  }
117
122
 
123
+ getDurationSeconds() {
124
+ if (!this.startTime) return 0;
125
+ return (Date.now() - this.startTime) / 1000;
126
+ }
127
+
118
128
  reset() {
119
129
  this.clearTimeout();
120
130
  this.tunnelId = null;
@@ -122,6 +132,7 @@ class TunnelState {
122
132
  this.port = null;
123
133
  this.tunnelProcess = null;
124
134
  this.connectionCount = 0;
135
+ this.startTime = null;
125
136
  }
126
137
  }
127
138
 
@@ -337,6 +348,11 @@ class VersionManager {
337
348
  const shouldUpdate =
338
349
  this.compareVersions(latestVersion, CONFIG.CURRENT_VERSION) > 0;
339
350
 
351
+ // Track update notification if available
352
+ if (shouldUpdate) {
353
+ analytics.trackUpdateAvailable(CONFIG.CURRENT_VERSION, latestVersion);
354
+ }
355
+
340
356
  return {
341
357
  current: CONFIG.CURRENT_VERSION,
342
358
  latest: latestVersion,
@@ -352,9 +368,16 @@ class VersionManager {
352
368
  const parts1 = v1.split(".").map(Number);
353
369
  const parts2 = v2.split(".").map(Number);
354
370
 
355
- for (let i = 0; i < 3; i++) {
356
- if (parts1[i] > parts2[i]) return 1;
357
- if (parts1[i] < parts2[i]) return -1;
371
+ // Compare up to the maximum length of both version arrays
372
+ const maxLength = Math.max(parts1.length, parts2.length);
373
+
374
+ for (let i = 0; i < maxLength; i++) {
375
+ // Treat missing parts as 0 (e.g., "1.0" is "1.0.0")
376
+ const part1 = parts1[i] || 0;
377
+ const part2 = parts2[i] || 0;
378
+
379
+ if (part1 > part2) return 1;
380
+ if (part1 < part2) return -1;
358
381
  }
359
382
 
360
383
  return 0;
@@ -370,6 +393,13 @@ class UI {
370
393
  if (!updateInfo || !updateInfo.shouldUpdate) return;
371
394
 
372
395
  const border = "═".repeat(59);
396
+ const boxWidth = 59;
397
+
398
+ // Calculate padding dynamically
399
+ const currentVersionText = ` Current version: v${updateInfo.current}`;
400
+ const latestVersionText = ` Latest version: v${updateInfo.latest}`;
401
+ const runCommandText = ` Run: npm install -g ${CONFIG.PACKAGE_NAME}@latest`;
402
+
373
403
  console.log(chalk.yellow(`\n╔${border}╗`));
374
404
  console.log(
375
405
  chalk.yellow("║") +
@@ -382,14 +412,14 @@ class UI {
382
412
  chalk.yellow("║") +
383
413
  chalk.gray(` Current version: `) +
384
414
  chalk.red(`v${updateInfo.current}`) +
385
- " ".repeat(26) +
415
+ " ".repeat(boxWidth - currentVersionText.length) +
386
416
  chalk.yellow("║")
387
417
  );
388
418
  console.log(
389
419
  chalk.yellow("║") +
390
420
  chalk.gray(` Latest version: `) +
391
421
  chalk.green(`v${updateInfo.latest}`) +
392
- " ".repeat(26) +
422
+ " ".repeat(boxWidth - latestVersionText.length) +
393
423
  chalk.yellow("║")
394
424
  );
395
425
  console.log(chalk.yellow(`╠${border}╣`));
@@ -397,7 +427,7 @@ class UI {
397
427
  chalk.yellow("║") +
398
428
  chalk.cyan(` Run: `) +
399
429
  chalk.bold(`npm install -g ${CONFIG.PACKAGE_NAME}@latest`) +
400
- " ".repeat(10) +
430
+ " ".repeat(boxWidth - runCommandText.length) +
401
431
  chalk.yellow("║")
402
432
  );
403
433
  console.log(chalk.yellow(`╚${border}╝\n`));
@@ -498,6 +528,12 @@ class TunnelOrchestrator {
498
528
  static async start(config) {
499
529
  state.setTunnel(null, config.subdomain, config.port);
500
530
 
531
+ // Initialize analytics
532
+ await analytics.initialize();
533
+
534
+ // Track CLI start
535
+ analytics.trackCliStart(config.port, config.subdomain, CONFIG.CURRENT_VERSION);
536
+
501
537
  // Display UI
502
538
  UI.displayStartupBanner(config.port);
503
539
 
@@ -507,6 +543,9 @@ class TunnelOrchestrator {
507
543
 
508
544
  // Validate binary
509
545
  if (!BinaryManager.validate(PATHS.BIN_PATH)) {
546
+ analytics.trackTunnelError("binary_missing", "Cloudflared binary not found");
547
+ // Give analytics a moment to send before exiting
548
+ await new Promise(resolve => setTimeout(resolve, 100));
510
549
  process.exit(1);
511
550
  }
512
551
 
@@ -517,6 +556,9 @@ class TunnelOrchestrator {
517
556
  const tunnel = await APIClient.createTunnel(config.subdomain);
518
557
  state.setTunnel(tunnel.tunnelId, config.subdomain, config.port);
519
558
 
559
+ // Track successful tunnel creation
560
+ analytics.trackTunnelCreated(config.subdomain, config.port);
561
+
520
562
  spinner.succeed(chalk.green("Tunnel created!"));
521
563
  UI.displayTunnelSuccess(tunnel.url);
522
564
 
@@ -532,16 +574,24 @@ class TunnelOrchestrator {
532
574
  // Set timeout
533
575
  const timeoutId = setTimeout(() => {
534
576
  UI.displayTimeoutWarning();
535
- this.cleanup();
577
+ this.cleanup("timeout");
536
578
  }, TUNNEL_TIMEOUT_MS);
537
579
  state.setTimeout(timeoutId);
538
580
  } catch (error) {
581
+ // Track tunnel creation error
582
+ const errorType = error.message.includes("already taken")
583
+ ? "subdomain_taken"
584
+ : "tunnel_creation_failed";
585
+ analytics.trackTunnelError(errorType, error.message);
586
+
539
587
  UI.displayError(error, spinner);
588
+ // Give analytics a moment to send before exiting
589
+ await new Promise(resolve => setTimeout(resolve, 100));
540
590
  process.exit(1);
541
591
  }
542
592
  }
543
593
 
544
- static async cleanup() {
594
+ static async cleanup(reason = "manual") {
545
595
  state.clearTimeout();
546
596
 
547
597
  if (!state.hasTunnel()) {
@@ -550,6 +600,10 @@ class TunnelOrchestrator {
550
600
 
551
601
  UI.displayCleanupStart();
552
602
 
603
+ // Track tunnel shutdown with duration
604
+ const duration = state.getDurationSeconds();
605
+ analytics.trackTunnelShutdown(reason, duration);
606
+
553
607
  try {
554
608
  // Kill process
555
609
  if (state.hasProcess()) {
@@ -563,6 +617,9 @@ class TunnelOrchestrator {
563
617
  UI.displayCleanupError();
564
618
  }
565
619
 
620
+ // Give analytics a moment to send (non-blocking)
621
+ await new Promise(resolve => setTimeout(resolve, 100));
622
+
566
623
  process.exit(0);
567
624
  }
568
625
  }
@@ -586,4 +643,4 @@ process.on("SIGINT", () => TunnelOrchestrator.cleanup());
586
643
  process.on("SIGTERM", () => TunnelOrchestrator.cleanup());
587
644
 
588
645
  // Start application
589
- main();
646
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nport",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Free & open source ngrok alternative - Tunnel HTTP/HTTPS connections via Cloudflare Edge with custom subdomains",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -73,6 +73,7 @@
73
73
  },
74
74
  "files": [
75
75
  "index.js",
76
+ "analytics.js",
76
77
  "bin-manager.js",
77
78
  "README.md",
78
79
  "LICENSE"