Skip to content

Releases

v0.10.2

Choose a tag to compare

@github-actions github-actions released this 03 Nov 09:08
f1b6d50

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.

  1. A route or boundary file must be named exactly. Only route.ts, <name>.route.ts, boundary.ts and <name>.boundary.ts (or .js) are loaded. A file that only ended the same way, such as reroute.ts, users-route.ts or myboundary.ts, used to load and no longer does, so its URLs answer 404 and nothing stops the start.
    Migrate: run npx exisjs doctor, which warns about these names, and rename any that are real route files to <name>.route.ts.

  2. A folder can hold only one route file. health.route.ts next to extra.route.ts stops the start, even when their paths differ.
    Migrate: delete the leftover file, or move the second resource to a sub-folder.

  3. A folder can hold only one boundary file. Before, boundary.ts won and admin.boundary.ts was silently ignored, so the guards in the ignored file never ran. This includes boundary.ts next to boundary.js.
    Migrate: merge them into one file.

  4. 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] and users/[slug] collide when both handle GET /.
    Migrate: keep one of them, or give each its own path.

  5. A route file can't repeat a route registered in code. An app.get('/a', ...) in server.ts and a GET /a from a route file stop the start. Two routes that are both registered in code are still allowed.

  6. { code } passed to three error classes is now a code, not details. BadRequestError, UnprocessableError and PayloadTooLargeError (and their factories) already took details second. An object holding only code and details keys, with a string code, 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' } }.

  7. A route with a body schema reads only JSON and forms. Any other Content-Type answers 415 UNSUPPORTED_MEDIA_TYPE, including text/plain, which a browser can send cross-site with no preflight. A request with no Content-Type is still read as JSON.
    Migrate: a client calling fetch(url, { body: JSON.stringify(data) }) sends text/plain, so it must also set content-type: application/json.

  8. serveStatic hides files and folders starting with a dot, except /.well-known/.
    Migrate: set dotfiles: 'allow' if you really serve them.

  9. ipFilter stops 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.

  10. tex.string() refuses a null byte, which databases either reject with a server error or cut the value at.

  11. An error is a 400 only when it is a real validation failure: ZodError, ValidationError, ValidatorError, an issues array, or a plain Error with an errors object. A custom class with an errors property now answers 500.
    Migrate: throw HttpError or one of its subclasses with { details }.

  12. Multipart fields count towards bodyLimit, and a field over the 1 MB field limit answers 413 instead of being cut short.

  13. Forwarded values are checked. req.protocol reads only http, https, ws or wss, req.host ignores a value that isn't a host, and trustProxy: 'cloudflare' reads X-Forwarded-Host and X-Forwarded-Proto only from Cloudflare.

  14. On uWebSockets.js, bodyLimit applies to uploads too, because uWS holds the whole body in memory.

  15. 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.

  16. Responses now send Cross-Origin-Resource-Policy: same-site. Another site can no longer embed them with a tag such as <img> or <script>. fetch with CORS and subdomains of your own site still work.
    Migrate: for files other sites embed, such as public images, set helmet: { crossOriginResourcePolicy: 'cross-origin' }.

  17. 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'] }, with credentials: true if the app sends cookies. cors: true keeps the old behaviour of allowing any site.

Security

  • serveStatic served dotfiles. A .env or .git folder left in the static folder could be downloaded.
  • ipFilter ignored IPv6 CIDR rules, so a deny rule such as 2001:db8::/32 blocked nobody.
  • Errors could show internal details in production. Any error with an errors property answered 400 with its own message, including AggregateError from a failed connection (connect ECONNREFUSED 10.0.0.5:27017) and MongoDB bulk write errors.
  • Content-Type was matched as a substring, so text/plain; x=multipart/form-data was read as multipart, and JSON was accepted as text/plain. This is the same class of bug as Fastify CVE-2026-25223.
  • X-Forwarded-Host and X-Forwarded-Proto were trusted from anyone under trustProxy: '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, id or retry could start a new event, so user text sent through res.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 ipFilter or on a VPN.
  • helmet sent no Cross-Origin-Opener-Policy or Cross-Origin-Resource-Policy, so another site could embed a signed-in user's response with a no-cors request. 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.
    • bodyLimit was 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, so ipFilter couldn't block anyone or could allow everyone, and all clients shared one rate-limit bucket. req.ip is now the real address.
    • ssl was silently ignored, so a server configured for TLS ran unencrypted.
    • bodyLimit was 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 204 threw 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 several Set-Cookie headers were merged into one.
  • Over HTTP/2, req.host was empty, so csrf() refused every same-origin POST, and a Host header added by a client could satisfy its Origin check. It now reads :authority.
  • A malformed multipart body answered 500. It now answers 400.
  • exisjs routes on a plain JavaScript entry file showed no access column.
  • ServiceUnavailableError, GatewayTimeoutError and their *Exception aliases were documented but not exported from exisjs/error.
  • Route and boundary matching was too loose: any file name ending in route.ts or boundary.ts was loaded. The server, exisjs build and exisjs doctor now share one rule.
  • Duplicate routes were silent: exisjs routes showed a doubled row, and one handler never answered.
  • exisjs routes hid start-up errors. A route conflict now prints with the files involved and exit...
Read more

v0.10.1

Choose a tag to compare

@github-actions github-actions released this 23 Oct 05:17

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.

  1. trustProxy: true no longer reads True-Client-IP, X-Real-IP or X-Client-IP. Any client can send these, so it could choose its own req.ip and get a fresh rate-limit bucket on every request. true now reads CF-Connecting-IP, then X-Forwarded-For.
    Migrate: behind nginx that sets only X-Real-IP, use trustProxy: { header: 'x-real-ip' }. Behind Cloudflare, use the new trustProxy: 'cloudflare'.
  2. 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 gets 403), not a 400 that lists the expected fields.
  3. exisjs test fails 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 an after() hook, or run exisjs test --force-exit.
  4. The default healthcheck.drainMs in production is 2 seconds (was 5). Drain plus up to 5 seconds for requests now fits inside Docker's default 10-second stop period. Set drainMs to keep the old value.
  5. hpp() leaves JSON bodies alone. It used to replace every array in a JSON body with its last element.
  6. 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.0 exisjs start logged it and kept running, createTestContext() ignored it, and boot() threw.
    Migrate: nothing, for a database the app needs. For one it can run without (a cache, a search index), set required: false on registerDatabase: the server then starts, /_ready answers 503, and it reconnects on its own.
  7. A declared ExisUser is exact. Once an app declares interface ExisUser { ... }, reading a field it doesn't declare (req.user.userId after userId was removed) is a compile error. Apps that don't declare ExisUser see no change. All six of our apps compile unchanged.
  8. New projects start with "start": "node .exis/server/main.js", and exisjs migrate changes "start": "exisjs start" to it. npm prune --omit=dev removes the exisjs command together with the CLI, and npm start then failed with exisjs: not found.
  9. The CLI stops when exisjs and @exisjs/cli are different versions, and names the matching install. npm already refused such an install; pnpm and npm install --legacy-peer-deps allowed it.

Fixed

  • Compressed responses were sent as 204 with no body (compression: true). The router saw the response as unsent while zlib was still working. The test client now decodes gzip, br and deflate, so a test that sends Accept-Encoding sees 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 a whitelist option 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 real import(), and a file that fails to load reports its own error instead of a require() fallback hiding it.
  • exisjs test printed nothing while it hung, and the reporter counted top-level it() tests (outside a describe) only sometimes. Each file now prints when it finishes, with PASS or FAIL from its real result.
  • csrf() behind a reverse proxy compared the browser's Origin with the upstream Host, so every state-changing request failed with 403 CSRF_FAILED. With trustProxy set it now uses X-Forwarded-Host. Outside production, a mismatch puts the expected host and the received origin in details.
  • mongoSanitize() dropped every dotted key without a way to keep them, which broke webhook handshakes such as Meta's hub.mode. It also tried to parse every body as JSON.
  • A /_ready 503 during shutdown was logged as an error on every deploy. It is intended, so it now logs at info. A 503 because a database is down still logs as an error.
  • create-exisjs templates: routes were open unless remembered, and the example guard allowed any Authorization header.
  • RateLimit-Reset and Retry-After could 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: req inside them didn't have the context's fields. A loose req.user type hid it.
  • exisjs migrate left 0.8 and 0.9 ranges of exisjs next to a 0.10 CLI, so the install failed on the peer dependency. It now moves exisjs to the CLI's release, and its Dockerfile, nixpacks.toml and Procfile advice points at node .exis/server/main.js.
  • Docs: the Dockerfile's HEALTHCHECK used port 3000 and the template's /health route; the testing page showed the wrong validation error shape and a --coverage flag that doesn't exist.

Added

  • trustProxy: 'cloudflare' trusts CF-Connecting-IP only 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 trustProxy is off (production, once): every client then shares the proxy's address and one rate-limit bucket.
  • req.host: the host with its port, from X-Forwarded-Host behind a trusted proxy. req.csrfToken is 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 at debug, like the health probes.
  • Lockouts keyed on a body field: consume(store, key, { max, windowMs }) and assertNotLimited(store, key, { max, windowMs }) throw the standard 429 RATE_LIMIT_EXCEEDED. RateLimitStore gains optional get() and reset(), 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 a 401.
  • Strict request types, opt-in: declare module 'exisjs' { interface ExisTypeOptions { strict: true } } types req.user as possibly undefined and refuses request properties nobody declared. See "Typing req.user" in the request docs.
  • exisjs test --force-exit, and EXIS_TEST_EXIT_GRACE_MS to 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 /_health and /_ready probes. 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.1

Then 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 answered 200 with the body, docker stop took 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 start works.
  • 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

Choose a tag to compare

@github-actions github-actions released this 21 Oct 06:29

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.

  1. queue is checked at start-up.

    • queue.driver must 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-features example had enableWorkers: true, which nothing ever read; remove it if you copied that block.
    • queue now applies backpressure (see Added). With queue: { 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 the queue block if you don't want a limit.

  2. 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.

  3. 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.env while the route file loads.

    Migrate: provide the variable, or fix the file named in the message. Development (exisjs dev) is unchanged: such a route answers 500 with the error.

The CLI moves to @exisjs/cli

  • New package @exisjs/cli with the exisjs command (dev, build, test, run, start, routes, generate, ...). It depends on the build tools (esbuild, tsx, chokidar, commander, pino-pretty) and takes typescript from the project as a peer dependency, so the project controls its TypeScript version.
  • exisjs no longer depends on the build tools. It lists @exisjs/cli as an optional dependency until 0.11, so projects that run exisjs dev without listing @exisjs/cli keep working, with a one-line notice. A production image leaves it out with npm ci --omit=dev --omit=optional.
  • exisjs build writes .exis/server/main.js. node .exis/server/main.js starts the server in that process: Node receives SIGTERM directly, and production needs neither the CLI nor npm. It changes to the project folder first, so it works from any directory, and defaults NODE_ENV to production (also when it is set but empty). In cluster mode it is the primary and forks the workers.
  • exisjs start is 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/cli can run it, so "start": "exisjs start" and npm start work everywhere; the other commands need @exisjs/cli.
  • exisjs migrate updates a 0.9 project: adds @exisjs/cli (and typescript when missing) to devDependencies, and lists the Dockerfile lines to change. The start script stays exisjs start. --dry-run only shows the changes.
  • create-exisjs generates the new layout.
  • pino-pretty is optional for the runtime. When pretty logs are requested but pino-pretty is 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 exceeds size-budget.json, or when any build tool is installed. publint and @arethetypeswrong/cli check both packages' exports and types.
  • Types resolve with every moduleResolution. exisjs/validator, exisjs/router and the other subpaths now also have types under the older node (node10) setting, through typesVersions.
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 serves GET /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.
  • queue limits concurrent requests. queue: { maxConcurrent, maxQueue, timeoutMs } caps requests handled at once. Extra requests wait, then get 503.
  • tex.object() takes optional, nullable and nullish, 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 that tex.env still refuses to start without the secrets;
    • running node_modules/.bin/exisjs start instead of npm start;
    • health checks and migrations.

Changes in 0.10.0 (since 0.10.0-beta.0)

  • exisjs migrate flags npm run build without --skip-env-check. It only recognised exisjs build written out in a Dockerfile RUN line. It now also flags npm run build when the project's build script is exisjs build, in a Dockerfile or in nixpacks.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 HEALTHCHECK every 30 seconds wrote 120 GET /health lines an hour at info. Successful requests to /health, and to /_health and /_ready when healthcheck is enabled, now log at debug, which production hides. A failing probe still logs as a warning or error.
  • A database that fails to connect at start is logged. exisjs start hid the error from registerDatabase() connections, so a wrong connection string left no trace. It is now logged as an error; the server still starts, and /_ready answers 503 until the database is healthy.

Changes in 0.10.0-beta.0

  • Ctrl+C in a terminal stops at once. With healthcheck on, Ctrl+C on exisjs start waited for the readiness drain (5 s in production), which is meant for load balancers. It is now skipped for SIGINT when stdin is a terminal. SIGTERM (Docker, Kubernetes) and SIGINT without a terminal (Fly.io, PM2) still drain.
  • Deployment guide: don't remove the CLI with npm prune. exisjs and @exisjs/cli both provide the exisjs command. npm prune --omit=dev --omit=optional removes the CLI's command and doesn't link the runtime's, so exisjs start is not found. Use a clean npm ci --omit=dev --omit=optional, as the documented Dockerfile does, or run npm rebuild exisjs after pruning, or start with node .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.

  • server in exis.config.ts takes effect. The engine was picked before the config file loaded, so only exis({ server }) worked, and the uWS fallback warning was logged while the logger was still silent. A server: '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 says failed to load with uWS's own error instead of not installed. Use node:24-trixie-slim with uWS (see the deployment guide).
  • "Install the CLI" hints name the matching version. npm install -D @exisjs/cli takes latest, which can be an older prerelease whose peer dependency pins a different exisjs, so npm refused with ERESOLVE. 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 wrong queue setting or a failing route showed a code frame from backpressure.js or route-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.

  • /_ready now answers 503 before the server stops accepting connections. Before, the listener closed at once on SIGTERM, so load balancers saw refused connections instead of a failing probe. With healthcheck enabled, the server keeps serving for healthcheck.drainMs (default 5000 in production) while /_ready answers 503, then closes the listener and lets requests finish.
  • The npx exisjs migrate notice appears. npm links @exisjs/cli's exisjs command, so the notice in the runtime's command never ran. The CLI now prints it when package.json lists exisjs but not @exisjs/cli.
  • A global @exisjs/cli hands off to the project's own CLI. Run in a project, it used its own copy of exisjs and typescript. In a project that has exisjs but not @exisjs/cli, it stops and says to install the CLI.
  • exisjs start logs like production. It printed the development banner (with "press h + enter") and dropped the JSON Server running at and shutdown lines....
Read more

v0.10.0-beta.0

v0.10.0-beta.0 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 20 Oct 17:09

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.

  1. queue is checked at start-up.

    • queue.driver must 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-features example had enableWorkers: true, which nothing ever read; remove it if you copied that block.
    • queue now applies backpressure (see Added). With queue: { 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 the queue block if you don't want a limit.

  2. 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.

  3. 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.env while the route file loads.

    Migrate: provide the variable, or fix the file named in the message. Development (exisjs dev) is unchanged: such a route answers 500 with the error.

The CLI moves to @exisjs/cli

  • New package @exisjs/cli with the exisjs command (dev, build, test, run, start, routes, generate, ...). It depends on the build tools (esbuild, tsx, chokidar, commander, pino-pretty) and takes typescript from the project as a peer dependency, so the project controls its TypeScript version.
  • exisjs no longer depends on the build tools. It lists @exisjs/cli as an optional dependency until 0.11, so projects that run exisjs dev without listing @exisjs/cli keep working, with a one-line notice. A production image leaves it out with npm ci --omit=dev --omit=optional.
  • exisjs build writes .exis/server/main.js. node .exis/server/main.js starts the server in that process: Node receives SIGTERM directly, and production needs neither the CLI nor npm. It changes to the project folder first, so it works from any directory, and defaults NODE_ENV to production (also when it is set but empty). In cluster mode it is the primary and forks the workers.
  • exisjs start is 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/cli can run it, so "start": "exisjs start" and npm start work everywhere; the other commands need @exisjs/cli.
  • exisjs migrate updates a 0.9 project: adds @exisjs/cli (and typescript when missing) to devDependencies, and lists the Dockerfile lines to change. The start script stays exisjs start. --dry-run only shows the changes.
  • create-exisjs generates the new layout.
  • pino-pretty is optional for the runtime. When pretty logs are requested but pino-pretty is 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 exceeds size-budget.json, or when any build tool is installed. publint and @arethetypeswrong/cli check both packages' exports and types.
  • Types resolve with every moduleResolution. exisjs/validator, exisjs/router and the other subpaths now also have types under the older node (node10) setting, through typesVersions.
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 serves GET /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.
  • queue limits concurrent requests. queue: { maxConcurrent, maxQueue, timeoutMs } caps requests handled at once. Extra requests wait, then get 503.
  • tex.object() takes optional, nullable and nullish, 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 that tex.env still refuses to start without the secrets;
    • running node_modules/.bin/exisjs start instead of npm start;
    • health checks and migrations.

Changes in 0.10.0-beta.0

  • Ctrl+C in a terminal stops at once. With healthcheck on, Ctrl+C on exisjs start waited for the readiness drain (5 s in production), which is meant for load balancers. It is now skipped for SIGINT when stdin is a terminal. SIGTERM (Docker, Kubernetes) and SIGINT without a terminal (Fly.io, PM2) still drain.
  • Deployment guide: don't remove the CLI with npm prune. exisjs and @exisjs/cli both provide the exisjs command. npm prune --omit=dev --omit=optional removes the CLI's command and doesn't link the runtime's, so exisjs start is not found. Use a clean npm ci --omit=dev --omit=optional, as the documented Dockerfile does, or run npm rebuild exisjs after pruning, or start with node .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.

  • server in exis.config.ts takes effect. The engine was picked before the config file loaded, so only exis({ server }) worked, and the uWS fallback warning was logged while the logger was still silent. A server: '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 says failed to load with uWS's own error instead of not installed. Use node:24-trixie-slim with uWS (see the deployment guide).
  • "Install the CLI" hints name the matching version. npm install -D @exisjs/cli takes latest, which can be an older prerelease whose peer dependency pins a different exisjs, so npm refused with ERESOLVE. 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 wrong queue setting or a failing route showed a code frame from backpressure.js or route-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.

  • /_ready now answers 503 before the server stops accepting connections. Before, the listener closed at once on SIGTERM, so load balancers saw refused connections instead of a failing probe. With healthcheck enabled, the server keeps serving for healthcheck.drainMs (default 5000 in production) while /_ready answers 503, then closes the listener and lets requests finish.
  • The npx exisjs migrate notice appears. npm links @exisjs/cli's exisjs command, so the notice in the runtime's command never ran. The CLI now prints it when package.json lists exisjs but not @exisjs/cli.
  • A global @exisjs/cli hands off to the project's own CLI. Run in a project, it used its own copy of exisjs and typescript. In a project that has exisjs but not @exisjs/cli, it stops and says to install the CLI.
  • exisjs start logs like production. It printed the development banner (with "press h + enter") and dropped the JSON Server running at and 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 queue setting 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 build removes its output, so exisjs start can't run a half-built .exis/.
  • exisjs migrate checks more deployment files: exisjs build without --skip-env-check in the Dockerfile, and start commands through npm start in nixpacks.toml and Procfile.
  • create-exisjs --help prints the options instead of starting the questions.
  • uWebSockets.js is no longer installed with exisjs (breaking for server: 'uws'). It was an optional dependency fetched from GitHub on every install, although only server: 'uws' uses it: installs needed git, lock files pinned an old commit, and where the clone failed, `serve...
Read more

v0.10.0-alpha.2

v0.10.0-alpha.2 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 20 Oct 16:33

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.

  1. queue is checked at start-up (B2).

    • queue.driver must 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-features example had enableWorkers: true, which nothing ever read; remove it if you copied that block.
    • queue now applies backpressure (see Added). With queue: { 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 the queue block if you don't want a limit.

  2. 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.

  3. 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.env while the route file loads.

    Migrate: provide the variable, or fix the file named in the message. Development (exisjs dev) is unchanged: such a route answers 500 with the error.

The CLI moves to @exisjs/cli (B8)

  • New package @exisjs/cli with the exisjs command (dev, build, test, run, start, routes, generate, ...). It depends on the build tools (esbuild, tsx, chokidar, commander, pino-pretty) and takes typescript from the project as a peer dependency, so the project controls its TypeScript version.
  • exisjs no longer depends on the build tools. It lists @exisjs/cli as an optional dependency until 0.11, so projects that run exisjs dev without listing @exisjs/cli keep working, with a one-line notice. A production image leaves it out with npm ci --omit=dev --omit=optional.
  • exisjs build writes .exis/server/main.js. node .exis/server/main.js starts the server in that process: Node receives SIGTERM directly, and production needs neither the CLI nor npm. It changes to the project folder first, so it works from any directory, and defaults NODE_ENV to production (also when it is set but empty). In cluster mode it is the primary and forks the workers.
  • exisjs start is 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/cli can run it, so "start": "exisjs start" and npm start work everywhere; the other commands need @exisjs/cli.
  • exisjs migrate updates a 0.9 project: adds @exisjs/cli (and typescript when missing) to devDependencies, and lists the Dockerfile lines to change. The start script stays exisjs start. --dry-run only shows the changes.
  • create-exisjs generates the new layout.
  • pino-pretty is optional for the runtime. When pretty logs are requested but pino-pretty is 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 exceeds size-budget.json, or when any build tool is installed. publint and @arethetypeswrong/cli check both packages' exports and types.
  • Types resolve with every moduleResolution. exisjs/validator, exisjs/router and the other subpaths now also have types under the older node (node10) setting, through typesVersions.
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 serves GET /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.
  • queue limits concurrent requests (B2). queue: { maxConcurrent, maxQueue, timeoutMs } caps requests handled at once. Extra requests wait, then get 503.
  • tex.object() takes optional, nullable and nullish (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 that tex.env still refuses to start without the secrets;
    • running node_modules/.bin/exisjs start instead of npm start;
    • health checks and migrations.

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).

  • server in exis.config.ts takes effect. The engine was picked before the config file loaded, so only exis({ server }) worked, and the uWS fallback warning was logged while the logger was still silent. A server: '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 says failed to load with uWS's own error instead of not installed. Use node:24-trixie-slim with uWS (see the deployment guide).
  • "Install the CLI" hints name the matching version. npm install -D @exisjs/cli takes latest, which can be an older prerelease whose peer dependency pins a different exisjs, so npm refused with ERESOLVE. 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 wrong queue setting or a failing route showed a code frame from backpressure.js or route-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).

  • /_ready now answers 503 before the server stops accepting connections. Before, the listener closed at once on SIGTERM, so load balancers saw refused connections instead of a failing probe. With healthcheck enabled, the server keeps serving for healthcheck.drainMs (default 5000 in production) while /_ready answers 503, then closes the listener and lets requests finish.
  • The npx exisjs migrate notice appears. npm links @exisjs/cli's exisjs command, so the notice in the runtime's command never ran. The CLI now prints it when package.json lists exisjs but not @exisjs/cli.
  • A global @exisjs/cli hands off to the project's own CLI. Run in a project, it used its own copy of exisjs and typescript. In a project that has exisjs but not @exisjs/cli, it stops and says to install the CLI.
  • exisjs start logs like production. It printed the development banner (with "press h + enter") and dropped the JSON Server running at and 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 queue setting 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 build removes its output, so exisjs start can't run a half-built .exis/.
  • exisjs migrate checks more deployment files: exisjs build without --skip-env-check in the Dockerfile, and start commands through npm start in nixpacks.toml and Procfile.
  • create-exisjs --help prints the options instead of starting the questions.
  • uWebSockets.js is no longer installed with exisjs (breaking for server: 'uws'). It was an optional dependency fetched from GitHub on every install, although only server: 'uws' uses it: installs needed git, 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@alpha for prereleases.

Fixed

  • partial() reset fields that an update left out. A PATCH schema made with .partial() kept the fields' ...
Read more

v0.10.0-alpha.1

v0.10.0-alpha.1 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 20 Oct 09:48

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.

  1. queue is checked at start-up (B2).

    • queue.driver must 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-features example had enableWorkers: true, which nothing ever read; remove it if you copied that block.
    • queue now applies backpressure (see Added). With queue: { 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 the queue block if you don't want a limit.

  2. 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.

  3. 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.env while the route file loads.

    Migrate: provide the variable, or fix the file named in the message. Development (exisjs dev) is unchanged: such a route answers 500 with the error.

The CLI moves to @exisjs/cli (B8)

  • New package @exisjs/cli with the exisjs command (dev, build, test, run, start, routes, generate, ...). It depends on the build tools (esbuild, tsx, chokidar, commander, pino-pretty) and takes typescript from the project as a peer dependency, so the project controls its TypeScript version.
  • exisjs no longer depends on the build tools. It lists @exisjs/cli as an optional dependency until 0.11, so projects that run exisjs dev without listing @exisjs/cli keep working, with a one-line notice. A production image leaves it out with npm ci --omit=dev --omit=optional.
  • exisjs build writes .exis/server/main.js. node .exis/server/main.js starts the server in that process: Node receives SIGTERM directly, and production needs neither the CLI nor npm. It changes to the project folder first, so it works from any directory, and defaults NODE_ENV to production (also when it is set but empty). In cluster mode it is the primary and forks the workers.
  • exisjs start is 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/cli can run it, so "start": "exisjs start" and npm start work everywhere; the other commands need @exisjs/cli.
  • exisjs migrate updates a 0.9 project: adds @exisjs/cli (and typescript when missing) to devDependencies, and lists the Dockerfile lines to change. The start script stays exisjs start. --dry-run only shows the changes.
  • create-exisjs generates the new layout.
  • pino-pretty is optional for the runtime. When pretty logs are requested but pino-pretty is 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 exceeds size-budget.json, or when any build tool is installed. publint and @arethetypeswrong/cli check both packages' exports and types.
  • Types resolve with every moduleResolution. exisjs/validator, exisjs/router and the other subpaths now also have types under the older node (node10) setting, through typesVersions.
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 serves GET /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.
  • queue limits concurrent requests (B2). queue: { maxConcurrent, maxQueue, timeoutMs } caps requests handled at once. Extra requests wait, then get 503.
  • tex.object() takes optional, nullable and nullish (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 that tex.env still refuses to start without the secrets;
    • running node_modules/.bin/exisjs start instead of npm start;
    • health checks and migrations.

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).

  • /_ready now answers 503 before the server stops accepting connections. Before, the listener closed at once on SIGTERM, so load balancers saw refused connections instead of a failing probe. With healthcheck enabled, the server keeps serving for healthcheck.drainMs (default 5000 in production) while /_ready answers 503, then closes the listener and lets requests finish.
  • The npx exisjs migrate notice appears. npm links @exisjs/cli's exisjs command, so the notice in the runtime's command never ran. The CLI now prints it when package.json lists exisjs but not @exisjs/cli.
  • A global @exisjs/cli hands off to the project's own CLI. Run in a project, it used its own copy of exisjs and typescript. In a project that has exisjs but not @exisjs/cli, it stops and says to install the CLI.
  • exisjs start logs like production. It printed the development banner (with "press h + enter") and dropped the JSON Server running at and 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 queue setting 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 build removes its output, so exisjs start can't run a half-built .exis/.
  • exisjs migrate checks more deployment files: exisjs build without --skip-env-check in the Dockerfile, and start commands through npm start in nixpacks.toml and Procfile.
  • create-exisjs --help prints the options instead of starting the questions.
  • uWebSockets.js is no longer installed with exisjs (breaking for server: 'uws'). It was an optional dependency fetched from GitHub on every install, although only server: 'uws' uses it: installs needed git, 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@alpha for 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 only status reset tags to [] (and any other field with a default). Fields left out of a partial schema now stay unset. Found while building examples/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 with require() (TypeScript compiles import() 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 real import().

  • The create-exisjs sample user service returned the password it was sent, and GET /users/:id answered 200 with null for an unknown id. It now stores and returns only the name and email, and answers 404.

  • Graceful shutdown skipped onClose and 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 as 0.10.0-alpha.0 would have reached every user. The npm tag now comes from th...

Read more

v0.10.0-alpha.0

v0.10.0-alpha.0 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 19 Oct 10:49

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.

  1. queue is checked at start-up (B2).

    • queue.driver must 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-features example had enableWorkers: true, which nothing ever read; remove it if you copied that block.
    • queue now applies backpressure (see Added). With queue: { 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 the queue block if you don't want a limit.

  2. 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.

  3. 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.env while the route file loads.

    Migrate: provide the variable, or fix the file named in the message. Development (exisjs dev) is unchanged: such a route answers 500 with the error.

The CLI moves to @exisjs/cli (B8)

  • New package @exisjs/cli with the exisjs command (dev, build, test, run, start, routes, generate, ...). It depends on the build tools (esbuild, tsx, chokidar, commander, pino-pretty) and takes typescript from the project as a peer dependency, so the project controls its TypeScript version.
  • exisjs no longer depends on the build tools. It lists @exisjs/cli as an optional dependency until 0.11, so projects that run exisjs dev without listing @exisjs/cli keep working, with a one-line notice. A production image leaves it out with npm ci --omit=dev --omit=optional.
  • exisjs build writes .exis/server/main.js. node .exis/server/main.js starts the server in that process: Node receives SIGTERM directly, and production needs neither the CLI nor npm. It changes to the project folder first, so it works from any directory, and defaults NODE_ENV to production (also when it is set but empty). In cluster mode it is the primary and forks the workers.
  • exisjs start is 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/cli can run it, so "start": "exisjs start" and npm start work everywhere; the other commands need @exisjs/cli.
  • exisjs migrate updates a 0.9 project: adds @exisjs/cli (and typescript when missing) to devDependencies, and lists the Dockerfile lines to change. The start script stays exisjs start. --dry-run only shows the changes.
  • create-exisjs generates the new layout.
  • pino-pretty is optional for the runtime. When pretty logs are requested but pino-pretty is 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 exceeds size-budget.json, or when any build tool is installed. publint and @arethetypeswrong/cli check both packages' exports and types.
  • Types resolve with every moduleResolution. exisjs/validator, exisjs/router and the other subpaths now also have types under the older node (node10) setting, through typesVersions.
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 serves GET /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.
  • queue limits concurrent requests (B2). queue: { maxConcurrent, maxQueue, timeoutMs } caps requests handled at once. Extra requests wait, then get 503.
  • tex.object() takes optional, nullable and nullish (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 that tex.env still refuses to start without the secrets;
    • running node_modules/.bin/exisjs start instead of npm start;
    • health checks and migrations.

Fixed

  • partial() reset fields that an update left out. A PATCH schema made with .partial() kept the fields' defaults, so a request that sent only status reset tags to [] (and any other field with a default). Fields left out of a partial schema now stay unset. Found while building examples/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 with require() (TypeScript compiles import() 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 real import().

  • The create-exisjs sample user service returned the password it was sent, and GET /users/:id answered 200 with null for an unknown id. It now stores and returns only the name and email, and answers 404.

  • Graceful shutdown skipped onClose and 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 as 0.10.0-alpha.0 would 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.

  • ValidatorError was not exported from exisjs/validator (B3). Neither were TexBuilder, TexType, the option types, or SafeParseResult, although the API reference listed them. Use err instanceof ValidatorError.

  • Ctrl+C in exisjs dev printed Failed 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 as Server process error.

  • exisjs dev did not reload when the project sat inside a folder such as Temp, data, build or public (B6). The watcher matched its ignore rules against the full path, so every file of such a project was ignored, .env included. The rules now apply only inside the project. .env files are also watched when watch.paths is 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 with create-exisjs (functional routes): a tasks API using query coercion and pagination, nested optional objects, tex.url, pattern, object refine, partial() updates, safeParse() batch import, healthcheck, metrics and queue. npm run verify:examples builds it and starts it with node .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, then npm install. Until you do, exisjs commands 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 with CMD ["node_modules/.bin/exisjs", "start"] (or CMD ["node", ".exis/server/main.js"]). If you use the uWebSockets.js engine, ...

Read more

v0.9.2

Choose a tag to compare

@github-actions github-actions released this 17 Oct 04:32

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 404 in 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 404 until the file was saved. The request now gets a 500 with 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. After Name.optional() in one object, every other object using Name treated it as optional. Every schema method (refine, sanitize, describe, example, transform too) now returns a new schema.
  • Rules inside nested objects were ignored. .sanitize(), .refine(), .refineAsync(), .transform(), default and tex.date() checks did not run in a nested tex.object, an array of objects or a record, and strict: true on a nested object was ignored. tex.array(tex.date()) accepted anything. Every field now behaves the same at any depth.
  • tex.record(tex.object(...)) and tex.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) answered 500. It is now a 400 validation error for that field, code SANITIZATION_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() without coerce accepted any string and returned it as text, although its type is Date. An unparsable date is now rejected (INVALID_DATE), and a valid date string or timestamp is always returned as a Date.
  • 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() and tex.env() dropped transforms, descriptions and examples of the fields they copied.

Added

  • tex.string({ pattern: /…/, length: 4 }), tex.number({ int: true }) and tex.url({ protocols }) (rejects javascript: 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 listed safeParse(); 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, anyOf for unions and additionalProperties: false for 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

  • stripHtml kept 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.
  • preventTraversal missed percent-encoded (%2e%2e%2f, double-encoded) and trailing .., and rejected file names such as report..final.pdf. It now checks for a .. path segment after decoding, and rejects NUL bytes.
  • slugify dropped accented letters ("Café" became "caf"); they now keep their base letter ("cafe").
  • pick copied inherited keys such as constructor; dedupe and compact threw on, or split, a non-array; truncate could cut an emoji in half; normalizeLineEndings ignored 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_ENV ran 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 with node, tsx, a process manager, or a Docker image without ENV NODE_ENV=production. env now defaults to 'production'; only an explicit NODE_ENV=development (which exisjs dev sets) enables development behaviour. exisjs start already set production and is not affected.

Added

  • dev.lazyRoutes (default true). Set it to false to load every route file when the dev server starts, the way exisjs start does:

    export default defineConfig({ dev: { lazyRoutes: false } })

Upgrade Notes

  • If you start a development server some way other than exisjs dev and rely on development error pages or lazy routes, set NODE_ENV=development or env: 'development' in exis.config.ts.
  • .env file selection is unchanged: without NODE_ENV, .env.development is still loaded.
  • Schema methods return a copy. Code that called field.refine(fn) or field.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, use tex.coerce.number() or a string literal.
  • tex.date() always returns a Date, also without coerce, 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.stripHtml now removes <script> / <style> content and keeps a < that does not start a tag; sanitize.slugify keeps accented letters.
  • ValidatorError has no stack trace in production (NODE_ENV=production). Its errors list is unchanged.

Full Changelog: v0.9.1...v0.9.2

v0.9.1

Choose a tag to compare

@github-actions github-actions released this 16 Oct 07:27

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 Buffer was sent as JSON behind a boundary wrapper (E-01, was T-13). A boundary handle(ctx, next) / default-export wrapper took the route's response back and re-sent every object with res.json, so a Buffer became {"type":"Buffer","data":[...]}, and res.json(5) or res.json(null) became text. The wrapper now replays the response through the method the route used. A wrapper that awaits next() but returns nothing also sends the route's response; before, the request stayed open.
  • .optional() on a nested tex.object was ignored. Address.optional(), tex.object(shape).partial().optional() and extend({ address: Address.optional() }) all required the key. The modifier now carries through tex.object, tex.array items, extend, merge, pick, omit and partial, and the OpenAPI output agrees with the validator.
  • partial() skipped nested objects. A nested object whose own fields were optional stayed required after partial().
  • A plain nested shape crashed. tex.object({ address: { city: tex.string() } }) failed with s.slice is not a function, although its types allow it. It now works the same as tex.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 }) threw Cannot read properties of undefined (reading 'length'); it now throws tex.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.md pointed to node_modules/exisjs/docs/, which does not exist. The docs ship in node_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-features example sent X-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 over bodyLimit reached the handler as an empty Buffer instead of answering 413.
  • The test client JSON-encoded Buffers. .send(buffer) in createTestApp sent {"type":"Buffer","data":[...]}; it now sends the bytes, as application/octet-stream unless a content type is set.
  • In development, res.cookie() copied each cookie into an X-Set-Cookie header, which page scripts can read, so httpOnly cookies were exposed to JavaScript. The header is gone.
  • res.html() sent no Content-Length and skipped ETag / 304 handling; it now goes through res.send().
  • A thrown non-Error value 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 sets ETag / Last-Modified and answers 304 when the client's copy is current (returns true). On writes it throws 412 PRECONDITION_FAILED when If-Match / If-Unmodified-Since no longer hold or If-None-Match: * targets an existing resource, and 428 PRECONDITION_REQUIRED with { required: true } when the client sent no precondition.
  • One RFC 9110 §13.2.2 evaluator for automatic ETags, serveStatic, res.download(path) and req.fresh: the standard evaluation order, strong comparison for If-Match, weak for If-None-Match, lists, *, and tags containing commas. Unparsable dates are ignored.
  • If-None-Match lists and weak tags now match on static files (only an exact single string matched before), and If-Modified-Since works: static files and downloads send Last-Modified.
  • A Cache-Control: no-cache request header no longer disables revalidation. It made the server answer 200 with the full body even when the client's tag matched; RFC 9110 has the origin evaluate conditionals regardless.
  • 304 responses carry no body metadata. Content-Type, Content-Length and Content-Encoding are removed; ETag, Last-Modified, Cache-Control and Vary are kept.
  • A failed If-Match on a read answers 412 with the standard error envelope.
  • res.download(path) supports ETag, Last-Modified, 304 and 412.
  • Automatic ETags (etag: true) only on successful GET/HEAD responses. Writes and error responses were hashed for nothing. Hashing uses Node's one-shot crypto.hash() where available (about 35% faster) and no longer copies string bodies into a Buffer first.
  • serveStatic no longer blocks the event loop: it picks the .br / .gz variant with async stat instead of two existsSync calls per request. It always sends Vary: Accept-Encoding, so a cache never serves the Brotli file to a client that did not ask for it.
  • br;q=0 is a refusal. serveStatic and compression() treated any mention of an encoding in Accept-Encoding as acceptance.
  • Static file ETags are weak (W/"size-mtime", as send / serve-static use): file metadata does not prove two files are byte-identical.
  • Dropped the fresh dependency; req.fresh uses 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

  • @Transactional handed 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), through AsyncLocalStorage; this stays the real instance, so #private fields 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 after healthTimeoutMs (default 5 s) with status: 'error'. Probes that arrive while a check is running share its result. Custom healthcheck.checks get the same 5 s limit.

Changed

  • 404s no longer scan the route table. Answering 405 / Allow on 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({ ... }) in route.ts files) 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

  • pino 10.4.0 and pino-pretty 13.2.0 (runtime, exisjs).
  • Removed fresh (replaced by the built-in conditional request evaluator).
  • Development and examples: typescript-eslint / @typescript-eslint/* 8.71.2, hono 4.13.13 (benchmarks), mongoose 8.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: async middleware is awaited, and a thrown HttpError becomes the response.

Upgrade Notes

npm install exisjs@0.9.1
  • Databases registered with registerDatabase() now connect at the same time. If one database's connect() relies on another being connected first, connect the second inside the first's connect().
  • 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-Match on GET requests to routes with etag: true, a stale tag now answers 412 instead of 200.
  • Code that relied on the development-only X-Set-Cookie header: read cookies from Set-Cookie in 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 to Address.optional() or Address.nullish().
  • Projects created before 0.9.0 keep the old template defaults after upg...
Read more

v0.9.0

Choose a tag to compare

@github-actions github-actions released this 14 Oct 17:06

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 @Idempotent replayed one user's response to another.
  • Every request gets an answer. A handler that returns nothing answers 204 instead of leaving the connection open; a rejection with null is a 500, not a 404.
  • Class controllers behave like functional routes. Returning res.json(), res.send(), download() or a Buffer works; @UseInterceptors actually wraps the handler.
  • Safer authorization. No magic isSuperAdmin bypass, 401 for 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 /docs in 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

  • undefined with nothing sent: 204 (or the @HttpCode status) instead of an open connection.
  • null: JSON null.
  • 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

  • ValidationPipe and DTO validate() failures are VALIDATION_ERROR with a per-field error.details list, and errorHttpStatusCode is honoured (it was ignored).
  • The /docs route is still controlled by docs.enabled; the template now sets it to process.env.NODE_ENV !== 'production'.
  • exisjs dev locks per port: the lock file is .exis/dev-<port>.pid instead of .exis/dev.pid.

Security

  • Request-scoped providers were shared between requests (T-01). @Injectable classes 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. transient gives a new instance per injection (T-12). inject() and ctx.resolve() share one cache per request.
  • @Idempotent leaked responses across users (T-04). Keys are now scoped to the user (req.user.id/sub, or a scope function) plus method and path. Reusing a key with a different body answers 422 IDEMPOTENCY_KEY_REUSED; a repeat while the first is running answers 409 IDEMPOTENCY_IN_PROGRESS.
  • Connection strings in validation errors (BUG-04). Fields named *_URL, *_URI, *_DSN, *CONNECTION* are redacted, and the password of any scheme://user:password@host value is masked in logs, responses and tex.env startup errors.
  • Authorization bypass via isSuperAdmin (T-07), see Breaking Changes.
  • beforeHandle responses were overwritten (T-09). A boundary hook that wrote a 401 and returned false sent a 403 instead; 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 the GET route without a body; a known path with the wrong method answers 405 with an Allow header (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 /user instead 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 .env files (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; --force skips the check.
  • tex.enum accepts readonly arrays (BUG-01), so as const lists work directly.
  • Fields with a default are typed as present after parsing (BUG-02). New ResolveInput / ResolveOutput types (like z.input / z.output).
  • exisjs build fails on mixed paradigms (T-25), listing the files of each kind, instead of failing at startup.
  • exisjs build output layout no longer depends on which folders hold entry files (esbuild outbase is the project root).
  • listen({ port: 0 }) reports the port the OS assigned.
  • Template lint (T-21). A fresh project passes npm run lint; the generated exis.d.ts is excluded and marked eslint-disable. The functional guard template read req.headers from an execution context and threw; both guard templates now use context.switchToHttp().getRequest().
  • ExisUser augmentation (SEC-2). exisjs exported a second, unrelated ExisUser, so declare module 'exisjs' { interface ExisUser {...} } never reached req.user. It now does.

Added

  • authorization.defaultPolicy: 'authenticated': deny by default, so a controller route without @Roles / @Permissions / @Public requires a signed-in user (SEC-1).
  • authorization.superUser config option.
  • errors.legacyFields config option (deprecated on arrival).
  • rateLimit({ store }) with RateLimitStore / 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); lockTtlMs job option.
  • /_ready readiness endpoint (healthcheck.readyPath): 503 unless 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's requestId (ENT-3).
  • exisjs routes shows each route's access rule (ACCESS column, and access in --json) (DEV-3).
  • Test harness (T-26): .query({...}), .timeout(ms), startTestServer(app) for real-socket tests.
  • Inject, Optional and forwardRef are also exported from exisjs/decorators, as the reference documents.
  • exisjs dev --force.
  • Bench target exis-class (class controllers, same routes as exis-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.md no 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...
Read more