From 98621b7cbd585f267203cf463c05a7f5c70b5b5b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 04:45:04 +0000 Subject: [PATCH] feat(dev): live reload with an error overlay (--no-live-reload to opt out) HTML responses from kody dev get a small module script that listens on /__kody/dev/events (SSE). The page reloads after each swap to a new build; build errors and runtime errors from the page's fetches show an overlay, and the error pages reload themselves once the next build is good. Compressed HTML and kody dev fetch responses are untouched. No HMR yet. --- README.md | 9 +- src/cli.ts | 2 + src/dev-command.ts | 3 + src/dev/live-reload.ts | 163 +++++++++++++++++++++++++++++++++++ src/help.ts | 6 +- src/package-app-dev.ts | 77 ++++++++++++++++- test/dev-live-reload.test.ts | 135 +++++++++++++++++++++++++++++ test/dev-server-harness.ts | 95 ++++++++++++++++++++ test/dev-server.test.ts | 89 +------------------ 9 files changed, 488 insertions(+), 91 deletions(-) create mode 100644 src/dev/live-reload.ts create mode 100644 test/dev-live-reload.test.ts create mode 100644 test/dev-server-harness.ts diff --git a/README.md b/README.md index 357bf3b..ea443d7 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ wipes the mock session's scratch store. Share previews are out of scope; ```bash cd ./my-package -kody dev # serve kody.app.entry; hot reload; --target mock +kody dev # serve kody.app.entry; live reload; --target mock kody dev fetch /health # agent-friendly HTTP probe kody dev job heartbeat # run a declared job once (never schedules locally) kody dev webhook ping --body ./fixture.json @@ -261,6 +261,13 @@ kody search --target mock local - **Logs**: app `console.*` output streams to the terminal tagged with the request, e.g. `[GET /scores #12] saved 3 rows`. `warn` and `error` go to stderr. +- **Live reload** (on by default; `--no-live-reload` turns it off): HTML + responses get a small `` + +const keepAliveMs = 15_000 + +export type LiveReloadMessage = + | { event: 'reload'; data: { buildId: number } } + | { event: 'build_error'; data: DevErrorView } + | { event: 'runtime_error'; data: DevErrorView } + +export type LiveReloadHub = { + /** Serve the SSE stream; sends `hello` with the current build first. */ + handleEvents: (req: IncomingMessage, res: ServerResponse) => void + broadcast: (message: LiveReloadMessage) => void + clientCount: () => number + close: () => void +} + +export function createLiveReloadHub(input: { + state: () => { buildId: number | null; error: DevErrorView | null } +}): LiveReloadHub { + const clients = new Set() + const send = (res: ServerResponse, event: string, data: unknown) => { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + } + const keepAlive = setInterval(() => { + for (const res of clients) res.write(': keep-alive\n\n') + }, keepAliveMs) + keepAlive.unref() + return { + handleEvents: (req, res) => { + res.writeHead(200, { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-store', + connection: 'keep-alive', + }) + res.write('retry: 500\n\n') + send(res, 'hello', input.state()) + clients.add(res) + req.on('close', () => clients.delete(res)) + }, + broadcast: (message) => { + for (const res of clients) send(res, message.event, message.data) + }, + clientCount: () => clients.size, + close: () => { + clearInterval(keepAlive) + for (const res of clients) res.end() + clients.clear() + }, + } +} + +/** Insert the client tag before ``, else ``, else at the end. */ +export function injectLiveReloadScript(html: string): string { + if (html.includes(liveReloadClientPath)) return html + for (const pattern of [/<\/head\s*>/i, /<\/body\s*>/i]) { + const match = pattern.exec(html) + if (match) { + return `${html.slice(0, match.index)}${liveReloadScriptTag}${html.slice(match.index)}` + } + } + return `${html}${liveReloadScriptTag}` +} + +export function shouldInjectLiveReload(input: { + method: string | undefined + status: number + headers: Record | number | undefined> +}): boolean { + if (input.method === 'HEAD') return false + if (input.status === 204 || input.status === 304 || input.status < 200) return false + const contentType = String(input.headers['content-type'] ?? '') + if (!/^\s*text\/html\b/i.test(contentType)) return false + const encoding = String(input.headers['content-encoding'] ?? 'identity').trim().toLowerCase() + return encoding === '' || encoding === 'identity' +} + +export const liveReloadClientSource = ` +const eventsUrl = ${JSON.stringify(liveReloadEventsPath)}; +let buildId = null; +let overlay = null; + +function hideOverlay() { + overlay?.remove(); + overlay = null; +} + +function showOverlay(view) { + if (globalThis.__kodyDevErrorPage) return; + hideOverlay(); + const host = document.createElement('kody-dev-overlay'); + const root = host.attachShadow({ mode: 'open' }); + const label = view.kind === 'build' ? 'Build error' : 'Runtime error'; + root.innerHTML = \` + +
+
+
+

+

+

+		

Fix it and save. The page reloads on the next good build. Esc dismisses.

+
+
\`; + root.querySelector('.label').textContent = label; + root.querySelector('h1').textContent = view.title; + const request = root.querySelector('.request'); + if (view.request) request.textContent = view.request; + else request.remove(); + root.querySelector('pre').textContent = view.detail; + root.querySelector('button').addEventListener('click', hideOverlay); + document.documentElement.appendChild(host); + overlay = host; + root.querySelector('button').focus(); +} + +addEventListener('keydown', (event) => { + if (event.key === 'Escape') hideOverlay(); +}); + +function connect() { + const source = new EventSource(eventsUrl); + source.addEventListener('hello', (event) => { + const state = JSON.parse(event.data); + if (buildId !== null && state.buildId !== null && state.buildId !== buildId) { + location.reload(); + return; + } + buildId = state.buildId; + if (state.error) showOverlay(state.error); + }); + source.addEventListener('reload', () => location.reload()); + source.addEventListener('build_error', (event) => showOverlay(JSON.parse(event.data))); + source.addEventListener('runtime_error', (event) => showOverlay(JSON.parse(event.data))); +} + +connect(); +`.trimStart() diff --git a/src/help.ts b/src/help.ts index 1e17192..b13aa4d 100644 --- a/src/help.ts +++ b/src/help.ts @@ -67,7 +67,7 @@ Usage: kody package revoke (--team | --user | --grant ) [--org ] [--json] kody execute [--org ] [--invoke | --code | --file ] [--params ] [--conversation-id ] [--json] [--credential ]… [--target prod|mock] [--token ] [--api-url ] [--local] [--allow-private-network] - kody dev [serve] [--org ] [--target prod|mock] [--reset-storage] [--port ] [--app-base-path ] [--allow-private-network] + kody dev [serve] [--org ] [--target prod|mock] [--reset-storage] [--port ] [--app-base-path ] [--allow-private-network] [--no-live-reload] kody dev fetch [--method ] [--body ] [--target prod|mock] [--json] kody dev job [--target prod|mock] [--dry-run] [--json] kody dev webhook --body [--target prod|mock] [--dry-run] [--json] @@ -287,7 +287,9 @@ ${orgBlock} wait; no 503 or stale bundle). Build errors (file:line:col + code frame) and uncaught handler errors (stack mapped to your .ts sources) are served as dev-only 500 pages until fixed. App - console.* streams to the terminal tagged by request. + console.* streams to the terminal tagged by request. HTML pages + live-reload after each rebuild and show an error overlay + (SSE at /__kody/dev/events; \`--no-live-reload\` disables). Bundles with the same esbuild path as publish (\`local-bundle\`). Four surfaces: exports (\`execute --invoke ./export\` or \`--file\` against the local package), webhooks diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index 78b4a0f..d66ca1a 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -32,6 +32,15 @@ import { parseWorkerdStdoutLine, type DevEvent, type DevRequestTag } from './dev import { devErrorHeader, devRequestIdHeader } from './dev/dev-protocol.js' import { createDevWatcher, type DevWatcher } from './dev/dev-watcher.js' import { sendDevError, type DevErrorView } from './dev/error-page.js' +import { + createLiveReloadHub, + injectLiveReloadScript, + liveReloadClientPath, + liveReloadClientSource, + liveReloadEventsPath, + liveReloadScriptTag, + shouldInjectLiveReload, +} from './dev/live-reload.js' import { devWorkDirPrefix, startProcessReaper, @@ -110,6 +119,11 @@ export type PackageAppDevOptions = { watch?: boolean /** Quiet period after the last file event before rebuilding. */ debounceMs?: number + /** + * Inject the live-reload client into HTML responses and serve its SSE + * stream (default: on whenever `watch` is on). + */ + liveReload?: boolean onStatus?: (message: string) => void onError?: (message: string) => void /** Rebuilds, build/runtime errors, and app logs. */ @@ -168,6 +182,10 @@ export async function startPackageAppDev( const root = options.manifest.root const runSecret = randomBytes(32).toString('hex') const emit = (event: DevEvent) => options.onEvent?.(event) + const liveReload = options.liveReload ?? options.watch !== false + const errorPageTail = liveReload + ? `${liveReloadScriptTag}` + : '' void sweepStaleDevDirs().catch(() => {}) const workDir = await mkdtemp(join(tmpdir(), devWorkDirPrefix)) const reaper = await startProcessReaper({ workDir }) @@ -193,6 +211,12 @@ export async function startPackageAppDev( let buildLoop: Promise | null = null let rebuildAgain = false const changedFiles = new Set() + const liveReloadHub = createLiveReloadHub({ + state: () => ({ + buildId: active?.id ?? null, + error: buildFailure ? buildErrorView(buildFailure) : null, + }), + }) let markStarted: () => void = () => {} const started = new Promise((resolve) => { markStarted = resolve @@ -302,6 +326,7 @@ export async function startPackageAppDev( buildFailure = null if (previous) retireGeneration(previous) emit({ type: 'rebuild_ok', ms: elapsed(started), buildId: generation.id, restarted: true }) + liveReloadHub.broadcast({ event: 'reload', data: { buildId: generation.id } }) } catch (error) { buildFailure = toBuildFailure(error) const [first] = buildFailure.diagnostics @@ -314,6 +339,7 @@ export async function startPackageAppDev( col: first?.col ?? null, diagnostics: buildFailure.diagnostics, }) + liveReloadHub.broadcast({ event: 'build_error', data: buildErrorView(buildFailure) }) } } @@ -423,11 +449,32 @@ export async function startPackageAppDev( ]) } + function handleDevRoute(req: IncomingMessage, res: ServerResponse): boolean { + const pathname = stripMount(new URL(req.url ?? '/', hostedUrl).pathname, appBasePath) + if (!liveReload) return false + if (pathname === liveReloadEventsPath) { + liveReloadHub.handleEvents(req, res) + return true + } + if (pathname === liveReloadClientPath) { + res.writeHead(200, { + 'content-type': 'text/javascript; charset=utf-8', + 'cache-control': 'no-store', + }) + res.end(liveReloadClientSource) + return true + } + return false + } + async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise { try { + if (handleDevRoute(req, res)) return await waitForBuild() if (buildFailure) { - sendDevError(req, res, 500, buildErrorView(buildFailure)) + sendDevError(req, res, 500, buildErrorView(buildFailure), { + htmlTail: errorPageTail, + }) return } const generation = active @@ -503,6 +550,27 @@ export async function startPackageAppDev( request: tag, }) } + if ( + liveReload && + shouldInjectLiveReload({ + method: req.method, + status, + headers: upstreamRes.headers, + }) + ) { + void readStream(upstreamRes) + .then((html) => { + const body = Buffer.from(injectLiveReloadScript(html), 'utf8') + const headers = { ...upstreamRes.headers } + delete headers['transfer-encoding'] + headers['content-length'] = String(body.length) + res.writeHead(status, headers) + res.end(body) + resolve() + }) + .catch(reject) + return + } res.writeHead(status, upstreamRes.headers) upstreamRes.pipe(res) upstreamRes.on('end', () => resolve()) @@ -536,12 +604,14 @@ export async function startPackageAppDev( } const mapped = redact(generation.mapStack(stack)) emit({ type: 'runtime_error', message: redact(message), stack: mapped, request: tag }) - sendDevError(req, res, 500, { + const view: DevErrorView = { kind: 'runtime', title: redact(message), detail: mapped, request: `${tag.method} ${tag.path}`, - }) + } + liveReloadHub.broadcast({ event: 'runtime_error', data: view }) + sendDevError(req, res, 500, view, { htmlTail: errorPageTail }) } const watcher: DevWatcher | null = @@ -575,6 +645,7 @@ export async function startPackageAppDev( await Promise.all([...generations].map((generation) => stopGeneration(generation))) reaper.close() await bridge.close() + liveReloadHub.close() proxy.closeAllConnections() await new Promise((resolve) => { proxy.close(() => resolve()) diff --git a/test/dev-live-reload.test.ts b/test/dev-live-reload.test.ts new file mode 100644 index 0000000..e29e5dc --- /dev/null +++ b/test/dev-live-reload.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict' +import { request } from 'node:http' +import { test } from 'node:test' +import { + injectLiveReloadScript, + liveReloadScriptTag, + shouldInjectLiveReload, +} from '../src/dev/live-reload.js' +import { waitFor, withDevServer } from './dev-server-harness.js' + +test('injectLiveReloadScript prefers , then , then appends once', () => { + assert.equal( + injectLiveReloadScript('x'), + `x${liveReloadScriptTag}`, + ) + assert.equal( + injectLiveReloadScript('hi'), + `hi${liveReloadScriptTag}`, + ) + assert.equal(injectLiveReloadScript('

hi

'), `

hi

${liveReloadScriptTag}`) + const once = injectLiveReloadScript('

hi

') + assert.equal(injectLiveReloadScript(once), once) +}) + +test('shouldInjectLiveReload only touches uncompressed HTML bodies', () => { + const html = { 'content-type': 'text/html; charset=utf-8' } + assert.equal(shouldInjectLiveReload({ method: 'GET', status: 200, headers: html }), true) + assert.equal(shouldInjectLiveReload({ method: 'HEAD', status: 200, headers: html }), false) + assert.equal(shouldInjectLiveReload({ method: 'GET', status: 304, headers: html }), false) + assert.equal( + shouldInjectLiveReload({ + method: 'GET', + status: 200, + headers: { ...html, 'content-encoding': 'gzip' }, + }), + false, + ) + assert.equal( + shouldInjectLiveReload({ + method: 'GET', + status: 200, + headers: { 'content-type': 'application/json' }, + }), + false, + ) +}) + +type SseEvent = { event: string; data: unknown } + +function openEventStream(url: string): { + events: Array + close: () => void +} { + const events: Array = [] + const req = request(url, { headers: { accept: 'text/event-stream' } }, (res) => { + let buffer = '' + res.setEncoding('utf8') + res.on('data', (chunk: string) => { + buffer += chunk + const blocks = buffer.split('\n\n') + buffer = blocks.pop() ?? '' + for (const block of blocks) { + let event = 'message' + let data = '' + for (const line of block.split('\n')) { + if (line.startsWith('event: ')) event = line.slice(7) + if (line.startsWith('data: ')) data += line.slice(6) + } + if (data) events.push({ event, data: JSON.parse(data) }) + } + }) + res.on('error', () => {}) + }) + req.on('error', () => {}) + req.end() + return { events, close: () => req.destroy() } +} + +test('kody dev injects the live-reload client and pushes reload / build_error over SSE', async () => { + await withDevServer(async ({ server, writeMessage }) => { + const page = await server.fetchPath('/') + assert.equal(page.status, 200) + assert.match(page.body, /