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:

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:

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.

CommandServesPort
npm run devthe Tally app3000
npm run dev:designthis site4000
npm run devAllapp, GraphEditor, Design, and the landing site together3000 / 8088 / 4000 / 3001
node server/server.jsproduction; 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.