a2acalling 0.5.4 → 0.6.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.
package/README.md CHANGED
@@ -33,7 +33,7 @@ a2a create --name "My Agent" --owner "Your Name" --tier friends
33
33
  # Output:
34
34
  # 🤝 Your Name is inviting you to connect agents!
35
35
  # Your agent can reach My Agent for: chat, web, files
36
- # a2a://random-name.trycloudflare.com:443/fed_abc123xyz
36
+ # a2a://your-host.com/fed_abc123xyz
37
37
  ```
38
38
 
39
39
  ### Call someone else's agent
@@ -74,9 +74,9 @@ node scripts/install-openclaw.js setup
74
74
 
75
75
  Setup behavior:
76
76
  - Runtime auto-detects OpenClaw when available and falls back to generic mode if unavailable.
77
- - If OpenClaw gateway is detected, dashboard is exposed on gateway at `/a2a` (proxied to A2A backend).
77
+ - If OpenClaw gateway is detected, dashboard is exposed on gateway at `/a2a` and A2A API at `/api/a2a/*` (proxied to A2A backend).
78
78
  - If OpenClaw is not detected, setup bootstraps standalone config + bridge templates and serves dashboard at `/dashboard`.
79
- - If no public hostname is configured, setup defaults to secure Cloudflare Quick Tunnel for internet-facing invites.
79
+ - Setup inspects port 80 and prints reverse proxy guidance for stable internet-facing ingress.
80
80
  - Setup prints the exact dashboard URL at the end.
81
81
 
82
82
  Before the first `a2a call`, the owner must set permissions and disclosure tiers. Run onboarding first:
@@ -369,9 +369,8 @@ app.listen(3001);
369
369
 
370
370
  | Variable | Description |
371
371
  |----------|-------------|
372
- | `A2A_HOSTNAME` | Hostname for invite URLs (required for creates) |
372
+ | `A2A_HOSTNAME` | Hostname for invite URLs (required for internet-facing invites) |
373
373
  | `A2A_PORT` | Server port (default: 3001) |
374
- | `A2A_DISABLE_QUICK_TUNNEL` | Set `true` to disable auto Cloudflare Quick Tunnel host resolution |
375
374
  | `A2A_CONFIG_DIR` | Config directory (default: `~/.config/openclaw`) |
376
375
  | `A2A_WORKSPACE` | Workspace root for context files like `USER.md` (default: current directory) |
377
376
  | `A2A_RUNTIME` | Runtime mode: `auto` (default), `openclaw`, or `generic` |
package/SKILL.md CHANGED
@@ -53,9 +53,10 @@ Extract: professional context, interests, goals, skills, sensitive areas. Group
53
53
 
54
54
  ## Network Ingress (Internet-Facing Invites)
55
55
 
56
- - By default, A2A now prefers a secure Quick Tunnel endpoint for invite URLs when no public hostname is configured.
57
- - If the owner has custom infrastructure (domain, reverse proxy, managed tunnel), they can set `A2A_HOSTNAME` to their own public endpoint and bypass Quick Tunnel.
58
- - Managed/domain-specific ingress automation is intentionally not bundled yet. Advanced users can implement it based on their own network and security requirements.
56
+ - A2A does not bundle an auto-tunneling service for internet-facing ingress.
57
+ - For stable internet-facing invites, set `A2A_HOSTNAME` to your public endpoint (domain or public IP).
58
+ - Recommended: run the A2A backend on an internal port and expose it via a reverse proxy on `:443` (HTTPS) or `:80` (HTTP), routing `/api/a2a/*` to the backend.
59
+ - `npx a2acalling setup` inspects port 80 and prints reverse proxy guidance + an external reachability check.
59
60
 
60
61
  ## Commands
61
62
 
package/bin/cli.js CHANGED
@@ -170,15 +170,13 @@ async function resolveInviteHostname() {
170
170
  const config = new A2AConfig();
171
171
  const resolved = await resolveInviteHost({
172
172
  config,
173
- defaultPort: process.env.PORT || process.env.A2A_PORT || 3001,
174
- preferQuickTunnel: true
173
+ defaultPort: process.env.PORT || process.env.A2A_PORT || 3001
175
174
  });
176
175
  return resolved;
177
176
  } catch (err) {
178
177
  return resolveInviteHost({
179
178
  fallbackHost: process.env.OPENCLAW_HOSTNAME || process.env.HOSTNAME || 'localhost',
180
- defaultPort: process.env.PORT || process.env.A2A_PORT || 3001,
181
- preferQuickTunnel: true
179
+ defaultPort: process.env.PORT || process.env.A2A_PORT || 3001
182
180
  });
183
181
  }
184
182
  }
package/docs/protocol.md CHANGED
@@ -259,13 +259,16 @@ Owner can reply to inject into the conversation.
259
259
 
260
260
  ## OpenClaw Integration
261
261
 
262
- ### Gateway Route Registration
262
+ ### Gateway Proxy (Recommended)
263
263
 
264
- Add to gateway routes:
265
- ```javascript
266
- const a2a = require('./skills/a2a/scripts/server');
267
- app.use('/api/a2a', a2a);
268
- ```
264
+ Run A2A Calling as its own server (separate process), and let the OpenClaw gateway proxy to it:
265
+
266
+ - A2A backend (separate): `a2a server --port 3001`
267
+ - OpenClaw gateway path(s):
268
+ - `/a2a` proxies to the A2A dashboard UI (`/dashboard`)
269
+ - `/api/a2a/*` proxies to the A2A backend API
270
+
271
+ This keeps the A2A server decoupled from OpenClaw's gateway runtime while still allowing the gateway to be the single public entry point.
269
272
 
270
273
  ### Agent Context
271
274
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -4,11 +4,11 @@
4
4
  *
5
5
  * Supports automatic setup:
6
6
  * - If OpenClaw gateway is detected, install a gateway HTTP proxy plugin
7
- * so dashboard is accessible at /a2a on gateway.
7
+ * so dashboard and A2A API are accessible at /a2a and /api/a2a on gateway.
8
8
  * - If gateway is not detected, dashboard runs on standalone A2A server.
9
9
  * - If OpenClaw is not installed, bootstrap standalone runtime templates.
10
- * - If no public hostname is configured, default to secure Quick Tunnel
11
- * for internet-facing invite URLs (lazy cloudflared download).
10
+ * - Networking-aware: inspects port 80 and prints reverse proxy guidance
11
+ * for stable internet-facing ingress.
12
12
  *
13
13
  * Usage:
14
14
  * npx a2acalling install
@@ -20,6 +20,8 @@
20
20
  const fs = require('fs');
21
21
  const path = require('path');
22
22
  const crypto = require('crypto');
23
+ const http = require('http');
24
+ const https = require('https');
23
25
  const { execSync } = require('child_process');
24
26
 
25
27
  // Paths
@@ -287,8 +289,8 @@ echo "a2a notify: $payload" >&2
287
289
 
288
290
  const DASHBOARD_PLUGIN_MANIFEST = {
289
291
  id: DASHBOARD_PLUGIN_ID,
290
- name: 'A2A Dashboard Proxy',
291
- description: 'Proxy A2A dashboard routes through OpenClaw gateway',
292
+ name: 'A2A Gateway Proxy',
293
+ description: 'Proxy A2A API + dashboard routes through OpenClaw gateway (backend runs separately)',
292
294
  version: '1.0.0',
293
295
  configSchema: {
294
296
  type: 'object',
@@ -309,7 +311,7 @@ import https from "node:https";
309
311
 
310
312
  const PLUGIN_ID = "a2a-dashboard-proxy";
311
313
  const UI_PREFIX = "/a2a";
312
- const API_PREFIX = "/api/a2a/dashboard";
314
+ const API_PREFIX = "/api/a2a";
313
315
 
314
316
  function sendJson(res: ServerResponse, status: number, body: unknown) {
315
317
  res.statusCode = status;
@@ -364,8 +366,8 @@ function rewriteUiPath(pathname: string): string {
364
366
 
365
367
  const plugin = {
366
368
  id: PLUGIN_ID,
367
- name: "A2A Dashboard Proxy",
368
- description: "Proxy A2A dashboard routes through OpenClaw gateway",
369
+ name: "A2A Gateway Proxy",
370
+ description: "Proxy A2A API + dashboard routes through OpenClaw gateway (backend runs separately)",
369
371
  configSchema: {
370
372
  type: "object" as const,
371
373
  additionalProperties: false,
@@ -423,11 +425,12 @@ const plugin = {
423
425
 
424
426
  proxyReq.on("error", (err) => {
425
427
  const backend = backendBase.toString();
428
+ const suggestedPort = backendBase.port || (backendBase.protocol === "https:" ? "443" : "80");
426
429
  if (isApi) {
427
430
  sendJson(res, 502, {
428
431
  success: false,
429
- error: "dashboard_backend_unreachable",
430
- message: \`Could not reach A2A server at \${backend}: \${err.message}\`
432
+ error: "a2a_backend_unreachable",
433
+ message: \`Could not reach A2A backend at \${backend}: \${err.message}\`
431
434
  });
432
435
  return;
433
436
  }
@@ -438,7 +441,7 @@ const plugin = {
438
441
  <h1>A2A Dashboard Unavailable</h1>
439
442
  <p>The gateway proxy is active, but the A2A backend is not reachable.</p>
440
443
  <p>Expected backend: <code>\${backend}</code></p>
441
- <p>Start the backend with: <code>a2a server --port 3001</code></p>
444
+ <p>Start the backend with: <code>a2a server --port \${suggestedPort}</code></p>
442
445
  </body></html>\`);
443
446
  });
444
447
 
@@ -491,69 +494,221 @@ function readExistingConfiguredInviteHost() {
491
494
  if (!parsed.hostname || isLocalOrUnroutableHost(parsed.hostname)) {
492
495
  return '';
493
496
  }
497
+ // Legacy: Cloudflare quick tunnels are ephemeral and no longer supported.
498
+ if (String(parsed.hostname).toLowerCase().endsWith('.trycloudflare.com')) {
499
+ return '';
500
+ }
494
501
  return existing;
495
502
  } catch (err) {
496
503
  return '';
497
504
  }
498
505
  }
499
506
 
500
- async function maybeSetupQuickTunnel(port) {
501
- const disabled = Boolean(flags['no-quick-tunnel']) ||
502
- String(process.env.A2A_DISABLE_QUICK_TUNNEL || '').toLowerCase() === 'true';
503
- if (disabled) return null;
504
-
507
+ function safeJsonParse(text) {
505
508
  try {
506
- const { ensureQuickTunnel } = require('../src/lib/quick-tunnel');
507
- const tunnel = await ensureQuickTunnel({ localPort: Number.parseInt(String(port), 10) || 3001 });
508
- if (!tunnel || !tunnel.host) return null;
509
- return {
510
- ...tunnel,
511
- inviteHost: `${tunnel.host}:443`
512
- };
509
+ return JSON.parse(String(text || ''));
513
510
  } catch (err) {
514
- warn(`Quick Tunnel setup failed: ${err.message}`);
515
- warn('Falling back to direct host invites (may require firewall/NAT config).');
516
511
  return null;
517
512
  }
518
513
  }
519
514
 
515
+ function looksLikePong(body) {
516
+ const parsed = safeJsonParse(body);
517
+ if (parsed && typeof parsed === 'object' && parsed.pong === true) return true;
518
+ return String(body || '').includes('"pong":true') || String(body || '').includes('"pong": true');
519
+ }
520
+
521
+ function fetchUrlText(url, timeoutMs = 5000) {
522
+ return new Promise((resolve, reject) => {
523
+ let parsed;
524
+ try {
525
+ parsed = new URL(url);
526
+ } catch (err) {
527
+ reject(new Error('invalid_url'));
528
+ return;
529
+ }
530
+
531
+ const client = parsed.protocol === 'https:' ? https : http;
532
+ const req = client.request({
533
+ protocol: parsed.protocol,
534
+ hostname: parsed.hostname,
535
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
536
+ method: 'GET',
537
+ path: parsed.pathname + parsed.search,
538
+ headers: {
539
+ 'User-Agent': `a2acalling/${process.env.npm_package_version || 'dev'} (setup-check)`
540
+ },
541
+ timeout: timeoutMs
542
+ }, (res) => {
543
+ let data = '';
544
+ res.setEncoding('utf8');
545
+
546
+ res.on('data', (chunk) => {
547
+ data += chunk;
548
+ if (data.length > 1024 * 256) {
549
+ req.destroy(new Error('response_too_large'));
550
+ }
551
+ });
552
+
553
+ res.on('end', () => {
554
+ resolve({
555
+ statusCode: res.statusCode || 0,
556
+ headers: res.headers || {},
557
+ body: data
558
+ });
559
+ });
560
+ });
561
+
562
+ req.on('error', reject);
563
+ req.on('timeout', () => req.destroy(new Error('timeout')));
564
+ req.end();
565
+ });
566
+ }
567
+
568
+ async function probeLocalA2APing(port) {
569
+ try {
570
+ const res = await fetchUrlText(`http://127.0.0.1:${port}/api/a2a/ping`, 900);
571
+ return { ok: looksLikePong(res.body), statusCode: res.statusCode, body: res.body };
572
+ } catch (err) {
573
+ return { ok: false, error: err && err.message ? err.message : 'request_failed' };
574
+ }
575
+ }
576
+
577
+ async function scanPort80() {
578
+ const { isPortListening, tryBindPort } = require('../src/lib/port-scanner');
579
+
580
+ const listening = await isPortListening(80, '127.0.0.1', { timeoutMs: 500 });
581
+ const bind = await tryBindPort(80, '0.0.0.0');
582
+ const a2aPing = listening.listening ? await probeLocalA2APing(80) : { ok: false };
583
+
584
+ return {
585
+ listening: Boolean(listening.listening),
586
+ listeningCode: listening.code,
587
+ bindOk: Boolean(bind.ok),
588
+ bindCode: bind.code,
589
+ a2aPingOk: Boolean(a2aPing.ok),
590
+ a2aPingStatusCode: a2aPing.statusCode,
591
+ a2aPingError: a2aPing.error
592
+ };
593
+ }
594
+
595
+ async function externalPingCheck(targetUrl) {
596
+ const providers = [
597
+ {
598
+ name: 'allorigins',
599
+ buildUrl: () => {
600
+ const u = new URL('https://api.allorigins.win/raw');
601
+ u.searchParams.set('url', targetUrl);
602
+ return u.toString();
603
+ }
604
+ },
605
+ {
606
+ name: 'jina',
607
+ buildUrl: () => `https://r.jina.ai/${targetUrl}`
608
+ }
609
+ ];
610
+
611
+ const attempts = [];
612
+ for (const provider of providers) {
613
+ const providerUrl = provider.buildUrl();
614
+ try {
615
+ // eslint-disable-next-line no-await-in-loop
616
+ const res = await fetchUrlText(providerUrl, 8000);
617
+ const ok = looksLikePong(res.body);
618
+ attempts.push({ provider: provider.name, ok, statusCode: res.statusCode });
619
+ if (ok) {
620
+ return { ok: true, provider: provider.name, statusCode: res.statusCode, attempts };
621
+ }
622
+ } catch (err) {
623
+ attempts.push({ provider: provider.name, ok: false, error: err && err.message ? err.message : 'request_failed' });
624
+ }
625
+ }
626
+
627
+ return { ok: false, attempts };
628
+ }
629
+
520
630
  async function install() {
521
631
  log('Installing A2A Calling...\n');
522
632
 
523
633
  const localHostname = process.env.HOSTNAME || 'localhost';
524
634
 
525
- // Port scanning: use explicit port or scan for available one
526
- let port;
635
+ // Networking scan (port 80) + backend port selection.
636
+ const port80 = await scanPort80();
637
+ if (port80.a2aPingOk) {
638
+ log('Port 80 already serves /api/a2a/ping (A2A detected on :80).');
639
+ } else if (port80.listening) {
640
+ warn(`Port 80 is already bound (${port80.listeningCode || 'in_use'}). Setup will use an internal port and recommend reverse proxy routing.`);
641
+ } else if (!port80.bindOk && port80.bindCode === 'EACCES') {
642
+ warn('Port 80 appears free but is not bindable by this user (EACCES). Setup will use an unprivileged port unless you run with elevated privileges.');
643
+ } else if (port80.bindOk) {
644
+ log('Port 80 is bindable by this user (recommended for inbound A2A if you intend to serve directly on :80).');
645
+ }
646
+
647
+ let backendPort = null;
527
648
  if (flags.port) {
528
- port = String(flags.port);
649
+ backendPort = Number.parseInt(String(flags.port), 10);
529
650
  } else if (process.env.A2A_PORT) {
530
- port = String(process.env.A2A_PORT);
651
+ backendPort = Number.parseInt(String(process.env.A2A_PORT), 10);
531
652
  } else {
532
- try {
533
- const { findAvailablePort } = require('../src/lib/port-scanner');
534
- const available = await findAvailablePort([80, 3001, 8080, 8443, 9001]);
535
- port = available ? String(available) : '3001';
536
- if (available === 80) {
537
- log(`Port 80 is available (recommended for inbound A2A connections)`);
538
- } else if (available) {
539
- log(`Auto-detected available port: ${available}`);
653
+ if (port80.bindOk && !port80.listening) {
654
+ backendPort = 80;
655
+ } else {
656
+ try {
657
+ const { findAvailablePort } = require('../src/lib/port-scanner');
658
+ backendPort = await findAvailablePort([3001, 8080, 8443, 9001]);
659
+ } catch (e) {
660
+ backendPort = null;
540
661
  }
541
- } catch (e) {
542
- port = '3001';
662
+ if (!backendPort) {
663
+ backendPort = 3001;
664
+ }
665
+ log(`Auto-detected available internal port: ${backendPort}`);
543
666
  }
544
667
  }
545
- const backendUrl = flags['dashboard-backend'] || `http://127.0.0.1:${port}`;
668
+ if (!Number.isFinite(backendPort) || backendPort <= 0 || backendPort > 65535) {
669
+ backendPort = 3001;
670
+ }
671
+
672
+ const backendUrl = flags['dashboard-backend'] || `http://127.0.0.1:${backendPort}`;
673
+
674
+ // Invite host selection: explicit > existing configured public host > auto resolve to external IP.
546
675
  const explicitInviteHost = flags.hostname ||
547
676
  process.env.A2A_HOSTNAME ||
548
677
  process.env.OPENCLAW_HOSTNAME ||
549
678
  readExistingConfiguredInviteHost();
550
- const quickTunnel = explicitInviteHost ? null : await maybeSetupQuickTunnel(port);
551
- const inviteHost = explicitInviteHost || (quickTunnel && quickTunnel.inviteHost) || `${localHostname}:${port}`;
679
+
680
+ let inviteHost = explicitInviteHost;
681
+ let inviteHostWarnings = [];
682
+
683
+ if (!inviteHost) {
684
+ try {
685
+ const { A2AConfig } = require('../src/lib/config');
686
+ const { resolveInviteHost } = require('../src/lib/invite-host');
687
+ const config = new A2AConfig();
688
+ const inviteDefaultPort = port80.listening ? 80 : backendPort;
689
+ const resolved = await resolveInviteHost({
690
+ config,
691
+ fallbackHost: localHostname,
692
+ defaultPort: inviteDefaultPort,
693
+ refreshExternalIp: true
694
+ });
695
+ inviteHost = resolved.host;
696
+ inviteHostWarnings = resolved.warnings || [];
697
+ } catch (err) {
698
+ inviteHost = `${localHostname}:${backendPort}`;
699
+ }
700
+ }
701
+
702
+ for (const w of inviteHostWarnings) {
703
+ warn(w);
704
+ }
705
+
552
706
  const forceStandalone = Boolean(flags.standalone) || String(process.env.A2A_FORCE_STANDALONE || '').toLowerCase() === 'true';
553
707
  const hasOpenClawBinary = commandExists('openclaw');
554
708
  const hasOpenClawConfig = fs.existsSync(OPENCLAW_CONFIG);
555
709
  const hasOpenClaw = !forceStandalone && (hasOpenClawBinary || hasOpenClawConfig);
556
710
  let standaloneBootstrap = null;
711
+ const forceHostname = Boolean(flags.hostname || process.env.A2A_HOSTNAME || process.env.OPENCLAW_HOSTNAME) || !explicitInviteHost;
557
712
 
558
713
  if (hasOpenClaw) {
559
714
  // 1. Create skills directory if needed
@@ -571,13 +726,13 @@ async function install() {
571
726
  log(`Installed skill to: ${skillDir}`);
572
727
 
573
728
  // Ensure config and manifest exist even in OpenClaw path
574
- ensureConfigAndManifest(inviteHost, port, {
575
- forceHostname: Boolean(flags.hostname || process.env.A2A_HOSTNAME || process.env.OPENCLAW_HOSTNAME || quickTunnel)
729
+ ensureConfigAndManifest(inviteHost, backendPort, {
730
+ forceHostname
576
731
  });
577
732
  } else {
578
733
  warn('OpenClaw not detected. Enabling standalone A2A bootstrap.');
579
- standaloneBootstrap = ensureStandaloneBootstrap(inviteHost, port, {
580
- forceHostname: Boolean(flags.hostname || process.env.A2A_HOSTNAME || process.env.OPENCLAW_HOSTNAME || quickTunnel)
734
+ standaloneBootstrap = ensureStandaloneBootstrap(inviteHost, backendPort, {
735
+ forceHostname
581
736
  });
582
737
  }
583
738
 
@@ -586,7 +741,7 @@ async function install() {
586
741
  let configUpdated = false;
587
742
  let gatewayDetected = false;
588
743
  let dashboardMode = 'standalone';
589
- let dashboardUrl = `http://${localHostname}:${port}/dashboard`;
744
+ let dashboardUrl = `http://${localHostname}:${backendPort}/dashboard`;
590
745
 
591
746
  if (config) {
592
747
  // Add custom command for each enabled channel
@@ -650,12 +805,76 @@ async function install() {
650
805
  ? 'Runtime auto-selects OpenClaw when available and falls back to generic if needed.'
651
806
  : 'Runtime defaults to generic fallback (no OpenClaw dependency required).';
652
807
 
808
+ const { splitHostPort, isLocalOrUnroutableHost } = require('../src/lib/invite-host');
809
+ const inviteParsed = splitHostPort(inviteHost);
810
+ const invitePort = inviteParsed.port;
811
+ const inviteScheme = (!invitePort || invitePort === 443) ? 'https' : 'http';
812
+ const invitePingUrl = `${inviteScheme}://${inviteHost}/api/a2a/ping`;
813
+ const inviteLooksLocal = isLocalOrUnroutableHost(inviteParsed.hostname);
814
+ const expectsReverseProxy = Boolean(
815
+ (invitePort === 80 && backendPort !== 80) ||
816
+ ((!invitePort || invitePort === 443) && backendPort !== 443)
817
+ );
818
+
819
+ let externalPing = null;
820
+ if (!inviteLooksLocal && inviteParsed.hostname) {
821
+ externalPing = await externalPingCheck(invitePingUrl);
822
+ }
823
+
653
824
  console.log(`
654
825
  ${bold('━━━ Server Setup ━━━')}
655
826
 
656
827
  To receive incoming calls and host A2A APIs:
657
828
 
658
- ${green(`A2A_HOSTNAME="${inviteHost}" a2a server --port ${port}`)}
829
+ ${green(`A2A_HOSTNAME="${inviteHost}" a2a server --port ${backendPort}`)}
830
+
831
+ ${bold('━━━ Ingress Setup ━━━')}
832
+
833
+ Invite host: ${green(inviteHost)}
834
+ Expected ping URL: ${green(invitePingUrl)}
835
+
836
+ Port 80 scan:
837
+ ${port80.a2aPingOk
838
+ ? green('Port 80 responds to /api/a2a/ping (A2A ready on :80)')
839
+ : port80.listening
840
+ ? yellow(`Port 80 has a listener (${port80.listeningCode || 'in_use'})`)
841
+ : port80.bindOk
842
+ ? green('Port 80 is free and bindable by this user')
843
+ : yellow(`Port 80 not bindable (${port80.bindCode || 'unknown'})`)}
844
+
845
+ ${expectsReverseProxy
846
+ ? `Reverse proxy required:
847
+ Route ${green('/api/a2a/*')} -> ${green(backendUrl)}
848
+ Route ${green('/a2a/*')} -> ${green(backendUrl.replace(/\/$/, ''))}${green('/dashboard/*')} (optional dashboard)
849
+
850
+ Example (Caddy):
851
+ ${green(`YOUR_DOMAIN {
852
+ handle /api/a2a/* {
853
+ reverse_proxy 127.0.0.1:${backendPort}
854
+ }
855
+ handle /a2a* {
856
+ uri replace /a2a /dashboard
857
+ reverse_proxy 127.0.0.1:${backendPort}
858
+ }
859
+ }`)}
860
+
861
+ Example (nginx):
862
+ ${green(`location /api/a2a/ {
863
+ proxy_pass http://127.0.0.1:${backendPort}/api/a2a/;
864
+ }
865
+ location = /a2a { return 301 /a2a/; }
866
+ location /a2a/ {
867
+ proxy_pass http://127.0.0.1:${backendPort}/dashboard/;
868
+ }`)}`
869
+ : 'Reverse proxy not required for the selected invite host.'}
870
+
871
+ ${bold('━━━ External Ping ━━━')}
872
+
873
+ ${inviteLooksLocal
874
+ ? yellow('Skipped external ping: invite host looks local/unroutable. Set --hostname to a public endpoint to enable this check.')
875
+ : externalPing && externalPing.ok
876
+ ? green(`External ping OK via ${externalPing.provider}`)
877
+ : yellow(`External ping FAILED (expected if the server is not running yet, or ingress is not publicly reachable).`)}
659
878
 
660
879
  ${bold('━━━ Dashboard Setup ━━━')}
661
880
 
@@ -663,18 +882,12 @@ Mode: ${dashboardMode === 'gateway' ? green('gateway') : yellow('standalone')}
663
882
  Dashboard URL: ${green(dashboardUrl)}
664
883
 
665
884
  ${dashboardMode === 'gateway'
666
- ? `Gateway path /a2a is now proxied to ${backendUrl}.`
885
+ ? `Gateway paths /a2a and /api/a2a/* are now proxied to ${backendUrl}.`
667
886
  : 'No gateway detected. Dashboard is served directly from the A2A server.'}
668
887
 
669
888
  ${bold('━━━ Runtime Setup ━━━')}
670
889
 
671
890
  ${runtimeLine}
672
- ${quickTunnel
673
- ? `Quick Tunnel enabled:
674
- ${green(quickTunnel.url)}
675
- Invites will use: ${green(inviteHost)}
676
- `
677
- : 'Quick Tunnel not enabled (using configured/direct invite host).'}
678
891
  ${standaloneBootstrap
679
892
  ? `Standalone bridge templates:
680
893
  ${green(standaloneBootstrap.turnScript)}
@@ -740,12 +953,11 @@ Usage:
740
953
  npx a2acalling server Start A2A server
741
954
 
742
955
  Install Options:
743
- --hostname <host> Hostname for invite URLs (skip quick tunnel when set)
956
+ --hostname <host> Public hostname for invite URLs (e.g. myserver.com, myserver.com:80)
744
957
  --port <port> A2A server port (default: 3001)
745
958
  --gateway-url <url> Force gateway base URL for printed dashboard link
746
959
  --dashboard-backend <url> Backend URL used by gateway dashboard proxy
747
960
  --standalone Force standalone bootstrap (ignore OpenClaw detection)
748
- --no-quick-tunnel Disable auto quick tunnel for no-DNS environments
749
961
 
750
962
  Examples:
751
963
  npx a2acalling install --hostname myserver.com --port 3001
package/src/lib/client.js CHANGED
@@ -5,6 +5,51 @@
5
5
  const https = require('https');
6
6
  const http = require('http');
7
7
 
8
+ function splitHostPort(rawHost) {
9
+ const host = String(rawHost || '').trim();
10
+ if (!host) return { hostname: '', port: null };
11
+
12
+ // IPv6 in brackets: [::1]:3001
13
+ const bracketed = host.match(/^\[([^\]]+)\](?::(\d+))?$/);
14
+ if (bracketed) {
15
+ return {
16
+ hostname: bracketed[1],
17
+ port: bracketed[2] ? Number.parseInt(bracketed[2], 10) : null
18
+ };
19
+ }
20
+
21
+ // Only treat the last ":" as a port separator when there's exactly one colon.
22
+ const lastColon = host.lastIndexOf(':');
23
+ if (lastColon !== -1 && host.indexOf(':') === lastColon) {
24
+ const maybePort = host.slice(lastColon + 1);
25
+ if (/^\d+$/.test(maybePort)) {
26
+ return {
27
+ hostname: host.slice(0, lastColon),
28
+ port: Number.parseInt(maybePort, 10)
29
+ };
30
+ }
31
+ }
32
+
33
+ return { hostname: host, port: null };
34
+ }
35
+
36
+ function resolveProtocolAndPort(host) {
37
+ const parsed = splitHostPort(host);
38
+ const hostname = parsed.hostname;
39
+ const hasExplicitPort = Number.isFinite(parsed.port);
40
+ const isLocalhost = hostname === 'localhost' ||
41
+ hostname === '127.0.0.1' ||
42
+ hostname === '::1' ||
43
+ hostname.startsWith('127.');
44
+
45
+ const port = hasExplicitPort ? parsed.port : (isLocalhost ? 80 : 443);
46
+ // Use HTTP for localhost or explicit non-443 ports, HTTPS otherwise.
47
+ const useHttp = isLocalhost || (hasExplicitPort && port !== 443);
48
+ const protocol = useHttp ? http : https;
49
+
50
+ return { protocol, hostname, port };
51
+ }
52
+
8
53
  class A2AClient {
9
54
  constructor(options = {}) {
10
55
  this.timeout = options.timeout || 60000;
@@ -50,13 +95,7 @@ class A2AClient {
50
95
  timeout_seconds: timeoutSeconds || 60
51
96
  });
52
97
 
53
- const isLocalhost = host === 'localhost' || host.startsWith('localhost:') || host.startsWith('127.');
54
- const hasExplicitPort = host.includes(':');
55
- const port = hasExplicitPort ? parseInt(host.split(':')[1]) : (isLocalhost ? 80 : 443);
56
- // Use HTTP for localhost or explicit non-443 ports, HTTPS otherwise
57
- const useHttp = isLocalhost || (hasExplicitPort && port !== 443);
58
- const protocol = useHttp ? http : https;
59
- const hostname = host.split(':')[0];
98
+ const { protocol, hostname, port } = resolveProtocolAndPort(host);
60
99
 
61
100
  return new Promise((resolve, reject) => {
62
101
  const req = protocol.request({
@@ -125,12 +164,7 @@ class A2AClient {
125
164
  conversation_id: conversationId
126
165
  });
127
166
 
128
- const isLocalhost = host === 'localhost' || host.startsWith('localhost:') || host.startsWith('127.');
129
- const hasExplicitPort = host.includes(':');
130
- const port = hasExplicitPort ? parseInt(host.split(':')[1]) : (isLocalhost ? 80 : 443);
131
- const useHttp = isLocalhost || (hasExplicitPort && port !== 443);
132
- const protocol = useHttp ? http : https;
133
- const hostname = host.split(':')[0];
167
+ const { protocol, hostname, port } = resolveProtocolAndPort(host);
134
168
 
135
169
  return new Promise((resolve, reject) => {
136
170
  const req = protocol.request({
@@ -187,12 +221,7 @@ class A2AClient {
187
221
  ({ host } = endpoint);
188
222
  }
189
223
 
190
- const isLocalhost = host === 'localhost' || host.startsWith('localhost:') || host.startsWith('127.');
191
- const hasExplicitPort = host.includes(':');
192
- const port = hasExplicitPort ? parseInt(host.split(':')[1]) : (isLocalhost ? 80 : 443);
193
- const useHttp = isLocalhost || (hasExplicitPort && port !== 443);
194
- const protocol = useHttp ? http : https;
195
- const hostname = host.split(':')[0];
224
+ const { protocol, hostname, port } = resolveProtocolAndPort(host);
196
225
 
197
226
  return new Promise((resolve, reject) => {
198
227
  const req = protocol.request({
@@ -234,12 +263,7 @@ class A2AClient {
234
263
  ({ host } = endpoint);
235
264
  }
236
265
 
237
- const isLocalhost = host === 'localhost' || host.startsWith('localhost:') || host.startsWith('127.');
238
- const hasExplicitPort = host.includes(':');
239
- const port = hasExplicitPort ? parseInt(host.split(':')[1]) : (isLocalhost ? 80 : 443);
240
- const useHttp = isLocalhost || (hasExplicitPort && port !== 443);
241
- const protocol = useHttp ? http : https;
242
- const hostname = host.split(':')[0];
266
+ const { protocol, hostname, port } = resolveProtocolAndPort(host);
243
267
 
244
268
  return new Promise((resolve, reject) => {
245
269
  const req = protocol.request({
@@ -124,10 +124,10 @@ function isPublicIpHostname(hostname) {
124
124
  return false;
125
125
  }
126
126
 
127
- function isEphemeralTunnelHostname(hostname) {
127
+ function isLegacyTunnelHostname(hostname) {
128
128
  const host = String(hostname || '').trim().toLowerCase();
129
129
  if (!host) return false;
130
- // Quick tunnels (cloudflared) are not stable across restarts.
130
+ // Legacy: Cloudflare Quick Tunnel hostnames were ephemeral and are now unsupported.
131
131
  if (host.endsWith('.trycloudflare.com')) return true;
132
132
  return false;
133
133
  }
@@ -155,7 +155,8 @@ async function resolveInviteHost(options = {}) {
155
155
  : 'default';
156
156
 
157
157
  const parsed = splitHostPort(candidate);
158
- const desiredPort = parsed.port ||
158
+ const candidateIsLegacyTunnel = candidateSource !== 'env' && isLegacyTunnelHostname(parsed.hostname);
159
+ const desiredPort = (candidateIsLegacyTunnel ? null : parsed.port) ||
159
160
  Number.parseInt(String(options.defaultPort || ''), 10) ||
160
161
  readIntEnv('PORT') ||
161
162
  readIntEnv('A2A_PORT') ||
@@ -169,18 +170,17 @@ async function resolveInviteHost(options = {}) {
169
170
  ? options.externalIpTtlMs
170
171
  : undefined;
171
172
 
172
- const preferQuickTunnel = Boolean(options.preferQuickTunnel) ||
173
- String(process.env.A2A_PREFER_QUICK_TUNNEL || '').toLowerCase() === 'true';
174
- const quickTunnelDisabled = Boolean(options.disableQuickTunnel) ||
175
- String(process.env.A2A_DISABLE_QUICK_TUNNEL || '').toLowerCase() === 'true';
176
-
177
- // If a previous run persisted an ephemeral tunnel hostname into config (e.g. trycloudflare),
178
- // treat it like "unroutable" so we always refresh via tunnel/external IP instead of returning
179
- // a stale endpoint.
180
- const candidateIsEphemeralTunnel = candidateSource !== 'env' && isEphemeralTunnelHostname(parsed.hostname);
173
+ // If a previous run persisted a legacy Cloudflare Quick Tunnel hostname into config (e.g. trycloudflare),
174
+ // treat it like "unroutable" so we don't emit stale/unsupported invite endpoints.
175
+ if (candidateIsLegacyTunnel) {
176
+ warnings.push(
177
+ `Detected legacy Quick Tunnel hostname "${candidateHostWithPort}". Quick Tunnel support was removed. ` +
178
+ `Set A2A_HOSTNAME="your-public-host:port" (or wire a reverse proxy) to enable internet-facing invites.`
179
+ );
180
+ }
181
181
 
182
182
  const shouldReplaceWithExternalIp = isLocalOrUnroutableHost(parsed.hostname) ||
183
- candidateIsEphemeralTunnel || (
183
+ candidateIsLegacyTunnel || (
184
184
  options.refreshExternalIp && isPublicIpHostname(parsed.hostname)
185
185
  );
186
186
 
@@ -193,28 +193,6 @@ async function resolveInviteHost(options = {}) {
193
193
  };
194
194
  }
195
195
 
196
- if (preferQuickTunnel && !quickTunnelDisabled) {
197
- try {
198
- const { ensureQuickTunnel } = require('./quick-tunnel');
199
- const tunnel = await ensureQuickTunnel({
200
- localPort: desiredPort
201
- });
202
- if (tunnel && tunnel.host) {
203
- const tunnelParsed = splitHostPort(tunnel.host);
204
- const finalHost = formatHostPort(tunnelParsed.hostname, tunnelParsed.port || 443);
205
- warnings.push(`Using secure Quick Tunnel endpoint "${finalHost}" for internet-facing invites.`);
206
- return {
207
- host: finalHost,
208
- source: 'quick_tunnel',
209
- originalHost: candidateHostWithPort,
210
- warnings
211
- };
212
- }
213
- } catch (err) {
214
- warnings.push(`Quick Tunnel unavailable (${err.message}). Falling back to external IP host detection.`);
215
- }
216
- }
217
-
218
196
  const external = await getExternalIp({
219
197
  ttlMs,
220
198
  timeoutMs: options.externalIpTimeoutMs,
@@ -8,20 +8,32 @@
8
8
  const net = require('net');
9
9
 
10
10
  /**
11
- * Check if a port is available on the given host.
11
+ * Attempt to bind a port and return a structured result.
12
+ * Useful for distinguishing "already in use" from "permission denied".
13
+ *
12
14
  * @param {number} port
13
15
  * @param {string} [host='0.0.0.0']
14
- * @returns {Promise<boolean>}
16
+ * @returns {Promise<{ ok: boolean, code?: string, message?: string }>}
15
17
  */
16
- function isPortAvailable(port, host = '0.0.0.0') {
18
+ function tryBindPort(port, host = '0.0.0.0') {
17
19
  return new Promise((resolve) => {
18
20
  const server = net.createServer();
19
- server.once('error', () => resolve(false));
20
- server.once('listening', () => server.close(() => resolve(true)));
21
+ server.once('error', (err) => resolve({ ok: false, code: err && err.code ? String(err.code) : 'ERROR', message: err ? String(err.message || '') : '' }));
22
+ server.once('listening', () => server.close(() => resolve({ ok: true })));
21
23
  server.listen(port, host);
22
24
  });
23
25
  }
24
26
 
27
+ /**
28
+ * Check if a port is available on the given host for binding by the current user.
29
+ * @param {number} port
30
+ * @param {string} [host='0.0.0.0']
31
+ * @returns {Promise<boolean>}
32
+ */
33
+ function isPortAvailable(port, host = '0.0.0.0') {
34
+ return tryBindPort(port, host).then((res) => Boolean(res && res.ok));
35
+ }
36
+
25
37
  /**
26
38
  * Find the first available port from a list of candidates.
27
39
  * @param {number[]} candidates - Ports to try in order
@@ -35,4 +47,37 @@ async function findAvailablePort(candidates, host = '0.0.0.0') {
35
47
  return null;
36
48
  }
37
49
 
38
- module.exports = { isPortAvailable, findAvailablePort };
50
+ /**
51
+ * Check whether a TCP listener exists on the given host/port.
52
+ * This does NOT require binding privileges; it simply attempts to connect.
53
+ *
54
+ * @param {number} port
55
+ * @param {string} [host='127.0.0.1']
56
+ * @param {object} [options]
57
+ * @param {number} [options.timeoutMs=400]
58
+ * @returns {Promise<{ listening: boolean, code?: string, message?: string }>}
59
+ */
60
+ function isPortListening(port, host = '127.0.0.1', options = {}) {
61
+ const timeoutMs = Number.parseInt(String(options.timeoutMs || 400), 10) || 400;
62
+
63
+ return new Promise((resolve) => {
64
+ const socket = net.connect({ host, port });
65
+ let settled = false;
66
+
67
+ const finish = (result) => {
68
+ if (settled) return;
69
+ settled = true;
70
+ try { socket.destroy(); } catch (e) {}
71
+ resolve(result);
72
+ };
73
+
74
+ socket.setTimeout(timeoutMs, () => finish({ listening: false, code: 'ETIMEDOUT', message: 'connect_timeout' }));
75
+ socket.once('connect', () => finish({ listening: true }));
76
+ socket.once('error', (err) => {
77
+ const code = err && err.code ? String(err.code) : 'ERROR';
78
+ finish({ listening: false, code, message: err ? String(err.message || '') : '' });
79
+ });
80
+ });
81
+ }
82
+
83
+ module.exports = { tryBindPort, isPortAvailable, isPortListening, findAvailablePort };
@@ -1,332 +0,0 @@
1
- /**
2
- * Quick Tunnel Support (Cloudflare)
3
- *
4
- * Default behavior for no-DNS environments:
5
- * - lazily download cloudflared on first use (smallest viable binary path)
6
- * - start a quick tunnel to local A2A server
7
- * - persist tunnel metadata so invite generation can reuse it
8
- *
9
- * Intentionally minimal:
10
- * - no account-required managed tunnel flow yet
11
- * - no domain routing automation yet
12
- *
13
- * If owners need fixed hostnames or custom ingress, they can wire their own
14
- * proxy/tunnel and set A2A_HOSTNAME to that endpoint.
15
- */
16
-
17
- const fs = require('fs');
18
- const path = require('path');
19
- const http = require('http');
20
- const https = require('https');
21
- const { spawn } = require('child_process');
22
-
23
- const CONFIG_DIR = process.env.A2A_CONFIG_DIR ||
24
- process.env.OPENCLAW_CONFIG_DIR ||
25
- path.join(process.env.HOME || '/tmp', '.config', 'openclaw');
26
-
27
- const BIN_DIR = path.join(CONFIG_DIR, 'bin');
28
- const CLOUDFLARED_BIN = path.join(BIN_DIR, process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared');
29
- const TUNNEL_STATE_FILE = path.join(CONFIG_DIR, 'a2a-quick-tunnel.json');
30
-
31
- function ensureDir(dirPath) {
32
- if (!fs.existsSync(dirPath)) {
33
- fs.mkdirSync(dirPath, { recursive: true });
34
- }
35
- }
36
-
37
- function isExecutable(filePath) {
38
- try {
39
- fs.accessSync(filePath, fs.constants.X_OK);
40
- return true;
41
- } catch (err) {
42
- return false;
43
- }
44
- }
45
-
46
- function commandExists(name) {
47
- const paths = String(process.env.PATH || '').split(path.delimiter);
48
- for (const p of paths) {
49
- const full = path.join(p, name);
50
- if (fs.existsSync(full) && isExecutable(full)) return full;
51
- if (process.platform === 'win32') {
52
- const exe = `${full}.exe`;
53
- if (fs.existsSync(exe)) return exe;
54
- }
55
- }
56
- return null;
57
- }
58
-
59
- function readJson(filePath) {
60
- try {
61
- if (!fs.existsSync(filePath)) return null;
62
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
63
- } catch (err) {
64
- return null;
65
- }
66
- }
67
-
68
- function writeJson(filePath, value) {
69
- ensureDir(path.dirname(filePath));
70
- const tmp = `${filePath}.tmp`;
71
- fs.writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 });
72
- fs.renameSync(tmp, filePath);
73
- try {
74
- fs.chmodSync(filePath, 0o600);
75
- } catch (err) {
76
- // Best effort.
77
- }
78
- }
79
-
80
- function isProcessAlive(pid) {
81
- if (!pid || !Number.isFinite(pid)) return false;
82
- try {
83
- process.kill(pid, 0);
84
- return true;
85
- } catch (err) {
86
- return false;
87
- }
88
- }
89
-
90
- function resolveCloudflaredAssetName() {
91
- const arch = process.arch;
92
- const platform = process.platform;
93
-
94
- if (platform === 'linux') {
95
- if (arch === 'x64') return 'cloudflared-linux-amd64';
96
- if (arch === 'arm64') return 'cloudflared-linux-arm64';
97
- }
98
- if (platform === 'darwin') {
99
- if (arch === 'x64') return 'cloudflared-darwin-amd64.tgz';
100
- if (arch === 'arm64') return 'cloudflared-darwin-arm64.tgz';
101
- }
102
- if (platform === 'win32' && arch === 'x64') {
103
- return 'cloudflared-windows-amd64.exe';
104
- }
105
-
106
- throw new Error(`cloudflared_unsupported_platform:${platform}-${arch}`);
107
- }
108
-
109
- function downloadFile(url, destination, redirectsLeft = 5) {
110
- return new Promise((resolve, reject) => {
111
- let parsed;
112
- try {
113
- parsed = new URL(url);
114
- } catch (err) {
115
- reject(new Error('invalid_download_url'));
116
- return;
117
- }
118
-
119
- const client = parsed.protocol === 'https:' ? https : http;
120
- const req = client.get({
121
- protocol: parsed.protocol,
122
- hostname: parsed.hostname,
123
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
124
- path: parsed.pathname + parsed.search,
125
- headers: {
126
- 'User-Agent': 'a2acalling/quick-tunnel'
127
- }
128
- }, (res) => {
129
- const status = res.statusCode || 0;
130
- if (status >= 300 && status < 400 && res.headers.location && redirectsLeft > 0) {
131
- res.resume();
132
- downloadFile(res.headers.location, destination, redirectsLeft - 1).then(resolve).catch(reject);
133
- return;
134
- }
135
- if (status < 200 || status >= 300) {
136
- res.resume();
137
- reject(new Error(`download_failed_status_${status}`));
138
- return;
139
- }
140
-
141
- const tmp = `${destination}.download`;
142
- const out = fs.createWriteStream(tmp, { mode: 0o755 });
143
- res.pipe(out);
144
- out.on('finish', () => {
145
- out.close(() => {
146
- fs.renameSync(tmp, destination);
147
- resolve(destination);
148
- });
149
- });
150
- out.on('error', (err) => {
151
- reject(err);
152
- });
153
- });
154
-
155
- req.on('error', reject);
156
- });
157
- }
158
-
159
- function extractTarGz(archivePath, outputDir) {
160
- return new Promise((resolve, reject) => {
161
- const child = spawn('tar', ['-xzf', archivePath, '-C', outputDir], {
162
- stdio: ['ignore', 'pipe', 'pipe']
163
- });
164
- let stderr = '';
165
- child.stderr.on('data', chunk => { stderr += chunk.toString('utf8'); });
166
- child.on('error', reject);
167
- child.on('close', (code) => {
168
- if (code === 0) resolve();
169
- else reject(new Error(`tar_extract_failed:${stderr || code}`));
170
- });
171
- });
172
- }
173
-
174
- async function ensureCloudflaredBinary() {
175
- if (process.env.A2A_CLOUDFLARED_BIN && isExecutable(process.env.A2A_CLOUDFLARED_BIN)) {
176
- return { path: process.env.A2A_CLOUDFLARED_BIN, source: 'env' };
177
- }
178
-
179
- const systemPath = commandExists(process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared');
180
- if (systemPath) {
181
- return { path: systemPath, source: 'system' };
182
- }
183
-
184
- if (fs.existsSync(CLOUDFLARED_BIN) && isExecutable(CLOUDFLARED_BIN)) {
185
- return { path: CLOUDFLARED_BIN, source: 'cached' };
186
- }
187
-
188
- ensureDir(BIN_DIR);
189
- const asset = resolveCloudflaredAssetName();
190
- const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`;
191
- const downloadedPath = path.join(BIN_DIR, asset);
192
-
193
- await downloadFile(url, downloadedPath);
194
-
195
- if (asset.endsWith('.tgz')) {
196
- await extractTarGz(downloadedPath, BIN_DIR);
197
- try {
198
- fs.unlinkSync(downloadedPath);
199
- } catch (err) {
200
- // Best effort.
201
- }
202
- } else if (downloadedPath !== CLOUDFLARED_BIN) {
203
- fs.renameSync(downloadedPath, CLOUDFLARED_BIN);
204
- }
205
-
206
- if (!fs.existsSync(CLOUDFLARED_BIN)) {
207
- throw new Error('cloudflared_install_failed');
208
- }
209
- try {
210
- fs.chmodSync(CLOUDFLARED_BIN, 0o755);
211
- } catch (err) {
212
- // Best effort.
213
- }
214
-
215
- return { path: CLOUDFLARED_BIN, source: 'downloaded' };
216
- }
217
-
218
- function parseTryCloudflareUrl(line) {
219
- const match = String(line || '').match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
220
- return match ? match[0] : null;
221
- }
222
-
223
- function readQuickTunnelState() {
224
- return readJson(TUNNEL_STATE_FILE);
225
- }
226
-
227
- function normalizeHostFromUrl(urlText) {
228
- try {
229
- const parsed = new URL(urlText);
230
- return parsed.host;
231
- } catch (err) {
232
- return '';
233
- }
234
- }
235
-
236
- async function startQuickTunnel(options = {}) {
237
- const localPort = Number.parseInt(String(options.localPort || 3001), 10) || 3001;
238
- const targetUrl = options.targetUrl || `http://127.0.0.1:${localPort}`;
239
- const timeoutMs = Number.parseInt(String(options.timeoutMs || 25000), 10) || 25000;
240
- const binary = options.binaryPath || (await ensureCloudflaredBinary()).path;
241
-
242
- const args = [
243
- 'tunnel',
244
- '--url',
245
- targetUrl,
246
- '--no-autoupdate',
247
- '--protocol',
248
- 'http2'
249
- ];
250
-
251
- const child = spawn(binary, args, {
252
- detached: true,
253
- stdio: ['ignore', 'pipe', 'pipe']
254
- });
255
-
256
- return new Promise((resolve, reject) => {
257
- let finished = false;
258
- const timer = setTimeout(() => {
259
- if (finished) return;
260
- finished = true;
261
- try { process.kill(child.pid); } catch (err) {}
262
- reject(new Error('quick_tunnel_timeout'));
263
- }, timeoutMs);
264
-
265
- function handleLine(line) {
266
- const url = parseTryCloudflareUrl(line);
267
- if (!url || finished) return;
268
- finished = true;
269
- clearTimeout(timer);
270
-
271
- const host = normalizeHostFromUrl(url);
272
- const state = {
273
- pid: child.pid,
274
- url,
275
- host,
276
- local_port: localPort,
277
- target_url: targetUrl,
278
- started_at: new Date().toISOString(),
279
- binary
280
- };
281
- writeJson(TUNNEL_STATE_FILE, state);
282
-
283
- // Keep tunnel alive independently from caller process.
284
- child.unref();
285
- resolve({
286
- url,
287
- host,
288
- pid: child.pid,
289
- localPort,
290
- source: 'started'
291
- });
292
- }
293
-
294
- child.stdout.on('data', (chunk) => handleLine(chunk.toString('utf8')));
295
- child.stderr.on('data', (chunk) => handleLine(chunk.toString('utf8')));
296
- child.on('error', (err) => {
297
- if (finished) return;
298
- finished = true;
299
- clearTimeout(timer);
300
- reject(err);
301
- });
302
- child.on('exit', (code) => {
303
- if (finished) return;
304
- finished = true;
305
- clearTimeout(timer);
306
- reject(new Error(`quick_tunnel_exit_${code}`));
307
- });
308
- });
309
- }
310
-
311
- async function ensureQuickTunnel(options = {}) {
312
- const localPort = Number.parseInt(String(options.localPort || 3001), 10) || 3001;
313
- const state = readQuickTunnelState();
314
- if (state && state.url && state.host && state.local_port === localPort && isProcessAlive(state.pid)) {
315
- return {
316
- url: state.url,
317
- host: state.host,
318
- pid: state.pid,
319
- localPort,
320
- source: 'state'
321
- };
322
- }
323
-
324
- return startQuickTunnel({ ...options, localPort });
325
- }
326
-
327
- module.exports = {
328
- TUNNEL_STATE_FILE,
329
- ensureCloudflaredBinary,
330
- ensureQuickTunnel,
331
- readQuickTunnelState
332
- };