Examples
The repo ships one runnable example app, device-playground, that exercises the
whole bext-lite stack in a single demo: server-rendered string-builder pages, a
"use client" React page, on-device KV data, and the native device SDK with
graceful off-device degradation. It runs on the same offline-first QuickJS runtime
that embeds into an iOS or Android shell, and it is served headless by
bext-lite serve so you can open it in a browser today.
The source is at crates/bext-lite/examples/device-playground (its README.md
has a route-by-route table and the embedding notes).
If you have not built the binaries yet, do Installation
first. The commands below assume bext-lite-build and bext-lite are on your
PATH and that you are at the root of the bext monorepo.
Run it#
Two commands: export, then serve. The example has "use client" React routes, so
the export runs a real React SSR build. Point it at the vendored React and the
shared framework:
BEXT_LITE_VENDOR_NODE_MODULES="$PWD/vendor/node_modules" \
BEXT_SHARED_FRAMEWORK_DIR="$PWD/sites/shared/framework" \
bext-lite-build crates/bext-lite/examples/device-playground -o /tmp/dp
BEXT_LITE_VENDOR_NODE_MODULESpoints the React AOT build at the repo's vendoredreact/react-dom/scheduler. The exporter symlinks them into the app'snode_modulesfor thebun build, then removes them again.BEXT_SHARED_FRAMEWORK_DIRresolves the@bext-stack/frameworkstring-builder runtime import that the server-rendered pages use.
Then serve the exported bundle:
bext-lite serve /tmp/dp --port 8080
Open http://127.0.0.1:8080/ or curl the routes.
A string-builder-only PRISM app (no "use client" React pages) needs neither env
var and no bun - the export is just bext-lite-build ./app -o ./dist. Both env
vars are only here because this example includes React routes. See
Quickstart.
A tour of the routes#
/ - the overview (string-builder)#
src/app/page.tsx is a pure string-builder page: no "use client", no React, no
client JS. It compiles to a __bextPrismRender string bundle that runs on QuickJS
with no React runtime, and the root layout (src/app/layout.tsx) wraps it in the
full document shell - doctype, <head> with the page's metadata title, and the
island loader. Everything the visitor sees is server-rendered HTML.
curl -s http://127.0.0.1:8080/ # overview page, a full HTML document
/interactive - React, ahead of time#
src/app/interactive/page.tsx is a "use client" React page. The exporter builds
a real React bundle and runs ReactDOMServer.renderToString on QuickJS at serve
time, so useState has a genuine runtime under the interpreter. Seed the counter
from the query string and the server-rendered markup reflects it:
curl -s "http://127.0.0.1:8080/interactive?start=42" # React SSR renders 42
Live interactivity is layered on with a small inline enhancement script - the same
progressive-enhancement model as the island loader. A status pill
starts as server-rendered and flips to live in browser once the browser runs
the shipped JS, which is how you can tell the page hydrated. For the two
interactivity styles, see React and Islands.
/device - the native device SDK#
src/app/device/page.tsx is a "use client" page whose buttons post to
/__bext/sdk/device/* wire paths - the exact channel the bext.device.* client
uses - and print the raw { ok, status, body } envelope. Served headless there is
no native bridge installed, so every device call resolves ok:false, status:501:
curl -s -X POST http://127.0.0.1:8080/__bext/sdk/device/app/version \
-H 'content-type: application/json' -d '{}'
# -> { ... "status": 501 ... } (no bridge off-device)
That 501 is the feature, not a failure. Inside the iOS or Android shell the same
buttons return real camera, Face ID, and GPS results with status:200; the app
source does not change, only the runtime underneath it does. See
Device SDK for the surface and Mobile for the
bridge.
/notes and /api/notes - the on-device KV round-trip#
src/app/notes/page.tsx is a string-builder page with a form and a list. It reads
and writes through the app's own /api/notes route (src/app/api/notes/route.ts),
which persists to on-device KV (SQLite) via an in-process /__bext/sdk/kv/* call.
GET lists, POST adds, DELETE clears - so it exercises kv.list / kv.get /
kv.set / kv.delete end to end:
curl -s http://127.0.0.1:8080/api/notes # [] (empty KV)
curl -s -X POST http://127.0.0.1:8080/api/notes \
-H 'content-type: application/json' -d '{"text":"hello"}' # add a note
curl -s http://127.0.0.1:8080/api/notes # now lists the note
The note survives across requests because it is written to SQLite: this is the offline-first data path, with no network and no external database.
The client talks to /api/notes, never to KV directly, on purpose. The data SDK
is not exposed over HTTP - a direct POST /__bext/sdk/kv/get from a browser
returns 404 by design - so a tenant's data can never leak. Only /api/* is
HTTP-reachable, and it touches KV server-side where it belongs. See
Data and storage.
What works headless versus in the shell#
Everything here runs headless under bext-lite serve - that is the point of the
demo. The one thing that behaves differently off-device is the device bridge:
| Surface | Headless (bext-lite serve) |
Inside the iOS / Android shell |
|---|---|---|
/, /interactive, /notes |
Fully working | Same |
/api/notes + on-device KV |
Fully working (SQLite in the data dir) | Same, on the device's writable data dir |
/device buttons |
ok:false, status:501 (no bridge) |
ok:true, status:200 with real results |
Check it before you ship#
bext-lite check renders every route once under QuickJS and runs a static policy
scan, then tells you whether the app is publishable:
bext-lite check /tmp/dp
A clean run ends with => PUBLISHABLE (lite-eligible + policy-clean).
Ship it to a device#
The same dist-lite/ is what a mobile app ships. Scaffold the shell projects, then
build and run on a simulator or device:
bext-lite mobile init # scaffolds ios/ and android/ shells
bext-lite mobile run ios --dist /tmp/dp
On a machine without the platform toolchain, mobile run stops cleanly at a
toolchain guard that prints exactly what is missing, and exits non-zero. The shell
copies dist-lite/ in as an app resource, boots the same embedded bext-lite
server, installs a native bridge that answers the device/* namespace, and points
a WebView at it. See bext-lite mobile and Mobile.
Related#
- Quickstart - the same two-command flow, explained step by step
- Islands - the free client runtime the demo uses for liveness
- Device SDK - the
bext.device.*surface/devicecalls - Data and storage - the KV path behind
/notes