From 7cfd50532d46f852243a8e10e89ae8106a27415d Mon Sep 17 00:00:00 2001 From: Shinwoo PARK Date: Fri, 1 May 2026 08:14:12 +0900 Subject: [PATCH] feat: add minimal bun-compiled docker image --- .dockerignore | 16 ++++++ Dockerfile | 31 ++++++++++ README.md | 34 +++++++++++ apps/api/package.json | 3 + apps/api/src/main.ts | 36 +++++++++++- apps/api/src/runtime-assets.spec.ts | 87 +++++++++++++++++++++++++++++ apps/api/src/runtime-assets.ts | 71 +++++++++++++++++++++++ bun.lock | 8 +++ 8 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 apps/api/src/runtime-assets.spec.ts create mode 100644 apps/api/src/runtime-assets.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..874e13d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +.hermes +node_modules +**/node_modules +apps/api/dist +apps/web/dist +coverage +build +*.log +bun-debug.log* +.env +.env.* +!.env.example +apps/api/prisma/dev.db* +README.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..db951fb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM oven/bun:1.3.13 AS builder +WORKDIR /app + +ENV DATABASE_URL=file:./dev.db + +COPY package.json bun.lock ./ +COPY apps/api/package.json apps/api/package.json +COPY apps/api/prisma apps/api/prisma +COPY apps/web/package.json apps/web/package.json +COPY packages/shared-types/package.json packages/shared-types/package.json + +RUN bun install --frozen-lockfile + +COPY . . + +RUN bun run --filter @codexdash/web build +RUN bun run --filter @codexdash/api bundle + +FROM gcr.io/distroless/cc-debian12:nonroot +WORKDIR /app + +ENV PORT=3001 \ + WEB_DIST_DIR=/app/web \ + CODEX_OAUTH_CALLBACK_BIND_HOST=0.0.0.0 + +COPY --from=builder /app/apps/api/dist/codexdash /app/codexdash +COPY --from=builder /app/apps/web/dist /app/web + +EXPOSE 3001 1455 + +ENTRYPOINT ["/app/codexdash"] diff --git a/README.md b/README.md index b12759f..81580de 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,40 @@ bun run dev:api bun run dev:web -- --host 0.0.0.0 ``` +## Docker image + +The production image uses a multi-stage build: +- `bun install` + frontend build in the builder stage +- `bun build --compile` to emit a single API executable at `apps/api/dist/codexdash` +- a distroless runtime image that only contains the compiled binary and the built web assets + +Build the image: + +```bash +docker build -t codexdash:latest . +``` + +Run it: + +```bash +docker run --rm \ + -p 3001:3001 \ + -p 1455:1455 \ + -e JWT_SECRET=replace-me \ + -e ENCRYPTION_SECRET=replace-with-32-plus-chars \ + -e DATABASE_URL=file:/data/codexdash.db \ + -e CODEXDASH_FRONTEND_ORIGIN=http://localhost:3001 \ + -e CODEX_OAUTH_REDIRECT_URI=http://localhost:1455/auth/callback \ + -v codexdash-data:/data \ + codexdash:latest +``` + +Notes: +- The container serves the built React app from the same process on port `3001`. +- The bundled frontend defaults to `http://localhost:3001` for API calls. If you need a different origin, rebuild the image with `VITE_API_BASE_URL` set at build time. +- `CODEX_OAUTH_CALLBACK_BIND_HOST=0.0.0.0` is baked into the image so the callback bridge remains reachable through Docker port publishing while the public redirect URL can still stay on `localhost:1455`. +- If the callback bridge is still unreachable in your setup, the manual callback URL paste fallback remains available. + ## Environment variables ### Root `.env` diff --git a/apps/api/package.json b/apps/api/package.json index babf738..fd9141c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,6 +5,7 @@ "license": "UNLICENSED", "scripts": { "build": "nest build", + "bundle": "bun build --compile ./src/main.ts --target=bun-linux-x64-baseline --minify --outfile ./dist/codexdash -e @nestjs/platform-socket.io -e amqplib -e amqp-connection-manager -e ioredis -e nats -e mqtt -e @grpc/grpc-js -e @grpc/proto-loader -e kafkajs", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", "start:dev": "nest start --watch", @@ -21,7 +22,9 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/jwt": "^11.0.1", + "@nestjs/microservices": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/websockets": "^11.0.1", "@prisma/client": "6.16.2", "argon2": "^0.44.0", "class-transformer": "^0.5.1", diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 5ada6e1..00f9c75 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,11 +1,19 @@ import { createServer } from 'node:http'; import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import type { NextFunction, Request, Response } from 'express'; import { AppModule } from './app.module'; import { CodexService } from './codex/codex.service'; +import { + buildSpaFallbackPath, + resolveCallbackListenHost, + resolveWebDistDir, + shouldServeSpaFallback, +} from './runtime-assets'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); app.enableCors({ origin: true, credentials: true }); app.useGlobalPipes( new ValidationPipe({ @@ -15,6 +23,27 @@ async function bootstrap() { }), ); + const webDistDir = resolveWebDistDir({ + envWebDistDir: process.env.WEB_DIST_DIR, + }); + if (webDistDir) { + app.useStaticAssets(webDistDir); + app.use((req: Request, res: Response, next: NextFunction) => { + if ( + shouldServeSpaFallback({ + method: req.method, + path: req.path, + acceptHeader: req.headers.accept, + }) + ) { + res.sendFile(buildSpaFallbackPath(webDistDir)); + return; + } + + next(); + }); + } + await app.listen(process.env.PORT ?? 3001); const codexService = app.get(CodexService); @@ -49,6 +78,9 @@ async function bootstrap() { })(); }); - callbackServer.listen(Number(callbackUrl.port || 80), callbackUrl.hostname); + callbackServer.listen( + Number(callbackUrl.port || 80), + resolveCallbackListenHost(callbackUrl.hostname), + ); } void bootstrap(); diff --git a/apps/api/src/runtime-assets.spec.ts b/apps/api/src/runtime-assets.spec.ts new file mode 100644 index 0000000..0fa06af --- /dev/null +++ b/apps/api/src/runtime-assets.spec.ts @@ -0,0 +1,87 @@ +import { join } from 'node:path'; +import { + buildSpaFallbackPath, + resolveCallbackListenHost, + resolveWebDistDir, + shouldServeSpaFallback, +} from './runtime-assets'; + +describe('runtime asset helpers', () => { + it('prefers WEB_DIST_DIR when it points to an existing directory', () => { + const webDistDir = resolveWebDistDir({ + envWebDistDir: '/tmp/codexdash-web', + execPath: '/app/codexdash', + directoryExists: (candidate) => candidate === '/tmp/codexdash-web', + }); + + expect(webDistDir).toBe('/tmp/codexdash-web'); + }); + + it('falls back to a web directory next to the compiled binary', () => { + const expected = join('/app', 'web'); + + const webDistDir = resolveWebDistDir({ + execPath: '/app/codexdash', + directoryExists: (candidate) => candidate === expected, + }); + + expect(webDistDir).toBe(expected); + }); + + it('returns null when no candidate directory exists', () => { + expect( + resolveWebDistDir({ + envWebDistDir: '/missing', + execPath: '/app/codexdash', + directoryExists: () => false, + }), + ).toBeNull(); + }); + + it('serves SPA fallback only for browser GET requests outside API routes', () => { + expect( + shouldServeSpaFallback({ + method: 'GET', + path: '/dashboard', + acceptHeader: 'text/html,application/xhtml+xml', + }), + ).toBe(true); + expect( + shouldServeSpaFallback({ + method: 'GET', + path: '/health', + acceptHeader: 'text/html', + }), + ).toBe(false); + expect( + shouldServeSpaFallback({ + method: 'GET', + path: '/auth/login', + acceptHeader: 'text/html', + }), + ).toBe(false); + expect( + shouldServeSpaFallback({ + method: 'POST', + path: '/dashboard', + acceptHeader: 'text/html', + }), + ).toBe(false); + expect( + shouldServeSpaFallback({ + method: 'GET', + path: '/app.js', + acceptHeader: 'text/html', + }), + ).toBe(false); + }); + + it('builds the SPA entrypoint path from the web dist directory', () => { + expect(buildSpaFallbackPath('/app/web')).toBe('/app/web/index.html'); + }); + + it('prefers an explicit callback bind host override for containers', () => { + expect(resolveCallbackListenHost('localhost', '0.0.0.0')).toBe('0.0.0.0'); + expect(resolveCallbackListenHost('127.0.0.1')).toBe('127.0.0.1'); + }); +}); diff --git a/apps/api/src/runtime-assets.ts b/apps/api/src/runtime-assets.ts new file mode 100644 index 0000000..5e195aa --- /dev/null +++ b/apps/api/src/runtime-assets.ts @@ -0,0 +1,71 @@ +import { existsSync } from 'node:fs'; +import { dirname, extname, join } from 'node:path'; + +type ResolveWebDistDirOptions = { + envWebDistDir?: string | null; + execPath?: string; + directoryExists?: (candidate: string) => boolean; +}; + +type SpaFallbackRequest = { + method: string; + path: string; + acceptHeader?: string | null; +}; + +const API_PREFIXES = ['/auth', '/codex', '/health']; + +export function resolveWebDistDir({ + envWebDistDir, + execPath = process.execPath, + directoryExists = existsSync, +}: ResolveWebDistDirOptions = {}): string | null { + const candidates = [ + envWebDistDir, + join(dirname(execPath), 'web'), + join(process.cwd(), 'apps/web/dist'), + ].filter((candidate): candidate is string => Boolean(candidate)); + + for (const candidate of candidates) { + if (directoryExists(candidate)) { + return candidate; + } + } + + return null; +} + +export function shouldServeSpaFallback({ + method, + path, + acceptHeader, +}: SpaFallbackRequest): boolean { + if (method !== 'GET') { + return false; + } + + if (!(acceptHeader ?? '').includes('text/html')) { + return false; + } + + if ( + API_PREFIXES.some( + (prefix) => path === prefix || path.startsWith(`${prefix}/`), + ) + ) { + return false; + } + + return extname(path) === ''; +} + +export function buildSpaFallbackPath(webDistDir: string): string { + return join(webDistDir, 'index.html'); +} + +export function resolveCallbackListenHost( + redirectHostname: string, + bindHost = process.env.CODEX_OAUTH_CALLBACK_BIND_HOST, +): string { + return bindHost || redirectHostname; +} diff --git a/bun.lock b/bun.lock index 54462e9..7c67d1b 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,9 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/jwt": "^11.0.1", + "@nestjs/microservices": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/websockets": "^11.0.1", "@prisma/client": "6.16.2", "argon2": "^0.44.0", "class-transformer": "^0.5.1", @@ -330,12 +332,16 @@ "@nestjs/jwt": ["@nestjs/jwt@11.0.2", "", { "dependencies": { "@types/jsonwebtoken": "9.0.10", "jsonwebtoken": "9.0.3" }, "peerDependencies": { "@nestjs/common": "11.1.19" } }, "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA=="], + "@nestjs/microservices": ["@nestjs/microservices@11.1.19", "", { "dependencies": { "iterare": "1.2.1", "tslib": "2.8.1" }, "peerDependencies": { "@grpc/grpc-js": "*", "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", "@nestjs/websockets": "^11.0.0", "amqp-connection-manager": "*", "amqplib": "*", "cache-manager": "*", "ioredis": "*", "kafkajs": "*", "mqtt": "*", "nats": "*", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["@grpc/grpc-js", "@nestjs/websockets", "amqp-connection-manager", "amqplib", "cache-manager", "ioredis", "kafkajs", "mqtt", "nats"] }, "sha512-3Oja56ydTlSaui19/i7gYM0MMqz/w4UR2aqZeL4K8B+Fq0Ztg3zHb8et76atToJGpSCevJLEsoEMOMaGgzRwfg=="], + "@nestjs/platform-express": ["@nestjs/platform-express@11.1.19", "", { "dependencies": { "cors": "2.8.6", "express": "5.2.1", "multer": "2.1.1", "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "11.1.19", "@nestjs/core": "11.1.19" } }, "sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg=="], "@nestjs/schematics": ["@nestjs/schematics@11.1.0", "", { "dependencies": { "@angular-devkit/core": "19.2.24", "@angular-devkit/schematics": "19.2.24", "comment-json": "5.0.0", "jsonc-parser": "3.3.1", "pluralize": "8.0.0" }, "optionalDependencies": { "prettier": "3.8.3" }, "peerDependencies": { "typescript": "5.9.3" } }, "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw=="], "@nestjs/testing": ["@nestjs/testing@11.1.19", "", { "dependencies": { "tslib": "2.8.1" }, "optionalDependencies": { "@nestjs/platform-express": "11.1.19" }, "peerDependencies": { "@nestjs/common": "11.1.19", "@nestjs/core": "11.1.19" } }, "sha512-/UFNWXvPEdu4v4DlC5oWLbGKmD27LehLK06b8oLzs6D6lf4vAQTdST8LRAXBadyMUQnVEQWMuBo3CtAVtlfXtQ=="], + "@nestjs/websockets": ["@nestjs/websockets@11.1.19", "", { "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", "@nestjs/platform-socket.io": "^11.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["@nestjs/platform-socket.io"] }, "sha512-2qo8jtIwwwgkqAI1BtnJ02EaFLrRkKA39eYXS8IhZCHilhBHCWdjnJ5cLcFq4oF+s+KZ7LcLGD/3stxJy8ijzg=="], + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "@nuxt/opencollective": ["@nuxt/opencollective@0.4.1", "", { "dependencies": { "consola": "3.4.2" }, "bin": { "opencollective": "bin/opencollective.js" } }, "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ=="], @@ -1334,6 +1340,8 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],