PocketJS is a cross-platform UI stack built on one principle: one Rust core, framework-specific JS adapters, one layout engine everywhere. The Rust core —Documentation Index
Fetch the complete documentation index at: https://mintlify.com/pocket-stack/pocketjs/llms.txt
Use this file to discover all available pages before exploring further.
pocketjs-core — is a platform-agnostic #![no_std] library that owns the retained node tree, flexbox layout, style resolution, animation tracks, baked text, and DrawList generation. It is compiled twice: once to bare-metal MIPS for the PSP EBOOT, once to wasm32-unknown-unknown for the browser dev host and headless Bun tests. The JavaScript side — Solid or Vue Vapor, your choice — drives it through a narrow, mutation-only op surface. This page explains the full pipeline, the host table, each layer’s responsibilities, and the constraints that make it all work on 333 MHz hardware with an 8 MB memory budget.
The Full Pipeline
app.tsxis ordinary JSX:View,Text, andImagefrom@pocketjs/framework/components, state fromsolid-jsorvue, andclassstrings from the Tailwind subset.- The two-pass build (
bun scripts/build.ts <app>) runs the framework JSX transform over every reachable module (cached by content hash), collects class strings and text codepoints from the AST, compiles them tostyles.binand baked font atlases, packs everything intoapp.pak, then bundles the transformed JS tobundle.jswith Bun. - At runtime, the framework runtime (Solid or Vue Vapor) executes on the host’s JS engine and emits mutation ops (
ui.*) intopocketjs-core. pocketjs-coreowns the retained node tree: it runs flexbox layout via taffy, resolves styles, ticks animation tracks, measures and lays out text, and produces a flat DrawList each frame.- A thin backend turns the DrawList into pixels:
sceGu(the PSP’s Graphics Engine) on hardware, or a deterministic software rasterizer inwasm32for the browser canvas and for byte-exact PNG goldens.
Host Table
| Host | JS engine | Rust build | Backend |
|---|---|---|---|
| PSP hardware | QuickJS (Bellard 2025, ~ES2023) | mipsel-sony-psp | sceGu (the GE) |
| PPSSPP | QuickJS | mipsel-sony-psp | sceGu, run headless for e2e frame goldens |
| Browser | The browser’s own engine | wasm32-unknown-unknown | Deterministic software rasterizer → 480×272 canvas |
| Headless Bun | Bun | wasm32-unknown-unknown | Same rasterizer → byte-exact PNG goldens |
wasm32 binary, which is why their goldens are byte-exact.
pocketjs-core Responsibilities
core/ is a #![no_std] + alloc Rust library. It has no I/O, no graphics API calls, and no timing. Every module has a narrow, well-defined job:
| Module | Responsibility |
|---|---|
src/lib.rs | Public Ui struct: apply_ops(), tick(1/60), draw() → &DrawList |
src/tree.rs | Node arena: Vec<Node> + free list + generation counter. Stale handles are safe no-ops. |
src/style.rs | Style table parse and resolve; base / focus: / active: variant blocks |
src/layout.rs | taffy 0.11 sync, text-measure closures, dirty tracking; empty text nodes excluded from the taffy tree |
src/text.rs | Atlas registry, cmap (miss → gid 0 tofu + miss counter), glyph measurement, inline-run layout |
src/anim.rs | Tween / spring tracks; transitions on style swap; fixed dt = 1/60 s |
src/draw.rs | Tree walk → DrawList + CPU clip stage: axis-aligned clip with UV/color re-interpolation; rotated quads Sutherland–Hodgman-clipped or culled |
src/spec.rs | Generated from spec/spec.ts — op codes, prop IDs, enums, format constants |
draw.rs is worth calling out: it guarantees that no negative or oversized coordinates ever reach a backend. Axis-aligned quads are clipped with UV and color re-interpolation; rotated quads are Sutherland–Hodgman-clipped, or culled entirely if they fall outside the viewport. Backends receive only well-formed draw commands.
The node arena uses generation-tagged handles. A node ID is (generation << 20) | slot. When a node is destroyed and the slot is reused, the generation counter increments. Any op referencing the old ID silently no-ops — no use-after-free on a device with no memory protection.
How the Rust Core Is Compiled Twice
core/ is platform-agnostic. Two thin wrappers give it a body for each target class:
pocketjs-psp (native/) — the PSP EBOOT. It embeds QuickJS, exposes ui.* ops to the guest via src/ffi.rs, and renders the DrawList through src/ge.rs (the sceGu backend). build.rs embeds the app JS and app.pak as include_bytes! so the EBOOT is self-contained.
pocketjs-wasm (wasm/) — a wasm32-unknown-unknown cdylib. It mirrors the ui.* op set through extern "C" functions and renders the DrawList to an RGBA8 480×272 framebuffer via src/raster.rs. There is no wasm-bindgen; the host side calls the C ABI directly.
The JS Mirror Tree
The Solid universal renderer (src/renderer.ts) and the Vue Vapor adapter maintain a lightweight JavaScript mirror of the native tree. Each JS node looks roughly like:
children[] from JS objects; Vue Vapor’s reconciler does the same. Only mutations cross into native — createNode, insertBefore, setStyle, setText, etc. This keeps the reconciler fast on QuickJS, which has no JIT and runs on a 333 MHz MIPS chip.
setProperty in src/renderer.ts runs through a dispatch table:
className→ resolves to astyleIdviasrc/styles.ts, then callsui.setStyleon*→ registers in the input system (src/input.ts)src→ registers in the texture registrystyle={{…}}object → per-keypropIdlookup, previous-value diffed, callsui.setProp- Anything unrecognized → loud dev-time error, not a silent no-op
src/renderer.ts runs a sweep that destroys any subtree that was removed from the native tree during the frame and not re-attached (Solid’s <For> reorder, for example, removes and re-inserts — the sweep only destroys what is still detached). FinalizationRegistry acts as a backstop for nodes that escape the sweep. The retain() / release() escape hatch is available for nodes that must outlive their JSX parent.
insertBefore has DOM move semantics: if the child is already attached anywhere, the core unlinks it from its current parent before inserting it at the new position — in the native tree, the taffy tree, and the JS mirror simultaneously. Append when anchor = 0.Frame Order on PSP
One complete frame on PSP hardware, from input to scanout:sceGuStart or sceGuFinish — the display list is owned by src/main.rs.
On browser and Bun hosts, the equivalent is a requestAnimationFrame callback (or a fixed-step Bun timer). The JS turn, the sweep, and core.tick() happen in the same order.
Memory on PSP
The PSP has 32 MB of system RAM, butrust-psp’s default #[global_allocator] makes one kernel object per allocation. That caps out at roughly 4096 kernel objects — nowhere near enough for a real UI tree (taffy’s internal slotmaps, children Vecs, per-pass .collect()s, and the DrawList all allocate through the global allocator).
PocketJS installs its own global allocator (native/src/alloc.rs) backed by a single arena (native/src/arena.rs). The arena calls sceKernelAllocPartitionMemory directly in ensure_init — with no recursion through alloc::alloc, since the arena is the global allocator. QuickJS, pocketjs-core, and texture uploads all share that single kernel block.
JS runs on the 2 MB USER|VFPU worker thread. A 2 MB margin is maintained for the GE display list and stack safety. Per-frame vertex data is written into a bump pool of Vec<Chunk16> pages allocated at boot and reset after sceGuSync — never reuse a region within a frame, because the GE reads asynchronously in Direct mode.
Key memory rules:
- 8 MB total arena (including the 2 MB GE + stack margin)
- 2 MB
USER|VFPUworker — JS + QuickJS heap live here - Main stack: 256 KB
- GE vertex coords:
i16— the CPU clip stage guarantees in-range values - Textures:
pow2,≤ 512px, sampled from main RAM size_t = usize(MIPS o32)- JS bundle: NUL-terminated, evaluated with
len - 1
The spec/spec.ts Single Source of Truth
spec/spec.ts is the single source of truth for everything that crosses the JS/Rust boundary:
- Op codes —
createNode,destroyNode,insertBefore,removeChild,setStyle,setProp,setText,replaceText,uploadTexture,setImage,setSprite,animate,cancelAnim,setFocus,loadStyles,loadFontAtlas,measureText - Prop IDs — numeric identifiers for every animatable and settable property
- Enums —
NodeType,Easing,PosType,Overflow,Align,Justify, … - Style-table format — binary layout of
styles.bin: base style records, variant blocks, per-property encoding - Atlas format — cell dimensions, advance table, cmap, coverage byte layout
- DrawList format — command opcodes, quad types, gradient encoding
- Pak container constants — magic bytes, header layout, entry alignment, FNV-1a hash
spec/gen-rust.ts generates core/src/spec.rs from spec.ts. That generated file is committed. test/contract.ts regenerates it in memory and byte-compares it against the committed version — if spec.ts and core/src/spec.rs ever diverge, CI fails immediately.
The Two-Pass Build in Detail
scripts/build.ts <app> is two passes to break a dependency cycle: the Tailwind compiler and font baker need to see all class strings and text codepoints before they can generate styles.bin and styles.generated.ts, but Bun needs styles.generated.ts to exist before it can bundle.
Pass 1 — transform and collect. For every .tsx / .ts source reachable from the app entry:
- Run the Babel transform: the framework JSX preset (
babel-preset-solidwithgenerate: 'universal'for Solid;vue-jsx-vaporfor Vue Vapor) plus@babel/preset-typescript. Transforms are content-hash cached in.cache/. - Collect, from the AST: (a) candidate class strings from
StringLiteralnodes, and (b) text codepoints fromStringLiteralandTemplateLiteralquasis — including JSX text children, which Babel compiles to template literals. Class strings and codepoints are never extracted with regex over source text. - Validate collected class strings: a literal becomes a style record if and only if every whitespace-separated token parses as a supported utility. Invalid tokens drop the whole literal.
- Write
styles.bin+styles.generated.ts(theSTYLE_IDSmap, excluded from future scans). - Bake Inter font atlases for exactly the collected charset (plus ASCII always, plus
--extra-chars). - Pack
styles.bin+ atlases + images →dist/<app>.pak.
Bun.build with an onLoad plugin that serves the cached pass-1 transforms. styles.generated.ts now exists and is importable. Format: "iife", unminified, target: "browser". Output: dist/<app>.js.
Where to Go Next
- Quickstart — build and run your first component in the browser dev host.
- Native contract — the full
ui.*op table, node lifecycle, generation-tagged handles, and per-frame ordering. - Build pipeline — two-pass build, style compilation, font baking, and the
.pakformat in detail. - Frameworks — Solid and Vue Vapor selection, import paths, and output naming conventions.
- Reactivity — how Solid signals and effects behave in the universal renderer on QuickJS.
- Animation —
animate(),spring(),transition-*classes, and the fixed-dt animation clock.