diff --git a/.env.example b/.env.example index 10a104f..19b1fc6 100644 --- a/.env.example +++ b/.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 diff --git a/package-lock.json b/package-lock.json index 95ab658..18bd6c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/server/package.json b/server/package.json index fc938fd..1bb5d38 100644 --- a/server/package.json +++ b/server/package.json @@ -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" } diff --git a/server/src/index.ts b/server/src/index.ts index 8ad5104..81155ac 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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) { diff --git a/server/src/io/write-content.ts b/server/src/io/write-content.ts index 3396d8d..443ccd1 100644 --- a/server/src/io/write-content.ts +++ b/server/src/io/write-content.ts @@ -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, + }); } diff --git a/server/src/live-reload.ts b/server/src/live-reload.ts new file mode 100644 index 0000000..755ddd4 --- /dev/null +++ b/server/src/live-reload.ts @@ -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): 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); + } + } +} + diff --git a/server/src/llm/client.ts b/server/src/llm/client.ts index 459a1b5..d162a18 100644 --- a/server/src/llm/client.ts +++ b/server/src/llm/client.ts @@ -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 ── diff --git a/server/src/routes/api-edit.ts b/server/src/routes/api-edit.ts index c32ff45..aa96925 100644 --- a/server/src/routes/api-edit.ts +++ b/server/src/routes/api-edit.ts @@ -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; diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 2d6893d..252669a 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -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 || ''; --- @@ -113,5 +117,38 @@ const { title, primaryColor = '#2d5016' } = Astro.props; © {new Date().getFullYear()} + + {liveReloadEnabled && ( + + )}