Skip to content
v0.7.0: env files, config restarts, closed payloads

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:

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

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 › 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 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:

src/admin/kosmo.config.ts
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.

Released under the MIT License.