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 targetProject 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 deleteroots.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| Key | Default | Notes |
|---|---|---|
:name | directory name | used for storage namespaces, package names, cache names |
:title | :name capitalised | page title, manifest names, extension name |
:version | "0.1.0" | the extension manifest gets the numeric part; the prerelease goes to version_name |
:id | com.example.<name> | set a real one before publishing |
:entry | first existing src/App.* | B111 if none |
:use | () | each module's default export is installed with app.use(…) before mounting |
:theme | built-in palette | tokens: 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) |
:targets | all six, no options | building a target that is not listed is B109 |
:native | Roots shell, the plugins the app uses, no backups | ios/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) |
:csp | Trusted 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) |
:data | the toolchain's pinned SQLite WASM build | roots/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) |
:packages | cooldown 7 days | package-manager policy, every exception with a reason: packages.md |
:bundle | t | roots 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 |
:minify | t | minify the bundle (nothing without :bundle) |
:error-codes | nil | t: 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 :minify | output 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 #
| Command | What 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)Bootstrap. A generated module creates the app, installs the
:usemodules and mounts it:createApp(App).use(router).mount(backend, "#app")on DOM targets,.mount(backend)(the process terminal) onterm.Module graph. From the bootstrap, every import is followed. Each file is compiled by its kind:
Files Compiler .vue,.rootsroots.template:compile-sfc(templates, `<script setup lang="ts.ts,.mtsroots.parse:strip-types(erasable TypeScript only; type checking stays withtsc).lispthe Roots Lisp → JS compiler, with roots.js:*target*bound, sotarget-case/when-targetpick 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.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.Specifier Resolves to roots/runtimeruntime/core/index.lisproots/backendruntime/dom/index.lisp(web, pwa, ext, ios, android) ·runtime/term/index.lisp(term)roots/routerruntime/router/index.lisproots/uiruntime/ui/index.lisp(the UI kit; its skin per target family)roots/platformruntime/platform/<target>.lisproots/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 CSPBundle.
roots buildhands the linked modules to the bundler (bundling.md): the page's graph becomes one file at the bootstrap's name (app.js;app.mjson term), tree-shaken at the granularity of top-level statements, scope-hoisted, then minified. What else can appear next to it:File When 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; theimport()specifiers in the bundle are rewritten to itruntime/data/worker.jsthe roots/data worker, a self-contained bundle of its own (another realm), next to sqlite-wasm/(data.md)index.htmlcarriesintegrityforapp.cssand 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 throughimport()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_ENVbecomes the literal"production"("development"underroots devandroots run) — in npm packages on every target, in app code on DOM targets (there is noprocessthere), never in the runtime or in app code on term, whereprocess.envis real. The free identifier__ROOTS_DEV__and the runtime'sDEVexport becomefalse, soif (DEV) …development checks fold away. Literal conditions fold everywhere, inside function bodies too; a dead branch'svars stay declared. The unbundled build applies the member-chain defines module by module at link time: the sameprocess.env.NODE_ENVreplacement, and inroots build --no-bundle__ROOTS_DEV__→falsein the runtime, which folds the initializer toexport var DEV = false(theif (DEV)branches stay in the files; they do not run).roots dev(every target) androots runare development builds: they leave__ROOTS_DEV__alone, soDEVistruethere: 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_BACKENDbecomestrue. It is the build's promise that the app's backend copies static templates (cloneNode,firstChild,isElement, which the DOM backend has):clonethen 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 withoutcloneNodegets a TypeError where it used to get templates built bycreateElement; such an app builds with--no-bundle. The terminal target and every unbundled build leave the bindingfalseand ask the backend each time.Error codes (
--error-codes, or:error-codes tin roots.lisp; off by default). The runtime's errors and warnings are raised asfail("R208", "<text>"),runtimeWarn("R217", "<text>")andnew RootsError("R102", "<text>"). With the option the bundler writes""for the text at every such call — the calls ofRootsError, of thefailofruntime/reactivity,runtime/core,runtime/domandruntime/router, of the exportedruntimeWarnand of thewarnofruntime/dom(R307, R308, which go to the console and not to thewarnHandler) and ofruntime/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.messageof aRootsErroris"[R208] "instead of"[R208] <App>: setup() returned a Promise; …", a warning prints[roots] [R217]and thewarnHandlerreceives"[R217] ";error.code, the handler'scodeargument 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 toruntimeWarn(the UI kit's R6xx). Not affected: roots/data'sDataErrors (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) forexamples/todo.Not bundled:
roots dev(every target,--target termincluded) androots runnever 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 nilgives that layout to a production build (src/…,runtime/…,npm/…, imports rewritten to relative.jspaths);--no-minify/:minify nilkeeps the bundle readable, with a// ---- <module>marker above each module's statements. Toggling:bundlein 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.htmlcarries one<link rel="modulepreload" href="…" integrity="…">per module the page imports statically, the bootstrap excepted, a module after the modules it imports (15 links forexamples/todo). The browser requests the whole graph in one round. Modules reached only throughimport()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.jsfiles (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,
evaluateModule4.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/todo0.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/gallery1.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/todo0.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.- Preload.
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 andnode_modulessurvive. The list of files written is kept indist/<target>/.roots-build; roots refuses to write into a non-empty directory without that marker, into the project directory, or intosrc//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--outspells the project through a link above it,/tmp/app/dist/webfor/private/tmp/app; an explicit--outoutside the project is your own path), and a.roots-buildthat 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.
.tsand.vuefiles 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
.lispfile reached throughmain,module,exportsor a relative import inside the package, not a.vuewith 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 refusesdefmacro,macroletand(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 insrc/pointing there) is B125. Import packages by name. - A package directory must be inside the project (
roots installlinksnode_modules/<name>intonode_modules/.roots/, never outside): anode_modules/xthat 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.jsonmust parse, and itsnameandversionmust 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:
storage.T.*storage.<family>.*, most specific family first:browser(web, pwa, ext) beforedom;mobile, thennative(ios, android), beforedom;termstorage.*
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).
| Specifier | Result |
|---|---|
./x, ../lib/y | a project file (per-target variants first) |
roots/runtime, roots/backend, roots/router, roots/ui, roots/platform, roots/data | the runtime |
node:fs … (and bun: modules) | allowed on term only (left as is); elsewhere B121 |
vue, @scope/pkg, pkg/sub/path | an 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/path | B122: 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:
- theme variables (
--roots-primary…) with their dark-mode values (Theme), - base styles of the host primitives (
Boxis a flex column, form controls, focus ring), - the target's own CSS (extension popup size), then the stylesheets of runtime modules in the graph: a runtime module
x.lispwith a siblingx.cssships it (the UI kit's DOM skin,runtime/ui/skin.dom.css, which the terminal never imports), - the utilities actually used, in vocabulary order, then variants (states, dark, breakpoints
sm<md<lg), so later rules win predictably (hiddenbeatsflex-row), - 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).
| Group | Utilities |
|---|---|
| Layout | flex-row flex-col flex-1 grow shrink-0 wrap `items-start |
| Gap | gap-N gap-x-N gap-y-N |
| Spacing | p-N px-N py-N `pt |
| Size | w-N h-N min-w-N max-w-N (N = 0–96 steps), w-full h-full w-1/2 |
| Position | hidden absolute relative overflow-hidden |
| Border | border border-round border-double border-<color> |
| Color | bg-<color> text-<color>, literal bg-[#rrggbb] |
| Type | bold italic underline dim strike `text-left |
| Effects | rounded 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.
| Variable | On | Default light | Default dark |
|---|---|---|---|
--roots-on-primary | primary | #111827 (white on #42b883 is 2.5:1) | #111827 |
--roots-on-success | success | #ffffff | #111827 |
--roots-on-error | error | #ffffff | #111827 |
--roots-on-info | info | #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:
| Variable | Light | Dark | Use |
|---|---|---|---|
--roots-warning-text | #b45309 | #d97706 | warning 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 | #6b7280 | the 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::1when 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 anHttpOnly; SameSite=Strictcookie, then 403 without it). The cookie is namedroots_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&inlinechange 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.
Hostmust belocalhost,*.localhost,127.0.0.1or[::1]with the server's port (plus:dev :hosts, and IP literals for token holders), against DNS rebinding; aHostwithout a port is only accepted on port 80. A presentOriginmust be the server's own. Fetch Metadata: a request whoseSec-Fetch-Siteis notsame-originornoneis served only as a top-level navigation (Sec-Fetch-Mode: navigateandSec-Fetch-Dest: document, a link to the app); subresource requests and frames from other sites get 403. No CORS headers at all; every response hasCross-Origin-Resource-Policy: same-origin,X-Content-Type-Options: nosniff,Cross-Origin-Opener-Policy: same-origin,Referrer-Policy: no-referrerandCache-Control: no-store. - No framing by other origins. Every response has a
Content-Security-Policywithframe-ancestorsand anX-Frame-Optionsheader: 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:andblob:URLs, or exactly the:img-srclist of roots.lisp. So an image that shows in production shows underroots devandroots previewtoo. - Notification-only reload channel.
/__roots/eventssendsok(with the build's warnings),reload,cssandbuild-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 ownEventSourcemay open it (Sec-Fetch-Site: same-origin,Sec-Fetch-Dest: emptywhen 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 andtextContentand styled through the CSSOM, so it needs nothing from the page's CSP. - Secrets stay out. Dotfiles,
*.pem,*.key,*.p12,*.pfx,*.crtand symlinks inpublic/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,Originand the threeSec-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.
| Panel | Shown when | Goes away |
|---|---|---|
| Build failed (full page) | the build or its link check failed | when the next build is good |
| Run-time error (full page) | the page threw an error nobody handled | Close, or Escape |
| Build warnings (bottom of the page) | the last build had warnings | Close; 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 onlysrc/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 listThe 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 reloadThe 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 8080roots 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 terminalscreen 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 / pwa | ext | ios / android | term | |
|---|---|---|---|---|
| storage | localStorage (<name>:key; memory when blocked) | chrome.storage.local, else localStorage | the storage plugin (Roots shell) or Capacitor Preferences, else localStorage | $XDG_CONFIG_HOME/<name>/storage.json (0600, atomic writes) |
| clipboard | async Clipboard API | async Clipboard API | the clipboard plugin or Capacitor Clipboard, else browser | pbcopy / wl-copy / xclip / clip, write falls back to OSC 52 |
| openUrl | window.open(…, "noopener") | chrome.tabs.create | the browser plugin (http/https; mailto/tel go to the system) or Capacitor Browser, else browser | open / xdg-open / rundll32, no shell |
| notify | Notification API | Notification API | LocalNotifications if installed (Capacitor), else browser | notify-send / osascript (argv, never interpolated) |
| info.platform | navigator.platform | chrome-extension / moz-extension | ios / android (RootsNative.platform or Capacitor's) | process.platform |
| theme | data-theme on <html>, kept in localStorage (<name>:roots-theme), theme.js before the first paint | same | same | the backend's color scheme (dark: variants, :theme :dark colors), kept in the storage file |
| screen | the viewport (resize), window.scrollTo | same | same | the mounted screen's columns × rows, the page's scroll offset |
| updates | pwa: service-worker update flow; onError when an install fails (a file missing or not the build's bytes; the running version stays); web: never | never | never | never |
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.
| Code | Meaning |
|---|---|
| B100 | project directory or roots.lisp not found |
| B101 | roots.lisp cannot be read (syntax, a refused # ' ` , ` |
| B102 | roots.lisp is not exactly one property list |
| B103 | a value that is not data (symbol, dotted list, character, vector…) |
| B104 | unknown or duplicate key |
| B105 | wrong type or malformed value (name, id, version, colour, path…) |
| B109 | unknown target, or a target not enabled in :targets |
| B111 | entry component missing |
| B120 | an import cannot be resolved |
| B121 | an npm package that is not a dependency (or roots-pm not loaded), unknown roots/… module, or node: outside the term target |
| B122 | remote, inline or absolute import; unsupported file type |
| B123 | an import of a package the importer does not declare (a phantom dependency; the message names the chain) |
| B124 | the TypeScript or SFC compiler is not available in this toolchain |
| B125 | an 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 |
| B126 | two sources compile to the same module |
| B127 | a file in public/ collides with a generated file |
| B128 | a 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 |
| B129 | Lisp 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) |
| B130 | a runtime module is missing from the toolchain |
| B131 | Lisp compile or read error |
| B132 | bundler: 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 |
| B133 | bundler 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 |
| B134 | bundler 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 |
| B135 | bundler: a namespace member assigned, updated or deleted (ns.x = 1, ({ a: ns.x } = o)): ESM throws; export a function from the module instead |
| B136 | bundler: a package file without import/export and without CommonJS markers is a script, not a module, unless its package.json says "type": "module" |
| B137 | bundler: 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) |
| B140 | internal: refusing to emit a dotfile, or a path that is not a plain relative path (.., empty segments, control characters) |
| B141 | unsafe 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 |
| B151 | a --native or --binary step (Gradle, xcodebuild, npm / npx cap, bun) failed |
| B152 | a plugin in :native :plugins has no implementation for this platform (built in, or native/<platform>/<Name>Plugin.*) |
| B153 | a file in native/<platform>/ has the name of a built-in plugin |
| B154 | warning: a file in native/<platform>/ that is not a source file was ignored |
| B155 | warning: icon.png is not 1024×1024, so iOS uses the default icon |
| B156 | warning: --binary needs Bun, which is not installed (skipped) |
| B157 | warning: 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 |
| B158 | warning: 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 |
| B160 | unknown utility class (warning; error with --strict or :strict t) |
| B163 | warning: 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 |
| B164 | warning: 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 |
| B165 | warning: icon.png is not a PNG (the default icon is used) |
| B166 | warning: icon.png is not square |
| B167 | warning: the compile cache directory is not a directory only you can write (or is a link); it is not used |
| B168 | warning: 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 |
| B171 | the port is in use |
| B172 | the dev server and roots preview only serve DOM targets (use roots dev --target term for the terminal; a terminal build runs by itself) |
| B180 | command-line usage |
| B190 | roots new: invalid name or non-empty directory |
| B191 | internal error: a condition the toolchain did not expect (a bug; ROOTS_DEBUG=1 shows the backtrace) |
| B192 | a 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 neededroots 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 testis 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 --binaryturns 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.