---
url: /blog/2026/v0.7.0.md
description: >-
  v0.7.0 loads env files the way Vite does, per source folder and per scope,
  restarts the dev server when a config or env file changes, and rejects
  payloads and responses with fields their type does not declare.
---

A minor release with three features and a few fixes.
One of the features is breaking: validation now rejects fields a type does not declare.
Check the upgrade note before you update.

## Env files, the Vite way

KosmoJS now loads env files on its own, and it does it the way Vite does.
The same files, `.env`, `.env.local`, `.env.[mode]` and `.env.[mode].local`,
in the same order, with `envDir` and `envPrefix` working as you know them.
`$VAR` and `${VAR}` expand as well, even when the key lives in another file.

If you know Vite's env files, you know these.
There are two differences,
both because a KosmoJS project is several source folders rather than one app.

### Project root first, then the folder

Files are read from the project root first, then from the source folder itself.
The folder's files win, even over a root `.env.[mode]`.
So the root holds what every folder shares, and a folder overrides only what it needs:

```sh
# .env
VITE_API_URL=https://api.example.com
VITE_TITLE=Acme

# src/admin/.env
VITE_TITLE=Acme Admin
```

The admin pages get `VITE_TITLE=Acme Admin`, and `VITE_API_URL` from the root.

Keys already in `process.env` are kept.
A value set by the shell, the container or CI wins over any file.

### Frontend, backend and sidecar, apart

Files are loaded per scope, and each scope gets its values its own way.

The frontend reads them from `import.meta.env`.
Only keys matching `envPrefix` reach it, `VITE_` by default, since they end up in the bundle.

The backend and sidecars read them from `process.env`, and get every key,
unless you give them an `envPrefix` of their own.
That `process.env` is shared by every source folder in the dev server,
so a key that two folders set differently ends up with one value.

To give a scope files of its own, point its `envDir` at them, relative to the source folder:

```ts [src/admin/kosmo.config.ts]
export default defineConfig({
  frontend: {
    stack: "react",
    viteConfig: {
      envDir: "env", // [!code ++]
    },
  },
});
```

Files there are loaded for the frontend only.
And `envDir: false` turns env files off for a scope altogether.

### --mode

`serve` and `build` take a `--mode` flag, and so do tests:

```sh
pnpm dev --mode stage
pnpm build --mode stage
pnpm test --mode stage
```

With `--mode stage`, `.env.stage` and `.env.stage.local` are loaded
on top of `.env` and `.env.local`.

### Not in production

Env files are a development convenience.

`kosmo preview` builds the app and runs `dist/run.js` itself,
so by the time the app starts, the preview server has loaded the env files already.

A production `dist/run.js` loads none.
Configuration comes from the environment, the twelve-factor way,
so set it in your host or container, or pass a file to Node yourself:

```sh
node --env-file .env.production dist/run.js
```

[Env files ›](/essentials/env-files) has the whole picture.

## Restart on config change

Until now, a change to `kosmo.config.ts` needed a manual restart of `pnpm dev`.

The dev and preview servers now run in a child process,
and a fresh one replaces it when one of these changes:

* `package.json` at the project root
* `kosmo.config.ts` in any source folder
* env files for the current mode, at the project root and in any source folder

A fresh process reads every config and env file from scratch,
so nothing from the previous run lingers.

Files imported by `kosmo.config.ts` are not watched.
If you move a part of the config into a module of its own,
save `kosmo.config.ts` after changing it, or restart by hand.
The same goes for a source folder added while the server runs.

A server that crashes or fails to start stays down until a watched file changes,
so the save that fixes it also brings it back.

### Sidecars shut down cleanly

A restart stops the old process, so everything in it has to let go of what it holds.

Sidecars did not. On shutdown only their Vite server was closed,
and the service's `teardown` never ran.
Vite's own SIGTERM handler could also exit before anything else got the chance.

Now the service is torn down first, then its Vite server.
A sidecar holding a port, a connection or a queue consumer releases it
before the next process starts.

Sidecars also get their env files now, the same way the backend does.

## Payloads and responses reject extra fields

Validation used to check the fields a type declares, and let any others through.

That is fine to read, and risky to write.
A handler that passes the payload on to the database
writes whatever the client sent, not just what the type allowed:

```ts
type Payload = {
  name: string;
  email: string;
};

// a client sends { name, email, role: "admin" }
await db.update(users).set(payload).where(eq(users.id, id));
```

The type says nothing about `role`, and `role` gets updated anyway.

Now a `json` or `form` payload accepts what its type declares and nothing else.
An extra field fails validation with a 400, before the handler runs:

```
400 ValidationError, target "json": should not have additional property "role"
```

It holds at every depth: nested objects, array elements, record values,
and the results of `Partial`, `Pick`, `Omit` and the like.

Responses are closed the same way.
A database row returned as-is, with a `password_hash` the response type never mentions,
fails response validation instead of reaching the client.
That is in development and tests;
in production, response validation runs only with `runtimeValidation: true`.

`query`, `headers` and `cookies` stay open.
Browsers, proxies and analytics add parameters and headers no route declares,
and rejecting those would turn ordinary traffic into errors.

### Upgrading

A client that sends fields beyond the type now gets a 400.
Fetch clients validate with the same schemas, so they throw before sending,
and you will see it in development first.

Watch for plain HTML forms.
They often post fields no route declares, like the clicked button's `name` or a CSRF token.

Declare the extra fields, or reopen the object where it should stay open:

```ts
POST<{
  form: VRefine<{ title: string; body: string }, { additionalProperties: true }>;
}>
```

For keys not known in advance, `Record<string, T>` accepts any key,
and still checks each value against `T`.
[Undeclared properties](/validation/payload#undeclared-properties) has the details.

## HMR on the dev port

The dev server listens on one port, but it used to open more behind it.
Each source folder took four from a free range found at start:
two per server, one for Vite and one for its HMR socket.

The browser connected its HMR socket straight to one of those ports,
a different one on every start.
Behind a reverse proxy, or on a remote box with only the dev port forwarded,
that port was out of reach.
Pages loaded fine, but HMR never connected, so nothing updated on save.

Now the socket shares the dev port, under each folder's base:
`/@__kosmo_hmr__` for a folder at `/`, `/admin/@__kosmo_hmr__` for one at `/admin`.
A proxy that forwards the dev port, with WebSocket upgrades enabled, carries HMR as well.

The backend opens no socket at all now. Nothing in the browser ever connected to it.

If your proxy passes its own `Host` header,
Vite turns those requests away until the host is allowed.
Add it in the folder's `viteConfig`:

```ts [src/admin/kosmo.config.ts]
viteConfig: {
  server: {
    allowedHosts: ["dev.example.com"],
  },
},
```

## A catch-all right under the base

An API route that starts with a splat or an optional param,
say `api/{...path}/index.ts` or `api/{id}/index.ts`,
only ever matched the base itself.
With the API at `/api`, `/api/users` was a 404.

Both groups carry their own leading slash.
The routes joined the base and the pattern with Node's `path.join`,
which knows nothing about route groups and kept the separator: `/api/{/*path}`.
That asks for `/api/` and then an optional `/users`, so `/api/users` never fits.

Now the separator goes.
`/api{/*path}` matches `/api` and `/api/users` alike,
and so does `{/*path}` for an API at `/`.

A group deeper down, like `/api/docs{/*path}`, has a segment before it, and was fine all along.

## Optional params, typed right

Two fixes to the types of route params.

A splat followed by an optional param was typed as required,
as in `files/{...path}/{version}`.
Both may be left out, so `/files` is a valid URL, but `path()` demanded a value for the splat.
Now a splat is optional unless a required param comes after it, as in `files/{...path}/[id]`.
A tuple cannot hold a required element after an optional one, so that splat stays required.

And `path()` and `href()` take no params whenever every param is optional,
as in `files/{...path}/edit` or `{filter}`.
Before, only a route with no params at all could skip them,
and the rest had to pass an empty tuple.

## Koa: HEAD after GET

A route can define a `HEAD` handler of its own,
to answer without doing the work its `GET` does:

```ts
export default defineRoute<"files/[id]">(({ GET, HEAD }) => [
  GET(async (ctx) => {
    // read the file and send it
  }),
  HEAD(async (ctx) => {
    // send its size, without reading it
  }),
]);
```

The routing docs promise that the order of handlers does not matter.
On Koa, it did.

`@koa/router` registers every `GET` route for `HEAD` as well,
and tries routes in the order they were registered.
Handlers were registered in the order they were defined,
so the `GET` above answered every HEAD request, and the `HEAD` handler never ran.
Koa apps printed a warning saying so, and asked you to define `HEAD` before `GET`.

Now `HEAD` is registered first, wherever it sits in the array, and the warning is gone.
