Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
121 changes: 121 additions & 0 deletions backend/scripts/dev-watch.js
Original file line number Diff line number Diff line change
@@ -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);
});
}
}
61 changes: 61 additions & 0 deletions backend/scripts/dev-watch.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
8 changes: 6 additions & 2 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -68,6 +69,7 @@ services:
environment:
VITE_API_URL: http://backend:3001
VITE_STELLAR_NETWORK: testnet
VITE_USE_POLLING: 'true'
depends_on:
- backend
ports:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
3 changes: 3 additions & 0 deletions frontend/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down