nport 1.0.5 → 2.0.1

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/index.js ADDED
@@ -0,0 +1,589 @@
1
+ #!/usr/bin/env node
2
+
3
+ import axios from "axios";
4
+ import { spawn } from "child_process";
5
+ import chalk from "chalk";
6
+ import ora from "ora";
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import { fileURLToPath } from "url";
10
+ import { createRequire } from "module";
11
+
12
+ // ============================================================================
13
+ // Module Setup & Constants
14
+ // ============================================================================
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = path.dirname(__filename);
18
+ const require = createRequire(import.meta.url);
19
+ const packageJson = require("./package.json");
20
+
21
+ // Application constants
22
+ const CONFIG = {
23
+ PACKAGE_NAME: packageJson.name,
24
+ CURRENT_VERSION: packageJson.version,
25
+ BACKEND_URL: "https://nport.tuanngocptn.workers.dev",
26
+ DEFAULT_PORT: 8080,
27
+ SUBDOMAIN_PREFIX: "user-",
28
+ TUNNEL_TIMEOUT_HOURS: 4,
29
+ UPDATE_CHECK_TIMEOUT: 3000,
30
+ };
31
+
32
+ // Platform-specific configuration
33
+ const PLATFORM = {
34
+ IS_WINDOWS: process.platform === "win32",
35
+ BIN_NAME: process.platform === "win32" ? "cloudflared.exe" : "cloudflared",
36
+ };
37
+
38
+ // Paths
39
+ const PATHS = {
40
+ BIN_DIR: path.join(__dirname, "bin"),
41
+ BIN_PATH: path.join(__dirname, "bin", PLATFORM.BIN_NAME),
42
+ };
43
+
44
+ // Log patterns for filtering cloudflared output
45
+ const LOG_PATTERNS = {
46
+ SUCCESS: ["Registered tunnel connection"],
47
+ ERROR: ["ERR", "error"],
48
+ IGNORE: [
49
+ "Cannot determine default origin certificate path",
50
+ "No file cert.pem",
51
+ "origincert option",
52
+ "TUNNEL_ORIGIN_CERT",
53
+ "context canceled",
54
+ "failed to run the datagram handler",
55
+ "failed to serve tunnel connection",
56
+ "Connection terminated",
57
+ "no more connections active and exiting",
58
+ "Serve tunnel error",
59
+ "accept stream listener encountered a failure",
60
+ "Retrying connection",
61
+ "icmp router terminated",
62
+ "use of closed network connection",
63
+ "Application error 0x0",
64
+ ],
65
+ };
66
+
67
+ // Computed constants
68
+ const TUNNEL_TIMEOUT_MS = CONFIG.TUNNEL_TIMEOUT_HOURS * 60 * 60 * 1000;
69
+
70
+ // ============================================================================
71
+ // Application State
72
+ // ============================================================================
73
+
74
+ class TunnelState {
75
+ constructor() {
76
+ this.tunnelId = null;
77
+ this.subdomain = null;
78
+ this.port = null;
79
+ this.tunnelProcess = null;
80
+ this.timeoutId = null;
81
+ this.connectionCount = 0;
82
+ }
83
+
84
+ setTunnel(tunnelId, subdomain, port) {
85
+ this.tunnelId = tunnelId;
86
+ this.subdomain = subdomain;
87
+ this.port = port;
88
+ }
89
+
90
+ setProcess(process) {
91
+ this.tunnelProcess = process;
92
+ }
93
+
94
+ setTimeout(timeoutId) {
95
+ this.timeoutId = timeoutId;
96
+ }
97
+
98
+ clearTimeout() {
99
+ if (this.timeoutId) {
100
+ clearTimeout(this.timeoutId);
101
+ this.timeoutId = null;
102
+ }
103
+ }
104
+
105
+ incrementConnection() {
106
+ this.connectionCount++;
107
+ return this.connectionCount;
108
+ }
109
+
110
+ hasTunnel() {
111
+ return this.tunnelId !== null;
112
+ }
113
+
114
+ hasProcess() {
115
+ return this.tunnelProcess && !this.tunnelProcess.killed;
116
+ }
117
+
118
+ reset() {
119
+ this.clearTimeout();
120
+ this.tunnelId = null;
121
+ this.subdomain = null;
122
+ this.port = null;
123
+ this.tunnelProcess = null;
124
+ this.connectionCount = 0;
125
+ }
126
+ }
127
+
128
+ const state = new TunnelState();
129
+
130
+ // ============================================================================
131
+ // Argument Parsing
132
+ // ============================================================================
133
+
134
+ class ArgumentParser {
135
+ static parse(argv) {
136
+ const port = this.parsePort(argv);
137
+ const subdomain = this.parseSubdomain(argv);
138
+ return { port, subdomain };
139
+ }
140
+
141
+ static parsePort(argv) {
142
+ const portArg = parseInt(argv[0]);
143
+ return portArg || CONFIG.DEFAULT_PORT;
144
+ }
145
+
146
+ static parseSubdomain(argv) {
147
+ // Try all subdomain formats
148
+ const formats = [
149
+ () => this.findFlagWithEquals(argv, "--subdomain="),
150
+ () => this.findFlagWithEquals(argv, "-s="),
151
+ () => this.findFlagWithValue(argv, "--subdomain"),
152
+ () => this.findFlagWithValue(argv, "-s"),
153
+ ];
154
+
155
+ for (const format of formats) {
156
+ const subdomain = format();
157
+ if (subdomain) return subdomain;
158
+ }
159
+
160
+ return this.generateRandomSubdomain();
161
+ }
162
+
163
+ static findFlagWithEquals(argv, flag) {
164
+ const arg = argv.find((a) => a.startsWith(flag));
165
+ return arg ? arg.split("=")[1] : null;
166
+ }
167
+
168
+ static findFlagWithValue(argv, flag) {
169
+ const index = argv.indexOf(flag);
170
+ return index !== -1 && argv[index + 1] ? argv[index + 1] : null;
171
+ }
172
+
173
+ static generateRandomSubdomain() {
174
+ return `${CONFIG.SUBDOMAIN_PREFIX}${Math.floor(Math.random() * 10000)}`;
175
+ }
176
+ }
177
+
178
+ // ============================================================================
179
+ // Binary Management
180
+ // ============================================================================
181
+
182
+ class BinaryManager {
183
+ static validate(binaryPath) {
184
+ if (fs.existsSync(binaryPath)) {
185
+ return true;
186
+ }
187
+
188
+ console.error(
189
+ chalk.red(`\n❌ Error: Cloudflared binary not found at: ${binaryPath}`)
190
+ );
191
+ console.error(
192
+ chalk.yellow(
193
+ "👉 Please run 'npm install' again to download the binary.\n"
194
+ )
195
+ );
196
+ return false;
197
+ }
198
+
199
+ static spawn(binaryPath, token, port) {
200
+ return spawn(binaryPath, [
201
+ "tunnel",
202
+ "run",
203
+ "--token",
204
+ token,
205
+ "--url",
206
+ `http://localhost:${port}`,
207
+ ]);
208
+ }
209
+
210
+ static attachHandlers(process, spinner = null) {
211
+ process.stderr.on("data", (chunk) => this.handleStderr(chunk));
212
+ process.on("error", (err) => this.handleError(err, spinner));
213
+ process.on("close", (code) => this.handleClose(code));
214
+ }
215
+
216
+ static handleStderr(chunk) {
217
+ const msg = chunk.toString();
218
+
219
+ // Skip harmless warnings
220
+ if (LOG_PATTERNS.IGNORE.some((pattern) => msg.includes(pattern))) {
221
+ return;
222
+ }
223
+
224
+ // Show success messages with connection count
225
+ if (LOG_PATTERNS.SUCCESS.some((pattern) => msg.includes(pattern))) {
226
+ const count = state.incrementConnection();
227
+
228
+ const messages = [
229
+ "✔ Connection established [1/4] - Establishing redundancy...",
230
+ "✔ Connection established [2/4] - Building tunnel network...",
231
+ "✔ Connection established [3/4] - Almost there...",
232
+ "✔ Connection established [4/4] - Tunnel is fully active! 🚀",
233
+ ];
234
+
235
+ if (count <= 4) {
236
+ console.log(chalk.blueBright(messages[count - 1]));
237
+ }
238
+ return;
239
+ }
240
+
241
+ // Show critical errors only
242
+ if (LOG_PATTERNS.ERROR.some((pattern) => msg.includes(pattern))) {
243
+ console.error(chalk.red(`[Cloudflared] ${msg.trim()}`));
244
+ }
245
+ }
246
+
247
+ static handleError(err, spinner) {
248
+ if (spinner) {
249
+ spinner.fail("Failed to spawn cloudflared process.");
250
+ }
251
+ console.error(chalk.red(`Process Error: ${err.message}`));
252
+ }
253
+
254
+ static handleClose(code) {
255
+ if (code !== 0 && code !== null) {
256
+ console.log(chalk.red(`Tunnel process exited with code ${code}`));
257
+ }
258
+ }
259
+ }
260
+
261
+ // ============================================================================
262
+ // API Client
263
+ // ============================================================================
264
+
265
+ class APIClient {
266
+ static async createTunnel(subdomain) {
267
+ try {
268
+ const { data } = await axios.post(CONFIG.BACKEND_URL, { subdomain });
269
+
270
+ if (!data.success) {
271
+ throw new Error(data.error || "Unknown error from backend");
272
+ }
273
+
274
+ return {
275
+ tunnelId: data.tunnelId,
276
+ tunnelToken: data.tunnelToken,
277
+ url: data.url,
278
+ };
279
+ } catch (error) {
280
+ throw this.handleError(error, subdomain);
281
+ }
282
+ }
283
+
284
+ static async deleteTunnel(subdomain, tunnelId) {
285
+ await axios.delete(CONFIG.BACKEND_URL, {
286
+ data: { subdomain, tunnelId },
287
+ });
288
+ }
289
+
290
+ static handleError(error, subdomain) {
291
+ if (error.response?.data?.error) {
292
+ const errorMsg = error.response.data.error;
293
+
294
+ // Check for duplicate tunnel error
295
+ if (
296
+ errorMsg.includes("already have a tunnel") ||
297
+ errorMsg.includes("[1013]")
298
+ ) {
299
+ return new Error(
300
+ `Subdomain "${subdomain}" is already taken or in use.\n\n` +
301
+ chalk.yellow(`💡 Try one of these options:\n`) +
302
+ chalk.gray(` 1. Choose a different subdomain: `) +
303
+ chalk.cyan(`nport ${state.port || CONFIG.DEFAULT_PORT} -s ${subdomain}-v2\n`) +
304
+ chalk.gray(` 2. Use a random subdomain: `) +
305
+ chalk.cyan(`nport ${state.port || CONFIG.DEFAULT_PORT}\n`) +
306
+ chalk.gray(
307
+ ` 3. Wait a few minutes and retry if you just stopped a tunnel with this name`
308
+ )
309
+ );
310
+ }
311
+
312
+ return new Error(`Backend Error: ${errorMsg}`);
313
+ }
314
+
315
+ if (error.response) {
316
+ const errorMsg = JSON.stringify(error.response.data, null, 2);
317
+ return new Error(`Backend Error: ${errorMsg}`);
318
+ }
319
+
320
+ return error;
321
+ }
322
+ }
323
+
324
+ // ============================================================================
325
+ // Version Management
326
+ // ============================================================================
327
+
328
+ class VersionManager {
329
+ static async checkForUpdates() {
330
+ try {
331
+ const response = await axios.get(
332
+ `https://registry.npmjs.org/${CONFIG.PACKAGE_NAME}/latest`,
333
+ { timeout: CONFIG.UPDATE_CHECK_TIMEOUT }
334
+ );
335
+
336
+ const latestVersion = response.data.version;
337
+ const shouldUpdate =
338
+ this.compareVersions(latestVersion, CONFIG.CURRENT_VERSION) > 0;
339
+
340
+ return {
341
+ current: CONFIG.CURRENT_VERSION,
342
+ latest: latestVersion,
343
+ shouldUpdate,
344
+ };
345
+ } catch (error) {
346
+ // Silently fail if can't check for updates
347
+ return null;
348
+ }
349
+ }
350
+
351
+ static compareVersions(v1, v2) {
352
+ const parts1 = v1.split(".").map(Number);
353
+ const parts2 = v2.split(".").map(Number);
354
+
355
+ for (let i = 0; i < 3; i++) {
356
+ if (parts1[i] > parts2[i]) return 1;
357
+ if (parts1[i] < parts2[i]) return -1;
358
+ }
359
+
360
+ return 0;
361
+ }
362
+ }
363
+
364
+ // ============================================================================
365
+ // UI Display
366
+ // ============================================================================
367
+
368
+ class UI {
369
+ static displayUpdateNotification(updateInfo) {
370
+ if (!updateInfo || !updateInfo.shouldUpdate) return;
371
+
372
+ const border = "═".repeat(59);
373
+ console.log(chalk.yellow(`\n╔${border}╗`));
374
+ console.log(
375
+ chalk.yellow("║") +
376
+ chalk.bold.yellow(" 📦 Update Available!") +
377
+ " ".repeat(37) +
378
+ chalk.yellow("║")
379
+ );
380
+ console.log(chalk.yellow(`╠${border}╣`));
381
+ console.log(
382
+ chalk.yellow("║") +
383
+ chalk.gray(` Current version: `) +
384
+ chalk.red(`v${updateInfo.current}`) +
385
+ " ".repeat(26) +
386
+ chalk.yellow("║")
387
+ );
388
+ console.log(
389
+ chalk.yellow("║") +
390
+ chalk.gray(` Latest version: `) +
391
+ chalk.green(`v${updateInfo.latest}`) +
392
+ " ".repeat(26) +
393
+ chalk.yellow("║")
394
+ );
395
+ console.log(chalk.yellow(`╠${border}╣`));
396
+ console.log(
397
+ chalk.yellow("║") +
398
+ chalk.cyan(` Run: `) +
399
+ chalk.bold(`npm install -g ${CONFIG.PACKAGE_NAME}@latest`) +
400
+ " ".repeat(10) +
401
+ chalk.yellow("║")
402
+ );
403
+ console.log(chalk.yellow(`╚${border}╝\n`));
404
+ }
405
+
406
+ static displayProjectInfo() {
407
+ const line = "─".repeat(65);
408
+ console.log(chalk.gray(`\n${line}`));
409
+ console.log(
410
+ chalk.cyan.bold("NPort") +
411
+ chalk.gray(" - ngrok who? (Free & Open Source from Vietnam)")
412
+ );
413
+ console.log(chalk.gray(line));
414
+ console.log(
415
+ chalk.magenta("⚡ Built different: ") +
416
+ chalk.white("No cap, actually free forever")
417
+ );
418
+ console.log(
419
+ chalk.gray("🌐 Website: ") +
420
+ chalk.blue("https://nport.link")
421
+ );
422
+ console.log(
423
+ chalk.gray("📦 NPM: ") +
424
+ chalk.blue("npm i -g nport")
425
+ );
426
+ console.log(
427
+ chalk.gray("💻 GitHub: ") +
428
+ chalk.blue("https://github.com/tuanngocptn/nport")
429
+ );
430
+ console.log(
431
+ chalk.gray("👤 Made by: ") +
432
+ chalk.cyan("@tuanngocptn") +
433
+ chalk.gray(" (") +
434
+ chalk.blue("https://github.com/tuanngocptn") +
435
+ chalk.gray(")")
436
+ );
437
+ console.log(
438
+ chalk.gray("☕ Buy me coffee: ") +
439
+ chalk.yellow("https://buymeacoffee.com/tuanngocptn")
440
+ );
441
+ console.log(chalk.gray(line));
442
+ console.log(chalk.dim("💭 No paywalls. No BS. Just vibes. ✨"));
443
+ console.log(chalk.gray(`${line}\n`));
444
+ }
445
+
446
+ static displayStartupBanner(port) {
447
+ this.displayProjectInfo();
448
+ console.log(chalk.green(`🚀 Starting Tunnel for port ${port}...`));
449
+ }
450
+
451
+ static displayTunnelSuccess(url) {
452
+ console.log(chalk.yellow(`🌍 Public URL: ${chalk.bold(url)}`));
453
+ console.log(chalk.gray(` (Using bundled binary)`));
454
+ console.log(
455
+ chalk.gray(` Auto-cleanup in ${CONFIG.TUNNEL_TIMEOUT_HOURS} hours`)
456
+ );
457
+ console.log(chalk.gray("Connecting to global network..."));
458
+ }
459
+
460
+ static displayTimeoutWarning() {
461
+ console.log(
462
+ chalk.yellow(
463
+ `\n⏰ Tunnel has been running for ${CONFIG.TUNNEL_TIMEOUT_HOURS} hours.`
464
+ )
465
+ );
466
+ console.log(chalk.yellow(" Automatically shutting down..."));
467
+ }
468
+
469
+ static displayError(error, spinner = null) {
470
+ if (spinner) {
471
+ spinner.fail("Failed to connect to server.");
472
+ }
473
+ console.error(chalk.red(error.message));
474
+ }
475
+
476
+ static displayCleanupStart() {
477
+ console.log(
478
+ chalk.yellow("\n\n🛑 Shutting down... Cleaning up resources...")
479
+ );
480
+ }
481
+
482
+ static displayCleanupSuccess() {
483
+ console.log(chalk.green("✔ Cleanup successful. Subdomain released."));
484
+ }
485
+
486
+ static displayCleanupError() {
487
+ console.error(
488
+ chalk.red("✖ Cleanup failed (Server might be down or busy).")
489
+ );
490
+ }
491
+ }
492
+
493
+ // ============================================================================
494
+ // Tunnel Orchestrator
495
+ // ============================================================================
496
+
497
+ class TunnelOrchestrator {
498
+ static async start(config) {
499
+ state.setTunnel(null, config.subdomain, config.port);
500
+
501
+ // Display UI
502
+ UI.displayStartupBanner(config.port);
503
+
504
+ // Check for updates
505
+ const updateInfo = await VersionManager.checkForUpdates();
506
+ UI.displayUpdateNotification(updateInfo);
507
+
508
+ // Validate binary
509
+ if (!BinaryManager.validate(PATHS.BIN_PATH)) {
510
+ process.exit(1);
511
+ }
512
+
513
+ const spinner = ora("Requesting access...").start();
514
+
515
+ try {
516
+ // Create tunnel
517
+ const tunnel = await APIClient.createTunnel(config.subdomain);
518
+ state.setTunnel(tunnel.tunnelId, config.subdomain, config.port);
519
+
520
+ spinner.succeed(chalk.green("Tunnel created!"));
521
+ UI.displayTunnelSuccess(tunnel.url);
522
+
523
+ // Spawn cloudflared
524
+ const process = BinaryManager.spawn(
525
+ PATHS.BIN_PATH,
526
+ tunnel.tunnelToken,
527
+ config.port
528
+ );
529
+ state.setProcess(process);
530
+ BinaryManager.attachHandlers(process, spinner);
531
+
532
+ // Set timeout
533
+ const timeoutId = setTimeout(() => {
534
+ UI.displayTimeoutWarning();
535
+ this.cleanup();
536
+ }, TUNNEL_TIMEOUT_MS);
537
+ state.setTimeout(timeoutId);
538
+ } catch (error) {
539
+ UI.displayError(error, spinner);
540
+ process.exit(1);
541
+ }
542
+ }
543
+
544
+ static async cleanup() {
545
+ state.clearTimeout();
546
+
547
+ if (!state.hasTunnel()) {
548
+ process.exit(0);
549
+ }
550
+
551
+ UI.displayCleanupStart();
552
+
553
+ try {
554
+ // Kill process
555
+ if (state.hasProcess()) {
556
+ state.tunnelProcess.kill();
557
+ }
558
+
559
+ // Delete tunnel
560
+ await APIClient.deleteTunnel(state.subdomain, state.tunnelId);
561
+ UI.displayCleanupSuccess();
562
+ } catch (err) {
563
+ UI.displayCleanupError();
564
+ }
565
+
566
+ process.exit(0);
567
+ }
568
+ }
569
+
570
+ // ============================================================================
571
+ // Application Entry Point
572
+ // ============================================================================
573
+
574
+ async function main() {
575
+ try {
576
+ const config = ArgumentParser.parse(process.argv.slice(2));
577
+ await TunnelOrchestrator.start(config);
578
+ } catch (error) {
579
+ console.error(chalk.red(`Fatal Error: ${error.message}`));
580
+ process.exit(1);
581
+ }
582
+ }
583
+
584
+ // Register cleanup handlers
585
+ process.on("SIGINT", () => TunnelOrchestrator.cleanup());
586
+ process.on("SIGTERM", () => TunnelOrchestrator.cleanup());
587
+
588
+ // Start application
589
+ main();
package/package.json CHANGED
@@ -1,21 +1,50 @@
1
1
  {
2
2
  "name": "nport",
3
- "version": "1.0.5",
4
- "description": "Tunnel HTTP Connections via socket.io streams.",
5
- "keywords": ["tunnel", "socket.io", "http", "streaming", "networking", "proxy"],
6
- "homepage": "https://nport.link",
7
- "bugs": {
8
- "url": "https://github.com/tuanngocptn/nport/issues",
9
- "email": "tuanngocptn@gmail.com"
3
+ "version": "2.0.1",
4
+ "description": "Free & open source ngrok alternative - Tunnel HTTP/HTTPS connections via Cloudflare Edge with custom subdomains",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "nport": "index.js"
9
+ },
10
+ "engines": {
11
+ "node": ">=18.0.0",
12
+ "npm": ">=8.0.0"
10
13
  },
14
+ "keywords": [
15
+ "ngrok",
16
+ "ngrok-alternative",
17
+ "tunnel",
18
+ "cloudflare",
19
+ "cloudflared",
20
+ "http-tunnel",
21
+ "https-tunnel",
22
+ "localhost",
23
+ "localhost-tunnel",
24
+ "port-forwarding",
25
+ "webhook",
26
+ "webhook-testing",
27
+ "development",
28
+ "proxy",
29
+ "networking",
30
+ "custom-subdomain",
31
+ "free-ngrok",
32
+ "localtunnel"
33
+ ],
34
+ "homepage": "https://nport.link",
11
35
  "repository": {
12
36
  "type": "git",
13
37
  "url": "git+https://github.com/tuanngocptn/nport.git"
14
38
  },
39
+ "bugs": {
40
+ "url": "https://github.com/tuanngocptn/nport/issues",
41
+ "email": "tuanngocptn@gmail.com"
42
+ },
15
43
  "license": "MIT",
16
44
  "author": {
17
45
  "name": "Nick Pham",
18
- "email": "tuanngocptn@gmail.com"
46
+ "email": "tuanngocptn@gmail.com",
47
+ "url": "https://github.com/tuanngocptn"
19
48
  },
20
49
  "contributors": [
21
50
  {
@@ -23,32 +52,34 @@
23
52
  "email": "ebarch@nooplabs.com"
24
53
  }
25
54
  ],
26
- "dependencies": {
27
- "is-valid-domain": "0.0.5",
28
- "optimist": "^0.6.1",
29
- "socket.io": "^2.1.0",
30
- "socket.io-client": "^2.1.0",
31
- "socket.io-stream": "^0.9.1",
32
- "tldjs": "^2.3.1",
33
- "uuid": "^3.2.1"
34
- },
35
- "bin": {
36
- "nport": "./bin/client"
55
+ "funding": [
56
+ {
57
+ "type": "individual",
58
+ "url": "https://github.com/sponsors/tuanngocptn"
59
+ },
60
+ {
61
+ "type": "buymeacoffee",
62
+ "url": "https://buymeacoffee.com/tuanngocptn"
63
+ }
64
+ ],
65
+ "scripts": {
66
+ "postinstall": "node bin-manager.js",
67
+ "start": "node index.js"
37
68
  },
38
- "engines": {
39
- "node": ">=14.0.0",
40
- "npm": ">=6.0.0"
69
+ "dependencies": {
70
+ "axios": "^1.13.2",
71
+ "chalk": "^5.6.2",
72
+ "ora": "^9.0.0"
41
73
  },
42
74
  "files": [
43
- "bin/",
44
- "client.js",
45
- "server.js",
46
- "lib/",
47
- "examples/",
48
- "nginx.conf.sample"
75
+ "index.js",
76
+ "bin-manager.js",
77
+ "README.md",
78
+ "LICENSE"
49
79
  ],
50
- "funding": {
51
- "type": "individual",
52
- "url": "https://github.com/sponsors/tuanngocptn"
53
- }
54
- }
80
+ "os": [
81
+ "darwin",
82
+ "linux",
83
+ "win32"
84
+ ]
85
+ }