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 |
|
Several pages |
several page routes |
|
Some HTML |
a sub-path of your app |
|
Full custom HTML |
one node in your page |
|
Notebook |
a cell’s widget |
|
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 (afterpersist: 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 aSwitchon 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 |
|---|---|
|
the bootstrap HTML ( |
|
the authored tree ( |
|
core assets under |
|
assets for each selected |
|
a transports endpoint (only when wired) |