RunningDocumentation 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.
bun scripts/build.ts hero produces exactly two files: dist/hero.js (the JavaScript bundle) and dist/hero.pak (compiled styles, baked font atlases, and images in a single binary container). Those two files are everything a PocketJS host needs — the same pair loads on real PSP hardware, in PPSSPP, in the browser dev host, and in headless Bun. Nothing else is required at runtime.
The build is two passes over the same module graph. This matters because the style compiler needs to know every class string across every module before it can write styles.generated.ts, and the bundle in turn needs to import that generated file. A single pass would create a scan cycle: the bundle can’t start until the style table exists, but the style table can’t be built until the bundle’s imports are known. Two passes with a shared content-hash cache solve this cleanly and keep the expensive Babel work from running more than once per file.
Why two passes
Pass 1 traverses every source file reachable from the entry, runs the Babel JSX+TypeScript transform on each one, and — in the same traversal — records every candidate class string and every text codepoint from the AST. Those collections feed the style compiler, font baker, and pak assembler, which together producestyles.generated.ts and <app>.pak. Pass 2 then runs Bun.build using the cached pass-1 transform output, so styles.generated.ts is already present when the bundler tries to import it.
Build commands
build.ts can be a file path or a bare name. Bare names resolve under demos/: hero finds demos/hero/app.tsx, and a name ending in -main finds demos/hero/main.tsx. A demo typically has app.tsx (the exported component) and main.tsx (a tiny file that calls mount()); build hero-main when you want a self-mounting, runnable bundle.
| Flag | Effect |
|---|---|
--framework=solid|vue-vapor | Select the framework, overriding pocket.config.ts. |
--config=<path> | Load a different Pocket config file. |
--no-config | Ignore pocket.config.ts; defaults to Solid unless --framework is set. |
--extra-chars=<string> | Force extra codepoints into every baked atlas, on top of the collected charset and ASCII. |
--outdir=<path> | Write <app>.js and <app>.pak to a custom directory instead of dist/. |
Output naming
| Entry | Outputs in dist/ | Notes |
|---|---|---|
demos/hero/app.tsx | hero.js, hero.pak | the app component |
demos/hero/main.tsx | hero-main.js, hero-main.pak | the mounted entry — calls mount() |
foo/bar.tsx | bar.js, bar.pak | non-demo path: basename |
--framework=vue-vapor | <name>.vue-vapor.js, <name>.vue-vapor.pak | Vue Vapor artifacts coexist with Solid artifacts |
Pass 1 — transform and collect
Pass 1 starts at the entry file and walks its import graph. For each.tsx or .ts module it calls transformFile(path, src), which runs Babel and — in the same AST traversal — harvests two things later stages need.
The JSX transform
The selected framework determines the JSX transform applied to every file:src/renderer-solid.ts. Vue Vapor compiles JSX with vue-jsx-vapor and bundles against src/renderer-vue-vapor.ts plus the small DOM facade needed by Vue’s Vapor helpers. Both paths also run @babel/preset-typescript to strip types.
The moduleName passed to babel-preset-solid must be the absolute filesystem path to the renderer — it is emitted verbatim into every import the preset generates, so a relative path or alias would break the bundle.
What gets collected
While the pristine AST is still in the author’s shape — before the framework JSX lowerer rewrites subtrees — a collector visitor records:- Candidate class strings: every
StringLiteralvalue, everyTemplateLiteralquasi (the static chunks), and everyJSXTextrun. The collector reads real AST nodes and never regexes over quote characters. Whether a given string is actually a class literal is decided later bytailwind.ts— a string like"Loading…"is collected as a candidate and simply fails to parse as a set of utilities, so it is silently dropped. - Text codepoints: every codepoint appearing in any collected literal. This is the charset input for the font baker — if a character appears anywhere in a string literal, template quasi, or JSX text, its glyph gets baked into the atlas.
Build-time lints
Some patterns are silently broken on the PSP or are fundamentally incompatible with build-time styling, so the transform throws with a code frame rather than miscompiling:| Lint | Why it errors |
|---|---|
classList={…} attribute | Not supported in v1. Use ternaries of full class literals: class={cond ? "p-2 bg-red-500" : "p-2 bg-slate-700"} |
class={\a $`}` (interpolated class) | Styles compile at build time; a dynamically interpolated fragment cannot be resolved to a styleId. |
import { createResource, useTransition, startTransition } from "solid-js" | The PSP QuickJS host has no scheduler — these cannot run there. Use signals + createEffect, or animate() for motion. |
HTML entities in JSX text (é) | The universal codegen emits raw text, so the entity would render literally. Write the literal character or a string expression instead. |
The transform cache
Each transform result is cached in.cache/transforms/, keyed by a SHA-256 hash of the file contents plus the toolchain identity: the selected framework, the versions of babel-preset-solid, @babel/core, and @babel/preset-typescript, the renderer path, and an internal cache version constant. Bumping any dependency or changing the renderer path invalidates cached output automatically.
Because pass 2 loads through the same transformFile function, the expensive Babel work runs once per file per content change — pass 2 gets the cached output for free.
The walker skips *.generated.ts files entirely so the compiler-generated styles module never feeds its own synthetic class literals back into the scan.
A typical pass 1 summary:
Style compilation
The collected class strings go tocompileClasses() in compiler/tailwind.ts. Each candidate literal compiles to a style record if and only if every whitespace-separated token parses as a supported utility; otherwise the literal is silently ignored (it was ordinary text, not a class string). A string like "PocketJS" is collected, fails to parse as utilities, and is dropped with no error.
Two literals that produce byte-identical style records share a single styleId, so "p-2 bg-slate-700" and "bg-slate-700 p-2" cost exactly one record in the table. The compiler emits:
styles.bin— the encoded style table, packed into the pak asui:styles.src/styles.generated.ts— a TypeScript module the renderer imports at bundle time, mapping each source class literal to itsstyleId, plus the font-slot metadata and total record count.
rounded-fullon a literal that does not also include bothw-Nandh-N. The corner radius must be build-time computable; without pinned dimensions it cannot be.- Any
hover:variant. The PSP has no pointer device — usefocus:oractive:instead.
Font baking
bakeAtlases() bakes one Inter font atlas per slot referenced by the compiled styles. A slot is a pinned (size, weight) pair: sizes 12/14/16/18/20/24/36 px, both regular and bold, assigned by the text-* and font-bold utilities. The 16 px regular slot is always included as the default.
The charset baked into every slot is the union of:
- ASCII 32–126, always, so basic text never depends on the scan output.
- The codepoints collected in pass 1 (printable, excluding DEL).
- Any codepoints added via
--extra-chars.
Image gathering
Any collected string literal that looks like a filename ending in.png or .svg is treated as an image reference — the same string that appears in <Image src="logo.png" />. For each name the build searches, in order:
- Next to the app entry file
assets/images/<name>assets/<name>
ui:img.<name> entry. Texture dimensions must be power-of-two and within the hardware limit.
Pak assembly
All binary output is packed into one container,dist/<app>.pak, in the dreamcart .pak format (so existing tooling can open PocketJS packs). PocketJS uses three families of entry keys:
| Key | Contents |
|---|---|
ui:styles | styles.bin — the compiled style table |
ui:font.<slot> | one baked font atlas per used slot |
ui:img.<name> | one RGBA texture per referenced image |
ui:sprite.<name> | one animated sprite atlas per sprite manifest entry |
native/src/pak.rs feeds styles and atlases directly to the Rust core from include_bytes! before QuickJS evaluates the JS bundle — zero bytes of pak data ever touch the JS heap. The web and headless-Bun hosts load them via loadStyles/loadFontAtlas ops instead.
Pass 2 — bundle
Withstyles.generated.ts now written, pass 2 runs Bun.build:
onLoad hook intercepts every project .ts/.tsx file and serves the cached pass-1 transform. node_modules and .d.ts files fall through to Bun. The bundle is therefore built from exactly the code the class/charset scan saw — the two passes agree on the module graph by construction.
Three settings deserve explanation:
format: "iife"— a single self-contained script, the shape QuickJS evaluates on the PSP.conditions: ["browser"]— forces browser-runtime exports for framework packages. For Solid, thenodecondition would pulldist/server.jswhere reactive updates silently no-op; Bun’s defaultdevelopmentcondition can also pull dev builds and duplicate runtimes.minify: false— the bundle ships tree-shaken but unminified. Base64 blobs embedded in JS are a known QuickJS boot performance killer, which is why all binary assets live in the pak instead of being inlined.
PSP EBOOT build flow
scripts/psp.ts wraps build.ts and then invokes cargo psp with the full cross-compilation environment. The toolchain requires:
- Rust nightly
nightly-2026-05-28 cargo-pspllvm-arfrom LLVM (Homebrew LLVM on macOS;brew install llvm)- A PSP SDK (
mipsel-sony-psp/) — setPSP_SDKor place it next to the PocketJS checkout.
Build the JS bundle and pak
bun scripts/build.ts <app> runs both passes and produces dist/<app>.js and dist/<app>.pak.Set the cross-compilation environment
scripts/psp.ts assembles the env block: PATH prepended with the LLVM bin directory, TARGET_CFLAGS with -target mipsel-sony-psp -mcpu=mips2 -msingle-float -mno-abicalls, AR_mipsel_sony_psp=llvm-ar, RUST_PSP_TARGET, RUST_PSP_ABORT_ONLY=1, and POCKETJS_APP=<app> (consumed by native/build.rs to embed the bundle via include_bytes!).Run cargo psp via rustup
native/build.rs embeds dist/<app>.js and dist/<app>.pak into the binary at compile time. The pak is fed to the Rust core before QuickJS evaluates the bundle.The browser Playground
The Playground runs this exact pipeline live in the browser: it transforms and collects, compiles the Tailwind subset, bakes atlases, packs a pak, and bundles — then loads the result into the WebAssembly build of the core and renders to a canvas. There is no separate web toolchain. Edit the code, rebuild, and the same.js + .pak pair that a PSP would run is what draws in the preview.