PocketJS

Build pipeline

One command turns a PocketJS app into two files:

bun scripts/build.ts hero

produces dist/hero.js (the bundle) and dist/hero.dcpak (styles, font atlases, and images packed into a single binary container). Those two artifacts are everything a host needs — the same pair loads on real PSP hardware, in PPSSPP, in the browser dev host, and in headless Bun.

The build is two passes over the same module graph. Pass 1 transforms every reachable source file and, in the same traversal, collects the class strings and text codepoints the app actually uses — so styles and fonts can be compiled for exactly that set. Pass 2 bundles, reusing the cached pass‑1 output. This page walks through both.

Invoking the build

The one required argument is the app to build. It can be a path or a bare name:

bun scripts/build.ts demos/hero/app.tsx     # explicit path
bun scripts/build.ts hero                    # bare name -> demos/hero/app.tsx
bun scripts/build.ts hero-main               # the mounted entry (demos/hero/main.tsx)

A bare name resolves against demos/: hero finds demos/hero/app.tsx, and a name ending in -main finds demos/hero/main.tsx. There is exactly one flag:

Flag Effect
--extra-chars=<string> Force these codepoints into every baked atlas, on top of the collected charset and ASCII.
bun scripts/build.ts settings --extra-chars="←→↑↓✓✕"

Output naming

The output name is derived from the entry path, and both artifacts share it:

Entry dist/ outputs Notes
demos/hero/app.tsx hero.js, hero.dcpak the app component
demos/hero/main.tsx hero-main.js, hero-main.dcpak the mounted entry — calls mount()
foo/bar.tsx bar.js, bar.dcpak non‑demo path: basename

A demo typically has app.tsx (the exported UI) and main.tsx (a tiny file that imports the app and mounts it). You build hero-main when you want a runnable, self‑mounting bundle; you build hero to bundle the component on its own. See Components and App shell for what those entries contain.

Pass 1 — transform & collect

Pass 1 starts at the entry file and walks its import graph. For each .tsx/.ts module it calls transformFile(path, src), which runs Babel and, in the same AST traversal, harvests two things the later stages need.

The Babel transform

Every source file goes through two presets, in this order (Babel runs presets last‑to‑first, so TypeScript strips types first, then Solid compiles the JSX):

presets: [
  [solidPreset, { generate: "universal", moduleName: RENDERER_PATH }],
  [tsPreset,    { isTSX: true, allExtensions: true }],
]

babel-preset-solid with generate: "universal" compiles JSX into calls against the universal renderer (createElement, insertNode, setProp, …) rather than DOM operations. moduleName is the absolute path to src/renderer.ts — the preset emits it verbatim into every generated import, so it must be absolute. @babel/preset-typescript handles the type stripping.

What it collects

While the pristine AST is still in the author's shape (before the Solid preset rewrites JSX subtrees), a collector visitor records:

  • Candidate class strings — every StringLiteral value, every TemplateLiteral quasi (the static chunks), and every JSXText run. It never regexes over quotes; it reads real AST nodes. It does not decide what is a class here — tailwind.ts does that later. A string like "Loading…" is collected as a candidate and simply fails to parse as a utility, so it is dropped.
  • Text codepoints — every codepoint of those same literals. This is the charset input for the font baker: if a character appears anywhere in a string, template chunk, or JSX text, its glyph gets baked.

Build‑time lints

Some patterns can't work on the PSP or don't fit the build‑time styling model, so the transform throws with a code frame rather than silently miscompiling:

Lint Why
classList={…} attribute Not supported (v1). Use ternaries of full class literals.
class={a ${b}} (interpolated class) Styles compile at build time; an interpolated fragment can't be resolved to a styleId.
import { createResource, useTransition, startTransition } from "solid-js" The PSP QuickJS host has no scheduler — these can't run there. Use signals + createEffect, or animate().
HTML entities in JSX text (&eacute;) The universal codegen emits raw text, so the entity would render literally. Write the actual character or a string expression.

The transform cache

Each transform result is cached in .cache/transforms/, keyed by a SHA‑256 of the file contents plus the toolchain identity — the versions of babel-preset-solid, @babel/core, and @babel/preset-typescript, the renderer path, and an internal cache version. Bumping any dependency invalidates the cache automatically. Because pass 2 loads through the same transformFile, the expensive Babel work runs once per file per build and pass 2 gets it for free.

The walker skips *.generated.ts files entirely — the generated styles module (below) must never feed its own synthetic literals back into the scan.

A pass‑1 summary line looks like:

PocketJS build: hero (/…/demos/hero/app.tsx)
  pass 1: 7 module(s), 42 candidate literal(s), 96 codepoint(s)

Compile styles

The collected class strings go to compileClasses(). 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). See Styling and the Tailwind subset for the utility set.

Two literals that produce byte‑identical records share a single styleId, so class="p-2 bg-slate-700" and class="bg-slate-700 p-2" cost one record. The compiler emits:

  • styles.bin — the encoded style table, packed into the dcpak as ui:styles.
  • src/styles.generated.ts — a TypeScript module the renderer imports, mapping each source class literal to its styleId, plus the font‑slot metadata and 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 class literals earn a hard compile error instead of being dropped, even though they otherwise parse: rounded-full on a literal that doesn't also pin both w-N and h-N (the radius must be build‑time bakeable), and any hover: variant (the PSP has no pointer — use focus:/active:).

  tailwind: 18 style record(s), 23 literal(s) -> src/styles.generated.ts

Bake fonts

bakeAtlases() bakes one Inter atlas per font slot referenced by the compiled styles (styles.usedFontSlots, which always includes the 16px‑regular default slot). Slots are pinned pairs of size and weight — sizes 12/14/16/18/20/24/36 px, regular and bold — chosen by text-* and font-bold utilities.

The charset baked into every slot is the union of:

  • ASCII 32–126, always — so basic text never depends on the scan;
  • the codepoints collected in pass 1 (printable, excluding DEL);
  • anything passed via --extra-chars.

Codepoints the font doesn't map are left out; the core resolves a cmap miss to glyph 0 (a hollow "tofu" box) at runtime. Each atlas is horizontally supersampled 8‑bit coverage cells plus proportional advances and a cmap, and is packed into the dcpak as ui:font.<slot>.

  font: slot 2 (16px) 96 glyphs, cell 10x19, 18240 bytes
  font: slot 9 (16px bold) 96 glyphs, cell 11x19, 20064 bytes

Gather images

Any collected literal that looks like a filename ending in .png or .svg is treated as an image reference (this is how <Image src="logo.png" /> pulls its asset in). For each name the build looks, in order, next to the app entry, then in assets/images/, then in assets/:

  • a PNG is decoded (8‑bit RGB/RGBA/grayscale, non‑interlaced — palette, 16‑bit, and interlaced PNGs are rejected with a clear error);
  • an 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 encoded as an 8888 (RGBA) texture entry and packed as ui:img.<name>. Texture dimensions must be power‑of‑two and within the hardware limit.

  image: logo.png <- /…/demos/hero/logo.png (128x64)

Pack the dcpak

All the binary output is written to one container, dist/<app>.dcpak. It is byte‑for‑byte the dreamcart .dcpak format, so existing tooling can open it. 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 texture per referenced image

Entries are sorted by key and 16‑byte aligned. How a host reads these blobs — and how the PSP feeds them straight into the Rust core from include_bytes! without touching the JS heap — is covered in the Native contract.

  dcpak: 4 entries, 20480 bytes -> dist/hero.dcpak

Pass 2 — bundle

With styles.generated.ts now written, Bun.build bundles the app:

Bun.build({
  entrypoints: [entry],
  naming: `${appName}.js`,
  format: "iife",
  target: "browser",
  conditions: ["browser"],
  define: { "process.env.NODE_ENV": '"production"' },
  minify: false,
  sourcemap: "none",
  plugins: [solidUniversalPlugin()],
});

The plugin's onLoad hook intercepts every project .ts/.tsx file and serves the cached pass‑1 transform (node_modules and .d.ts 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, so a style can never be shipped that the bundle doesn't use, or vice versa.

A few settings are deliberate:

  • format: "iife" — a single self‑contained script, the shape QuickJS evaluates on the PSP.
  • conditions: ["browser"] — forces solid-js to resolve through its browser export. The node condition would pull Solid's SSR build (where reactive updates no‑op), and Bun's default development condition would pull Solid's dev builds and duplicate the runtimes.
  • minify: false — the bundle ships unminified but tree‑shaken; base64 blobs in JS are the known QuickJS boot killer, which is why all binary assets live in the dcpak instead.
  pass 2: dist/hero.js (128000 bytes)
PocketJS build: done

The same pipeline in the browser — the Playground

The Playground runs this exact pipeline live in the browser: it transforms and collects, compiles the Tailwind subset, bakes atlases, packs a dcpak, 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 + .dcpak a PSP would run is what draws in the preview.

Related