Skip to main content

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 is a cross-platform UI stack built on one principle: one Rust core, framework-specific JS adapters, one layout engine everywhere. The Rust core — 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.tsx  (Solid or Vue Vapor + Tailwind-subset classes)

           │  framework JSX transform   (two-pass build)

   ┌────────────────────────────────────────────────────┐
   │  bundle.js   +   styles.bin + font atlases + images │
   │      │                       │                      │
   │      │                       └──────► app.pak       │
   └──────┼─────────────────────────────────────────────┘

   ┌──────┴───────────────────┐   ┌──────────────────────────────┐
   │  QuickJS (PSP/PPSSPP)    │   │  browser engine / Bun        │
   │    framework runtime     │   │    framework runtime         │
   │      │ ui.* ops          │   │      │ same ui.* ops         │
   │      ▼                   │   │      ▼                       │
   │  pocketjs-core           │   │  pocketjs-core               │
   │  (Rust, no_std)          │   │  (same Rust → wasm32)        │
   │  tree · taffy · anim     │   │  tree · taffy · anim         │
   │  · style · text          │   │  · style · text              │
   │      │ DrawList           │   │      │ DrawList              │
   │      ▼                   │   │      ▼                       │
   │  sceGu backend (GE)      │   │  software rasterizer         │
   └──────────────────────────┘   │    → canvas / PNG golden     │
                                  └──────────────────────────────┘
Reading it top to bottom:
  1. app.tsx is ordinary JSX: View, Text, and Image from @pocketjs/framework/components, state from solid-js or vue, and class strings from the Tailwind subset.
  2. 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 to styles.bin and baked font atlases, packs everything into app.pak, then bundles the transformed JS to bundle.js with Bun.
  3. At runtime, the framework runtime (Solid or Vue Vapor) executes on the host’s JS engine and emits mutation ops (ui.*) into pocketjs-core.
  4. pocketjs-core owns 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.
  5. A thin backend turns the DrawList into pixels: sceGu (the PSP’s Graphics Engine) on hardware, or a deterministic software rasterizer in wasm32 for the browser canvas and for byte-exact PNG goldens.
Everything above the backend is identical across targets. The layout you see in the browser playground is computed by the same code, from the same Rust binary, as on the handheld.

Host Table

HostJS engineRust buildBackend
PSP hardwareQuickJS (Bellard 2025, ~ES2023)mipsel-sony-pspsceGu (the GE)
PPSSPPQuickJSmipsel-sony-pspsceGu, run headless for e2e frame goldens
BrowserThe browser’s own enginewasm32-unknown-unknownDeterministic software rasterizer → 480×272 canvas
Headless BunBunwasm32-unknown-unknownSame rasterizer → byte-exact PNG goldens
The core is compiled exactly twice — once per target. The PPSSPP host uses the same MIPS EBOOT as real hardware; PPSSPP runs it headless to produce frame goldens for the e2e test suite, stamped with the PPSSPP build commit. The browser and Bun hosts share one 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:
ModuleResponsibility
src/lib.rsPublic Ui struct: apply_ops(), tick(1/60), draw() → &DrawList
src/tree.rsNode arena: Vec<Node> + free list + generation counter. Stale handles are safe no-ops.
src/style.rsStyle table parse and resolve; base / focus: / active: variant blocks
src/layout.rstaffy 0.11 sync, text-measure closures, dirty tracking; empty text nodes excluded from the taffy tree
src/text.rsAtlas registry, cmap (miss → gid 0 tofu + miss counter), glyph measurement, inline-run layout
src/anim.rsTween / spring tracks; transitions on style swap; fixed dt = 1/60 s
src/draw.rsTree walk → DrawList + CPU clip stage: axis-aligned clip with UV/color re-interpolation; rotated quads Sutherland–Hodgman-clipped or culled
src/spec.rsGenerated from spec/spec.ts — op codes, prop IDs, enums, format constants
The CPU clip stage in 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.
# build the MIPS EBOOT
bun scripts/psp.ts hero
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.
# build the wasm32 core
bun scripts/wasm.ts
# or as part of the dev server:
bun scripts/dev.ts
One layout engine, one animation clock, one text layouter — compiled twice, never reimplemented per platform.

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:
interface NodeMirror {
  id: number;          // generation-tagged native handle
  parent: NodeMirror | null;
  children: NodeMirror[];
  // ... style cache, event registrations
}
The mirror exists for one reason: reconciler reads never cross the FFI boundary. Solid reads 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 a styleId via src/styles.ts, then calls ui.setStyle
  • on* → registers in the input system (src/input.ts)
  • src → registers in the texture registry
  • style={{…}} object → per-key propId lookup, previous-value diffed, calls ui.setProp
  • Anything unrecognized → loud dev-time error, not a silent no-op
Node reclamation uses a two-tier strategy. At end-of-frame, 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:
sceCtrlRead


sceGuStart                  ← display list opened


JS frame(buttons)           ← frame hooks + input edge-detect + onPress → Solid effects


drain jobs                  ← while JS_ExecutePendingJob(rt, &mut ctx) > 0
  │                           (Solid microtasks; queueMicrotask polyfilled via Promise)

renderer sweep              ← destroy subtrees still detached after this frame [R]


core.tick(1/60)             ← advance animation tracks; layout if dirty


DrawList


ge::render                  ← DrawList → sceGu draw calls (bump vertex arena)


sceGuFinish / sceGuSync     ← GE finishes


sceDisplayWaitVblankStart   ← wait for vblank


sceGuSwapBuffers            ← flip
The renderer sweep runs inside the JS frame turn so that Solid effects triggered by input (which may re-attach a just-removed subtree) run before the sweep decides what to destroy. Backends never call 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, but rust-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|VFPU worker — 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 “8 MB” figure in PocketJS documentation refers to the application arena with safety headroom — not the PSP’s full 32 MB system RAM. Code and embedded .pak/JS bytes live in the EBOOT image, the worker stack is separate, and PSP display framebuffers are in VRAM.

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 codescreateNode, 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
  • EnumsNodeType, 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.
# regenerate core/src/spec.rs
bun spec/gen-rust.ts

# run the drift guard
bun test/contract.ts
Op codes are append-only and never renumbered. A spec is versioned by adding to the end; existing codes and IDs are stable forever.

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:
  1. Run the Babel transform: the framework JSX preset (babel-preset-solid with generate: 'universal' for Solid; vue-jsx-vapor for Vue Vapor) plus @babel/preset-typescript. Transforms are content-hash cached in .cache/.
  2. Collect, from the AST: (a) candidate class strings from StringLiteral nodes, and (b) text codepoints from StringLiteral and TemplateLiteral quasis — including JSX text children, which Babel compiles to template literals. Class strings and codepoints are never extracted with regex over source text.
  3. 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.
  4. Write styles.bin + styles.generated.ts (the STYLE_IDS map, excluded from future scans).
  5. Bake Inter font atlases for exactly the collected charset (plus ASCII always, plus --extra-chars).
  6. Pack styles.bin + atlases + images → dist/<app>.pak.
Pass 2 — bundle. 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.
bun scripts/build.ts hero             # Solid (default from pocket.config.ts)
bun scripts/build.ts hero --framework=vue-vapor
bun scripts/build.ts hero --extra-chars="0123456789€"

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 .pak format 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.
  • Animationanimate(), spring(), transition-* classes, and the fixed-dt animation clock.

Build docs developers (and LLMs) love