From 9258c8dfef49334ede72419ac4e0436e1e6da785 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sat, 5 Sep 2026 16:31:04 +0300 Subject: [PATCH] fix(deploy): make `bun dev` actually start, and sign-in actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six defects found by running the thing rather than reading it. The previous change was verified by bundling, by tests, and by the container images — none of which start a Worker, so every one of these was invisible. **`bun dev` did not start.** It ran one multi-config process, which does not connect a service binding between the workers it loads; the API reported `AUTH [not connected]` and could not verify a token. It is two processes now, which is what the dev registry connects, and the second is backgrounded with the first killed on exit so stopping the pair stops both. **Neither server could bind.** Wrangler resolves `localhost` and takes `::1` first; a host with no IPv6 address on its loopback dies with a bind error from inside the runtime that names neither the app nor the port. `dev.ip` is pinned to `127.0.0.1`, and `inspector_port` is now distinct per app — it is not derived from the port above, so the second server to start died on an address already in use. **The API worker failed to evaluate.** A specifier ending in `.sql` is claimed by the bundler as a module of its own, so the schema file was emitted verbatim beside the bundle and the runtime threw on an export it could not find. The route was reaching past the domain module into the schema to spell a status; it now asks the domain module, which is the rule everywhere else here and happens to also avoid the hazard. **Signing in failed on the second request that touched the database.** A pool is cached per connection string, and on a Worker an I/O object created while handling one request may not be touched while handling another. The first request always succeeded, which is why it went unnoticed — a sign-in is several. The cache is now kept only where a process outlives its requests, which is the case it was added for. **The images named a base that podman will not resolve.** A short name needs a registry; the database service alongside them already spelled one. **Compose pinned container names.** The name is not scoped to the project, so a second checkout got the same three, and `down` in one stopped the other's containers. This is not hypothetical — it stopped a running development database while this was being tested. Verified by signing in end to end against both dev servers: a code requested over HTTP, read from the issuer's log, redeemed, exchanged for tokens, and presented to the API, which resolved it to the account the sign-in had just created. --- apps/api/Dockerfile | 7 +++++-- apps/api/app/routes/game.ts | 3 +-- apps/api/wrangler.jsonc | 12 +++++++++++- apps/auth/Dockerfile | 7 +++++-- apps/auth/wrangler.jsonc | 12 +++++++++++- docker-compose.yml | 10 +++++++--- package.json | 4 ++-- packages/core/src/db/index.ts | 21 +++++++++++++++++++++ packages/core/src/game/download.ts | 14 +++++++++++++- 9 files changed, 76 insertions(+), 14 deletions(-) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index cdcea82c..8f3dc936 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -10,7 +10,10 @@ # exclude the whole TypeScript half, because the guest rootfs build was the # only Dockerfile here — that part now lives in `build/Dockerfile.dockerignore`, # beside the build it belongs to. -FROM oven/bun:1.3.11-alpine AS deps +# The registry host is part of the name on purpose: podman refuses a +# short name that resolves to nothing, and a self-hoster is as likely to +# have podman as docker. +FROM docker.io/oven/bun:1.3.11-alpine AS deps WORKDIR /app @@ -30,7 +33,7 @@ COPY packages/auth/package.json packages/auth/ RUN bun install --frozen-lockfile --production -FROM oven/bun:1.3.11-alpine AS runtime +FROM docker.io/oven/bun:1.3.11-alpine AS runtime WORKDIR /app diff --git a/apps/api/app/routes/game.ts b/apps/api/app/routes/game.ts index a4dbbe3f..7da2548c 100644 --- a/apps/api/app/routes/game.ts +++ b/apps/api/app/routes/game.ts @@ -3,7 +3,6 @@ import { ErrorCodes, VisibleError } from '@nestri/core/error'; import { Examples } from '@nestri/core/examples'; import { Depot } from '@nestri/core/game/depot'; import { GameDownload } from '@nestri/core/game/download'; -import { GameDownloadStatus } from '@nestri/core/game/download.sql'; import { Game } from '@nestri/core/game/index'; import { Identifier } from '@nestri/core/id'; import { Library } from '@nestri/core/user/library'; @@ -448,7 +447,7 @@ export namespace GameApi { description: 'Steam application ID', example: Examples.Game.steamAppId }), - status: z.enum(GameDownloadStatus.enumValues).meta({ + status: z.enum(GameDownload.Status).meta({ description: 'New download status', example: Examples.GameDownload.status }), diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index b207a15d..c201ccce 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -12,8 +12,18 @@ "compatibility_date": "2026-09-05", "compatibility_flags": ["nodejs_compat"], "workers_dev": false, + // `ip` is pinned rather than left to default. Wrangler otherwise binds + // whatever `localhost` resolves to, which is `::1` first on most systems + // — and a host with no IPv6 address on its loopback interface fails to + // start at all, with a bind error from deep inside the runtime rather + // than anything naming a port. "dev": { - "port": 3000 + "ip": "127.0.0.1", + "port": 3000, + // Distinct per app. Both dev servers run at once and the debugger + // port is not derived from the one above, so leaving it default + // meant the second to start died on an address already in use. + "inspector_port": 9230 }, // `AUTH` routes by binding rather than by hostname, so it is one hop diff --git a/apps/auth/Dockerfile b/apps/auth/Dockerfile index cfef0228..47b60f56 100644 --- a/apps/auth/Dockerfile +++ b/apps/auth/Dockerfile @@ -10,7 +10,10 @@ # exclude the whole TypeScript half, because the guest rootfs build was the # only Dockerfile here — that part now lives in `build/Dockerfile.dockerignore`, # beside the build it belongs to. -FROM oven/bun:1.3.11-alpine AS deps +# The registry host is part of the name on purpose: podman refuses a +# short name that resolves to nothing, and a self-hoster is as likely to +# have podman as docker. +FROM docker.io/oven/bun:1.3.11-alpine AS deps WORKDIR /app @@ -30,7 +33,7 @@ COPY packages/auth/package.json packages/auth/ RUN bun install --frozen-lockfile --production -FROM oven/bun:1.3.11-alpine AS runtime +FROM docker.io/oven/bun:1.3.11-alpine AS runtime WORKDIR /app diff --git a/apps/auth/wrangler.jsonc b/apps/auth/wrangler.jsonc index fe8030a8..262a00a6 100644 --- a/apps/auth/wrangler.jsonc +++ b/apps/auth/wrangler.jsonc @@ -18,8 +18,18 @@ // second issuer as far as a token's `iss` claim is concerned, and every // token minted through it is rejected by the API. "workers_dev": false, + // `ip` is pinned rather than left to default. Wrangler otherwise binds + // whatever `localhost` resolves to, which is `::1` first on most systems + // — and a host with no IPv6 address on its loopback interface fails to + // start at all, with a bind error from deep inside the runtime rather + // than anything naming a port. "dev": { - "port": 1337 + "ip": "127.0.0.1", + "port": 1337, + // Distinct per app. Both dev servers run at once and the debugger + // port is not derived from the one above, so leaving it default + // meant the second to start died on an address already in use. + "inspector_port": 9229 }, // Local-only settings live in `.dev.vars` beside this file rather than in diff --git a/docker-compose.yml b/docker-compose.yml index ee75a0ad..58a985ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,13 @@ # docker compose up --build everything, built from source # docker compose up postgres just the database, for `bun dev` # +# No service sets `container_name`. It reads like a nicety and behaves like a +# global: the name is not scoped to the project, so a second checkout of this +# repo — a worktree, a colleague's clone — gets the same three names, and +# `docker compose down` in one of them stops the containers belonging to the +# other. Compose names them per project on its own, which is what makes two +# checkouts independent. +# # **There are no credentials in this file, and none of them have defaults.** # Every one is read from `.env`, and compose refuses to start naming the # variable it wanted rather than falling back to something. A default is worth @@ -26,7 +33,6 @@ x-postgres-url: &postgres-url services: postgres: image: docker.io/postgres:18-alpine - container_name: nestri_postgres environment: POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} @@ -52,7 +58,6 @@ services: # there. Same reason for the API below. context: . dockerfile: apps/auth/Dockerfile - container_name: nestri_auth depends_on: postgres: condition: service_healthy @@ -80,7 +85,6 @@ services: build: context: . dockerfile: apps/api/Dockerfile - container_name: nestri_api depends_on: postgres: condition: service_healthy diff --git a/package.json b/package.json index 0faad459..5f36916f 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ }, "type": "module", "scripts": { - "dev": "wrangler dev -c apps/auth/wrangler.jsonc -c apps/api/wrangler.jsonc", - "dev:server": "bun run --cwd apps/auth serve & bun run --cwd apps/api serve", + "dev": "wrangler dev -c apps/auth/wrangler.jsonc & issuer=$!; trap \"kill $issuer 2>/dev/null\" EXIT; wrangler dev -c apps/api/wrangler.jsonc", + "dev:server": "bun run --cwd apps/auth serve & issuer=$!; trap \"kill $issuer 2>/dev/null\" EXIT; bun run --cwd apps/api serve", "dev:docker": "docker compose up --build", "db:migrate": "bun run --cwd packages/core db:migrate", "db:push": "bun run --cwd packages/core db:push", diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts index 066a283e..b22115c0 100644 --- a/packages/core/src/db/index.ts +++ b/packages/core/src/db/index.ts @@ -67,10 +67,31 @@ export namespace Database { const clients = new Map(); + /** + * Whether a pool may outlive the request that opened it. + * + * On a Worker it may not. An I/O object created while handling one request + * cannot be touched while handling another — *"Cannot perform I/O on behalf + * of a different request"* — so a kept socket is not a saving there, it is + * an error thrown on the second request that reuses it, and the first + * request always succeeds. That shape is why it went unnoticed: a single + * call works, and a sign-in is two. + * + * A long-lived process has the opposite problem, which is what the cache + * exists for — so the answer is not one rule but this test. + */ + const poolsOutliveRequests = !( + typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers' + ); + export function client(): Client { const url = Env.get().DATABASE_URL || process.env.DATABASE_URL; const key = url ?? 'local:nestri'; + if (!poolsOutliveRequests) { + return connect(url); + } + const cached = clients.get(key); if (cached) { return cached; diff --git a/packages/core/src/game/download.ts b/packages/core/src/game/download.ts index ce2a9ab9..24893286 100644 --- a/packages/core/src/game/download.ts +++ b/packages/core/src/game/download.ts @@ -8,6 +8,18 @@ import { Identifier } from '../id.js'; import { GameDownloadStatus, GameDownloadTable } from './download.sql.js'; export namespace GameDownload { + /** + * The statuses a download can be in, for callers that need the list. + * + * Re-exported from the schema so that nothing outside this module has to + * import a `.sql` module to spell a status. That is the layering rule + * everywhere here, and it also avoids a concrete hazard: a specifier + * ending in `.sql` is claimed by the Workers bundler as a module of its + * own, which emitted this file's source verbatim beside the bundle and + * failed at startup with an export it could not find. + */ + export const Status = GameDownloadStatus.enumValues; + export const Info = z .object({ id: z.string().meta({ @@ -22,7 +34,7 @@ export namespace GameDownload { description: 'The game being downloaded', example: Examples.GameDownload.gameId }), - status: z.enum(GameDownloadStatus.enumValues).meta({ + status: z.enum(Status).meta({ description: 'Current download status', example: Examples.GameDownload.status }),