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.

Running 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 produce styles.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 one app (required argument)
bun scripts/build.ts <app> [--framework=solid|vue-vapor] [--extra-chars=...] [--outdir=<path>]

# Build one or more demos and start the browser dev server
bun scripts/dev.ts [app-names...] [--framework=...]

# Build the WebAssembly core
bun scripts/wasm.ts

# Full PSP EBOOT build (build.ts + cargo psp)
bun scripts/psp.ts <app> [--framework=...] [--release]
The app argument to 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.
FlagEffect
--framework=solid|vue-vaporSelect the framework, overriding pocket.config.ts.
--config=<path>Load a different Pocket config file.
--no-configIgnore 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/.
# Force arrow and check codepoints into every font atlas
bun scripts/build.ts settings --extra-chars="←→↑↓✓✕"

Output naming

EntryOutputs in dist/Notes
demos/hero/app.tsxhero.js, hero.pakthe app component
demos/hero/main.tsxhero-main.js, hero-main.pakthe mounted entry — calls mount()
foo/bar.tsxbar.js, bar.paknon-demo path: basename
--framework=vue-vapor<name>.vue-vapor.js, <name>.vue-vapor.pakVue 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:
// Solid: babel-preset-solid in universal mode
[solidPreset, { generate: "universal", moduleName: RENDERER_SOLID_PATH }]
// @babel/preset-typescript strips types in the same pass

// Vue Vapor: vue-jsx-vapor + @babel/preset-typescript
transformVueJsxVapor(source, path)
Solid compiles JSX into calls against 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 StringLiteral value, every TemplateLiteral quasi (the static chunks), and every JSXText run. The collector reads real AST nodes and never regexes over quote characters. Whether a given string is actually a class literal is decided later by tailwind.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.
Traversal happens before the JSX lowerer so the collector sees exactly what the developer wrote, not the synthetic identifiers and renderer-path strings that Babel’s output would inject.

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:
LintWhy it errors
classList={…} attributeNot 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 (&eacute;)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:
PocketJS build: hero (/…/demos/hero/app.tsx)
  pass 1: 7 module(s), 42 candidate literal(s), 96 codepoint(s)

Style compilation

The collected class strings go to compileClasses() 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 as ui:styles.
  • src/styles.generated.ts — a TypeScript module the renderer imports at bundle time, mapping each source class literal to its styleId, plus the font-slot metadata and total record count.
// AUTO-GENERATED by PocketJS compiler/tailwind.ts — DO NOT EDIT.
export const STYLE_IDS: Record<string, number> = {
  "flex flex-col gap-2 p-4 bg-slate-800": 0,
  "text-lg font-bold text-slate-100": 1,
  // …
};
export const STYLE_COUNT = 18;
export const FONT_SLOTS: Record<number, { px: number; bold: boolean }> = {
  2: { px: 16, bold: false },
  9: { px: 16, bold: true },
  // …
};
export const DEFAULT_FONT_SLOT = 2;
Two patterns produce a hard compile error rather than being silently dropped — even though their tokens otherwise parse:
  • rounded-full on a literal that does not also include both w-N and h-N. The corner radius must be build-time computable; without pinned dimensions it cannot be.
  • Any hover: variant. The PSP has no pointer device — use focus: or active: instead.
  tailwind: 18 style record(s), 23 literal(s) -> src/styles.generated.ts

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.
Codepoints the Inter font does not map are left out. The Rust core resolves a cmap miss to glyph 0 — a hollow “tofu” box — at runtime and increments a miss counter. Each atlas is horizontally supersampled 8-bit coverage cells plus proportional advances and a cmap.
  font: slot 2 (16px) 96 glyphs, cell 10x19, 18240 bytes
  font: slot 9 (16px bold) 96 glyphs, cell 11x19, 20064 bytes

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:
  1. Next to the app entry file
  2. assets/images/<name>
  3. assets/<name>
A found PNG is decoded (8-bit RGB/RGBA/grayscale, non-interlaced; palette, 16-bit, and interlaced PNGs are rejected with a clear error). A found SVG is rasterized. If nothing is found, a 32×32 checkerboard placeholder is baked so the build still succeeds, with a warning. Each image is packed as a ui:img.<name> entry. Texture dimensions must be power-of-two and within the hardware limit.
  image: logo.png <- /…/demos/hero/logo.png (128x64)
If you need sprites (animated atlas images), add a sprites.json manifest next to your app entry. Any image name listed there is baked as a ui:sprite.<name> entry with frameCount, cols, and frameStep fields instead of a static ui:img.* entry.

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:
KeyContents
ui:stylesstyles.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
Entries are sorted by key and 16-byte aligned. The container header holds the magic value, version, entry count, and offsets to the directory, name table, and data region. Entry keys are FNV1a-hashed for fast lookup. On the PSP, 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.
  pak: 4 entries, 20480 bytes -> dist/hero.pak

Pass 2 — bundle

With styles.generated.ts now written, pass 2 runs Bun.build:
Bun.build({
  entrypoints: [entry],
  naming: `${outName}.js`,
  format: "iife",
  target: "browser",
  conditions: ["browser"],
  define: { "process.env.NODE_ENV": '"production"' },
  minify: false,
  sourcemap: "none",
  plugins: [jsxPlugin(framework, { entry })],
});
The plugin’s 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, the node condition would pull dist/server.js where reactive updates silently no-op; Bun’s default development condition 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.
  pass 2: dist/hero.js (128000 bytes)
PocketJS build: done

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-psp
  • llvm-ar from LLVM (Homebrew LLVM on macOS; brew install llvm)
  • A PSP SDK (mipsel-sony-psp/) — set PSP_SDK or place it next to the PocketJS checkout.
Using llvm-ar is critical. Apple’s system ar silently drops MIPS object files from archives, leading to undefined JS_* linker errors. The env block explicitly sets AR_mipsel_sony_psp=llvm-ar to prevent this.
1

Build the JS bundle and pak

bun scripts/build.ts <app> runs both passes and produces dist/<app>.js and dist/<app>.pak.
2

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!).
3

Run cargo psp via rustup

rustup run nightly-2026-05-28 cargo psp --bin pocketjs-psp
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.
4

Rename the output

cargo psp produces target/mipsel-sony-psp/<profile>/pocketjs-psp.EBOOT.PBP. scripts/psp.ts copies it to target/mipsel-sony-psp/<profile>/EBOOT.PBP — the conventional PSP filename.
Full PSP build invocation:
bun scripts/psp.ts hero --release
# -> native/target/mipsel-sony-psp/release/EBOOT.PBP

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.

Build docs developers (and LLMs) love