mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
chore: optimize docker image and scanner performance
This commit is contained in:
@@ -6,3 +6,10 @@
|
||||
client/.env
|
||||
server/.env
|
||||
docker/postgres/data
|
||||
/data
|
||||
/books
|
||||
/book-bucket
|
||||
/local
|
||||
/coverage
|
||||
/test-results
|
||||
**/*.tsbuildinfo
|
||||
|
||||
+4
-8
@@ -25,18 +25,14 @@ APP_URL=https://projectx.example.com
|
||||
# DATABASE_URL=postgres://user:password@yourhost:5432/projectx
|
||||
# Or set individual parts (POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB)
|
||||
|
||||
# JWT token lifetimes
|
||||
# JWT_EXPIRES_IN=15m
|
||||
# JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# CORS origin for the frontend when served from a different domain than APP_URL
|
||||
# CLIENT_URL=https://app.example.com
|
||||
|
||||
# Book Bucket directory (defaults to {BOOKS_PATH}/book-bucket inside the container)
|
||||
# BOOK_BUCKET_PATH=/book-bucket
|
||||
|
||||
# AES-256 key for encrypting stored email credentials (generate: openssl rand -hex 32)
|
||||
# Recommended if you store SMTP provider credentials (generate: openssl rand -hex 32)
|
||||
# EMAIL_ENCRYPTION_KEY=
|
||||
|
||||
# Recommended if you store migration source credentials (generate: openssl rand -hex 32)
|
||||
# MIGRATION_ENCRYPTION_KEY=
|
||||
|
||||
# Log level (default: info). Set to debug for verbose output.
|
||||
# LOG_LEVEL=info
|
||||
|
||||
+11
-7
@@ -1,30 +1,33 @@
|
||||
ARG NODE_IMAGE=node:24.11-alpine
|
||||
|
||||
# Stage 1: Build client
|
||||
FROM ${NODE_IMAGE} AS client-builder
|
||||
FROM ${NODE_IMAGE} AS base
|
||||
RUN npm install -g pnpm@10
|
||||
|
||||
# Stage 1: Build client
|
||||
FROM base AS client-builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY packages/ packages/
|
||||
COPY packages/types/package.json ./packages/types/
|
||||
COPY client/package.json ./client/
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --filter client... --frozen-lockfile
|
||||
|
||||
COPY packages/ ./packages/
|
||||
COPY client/ ./client/
|
||||
RUN pnpm --filter client run build-only
|
||||
|
||||
# Stage 2: Build server + create deploy bundle
|
||||
FROM ${NODE_IMAGE} AS server-builder
|
||||
RUN npm install -g pnpm@10
|
||||
FROM base AS server-builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY packages/ packages/
|
||||
COPY packages/types/package.json ./packages/types/
|
||||
COPY server/package.json ./server/
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --filter server... --frozen-lockfile
|
||||
|
||||
COPY packages/ ./packages/
|
||||
COPY server/ ./server/
|
||||
RUN pnpm --filter server run build
|
||||
|
||||
@@ -38,6 +41,7 @@ FROM ${NODE_IMAGE} AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk upgrade --no-cache && \
|
||||
apk add --no-cache poppler-utils && \
|
||||
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
ENV NODE_ENV=production
|
||||
@@ -56,4 +60,4 @@ EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD node -e "const p=process.env.PORT||3000;fetch('http://127.0.0.1:'+p+'/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["sh", "entrypoint.sh"]
|
||||
CMD ["sh", "/app/entrypoint.sh"]
|
||||
|
||||
@@ -69,14 +69,11 @@ projectx/
|
||||
│ └── types/ Shared TypeScript types (@projectx/types)
|
||||
├── docker/
|
||||
│ └── postgres/ Postgres init scripts (extensions)
|
||||
├── docs/
|
||||
│ └── production.md Production deploy/backup/restore runbook
|
||||
├── local/ Local dev data (covers, docs) - gitignored
|
||||
├── docker-compose.yml Dev: runs Postgres only (app runs natively)
|
||||
├── docker-compose.prod.yml Prod: app + Postgres + migration job
|
||||
├── .env.prod.example Template env file for production compose
|
||||
├── Dockerfile Production multi-stage build
|
||||
└── Dockerfile.dev Runs entire stack in Docker (not used for normal dev)
|
||||
├── docker-compose.dev.yml Dev: runs Postgres only (app runs natively)
|
||||
├── docker-compose.yml Prod: app + Postgres
|
||||
├── .env.example Template env file for production compose
|
||||
└── Dockerfile Production multi-stage build
|
||||
```
|
||||
|
||||
### Monorepo layout
|
||||
@@ -212,39 +209,34 @@ The fastest way to learn the patterns is to follow an existing module:
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Server environment is configured in `server/.env` (created from `.env.example` during setup).
|
||||
Server environment is configured in `server/.env` for local development. Production compose uses the repo-root `.env` created from `.env.example`.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ------------------------ | ------------------------------------------------------ | ---------------------------------------------------------- |
|
||||
| `DATABASE_URL` | `postgres://projectx:projectx@localhost:5432/projectx` | PostgreSQL connection string |
|
||||
| `PORT` | `3000` | Server port |
|
||||
| `NODE_ENV` | `development` | Environment mode |
|
||||
| `JWT_SECRET` | `change-me-in-production` | JWT signing secret |
|
||||
| `JWT_EXPIRES_IN` | `15m` | Access token lifetime |
|
||||
| `JWT_REFRESH_EXPIRES_IN` | `7d` | Refresh token lifetime |
|
||||
| `SETUP_BOOTSTRAP_TOKEN` | (empty) | Required in production for initial `/api/v1/auth/setup` |
|
||||
| `BOOKS_PATH` | `../local/data` | App data directory for cover images (not where books live) |
|
||||
| `SMTP_*` | (empty) | Optional SMTP config for password-reset emails |
|
||||
| `APP_URL` | `http://localhost:5173` | Client URL (used in emails) |
|
||||
| Variable | Default | Description |
|
||||
| ----------------------- | ------------------------------------------------------ | ---------------------------------------------------------- |
|
||||
| `DATABASE_URL` | `postgres://projectx:projectx@localhost:5432/projectx` | PostgreSQL connection string |
|
||||
| `PORT` | `3000` | Server port |
|
||||
| `NODE_ENV` | `development` | Environment mode |
|
||||
| `JWT_SECRET` | `change-me-in-production` | JWT signing secret |
|
||||
| `SETUP_BOOTSTRAP_TOKEN` | (empty) | Required in production for initial `/api/v1/auth/setup` |
|
||||
| `BOOKS_PATH` | `../local/data` | App data directory for cover images (not where books live) |
|
||||
| `APP_URL` | `http://localhost:5173` | Client URL (used in emails) |
|
||||
|
||||
## Production Deploy
|
||||
|
||||
Production uses a separate compose file and env file:
|
||||
Production uses the root compose file and env file:
|
||||
|
||||
- Compose: `docker-compose.prod.yml`
|
||||
- Env file: `.env.prod` (from `.env.prod.example`)
|
||||
- Compose: `docker-compose.yml`
|
||||
- Env file: `.env` (from `.env.example`)
|
||||
- Image source: immutable images built in CI (`.github/workflows/container-image.yml`)
|
||||
|
||||
Quick start:
|
||||
|
||||
```bash
|
||||
cp .env.prod.example .env.prod
|
||||
# edit .env.prod and set APP_IMAGE + secrets
|
||||
cp .env.example .env
|
||||
# edit .env and set APP_IMAGE + secrets
|
||||
pnpm prod:up
|
||||
```
|
||||
|
||||
Detailed runbook (deploy, backup, restore, restore drills): [docs/production.md](docs/production.md)
|
||||
|
||||
---
|
||||
|
||||
## Database Workflow
|
||||
|
||||
@@ -292,6 +292,7 @@ class View {
|
||||
}
|
||||
render(layout) {
|
||||
if (!layout) return
|
||||
if (!this.document) return
|
||||
this.#column = layout.flow !== 'scrolled'
|
||||
this.#layout = layout
|
||||
if (this.#column) this.columnize(layout)
|
||||
|
||||
@@ -2,20 +2,22 @@ name: projectx-dev
|
||||
|
||||
services:
|
||||
postgres:
|
||||
container_name: projectx-dev-db
|
||||
image: pgvector/pgvector:pg16
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-projectx}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-projectx}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-projectx}
|
||||
POSTGRES_USER: ${DEV_POSTGRES_USER:-projectx}
|
||||
POSTGRES_PASSWORD: ${DEV_POSTGRES_PASSWORD:-projectx}
|
||||
POSTGRES_DB: ${DEV_POSTGRES_DB:-projectx}
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "${DEV_POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U projectx"]
|
||||
test:
|
||||
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
+30
-23
@@ -1,46 +1,35 @@
|
||||
name: projectx
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16@sha256:7d400e340efb42f4d8c9c12c6427adb253f726881a9985d2a471bf0eed824dff
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER?required}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?required}
|
||||
POSTGRES_DB: ${POSTGRES_DB?required}
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${POSTGRES_USER?required} -d ${POSTGRES_DB?required}",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
|
||||
app:
|
||||
container_name: projectx-app
|
||||
image: ${APP_IMAGE?required}
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
- "${APP_PORT:-3000}:${PORT:-3000}"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: ${PORT:-3000}
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
POSTGRES_HOST: ${POSTGRES_HOST:-postgres}
|
||||
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
||||
POSTGRES_USER: ${POSTGRES_USER?required}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?required}
|
||||
POSTGRES_DB: ${POSTGRES_DB?required}
|
||||
JWT_SECRET: ${JWT_SECRET?required}
|
||||
SETUP_BOOTSTRAP_TOKEN: ${SETUP_BOOTSTRAP_TOKEN?required}
|
||||
APP_URL: ${APP_URL?required}
|
||||
CLIENT_URL: ${CLIENT_URL:-${APP_URL?required}}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./books:/books
|
||||
- ./book-bucket:/book-bucket
|
||||
- ./data:/data
|
||||
- ./data/app:/data
|
||||
- ./book-bucket:/data/book-bucket
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
@@ -59,3 +48,21 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
postgres:
|
||||
container_name: projectx-db
|
||||
image: pgvector/pgvector:pg16@sha256:7d400e340efb42f4d8c9c12c6427adb253f726881a9985d2a471bf0eed824dff
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER?required}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?required}
|
||||
POSTGRES_DB: ${POSTGRES_DB?required}
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD-SHELL", 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
|
||||
@@ -9,4 +9,4 @@ export BOOKS_PATH="${BOOKS_PATH:-/data}"
|
||||
export BOOK_BUCKET_PATH="${BOOK_BUCKET_PATH:-/book-bucket}"
|
||||
|
||||
node dist/scripts/migrate.js
|
||||
exec node dist/main.js
|
||||
exec node --max-old-space-size=512 dist/main.js
|
||||
|
||||
+7
-8
@@ -37,8 +37,7 @@
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/formbody": "^8.0.2",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/static": "^9.1.0",
|
||||
"@nestjs/common": "^11.1.18",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
@@ -59,7 +58,7 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fast-xml-parser": "^5.5.10",
|
||||
"fast-xml-parser": "^5.5.11",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"fastify": "^5.8.4",
|
||||
"jose": "^6.2.2",
|
||||
@@ -69,7 +68,7 @@
|
||||
"mysql2": "^3.20.0",
|
||||
"nestjs-pino": "^4.6.1",
|
||||
"node-unrar-js": "^2.0.2",
|
||||
"nodemailer": "^8.0.4",
|
||||
"nodemailer": "^8.0.5",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
@@ -85,7 +84,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@nestjs/cli": "^11.0.17",
|
||||
"@nestjs/cli": "^11.0.18",
|
||||
"@nestjs/schematics": "^11.0.10",
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@swc/cli": "^0.8.1",
|
||||
@@ -98,7 +97,7 @@
|
||||
"@types/pg": "^8.18.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"@vitest/coverage-v8": "^4.1.3",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
@@ -113,7 +112,7 @@
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vitest": "^4.1.2"
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"vitest": "^4.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
+15
-17
@@ -9,7 +9,6 @@ import { join } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import fastifyRateLimit from '@fastify/rate-limit';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fastifyHelmet from '@fastify/helmet';
|
||||
import fastifyCompress from '@fastify/compress';
|
||||
@@ -69,11 +68,12 @@ async function bootstrap() {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", 'blob:'],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
connectSrc: ["'self'", 'ws:', 'wss:'],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
fontSrc: ["'self'", 'data:', 'blob:'],
|
||||
objectSrc: ["'none'"],
|
||||
frameSrc: ["'self'", 'blob:'],
|
||||
frameAncestors: ["'none'"],
|
||||
},
|
||||
},
|
||||
@@ -84,19 +84,6 @@ async function bootstrap() {
|
||||
await app.register(fastifyCookie as never);
|
||||
await app.register(fastifyMultipart as never, { limits: { fileSize: MAX_COVER_BYTES } });
|
||||
|
||||
// Rate limit unauthenticated requests only (brute-force protection on public endpoints).
|
||||
// Bypass for: JWT cookie (web app), Authorization header (OPDS Basic Auth),
|
||||
// and Kobo device endpoints (token-authenticated via URL).
|
||||
await app.register(fastifyRateLimit as never, {
|
||||
max: 100,
|
||||
timeWindow: '1 minute',
|
||||
allowList: (req: { cookies?: { access_token?: string }; headers?: { authorization?: string }; url?: string }) =>
|
||||
!!req.cookies?.access_token ||
|
||||
!!req.headers?.authorization ||
|
||||
/^\/api\/v1\/kobo\//.test(req.url ?? '') ||
|
||||
/^\/api\/v1\/epub\/\d+\/file\//.test(req.url ?? ''),
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
app.enableCors({
|
||||
origin: process.env.CLIENT_URL ?? 'http://localhost:5173',
|
||||
@@ -105,10 +92,21 @@ async function bootstrap() {
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// NestJS calls setNotFoundHandler during init — intercept it so non-API 404s
|
||||
// fall back to index.html instead of returning a JSON 404 (SPA routing support).
|
||||
const adapterAny = adapter as any;
|
||||
adapterAny.setNotFoundHandler = (nestHandler: (req: unknown, res: unknown) => void) => {
|
||||
fastify.setNotFoundHandler(async (request, reply) => {
|
||||
if (request.url.startsWith('/api')) {
|
||||
return nestHandler(request, reply);
|
||||
}
|
||||
return reply.sendFile('index.html');
|
||||
});
|
||||
};
|
||||
|
||||
await app.register(fastifyStatic as never, {
|
||||
root: join(__dirname, '..', 'public'),
|
||||
prefix: '/',
|
||||
decorateReply: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { ScannerRepository } from './scanner.repository';
|
||||
import { assembleBookCards } from '../book/utils/assemble-book-cards';
|
||||
|
||||
const METADATA_FORMATS = new Set(['epub', 'mobi', 'azw3', 'azw', 'cbz', 'cbr', 'cb7', 'fb2', 'pdf', 'm4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac']);
|
||||
const BATCH_SIZE = 5;
|
||||
const BATCH_SIZE = 3;
|
||||
const BOOK_EMIT_BUFFER_SIZE = 30;
|
||||
const BOOK_EMIT_FLUSH_INTERVAL_MS = 1500;
|
||||
type OrganizationMode = 'book_per_file' | 'book_per_folder';
|
||||
|
||||
Reference in New Issue
Block a user