Skip to content

v0.9.2

Choose a tag to compare

@github-actions github-actions released this 17 Oct 04:32
· 18 commits to main since this release

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