-
Notifications
You must be signed in to change notification settings - Fork 25
Expand file tree
/
Copy pathdev.py
More file actions
291 lines (236 loc) · 9.95 KB
/
dev.py
File metadata and controls
291 lines (236 loc) · 9.95 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
"""Development server command with Cloudflare Tunnel integration."""
import http.server
import json
import os
import platform
import re
import socketserver
import subprocess
import sys
import threading
import time
from pathlib import Path
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
console = Console()
def check_cloudflared_installed() -> bool:
"""Check if cloudflared is installed."""
try:
subprocess.run(
["cloudflared", "--version"],
capture_output=True,
check=True,
)
return True
except (FileNotFoundError, subprocess.CalledProcessError):
return False
def install_cloudflared() -> bool:
"""Install cloudflared automatically."""
console.print("\n[cyan]Installing cloudflared...[/cyan]")
system = platform.system()
try:
if system == "Darwin": # macOS
console.print("[dim]Using Homebrew...[/dim]")
subprocess.run(
["brew", "install", "cloudflare/cloudflare/cloudflared"],
check=True,
)
elif system == "Linux":
console.print("[dim]Downloading binary...[/dim]")
arch = platform.machine()
if arch == "x86_64":
arch = "amd64"
elif arch == "aarch64":
arch = "arm64"
url = f"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-{arch}"
subprocess.run(["wget", url, "-O", "/tmp/cloudflared"], check=True)
subprocess.run(["chmod", "+x", "/tmp/cloudflared"], check=True)
subprocess.run(["sudo", "mv", "/tmp/cloudflared", "/usr/local/bin/"], check=True)
elif system == "Windows":
console.print("[dim]Downloading Windows binary...[/dim]")
subprocess.run([
"powershell", "-Command",
"Invoke-WebRequest -Uri 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe' -OutFile 'C:\\Windows\\System32\\cloudflared.exe'"
], check=True)
else:
console.print(f"[red]Unsupported platform: {system}[/red]")
return False
console.print("[green]✓ cloudflared installed successfully[/green]\n")
return True
except subprocess.CalledProcessError as e:
console.print(f"[red]Installation failed: {e}[/red]")
console.print("\n[yellow]Manual installation:[/yellow]")
console.print(" macOS: brew install cloudflare/cloudflare/cloudflared")
console.print(" Linux: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/")
console.print(" Windows: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/")
return False
def start_cloudflare_tunnel(port: int) -> tuple[subprocess.Popen, str]:
"""
Start Cloudflare Tunnel and return process and public URL.
Returns:
(process, public_url)
"""
console.print(f"[cyan]Starting Cloudflare Tunnel on port {port}...[/cyan]")
# Start cloudflared tunnel
process = subprocess.Popen(
["cloudflared", "tunnel", "--url", f"http://localhost:{port}"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
# Wait for tunnel URL
public_url = None
for _ in range(30): # 30 seconds timeout
if process.stderr:
line = process.stderr.readline()
if line:
# Look for URL in output: https://random-name.trycloudflare.com
match = re.search(r'https://[a-zA-Z0-9-]+\.trycloudflare\.com', line)
if match:
public_url = match.group(0)
break
time.sleep(0.1)
if not public_url:
process.terminate()
raise RuntimeError("Failed to get tunnel URL from cloudflared")
return process, public_url
def start_asset_server(assets_dir: Path, port: int = 4444):
"""
Start static file server for assets (hosted mode).
Serves files from the assets directory with CORS headers enabled.
"""
class CORSHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(assets_dir), **kwargs)
def end_headers(self):
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
self.send_header('Access-Control-Allow-Headers', '*')
self.send_header('Cache-Control', 'no-cache')
super().end_headers()
def do_OPTIONS(self):
self.send_response(200)
self.end_headers()
def log_message(self, format, *args):
# Suppress request logs to keep output clean
pass
handler = CORSHTTPRequestHandler
# Use ThreadingTCPServer for concurrent requests
class ThreadedAssetServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
# Allow socket reuse to prevent "Address already in use" errors
allow_reuse_address = True
# Set daemon threads so server shuts down cleanly
daemon_threads = True
with ThreadedAssetServer(("", port), handler) as httpd:
console.print(f"[green]✓ Asset server running on http://localhost:{port}[/green]")
httpd.serve_forever()
def start_dev_server(port=8001, host="0.0.0.0", mode="hosted", protocol="openai-apps"):
"""Start development server with Cloudflare Tunnel.
Args:
port: Port for MCP server (default: 8001)
host: Host to bind server (default: "0.0.0.0")
mode: Build mode - "hosted" (default) or "inline"
protocol: UI protocol adapter ("openai-apps" | "mcp-apps")
"""
# Check if we're in a FastApps project
if not Path("server/main.py").exists():
console.print("[red]Error: Not in a FastApps project directory[/red]")
console.print(
"[yellow]Run this command from your project root (where server/main.py exists)[/yellow]"
)
return False
# Start asset server if hosted mode
asset_server_thread = None
if mode == "hosted":
assets_dir = Path.cwd() / "assets"
if not assets_dir.exists():
console.print("[yellow]Creating assets directory...[/yellow]")
assets_dir.mkdir(parents=True, exist_ok=True)
console.print(f"[cyan]Starting asset server on port 4444...[/cyan]")
asset_server_thread = threading.Thread(
target=start_asset_server,
args=(assets_dir, 4444),
daemon=True
)
asset_server_thread.start()
time.sleep(0.5)
console.print()
# Check if cloudflared is installed
if not check_cloudflared_installed():
console.print("[yellow]cloudflared not found[/yellow]")
if not install_cloudflared():
return False
# Start Cloudflare Tunnel
try:
tunnel_process, public_url = start_cloudflare_tunnel(port)
except RuntimeError as e:
console.print(f"[red]Failed to start tunnel: {e}[/red]")
return False
console.print()
# Set PUBLIC_URL environment variable for builder
os.environ["PUBLIC_URL"] = public_url
# Import and start server (shows uvicorn boot logs first)
console.print("[cyan]Starting FastApps server...[/cyan]\n")
try:
import uvicorn
import asyncio
# Import project server
sys.path.insert(0, str(Path.cwd()))
# Reset sys.argv to avoid argparse conflicts in server/main.py
# Pass mode to server for builder
sys.argv = ["server/main.py", "--build", f"--mode={mode}", f"--protocol={protocol}"]
from server.main import app
# Create server config
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
# Start server in background thread to show info panel
def run_server():
asyncio.run(server.serve())
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
# Wait a moment for server to start and show logs
time.sleep(1)
# Now display connection info (will stay visible above ongoing logs)
console.print()
table = Table(title="FastApps Development Server", title_style="bold green")
table.add_column("Type", style="cyan", no_wrap=True)
table.add_column("URL", style="white")
table.add_row("Local", f"http://{host}:{port}")
table.add_row("Public", f"[bold green]{public_url}[/bold green]")
console.print(table)
console.print()
# Display MCP endpoint info
mcp_panel = Panel(
f"[bold]MCP Server Endpoint:[/bold]\n"
f"[green]{public_url}/mcp[/green]\n\n"
f"[dim]Use this URL in your MCP client configuration[/dim]",
title="Model Context Protocol",
border_style="blue",
)
console.print(mcp_panel)
console.print()
console.print("[yellow]Press Ctrl+C to stop the server[/yellow]\n")
# Keep main thread alive
server_thread.join()
except KeyboardInterrupt:
console.print("\n[yellow]Shutting down server...[/yellow]")
try:
tunnel_process.terminate()
tunnel_process.wait(timeout=5)
console.print("[green]Server stopped[/green]")
except Exception:
pass
return True
except ImportError as e:
console.print(f"[red]Error: Could not import server: {e}[/red]")
console.print(
"[yellow]Make sure you're in a FastApps project and dependencies are installed[/yellow]"
)
tunnel_process.terminate()
return False
except Exception as e:
console.print(f"[red]Error starting server: {e}[/red]")
tunnel_process.terminate()
return False