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, /