PANELITH DOCS Open the interactive docs ↗

Guide · Panelith 0.1.0 · plain HTML edition; the interactive edition has search and live examples

Under the hood and hosting

On this page 8 sections
  1. The manifest
  2. The API
  3. Routes
  4. Running behind a reverse proxy
  5. Several apps in one process
  6. How lineshanley.com runs this
  7. Crawlable pages
  8. What is deliberately not there

The manifest#

At startup the server walks the app's classes and builds one pydantic Manifest: app info, brand, theme, canvas limits, pane header flags, top bar, command sigil, hotkey modifiers, every kind as a KindSpec, every command as a CommandSpec with its parsed hotkey, every list as a ListSpec, the feature flags and the plugin URLs. The shell fetches it once from GET /api/manifest and builds itself from it. Nothing about an application reaches the browser any other way, which is why panelith manifest module:App is the fastest way to debug a kind that does not appear where you expected.

The schema is the contract between the halves. panelith types emits TypeScript from the same pydantic models.

The API#

Endpoint Purpose
GET /api/manifest the manifest; sets the session cookie on a new visitor
GET /api/time the server's epoch time, for countdowns
POST /api/panes register a new or restored pane; returns its state and any actions (children, on_open)
GET /api/panes/{cid} the server's copy of a pane's state
DELETE /api/panes/{cid} the pane was closed
POST /api/panes/{cid}/event an event from a widget; returns actions
POST /api/commands/{id} run a command; returns actions
GET /api/lists/{id}[?q=…] a list source's rows; with q, a live list's search_rows
POST /api/route the URL hash, for App.on_route
GET /api/events the server-sent-events stream
GET /plugins/{name} an app's custom widget module
anything else the shell: a static file if one matches, otherwise index.html

Every request that can carry a pane or command context does: the focused tab, every open tab with its kind and parent, the column count and the viewport size. That is what ctx.tabs and ctx.narrow are made of. Event and command bodies are capped at 64 KB; a list query at 500 characters.

The responses are all the same shape, an ActionList, and the shell applies them through the one function that also handles pushed actions. There is no second code path for "the server changed something on its own".

Routes#

An app that overrides on_route(ctx, path) gets the URL hash (without the #) when the shell loads and whenever the visitor changes it by hand, and answers with actions; set_route(path) writes the hash back without reloading or adding history. These docs use it so #/guide/hosting#routes opens this section.

class Docs(App):
    def on_route(self, ctx, path):
        pid, _, anchor = path.lstrip("/").partition("#")
        return self.open_page(ctx, pid, anchor or None)

Running behind a reverse proxy#

The shell's asset URLs and API base are relative, so the whole application can be mounted under a prefix with nothing changed on the Python side. The proxy strips the prefix, and the only block that needs care is the event stream, which must not be buffered. The nginx configuration below is the one lineshanley.com uses. Pass --forwarded-allow-ips with the proxy's address, or * when the proxy is the only thing that can reach the server, so the forwarded scheme is trusted and the session cookie is marked Secure on https.

upstream panelith_api { server panelith:8002; keepalive 16; }

location = /panelith/api/events {
    proxy_pass         http://panelith_api/api/events;
    proxy_http_version 1.1;
    proxy_set_header   Connection "";
    proxy_set_header   X-Forwarded-Proto $scheme;
    proxy_buffering    off;
    proxy_read_timeout 1h;
}
location ^~ /panelith/api/ {
    limit_req zone=panelith_api burst=30 nodelay;
    proxy_pass         http://panelith_api/api/;
    proxy_set_header   X-Forwarded-Proto $scheme;
    client_max_body_size 64k;
}
location ^~ /panelith/assets/ {
    proxy_pass http://panelith_api/assets/;
    add_header Cache-Control "public, max-age=31536000, immutable";
}
location ^~ /panelith/ {
    proxy_pass http://panelith_api/;
    add_header Cache-Control "no-cache";
}

The hashed asset files can be cached forever; the shell HTML that names them must not be.

Several apps in one process#

panelith serve mounts several apps under prefixes in one uvicorn process. Each keeps its own sessions, cookie (named after its app id), local-storage namespace and event stream; the parent runs every child's startup and redirects a prefix without its trailing slash, which the shell's relative asset URLs need.

panelith serve examples.tour:Tour panelith.docs:Docs@/docs panelith.docs.variants:ALL@/demo --port 8002

module:attr@/mount names the mount; without @ the first app takes /. An attribute that is a dict of apps mounts each entry under /mount/<key>, which is how the demo variants behind these docs' Open ↗ buttons are served. From Python the same thing is make_multi_server({"/": Tour(), "/docs": Docs()}).

Behind a proxy, each mounted app's event stream needs its own unbuffered location (a regex such as ^/panelith/(docs|demo/[a-z0-9-]+)/api/events$).

How lineshanley.com runs this#

The Tour, these docs and the demo variants are one panelith serve process in a container beside the site's other services on a single small droplet. The image is built from a named build context pointing at the framework's repository; the wheel carries the prebuilt shell and these docs, so no Node runs in the build. The container runs as a non-root user on a read-only filesystem with every capability dropped, a 256 MB memory cap, a 64-process cap and a network that can reach nothing but nginx.

FROM python:3.12-slim AS build
COPY --from=panelith pyproject.toml README.md /src/
COPY --from=panelith src /src/src
RUN pip install build && python -m build --wheel --outdir /wheels /src

FROM python:3.12-slim
COPY --from=build /wheels/*.whl /tmp/
RUN pip install /tmp/*.whl
COPY --from=panelith examples /app/examples
USER panelith
CMD ["panelith", "serve", "examples.tour:Tour", "panelith.docs:Docs@/docs", "panelith.docs.variants:ALL@/demo",
     "--host", "0.0.0.0", "--port", "8002", "--forwarded-allow-ips", "*"]

Crawlable pages#

A Panelith app is a single page, and its routes live behind #, so a search engine sees one document. For these docs that was not enough: every page also exists as plain HTML at p/<page id> beside the app (this page is p/guide/hosting), with the same heading ids, and sitemap.xml lists them all. They are rendered on the server from the same markdown (markdown-it-py), links between them are relative, and each one links back to the interactive edition. The sitemap and the canonical URLs need the public base: set PANELITH_PUBLIC_BASE (for example https://lineshanley.com/panelith/docs), or have the proxy send X-Forwarded-Prefix when it strips a prefix, and the base is read from the request.

The hook is App.extend_server(api), called with the FastAPI application before the shell's catch-all route is added; an app can register anything there. App.description fills the shell's <meta name="description">, and the shell's <title> is App.title.

What is deliberately not there#

Panelith has no accounts, no database and no persistence of its own. State lives in the Python process, per session, and in the browser's local storage. That is the right shape for a personal tool, a local dashboard or a demo, and the wrong shape for anything that must survive a restart with its data intact, which an application does by keeping its own store and treating pane state as a view of it.

It runs as one process (Sessions). The pane canvas is capped at a few columns and rows because that is what fits on a screen. The heavier widget packs the plan names (a code editor, a math pad, plots) do not exist yet; a plugin is the way to one today.