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.
- package/analytics.js +259 -0
- package/index.js +52 -6
- 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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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.
|
|
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"
|