diff --git a/.gitignore b/.gitignore index c47d004..f242b1e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,7 @@ .env # Deployment artifacts -.fastapps-deploy-*.tar.gz \ No newline at end of file +.fastapps-deploy-*.tar.gz + +# examples will be uploaded soon +examples/ \ No newline at end of file diff --git a/fastapps/builder/build-all.mts b/fastapps/builder/build-all.mts index 1b7b1ea..1afa0b5 100644 --- a/fastapps/builder/build-all.mts +++ b/fastapps/builder/build-all.mts @@ -157,6 +157,13 @@ for (const file of entries) { console.groupEnd(); builtNames.push(name); console.log(`Built ${name}`); + + // Ensure CSS file exists (create empty one if not generated) + const cssFile = path.join(outDir, `${name}.css`); + if (!fs.existsSync(cssFile)) { + fs.writeFileSync(cssFile, "", "utf8"); + console.log(`Created empty CSS file for ${name}`); + } } const outputs = fs @@ -218,7 +225,9 @@ if (MODE === "inline") { console.log(`${htmlPath} (generated inline)`); } } else { - const defaultBaseUrl = "http://localhost:4444"; + // Use relative /assets path to work with the proxy server + // This allows assets to be served through the same origin, avoiding CORS/PNA/mixed content issues + const defaultBaseUrl = "/assets"; const baseUrlCandidate = process.env.BASE_URL?.trim() ?? ""; const baseUrlRaw = baseUrlCandidate || defaultBaseUrl; const normalizedBaseUrl = baseUrlRaw.replace(/\/+$/, ""); @@ -238,6 +247,6 @@ if (MODE === "inline") { `; fs.writeFileSync(htmlPath, html, { encoding: "utf8" }); - console.log(`${htmlPath} (generated with external references)`); + console.log(`${htmlPath} (generated)`); } } diff --git a/fastapps/builder/compiler.py b/fastapps/builder/compiler.py index b132a34..4630221 100644 --- a/fastapps/builder/compiler.py +++ b/fastapps/builder/compiler.py @@ -53,12 +53,18 @@ def build_all(self, mode: str = "hosted") -> Dict[str, WidgetBuildResult]: npx_cmd = "npx.cmd" if platform.system() == "Windows" else "npx" build_script = "build-all.mts" - # Pass mode and BASE_URL (for hosted) via environment + # Pass mode and asset URLs via environment env = os.environ.copy() # Explicit mode for the script env["MODE"] = mode if mode == "hosted": - env["BASE_URL"] = env.get("BASE_URL", "http://localhost:4444") + # Use PUBLIC_URL if available (for absolute URLs in iframes) + # Otherwise fall back to relative /assets path + public_url = env.get("PUBLIC_URL", "") + if public_url: + env["BASE_URL"] = f"{public_url}/assets" + else: + env["BASE_URL"] = "/assets" subprocess.run( [npx_cmd, "tsx", build_script], diff --git a/fastapps/cli/commands/dev.py b/fastapps/cli/commands/dev.py index 948a8c8..19d8039 100644 --- a/fastapps/cli/commands/dev.py +++ b/fastapps/cli/commands/dev.py @@ -131,15 +131,28 @@ def __init__(self, *args, **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', 'Content-Type') + 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 - with socketserver.TCPServer(("", port), handler) as httpd: + + # 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() @@ -194,6 +207,9 @@ def start_dev_server(port=8001, host="0.0.0.0", mode="hosted"): 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") @@ -237,7 +253,7 @@ def run_server(): # Display MCP endpoint info mcp_panel = Panel( f"[bold]MCP Server Endpoint:[/bold]\n" - f"[green]{public_url}[/green]\n\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", diff --git a/fastapps/cli/main.py b/fastapps/cli/main.py index c14deea..2dfbb06 100644 --- a/fastapps/cli/main.py +++ b/fastapps/cli/main.py @@ -50,10 +50,8 @@ def init(project_name): @click.option("--public", is_flag=True, help="Add no_auth decorator (public widget)") @click.option("--optional-auth", is_flag=True, help="Add optional_auth decorator") @click.option("--scopes", help="OAuth scopes (comma-separated, e.g., 'user,read:data')") -@click.option("--list", "use_list_template", is_flag=True, help="Create a list widget using the list template") -@click.option("--carousel", "use_carousel_template", is_flag=True, help="Create a carousel widget using the carousel template") -@click.option("--albums", "use_albums_template", is_flag=True, help="Create an albums widget using the albums template") -def create(widget_name, auth, public, optional_auth, scopes, use_list_template, use_carousel_template, use_albums_template): +@click.option("--template", type=click.Choice(['list', 'carousel', 'albums'], case_sensitive=False), help="Widget template to use (list, carousel, or albums)") +def create(widget_name, auth, public, optional_auth, scopes, template): """Create a new widget with tool and component files. Examples: @@ -61,9 +59,9 @@ def create(widget_name, auth, public, optional_auth, scopes, use_list_template, fastapps create mywidget --auth --scopes user,read:data fastapps create mywidget --public fastapps create mywidget --optional-auth --scopes user - fastapps create mywidget --list - fastapps create mywidget --carousel - fastapps create mywidget --albums + fastapps create mywidget --template list + fastapps create mywidget --template carousel + fastapps create mywidget --template albums Authentication options: --auth: Require OAuth authentication @@ -72,9 +70,9 @@ def create(widget_name, auth, public, optional_auth, scopes, use_list_template, --scopes: OAuth scopes to require Template options: - --list: Use the list widget template (includes Tailwind CSS styling) - --carousel: Use the carousel widget template (includes Embla Carousel) - --albums: Use the albums widget template (includes fullscreen photo viewer) + --template list: Use the list widget template (includes Tailwind CSS styling) + --template carousel: Use the carousel widget template (includes Embla Carousel) + --template albums: Use the albums widget template (includes fullscreen photo viewer) """ # Parse scopes scope_list = scopes.split(",") if scopes else None @@ -96,23 +94,6 @@ def create(widget_name, auth, public, optional_auth, scopes, use_list_template, elif optional_auth: auth_type = "optional" - # Validate template options - template_count = sum([use_list_template, use_carousel_template, use_albums_template]) - if template_count > 1: - console.print( - "[red]Error: Only one template option allowed (--list, --carousel, or --albums)[/red]" - ) - return - - # Determine template - template = None - if use_list_template: - template = "list" - elif use_carousel_template: - template = "carousel" - elif use_albums_template: - template = "albums" - create_widget(widget_name, auth_type=auth_type, scopes=scope_list, template=template) diff --git a/fastapps/core/server.py b/fastapps/core/server.py index 085dc70..e4f74c3 100644 --- a/fastapps/core/server.py +++ b/fastapps/core/server.py @@ -73,6 +73,9 @@ def __init__( self.widgets_by_uri = {w.template_uri: w for w in widgets} self.client_locale: Optional[str] = None + # Auto-configure widget CSP based on PUBLIC_URL environment variable + self._configure_widget_csp(widgets) + # Store server auth configuration for per-widget inheritance self.server_requires_auth = bool(auth_issuer_url and auth_resource_server_url) self.server_auth_scopes = auth_required_scopes or [] @@ -122,6 +125,29 @@ def __init__( self._register_handlers() + def _configure_widget_csp(self, widgets: List[BaseWidget]): + """Auto-configure widget CSP based on PUBLIC_URL environment variable.""" + import os + + public_url = os.environ.get("PUBLIC_URL", "").strip() + + if not public_url: + return + + # Configure CSP for all widgets that don't have custom CSP + for widget in widgets: + # Check if CSP is not configured (None or empty resource_domains) + needs_csp = ( + widget.widget_csp is None or + not widget.widget_csp.get("resource_domains") + ) + + if needs_csp: + widget.widget_csp = { + "resource_domains": [public_url], + "connect_domains": [] + } + def _register_handlers(self): """Register all MCP handlers for widget support.""" server = self.mcp._mcp_server @@ -185,17 +211,19 @@ async def list_tools_handler() -> List[types.Tool]: @server.list_resources() async def list_resources_handler() -> List[types.Resource]: - return [ - types.Resource( + resources = [] + for w in self.widgets_by_id.values(): + meta = w.get_resource_meta() + resource = types.Resource( name=w.title, title=w.title, uri=w.template_uri, description=f"{w.title} widget markup", mimeType="text/html+skybridge", - _meta=w.get_resource_meta(), + _meta=meta, ) - for w in self.widgets_by_id.values() - ] + resources.append(resource) + return resources @server.list_resource_templates() async def list_resource_templates_handler() -> List[types.ResourceTemplate]: @@ -356,7 +384,7 @@ async def call_tool_handler(req: types.CallToolRequest) -> types.ServerResult: server.request_handlers[types.CallToolRequest] = call_tool_handler def get_app(self): - """Get FastAPI app with CORS enabled.""" + """Get FastAPI app with CORS enabled and /assets proxy.""" app = self.mcp.http_app() try: @@ -372,4 +400,62 @@ def get_app(self): except Exception: pass + # Add /assets proxy route to forward requests to local asset server + # This eliminates CORS/PNA/mixed content issues by serving assets from the same origin + try: + import httpx + from starlette.responses import Response + from starlette.routing import Route + + async def proxy_assets(request): + """Proxy asset requests to local asset server (port 4444).""" + # Extract path from request + path = request.path_params.get('path', '') + upstream_url = f"http://127.0.0.1:4444/{path}" + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + # Forward the request to the asset server + upstream_response = await client.get(upstream_url) + + # Filter headers to only include content-related ones + allowed_headers = { + "content-type", "cache-control", "etag", + "last-modified", "content-length" + } + response_headers = { + k: v for k, v in upstream_response.headers.items() + if k.lower() in allowed_headers + } + + # Ensure content-type is set + if "content-type" not in response_headers: + response_headers["content-type"] = "application/octet-stream" + + # Add CORS headers for cross-origin access + response_headers["access-control-allow-origin"] = "*" + response_headers["access-control-allow-methods"] = "GET, OPTIONS" + response_headers["access-control-allow-headers"] = "*" + + return Response( + content=upstream_response.content, + status_code=upstream_response.status_code, + headers=response_headers + ) + except httpx.RequestError: + return Response( + content=b"Asset server unavailable", + status_code=502, + headers={"content-type": "text/plain"} + ) + + # Add route to Starlette app + app.routes.append( + Route("/assets/{path:path}", proxy_assets, methods=["GET"]) + ) + except Exception as e: + # Log error but don't crash + print(f"Warning: Could not register /assets proxy route: {e}") + pass + return app diff --git a/fastapps/core/widget.py b/fastapps/core/widget.py index a011a8c..d7b79f5 100644 --- a/fastapps/core/widget.py +++ b/fastapps/core/widget.py @@ -123,7 +123,6 @@ class BaseWidget(ABC): widget_csp: Optional[Dict[str, List[str]]] = None widget_prefers_border: bool = False widget_domain: Optional[str] = None - read_only: bool = True # Localization support supported_locales: Optional[List[str]] = ( @@ -202,10 +201,7 @@ def get_tool_meta(self) -> Dict[str, Any]: "openai/toolInvocation/invoking": self.invoking, "openai/toolInvocation/invoked": self.invoked, "openai/widgetAccessible": self.widget_accessible, - "openai/resultCanProduceWidget": True, - "annotations": { - "readOnlyHint": self.read_only, - }, + "openai/resultCanProduceWidget": True } # Add security schemes if defined (per MCP spec)