From e883e4ed9bb1747d237a3c9f42584262ab29f19f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 04:49:32 +0000 Subject: [PATCH 01/12] =?UTF-8?q?feat(dev):=20agent=20mode=20=E2=80=94=20-?= =?UTF-8?q?-json=20NDJSON=20events,=20/=5F=5Fkody/dev/status,=20--ready-fi?= =?UTF-8?q?le,=20attach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kody dev --json prints ready / rebuild_start / rebuild_ok / build_error / runtime_error / log events as NDJSON on stdout. - GET /__kody/dev/status returns state, build id, and the last build or runtime error. - --ready-file writes { url, port, basePath, pid, statusUrl } once the first build settles and removes it on exit. - kody dev fetch attaches to a running kody dev for the same package and target (registry in the user cache dir, confirmed via status); --no-attach forces a one-shot server. - Live reload only injects into requests that accept text/html, so agent fetches see the app's bytes. - The watcher's stat poll re-baselines after each build (no echo rebuilds). - Dev server tests reuse the cached workerd instead of re-downloading it. --- README.md | 35 ++++++- src/cli.ts | 19 ++++ src/dev-command.ts | 126 +++++++++++++++++++++-- src/dev/dev-registry.ts | 139 +++++++++++++++++++++++++ src/dev/dev-watcher.ts | 8 +- src/dev/live-reload.ts | 6 ++ src/help.ts | 11 +- src/package-app-dev.ts | 68 ++++++++++++- test/dev-agent.test.ts | 190 +++++++++++++++++++++++++++++++++++ test/dev-live-reload.test.ts | 19 +++- test/dev-server-harness.ts | 4 + 11 files changed, 599 insertions(+), 26 deletions(-) create mode 100644 src/dev/dev-registry.ts create mode 100644 test/dev-agent.test.ts 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..006dbb0 100644 --- a/src/dev-command.ts +++ b/src/dev-command.ts @@ -4,7 +4,7 @@ * against the local package), webhooks, schedules (job once), and subscriptions. */ -import { access, readFile } from 'node:fs/promises' +import { access, readFile, rm, writeFile } from 'node:fs/promises' import { join, resolve } from 'node:path' import { detectCredentialedFetchInSource, @@ -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,64 @@ 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 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 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`, + ) + } + const shutdown = async () => { await server.close() + await removeDevRegistration(manifest.root, process.pid).catch(() => {}) + if (input.readyFile) await rm(input.readyFile, { force: true }).catch(() => {}) await input.auth.closeMock?.() + } + 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 +216,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 +748,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`) diff --git a/src/dev/dev-registry.ts b/src/dev/dev-registry.ts new file mode 100644 index 0000000..9af205f --- /dev/null +++ b/src/dev/dev-registry.ts @@ -0,0 +1,139 @@ +/** + * 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 + * 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, cacheDir: string = userCacheDir()): string { + const key = createHash('sha256').update(root).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, 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` (a newer server may own it). */ +export async function removeDevRegistration( + root: string, + pid: number, + cacheDir?: string, +): Promise { + const path = devRegistryPath(root, cacheDir) + const current = await readRegistration(path) + if (current && current.pid !== pid) return + await rm(path, { force: true }) +} + +/** + * 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.cacheDir) + const registration = await readRegistration(path) + if (!registration || registration.root !== input.root) return null + if (!isProcessAlive(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..c343bac 100644 --- a/src/dev/dev-watcher.ts +++ b/src/dev/dev-watcher.ts @@ -14,7 +14,7 @@ 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 + track: (paths: ReadonlyArray) => Promise close: () => Promise } @@ -78,14 +78,14 @@ export function createDevWatcher(input: { return { ready, - track: (paths) => { + track: async (paths) => { + // Re-baseline right after a build so the poll only reports later edits. const next = new Map() for (const path of paths) { if (ignored(path)) continue - next.set(path, tracked.get(path) ?? null) + next.set(path, await fingerprint(path)) } tracked = next - void poll() }, close: async () => { clearInterval(timer) 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..7d95c67 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 } @@ -217,6 +220,10 @@ export async function startPackageAppDev( 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,7 +313,7 @@ export async function startPackageAppDev( let bundle: DevBundle try { bundle = await bundler.build() - watcher?.track([...bundle.inputs, options.manifest.packageJsonPath]) + await watcher?.track([...bundle.inputs, options.manifest.packageJsonPath]) if (active && !buildFailure && bundle.code === active.code) { emit({ type: 'rebuild_ok', @@ -325,10 +332,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', @@ -449,8 +459,53 @@ export async function startPackageAppDev( ]) } + function status(): DevServerStatus { + const [first] = buildFailure?.diagnostics ?? [] + return { + ok: buildFailure === null && active !== undefined, + state: buildLoop + ? '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 +609,7 @@ export async function startPackageAppDev( liveReload && shouldInjectLiveReload({ method: req.method, + accept: req.headers.accept, status, headers: upstreamRes.headers, }) @@ -603,6 +659,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', @@ -690,6 +753,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..69543ec --- /dev/null +++ b/test/dev-agent.test.ts @@ -0,0 +1,190 @@ +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 { resolveDevAuth, runDevServe, tryAttachedDevFetch } from '../src/dev-command.js' +import { + devRegistryPath, + findRunningDevServer, + 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, 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, 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('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 }) + } +}) 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, /