Upgrading

Edit docs/guide/25-upgrading.md on GitHub

What changed, and the line that replaces each thing. Every @dunx/* package shares one version, so each heading below is the release a change landed in, newest first. The changelog lists every release; this page covers only what can change the behaviour of an app that upgrades without touching its code, and the new spelling for what it can replace.

3.10.0

req.cookies exists on an unmatched path

On a path no route matches, req.cookies was undefined: Bun only builds it for a route handler, so a global guard that read a cookie there threw a TypeError before the 404. It is now a CookieMap there too, and a cookie a guard sets on a miss reaches the response. A route handler is unchanged. See Cookies.

Compression stamps a 304 as its 200

A 304 now gets the same headers its 200 would (RFC 9110 15.4.5). When its content-type is one Compression encodes, the 304 carries Vary: accept-encoding, and its ETag is made weak if the request negotiated an encoding. This applies to every 304, including one the app builds itself, with or without etag. A 304 with any other content type is unchanged.

An unmatched-path flood logs one line a second

Request logging's warn for a 4xx on a path no route matched and no middleware claims is now written at most once a second, and the next line carries suppressed, the count dropped since the last one, so a scanner walking /wp-admin and /.env writes one line a second.

A 4xx on a matched route logs exactly as before, and metrics still count every request. See Logging.

The explorer and dashboard pages send their own CSP

Swagger UI, Scalar and the dashboard now answer with a Content-Security-Policy whether or not securityHeaders is on. It admits the scripts the page itself loads: same-origin files, the origin of each <script src> on another host, and each inline boot script by hash.

object-src is 'none', base-uri is 'self' and frame-ancestors is 'self', so a portal on another origin can no longer embed these pages in an iframe. Styles, fonts and connections are left open.

The built-in renderers still load, and so does a custom DocsRenderer whose page loads its bundle from a CDN. What the policy blocks is script the page does not declare: inline event handlers such as onclick, eval, and a file a script adds at runtime from an origin no <script src> names. See Security.

3.9.3

Two copies of @dunx/core fail at boot

AppFactory.create counts the loaded copies of @dunx/core and refuses to boot on a second one. Before, each copy had its own Logger and RequestContext, so a binding made against one was invisible to the other and the container injected an instance with none of the methods. The failure surfaced later, in whichever package called one.

Run bun why @dunx/core to find the second copy. The usual cause is a package listing @dunx/core under dependencies rather than peerDependencies; see Publishing a package.

An MCP tool rejects an argument it never declared

@dunx/mcp read the keys a tool recognised and dropped the rest, so a misspelled key behaved like an absent one. A key the tool never declared, or a declared key of the wrong type, is now a tool error naming what the tool accepts. A tool that declares no arguments stays permissive. An explicit null reads as the absent filter it means. See Agent tooling.

3.9.1

createTestApp zeroes the health drain

createTestApp and createTestServer override ReadinessOptions with drainDelayMs: 0, ahead of your own overrides. A suite that asserts on the drain passes its own ReadinessOptions override and wins. See Health checks.

3.1.1

Config takes a schema directly

validate: (env) => envSchema.parse(env) still works. The wrapper is optional:

BeforeAfter
validate: (env) => envSchema.parse(env)schema: envSchema

One or the other, never both. A schema failure becomes a ConfigError naming every issue and its path.

An OpenAPI contribution can be a provider

contribute took a fragment or a thunk. It now also takes anything with a contribute() method, so a contributor that needs an injected instance is a provider rather than a closure:

TS
export class AuthDocs extends DocumentSource {
  constructor(private readonly auth: Auth) {
    super();
  }

  override async contribute(): Promise<DocumentFragment> {
    return betterAuthDocument(this.auth, { basePath: '/api/auth' })();
  }
}

OpenApiModule.forRootAsync({
  root: AppModule,
  imports: [DocsModule],
  inject: [AuthDocs],
  useFactory: (docs: AuthDocs) => ({ contribute: [docs] }),
});

Put DocsModule in the factory's own imports, as above, so it can inject AuthDocs. Importing DocsModule into the root module is not enough.

3.1.0

An unmatched path answers 404

HttpFactory.create used to pass a request for an unmatched path to global middleware with no route metadata. A global guard then refused it with a 401, so a prober could not tell a missing path from a protected one. An unmatched path now answers 404, and the old behaviour is opt-in.

BeforeAfter
a miss behind a global guard: 401a miss behind a global guard: 404
notFound: 'public' to get 404notFound: 'guarded' to get the old 401

An app with no global authentication guard needs no change. It now behaves the way every other framework does. An app that has one, and wants a miss to look like every other refused request, sets it back:

TS
await HttpFactory.create(AppModule, { notFound: 'guarded' });

HTTP settings can come from validated config

HttpFactory.create(root, options) builds the container, so its options argument has to be ready before any provider exists. An app whose request logging follows its own configuration had to validate that configuration twice.

HttpOptionsProvider is a subclass resolved from the container:

TS
export class AppHttpOptions extends HttpOptionsProvider {
  constructor(private readonly config: AppConfigService) {
    super();
  }

  override readonly trustProxy = true;

  override get prefix(): string {
    return this.config.get('prefix');
  }

  override get requestLogging(): RequestLoggingOptions {
    return { requestBody: this.config.get('log').requestBody };
  }
}

@Module({
  providers: [provide(HttpOptionsProvider, { useClass: AppHttpOptions })],
  exports: [HttpOptionsProvider],
})
export class HttpConfigModule {}
BeforeAfter
const log = validate(Bun.env).log in main.tsinject AppConfigService
app.setGlobalPrefix('api')override get prefix(), or both
app.enableCors({ ... })override get cors(), or both
app.set('trust proxy', true)override readonly trustProxy

Nothing is removed. setGlobalPrefix, enableCors, set and enableShutdownHooks all still work. A call to one wins over the provider, because it happens after construction. An argument to create() wins too, field by field, so a setting can move into the container one at a time.

Override a field with a field and a getter with a getter: TypeScript rejects the other pairing with TS2611 and TS2610. To derive a field from config, declare override trustProxy: boolean and assign it in the constructor.

A named outbound client can be a class

httpClient(name) returns a Token, and a token is not a class. A named client could only be reached with inject() in a field initialiser.

TS
export class EmailClient extends HttpService {}

HttpModule.forRootAsync({ useFactory, inject }, EmailClient);
BeforeAfter
readonly email = inject(httpClient('email'))constructor(private email: EmailClient)
HttpModule.forRoot({ name: 'email', ... })HttpModule.forRoot({ ... }, EmailClient)

The string form still works. A subclass does not claim HttpService, so a default client and any number of named ones coexist.

A named Redis connection can be a class

The same change, in @dunx/infra/redis. redisConnection(name) returns a Token, so a named connection could only be reached with inject() in a field.

TS
export class SessionsRedis extends Redis {}

RedisModule.forRootAsync({ useFactory, inject }, SessionsRedis);
BeforeAfter
readonly redis = inject(redisConnection('sessions'))constructor(private redis: SessionsRedis)
RedisModule.forRoot({ name: 'sessions', ... })RedisModule.forRoot({ ... }, SessionsRedis)

Redis is now exported for this: RedisConnection is the abstract contract and takes no options, so it cannot be the base a subclass extends. The string form still works. A subclass does not claim RedisConnection.

The websocket relay can be a provider

relay: new RedisRelay({...}) used to be built in main.ts and passed to HttpFactory.create. It was the only setting that could not come from config through an options provider.

BeforeAfter
new RedisRelay(...) in main.tsWsRelayModule.forRootAsync({ useFactory })
relay: and relayChannel: on create()override get relay() on the provider

The container closes the relay at shutdown, including in an app that never opened a socket, where PubSub.close() would not close it. Passing an instance to create() still works.

A constraint violation answers 409

A unique violation reaching the HTTP layer used to answer 500.

TS
try {
  return this.db.insert(users).values({ name }).returning().get();
} catch (error) {
  throw toDatabaseError(error);
}

toDatabaseError returns a ConstraintError carrying a status - 409 for a unique or foreign key violation, 400 for not-null and check. Anything it does not recognise passes through untouched. @dunx/http reads the status off it, so no error filter is involved. transaction, transactionSync and runSeeds already classify on the way out.

The driver's own message stays on cause rather than in the response body, which would otherwise carry the table and column names.

Four more fetch options

HttpClientOptionsInit passes compress, protocol and maxRedirects through. proxy widens to string | URL | { url, headers }: the object form sends Proxy-Authorization to the proxy rather than to the target.

protocol: 'http2' lets concurrent requests to one origin share a connection. Against a cleartext http:// upstream Bun raises HTTP2Unsupported rather than falling back, so set it only against an HTTPS origin that offers h2.