Build pipeline
This page describes the low-level JS/style/font/pak compiler. Product builds
should first resolve pocket.json through bun pocket check, compile, or
build; see Platform contracts for how one small,
checksummed target plan becomes the authoritative input to this pipeline and
native packaging.
The manifest-first compiler resolves the target before producing its bundle:
bun pocket check --target psp
bun pocket compile --target psp
bun pocket build --target psp -- --release
bun pocket check --target vita
bun pocket build --target vita -- --releasecheck validates the schema, capabilities, viewport, and reachable TypeScript.
compile also writes .pocket/<target>/plan.json plus the target-specific JS
and pak. build dispatches that same plan to the registered PSP or Vita native
backend.
The lower-level compiler can still turn one entry into two files directly:
bun tools/build.ts heroproduces dist/hero.js (the bundle) and dist/hero.pak (styles, font
atlases, and images packed into a single binary container). This direct command
uses the default density-1 development contract. A manifest build is
target-specific: PSP and Vita compile from the same source and logical layout,
but Vita receives density-2 atlases/assets plus an embedded target/HostOps-ABI
handshake. Do not copy one target's pair into another target's native package.
Solid is the default framework. Vue Vapor and Octane build beside the Solid artifacts by adding a suffix:
bun tools/build.ts hero-vue-vapor-main --framework=vue-vapor
# -> dist/hero-vue-vapor-main.vue-vapor.js + dist/hero-vue-vapor-main.vue-vapor.pak
bun tools/build.ts hero-main --framework=octane
# -> dist/hero-main.octane.js + dist/hero-main.octane.pakThe 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 low-level compiler
The one required argument is the app to build. It can be a path or a bare name:
bun tools/build.ts apps/hero/app.tsx # explicit path
bun tools/build.ts hero # bare name -> apps/hero/app.tsx
bun tools/build.ts hero-main # the mounted entry (apps/hero/main.tsx)A bare name resolves against apps/: hero finds apps/hero/app.tsx, and a
name ending in -main finds apps/hero/main.tsx.
| Flag | Effect |
|---|---|
--framework=solid|vue-vapor|octane |
Select the framework for this low-level build, overriding pocket.config.ts. Manifest builds take it from pocket.json. |
--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 these codepoints into every baked atlas, on top of the collected charset and ASCII. |
bun tools/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 |
|---|---|---|
apps/hero/app.tsx |
hero.js, hero.pak |
the app component |
apps/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 |
--framework=octane |
<name>.octane.js, <name>.octane.pak |
Octane artifacts coexist with the other two |
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 JSX transform
The selected framework owns the JSX transform:
// Solid
[solidPreset, { generate: "universal", moduleName: RENDERER_SOLID_PATH }]
// Vue Vapor
transformVueJsxVapor(source, path)
// Octane
octaneCompile(source, path, { mode: "client", renderer: OCTANE_RENDERER_DESCRIPTOR })Solid compiles JSX into calls against framework/src/renderer-solid.ts. Vue Vapor
compiles JSX with vue-jsx-vapor and bundles against framework/src/renderer-vue-vapor.ts
plus the small DOM facade needed by Vue's Vapor helpers. Octane runs the
collector pass over the pristine source first, then hands the same source to
the Octane universal compiler, which lowers JSX and hooks to static host plans
plus dynamic slots against the "pocket" renderer descriptor — the compiled
output's runtime imports retarget to @pocketjs/framework/octane/renderer
(framework/src/renderer-octane.ts), whose driver maps host command batches
onto the native ui.* tree with no DOM shim. @babel/preset-typescript
still strips types in every case, and the same collector/lints run before JSX is
lowered.
Package imports are framework-aware during both pass 1 and pass 2. For example,
@pocketjs/framework/components resolves to framework/src/components.ts for Solid,
framework/src/components-vue-vapor.ts for Vue Vapor, and
framework/src/components-octane.tsx for Octane. The mapping is centralized in
framework/compiler/jsx-plugin.ts; see Frameworks for the public
contract.
What it collects
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. It never regexes over quotes; it reads real AST nodes. It does not decide what is a class here —tailwind.tsdoes that later. A string like"Loading…"is collected as a candidate, fails to parse as a utility, and 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 (é) |
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 selected framework, the
versions of the JSX/compiler packages, 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 (/…/apps/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 pak asui:styles. Anytheme.keyframes/theme.animationentries referenced by ananimate-<name>class are baked into it too, as frame-precise per-property segment timelines (the ANIM TABLE) — see Animation → baked keyframe timelines.framework/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 framework/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 produce 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) -> framework/src/styles.generated.tsBake 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 pak as ui:font.<slot>.
The resolved target owns raster density. PSP bakes one coverage sample per logical pixel; Vita bakes two while preserving the same logical font metrics, so layout remains 480×272 and glyph edges use the full 960×544 framebuffer.
font: slot 2 (16px) 96 glyphs, cell 10x19, 18240 bytes
font: slot 9 (16px bold) 96 glyphs, cell 11x19, 20064 bytesGather 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.
For density-2 targets the compiler prefers a sibling @2x PNG and otherwise
falls back to the base bitmap. SVGs and rounded masks rasterize directly at the
resolved density; their logical dimensions do not change. Runtime texture
producers use platform.pixelRatio instead of branching on a target name.
image: logo.png <- /…/apps/hero/logo.png (128x64)Pack the pak
All the binary output is written to one container, dist/<app>.pak. It uses
the stable PocketJS pak layout (compatible with earlier DreamCart-era tooling).
PocketJS uses these entry families:
| 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 |
ui:sprite.<name> |
one native-ticked sprite atlas plus frame metadata |
ui:tile.<name> |
a prebaked TILESET pyramid entry supplied through the app's pak.json |
pak.json may append other explicitly named prebaked u8 blobs as well; the
compiler copies those entries verbatim and prefers an @<density>x sibling
when the selected target provides one.
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.
pak: 4 entries, 20480 bytes -> dist/hero.pakPass 2 — bundle
With the style table compiled, Bun.build bundles the app:
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, generatedStyles })],
});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). It also serves this build's generated style module directly from memory;
the ignored framework/src/styles.generated.ts file is only a human-readable
mirror. Parallel target builds therefore cannot consume one another's transient
style table. The bundle is 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.
With a resolved plan, pass 1 also replaces literal
hasFeature("capability.id") calls with true or false; normal tree shaking
can then remove an unavailable enhancement branch. Pass 2 defines the target,
HostOps ABI, feature map, and pixel ratio consumed by the runtime contract.
A few settings are deliberate:
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 pull the SSR build (where reactive updates no‑op); Bun's defaultdevelopmentcondition can also pull dev builds and duplicate 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 pak instead.
pass 2: dist/hero.js (128000 bytes)
PocketJS build: doneThe same pipeline in the browser — the Playground
The Playground runs the same compiler stages 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 core and renders to a canvas. It exercises the same logical UI and pak formats, but its density-1 browser development pair is not interchangeable with a manifest-built PSP or Vita artifact carrying target-specific density and ABI constants.
Related
- Styling and Tailwind subset — what the class compiler accepts.
- Native contract — how a host consumes the pak and drives the core.
- Architecture — where the renderer, core, and hosts fit together.
- Getting started — install, scaffold, and run your first build.