Serve and embed a spaday app

This guide shows you how to deliver a spaday UI to a browser — from “spaday runs the whole app” down to “spaday is one node on a page you already own.” Each rung hands more control to the host, and every rung takes the same generation options. (For keeping the UI in sync with a server-side model, see Sync over transports; for a notebook, see Use in a notebook.)

There is no hand-written HTML: spaday generates the bootstrap page from your Python description.

Rung

spaday owns

Seam

No HTML

the whole app

serve(page, …)

Several pages

several page routes

mount_site(app, pages, …) / build_site(pages, …)

Some HTML

a sub-path of your app

mount(app, page, prefix=…)

Full custom HTML

one node in your page

bootstrap(fragment=True, target=…) + tree_json(page)

Notebook

a cell’s widget

Widget(component)

page is a built component or a zero-arg callable returning one — a callable is re-rendered per request, so the tree can reflect current state.

Serve a whole app

serve creates a Starlette app and mounts your page on it: it generates the bootstrap HTML, hosts the tree at /tree.json, and serves core JS at /js. Pull a component library into <head> with packages=, add your own routes with routes=, and run lifetime coroutines with background=:

import uvicorn
from spaday.backends.starlette import serve
from spaday_webawesome import WaButton

app = serve(
    lambda: WaButton(variant="brand").text("Hi"),
    packages=["webawesome"],          # pull WebAwesome's styles + catalog into <head>
    styles=["body{margin:2rem}"],     # inline <style> blocks; stylesheets=[...] adds <link> URLs
    title="my app",
)

if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=8000)

Component integrations are peer packages: spaday-webawesome, spaday-lightweight-charts, spaday-regular-layout, spaday-regular-table, and spaday-perspective all use packages= (see below). serve is the one-line happy path — it is mount onto a fresh app, so drop to mount when the app is yours. In a source checkout it serves built assets from js/; from a wheel it automatically serves packaged spaday/extension assets. Use layout="source" or layout="installed" only to override detection, such as when supplying a matching custom js= directory.

For a small page that does not change on the server, use tree="inline":

app = serve(login_page, tree="inline", packages=["webawesome"])

The component tree is embedded in the bootstrap page, so no /tree.json route or second request is needed. A callable page is evaluated once while the routes are built. Use the default JSON mode when it must be evaluated for each request. RefreshTree also needs an explicit url= in inline mode because there is no default tree route to refresh from.

Install an external component package

External integrations use one ComponentPackage descriptor for both <head> tags and static routes. An application can pass the descriptor directly or select it by Python path:

from spaday_trees import package as trees

app = serve(page, packages=[trees])
app = serve(page, packages=["spaday_trees:package"])

An integration package defines that descriptor beside its built assets:

from pathlib import Path

from spaday import ComponentPackage
from .components import SpadayTree

package = ComponentPackage(
    name="trees",
    assets_dir=Path(__file__).parent / "extension",
    assets=(("css", "trees.css"), ("js", "trees.js")),
    components=(SpadayTree,),
)

Classes generated with spaday-cem already contain property, event, and slot schemas. Add the public classes to components so editors and other tools can read package.catalog without constructing them. ComponentSchema and PropertySchema are frozen Pydantic models, so catalog consumers can validate external data with model_validate(), serialize it with model_dump(), and generate JSON Schema with model_json_schema(). For a hand-authored component, define the same metadata explicitly:

from spaday import Component, ComponentSchema, PropertySchema

class DemoGauge(Component):
    tag = "demo-gauge"
    schema = ComponentSchema(
        tag=tag,
        class_name="DemoGauge",
        props=(PropertySchema(name="value", kind="number"),),
        events=("change",),
        slots=("",),
    )

To make the short form packages=["trees"] available, publish the same object as a Python packaging entry point:

[project.entry-points."spaday.component_packages"]
trees = "spaday_trees:package"

Entry-point packages are opt-in: spaday loads only names selected by the application, never every installed integration. All four backends serve each selected descriptor’s assets_dir at {prefix}/components/{name}/; bootstrap emits its CSS and module-script URLs from the same assets list. This registration is host-side only: component tags and props already cross the generic spaday tree, so an integration needs no Rust plugin.

Use discover_component_package_names() when listing installed integrations without importing them. Use resolve_component_packages() for explicitly selected names and read each descriptor’s catalog.

Embed in an existing app

mount adds spaday’s routes to an app you already have, under a prefix — it touches nothing else. The page, tree, /js, and your supplied routes are all prefixed, so a wired panel’s generated websocket URL lines up with its endpoint (pass the unprefixed path; mount adds the prefix). mount only adds routes — the host owns the app’s lifespan, so run any background work in your own lifespan:

from starlette.applications import Starlette
from starlette.routing import Route
from spaday.backends.starlette import mount

app = Starlette(routes=[Route("/", my_own_homepage)])   # your app, your routes
mount(app, page, prefix="/panel", packages=["webawesome"])   # spaday lives only under /panel

Starlette hosts that need to choose how each route is registered can call build_routes() instead. It returns the same page, tree, package, supplied, and core asset routes without changing an app. A FastAPI host can register request routes on a router with dependencies while keeping static mounts public:

from fastapi import APIRouter, Depends
from starlette.routing import Mount, WebSocketRoute
from spaday.backends.starlette import build_routes

router = APIRouter(dependencies=[Depends(require_auth)])
for route in build_routes(page, packages=["webawesome"]):
    if isinstance(route, Mount):
        app.routes.append(route)
    elif isinstance(route, WebSocketRoute):
        router.add_api_websocket_route(route.path, route.endpoint)
    else:
        router.add_api_route(route.path, route.endpoint, methods=route.methods, include_in_schema=False)
app.include_router(router)

Mount several pages with one asset surface

For a conventional multi-page application, use build_site() or mount_site(). Each PageSpec owns its page-generation settings. Component packages and spaday’s /js bundle mount once for the site:

from starlette.applications import Starlette
from spaday.backends.starlette import PageSpec, mount_site

app = Starlette()
mount_site(
    app,
    {
        "/": PageSpec(home, title="Home", packages=["webawesome"]),
        "/login": PageSpec(login, title="Sign in", packages=["webawesome"], tree="inline"),
    },
    prefix="/account",
)

This serves the pages at /account/ and /account/login. The first page fetches its tree from /account/tree.json; the inline login page needs no tree route. JSON and frame trees for non-root pages live below their page path, such as /account/settings/tree.json. Page keys are static paths; add parameterized HTTP and websocket endpoints through routes=.

build_site() returns a SiteRoutes object instead of one flat list. Use site.pages and site.supplied for routes that need FastAPI dependencies, and mount site.assets publicly. Use site.all() when every route can be added directly to Starlette.

Backends ship for Starlette/FastAPI, aiohttp, Flask, and Tornado — import serve/mount from spaday.backends.<name>. They are thin glue over the framework-agnostic generator.

Drop into a host page

When the host owns the entire HTML page (its own markup, CSS, bundler), emit spaday as a fragment — just the bundle tags + the mounting <script>, with no document — and splice it into a node the host provides. The host serves the tree, core /js, and selected package assets itself:

from starlette.responses import HTMLResponse, Response
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from spaday_webawesome import package as webawesome
from spaday.bootstrap import bootstrap, bundles_dir, tree_json

async def home(_request):
    fragment = bootstrap(fragment=True, target="#spaday-root", packages=[webawesome])
    return HTMLResponse(f"<!doctype html>… <div id='spaday-root'></div> {fragment} …")

routes = [
    Route("/", home),
    Route("/tree.json", lambda _r: Response(tree_json(page), media_type="application/json")),
    Mount("/js", StaticFiles(directory=bundles_dir())),
    Mount("/components/webawesome", StaticFiles(directory=webawesome.assets_dir)),
]

The mounting script is inline, so a host with a strict script-src Content-Security-Policy passes a per-request nonce — it stamps the generated <script>/<link>/<style> tags so the policy can allow them. Application CSS belongs in stylesheets= (URLs) or styles= (inline blocks), which are stamped like every generated tag; raw head= markup is concatenated verbatim and is not stamped:

fragment = bootstrap(fragment=True, target="#spaday-root", packages=[webawesome], nonce=request_nonce)
# ...and set `Content-Security-Policy: script-src 'self' 'nonce-<request_nonce>' 'wasm-unsafe-eval'`

Seed reactive state without a server

For client-side reactive UI (two-way bindings, field actions) that needs no server model, seed a local signal store with store= — the page mounts with that state, no wire required:

app = serve(page, store={"dark": False, "view": "list"})   # the tree's bindings read/write these fields

Two channels keep such fields alive across page loads, both mapping store fields to keys:

  • persist={"dark": "app:dark"} — localStorage. The stored value overrides the field’s seed at boot and every later write is stored, so per-browser preferences survive reloads.

  • url={"selected": "model"} — the page URL. ?model=… seeds the field at boot (after persist: a deep link beats a remembered preference), every later change pushes a history entry, and back/forward write the field back — so what the user is looking at is linkable, bookmarkable, and survives a reload, and a Switch on the field is a router. Strings ride the URL verbatim; a field seeded with another type JSON-encodes and reads back as JSON (?page=2 → 2); None/"" clears the parameter.

app = serve(page, store={"selected": ""}, url={"selected": "model"})   # /?model=a%2Fb opens card a/b

Connect a live model

To keep the UI in sync with a server-side model, add a wire: wire="transports" for the default single connection, a typed Wire for connection options, or a list of Wire specs for several models. The generated page opens the WebSocket connections and binds them to the store; you supply the routes and run autosync. See Sync over transports.

import transports
from starlette.routing import WebSocketRoute

app = serve(
    page,
    wire="transports",
    routes=[WebSocketRoute("/ws", transports.ws_endpoint(server))],
    background=[transports.autosync(server)],
)

The route contract

Whatever rung you pick, the generated page expects the host to serve these paths ({base} is the prefix, empty by default) — serve/mount wire them for you:

Path

Serves

GET {base}/

the bootstrap HTML (bootstrap(...))

GET {base}/tree.json

the authored tree (tree_json(page)), unless tree="inline"

GET {base}/js/*

core assets under bundles_dir()

GET {base}/components/{name}/*

assets for each selected ComponentPackage

WS {base}/ws

a transports endpoint (only when wired)