Repository navigation
v0.9.2
ExisJS Release v0.9.2
Release Date: 2026-10-17
Type: Patch: Development Server Fixes, Safe Default Environment, Validator & Sanitizer Overhaul
Affected Packages: exisjs, create-exisjs, @exisjs/fetch (all aligned to 0.9.2)
Highlights
Fixes from a report on 0.9.1 by a team running two production backends. Four of the five issues affect only exisjs dev. The fifth is a security fix: a server started without NODE_ENV sent error messages and stack traces to clients.
The tex validator was reworked. Nested objects, array items, records and unions now get every rule a top-level field gets (sanitizers, refinements, defaults, dates, strict mode), schemas can be reused safely, and parse() no longer modifies its input. Schemas with defaults, trimming or refinements validate 4-5x faster, and invalid requests are rejected about 10x faster.
Fixed
- Concurrent first requests to a route could get
404in development. The placeholder for a route that had not loaded yet removed itself before it imported the route file, so a request arriving during the import found no route. This was common when React's development mode sent each request twice. Every request now waits for the same load, and the placeholder is removed only once the real routes are mounted. - A route file that failed to load left the request open. The error was printed to the console, but the client got no response, and every later request got
404until the file was saved. The request now gets a500with the load error (development only), and the next request tries the file again. - Route files ran twice when the dev server started. The startup check for mixed class-based and functional routes imported each file, and the first request imported it again at a new address. Module-level state (caches, counters, connections opened at import) existed twice. The first mount now reuses the module the check imported.
Validator (tex)
Fixed
.optional(),.nullable()and.nullish()changed the schema they were called on. AfterName.optional()in one object, every other object usingNametreated it as optional. Every schema method (refine,sanitize,describe,example,transformtoo) now returns a new schema.- Rules inside nested objects were ignored.
.sanitize(),.refine(),.refineAsync(),.transform(),defaultandtex.date()checks did not run in a nestedtex.object, an array of objects or a record, andstrict: trueon a nested object was ignored.tex.array(tex.date())accepted anything. Every field now behaves the same at any depth. tex.record(tex.object(...))andtex.union([..., tex.object(...)])did not validate the object.parse()wrote into its input: defaults, coerced numbers and sanitized values replaced the caller's data. The input is now left untouched; object and array defaults are copied for each parse.- A sanitizer that throws (such as
sanitize.preventSql) answered500. It is now a400validation error for that field, codeSANITIZATION_FAILED. tex.literal(42)accepted"42"and returned the string. Literals now match by value and type.- Enum values containing
,or|or surrounding spaces, and literals containing|, never matched. tex.date()withoutcoerceaccepted any string and returned it as text, although its type isDate. An unparsable date is now rejected (INVALID_DATE), and a valid date string or timestamp is always returned as aDate.- Only the first bad array item was reported. A failed parse now lists every failing value: each bad item of an array, each bad field of a nested object and each bad record value (up to 100).
partial()andtex.env()dropped transforms, descriptions and examples of the fields they copied.
Added
tex.string({ pattern: /…/, length: 4 }),tex.number({ int: true })andtex.url({ protocols })(rejectsjavascript:and other schemes by default).safeParse()/safeParseAsync()on object schemas, transformed schemas and discriminated unions:{ success, data }or{ success: false, errors, error }. The API reference already listedsafeParse(); it now exists.- Object-level
.refine(fn, { message, path })for checks across fields (for example password confirmation), also when the object is nested. The guide already showed it; it now exists. .default(value)on fields, as documented.- A transformed object (
tex.object(...).transform(...)) and a discriminated union can be used as fields. - OpenAPI output includes string
minLength/maxLength/pattern,format: uri,type: integer, record value schemas,anyOffor unions andadditionalProperties: falsefor strict objects.
Performance
npm run bench:validation with NODE_ENV=production (mitata, time per parse; the 0.9.1 column used EXIS_DIST on the 0.9.1 build):
| Schema | 0.9.1 | 0.9.2 | Ajv |
|---|---|---|---|
| Flat object, 3 fields | 44.7 ns | 43.3 ns | 80.4 ns |
| Nested object with an array of 3 | 91.5 ns | 94.1 ns | 69.8 ns |
| Trim, default, optional array, refine | 183 ns | 39.9 ns | — |
| Query coercion with defaults | 205 ns | 42.2 ns | — |
| Invalid payload, all errors listed | 64.0 µs | 6.2 µs | 64.4 ns |
Ajv only marks a payload invalid; tex also builds the error list (path, code, message, received value per field) by throwing out of the compiled check, which is why rejection is still slower than Ajv. In development the rejection takes about 16 µs because the error keeps its stack trace.
Defaults, sanitizers, refinements, transforms and query coercion are now part of the compiled validator, not separate passes over the data. A failed check no longer builds three Error objects with stack traces; in production a ValidatorError is created without one.
Sanitizers (exisjs/sanitize)
Fixed
stripHtmlkept the content of<script>and<style>("<script>alert(1)</script>"became"alert(1)") and deleted ordinary text after a<("a < b and c > d"became"a d"). It now removes comments and script/style elements with their content, and a<that does not start a tag stays.preventTraversalmissed percent-encoded (%2e%2e%2f, double-encoded) and trailing.., and rejected file names such asreport..final.pdf. It now checks for a..path segment after decoding, and rejects NUL bytes.slugifydropped accented letters ("Café"became"caf"); they now keep their base letter ("cafe").pickcopied inherited keys such asconstructor;dedupeandcompactthrew on, or split, a non-array;truncatecould cut an emoji in half;normalizeLineEndingsignored a lone\r.- The API reference listed sanitizers that could not be imported (
stripUnknownKeys,deepTrimStringValues,defaultIfNaN,normalizeToIsoString,stripTime): they lived in an unused duplicate module, which is removed.
Added
stripControlChars (control characters and bidirectional overrides), normalizeEmail, round(decimals), clamp(min, max), trimElements, limitLength(n) and deepTrim.
Security
- A server started without
NODE_ENVran in development mode and sent the full message and stack trace of unexpected errors to clients, including SQL text, query values and file paths. This affected servers started withnode,tsx, a process manager, or a Docker image withoutENV NODE_ENV=production.envnow defaults to'production'; only an explicitNODE_ENV=development(whichexisjs devsets) enables development behaviour.exisjs startalready set production and is not affected.
Added
-
dev.lazyRoutes(defaulttrue). Set it tofalseto load every route file when the dev server starts, the wayexisjs startdoes:export default defineConfig({ dev: { lazyRoutes: false } })
Upgrade Notes
- If you start a development server some way other than
exisjs devand rely on development error pages or lazy routes, setNODE_ENV=developmentorenv: 'development'inexis.config.ts. .envfile selection is unchanged: withoutNODE_ENV,.env.developmentis still loaded.- Schema methods return a copy. Code that called
field.refine(fn)orfield.sanitize(fn)without using the returned schema relied on the old mutation; use the return value (const Email = tex.email().refine(fn)). tex.literal(42)no longer accepts the string"42". For query strings or path params, usetex.coerce.number()or a string literal.tex.date()always returns aDate, also withoutcoerce, and rejects unparsable values. Code that compared the parsed value with a string (body.at === '2026-01-01') must compare dates instead (body.at.toISOString()); JSON responses still send an ISO string.- Validation errors now list every bad array item and nested field. Clients that showed only
details[0]keep working; those that counted errors see more of them. sanitize.stripHtmlnow removes<script>/<style>content and keeps a<that does not start a tag;sanitize.slugifykeeps accented letters.ValidatorErrorhas no stack trace in production (NODE_ENV=production). Itserrorslist is unchanged.
Full Changelog: v0.9.1...v0.9.2