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, …)

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>
    head="<style>body{margin:2rem}</style>",
    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.

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

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

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.

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

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> tags so the policy can allow them:

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

Connect a live model

To keep the UI in sync with a server-side model, add a wire: wire="transports" for one model, or a list of Wire specs for several. The generated page opens the websocket(s) and binds them to the store; you supply the websocket route 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))

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)