Skip to the contentSkip to the navigation
RootsGitHubMenu
On this page

Building Roots apps

roots build turns one app source into a web app, a PWA, a browser extension, an iOS or Android project, or a terminal program. This page covers the project file, the commands, how modules are found and compiled, the bundled output, the generated CSS, the dev server and its security rules, and the error codes. Per-target details and the maturity of each target are in targets.md; the bundler and tree shaker have their own reference, bundling.md.

roots new my-app                 # scaffold a project (roots.lisp, src/App.vue, a component, a per-target file)
cd my-app
roots dev                        # web, http://localhost:5173, live reload
roots run                        # build and run the terminal version
roots build --target pwa         # dist/pwa
roots build --target all         # every target listed in roots.lisp
roots doctor                     # which toolchains this machine has, per target

Project layout #

my-app/
  roots.lisp          project data (read, never evaluated)
  src/App.vue         entry component (or App.roots / App.lisp / App.ts / App.js)
  src/…               .vue .roots .ts .mts .js .mjs .lisp, per-target variants (x.term.ts …)
  public/             copied as is into the web targets (not dotfiles, keys or symlinks)
  icon.png            app icon (optional; a default one is drawn from the theme colour)
  dist/<target>/      build output (roots build)
  .roots/             dev-server builds (roots dev); safe to delete

roots.lisp #

roots.lisp is data. It is read with *read-eval* off, in a throwaway package, with a readtable in which #, ', `, , and | are errors, so #.(…) and other reader tricks cannot run code. Only lists, strings, numbers, keywords and t/nil are accepted, and the result is validated against a schema (roots.build:*config-schema*, itself data). Every key is optional.

(:name "todo"                          ; lowercase letters, digits, dashes (default: the directory name)
 :title "Todo"                         ; human-readable name (default: from :name)
 :version "0.1.0"                      ; MAJOR.MINOR.PATCH[-pre]
 :id "com.example.todo"                ; reverse-DNS id: bundle id, application id, extension id
 :description "A todo list."
 :entry "src/App.vue"                  ; default: first of src/App.vue .roots .lisp .ts .js
 :use ("src/router.ts")                ; modules whose default export is passed to app.use()
 :theme (:primary "#42b883"            ; colour tokens (see CSS below)
         :dark (:bg "#0b0b0b"))        ; overrides in dark mode
 :safelist ("bg-error" "md:flex-row")  ; utilities to generate although no template spells them out
 :public "public"                      ; static files directory
 :icon "icon.png"
 :strict nil                           ; t: unknown utility classes are errors (like --strict)
 :bundle t                             ; roots build: one tree-shaken file per entry (default t)
 :minify t                             ; roots build: minify the bundle (default t)
 :error-codes nil                      ; t: the bundle's runtime errors and warnings carry their code only (like --error-codes)
 :dev (:port 5173 :hosts ("my-laptop.local"))   ; dev server; :hosts only matter with --host
 :csp (:connect-src ("https://api.example.com")  ; origins added to the page's policy (docs/targets.md)
       :trusted-types t)                         ; require Trusted Types (the default)
 :targets (:web (:bundle :inherit :minify :inherit)   ; every target: t, nil, or the top-level value
           :pwa (:theme-color "#42b883" :background-color "#ffffff" :short-name "Todo" :display :standalone
                 :precache-exclude ("video/"))                ; not stored by the service worker
           :ext (:permissions ("storage") :browsers (:chrome :firefox) :gecko-id "todo@example.com"
                 :popup-width 360 :popup-height 480)
           :ios (:app-id "com.example.todo" :app-name "Todo")
           :android (:shell :capacitor                        ; this target in a Capacitor shell
                     :plugins ("@capacitor/preferences" "@capacitor/clipboard" "@capacitor/browser"))
           :term ())
 :native (:shell :roots                                       ; ios/android shells (docs/native.md)
          :plugins (:sqlite :storage :app)                     ; RootsNative allow-list (default: what the app uses)
          :backup nil                                          ; t: app data goes into device and cloud backups
          :build 12))                                         ; versionCode / CFBundleVersion
KeyDefaultNotes
:namedirectory nameused for storage namespaces, package names, cache names
:title:name capitalisedpage title, manifest names, extension name
:version"0.1.0"the extension manifest gets the numeric part; the prerelease goes to version_name
:idcom.example.<name>set a real one before publishing
:entryfirst existing src/App.*B111 if none
:use()each module's default export is installed with app.use(…) before mounting
:themebuilt-in palettetokens: primary accent muted surface border focus success warning error info text bg; :dark (…) holds the dark values (Theme)
:safelist()for classes computed at run time ("text-" + kind)
:targetsall six, no optionsbuilding a target that is not listed is B109
:nativeRoots shell, the plugins the app uses, no backupsios/android: the shell, its plugin allow-list, ids, build number (native.md); a target's own :shell, :app-id, :app-name win; its :plugins are Capacitor npm packages. Without :plugins the allow-list is derived from the app's imports (storage and app always, sqlite with roots/data, clipboard / browser when clipboard / openUrl of roots/platform are imported) and printed in the build summary; an app that calls RootsNative.call(…) itself lists its plugins. :backup t lets Android and iOS include the app's data in backups and device transfer (targets.md)
:cspTrusted Types on, no extra sources (images: the app's own, data: and blob:):connect-src, :img-src, :font-src, :media-src, :frame-src: lists of origins ("https://api.example.com", "https://*.example.com"; "data:" and "blob:" for images, fonts and media) added to the page's Content-Security-Policy and to the dev server's header (an :img-src list replaces the default image sources: name "data:" and "blob:" in it to keep them). Keywords, *, whole schemes and anything for script-src are B105 / B104. :trusted-types nil leaves out require-trusted-types-for 'script' for an app that ships a library which assigns innerHTML (targets.md)
:datathe toolchain's pinned SQLite WASM buildroots/data on DOM targets: (:sqlite-version "…" :sqlite-mjs-sha256 "…" :sqlite-wasm-sha256 "…") pins another @sqlite.org/sqlite-wasm build by the SHA-256 of its two files; all three keys together (data.md, D164, D166)
:packagescooldown 7 dayspackage-manager policy, every exception with a reason: packages.md
:bundletroots build bundles each target's modules into one tree-shaken file per entry (Output); nil keeps one file per module. roots dev and roots run never bundle
:minifytminify the bundle (nothing without :bundle)
:error-codesnilt: the text of the runtime's errors and warnings is left out of the bundle; they carry their code only (Error codes, --error-codes)
:pwa :precache-exclude()files (or directories, with a trailing /) the service worker does not precache: published, but fetched from the network when used
every target:out :bundle :minifyoutput directory, relative to the project (default dist/<target>); :bundle / :minify per target: t, nil or :inherit (the default: the top-level value)

Unknown keys, wrong types and malformed values stop the build with a coded error and, when there is one, a suggestion: roots.lisp:3: B104 unknown key :nmae (hint: did you mean :name?).

Commands #

CommandWhat it does
roots new <dir> [--name n]Scaffold an app. Refuses a non-empty directory (B190).
roots build [dir] --target t [--out d] [--strict] [--native] [--binary] [--no-bundle] [--no-minify] [--error-codes]Build for one target (web by default) or all. --strict turns unknown classes into errors. --no-bundle keeps one file per module and --no-minify a readable bundle, for every target built by the command (above roots.lisp's :bundle / :minify). --error-codes leaves the text of the runtime's errors and warnings out of the bundle (as :error-codes t). --native (ios/android) builds the native app when the toolchain is present: Gradle assembleDebug (Roots shell, Android), xcodebuild (Roots shell, iOS on macOS), npm install + npx cap add/sync (Capacitor). --binary (term) writes one executable with bun build --compile.
roots dev [dir] [--target t] [--port n] [--host addr]Dev server with live reload for DOM targets; for term, rebuild and restart the Node process on every change.
roots preview [dir] [--target t] [--port n] [--out d]Make the production build of a DOM target and serve it on localhost (roots preview).
roots run [dir] [--target term]Build the terminal app and run it. roots run file.lisp still compiles and runs a single Lisp file.
roots doctor [--target t]Toolchain check per target and shell (Node, npm, macOS/Xcode, Android SDK with its platforms, build-tools and licenses, Java, Gradle).
roots self binary [--out path] [--compress]Write the whole toolchain as one executable (below).

Options take a value either as the next argument or with = (--target=pwa). Unknown options are an error (B180).

roots docs build | check | dev | preview builds this documentation site from the Markdown files of docs/; Writing documentation describes it.

How a build works #

Every target and the dev server use the same pipeline (DS-6):

roots.lisp ─► bootstrap ─► module graph ─► bundle ─► assets ─► target files ─► page ─► finalize ─► sync to dist/
              app.js       compile, resolve,  tree shake,  app.css    manifests,     index.html  sw.js
                           rewrite imports    minify       public/    icons, shell   (CSP, SRI)  (PWA)
  1. Bootstrap. A generated module creates the app, installs the :use modules and mounts it: createApp(App).use(router).mount(backend, "#app") on DOM targets, .mount(backend) (the process terminal) on term.

  2. Module graph. From the bootstrap, every import is followed. Each file is compiled by its kind:

    FilesCompiler
    .vue, .rootsroots.template:compile-sfc (templates, `<script setup lang="ts
    .ts, .mtsroots.parse:strip-types (erasable TypeScript only; type checking stays with tsc)
    .lispthe Roots Lisp → JS compiler, with roots.js:*target* bound, so target-case / when-target pick code per target
    .js, .mjsas is

    Import specifiers are found with roots.parse:module-imports (offsets of the string literals) and rewritten in place to relative output paths ending in .js.

  3. Runtime. The framework specifiers resolve inside the toolchain's runtime/ (CONTRACT §1), and the runtime modules are compiled by the same walk, so a target only ships what it imports: DOM targets never contain the terminal backend and vice versa.

    SpecifierResolves to
    roots/runtimeruntime/core/index.lisp
    roots/backendruntime/dom/index.lisp (web, pwa, ext, ios, android) · runtime/term/index.lisp (term)
    roots/routerruntime/router/index.lisp
    roots/uiruntime/ui/index.lisp (the UI kit; its skin per target family)
    roots/platformruntime/platform/<target>.lisp
    roots/dataruntime/data/index.lisp, its driver per target (data.md); for DOM targets the build also copies the SQLite WASM build and allows 'wasm-unsafe-eval' in the CSP
  4. Bundle. roots build hands the linked modules to the bundler (bundling.md): the page's graph becomes one file at the bootstrap's name (app.js; app.mjs on term), tree-shaken at the granularity of top-level statements, scope-hoisted, then minified. What else can appear next to it:

    FileWhen
    chunks/<m>-<hash8>.jsa module reached from several entries (the page and a dynamic import() target); <hash8> comes from the module ids, so it is stable across runs
    <m>-<hash8>.jsthe entry file of a dynamic import("./x.js") target; the import() specifiers in the bundle are rewritten to it
    runtime/data/worker.jsthe roots/data worker, a self-contained bundle of its own (another realm), next to sqlite-wasm/ (data.md)

    index.html carries integrity for app.css and the bundle, and a <link rel="modulepreload" href="chunks/…" integrity="…"> for every shared chunk the bundle imports statically (their content is known when the page is written). Chunks reached only through import() are not pinned: a page cannot know them in advance. node: / bun: imports (term) stay imports, merged into one per module name at the top.

    Defines. Before analysis, process.env.NODE_ENV becomes the literal "production" ("development" under roots dev and roots run) — in npm packages on every target, in app code on DOM targets (there is no process there), never in the runtime or in app code on term, where process.env is real. The free identifier __ROOTS_DEV__ and the runtime's DEV export become false, so if (DEV) … development checks fold away. Literal conditions fold everywhere, inside function bodies too; a dead branch's vars stay declared. The unbundled build applies the member-chain defines module by module at link time: the same process.env.NODE_ENV replacement, and in roots build --no-bundle __ROOTS_DEV__ → false in the runtime, which folds the initializer to export var DEV = false (the if (DEV) branches stay in the files; they do not run). roots dev (every target) and roots run are development builds: they leave __ROOTS_DEV__ alone, so DEV is true there: prop validation (R202, R203) and the duplicate-key scans (R215) are development checks (runtime.md).

    On the DOM targets (web, pwa, ext, ios, android) one more binding of the runtime is defined: CLONE_BACKEND becomes true. It is the build's promise that the app's backend copies static templates (cloneNode, firstChild, isElement, which the DOM backend has): clone then does not ask the backend, and the code that builds a template node by node is not in the bundle (about 0.8 KB, 0.2 KB gzip). Consequence: in a bundle for a DOM target, an app that mounts through a backend of its own without cloneNode gets a TypeError where it used to get templates built by createElement; such an app builds with --no-bundle. The terminal target and every unbundled build leave the binding false and ask the backend each time.

    Error codes (--error-codes, or :error-codes t in roots.lisp; off by default). The runtime's errors and warnings are raised as fail("R208", "<text>"), runtimeWarn("R217", "<text>") and new RootsError("R102", "<text>"). With the option the bundler writes "" for the text at every such call — the calls of RootsError, of the fail of runtime/reactivity, runtime/core, runtime/dom and runtime/router, of the exported runtimeWarn and of the warn of runtime/dom (R307, R308, which go to the console and not to the warnHandler) and of runtime/router, by whatever import they are named — when the text is a string or a template literal written at the call (a variable or a call in that position stays). What only the text used is shaken away with it. What changes: error.message of a RootsError is "[R208] " instead of "[R208] <App>: setup() returned a Promise; …", a warning prints [roots] [R217] and the warnHandler receives "[R217] "; error.code, the handler's code argument and the component trace are unchanged, and the expressions inside a dropped template literal are not evaluated. The same holds for a library that passes a literal to runtimeWarn (the UI kit's R6xx). Not affected: roots/data's DataErrors (D…), the terminal backend's own errors, the toolchain's messages, development builds, and --no-bundle (the option is the bundler's). The codes are listed in runtime.md. Measured on 0.3.2's runtime: 2,498 B less for a one-component app (1,103 B gzip), 2,555 B (1,156 B) for the benchmark app, 3,500 B (1,555 B) for examples/todo.

    Not bundled: roots dev (every target, --target term included) and roots run never bundle, whatever roots.lisp says: they are the edit-run loop, with one readable file per module and the dev server's allowlist built from them. roots build --no-bundle / :bundle nil gives that layout to a production build (src/…, runtime/…, npm/…, imports rewritten to relative .js paths); --no-minify / :minify nil keeps the bundle readable, with a // ---- <module> marker above each module's statements. Toggling :bundle in one output directory is fine: the sync step deletes the files of the other layout and the directories they emptied.

    An unbundled page would fetch its graph one import level at a time, and V8 would compile each function at its first call. Two things in the unbundled output (DOM targets) take that back; a bundle has neither:

    • Preload. index.html carries one <link rel="modulepreload" href="…" integrity="…"> per module the page imports statically, the bootstrap excepted, a module after the modules it imports (15 links for examples/todo). The browser requests the whole graph in one round. Modules reached only through import() are not listed. The hash is that of the file as emitted, and it pins the module for the import that finds it preloaded.
    • Compile hint. Modules the toolchain compiled (the runtime, .vue, .ts, .lisp) and that are smaller than 128 KB start with the line //# allFunctionsCalledOnLoad: Chromium 136+ compiles their functions off the main thread while the deeper modules are still loading; other engines read a comment. Not on the bootstrap, not on .js files (copied as written), never on npm packages: compiling all of a large library costs more than it saves. The hint is added before anything hashes the file.

    A bundle carries no hint, on a measurement (round 7, Chromium 143, the hint as the bundle's first line or after its header line; V8 honours both: 134 lazy compilations on the main thread become 0 for the benchmark app, evaluateModule 4.4 → 1.0 ms, background parsing 2.4 → 6.0 ms). First render, warm renderer, localhost, two sessions each with its own A/A control: examples/todo 0.88–0.91× (11.3 → 10.0 ms; control 0.97–1.04), the benchmark app 1.02–1.05× (not resolved, or slightly slower), examples/gallery 1.00–1.02× (not resolved). Over an emulated 25 ms / 10 Mbit/s link with gzip no app showed a difference outside its control (0.95–1.05). Repeated on the final bundles of the round (two warm sessions of 40 and 60 loads): examples/todo 0.91× and 0.92× (11.1 → 10.1 ms), the benchmark app 1.09× and 1.07× (6.2 → 6.8 ms: slower, outside its control of 0.97–1.04), the gallery 1.01× and 1.02× (not resolved); over the emulated link again nothing outside the control. One app of three gains and one loses, so the bundle stays as it is.

  5. Output. Files are produced in memory, then synced: changed files are written, files from the previous build that no longer exist are deleted (and the directories that left empty), and nothing else in the directory is touched, so npx cap add's native projects and node_modules survive. The list of files written is kept in dist/<target>/.roots-build; roots refuses to write into a non-empty directory without that marker, into the project directory, or into src/ / public/ (B141). The output is byte-identical across runs, machines, paths and cache states (build-is-reproducible).

    The output directory is not trusted either: a checkout can bring a ready-made dist/ along, and git stores symbolic links and dotfiles. Every write and every delete walks from the output directory to the file one directory at a time without following links, and a symbolic link on the way (dist/web/assets -> ~/.ssh) stops the build with B141 before anything is written or deleted; so does an output directory inside the project that is itself reached through a link (dist -> /elsewhere, also when --out spells the project through a link above it, /tmp/app/dist/web for /private/tmp/app; an explicit --out outside the project is your own path), and a .roots-build that is not a regular file. A link at a file's own place is replaced by the file (the link is removed, its target is not written), and so is a file with a second hard link. The marker's lines are only candidates for deletion inside the output directory: a line with .., an absolute path, a dotfile, a control character or a directory's name is ignored. Output paths themselves never contain those (B140).

Caching. Compiled JavaScript is cached by a hash of the source, the target and the toolchain (its version and the modification times of its sources): in memory across dev-server rebuilds, and on disk for runtime modules ($ROOTS_CACHE_DIR, else $XDG_CACHE_HOME/roots, else ~/.cache/roots). Resolution and rewriting always run again, because adding storage.web.ts changes what ./storage means.

What is read back from the disk cache goes into the app as it is, and its key is one anybody can compute, so the cache is only as good as its directory: runtime/ in it is created 0700, and it is used only while it is a real directory (not a link) that belongs to you and that group and others cannot write; an entry only when it is such a file. Otherwise the cache is neither read nor written, the build compiles the runtime itself (about a second) and warns (B167). ROOTS_NO_CACHE=1 turns the disk cache off: use it for a release build on a machine whose cache directory is restored from somewhere you do not control (a shared CI cache).

No third-party code runs during a build (SC-11). Parsing, compiling, CSS, icons (a small PNG encoder) and the dev server are Lisp inside the toolchain. Node.js is only used to run the output; Gradle, Xcode or npm only by the opt-in --native step, Bun only by --binary.

What a build trusts #

Your project's sources: completely. roots build, roots dev, roots run, roots compile and roots eval compile the project's .lisp files and <script lang="lisp"> blocks, and compiling them runs their macros (defmacro, macrolet) and (lisp …) forms as full Common Lisp inside the toolchain, with your rights. That is the language, not an accident. roots.lisp is data and is never evaluated; the sources are not data. Building a project is running its code: only build projects you trust, exactly as you would only run them. (A project without any Lisp source runs nothing at build time, but nothing checks that for you.)

Packages: not at all. The files of node_modules are data to the build, whoever wrote them and however deep in the dependency tree:

  • A package's JavaScript is parsed, linked and bundled, never executed. .ts and .vue files of a package (without a Lisp block) are compiled by the same text-to-text passes.
  • A package's Lisp is not compiled (B129): not a .lisp file reached through main, module, exports or a relative import inside the package, not a .vue with a <script lang="lisp"> tag anywhere in it. Compiling it would run the package's macros in the toolchain, which is the install script the package manager refuses to run, by another door. There is no opt-in: a package ships JavaScript; if you want its Lisp source, copy it into your project, where it is your code. As a second lock the Lisp compiler itself refuses defmacro, macrolet and (lisp …) whenever the file being compiled is a package's.
  • App code cannot make a package file its own either: a relative import that ends in node_modules (../node_modules/x/y.lisp, or a link in src/ pointing there) is B125. Import packages by name.
  • A package directory must be inside the project (roots install links node_modules/<name> into node_modules/.roots/, never outside): a node_modules/x that links to a directory elsewhere on the disk is B125, so nothing outside the project is inlined into a bundle. npm link-style packages are therefore not built.
  • A package's package.json must parse, and its name and version must be a valid package name and a semantic version (B128): both become part of output paths (npm/<name>@<version>/…).

import "pkg" means "run it". When app code imports a package only for its effects (import "harden": a lockdown, a polyfill, an error reporter), the bundle keeps that module even if the package's package.json says "sideEffects": false; import {} from "harden" (no binding at all) counts the same, and so does what that module itself imports for effects (an index.js that is only import "./lockdown.js"). The field still decides about modules the app imports names from and does not use, and about side-effect imports in package code the app reaches any other way. So the bundle runs what roots dev and --no-bundle run. (Still removed, by design: a bare call to a function a package annotates @__NO_SIDE_EFFECTS__, bundling.md.)

What bundling does not keep. A bundle puts the modules of a chunk into one scope. Names are renamed apart, so a module's free identifiers never see another module's bindings; but module scope is not an isolation boundary in a bundle: on the term target (no CSP) a direct eval inside a package function can reach the top-level bindings of the other modules of its chunk. Do not rely on module privacy against code you bundle. <style> blocks of every .vue, packages included, go to app.css as written (url(…), @import); it is the page's CSP (default-src 'self'; style-src 'self') that keeps them from loading anything remote, so keep that in mind before relaxing it.

What SRI covers. index.html pins the entry script, the stylesheet and the statically imported chunks or modules. import() chunks, files from public/ and the term target are not pinned, and hashes inside index.html protect against a tampered asset host, not against a tampered index.html.

The SQLite WASM files of roots/data are JavaScript and WebAssembly from node_modules that run in your app's origin: they are shipped only when their SHA-256 is the pinned one (data.md).

Every failure is one coded line. A syntax error in a .js file is reported as src/x.js:line:column: C1xx … like any other source error; a condition nobody expected is B191 (a toolchain bug: one line with the condition's own text, not a backtrace with every frame's absolute paths and the whole configuration in a CI log), and an input nested deeper than the toolchain's stack (tens of thousands of nested lists, elements or operators) is B192. roots dev shows them in the overlay and keeps serving the last good build. ROOTS_DEBUG=1 lets such a condition through with its backtrace.

Module resolution #

Relative imports resolve inside the importing file's root (the project, or the runtime for runtime modules). For target T, ./storage (or ./storage.ts, or ./storage.js) resolves to the first file that exists among:

  1. storage.T.*
  2. storage.<family>.*, most specific family first: browser (web, pwa, ext) before dom; mobile, then native (ios, android), before dom; term
  3. storage.*

where * runs over vue roots ts mts lisp js mjs in that order. A .js specifier also finds .vue/.ts/.lisp sources (compiled code imports .js), and a directory resolves to its index.*. Two sources that would compile to the same module (x.vue and x.ts) are an error (B126), as is an import that leaves the project, also through a symbolic link, or that reaches into node_modules by path (B125).

SpecifierResult
./x, ../lib/ya project file (per-target variants first)
roots/runtime, roots/backend, roots/router, roots/ui, roots/platform, roots/datathe runtime
node:fs … (and bun: modules)allowed on term only (left as is); elsewhere B121
vue, @scope/pkg, pkg/sub/pathan npm package the importer declares, installed with roots install (packages.md): exports (conditions browser/node, import, module, default), else module, else main; emitted as npm/<name>@<version>/…, its code read as data, never run (What a build trusts). Undeclared: B123 (naming the chain), not installed: B121, no ES module entry or an unusable package.json: B128, Lisp source: B129, a package directory linked from outside the project: B125
https://…, //…, data:…, /abs/pathB122: no remote or inline code, no absolute paths

Per-target files: the way to keep platform ifs out of components #

src/hint.ts          export const hint = "Press Enter to add."
src/hint.term.ts     export const hint = "Tab moves the focus, Ctrl+C quits."
src/hint.ext.ts      export const hint = "Pin the extension."

Components import ./hint and get the right one. For a few lines of difference inside a Lisp file, target-case selects code at compile time ((target-case (:term …) (:mobile …) (t …))); the other branches never reach the output.

CSS #

DOM targets get one stylesheet, app.css:

  1. theme variables (--roots-primary …) with their dark-mode values (Theme),
  2. base styles of the host primitives (Box is a flex column, form controls, focus ring),
  3. the target's own CSS (extension popup size), then the stylesheets of runtime modules in the graph: a runtime module x.lisp with a sibling x.css ships it (the UI kit's DOM skin, runtime/ui/skin.dom.css, which the terminal never imports),
  4. the utilities actually used, in vocabulary order, then variants (states, dark, breakpoints sm < md < lg), so later rules win predictably (hidden beats flex-row),
  5. the components' <style> blocks (plain CSS; global in 0.1).

Classes are collected from the compiled templates (setAttr(n, "class", "…"), bindClass(n, "…", …)), from class="…" and :class="…" in SFC templates, from every string literal in app code (so { bold: done } and "text-error" are found), and from :safelist. A class written literally in a class attribute that is neither a utility, nor defined by a component <style> or a CSS file in public/, is a warning B160 with a suggestion (an error with --strict).

GroupUtilities
Layoutflex-row flex-col flex-1 grow shrink-0 wrap `items-start
Gapgap-N gap-x-N gap-y-N
Spacingp-N px-N py-N `pt
Sizew-N h-N min-w-N max-w-N (N = 0–96 steps), w-full h-full w-1/2
Positionhidden absolute relative overflow-hidden
Borderborder border-round border-double border-<color>
Colorbg-<color> text-<color>, literal bg-[#rrggbb]
Typebold italic underline dim strike `text-left
Effectsrounded shadow

Variants (stackable, dark:md:hover:bg-surface): hover: focus: disabled:, dark: (dark mode: the system's, or the app's choice, see Theme), sm: md: lg: (min-width 640/768/1024 px), and the surfaces dom: term: mobile:, which are decided at build time: term:p-1 produces no CSS on the web and mobile: classes only exist in the ios/android builds.

Theme #

:theme in roots.lisp sets the color tokens; :dark inside it the values for dark mode.

:theme (:primary "#7c3aed" :accent "#db2777"
        :dark (:primary "#a78bfa" :bg "#0f0d15" :surface "#1b1726" :text "#f1eef7"))

Each token is a CSS variable (--roots-primary), used by the base styles, the UI kit and the color utilities (bg-primary, text-muted, border-error). A token you do not set keeps the built-in value; in dark mode muted surface border focus text bg (and, while you leave them alone in light mode too, success error info) have built-in dark values, and every other token keeps its light value unless :dark names it.

The default palette is readable. text, muted, success, error and info are at least 4.5:1 on the background in light and in dark mode. The exception is the default primary (#42b883): it is a brand color, 2.5:1 as text on white (a plain <a> link), and B168 below does not report a default. Set a darker :primary (#15803d, #2563eb) when links in the primary color carry text you need read.

Text on a fill. The UI kit fills some controls with a token and writes on it: a primary or danger Button, a Badge, an Avatar, the current page of a Pagination. The color of that text is a variable per fill, derived from your theme for each mode: white where white reaches 4.5:1 on the fill, else the dark ink #111827.

VariableOnDefault lightDefault dark
--roots-on-primaryprimary#111827 (white on #42b883 is 2.5:1)#111827
--roots-on-successsuccess#ffffff#111827
--roots-on-errorerror#ffffff#111827
--roots-on-infoinfo#ffffff#111827
:theme (:primary "#2563eb" :dark (:primary "#60a5fa"))
;; light: white on #2563eb (5.2:1); dark: #111827 on #60a5fa (7.0:1; white would be 2.5:1)

To decide yourself, set the variable in your own CSS (:root { --roots-on-primary: #fff; }).

Two more variables exist besides the tokens:

VariableLightDarkUse
--roots-warning-text#b45309#d97706warning as a text color: warning itself (#d97706) is a fill with dark text on it, too light to read on white. text-warning uses this variable; it follows a :warning you set
--roots-control-border#878e99#6b7280the boundary of a button or an input, about 3:1 against the background (WCAG 1.4.11). --roots-border stays the quiet line of dividers and cards

The build measures your theme. A text color that :theme leaves under 4.5:1 against its background is the warning B168, with the ratio:

roots: warning: roots.lisp: B168 :theme :dark: :primary #7c3aed on :bg #0f0d15 has a contrast
of 3.38:1; text needs 4.5:1 (dark mode has no :primary of its own: set one under :dark)

Checked, in light and in dark mode: text on bg and on surface; muted, primary (links), success, warning (as text), error and info on bg; and the text on each fill (primary, success, error, info): a mid-tone on which neither white nor the dark ink reaches 4.5:1 is reported too ("neither white nor dark text reaches 4.5:1 on :error #dc4a26"). Only what roots.lisp causes is reported: a color you chose, or a background you chose under a color that was readable on the default one. accent, focus and border are not text colors and are not measured. It stays a warning with --strict.

Light and dark. Dark mode follows the system (prefers-color-scheme) until the app chooses, with the theme API of roots/platform:

import { theme } from "roots/platform"

theme.set("dark")            // "dark" | "light" | "system"; remembered on this device
theme.current.value          // the choice (a ref)
theme.resolved.value         // "dark" | "light": what is shown now (a ref)
<Button :label="theme.resolved.value === 'dark' ? 'Light' : 'Dark'"
        @click="theme.set(theme.resolved.value === 'dark' ? 'light' : 'dark')" />

The choice is the attribute data-theme on <html>. The stylesheet has every dark rule twice, so both switches work without any JavaScript in between:

:root:where([data-theme="dark"]) { --roots-bg: #0f0d15; … }               /* the app chose dark */
@media (prefers-color-scheme: dark) {
  :root:where(:not([data-theme="light"])) { --roots-bg: #0f0d15; … }      /* the system is dark, the app did not choose light */
}
:where(:root[data-theme="dark"]) .dark\:bg-surface { … }                  /* dark: utilities, the same two ways */

The :where() keeps both variable rules at the weight of a plain :root. Your own CSS comes later in app.css, so :root { --roots-bg: … } there wins in both modes, and a dark value of your own is written with the same two selectors (or simply use the variables, which already switch):

:root { --roots-surface: #f6f4ef; }
:root:where([data-theme="dark"]) { --roots-surface: #1b1722; }
@media (prefers-color-scheme: dark) { :root:where(:not([data-theme="light"])) { --roots-surface: #1b1722; } }

The theme API starts when the app first reads theme.current or theme.resolved or calls theme.set: from then on it owns data-theme, follows the system and other tabs. An app that never uses it can set the attribute itself; nothing removes it, in a bundled build and under roots dev alike.

An app that imports theme gets one more file, theme.js: a few lines, loaded before the stylesheet from the app's own files (the policy has no inline script and needs none), that put the remembered choice on <html> before the first paint, so a reload does not flash the other theme. The choice is kept in localStorage under <name>:roots-theme; another tab of the app follows it.

The dev server #

roots dev builds into .roots/dev/<target>/, serves it, polls the sources (and the runtime) for changes, rebuilds incrementally and tells the open pages over server-sent events what to do: reload, swap the stylesheet, or show an error. A failed rebuild shows an overlay with the error (project-relative paths only) and keeps serving the last good build. Every build is also link-checked the way roots build checks it, the build's warnings are shown in the page, and so are the errors the page throws while it runs (What the page shows). It is written on sb-bsd-sockets; no third-party code.

Security rules (research/FEEDBACK.md DS-1…DS-5), all covered by regression tests (src/build/tests/devserver.lisp):

  • Loopback only. It binds 127.0.0.1 (and ::1 when the system has IPv6). --host <ipv4> binds another address, prints a warning and a random access token; requests from peers that are not loopback need it (?roots_token=… once, traded for an HttpOnly; SameSite=Strict cookie, then 403 without it). The cookie is named roots_token_<port>: cookies are shared by all ports of an address, so with a plain name two dev servers would overwrite each other's cookie. (A browser still sends the cookie to every other port of that address; the token is new on every run.)
  • Allowlist, not deny-list. The server serves exactly the files of the last successful build (which include the copy of public/). The path is percent-decoded once and looked up in that table; it is never joined to a directory. .., ., empty segments, backslashes, NUL and control characters are rejected (400); dotfiles are never served; the query string is ignored (?raw, ?import&raw??, ?url&inline change nothing); there is no /@fs/ route; the file's canonical path must still be inside the output (a symlink planted later is not followed).
  • Host and Origin. Host must be localhost, *.localhost, 127.0.0.1 or [::1] with the server's port (plus :dev :hosts, and IP literals for token holders), against DNS rebinding; a Host without a port is only accepted on port 80. A present Origin must be the server's own. Fetch Metadata: a request whose Sec-Fetch-Site is not same-origin or none is served only as a top-level navigation (Sec-Fetch-Mode: navigate and Sec-Fetch-Dest: document, a link to the app); subresource requests and frames from other sites get 403. No CORS headers at all; every response has Cross-Origin-Resource-Policy: same-origin, X-Content-Type-Options: nosniff, Cross-Origin-Opener-Policy: same-origin, Referrer-Policy: no-referrer and Cache-Control: no-store.
  • No framing by other origins. Every response has a Content-Security-Policy with frame-ancestors and an X-Frame-Options header: HTML (with the page CSP), error answers and the reload channel may not be framed at all ('none', DENY); other files only by the app itself ('self', SAMEORIGIN), so an <iframe> or <object> showing the app's own PDF or SVG works as it does in production. The page CSP of the header allows the same images as the built page's own policy: the app's files, data: and blob: URLs, or exactly the :img-src list of roots.lisp. So an image that shows in production shows under roots dev and roots preview too.
  • Notification-only reload channel. /__roots/events sends ok (with the build's warnings), reload, css and build-error; nothing is ever read from it, and the dev client sends nothing: an error a page throws is shown in that page and never leaves it. Only the page's own EventSource may open it (Sec-Fetch-Site: same-origin, Sec-Fetch-Dest: empty when the browser sends them); a navigation or a frame gets 403. At most 32 channels are open (503 beyond that); a closed one is noticed within about a second; a build error sent to the pages is cut at 16 384 characters, and so are the warnings together (at most 50 of them, 2 000 characters each; the terminal has all of it). The overlay is built with DOM calls and textContent and styled through the CSSOM, so it needs nothing from the page's CSP.
  • Secrets stay out. Dotfiles, *.pem, *.key, *.p12, *.pfx, *.crt and symlinks in public/ are never copied to any output, so they cannot be in the allowlist. No environment variables are inlined into the client in 0.1.
  • Strict HTTP. GET and HEAD only (405 otherwise, without CORS preflight answers), no request bodies, one request per connection, 16 KB of headers. Host, Origin and the three Sec-Fetch-* headers may each be there once (400 otherwise).
  • Bounded resources. 64 connections; with --host, peers that are not loopback share 48 of them and one address gets at most 16, so the network cannot keep the developer's own browser out. The request head must be complete 5 s after the connection was accepted (408 otherwise; a byte every few seconds does not extend it), and a response must be taken at 64 KB per 10 s at least and as a whole within 10 s plus one second per 64 KB, or the connection is closed. No socket is written to while the server's lock is held: a page that stops reading its reload channel is dropped after 2 s and cannot stall other requests or the watcher. An error in the watcher that is not a build error (an internal error, a file that vanished, the stack running out on deeply nested source) is shown like a failed build and the server keeps running.

roots dev --target term rebuilds and restarts node app.mjs on every change (the build errors, link errors included, are printed and the running version keeps going).

What the page shows #

A dev page loads a small client, /__roots/client.js, ahead of the app's own script (so it is listening before the first line of the app runs). It draws three panels. None of them exists in a production build: roots build writes neither the client nor a reference to it.

PanelShown whenGoes away
Build failed (full page)the build or its link check failedwhen the next build is good
Run-time error (full page)the page threw an error nobody handledClose, or Escape
Build warnings (bottom of the page)the last build had warningsClose; it stays closed until the warnings change

Link errors. roots build bundles, and the bundler refuses a program a browser could not link. roots dev does not bundle, but it runs the same check over the dev build's modules after every build, so a misspelt import is a coded error in the overlay and the terminal instead of a white page:

import { money, monney } from "./util"      // util.ts exports money only
src/App.vue: B134 src/App.js imports "monney" from src/util.js, which does not export it
  hint: in ESM this fails to link; check the name or the module's export list

The other codes of the bundler come the same way: B132 and B135 as errors, B133 and the warning form of B134 as warnings (Error codes). The modules checked are the ones the page runs (DEV true, process.env.NODE_ENV "development").

Warnings. Whatever roots build would print as roots: warning: … (an unknown utility class, a skipped file in public/, a bundler warning) is listed in the warnings panel and in the terminal.

Run-time errors. The client listens for the window's error and unhandledrejection events. The runtime rethrows every error that no onErrorCaptured hook and no app.config.errorHandler took, so those arrive there. The panel shows the message, the component chain when the error has one, and the stack with paths relative to the server:

Error: boom in handler
in <BookCard> < <CataloguePage> < <App>
    at Proxy.openDrawer (/src/pages/CataloguePage.js:36:41)

The lines are those of the compiled modules under .roots/dev/ (one readable file per source file; there are no source maps yet). The chain is read from the error's componentChain property (a string, or an array of names, innermost first). An app that sets its own errorHandler keeps errors from reaching the window; it can still show them:

app.config.errorHandler = (error, instance, info) => {
  myReporting(error)
  window.dispatchEvent(new CustomEvent("roots:error", { detail: { error, info } }))   // detail.chain is optional
}

In a production build nothing listens for roots:error, so the line is harmless there. An error object is shown once; the panel keeps the first 20 errors and counts the rest.

Style changes keep the page #

When a build changes app.css and nothing else (a <style> block, a theme colour or the safelist in roots.lisp), the pages are sent css instead of reload: the client loads the new stylesheet next to the old one, with its new integrity value, and removes the old one when it has arrived. An open drawer or a half-filled form stays as it is. A change that also alters a module (a new utility class in a template changes the component and the CSS) is a reload, and so is the first good build after a failed one.

The time in "rebuilt in" #

roots: rebuilt in 0.47s (build 0.32s, link check 0.08s): page reloaded
roots: rebuilt in 0.21s (build 0.15s, link check 0.04s): styles swapped, no reload

The first number is wall time: from the moment the watcher saw the change until the pages had been told. The watcher looks at the sources every 0.3 s, so a save waits up to that long before the clock starts.

roots preview #

roots dev serves a development build. To try what roots build writes (the bundle, the service worker of a PWA and its offline behaviour) use roots preview:

roots preview                    # builds dist/web and serves it on http://localhost:4173
roots preview --target pwa       # the PWA: install it, go offline, reload
roots preview --port 8080

roots preview [dir] [--target web|pwa|ext|ios|android] [--port n] [--out dir] makes the production build (as roots build does, into the same directory) and serves it with the rules above: loopback only, the allowlist of the build's files, the page's CSP as a header. There is no --host, no watcher and no dev client; build again to see a change. Without --port the next free port from 4173 is taken. A terminal target is B172.

When the toolchain does not load #

bin/roots runs the toolchain from its source and compiles what has changed. If a part of it does not compile (a half-saved file in a checkout you are working on), every command stops with exit status 1 and one message, whichever project it was run in:

roots: the toolchain did not load: roots-template
  COMPILE-FILE-ERROR while compiling #<CL-SOURCE-FILE "roots-template" "codegen">
  the end of what the compiler said:
    ; caught ERROR:
    ;   READ error during COMPILE-FILE:
    ;     end of file on #<SB-INT:FORM-TRACKING-STREAM for "file …/src/template/codegen.lisp" …>
    ;     (in form starting at line: 412, column: 0, position: 19022)
  hint: no command can run until this is fixed. If you did not change the toolchain, update it or use a released binary (roots self binary). ROOTS_DEBUG=1 shows everything the compiler says.

What SBCL prints while it compiles (its optimisation notes, pages of them after a change) is kept back; ROOTS_DEBUG=1 shows it.

Platform adapters (roots/platform) #

One API on every target, implemented per target in runtime/platform/<target>.lisp (typed Roots Lisp that passes tsc --strict):

import { storage, clipboard, openUrl, notify, info, updates, theme, screen } from "roots/platform"

await storage.set("todos", todos)          // JSON values, namespaced by app name
const saved = await storage.get("todos")   // null when missing
await storage.remove("todos")
await storage.persist()                    // ask for durable storage (web/PWA)
await clipboard.write("text"); const text = await clipboard.read()
await openUrl("https://example.com")       // http(s), mailto, tel only, else R501
await notify("Saved", { body: "3 todos" }) // true when shown
info.target; info.platform; info.version; info.name; info.title; info.id; info.roots
updates.onAvailable((update) => update.apply())   // PWA: a new version is waiting
updates.onError((message) => console.warn(message)) // PWA: a new version could not be installed
theme.set("dark")                          // "dark" | "light" | "system" (see Theme above); R504 otherwise
theme.current.value; theme.resolved.value  // refs: the choice, and "dark" | "light" as shown
screen.columns.value; screen.rows.value    // refs: the terminal's size in cells
screen.width.value; screen.height.value    // refs: the viewport in CSS pixels (cells in the terminal)
screen.scrollTo(0)                         // scroll the page: a pixel offset on the DOM, a row in the terminal

screen follows resizes, so a layout can depend on it on every target:

<script setup lang="ts">
import { computed } from "roots/runtime"
import { screen } from "roots/platform"
const wide = computed(() => screen.columns.value >= 96)      // the md: breakpoint, as a value
</script>
<template>
  <Box :class="wide ? 'flex-row gap-4' : 'flex-col gap-2'">…</Box>
</template>

On the DOM screen.columns and screen.rows are the viewport in cells of 8 × 16 CSS pixels, the unit of the terminal's breakpoints (80 columns = 640 px = sm:), so one comparison serves both surfaces. theme and screen cost nothing in an app that does not import them.

web / pwaextios / androidterm
storagelocalStorage (<name>:key; memory when blocked)chrome.storage.local, else localStoragethe storage plugin (Roots shell) or Capacitor Preferences, else localStorage$XDG_CONFIG_HOME/<name>/storage.json (0600, atomic writes)
clipboardasync Clipboard APIasync Clipboard APIthe clipboard plugin or Capacitor Clipboard, else browserpbcopy / wl-copy / xclip / clip, write falls back to OSC 52
openUrlwindow.open(…, "noopener")chrome.tabs.createthe browser plugin (http/https; mailto/tel go to the system) or Capacitor Browser, else browseropen / xdg-open / rundll32, no shell
notifyNotification APINotification APILocalNotifications if installed (Capacitor), else browsernotify-send / osascript (argv, never interpolated)
info.platformnavigator.platformchrome-extension / moz-extensionios / android (RootsNative.platform or Capacitor's)process.platform
themedata-theme on <html>, kept in localStorage (<name>:roots-theme), theme.js before the first paintsamesamethe backend's color scheme (dark: variants, :theme :dark colors), kept in the storage file
screenthe viewport (resize), window.scrollTosamesamethe mounted screen's columns × rows, the page's scroll offset
updatespwa: service-worker update flow; onError when an install fails (a file missing or not the build's bytes; the running version stays); web: nevernevernevernever

Errors: R501 URL scheme not allowed, R502 clipboard read unavailable, R503 value not storable as JSON, R504 theme.set got something other than "dark", "light" or "system".

Your own adapters follow the same pattern with per-target files: write src/haptics.ts (the fallback), src/haptics.mobile.ts (calls RootsNative, with a plugin of your own: native.md), and src/haptics.term.ts (rings the bell); import ./haptics everywhere. The build picks the file for the target; the others never ship.

Error codes #

Build errors print file:line:column: CODE message and usually a hint. Errors about migrations and the SQLite WASM package are D16x (data.md). Errors from the parser (C1xx) and the template/SFC compiler (C2xx, C3xx) are shown as those stages report them, with a code frame. Three parser codes come from input a hostile or broken file can hold and are worth knowing here (parser.md): C109, nesting deeper than 1000 levels or a member/call chain too long for the bundler's analyses; C110, a name declared twice in a module (let a; var a), reported by the bundler and, in roots dev and --no-bundle, when one of the project's own modules is compiled, with the line and column of the second declaration in the compiled JavaScript; C111, <!-- or a leading --> in module code, which engines refuse.

CodeMeaning
B100project directory or roots.lisp not found
B101roots.lisp cannot be read (syntax, a refused # ' ` , `
B102roots.lisp is not exactly one property list
B103a value that is not data (symbol, dotted list, character, vector…)
B104unknown or duplicate key
B105wrong type or malformed value (name, id, version, colour, path…)
B109unknown target, or a target not enabled in :targets
B111entry component missing
B120an import cannot be resolved
B121an npm package that is not a dependency (or roots-pm not loaded), unknown roots/… module, or node: outside the term target
B122remote, inline or absolute import; unsupported file type
B123an import of a package the importer does not declare (a phantom dependency; the message names the chain)
B124the TypeScript or SFC compiler is not available in this toolchain
B125an import leaves the project (also through a symbolic link) or reaches into node_modules by path; a package file outside its package; a package directory that is a link to somewhere outside the project
B126two sources compile to the same module
B127a file in public/ collides with a generated file
B128a declared package that is not installed, or has no ES module entry for the import, or whose package.json is missing, does not parse, or has no valid name and version; when bundling, a package module that is CommonJS (free require(), module or exports, no import/export): only ES modules can be bundled
B129Lisp source from an npm package (a .lisp file, or a .vue with <script lang="lisp">): not compiled, because compiling Lisp runs its macros in the toolchain (What a build trusts)
B130a runtime module is missing from the toolchain
B131Lisp compile or read error
B132bundler: an import cycle where a module reads, at top level, a lexical binding (let/const/class, a default export) of a module that has not run yet; break the cycle, move the use into a function, or build with --no-bundle. A namespace object (import * as ns) is not part of this: it exists from the start, as in ESM
B133bundler warning: import.meta.url used outside new URL("./…", import.meta.url), or in a new URL("../…", import.meta.url) whose path leaves the output directory (it is not rewritten); in the bundle it is the chunk's URL
B134bundler warning: ns.name on a namespace import where the target does not export name: emitted as (void 0). As an error: a named import, used or not, of a name the module does not export, or that export * provides from two modules (ESM fails to link, and so would the unbundled modules). A target that re-exports everything of an external is not checked
B135bundler: a namespace member assigned, updated or deleted (ns.x = 1, ({ a: ns.x } = o)): ESM throws; export a function from the module instead
B136bundler: a package file without import/export and without CommonJS markers is a script, not a module, unless its package.json says "type": "module"
B137bundler: a module-level var redeclares the parameter of the catch clause it is written in (catch (e) { var e = … }) and cannot keep its name: another module of the chunk has such a pair or reads a global of that name, or an importer reads the variable inside a scope with a local of that name; rename one of the two (bundling.md)
B140internal: refusing to emit a dotfile, or a path that is not a plain relative path (.., empty segments, control characters)
B141unsafe output directory: the project, the sources, a foreign non-empty directory; a symbolic link inside it on the way to a file the build writes or deletes; an output directory inside the project reached through a link; a .roots-build that is not a regular file
B150--native without the platform toolchain, or on a non-mobile target; --binary on a target other than term
B151a --native or --binary step (Gradle, xcodebuild, npm / npx cap, bun) failed
B152a plugin in :native :plugins has no implementation for this platform (built in, or native/<platform>/<Name>Plugin.*)
B153a file in native/<platform>/ has the name of a built-in plugin
B154warning: a file in native/<platform>/ that is not a source file was ignored
B155warning: icon.png is not 1024×1024, so iOS uses the default icon
B156warning: --binary needs Bun, which is not installed (skipped)
B157warning: a target's :plugins (Capacitor npm plugins) is set, but the target uses the Roots shell, which ignores it; native plugins go in :native :plugins
B158warning: an entry of :ext :permissions that reaches beyond the popup (tabs, scripting, webRequest, cookies, history, nativeMessaging, …) or is a host pattern; the manifest is written as asked
B160unknown utility class (warning; error with --strict or :strict t)
B163warning: a symbolic link in public/ or native/<platform>/ was skipped. As an error: public/ (or a parent directory named in :public), icon.png, migrations/, native/android/, native/ios/ or native/android/gradle/verification-metadata.xml is itself a symbolic link; what the build publishes or compiles in comes from inside the project
B164warning: a key, certificate or secret in public/ was skipped: .pem .key .p12 .pfx .crt .der .jks .keystore .p8 .mobileprovision .ppk .kdbx .env, id_rsa-style names, and any file that starts with a PEM, OpenPGP or PuTTY private-key header. A net for mistakes, not a secret scanner
B165warning: icon.png is not a PNG (the default icon is used)
B166warning: icon.png is not square
B167warning: the compile cache directory is not a directory only you can write (or is a link); it is not used
B168warning: a text color of :theme is under 4.5:1 against its background, or neither white nor dark text reaches 4.5:1 on a fill color (the message has the colors and the ratio)
B170--host is not an IPv4 address
B171the port is in use
B172the dev server and roots preview only serve DOM targets (use roots dev --target term for the terminal; a terminal build runs by itself)
B180command-line usage
B190roots new: invalid name or non-empty directory
B191internal error: a condition the toolchain did not expect (a bug; ROOTS_DEBUG=1 shows the backtrace)
B192a source file nests too deeply: the toolchain ran out of stack while compiling or bundling it

The single binary #

bin/roots self binary --out ~/bin/roots     # 51 MB, starts in about 12 ms (Linux x86-64, measured for 0.2.0)
bin/roots self binary --compress --out roots # 12 MB, starts in about 100 ms (zlib-compressed core)
roots new app && cd app && roots build --target web   # anywhere: no checkout, no SBCL needed

roots self binary saves the running toolchain image (every subsystem bin/roots loads) as one executable with save-lisp-and-die. The files the toolchain reads at run time travel inside it: every file below runtime/ and shells/, whole directories, so a new runtime directory is carried without a change to the binary's code (*embedded-directories* in src/build/binary.lisp).

On its first start the binary extracts them into a directory named after its version and the files' hash, under the cache directory ($ROOTS_CACHE_DIR, else $XDG_CACHE_HOME/roots, else ~/.cache/roots): toolchain/<version>-<hash>/. It writes a temporary directory, checks every file's SHA-256 against the hash stored in the binary, makes the files (0444) and the directories inside (0555) read-only, renames the directory into place and then locks the top directory too (macOS refuses to rename a read-only directory), so two binaries starting at once never see half a tree. Every later start compares each file's size and modification time with the manifest (.roots-toolchain) and extracts a fresh copy when anything differs. The build then reads runtime/ and shells/ from there; the binary never touches the source checkout.

Those files are the toolchain's own source (compiled into every app, their macros run in the build), so the extracted tree follows the rule of the compile cache: toolchain/ and the tree in it must be real directories that belong to you and that group and others cannot write. A tree somebody else put there, or a link, is never used (a fresh copy is extracted); a toolchain/ that is somebody else's or a link stops the binary with a hint to set ROOTS_CACHE_DIR. With ROOTS_NO_CACHE=1 every start compares each file with the binary's own copy byte for byte instead of believing size and modification time: use it where the cache directory is restored from somewhere you do not control.

  • The version is fixed in the binary; roots test is not in it (the suites need the checkout).
  • The compile cache keys include the toolchain's fingerprint at build time, so a rebuilt binary never reuses another toolchain's compiled runtime.
  • Foreign libraries (libsqlite3 for roots db) are loaded from the system when first used.
  • Node.js is still what runs terminal apps; roots build --target term --binary turns one into a single executable with Bun (about 100 MB, it carries the Bun runtime).

Tests #

bin/test-system roots-build/tests            # everything (about two minutes)
bin/test-system roots-build/tests devserver  # the dev-server security suite
bin/test-system roots-build/tests e2e        # end-to-end: Chromium, PTY
bin/test-system roots-build/tests native     # the native shells (Gradle, swiftc when present)
bin/test-system roots-bundle/tests           # the bundler and tree shaker (bundling.md)

The build tests cover both layouts: the bundled default (assertions on what survives minification: string literals, property names, behaviour in Node and Chromium) and :bundle nil (per-module files and rewritten specifiers, the preload links and compile hints), plus switching between them in one output directory and byte-identical output across runs, paths and cold/warm caches. e2e-production-builds-define-dev-false runs one app as roots build, --no-bundle and a dev build and reads DEV and the warnings each prints; e2e-clone-backend-define reads the web and the terminal bundle of examples/todo for the template paths each keeps, and e2e-error-codes builds it with --error-codes, checks which texts and codes are in the bundle and runs it in Chromium.

src/build/tests/security.lisp (filter build-) holds the regression tests of What a build trusts: package Lisp that must not run (a sentinel file that must not appear), links in the output directory and a forged marker, package directories and manifests, import "pkg" for effects, coded errors for broken and deeply nested input, the private disk cache, the pinned SQLite WASM files and the launchers.

bin/roots and bin/test-system load the toolchain's system definitions from three places only (roots.asd, src/<subsystem>/*.asd, runtime/<part>/*.asd): loading an .asd evaluates it, so one that lands anywhere else below the checkout (an example's output, an unpacked archive, node_modules) is never picked up. The checkout's path and the arguments reach Lisp through the environment, not spliced into Lisp source.

The end-to-end tests build both examples for all six targets and run them (Chromium via Playwright, a pseudo-terminal via python3). A test whose prerequisites are missing prints PENDING <test>: <reason> and passes; ROOTS_E2E_REQUIRE=1 makes it fail instead. ROOTS_E2E_BROWSER=webkit|firefox runs the browser tests in another Playwright engine.