diff --git a/.env.example b/.env.example index fccd2da8..9ee928e8 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,8 @@ AUTO_START_SESSIONS=false # Domain Configuration DOMAIN=localhost +# The dashboard is bundled into the API and served on the API port. This only sets the +# public port for the optional Traefik proxy (with-proxy profile). DASHBOARD_PORT=2886 # Host interface the dev compose binds the API/dashboard ports to. Defaults to 127.0.0.1 @@ -47,7 +49,6 @@ CORS_ORIGINS=* # ============================================================================= # COMPONENTS (enable/disable container profiles) # ============================================================================= -DASHBOARD_ENABLED=true # Enable dashboard container PROXY_ENABLED=true # Enable Traefik proxy container # ============================================================================= diff --git a/CHANGELOG.md b/CHANGELOG.md index adc78428..fb052a9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING — single-port dashboard: the API now serves the bundled dashboard SPA.** In production the + NestJS API serves the built dashboard from its own port (default `2785`) via `@nestjs/serve-static`, so + there is no separate dashboard container and the UI is available by default wherever the API runs. `/api` + and `/socket.io` are excluded so they keep returning real API/WebSocket responses. Opt out with + `SERVE_DASHBOARD=false`. Dev is unchanged: `npm run dev` still runs the Vite dev server on `:2886` (HMR) + proxying to the API. Split-origin hosting (dashboard on a separate origin/CDN) still works: build with + `VITE_API_URL=` and host `dashboard/dist` anywhere. (#275) +- The API's Content-Security-Policy now allows `https://fonts.googleapis.com` (`style-src`) and + `https://fonts.gstatic.com` (`font-src`) so the dashboard's webfonts load now that it is served under the + API's CSP. (#275) + +### Added + +- `npm run build:all` (build API + dashboard) and `npm run prod` (build then serve) for running the + production build directly without Docker. (#275) + +### Migration + +- The dashboard moved from `:2886` (separate nginx container) to the API port `:2785`. Update bookmarks, + monitoring, and any external reverse-proxy config accordingly. (#275) +- The `with-dashboard` compose profile and the `DASHBOARD_ENABLED` env var are removed (the dashboard ships + with the API; ignored if still set). (#275) + ## [0.3.0] - 2026-06-18 Engine pluggability and plugin extensibility. OpenWA can now run on a second, browser-free WhatsApp engine diff --git a/Dockerfile b/Dockerfile index 751e32c9..4837f794 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,8 +22,10 @@ RUN npm ci # Copy source code COPY . . -# Build the application -RUN npm run build +# Build the API (dist/) and the dashboard SPA (dashboard/dist/). The root `npm ci` above +# ran before the dashboard source was copied, so its postinstall hook skipped the dashboard +# deps - install them explicitly here (npm ci, reproducible from dashboard/package-lock.json). +RUN npm run build && npm run dashboard:ci && npm run dashboard:build # ===== Stage 2: Production ===== FROM docker.io/node:22-slim AS production @@ -71,6 +73,10 @@ RUN npm ci --omit=dev && npm cache clean --force # Copy built application from builder stage COPY --from=builder /app/dist ./dist +# Copy the bundled dashboard SPA; ServeStaticModule serves it from this same process/port +# (app.module.ts resolves dashboard/dist relative to dist/). Single container, single port. +COPY --from=builder /app/dashboard/dist ./dashboard/dist + # Create data directories with correct ownership RUN mkdir -p ./data/sessions ./data/media && \ chown -R openwa:openwa /app diff --git a/README.md b/README.md index 272da9cc..3ba20d17 100644 --- a/README.md +++ b/README.md @@ -104,8 +104,8 @@ git clone https://github.com/rmyndharis/OpenWA.git cd OpenWA docker compose -f docker-compose.dev.yml up -d -# Access -# Dashboard: http://localhost:2886 +# Access (the dashboard is bundled into the API image and served on the same port) +# Dashboard: http://localhost:2785 # API: http://localhost:2785/api # Swagger: http://localhost:2785/api/docs ``` @@ -134,7 +134,7 @@ npm install # Start API + Dashboard (config is auto-generated on first run) npm run dev -# Access +# Access (in dev the dashboard runs on the Vite server with hot reload) # Dashboard: http://localhost:2886 # API: http://localhost:2785/api # Swagger: http://localhost:2785/api/docs @@ -187,22 +187,24 @@ docker compose up -d # With PostgreSQL database docker compose --profile postgres up -d -# Full stack (PostgreSQL, Redis, Dashboard, Traefik) +# Full stack (PostgreSQL, Redis, Traefik) docker compose --profile full up -d ``` -| Profile | Services | -| ---------------- | --------------------- | -| `postgres` | PostgreSQL database | -| `redis` | Redis cache | -| `minio` | S3-compatible storage | -| `with-dashboard` | Web dashboard | -| `with-proxy` | Traefik reverse proxy | -| `full` | All services above | +| Profile | Services | +| ------------ | --------------------- | +| `postgres` | PostgreSQL database | +| `redis` | Redis cache | +| `minio` | S3-compatible storage | +| `with-proxy` | Traefik reverse proxy | +| `full` | All services above | + +> The dashboard is bundled into the API image and served by NestJS on the API port, so it +> needs no profile - it is always available wherever `openwa-api` runs. > **Development vs Production** > -> - Development (`docker-compose.dev.yml`): SQLite, local storage, both API & Dashboard included +> - Development (`docker-compose.dev.yml`): SQLite, local storage, API serves the bundled dashboard > - Production (`docker-compose.yml`): Configurable database, profiles for optional services > > Official GHCR images are published as multi-arch manifests for: @@ -211,11 +213,11 @@ docker compose --profile full up -d ## 🔌 Ports -| Service | Port | Description | -| --------- | --------------- | ------------------------ | -| API | `2785` | REST API endpoints | -| Dashboard | `2886` | Web management interface | -| Swagger | `2785/api/docs` | Interactive API docs | +| Service | Port | Description | +| --------------- | --------------- | ----------------------------------------------- | +| API & Dashboard | `2785` | REST API + bundled web dashboard (same port) | +| Swagger | `2785/api/docs` | Interactive API docs | +| Dashboard (dev) | `2886` | Vite dev server with hot reload (`npm run dev`) | --- diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile deleted file mode 100644 index 4b6e1f4c..00000000 --- a/dashboard/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -FROM docker.io/node:20-alpine AS builder - -WORKDIR /app - -# Copy package files -COPY package*.json ./ - -# Install dependencies -RUN npm ci - -# Copy source code -COPY . . - -# Build the app -RUN npm run build - -# Production stage with nginx -FROM docker.io/nginx:alpine - -# Copy built files -COPY --from=builder /app/dist /usr/share/nginx/html - -# Copy nginx config -COPY nginx.conf /etc/nginx/conf.d/default.conf - -EXPOSE 80 - -CMD ["nginx", "-g", "daemon off;"] diff --git a/dashboard/Dockerfile.traefik b/dashboard/Dockerfile.traefik deleted file mode 100644 index 397fd0d9..00000000 --- a/dashboard/Dockerfile.traefik +++ /dev/null @@ -1,50 +0,0 @@ -# Dashboard with lightweight static file server for Traefik -FROM node:20-alpine AS builder - -WORKDIR /app - -COPY package*.json ./ -RUN npm ci - -COPY . . -RUN npm run build - -# ===== Production: Lightweight static server ===== -FROM nginx:alpine - -# Copy built static files -COPY --from=builder /app/dist /usr/share/nginx/html - -# Simple nginx config for SPA (Traefik handles public routing). -# Keep API/WebSocket requests same-origin so the dashboard also works when -# users bring their own external reverse proxy. -RUN echo 'server { \ - listen 80; \ - server_name localhost; \ - root /usr/share/nginx/html; \ - index index.html; \ - location /api/ { \ - proxy_pass http://openwa-api:2785; \ - proxy_http_version 1.1; \ - proxy_set_header Host $host; \ - proxy_set_header X-Real-IP $remote_addr; \ - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \ - proxy_set_header X-Forwarded-Proto $scheme; \ - } \ - location /socket.io/ { \ - proxy_pass http://openwa-api:2785; \ - proxy_http_version 1.1; \ - proxy_set_header Upgrade $http_upgrade; \ - proxy_set_header Connection "upgrade"; \ - proxy_set_header Host $host; \ - proxy_set_header X-Real-IP $remote_addr; \ - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \ - proxy_set_header X-Forwarded-Proto $scheme; \ - } \ - location / { \ - try_files $uri $uri/ /index.html; \ - } \ -}' > /etc/nginx/conf.d/default.conf - -EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] diff --git a/dashboard/README.md b/dashboard/README.md index 9d5e8374..ae4254ab 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -45,7 +45,11 @@ npm install npm run dev ``` -Dashboard will be available at `http://localhost:2886` +Dashboard will be available at `http://localhost:2886` (Vite dev server with hot reload; it +proxies `/api` + `/socket.io` to the NestJS API on `:2785`). + +In production the build (`npm run build` → `dist/`) is served by the NestJS API itself on the +same port via `@nestjs/serve-static`, so there is no separate dashboard container. ### Production Build diff --git a/dashboard/nginx.conf b/dashboard/nginx.conf deleted file mode 100644 index 5f30d18b..00000000 --- a/dashboard/nginx.conf +++ /dev/null @@ -1,35 +0,0 @@ -server { - listen 80; - server_name localhost; - root /usr/share/nginx/html; - index index.html; - - # Gzip compression - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml; - - # SPA routing - serve index.html for all routes - location / { - try_files $uri $uri/ /index.html; - } - - # Proxy API requests to backend - location /api/ { - proxy_pass http://openwa-api:2785; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } - - # WebSocket proxy - location /socket.io/ { - proxy_pass http://openwa-api:2785; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } -} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c62de8f4..1b9cc6f9 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -70,20 +70,8 @@ services: retries: 3 start_period: 30s - # Dashboard Frontend - dashboard: - build: - context: ./dashboard - dockerfile: Dockerfile - container_name: openwa-dashboard - ports: - - '${BIND_HOST:-127.0.0.1}:2886:80' - volumes: - - ./dashboard/nginx.conf:/etc/nginx/conf.d/default.conf:ro - depends_on: - openwa: - condition: service_healthy - restart: unless-stopped + # The dashboard SPA is bundled into the image and served by NestJS on the same port: + # open http://localhost:2785 — there is no separate dashboard container. networks: default: diff --git a/docker-compose.yml b/docker-compose.yml index d65d7915..10c3f375 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -170,24 +170,9 @@ services: - 'com.openwa.service=api' - 'com.openwa.core=true' - # ===== CORE: Dashboard Frontend ===== - dashboard: - build: - context: ./dashboard - dockerfile: Dockerfile.traefik - container_name: openwa-dashboard - profiles: ['with-dashboard', 'full'] - restart: unless-stopped - # App network only — the dashboard must NOT be able to reach docker-proxy:2375 (H6). - networks: - - openwa-network - expose: - - '80' - depends_on: - - openwa-api - labels: - - 'com.openwa.service=dashboard' - - 'com.openwa.core=true' + # The dashboard SPA is now bundled into the openwa-api image and served by NestJS on the + # same port (2785) - there is no separate dashboard container. Reach it at the openwa-api + # port directly, or via Traefik (above) when the with-proxy profile is enabled. # ===== OPTIONAL: Built-in PostgreSQL ===== postgres: diff --git a/docs/10-devops-infrastructure.md b/docs/10-devops-infrastructure.md index 002d801a..751cb74e 100644 --- a/docs/10-devops-infrastructure.md +++ b/docs/10-devops-infrastructure.md @@ -142,15 +142,8 @@ services: ports: - "6379:6379" - dashboard: - build: - context: ./dashboard - ports: - - "2886:2886" - environment: - - VITE_API_URL=http://localhost:2785 - depends_on: - - app + # No separate dashboard service: the `app` image bundles the dashboard SPA and serves it + # from the same port (2785) via NestJS. Open http://localhost:2785 for the UI. volumes: postgres-data: diff --git a/docs/12-troubleshooting-faq.md b/docs/12-troubleshooting-faq.md index b15868b8..7f7a4d7a 100644 --- a/docs/12-troubleshooting-faq.md +++ b/docs/12-troubleshooting-faq.md @@ -85,11 +85,10 @@ and no unqualified-search registries are defined **Cause:** Podman rootless mode does not fall back to Docker Hub for unqualified image names. -**Fix:** All `FROM` directives in `Dockerfile` and `dashboard/Dockerfile` must use fully-qualified names: +**Fix:** All `FROM` directives in the `Dockerfile` must use fully-qualified names: ```dockerfile FROM docker.io/node:22-slim -FROM docker.io/nginx:alpine ``` --- diff --git a/docs/14-migration-guide.md b/docs/14-migration-guide.md index 0031bb92..466581c3 100644 --- a/docs/14-migration-guide.md +++ b/docs/14-migration-guide.md @@ -103,7 +103,7 @@ curl -s 'http://localhost:2785/api/infra/export-data' \ # POSTGRES_BUILTIN=true # Step 3: Restart with new configuration -docker compose --profile with-dashboard --profile with-proxy up -d +docker compose --profile with-proxy up -d # Step 4: Import data to new database curl -X POST 'http://localhost:2785/api/infra/import-data' \ diff --git a/docs/17-dashboard-design.md b/docs/17-dashboard-design.md index 4a242e95..02617afc 100644 --- a/docs/17-dashboard-design.md +++ b/docs/17-dashboard-design.md @@ -980,76 +980,41 @@ export default defineConfig({ }); ``` -### Docker Build +### Production Build & Serving -```dockerfile -# dashboard/Dockerfile +The dashboard has **no container of its own**. In production the NestJS API serves the bundled +SPA from the same process and port (default `2785`) via `@nestjs/serve-static`, so there is no +nginx image and no separate dashboard service to deploy. -# Build stage -FROM node:20-alpine AS builder +How it fits together: -WORKDIR /app +- `npm run build:all` builds the API (`dist/`) **and** the dashboard (`dashboard/dist/`). The root + `Dockerfile` does this in its builder stage and copies `dashboard/dist` into the runtime image. +- `ServeStaticModule` is registered conditionally in `src/app.module.ts`: it only activates when + `dashboard/dist/index.html` exists, serves it with SPA fallback, and `exclude`s `/api` and + `/socket.io` so those keep returning real API/WebSocket responses. Opt out with + `SERVE_DASHBOARD=false`. +- Run it directly with `npm run prod` (build + serve) or `node dist/main` against a prebuilt image. -COPY package*.json ./ -RUN npm ci +In development the build is absent, so serve-static stays inert and the Vite dev server (port +`2886`, see above) handles the UI with HMR while proxying `/api` + `/socket.io` to the API. -COPY . . -RUN npm run build +**Split-origin hosting is still supported.** Same-origin serving is the default, not a lock-in: to +host the dashboard separately (a CDN, an object store, or its own container), build it with the API +origin baked in and deploy `dashboard/dist` wherever you like: -# Production stage -FROM nginx:alpine - -COPY --from=builder /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf - -EXPOSE 80 - -CMD ["nginx", "-g", "daemon off;"] +```bash +VITE_API_URL=https://api.example.com npm run build # in dashboard/ ``` -```nginx -# dashboard/nginx.conf - -server { - listen 80; - server_name _; - root /usr/share/nginx/html; - index index.html; - - # Gzip compression - gzip on; - gzip_types text/plain text/css application/json application/javascript; - - # SPA routing - location / { - try_files $uri $uri/ /index.html; - } - - # API proxy (if needed) - location /api { - proxy_pass http://openwa:2785; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } +`dashboard/src/services/api.ts` reads `VITE_API_URL` and calls that origin instead of same-origin +`/api`. Set `SERVE_DASHBOARD=false` on the API so it stops serving its own copy. Remember to add the +dashboard's origin to `CORS_ORIGINS` on the API. - # WebSocket proxy - location /ws { - proxy_pass http://openwa:2785; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } +For TLS or public exposure of the default single-port setup, terminate at your own reverse proxy +(nginx, Caddy, a cloud load balancer, or a k8s Ingress) in front of the API; see +`docs/12-troubleshooting-faq.md` for an nginx example. - # Cache static assets - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - } -} -``` ---
diff --git a/docs/DOCKER_ID.md b/docs/DOCKER_ID.md index 84e853f5..595165ce 100644 --- a/docs/DOCKER_ID.md +++ b/docs/DOCKER_ID.md @@ -16,8 +16,7 @@ Sebelum memulai, pastikan server Anda sudah terinstal: OpenWA menggunakan fitur **Profiles** di Docker Compose untuk mempermudah orkestrasi layanan tambahan (database, cache, dll) sesuai kebutuhan infrastruktur Anda. ### Layanan Utama (Core) -* **`openwa-api`**: Server backend utama REST API OpenWA. -* **`dashboard`**: Antarmuka web (React UI) untuk mengelola sesi dan webhook. +* **`openwa-api`**: Server backend utama REST API OpenWA, sekaligus menyajikan Dashboard web (React UI) pada port yang sama. ### Layanan Tambahan (Optional Profiles) * **`postgres`**: Database PostgreSQL (bawaan docker). @@ -40,8 +39,8 @@ cd OpenWA docker compose -f docker-compose.dev.yml up -d ``` -Aplikasi dapat diakses di: -* **Dashboard UI**: `http://localhost:2886` +Aplikasi dapat diakses di (Dashboard sudah menyatu dengan API pada port yang sama): +* **Dashboard UI**: `http://localhost:2785` * **Swagger Docs**: `http://localhost:2785/api/docs` --- @@ -61,19 +60,22 @@ Pastikan untuk mengubah nilai rahasia berikut di dalam `.env`: ### 2. Memilih Skenario Deployment (Docker Profiles) +> Dashboard sudah menyatu ke dalam image API dan disajikan oleh NestJS pada port API (2785), +> jadi tidak ada lagi profil/container `with-dashboard` terpisah. + #### Skenario A: Produksi Minimalis (SQLite + Dashboard) Cocok untuk VPS resource kecil (RAM 1GB - 2GB): ```bash -docker compose --profile with-dashboard up -d +docker compose up -d ``` #### Skenario B: Produksi Menengah (PostgreSQL + Dashboard) Cocok untuk keandalan data lebih tinggi: ```bash -docker compose --profile postgres --profile with-dashboard up -d +docker compose --profile postgres up -d ``` -#### Skenario C: Full Stack (PostgreSQL + Redis + S3 MinIO + Dashboard + Traefik) +#### Skenario C: Full Stack (PostgreSQL + Redis + S3 MinIO + Traefik) Cocok untuk skala enterprise dengan multi-sesi aktif: ```bash docker compose --profile full up -d @@ -87,8 +89,8 @@ Berikut variabel penting yang bisa disesuaikan di `.env`: | Nama Variabel | Nilai Default | Deskripsi | |---|---|---| -| `API_PORT` | `2785` | Port yang digunakan untuk akses REST API. | -| `DASHBOARD_PORT` | `2886` | Port untuk mengakses Dashboard UI. | +| `API_PORT` | `2785` | Port REST API sekaligus Dashboard UI (disajikan oleh NestJS). | +| `DASHBOARD_PORT` | `2886` | Port publik untuk proxy Traefik opsional (profil `with-proxy`). | | `DATABASE_TYPE` | `sqlite` | Jenis database yang digunakan (`sqlite` atau `postgres`). | | `DATABASE_NAME` | `/app/data/openwa.sqlite` | Lokasi database SQLite atau nama database PostgreSQL. | | `ENGINE_TYPE` | `whatsapp-web.js` | Driver/mesin engine WhatsApp yang digunakan. | diff --git a/docs/README.md b/docs/README.md index 38777df1..cfb39c69 100644 --- a/docs/README.md +++ b/docs/README.md @@ -83,7 +83,7 @@ Access: - Swagger: `http://localhost:2785/api/docs` - Health: `http://localhost:2785/api/health` -### Option B: Docker (Traefik + API + Dashboard) +### Option B: Docker (Traefik + API, dashboard bundled into the API) ```bash # Clone repository @@ -96,10 +96,10 @@ docker compose up -d Access: -- Dashboard: `http://localhost:2886` +- Dashboard: `http://localhost:2785` (bundled into the API, same port) - API: `http://localhost:2785/api` - Swagger: `http://localhost:2785/api/docs` -- Traefik (optional): `http://localhost:2886/api` +- Traefik (optional, with-proxy profile): `http://localhost:2886` ### API Key @@ -204,7 +204,7 @@ socket.on('message', msg => { OpenWA/ ├── src/ # Backend source code ├── dashboard/ # Frontend dashboard -├── docker-compose.yml # Traefik + API + Dashboard +├── docker-compose.yml # Traefik (optional) + API (serves bundled dashboard) ├── docker-compose.dev.yml # Dev-only compose ├── docs/ # Project documentation └── data/ # Local runtime data (sessions, media, api key) diff --git a/package-lock.json b/package-lock.json index a78bd7df..0dddc8e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@nestjs/core": "^11.1.27", "@nestjs/platform-express": "^11.1.27", "@nestjs/platform-socket.io": "^11.1.27", + "@nestjs/serve-static": "^5.0.5", "@nestjs/swagger": "^11.4.4", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.0", @@ -760,7 +761,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1266,7 +1266,6 @@ "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-8.0.0.tgz", "integrity": "sha512-GYXJNJclgm9H5Tt/tmuNWfjyF2BuygMwl8xrRIfxxOTgcK4SB8zNUPveTcHGAOoKKtPDVotHNZCBRrFCcOAXMA==", "license": "MIT", - "peer": true, "dependencies": { "redis-info": "^3.1.0" }, @@ -1305,7 +1304,6 @@ "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-8.0.0.tgz", "integrity": "sha512-X/F256CmpBj9oj+fK2wOzjygkhTtjgWnc3f/SxPiUs3rQFvgYCplJLEaURh13J+Tpq46/1drAxq4Ukt4e5LelA==", "license": "MIT", - "peer": true, "dependencies": { "@bull-board/api": "8.0.0" } @@ -1674,7 +1672,6 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -1706,7 +1703,6 @@ "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", @@ -3495,7 +3491,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.4.tgz", "integrity": "sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "2.8.1" }, @@ -3650,7 +3645,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.27.tgz", "integrity": "sha512-kEGSzqM2lWr4whh4Ubflw+oPZSEzxvRMu9WL+LveZploJWTjec5bBlCiRVlVzTPg2kIwBiLwWSvCCW7Wnin1gg==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", @@ -3697,7 +3691,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz", "integrity": "sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==", "license": "MIT", - "peer": true, "dependencies": { "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", @@ -3757,7 +3750,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.27.tgz", "integrity": "sha512-0ZFhz6H6EdGh4xQVbUNwjoAwBuz73P7FvUAl67h9CTdMqQlJDaQYJApBv8pKfVZ1fGjMCbl0m9DcC6pXaZPWSQ==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -3779,7 +3771,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.27.tgz", "integrity": "sha512-xgpLzaIDGOCC6xOAtHnRAz8sqieFgGxxu3MN5ID026Jt6oeL3efp29N5QHhPr7UlqBfy/Jd02uj0POkZq6Au3Q==", "license": "MIT", - "peer": true, "dependencies": { "socket.io": "4.8.3", "tslib": "2.8.1" @@ -3817,6 +3808,33 @@ } } }, + "node_modules/@nestjs/serve-static": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-5.0.5.tgz", + "integrity": "sha512-AhYx3N9aMwR2cb0w5Nlb5nHNYiAcF74ea/D/xna+PxlXwjmwGN/PpC/5fuMtOwmPBMgOTxNPOnB8C9LDZBSgyw==", + "license": "MIT", + "dependencies": { + "path-to-regexp": "8.4.2" + }, + "peerDependencies": { + "@fastify/static": "^8.0.4 || ^9.0.0", + "@nestjs/common": "^11.0.2", + "@nestjs/core": "^11.0.2", + "express": "^5.0.1", + "fastify": "^5.2.1" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "express": { + "optional": true + }, + "fastify": { + "optional": true + } + } + }, "node_modules/@nestjs/swagger": { "version": "11.4.4", "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.4.tgz", @@ -3965,7 +3983,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.1.tgz", "integrity": "sha512-8rw/nKT0S+L+MkzgE9F2/mox7mAgsPlwfzmW9gsESN1lmQtIrVEfiiBwC2O8+guS1jBfQehJIdcdUj2OAp4VUQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -4546,7 +4563,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4666,7 +4682,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -4892,7 +4907,6 @@ "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", @@ -5644,7 +5658,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5733,7 +5746,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6331,7 +6343,6 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -6634,7 +6645,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -6725,7 +6735,6 @@ "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.78.1.tgz", "integrity": "sha512-zD5IT+qMqbMgPFPdL9FwnZka1bz6nckM+5lXj4N0vsXqdzoVO6wizmXpwsg/4GnHmXJsL7XOKeWA64tYUdPrOA==", "license": "MIT", - "peer": true, "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.10.1", @@ -7043,7 +7052,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -7113,15 +7121,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -7974,8 +7980,7 @@ "version": "0.0.1581282", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dezalgo": { "version": "1.0.4", @@ -8473,7 +8478,6 @@ "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -8533,7 +8537,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -10045,7 +10048,6 @@ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", "license": "MIT", - "peer": true, "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", @@ -10328,7 +10330,6 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -12320,6 +12321,7 @@ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 6" } @@ -12708,7 +12710,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", @@ -13038,7 +13039,6 @@ "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13754,8 +13754,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/require-directory": { "version": "2.1.1", @@ -13905,7 +13904,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -14506,7 +14504,6 @@ "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", "hasInstallScript": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "bindings": "^1.5.0", "node-addon-api": "^7.0.0", @@ -15032,7 +15029,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15401,7 +15397,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15586,7 +15581,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.30.tgz", "integrity": "sha512-8T35PzjefOdqc2ZR9mwLQj0pUGp6lQhMbK2EvVMwJVJWlaoHm0v/Q6dThNOZkFchD+0yMg8gwjKM28ePiLSXSQ==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -15806,7 +15800,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16138,7 +16131,6 @@ "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -16207,7 +16199,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/package.json b/package.json index 29278381..1088187f 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,16 @@ "license": "MIT", "scripts": { "build": "nest build", + "build:all": "nest build && npm run dashboard:build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", + "prod": "npm run build:all && node dist/main", "dev": "concurrently -n api,dashboard -c blue,green \"npm run start:dev\" \"npm run dashboard:dev\"", "dashboard:install": "cd dashboard && npm install", + "dashboard:ci": "cd dashboard && npm ci", "dashboard:dev": "cd dashboard && npm run dev", "dashboard:build": "cd dashboard && npm run build", "postinstall": "node -e \"if (require('fs').existsSync('dashboard')) require('child_process').spawnSync('npm', ['run', 'dashboard:install'], { stdio: 'inherit', shell: true });\"", @@ -47,6 +50,7 @@ "@nestjs/core": "^11.1.27", "@nestjs/platform-express": "^11.1.27", "@nestjs/platform-socket.io": "^11.1.27", + "@nestjs/serve-static": "^5.0.5", "@nestjs/swagger": "^11.4.4", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.0", diff --git a/scripts/openwa.sh b/scripts/openwa.sh index eea0acf6..0a8c7ce3 100755 --- a/scripts/openwa.sh +++ b/scripts/openwa.sh @@ -37,11 +37,8 @@ load_env() { get_profiles() { local profiles="" - # Dashboard (default: enabled) - if [ "${DASHBOARD_ENABLED:-true}" = "true" ]; then - profiles="$profiles --profile with-dashboard" - log_info "Dashboard: enabled" - fi + # Dashboard is bundled into the openwa-api image and served by NestJS on the API port - + # no separate container/profile needed. # Proxy (default: enabled) if [ "${PROXY_ENABLED:-true}" = "true" ]; then @@ -119,15 +116,17 @@ cmd_start() { echo "" log_success "OpenWA started successfully!" echo "" - log_info "Dashboard: http://localhost:${DASHBOARD_PORT:-2886}" - log_info "API: http://localhost:${API_PORT:-2785}" + log_info "Dashboard & API: http://localhost:${API_PORT:-2785}" + if [ "${PROXY_ENABLED:-true}" = "true" ]; then + log_info "Public (Traefik): http://localhost:${DASHBOARD_PORT:-2886}" + fi } # Stop OpenWA cmd_stop() { log_info "Stopping OpenWA..." cd "$PROJECT_DIR" - docker compose --profile postgres --profile redis --profile minio --profile with-dashboard --profile with-proxy down + docker compose --profile postgres --profile redis --profile minio --profile with-proxy down log_success "OpenWA stopped" } diff --git a/src/app.module.ts b/src/app.module.ts index 395b9839..22009cad 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,8 @@ import { Module, DynamicModule, Type } from '@nestjs/common'; +import { ServeStaticModule } from '@nestjs/serve-static'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import * as fs from 'fs'; +import * as path from 'path'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ThrottlerModule } from '@nestjs/throttler'; import configuration from './config/configuration'; @@ -41,6 +44,27 @@ if (process.env.QUEUE_ENABLED === 'true') { queueModules.push(queueModule.QueueModule); } +// Serve the bundled dashboard SPA from this same NestJS process/port when a build is +// present (the production image copies dashboard/dist in). In local dev the build is +// absent, so this stays inert and the Vite dev server (:2886) handles the UI. Opt out +// explicitly with SERVE_DASHBOARD=false. The path + flags are exported so main.ts can +// log a clear status line (served / disabled / build missing) at startup. +export const DASHBOARD_DIST = path.resolve(__dirname, '..', 'dashboard', 'dist'); +export const dashboardServingEnabled = process.env.SERVE_DASHBOARD !== 'false'; +export const dashboardBuildPresent = fs.existsSync(path.join(DASHBOARD_DIST, 'index.html')); + +const serveStaticModules: Array = []; +if (dashboardServingEnabled && dashboardBuildPresent) { + serveStaticModules.push( + ServeStaticModule.forRoot({ + rootPath: DASHBOARD_DIST, + // Let Nest own these so unknown API/socket routes return real 404s/JSON rather + // than the SPA index.html fallback (Express 5 / path-to-regexp v8 wildcard syntax). + exclude: ['/api/{*splat}', '/socket.io/{*splat}'], + }), + ); +} + @Module({ imports: [ // Configuration @@ -189,6 +213,7 @@ if (process.env.QUEUE_ENABLED === 'true') { CatalogModule, // Phase 3: Catalog API (WhatsApp Business) PluginsApiModule, // Phase 5: Plugins API ExtensionsModule, // First-party extension plugins (registered disabled) + ...serveStaticModules, // Bundled dashboard SPA (production single-port setup) ], }) export class AppModule {} diff --git a/src/main.ts b/src/main.ts index f52956a8..372a2b11 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,7 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { SwaggerModule } from '@nestjs/swagger'; import helmet from 'helmet'; -import { AppModule } from './app.module'; +import { AppModule, DASHBOARD_DIST, dashboardServingEnabled, dashboardBuildPresent } from './app.module'; import { ShutdownService } from './common/services/shutdown.service'; import { LoggerService, LogLevel, createLogger } from './common/services/logger.service'; import { createSwaggerConfig } from './config/swagger.config'; @@ -132,11 +132,14 @@ async function bootstrap() { contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'"], + // The bundled dashboard pulls webfonts from Google Fonts (CSS from fonts.googleapis.com, + // font files from fonts.gstatic.com). Now that NestJS serves the dashboard under this CSP, + // allow those origins or the @import'd fonts are blocked and the UI falls back to system fonts. + styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], scriptSrc: ["'self'"], imgSrc: ["'self'", 'data:', 'https:'], connectSrc: ["'self'"], - fontSrc: ["'self'"], + fontSrc: ["'self'", 'https://fonts.gstatic.com'], objectSrc: ["'none'"], upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null, }, @@ -221,6 +224,19 @@ async function bootstrap() { console.log(`🚀 OpenWA is running on: http://localhost:${port}`); console.log(`📚 Swagger docs: http://localhost:${port}/api/docs`); + + // Make the dashboard-serving outcome explicit so a missing build (no UI on `/`) + // is obvious instead of a silent 404. + if (!dashboardServingEnabled) { + console.log('🖥️ Dashboard: serving disabled (SERVE_DASHBOARD=false); API only'); + } else if (dashboardBuildPresent) { + console.log(`🖥️ Dashboard: serving bundled UI at http://localhost:${port}`); + } else { + console.warn( + `⚠️ Dashboard: no build at ${DASHBOARD_DIST} - UI disabled (API still serves /api). ` + + 'Run `npm run build:all` to bundle it, or use the Vite dev server (`npm run dev`).', + ); + } } void bootstrap(); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 03ca81a7..447679cd 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -75,7 +75,8 @@ export class AuthService implements OnModuleInit { // Always show the welcome banner on startup const apiBaseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 2785}`; - const dashboardUrl = process.env.DASHBOARD_URL || `http://localhost:${process.env.DASHBOARD_PORT || 2886}`; + // The dashboard is served by NestJS at the same origin as the API now, so default to it. + const dashboardUrl = process.env.DASHBOARD_URL || apiBaseUrl; this.logger.log(''); this.logger.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); diff --git a/src/modules/docker/compose-network.spec.ts b/src/modules/docker/compose-network.spec.ts index 1330fa3f..e462d585 100644 --- a/src/modules/docker/compose-network.spec.ts +++ b/src/modules/docker/compose-network.spec.ts @@ -11,7 +11,7 @@ interface ComposeFile { /** * Regression lock: the Docker socket proxy must live on a dedicated - * internal network that the dashboard (and other untrusted peers) cannot reach. + * internal network that untrusted peers cannot reach. */ describe('docker-compose network segmentation', () => { const compose = yaml.load(readFileSync(join(__dirname, '../../../docker-compose.yml'), 'utf8')) as ComposeFile; @@ -24,10 +24,6 @@ describe('docker-compose network segmentation', () => { expect(compose.services['docker-proxy'].networks).toEqual(['internal-docker']); }); - it('keeps the dashboard OFF the internal docker network (cannot reach docker-proxy:2375)', () => { - expect(compose.services['dashboard'].networks ?? []).not.toContain('internal-docker'); - }); - it('lets openwa-api reach the proxy via the internal network', () => { expect(compose.services['openwa-api'].networks).toContain('internal-docker'); }); diff --git a/test/serve-static.e2e-spec.ts b/test/serve-static.e2e-spec.ts new file mode 100644 index 00000000..e24442e7 --- /dev/null +++ b/test/serve-static.e2e-spec.ts @@ -0,0 +1,86 @@ +import { Module, INestApplication, Controller, Get } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +// Throwaway dashboard build, evaluated before the module decorator (forRoot reads rootPath eagerly). +const distDir = mkdtempSync(join(tmpdir(), 'openwa-dash-')); +writeFileSync(join(distDir, 'index.html'), 'OpenWA Dashboard'); +mkdirSync(join(distDir, 'assets')); +writeFileSync(join(distDir, 'assets', 'app.js'), 'console.log(1)'); + +@Controller() +class PingController { + @Get('ping') + ping() { + return { ok: true }; + } +} + +@Module({ + imports: [ + ServeStaticModule.forRoot({ + rootPath: distDir, + exclude: ['/api/{*splat}', '/socket.io/{*splat}'], + }), + ], + controllers: [PingController], +}) +class ServeStaticTestModule {} + +/** + * Regression lock for single-port dashboard serving (app.module.ts). Bootstraps the SAME + * serve-static config (rootPath + exclude) via NestFactory against a throwaway build dir: + * the SPA must be served at `/` with client-side fallback, while Nest keeps ownership of + * `/api` so unknown API routes return JSON 404s (not the SPA index.html). Pins the Express 5 + * / path-to-regexp v8 wildcard syntax (`/api/{*splat}`) - if a dep bump breaks it, /api/* + * would start returning index.html and these tests fail. + */ +describe('Dashboard serve-static (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + app = await NestFactory.create(ServeStaticTestModule, { logger: false }); + app.setGlobalPrefix('api'); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('serves the dashboard index.html at /', async () => { + const res = await request(app.getHttpServer()).get('/'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/html/); + expect(res.text).toContain('OpenWA Dashboard'); + }); + + it('serves index.html for client-side routes (SPA fallback)', async () => { + const res = await request(app.getHttpServer()).get('/sessions'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/html/); + }); + + it('serves built assets', async () => { + const res = await request(app.getHttpServer()).get('/assets/app.js'); + expect(res.status).toBe(200); + }); + + it('lets Nest handle /api routes (real controller, not the SPA)', async () => { + const res = await request(app.getHttpServer()).get('/api/ping'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + }); + + it('returns a JSON 404 (not the SPA) for unknown /api routes', async () => { + const res = await request(app.getHttpServer()).get('/api/does-not-exist'); + expect(res.status).toBe(404); + expect(res.headers['content-type']).toMatch(/json/); + expect(res.text).not.toContain('OpenWA Dashboard'); + }); +}); diff --git a/traefik/dynamic.yml b/traefik/dynamic.yml index 8f85f6db..f6184ce1 100644 --- a/traefik/dynamic.yml +++ b/traefik/dynamic.yml @@ -1,37 +1,18 @@ # Traefik Dynamic Configuration +# +# The dashboard SPA is bundled into and served by openwa-api (NestJS) on the same port, +# so every route - UI, /api, and /socket.io - goes to the single api service. Traefik +# stays as the optional public entry point (TLS termination / single front-door). http: routers: - # API Router - highest priority for /api paths - api: - rule: 'PathPrefix(`/api/`)' - entryPoints: - - web - service: api - priority: 100 - - # WebSocket Router - highest priority for socket.io - websocket: - rule: 'PathPrefix(`/socket.io`)' - entryPoints: - - web - service: api - priority: 100 - - # Dashboard Router - catch-all for frontend (lowest priority) - dashboard: + openwa: rule: 'PathPrefix(`/`)' entryPoints: - web - service: dashboard - priority: 1 + service: api services: api: loadBalancer: servers: - url: 'http://openwa-api:2785' - - dashboard: - loadBalancer: - servers: - - url: 'http://openwa-dashboard:80'