Add live reload on changes

This commit is contained in:
khalid@traclabs.com
2026-04-23 08:04:34 -05:00
parent 233fb6d003
commit 36fadf710d
9 changed files with 153 additions and 3 deletions

View File

@@ -13,6 +13,18 @@ IDEMPOTENCY_DB_PATH=./data/dynamic-sites.db
# SSR cache
SITE_DATA_TTL_MS=500
# Browser live reload (optional)
# When enabled, the orchestrator (port 3001) hosts a websocket endpoint that broadcasts
# a reload event whenever content is written. Pages that opt-in will reload on message.
LIVE_RELOAD_WS_ENABLED=false
LIVE_RELOAD_WS_PATH=/__live_reload
# These are read by the Astro app (public envs are embedded client-side)
PUBLIC_LIVE_RELOAD_WS_ENABLED=false
PUBLIC_LIVE_RELOAD_WS_PATH=/__live_reload
# Optional override for deployments behind a reverse proxy:
# PUBLIC_LIVE_RELOAD_WS_URL=wss://your-domain.example/__live_reload
PUBLIC_LIVE_RELOAD_WS_URL=
# SMS (Vonage)
VONAGE_API_KEY=your_vonage_api_key
VONAGE_API_SECRET=your_vonage_api_secret

33
package-lock.json generated
View File

@@ -4850,6 +4850,16 @@
"integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz",
@@ -14658,6 +14668,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/wsl-utils": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
@@ -14853,6 +14884,7 @@
"express-rate-limit": "^7.5.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"ws": "^8.20.0",
"zod": "^3.24.0"
},
"devDependencies": {
@@ -14860,6 +14892,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^5.0.2",
"@types/jsonwebtoken": "^9.0.7",
"@types/ws": "^8.18.1",
"tsx": "^4.19.0",
"typescript": "^5.8.0"
}

View File

@@ -19,6 +19,7 @@
"express-rate-limit": "^7.5.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"ws": "^8.20.0",
"zod": "^3.24.0"
},
"devDependencies": {
@@ -26,6 +27,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^5.0.2",
"@types/jsonwebtoken": "^9.0.7",
"@types/ws": "^8.18.1",
"tsx": "^4.19.0",
"typescript": "^5.8.0"
}

View File

@@ -3,6 +3,7 @@ import { createEditQueue } from './queue/edit-queue.js';
import { processEditJob } from './queue/process-edit-job.js';
import { openDb, closeDb, pruneExpiredProposals, pruneIdempotencyKeys } from './db.js';
import { logger } from './logger.js';
import { initLiveReload } from './live-reload.js';
const PORT = parseInt(process.env.ORCHESTRATOR_PORT || '3001', 10);
@@ -27,6 +28,9 @@ export async function startServer() {
logger.info({ event: 'server.started', port: PORT }, `Orchestrator listening on port ${PORT}`);
});
// Optional websocket for browser reload on content writes
initLiveReload(server);
// Graceful shutdown
let shuttingDown = false;
async function shutdown(signal: string) {

View File

@@ -4,6 +4,7 @@ import crypto from 'node:crypto';
import { stringifyCanonical } from '@dynamic-sites/shared';
import { writeAuditLog } from '../db.js';
import { logger } from '../logger.js';
import { broadcastReload } from '../live-reload.js';
const REPO_ROOT = process.env.REPO_ROOT || '.';
const MAX_BACKUPS = 20;
@@ -70,4 +71,13 @@ export function writeContentFile(
});
logger.info({ event: 'content.written', path: repoRelativePath, size: canonical.length }, 'Content file written');
// Notify any connected browsers to reload.
broadcastReload('content.written', {
path: repoRelativePath,
proposalId: opts?.proposalId,
source: opts?.source,
beforeHash,
afterHash,
});
}

45
server/src/live-reload.ts Normal file
View File

@@ -0,0 +1,45 @@
import type http from 'node:http';
import { WebSocketServer, type WebSocket } from 'ws';
import { logger } from './logger.js';
let wss: WebSocketServer | null = null;
export function initLiveReload(server: http.Server): void {
if (wss) return;
const enabled = process.env.LIVE_RELOAD_WS_ENABLED === 'true';
if (!enabled) {
logger.info({ event: 'live_reload.disabled' }, 'Live reload websocket disabled');
return;
}
const wsPath = process.env.LIVE_RELOAD_WS_PATH || '/__live_reload';
wss = new WebSocketServer({ server, path: wsPath });
wss.on('connection', (socket: WebSocket) => {
logger.info({ event: 'live_reload.connected' }, 'Live reload client connected');
socket.on('close', () => {
logger.info({ event: 'live_reload.disconnected' }, 'Live reload client disconnected');
});
});
logger.info({ event: 'live_reload.started', path: wsPath }, 'Live reload websocket started');
}
export function broadcastReload(reason: string, data?: Record<string, unknown>): void {
if (!wss) return;
const payload = JSON.stringify({
type: 'reload',
ts: Date.now(),
reason,
...data,
});
for (const client of wss.clients) {
if (client.readyState === client.OPEN) {
client.send(payload);
}
}
}

View File

@@ -145,7 +145,12 @@ Example response:
},
];
return generateWithValidation({ messages, schema: routingOutputSchema, chat });
const routed = await generateWithValidation({ messages, schema: routingOutputSchema, chat });
// Some TS setups infer optional fields from the Zod schema; normalize to our contract type.
return {
...routed,
needs_clarification: routed.needs_clarification ?? false,
} satisfies RoutingOutput;
}
// ── Message Classification ──

View File

@@ -192,7 +192,8 @@ export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
// GET /api/job/:id — poll for a proposal created by an async job
router.get('/job/:id', (req: Request, res: Response) => {
const entry = jobProposalMap.get(req.params.id);
const jobId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const entry = jobProposalMap.get(jobId);
if (!entry) {
res.json({ status: 'processing' });
return;
@@ -214,7 +215,8 @@ export function createApiEditRouter(deps: ApiEditRouterDeps): Router {
// GET /api/proposal/:id — check proposal status
router.get('/proposal/:id', (req: Request, res: Response) => {
const proposal = getProposal(req.params.id);
const proposalId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const proposal = getProposal(proposalId);
if (!proposal) {
res.status(404).json({ error: 'Proposal not found' });
return;

View File

@@ -5,6 +5,10 @@ interface Props {
}
const { title, primaryColor = '#2d5016' } = Astro.props;
const liveReloadEnabled = import.meta.env.PUBLIC_LIVE_RELOAD_WS_ENABLED === 'true';
const wsPath = import.meta.env.PUBLIC_LIVE_RELOAD_WS_PATH || '/__live_reload';
const wsUrl = import.meta.env.PUBLIC_LIVE_RELOAD_WS_URL || '';
---
<!doctype html>
<html lang="en">
@@ -113,5 +117,38 @@ const { title, primaryColor = '#2d5016' } = Astro.props;
<slot name="footer">© {new Date().getFullYear()}</slot>
</div>
</footer>
{liveReloadEnabled && (
<script define:vars={{ wsPath, wsUrl }}>
(() => {
const configuredUrl = (typeof wsUrl === 'string' ? wsUrl : '').trim();
const path = (typeof wsPath === 'string' ? wsPath : '/__live_reload').trim() || '/__live_reload';
const defaultPort = '3001';
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.hostname;
const url = configuredUrl || `${proto}//${host}:${defaultPort}${path.startsWith('/') ? '' : '/'}${path}`;
function connect() {
const ws = new WebSocket(url);
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(String(ev.data || ''));
if (msg && msg.type === 'reload') window.location.reload();
} catch {
// ignore
}
};
ws.onclose = () => setTimeout(connect, 1000);
ws.onerror = () => {
try { ws.close(); } catch { /* ignore */ }
};
}
connect();
})();
</script>
)}
</body>
</html>