Manual Build

Build

elements man build Read as markdown

A build in Elements means doing everything required to get an application ready for delivery: installing packages, compiling, migrating, testing, and finally releasing the new application to the .elements/release directory. You don't run individual commands to get the app ready. The project server does this automatically as you save files.

The Build Loop

The build loop is the same idea as the JavaScript event loop, applied to the build system. It's a concurrent-safe sequential task loop. File change events, manual build requests, install commands, and test runs are all sequenced through it, so concurrent clients never conflict and no two pieces of work step on each other. A single-file save often builds and hot-reloads in a few milliseconds.

Verifying UI

A green build means the code compiles, not that the page looks right. When you change a template or a stylesheet, look at the rendered route.

If the app is running (elements start, serving http://localhost:4000) and a Chromium-based browser is installed, screenshot the route headless and open the image:

# Windows: chrome.exe/msedge.exe. Linux: google-chrome.
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --headless=new --disable-gpu --hide-scrollbars --window-size=1200,900 \
  --screenshot=/tmp/page.png "http://localhost:4000/your-route"

Then open the image. For a phone layout set --window-size=390,844.

You do not need Playwright, Puppeteer, chromedriver, or any npm install, and you should not add one. Do not report a layout as unverified because a browser automation library is missing: look for a Chromium binary first, and only say you could not look if there is none.

To click, or read state back, drive the same Chrome over the DevTools Protocol: start it with --remote-debugging-port=9333 and talk to it from a short script.

The Project Server

The project server is started automatically when you work in a project. It stays running between commands so multiple clients can connect concurrently and see the same build state. Your terminal, the editor LSP, and any agents running in parallel all talk to the same server. State is cached and incremental, so a second elements build -json against an unchanged project answers in microseconds without redoing work.

In development the server idle-shuts-down after a period of inactivity. You can stop it manually with elements kill from inside a project directory, but you shouldn't need to.

elements start

Builds and runs your program. Pass a file to run a different program.

The output is dual-mode:

  • When there are build errors, it opens a terminal view that lets you navigate the errors.
  • When build errors are at zero, you see the green OK state and the app logs flow as normal.

An agent should start the app with elements start & once there is something to look at, unless it is already serving, which means the user started it. The & matters: the app then dies with the agent's session, where nohup strands a server the user cannot stop.

elements build

elements build shows you the build state. It does not start any user programs. Unlike elements start, there's no application running underneath and no app logs in the output, just the build view itself. Green OK means everything is good. Red Error means something needs fixing. The view watches for changes automatically.

elements build                          # build view, watch mode (in a tty)
elements build -json                    # structured json (one-shot, ideal for agents)
elements build app/pages/*.ts           # shell glob expands to matching files
elements build -tgz                     # release as a gzipped tarball to stdout

These commands return the latest build state. They don't trigger a build by themselves; the server is already building in response to file changes.

-tgz writes a gzipped tarball of the release directory to stdout. Pipe it wherever. There's no reason to reach into the .elements directory yourself.

elements test

Tests run as part of every build. Test results are delivered alongside compile errors in the build state.

elements test gives a test-specific view: a pass/fail test tree with errors listed below. Compare to elements build, which only shows errors. Use elements test when you want the test tree directly.

elements test                  # test tree view, watch mode (in a tty)
elements test -json            # structured json

If tests already ran as part of the build and nothing has changed, elements test returns the cached results instantly. If the dependencies of a test have changed, only the affected tests run.

-json

Every command that reports diagnostics takes -json: one shot, structured, and exit 1 when there are errors. The envelope is the same for every command.

{
  "path": "/abs/path/to/project",
  "ok": false,
  "errorCount": 1,
  "warningCount": 0,
  "message": "The build has 1 error.",
  "buildGen": 42,
  "buildRanAt": "2026-08-21T09:15:06.612-07:00",
  "elapsedSeconds": 0.259,
  "diagnostics": [
    {
      "code": 200012,
      "level": "error",
      "message": "Test equality failed. Got: 1, Want: 2.",
      "path": "app/pages/home/test.ts",
      "loc": { "start": 220, "finish": 231, "line": 13, "column": 5 }
    }
  ]
}

Read ok for the outcome; message says the same thing as a sentence. Diagnostic paths are relative to path (files outside the project stay absolute), and loc.line/loc.column are 1-based. Source text is not included: read the file when you want the code around a diagnostic.

The answer always reflects what is on disk. Write a file and ask in the same command: the query waits for your edit to build rather than returning the state from before it. buildGen moves only when a build runs, so comparing it across two calls says whether anything rebuilt; buildRanAt is when that build finished. elapsedSeconds times the request, not the build.

elements test -json adds a tests object: total, passed, failed, and a files array with each file's pass/fail tree. A failing assertion is also a diagnostic, with a breadcrumbs trail naming the test.

elements install

Package installation is part of the build. elements install <pkg> adds a package to your project and the project server immediately picks it up, fetches the package, updates the dependency graph, and rebuilds against the new version. Installation is part of the build, not a separate step you run yourself.

elements install @elements/style
elements install lodash dayjs
elements install stripe@^14

Dependencies live under the package namespace in config.jsoc, and the resolved set is written to package.lock (generated; don't edit by hand). Edits to the package.dependencies map in config.jsoc are picked up automatically: save the file with a new dependency and the project server installs it as part of the next build, the same as if you'd run elements install.

The installer runs through the same build loop as everything else, so it's sequenced with file changes, type checks, and test runs. Two concurrent installs don't conflict, and an install never interleaves with a partial build.

The .elements Directory

.elements/ is the project's working directory. Don't modify it by hand. You'll occasionally want to look at logs under .elements/logs/ when debugging the project server.

  • .elements/build/ is the scratch directory for the in-progress build.
  • .elements/release/ is the atomic release directory. In development it's a symlink to the current build. In production it's updated in one shot, when a new build is fully ready, so a live service never sees individual file drift mid-deploy.

If you need the release as a tarball, use elements build -tgz.

Modules and Emit

Elements emits a release graph structured for fast hot reloads and aggressive browser caching. Four properties matter:

  1. All reachable program files ship. Elements walks the import graph from the program entry points and writes every file the program actually reaches, including reachable node modules. Nothing the program can't reach ships in the release.

  2. Build-time resolution equals runtime resolution. Elements rewrites every package import path to point directly at the resolved file, rather than relying on Node.js resolution semantics at runtime. If a path resolved to a particular file at build time, that's the exact file you'll resolve to at runtime. By construction.

  3. CJS at runtime on both targets. Every module is transformed to CJS, on browser and server. Two reasons. First, hot reloading Node.js requires CJS. Second, the browser supports ESM, but not every npm package is written in ESM, and converting ESM to CJS is the safe direction. Converting CJS to ESM safely isn't always possible. CJS at runtime keeps both targets compatible with every package, and keeps hot reload working everywhere.

  4. Browser modules are linked via a small loader. Each html page automatically embeds a tiny module loader. The loader lets modules require other modules across different HTTP assets. They don't have to be bundled into one file. Each browser file name carries a content hash, and Elements tells the browser to cache those URLs forever. Cache invalidation is just a URL change.

Why It Matters

  • Hot reloading gives near-instant feedback from code change to running app.
  • Tests run near-instantly.
  • The browser's require system works across disparate hashed-URL assets, so a small code change invalidates a small number of URLs and the rest stay cached at the edge.

Build Caching and Versioning

Elements has a sophisticated graph versioning system. Every source has a version derived from its content plus the versions of everything it depends on. Type checking, compilation, and emit each consult that version before doing any work. If the version hasn't changed since the last run, the cached result is reused. If it has, the affected node and its dependents recompute and nothing else does.

Work happens only when it has to, at every layer: watching, scanning, parsing, binding, type checking, emit, hot reload, and the wire protocols between clients and the project server. A change deep in the graph rebuilds exactly the affected subgraph, and nothing else.

Build Transforms

A small set of compiler transforms run during the build to make code easier to write:

  • RPC. Functions marked @rpc are callable from the browser. The compiler securely rewrites browser call sites into network requests and strips the function body out of the browser bundle.
  • Sync-style async. Participating function calls (sql, tx, Channel, LiveTable, @rpc) are automatically converted to await calls, and the surrounding function declaration becomes async. The conversion propagates up the call stack.
  • Server-only stripping. Server-only code is removed from the browser bundle automatically. You do not mark anything: the compiler knows which declarations are server-only and follows the call graph. Calling one (sql, tx, session.login) from browser-reachable code is a compile error pointing at the call site, so a query or a secret cannot reach the browser by accident.
  • Module-level tree shaking. Unused exports are dropped from the output, file by file. Combined with content-hashed URLs, this keeps the wire payload minimal and cache invalidation precise.

Config and Env

Elements evaluates environment variables at build time. Every env reference resolves against the active .env file as part of the build, so missing variables, typos, and wrong-typed values fail the build before they reach a release. Because the values are resolved at build time, constant folding works perfectly off them. For example:

if (process.env.ENV === "production") {
  // production-only code
} else {
  // dev-only code
}

The compiler folds the comparison against the actual value of ENV and drops the unreachable branch from the output entirely.

JSOC config files follow the same model. Every env(...) call inside config.jsoc resolves at build time, and the compiler emits the result as a plain JSON file. At runtime your app reads that JSON directly. There's no JSOC parsing and no env lookup at runtime.

Full config syntax: elements man config.

TypeScript

TypeScript's Golang source code is integrated directly into the Elements build pipeline. The same Go binary that runs the build also runs the TypeScript checker, against the same in-memory program graph. There is no separate tsc process to spawn, no JSON-RPC across a language server boundary, no per-file invocation cost. Checking happens incrementally as part of the build loop, on the same thread that watches your files.

This native integration is also what makes the Elements html and JSOC languages work the way they do. Both are implemented directly on top of the TypeScript language tooling, so they participate fully in TypeScript's binder, symbol resolution, and type checker. A template attribute is a real TypeScript declaration. A reference to a template name resolves through the normal symbol table. The expressions inside {} blocks are checked against the surrounding scope by the same checker that checks plain .ts files. There is no parallel type system to learn or maintain, and there is no awkward seam where TypeScript ends and Elements begins.

TypeScript options live under build.typescript in config.jsoc, a flat mirror of tsconfig's compilerOptions, limited to the options Elements honors. There is no tsconfig.json in an Elements project.

Elements also replaces TypeScript's source graph and version-tracking machinery entirely. Upstream tsc (and tsgo) re-discover and re-version files via a separate program structure. Elements drives type checking off its own source graph, the same graph that tracks dependencies for the build cache, the test runner, hot reload, and the deploy. One graph, one version per source, one source of truth across every consumer of the build. The practical effect is faster checking, more precise incremental rechecking when only a few files have changed, and consistent type checking behavior between the build and the deploy.

The project server also speaks the Language Server Protocol, giving editors full integration with the Elements languages and replacing the native tsserver program. VSCode and its derivatives get this support automatically. The extension is installed for you when you install Elements.

Peer-to-Peer Deploys

The build system runs peer-to-peer on deploy. A project server on the deploy machine talks directly to your local project server over the SSH tunnel. The two diff their source graphs, transfer only what changed, and the remote side does a minimal incremental build against its own cache. Deploys frequently complete in under a second.

Performance

A single-file save often builds and hot-reloads in a few milliseconds. Several factors compound:

  • Graph versioning means most work is cached. A second build that touches no sources returns instantly.
  • The TypeScript checker runs in the same process as the build, against the same graph.
  • Hot reload patches the running process or browser at the smallest level required by the change.
  • Peer-to-peer deploy reuses the same versioning and caching, so production releases are as incremental as development builds.

Related

  • Install: elements install <pkg> adds a package and the project server picks it up automatically.
  • Test: elements man tests. Tests run as part of the build by default.
  • Migrate: elements man migrations. Migrations run as part of the build.
  • Deploy: elements man deploy. Deploys use the same build system end to end.