mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
fix(deploy): make bun dev actually start, and sign-in actually work (#326)
Follow-up to #325. Six defects, all found by running the thing rather
than reading it — #325 was verified by bundling, by tests, and by the
container images, and none of those start a Worker.
| | |
|---|---|
| `bun dev` never started | one multi-config process does not connect a
service binding between the workers it loads — the API reported `AUTH
[not connected]`. Two processes now, which is what the dev registry
connects |
| Neither server could bind | wrangler resolves `localhost` and takes
`::1` first; a host with no IPv6 on its loopback dies with a bind error
naming neither app nor port. `dev.ip` pinned, and `inspector_port` made
distinct — it is not derived from the port, so the second server died on
an address already in use |
| The API worker failed to evaluate | a specifier ending in `.sql` is
claimed by the bundler as its own module, so the schema file was emitted
verbatim beside the bundle and the runtime threw on a missing export |
| Sign-in failed on the second DB request | a pooled socket created
while handling one request may not be touched while handling another on
a Worker. The *first* request always succeeded, which is why nobody saw
it |
| The images named a base podman will not resolve | a short name needs a
registry; the database service alongside them already spelled one |
| Compose pinned container names | not scoped to the project, so `down`
in one checkout stops another's containers — it stopped a running
development database while this was being tested |
Two of these are worth a second look because they are not confined to
local development.
**The database pool one is a live bug on Workers**, and it predates #325
— it arrived with the pool cache in `6c1d407`. On a Worker an I/O object
created during one request cannot be used during another, so the cached
socket throws *"Cannot perform I/O on behalf of a different request"* on
the second request that reuses it. The cache is now kept only where a
process outlives its requests, which is the case it was added for; a
Worker goes back to a pool per invocation, which is what it did before.
**The `.sql` import was also a layering break.** A route was reaching
past the domain module into the schema to spell a download status. It
asks the domain module now, which is the rule everywhere else here and
happens to be what removes the bundler hazard.
## Verified
A real sign-in, end to end, against both dev servers: a code requested
over HTTP, read out of the issuer's log the way a person reads it out of
their mail, redeemed, exchanged for tokens, and presented to the API —
which resolved it to the account the sign-in had just created, and
refused the same request without it.
```
1. asked for a code -> 200
2. code from the log -> 811548
3. redeemed the code -> 302
4. exchanged for tokens-> access eyJhbGciOiJFUzI1NiIsImtp…
5. GET /user with it -> 200 {"data":{"id":"usr_071b56380001F9L3Wf8OQj8ogS", …}}
6. GET /user without -> 401
```
Also: 316 tests pass, both images build, and compose substitution
resolves with the required-variable guards refusing correctly when
`.env` is incomplete.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- **Bug Fixes**
- Improved game download status validation for more consistent API
behavior.
- Improved database connection handling in Cloudflare Workers by using
request-local connections.
- **Developer Experience**
- Added explicit local development and debugger ports for the API and
authentication services.
- Improved development process handling when running services together.
- **Chores**
- Updated container configuration to support multiple project checkouts
without naming conflicts.
- Standardized container image references for more reliable builds.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
This PR repairs local development startup and sign-in behavior while
making containerized development safer across environments.
- Runs the authentication and API Workers as separate, jointly
supervised development processes.
- Assigns explicit IPv4 listener and distinct inspector ports.
- Prevents Cloudflare Workers from reusing database I/O objects across
requests.
- Exposes download statuses through the domain module rather than
importing a schema module from the route.
- Corrects the authentication app’s Hono JSX transform configuration.
- Uses fully qualified Bun image names and Compose-managed container
names.
<h3>Confidence Score: 5/5</h3>
The PR appears safe to merge, with the previous process-supervision
issue resolved and no new actionable regressions identified.
The current development scripts stop the sibling server when either
process exits, and the changes since the previous review preserve server
entrypoint behavior while correcting the Hono JSX runtime selection. The
previous thread was manually resolved after the supervision fix.
<h3>Important Files Changed</h3>
| Filename | Overview |
|----------|----------|
| package.json | Starts and supervises the two development servers
independently; the follow-up direct entrypoint invocation preserves
their intended behavior. |
| apps/auth/tsconfig.json | Aligns authentication-server JSX
transformation with Hono and the repository’s existing TypeScript
configuration. |
| packages/core/src/db/index.ts | Limits database pool caching to
long-lived process environments so Worker requests do not reuse
request-bound I/O. |
| packages/core/src/game/download.ts | Exposes valid download statuses
through the domain namespace for API consumers. |
| apps/api/app/routes/game.ts | Uses the domain-level download status
export, avoiding a direct runtime import of the SQL schema module. |
| docker-compose.yml | Removes globally fixed container names so Compose
projects remain isolated between checkouts. |
<h3>Flowchart</h3>
```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Dev["bun dev"] --> Auth["Auth Worker<br/>127.0.0.1:1337<br/>Inspector 9229"]
Dev --> API["API Worker<br/>127.0.0.1:3000<br/>Inspector 9230"]
API -->|AUTH service binding| Auth
Auth --> DB["Request-local DB pool<br/>on Cloudflare Workers"]
API --> DB
Supervisor["Process supervisor"] --> Dev
Auth -->|Either process exits| Supervisor
API -->|Either process exits| Supervisor
Supervisor -->|Stops sibling process| Dev
```
<sub>Reviews (3): Last reviewed commit: ["fix(deploy): make \`bun run
dev:server\`
s..."](46c3a67d4d)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=60753832)</sub>
<!-- /greptile_comment -->
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -4,8 +4,14 @@
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "react",
|
||||
// The sign-in screens are `hono/jsx`, and there is no React in this
|
||||
// repository at all. Naming React here was left over from an earlier
|
||||
// scaffold and only bit when something transpiled from this directory:
|
||||
// `bun run serve` resolved `react/jsx-dev-runtime` from a component in
|
||||
// `packages/auth` and stopped before the server bound a port. The
|
||||
// package holding the components already says this; so does the root.
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"types": ["@cloudflare/workers-types", "node", "bun"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=$!; wrangler dev -c apps/api/wrangler.jsonc & api=$!; trap 'kill $issuer $api 2>/dev/null' EXIT INT TERM; while kill -0 $issuer 2>/dev/null && kill -0 $api 2>/dev/null; do sleep 1; done",
|
||||
"dev:server": "bun run apps/auth/src/server.ts & issuer=$!; bun run apps/api/app/server.ts & api=$!; trap 'kill $issuer $api 2>/dev/null' EXIT INT TERM; while kill -0 $issuer 2>/dev/null && kill -0 $api 2>/dev/null; do sleep 1; done",
|
||||
"dev:docker": "docker compose up --build",
|
||||
"db:migrate": "bun run --cwd packages/core db:migrate",
|
||||
"db:push": "bun run --cwd packages/core db:push",
|
||||
|
||||
@@ -67,10 +67,31 @@ export namespace Database {
|
||||
|
||||
const clients = new Map<string, Client>();
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user