Repository navigation
Releases
v0.10.2
ExisJS Release v0.10.2
Status: Released 2026-11-03 (npm tag latest)
Type: Patch: security fixes from a full audit, route and boundary file scanning, error codes, tex.objectId(), testing kit
Affected Packages: exisjs, @exisjs/cli, create-exisjs
Upgrade if you use serveStatic, ipFilter, server: 'uws' or run on Bun. A security audit found that serveStatic served dotfiles such as .env, that ipFilter ignored IPv6 CIDR rules, and that on uWebSockets.js and Bun anyone could crash the server with one request.
A copied route file also used to register the same path twice with no warning, and which handler answered depended on load order. The server now refuses to start on these mistakes and names the files.
Behaviour changes
Each is a fix, but a running app can notice it. Check the ones that apply before upgrading.
-
A route or boundary file must be named exactly. Only
route.ts,<name>.route.ts,boundary.tsand<name>.boundary.ts(or.js) are loaded. A file that only ended the same way, such asreroute.ts,users-route.tsormyboundary.ts, used to load and no longer does, so its URLs answer404and nothing stops the start.
Migrate: runnpx exisjs doctor, which warns about these names, and rename any that are real route files to<name>.route.ts. -
A folder can hold only one route file.
health.route.tsnext toextra.route.tsstops the start, even when their paths differ.
Migrate: delete the leftover file, or move the second resource to a sub-folder. -
A folder can hold only one boundary file. Before,
boundary.tswon andadmin.boundary.tswas silently ignored, so the guards in the ignored file never ran. This includesboundary.tsnext toboundary.js.
Migrate: merge them into one file. -
Two routes can't answer the same method and path. This covers two files, two handlers in one file, and class controllers. Parameter names don't make routes different:
users/[id]andusers/[slug]collide when both handleGET /.
Migrate: keep one of them, or give each its own path. -
A route file can't repeat a route registered in code. An
app.get('/a', ...)inserver.tsand aGET /afrom a route file stop the start. Two routes that are both registered in code are still allowed. -
{ code }passed to three error classes is now a code, not details.BadRequestError,UnprocessableErrorandPayloadTooLargeError(and their factories) already tookdetailssecond. An object holding onlycodeanddetailskeys, with a stringcode, now sets those fields. Any other value is still details.
Migrate: only if you send details shaped exactly like that: wrap them,{ details: { code: 'X' } }. -
A route with a body schema reads only JSON and forms. Any other
Content-Typeanswers415 UNSUPPORTED_MEDIA_TYPE, includingtext/plain, which a browser can send cross-site with no preflight. A request with noContent-Typeis still read as JSON.
Migrate: a client callingfetch(url, { body: JSON.stringify(data) })sendstext/plain, so it must also setcontent-type: application/json. -
serveStatichides files and folders starting with a dot, except/.well-known/.
Migrate: setdotfiles: 'allow'if you really serve them. -
ipFilterstops the start on a rule it can't read, and now applies IPv6 CIDR rules, which it used to ignore. An IPv6 client an existing rule covers is now blocked or allowed by that rule. -
tex.string()refuses a null byte, which databases either reject with a server error or cut the value at. -
An error is a
400only when it is a real validation failure:ZodError,ValidationError,ValidatorError, anissuesarray, or a plainErrorwith anerrorsobject. A custom class with anerrorsproperty now answers500.
Migrate: throwHttpErroror one of its subclasses with{ details }. -
Multipart fields count towards
bodyLimit, and a field over the 1 MB field limit answers413instead of being cut short. -
Forwarded values are checked.
req.protocolreads onlyhttp,https,wsorwss,req.hostignores a value that isn't a host, andtrustProxy: 'cloudflare'readsX-Forwarded-HostandX-Forwarded-Protoonly from Cloudflare. -
On uWebSockets.js,
bodyLimitapplies to uploads too, because uWS holds the whole body in memory. -
ExisJS needs Node.js 24 or newer. Node.js 20 stopped getting fixes on 30 April 2026, and the dependencies below no longer support it.
Migrate: move to the latest Node.js 24 or 26, including your Dockerfile and CI. -
Responses now send
Cross-Origin-Resource-Policy: same-site. Another site can no longer embed them with a tag such as<img>or<script>.fetchwith CORS and subdomains of your own site still work.
Migrate: for files other sites embed, such as public images, sethelmet: { crossOriginResourcePolicy: 'cross-origin' }. -
CORS is off until you set it. It was on for every site. A browser app on a different origin from the API now gets a CORS error. Requests from servers and mobile apps aren't affected.
Migrate: list your sites:cors: { origin: ['https://app.example.com'] }, withcredentials: trueif the app sends cookies.cors: truekeeps the old behaviour of allowing any site.
Security
serveStaticserved dotfiles. A.envor.gitfolder left in the static folder could be downloaded.ipFilterignored IPv6 CIDR rules, so a deny rule such as2001:db8::/32blocked nobody.- Errors could show internal details in production. Any error with an
errorsproperty answered400with its own message, includingAggregateErrorfrom a failed connection (connect ECONNREFUSED 10.0.0.5:27017) and MongoDB bulk write errors. Content-Typewas matched as a substring, sotext/plain; x=multipart/form-datawas read as multipart, and JSON was accepted astext/plain. This is the same class of bug as Fastify CVE-2026-25223.X-Forwarded-HostandX-Forwarded-Protowere trusted from anyone undertrustProxy: 'cloudflare', so a direct client could choose the host an app builds links from. This is the same class of bug as Next.js CVE-2026-64649.- Multipart fields ignored
bodyLimit, so one request could hold about 100 MB in memory against a 1 MB limit. csrf()compared tokens in a way that leaks timing. It now uses a constant-time comparison.- Server-sent events could be forged. Text in
data(a lone\r),event,idorretrycould start a new event, so user text sent throughres.sse()could impersonate the server. - Over HTTP/2, an idle session stayed open forever, each one holding a connection. Sessions now close after 2 minutes idle, or
keepAlive.timeoutMs. - The docs page loaded CDN scripts with no integrity check, so a changed CDN file would have run on the page.
- The docs page allowed any inline script. Its policy now allows only the page's own script, by hash, and limits where forms can post.
- CORS allowed every site by default. Cookies were never allowed, but any website a visitor opened could read whatever that visitor's network could reach, such as an API limited by
ipFilteror on a VPN. helmetsent noCross-Origin-Opener-PolicyorCross-Origin-Resource-Policy, so another site could embed a signed-in user's response with ano-corsrequest. Both are now sent by default.- Only on
server: 'uws':- One large request could crash the server, with no login. Any body spanning more than one chunk read memory uWS had already released.
bodyLimitwas ignored, so a client could fill the server's memory.- A client disconnecting mid-body could crash the process through an unhandled rejection.
- Response headers weren't checked, so a CRLF in a value could inject headers.
- On Bun, which
server: 'auto'picks when an app runs under Bun:- One request for a static file crashed the server, with no login.
- Every client's address was
127.0.0.1, soipFiltercouldn't block anyone or could allow everyone, and all clients shared one rate-limit bucket.req.ipis now the real address. sslwas silently ignored, so a server configured for TLS ran unencrypted.bodyLimitwas ignored, and response headers weren't checked for CRLF.
- Publishing: the workflows install with
npm ci, pin every action to a commit, pass the tag name through an environment variable instead of into the script, and drop an unused permission.
Fixed
- On Bun: responses were held in memory until the end, so SSE events and streams didn't reach the client as they were sent; a
204threw an error; and header names were case-sensitive, so one header could be sent twice. - On
server: 'uws': multipart uploads hung forever, responses that write and then end (static files, streams) never finished, chunked request bodies arrived empty, and severalSet-Cookieheaders were merged into one. - Over HTTP/2,
req.hostwas empty, socsrf()refused every same-originPOST, and aHostheader added by a client could satisfy its Origin check. It now reads:authority. - A malformed multipart body answered
500. It now answers400. exisjs routeson a plain JavaScript entry file showed no access column.ServiceUnavailableError,GatewayTimeoutErrorand their*Exceptionaliases were documented but not exported fromexisjs/error.- Route and boundary matching was too loose: any file name ending in
route.tsorboundary.tswas loaded. The server,exisjs buildandexisjs doctornow share one rule. - Duplicate routes were silent:
exisjs routesshowed a doubled row, and one handler never answered. exisjs routeshid start-up errors. A route conflict now prints with the files involved and exit...
v0.10.1
ExisJS Release v0.10.1
Status: Released 2026-10-23 (npm tag latest)
Type: Patch: one critical and two high-severity bugs, security fixes behind proxies, test runner fixes
Affected Packages: exisjs, @exisjs/cli, create-exisjs
Upgrade if you use compression: true (the project template's default). In 0.10.0, any response large enough to be compressed reached browsers as 204 No Content with no body.
Behaviour changes
Each is a fix, but a running app can notice it. Check the ones that apply before upgrading.
trustProxy: trueno longer readsTrue-Client-IP,X-Real-IPorX-Client-IP. Any client can send these, so it could choose its ownreq.ipand get a fresh rate-limit bucket on every request.truenow readsCF-Connecting-IP, thenX-Forwarded-For.
Migrate: behind nginx that sets onlyX-Real-IP, usetrustProxy: { header: 'x-real-ip' }. Behind Cloudflare, use the newtrustProxy: 'cloudflare'.- Access is checked before input is validated. An anonymous request with a bad body to a protected route now gets
401(a signed-in user without the permission gets403), not a400that lists the expected fields. exisjs testfails a test file whose process doesn't exit. Five seconds after a file's last test, if something is still open (a database client, a server, a timer), the file fails with a message naming it. Before, the whole run hung with no output.
Migrate: close it in anafter()hook, or runexisjs test --force-exit.- The default
healthcheck.drainMsin production is 2 seconds (was 5). Drain plus up to 5 seconds for requests now fits inside Docker's default 10-second stop period. SetdrainMsto keep the old value. hpp()leaves JSON bodies alone. It used to replace every array in a JSON body with its last element.- A database that can't connect at start stops the start (
exisjs start,node .exis/server/main.js,boot(),createTestContext()), with an error naming it, and the process exits with code 1. In 0.10.0exisjs startlogged it and kept running,createTestContext()ignored it, andboot()threw.
Migrate: nothing, for a database the app needs. For one it can run without (a cache, a search index), setrequired: falseonregisterDatabase: the server then starts,/_readyanswers503, and it reconnects on its own. - A declared
ExisUseris exact. Once an app declaresinterface ExisUser { ... }, reading a field it doesn't declare (req.user.userIdafteruserIdwas removed) is a compile error. Apps that don't declareExisUsersee no change. All six of our apps compile unchanged. - New projects start with
"start": "node .exis/server/main.js", andexisjs migratechanges"start": "exisjs start"to it.npm prune --omit=devremoves theexisjscommand together with the CLI, andnpm startthen failed withexisjs: not found. - The CLI stops when
exisjsand@exisjs/cliare different versions, and names the matching install. npm already refused such an install; pnpm andnpm install --legacy-peer-depsallowed it.
Fixed
- Compressed responses were sent as
204with no body (compression: true). The router saw the response as unsent while zlib was still working. The test client now decodesgzip,branddeflate, so a test that sendsAccept-Encodingsees the body. hpp()turned{ "ids": ["a", "b"] }into{ "ids": "b" }, so strict schemas answered "Must be an array" and loose ones lost data. It now only flattens repeated query keys and form fields, with awhitelistoption for keys that may repeat (?tags=a&tags=b).- Boundary files loaded a second copy of every module they import under
NODE_ENV=test. Each database client and module-level cache existed twice, and the test process never exited. Boundaries and routes now share one realimport(), and a file that fails to load reports its own error instead of arequire()fallback hiding it. exisjs testprinted nothing while it hung, and the reporter counted top-levelit()tests (outside adescribe) only sometimes. Each file now prints when it finishes, withPASSorFAILfrom its real result.csrf()behind a reverse proxy compared the browser'sOriginwith the upstreamHost, so every state-changing request failed with403 CSRF_FAILED. WithtrustProxyset it now usesX-Forwarded-Host. Outside production, a mismatch puts the expected host and the received origin indetails.mongoSanitize()dropped every dotted key without a way to keep them, which broke webhook handshakes such as Meta'shub.mode. It also tried to parse every body as JSON.- A
/_ready503during shutdown was logged as an error on every deploy. It is intended, so it now logs atinfo. A503because a database is down still logs as an error. create-exisjstemplates: routes were open unless remembered, and the example guard allowed anyAuthorizationheader.RateLimit-ResetandRetry-Aftercould say one second too many when the rate-limit store took a moment to answer (a Redis or database store): the clock was read before the store set the window's end.createRouter<Context>()didn't pass its context to handlers:reqinside them didn't have the context's fields. A loosereq.usertype hid it.exisjs migrateleft 0.8 and 0.9 ranges ofexisjsnext to a 0.10 CLI, so the install failed on the peer dependency. It now movesexisjsto the CLI's release, and its Dockerfile,nixpacks.tomlandProcfileadvice points atnode .exis/server/main.js.- Docs: the Dockerfile's
HEALTHCHECKused port 3000 and the template's/healthroute; the testing page showed the wrong validation error shape and a--coverageflag that doesn't exist.
Added
trustProxy: 'cloudflare'trustsCF-Connecting-IPonly when the request came from a Cloudflare address, directly or through a local proxy (Traefik, nginx) whose last hop is Cloudflare.trustProxy: { header }reads one header your own proxy sets.- A warning when requests come through a proxy and
trustProxyis off (production, once): every client then shares the proxy's address and one rate-limit bucket. req.host: the host with its port, fromX-Forwarded-Hostbehind a trusted proxy.req.csrfTokenis now part of the request type.csrf({ allowedOrigins })for an API served from another host than the site.mongoSanitize({ allowDots, exclude }).logger.quietPaths: more paths whose successful requests log atdebug, like the health probes.- Lockouts keyed on a body field:
consume(store, key, { max, windowMs })andassertNotLimited(store, key, { max, windowMs })throw the standard429 RATE_LIMIT_EXCEEDED.RateLimitStoregains optionalget()andreset(), which the memory store implements. The security docs include a MongoDB store. registerDatabase({ required: false })for a database the app can run without.requireUser(req)(exisjs/router): the signed-in user, typed, or a401.- Strict request types, opt-in:
declare module 'exisjs' { interface ExisTypeOptions { strict: true } }typesreq.useras possiblyundefinedand refuses request properties nobody declared. See "Typingreq.user" in the request docs. exisjs test --force-exit, andEXIS_TEST_EXIT_GRACE_MSto change the five seconds.create-exisjs: new projects deny by default (authorization: { defaultPolicy: 'authenticated' }), with the root, health and example users routes marked public, and turn on the/_healthand/_readyprobes. The example guard refuses until it is implemented.- Docs: "Going to production" (Dokploy, environment quoting, Cloudflare, an authentication checklist, seed scripts, health), liveness versus readiness, when a longer drain helps, and what happens when a database can't connect at start.
Upgrade
npm install exisjs@0.10.1
npm install -D @exisjs/cli@0.10.1Then run npx exisjs migrate (it moves the start script to node .exis/server/main.js and lists deployment lines to change), and check the behaviour changes above: trustProxy: true behind nginx, test files that leave a connection open, and databases the app can run without.
Checked
- 916 tests (915 pass, 1 Linux-only skipped); build, typecheck and lint.
- The documented Dockerfile, built and run with
PORT=4000: healthy, gzip and br answered200with the body,docker stoptook 3 s with exit code 0 and no error lines, 8.2 MB of production dependencies with no build tools. - A Dockerfile that builds and then runs
npm prune:npm startworks. - pnpm workspaces (install, test, build, start, and a production install without build tools) and Yarn 4 Plug'n'Play, in
node:24-slim. - Six of our apps typecheck against the new types with no new errors; two of them, on 0.8 and 0.9, install, build and typecheck after
exisjs migrate.
Full Changelog: v0.10.0...v0.10.1
v0.10.0
ExisJS Release v0.10.0
Status: Released 2026-10-21 (npm tag latest)
Type: Minor: the CLI moves to @exisjs/cli; one-process production start; settings that did nothing now work or fail at start-up; metrics endpoint
Affected Packages: exisjs, @exisjs/cli (new), create-exisjs
Production installs are about 10x smaller. The exisjs command moved to the new @exisjs/cli package, a development dependency. A production install of exisjs is now 4.5 MB and 25 packages, with no TypeScript, esbuild, tsx, chokidar, commander or pino-pretty (before: about 45 MB, of which 34 MB build tooling). exisjs build writes .exis/server/main.js, and node .exis/server/main.js runs the server as one process.
Breaking Changes
0.10.0 follows the project rule that a setting which is accepted but does nothing is worse than no setting. Each change below makes a server that seemed fine fail at start-up, with a message naming the option.
-
queueis checked at start-up.queue.drivermust be'memory'.'redis'and any other value stop the start. They never did anything: there is no Redis queue.- Unknown keys stop the start. The
03-all-featuresexample hadenableWorkers: true, which nothing ever read; remove it if you copied that block. queuenow applies backpressure (see Added). Withqueue: { driver: 'memory' }alone, the defaults apply: 1000 requests at once, 500 waiting, 10 s wait.
Migrate: delete
queue.driver: 'redis'and unknown keys, or delete thequeueblock if you don't want a limit. -
tex.object(shape, options)rejects unknown options. In plain JavaScript a misspelled option such as{ optinal: true }used to be ignored. It now throws when the schema is defined. -
Outside development, a route file that cannot load stops the start.
- Before, it was logged and skipped: the server started without that part of its API and reported healthy.
- The usual cause is a missing environment value read by
tex.envwhile the route file loads.
Migrate: provide the variable, or fix the file named in the message. Development (
exisjs dev) is unchanged: such a route answers500with the error.
The CLI moves to @exisjs/cli
- New package
@exisjs/cliwith theexisjscommand (dev,build,test,run,start,routes,generate, ...). It depends on the build tools (esbuild, tsx, chokidar, commander, pino-pretty) and takestypescriptfrom the project as a peer dependency, so the project controls its TypeScript version. exisjsno longer depends on the build tools. It lists@exisjs/clias an optional dependency until 0.11, so projects that runexisjs devwithout listing@exisjs/clikeep working, with a one-line notice. A production image leaves it out withnpm ci --omit=dev --omit=optional.exisjs buildwrites.exis/server/main.js.node .exis/server/main.jsstarts the server in that process: Node receivesSIGTERMdirectly, and production needs neither the CLI nor npm. It changes to the project folder first, so it works from any directory, and defaultsNODE_ENVtoproduction(also when it is set but empty). In cluster mode it is the primary and forks the workers.exisjs startis part of the runtime and runs the server in its own process, instead of spawning a second Node process and forwarding signals to it. Production installs without@exisjs/clican run it, so"start": "exisjs start"andnpm startwork everywhere; the other commands need@exisjs/cli.exisjs migrateupdates a 0.9 project: adds@exisjs/cli(andtypescriptwhen missing) todevDependencies, and lists the Dockerfile lines to change. Thestartscript staysexisjs start.--dry-runonly shows the changes.create-exisjsgenerates the new layout.pino-prettyis optional for the runtime. When pretty logs are requested butpino-prettyis not installed (a production install), logs stay JSON and one hint is printed, instead of a crash at start-up.- CI checks the production install on every change (
scripts/size-budget.mjs): it fails when the size or package count exceedssize-budget.json, or when any build tool is installed.publintand@arethetypeswrong/clicheck both packages' exports and types. - Types resolve with every
moduleResolution.exisjs/validator,exisjs/routerand the other subpaths now also have types under the oldernode(node10) setting, throughtypesVersions.
Production install of exisjs |
0.9.2 | 0.10.0 |
|---|---|---|
node_modules size |
~45 MB | 4.5 MB |
| Packages | — | 25 |
| Build tooling | 34 MB | none |
| Node processes in the container | 2 | 1 |
Added
- Prometheus metrics.
metrics: { enabled: true }now servesGET /metrics. Before, the setting was accepted and no route existed.- Request counts and duration histograms are labelled by route pattern (
/users/:id), never the raw URL. - Also reported: requests in flight, memory and event-loop lag.
- In cluster mode, a scrape that reaches any worker reports the totals of every worker.
- Request counts and duration histograms are labelled by route pattern (
queuelimits concurrent requests.queue: { maxConcurrent, maxQueue, timeoutMs }caps requests handled at once. Extra requests wait, then get503.tex.object()takesoptional,nullableandnullish, like the field builders, with matching types.- Docs: "Deploying with Docker". It covers:
- a tested multi-stage Dockerfile and
.dockerignore; - why the build needs
--skip-env-check, and thattex.envstill refuses to start without the secrets; - running
node_modules/.bin/exisjs startinstead ofnpm start; - health checks and migrations.
- a tested multi-stage Dockerfile and
Changes in 0.10.0 (since 0.10.0-beta.0)
exisjs migrateflagsnpm run buildwithout--skip-env-check. It only recognisedexisjs buildwritten out in a DockerfileRUNline. It now also flagsnpm run buildwhen the project'sbuildscript isexisjs build, in a Dockerfile or innixpacks.toml(Dokploy, Railway), where the build otherwise fails, or needs the secrets at build time and keeps them in the image.- Health probes no longer fill the production log. A Docker
HEALTHCHECKevery 30 seconds wrote 120GET /healthlines an hour atinfo. Successful requests to/health, and to/_healthand/_readywhenhealthcheckis enabled, now log atdebug, which production hides. A failing probe still logs as a warning or error. - A database that fails to connect at start is logged.
exisjs starthid the error fromregisterDatabase()connections, so a wrong connection string left no trace. It is now logged as an error; the server still starts, and/_readyanswers503until the database is healthy.
Changes in 0.10.0-beta.0
- Ctrl+C in a terminal stops at once. With
healthcheckon, Ctrl+C onexisjs startwaited for the readiness drain (5 s in production), which is meant for load balancers. It is now skipped forSIGINTwhen stdin is a terminal.SIGTERM(Docker, Kubernetes) andSIGINTwithout a terminal (Fly.io, PM2) still drain. - Deployment guide: don't remove the CLI with
npm prune.exisjsand@exisjs/cliboth provide theexisjscommand.npm prune --omit=dev --omit=optionalremoves the CLI's command and doesn't link the runtime's, soexisjs startis not found. Use a cleannpm ci --omit=dev --omit=optional, as the documented Dockerfile does, or runnpm rebuild exisjsafter pruning, or start withnode .exis/server/main.js.
Changes in 0.10.0-alpha.2
Found by testing 0.10.0-alpha.1 from npm on Windows, Linux containers and a production CRM server.
serverinexis.config.tstakes effect. The engine was picked before the config file loaded, so onlyexis({ server })worked, and the uWS fallback warning was logged while the logger was still silent. Aserver: 'uws'app ran on Node without saying so.app.create()now picks the engine again once the config and logger are loaded.- An installed uWS that fails to load says so. uWS v20.71.0 needs glibc 2.38;
node:24-slim(Debian 12) has 2.36. The warning now saysfailed to loadwith uWS's own error instead ofnot installed. Usenode:24-trixie-slimwith uWS (see the deployment guide). - "Install the CLI" hints name the matching version.
npm install -D @exisjs/clitakeslatest, which can be an older prerelease whose peer dependency pins a differentexisjs, so npm refused withERESOLVE. Both hints now print@exisjs/cli@<the project's exisjs version>. - On Windows, start-up errors no longer show framework code. The filter for framework stack frames only matched
/, so a wrongqueuesetting or a failing route showed a code frame frombackpressure.jsorroute-scanner.js.
Changes in 0.10.0-alpha.1
Found by testing 0.10.0-alpha.0 from npm on new projects, Docker, Linux, pnpm, Yarn, a workspace and a production CRM server.
/_readynow answers503before the server stops accepting connections. Before, the listener closed at once onSIGTERM, so load balancers saw refused connections instead of a failing probe. Withhealthcheckenabled, the server keeps serving forhealthcheck.drainMs(default 5000 in production) while/_readyanswers503, then closes the listener and lets requests finish.- The
npx exisjs migratenotice appears. npm links@exisjs/cli'sexisjscommand, so the notice in the runtime's command never ran. The CLI now prints it whenpackage.jsonlistsexisjsbut not@exisjs/cli. - A global
@exisjs/clihands off to the project's own CLI. Run in a project, it used its own copy ofexisjsandtypescript. In a project that hasexisjsbut not@exisjs/cli, it stops and says to install the CLI. exisjs startlogs like production. It printed the development banner (with "press h + enter") and dropped the JSONServer running atand shutdown lines....
v0.10.0-beta.0
ExisJS Release v0.10.0
Status: Prerelease 0.10.0-beta.0, published with the npm tag beta (npm install exisjs@beta @exisjs/cli@beta). Not yet latest.
Type: Minor: the CLI moves to @exisjs/cli; one-process production start; settings that did nothing now work or fail at start-up; metrics endpoint
Affected Packages: exisjs, @exisjs/cli (new), create-exisjs
Production installs are about 10x smaller. The exisjs command moved to the new @exisjs/cli package, a development dependency. A production install of exisjs is now 4.5 MB and 25 packages, with no TypeScript, esbuild, tsx, chokidar, commander or pino-pretty (before: about 45 MB, of which 34 MB build tooling). exisjs build writes .exis/server/main.js, and node .exis/server/main.js runs the server as one process.
Breaking Changes
0.10.0 follows the project rule that a setting which is accepted but does nothing is worse than no setting. Each change below makes a server that seemed fine fail at start-up, with a message naming the option.
-
queueis checked at start-up.queue.drivermust be'memory'.'redis'and any other value stop the start. They never did anything: there is no Redis queue.- Unknown keys stop the start. The
03-all-featuresexample hadenableWorkers: true, which nothing ever read; remove it if you copied that block. queuenow applies backpressure (see Added). Withqueue: { driver: 'memory' }alone, the defaults apply: 1000 requests at once, 500 waiting, 10 s wait.
Migrate: delete
queue.driver: 'redis'and unknown keys, or delete thequeueblock if you don't want a limit. -
tex.object(shape, options)rejects unknown options. In plain JavaScript a misspelled option such as{ optinal: true }used to be ignored. It now throws when the schema is defined. -
Outside development, a route file that cannot load stops the start.
- Before, it was logged and skipped: the server started without that part of its API and reported healthy.
- The usual cause is a missing environment value read by
tex.envwhile the route file loads.
Migrate: provide the variable, or fix the file named in the message. Development (
exisjs dev) is unchanged: such a route answers500with the error.
The CLI moves to @exisjs/cli
- New package
@exisjs/cliwith theexisjscommand (dev,build,test,run,start,routes,generate, ...). It depends on the build tools (esbuild, tsx, chokidar, commander, pino-pretty) and takestypescriptfrom the project as a peer dependency, so the project controls its TypeScript version. exisjsno longer depends on the build tools. It lists@exisjs/clias an optional dependency until 0.11, so projects that runexisjs devwithout listing@exisjs/clikeep working, with a one-line notice. A production image leaves it out withnpm ci --omit=dev --omit=optional.exisjs buildwrites.exis/server/main.js.node .exis/server/main.jsstarts the server in that process: Node receivesSIGTERMdirectly, and production needs neither the CLI nor npm. It changes to the project folder first, so it works from any directory, and defaultsNODE_ENVtoproduction(also when it is set but empty). In cluster mode it is the primary and forks the workers.exisjs startis part of the runtime and runs the server in its own process, instead of spawning a second Node process and forwarding signals to it. Production installs without@exisjs/clican run it, so"start": "exisjs start"andnpm startwork everywhere; the other commands need@exisjs/cli.exisjs migrateupdates a 0.9 project: adds@exisjs/cli(andtypescriptwhen missing) todevDependencies, and lists the Dockerfile lines to change. Thestartscript staysexisjs start.--dry-runonly shows the changes.create-exisjsgenerates the new layout.pino-prettyis optional for the runtime. When pretty logs are requested butpino-prettyis not installed (a production install), logs stay JSON and one hint is printed, instead of a crash at start-up.- CI checks the production install on every change (
scripts/size-budget.mjs): it fails when the size or package count exceedssize-budget.json, or when any build tool is installed.publintand@arethetypeswrong/clicheck both packages' exports and types. - Types resolve with every
moduleResolution.exisjs/validator,exisjs/routerand the other subpaths now also have types under the oldernode(node10) setting, throughtypesVersions.
Production install of exisjs |
0.9.2 | 0.10.0 |
|---|---|---|
node_modules size |
~45 MB | 4.5 MB |
| Packages | — | 25 |
| Build tooling | 34 MB | none |
| Node processes in the container | 2 | 1 |
Added
- Prometheus metrics.
metrics: { enabled: true }now servesGET /metrics. Before, the setting was accepted and no route existed.- Request counts and duration histograms are labelled by route pattern (
/users/:id), never the raw URL. - Also reported: requests in flight, memory and event-loop lag.
- In cluster mode, a scrape that reaches any worker reports the totals of every worker.
- Request counts and duration histograms are labelled by route pattern (
queuelimits concurrent requests.queue: { maxConcurrent, maxQueue, timeoutMs }caps requests handled at once. Extra requests wait, then get503.tex.object()takesoptional,nullableandnullish, like the field builders, with matching types.- Docs: "Deploying with Docker". It covers:
- a tested multi-stage Dockerfile and
.dockerignore; - why the build needs
--skip-env-check, and thattex.envstill refuses to start without the secrets; - running
node_modules/.bin/exisjs startinstead ofnpm start; - health checks and migrations.
- a tested multi-stage Dockerfile and
Changes in 0.10.0-beta.0
- Ctrl+C in a terminal stops at once. With
healthcheckon, Ctrl+C onexisjs startwaited for the readiness drain (5 s in production), which is meant for load balancers. It is now skipped forSIGINTwhen stdin is a terminal.SIGTERM(Docker, Kubernetes) andSIGINTwithout a terminal (Fly.io, PM2) still drain. - Deployment guide: don't remove the CLI with
npm prune.exisjsand@exisjs/cliboth provide theexisjscommand.npm prune --omit=dev --omit=optionalremoves the CLI's command and doesn't link the runtime's, soexisjs startis not found. Use a cleannpm ci --omit=dev --omit=optional, as the documented Dockerfile does, or runnpm rebuild exisjsafter pruning, or start withnode .exis/server/main.js.
Changes in 0.10.0-alpha.2
Found by testing 0.10.0-alpha.1 from npm on Windows, Linux containers and a production CRM server.
serverinexis.config.tstakes effect. The engine was picked before the config file loaded, so onlyexis({ server })worked, and the uWS fallback warning was logged while the logger was still silent. Aserver: 'uws'app ran on Node without saying so.app.create()now picks the engine again once the config and logger are loaded.- An installed uWS that fails to load says so. uWS v20.71.0 needs glibc 2.38;
node:24-slim(Debian 12) has 2.36. The warning now saysfailed to loadwith uWS's own error instead ofnot installed. Usenode:24-trixie-slimwith uWS (see the deployment guide). - "Install the CLI" hints name the matching version.
npm install -D @exisjs/clitakeslatest, which can be an older prerelease whose peer dependency pins a differentexisjs, so npm refused withERESOLVE. Both hints now print@exisjs/cli@<the project's exisjs version>. - On Windows, start-up errors no longer show framework code. The filter for framework stack frames only matched
/, so a wrongqueuesetting or a failing route showed a code frame frombackpressure.jsorroute-scanner.js.
Changes in 0.10.0-alpha.1
Found by testing 0.10.0-alpha.0 from npm on new projects, Docker, Linux, pnpm, Yarn, a workspace and a production CRM server.
/_readynow answers503before the server stops accepting connections. Before, the listener closed at once onSIGTERM, so load balancers saw refused connections instead of a failing probe. Withhealthcheckenabled, the server keeps serving forhealthcheck.drainMs(default 5000 in production) while/_readyanswers503, then closes the listener and lets requests finish.- The
npx exisjs migratenotice appears. npm links@exisjs/cli'sexisjscommand, so the notice in the runtime's command never ran. The CLI now prints it whenpackage.jsonlistsexisjsbut not@exisjs/cli. - A global
@exisjs/clihands off to the project's own CLI. Run in a project, it used its own copy ofexisjsandtypescript. In a project that hasexisjsbut not@exisjs/cli, it stops and says to install the CLI. exisjs startlogs like production. It printed the development banner (with "press h + enter") and dropped the JSONServer running atand shutdown lines.- A route that fails to load in a built app is named. The start stopped with "the routes manifest failed to load"; it now names the route file.
- A wrong
queuesetting stops the start before the app's code runs. Before, the entry file had already connected to databases and scheduled cron jobs. - A failed
exisjs buildremoves its output, soexisjs startcan't run a half-built.exis/. exisjs migratechecks more deployment files:exisjs buildwithout--skip-env-checkin theDockerfile, and start commands throughnpm startinnixpacks.tomlandProcfile.create-exisjs --helpprints the options instead of starting the questions.- uWebSockets.js is no longer installed with
exisjs(breaking forserver: 'uws'). It was an optional dependency fetched from GitHub on every install, although onlyserver: 'uws'uses it: installs neededgit, lock files pinned an old commit, and where the clone failed, `serve...
v0.10.0-alpha.2
ExisJS Release v0.10.0
Status: Prerelease 0.10.0-alpha.2, published with the npm tag alpha (npm install exisjs@alpha @exisjs/cli@alpha). Not yet latest; see docs/RELEASING.md.
Type: Minor: the CLI moves to @exisjs/cli; one-process production start; settings that did nothing now work or fail at start-up; metrics endpoint
Affected Packages: exisjs, @exisjs/cli (new), create-exisjs
Fixes B1 to B9 from the 0.9.2 report (docs/REPORT.md). B9 was found while verifying the Docker guide.
Production installs are about 10x smaller (B8). The exisjs command moved to the new @exisjs/cli package, a development dependency. A production install of exisjs is now 4.5 MB and 25 packages, with no TypeScript, esbuild, tsx, chokidar, commander or pino-pretty (before: about 45 MB, of which 34 MB build tooling). exisjs build writes .exis/server/main.js, and node .exis/server/main.js runs the server as one process.
Breaking Changes
0.10.0 follows the project rule that a setting which is accepted but does nothing is worse than no setting. Each change below makes a server that seemed fine fail at start-up, with a message naming the option.
-
queueis checked at start-up (B2).queue.drivermust be'memory'.'redis'and any other value stop the start. They never did anything: there is no Redis queue.- Unknown keys stop the start. The
03-all-featuresexample hadenableWorkers: true, which nothing ever read; remove it if you copied that block. queuenow applies backpressure (see Added). Withqueue: { driver: 'memory' }alone, the defaults apply: 1000 requests at once, 500 waiting, 10 s wait.
Migrate: delete
queue.driver: 'redis'and unknown keys, or delete thequeueblock if you don't want a limit. -
tex.object(shape, options)rejects unknown options (B4). In plain JavaScript a misspelled option such as{ optinal: true }used to be ignored. It now throws when the schema is defined. -
Outside development, a route file that cannot load stops the start (B9).
- Before, it was logged and skipped: the server started without that part of its API and reported healthy.
- The usual cause is a missing environment value read by
tex.envwhile the route file loads.
Migrate: provide the variable, or fix the file named in the message. Development (
exisjs dev) is unchanged: such a route answers500with the error.
The CLI moves to @exisjs/cli (B8)
- New package
@exisjs/cliwith theexisjscommand (dev,build,test,run,start,routes,generate, ...). It depends on the build tools (esbuild, tsx, chokidar, commander, pino-pretty) and takestypescriptfrom the project as a peer dependency, so the project controls its TypeScript version. exisjsno longer depends on the build tools. It lists@exisjs/clias an optional dependency until 0.11, so projects that runexisjs devwithout listing@exisjs/clikeep working, with a one-line notice. A production image leaves it out withnpm ci --omit=dev --omit=optional.exisjs buildwrites.exis/server/main.js.node .exis/server/main.jsstarts the server in that process: Node receivesSIGTERMdirectly, and production needs neither the CLI nor npm. It changes to the project folder first, so it works from any directory, and defaultsNODE_ENVtoproduction(also when it is set but empty). In cluster mode it is the primary and forks the workers.exisjs startis part of the runtime and runs the server in its own process, instead of spawning a second Node process and forwarding signals to it. Production installs without@exisjs/clican run it, so"start": "exisjs start"andnpm startwork everywhere; the other commands need@exisjs/cli.exisjs migrateupdates a 0.9 project: adds@exisjs/cli(andtypescriptwhen missing) todevDependencies, and lists the Dockerfile lines to change. Thestartscript staysexisjs start.--dry-runonly shows the changes.create-exisjsgenerates the new layout.pino-prettyis optional for the runtime. When pretty logs are requested butpino-prettyis not installed (a production install), logs stay JSON and one hint is printed, instead of a crash at start-up.- CI checks the production install on every change (
scripts/size-budget.mjs): it fails when the size or package count exceedssize-budget.json, or when any build tool is installed.publintand@arethetypeswrong/clicheck both packages' exports and types. - Types resolve with every
moduleResolution.exisjs/validator,exisjs/routerand the other subpaths now also have types under the oldernode(node10) setting, throughtypesVersions.
Production install of exisjs |
0.9.2 | 0.10.0 |
|---|---|---|
node_modules size |
~45 MB | 4.5 MB |
| Packages | — | 25 |
| Build tooling | 34 MB | none |
| Node processes in the container | 2 | 1 |
Added
- Prometheus metrics (B1).
metrics: { enabled: true }now servesGET /metrics. Before, the setting was accepted and no route existed.- Request counts and duration histograms are labelled by route pattern (
/users/:id), never the raw URL. - Also reported: requests in flight, memory and event-loop lag.
- In cluster mode, a scrape that reaches any worker reports the totals of every worker.
- Request counts and duration histograms are labelled by route pattern (
queuelimits concurrent requests (B2).queue: { maxConcurrent, maxQueue, timeoutMs }caps requests handled at once. Extra requests wait, then get503.tex.object()takesoptional,nullableandnullish(B4), like the field builders, with matching types.- Docs: "Deploying with Docker" (B7). It covers:
- a tested multi-stage Dockerfile and
.dockerignore; - why the build needs
--skip-env-check, and thattex.envstill refuses to start without the secrets; - running
node_modules/.bin/exisjs startinstead ofnpm start; - health checks and migrations.
- a tested multi-stage Dockerfile and
Changes in 0.10.0-alpha.2
Found by testing 0.10.0-alpha.1 from npm on Windows, Linux containers and the CRM server (docs/testing/TESTING-0.10.0-alpha.1.md).
serverinexis.config.tstakes effect. The engine was picked before the config file loaded, so onlyexis({ server })worked, and the uWS fallback warning was logged while the logger was still silent. Aserver: 'uws'app ran on Node without saying so.app.create()now picks the engine again once the config and logger are loaded.- An installed uWS that fails to load says so. uWS v20.71.0 needs glibc 2.38;
node:24-slim(Debian 12) has 2.36. The warning now saysfailed to loadwith uWS's own error instead ofnot installed. Usenode:24-trixie-slimwith uWS (see the deployment guide). - "Install the CLI" hints name the matching version.
npm install -D @exisjs/clitakeslatest, which can be an older prerelease whose peer dependency pins a differentexisjs, so npm refused withERESOLVE. Both hints now print@exisjs/cli@<the project's exisjs version>. - On Windows, start-up errors no longer show framework code. The filter for framework stack frames only matched
/, so a wrongqueuesetting or a failing route showed a code frame frombackpressure.jsorroute-scanner.js.
Changes in 0.10.0-alpha.1
Found by testing 0.10.0-alpha.0 from npm on new projects, Docker, Linux, pnpm, Yarn, a workspace and the CRM server (docs/TESTING-0.10.0-alpha.md).
/_readynow answers503before the server stops accepting connections. Before, the listener closed at once onSIGTERM, so load balancers saw refused connections instead of a failing probe. Withhealthcheckenabled, the server keeps serving forhealthcheck.drainMs(default 5000 in production) while/_readyanswers503, then closes the listener and lets requests finish.- The
npx exisjs migratenotice appears. npm links@exisjs/cli'sexisjscommand, so the notice in the runtime's command never ran. The CLI now prints it whenpackage.jsonlistsexisjsbut not@exisjs/cli. - A global
@exisjs/clihands off to the project's own CLI. Run in a project, it used its own copy ofexisjsandtypescript. In a project that hasexisjsbut not@exisjs/cli, it stops and says to install the CLI. exisjs startlogs like production. It printed the development banner (with "press h + enter") and dropped the JSONServer running atand shutdown lines.- A route that fails to load in a built app is named. The start stopped with "the routes manifest failed to load"; it now names the route file.
- A wrong
queuesetting stops the start before the app's code runs. Before, the entry file had already connected to databases and scheduled cron jobs. - A failed
exisjs buildremoves its output, soexisjs startcan't run a half-built.exis/. exisjs migratechecks more deployment files:exisjs buildwithout--skip-env-checkin theDockerfile, and start commands throughnpm startinnixpacks.tomlandProcfile.create-exisjs --helpprints the options instead of starting the questions.- uWebSockets.js is no longer installed with
exisjs(breaking forserver: 'uws'). It was an optional dependency fetched from GitHub on every install, although onlyserver: 'uws'uses it: installs neededgit, lock files pinned an old commit, and where the clone failed,server: 'uws'quietly ran on Node. It is now an optional peer dependency. Apps that use it install it themselves:npm install https://github.com/uNetworking/uWebSockets.js/archive/refs/tags/v20.71.0.tar.gz. - Docs: the validation error examples show the current response format; the deployment guide describes the readiness drain and the real time limits; Yarn 1 users run
npx create-exisjs@alphafor prereleases.
Fixed
partial()reset fields that an update left out. A PATCH schema made with.partial()kept the fields' ...
v0.10.0-alpha.1
ExisJS Release v0.10.0
Status: Prerelease 0.10.0-alpha.1, published with the npm tag alpha (npm install exisjs@alpha @exisjs/cli@alpha). Not yet latest; see docs/RELEASING.md.
Type: Minor: the CLI moves to @exisjs/cli; one-process production start; settings that did nothing now work or fail at start-up; metrics endpoint
Affected Packages: exisjs, @exisjs/cli (new), create-exisjs
Fixes B1 to B9 from the 0.9.2 report (docs/REPORT.md). B9 was found while verifying the Docker guide.
Production installs are about 10x smaller (B8). The exisjs command moved to the new @exisjs/cli package, a development dependency. A production install of exisjs is now 4.5 MB and 25 packages, with no TypeScript, esbuild, tsx, chokidar, commander or pino-pretty (before: about 45 MB, of which 34 MB build tooling). exisjs build writes .exis/server/main.js, and node .exis/server/main.js runs the server as one process.
Breaking Changes
0.10.0 follows the project rule that a setting which is accepted but does nothing is worse than no setting. Each change below makes a server that seemed fine fail at start-up, with a message naming the option.
-
queueis checked at start-up (B2).queue.drivermust be'memory'.'redis'and any other value stop the start. They never did anything: there is no Redis queue.- Unknown keys stop the start. The
03-all-featuresexample hadenableWorkers: true, which nothing ever read; remove it if you copied that block. queuenow applies backpressure (see Added). Withqueue: { driver: 'memory' }alone, the defaults apply: 1000 requests at once, 500 waiting, 10 s wait.
Migrate: delete
queue.driver: 'redis'and unknown keys, or delete thequeueblock if you don't want a limit. -
tex.object(shape, options)rejects unknown options (B4). In plain JavaScript a misspelled option such as{ optinal: true }used to be ignored. It now throws when the schema is defined. -
Outside development, a route file that cannot load stops the start (B9).
- Before, it was logged and skipped: the server started without that part of its API and reported healthy.
- The usual cause is a missing environment value read by
tex.envwhile the route file loads.
Migrate: provide the variable, or fix the file named in the message. Development (
exisjs dev) is unchanged: such a route answers500with the error.
The CLI moves to @exisjs/cli (B8)
- New package
@exisjs/cliwith theexisjscommand (dev,build,test,run,start,routes,generate, ...). It depends on the build tools (esbuild, tsx, chokidar, commander, pino-pretty) and takestypescriptfrom the project as a peer dependency, so the project controls its TypeScript version. exisjsno longer depends on the build tools. It lists@exisjs/clias an optional dependency until 0.11, so projects that runexisjs devwithout listing@exisjs/clikeep working, with a one-line notice. A production image leaves it out withnpm ci --omit=dev --omit=optional.exisjs buildwrites.exis/server/main.js.node .exis/server/main.jsstarts the server in that process: Node receivesSIGTERMdirectly, and production needs neither the CLI nor npm. It changes to the project folder first, so it works from any directory, and defaultsNODE_ENVtoproduction(also when it is set but empty). In cluster mode it is the primary and forks the workers.exisjs startis part of the runtime and runs the server in its own process, instead of spawning a second Node process and forwarding signals to it. Production installs without@exisjs/clican run it, so"start": "exisjs start"andnpm startwork everywhere; the other commands need@exisjs/cli.exisjs migrateupdates a 0.9 project: adds@exisjs/cli(andtypescriptwhen missing) todevDependencies, and lists the Dockerfile lines to change. Thestartscript staysexisjs start.--dry-runonly shows the changes.create-exisjsgenerates the new layout.pino-prettyis optional for the runtime. When pretty logs are requested butpino-prettyis not installed (a production install), logs stay JSON and one hint is printed, instead of a crash at start-up.- CI checks the production install on every change (
scripts/size-budget.mjs): it fails when the size or package count exceedssize-budget.json, or when any build tool is installed.publintand@arethetypeswrong/clicheck both packages' exports and types. - Types resolve with every
moduleResolution.exisjs/validator,exisjs/routerand the other subpaths now also have types under the oldernode(node10) setting, throughtypesVersions.
Production install of exisjs |
0.9.2 | 0.10.0 |
|---|---|---|
node_modules size |
~45 MB | 4.5 MB |
| Packages | — | 25 |
| Build tooling | 34 MB | none |
| Node processes in the container | 2 | 1 |
Added
- Prometheus metrics (B1).
metrics: { enabled: true }now servesGET /metrics. Before, the setting was accepted and no route existed.- Request counts and duration histograms are labelled by route pattern (
/users/:id), never the raw URL. - Also reported: requests in flight, memory and event-loop lag.
- In cluster mode, a scrape that reaches any worker reports the totals of every worker.
- Request counts and duration histograms are labelled by route pattern (
queuelimits concurrent requests (B2).queue: { maxConcurrent, maxQueue, timeoutMs }caps requests handled at once. Extra requests wait, then get503.tex.object()takesoptional,nullableandnullish(B4), like the field builders, with matching types.- Docs: "Deploying with Docker" (B7). It covers:
- a tested multi-stage Dockerfile and
.dockerignore; - why the build needs
--skip-env-check, and thattex.envstill refuses to start without the secrets; - running
node_modules/.bin/exisjs startinstead ofnpm start; - health checks and migrations.
- a tested multi-stage Dockerfile and
Changes in 0.10.0-alpha.1
Found by testing 0.10.0-alpha.0 from npm on new projects, Docker, Linux, pnpm, Yarn, a workspace and the CRM server (docs/TESTING-0.10.0-alpha.md).
/_readynow answers503before the server stops accepting connections. Before, the listener closed at once onSIGTERM, so load balancers saw refused connections instead of a failing probe. Withhealthcheckenabled, the server keeps serving forhealthcheck.drainMs(default 5000 in production) while/_readyanswers503, then closes the listener and lets requests finish.- The
npx exisjs migratenotice appears. npm links@exisjs/cli'sexisjscommand, so the notice in the runtime's command never ran. The CLI now prints it whenpackage.jsonlistsexisjsbut not@exisjs/cli. - A global
@exisjs/clihands off to the project's own CLI. Run in a project, it used its own copy ofexisjsandtypescript. In a project that hasexisjsbut not@exisjs/cli, it stops and says to install the CLI. exisjs startlogs like production. It printed the development banner (with "press h + enter") and dropped the JSONServer running atand shutdown lines.- A route that fails to load in a built app is named. The start stopped with "the routes manifest failed to load"; it now names the route file.
- A wrong
queuesetting stops the start before the app's code runs. Before, the entry file had already connected to databases and scheduled cron jobs. - A failed
exisjs buildremoves its output, soexisjs startcan't run a half-built.exis/. exisjs migratechecks more deployment files:exisjs buildwithout--skip-env-checkin theDockerfile, and start commands throughnpm startinnixpacks.tomlandProcfile.create-exisjs --helpprints the options instead of starting the questions.- uWebSockets.js is no longer installed with
exisjs(breaking forserver: 'uws'). It was an optional dependency fetched from GitHub on every install, although onlyserver: 'uws'uses it: installs neededgit, lock files pinned an old commit, and where the clone failed,server: 'uws'quietly ran on Node. It is now an optional peer dependency. Apps that use it install it themselves:npm install https://github.com/uNetworking/uWebSockets.js/archive/refs/tags/v20.71.0.tar.gz. - Docs: the validation error examples show the current response format; the deployment guide describes the readiness drain and the real time limits; Yarn 1 users run
npx create-exisjs@alphafor prereleases.
Fixed
-
partial()reset fields that an update left out. A PATCH schema made with.partial()kept the fields' defaults, so a request that sent onlystatusresettagsto[](and any other field with a default). Fields left out of a partial schema now stay unset. Found while buildingexamples/05-tasks-api. -
In tests, route files and the test file used two copies of the same module. With
NODE_ENV=test, route files were loaded withrequire()(TypeScript compilesimport()to it for CommonJS) while the test imported them as ES modules, so resetting an in-memory store or spying on a service from a test did not affect the routes. Route files are now always loaded with a realimport(). -
The
create-exisjssample user service returned the password it was sent, andGET /users/:idanswered200withnullfor an unknown id. It now stores and returns only the name and email, and answers404. -
Graceful shutdown skipped
onCloseand the database disconnect when a request was still running. The server stopped listening, the request finished, and nothing kept the process alive any more: Node exited with code 0 before the close hooks ran. Found in Docker while verifying B8. The shutdown now waits for the hooks. -
The publish workflow always published to
latest. A prerelease such as0.10.0-alpha.0would have reached every user. The npm tag now comes from th...
v0.10.0-alpha.0
ExisJS Release v0.10.0
Status: Prerelease 0.10.0-alpha.0, published with the npm tag alpha (npm install exisjs@alpha @exisjs/cli@alpha). Not yet latest; see docs/RELEASING.md.
Type: Minor: the CLI moves to @exisjs/cli; one-process production start; settings that did nothing now work or fail at start-up; metrics endpoint
Affected Packages: exisjs, @exisjs/cli (new), create-exisjs
Fixes B1 to B9 from the 0.9.2 report (docs/REPORT.md). B9 was found while verifying the Docker guide.
Production installs are about 10x smaller (B8). The exisjs command moved to the new @exisjs/cli package, a development dependency. A production install of exisjs is now 4.5 MB and 25 packages, with no TypeScript, esbuild, tsx, chokidar, commander or pino-pretty (before: about 45 MB, of which 34 MB build tooling). exisjs build writes .exis/server/main.js, and node .exis/server/main.js runs the server as one process.
Breaking Changes
0.10.0 follows the project rule that a setting which is accepted but does nothing is worse than no setting. Each change below makes a server that seemed fine fail at start-up, with a message naming the option.
-
queueis checked at start-up (B2).queue.drivermust be'memory'.'redis'and any other value stop the start. They never did anything: there is no Redis queue.- Unknown keys stop the start. The
03-all-featuresexample hadenableWorkers: true, which nothing ever read; remove it if you copied that block. queuenow applies backpressure (see Added). Withqueue: { driver: 'memory' }alone, the defaults apply: 1000 requests at once, 500 waiting, 10 s wait.
Migrate: delete
queue.driver: 'redis'and unknown keys, or delete thequeueblock if you don't want a limit. -
tex.object(shape, options)rejects unknown options (B4). In plain JavaScript a misspelled option such as{ optinal: true }used to be ignored. It now throws when the schema is defined. -
Outside development, a route file that cannot load stops the start (B9).
- Before, it was logged and skipped: the server started without that part of its API and reported healthy.
- The usual cause is a missing environment value read by
tex.envwhile the route file loads.
Migrate: provide the variable, or fix the file named in the message. Development (
exisjs dev) is unchanged: such a route answers500with the error.
The CLI moves to @exisjs/cli (B8)
- New package
@exisjs/cliwith theexisjscommand (dev,build,test,run,start,routes,generate, ...). It depends on the build tools (esbuild, tsx, chokidar, commander, pino-pretty) and takestypescriptfrom the project as a peer dependency, so the project controls its TypeScript version. exisjsno longer depends on the build tools. It lists@exisjs/clias an optional dependency until 0.11, so projects that runexisjs devwithout listing@exisjs/clikeep working, with a one-line notice. A production image leaves it out withnpm ci --omit=dev --omit=optional.exisjs buildwrites.exis/server/main.js.node .exis/server/main.jsstarts the server in that process: Node receivesSIGTERMdirectly, and production needs neither the CLI nor npm. It changes to the project folder first, so it works from any directory, and defaultsNODE_ENVtoproduction(also when it is set but empty). In cluster mode it is the primary and forks the workers.exisjs startis part of the runtime and runs the server in its own process, instead of spawning a second Node process and forwarding signals to it. Production installs without@exisjs/clican run it, so"start": "exisjs start"andnpm startwork everywhere; the other commands need@exisjs/cli.exisjs migrateupdates a 0.9 project: adds@exisjs/cli(andtypescriptwhen missing) todevDependencies, and lists the Dockerfile lines to change. Thestartscript staysexisjs start.--dry-runonly shows the changes.create-exisjsgenerates the new layout.pino-prettyis optional for the runtime. When pretty logs are requested butpino-prettyis not installed (a production install), logs stay JSON and one hint is printed, instead of a crash at start-up.- CI checks the production install on every change (
scripts/size-budget.mjs): it fails when the size or package count exceedssize-budget.json, or when any build tool is installed.publintand@arethetypeswrong/clicheck both packages' exports and types. - Types resolve with every
moduleResolution.exisjs/validator,exisjs/routerand the other subpaths now also have types under the oldernode(node10) setting, throughtypesVersions.
Production install of exisjs |
0.9.2 | 0.10.0 |
|---|---|---|
node_modules size |
~45 MB | 4.5 MB |
| Packages | — | 25 |
| Build tooling | 34 MB | none |
| Node processes in the container | 2 | 1 |
Added
- Prometheus metrics (B1).
metrics: { enabled: true }now servesGET /metrics. Before, the setting was accepted and no route existed.- Request counts and duration histograms are labelled by route pattern (
/users/:id), never the raw URL. - Also reported: requests in flight, memory and event-loop lag.
- In cluster mode, a scrape that reaches any worker reports the totals of every worker.
- Request counts and duration histograms are labelled by route pattern (
queuelimits concurrent requests (B2).queue: { maxConcurrent, maxQueue, timeoutMs }caps requests handled at once. Extra requests wait, then get503.tex.object()takesoptional,nullableandnullish(B4), like the field builders, with matching types.- Docs: "Deploying with Docker" (B7). It covers:
- a tested multi-stage Dockerfile and
.dockerignore; - why the build needs
--skip-env-check, and thattex.envstill refuses to start without the secrets; - running
node_modules/.bin/exisjs startinstead ofnpm start; - health checks and migrations.
- a tested multi-stage Dockerfile and
Fixed
-
partial()reset fields that an update left out. A PATCH schema made with.partial()kept the fields' defaults, so a request that sent onlystatusresettagsto[](and any other field with a default). Fields left out of a partial schema now stay unset. Found while buildingexamples/05-tasks-api. -
In tests, route files and the test file used two copies of the same module. With
NODE_ENV=test, route files were loaded withrequire()(TypeScript compilesimport()to it for CommonJS) while the test imported them as ES modules, so resetting an in-memory store or spying on a service from a test did not affect the routes. Route files are now always loaded with a realimport(). -
The
create-exisjssample user service returned the password it was sent, andGET /users/:idanswered200withnullfor an unknown id. It now stores and returns only the name and email, and answers404. -
Graceful shutdown skipped
onCloseand the database disconnect when a request was still running. The server stopped listening, the request finished, and nothing kept the process alive any more: Node exited with code 0 before the close hooks ran. Found in Docker while verifying B8. The shutdown now waits for the hooks. -
The publish workflow always published to
latest. A prerelease such as0.10.0-alpha.0would have reached every user. The npm tag now comes from the version (-alpha→alpha,-beta→beta,-rc→next), and the GitHub release is marked as a prerelease. -
ValidatorErrorwas not exported fromexisjs/validator(B3). Neither wereTexBuilder,TexType, the option types, orSafeParseResult, although the API reference listed them. Useerr instanceof ValidatorError. -
Ctrl+C in
exisjs devprintedFailed to start process: write EPIPE(B5). On Windows the server process often exits first; telling it to stop then failed, and the error was printed as a start failure. A closed channel during shutdown is now expected, and real errors after start are reported asServer process error. -
exisjs devdid not reload when the project sat inside a folder such asTemp,data,buildorpublic(B6). The watcher matched its ignore rules against the full path, so every file of such a project was ignored,.envincluded. The rules now apply only inside the project..envfiles are also watched whenwatch.pathsis set. -
Backpressure leaked a slot for every streamed or aborted response. It counted a request as finished only through
res.end(), so a server under the limit eventually queued or rejected everything. It now counts the connection closing. A client that gives up while waiting also frees its place in the queue. -
Production startup hid a broken routes manifest. If the manifest could not be imported, a warning was logged and the server fell back to scanning the source folders; it now stops (B9).
Examples
- New
examples/05-tasks-api, created withcreate-exisjs(functional routes): a tasks API using query coercion and pagination, nested optional objects,tex.url,pattern, objectrefine,partial()updates,safeParse()batch import,healthcheck,metricsandqueue.npm run verify:examplesbuilds it and starts it withnode .exis/server/main.js. - Examples 01 to 04 were converted with
exisjs migrate.
Upgrade Notes
-
partial()no longer applies defaults. A field left out of a partial schema stays unset. Code that relied on a PATCH resetting omitted fields to their defaults must send them. -
Run
npx exisjs migrate, thennpm install. Until you do,exisjscommands still work through the runtime package and print a notice; from 0.11 they need@exisjs/cli. -
In a Dockerfile: install production dependencies with
npm ci --omit=dev --omit=optional, and start withCMD ["node_modules/.bin/exisjs", "start"](orCMD ["node", ".exis/server/main.js"]). If you use the uWebSockets.js engine, ...
v0.9.2
ExisJS Release v0.9.2
Release Date: 2026-10-17
Type: Patch: Development Server Fixes, Safe Default Environment, Validator & Sanitizer Overhaul
Affected Packages: exisjs, create-exisjs, @exisjs/fetch (all aligned to 0.9.2)
Highlights
Fixes from a report on 0.9.1 by a team running two production backends. Four of the five issues affect only exisjs dev. The fifth is a security fix: a server started without NODE_ENV sent error messages and stack traces to clients.
The tex validator was reworked. Nested objects, array items, records and unions now get every rule a top-level field gets (sanitizers, refinements, defaults, dates, strict mode), schemas can be reused safely, and parse() no longer modifies its input. Schemas with defaults, trimming or refinements validate 4-5x faster, and invalid requests are rejected about 10x faster.
Fixed
- Concurrent first requests to a route could get
404in development. The placeholder for a route that had not loaded yet removed itself before it imported the route file, so a request arriving during the import found no route. This was common when React's development mode sent each request twice. Every request now waits for the same load, and the placeholder is removed only once the real routes are mounted. - A route file that failed to load left the request open. The error was printed to the console, but the client got no response, and every later request got
404until the file was saved. The request now gets a500with the load error (development only), and the next request tries the file again. - Route files ran twice when the dev server started. The startup check for mixed class-based and functional routes imported each file, and the first request imported it again at a new address. Module-level state (caches, counters, connections opened at import) existed twice. The first mount now reuses the module the check imported.
Validator (tex)
Fixed
.optional(),.nullable()and.nullish()changed the schema they were called on. AfterName.optional()in one object, every other object usingNametreated it as optional. Every schema method (refine,sanitize,describe,example,transformtoo) now returns a new schema.- Rules inside nested objects were ignored.
.sanitize(),.refine(),.refineAsync(),.transform(),defaultandtex.date()checks did not run in a nestedtex.object, an array of objects or a record, andstrict: trueon a nested object was ignored.tex.array(tex.date())accepted anything. Every field now behaves the same at any depth. tex.record(tex.object(...))andtex.union([..., tex.object(...)])did not validate the object.parse()wrote into its input: defaults, coerced numbers and sanitized values replaced the caller's data. The input is now left untouched; object and array defaults are copied for each parse.- A sanitizer that throws (such as
sanitize.preventSql) answered500. It is now a400validation error for that field, codeSANITIZATION_FAILED. tex.literal(42)accepted"42"and returned the string. Literals now match by value and type.- Enum values containing
,or|or surrounding spaces, and literals containing|, never matched. tex.date()withoutcoerceaccepted any string and returned it as text, although its type isDate. An unparsable date is now rejected (INVALID_DATE), and a valid date string or timestamp is always returned as aDate.- Only the first bad array item was reported. A failed parse now lists every failing value: each bad item of an array, each bad field of a nested object and each bad record value (up to 100).
partial()andtex.env()dropped transforms, descriptions and examples of the fields they copied.
Added
tex.string({ pattern: /…/, length: 4 }),tex.number({ int: true })andtex.url({ protocols })(rejectsjavascript:and other schemes by default).safeParse()/safeParseAsync()on object schemas, transformed schemas and discriminated unions:{ success, data }or{ success: false, errors, error }. The API reference already listedsafeParse(); it now exists.- Object-level
.refine(fn, { message, path })for checks across fields (for example password confirmation), also when the object is nested. The guide already showed it; it now exists. .default(value)on fields, as documented.- A transformed object (
tex.object(...).transform(...)) and a discriminated union can be used as fields. - OpenAPI output includes string
minLength/maxLength/pattern,format: uri,type: integer, record value schemas,anyOffor unions andadditionalProperties: falsefor strict objects.
Performance
npm run bench:validation with NODE_ENV=production (mitata, time per parse; the 0.9.1 column used EXIS_DIST on the 0.9.1 build):
| Schema | 0.9.1 | 0.9.2 | Ajv |
|---|---|---|---|
| Flat object, 3 fields | 44.7 ns | 43.3 ns | 80.4 ns |
| Nested object with an array of 3 | 91.5 ns | 94.1 ns | 69.8 ns |
| Trim, default, optional array, refine | 183 ns | 39.9 ns | — |
| Query coercion with defaults | 205 ns | 42.2 ns | — |
| Invalid payload, all errors listed | 64.0 µs | 6.2 µs | 64.4 ns |
Ajv only marks a payload invalid; tex also builds the error list (path, code, message, received value per field) by throwing out of the compiled check, which is why rejection is still slower than Ajv. In development the rejection takes about 16 µs because the error keeps its stack trace.
Defaults, sanitizers, refinements, transforms and query coercion are now part of the compiled validator, not separate passes over the data. A failed check no longer builds three Error objects with stack traces; in production a ValidatorError is created without one.
Sanitizers (exisjs/sanitize)
Fixed
stripHtmlkept the content of<script>and<style>("<script>alert(1)</script>"became"alert(1)") and deleted ordinary text after a<("a < b and c > d"became"a d"). It now removes comments and script/style elements with their content, and a<that does not start a tag stays.preventTraversalmissed percent-encoded (%2e%2e%2f, double-encoded) and trailing.., and rejected file names such asreport..final.pdf. It now checks for a..path segment after decoding, and rejects NUL bytes.slugifydropped accented letters ("Café"became"caf"); they now keep their base letter ("cafe").pickcopied inherited keys such asconstructor;dedupeandcompactthrew on, or split, a non-array;truncatecould cut an emoji in half;normalizeLineEndingsignored a lone\r.- The API reference listed sanitizers that could not be imported (
stripUnknownKeys,deepTrimStringValues,defaultIfNaN,normalizeToIsoString,stripTime): they lived in an unused duplicate module, which is removed.
Added
stripControlChars (control characters and bidirectional overrides), normalizeEmail, round(decimals), clamp(min, max), trimElements, limitLength(n) and deepTrim.
Security
- A server started without
NODE_ENVran in development mode and sent the full message and stack trace of unexpected errors to clients, including SQL text, query values and file paths. This affected servers started withnode,tsx, a process manager, or a Docker image withoutENV NODE_ENV=production.envnow defaults to'production'; only an explicitNODE_ENV=development(whichexisjs devsets) enables development behaviour.exisjs startalready set production and is not affected.
Added
-
dev.lazyRoutes(defaulttrue). Set it tofalseto load every route file when the dev server starts, the wayexisjs startdoes:export default defineConfig({ dev: { lazyRoutes: false } })
Upgrade Notes
- If you start a development server some way other than
exisjs devand rely on development error pages or lazy routes, setNODE_ENV=developmentorenv: 'development'inexis.config.ts. .envfile selection is unchanged: withoutNODE_ENV,.env.developmentis still loaded.- Schema methods return a copy. Code that called
field.refine(fn)orfield.sanitize(fn)without using the returned schema relied on the old mutation; use the return value (const Email = tex.email().refine(fn)). tex.literal(42)no longer accepts the string"42". For query strings or path params, usetex.coerce.number()or a string literal.tex.date()always returns aDate, also withoutcoerce, and rejects unparsable values. Code that compared the parsed value with a string (body.at === '2026-01-01') must compare dates instead (body.at.toISOString()); JSON responses still send an ISO string.- Validation errors now list every bad array item and nested field. Clients that showed only
details[0]keep working; those that counted errors see more of them. sanitize.stripHtmlnow removes<script>/<style>content and keeps a<that does not start a tag;sanitize.slugifykeeps accented letters.ValidatorErrorhas no stack trace in production (NODE_ENV=production). Itserrorslist is unchanged.
Full Changelog: v0.9.1...v0.9.2
v0.9.1
ExisJS Release v0.9.1
Release Date: 2026-10-16
Type: Patch: Bug Fixes, Conditional Requests & Request Path Cleanup
Affected Packages: exisjs, create-exisjs, @exisjs/fetch (all aligned to 0.9.1)
Highlights
Fixes from two reports on 0.9.0: the 0.8.1 test suite re-run against 0.9.0 (E-01, E-02), and a CRM built on 0.9.0 (nested object schemas). No breaking changes.
ETags and conditional requests follow RFC 9110 everywhere. Automatic ETags, static files and downloads share one evaluator for If-Match, If-Unmodified-Since, If-None-Match and If-Modified-Since. The new res.checkPreconditions() lets handlers answer 304 from a version column before loading data, and refuse lost updates with 412 / 428.
Fixed
- A returned
Bufferwas sent as JSON behind a boundary wrapper (E-01, was T-13). A boundaryhandle(ctx, next)/ default-export wrapper took the route's response back and re-sent every object withres.json, so aBufferbecame{"type":"Buffer","data":[...]}, andres.json(5)orres.json(null)became text. The wrapper now replays the response through the method the route used. A wrapper that awaitsnext()but returns nothing also sends the route's response; before, the request stayed open. .optional()on a nestedtex.objectwas ignored.Address.optional(),tex.object(shape).partial().optional()andextend({ address: Address.optional() })all required the key. The modifier now carries throughtex.object,tex.arrayitems,extend,merge,pick,omitandpartial, and the OpenAPI output agrees with the validator.partial()skipped nested objects. A nested object whose own fields were optional stayed required afterpartial().- A plain nested shape crashed.
tex.object({ address: { city: tex.string() } })failed withs.slice is not a function, although its types allow it. It now works the same astex.object({ city: ... }). - An array item error was listed twice in the validation error details.
- Unclear error for a missing field schema.
tex.object({ address: undefined })threwCannot read properties of undefined (reading 'length'); it now throwstex.object(): field "address" has no schema (got undefined). A transformed or discriminated-union schema used as a field, which never worked, gets a clear error too. - The generated
AGENTS.mdpointed tonode_modules/exisjs/docs/, which does not exist. The docs ship innode_modules/exisjs/dist/docs/. - The API reference index linked to absolute paths on the author's machine; the links are relative now.
- The
03-all-featuresexample sentX-Powered-By(E-02). - Binary request bodies were corrupted. The body was decoded as UTF-8 text and turned back into bytes, so
req.buffer()and@RawBody()replaced every byte that is not valid UTF-8 (images, protobuf, compressed or signed payloads). The bytes are now kept as received, and text is decoded from them only when asked for. @RawBody()hid body errors. A body overbodyLimitreached the handler as an empty Buffer instead of answering413.- The test client JSON-encoded Buffers.
.send(buffer)increateTestAppsent{"type":"Buffer","data":[...]}; it now sends the bytes, asapplication/octet-streamunless a content type is set. - In development,
res.cookie()copied each cookie into anX-Set-Cookieheader, which page scripts can read, sohttpOnlycookies were exposed to JavaScript. The header is gone. res.html()sent noContent-Lengthand skipped ETag /304handling; it now goes throughres.send().- A thrown non-
Errorvalue in a functional route is handled the same way as in class controllers.
Conditional Requests (ETag, 304, 412)
- New
res.checkPreconditions({ etag, lastModified, exists }, { required }). On GET/HEAD it setsETag/Last-Modifiedand answers304when the client's copy is current (returnstrue). On writes it throws412 PRECONDITION_FAILEDwhenIf-Match/If-Unmodified-Sinceno longer hold orIf-None-Match: *targets an existing resource, and428 PRECONDITION_REQUIREDwith{ required: true }when the client sent no precondition. - One RFC 9110 §13.2.2 evaluator for automatic ETags,
serveStatic,res.download(path)andreq.fresh: the standard evaluation order, strong comparison forIf-Match, weak forIf-None-Match, lists,*, and tags containing commas. Unparsable dates are ignored. If-None-Matchlists and weak tags now match on static files (only an exact single string matched before), andIf-Modified-Sinceworks: static files and downloads sendLast-Modified.- A
Cache-Control: no-cacherequest header no longer disables revalidation. It made the server answer200with the full body even when the client's tag matched; RFC 9110 has the origin evaluate conditionals regardless. 304responses carry no body metadata.Content-Type,Content-LengthandContent-Encodingare removed;ETag,Last-Modified,Cache-ControlandVaryare kept.- A failed
If-Matchon a read answers412with the standard error envelope. res.download(path)supportsETag,Last-Modified,304and412.- Automatic ETags (
etag: true) only on successful GET/HEAD responses. Writes and error responses were hashed for nothing. Hashing uses Node's one-shotcrypto.hash()where available (about 35% faster) and no longer copies string bodies into a Buffer first. serveStaticno longer blocks the event loop: it picks the.br/.gzvariant with asyncstatinstead of twoexistsSynccalls per request. It always sendsVary: Accept-Encoding, so a cache never serves the Brotli file to a client that did not ask for it.br;q=0is a refusal.serveStaticandcompression()treated any mention of an encoding inAccept-Encodingas acceptance.- Static file ETags are weak (
W/"size-mtime", assend/serve-staticuse): file metadata does not prove two files are byte-identical. - Dropped the
freshdependency;req.freshuses the same evaluator. - The in-process test client closes its connection once the response is complete. Tests of static files and downloads took about 4 s per request while it waited for the keep-alive socket to time out.
Database
@Transactionalhanded a transaction to concurrent requests. It wrote the transaction onto the service instance (this.db = tx) for the length of the call; services are shared singletons, so any other request using the same service meanwhile ran its queries inside that transaction, and could be committed or rolled back with it. The transaction is now visible only in the async context of the call (and the methods it calls), throughAsyncLocalStorage;thisstays the real instance, so#privatefields keep working.- Databases connect in parallel at boot.
connectAll()connected one after another, so boot took the sum of every connection time; it now takes the slowest one. A failure names every database that failed (error.failures), the others stay connected, and a retry only reconnects the failed ones. - Health checks run in parallel, with a timeout. One unresponsive database hung
/_ready(and the load balancer marked the instance dead on its own timeout); each check now fails afterhealthTimeoutMs(default 5 s) withstatus: 'error'. Probes that arrive while a check is running share its result. Customhealthcheck.checksget the same 5 s limit.
Changed
- 404s no longer scan the route table. Answering
405/Allowon a miss looked at every registered route on every unmatched request; the router now keeps the set of methods in use, so a miss costs the same with 10 or 10,000 routes. - Functional controllers (
controller({ ... })inroute.tsfiles) work out guards, interceptors and the route timeout once per route instead of on every request, and build the guard context only when a guard or interceptor needs it. fast-json-stringify(used for response schemas when installed) is loaded on first use instead of at startup.- Removed unused request/response pooling code (
ExisRequest.init()/cleanup(),ExisResponse.init()). - New
npm run bench:pipeline: per-request framework cost without the network, for checking routing, request and response changes.
Dependencies
pino10.4.0 andpino-pretty13.2.0 (runtime,exisjs).- Removed
fresh(replaced by the built-in conditional request evaluator). - Development and examples:
typescript-eslint/@typescript-eslint/*8.71.2,hono4.13.13 (benchmarks),mongoose8.24.5 (02-bookstore).
Added
.nullable()and.nullish()on object schemas (TexEngine), next to.optional().
Documentation
- Validation: modifiers on nested object schemas, and plain nested shapes.
- Boundaries: what a
handle(ctx, next)wrapper's return value does. - Middleware:
asyncmiddleware is awaited, and a thrownHttpErrorbecomes the response.
Upgrade Notes
npm install exisjs@0.9.1- Databases registered with
registerDatabase()now connect at the same time. If one database'sconnect()relies on another being connected first, connect the second inside the first'sconnect(). - Static file ETags change format once (strong to weak), so browsers download each file one more time after the upgrade.
- If your clients send
If-Matchon GET requests to routes withetag: true, a stale tag now answers412instead of200. - Code that relied on the development-only
X-Set-Cookieheader: read cookies fromSet-Cookiein your HTTP client or the browser's developer tools instead. - If you worked around nested optional objects with a hand-written rule such as
`object<…> | optional | nullable`, you can switch back toAddress.optional()orAddress.nullish(). - Projects created before 0.9.0 keep the old template defaults after upg...
v0.9.0
ExisJS Release v0.9.0
Release Date: 2026-10-14
Type: Minor (breaking before 1.0): Correctness, Security & Production Readiness
Affected Packages: exisjs, create-exisjs, @exisjs/fetch (all aligned to 0.9.0)
Highlights
This release works through two field reports from a production ERP backend built on 0.8.1 (BUG-01 to BUG-10, T-01 to T-26). Every fixed finding has a regression test in packages/exisjs/tests/conformance.test.ts or next to the code it covers.
- No more cross-user leaks. Request-scoped providers were shared between requests, and
@Idempotentreplayed one user's response to another. - Every request gets an answer. A handler that returns nothing answers
204instead of leaving the connection open; a rejection withnullis a500, not a404. - Class controllers behave like functional routes. Returning
res.json(),res.send(),download()or aBufferworks;@UseInterceptorsactually wraps the handler. - Safer authorization. No magic
isSuperAdminbypass,401for anonymous callers, permissions are all-of, and the same rules apply to both paradigms. - One error envelope everywhere, reporting every failing field.
- Production defaults: a Content-Security-Policy by default, no public
/docsin the template, readiness endpoint, shared stores for rate limits and cron locks.
⚠️ Breaking Changes
1. @Permissions requires all listed permissions
@Permissions('report:read', 'report:export') (and functional permissions: [...]) now needs both. It used to pass with either one in class controllers. A role is no longer counted as a permission: permissions come from req.user.permissions only. @Roles still needs one of the listed roles.
2. Anonymous callers get 401
A route with roles or permissions answers 401 UNAUTHORIZED when there is no req.user, and 403 FORBIDDEN when the user lacks them. Both use the standard error envelope (they used to lack statusCode).
3. req.user.isSuperAdmin no longer bypasses checks
The undocumented bypass is gone: any app that fills req.user from client-controlled data could be fully opened with one field. Declare super users explicitly:
// exis.config.ts
authorization: { superUser: (user) => user.role === 'owner' }4. One error envelope; legacy fields removed
Error bodies are { success: false, statusCode, error: { code, message, details? } } on every path. The duplicate top-level message, validationErrors, errors, details and timestamp fields are gone, and HttpError details are no longer copied to the top level (a details key named success or statusCode could overwrite the real values). Read error.details instead. To keep the old fields while clients migrate: errors: { legacyFields: true } (deprecated, removed in 1.0).
5. Cron jobs do not start under NODE_ENV=test
Jobs and timeout tasks are registered but not scheduled during tests, so a test run cannot fire real jobs. Run one with app.cron.trigger(name), or opt in with createTestContext(app, { cron: true }) / app.cron.autoStart = true.
6. Helmet sends a Content-Security-Policy by default
default-src 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'; form-action 'self'. Apps that serve HTML with inline scripts or third-party assets set their own contentSecurityPolicy, or false to send none. The /docs page and the development error page send their own policies. A policy already set by your own middleware is kept.
7. What a returned value becomes is the same in both paradigms
undefinedwith nothing sent:204(or the@HttpCodestatus) instead of an open connection.null: JSONnull.- A string:
text/plain(class controllers used to send it as a JSON string). - A number or boolean: JSON (functional routes used to send it as text).
8. Malformed JSON is always 400 INVALID_JSON
Class controllers (@Body()) and functional routes used to treat a body that failed to parse as {}.
9. Smaller changes
ValidationPipeand DTOvalidate()failures areVALIDATION_ERRORwith a per-fielderror.detailslist, anderrorHttpStatusCodeis honoured (it was ignored).- The
/docsroute is still controlled bydocs.enabled; the template now sets it toprocess.env.NODE_ENV !== 'production'. exisjs devlocks per port: the lock file is.exis/dev-<port>.pidinstead of.exis/dev.pid.
Security
- Request-scoped providers were shared between requests (T-01).
@Injectableclasses are registered as{ useClass }at boot, and that path ignored@Injectable({ scope }), so every provider became a singleton. Controllers were also built once. Scopes now work, and propagate: anything that depends on a request-scoped provider is itself built per request.transientgives a new instance per injection (T-12).inject()andctx.resolve()share one cache per request. @Idempotentleaked responses across users (T-04). Keys are now scoped to the user (req.user.id/sub, or ascopefunction) plus method and path. Reusing a key with a different body answers422 IDEMPOTENCY_KEY_REUSED; a repeat while the first is running answers409 IDEMPOTENCY_IN_PROGRESS.- Connection strings in validation errors (BUG-04). Fields named
*_URL,*_URI,*_DSN,*CONNECTION*are redacted, and the password of anyscheme://user:password@hostvalue is masked in logs, responses andtex.envstartup errors. - Authorization bypass via
isSuperAdmin(T-07), see Breaking Changes. beforeHandleresponses were overwritten (T-09). A boundary hook that wrote a401and returnedfalsesent a403instead; the hook's response is now kept.
Fixed
- Class controllers sent internal objects (T-03 / BUG-10).
download()results,Buffers (T-13) and response objects returned from handlers are handled by one shared function for both paradigms. @UseInterceptors(T-05). The interceptor's result is now the route's result, and the handler runs once (it used to run twice, once inside the interceptor with its result discarded).- Hung requests (T-02). See Breaking Changes 7. The test client also fails a request after 5 s (
.timeout(ms),EXIS_TEST_TIMEOUT), naming the route, instead of hanging the run. HEAD(T-11) is answered by theGETroute without a body; a known path with the wrong method answers405with anAllowheader (T-14).- Validation reports every failing field (T-17), capped at 100.
- Cron jobs registered twice (T-22 / BUG-05). Discovery scans either the source or the compiled tree, never both.
- Validation logs showed
/userinstead of/fn/user(T-23) for functional controllers mounted under a prefix. - Dev error page (BUG-03). API clients get JSON
503 BUILD_ERROR, browsers the HTML page; ANSI codes are stripped; it binds the configured host; edits to.envfiles (including ones created later) reload the app. - Dev lock (BUG-06). One lock per port, so a second instance on another port can run; a lock left by a crashed process whose PID was reused no longer blocks;
--forceskips the check. tex.enumaccepts readonly arrays (BUG-01), soas constlists work directly.- Fields with a
defaultare typed as present after parsing (BUG-02). NewResolveInput/ResolveOutputtypes (likez.input/z.output). exisjs buildfails on mixed paradigms (T-25), listing the files of each kind, instead of failing at startup.exisjs buildoutput layout no longer depends on which folders hold entry files (esbuildoutbaseis the project root).listen({ port: 0 })reports the port the OS assigned.- Template lint (T-21). A fresh project passes
npm run lint; the generatedexis.d.tsis excluded and markedeslint-disable. The functional guard template readreq.headersfrom an execution context and threw; both guard templates now usecontext.switchToHttp().getRequest(). ExisUseraugmentation (SEC-2).exisjsexported a second, unrelatedExisUser, sodeclare module 'exisjs' { interface ExisUser {...} }never reachedreq.user. It now does.
Added
authorization.defaultPolicy: 'authenticated': deny by default, so a controller route without@Roles/@Permissions/@Publicrequires a signed-in user (SEC-1).authorization.superUserconfig option.errors.legacyFieldsconfig option (deprecated on arrival).rateLimit({ store })withRateLimitStore/MemoryRateLimitStore, for one limit across instances (SEC-3). A failing store lets requests through and logs a warning.app.cron.lock(CronLock) so only one instance runs each scheduled job (ENT-1);lockTtlMsjob option./_readyreadiness endpoint (healthcheck.readyPath):503unless checks pass, registered databases are healthy and the app is not shutting down (ENT-4).- Every line logged through
req.log(handlers, the error handler, validation diagnostics) carries the request'srequestId(ENT-3). exisjs routesshows each route's access rule (ACCESS column, andaccessin--json) (DEV-3).- Test harness (T-26):
.query({...}),.timeout(ms),startTestServer(app)for real-socket tests. Inject,OptionalandforwardRefare also exported fromexisjs/decorators, as the reference documents.exisjs dev --force.- Bench target
exis-class(class controllers, same routes asexis-node).
Changed
- Per-route work in class controllers (access rule, guards, interceptors, parameter metadata) is computed once at registration instead of on every request (T-20).
- The generated
AGENTS.mdno longer points assistants to a built-in migrations/query-builder/OAuth layer that does not exist.
Documentation
- New: route authorization semantics, security baseline, what a return value becomes, input/output types, readiness, rate-limit and cron stores, cron in t...