Skip to content

Commit a57b308

Browse files
davindicodeclaude
andcommitted
Cloudflare tunnels, cross-platform start.sh, terminal UX improvements
- Auto-install cloudflared on macOS/Linux/Windows, download to .bin/ locally - Per-port cloudflare tunnels for browser preview (open in new tab) - Tunnel cleanup on client disconnect and script exit (trap) - Cross-platform shell detection for node-pty (zsh/bash/cmd.exe) - macOS quarantine flag cleanup for node-pty spawn-helper - Terminal input: Enter creates newlines, Send button sends, auto-grow textarea - Action buttons: long-press repeat with scroll detection (no accidental fires) - Tmux: scroll/copy mode button, paste buffer button - DNS troubleshooting guide in README with commands for all OSes - Strip X-Frame-Options in proxy, cache proxy instances per port Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e164ea2 commit a57b308

9 files changed

Lines changed: 650 additions & 214 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
.idea/
1616
Thumbs.db
1717

18+
# Local binaries (cloudflared etc.)
19+
/.bin/
20+
1821
# Logs
1922
*.log
2023
/tmp/

README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,39 @@ pnpm dev
5353

5454
The `start.sh` script installs dependencies if needed, builds the app, starts the server, and launches a Cloudflare Quick Tunnel — printing a public URL you can open on any device.
5555

56+
> **DNS tip:** Quick tunnel URLs (e.g. `xxxx.trycloudflare.com`) resolve instantly on Cloudflare DNS but may take time on ISP/router DNS servers, or fail entirely if a stale NXDOMAIN gets cached. Set your DNS to **1.1.1.1** (Cloudflare) for instant resolution — it's faster and more reliable than most ISP DNS:
57+
>
58+
> **macOS:**
59+
> ```bash
60+
> # Set DNS for Wi-Fi (most common)
61+
> networksetup -setdnsservers Wi-Fi 1.1.1.1 1.0.0.1
62+
> # Flush DNS cache
63+
> sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder
64+
> # To revert to automatic/router DNS:
65+
> # networksetup -setdnsservers Wi-Fi empty
66+
> ```
67+
>
68+
> **Linux (systemd-resolved):**
69+
> ```bash
70+
> # Find your interface name (e.g. eth0, wlan0)
71+
> resolvectl dns $(ip route show default | awk '{print $5}') 1.1.1.1 1.0.0.1
72+
> # Or permanently via /etc/systemd/resolved.conf:
73+
> # [Resolve]
74+
> # DNS=1.1.1.1 1.0.0.1
75+
> # sudo systemctl restart systemd-resolved
76+
> ```
77+
>
78+
> **Windows (PowerShell as Admin):**
79+
> ```powershell
80+
> # Find your interface (e.g. "Wi-Fi" or "Ethernet")
81+
> Get-NetAdapter | Select Name, Status
82+
> # Set DNS
83+
> Set-DnsClientServerAddress -InterfaceAlias "Wi-Fi" -ServerAddresses 1.1.1.1,1.0.0.1
84+
> # Flush DNS cache
85+
> ipconfig /flushdns
86+
> # To revert: Set-DnsClientServerAddress -InterfaceAlias "Wi-Fi" -ResetServerAddresses
87+
> ```
88+
5689
## Scripts
5790
5891
| Command | Description |
@@ -87,7 +120,7 @@ React Router v7, Express, Socket.IO, node-pty, xterm.js, Monaco Editor, Zustand,
87120
- **Node.js** 20+
88121
- **pnpm** (package manager)
89122
- **C compiler** for node-pty on Linux (`apt install build-essential`)
90-
- **cloudflared** (optional) for remote access via Cloudflare tunnel[install guide](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)
123+
- **cloudflared** (auto-installed by `start.sh` if not found) for remote access via Cloudflare tunnel
91124

92125
## License
93126

app/components/browser/BrowserPage.tsx

Lines changed: 136 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,78 @@
11
import { useState, useRef, useEffect, useCallback } from "react";
22
import { useBrowserStore, type BrowserTab } from "~/stores/browserStore";
33
import { isPortBlocked } from "~/lib/constants";
4+
import { getSocket } from "~/lib/socket";
45

5-
interface ConsoleEntry {
6-
method: string;
7-
args: string[];
8-
timestamp: number;
6+
// Cache tunnel URLs across renders
7+
const tunnelCache = new Map<number, string>();
8+
9+
type TunnelState =
10+
| { status: "idle" }
11+
| { status: "connecting" }
12+
| { status: "ready"; url: string }
13+
| { status: "error"; message: string };
14+
15+
function usePortTunnel(port: string | undefined): {
16+
state: TunnelState;
17+
retry: () => void;
18+
} {
19+
const [state, setState] = useState<TunnelState>({ status: "idle" });
20+
const [retryCount, setRetryCount] = useState(0);
21+
22+
const retry = useCallback(() => {
23+
if (!port) return;
24+
tunnelCache.delete(parseInt(port, 10));
25+
setState({ status: "connecting" });
26+
setRetryCount((c) => c + 1);
27+
}, [port]);
28+
29+
useEffect(() => {
30+
if (!port) {
31+
setState({ status: "idle" });
32+
return;
33+
}
34+
35+
const portNum = parseInt(port, 10);
36+
const cached = tunnelCache.get(portNum);
37+
if (cached) {
38+
setState({ status: "ready", url: cached });
39+
return;
40+
}
41+
42+
setState({ status: "connecting" });
43+
const socket = getSocket();
44+
45+
const onReady = (data: { port: number; url: string }) => {
46+
if (data.port === portNum) {
47+
tunnelCache.set(portNum, data.url);
48+
setState({ status: "ready", url: data.url });
49+
}
50+
};
51+
const onError = (data: { port: number; error: string }) => {
52+
if (data.port === portNum) {
53+
setState({ status: "error", message: data.error });
54+
}
55+
};
56+
const onDied = (data: { port: number }) => {
57+
if (data.port === portNum) {
58+
tunnelCache.delete(portNum);
59+
setState({ status: "error", message: "Tunnel disconnected" });
60+
}
61+
};
62+
63+
socket.on("port_tunnel_ready", onReady);
64+
socket.on("port_tunnel_error", onError);
65+
socket.on("port_tunnel_died", onDied);
66+
socket.emit("request_port_tunnel", { port: portNum });
67+
68+
return () => {
69+
socket.off("port_tunnel_ready", onReady);
70+
socket.off("port_tunnel_error", onError);
71+
socket.off("port_tunnel_died", onDied);
72+
};
73+
}, [port, retryCount]);
74+
75+
return { state, retry };
976
}
1077

1178
function TabInput({ tab }: { tab: BrowserTab }) {
@@ -60,78 +127,81 @@ function TabInput({ tab }: { tab: BrowserTab }) {
60127
);
61128
}
62129

63-
const METHOD_COLORS: Record<string, string> = {
64-
log: "text-gray-300",
65-
info: "text-blue-400",
66-
warn: "text-yellow-400",
67-
error: "text-red-400",
68-
debug: "text-gray-500",
69-
};
70-
71-
function ConsolePanel({ entries, onClear }: { entries: ConsoleEntry[]; onClear: () => void }) {
72-
const bottomRef = useRef<HTMLDivElement>(null);
130+
function PortTunnelView({ tab }: { tab: BrowserTab }) {
131+
const { state, retry } = usePortTunnel(tab.port);
132+
const [copied, setCopied] = useState(false);
73133

74-
useEffect(() => {
75-
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
76-
}, [entries.length]);
134+
const handleCopy = (url: string) => {
135+
navigator.clipboard.writeText(url);
136+
setCopied(true);
137+
setTimeout(() => setCopied(false), 2000);
138+
};
77139

78140
return (
79-
<div className="flex flex-col h-full border-t border-gray-700 bg-[#0d0d1a]">
80-
<div className="flex items-center justify-between px-3 py-1 bg-[#16162a] border-b border-gray-700 shrink-0">
81-
<span className="text-xs text-gray-400 font-medium">Console</span>
82-
<div className="flex items-center gap-2">
83-
<span className="text-[10px] text-gray-500">{entries.length} messages</span>
141+
<div className="flex flex-col items-center justify-center h-full bg-[#0d0d1a] gap-4">
142+
{state.status === "connecting" && (
143+
<>
144+
<svg className="w-10 h-10 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24">
145+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
146+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
147+
</svg>
148+
<span className="text-sm text-gray-400">Starting tunnel for localhost:{tab.port}...</span>
149+
<span className="text-xs text-gray-600">This may take a few seconds</span>
150+
</>
151+
)}
152+
153+
{state.status === "ready" && (
154+
<>
155+
<svg className="w-10 h-10 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
156+
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.06a4.5 4.5 0 00-6.364-6.364L4.5 8.743" />
157+
</svg>
158+
<span className="text-sm text-gray-300">localhost:{tab.port}</span>
159+
<a
160+
href={state.url}
161+
target="_blank"
162+
rel="noopener noreferrer"
163+
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
164+
>
165+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
166+
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-4.5-6H18m0 0v4.5m0-4.5L10.5 13.5" />
167+
</svg>
168+
Open in new tab
169+
</a>
84170
<button
85-
onClick={onClear}
86-
className="text-[10px] text-gray-500 hover:text-white transition-colors"
171+
onClick={() => handleCopy(state.url)}
172+
className="text-xs text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1"
173+
title="Copy URL"
87174
>
88-
Clear
175+
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
176+
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
177+
</svg>
178+
{copied ? "Copied!" : state.url}
89179
</button>
90-
</div>
91-
</div>
92-
<div className="flex-1 overflow-y-auto p-2 font-mono text-xs space-y-0.5">
93-
{entries.length === 0 ? (
94-
<span className="text-gray-600">No console output yet</span>
95-
) : (
96-
entries.map((entry, i) => (
97-
<div key={i} className={`flex gap-2 ${METHOD_COLORS[entry.method] || "text-gray-300"}`}>
98-
<span className="text-gray-600 shrink-0 w-12 text-right">
99-
{new Date(entry.timestamp).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })}
100-
</span>
101-
<span className="shrink-0 w-10 text-right opacity-60">{entry.method}</span>
102-
<span className="break-all whitespace-pre-wrap">{entry.args.join(" ")}</span>
103-
</div>
104-
))
105-
)}
106-
<div ref={bottomRef} />
107-
</div>
180+
</>
181+
)}
182+
183+
{state.status === "error" && (
184+
<>
185+
<svg className="w-10 h-10 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
186+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
187+
</svg>
188+
<span className="text-sm text-red-400">{state.message}</span>
189+
<button
190+
onClick={retry}
191+
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm font-medium transition-colors"
192+
>
193+
Retry
194+
</button>
195+
</>
196+
)}
108197
</div>
109198
);
110199
}
111200

112201
export default function BrowserPage() {
113-
const { tabs, activeTabId, addTab, removeTab, setActiveTab, refreshTab } = useBrowserStore();
202+
const { tabs, activeTabId, addTab, removeTab, setActiveTab } = useBrowserStore();
114203
const activeTab = tabs.find((t) => t.id === activeTabId);
115204

116-
const [showConsole, setShowConsole] = useState(false);
117-
const [consoleEntries, setConsoleEntries] = useState<ConsoleEntry[]>([]);
118-
119-
// Listen for postMessage from proxied iframes
120-
useEffect(() => {
121-
const handler = (e: MessageEvent) => {
122-
if (e.data?.type === "proxy-console") {
123-
setConsoleEntries((prev) => [
124-
...prev,
125-
{ method: e.data.method, args: e.data.args, timestamp: e.data.timestamp },
126-
]);
127-
}
128-
};
129-
window.addEventListener("message", handler);
130-
return () => window.removeEventListener("message", handler);
131-
}, []);
132-
133-
const clearConsole = useCallback(() => setConsoleEntries([]), []);
134-
135205
return (
136206
<div className="flex flex-col h-full min-h-0">
137207
{/* Tab bar */}
@@ -152,28 +222,6 @@ export default function BrowserPage() {
152222
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9" />
153223
</svg>
154224
<span>{tab.port ? `:${tab.port}` : "new"}</span>
155-
{isActive && tab.port && (
156-
<>
157-
<button
158-
onClick={(e) => { e.stopPropagation(); refreshTab(tab.id); }}
159-
className="p-0.5 rounded hover:bg-gray-600 text-gray-500 hover:text-gray-200 transition-colors"
160-
title="Refresh"
161-
>
162-
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
163-
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
164-
</svg>
165-
</button>
166-
<button
167-
onClick={(e) => { e.stopPropagation(); setShowConsole(!showConsole); }}
168-
className={`p-0.5 rounded hover:bg-gray-600 transition-colors ${showConsole ? "text-blue-400" : "text-gray-500 hover:text-gray-200"}`}
169-
title="Toggle console"
170-
>
171-
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
172-
<path strokeLinecap="round" strokeLinejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
173-
</svg>
174-
</button>
175-
</>
176-
)}
177225
<button
178226
onClick={(e) => { e.stopPropagation(); removeTab(tab.id); }}
179227
className="p-0.5 rounded hover:bg-gray-600 text-gray-500 hover:text-gray-200 transition-colors"
@@ -198,40 +246,18 @@ export default function BrowserPage() {
198246
</div>
199247

200248
{/* Content */}
201-
<div className={`flex-1 relative min-h-0 ${showConsole ? "flex flex-col" : ""}`}>
249+
<div className="flex-1 min-h-0">
202250
{!activeTab ? (
203251
<div className="flex flex-col items-center justify-center h-full bg-[#0d0d1a] text-gray-500 gap-3">
204252
<svg className="w-12 h-12 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
205253
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
206254
</svg>
207-
<span className="text-sm">Click + to preview a localhost port</span>
255+
<span className="text-sm">Click + to tunnel a localhost port</span>
208256
</div>
209257
) : !activeTab.port ? (
210258
<TabInput tab={activeTab} />
211259
) : (
212-
<>
213-
<div className={showConsole ? "flex-1 relative min-h-0" : "absolute inset-0"}>
214-
{tabs.filter((t) => t.port).map((tab) => (
215-
<div
216-
key={tab.id}
217-
className="absolute inset-0"
218-
style={{ visibility: tab.id === activeTabId ? "visible" : "hidden" }}
219-
>
220-
<iframe
221-
key={`${tab.port}-${tab.refreshKey}`}
222-
src={`/proxy/${tab.port}/`}
223-
className="w-full h-full border-0 bg-[#0d0d1a]"
224-
title={`localhost:${tab.port}`}
225-
/>
226-
</div>
227-
))}
228-
</div>
229-
{showConsole && (
230-
<div className="h-[40%] shrink-0">
231-
<ConsolePanel entries={consoleEntries} onClear={clearConsole} />
232-
</div>
233-
)}
234-
</>
260+
<PortTunnelView tab={activeTab} />
235261
)}
236262
</div>
237263
</div>

0 commit comments

Comments
 (0)