Skip to main content

Platforms

Trellis applications can run on multiple platforms using the same component code. Each platform provides a transport layer that connects the Python backend to a frontend renderer, adapting to platform-specific capabilities and constraints.

Table of Contents

  1. Platform Architecture
  2. Message Protocol
  3. Key Classes
  4. Server Platform
  5. Desktop Platform
  6. Browser Platform
  7. Platform Selection

Platform Architecture

All platforms share the same fundamental architecture: Python manages application state and produces a UI tree, which is serialized and sent to a React renderer. User interactions flow back as messages that trigger callbacks, modify state, and produce updated UI.

┌─────────────────────────────────────────────────────────────────────┐
│ Trellis Application │
│ ┌────────────────────────┐ ┌────────────────────────────┐ │
│ │ Python Backend │ │ Frontend Renderer │ │
│ │ │ │ │ │
│ │ ┌──────────────────┐ │ │ ┌──────────────────────┐ │ │
│ │ │ Components + │ │ │ │ React + Widgets │ │ │
│ │ │ State │ │ │ │ │ │ │
│ │ └────────┬─────────┘ │ │ └──────────┬───────────┘ │ │
│ │ │ │ │ │ │ │
│ │ ┌────────▼─────────┐ │ │ ┌──────────▼───────────┐ │ │
│ │ │ RenderTree + │ │ Message │ │ TreeRenderer + │ │ │
│ │ │ MessageHandler │◄─┼─Protocol┼─►│ TrellisClient │ │ │
│ │ └──────────────────┘ │ │ └──────────────────────┘ │ │
│ └────────────────────────┘ └────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘

What varies between platforms:

AspectServerDesktopBrowser
TransportWebSocketPyTauri IPC ChannelpostMessage (Worker)
Python locationRemote serverLocal processBrowser (Web Worker)
Frontend locationBrowserSystem webviewSame browser
Multiple clientsYes (multi-session)No (single window)No (single app)
Event loopasyncio on main threadasyncio in background threadasyncio in Pyodide

What stays the same:

  • Component model and rendering
  • State management and reactivity
  • Message protocol and serialization
  • React-based frontend rendering

Message Protocol

All platforms use the same message types, serialized with msgpack:

MessageDirectionPurpose
HelloMessageClient → ServerInitialize session with client ID
HelloResponseMessageServer → ClientReturn session ID and server version
RenderMessageServer → ClientSerialized component tree
EventMessageClient → ServerUser interaction (callback ID + args)
ErrorMessageServer → ClientRender or callback error details

Message Flow

┌──────────┐                              ┌──────────┐
│ Client │ │ Server │
└────┬─────┘ └────┬─────┘
│ │
│ HelloMessage { client_id } │
│────────────────────────────────────────►│
│ │
│ HelloResponseMessage { session_id } │
│◄────────────────────────────────────────│
│ │
│ RenderMessage { tree } │
│◄────────────────────────────────────────│
│ │
│ EventMessage { callback_id, args } │
│────────────────────────────────────────►│
│ │
│ RenderMessage { tree } │
│◄────────────────────────────────────────│
│ │

The initial handshake establishes a session, then the server sends the initial render. Subsequent interactions follow the event → re-render cycle.


Key Classes

Platform (base class)

class Platform(ABC):
@property
@abstractmethod
def name(self) -> str: ...

@abstractmethod
async def run(self, root_component, **kwargs) -> None: ...

Each platform implements run() to start the application with platform-specific setup.

MessageHandler

The MessageHandler base class provides the core render/event/re-render loop:

class MessageHandler:
tree: RenderTree
session_id: str | None

def __init__(self, root_component): ...

# Transport methods - subclasses implement these
async def send_message(self, msg: Message) -> None: ...
async def receive_message(self) -> Message: ...

# Core protocol
async def handle_hello(self) -> str: ...
def initial_render(self) -> Message: ...
async def handle_message(self, msg: Message) -> Message | None: ...

# Main loop
async def run(self) -> None:
await self.handle_hello()
await self.send_message(self.initial_render())
while True:
msg = await self.receive_message()
response = await self.handle_message(msg)
if response:
await self.send_message(response)

Platform-specific handlers extend this, implementing send_message() and receive_message() for their transport.

Client-Side Classes

On the frontend, each platform has a client class that:

  • Manages the transport connection
  • Serializes/deserializes messages
  • Provides send() and receives messages via callbacks

The TreeRenderer component consumes messages and renders the UI tree using React.


Server Platform

The server platform runs Python on a remote server, with the UI rendered in a web browser connected via WebSocket.

Architecture

┌─────────────────────────────────────────────────────────────────────┐
│ Server Platform │
│ ┌────────────────────────┐ ┌────────────────────────────┐ │
│ │ Python (Server) │ │ Browser │ │
│ │ │ │ │ │
│ │ FastAPI + Uvicorn │ │ React + TrellisClient │ │
│ │ WebSocketHandler │◄───────►│ WebSocket connection │ │
│ │ │ WS │ │ │
│ └────────────────────────┘ └────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘

Components

ServerPlatform (platforms/server/platform.py)

  • Creates FastAPI application with routes
  • Configures uvicorn server
  • Serves static bundle at /static/bundle.js
  • Returns HTML page at /

WebSocketMessageHandler (platforms/server/handler.py)

  • Extends MessageHandler with WebSocket transport
  • send_message(): sends msgpack bytes via WebSocket
  • receive_message(): receives msgpack bytes via WebSocket
  • Uses the standard run() loop

TrellisClient (platforms/server/client/)

  • TypeScript WebSocket client
  • Connects to /ws endpoint
  • Sends/receives msgpack-encoded messages

Event Loop

The server platform uses asyncio on the main thread:

async def run(self, root_component, **kwargs):
# FastAPI app with WebSocket endpoint
app = create_app(root_component)

# Uvicorn runs the asyncio event loop
server = uvicorn.Server(config)
await server.serve()

Each WebSocket connection gets its own WebSocketMessageHandler instance running handler.run() as a coroutine.

Multi-Session Support

The server platform supports multiple concurrent clients:

  • Each WebSocket connection creates a new handler
  • Each handler has its own RenderTree and session
  • State is isolated per session by default
  • Shared state requires explicit coordination

Usage

trellis_config.py:

from trellis.app.config import Config

config = Config(name="My App", module="app")

app.py:

from trellis import App, component

@component
def MyApp() -> None:
...

app = App(MyApp)

Run with: trellis run


Desktop Platform

The desktop platform runs Python locally with UI rendered in a system webview, using PyTauri for the native shell.

Architecture

┌─────────────────────────────────────────────────────────────────────┐
│ Desktop Platform │
│ ┌────────────────────────┐ ┌────────────────────────────┐ │
│ │ Python (Local) │ │ System WebView │ │
│ │ │ │ │ │
│ │ PyTauri Commands │ │ React + DesktopClient │ │
│ │ PyTauriMessageHandler │◄───────►│ Channel-based IPC │ │
│ │ │ IPC │ │ │
│ └────────────────────────┘ └────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘

Components

DesktopPlatform (platforms/desktop/platform.py)

  • Creates PyTauri application
  • Registers IPC commands
  • Manages platform state (root component, handler)
  • Bridges async handlers to PyTauri's event loop

PyTauriMessageHandler (platforms/desktop/handler.py)

  • Extends MessageHandler with queue-based transport
  • send_message(): sends via PyTauri channel
  • receive_message(): pulls from async queue
  • Uses the standard run() loop

DesktopClient (platforms/desktop/client/)

  • TypeScript PyTauri client
  • Uses @tauri-apps/api for IPC
  • Calls trellis_connect to establish channel
  • Calls trellis_send to send messages
  • Receives messages via channel callback

Event Loop

PyTauri controls the main thread with its own event loop for window management. Trellis uses a blocking portal to run asyncio in a background thread:

async def run(self, root_component, **kwargs):
self._root_component = root_component
commands = self._create_commands()

# PyTauri runs on main thread, asyncio runs in background thread
with start_blocking_portal("asyncio") as portal:
app = builder_factory().build(
context=context_factory(config_dir),
invoke_handler=commands.generate_handler(portal),
)
app.run_return() # Blocks until window closes

The portal bridges PyTauri commands (called from main thread) to async handlers (running in background thread).

IPC Commands

The desktop platform registers three PyTauri commands:

CommandPurpose
trellis_connectEstablish IPC channel, create handler, spawn run() task
trellis_sendEnqueue message data for handler to process
trellis_logForward JavaScript console output to Python stdout

Message Queue Pattern

Unlike WebSocket's bidirectional stream, PyTauri IPC uses separate channels for each direction:

  • Client → Server: via trellis_send command → async queue → receive_message()
  • Server → Client: via channel.send() from send_message()

This allows the standard MessageHandler.run() loop to work:

class PyTauriMessageHandler(MessageHandler):
_queue: asyncio.Queue[bytes]

async def receive_message(self) -> Message:
data = await self._queue.get() # Waits for trellis_send
return self._decoder.decode(data)

def enqueue(self, data: bytes) -> None:
self._queue.put_nowait(data) # Called by trellis_send command

Dependencies

Desktop requires additional packages (automatically included):

  • pytauri - Python/Tauri bridge
  • pytauri-wheel - Pre-built Tauri runtime
  • @tauri-apps/api (JS) - Tauri JavaScript API
  • tauri-plugin-pytauri-api (JS) - PyTauri JavaScript bindings

Usage

trellis_config.py:

from trellis.app.config import Config

config = Config(name="My App", module="app")

app.py:

from trellis import App, component

@component
def MyApp() -> None:
...

app = App(MyApp)

Run with: trellis run --desktop


Browser Platform

The browser platform runs Python directly in the browser using Pyodide (Python compiled to WebAssembly). No server-side Python is required—the entire application runs client-side.

Architecture

┌─────────────────────────────────────────────────────────────────────┐
│ Browser Platform │
│ ┌────────────────────────┐ ┌────────────────────────────┐ │
│ │ Web Worker │ │ Main Thread │ │
│ │ │ │ │ │
│ │ Pyodide Runtime │ │ React + BrowserClient │ │
│ │ BrowserMessageHandler │◄───────►│ TrellisApp + TreeRenderer │ │
│ │ │ postMsg │ │ │
│ └────────────────────────┘ └────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘

Components

BrowserPlatform (platforms/browser/platform.py)

  • Runs inside Pyodide in the browser
  • Connects to JavaScript via a registered bridge module
  • bundle(): No-op (bundling done before loading)
  • run(): Sets up bridge, runs message loop

BrowserMessageHandler (platforms/browser/handler.py)

  • Extends MessageHandler with queue-based transport
  • send_message(): calls bridge callback → postMessage to main thread
  • receive_message(): awaits on async queue populated by JavaScript
  • enqueue_message(): called by JS bridge to queue incoming messages

BrowserServePlatform (platforms/browser/serve_platform.py)

  • CLI mode for development: trellis run --browser
  • Packages source code and serves via HTTP
  • Generates HTML with embedded source config

TrellisApp (platforms/browser/client/src/TrellisApp.tsx)

  • React component orchestrating the browser platform
  • Creates and manages PyodideWorker
  • Wires up bidirectional messaging
  • Renders loading states, errors, and the UI tree

PyodideWorker (platforms/browser/client/src/PyodideWorker.ts)

  • Manages the Web Worker lifecycle
  • Worker code is built separately and loaded via blob URL
  • Handles init/run/message protocol with worker

pyodide.worker.ts (platforms/browser/client/src/pyodide.worker.ts)

  • Runs in isolated Web Worker context
  • Loads Pyodide runtime from CDN
  • Installs packages (micropip, msgspec, rich, trellis)
  • Registers trellis_browser_bridge JS module for Python
  • Executes Python source code

Worker Isolation Model

The browser platform uses Web Worker isolation:

  1. Clean Restarts: Worker termination kills all Python state instantly
  2. Non-blocking UI: Pyodide runs off the main thread
  3. Message Queue Safety: Async queue prevents race conditions
  4. Sandboxing: Python can't access DOM directly (must use bridge)

The worker acts as a process boundary—terminating it is equivalent to killing a subprocess.

Source Loading Modes

The browser platform supports three ways to load Python code:

Code Mode — Raw Python string (simplest, used by docs demos):

source = { type: "code", code: "print('hello')" }

Module Mode — Package files (used by --browser CLI):

source = {
type: "module",
files: {
"myapp/__init__.py": "...",
"myapp/app.py": "...",
},
moduleName: "myapp.app",
}

Files are written to Pyodide's virtual filesystem, then executed via runpy.run_module().

Wheel Mode — Install from URL (production deployment):

source = {
type: "wheel",
wheelUrl: "https://example.com/app-0.1.0-py3-none-any.whl",
}

Message Flow

The browser platform uses a bridge pattern for communication:

TrellisApp creates PyodideWorker
↓ (postMessage: "init")
Worker loads Pyodide, installs packages
Worker registers trellis_browser_bridge module
↓ (postMessage: "ready")
TrellisApp calls worker.run(source)
↓ (postMessage: "run")
Worker executes Python source
Python imports bridge, creates handler
Python calls bridge.set_handler(handler)
Python calls handler.run() (waits for messages)

TrellisApp calls client.sendHello()
↓ (postMessage: "message" with HELLO)
Worker calls handler.enqueue_message(HELLO)
Python receives HELLO, sends HELLO_RESPONSE
↓ (bridge.send_message → postMessage)
TrellisApp receives HELLO_RESPONSE
TrellisApp renders <TreeRenderer />

Event Loop

Python's asyncio runs inside Pyodide's event loop, which integrates with the browser's event loop. The message handler's run() loop works the same as other platforms:

async def run(self):
await self.handle_hello()
await self.send_message(self.initial_render())
while True:
msg = await self.receive_message() # Awaits on queue
response = await self.handle_message(msg)
if response:
await self.send_message(response)

Bridge Module

JavaScript registers a module that Python imports:

# Python side (in BrowserPlatform.run)
import trellis_browser_bridge as bridge
bridge.set_handler(handler_proxy) # Register Python handler
bridge.send_message(msg) # Send to JS
// JavaScript side (in worker)
const workerBridge = {
set_handler(handler) {
pythonHandler = handler;
// Flush any queued messages
},
send_message(msg) {
self.postMessage({ type: "message", payload: msg });
},
};
pyodide.registerJsModule("trellis_browser_bridge", workerBridge);

Usage

Development (CLI serve mode):

python myapp.py --browser
# Serves at http://localhost:PORT with auto-selected port

Documentation/Playground: The docs use TrellisApp directly to run interactive examples.

Production: Build and deploy the HTML with embedded wheel URL.


Platform Selection

Automatic Detection

By default, Trellis auto-detects the platform:

  1. If running in Pyodide → Browser platform
  2. Otherwise → Server platform

Explicit Selection

# Via constructor
app = Trellis(top=MyApp, platform="desktop")

# Via CLI
python myapp.py --platform=desktop
python myapp.py --desktop # Shortcut
python myapp.py --browser # Serve for browser via Pyodide

Platform-Specific Arguments

Some arguments only apply to specific platforms:

ArgumentPlatformPurpose
hostServerBind address
portServerBind port
static_dirServerCustom static files
window_titleDesktopWindow title
window_widthDesktopInitial width
window_heightDesktopInitial height

Using an argument with the wrong platform raises PlatformArgumentError.


Implementation Files

FilePurpose
core/platform.pyPlatform base class, PlatformType enum
core/message_handler.pyMessageHandler base class
core/messages.pyMessage types (HelloMessage, etc.)
core/trellis.pyTrellis class, platform selection
platforms/server/platform.pyServerPlatform
platforms/server/handler.pyWebSocketMessageHandler
platforms/server/routes.pyFastAPI routes
platforms/server/client/TypeScript WebSocket client
platforms/desktop/platform.pyDesktopPlatform
platforms/desktop/handler.pyPyTauriMessageHandler
platforms/desktop/client/TypeScript PyTauri client
platforms/browser/platform.pyBrowserPlatform (runs in Pyodide)
platforms/browser/handler.pyBrowserMessageHandler (queue-based)
platforms/browser/serve_platform.pyBrowserServePlatform (CLI serve mode)
platforms/browser/client/src/TrellisApp.tsxReact component for browser apps
platforms/browser/client/src/PyodideWorker.tsWorker lifecycle manager
platforms/browser/client/src/pyodide.worker.tsWorker runtime (Pyodide init, bridge)
platforms/browser/client/src/BrowserClient.tsMessage client for browser
platforms/common/client/Shared TypeScript (types, TreeRenderer, widgets)