Add live reload on changes
This commit is contained in:
12
.env.example
12
.env.example
@@ -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
33
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
45
server/src/live-reload.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user