From 9fd85761e13832565aefad9469de1e0c55ec711b Mon Sep 17 00:00:00 2001 From: Framma Williansyah Akbar Date: Wed, 30 Sep 2026 11:37:10 +0700 Subject: [PATCH] feat(infra): Docker Compose dev environment auto-reload file watcher (#1366) --- backend/package.json | 2 + backend/scripts/dev-watch.js | 121 ++++++++++++++++++++++++++++++ backend/scripts/dev-watch.test.js | 61 +++++++++++++++ compose.yaml | 8 +- docs/DEVELOPMENT.md | 10 +++ frontend/vite.config.js | 3 + 6 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 backend/scripts/dev-watch.js create mode 100644 backend/scripts/dev-watch.test.js diff --git a/backend/package.json b/backend/package.json index b44d1840..b694d005 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,8 +6,10 @@ "type": "module", "scripts": { "dev": "node --watch src/server.js", + "dev:docker": "node scripts/dev-watch.js", "start": "node src/server.js", "test": "node --test src/**/*.test.js", + "test:scripts": "node --test scripts/*.test.js", "test:integration": "node --test src/integration/*.test.js", "test:contract": "node --test src/integration/openapi-contract.test.js", "test:snapshots": "node --test src/tests/api-snapshot.test.js", diff --git a/backend/scripts/dev-watch.js b/backend/scripts/dev-watch.js new file mode 100644 index 00000000..e3068d9d --- /dev/null +++ b/backend/scripts/dev-watch.js @@ -0,0 +1,121 @@ +#!/usr/bin/env node +/** + * Polling file watcher that restarts the backend on source changes. + * + * `node --watch` relies on inotify/FSEvents, which are unreliable on Docker + * bind mounts (Docker Desktop on macOS/Windows, network filesystems). This + * watcher stats files on an interval so hot reload works everywhere. + * + * Env: WATCH_POLL_INTERVAL_MS (default 500, min 50). + */ +import { spawn } from 'node:child_process'; +import { readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const DEFAULT_EXTENSIONS = ['.js', '.mjs', '.cjs', '.json']; +const IGNORED_DIRS = new Set(['node_modules', '.git', 'uploads']); + +export function parseInterval(value, fallback = 500) { + const n = Number.parseInt(value, 10); + if (!Number.isFinite(n) || n <= 0) return fallback; + return Math.max(n, 50); +} + +/** Map of absolute file path -> mtimeMs:size for watched files under `dir`. */ +export function snapshot(dir, extensions = DEFAULT_EXTENSIONS, out = new Map()) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!IGNORED_DIRS.has(entry.name)) snapshot(full, extensions, out); + } else if (extensions.includes(path.extname(entry.name))) { + try { + const st = statSync(full); + out.set(full, `${st.mtimeMs}:${st.size}`); + } catch { + // File vanished between readdir and stat; next poll will reflect it. + } + } + } + return out; +} + +/** Returns the list of added, removed and modified paths between snapshots. */ +export function diffSnapshots(prev, next) { + const changed = []; + for (const [file, sig] of next) { + if (prev.get(file) !== sig) changed.push(file); + } + for (const file of prev.keys()) { + if (!next.has(file)) changed.push(file); + } + return changed.sort(); +} + +export function startWatcher({ + dirs, + command = process.execPath, + args, + intervalMs = 500, + log = console.log, +}) { + let child; + let prev = new Map(); + for (const d of dirs) snapshot(d, DEFAULT_EXTENSIONS, prev); + + const start = () => { + child = spawn(command, args, { stdio: 'inherit' }); + child.on('exit', (code, signal) => { + if (signal !== 'SIGTERM') log(`[dev-watch] process exited (${code ?? signal}); waiting for changes`); + }); + }; + const restart = () => { + const old = child; + if (old && old.exitCode === null && old.signalCode === null) { + old.once('exit', start); + old.kill('SIGTERM'); + } else { + start(); + } + }; + + start(); + const timer = setInterval(() => { + const next = new Map(); + for (const d of dirs) snapshot(d, DEFAULT_EXTENSIONS, next); + const changed = diffSnapshots(prev, next); + prev = next; + if (changed.length > 0) { + log(`[dev-watch] change detected (${changed.length} file(s)), restarting`); + restart(); + } + }, intervalMs); + + return { + stop() { + clearInterval(timer); + child?.kill('SIGTERM'); + }, + }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const watcher = startWatcher({ + dirs: [path.join(root, 'src')], + args: [path.join(root, 'src/server.js')], + intervalMs: parseInterval(process.env.WATCH_POLL_INTERVAL_MS), + }); + for (const sig of ['SIGINT', 'SIGTERM']) { + process.on(sig, () => { + watcher.stop(); + process.exit(0); + }); + } +} diff --git a/backend/scripts/dev-watch.test.js b/backend/scripts/dev-watch.test.js new file mode 100644 index 00000000..c82e3fcf --- /dev/null +++ b/backend/scripts/dev-watch.test.js @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { diffSnapshots, parseInterval, snapshot } from './dev-watch.js'; + +function fixture() { + const dir = mkdtempSync(path.join(tmpdir(), 'devwatch-')); + mkdirSync(path.join(dir, 'node_modules')); + writeFileSync(path.join(dir, 'a.js'), 'a'); + writeFileSync(path.join(dir, 'notes.md'), 'x'); + writeFileSync(path.join(dir, 'node_modules', 'dep.js'), 'x'); + return dir; +} + +test('snapshot only tracks watched extensions and skips node_modules', () => { + const dir = fixture(); + try { + const files = [...snapshot(dir).keys()].map((f) => path.basename(f)); + assert.deepEqual(files, ['a.js']); + } finally { + rmSync(dir, { recursive: true }); + } +}); + +test('snapshot of a missing directory is empty', () => { + assert.equal(snapshot('/nonexistent-dir-xyz').size, 0); +}); + +test('diffSnapshots detects modified, added and removed files', () => { + const dir = fixture(); + try { + const before = snapshot(dir); + assert.deepEqual(diffSnapshots(before, snapshot(dir)), []); + + utimesSync(path.join(dir, 'a.js'), new Date(), new Date(Date.now() + 5000)); + writeFileSync(path.join(dir, 'b.js'), 'b'); + const after = snapshot(dir); + assert.deepEqual( + diffSnapshots(before, after).map((f) => path.basename(f)), + ['a.js', 'b.js'], + ); + + rmSync(path.join(dir, 'b.js')); + assert.deepEqual( + diffSnapshots(after, snapshot(dir)).map((f) => path.basename(f)), + ['a.js', 'b.js'].slice(1), + ); + } finally { + rmSync(dir, { recursive: true }); + } +}); + +test('parseInterval validates and clamps input', () => { + assert.equal(parseInterval(undefined), 500); + assert.equal(parseInterval('abc'), 500); + assert.equal(parseInterval('-5'), 500); + assert.equal(parseInterval('10'), 50); + assert.equal(parseInterval('1000'), 1000); +}); diff --git a/compose.yaml b/compose.yaml index e8abe300..82be4f35 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,12 +4,13 @@ services: backend: image: node:20-alpine working_dir: /app/backend - command: sh -c "npm install && npm run dev" + command: sh -c "npm install && npm run dev:docker" environment: PORT: 3001 CORS_ALLOWED_ORIGINS: http://localhost:5173 STELLAR_NETWORK: testnet DEPLOY_STRATEGY: blue-green + WATCH_POLL_INTERVAL_MS: ${WATCH_POLL_INTERVAL_MS:-500} ports: - '3001:3001' volumes: @@ -68,6 +69,7 @@ services: environment: VITE_API_URL: http://backend:3001 VITE_STELLAR_NETWORK: testnet + VITE_USE_POLLING: 'true' depends_on: - backend ports: @@ -145,13 +147,14 @@ services: image: node:20-alpine profiles: ['devnet'] working_dir: /app/backend - command: sh -c "npm install && npm run dev" + command: sh -c "npm install && npm run dev:docker" environment: PORT: 3001 STELLAR_NETWORK: local SOROBAN_RPC_URL: http://stellar-local:8001 HORIZON_URL: http://stellar-local:8000 CORS_ALLOWED_ORIGINS: http://localhost:5173 + WATCH_POLL_INTERVAL_MS: ${WATCH_POLL_INTERVAL_MS:-500} ports: - '3001:3001' volumes: @@ -169,6 +172,7 @@ services: environment: VITE_STELLAR_NETWORK: local VITE_SOROBAN_RPC_URL: http://localhost:8001 + VITE_USE_POLLING: 'true' ports: - '5173:5173' volumes: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 6a682ff7..1b3d4e68 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -129,3 +129,13 @@ k6 prints a summary block after each run. The two gating metrics: A non-zero exit means a threshold was breached. The CI workflow (`.github/workflows/load-test.yml`) is `workflow_dispatch`-only — run it from the Actions tab against staging when you need a higher-fidelity report than your local machine can produce. + +## Docker Compose Hot Reload + +`docker compose up backend frontend` bind-mounts the repo into the containers and reloads on save: + +- **Backend** runs `npm run dev:docker` (`backend/scripts/dev-watch.js`), a polling watcher that restarts the server when `.js/.mjs/.cjs/.json` files under `backend/src` change. Polling is used because inotify events are unreliable on Docker bind mounts (macOS/Windows). Tune with `WATCH_POLL_INTERVAL_MS` (default `500`, minimum `50`). +- **Frontend** runs Vite with `VITE_USE_POLLING=true`, enabling polling-based HMR. +- The `devnet` profile services use the same setup. + +Test the watcher with `npm run test:scripts --workspace=backend`. diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8051e2ff..67bfe1d7 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -94,6 +94,9 @@ export default defineConfig({ }, server: { port: 5173, + // File events do not propagate through Docker bind mounts on all hosts; + // compose.yaml sets VITE_USE_POLLING so HMR still picks up edits. + watch: process.env.VITE_USE_POLLING === 'true' ? { usePolling: true, interval: 300 } : undefined, proxy: { '/api/v1': { target: 'http://localhost:3001', changeOrigin: true }, '/api': { target: 'http://localhost:3001', changeOrigin: true },