diff --git a/examples/embed_demo.html b/examples/embed_demo.html new file mode 100644 index 000000000..23192e719 --- /dev/null +++ b/examples/embed_demo.html @@ -0,0 +1,122 @@ + + + + + + Preswald Component Embedding Demo + + + +

Preswald Component Embedding Demo

+ +

This page demonstrates how to embed Preswald components in external web pages.

+ +
+

Embedding a Specific Component

+

Below is an embedded card component with ID embeddable_card:

+ +
+ + +
+ +

HTML code:

+
<iframe 
+    src="http://localhost:8000/embed?component_id=embeddable_card" 
+    width="100%" 
+    height="200" 
+    frameborder="0">
+</iframe>
+
+ +
+

Embedding a Plot Component

+

Below is an embedded plot component with ID sample_plot:

+ +
+ + +
+ +

HTML code:

+
<iframe 
+    src="http://localhost:8000/embed?component_id=sample_plot" 
+    width="100%" 
+    height="350" 
+    frameborder="0">
+</iframe>
+
+ +
+

Embedding the Entire App

+

You can also embed the entire application:

+ +
+ + +
+ +

HTML code:

+
<iframe 
+    src="http://localhost:8000/embed" 
+    width="100%" 
+    height="500" 
+    frameborder="0">
+</iframe>
+
+ +

Note: Make sure the Preswald server is running with the embed_example.py script before viewing this page.

+ + \ No newline at end of file diff --git a/examples/embed_example.py b/examples/embed_example.py new file mode 100644 index 000000000..d98651227 --- /dev/null +++ b/examples/embed_example.py @@ -0,0 +1,37 @@ +""" +Example script demonstrating component embedding +""" +import preswald as pw + +# Create some components with unique IDs +pw.header("Embed Example", level=1, id="main_header") +pw.text("This is a sample application with multiple components.", id="intro_text") + +# Create a component specifically for embedding +with pw.card(id="embeddable_card", title="Embeddable Component"): + pw.text("This component can be embedded on its own.", id="card_text") + pw.button("Click Me", id="embed_button") + +# Create another component +pw.plotly({ + "data": [{"y": [1, 2, 3, 4], "type": "scatter"}], + "layout": {"title": "Sample Plot"} +}, id="sample_plot") + +# Add instructions for embedding +pw.markdown(""" +## How to Embed + +To embed the card component, use the following HTML code: + +```html + +``` + +This will only show the card component, not the rest of the application. +""", id="embed_instructions") \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fae9a0950..5495de557 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,6 +5,7 @@ import { BrowserRouter as Router } from 'react-router-dom'; import Layout from './components/Layout'; import LoadingState from './components/LoadingState'; import Dashboard from './components/pages/Dashboard'; +import EmbedView from './components/pages/EmbedView'; import { comm } from './utils/websocket'; const App = () => { @@ -13,8 +14,14 @@ const App = () => { const [config, setConfig] = useState(null); const [isConnected, setIsConnected] = useState(false); const [areComponentsLoading, setAreComponentsLoading] = useState(true); + const [isEmbedMode, setIsEmbedMode] = useState(false); useEffect(() => { + // Check if in embed mode (either from URL or from window.EMBED_CONFIG) + const isEmbed = window.location.pathname.startsWith('/embed') || + (window.EMBED_CONFIG && window.EMBED_CONFIG.embed_mode); + setIsEmbedMode(isEmbed); + comm.connect(); const unsubscribe = comm.subscribe(handleMessage); @@ -147,8 +154,12 @@ const App = () => { setError(message.connected ? null : 'Lost connection. Attempting to reconnect...'); }; - console.log('[App] Rendering with:', { components, isConnected, error }); - console.log(window.location.pathname); + console.log('[App] Rendering with:', { components, isConnected, error, isEmbedMode }); + + // If in embed mode, render the EmbedView directly without Layout + if (isEmbedMode) { + return ; + } return ( diff --git a/frontend/src/components.css b/frontend/src/components.css index f9ef52706..136483708 100644 --- a/frontend/src/components.css +++ b/frontend/src/components.css @@ -848,3 +848,57 @@ background-color: #4b5563; } } + +/* Embeddable Components Styles */ +.embed-container { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + width: 100%; + height: 100%; + min-height: 50px; + margin: 0; + padding: 0; + overflow: auto; + background-color: transparent; +} + +.embed-loading { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100px; + color: #333; + font-size: 1rem; +} + +.embed-error { + background-color: #fff0f0; + border: 1px solid #ffcccc; + border-radius: 6px; + padding: 12px; + margin: 12px 0; + color: #cc0000; +} + +.embed-error h3 { + margin-top: 0; + margin-bottom: 8px; + font-size: 1rem; + font-weight: 600; +} + +.embed-error p { + margin: 0; + font-size: 0.9rem; +} + +/* When in embed mode, apply tighter spacing */ +.embed-container .preswald-component { + margin-bottom: 12px; +} + +/* Hide certain UI elements in embed mode */ +.embed-container .preswald-debug-info, +.embed-container .preswald-toolbar { + display: none; +} diff --git a/frontend/src/components/pages/EmbedView.jsx b/frontend/src/components/pages/EmbedView.jsx new file mode 100644 index 000000000..8e5a4219d --- /dev/null +++ b/frontend/src/components/pages/EmbedView.jsx @@ -0,0 +1,169 @@ +import React, { useEffect, useState, useRef } from 'react'; +import DynamicComponents from '../DynamicComponents'; + +/** + * EmbedView component for embedded app or single component view. + * This provides a minimal layout without navigation, toolbars, etc. + */ +const EmbedView = () => { + const [componentData, setComponentData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const containerRef = useRef(null); + + // Auto-resize handler for iframes + useEffect(() => { + if (!containerRef.current) return; + + // Create a ResizeObserver to detect size changes + const resizeObserver = new ResizeObserver(() => { + // Send message to parent window with new height + if (window.parent !== window) { + window.parent.postMessage({ + height: containerRef.current.scrollHeight, + type: 'preswald-resize' + }, '*'); + } + }); + + // Start observing + resizeObserver.observe(containerRef.current); + + // Stop observing on cleanup + return () => { + resizeObserver.disconnect(); + }; + }, [componentData]); + + // Handle initial data load + useEffect(() => { + const handleComponentUpdate = (event) => { + if (event.data && event.data.type === 'components') { + setComponentData(event.data.components); + setLoading(false); + } else if (event.data && event.data.type === 'error') { + setError(event.data.content); + setLoading(false); + } + }; + + // Add message event listener + window.addEventListener('message', handleComponentUpdate); + + // Connect via WebSocket for embedded view + const connectWebSocket = async () => { + try { + const embedConfig = window.EMBED_CONFIG || { embed_mode: true }; + const clientId = `embed-${Date.now()}`; + + // Connect to WebSocket with embed flag + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket(`${protocol}//${window.location.host}/ws/${clientId}`); + + ws.onopen = () => { + // Send init message with embed mode + ws.send(JSON.stringify({ + type: 'init', + embed_mode: true, + component_id: embedConfig.component_id + })); + }; + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data.type === 'components') { + if (embedConfig.component_id) { + // Filter to just the requested component if specified + const filteredComponents = filterComponentById(data.components, embedConfig.component_id); + setComponentData(filteredComponents); + } else { + setComponentData(data.components); + } + setLoading(false); + } else if (data.type === 'error') { + setError(data.content); + setLoading(false); + } + }; + + ws.onerror = (error) => { + console.error('WebSocket error:', error); + setError({ message: 'Failed to connect to the server' }); + setLoading(false); + }; + + ws.onclose = () => { + console.log('WebSocket connection closed'); + }; + + // Store the WebSocket connection for cleanup + return ws; + } catch (error) { + console.error('Failed to connect to WebSocket:', error); + setError({ message: 'Failed to connect to the server' }); + setLoading(false); + } + }; + + const wsConnection = connectWebSocket(); + + // Cleanup function + return () => { + window.removeEventListener('message', handleComponentUpdate); + if (wsConnection) { + wsConnection.then(ws => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(); + } + }); + } + }; + }, []); + + // Filter components to return only the specified component and its dependencies + const filterComponentById = (components, componentId) => { + if (!components || !componentId) return components; + + // Create a new components object with the same structure + const filteredComponents = { ...components }; + + // If components use a rows structure, filter them + if (filteredComponents.rows) { + const flattenedComponents = []; + + // Find the specified component in the rows + filteredComponents.rows.forEach(row => { + row.forEach(component => { + if (component.id === componentId) { + flattenedComponents.push(component); + } + }); + }); + + // Create a single row with the filtered component + filteredComponents.rows = flattenedComponents.length > 0 ? [flattenedComponents] : []; + } + + return filteredComponents; + }; + + return ( +
+ {loading &&
Loading...
} + + {error && ( +
+

Error

+

{error.message}

+
+ )} + + {componentData && !loading && !error && ( + + )} +
+ ); +}; + +export default EmbedView; \ No newline at end of file diff --git a/preswald/cli.py b/preswald/cli.py index f0a9b7ab7..bdeb4a924 100644 --- a/preswald/cli.py +++ b/preswald/cli.py @@ -175,7 +175,13 @@ def init(name, template): default=False, help="Disable automatically opening a new browser tab", ) -def run(port, log_level, disable_new_tab): +@click.option( + "--embed", + is_flag=True, + default=False, + help="Run in embed mode (for iframe embedding)", +) +def run(port, log_level, disable_new_tab, embed): """ Run a Preswald app from the current directory. @@ -221,6 +227,7 @@ def run(port, log_level, disable_new_tab): "port": port, "log_level": log_level, "disable_new_tab": disable_new_tab, + "embed": embed, }, ) @@ -233,7 +240,7 @@ def run(port, log_level, disable_new_tab): webbrowser.open(url) - start_server(script=script, port=port) + start_server(script=script, port=port, embed=embed) except Exception as e: click.echo(f"Error: {e}") diff --git a/preswald/engine/base_service.py b/preswald/engine/base_service.py index 77c251ab6..31f65d35d 100644 --- a/preswald/engine/base_service.py +++ b/preswald/engine/base_service.py @@ -41,6 +41,7 @@ def __init__(self): self._script_path: str | None = None self._is_shutting_down: bool = False self._render_buffer = RenderBuffer() + self._embed_mode: bool = False # Flag for embed mode # DAG workflow engine self._workflow = Workflow(service=self) @@ -106,6 +107,17 @@ def script_path(self, path: str): def is_reactivity_enabled(self): return self._reactivity_enabled + @property + def embed_mode(self) -> bool: + """Get the embed mode status.""" + return self._embed_mode + + @embed_mode.setter + def embed_mode(self, value: bool): + """Set embed mode status.""" + self._embed_mode = value + logger.info(f"Embed mode set to: {value}") + def _ensure_dummy_atom(self, atom_name: str): """ Register a placeholder (dummy) atom if one does not already exist. @@ -301,7 +313,26 @@ def get_rendered_components(self): rows = self._layout_manager.get_layout() return {"rows": rows} + def get_component(self, component_id: str) -> dict | None: + """ + Get a specific component by its ID. + + Args: + component_id: The ID of the component to retrieve + + Returns: + The component dictionary or None if not found + """ + components = self._layout_manager.get_layout() + for row in components: + for component in row: + if component.get("id") == component_id: + return component + + return None + def get_workflow(self) -> Workflow: + """Get the workflow instance.""" return self._workflow async def handle_client_message(self, client_id: str, message: Dict[str, Any]): diff --git a/preswald/engine/server_service.py b/preswald/engine/server_service.py index 707260e6e..c7bf2a61b 100644 --- a/preswald/engine/server_service.py +++ b/preswald/engine/server_service.py @@ -26,9 +26,9 @@ class ServerPreswaldService(BasePreswaldService): def __init__(self): super().__init__() - # TODO: deprecated - # Connection management - self._connections: dict[str, Any] = {} + # Initialize connection tracking + self.connections = [] # Track connections for broadcasting + self._connections: dict[str, Any] = {} # TODO: deprecated # Branding management self.branding_manager = None # set during server creation @@ -44,17 +44,78 @@ async def register_client( logger.info(f"[WebSocket] New connection request from client: {client_id}") await websocket.accept() logger.info(f"[WebSocket] Connection accepted for client: {client_id}") - - return await self._register_common_client_setup(client_id, websocket) + + # Store for broadcasting + self.connections.append({"id": client_id, "socket": websocket}) + + # Initialize with common setup + runner = await self._register_common_client_setup(client_id, websocket) + + # Wait for initialization message from client + try: + init_message = await websocket.receive_json() + logger.info(f"Received init message: {init_message}") + + # Check if this is an embed request + if init_message.get("type") == "init" and init_message.get("embed_mode"): + # Set embed mode flag + self.embed_mode = True + + # If component_id is specified, we'll only send that component + component_id = init_message.get("component_id") + if component_id: + logger.info(f"Embed request for component: {component_id}") + + # Get the specific component + component = self.get_component(component_id) + + if component: + # Create a new components structure with just this component + filtered_components = {"rows": [[component]]} + await websocket.send_json({ + "type": "components", + "components": filtered_components + }) + else: + # Component not found + logger.warning(f"Component not found for embed: {component_id}") + await websocket.send_json({ + "type": "error", + "content": {"message": f"Component '{component_id}' not found"} + }) + else: + # No specific component requested, send all components + await self._broadcast_components(client_id=client_id) + except Exception as e: + logger.error(f"Error processing init message: {e}") + + # Broadcast new connection to all clients + await self._broadcast_connections() + + return runner except WebSocketDisconnect: logger.error(f"[WebSocket] Client disconnected: {client_id}") + # Clean up if registration fails + if client_id in self.websocket_connections: + self.websocket_connections.pop(client_id) + if client_id in self.script_runners: + runner = self.script_runners.pop(client_id) + await runner.stop() + # Remove from connections list + self.connections = [c for c in self.connections if c.get("id") != client_id] except Exception as e: logger.error(f"Error registering client {client_id}: {e}") raise async def unregister_client(self, client_id: str): + """Clean up resources for a disconnected client""" await super().unregister_client(client_id) + + # Remove from connections list + self.connections = [conn for conn in self.connections if conn.get("id") != client_id] + + # Broadcast updated connections to all clients asyncio.create_task(self._broadcast_connections()) # noqa: RUF006 async def _broadcast_connections(self): @@ -82,3 +143,35 @@ async def _broadcast_connections(self): except Exception as e: logger.error(f"Error broadcasting connections: {e}") # Don't raise the exception to prevent disrupting the main flow + + async def _broadcast_components(self, client_id=None): + """ + Broadcast component data to one or all clients + + Args: + client_id: Optional client ID to send to. If None, sends to all clients. + """ + try: + # Get all components + components = self.get_rendered_components() + + if client_id: + # Send to specific client + websocket = self.websocket_connections.get(client_id) + if websocket: + await websocket.send_json({ + "type": "components", + "components": components + }) + else: + # Send to all clients + for conn in self.connections: + try: + await conn["socket"].send_json({ + "type": "components", + "components": components + }) + except Exception as e: + logger.error(f"Error broadcasting components to {conn['id']}: {e}") + except Exception as e: + logger.error(f"Error broadcasting components: {e}") diff --git a/preswald/main.py b/preswald/main.py index 3606e9c2f..d43a0da81 100644 --- a/preswald/main.py +++ b/preswald/main.py @@ -19,10 +19,13 @@ logger = logging.getLogger(__name__) -def create_app(script_path: str | None = None) -> FastAPI: +def create_app(script_path: str | None = None, embed: bool = False) -> FastAPI: """Create and configure the FastAPI application""" app = FastAPI() service = PreswaldService.initialize(script_path) + + # Store embed mode in the service + service.embed_mode = embed if reactivity_explicitly_disabled(): service.disable_reactivity() @@ -63,6 +66,24 @@ async def serve_index(): except Exception as e: logger.error(f"Error serving index: {e}") raise HTTPException(status_code=500, detail="Internal server error") from e + + @app.get("/embed") + async def serve_embed_app(): + """Serve the full app in embed mode""" + try: + return _handle_embed_request(app.state.service) + except Exception as e: + logger.error(f"Error serving embed view: {e}") + raise HTTPException(status_code=500, detail="Internal server error") from e + + @app.get("/embed/{component_id}") + async def serve_embed_component(component_id: str): + """Serve a specific component in embed mode""" + try: + return _handle_embed_request(app.state.service, component_id) + except Exception as e: + logger.error(f"Error serving component embed view: {e}") + raise HTTPException(status_code=500, detail="Internal server error") from e @app.get("/favicon.ico") async def serve_favicon(): @@ -142,10 +163,10 @@ async def noop_message_handler(msg): return service.get_rendered_components() -def start_server(script: str | None = None, port: int = 8501): +def start_server(script: str | None = None, port: int = 8501, embed: bool = False): """Start the FastAPI server""" - app = create_app(script) - + app = create_app(script, embed=embed) + config = uvicorn.Config(app, host="0.0.0.0", port=port, loop="asyncio") server = uvicorn.Server(config) @@ -164,6 +185,13 @@ def sync_handle_shutdown(signum, frame): signal.signal(signal.SIGINT, sync_handle_shutdown) signal.signal(signal.SIGTERM, sync_handle_shutdown) + # Log embedding info if in embed mode + if embed: + embed_url = f"http://localhost:{port}/embed" + logger.info(f"🖼️ Running in embed mode - use this URL for embedding: {embed_url}") + logger.info("📋 Embed code example:") + logger.info(f"") + try: import asyncio @@ -261,3 +289,60 @@ def _handle_favicon_request(service: PreswaldService) -> HTMLResponse: except Exception as e: logger.error(f"Error serving index: {e}") raise HTTPException(status_code=500, detail="Internal server error") from e + + +def _handle_embed_request(service: PreswaldService, component_id: str | None = None) -> HTMLResponse: + """ + Handle embed requests for full app or specific components. + + Args: + service: The PreswaldService instance + component_id: Optional component ID to embed just one component + + Returns: + HTMLResponse with the embedded content + """ + try: + # Get the index.html content as a base + with open(os.path.join(service.branding_manager.static_dir, "index.html"), "r") as f: + html_content = f.read() + + # Extract branding information + branding = {} + if hasattr(service, "branding_manager") and service.branding_manager: + branding = service.branding_manager.get_branding_config_with_data_urls( + service.script_path or "" + ) + + # Inject component_id if specified + embed_config = { + "embed_mode": True, + "component_id": component_id + } + + # Inject the embed configuration into the HTML + html_content = html_content.replace( + "", + f"" + ) + + # Inject branding if available + if branding: + html_content = html_content.replace( + "", + f"" + ) + + # Add special headers for embedding + response = HTMLResponse(content=html_content) + + # Set headers to allow embedding in iframes + response.headers["X-Frame-Options"] = "ALLOWALL" + response.headers["Content-Security-Policy"] = "frame-ancestors *" + response.headers["Access-Control-Allow-Origin"] = "*" + + return response + + except Exception as e: + logger.error(f"Error handling embed request: {e}") + raise HTTPException(status_code=500, detail="Error serving embedded content") from e diff --git a/tests/embed_test.py b/tests/embed_test.py new file mode 100644 index 000000000..28796a914 --- /dev/null +++ b/tests/embed_test.py @@ -0,0 +1,159 @@ +import pytest +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from preswald.engine.server_service import ServerPreswaldService +from preswald.engine.base_service import BasePreswaldService + + +@pytest.fixture +def service(): + # Create a service instance for testing + service = ServerPreswaldService() + + # Mock the _register_common_client_setup method to avoid actual script execution + service._register_common_client_setup = AsyncMock(return_value=MagicMock()) + + # Mock get_component method + mock_component = {"id": "test_component", "type": "text", "value": "Test Content"} + service.get_component = MagicMock(return_value=mock_component) + + return service + + +@pytest.mark.asyncio +async def test_register_client_with_embed_mode(service): + """Test client registration with embed mode""" + + # Create a mock WebSocket + mock_websocket = AsyncMock() + mock_websocket.receive_json = AsyncMock(return_value={ + "type": "init", + "embed_mode": True, + "component_id": "test_component" + }) + + # Register the client + await service.register_client("test_client", mock_websocket) + + # Verify that embed_mode was set + assert service.embed_mode is True + + # Verify that the component was sent to the client + mock_websocket.send_json.assert_any_call({ + "type": "components", + "components": {"rows": [[{"id": "test_component", "type": "text", "value": "Test Content"}]]} + }) + + +@pytest.mark.asyncio +async def test_register_client_with_nonexistent_component(service): + """Test client registration with a component ID that doesn't exist""" + + # Create a mock WebSocket + mock_websocket = AsyncMock() + mock_websocket.receive_json = AsyncMock(return_value={ + "type": "init", + "embed_mode": True, + "component_id": "nonexistent_component" + }) + + # Mock get_component to return None for nonexistent component + service.get_component = MagicMock(return_value=None) + + # Register the client + await service.register_client("test_client", mock_websocket) + + # Verify that embed_mode was set + assert service.embed_mode is True + + # Verify that an error message was sent to the client + mock_websocket.send_json.assert_any_call({ + "type": "error", + "content": {"message": "Component 'nonexistent_component' not found"} + }) + + +@pytest.mark.asyncio +async def test_broadcast_components(service): + """Test broadcasting components to clients""" + + # Create mock WebSockets + mock_websocket1 = AsyncMock() + mock_websocket2 = AsyncMock() + + # Add mock connections + service.connections = [ + {"id": "client1", "socket": mock_websocket1}, + {"id": "client2", "socket": mock_websocket2} + ] + + # Mock get_rendered_components + mock_components = {"rows": [[{"id": "test_component", "type": "text", "value": "Test Content"}]]} + service.get_rendered_components = MagicMock(return_value=mock_components) + + # Broadcast to all clients + await service._broadcast_components() + + # Verify that both clients received the components + mock_websocket1.send_json.assert_called_with({ + "type": "components", + "components": mock_components + }) + + mock_websocket2.send_json.assert_called_with({ + "type": "components", + "components": mock_components + }) + + +@pytest.mark.asyncio +async def test_broadcast_components_to_specific_client(service): + """Test broadcasting components to a specific client""" + + # Create mock WebSocket + mock_websocket = AsyncMock() + + # Add the client ID to websocket_connections + service.websocket_connections = {"client1": mock_websocket} + + # Mock get_rendered_components + mock_components = {"rows": [[{"id": "test_component", "type": "text", "value": "Test Content"}]]} + service.get_rendered_components = MagicMock(return_value=mock_components) + + # Broadcast to specific client + await service._broadcast_components(client_id="client1") + + # Verify that the client received the components + mock_websocket.send_json.assert_called_with({ + "type": "components", + "components": mock_components + }) + + +@pytest.mark.asyncio +async def test_unregister_client(service): + """Test unregistering a client""" + + # Create mock websocket connections and connections list + mock_websocket = AsyncMock() + service.websocket_connections = {"client1": mock_websocket} + service.connections = [{"id": "client1", "socket": mock_websocket}] + + # Mock the super().unregister_client method + with patch.object(BasePreswaldService, 'unregister_client', AsyncMock()) as mock_super: + # Mock the _broadcast_connections method + service._broadcast_connections = AsyncMock() + + # Unregister the client + await service.unregister_client("client1") + + # Verify that the super method was called + mock_super.assert_called_once_with("client1") + + # Verify that the client was removed from the connections list + assert len(service.connections) == 0 + + # Verify that _broadcast_connections was called + assert service._broadcast_connections.called \ No newline at end of file