Skip to content
This repository was archived by the owner on Oct 16, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<script>` that listens on
`/__kody/dev/events` (server-sent events). The page reloads after each swap
to a new build. Build errors, and runtime errors from the page's own
`fetch` calls, show an overlay; the page reloads on the next good build.
There is no HMR. Compressed HTML and `kody dev fetch` responses are left
untouched.
- **Cleanup**: Ctrl-C, `SIGTERM`, and `SIGHUP` stop workerd and remove the
`kody-dev-*` temp dir. If the CLI is killed outright, a small reaper process
does the same. Each start also sweeps dirs and orphaned workerd processes
Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ function parseKnown(args: Array<string>) {
passthrough: { type: 'string' },
port: { type: 'string' },
'app-base-path': { type: 'string' },
'no-live-reload': { type: 'boolean' },
method: { type: 'string' },
body: { type: 'string' },
clients: { type: 'string' },
Expand Down Expand Up @@ -1225,6 +1226,7 @@ async function dispatch(
port,
appBasePath,
allowPrivateNetwork,
liveReload: parsed.values['no-live-reload'] !== true,
io,
})
case 'fetch': {
Expand Down
3 changes: 3 additions & 0 deletions src/dev-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ export async function runDevServe(input: {
appBasePath?: string
allowPrivateNetwork?: boolean
passthroughHosts?: ReadonlyArray<string>
/** Inject the live-reload client into HTML responses (default true). */
liveReload?: boolean
cwd?: string
io: DevIo
/** Injected for tests — when set, serve returns immediately after start. */
Expand All @@ -130,6 +132,7 @@ export async function runDevServe(input: {
? { passthroughHosts: input.auth.passthroughHosts }
: {}),
allowPrivateNetwork: input.allowPrivateNetwork,
liveReload: input.liveReload ?? true,
onStatus: (message) => input.io.stderr(`${message}\n`),
onError: (message) => input.io.stderr(`${message}\n`),
onEvent: (event) => writeDevEvent(input.io, event),
Expand Down
163 changes: 163 additions & 0 deletions src/dev/live-reload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* Live reload for `kody dev`: an SSE stream at {@link liveReloadEventsPath}, a
* tiny client injected into HTML responses that reloads after each swap, and
* an error overlay for build and runtime errors. No HMR: a reload re-renders
* the page from the new build.
*/

import type { IncomingMessage, ServerResponse } from 'node:http'
import type { DevErrorView } from './error-page.js'

export const devRoutePrefix = '/__kody/dev/'
export const liveReloadEventsPath = '/__kody/dev/events'
export const liveReloadClientPath = '/__kody/dev/client.js'

export const liveReloadScriptTag = `<script type="module" src="${liveReloadClientPath}"></script>`

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<ServerResponse>()
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 `</head>`, else `</body>`, 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<string, string | Array<string> | 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 = \`
<style>
.backdrop { position: fixed; inset: 0; z-index: 2147483647; background: rgba(10, 4, 7, .82); display: flex; align-items: flex-start; justify-content: center; overflow: auto; font: 14px/1.5 ui-sans-serif, system-ui, sans-serif; }
.panel { margin: 48px 16px; max-width: 960px; width: 100%; background: #1a1015; color: #f5e9ee; border: 1px solid #3a2530; border-radius: 10px; padding: 20px 24px; box-shadow: 0 20px 60px rgba(0,0,0,.5); }
.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.label { font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: #ff8fab; }
button { all: unset; cursor: pointer; color: #bfa9b3; font-size: 20px; line-height: 1; padding: 4px 8px; border-radius: 6px; }
button:hover, button:focus-visible { background: #2a1a22; color: #fff; }
h1 { font-size: 18px; margin: 8px 0 4px; word-break: break-word; }
.request { color: #bfa9b3; font-size: 13px; margin: 0 0 12px; }
pre { margin: 12px 0 0; padding: 14px; overflow: auto; background: #0e080b; border: 1px solid #3a2530; border-radius: 8px; font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre; }
.hint { color: #bfa9b3; font-size: 12px; margin-top: 12px; }
</style>
<div class="backdrop" role="alertdialog" aria-modal="true" aria-labelledby="kody-dev-title">
<div class="panel">
<div class="top"><span class="label"></span><button type="button" aria-label="Dismiss">×</button></div>
<h1 id="kody-dev-title"></h1>
<p class="request"></p>
<pre></pre>
<p class="hint">Fix it and save. The page reloads on the next good build. Esc dismisses.</p>
</div>
</div>\`;
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()
6 changes: 4 additions & 2 deletions src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Usage:
kody package revoke <package> (--team <slug> | --user <username> | --grant <grant_id>) [--org <slug>] [--json]
kody execute [--org <slug>] [--invoke <ref> | --code <esm> | --file <path>] [--params <json>] [--conversation-id <id>] [--json]
[--credential <key=name>]… [--target prod|mock] [--token <token>] [--api-url <url>] [--local] [--allow-private-network]
kody dev [serve] [--org <slug>] [--target prod|mock] [--reset-storage] [--port <n>] [--app-base-path <path>] [--allow-private-network]
kody dev [serve] [--org <slug>] [--target prod|mock] [--reset-storage] [--port <n>] [--app-base-path <path>] [--allow-private-network] [--no-live-reload]
kody dev fetch <path> [--method <verb>] [--body <file>] [--target prod|mock] [--json]
kody dev job <name> [--target prod|mock] [--dry-run] [--json]
kody dev webhook <name> --body <file.json> [--target prod|mock] [--dry-run] [--json]
Expand Down Expand Up @@ -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
Expand Down
77 changes: 74 additions & 3 deletions src/package-app-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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
? `<script>globalThis.__kodyDevErrorPage = true</script>${liveReloadScriptTag}`
: ''
void sweepStaleDevDirs().catch(() => {})
const workDir = await mkdtemp(join(tmpdir(), devWorkDirPrefix))
const reaper = await startProcessReaper({ workDir })
Expand All @@ -193,6 +211,12 @@ export async function startPackageAppDev(
let buildLoop: Promise<void> | null = null
let rebuildAgain = false
const changedFiles = new Set<string>()
const liveReloadHub = createLiveReloadHub({
state: () => ({
buildId: active?.id ?? null,
error: buildFailure ? buildErrorView(buildFailure) : null,
}),
})
let markStarted: () => void = () => {}
const started = new Promise<void>((resolve) => {
markStarted = resolve
Expand Down Expand Up @@ -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
Expand All @@ -314,6 +339,7 @@ export async function startPackageAppDev(
col: first?.col ?? null,
diagnostics: buildFailure.diagnostics,
})
liveReloadHub.broadcast({ event: 'build_error', data: buildErrorView(buildFailure) })
}
}

Expand Down Expand Up @@ -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<void> {
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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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<void>((resolve) => {
proxy.close(() => resolve())
Expand Down
Loading
Loading