From c16f2af9cb23ac043da700f8a0cb3cbf1a462a96 Mon Sep 17 00:00:00 2001 From: Carlos Murillo Date: Wed, 30 Sep 2026 06:25:18 -0500 Subject: [PATCH] feat(infra): Docker Compose dev environment auto-reload file watcher (Closes #1366) --- Makefile | 6 +- backend/tests/composeWatch.test.js | 83 ++++++++++++++++++ compose.yaml | 18 ++++ docs/DEVELOPMENT.md | 15 ++++ docs/DOCKER_COMPOSE_WATCH.md | 130 +++++++++++++++++++++++++++++ frontend/vite.config.js | 3 + package.json | 1 + 7 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 backend/tests/composeWatch.test.js create mode 100644 docs/DOCKER_COMPOSE_WATCH.md diff --git a/Makefile b/Makefile index ffb9c5ed..5913a6f0 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Trivela development helpers -.PHONY: dev dev-down codegen codegen-check +.PHONY: dev dev-down dev-watch codegen codegen-check # Issue #616: one-command local devnet (contracts + backend + frontend) dev: @@ -8,6 +8,10 @@ dev: dev-down: docker compose --profile devnet down -v +# Issue #1366: Docker Compose auto-reload file watcher +dev-watch: + docker compose watch + # Issue #615: regenerate TypeScript client from openapi.yaml codegen: cd sdk/client && npm install --silent && npm run generate diff --git a/backend/tests/composeWatch.test.js b/backend/tests/composeWatch.test.js new file mode 100644 index 00000000..d1c11a84 --- /dev/null +++ b/backend/tests/composeWatch.test.js @@ -0,0 +1,83 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'yaml'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, '../../'); + +describe('Docker Compose Dev Watch Configuration (Issue #1366)', () => { + const composePath = path.join(rootDir, 'compose.yaml'); + + it('compose.yaml exists and is valid YAML', () => { + assert.ok(fs.existsSync(composePath), 'compose.yaml must exist'); + const content = fs.readFileSync(composePath, 'utf8'); + const doc = yaml.parse(content); + assert.ok(doc && typeof doc === 'object', 'compose.yaml must parse as an object'); + assert.ok(doc.services, 'compose.yaml must have services'); + }); + + it('backend service has develop.watch configured correctly', () => { + const content = fs.readFileSync(composePath, 'utf8'); + const doc = yaml.parse(content); + const backend = doc.services.backend; + assert.ok(backend, 'backend service must exist in compose.yaml'); + assert.ok(backend.develop, 'backend service must have develop block'); + assert.ok(Array.isArray(backend.develop.watch), 'backend.develop.watch must be an array'); + + const srcSync = backend.develop.watch.find( + (w) => w.action === 'sync' && w.path === './backend/src' + ); + assert.ok(srcSync, 'backend watch must include sync for ./backend/src'); + assert.strictEqual(srcSync.target, '/app/backend/src'); + + const pkgRebuild = backend.develop.watch.find( + (w) => w.action === 'rebuild' && w.path === './backend/package.json' + ); + assert.ok(pkgRebuild, 'backend watch must include rebuild for ./backend/package.json'); + }); + + it('frontend service has develop.watch and CHOKIDAR_USEPOLLING configured', () => { + const content = fs.readFileSync(composePath, 'utf8'); + const doc = yaml.parse(content); + const frontend = doc.services.frontend; + assert.ok(frontend, 'frontend service must exist in compose.yaml'); + assert.strictEqual( + frontend.environment?.CHOKIDAR_USEPOLLING, + 'true', + 'frontend must define CHOKIDAR_USEPOLLING: "true"' + ); + assert.ok(frontend.develop, 'frontend service must have develop block'); + assert.ok(Array.isArray(frontend.develop.watch), 'frontend.develop.watch must be an array'); + + const srcSync = frontend.develop.watch.find( + (w) => w.action === 'sync' && w.path === './frontend/src' + ); + assert.ok(srcSync, 'frontend watch must include sync for ./frontend/src'); + assert.strictEqual(srcSync.target, '/app/frontend/src'); + + const publicSync = frontend.develop.watch.find( + (w) => w.action === 'sync' && w.path === './frontend/public' + ); + assert.ok(publicSync, 'frontend watch must include sync for ./frontend/public'); + assert.strictEqual(publicSync.target, '/app/frontend/public'); + + const pkgRebuild = frontend.develop.watch.find( + (w) => w.action === 'rebuild' && w.path === './frontend/package.json' + ); + assert.ok(pkgRebuild, 'frontend watch must include rebuild for ./frontend/package.json'); + }); + + it('frontend/vite.config.js references CHOKIDAR_USEPOLLING', () => { + const viteConfigPath = path.join(rootDir, 'frontend', 'vite.config.js'); + assert.ok(fs.existsSync(viteConfigPath), 'vite.config.js must exist'); + const viteContent = fs.readFileSync(viteConfigPath, 'utf8'); + assert.ok( + viteContent.includes('CHOKIDAR_USEPOLLING'), + 'vite.config.js must check process.env.CHOKIDAR_USEPOLLING' + ); + }); +}); diff --git a/compose.yaml b/compose.yaml index e8abe300..d261f53c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -15,6 +15,13 @@ services: volumes: - ./:/app - backend_node_modules:/app/backend/node_modules + develop: + watch: + - action: sync + path: ./backend/src + target: /app/backend/src + - action: rebuild + path: ./backend/package.json # Blue environment — serves production traffic by default. # In a blue/green deployment this is the currently live backend. @@ -68,6 +75,7 @@ services: environment: VITE_API_URL: http://backend:3001 VITE_STELLAR_NETWORK: testnet + CHOKIDAR_USEPOLLING: 'true' depends_on: - backend ports: @@ -75,6 +83,16 @@ services: volumes: - ./:/app - frontend_node_modules:/app/frontend/node_modules + develop: + watch: + - action: sync + path: ./frontend/src + target: /app/frontend/src + - action: sync + path: ./frontend/public + target: /app/frontend/public + - action: rebuild + path: ./frontend/package.json redis: image: redis:7-alpine diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 6a682ff7..9d28084d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,5 +1,20 @@ # Development Guide +## Docker Compose File Watching & Auto-Reload + +For containerized development with automated file synchronization and Hot Module Replacement (HMR), +Trivela configures native Docker Compose file watching. See the full +[Docker Compose Watch Guide](./DOCKER_COMPOSE_WATCH.md) for details. + +Quick start: + +```bash +# Run watcher on active compose stack +npm run dev:watch +# Or with make +make dev-watch +``` + ## Running Without Contracts Both frontend and backend can run without deployed contracts. This allows development and testing diff --git a/docs/DOCKER_COMPOSE_WATCH.md b/docs/DOCKER_COMPOSE_WATCH.md new file mode 100644 index 00000000..d68c0f2a --- /dev/null +++ b/docs/DOCKER_COMPOSE_WATCH.md @@ -0,0 +1,130 @@ +# Docker Compose Auto-Reload File Watcher Guide + +## Overview + +Trivela supports native Docker Compose File Watching via `develop.watch` (Docker Compose v2.22.0+). +This allows developers to edit code locally and have changes synchronize instantly with running +containers, triggering Hot Module Replacement (HMR) in the Vite frontend and instant code execution +in the Node.js backend without having to restart or rebuild containers manually. + +--- + +## Architecture & Configuration + +The root [`compose.yaml`](../compose.yaml) declares watch configurations for both the `backend` and +`frontend` services: + +### 1. Backend Service (`backend`) + +```yaml +develop: + watch: + - action: sync + path: ./backend/src + target: /app/backend/src + - action: rebuild + path: ./backend/package.json +``` + +- **`sync`**: Any changes to files inside `./backend/src` are synchronized immediately into + `/app/backend/src`. The backend service uses `nodemon` or Node file watchers to reload affected + modules. +- **`rebuild`**: Any modification to `./backend/package.json` (such as installing a new dependency) + automatically triggers an image rebuild and container restart. + +### 2. Frontend Service (`frontend`) + +```yaml +environment: + VITE_API_URL: http://backend:3001 + VITE_STELLAR_NETWORK: testnet + CHOKIDAR_USEPOLLING: 'true' +develop: + watch: + - action: sync + path: ./frontend/src + target: /app/frontend/src + - action: sync + path: ./frontend/public + target: /app/frontend/public + - action: rebuild + path: ./frontend/package.json +``` + +- **`sync`**: Changes to `./frontend/src` and static assets in `./frontend/public` are mirrored into + the running container instantly. Vite's development server receives the event and sends Hot Module + Replacement (HMR) updates directly to connected browsers. +- **`rebuild`**: Changing `./frontend/package.json` prompts Docker Compose to rebuild the frontend + container image to guarantee consistent `node_modules`. +- **`CHOKIDAR_USEPOLLING="true"`**: Configured in `frontend/vite.config.js` + (`server.watch.usePolling`) to ensure file system notifications reliably cross hypervisor and + mount boundaries on Windows (Docker Desktop / WSL2) and macOS (VirtioFS / gRPC FUSE). + +--- + +## Usage + +### Prerequisites + +- Docker Desktop or Docker Engine with Docker Compose v2.22.0 or newer. + +### Starting Dev Environment with Watcher + +You can run the watch process using any of the following convenient entry points: + +1. **Using npm:** + + ```bash + npm run dev:watch + ``` + +2. **Using make:** + + ```bash + make dev-watch + ``` + +3. **Using Docker Compose CLI directly:** + + ```bash + # Run along with the default compose stack + docker compose watch + + # Or launch and watch simultaneously + docker compose up --watch + ``` + +--- + +## Cross-Platform File System Polling + +When running Docker on Windows (WSL2 or Hyper-V) or macOS, standard Linux inotify events inside the +container may occasionally be missed when files are edited on the host OS. + +Trivela handles this transparently: + +1. `compose.yaml` sets `CHOKIDAR_USEPOLLING: "true"` on the frontend service. +2. `frontend/vite.config.js` inspects `process.env.CHOKIDAR_USEPOLLING`: + ```javascript + server: { + port: 5173, + watch: { + usePolling: process.env.CHOKIDAR_USEPOLLING === 'true', + }, + // ... + } + ``` +3. This guarantees that file change events are captured reliably across all host operating systems + without impacting Linux host performance where polling is disabled by default. + +--- + +## Troubleshooting + +- **Container does not pick up dependencies:** If you add a package to `package.json`, compose watch + triggers a rebuild. If your lockfile (`package-lock.json`) was also updated, run + `docker compose up --build` to force a clean installation. +- **Port conflicts:** Ensure ports `3001` (backend), `5173` (frontend), and `6379` (redis) are free + on your host machine before starting. +- **Logs:** To view logs alongside the watcher, run `docker compose logs -f backend frontend` in a + separate terminal. diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8051e2ff..04574d63 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -94,6 +94,9 @@ export default defineConfig({ }, server: { port: 5173, + watch: { + usePolling: process.env.CHOKIDAR_USEPOLLING === 'true', + }, proxy: { '/api/v1': { target: 'http://localhost:3001', changeOrigin: true }, '/api': { target: 'http://localhost:3001', changeOrigin: true }, diff --git a/package.json b/package.json index d3087ea9..c3aa55c8 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "check:bundle": "node scripts/check-bundle-size.mjs", "dev:devnet": "docker compose --profile devnet up --build", "dev:devnet:down": "docker compose --profile devnet down -v", + "dev:watch": "docker compose watch", "codegen": "cd sdk/client && npm install && npm run generate", "codegen:check": "cd sdk/client && npm install && npm run check-drift" },