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 }),