Upgrading
Edit docs/guide/25-upgrading.md on GitHubWhat 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:
| Before | After |
|---|---|
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:
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.
| Before | After |
|---|---|
| a miss behind a global guard: 401 | a miss behind a global guard: 404 |
notFound: 'public' to get 404 | notFound: '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:
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:
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 {}| Before | After |
|---|---|
const log = validate(Bun.env).log in main.ts | inject 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.
export class EmailClient extends HttpService {}
HttpModule.forRootAsync({ useFactory, inject }, EmailClient);| Before | After |
|---|---|
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.
export class SessionsRedis extends Redis {}
RedisModule.forRootAsync({ useFactory, inject }, SessionsRedis);| Before | After |
|---|---|
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.
| Before | After |
|---|---|
new RedisRelay(...) in main.ts | WsRelayModule.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.
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.