Skip to content

feat: bun, biome, and strict typescript foundation for 2.0 - #1245

Merged
TurtIeSocks merged 20 commits into
v2from
feat/bun-biome-foundation
Aug 24, 2026
Merged

feat: bun, biome, and strict typescript foundation for 2.0#1245
TurtIeSocks merged 20 commits into
v2from
feat/bun-biome-foundation

Conversation

@TurtIeSocks

Copy link
Copy Markdown
Collaborator

First of five plans for 2.0. This one is all groundwork: it swaps the toolchain and clears out the dependencies that would have made the rest awkward, without touching how anything actually behaves. No UI, no routing, no schema work. Those come later.

What changed

Bun replaces Node and Yarn as runtime, package manager, and test runner. yarn.lock is gone in favour of bun.lock, the Dockerfile builds on oven/bun, and all six CI workflows now use oven-sh/setup-bun.

Biome replaces ESLint and Prettier. One tool, one config, and it checks 629 files in under 100ms where the old setup took long enough that nobody ran it locally.

TypeScript is now checked strictly, with tsc wired into both CI and a pre-commit hook. Only app/ is strict today, since src/ and server/ still hold 531 @ts-check'd JavaScript files carrying 612 latent type errors. Those get converted per-plan rather than in one heroic sweep, and the gate stops new ones arriving in the meantime.

Tailwind v4 arrives with a second Vite entry at app.html. It renders one line of placeholder text right now. Nothing serves it yet, which is deliberate: the point was to prove the two-entry build works before anything depends on it.

Three dependencies leave. bcrypt becomes Bun.password, node-fetch becomes the native fetch, and the .custom.jsx override plugin is deleted outright.

Breaking changes

.custom.jsx and .custom.css overrides no longer apply. The mechanism let anyone drop a Foo.custom.jsx beside any source file and silently replace it at build time, with only a build warning. It was never officially supported, and the 2.0 client restructure would have broken every existing override with no compiler signal at all. Better to remove it deliberately than to let it break by accident. Anyone relying on it will need to fork or send the change upstream.

Password hashing moves to Bun.password. Existing bcrypt hashes still verify, including $2a$, $2b$ and $2y$ prefixes at cost factors 4 through 12, all checked against real fixtures generated by bcrypt 5.1.1.

Two things the final review caught

Worth calling out, because both would have shipped and both came from the same root cause.

A single biome check --write --unsafe run early in the branch rewrote code semantically while the test suite stayed green. Most of that damage was found and restored at the time. One piece was not: noThisInStatic had rewritten 95 static call sites in server/src/models/ from this.query() to the hardcoded class name. That reads as harmless, and it was cleared as safe once on the grounds that every model extends Model directly. True, and beside the point. DbManager only binds the base class for the five singleModels. The twelve scanner-backed models get their connection through bindKnex(), which lives on a runtime subclass, so static dispatch through that subclass is the whole mechanism. Naming the base class targets a class that is never bound in any configuration, and every map query would have thrown on first load. No test caught it because none exercises a model through a bound SubModel.

The revert alone would not have held, either. noThisInStatic is part of Biome's recommended preset, so the next bun run format would have quietly put all 95 back. The rule is now off, next to noImportantStyles, which is already off for exactly the same reason after the earlier round of this.

The second one was in the 72-byte bcrypt fallback. bcrypt truncated its input at 72 bytes and Bun does not, so old hashes need a retry against the prefix. The retry worked. The problem was what came after: on success it re-hashed the full submitted password and wrote it back, and the bytes past 72 had never been verified against anything. Someone with a 90 byte password, whose tail has been ignored for years, logs in with a slightly different tail and that typo silently becomes their real password. There is no reset flow, so they would have been locked out until someone edited the database by hand. The retry stays, the write is gone, and there is now a test asserting that a login with a different tail performs zero writes.

Verification

bun install --frozen-lockfile   ok
bun test                        76 pass, 0 fail, 11 files
bun run typecheck               ok
bun run lint                    629 files, clean
bun run build                   ok, both entries emitted

Docker builds and runs, reaching the expected database-config failure with no module resolution problems. The server boots the same way under bun server/src/index.js. The two Vite entries emit separate stylesheets, so Tailwind's global reset stays out of the 1.0 bundle. Every task was reviewed before landing, and the whole branch went through three separate review passes at the end covering auth, boot, and behavioural regressions in the existing client.

What this plan does not do

No existing file is converted to TypeScript. src/ and server/src/ are untouched beyond the two dependency swaps and the model fix above. There is no shadcn, no MapLibre, no deck.gl, and no UI. Routing and the per-user flag that decides who sees app.html are the next plan.

🤖 Generated with Claude Code

TurtIeSocks and others added 20 commits August 23, 2026 19:02
Bun becomes the package manager, runtime and script runner. CI and the
Dockerfile follow. The Prettier CI step is removed here and folded into
lint in a later commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Without root:true, eslint walks up out of a nested worktree into the outer
checkout's config, loads eslint-plugin-react twice and refuses to run. Any
project-root config should set it; the bug only shows up when the repo is
checked out inside itself.

Also registers bun:test as a core module so the import resolver stops
reporting it unresolved.

Both settings die with the file in the biome swap. They are here so commits
can happen until then.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Import swap plus three API differences. t.mock.method becomes
spyOn().mockImplementation(), and mock.module takes a factory rather than a
namedExports object. node:test auto-restores mocks between tests and bun:test
does not, so files that mock now restore explicitly in afterEach.

bun:test also has no before/after exports and no per-test cleanup hook, so
file-level hooks became beforeAll/afterAll and the twelve per-test t.after
calls became try/finally.

Assertions are unchanged. node:assert/strict works as-is under Bun.
eslint:recommended turns on no-undef and Bun is in no eslint env, so any use
of Bun.password or Bun.serve fails the pre-commit hook. Verified: without the
global the probe reports one no-undef error, with it none.

Dies with the file in the biome swap, like the other two settings here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bun.password produces and verifies $2b$ bcrypt hashes, so existing stored
password hashes keep working, covered by a test using a hash generated by
bcrypt@5.

One behaviour change: bcrypt silently truncated passwords at 72 bytes, so
every byte past the limit was ignored. Bun pre-hashes longer passwords
instead, keeping every byte significant. Both behaviours are covered by a
test. A consequence is that a hash bcrypt@5 wrote for a password longer than
72 bytes will no longer verify, since it encodes only the first 72 bytes.
Any such account needs its password reset.

Removes the only native dependency in the tree.

BREAKING CHANGE: ReactMap now requires Bun rather than Node. Operators must
install Bun and rebuild their images.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bun provides a WHATWG-standard global fetch and Response, so every
import is redundant. fetchJson keeps its exact signature, including
the deliberate 404-is-a-miss behaviour for by-id endpoints, which is
now covered by a test.

Also drops node-fetch and its types from the locales and masterfile
workspace packages, and updates packages/locales/lib/utils.js, which
the plan's file list omitted but which imported node-fetch too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bcrypt truncated its input at 72 bytes, so every hash written before the move
to Bun.password encodes only that prefix. Bun pre-hashes the whole input
instead, so a password longer than 72 bytes stopped matching its own stored
hash. With no reset flow, and auto-registration limited to usernames that do
not already exist, an affected account returned invalid_credentials
permanently and needed operator database surgery.

On a failed verify, retry against the first 72 bytes and, when that succeeds,
re-hash the full password and update the row. Accepting the prefix is what
bcrypt itself did for years, so it gives up nothing relative to the previous
behaviour, and each account takes the path at most once.

The length check measures bytes via Buffer.byteLength and the retry slices a
Buffer rather than the string, because a character boundary is not a byte
boundary for any non-ASCII password. The slice may land inside a multi-byte
character, which is why it stays a Buffer: re-encoding it would change the
bytes bcrypt hashed. Hashing still covers the full password.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Records the later rounds of goals: no obligation to any public surface beyond
a migration path from the existing tables, uicons 3.x, the pogo-masterfile
package, drizzle over knex and objection, dropping multi-domain, no untyped
baggage, express as an open question, passport being dead enough to plan
around, first-class local auth, and an entitlement API for external billing.

Blast radius is measured rather than guessed where it matters: 40 call sites
for configurable table names, 25 references to multi-domain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One tool instead of two. The formatter config reproduces the previous
prettier settings; the linter config re-disables the rules the airbnb
config had turned off, plus a documented set of rules that biome's
recommended preset enables but the old eslint config never ran (mostly
because eslint-plugin-react-hooks was installed but never wired into
.eslintrc's extends, and .d.ts files were excluded from both eslint and
prettier entirely), so this stays a tooling swap rather than a style or
behavior change.

Three of those disables exist because their unsafe autofix rewrites
behavior rather than style. useExhaustiveDependencies edits hook
dependency arrays, which changes when an effect or memo re-runs.
useValidAriaRole strips any prop named role, including the non-ARIA one
the Footer component reads for its locale key lookup. noImportantStyles
drops !important from declarations that exist to outrank leaflet's own
stylesheet. Each is worth revisiting as its own reviewed change with
tests, not as a side effect of a formatting pass.

Two stylesheet fixes do change rendering and are called out rather than
hidden: border-spacing gains a valid px unit in place of an invalid 1x
that browsers were discarding, and the Roboto font stack gains a
sans-serif fallback.

Also removes the four eslint/prettier scripts from all six workspace
packages under packages/, which the tool swap alone would have left
pointing at binaries that no longer exist, and drops the matching
resolutions overrides and vite-plugin-checker's eslint integration.

Drops ten devDependencies and the lint-staged layer. Biome checks staged
files directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ReactMap ships no payment integration. No provider SDKs, no webhook parsing,
no billing state. It exposes a generic entitlement API and anyone who wants
a payment flow builds it themselves and calls in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 5's biome --unsafe rewrite converted @ts-ignore to @ts-expect-error
at 58 sites without checking whether an error still existed on the next
line. @ts-expect-error itself errors when the following line is clean,
which @ts-ignore never did. Running tsc for the first time on this repo
surfaced 13 sites where the directive now suppresses nothing, so it is
removed.
tsconfig.json replaces jsconfig.json with the same options and aliases,
so the editor experience over the existing @ts-check'd JS is unchanged.
tsconfig.app.json is strict and covers only app/, which is where 2.0
code goes; converting the existing 531 files is a separate plan.

The typecheck script and CI/pre-commit gate run tsc -p tsconfig.app.json
only, not the root config. Running tsc -p tsconfig.json for the first
time surfaced 612 pre-existing errors across 140 files: the @ts-check
pragma forces per-file checking regardless of the project's checkJs
setting, so nothing in this codebase has ever actually been verified by
tsc before now, editor squiggles aside. Gating on the root config would
make every future commit fail on unrelated debt, so tsconfig.json stays
available for editor tooling but out of the automated gate until that
debt has its own cleanup plan.

vite-plugin-checker's typescript block now points at tsconfig.app.json
for the first time; its eslint key was already removed in Task 5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Running tsc against this repository for the first time surfaces 612 errors
across 139 files. A ts-check pragma forces per-file checking regardless of
checkJs being false, and 531 files carry one while nothing ever ran the
compiler.

Any plan to convert existing source to TypeScript starts from 612, not zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
app.html and app/ build alongside the 1.0 entry from one vite config, so
both bundles land in dist/ and a per-user flag can choose between them
later. app/ renders a placeholder for now.

Tailwind v4 is CSS-first, tokens live in app/styles.css and there is no
tailwind.config.js. The new entry deliberately omits user-scalable=no.

Also moves rollupOptions.input to where vite actually reads it; it was
previously a sibling key and silently ignored.

app/build.test.ts asserts on the resolved vite config's
build.rollupOptions.input rather than dist/ artifacts, since CI runs its
Test step before its Build step against a fresh checkout with no dist/.
The build itself is proven by CI's own Build step.

Enables tailwindDirectives in biome's css parser so @theme in
app/styles.css does not trip the linter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
manualChunks funneled every CSS module into a single "index" chunk, a
rule that predates the second Vite entry and was harmless while
index.html was the only one loading it. Once app/styles.css pulled in
Tailwind's Preflight reset, that reset started shipping inside the
stylesheet the 1.0 map UI also loads, stripping MUI borders and
typography spacing.

CSS under the app directory now resolves to its own "app" chunk, keyed
on the resolved absolute path so a dependency merely containing
"/app/" in its path can't false-positive into either bucket. The 1.0
entry keeps the untouched "index" chunk and the byte-identical
pre-task-7 stylesheet; the 2.0 entry keeps its Tailwind reset, scoped
to itself.

Added a regression test asserting the resolved manualChunks function
puts app and legacy CSS in different chunks, without requiring a build
to have run first.
The .custom.jsx mechanism let an operator drop a Foo.custom.jsx or
Foo.custom.css beside any source file and have Vite silently swap it in
at build time, backed only by a build warning. It was never officially
supported, and the 2.0 client restructure would break every existing
override with no compiler signal to catch it.

Removes the plugin, its wiring in vite.config.js, the hasCustom flag it
fed into CONFIG.client, and the type declaration for that flag. Sentry's
beforeSend hook also read CONFIG.client.hasCustom to suppress every
event when a custom file was present; that check is dropped rather than
hardcoded false, so operators who had custom files will now report
errors normally instead of having Sentry silently swallow them all.

BREAKING CHANGE: operators relying on .custom.jsx or .custom.css
overrides lose that mechanism. There is no replacement; drop the
override files or fork the source they were patching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
biome check --write --unsafe rewrote 95 static call sites in the model
layer from this.query()/raw()/knex() to the hardcoded class name. Models
in DbManager.validModels are reached through bindKnex subclasses, and the
connection lives only on that runtime subclass, so naming the base class
targets something that is never bound and throws "no database connection
available for a query" on the first map load.

Turn noThisInStatic off in biome.json so the next format run does not
put it back, alongside noForEach and noImportantStyles which are off for
the same reason.
The over-72-byte fallback verified the first 72 bytes and then re-hashed
the full submitted string into the row. Nothing ever checked the bytes
past 72, so a mistyped tail was accepted and then became the account's
canonical password, with no reset flow to recover through.

Keep the truncation fallback, which legacy accounts need to log in at
all, and drop the rewrite. That leaves the behaviour bcrypt@5 had for
years, which is the right target for a migration branch. The tests that
asserted the write now assert the row is untouched, including one for a
login whose tail differs past byte 72.
release, sentry, config and locales still used actions/setup-node with
cache: yarn, which hashes yarn.lock. That file is gone on this branch, so
setup-node failed with "Dependencies lock file is not found" before any
step ran and took config sync, locale sync, semantic-release and the
Sentry sourcemap upload down with it.

They now use oven-sh/setup-bun plus bun install --frozen-lockfile, the
same pattern lint.yml uses. config.yml never had an install step, so it
gets one. The semantic-release git assets list points at bun.lock.
Bun.password.verify throws on a password column holding null or anything
that is not a bcrypt hash, where the library it replaced returned false.
The throw reached the auth handler's outer catch, which logs and returns
without calling done, so the request hung rather than coming back as
invalid credentials. Discord and Telegram rows all have a null password,
so this was reachable without any bad data.

Anything unparseable now counts as a failed match.
@TurtIeSocks
TurtIeSocks merged commit 845eb7f into v2 Aug 24, 2026
2 checks passed
@TurtIeSocks
TurtIeSocks deleted the feat/bun-biome-foundation branch August 24, 2026 01:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant