Build & runtime
Tally has no framework and no bundler output to speak of, but it does have
a build. This is the shape of it: what turns app/js/ into the
files a browser actually loads, how the app keeps working offline, and how
an installed copy learns that a new version exists.
Source and served are different trees
Everything is authored under app/js/ and
app/ui-modules/, but app/index.html loads
dist/js/app.js. app/dist/ is build
output — gitignored, produced by npm run build (the
Docker image runs it while building the image), and never edited by hand. Editing a file there is silently undone by the next build,
so if a change appears to have no effect, check which tree it landed in.
Templates, CSS and icons are not copied: they are served straight
from app/ui-modules/ and app/css/, so only
JS/TS passes through the build.
The build: per-file, not bundled
scripts/build-client.cjs runs esbuild with
bundle: false, transpiling every .ts and
.js file individually into app/dist/ with the
directory layout preserved. One source file, one output file. That choice
buys three things:
- Import paths never change.
import "./foo.js"resolves to./foo.jsat runtime whetherfoois authored as JS or TS — so migrating a file to TypeScript touches nothing but that file. - Lazy loading keeps working. Dev seeds and the vendor loader fetch modules by path at runtime; a bundle would have dissolved those paths.
- The output stays readable, which matters for a project whose stated aim is to be legible.
app/js/vendor/*.js passes through untouched — it contains no
TypeScript syntax, so esbuild emits it verbatim. Watch mode
(--watch) is mtime-driven and incremental; cold builds touch
everything once.
Why some files are TypeScript
The codebase is migrating to TypeScript selectively, not wholesale.
Data-heavy modules that define or normalise structured state — the
data model,
the ledger, storage and migrations — are
TypeScript, because that is where a wrong shape is expensive and where the
types document the contract. UI modules under app/js/ui/ and
app/ui-modules/ stay JavaScript. npm run typecheck
runs tsc --noEmit; esbuild itself does not type-check.
Offline shell and updates
Tally is an installable PWA that must work with no network — a debt you
recorded offline is still a debt. app/sw.js is a
precache + cache-first service worker: at build time the
same script writes dist/precache-manifest.js, listing every
static asset with a content hash plus one combined version hash. On install
the worker fetches that whole list into a versioned cache; afterwards it
serves from cache, so the app boots instantly and offline.
Because the manifest's bytes change whenever any cached file changes, the
browser's byte-comparison of the worker detects an update on its own — no
version string to remember to bump. The browser only runs that comparison on navigation, though, so a
desktop tab left open for days would never notice; the app therefore
also asks for a check whenever it regains focus or comes back online, and
every 30 minutes while it stays open. The new worker installs into the
waiting state and deliberately does not
auto-activate: js/ui/app-update.js surfaces "update available"
as one piece of state (a nav badge, a settings banner, a toast), and only
when the user accepts does the app post SKIP_WAITING and
reload. A refresh therefore never lands in the middle of recording a
transaction.
app/sw-crypto-entry.js is the one place a real bundle is
produced (an IIFE at dist/sw-crypto.js): the service worker is
a classic worker that cannot import the app's ES modules, and it needs
secp256k1 to decrypt push hints —
which WebCrypto cannot do. See
TIP-005.
Two version numbers, two jobs
They are unrelated, and conflating them causes confusion:
- The precache version is a content hash, computed at build time. It decides when the browser fetches new files.
app/data/version.jsonis a hand-bumped semver (scripts/bump-version.sh) read byjs/version.js. It gates local data concerns, not assets. Note that persisted state is versioned separately again, byDATA_MODEL_VERSIONand the storage migrations.
Running it
npm run dev starts the build in watch mode and the Node server
together (port 3000, nodemon restarting the backend). The server does
double duty — static files and the WebSocket relay — for the reasons in
Communication.
| Command | Serves | Port |
|---|---|---|
npm run dev | the Tally app | 3000 |
npm run dev:design | this site | 4000 |
npm run devAll | app, GraphEditor, Design, and the landing site together | 3000 / 8088 / 4000 / 3001 |
node server/server.js | production; the Docker entry point, after npm run build | $PORT, else 3000 |
Under devAll each child gets its port explicitly, because all
four inherit one PORT from the parent otherwise and collide.
Test tooling — the seeded Alice/Bob/Carol identities, the headless peer
helper, and the two-client end-to-end scenarios — is covered in
Tooling.