Skip to content

Changelog

All notable changes to Veloce-TS are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Fixed — mixing import specifiers silently broke decorators

Section titled “Fixed — mixing import specifiers silently broke decorators”

veloce-ts and veloce-ts/plugins were separate bundles, so importing from both put two copies of the framework in one process. Metadata keys were plain Symbol(), which is unique per module instance — so a class decorated through one specifier was invisible to code loaded through the other, with no error anywhere.

What that looked like: the fullstack template imported GraphQLPlugin from veloce-ts/plugins while its resolver used decorators from veloce-ts. The plugin found no resolver metadata, generated an empty schema, and the first query died on buildSchema('') with “Syntax Error: Unexpected <EOF>” — an error pointing at the query, which was fine. Routes and guards registered across a specifier boundary would have failed the same way.

  • The ESM build now uses code splitting, so every subpath shares one copy of the framework. Class identity works again too — instanceof across specifiers had the same problem. Deduplicating across the 15 entrypoints also took the package from 0.45 MB to 0.39 MB.
  • Every metadata key and DI token is Symbol.for('veloce-ts:…'), resolved through the global registry. CommonJS cannot be split, so this is what fixes it for require() consumers.

An empty schema now explains itself instead of failing inside graphql-js, and names this as a likely cause.

  • veloce new --install runs the package manager (Bun when available, npm otherwise); --git initialises a repository with the first commit, skipping it when the target is already inside one.
  • veloce generate gateway <name> scaffolds a @WebSocket gateway and veloce generate listener <name> an @On listener class. Both surfaces existed with no scaffolding behind them.
  • Generated projects get a .env.example listing the variables the code actually reads. The templates have configured CORS from CORS_ORIGINS since 3.1.0, documented nowhere the reader would look.

Changed — the templates teach the framework now

Section titled “Changed — the templates teach the framework now”
  • Dependencies are injected, not constructed. The example controller and resolver ask for a UserService with @Inject(UserService); swapping the in-memory array for a database leaves every caller untouched.
  • app.listen() is awaited. It has been async since the Node path landed, and without the await a failure to bind became an unhandled rejection instead of the error the template prints.
  • The GraphQL and fullstack templates enable subscriptions and demonstrate one end to end with PubSub.
  • The GraphQL templates now depend on graphql. It is an optional peer, so nothing executed without it — and its absence is not a type error, only a 501 at the first query.
  • The README matches what was written: per-template layout, real endpoints, the scripts table, and the fact that the project runs on Node as well as Bun. It previously showed a generic tree and pointed at /docs.html, which is not a path that is served.
  • veloce build --sourcemap defaults to off.
  • A GraphQL project no longer ships an empty src/controllers/ directory.

Changed — the template smoke test was too weak to catch any of this

Section titled “Changed — the template smoke test was too weak to catch any of this”

It asserted only that a route answered with a non-5xx status, and the GraphQL endpoint returns 200 with an errors payload when the graphql package is missing — so a template shipping without that dependency looked healthy. It now runs a real mutation and query, runs the probe exactly once (retrying it applied the mutation twice), and refuses to start when port 3000 is already held.

Added — GraphQL subscriptions actually run

Section titled “Added — GraphQL subscriptions actually run”

Subscriptions were generated into the SDL and nothing more: there was no execution transport, so subscribe did nothing and this documentation said so. It was the framework’s largest remaining gap.

GraphQLPlugin now serves them over graphql-transport-ws — the subprotocol Apollo Client, urql, GraphiQL and the graphql-ws client speak — on Bun, Deno and Node.

const pubsub = new PubSub();
@Resolver()
class ChatResolver {
@GQLSubscription()
@Returns(MessageSchema, { name: 'ChatMessage' })
messageAdded() {
return pubsub.subscribe('MESSAGE_ADDED');
}
@GQLMutation()
async send(@Arg('input', SendMessage) input: SendMessage) {
await pubsub.publish('MESSAGE_ADDED', input);
return input;
}
}
app.usePlugin(new GraphQLPlugin({
resolvers: [ChatResolver],
subscriptions: {
// A browser WebSocket cannot send an Authorization header, so credentials
// arrive in connectionParams instead.
onConnect: ({ connectionParams }) => verify(connectionParams?.token),
},
}));
  • Off by default. Opening a WebSocket endpoint should be a deliberate choice; subscriptions: true takes the defaults.
  • Shares the GraphQL path, which is what clients assume — an upgrade and a POST can coexist there because they are different methods.
  • PubSub is the source: publish(topic, payload) / subscribe(topic), returning the AsyncIterableIterator that graphql’s subscribe() wants. It is built on EventBus, so passing globalEvents makes an @On('user.created') listener and a GraphQL subscription react to the same publish. A resolver can equally return a plain async generator.
  • Backpressure is bounded. maxQueueSize (1000) with drop-oldest, drop-newest or error decides what gives when a subscriber cannot keep up.
  • Nothing leaks. Ending an operation, closing the socket and app.shutdown() all run the iterator’s teardown, which detaches its bus listener.
  • Requires the graphql package, now declared as an optional peer dependency. On Node it also needs @hono/node-ws, and the app must be served with app.listen().

The protocol implementation is the framework’s own — no runtime dependency on graphql-ws — so the interoperability claim is verified rather than assumed: CI runs the server against the official graphql-ws client under Node. Close codes follow the spec: 4400 bad request, 4401 unauthorized, 4403 forbidden, 4408 init timeout, 4409 duplicate subscriber, 4429 too many init requests.

See GraphQL → Subscriptions.

  • app.listen(0) reported port 0. The returned server instance carried the requested port, so a caller asking the OS for a free one had no way to learn which it got — the spread meant to pick it up copies own enumerable properties, and port lives on the server’s prototype under both Bun and Node. Fixed on all three runtimes.
  • Two WebSocket plugins on one Node app destroyed each other’s connections. WebSocketPlugin and the subscription endpoint each created their own @hono/node-ws instance, and every instance attaches an 'upgrade' listener that ends any socket it finds no waiter for. They now share one adapter per application.
  • A resolver set with no queries produced an unusable schema. GraphQL requires a query root on every schema, so subscriptions or mutations alone failed on the first operation with Query root type must be provided. A placeholder Query is emitted when nothing else declares one.
  • graphql’s validate() throws rather than returning errors when the schema itself is invalid. Inside a socket’s message handler that took the process down; it now reaches the client as an error message.
  • GraphQL resolvers could not be resolved over a WebSocket. They were always resolved in request scope against a context.request that does not exist on a connection outliving any single request. HTTP keeps request scope; a socket resolves as a singleton.
  • Subscription resolvers are attached to the executable schema, so introspection now describes a Subscription type whose fields can actually run. They were deliberately left off before.
  • A subscription resolver returning something that is not an async iterable fails with a message naming the class and method, instead of graphql-js’s generic “Subscription field must return Async Iterable”.

Declarative event listeners, the last item this documentation listed as missing from the event bus. A class with @On methods is registered with app.include() like a controller, and its instance comes from the DI container — so a listener injects the same services a controller can.

class NotificationListeners {
constructor(@Inject(Mailer) private mailer: Mailer) {}
@On('user.created')
async welcome(payload: { email: string }) {
await this.mailer.send(payload.email, 'Welcome!');
}
@On('app.ready', { once: true })
warmCaches() { /* … */ }
}
app.include(NotificationListeners);
  • Subscriptions attach at compile() and are removed at shutdown(). Without that, listeners bound to the process-wide globalEvents outlive the app that created them, and every app built in a test suite answers the same event.
  • A class can be both a controller and a listener — include() no longer stops at the first match.
  • new VeloceTS({ eventBus }) takes a bus of your own; it defaults to globalEvents. app.getEventBus() returns the one in use.
  • @On naming something that is not a method fails at compile(), with the class and property in the message, rather than silently at emit time.

See Event Bus.

Changed — benchmark methodology, and a correction

Section titled “Changed — benchmark methodology, and a correction”

The published claim that Veloce-TS beat raw Hono was wrong. The benchmarks page and the docs stated Veloce-TS was faster than raw Hono on JSON body parsing (+16.7%) and Zod validation (+43.8%). Those came from a single timed pass per framework, and a single pass is not a measurement: three consecutive runs of the identical scenario put Hono ahead by 2%, then 17%, then level — and a fourth put Veloce ahead by 14%.

With repeat rounds and a median, Hono leads in every scenario, which is the only sensible answer for a layer built on top of Hono. Measured cost of that layer: 2–11%. Against Express 4 it is ~1.8× faster (the README claimed 10×) and ~1.3–1.4× faster than Fastify 5.

The harness now runs 7 rounds, reports a median with its interquartile spread, calibrates iteration counts against wall time, and flags results too noisy to compare. The HTTP suite rotates server order between rounds so warm-up does not favour whichever framework ran first.

  • The version fallbacks said 0.3.0. veloce --version and, worse, the dependency range veloce new writes into a scaffolded package.json both fell back to a hardcoded version that had not been current for a long time. veloce new also consulted the current directory’s package.json first — whatever project you happened to be standing in, which is not veloce-ts.
  • veloce new could hang on the npm lookup. The registry request had no timeout; it now gives up after 5 seconds and scaffolds with the installed version, saying so.
  • A missing file was served as 200 with an empty body under Bun. Bun.file() is lazy and never throws for a nonexistent path, so Response.file() handed it straight to the response. The Node branch, which stats the file first, correctly returned 404 — the same call answered differently depending on the runtime.

Coverage over src/ went from 50.17% to ~57%, concentrated on code that had almost none and carries real risk: the Redis-backed stores (cache, session, token blacklist, rate limit, previously ~4%), WebSocketConnection, Response.file() root containment, and request-context. The CI coverage gate moves from 50% to 55% — it had been set at exactly the measured value, which made it a tripwire rather than a ratchet.

WebSocketPlugin threw at startup on Node (“requires Bun or Deno runtime”), which made the @WebSocket / @OnConnect / @OnMessage decorators — and the websocket and fullstack templates — unusable there. It was the framework’s number-one documented limitation.

Node now upgrades through @hono/node-ws, an optional peer dependency. Bun and Deno are untouched: they still upgrade natively and need no extra package.

Terminal window
npm install @hono/node-ws # only needed on Node

The whole gateway surface is verified end-to-end on Node in CI: upgrade handshake, @OnConnect, @OnMessage, rooms, broadcast() and @OnDisconnect. The template smoke test now boots all four templates instead of skipping the two that could not run.

authorizeUpgrade() still runs, with one difference worth knowing: on Bun and Deno an unauthorized client is refused during the handshake with 401, while @hono/node-ws owns the route and hands the socket over already open — so on Node the connection is accepted and then closed with 1008 Policy Violation.

The plugin attaches the WebSocket handler in onStart(), because it needs the real http.Server, which only exists once app.listen() has run. Serve with app.listen(); handing app.fetch to a server you created yourself leaves the plugin with nothing to attach to.

  • WebSocketConnection read WebSocket.OPEN off the global, which only became stable in Node 22 — on Node 20, which engines still supports, touching it threw a ReferenceError. It now uses the numeric constant fixed by the standard.
  • Socket parameters are typed structurally (WebSocketLike) instead of against the DOM WebSocket, so a Bun socket, a Deno one and node-ws’s WSContext all fit.
  • VeloceTS.getServer() exposes the running server, and ServerInstance.raw carries the runtime’s own object.

Changed — the published package shrank by 72%

Section titled “Changed — the published package shrank by 72%”

Production builds no longer emit sourcemaps. Bun embeds each source file’s full text in the map through sourcesContent, and with both an ESM and a CJS build that meant shipping the entire TypeScript source twice: sourcemaps were 73% of the tarball. Every install paid for it to serve the rare case of stepping into framework internals with a debugger — clone the repo for that instead.

PackedUnpacked
3.1.11.47 MB6.9 MB
3.1.20.41 MB1.73 MB

Development builds keep inline sourcemaps, which is where they are actually used.

Declaration maps are no longer emitted either. The 256 .d.ts.map files resolved to ../../src/*.ts, and files does not publish src/ — so they were 351 KB of maps pointing at nothing in an installed package.

Fixed — the published types were unusable on node16/nodenext

Section titled “Fixed — the published types were unusable on node16/nodenext”
  • Every relative specifier in the declarations lacked a file extension, so any consumer on moduleResolution: node16 or nodenext got nothing but TS2834 (“Relative import paths need explicit file extensions”) from node_modules/veloce-ts, and lost all types. The 332 relative specifiers across src/ now carry an explicit .js.
  • require('veloce-ts') was rejected by TypeScript with TS1479 even though it works at runtime. One declaration tree was shared by both conditions, and TypeScript infers a .d.ts file’s module kind from the nearest package.json — the root says "type": "module", so the types claimed ESM while require resolved to dist/cjs. The build now emits a separate CommonJS declaration tree and each exports condition carries its own types.
  • WebSocketManager.handleMessageBun typed a parameter as Buffer, which forced consumers to install @types/node just to type-check an import of the package. It is Uint8Array now.

Node parity for the CLI. Everything veloce new produces now scaffolds, type-checks, builds and runs under plain Node, not only Bun.

Fixed — the CLI produced projects that could not run

Section titled “Fixed — the CLI produced projects that could not run”
  • veloce new scaffolded an app that crashed on startup. The rest and fullstack templates emitted cors: { origin: '*', credentials: true } — the combination 3.0.0 began rejecting at construction time — so a freshly created project died before serving a single request.
  • veloce build failed under Node with ReferenceError: Bun is not defined; it called Bun.build unconditionally. It now compiles with the project’s own tsc when not running under Bun.
  • veloce dev only ever spawned bun. It now detects the runtime and falls back to tsx, which transpiles the Stage-2 decorators Node’s own type stripping cannot handle.
  • Compiled output was unloadable by Node. Templates imported relative modules without a file extension, which tsc emits verbatim and Node’s ESM loader rejects — node dist/index.js died with ERR_MODULE_NOT_FOUND. Templates and veloce generate output now write the .js extension, which Bun resolves back to the .ts source.
  • The websocket and fullstack templates passed a handlers option that does not exist on WebSocketPluginConfig, so those projects failed to type-check. Gateways are registered with app.include(), like controllers.
  • Generated projects lacked @hono/node-server, so app.listen() threw under Node.
  • --runtime <auto|bun|node> on veloce dev and veloce build. auto prefers Bun when it is usable and otherwise takes the Node path.
  • Generated projects are runtime-agnostic: scripts route through the veloce binary instead of hardcoding bun, engines.node is >=20, tsx is a devDependency for the Node dev server, and a typecheck script is included.
  • veloce new warns when scaffolding a WebSocket template without Bun installed, instead of leaving you with an app that throws on first start.
  • Template smoke test in CI (npm run test:templates): packs the working tree, scaffolds all four templates against that tarball, then installs, type-checks, builds and boots each one under Node. This is the check that was missing when the broken templates shipped.

Bun.build is an in-process API, so the Bun build path requires the CLI itself to be running under Bun. npm run build on a machine with Bun installed still takes the Node path, because npm starts the CLI with Node.

  • MemoryRateLimitStore and RedisRateLimitStore were not exported. 3.0.0 introduced the pluggable rate-limit store and documented it, but neither class was re-exported from veloce-ts or veloce-ts/middleware, so RateLimitOptions.store could not actually be supplied by a consumer. Both are now exported from both entry points; the RateLimitStore and RateLimitHit types were already reachable via veloce-ts/types.

Full-codebase review. Upgrade if you use @Auth(), @Session(), @RequireCSRF() or the @CanAccess family — those guards were never enforced in 2.x, so the routes they were meant to protect answered anonymous requests. See MIGRATION.md.

  • Route guards were never enforced. AuthPlugin, SessionPlugin and PermissionPlugin each replaced app.compile from inside their own install() — but install() runs from within the already-executing compile(), so the replacement never ran. In practice an @Auth() route without a @CurrentUser() parameter answered 200 to anonymous requests, @Auth({ roles: ['admin'] }) admitted any authenticated user, and @Session({ required: true }) / @RequireCSRF() did nothing at all. All three plugins now inject their guard into the route’s middleware during install(), the same way RBACPlugin already did. @Roles() was unaffected — RBAC was the one plugin wired correctly.
  • Session cookies are now signed with SessionConfig.secret (HMAC-SHA256, verified in constant time). The cookie previously carried the raw session id, so anyone could swap in another id and be handed that session. secret is now required, and cookies default to secure: true under NODE_ENV=production.
  • Refresh-token rotation is atomic. refreshAccessToken() verified the token and only then blacklisted it, so two concurrent requests carrying the same refresh token both succeeded and each received a valid new pair. The old token is now claimed before a new pair is minted; the loser fails.
  • Request bodies are size-limited. New VeloceTSConfig.bodyLimit (default 1 MiB, 0 disables) rejects oversized bodies with 413 before any handler reads them.
  • Cached responses can vary by caller. @Cache() now honours varyByHeaders and a new keyGenerator(c); previously the key was method + path + params only, so a cached per-user route could serve one user’s body to another. Caching a route that reads @CurrentUser() under a caller-independent key now logs a warning at compile time.
  • Auth flows no longer report internal failures as 401. A database outage inside login/register/refresh was wrapped into AuthenticationException(error.message), reporting infrastructure faults as bad credentials and leaking the internal message.
  • WebSocket handler errors are no longer echoed to the client. Only parse/schema problems are returned; everything else is logged server-side.
  • JWT revocation entries are keyed by jti instead of the full token, so the blacklist no longer stores replayable credentials.
  • CORS: Vary: Origin is emitted whenever the allowed origin depends on the request, and origin: '*' combined with credentials: true now throws at configuration time instead of producing responses browsers silently reject.
  • Response.file() gained a root option (plus Response.fileFrom(root, path)) that confines the lookup to a directory; paths escaping it get 403.
  • SessionConfig.secret is required. Session cookies change format, so sessions issued by 2.x are rejected and those users log in again.
  • bodyLimit defaults to 1 MiB. Raise it if you accept uploads: new Veloce({ bodyLimit: 25 * 1024 * 1024 }).
  • docs defaults to true and now actually mounts OpenAPI at /openapi.json and /docs. Registering OpenAPIPlugin yourself still wins; pass docs: false to disable.
  • engines.node raised to >= 20 (Node 18 is end-of-life).
  • Adapter.listen() may return a Promise<ServerInstance> — relevant only for custom adapters.
  • TokenBlacklist receives token ids (jti) instead of raw tokens; custom stores may implement the optional claim() for atomic rotation.
  • The response builder is exported as VeloceResponse (and Res). The old Response export shadowed the global Web API Response in any file that imported it; it remains as a deprecated alias.
  • Class-level @UseInterceptor never ran — the lookup read target.constructor on a value that was already the class.
  • Exception filters never saw validation or dependency-injection errors. Errors thrown by route middleware and guards now reach filters too.
  • DIContainer reported false circular dependencies under concurrency. Resolution paths are now per async context; concurrent first-time singleton resolves share one in-flight promise, so exactly one instance is created.
  • Two controllers with the same class name silently overwrote each other’s routes. The registry keyed on target.name; it now keys on class identity.
  • docs and plugins config options were accepted and ignored.
  • A malformed JSON body returned 422 “Expected object, received null” — it is now a 400 naming the JSON problem.
  • AuthService.refresh() was missing an await, so its catch never ran and callers got a bare Error instead of InvalidTokenException.
  • Node’s listen() could never work. It used require('@hono/node-server'), and the published ESM bundle has no require in scope, so it always threw and reported the package as missing. It now uses a dynamic import.
  • SSE/streaming generators that throw now error the stream and log, instead of producing an unhandled rejection.
  • process.on('SIGTERM'/'SIGINT') listeners registered by the rate limiter and MemorySessionStore suppressed Node’s default signal handling for the whole application.
  • RateLimitStore with MemoryRateLimitStore (default) and RedisRateLimitStore, so a rate limit can be enforced across instances rather than per process.
  • Typed context variables — c.get('auth.user') is typed and a mistyped key is a compile error.
  • VeloceTSConfig.bodyLimit and @Cache({ keyGenerator }).

Security, packaging and performance overhaul. If you deploy on Node.js, upgrade — the published 1.2.0 was broken there. See MIGRATION.md for the two breaking changes.

  • Refresh tokens could be replayed as access tokens. JWTProvider.verifyAccessToken() ignored the payload’s type claim, and since refreshSecret falls back to secret when unset, a refresh token — default lifetime 7 days — verified successfully as an access token. It now rejects any payload with type === 'refresh'.
  • RS256/RS384/RS512 never actually worked. They were accepted as JWTConfig.algorithm, but signing always used the shared secret, so tokens were HMAC-signed and the asymmetric guarantee did not exist. Added privateKey/publicKey; the constructor now throws if an RS* algorithm is set without both.
  • Rate limiting trusted spoofable headers. The default keyGenerator read X-Forwarded-For/X-Real-IP unconditionally, so any client could forge its rate-limit identity. These are now ignored unless the new trustProxy: true option is set; the untrusted default uses the real peer IP.
  • @ResponseSchema failures were swallowed, letting malformed handler output reach the client. They now log and return 500.
  • JWTProvider verification and revocation methods are now async: verifyAccessToken, verifyRefreshToken, refreshAccessToken, blacklistToken, isBlacklisted, cleanupBlacklist. This enables pluggable revocation stores. A missing await is dangerous, not merely wrong — the returned Promise is always truthy.
  • Decorator controllers now resolve as singleton, not a new instance per request. Per-request state in instance fields is now shared. Opt out with @Controller(prefix, { scope: 'transient' }).
  • Typed functional API — app.get/post/put/delete/patch and app.route() are generic over the declared Zod schema bag, so handler arguments infer end-to-end with no casts. Injection order is body, query, params, headers; only declared schemas contribute an argument.
  • Purged any from the public surface: plugin config, cache store config, parameter metadata, the auth/RBAC/session metadata registry, and the Express adapter’s return types.
  • @Controller(prefix, { scope }) — explicit instantiation scope (singleton | request | transient).
  • RedisTokenBlacklist / MemoryTokenBlacklist and the TokenBlacklist interface — token revocation that propagates across replicas. The in-memory blacklist never did, so logout silently failed to revoke on other instances.
  • Plugin.onStart(app) / Plugin.onStop(app) — lifecycle hooks. The contract was install-only, so a plugin holding resources had no teardown path. onStop runs in reverse install order with errors collected.
  • app.getFetchHandler() — the fetch(request, env, ctx) export shape serverless/edge runtimes expect. This is the supported Cloudflare Workers deploy path, since listen() cannot work there.
  • CacheManager.destroy() — releases every store’s timers and connections.
  • WebSocket hardening — heartbeatIntervalMs, heartbeatTimeoutMs, idleTimeoutMs, maxMessageSizeBytes. Oversized messages get an error frame and close code 1009.
  • @Returns() for GraphQL return types, and createTimeoutMiddleware() exported from veloce-ts/middleware.
  • LoggerConfig.pretty is now honored via a pino-pretty transport — it was previously accepted and ignored.
  • CI now runs a real multi-runtime matrix: pinned + latest Bun across ubuntu/windows/macos, a Node 20/22 smoke job that imports the built ESM and CJS artifacts, an allow-failure Deno leg, isolated package validation, and a coverage gate that uploads its report.
  • The CLI runs under plain Node. The bin entry pointed at a #!/usr/bin/env bun TypeScript file, so it could not run without Bun despite the package advertising Node >= 18.
  • .d.ts generation failure is now fatal in production builds; it previously warned and continued, so a release could ship broken types with a green pipeline.
  • Package validation verifies every exports condition resolves to a real file and type-checks a generated consumer against dist/types. This is what caught the broken adapters/* paths.
  • veloce generate openapi now uses the real OpenAPIGenerator instead of a hand-rolled builder that ignored Zod, and the generated client emits real interfaces derived from the schemas instead of Promise<any>.

The published 1.2.0 did not work for Node consumers:

  • veloce-ts/adapters/* did not resolve — the exports map pointed at dist/{esm,cjs}/adapters/*.js but the build emits dist/{esm,cjs}/src/adapters/*.js.
  • Runtime dependencies are no longer bundled into dist. Bundling shipped a second copy of each, which broke Zod identity (the framework checks schemas built with your zod; a bundled copy is a different class) and re-transpiled third-party code, mangling semver into an invalid RegExp that threw on require. The package is now 1.33 MB, down from 3.82 MB.
  • The ESM bundle emitted import.meta.require, a Bun-only global; switched to the node target.
  • import { sign } from 'jsonwebtoken' broke Node’s ESM loader — it is a CJS-only package. Now imported via its default export.
  • dist/cjs/*.js was parsed as ESM because the root package.json declares "type": "module". The build now emits dist/cjs/package.json with {"type":"commonjs"}.
  • GraphQL never executed. Resolvers were passed as a nested rootValue shape graphql-js does not read, so every query resolved to null, and every field’s return type was hardcoded to String (object types were never emitted). Resolvers now attach directly to schema fields, and object/input types derive from Zod. Subscriptions still have no execution transport.
  • Request dispatch is 1.9–2.6× faster. The “compiled metadata” layer precomputed a path regex, index orderings and type flags the handler never read; controllers were re-instantiated per request; interceptor lookup did two reflect reads per request; and cache/context/auth modules were await import()-ed inside the hot path. All resolved once at compile() time now.
  • Double rollback in BaseTransactionManager — a handler that succeeded but was marked rollbackOnly threw, the same catch rolled back again, and the resulting “transaction not found” masked the real error.
  • app.shutdown() never ran onShutdown() handlers — only the SIGTERM/SIGINT path did.
  • CacheManager.reset() leaked the previous store’s cleanup interval, accumulating timers and keeping processes alive.
  • In-memory cache “LRU” was actually FIFO — it evicted by insertion time, not last access. get() now updates recency.
  • Redis cache and session stores used the blocking KEYS command for clear()/deletePattern()/length()/all(), which stalls the Redis event loop on large keyspaces. Now cursor-based SCAN with batched DEL.
  • EventBus: one throwing listener aborted the rest (Promise.all). Every listener now runs; failures are collected and rethrown together as an AggregateError.
  • Response.file() threw on non-Bun runtimes — now streams via node:fs.
  • The Express adapter buffered whole response bodies, breaking @SSE() and @Stream() (clients got nothing until the generator finished). Now streams chunk-by-chunk with backpressure handling.
  • BaseRepository.count()/updateMany()/deleteMany() silently did full table scans (count() loaded every matching row into memory; the bulk writes did fetch-then-loop). They now throw NotImplementedError; all three adapters ship native overrides.
  • Two competing SIGTERM/SIGINT handlers with separate shutdown timeouts were consolidated into one.
  • Query parameters were validated twice per request with inconsistent error shapes.

Re-measured and re-published; the previous numbers were labelled v0.4.3 and had gone stale across several releases. Request dispatch improved 87–158% over v0.4.18. Against other frameworks Veloce-TS beats raw Hono on JSON body parsing (+16.7%) and Zod validation (+43.8%), and is ~33% slower on a trivial /hello route. Tests grew from 509 to 781.

  • veloce generate controller <name> — generates @Controller class with CRUD route stubs
  • veloce generate service <name> — generates a plain service class
  • veloce generate module <name> — generates controller + service + DTO + barrel in src/modules/<name>/
  • veloce generate resolver <name> — generates @Resolver class with @GQLQuery and @GQLMutation stubs
  • veloce generate dto <name> — generates Zod schema + inferred TypeScript type
  • veloce generate middleware <name> — generates Hono middleware function skeleton
  • veloce generate plugin <name> — generates VelocePlugin class scaffold
  • app.onShutdown(handler) — registers an async shutdown handler; handlers run in reverse-registration order on SIGTERM/SIGINT
  • app.setShutdownTimeout(ms) — configures max wait before process.exit(1) (default: 30 000 ms)
  • app.registerShutdownSignals() — called automatically by app.compile(); attaches process.on('SIGTERM') and process.on('SIGINT')
  • ExceptionFilter<T> interface — implement catch(error: T, c: Context): Response to produce a typed error response
  • @Catch(...errorClasses) — binds a filter class to one or more error types
  • app.useFilter(filter) — registers a global filter; checked in registration order, first match wins
  • FilterManager — internal manager; exported for custom integrations
  • Interceptor interface — implement intercept(ctx: ExecutionContext, next: () => Promise<Response>): Promise<Response>
  • ExecutionContext — typed context with request, controller, handler fields
  • @UseInterceptor(...interceptors) — applies interceptors on a method or class; layers around the handler like middleware
  • app.useInterceptor(interceptor) — registers a global interceptor; runs before class/method interceptors
  • @SSE() — marks an AsyncGenerator handler as a Server-Sent Events stream (text/event-stream)
  • @Stream(contentType) — marks an AsyncGenerator handler as a raw stream with a custom content type (e.g. 'text/csv')
  • EventBus — in-process pub/sub with on, once, off, emit (async, parallel), emitSync, removeAllListeners, listenerCount
  • globalEvents — shared singleton EventBus for cross-module communication without dependency injection
  • @Throttle(limit, windowMs) — per-endpoint rate limit that overrides the global setting
  • @ApiVersion(v) — prefixes the route path with /v<v> for versioned APIs
  • @ResponseHeader(name, value) — sets a fixed response header for the route
  • @Redirect(url, status?) — returns a redirect response (default status: 302)
  • @Deprecated(message?) — marks the route as deprecated in OpenAPI and logs a startup warning
  • isolate() — resets all singleton state (CacheManager, plugin registries) between parallel test runs
  • compileTestApp(app) — compiles the app and returns the Hono instance; convenience wrapper for unit tests
  • Spec version bumped from 3.0.x to 3.1.0
  • Added jsonSchemaDialect: 'https://spec.openapis.org/oas/3.1/dialect/base'
  • nullable: true normalized to { type: ['x', 'null'] } or { oneOf: [T, { type: 'null' }] } via normalizeFor31()

None — all changes are additive and backward compatible with v1.0.1.


  • GraphQL decorator pipeline: @Resolver/@GQLQuery/@GQLMutation decorators now work end-to-end. GraphQLSchemaBuilder was reading from MetadataRegistry instance Maps that were never populated (decorator writes used a separate local-symbol namespace). It now reads directly from reflect-metadata via getResolverMetadata/getFieldsMetadata.
  • PermissionManager.revokePermission(): Filter logic resourceId ? p.resourceId !== resourceId : true was always true when no resourceId was given, so nothing was ever removed. Fixed.
  • GraphQLPlugin resolvers option: Pass resolver classes directly — new GraphQLPlugin({ resolvers: [UserResolver] }) — as an alternative to app.include(). Both paths are merged and deduplicated.
  • app.include() resolver recognition: include() now detects @Resolver-decorated classes and registers their metadata, so app.include(UserResolver) feeds the GraphQL schema builder automatically.
  • 89 new tests (509 total): PermissionPlugin (16), HealthCheckPlugin (25), Logger (20), OAuthPlugin (28), GraphQL decorators/pipeline (8 new, 17 total in suite).

  • CORS on error responses: error handler responses (401, 422, 500, application/problem+json, etc.) now include Access-Control-Allow-Origin and the rest of the configured CORS headers, consistent with successful responses (mergeVeloceCorsHeaders, VELOCE_CORS_HEADERS_KEY, VeloceCorsHeadersSnapshot).
  • Same CORS fix as attempted in 0.4.4; npm does not allow republishing an unpublished version, so 0.4.5 is the installable release.

Do not use: the npm tarball was broken (404). After unpublish, the registry blocks reusing this version number. Use 0.4.5 for the CORS fix.


  • RFC 9457 (Problem Details): error responses default to Content-Type: application/problem+json with type, title, status, detail, instance, and extensions (violations / details for validation; debug on 500 in development only).
  • VeloceTSConfig.errorResponseFormat: 'rfc9457' (default) or 'legacy' for the previous { error, statusCode, details? } JSON shape.
  • Exported helpers: problemTypeUri, resolveProblemType, resolveProblemTitle, buildProblemInstance, toLegacyErrorBody, sendErrorResponse, PROBLEM_JSON_MEDIA_TYPE, DEFAULT_PROBLEM_TYPE_BASE.
  • HTTPExceptionOptions: optional problemType and title on HTTPException.
  • Auth exceptions (AuthenticationException, AuthorizationException) use the unified error format with authentication-error / authorization-error problem types.
  • @module headers across core, errors, validation, DI, responses, logging, middleware, adapters, testing, and barrels.
  • Benchmarks refreshed for v0.4.3.
  • Error JSON still includes error and statusCode alongside RFC 9457 fields. Clients that require application/json on errors can set errorResponseFormat: 'legacy'.

  • HealthCheckPlugin: checker names are set in a Bun-safe way (Object.defineProperty + fallback) so assigning .name on async checkers no longer throws.
  • @Req export: the parameter decorator is now exported from the main package entry (import { Req } from 'veloce-ts').
  • Guides and API reference updated for @Ctx(), @Req(), and the real HealthCheckPlugin API (checks array, HealthCheckers.database / memory / disk).

  • include() no longer drops decorator-set route fields such as statusCode (@HttpCode) and responseSchema (@ResponseSchema) when registering controller routes.
  • Comprehensive test suite (53 tests) covering functional API routing, decorator routing, body/query validation, HTTP exceptions, and DI container.
  • Console-based fallback logger: if pino is not installed the framework falls back silently to console instead of crashing.
  • pino, pino-pretty, and ioredis moved to optionalDependencies — no longer installed automatically (~7 MB lighter install by default). Install explicitly with bun add pino pino-pretty or bun add ioredis if needed.
  • winston removed from dependencies (it was listed but never used).
  • Core bundle reduced from 444 KB → 408 KB (minified ESM).
  • FastAPITS named export: use VeloceTS or the shorter Veloce alias. FastAPITS will be removed in v1.0.0.

This release is the largest update since the initial launch — over 25 new features spread across three waves of work (high, medium, and low priority), covering the full chain from route generation to OpenAPI documentation, testing, ORM, and the CLI.

Five one-liner decorators as a concise alternative to @ApiDoc({...}):

  • @Summary(text) — short description visible in the Swagger UI route list
  • @Description(text) — long description shown in the operation detail panel
  • @Tag(name) — assigns a single tag; stackable with multiple @Tag
  • @Tags(...names) — assigns several tags in a single decorator
  • @Deprecated() — marks the route as deprecated (shown with strikethrough in Swagger UI)
@Get('/products')
@Summary('List all products')
@Description('Returns a paginated list of all available products')
@Tags('Products', 'Catalog')
async listProducts() {
return products;
}
@Delete('/products/:id')
@Deprecated()
@Summary('Delete a product (deprecated)')
async deleteProduct(@Param('id') id: string) {
return { success: true };
}
  • @HttpCode(statusCode) — overrides the HTTP response code for the handler (e.g. 201 for creation). Also used by the OpenAPI generator as the documented success code
  • @ResponseSchema(schema, statusCode?) — validates and sanitizes the handler response with a Zod schema; informs the response model in the OpenAPI spec
@Post('/products')
@HttpCode(201)
@ResponseSchema(ProductSchema)
async createProduct(@Body(CreateProductSchema) data: any) {
return await db.create(data);
}
  • @Timeout(ms, message?) — aborts the request with 408 Request Timeout if the handler exceeds the limit. Automatically injects the middleware at the start of the pipeline and emits the X-Timeout-Ms header
  • @RateLimit(options) — applies rate-limiting at the individual route level using the same configuration as createRateLimitMiddleware(). Standard X-RateLimit-* headers are sent automatically
@Get('/slow-operation')
@Timeout(5000, 'Operation timed out')
async slowOperation() {
await heavyProcessing();
return result;
}
@Post('/submit')
@RateLimit({ windowMs: 60_000, max: 5 })
async submitForm(@Body(FormSchema) data: any) {
return process(data);
}
  • Auto-tagging: automatically derives tags from the first path segment (/products/:id → tag "Products") without manual annotation
  • Bearer security scheme: adds components.securitySchemes.bearerAuth to the spec and applies security: [{ bearerAuth: [] }] to protected routes automatically
  • Automatic 401: protected routes receive a documented 401 Unauthorized response without additional configuration
  • @HttpCode support: uses the decorator’s statusCode as the key for the success block in responses
  • @ResponseSchema in spec: when present, the Zod schema is converted to JSON Schema format for the response content block

Six new exception classes for common error cases:

  • ConflictException (409)
  • GoneException (410)
  • PayloadTooLargeException (413)
  • UnprocessableEntityException (422)
  • TooManyRequestsException (429)
  • ServiceUnavailableException (503)
  • 5xx errors are logged with getLogger().error including path, method, status, and stack
  • 4xx errors are logged as warn in development environment
  • Generic uncaught errors also pass through Pino
  • @UseMiddleware: now always calls MetadataRegistry.defineRoute so middleware is not lost regardless of decorator execution order
  • @Cache / @CacheInvalidate: same pattern — metadata is correctly merged regardless of stacking order

MetadataCompiler — Lazy Cache with Snapshots

Section titled “MetadataCompiler — Lazy Cache with Snapshots”
  • Lazy compilation: a route is only recompiled if its metadata changed (JSON snapshot comparison)
  • Unique IDs for functional handlers via WeakMap<Function, number> — prevents cache collisions when different app instances register the same path with different handlers (critical bug in parallel tests)
  • clearCache() method exposed to clean state between tests

🧪 TestClient — Fluent API and Auth Helpers

Section titled “🧪 TestClient — Fluent API and Auth Helpers”

Complete rewrite of TestClient:

  • TestResponse — new response class with status, headers, body, text, ok properties and chainable assertion methods:
    • expectStatus(code), expectOk(), expectCreated(), expectNotFound(), etc.
    • expectJson(partialObject) — partial body check
    • expectField(field, value?) — verify a specific field
    • expectHeader(name, value?) — verify a response header
    • expectArrayLength(n) — verify array length in response
  • withToken(token) — creates an immutable client instance with the Authorization: Bearer header already set
  • withHeaders(headers) — creates an immutable instance with additional headers
  • loginAs(credentials, endpoint?) — logs in, extracts the JWT, and injects it into the current client for subsequent requests
  • registerAndLogin(user, endpoints?) — registers and logs in with a single call
  • clearAuth() — clears the stored token
const client = new TestClient(app);
// Fluent assertions
const response = await client.get('/users');
await response.expectOk().expectArrayLength(2);
// Auth helpers
const authClient = await client.loginAs({ email: 'user@example.com', password: 'pass' });
await authClient.get('/profile').then(r => r.expectOk());
// Chain assertions
const res = await client.post('/products', productData);
await res.expectCreated().expectField('id').expectJson({ name: 'Product A' });
  • Uses fs.statfs (Node 18+ / Bun) to obtain real disk metrics: total, free, used, and percentage
  • Graceful fallback to "healthy" on unsupported platforms
  • The fullstack template now generates src/index.ts with GraphQLPlugin and WebSocketPlugin correctly imported and instantiated
  • Added ./src/auth/index.ts, ./src/adapters/base.ts, ./src/adapters/hono.ts, ./src/adapters/express.ts as explicit entrypoints so veloce-ts/auth and veloce-ts/adapters/* imports work correctly

New module to connect Drizzle (or another ORM) to the dependency injection container:

import { registerDrizzle, InjectDB } from 'veloce-ts';
// Register the DB instance
registerDrizzle(app, db);
// Inject into controllers
@Get('/')
async list(@InjectDB() db: DrizzleDB) {
return await db.select().from(products);
}
  • DB_TOKEN — default symbol for the injection token
  • registerDrizzle(app, db, token?) — registers as singleton in the DIContainer
  • @InjectDB(token?) — parameter decorator, alias of @Depends(DB_TOKEN)
  • PaginationMeta includes from and to (1-based range, e.g. from: 11, to: 20)
  • CursorPaginatedResult includes count (actual items returned in the page)
  • createCursorPaginatedResult(data, limit, cursorField, hadPrevCursor) — the new hadPrevCursor parameter correctly activates hasPrev: true when navigating forward with cursor
  • createMultiCursor(entity, fields[]) — creates composite cursors by multiple fields for stable ordering (e.g. { createdAt, id })
  • decodeMultiCursor(cursor) — decodes a multi-field cursor back to an object
  • paginate<T>(data, total, page, limit) — builds { data, meta } in one call, without instantiating PaginationHelper
  • parseCursorQuery(query, defaultLimit?, maxLimit?) — extracts cursor and limit from query params without throwing exceptions
  • PaginationHelper.parsePaginationQuery(query, defaultLimit?, maxLimit?) — equivalent for offset pagination

🔌 Express Adapter — ESM Compatibility

Section titled “🔌 Express Adapter — ESM Compatibility”

Complete rewrite of the adapter:

  • Loads Express lazily and safely using Function('return require')() for ESM compatibility without needing declare const require: any
  • Accepts a pre-created Express instance as the second constructor argument (to add your own middleware before the bridge)
  • Correct handling of raw body (Buffer) vs parsed body (JSON/urlencoded)
  • Omits the transfer-encoding header when forwarding responses (was a source of errors in Express)
  • Delegates unexpected errors to the Express error pipeline via next(err) instead of responding with 500 directly
  • ZodError cross-module: instanceof ZodError failed when the consuming app had a different Zod instance than the framework (common with bun link). Added fallback error.name === 'ZodError' to guarantee 422 responses in all cases
  • GET routes marked public returned 401: app.use() applied middleware to all methods; fixed using app.on(['POST', 'PUT', 'DELETE'], path, middleware) to restrict only write methods
  • Cache collision in MetadataCompiler: different functional handlers with the same path in different app instances shared the incorrect compiled result; fixed with unique IDs per function
  • @Cache / @UseMiddleware lost metadata: when stacked in reverse order of decorator execution, metadata could be overwritten; fixed by explicitly updating the registry in each decorator

The 422 error response now includes additional structured information:

  • field in conventional format: items[0].price instead of items.0.price
  • received — received type (when Zod reports it)
  • expected — expected type (when applicable)
  • minimum / maximum — numeric limits in range errors
{
"error": "Validation Error",
"statusCode": 422,
"details": [
{ "field": "email", "message": "Invalid email", "code": "invalid_string" },
{ "field": "age", "message": "Number must be ≥ 18", "code": "too_small", "minimum": 18 },
{ "field": "tags[1]", "message": "String must not be empty", "code": "too_small" }
]
}

None — all changes are backward compatible. The createCursorPaginatedResult signature has a new optional hadPrevCursor parameter (fourth argument, false by default).


  • JSON Response Serialization: Fixed critical bug where JSON responses were not being serialized correctly
  • CLI Version Resolution: Confirmed CLI correctly fetches and uses latest npm version
  • Application Compilation: Fixed missing await app.compile() call in generated templates
  • Version Fetching: CLI now correctly fetches latest version from npm registry
  • Template Generation: All templates now include proper await app.compile() call
  • Error Handling: Improved error handling in CLI operations

Patch release with internal build fixes. No functional changes.


  • Latest Version Fetching: CLI now automatically fetches the latest VeloceTS version from npm registry
  • Improved Error Handling: Better error messages and cleanup on project creation failure
  • Type Safety: Fixed TypeScript errors in CLI with proper type definitions for npm registry API
  • Better User Experience: Enhanced progress messages and visual feedback during project creation
  • Robust Fallbacks: Multiple fallback strategies for version detection when npm is unavailable
  • npm Registry Integration: Fixed CLI to use correct npm registry endpoint
  • Template Compilation: All generated templates include mandatory await app.compile() call

  • In-Memory Cache Store: Fast LRU-based caching with automatic cleanup
  • Redis Cache Store: Distributed caching support for multi-instance deployments
  • @Cache() Decorator: Declarative response caching with flexible TTL configuration
  • @CacheInvalidate() Decorator: Pattern-based cache invalidation for mutations
  • TTL Support: Flexible time-to-live with string format ('5m', '1h', '1d') or seconds
  • Pattern Invalidation: Wildcard pattern matching for cache invalidation ('products:*')
  • Cache Headers: Automatic X-Cache headers (HIT/MISS) in responses
@Get('/products')
@Cache({ ttl: '5m', key: 'products:list' })
async getProducts() {
return await db.products.findAll();
}
@Post('/products')
@CacheInvalidate('products:*')
async createProduct(@Body(ProductSchema) data: any) {
return await db.products.create(data);
}
  • Automatic Request IDs: UUID generation for every request
  • @RequestId() Decorator: Inject request ID into controller methods
  • @AbortSignal() Decorator: Request cancellation support for long-running operations
  • Request Timeouts: Configurable timeouts per route or globally
  • X-Request-ID header: Automatic header in all responses
@Get('/data')
async getData(
@RequestId() requestId: string,
@AbortSignal() signal: AbortSignal
) {
logger.info({ requestId }, 'Processing request');
return await longRunningQuery(signal);
}
  • Request Context Integration: Automatic request ID in all log entries
  • Child Loggers: Enhanced contextual logging with inheritance
  • Structured Logging: JSON-formatted logs for production
  • Pretty Printing: Human-readable logs for development
  • createRequestContextMiddleware(): Initialize request context with ID, timeout, and logging
  • createSimpleRequestIdMiddleware(): Minimal request ID middleware
  • createCacheMiddleware(): Functional API route caching
  • createCacheInvalidationMiddleware(): Functional API cache invalidation

None — all changes are additive and backward compatible.


  • Query Export: Added missing Query export from main index to resolve import conflicts
  • Parameter Decorators: HTTP @Query decorator now properly exported alongside GraphQL decorators
  • Import Resolution: Fixed “Export named ‘Query’ not found” error in applications
  • GraphQL Query Conflict: Removed conflicting alias Query from GraphQL decorators
  • Import Resolution: GraphQL decorators now use GQLQuery to avoid conflicts with HTTP @Query decorator
  • Type Safety: Eliminated TypeScript errors caused by decorator name conflicts
  • GraphQL queries now use @GQLQuery instead of @Query to avoid conflicts with HTTP parameter decorator
  • Query Decorator: Fixed @Query decorator to properly handle parameters without schemas
  • Query Parameter Extraction: Improved query parameter handling in router compiler
  • Validation: Added proper validation for query parameters with optional Zod schemas
  • Error Handling: Fixed missing ValidationError import, now using BadRequestException
  • Query Decorator Flexibility: @Query now supports multiple usage patterns:
    • @Query() - Extract all query parameters
    • @Query('param') - Extract specific parameter
    • @Query(Schema) - Validate with Zod schema
  • Router Compiler: Enhanced parameter extraction and validation logic
  • Type Safety: Better TypeScript support for query parameter handling
  • None
  • WebSocket Exports: Fixed missing WebSocket decorator export from WebSocket module
  • Import Resolution: WebSocket decorators now properly exported from veloce-ts/websocket
  • GraphQL Decorators: Added missing Query, Mutation, and Subscription aliases for GraphQL decorators
  • Import Conflicts: Fixed naming conflicts between params and GraphQL decorators
  • GraphQL Aliases: More intuitive names for GraphQL decorators:
    • @GQLQuery → @Query (GraphQL)
    • @GQLMutation → @Mutation (GraphQL)
    • @GQLSubscription → @Subscription (GraphQL)
  • CLI Version Resolution: Fixed CLI template to always use latest compatible version
  • Package Generation: Improved package.json generation in CLI
  • Major Feature Release: Complete rewrite with enhanced functionality
  • Authentication System: JWT-based authentication with refresh tokens
  • Role-Based Access Control (RBAC): Hierarchical roles and permissions
  • GraphQL Support: Full GraphQL integration with schema generation
  • WebSocket Support: Real-time communication capabilities
  • SQLite Integration: Built-in database support
  • OpenAPI Documentation: Automatic API documentation generation
  • CLI Tool: Project scaffolding and code generation
  • Dependency Injection: Advanced DI container with scopes
  • Middleware System: Custom middleware support
  • Validation Engine: Zod-based request validation
  • Error Handling: Comprehensive error handling system
  • Type Safety: Improved TypeScript support throughout
  • Performance: Optimized routing and request handling
  • Developer Experience: Better debugging and development tools
  • Complete API redesign from v0.1.x
  • New decorator syntax and patterns
  • Updated project structure
  • Minor bug fixes and improvements
  • Basic decorator support
  • Simple routing system
  • Initial framework structure
  • Core application class
  • Project setup and configuration
  • Basic TypeScript support
  • Initial package configuration
  • Package metadata and basic structure
  • Initial release
  • Basic framework structure
  • Package setup

:::tip Latest Version We recommend always using the latest version of Veloce-TS for the best experience and latest features. :::

:::note Breaking Changes Breaking changes are clearly marked in each release. Please review the changelog before upgrading to a new major version. :::