feat: bun, biome, and strict typescript foundation for 2.0 - #1245
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.lockis gone in favour ofbun.lock, the Dockerfile builds onoven/bun, and all six CI workflows now useoven-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
tscwired into both CI and a pre-commit hook. Onlyapp/is strict today, sincesrc/andserver/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.
bcryptbecomesBun.password,node-fetchbecomes the nativefetch, and the.custom.jsxoverride plugin is deleted outright.Breaking changes
.custom.jsxand.custom.cssoverrides no longer apply. The mechanism let anyone drop aFoo.custom.jsxbeside 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 --unsaferun 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:noThisInStatichad rewritten 95 static call sites inserver/src/models/fromthis.query()to the hardcoded class name. That reads as harmless, and it was cleared as safe once on the grounds that every model extendsModeldirectly. True, and beside the point.DbManageronly binds the base class for the fivesingleModels. The twelve scanner-backed models get their connection throughbindKnex(), 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 boundSubModel.The revert alone would not have held, either.
noThisInStaticis part of Biome's recommended preset, so the nextbun run formatwould have quietly put all 95 back. The rule is now off, next tonoImportantStyles, 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
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/andserver/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 seesapp.htmlare the next plan.🤖 Generated with Claude Code