Libraries¶
Reusable Peg UI packages — distributed on PyPI, imported like any Python
package, and safe by construction. A library is plain Python that composes
the existing public widgets (Canvas, Button, Column, …) and the SDK's
sanctioned capabilities. It is compiled by the same trusted SDK and the same
whitelist as your app, so it can never inject JavaScript, reach the
compiler/runtime, or do anything your own page couldn't.
At a glance:
# your app
from workpeg.ui import App, Column
from workpeg_pdf import PdfViewer # a third-party library, pip-installed
class Docs(App):
def build(self):
return Column(PdfViewer(self.file_url)) # used like any widget
# the library (workpeg_pdf/__init__.py)
from workpeg.ui import Component, Canvas, CanvasText, binary, codec
# ...composes Canvas/Text/etc. — no raw JS, no compiler imports
The safety model¶
This is the whole point, so it's worth stating precisely.
The compiler is the trust boundary. A Peg app is never run as the Python
you wrote — it is parsed and compiled to JavaScript by the SDK, which only
emits a fixed, whitelisted vocabulary: known widgets, known capabilities
(http, binary, codec, clock, …), and a small language subset. Anything
outside that vocabulary is a compile error, not a security hole.
A library is more input to that same compiler. When you pip install a
library and import a Component from it, the build:
- Locates the library's source file by path (via
importlib) — it does not import or execute the library's Python at build time; - Parses the imported
Componentfrom that file with the sameASTParserand the same whitelist used for your own pages; - Emits only the same widget trees + capability calls your code would.
So a library cannot:
- emit raw JavaScript (there is no construct that does — not even a string ever
reaches
innerHTML; text is alwaystextContent, URLs are scheme-checked); - import or call the compiler/runtime internals (
workpeg.ui.compiler.*, theWorkpeg.*JS object) — those names aren't in the whitelist; - read the filesystem, spawn processes, or touch the network except through the
vetted capabilities (
http,binary, …), which are pure data-in/data-out; - escape the widget sandbox — its output is a tree of the same widgets you already trust.
And because the final build runs on Workpeg's own SDK installation, an app author can't smuggle a doctored compiler in alongside a library either.
In short: a library has exactly the powers of a page you wrote yourself — no more. Review it like you'd review a snippet, not like a native plugin.
Installing untrusted libraries (the build pipeline)¶
The whitelist guards the compile step. But before any compiling, the build
has to pip install the app's library dependencies — and installing a package
runs arbitrary code (its setup.py / PEP-517 build hooks). That code runs
before a single line is parsed, so the parser is the wrong layer to rely on
here. A build that installs third-party packages (e.g. the store building a
submitted Peg) must isolate the install, not just trust the compiler:
- Sandbox the install in an ephemeral, locked-down container. No secrets or cloud credentials mounted, network egress restricted to the package index only, non-root, read-only filesystem except a scratch dir, CPU/memory/time limits — and the container is destroyed after the build. This contains arbitrary install-time code regardless of how hostile the package is.
- Prefer wheels; don't run build code.
pip install --only-binary=:all:installs pre-built wheels, so nosetup.pyexecutes at install time. - Pin and protect the core. Install
workpegfrom a trusted, pinned source and run the app's requirements under a constraints file (pip install -c constraints.txt -r requirements.txt) that forbids changing it — so a dependency can't downgrade or replace the compiler itself. - Never import the library into the compiler. Resolution is path-only
(
importlib.util.find_spec, no execution); the library's module code is only ever AST-parsed, never run. (This is how_resolve_library_moduleworks.) - Namespace guards. A package whose top-level name shadows
workpegor a stdlib module is never resolved as a library, so it can't masquerade as core. - The whitelist is the compile gate (above), and the only thing that leaves the sandbox is the compiled JS produced by our own generator — pure data.
Points 4–6 are properties of the SDK as built; points 1–3 are the build
infrastructure's responsibility around the pip install step — the SDK can't
enforce them, the pipeline must.
In practice: make build-ui¶
The repo ships a ready target that implements these controls — a locked-down, offline compile. It's two steps:
wheel-lib(trusted, dev-time) builds a local library into a wheel so the secure step can install it with no network and no source build. For a third-party library you'd instead fetch its prebuilt wheel from the index in a separate, network-restricted step — never building untrusted source.build-uithen compiles the app todist/inside a hardened, ephemeral container, installing the library from those wheels offline.
make build-ui # the bundled demo (builds its wheel, then compiles)
make build-ui APP=myapp PKG=thelib # an app whose library wheel is in wheels/
make build-ui APP=plain LIB= PKG= # an app with no library dependencies
Each flag maps to one of the controls above:
| Hardening | How build-ui does it |
Stops |
|---|---|---|
| No egress | --network none |
install/compile-time exfiltration; forces an offline install |
| No Docker escape | the docker.sock mount is omitted |
breaking out to the host daemon |
| Drop privileges | --cap-drop=ALL, --security-opt no-new-privileges:true |
capability abuse / setuid escalation |
| Resource caps | --pids-limit, --memory, --cpus |
a hostile build exhausting the host |
| Read-only source | repo mounted :ro (only dist/ writable) |
build code tampering with the source tree |
| No install-time code | pip install --only-binary=:all: |
setup.py / build-hook execution (injection) |
| No dependency poisoning | pip install --no-deps |
a dependency downgrading or replacing workpeg (the compiler) |
| No fetching | --no-index --find-links wheels/ |
pulling anything but the vetted wheels you provide |
| Ephemeral | --rm |
persistence between builds |
The only thing that leaves the container is dist/ — JS produced by our own
generator from a parsed, whitelisted tree.
serve-ui is the dev counterpart: it installs an editable library and
live-reloads, trading this hardening for convenience. Use build-ui for
anything that handles untrusted input.
Residuals for a production pipeline: egress-filter the wheel prefetch (the step that fetches third-party wheels), and run the locked container as a non-root user (
build-uikeeps root inside the sandbox, which--cap-drop/no-new-privileges/ no-socket already contain).
The library contract¶
A Workpeg library:
- MUST import only from the public
workpeg.uiAPI (widgets,Component,State, capabilities, validators). - MUST keep every
Component.build()and method inside the supported subset — the compiler enforces this, but knowing the limits up front saves you surprises (see Subset constraints). - MUST NOT import
workpeg.ui.compiler.*, the runtime, or reach forWorkpeg.*— there is no supported way to, and it won't compile. - MUST NOT contain raw JavaScript,
eval, dynamicimport, or attempt to build HTML strings. (Text/Code/Markdownalready render text safely.) - SHOULD expose its public
Components from the package's top-level (from your_lib import Thing) or a clearly named submodule (from your_lib.thing import Thing).
None of these are enforced by trust — they're enforced by the compiler. The list is a map of what will compile, not a set of locks you could pick.
What a library can build on¶
Everything in the public API. The pieces that make non-trivial libraries possible:
| Capability | Use | Notes |
|---|---|---|
Canvas + draw ops (Rect, Line, Path, CanvasText, CanvasImage, …) |
Pixel-level rendering | The general-purpose drawing surface — a PDF/chart/diagram library renders here |
binary.read(url[, max_bytes]) |
await a blob/URL → byte array (a typed array; len/indexing/codec.* all work) |
Parse binary formats (PDF, images, fonts) in pure Python. Scheme re-checked; never executes the bytes. Returns a typed array (not a boxed list), so a large file doesn't lock the tab. Pass max_bytes to cap the read — the reader gets the truncated prefix |
codec.inflate(data) |
await → decompress a zlib/DEFLATE byte array |
The decompressor for compressed streams (e.g. PDF FlateDecode). See below |
codec.inflate_all(list) |
await → decompress a list of streams → list of byte arrays |
One await for many streams — the subset can't await in a loop |
codec.base64(data) |
byte array → base64 string | Build a data: URL for CanvasImage |
codec.image(stream, w, h, comps, is_flate) |
await → a compact PNG data: URL |
Decode a RASTER image (a scanned page) entirely in the runtime: it inflates (when is_flate) and downsamples w×h, comps-channel (1 grey / 3 RGB) 8-bit pixels, so the library shows a large scan without ever materialising its millions of pixels. "" if it can't decode |
codec.latin1(data) |
byte array → string (one char per byte) | Scan/slice binary content as text (there's no chr/ord) |
codec.chars(codes) |
list of code points → string | Turn decoded code points (e.g. a PDF ToUnicode map) into text |
codec.codes(s) |
string → list of char codes | The inverse of chars (a batched ord) — read a byte's value |
FutureBuilder(future=self.m, key=…, done=lambda d: ...) |
Drive async work | The way a Component loads data — see below |
State + handlers |
Local state, prev/next, toggles | A stateful Component keeps its state across re-renders |
http, socket, clock, json, math, … |
The usual capabilities | Same as in app code |
binary and codec are general-purpose, vetted data transforms — they read
bytes and reshape them; they do not touch the DOM or run code. They exist so a
library can handle binary/compressed content in pure compiled Python instead
of reaching for raw JS.
codec.inflate — decompressing a stream¶
Many formats store their contents compressed to save space — a PDF page's text
and graphics live in a zlib/DEFLATE (FlateDecode) stream, the same compression
as .zip/.gz. To read such content a library must first decompress it, and
codec.inflate is that step:
raw = await binary.read(self.src) # the file's bytes
# … locate a compressed stream's bytes `seg` …
body = await codec.inflate(seg) # → the original, uncompressed bytes
text = codec.latin1(body) # read them as text to parse
How it behaves:
- Native, not bundled. It uses the browser's built-in
DecompressionStream(a platform API likefetch) — no decompression library ships in the app. It tries the zlib-wrapped format first, then raw DEFLATE. - Never throws. Decompression of malformed input fails quietly — it
resolves to an empty byte array (
[]) rather than rejecting. (An earlier version leaked those rejections as on-screen "error" toasts; it no longer does.) Always handle the empty case — e.g. show a message, or skip the stream. - Salvages a valid prefix. Real streams often have a stray byte (a newline
before the PDF
endstreamkeyword) after the compressed data — strictly, "junk found after end of compressed data."codec.inflatekeeps everything it decoded before the junk instead of discarding the whole result. - Needs
DecompressionStream. Present in all current browsers; where it's absent (or disabled, as in some sandboxes) inflate returns[], so design for that — e.g. uncompressed input still works without it.
Use codec.inflate_all([...]) to decompress several streams (a page's content
plus each font's map) in a single await, rather than one await per stream
in a loop.
Authoring patterns¶
A Component is the unit of reuse¶
Expose a Component (Flutter-style). Props are __init__ parameters; own state
is State; build() returns a widget tree:
from workpeg.ui import Component, Column, Text, Button, State
class Stepper(Component):
count = State(0)
def __init__(self, label="Count"):
self.label = label
def inc(self):
self.count += 1
def build(self):
return Column(
Text(f"{self.label}: {self.count}"),
Button("+", on_click=self.inc),
)
Async loading: use FutureBuilder, not on_mount¶
on_mount runs only for the top-level app, not for an embedded
Component. So a Component that needs to load/parse data drives it through
FutureBuilder, whose future may be a self.method (async). It takes
done= — the builder for the loaded value — and a key=, which is also what
re-runs it: change the key and the future runs again.
from workpeg.ui import (Component, FutureBuilder, Canvas, CanvasText, State,
Text, binary)
class Viewer(Component):
page = State(1)
def __init__(self, src):
self.src = src
async def render_page(self):
data = await binary.read(self.src) # bytes in
# ...parse + lay out... -> return plain dicts/lists
return {"texts": [], "w": 612, "h": 792}
def build(self):
return FutureBuilder(
future=self.render_page,
key=f"page-{self.page}", # a new page re-runs it
loading=Text("Loading…"),
done=lambda d: Canvas(
*[CanvasText(t["s"], x=t["x"], y=t["y"]) for t in d["texts"]],
width=d["w"], height=d["h"], background="#ffffff",
),
)
loading is an ordinary widget. A draw op such as CanvasText is not one —
it is valid only inside a Canvas.
Rendering parsed data to Canvas¶
build() is pure — it can't run a parser. So parse in an async method into
plain State (or the FutureBuilder value), then map those lists to widgets
with homogeneous splat-comprehensions — one comprehension per op type, since
each yields a single widget type:
Canvas(
*[Path(points=p["pts"], stroke=p["stroke"], closed=p["closed"]) for p in d["paths"]],
*[CanvasText(t["s"], x=t["x"], y=t["y"], font=t["font"]) for t in d["texts"]],
width=d["w"], height=d["h"],
)
Paging / interaction¶
Hold UI state (current page, zoom) in State; a button handler mutates it and
the Component re-renders:
Subset constraints to design around¶
Library code lives in the same compiled subset as app code. The ones that bite when writing parsers/algorithms:
- No
while/break/continue. Use boundedfor ... in range(n)loops with a state flag to "stop early". - No bitwise operators (
& | ^ << >>). Use arithmetic:hi * 256 + lo,b // 16,b % 256. build()is pure — no assignments and no calls that mutate state. Do work in async methods / handlers; render fromState. A method that returns a widget is fine: it is inlined wherebuild()calls it (self.row(item)), parameters and all — see Core concepts. What it lacks is state of its own; for that, make it aComponent. It may branch and return early exactly asbuild()may, and holds nothing else: a loop or an assignment in one is a compile error naming it. A method is a widget helper only when what it returns is a widget —return self._code()over a_code()that returns text is an ordinary method, so there is no need to route it through a local to keep it one.- Methods may have local variables,
for,if,try, aug-assign, andawait. - A
returninside aforloop works in a plain method — loops compile tofor … of, so it ends the method, as in Python. Not inbuild()or a widget helper, where a loop is a compile error: find the item in a plain method and branch on what it returns. awaitinside aforloop works in an async method — the loop is afor … ofin the method itself, not a callback, so each iteration waits its turn.- Indexing isn't negative-aware in expressions — use
xs[len(xs) - 1], notxs[-1](slicing does handle negatives). - Component
__init__defaults must be literals. A literal default (scale=1.0,label="Count") is filled in when the caller omits the argument, the way Python would. A computed default (a call, another name) isn't captured — the prop arrivesundefined— so keep defaults to plain scalars or guard in the body. codec.inflateneeds the browser-nativeDecompressionStream(all current browsers). Where it's unavailable, it resolves to an empty result — handle the empty case, or restrict to uncompressed input.
Packaging & distribution¶
A library is an ordinary Python package:
workpeg_pdf/
pyproject.toml
workpeg_pdf/
__init__.py # exposes the Component(s): class PdfViewer(Component): ...
Define the public Component in the file the app imports from — the
compiler resolves from workpeg_pdf import PdfViewer to workpeg_pdf/__init__.py
and looks for the class PdfViewer definition there (a re-export
from .viewer import PdfViewer won't be found — put the class in the imported
module, or import from the submodule that defines it).
Names, files and what does not travel¶
- A built-in widget's name is taken. A library
ComponentcalledPhoneInput,Select,MarkdownEditoror any other built-in widget's name is a compile error the moment an app uses it: it names the class, the module it came from and the built-in it collides with, and tells you to rename the class. Before that error your class was never emitted, and what you saw depended on the call: one the built-in accepts compiled silently to the SDK's widget, and one it does not failed with the built-in's own error (MarkdownEditor(self.body, self.on_edit)got "takes keyword arguments only"), which said nothing about the collision. The built-in keeps the name; prefix yours with the library's (MdEditor,CountryPhoneInput). The same holds through an alias (from lib import LibTag as Tag) and through a package that re-exports the class (from .parts import Tagin its__init__.py): both are refused rather than compiled to the SDK's widget. - A Component's base class must be in the same file. Inheritance is
resolved among the classes of the file that defines the subclass, so
class Child(Base)withBaseimported from another module is not recognised as a Component at all, and an app using it getsUnsupported widget: Child. Keep a shared base beside the Components that extend it. - A base class is copied into every subclass. The compiler merges the
base's members into each subclass when it compiles it, so a lookup table on a
shared base — a
State([...])of two hundred countries — is emitted once per subclass, not once. Put a large table on the one Component that reads it, or load it at runtime. - A library cannot ship static files. A build copies the app's own
public/directory and nothing from an installed package, so an image, font or stylesheet inside your library never reaches a deployed Peg. Point at anhttps:URL (a CDN), inline a smalldata:image/…URL, or document the image an app has to put in its ownpublic/, which refuses fonts, stylesheets and scripts (what it can hold). WORKPEG_LIBRARY_ALLOWLISTconfines which libraries a build will read. Unset — a developer's laptop — any installed package resolves. Set to a comma-separated list of top-level package names (workpeg_pdf,workpeg_md), only those do, and an import of anything else is not resolved: its name then fails as an unsupported widget. Set to the empty string, no library resolves at all. It exists for a shared build server, where the submitted source decides which installed files get opened and parsed, and a parse error reports what it found.
Install it like any package; during development, editable:
Then a Peg app just imports it. At build time the compiler finds the installed package, compiles its Components through the whitelist, and bundles them.
Testing a library¶
Compile the library's Component from a tiny host app and assert on the generated JS / run it in a headless DOM — exactly as the SDK tests do:
from workpeg.ui.compiler.ast_parser import ASTParser
from workpeg.ui.compiler.js_generator import JSGenerator
app = """
from workpeg.ui import App, Column
from workpeg_pdf import PdfViewer
class P(App):
def build(self):
return Column(PdfViewer("blob:demo"))
"""
# base_dir is the project root. Without it the compiler resolves no import at
# all, and PdfViewer reads as an unsupported widget.
js = JSGenerator().generate(ASTParser().parse_source(app, base_dir="."))
assert "class PdfViewer" in js # the library compiled into the bundle
A library that steps outside the subset (raw JS, eval, an unknown call) raises
UnsupportedSyntaxError at compile time — the same failure your own page would
get. See tests/ui/test_library_resolution.py for the resolution + whitelist
tests, and workpeg_pdf for a full worked example (a from-scratch PDF renderer
built only on Canvas + binary/codec).