nport 2.0.1 → 2.0.2

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 +52 -6
  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;
@@ -498,6 +521,12 @@ class TunnelOrchestrator {
498
521
  static async start(config) {
499
522
  state.setTunnel(null, config.subdomain, config.port);
500
523
 
524
+ // Initialize analytics
525
+ await analytics.initialize();
526
+
527
+ // Track CLI start
528
+ analytics.trackCliStart(config.port, config.subdomain, CONFIG.CURRENT_VERSION);
529
+
501
530
  // Display UI
502
531
  UI.displayStartupBanner(config.port);
503
532
 
@@ -507,6 +536,7 @@ class TunnelOrchestrator {
507
536
 
508
537
  // Validate binary
509
538
  if (!BinaryManager.validate(PATHS.BIN_PATH)) {
539
+ await analytics.trackTunnelError("binary_missing", "Cloudflared binary not found");
510
540
  process.exit(1);
511
541
  }
512
542
 
@@ -517,6 +547,9 @@ class TunnelOrchestrator {
517
547
  const tunnel = await APIClient.createTunnel(config.subdomain);
518
548
  state.setTunnel(tunnel.tunnelId, config.subdomain, config.port);
519
549
 
550
+ // Track successful tunnel creation
551
+ analytics.trackTunnelCreated(config.subdomain, config.port);
552
+
520
553
  spinner.succeed(chalk.green("Tunnel created!"));
521
554
  UI.displayTunnelSuccess(tunnel.url);
522
555
 
@@ -532,16 +565,22 @@ class TunnelOrchestrator {
532
565
  // Set timeout
533
566
  const timeoutId = setTimeout(() => {
534
567
  UI.displayTimeoutWarning();
535
- this.cleanup();
568
+ this.cleanup("timeout");
536
569
  }, TUNNEL_TIMEOUT_MS);
537
570
  state.setTimeout(timeoutId);
538
571
  } catch (error) {
572
+ // Track tunnel creation error
573
+ const errorType = error.message.includes("already taken")
574
+ ? "subdomain_taken"
575
+ : "tunnel_creation_failed";
576
+ analytics.trackTunnelError(errorType, error.message);
577
+
539
578
  UI.displayError(error, spinner);
540
579
  process.exit(1);
541
580
  }
542
581
  }
543
582
 
544
- static async cleanup() {
583
+ static async cleanup(reason = "manual") {
545
584
  state.clearTimeout();
546
585
 
547
586
  if (!state.hasTunnel()) {
@@ -550,6 +589,10 @@ class TunnelOrchestrator {
550
589
 
551
590
  UI.displayCleanupStart();
552
591
 
592
+ // Track tunnel shutdown with duration
593
+ const duration = state.getDurationSeconds();
594
+ analytics.trackTunnelShutdown(reason, duration);
595
+
553
596
  try {
554
597
  // Kill process
555
598
  if (state.hasProcess()) {
@@ -563,6 +606,9 @@ class TunnelOrchestrator {
563
606
  UI.displayCleanupError();
564
607
  }
565
608
 
609
+ // Give analytics a moment to send (non-blocking)
610
+ await new Promise(resolve => setTimeout(resolve, 100));
611
+
566
612
  process.exit(0);
567
613
  }
568
614
  }
@@ -586,4 +632,4 @@ process.on("SIGINT", () => TunnelOrchestrator.cleanup());
586
632
  process.on("SIGTERM", () => TunnelOrchestrator.cleanup());
587
633
 
588
634
  // Start application
589
- main();
635
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nport",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
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"