nucleus-core-ts
v0.10.142
Published
Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs
Maintainers
Readme
nucleus-core-ts
A TypeScript framework for multi-tenant APIs on Bun: authentication and authorization, entity CRUD generated from a config file, storage, payments, chat, pub/sub, monitoring — and a typed client generated from the same declarations the server serves.
bunx nucleus-core-ts initWhere the documentation is
This file is an index, not a manual. The maintained documentation is:
.claude/skills/— 21 skills, one per subsystem (nucleus-authentication,nucleus-entities,nucleus-payment, …). These are the reference.public/— the docs site shipped in the package.src/types.ts— the configuration surface, and the last word when a document and the code disagree.
Other .md files in the repo are working notes or historical audit records.
docs/ in particular is an archive: it records what was true when it was
written and is not updated.
Entry points
| import | what |
|---|---|
| nucleus-core-ts | the server plugin and the services it lends a host app |
| nucleus-core-ts/client | the generated, typed API client |
| nucleus-core-ts/fe | React components (auth pages, admin panels) |
| nucleus-core-ts/proxy | server-side proxy client |
client, fe and proxy are independent of the HTTP layer below.
The HTTP layer is ours
The server half used to run on Elysia. It does not any
more, and not partially: there is no adapter, no swagger plugin, no static
plugin, no peer dependency, no import anywhere in the source, and nothing in
node_modules. The reason was never performance — nucleus-core is a product of
its own, and a product's HTTP layer, type system and public API shape should not
belong to a third project we do not control.
What that means, concretely:
One entry point.
createNucleusServer({ config, port })serves the whole product onBun.serve.NucleusElysiaPluginis gone; a consumer that used it changes its boot line and its route modules and keeps everything else.A host brings its own routes. This is what took longest and it is what every consumer was blocked on:
await createNucleusServer({ config: { options, schema, relations }, port: 3000, routes: [{ prefix: '/reports', routes: [defineRoute({ … })] }], onRequest: (ctx) => Response | undefined, // BEFORE the inbound guard and auth socket: defineWebSocketRoute({ … }), onStop: [async () => { /* close your pool */ }], })A group's routes sit behind nucleus's auth, exactly as they did when mounted on an Elysia carrying the plugin — that is the inherited contract. Something that must answer without authentication goes in
onRequest, which runs first: a public webhook that needs a verbatim body, an anonymous share link."No Elysia" is measured, not asserted.
noFrameworkGraph.test.tschecks it two ways that miss different things: every entry point is bundled with packages left external and must contain nofrom "elysia", and every source file plus the manifest is scanned, because a dynamic import never reaches a bundle.The suites that compared two servers now state one. With no second arm to agree with, "the two answer alike" became "this is the answer" — and the claims got stronger doing it, from 53 tests and 189 assertions to 74 and 422. Parity passed whenever both arms were wrong in the same way; an absolute cannot.
src/Server/holds the replacement — response semantics,defineRoute, the router, the WebSocket upgrade — and every route in the repo declares against it. See thenucleus-routingskill for how to declare one.The work, its measurements and its open questions live in
ELYSIA_MIGRATION.md. Read that before touching the server's HTTP edge.
What the migration found. Twenty defects that existed in the shipped product and that no test caught. Seventeen came from making the two paths answer the same question; the last three came from removing Elysia from the TESTS, which moved ~58 suites off Elysia's routing and validation and onto the router that actually serves them. The ones worth naming:
| | |
|---|---|
| login sent ONE cookie | access_token and refresh_token never reached the browser |
| the 3-D Secure callback was dead | Body already used since the bridge landed |
| a resumable upload wrote nothing | application/octet-stream bodies arrived as undefined, so the chunk was appended as nothing and the request answered success |
| auth ran AFTER validation | an unauthenticated caller was handed the schema of a protected endpoint — property names, types, and the sample the validator builds |
| a thrown handler or guard leaked | Bun's DevErrorPage carried the raw error text where the product answers a JSON envelope whose whole job is withholding it |
| 422s and 404s shipped bare | no nosniff, no HSTS, no x-frame-options, no request id — on 151 routes |
| stop() leaked and killed | the DB pool, Redis and a live interval survived every restart, and in-flight requests were cut where Elysia drains them |
| monitoring was blind | the request log, the collector and the live store recorded nothing on the Bun path |
| form bodies were not parsed | application/x-www-form-urlencoded fell through every branch |
| schema default: never applied | a defaulted field left out became a 422; so did an extra property, an absent optional body, and a malformed one |
| nested form keys stayed flat | a.b=1 arrived as a key called a.b, so body.a.b found nothing |
| a WebSocket upgrade skipped the guards | no rate limit, no tenant resolution, no header strip |
Two of those were the OTHER way round, with the SHIPPED path broken and the
replacement correct: multipart uploads never worked on Elysia at all, and a
failed insert came back from it as 500 text/plain carrying the schema name,
the table name and every column, because Elysia discarded the Response the
product's error responder returned. Both are gone with the path that had them.
And three found by taking Elysia out of the tests — all of them live on the only path that remains, all now fixed:
| | |
|---|---|
| the 422 handed the caller their own submitted values | POST /login with a bad email answered "password": "hunter2-the-real-one" — into the response, and into any log that records one. Every value in a refusal is its TYPE now. The parity walk could not see it: it sends an empty body, so found was always {} |
| a malformed body on a route with NO body schema | reached the handler as undefined and 500'd where the handler dereferenced it. 69 of 134 write declarations carry no body schema and 36 read the body anyway — a client error was becoming a server error. Now a 400 |
| a JSON body claimed to be text/plain | seventeen returned-Error sites, including the whole authentication middleware. Copied from Elysia so the two arms agreed byte for byte; with Elysia gone the only argument for it went too |
ELYSIA_MIGRATION.md §11 has the rest, including the four audit claims that did
NOT reproduce when measured, and one regression this work introduced and then
removed.
Breaking changes will be announced here first. There is no 1.0 date; the 0.9.x line continues.
Gates
Every push runs, and all of these block:
bun test src scripts fe # unit tests
bunx tsc --noEmit -p tsconfig.check.json # the core library must type-check cleanThe lint gate is currently not one of them. CI runs bunx biome check .,
and biome on npm is not Biome — it is an unrelated 0.3.3 package for managing
environment variables. @biomejs/biome is the real one and is not declared as a
dependency, so the step resolves the wrong package, prints no findings and exits
0. It passes on every commit and checks nothing. Locally it works only for
whoever has Biome installed globally.
Repairing it means declaring @biomejs/biome, pointing CI at it, and then
making the repo clean under the real tool — which is a repo-wide change, so it
is written down here rather than done quietly. See ELYSIA_MIGRATION.md §7.
What that costs, measured (bunx @biomejs/biome check ., 1144 files):
| | count |
|---|---|
| errors today | 234 |
| fixed by --write | 90, touching 83 files |
| left needing a judgement call | 144 errors + 29 warnings + 9 infos |
The biggest groups are assist/source/organizeImports (33) and
lint/suspicious/noThenProperty (12 — mostly chainable drizzle stubs in tests,
where a then property is the point). So the repair is two commits, not one: a
mechanical --write pass, then a smaller pass that decides what to keep.
CI additionally runs every *.integration.test.ts against a real Postgres and
Redis (RUN_INTEGRATION=1); they self-skip otherwise, because the races they
catch — over-commit, non-atomic payout
bookkeeping — cannot be reproduced against a fake backend.
tsconfig.check.json covers src only — fe/ imports from the package root,
which only exists after a build, so it cannot be type-checked on a fresh
checkout. The build emits its declarations best-effort.
A local green is not a CI green. Path separators, case sensitivity, musl memory
behaviour and /proc fixtures all differ between a developer machine and the
Alpine image that runs in production; ELYSIA_MIGRATION.md §6 has the matrix
and the container command that reproduces it.
