@openchamber/web 1.3.9 → 1.4.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/dist/index.html CHANGED
@@ -160,10 +160,10 @@
160
160
  pointer-events: none;
161
161
  }
162
162
  </style>
163
- <script type="module" crossorigin src="/assets/index-CqiXpckC.js"></script>
164
- <link rel="modulepreload" crossorigin href="/assets/vendor-.bun-B0k_58z8.js">
165
- <link rel="stylesheet" crossorigin href="/assets/vendor--swUGvSZA.css">
166
- <link rel="stylesheet" crossorigin href="/assets/index-Coco_yFR.css">
163
+ <script type="module" crossorigin src="/assets/index-CbGhBA-v.js"></script>
164
+ <link rel="modulepreload" crossorigin href="/assets/vendor-.bun-CpaXrlrC.js">
165
+ <link rel="stylesheet" crossorigin href="/assets/vendor--Jn2c0Clh.css">
166
+ <link rel="stylesheet" crossorigin href="/assets/index-Cx6NUrC-.css">
167
167
  </head>
168
168
  <body class="h-full bg-background text-foreground">
169
169
  <div id="root" class="h-full">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchamber/web",
3
- "version": "1.3.9",
3
+ "version": "1.4.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
@@ -19,7 +19,7 @@
19
19
  "build:watch": "vite build --watch",
20
20
  "type-check": "tsc --noEmit",
21
21
  "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js",
22
- "start": "node server/index.js"
22
+ "start": "node bin/cli.js serve"
23
23
  },
24
24
  "dependencies": {
25
25
  "@fontsource/ibm-plex-mono": "^5.2.7",
@@ -37,15 +37,15 @@
37
37
  "@radix-ui/react-tooltip": "^1.2.8",
38
38
  "@remixicon/react": "^4.7.0",
39
39
  "@types/react-syntax-highlighter": "^15.5.13",
40
- "@xterm/addon-fit": "^0.10.0",
41
- "@xterm/xterm": "^5.3.0",
40
+ "ghostty-web": "0.3.0",
42
41
  "class-variance-authority": "^0.7.1",
43
42
  "clsx": "^2.1.1",
44
43
  "cmdk": "^1.1.1",
45
44
  "express": "^5.1.0",
46
45
  "http-proxy-middleware": "^3.0.5",
47
46
  "next-themes": "^0.4.6",
48
- "node-pty": "^1.0.0",
47
+ "bun-pty": "^0.4.5",
48
+ "node-pty": "^1.1.0",
49
49
  "react": "^19.1.1",
50
50
  "react-dom": "^19.1.1",
51
51
  "react-markdown": "^10.1.0",
package/server/index.js CHANGED
@@ -7,6 +7,7 @@ import http from 'http';
7
7
  import { fileURLToPath } from 'url';
8
8
  import os from 'os';
9
9
  import { createUiAuth } from './lib/ui-auth.js';
10
+ import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js';
10
11
 
11
12
  const __filename = fileURLToPath(import.meta.url);
12
13
  const __dirname = path.dirname(__filename);
@@ -578,6 +579,7 @@ let isOpenCodeReady = false;
578
579
  let openCodeNotReadySince = 0;
579
580
  let exitOnShutdown = true;
580
581
  let uiAuthController = null;
582
+ let cloudflareTunnelController = null;
581
583
 
582
584
  // Sync helper - call after modifying any HMR state variable
583
585
  const syncToHmrState = () => {
@@ -713,21 +715,18 @@ function resolveBinaryFromPath(binaryName, searchPath) {
713
715
  }
714
716
 
715
717
  function getOpencodeSpawnConfig() {
716
- const envPath = buildAugmentedPath();
717
- const resolvedEnv = { ...process.env, PATH: envPath };
718
-
719
718
  if (OPENCODE_BINARY_ENV) {
720
- const explicit = resolveBinaryFromPath(OPENCODE_BINARY_ENV, envPath);
719
+ const explicit = resolveBinaryFromPath(OPENCODE_BINARY_ENV, process.env.PATH);
721
720
  if (explicit) {
722
721
  console.log(`Using OpenCode binary from OPENCODE_BINARY: ${explicit}`);
723
- return { command: explicit, env: resolvedEnv };
722
+ return { command: explicit, env: undefined };
724
723
  }
725
724
  console.warn(
726
725
  `OPENCODE_BINARY path "${OPENCODE_BINARY_ENV}" not found. Falling back to search.`
727
726
  );
728
727
  }
729
728
 
730
- return { command: 'opencode', env: resolvedEnv };
729
+ return { command: 'opencode', env: undefined };
731
730
  }
732
731
 
733
732
  const ENV_CONFIGURED_OPENCODE_PORT = (() => {
@@ -1151,7 +1150,8 @@ function parseArgs(argv = process.argv.slice(2)) {
1151
1150
  process.env.OPENCHAMBER_UI_PASSWORD ||
1152
1151
  process.env.OPENCODE_UI_PASSWORD ||
1153
1152
  null;
1154
- const options = { port: DEFAULT_PORT, uiPassword: envPassword };
1153
+ const envCfTunnel = process.env.OPENCHAMBER_TRY_CF_TUNNEL === 'true';
1154
+ const options = { port: DEFAULT_PORT, uiPassword: envPassword, tryCfTunnel: envCfTunnel };
1155
1155
 
1156
1156
  const consumeValue = (currentIndex, inlineValue) => {
1157
1157
  if (typeof inlineValue === 'string') {
@@ -1188,6 +1188,11 @@ function parseArgs(argv = process.argv.slice(2)) {
1188
1188
  options.uiPassword = typeof value === 'string' ? value : '';
1189
1189
  continue;
1190
1190
  }
1191
+
1192
+ if (optionName === 'try-cf-tunnel') {
1193
+ options.tryCfTunnel = true;
1194
+ continue;
1195
+ }
1191
1196
  }
1192
1197
 
1193
1198
  return options;
@@ -1835,6 +1840,12 @@ async function gracefulShutdown(options = {}) {
1835
1840
  uiAuthController = null;
1836
1841
  }
1837
1842
 
1843
+ if (cloudflareTunnelController) {
1844
+ console.log('Stopping Cloudflare tunnel...');
1845
+ cloudflareTunnelController.stop();
1846
+ cloudflareTunnelController = null;
1847
+ }
1848
+
1838
1849
  console.log('Graceful shutdown complete');
1839
1850
  if (exitProcess) {
1840
1851
  process.exit(0);
@@ -1843,7 +1854,9 @@ async function gracefulShutdown(options = {}) {
1843
1854
 
1844
1855
  async function main(options = {}) {
1845
1856
  const port = Number.isFinite(options.port) && options.port >= 0 ? Math.trunc(options.port) : DEFAULT_PORT;
1857
+ const tryCfTunnel = options.tryCfTunnel === true;
1846
1858
  const attachSignals = options.attachSignals !== false;
1859
+ const onTunnelReady = typeof options.onTunnelReady === 'function' ? options.onTunnelReady : null;
1847
1860
  if (typeof options.exitOnShutdown === 'boolean') {
1848
1861
  exitOnShutdown = options.exitOnShutdown;
1849
1862
  }
@@ -3387,12 +3400,12 @@ async function main(options = {}) {
3387
3400
  return res.status(400).json({ error: 'directory parameter is required' });
3388
3401
  }
3389
3402
 
3390
- const { path, branch, createBranch } = req.body;
3403
+ const { path, branch, createBranch, startPoint } = req.body;
3391
3404
  if (!path || !branch) {
3392
3405
  return res.status(400).json({ error: 'path and branch are required' });
3393
3406
  }
3394
3407
 
3395
- const result = await addWorktree(directory, path, branch, { createBranch });
3408
+ const result = await addWorktree(directory, path, branch, { createBranch, startPoint });
3396
3409
  res.json(result);
3397
3410
  } catch (error) {
3398
3411
  console.error('Failed to add worktree:', error);
@@ -3599,7 +3612,9 @@ async function main(options = {}) {
3599
3612
  const isSymbolicLink = dirent.isSymbolicLink();
3600
3613
 
3601
3614
  if (!isDirectory && isSymbolicLink) {
3602
- try {
3615
+
3616
+ try {
3617
+
3603
3618
  const linkStats = await fsPromises.stat(entryPath);
3604
3619
  isDirectory = linkStats.isDirectory();
3605
3620
  } catch {
@@ -3677,23 +3692,39 @@ async function main(options = {}) {
3677
3692
  }
3678
3693
  });
3679
3694
 
3680
- let ptyLib = null;
3681
- let ptyLoadError = null;
3682
- const getPtyLib = async () => {
3683
- if (ptyLib) return ptyLib;
3684
- if (ptyLoadError) throw ptyLoadError;
3685
-
3686
- try {
3687
- ptyLib = await import('node-pty');
3688
- console.log('node-pty loaded successfully');
3689
- return ptyLib;
3690
- } catch (error) {
3691
- ptyLoadError = error;
3692
- console.error('Failed to load node-pty:', error.message);
3693
- console.error('Terminal functionality will not be available.');
3694
- console.error('To fix: run "npm rebuild node-pty" or "npm install"');
3695
- throw new Error('node-pty is not available. Run: npm rebuild node-pty');
3695
+ let ptyProviderPromise = null;
3696
+ const getPtyProvider = async () => {
3697
+ if (ptyProviderPromise) {
3698
+ return ptyProviderPromise;
3696
3699
  }
3700
+
3701
+ ptyProviderPromise = (async () => {
3702
+ const isBunRuntime = typeof globalThis.Bun !== 'undefined';
3703
+
3704
+ if (isBunRuntime) {
3705
+ try {
3706
+ const bunPty = await import('bun-pty');
3707
+ console.log('Using bun-pty for terminal sessions');
3708
+ return { spawn: bunPty.spawn, backend: 'bun-pty' };
3709
+ } catch (error) {
3710
+ console.warn('bun-pty unavailable, falling back to node-pty');
3711
+ }
3712
+ }
3713
+
3714
+ try {
3715
+ const nodePty = await import('node-pty');
3716
+ console.log('Using node-pty for terminal sessions');
3717
+ return { spawn: nodePty.spawn, backend: 'node-pty' };
3718
+ } catch (error) {
3719
+ console.error('Failed to load node-pty:', error && error.message ? error.message : error);
3720
+ if (isBunRuntime) {
3721
+ throw new Error('No PTY backend available. Install bun-pty or node-pty.');
3722
+ }
3723
+ throw new Error('node-pty is not available. Run: npm rebuild node-pty (or install Bun for bun-pty)');
3724
+ }
3725
+ })();
3726
+
3727
+ return ptyProviderPromise;
3697
3728
  };
3698
3729
 
3699
3730
  const terminalSessions = new Map();
@@ -3730,7 +3761,6 @@ async function main(options = {}) {
3730
3761
  return res.status(400).json({ error: 'Invalid working directory' });
3731
3762
  }
3732
3763
 
3733
- const pty = await getPtyLib();
3734
3764
  const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh');
3735
3765
 
3736
3766
  const sessionId = Math.random().toString(36).substring(2, 15) +
@@ -3739,6 +3769,7 @@ async function main(options = {}) {
3739
3769
  const envPath = buildAugmentedPath();
3740
3770
  const resolvedEnv = { ...process.env, PATH: envPath };
3741
3771
 
3772
+ const pty = await getPtyProvider();
3742
3773
  const ptyProcess = pty.spawn(shell, [], {
3743
3774
  name: 'xterm-256color',
3744
3775
  cols: cols || 80,
@@ -3753,6 +3784,7 @@ async function main(options = {}) {
3753
3784
 
3754
3785
  const session = {
3755
3786
  ptyProcess,
3787
+ ptyBackend: pty.backend,
3756
3788
  cwd,
3757
3789
  lastActivity: Date.now(),
3758
3790
  clients: new Set(),
@@ -3786,12 +3818,14 @@ async function main(options = {}) {
3786
3818
  res.setHeader('Connection', 'keep-alive');
3787
3819
  res.setHeader('X-Accel-Buffering', 'no');
3788
3820
 
3789
- res.write('data: {"type":"connected"}\n\n');
3790
-
3791
3821
  const clientId = Math.random().toString(36).substring(7);
3792
3822
  session.clients.add(clientId);
3793
3823
  session.lastActivity = Date.now();
3794
3824
 
3825
+ const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun';
3826
+ const ptyBackend = session.ptyBackend || 'unknown';
3827
+ res.write(`data: ${JSON.stringify({ type: 'connected', runtime, ptyBackend })}\n\n`);
3828
+
3795
3829
  const heartbeatInterval = setInterval(() => {
3796
3830
  try {
3797
3831
 
@@ -3856,7 +3890,7 @@ async function main(options = {}) {
3856
3890
  req.on('close', cleanup);
3857
3891
  req.on('error', cleanup);
3858
3892
 
3859
- console.log(`Client ${clientId} connected to terminal session ${sessionId}`);
3893
+ console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${runtime} pty=${ptyBackend}`);
3860
3894
  });
3861
3895
 
3862
3896
  app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
@@ -3943,7 +3977,6 @@ async function main(options = {}) {
3943
3977
  return res.status(400).json({ error: 'Invalid working directory' });
3944
3978
  }
3945
3979
 
3946
- const pty = await getPtyLib();
3947
3980
  const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh');
3948
3981
 
3949
3982
  const newSessionId = Math.random().toString(36).substring(2, 15) +
@@ -3952,6 +3985,7 @@ async function main(options = {}) {
3952
3985
  const envPath = buildAugmentedPath();
3953
3986
  const resolvedEnv = { ...process.env, PATH: envPath };
3954
3987
 
3988
+ const pty = await getPtyProvider();
3955
3989
  const ptyProcess = pty.spawn(shell, [], {
3956
3990
  name: 'xterm-256color',
3957
3991
  cols: cols || 80,
@@ -3966,6 +4000,7 @@ async function main(options = {}) {
3966
4000
 
3967
4001
  const session = {
3968
4002
  ptyProcess,
4003
+ ptyBackend: pty.backend,
3969
4004
  cwd,
3970
4005
  lastActivity: Date.now(),
3971
4006
  clients: new Set(),
@@ -4026,6 +4061,7 @@ async function main(options = {}) {
4026
4061
  res.json({ success: true, killedCount });
4027
4062
  });
4028
4063
 
4064
+
4029
4065
  try {
4030
4066
  // Check if we can reuse an existing OpenCode process from a previous HMR cycle
4031
4067
  syncFromHmrState();
@@ -4086,13 +4122,35 @@ async function main(options = {}) {
4086
4122
  reject(error);
4087
4123
  };
4088
4124
  server.once('error', onError);
4089
- server.listen(port, () => {
4125
+ server.listen(port, async () => {
4090
4126
  server.off('error', onError);
4091
4127
  const addressInfo = server.address();
4092
4128
  activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port;
4093
4129
  console.log(`OpenChamber server running on port ${activePort}`);
4094
4130
  console.log(`Health check: http://localhost:${activePort}/health`);
4095
4131
  console.log(`Web interface: http://localhost:${activePort}`);
4132
+
4133
+ if (tryCfTunnel) {
4134
+ console.log('\nInitializing Cloudflare Quick Tunnel...');
4135
+ const cfCheck = await checkCloudflaredAvailable();
4136
+ if (cfCheck.available) {
4137
+ try {
4138
+ const originUrl = `http://localhost:${activePort}`;
4139
+ cloudflareTunnelController = await startCloudflareTunnel({ originUrl, port: activePort });
4140
+ printTunnelWarning();
4141
+ if (onTunnelReady) {
4142
+ const tunnelUrl = cloudflareTunnelController.getPublicUrl();
4143
+ if (tunnelUrl) {
4144
+ onTunnelReady(tunnelUrl);
4145
+ }
4146
+ }
4147
+ } catch (error) {
4148
+ console.error(`Failed to start Cloudflare tunnel: ${error.message}`);
4149
+ console.log('Continuing without tunnel...');
4150
+ }
4151
+ }
4152
+ }
4153
+
4096
4154
  resolve();
4097
4155
  });
4098
4156
  });
@@ -4119,6 +4177,7 @@ async function main(options = {}) {
4119
4177
  httpServer: server,
4120
4178
  getPort: () => activePort,
4121
4179
  getOpenCodePort: () => openCodePort,
4180
+ getTunnelUrl: () => cloudflareTunnelController?.getPublicUrl() ?? null,
4122
4181
  isReady: () => isOpenCodeReady,
4123
4182
  restartOpenCode: () => restartOpenCode(),
4124
4183
  stop: (shutdownOptions = {}) =>
@@ -4133,6 +4192,7 @@ if (isCliExecution) {
4133
4192
  exitOnShutdown = true;
4134
4193
  main({
4135
4194
  port: cliOptions.port,
4195
+ tryCfTunnel: cliOptions.tryCfTunnel,
4136
4196
  attachSignals: true,
4137
4197
  exitOnShutdown: true,
4138
4198
  uiPassword: cliOptions.uiPassword
@@ -0,0 +1,196 @@
1
+ import { spawn, spawnSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ const TRY_CF_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
11
+
12
+ async function searchPathFor(command) {
13
+ const pathValue = process.env.PATH || '';
14
+ const segments = pathValue.split(path.delimiter).filter(Boolean);
15
+ const WINDOWS_EXTENSIONS = process.platform === 'win32'
16
+ ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
17
+ .split(';')
18
+ .map((ext) => ext.trim().toLowerCase())
19
+ .filter(Boolean)
20
+ .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`))
21
+ : [''];
22
+
23
+ for (const dir of segments) {
24
+ for (const ext of WINDOWS_EXTENSIONS) {
25
+ const fileName = process.platform === 'win32' ? `${command}${ext}` : command;
26
+ const candidate = path.join(dir, fileName);
27
+ try {
28
+ const stats = fs.statSync(candidate);
29
+ if (stats.isFile()) {
30
+ if (process.platform !== 'win32') {
31
+ try {
32
+ fs.accessSync(candidate, fs.constants.X_OK);
33
+ } catch {
34
+ continue;
35
+ }
36
+ }
37
+ return candidate;
38
+ }
39
+ } catch {
40
+ continue;
41
+ }
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+
47
+ export async function checkCloudflaredAvailable() {
48
+ const cfPath = await searchPathFor('cloudflared');
49
+ if (cfPath) {
50
+ try {
51
+ const result = spawnSync(cfPath, ['--version'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
52
+ if (result.status === 0) {
53
+ return { available: true, path: cfPath, version: result.stdout.trim() };
54
+ }
55
+ } catch {
56
+ // Ignore
57
+ }
58
+ }
59
+ return { available: false, path: null, version: null };
60
+ }
61
+
62
+ export function printCloudflareTunnelInstallHelp() {
63
+ const platform = process.platform;
64
+ let installCmd = '';
65
+
66
+ if (platform === 'darwin') {
67
+ installCmd = 'brew install cloudflared';
68
+ } else if (platform === 'win32') {
69
+ installCmd = 'winget install --id Cloudflare.cloudflared';
70
+ } else {
71
+ installCmd = 'Download from https://github.com/cloudflare/cloudflared/releases';
72
+ }
73
+
74
+ console.log(`
75
+ ╔══════════════════════════════════════════════════════════════════╗
76
+ ║ Cloudflare tunnel requires 'cloudflared' to be installed ║
77
+ ╚══════════════════════════════════════════════════════════════════╝
78
+
79
+ Install instructions for your platform:
80
+
81
+ macOS: brew install cloudflared
82
+ Windows: winget install --id Cloudflare.cloudflared
83
+ Linux: Download from https://github.com/cloudflare/cloudflared/releases
84
+
85
+ Or visit: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflared/downloads/
86
+ `);
87
+ }
88
+
89
+ export async function startCloudflareTunnel({ originUrl, port }) {
90
+ const cfCheck = await checkCloudflaredAvailable();
91
+
92
+ if (!cfCheck.available) {
93
+ printCloudflareTunnelInstallHelp();
94
+ throw new Error('cloudflared is not installed');
95
+ }
96
+
97
+ console.log(`Using cloudflared: ${cfCheck.path} (${cfCheck.version})`);
98
+
99
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-cf-'));
100
+
101
+ const child = spawn('cloudflared', ['tunnel', '--url', originUrl], {
102
+ stdio: ['ignore', 'pipe', 'pipe'],
103
+ env: {
104
+ ...process.env,
105
+ HOME: tempDir,
106
+ CF_TELEMETRY_DISABLE: '1',
107
+ },
108
+ killSignal: 'SIGINT',
109
+ });
110
+
111
+ let publicUrl = null;
112
+ let tunnelReady = false;
113
+
114
+ const onData = (chunk, isStderr) => {
115
+ const text = chunk.toString('utf8');
116
+
117
+ if (!tunnelReady) {
118
+ const match = text.match(TRY_CF_URL_REGEX);
119
+ if (match) {
120
+ publicUrl = match[0];
121
+ tunnelReady = true;
122
+ }
123
+ }
124
+
125
+ process.stderr.write(isStderr ? text : '');
126
+ };
127
+
128
+ child.stdout.on('data', (chunk) => onData(chunk, false));
129
+ child.stderr.on('data', (chunk) => onData(chunk, true));
130
+
131
+ child.on('error', (error) => {
132
+ console.error(`Cloudflared error: ${error.message}`);
133
+ cleanupTempDir();
134
+ });
135
+
136
+ const cleanupTempDir = () => {
137
+ try {
138
+ if (fs.existsSync(tempDir)) {
139
+ fs.rmSync(tempDir, { recursive: true, force: true });
140
+ }
141
+ } catch {
142
+ // Ignore cleanup errors
143
+ }
144
+ };
145
+
146
+ await new Promise((resolve, reject) => {
147
+ const timeout = setTimeout(() => {
148
+ if (!publicUrl) {
149
+ reject(new Error('Tunnel URL not received within 30 seconds'));
150
+ }
151
+ }, 30000);
152
+
153
+ const checkReady = setInterval(() => {
154
+ if (publicUrl) {
155
+ clearTimeout(timeout);
156
+ clearInterval(checkReady);
157
+ resolve(null);
158
+ }
159
+ }, 100);
160
+
161
+ child.on('exit', (code) => {
162
+ clearTimeout(timeout);
163
+ clearInterval(checkReady);
164
+ cleanupTempDir();
165
+ if (code !== null && code !== 0) {
166
+ reject(new Error(`Cloudflared exited with code ${code}`));
167
+ }
168
+ });
169
+ });
170
+
171
+ return {
172
+ stop: () => {
173
+ try {
174
+ child.kill('SIGINT');
175
+ } catch {
176
+ // Ignore
177
+ }
178
+ },
179
+ process: child,
180
+ getPublicUrl: () => publicUrl,
181
+ };
182
+ }
183
+
184
+ export function printTunnelWarning() {
185
+ console.log(`
186
+ ⚠️ Cloudflare Quick Tunnel Limitations:
187
+
188
+ • Maximum 200 concurrent requests
189
+ • Server-Sent Events (SSE) are NOT supported
190
+ • URLs are temporary and will expire when the tunnel stops
191
+ • Password protection is required for tunnel access
192
+
193
+ For production use, set up a named Cloudflare Tunnel:
194
+ https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/
195
+ `);
196
+ }