diff --git a/README.md b/README.md index ea443d7..48fc920 100644 --- a/README.md +++ b/README.md @@ -266,13 +266,44 @@ kody search --target mock local `/__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. + There is no HMR. Only requests that accept `text/html` (browser page loads) + get the script; compressed HTML, agent requests, 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 left behind by dead CLI runs, including older CLI versions. +### Driving `kody dev` from an agent + +```bash +kody dev --json --ready-file .kody-dev.json & # NDJSON events on stdout +curl -s http://127.0.0.1:/__kody/dev/status +kody dev fetch /scores # reuses the running server +``` + +- `--json` prints one JSON object per line on stdout, each with a `type` and + an ISO `ts`: + - `ready`: `url`, `port`, `basePath`, `pid`, `statusUrl`, `buildId` + - `rebuild_start`: `files` + - `rebuild_ok`: `ms`, `buildId`, `restarted` + - `build_error`: `file`, `line`, `col`, `message`, `diagnostics` + - `runtime_error`: `message`, `stack`, `request` + - `log`: `source`, `level`, `message`, `request` + + Status lines stay on stderr. +- `GET /__kody/dev/status` returns `{ ok, state, buildId, url, port, + basePath, target, package, pid, lastRebuild, lastError }`. `state` is one of + `starting`, `building`, `ready`, or `build_error`. `lastError` holds the + current build error or the most recent runtime error. +- `--ready-file ` writes `{ url, port, basePath, pid, statusUrl }` once + the first build settles, and deletes the file on exit. +- `kody dev fetch` attaches to a running `kody dev` for the same package and + `--target` (found through the user cache dir and confirmed via the status + endpoint) instead of booting a second workerd. The request waits for any + in-progress rebuild, so fetching right after a save sees the new code. Pass + `--no-attach` to force a one-shot server. + ## Local execute `kody execute --local` runs the same execute module (default export called diff --git a/src/cli.ts b/src/cli.ts index 563949e..6cb55e3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -142,6 +142,8 @@ function parseKnown(args: Array) { port: { type: 'string' }, 'app-base-path': { type: 'string' }, 'no-live-reload': { type: 'boolean' }, + 'ready-file': { type: 'string' }, + 'no-attach': { type: 'boolean' }, method: { type: 'string' }, body: { type: 'string' }, clients: { type: 'string' }, @@ -1066,6 +1068,7 @@ async function dispatch( runDevJob, runDevServe, runDevWebhook, + tryAttachedDevFetch, } = await import('./dev-command.js') const target = resolveTarget({ command: 'dev', @@ -1157,6 +1160,18 @@ async function dispatch( } } } + if (subcommand === 'fetch' && parsed.values['no-attach'] !== true && rest[0]) { + const attached = await tryAttachedDevFetch({ + target, + path: rest[0], + method: + typeof parsed.values.method === 'string' ? parsed.values.method : undefined, + body: bodyPath ? await readFile(bodyPath, 'utf8') : undefined, + json, + io, + }) + if (attached !== null) return attached + } const token = target === 'prod' ? await resolveLocalExecuteBearer({ @@ -1227,6 +1242,10 @@ async function dispatch( appBasePath, allowPrivateNetwork, liveReload: parsed.values['no-live-reload'] !== true, + json, + ...(typeof parsed.values['ready-file'] === 'string' + ? { readyFile: parsed.values['ready-file'] } + : {}), io, }) case 'fetch': { diff --git a/src/dev-command.ts b/src/dev-command.ts index 4047943..805b074 100644 --- a/src/dev-command.ts +++ b/src/dev-command.ts @@ -4,8 +4,8 @@ * against the local package), webhooks, schedules (job once), and subscriptions. */ -import { access, readFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' +import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' import { detectCredentialedFetchInSource, mocksSuggestionDetail, @@ -26,6 +26,12 @@ import type { KodyTarget } from './target.js' import { formatToolResult } from './tool-result.js' import { bundleLocalUserModule } from './local-bundle.js' import { formatDevEvent, type DevEvent } from './dev/dev-events.js' +import { + devStatusPath, + findRunningDevServer, + removeDevRegistration, + writeDevRegistration, +} from './dev/dev-registry.js' export type DevIo = { stdout: (text: string) => void @@ -111,12 +117,19 @@ export async function runDevServe(input: { passthroughHosts?: ReadonlyArray /** Inject the live-reload client into HTML responses (default true). */ liveReload?: boolean + /** NDJSON events on stdout instead of human output. */ + json?: boolean + /** Write `{ url, port, basePath, pid, statusUrl }` here once the first build settles. */ + readyFile?: string cwd?: string io: DevIo /** Injected for tests — when set, serve returns immediately after start. */ serveOnce?: boolean }): Promise { const manifest = await loadPackageManifest(input.cwd) + const onEvent = input.json + ? (event: DevEvent) => writeJsonEvent(input.io, event) + : (event: DevEvent) => writeDevEvent(input.io, event) const server = await startPackageAppDev({ manifest, token: input.auth.token, @@ -135,25 +148,71 @@ export async function runDevServe(input: { liveReload: input.liveReload ?? true, onStatus: (message) => input.io.stderr(`${message}\n`), onError: (message) => input.io.stderr(`${message}\n`), - onEvent: (event) => writeDevEvent(input.io, event), + onEvent, }) - input.io.stdout( - `kody dev listening on ${server.hostedUrl}${server.appBasePath || '/'}\n` + - `target: ${input.target}\n` + - `package: ${manifest.name} (${manifest.kodyId})\n` + - `Agents: kody dev fetch /path | job | webhook --body file.json | emit --body file.json | check\n`, - ) - if (input.serveOnce) { + const shutdown = async () => { await server.close() + await removeDevRegistration(manifest.root, process.pid, input.target).catch(() => {}) + if (input.readyFile) await removeOwnedReadyFile(input.readyFile, process.pid) await input.auth.closeMock?.() + } + try { + const status = server.status() + const statusUrl = `${server.hostedUrl}${devStatusPath}` + const ready = { + url: status.url, + port: server.port, + basePath: server.appBasePath, + pid: process.pid, + statusUrl, + } + await writeDevRegistration({ + pid: process.pid, + origin: server.hostedUrl, + port: server.port, + basePath: server.appBasePath, + target: input.target, + root: manifest.root, + }).catch(() => {}) + if (input.readyFile) { + await mkdir(dirname(input.readyFile), { recursive: true }) + await writeFile(input.readyFile, `${JSON.stringify(ready, null, 2)}\n`) + } + if (input.json) { + input.io.stdout( + `${JSON.stringify({ + type: 'ready', + ts: new Date().toISOString(), + ...ready, + target: input.target, + package: status.package, + buildId: status.buildId, + state: status.state, + })}\n`, + ) + } else { + input.io.stdout( + `kody dev listening on ${status.url}\n` + + `target: ${input.target}\n` + + `package: ${manifest.name} (${manifest.kodyId})\n` + + `status: ${statusUrl}\n` + + `Agents: kody dev fetch /path | job | webhook --body file.json | emit --body file.json | check\n`, + ) + } + } catch (error) { + // Ready-file / registration failures must not leave the listen handles up. + await shutdown().catch(() => {}) + throw error + } + if (input.serveOnce) { + await shutdown() return 0 } await new Promise((resolveWait) => { const signals: Array = ['SIGINT', 'SIGTERM', 'SIGHUP'] const stop = async () => { for (const signal of signals) process.off(signal, onSig) - await server.close() - await input.auth.closeMock?.() + await shutdown() resolveWait() } const onSig = () => { @@ -164,6 +223,54 @@ export async function runDevServe(input: { return 0 } +/** + * `kody dev fetch` against an already running `kody dev` for this package and + * target, if there is one. Returns null when nothing is running so the caller + * can boot a one-shot server. + */ +export async function tryAttachedDevFetch(input: { + target: KodyTarget + path: string + method?: string + body?: string + cwd?: string + json?: boolean + io: DevIo +}): Promise { + let root: string + try { + root = (await loadPackageManifest(input.cwd)).root + } catch { + return null + } + const running = await findRunningDevServer({ root, target: input.target }) + if (!running) return null + const { registration } = running + const rest = input.path.startsWith('/') ? input.path : `/${input.path}` + const url = new URL( + `${registration.basePath}${rest === '/' && registration.basePath ? '' : rest}` || '/', + registration.origin, + ) + input.io.stderr(`Attached to running kody dev (pid ${registration.pid}) at ${registration.origin}\n`) + const response = await fetch(url, { + method: input.method ?? 'GET', + headers: input.body ? { 'content-type': 'application/json' } : undefined, + body: input.body, + }) + const body = await response.text() + const headers: Record = {} + response.headers.forEach((value, key) => { + headers[key] = value + }) + const result = { status: response.status, headers, body } + if (input.json) { + input.io.stdout(`${JSON.stringify(result, null, 2)}\n`) + } else { + input.io.stdout(`HTTP ${result.status}\n${result.body}\n`) + } + return result.status >= 400 ? 1 : 0 +} + export async function runDevFetch(input: { target: KodyTarget auth: DevAuth @@ -648,6 +755,10 @@ async function printDevDryRun(input: { } } +function writeJsonEvent(io: DevIo, event: DevEvent): void { + io.stdout(`${JSON.stringify({ ...event, ts: new Date().toISOString() })}\n`) +} + function writeDevEvent(io: DevIo, event: DevEvent): void { const { stream, text } = formatDevEvent(event) io[stream](`${text}\n`) @@ -677,4 +788,18 @@ export function parseDevSubcommand(positionals: Array): { } } +/** + * Remove `--ready-file` only when it still names this process. A successor + * `kody dev` may have rewritten the same path before our shutdown finishes. + */ +export async function removeOwnedReadyFile(path: string, ownerPid: number): Promise { + try { + const parsed = JSON.parse(await readFile(path, 'utf8')) as { pid?: unknown } + if (parsed.pid !== ownerPid) return + await rm(path, { force: true }) + } catch { + // Missing or unreadable — nothing to clean up. + } +} + void (null as unknown as PackageAppDevServer) diff --git a/src/dev/dev-registry.ts b/src/dev/dev-registry.ts new file mode 100644 index 0000000..1b25adf --- /dev/null +++ b/src/dev/dev-registry.ts @@ -0,0 +1,158 @@ +/** + * Where a running `kody dev` server advertises itself so `kody dev fetch` can + * reuse it instead of booting a second workerd. One JSON file per package root + * + target under the user cache dir; stale entries (dead pid, wrong package) + * are ignored. + */ + +import { createHash } from 'node:crypto' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { KodyTarget } from '../target.js' +import { userCacheDir } from '../workerd-binary.js' +import { isProcessAlive } from './process-reaper.js' + +export const devStatusPath = '/__kody/dev/status' + +export type DevRegistration = { + pid: number + /** Origin, e.g. `http://127.0.0.1:4321`. */ + origin: string + port: number + basePath: string + target: KodyTarget + root: string +} + +export type DevServerStatus = { + ok: boolean + state: 'starting' | 'building' | 'ready' | 'build_error' + buildId: number | null + url: string + port: number + basePath: string + target: KodyTarget + package: { name: string; kodyId: string; root: string } + pid: number + startedAt: string + lastRebuild: { ms: number; at: string } | null + lastError: + | { + kind: 'build' + message: string + file: string | null + line: number | null + col: number | null + at: string + } + | { + kind: 'runtime' + message: string + stack: string + request: { id: string; method: string; path: string } | null + at: string + } + | null +} + +export function devRegistryPath( + root: string, + target: KodyTarget, + cacheDir: string = userCacheDir(), +): string { + const key = createHash('sha256').update(`${root}\0${target}`).digest('hex').slice(0, 16) + return join(cacheDir, 'dev-servers', `${key}.json`) +} + +export async function writeDevRegistration( + registration: DevRegistration, + cacheDir?: string, +): Promise { + const path = devRegistryPath(registration.root, registration.target, cacheDir) + await mkdir(join(path, '..'), { recursive: true }) + await writeFile(path, `${JSON.stringify(registration, null, 2)}\n`) + return path +} + +/** + * Remove the entry only if it still belongs to `pid`. Missing or unreadable + * files are left alone — a successor may have truncated or rewritten the path + * before our shutdown finishes (same rule as `--ready-file` cleanup). + */ +export async function removeDevRegistration( + root: string, + pid: number, + target: KodyTarget, + cacheDir?: string, +): Promise { + const path = devRegistryPath(root, target, cacheDir) + try { + const current = await readRegistration(path) + if (!current || current.pid !== pid) return + await rm(path, { force: true }) + } catch { + // Missing or unreadable — nothing to clean up. + } +} + +/** + * The live `kody dev` server for `root` on `target`, confirmed via its status + * endpoint, or null. + */ +export async function findRunningDevServer(input: { + root: string + target: KodyTarget + cacheDir?: string + fetchFn?: typeof fetch + timeoutMs?: number +}): Promise<{ registration: DevRegistration; status: DevServerStatus } | null> { + const path = devRegistryPath(input.root, input.target, input.cacheDir) + const registration = await readRegistration(path) + if (!registration || registration.root !== input.root) return null + if (!isProcessAlive(registration.pid)) { + // Re-read before deleting: a successor may have rewritten the same path + // after we snapped `registration` but before we noticed the pid was dead. + const current = await readRegistration(path) + if (current && current.pid === registration.pid) { + await rm(path, { force: true }) + } + return null + } + if (registration.target !== input.target) return null + try { + const response = await (input.fetchFn ?? fetch)( + new URL(devStatusPath, registration.origin), + { signal: AbortSignal.timeout(input.timeoutMs ?? 1500) }, + ) + if (!response.ok) return null + const status = (await response.json()) as DevServerStatus + if (status.package?.root !== input.root || status.target !== input.target) return null + return { registration, status } + } catch { + return null + } +} + +async function readRegistration(path: string): Promise { + try { + const parsed = JSON.parse(await readFile(path, 'utf8')) as Partial + if ( + typeof parsed.pid !== 'number' || + typeof parsed.origin !== 'string' || + typeof parsed.root !== 'string' || + (parsed.target !== 'mock' && parsed.target !== 'prod') + ) { + return null + } + return { + pid: parsed.pid, + origin: parsed.origin, + port: typeof parsed.port === 'number' ? parsed.port : Number(new URL(parsed.origin).port), + basePath: typeof parsed.basePath === 'string' ? parsed.basePath : '', + target: parsed.target, + root: parsed.root, + } + } catch { + return null + } +} diff --git a/src/dev/dev-watcher.ts b/src/dev/dev-watcher.ts index d4d5ed3..f8a8dab 100644 --- a/src/dev/dev-watcher.ts +++ b/src/dev/dev-watcher.ts @@ -13,8 +13,26 @@ const defaultPollIntervalMs = 1000 export type DevWatcher = { ready: Promise - /** Replace the set of absolute paths the stat poll keeps an eye on. */ - track: (paths: ReadonlyArray) => void + /** + * Refresh fingerprints for the currently tracked paths without changing the + * path set or firing `onChange`. Call after intentional writers (e.g. + * `kody.dev.prebuild`) so their output is absorbed before a mid-build + * comparison. + */ + rebaseline: () => Promise + /** + * Replace the set of absolute paths the stat poll keeps an eye on. + * Re-baselines fingerprints so post-build mtime chatter does not echo a + * rebuild. Returns paths whose fingerprint changed since the previous + * baseline and are not listed in `ignoreChanges` (the files that triggered + * this build) — the caller should queue those for the next rebuild without + * going through `onChange` (which is debounced and would double-schedule + * when chokidar already saw the same mid-build edit). + */ + track: ( + paths: ReadonlyArray, + options?: { ignoreChanges?: ReadonlyArray }, + ) => Promise> close: () => Promise } @@ -54,19 +72,28 @@ export function createDevWatcher(input: { type Fingerprint = string let tracked = new Map() + let trackGeneration = 0 let polling = false const poll = async () => { if (polling) return polling = true + // Hold map + generation. track()/rebaseline() replace `tracked` with a + // fresh Map at the start so an in-flight poll fails `tracked !== map` + // immediately — otherwise it can fingerprint post-build bytes against + // the pre-build baseline and echo a no-op rebuild. + const map = tracked + const generation = trackGeneration try { - for (const [path, previous] of tracked) { + for (const [path, previous] of [...map]) { + if (tracked !== map || generation !== trackGeneration) return const next = await fingerprint(path) - if (!tracked.has(path)) continue + if (tracked !== map || generation !== trackGeneration) return + if (!map.has(path)) continue if (previous !== null && next !== previous) { - tracked.set(path, next) - input.onChange(path) + map.set(path, next) + if (tracked === map && generation === trackGeneration) input.onChange(path) } else if (previous === null) { - tracked.set(path, next) + map.set(path, next) } } } finally { @@ -78,17 +105,51 @@ export function createDevWatcher(input: { return { ready, - track: (paths) => { + rebaseline: async () => { + const generation = ++trackGeneration + const previousTracked = tracked + tracked = new Map() + const next = new Map() + for (const path of previousTracked.keys()) { + next.set(path, await fingerprint(path)) + if (generation !== trackGeneration) return + } + if (generation !== trackGeneration) return + tracked = next + trackGeneration++ + }, + track: async (paths, options) => { + const ignoreChanges = new Set(options?.ignoreChanges ?? []) + const generation = ++trackGeneration + const previousTracked = tracked + // Detach before fingerprinting so concurrent polls cannot treat the + // just-written files as fresh edits against the pre-build baseline. + tracked = new Map() + const changedDuringBuild: Array = [] const next = new Map() for (const path of paths) { if (ignored(path)) continue - next.set(path, tracked.get(path) ?? null) + const current = await fingerprint(path) + if (generation !== trackGeneration) return [] + const previous = previousTracked.get(path) + if ( + previous !== undefined && + previous !== null && + current !== previous && + !ignoreChanges.has(path) + ) { + changedDuringBuild.push(path) + } + next.set(path, current) } + if (generation !== trackGeneration) return [] tracked = next - void poll() + trackGeneration++ + return changedDuringBuild }, close: async () => { clearInterval(timer) + trackGeneration++ tracked = new Map() await watcher.close() }, diff --git a/src/dev/live-reload.ts b/src/dev/live-reload.ts index 1f6e239..1579d9c 100644 --- a/src/dev/live-reload.ts +++ b/src/dev/live-reload.ts @@ -76,12 +76,18 @@ export function injectLiveReloadScript(html: string): string { return `${html}${liveReloadScriptTag}` } +/** + * Only page loads that ask for HTML (browsers send `Accept: text/html`); agent + * and `kody dev fetch` requests get the app's bytes untouched. + */ export function shouldInjectLiveReload(input: { method: string | undefined + accept: string | undefined status: number headers: Record | number | undefined> }): boolean { if (input.method === 'HEAD') return false + if (!/\btext\/html\b/i.test(input.accept ?? '')) 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 diff --git a/src/help.ts b/src/help.ts index b13aa4d..4e916a0 100644 --- a/src/help.ts +++ b/src/help.ts @@ -67,8 +67,8 @@ 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] [--no-live-reload] - kody dev fetch [--method ] [--body ] [--target prod|mock] [--json] + kody dev [serve] [--org ] [--target prod|mock] [--reset-storage] [--port ] [--app-base-path ] [--allow-private-network] [--no-live-reload] [--json] [--ready-file ] + kody dev fetch [--method ] [--body ] [--target prod|mock] [--json] [--no-attach] kody dev job [--target prod|mock] [--dry-run] [--json] kody dev webhook --body [--target prod|mock] [--dry-run] [--json] kody dev emit --body [--target prod|mock] [--dry-run] [--json] @@ -290,6 +290,13 @@ ${orgBlock} 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). + Agents: \`--json\` streams NDJSON events (ready, rebuild_start, + rebuild_ok, build_error, runtime_error, log); GET + /__kody/dev/status returns build id + last error; + \`--ready-file \` writes { url, port, basePath, pid } once + ready. \`kody dev fetch\` attaches to a running \`kody dev\` for + the same package and target (\`--no-attach\` boots a one-shot + server). 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 d66ca1a..fd76785 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -30,6 +30,7 @@ import { apiTokenEnvVar } from './defaults.js' import { createDevBundler, type DevBundle } from './dev/dev-bundler.js' import { parseWorkerdStdoutLine, type DevEvent, type DevRequestTag } from './dev/dev-events.js' import { devErrorHeader, devRequestIdHeader } from './dev/dev-protocol.js' +import { devStatusPath, type DevServerStatus } from './dev/dev-registry.js' import { createDevWatcher, type DevWatcher } from './dev/dev-watcher.js' import { sendDevError, type DevErrorView } from './dev/error-page.js' import { @@ -140,6 +141,8 @@ export type PackageAppDevServer = { path: string, init?: { method?: string; headers?: Record; body?: string }, ) => Promise<{ status: number; headers: Record; body: string }> + /** Same JSON as `GET /__kody/dev/status`. */ + status: () => DevServerStatus close: () => Promise } @@ -211,12 +214,22 @@ export async function startPackageAppDev( let buildLoop: Promise | null = null let rebuildAgain = false const changedFiles = new Set() + /** True while the watcher debounce timer has paths waiting to rebuild. */ + let debouncePending = false + /** Resolves when the current debounce window flushes into `requestBuild`. */ + let debounceFlush: Promise | null = null + let resolveDebounceFlush: (() => void) | null = null + let debounceTimer: NodeJS.Timeout | undefined const liveReloadHub = createLiveReloadHub({ state: () => ({ buildId: active?.id ?? null, error: buildFailure ? buildErrorView(buildFailure) : null, }), }) + const startedAt = new Date().toISOString() + let lastRebuild: DevServerStatus['lastRebuild'] = null + let lastRuntimeError: DevServerStatus['lastError'] = null + let buildFailureAt = startedAt let markStarted: () => void = () => {} const started = new Promise((resolve) => { markStarted = resolve @@ -306,11 +319,24 @@ export async function startPackageAppDev( let bundle: DevBundle try { bundle = await bundler.build() - watcher?.track([...bundle.inputs, options.manifest.packageJsonPath]) + const midBuildEdits = + (await watcher?.track([...bundle.inputs, options.manifest.packageJsonPath], { + ignoreChanges: files, + })) ?? [] + // Queue mid-build edits for the next loop iteration directly — do not + // call onChange (debounced); chokidar may already have the same paths + // pending and would double-schedule a rebuild after debounce. + if (midBuildEdits.length > 0) { + for (const path of midBuildEdits) changedFiles.add(path) + rebuildAgain = true + } if (active && !buildFailure && bundle.code === active.code) { + // Still stamp lastRebuild so agents polling lastRebuild.at see + // the no-op rebuild finish (status already flipped building→ready). + lastRebuild = { ms: elapsed(started), at: new Date().toISOString() } emit({ type: 'rebuild_ok', - ms: elapsed(started), + ms: lastRebuild.ms, buildId: active.id, restarted: false, }) @@ -325,10 +351,13 @@ export async function startPackageAppDev( active = generation buildFailure = null if (previous) retireGeneration(previous) - emit({ type: 'rebuild_ok', ms: elapsed(started), buildId: generation.id, restarted: true }) + lastRuntimeError = null + lastRebuild = { ms: elapsed(started), at: new Date().toISOString() } + emit({ type: 'rebuild_ok', ms: lastRebuild.ms, buildId: generation.id, restarted: true }) liveReloadHub.broadcast({ event: 'reload', data: { buildId: generation.id } }) } catch (error) { buildFailure = toBuildFailure(error) + buildFailureAt = new Date().toISOString() const [first] = buildFailure.diagnostics emit({ type: 'build_error', @@ -441,6 +470,10 @@ export async function startPackageAppDev( } async function waitForBuild(): Promise { + // Flush any watcher debounce first so a save → immediate fetch cannot + // slip through on the previous generation while the timer is still open. + const flush = debounceFlush + if (flush) await flush const pending = buildLoop ?? (active || buildFailure ? null : started) if (!pending) return await Promise.race([ @@ -449,8 +482,54 @@ export async function startPackageAppDev( ]) } + function status(): DevServerStatus { + const [first] = buildFailure?.diagnostics ?? [] + return { + ok: buildFailure === null && active !== undefined, + state: + buildLoop || debouncePending + ? 'building' + : buildFailure + ? 'build_error' + : active + ? 'ready' + : 'starting', + buildId: active?.id ?? null, + url: `${hostedUrl}${appBasePath || '/'}`, + port: address.port, + basePath: appBasePath, + target: options.target, + package: { + name: options.manifest.name, + kodyId: options.manifest.kodyId, + root, + }, + pid: process.pid, + startedAt, + lastRebuild, + lastError: buildFailure + ? { + kind: 'build', + message: buildFailure.message, + file: first?.file ?? null, + line: first?.line ?? null, + col: first?.col ?? null, + at: buildFailureAt, + } + : lastRuntimeError, + } + } + function handleDevRoute(req: IncomingMessage, res: ServerResponse): boolean { const pathname = stripMount(new URL(req.url ?? '/', hostedUrl).pathname, appBasePath) + if (pathname === devStatusPath) { + res.writeHead(200, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }) + res.end(`${JSON.stringify(status(), null, 2)}\n`) + return true + } if (!liveReload) return false if (pathname === liveReloadEventsPath) { liveReloadHub.handleEvents(req, res) @@ -554,6 +633,7 @@ export async function startPackageAppDev( liveReload && shouldInjectLiveReload({ method: req.method, + accept: req.headers.accept, status, headers: upstreamRes.headers, }) @@ -603,6 +683,13 @@ export async function startPackageAppDev( stack = raw } const mapped = redact(generation.mapStack(stack)) + lastRuntimeError = { + kind: 'runtime', + message: redact(message), + stack: mapped, + request: tag, + at: new Date().toISOString(), + } emit({ type: 'runtime_error', message: redact(message), stack: mapped, request: tag }) const view: DevErrorView = { kind: 'runtime', @@ -618,18 +705,40 @@ export async function startPackageAppDev( options.watch === false ? null : (() => { - let timer: NodeJS.Timeout | undefined const pending = new Set() return createDevWatcher({ root, onChange: (path) => { if (closed) return + // Fold mid-build watcher events into the active loop + // (including another save of a file already building). + // Debouncing them instead schedules a second rebuild + // after buildLoop clears (often a no-op echo). + if (buildLoop) { + changedFiles.add(path) + rebuildAgain = true + return + } pending.add(path) - clearTimeout(timer) - timer = setTimeout(() => { + debouncePending = true + if (!debounceFlush) { + debounceFlush = new Promise((resolve) => { + resolveDebounceFlush = resolve + }) + } + clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { const paths = [...pending] pending.clear() - void requestBuild(paths) + debouncePending = false + // Start the build before releasing waiters so + // waitForBuild sees buildLoop, not a ready gap. + const build = requestBuild(paths) + const resolve = resolveDebounceFlush + resolveDebounceFlush = null + debounceFlush = null + resolve?.() + void build }, options.debounceMs ?? defaultDebounceMs) }, }) @@ -639,6 +748,12 @@ export async function startPackageAppDev( if (closed) return closed = true process.off('exit', killAllSync) + clearTimeout(debounceTimer) + debouncePending = false + const resolve = resolveDebounceFlush + resolveDebounceFlush = null + debounceFlush = null + resolve?.() await watcher?.close() await buildLoop?.catch(() => {}) await bundler.dispose() @@ -690,6 +805,7 @@ export async function startPackageAppDev( appBasePath, packageContext, fetchPath, + status, close, } } diff --git a/test/dev-agent.test.ts b/test/dev-agent.test.ts new file mode 100644 index 0000000..5bdd321 --- /dev/null +++ b/test/dev-agent.test.ts @@ -0,0 +1,276 @@ +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { cp, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { + removeOwnedReadyFile, + resolveDevAuth, + runDevServe, + tryAttachedDevFetch, +} from '../src/dev-command.js' +import { + devRegistryPath, + findRunningDevServer, + removeDevRegistration, + writeDevRegistration, + type DevServerStatus, +} from '../src/dev/dev-registry.js' +import { ensureWorkerdBinary } from '../src/workerd-binary.js' +import { waitFor, withDevServer } from './dev-server-harness.js' + +const fixtureRoot = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/sample-package') + +test('GET /__kody/dev/status reports build id, state, and the last error', async () => { + await withDevServer(async ({ server, root, writeMessage }) => { + const ready = await server.fetchPath('/__kody/dev/status') + assert.equal(ready.status, 200) + const status = JSON.parse(ready.body) as DevServerStatus + assert.equal(status.ok, true) + assert.equal(status.state, 'ready') + assert.equal(typeof status.buildId, 'number') + assert.equal(status.target, 'mock') + assert.equal(status.package.root, root) + assert.equal(status.package.kodyId, 'sample-dev-package') + assert.equal(status.url, `${server.hostedUrl}/`) + assert.equal(status.lastError, null) + + await server.fetchPath('/boom') + const afterBoom = server.status() + assert.equal(afterBoom.lastError?.kind, 'runtime') + assert.match(afterBoom.lastError?.kind === 'runtime' ? afterBoom.lastError.stack : '', /app\/entry\.ts/) + + await writeMessage(`export const message = = 'broken'\n`) + const broken = await waitFor(async () => { + const current = server.status() + return current.state === 'build_error' ? current : undefined + }, 'build_error state') + assert.equal(broken.ok, false) + assert.equal(broken.lastError?.kind, 'build') + if (broken.lastError?.kind === 'build') { + assert.equal(broken.lastError.file, 'app/message.ts') + assert.equal(broken.lastError.line, 1) + assert.equal(broken.lastError.col, 24) + } + }) +}) + +test('kody dev --json emits NDJSON events and --ready-file is written before ready', async () => { + const previousWorkerd = process.env.KODY_WORKERD_PATH + process.env.KODY_WORKERD_PATH = await ensureWorkerdBinary() + const root = await mkdtemp(join(tmpdir(), 'kody-dev-json-')) + const cache = await mkdtemp(join(tmpdir(), 'kody-dev-json-cache-')) + const previousCache = process.env.KODY_CACHE_DIR + process.env.KODY_CACHE_DIR = cache + await cp(fixtureRoot, root, { recursive: true }) + const readyFile = join(root, 'ready.json') + const auth = await resolveDevAuth({ + target: 'mock', + token: 'mock', + apiUrl: 'https://api.kody.codes', + resetStorage: true, + packageId: 'local:sample-dev-package', + }) + const lines: Array> = [] + let readyFileAtReady: Record | null = null + let registeredAtReady = false + try { + const code = await runDevServe({ + target: 'mock', + auth, + cwd: root, + json: true, + readyFile, + serveOnce: true, + io: { + stdout: (text) => { + for (const line of text.split('\n').filter(Boolean)) { + const event = JSON.parse(line) as Record + lines.push(event) + if (event.type === 'ready') { + readyFileAtReady = JSON.parse(readFileSync(readyFile, 'utf8')) as Record< + string, + unknown + > + registeredAtReady = existsSync(devRegistryPath(root, 'mock', cache)) + } + } + }, + stderr: () => {}, + }, + }) + assert.equal(code, 0) + const types = lines.map((event) => event.type) + assert.deepEqual(types, ['rebuild_start', 'rebuild_ok', 'ready']) + for (const event of lines) assert.equal(typeof event.ts, 'string') + const ready = lines.at(-1)! + assert.match(String(ready.url), /^http:\/\/127\.0\.0\.1:\d+\/$/) + assert.equal(typeof ready.port, 'number') + assert.equal(ready.basePath, '') + assert.equal(ready.pid, process.pid) + assert.match(String(ready.statusUrl), /\/__kody\/dev\/status$/) + assert.ok(readyFileAtReady) + assert.equal((readyFileAtReady as Record).url, ready.url) + assert.equal(registeredAtReady, true) + assert.equal(existsSync(readyFile), false, 'ready file is removed on shutdown') + assert.equal( + existsSync(devRegistryPath(root, 'mock', cache)), + false, + 'registry is removed on shutdown', + ) + } finally { + await auth.closeMock?.() + if (previousCache === undefined) delete process.env.KODY_CACHE_DIR + else process.env.KODY_CACHE_DIR = previousCache + if (previousWorkerd === undefined) delete process.env.KODY_WORKERD_PATH + else process.env.KODY_WORKERD_PATH = previousWorkerd + await rm(root, { recursive: true, force: true }) + await rm(cache, { recursive: true, force: true }) + } +}) + +test('removeOwnedReadyFile only deletes the ready file when the pid still matches', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kody-ready-file-')) + try { + const path = join(dir, 'ready.json') + await writeFile(path, `${JSON.stringify({ url: 'http://127.0.0.1:1/', pid: 111 }, null, 2)}\n`) + await removeOwnedReadyFile(path, 222) + assert.equal(existsSync(path), true, 'leaves a successor process ready file alone') + await removeOwnedReadyFile(path, 111) + assert.equal(existsSync(path), false, 'removes the file this process wrote') + await removeOwnedReadyFile(path, 111) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('kody dev fetch attaches to the running server for the same package and target', async () => { + await withDevServer(async ({ server, root, writeMessage }) => { + await writeDevRegistration({ + pid: process.pid, + origin: server.hostedUrl, + port: server.port, + basePath: server.appBasePath, + target: 'mock', + root, + }) + await writeMessage(`export const message = 'attached'\n`) + let stdout = '' + let stderr = '' + const code = await waitFor(async () => { + stdout = '' + const result = await tryAttachedDevFetch({ + target: 'mock', + path: '/', + cwd: root, + io: { + stdout: (text) => { + stdout += text + }, + stderr: (text) => { + stderr += text + }, + }, + }) + return stdout.includes('attached') ? result : undefined + }, 'attached fetch sees the new build') + assert.equal(code, 0) + assert.match(stdout, /^HTTP 200\n/) + assert.match(stderr, /Attached to running kody dev/) + + const otherTarget = await tryAttachedDevFetch({ + target: 'prod', + path: '/', + cwd: root, + io: { stdout: () => {}, stderr: () => {} }, + }) + assert.equal(otherTarget, null) + }) +}) + +test('findRunningDevServer drops registrations whose process is gone', async () => { + const cache = await mkdtemp(join(tmpdir(), 'kody-dev-registry-')) + try { + const path = await writeDevRegistration( + { + pid: 2 ** 22 + 12345, + origin: 'http://127.0.0.1:1', + port: 1, + basePath: '', + target: 'mock', + root: '/nowhere', + }, + cache, + ) + assert.equal(existsSync(path), true) + assert.equal(await findRunningDevServer({ root: '/nowhere', target: 'mock', cacheDir: cache }), null) + assert.equal(existsSync(path), false) + await writeFile(path, 'not json') + assert.equal(await findRunningDevServer({ root: '/nowhere', target: 'mock', cacheDir: cache }), null) + } finally { + await rm(cache, { recursive: true, force: true }) + } +}) + +test('dev registry keys by target and removeDevRegistration refuses successor/unreadable files', async () => { + const cache = await mkdtemp(join(tmpdir(), 'kody-dev-registry-target-')) + try { + const mockPath = await writeDevRegistration( + { + pid: 111, + origin: 'http://127.0.0.1:1', + port: 1, + basePath: '', + target: 'mock', + root: '/pkg', + }, + cache, + ) + const prodPath = await writeDevRegistration( + { + pid: 222, + origin: 'http://127.0.0.1:2', + port: 2, + basePath: '', + target: 'prod', + root: '/pkg', + }, + cache, + ) + assert.notEqual(mockPath, prodPath) + assert.equal(existsSync(mockPath), true) + assert.equal(existsSync(prodPath), true) + + await removeDevRegistration('/pkg', 999, 'mock', cache) + assert.equal(existsSync(mockPath), true, 'wrong pid leaves the registration alone') + await removeDevRegistration('/pkg', 111, 'mock', cache) + assert.equal(existsSync(mockPath), false, 'matching pid removes this target only') + assert.equal(existsSync(prodPath), true, 'other target registration survives') + + await writeFile(prodPath, 'not json') + await removeDevRegistration('/pkg', 222, 'prod', cache) + assert.equal(existsSync(prodPath), true, 'unreadable successor file is not deleted') + } finally { + await rm(cache, { recursive: true, force: true }) + } +}) + +test('status reports building during watcher debounce and fetch waits for the new build', async () => { + await withDevServer( + async ({ server, writeMessage }) => { + await writeMessage(`export const message = 'after-debounce'\n`) + await waitFor(async () => { + return server.status().state === 'building' ? true : undefined + }, 'building state while debounce is open') + const response = await server.fetchPath('/') + assert.equal(response.status, 200) + assert.match(response.body, /after-debounce/) + await waitFor(async () => { + return server.status().state === 'ready' ? true : undefined + }, 'ready after the debounced rebuild settles') + }, + { debounceMs: 80 }, + ) +}) diff --git a/test/dev-live-reload.test.ts b/test/dev-live-reload.test.ts index e29e5dc..f7b0caf 100644 --- a/test/dev-live-reload.test.ts +++ b/test/dev-live-reload.test.ts @@ -24,12 +24,18 @@ test('injectLiveReloadScript prefers , then , then appends 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) + const accept = 'text/html,application/xhtml+xml,*/*;q=0.8' + assert.equal(shouldInjectLiveReload({ method: 'GET', accept, status: 200, headers: html }), true) + assert.equal( + shouldInjectLiveReload({ method: 'GET', accept: '*/*', status: 200, headers: html }), + false, + ) + assert.equal(shouldInjectLiveReload({ method: 'HEAD', accept, status: 200, headers: html }), false) + assert.equal(shouldInjectLiveReload({ method: 'GET', accept, status: 304, headers: html }), false) assert.equal( shouldInjectLiveReload({ method: 'GET', + accept, status: 200, headers: { ...html, 'content-encoding': 'gzip' }, }), @@ -38,6 +44,7 @@ test('shouldInjectLiveReload only touches uncompressed HTML bodies', () => { assert.equal( shouldInjectLiveReload({ method: 'GET', + accept, status: 200, headers: { 'content-type': 'application/json' }, }), @@ -78,9 +85,11 @@ function openEventStream(url: string): { 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('/') + const page = await server.fetchPath('/', { headers: { accept: 'text/html' } }) assert.equal(page.status, 200) assert.match(page.body, /