day-lite: dynamic miniapps on day (normative)
day-lite runs miniapps (apps written in JavaScript or TypeScript, distributed as plain
git repositories, updated remotely) on top of day’s native pieces. A host app (a superapp)
embeds the day-lite crate, chooses which native capabilities to expose, and presents dynamic
apps that were never compiled: the JS layer drives the same pieces, signals, and parts a
compiled day app uses. The model generally follows the
W3C MiniApp white paper: a manifest-described
package of pages with app/page lifecycles, declared permissions, and platform-provided storage,
network, and sensor services.
The reference superapp — a catalog browser that installs, updates, and runs miniapps (§12) —
lived at day/apps/daylite and was removed from this repository in 2026-08. Everything it did
went through the public embedding API, which is the point: a superapp ships its own piece/part/
tweak sets and its own permission policy (§10), and none of that needs to live here.
Initial platform support is the three mobile targets (ios-uikit, android-mdc, harmony-arkui); the design has no mobile-specific dependencies, so desktop toolkits can follow later.
1. Architecture
miniapp repo (manifest.json + *.ts) catalog.json (anywhere on the web)
│ fetched + cached │
▼ ▼
┌──────────────────────────── superapp (compiled day app) ───────────────────────────┐
│ day-lite │
│ ┌───────────┐ ┌──────────────┐ ┌───────────────────────────────────────────┐ │
│ │ loader │→│ QuickJS │→│ bridges │ │
│ │ (fetch, │ │ (rquickjs; │ │ pieces DSL ─→ day-pieces dyn registry │ │
│ │ cache, │ │ one context │ │ signals ─→ day-reactive Signal │ │
│ │ TS strip │ │ per miniapp) │ │ nav/pages ─→ routes + stack │ │
│ │ via oxc) │ │ │ │ parts ─→ http / sensors / … │ │
│ └───────────┘ └──────────────┘ │ storage ─→ sqlite + sandboxed fs │ │
│ ▲ └───────────────────────────────────────────┘ │
│ │ ▲ every call permission-gated (§9) │
│ package store (per-app cache + db + fs sandbox) │
└────────────────────────────────────────────────────────────────────────────────────┘
- Engine: QuickJS via
rquickjs(bytecode interpreter: no JIT, which iOS forbids anyway). OneRuntime+Contextper running miniapp, created on the main thread and only touched there, the same single-threaded discipline day-reactive already imposes. Async work (http, timers) lands back on the main thread viaPlatform::postand resolves JS promises. - TypeScript: modules are type-stripped at load time with the
oxcparser/transformer (oxc_parser+oxc_semantic+oxc_transformer+oxc_codegen)..tsfiles run directly; no build step, no decorators/JSX in v1. - No WebView, no HTML: unlike WeChat-style hosts (and skip-miniapp), the UI layer is
day. JS builds real pieces through a dynamic registry (§4), and reactive text/bindings run
through real
Signals (§5). There is no template language, no virtual DOM, and no whole-statesetDatapush.
2. Miniapp package
A miniapp is any directory shape reachable over HTTP or the local filesystem: by design,
a git repository checked out or served raw (GitHub’s raw.githubusercontent.com/<owner>/<repo>/<branch>/
prefix works as-is, as does any static host or a local path during development):
my-weather/
├── manifest.json # §3 — identity, pages, permissions, files
├── app.ts # entry: App({...}) registration + page() definitions
├── pages/… # more modules, imported relatively from app.ts
├── icon.svg # app icon (svg or png)
├── i18n/en.ftl # Fluent catalogs, one per locale (§7.2)
├── dayscript/smoke.yaml # scripted drive of this miniapp inside a host (§11)
└── tests/app.test.ts # optional headless tests (§11)
Fetching is manifest-driven: install(origin) downloads <origin>/manifest.json, then every
file in its files list, into the package store. There is no zip step (the W3C packaging spec
allows a container format; day-lite treats the repo itself as the container). Local origins
(/path/to/dir) skip the cache and re-read from disk on every launch; the dev loop is
“edit, relaunch the miniapp”.
3. Manifest
manifest.json, W3C MiniApp Manifest field names (snake_case), plus day extensions:
{
"app_id": "dev.daybrite.weather",
"name": "Weather",
"description": "Current conditions via open-meteo",
"icons": [{ "src": "icon.svg", "sizes": "any" }],
"version": { "code": 3, "name": "1.2.0" },
"platform_version": { "min_code": 1 },
"pages": ["home", "detail"],
"req_permissions": [
{ "name": "day.permission.NETWORK", "reason": "Fetches forecasts from open-meteo.com" }
],
"window": { "background_color": "#101024", "orientation": "portrait" },
"day": {
"entry": "app.ts",
"files": ["app.ts", "pages/detail.ts", "icon.svg", "i18n/en.ftl"],
"net_origins": ["https://api.open-meteo.com"]
}
}
pages— route ids the app must register withpage(id, builder);pages[0]is the launch page.req_permissions— every capability the app may use, with a human reason. The superapp MUST show this list before install (§9); calls to undeclared or ungranted capabilities reject at runtime.day.entry— the module evaluated at launch (defaultapp.ts, falling back toapp.js).day.files— the complete fetch list (everything the app needs offline). The manifest and entry are always fetched even if unlisted.day.net_origins— URL prefixesday.net.fetchmay touch (W3C “domain validation”). Empty/absent means the NETWORK permission grants nothing concrete and fetch always rejects.version.code— monotonically increasing int; the update check (§8) compares it.
4. Driving pieces dynamically: the dyn registry
The piece layer’s API contract gains a machine-readable surface. day-pieces (feature
dyn-registry) registers every built-in piece constructor and every Decorate modifier in a
runtime registry keyed by name:
// day-pieces/src/dynreg.rs (feature dyn-registry)
pub enum DynValue { Null, Bool(bool), Num(f64), Str(String), List(Vec<DynValue>),
Map(Vec<(String, DynValue)>), Fn(DynCallback) } // DynCallback: host closure
pub struct DynPiece(/* erased piece + modifier dispatch */);
pub fn construct(name: &str, args: &[DynValue]) -> Result<DynPiece, DynError>;
impl DynPiece {
pub fn modify(&mut self, name: &str, args: &[DynValue]) -> Result<(), DynError>;
pub fn into_any(self) -> AnyPiece;
}
pub fn catalog() -> &'static [PieceSpec]; // introspection: names, arities, modifier lists
- Constructors cover the built-in vocabulary (
text,button,column,row,grid,grid_row,scroll,list,image,canvas,toggle,slider,text_input,spacer,divider,progress,when, …). Container constructors take childDynPieces. - Modifiers cover the
Decoratechain (frame,padding,spacing,background,corner_radius,font,foreground,align,grow,id,on_tap,action,overlay_aligned,defers_system_gestures, …).DynValue::Fncarries JS callbacks foraction/on_tap/canvas draw. - Names are snake_case throughout: constructors, modifiers, and string enum values
(
label(...).font("large_title"),grid_align("top_leading")), mirroring day’s Rust API exactly, so nothing needs re-casing between the two languages. catalog()is the introspection surface: JS generates its API (andday lite testits typings) from the same registry the bridge dispatches through, so the two cannot drift. Superapps that compile in extra piece crates extend the registry through the sameregister_piece!/register_modifier!macros the built-ins use; their pieces become scriptable with no day-lite changes (§10).
The JS side exposes each constructor as a function and each modifier as a chainable method
(column(...), .padding(12)), generated once at context startup from catalog().
5. Signals: one reactive system, two languages
JS signals are day_reactive::Signals. signal(initial) allocates a Rust
Signal<DynValue> scoped to the running page; sig.get() reads it through the normal
tracking path, so when a JS closure runs inside a Rust reactive computation, its get()s
register dependencies exactly like Rust code:
const count = signal(0)
page('home', () =>
column(
text(() => `Count: ${count.get()}`), // closure re-runs when count changes
button('Increment').action(() => count.set(count.get() + 1)),
).spacing(12))
A DynValue::Fn passed where a reactive value is accepted (text, bind-style modifier
args) is wrapped in the piece layer’s usual bind/watch: day-reactive re-invokes the JS
closure when its dependencies change, and only the affected piece patches. There is no
diffing and no bulk state push; the granularity is identical to a compiled day app.
watch(fn) and effect(fn) are exposed for non-UI reactions; all signal APIs are
main-thread only (enforced; calls from async callbacks are re-posted).
6. App shape, lifecycle, navigation
W3C-style two-level lifecycle, WeChat-compatible names:
App({
onLaunch(opts) {}, // once, before the first page builds
onShow() {}, onHide() {}, // foreground/background (day lifecycle phases)
onError(err) {},
})
page('home', () => column(...)) // builder; re-invoked per presentation
page('detail', (params) => column(...), {
onLoad(params) {}, onReady() {}, onShow() {}, onHide() {}, onUnload() {},
})
day.nav.navigateTo('detail', { id: 7 }) // push
day.nav.navigateBack() // pop
day.nav.reLaunch('home') // reset stack
Pages map to a day stack inside the miniapp’s host surface (the reference superapp presented
that surface in a fullscreen cover with the standard X-to-exit affordance). Each page
presentation runs its builder inside a fresh reactive Scope; onUnload coincides with
scope cleanup, so signals and watches created in a page die with it.
7. Built-in services
All namespaced under the global day object (the host may alias its own brand). Promise
returns throughout; errors are typed (PermissionError, NetError, DbError, FsError).
day.net.fetch(url, opts?)→Promise<{ok, status, headers, text(), json()}>— bridged today-part-http’s async fetch; gated byNETWORK+day.net_origins.day.db— sqlite, always available (STORAGE is a default-granted permission):db.migrate([...ddl])(§7.1),db.exec(sql, params?)→{changes, lastInsertRowId},db.query(sql, params?)→[{col: value}]. One database per app id.day.fs— OPFS-shaped sandboxed filesystem (same contract skip-miniapp validated):day.fs.root.getFileHandle(name, {create})/.getDirectoryHandle(...)/.entries()/.removeEntry(name, {recursive}); file handlesread(),write(data),remove(),size. Paths are confined to the app’s sandbox dir;.., absolute paths, and escapes reject withSecurityError.day.sensors— bridged today-part-sensorswhere compiled in; gated bySENSORS.day.prefs— small KV (get/set/remove), bridged today-part-prefs, app-scoped.day.sys.info()→{platform, appId, version, locale, host}.day.i18n.t(key, args?)(global shorthandt(...)) — Fluent localization (§7.2).- Timers/console:
setTimeout/setInterval/clearTimeout/clearInterval(Platform::post driven),console.log/warn/error→ the host log (visible inday launchoutput).
7.2 Fluent localization
Miniapps ship Fluent catalogs at the standardized location
i18n/<locale>.ftl (listed in day.files like any other package file). t(key, args?)
formats through a per-app FluentBundle for the RUN’s locale: day launch --locale and
day lite test deliver it via DAY_LOCALE, else day’s live locale signal applies, so a
set_locale in the host re-renders miniapp text reactively. Resolution falls back
zh-CN → zh → en, and a missing key returns the key itself (an unlocalized app keeps
working, visibly). Bidi isolates are stripped from output exactly as day-l10n does for
compiled apps, so scripted assert_text matches the authored string. The bundled samples
each carry en, fr, ar, and zh-CN catalogs.
7.1 sqlite migrations
db.migrate(migrations: string[]) is the schema contract: an append-only array of DDL
scripts. Position n runs exactly once, tracked in sqlite’s user_version pragma; on
launch the app calls migrate with its full history and day-lite applies the tail. Editing
history instead of appending is an error (a recorded hash per step catches it).
8. Install, update, catalog
The package store keeps, per app id: the manifest, fetched files, the sqlite db, the fs
sandbox, and install metadata (origin, granted permissions, installed version.code, file
hashes). Superapp-facing API (all async):
lite::store::install(origin: &str) -> InstallPlan // fetched manifest + permission list, NOT yet installed
InstallPlan::confirm(granted: &[Permission]) -> Installed // fetch files, persist
lite::store::check_update(app_id) -> Option<UpdatePlan> // refetch manifest, version.code compare
UpdatePlan::apply() // fetch changed files (hash-diff), atomic swap
lite::store::remove(app_id) // db + fs + cache gone
The two-step install is what makes permission disclosure structural: the UI cannot install
without passing the granted set through confirm. A catalog is any JSON document listing
origins; the superapp renders it, but installing an entry goes through the same
install(origin):
{ "apps": [ { "app_id": "dev.daybrite.weather", "name": "Weather",
"description": "…", "icon": "https://…/icon.svg",
"origin": "https://raw.githubusercontent.com/daybrite/miniapp-weather/main",
"version": { "code": 3, "name": "1.2.0" },
"req_permissions": [ { "name": "day.permission.NETWORK", "reason": "…" } ] } ] }
Catalog entries duplicate the permission list so disclosure can render before any fetch; at install time the fetched manifest is the source of truth and a mismatch surfaces in the UI. Updates re-run disclosure only when the permission set grew.
9. Permissions
Permission ids are plain strings (day.permission.NETWORK, .SENSORS, .FS, .STORAGE,
.PREFS). The mapping is: each bridge module declares the permission it requires;
day-lite installs a bridge into a context only if the manifest declares it AND the user
granted it. Ungranted calls don’t half-work: the namespace exists but every entry point
rejects with PermissionError (so feature detection is day.can('NETWORK'), not
try/catch-shaped guessing). STORAGE (sqlite) and PREFS are granted implicitly at install
(they touch only app-private data) but still must be declared to be visible in disclosure.
Hosts can reclassify (a kiosk superapp may implicit-grant nothing) and define new permission
ids for their own bridges (§10). Grants persist in the package store and are revocable from
the superapp’s app-detail UI.
10. Embedding in other superapps
The whole system is a library — the reference superapp held no privileged code of its own:
let host = day_lite::Host::builder()
.store(day_lite::Store::at(data_dir)) // package store root
.bridge(day_lite::bridges::net()) // day-part-http, wants NETWORK
.bridge(day_lite::bridges::sensors()) // day-part-sensors, wants SENSORS
.bridge(my_crate::payments_bridge()) // custom part, custom permission id
.implicit(&["day.permission.STORAGE", "day.permission.PREFS"])
.build();
let surface: AnyPiece = host.launch(app_id)?; // the miniapp's UI, place it anywhere
A bridge is { namespace, permission, install(ctx, services) }, the same seam the
built-ins use. Pieces compiled into the host (any crate using register_piece!) are
automatically scriptable (§4). A superapp is this builder plus a catalog UI.
11. Testing: day lite test
[!NOTE] The
day lite testCLI subcommand is not currently built into the publisheddaybinary: day-cli must not depend onday-litewhileday-litestays unpublished (publish = false) on crates.io. The runner itself lives in theday-litecrate; callday_lite::run_tests(dir)directly, or re-add theLitesubcommand to day-cli onceday-liteis publishable.
Headless unit tests ship inside the miniapp (tests/*.test.ts):
import { totalFor } from '../app.ts'
test('sums open todos', () => { expect(totalFor([{done:false},{done:true}])).toBe(1) })
test('db roundtrip', async () => {
await day.db.exec('insert into todos(title) values (?)', ['x'])
expect((await day.db.query('select count(*) n from todos'))[0].n).toBe(1)
})
Because miniapp pieces are real day pieces in the host’s tree, dayscript drives them
like any compiled UI: element ids assigned in JS (.id("ttt-cell-0")) are tappable,
assertable, and screenshot-able through the ordinary engine. The convention is a
dayscript/ directory in the miniapp repo whose flows run against the reference host:
cd <your superapp>
day launch -p ios-uikit --env DAYLITE_RESET=1 \
--script miniapps/tictactoe/dayscript/smoke.yaml # install → open → play → screenshot
day launch -p ios-uikit --locale fr --variant fr --env DAYLITE_RESET=1 \
--script dayscript/fr.yaml # localized-run screenshots per locale
The DAYLITE_RESET=1 in that example was the reference host’s own convention for starting from
an empty store so install flows are reproducible — a superapp reads it itself, the runtime does
not. --variant files each locale’s screenshots separately, mirroring the showcase galleries.
day lite test <path> (path = miniapp dir, default .) runs every test module in a fresh
context wired to the day-mock toolkit: pieces construct and patch for real (assertable
via ids), sqlite/fs run against a temp sandbox, day.net.fetch requires an explicit
mock.net.route(url, reply) (network is never live in tests). test(), expect().toBe/ toEqual/toContain/toThrow, async tests, and beforeEach are provided by the runner. Exit
code 5 on failure, mirroring dayscript. The same runner backs miniapp CI (a plain
day lite test in the repo’s workflow).
12. The shape of a superapp
What the reference host (day/apps/daylite, removed 2026-08) implemented, kept here because it
is the shape a superapp needs rather than a description of one program. Three surfaces:
- Catalog — renders a catalog JSON (default: the daybrite samples catalog; overridable
in settings). Each entry: icon, name, description, version, and the permission list with
reasons. Install → disclosure sheet →
confirm. - My apps — installed grid (tile per app), update-available badges (
check_updateon launch), open / update / revoke-permission / uninstall. - Add by URL — paste any origin (https prefix or local path) to install straight from a repo; this is also the dev loop for miniapp authors (point it at a working tree, edits apply on next launch).
Running apps present in a fullscreen cover (docs/cover.md) with the X affordance;
window.background_color seeds the cover background.
13. Build integration
rquickjs (bundled quickjs C) and rusqlite’s bundled sqlite3 cross-compile through each
platform’s NDK. The day CLI supplies, per target, what their build scripts need, the
same env it already curates for cc:
- iOS: the
bindgenfeature (rquickjs ships no prebuilt bindings foraarch64-apple-ios-sim). - Android/OHOS additionally:
BINDGEN_EXTRA_CLANG_ARGS_<target>=--sysroot=<ndk sysroot> --target=<triple>(bindgen’s libclang doesn’t inherit the CC sysroot) andAR_<target>= the NDK’sllvm-ar(the host Darwinarsilently produces an empty archive →JS_Callundefined at link).
14. Security considerations
- Miniapp JS is untrusted: no filesystem outside the sandbox, no process/env access, no dynamic native loading. The bridge surface is the entire capability set.
- Network is doubly confined: permission +
net_originsprefix allow-list. - Remote code is cached then executed: installs record per-file hashes; the update path re-verifies before swap. Origins are https-or-local only.
- A miniapp’s context is dropped on exit; runaway scripts are bounded by QuickJS’s interrupt handler (a main-thread watchdog cancels evaluation after a budget).
- Superapps choose their exposure: an app compiled without a sensors bridge simply has no sensors capability to grant.