From 92a110b82327666745b22bd04d1bd82f5f729e31 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 05:04:17 +0000 Subject: [PATCH 01/13] feat(dev): stable per-package ports, client bundle + /_assets, kody.dev.prebuild, faster rebuilds - Default port is derived from kody.id (4100-4999) and walks to the next free port; an explicit --port is exact and EADDRINUSE explains the fix. A second kody dev for the same package/target fails with the running URL. - Serve kody.app.client at /_assets/client..js (packageContext. clientModuleUrl), kody.app.assets under /_assets/, and __version.json, matching published apps. Asset edits reload open pages. - kody.dev.prebuild runs before every build (KODY_DEV=1); failures are build errors; its own writes don't retrigger. kody.dev.ignore adds watcher ignore prefixes. - esbuild incremental contexts and a 30ms debounce: save-to-visible p50 ~148ms -> ~58ms on the demo app. --- README.md | 32 ++++ src/cli.ts | 3 +- src/dev-command.ts | 16 ++ src/dev/dev-assets.ts | 237 +++++++++++++++++++++++++ src/dev/dev-bundler.ts | 28 ++- src/dev/dev-events.ts | 4 +- src/dev/dev-port.ts | 90 ++++++++++ src/dev/prebuild.ts | 58 +++++++ src/help.ts | 7 + src/package-app-dev.ts | 162 ++++++++++++++--- src/package-manifest.ts | 61 ++++++- src/structured-error.ts | 3 + test/dev-ports-assets.test.ts | 316 ++++++++++++++++++++++++++++++++++ test/dev-server-harness.ts | 9 +- 14 files changed, 989 insertions(+), 37 deletions(-) create mode 100644 src/dev/dev-assets.ts create mode 100644 src/dev/dev-port.ts create mode 100644 src/dev/prebuild.ts create mode 100644 test/dev-ports-assets.test.ts diff --git a/README.md b/README.md index 48fc920..402d820 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,38 @@ kody search --target mock local 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. +- **Fast rebuilds.** File events are debounced for 30ms, and esbuild reuses + an incremental context, so a save usually serves the new code in about + 60ms on a small app. +- **Ports.** Each package gets a stable default port in 4100–4999 (derived + from `kody.id`), so URLs survive restarts. If it is taken, `kody dev` moves + to the next free port. An explicit `--port ` is used exactly and fails + with a fix when the port is busy (`--port 0` picks any free port). Starting + a second `kody dev` for the same package and target without `--port` fails + and names the running server's URL and pid. +- **Client bundle and assets**, same URLs as a published app: + `kody.app.client` is bundled for the browser and served at + `/_assets/client..js` (inline source map; + `client.externals` stay bare imports). `packageContext.clientModuleUrl` + points at it, and `/_assets/__version.json` reports it. Files in + `kody.app.assets` are served under `/_assets/`. Both are `no-store` and + rebuild on save; editing an asset reloads open pages. +- **Prebuild**: `kody.dev.prebuild` runs a shell command from the package + root before every build (with `KODY_DEV=1`), for generated CSS, icons, or + data files. Its output is tagged `[prebuild]`. A non-zero exit is shown as a + build error. File changes made while it runs do not trigger another build. + `kody.dev.ignore` lists root-relative path prefixes the watcher skips: + + ```json + { + "kody": { + "app": { "entry": "./app/router.ts", "client": "./app/client.ts", "assets": "./public" }, + "dev": { "prebuild": "node scripts/build-css.mjs", "ignore": ["public/generated"] } + } + } + ``` + + `kody.dev` only affects `kody dev`; publish ignores it. - **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 diff --git a/src/cli.ts b/src/cli.ts index 6cb55e3..eb1cea0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1060,6 +1060,7 @@ async function dispatch( case 'dev': { const { resolveTarget } = await import('./target.js') const { + parseDevPort, parseDevSubcommand, resolveDevAuth, runDevCheck, @@ -1227,7 +1228,7 @@ async function dispatch( ...(org && target === 'mock' ? { orgSlug: org } : {}), }) const port = - typeof parsed.values.port === 'string' ? Number(parsed.values.port) : undefined + typeof parsed.values.port === 'string' ? parseDevPort(parsed.values.port) : undefined const appBasePath = typeof parsed.values['app-base-path'] === 'string' ? parsed.values['app-base-path'] diff --git a/src/dev-command.ts b/src/dev-command.ts index 805b074..207fc99 100644 --- a/src/dev-command.ts +++ b/src/dev-command.ts @@ -26,6 +26,7 @@ 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 { defaultDevPort } from './dev/dev-port.js' import { devStatusPath, findRunningDevServer, @@ -33,6 +34,8 @@ import { writeDevRegistration, } from './dev/dev-registry.js' +export { parseDevPort } from './dev/dev-port.js' + export type DevIo = { stdout: (text: string) => void stderr: (text: string) => void @@ -127,6 +130,18 @@ export async function runDevServe(input: { serveOnce?: boolean }): Promise { const manifest = await loadPackageManifest(input.cwd) + if (input.port === undefined) { + const running = await findRunningDevServer({ root: manifest.root, target: input.target }) + if (running) { + const { registration } = running + throw new StructuredCliError({ + code: 'dev_already_running', + target: input.target, + message: `kody dev is already running for ${manifest.name} at ${registration.origin}${registration.basePath || '/'} (pid ${registration.pid}).`, + fix: `Reuse it (\`kody dev fetch \` attaches automatically), stop it with \`kill ${registration.pid}\`, or pass --port to start a second server.`, + }) + } + } const onEvent = input.json ? (event: DevEvent) => writeJsonEvent(input.io, event) : (event: DevEvent) => writeDevEvent(input.io, event) @@ -136,6 +151,7 @@ export async function runDevServe(input: { apiUrl: input.auth.apiUrl, target: input.target, port: input.port, + defaultPort: defaultDevPort(manifest.kodyId), appBasePath: input.appBasePath, ...(input.auth.org ? { org: input.auth.org } : {}), ...(input.auth.depResolve ? { depResolve: input.auth.depResolve } : {}), diff --git a/src/dev/dev-assets.ts b/src/dev/dev-assets.ts new file mode 100644 index 0000000..2aa876a --- /dev/null +++ b/src/dev/dev-assets.ts @@ -0,0 +1,237 @@ +/** + * Local copy of the platform's `/_assets/` surface for `kody dev`: + * the browser bundle of `kody.app.client` (fingerprinted `client..js`), + * `__version.json`, and files from the `kody.app.assets` directory. Answered + * by the Node proxy before the app's fetch handler, like the platform does. + * Everything is `no-store` so a rebuild is always picked up. + */ + +import * as esbuild from 'esbuild' +import { createHash } from 'node:crypto' +import { readFile, stat } from 'node:fs/promises' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { join, resolve, sep } from 'node:path' +import { createBundleError } from '../local-bundle.js' +import { bundleInputs } from './dev-bundler.js' + +export const devAssetsPathPrefix = '/_assets/' +export const devVersionAssetName = '__version.json' +const clientModuleHashLength = 16 + +export type DevClientBundle = { + /** `client.<16-char base64url sha256>.js`, same shape as published apps. */ + name: string + code: string + inputs: Array +} + +export type DevClientBundler = { + build: () => Promise + dispose: () => Promise +} + +export function createDevClientBundler(input: { + entryPath: string + root: string + externals?: ReadonlyArray +}): DevClientBundler { + const externals = input.externals ?? [] + const workingDir = process.cwd() + let context: Promise> | null = null + const createContext = () => + esbuild.context({ + absWorkingDir: workingDir, + entryPoints: [resolve(input.entryPath)], + bundle: true, + write: false, + metafile: true, + format: 'esm', + platform: 'browser', + target: 'es2022', + sourcemap: 'inline', + logLevel: 'silent', + outfile: join(input.root, '_assets', 'client.js'), + plugins: externals.length > 0 ? [clientExternalsPlugin(externals)] : [], + }) + return { + build: async () => { + try { + context ??= createContext() + const result = await (await context).rebuild() + const code = result.outputFiles.find((file) => file.path.endsWith('.js'))?.text ?? '' + const hash = createHash('sha256') + .update(code) + .digest('base64url') + .slice(0, clientModuleHashLength) + return { + name: `client.${hash}.js`, + code, + inputs: bundleInputs(result.metafile, workingDir), + } + } catch (error) { + throw createBundleError(input.entryPath, error, { + workingDir, + displayRoot: input.root, + }) + } + }, + dispose: async () => { + const current = context + context = null + await (await current?.catch(() => null))?.dispose() + }, + } +} + +/** + * Exact specifier or a subpath of it, so `preact` does not also externalize + * `preact-render-to-string` (esbuild's own `external` matches by prefix). + */ +function clientExternalsPlugin(externals: ReadonlyArray): esbuild.Plugin { + return { + name: 'kody-dev-client-externals', + setup(build) { + build.onResolve({ filter: /^[^./]/ }, (args) => + externals.some( + (external) => args.path === external || args.path.startsWith(`${external}/`), + ) + ? { path: args.path, external: true } + : undefined, + ) + }, + } +} + +/** `/_assets/` → `` (percent-decoded), or null when not an asset path. */ +export function parseDevAssetPath(pathname: string): string | null { + if (!pathname.startsWith(devAssetsPathPrefix)) return null + const encoded = pathname.slice(devAssetsPathPrefix.length) + if (!encoded) return null + try { + return decodeURIComponent(encoded) + } catch { + return null + } +} + +/** Absolute file under `assetsDir`, or null for traversal / absolute paths. */ +export function resolveDevAssetFile(assetsDir: string, relativePath: string): string | null { + if (relativePath.includes('\0') || relativePath.startsWith('/')) return null + const root = resolve(assetsDir) + const file = resolve(root, relativePath) + return file.startsWith(`${root}${sep}`) ? file : null +} + +export async function serveDevAsset(input: { + req: IncomingMessage + res: ServerResponse + relativePath: string + assetsDir: string | null + client: DevClientBundle | null + clientModuleUrl: string | null + /** Origin-relative app mount ('' at the root). */ + appBasePath: string +}): Promise { + const { req, res } = input + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405, { allow: 'GET, HEAD', 'cache-control': 'no-store' }) + res.end('Method not allowed\n') + return + } + if (input.relativePath === devVersionAssetName) { + send(req, res, 'application/json; charset=utf-8', JSON.stringify({ + clientModuleUrl: input.clientModuleUrl, + assetBasePath: `${input.appBasePath}/_assets`, + publishedCommit: null, + })) + return + } + if (input.client && input.relativePath === input.client.name) { + send(req, res, 'text/javascript; charset=utf-8', input.client.code) + return + } + const file = input.assetsDir ? resolveDevAssetFile(input.assetsDir, input.relativePath) : null + const body = file ? await readAssetFile(file) : null + if (!file || !body) { + res.writeHead(404, { + 'content-type': 'text/plain; charset=utf-8', + 'cache-control': 'no-store', + }) + res.end('Not found\n') + return + } + const contentType = inferDevAssetContentType(file) + send(req, res, contentType, body, contentType.startsWith('text/javascript') + ? { 'service-worker-allowed': `${input.appBasePath}/` } + : {}) +} + +async function readAssetFile(file: string): Promise { + try { + if (!(await stat(file)).isFile()) return null + return await readFile(file) + } catch { + return null + } +} + +function send( + req: IncomingMessage, + res: ServerResponse, + contentType: string, + body: string | Buffer, + extraHeaders: Record = {}, +): void { + const bytes = typeof body === 'string' ? Buffer.from(body, 'utf8') : body + res.writeHead(200, { + 'content-type': contentType, + 'content-length': String(bytes.length), + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + ...extraHeaders, + }) + res.end(req.method === 'HEAD' ? undefined : bytes) +} + +const contentTypesByExtension: Record = { + avif: 'image/avif', + css: 'text/css; charset=utf-8', + csv: 'text/csv; charset=utf-8', + gif: 'image/gif', + htm: 'text/html; charset=utf-8', + html: 'text/html; charset=utf-8', + ico: 'image/x-icon', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + js: 'text/javascript; charset=utf-8', + json: 'application/json; charset=utf-8', + map: 'application/json; charset=utf-8', + md: 'text/markdown; charset=utf-8', + mjs: 'text/javascript; charset=utf-8', + mp3: 'audio/mpeg', + mp4: 'video/mp4', + oga: 'audio/ogg', + ogg: 'audio/ogg', + ogv: 'video/ogg', + otf: 'font/otf', + pdf: 'application/pdf', + png: 'image/png', + svg: 'image/svg+xml; charset=utf-8', + ttf: 'font/ttf', + txt: 'text/plain; charset=utf-8', + wasm: 'application/wasm', + wav: 'audio/wav', + webm: 'video/webm', + webmanifest: 'application/manifest+json; charset=utf-8', + webp: 'image/webp', + woff: 'font/woff', + woff2: 'font/woff2', + xml: 'application/xml; charset=utf-8', +} + +export function inferDevAssetContentType(path: string): string { + const name = path.split(/[\\/]/).pop() ?? '' + const separator = name.lastIndexOf('.') + const extension = separator <= 0 ? '' : name.slice(separator + 1).toLowerCase() + return contentTypesByExtension[extension] ?? 'application/octet-stream' +} diff --git a/src/dev/dev-bundler.ts b/src/dev/dev-bundler.ts index 1611d1f..fddb620 100644 --- a/src/dev/dev-bundler.ts +++ b/src/dev/dev-bundler.ts @@ -1,7 +1,8 @@ /** * `kody dev` bundles of `kody.app.entry`: same esbuild options as one-shot * local bundles, plus an in-memory source map (for mapping workerd stacks back - * to `.ts`) and the list of input files (for the watcher's stat poll). + * to `.ts`) and the list of input files (for the watcher's stat poll). Uses an + * incremental esbuild context so rebuilds only reparse changed files. */ import * as esbuild from 'esbuild' @@ -21,6 +22,8 @@ export type DevBundler = { dispose: () => Promise } +type DevBuildContext = esbuild.BuildContext<{ write: false; metafile: true }> + export function createDevBundler(input: { entryPath: string; root: string }): DevBundler { const entry = resolve(input.entryPath) const base = localBundleOptions(entry) @@ -33,21 +36,33 @@ export function createDevBundler(input: { entryPath: string; root: string }): De outfile: join(input.root, '__kody_dev_bundle.js'), } satisfies esbuild.BuildOptions const paths = { workingDir: base.absWorkingDir, displayRoot: input.root } + let context: Promise | null = null return { build: async () => { - let result: esbuild.BuildResult + let result: esbuild.BuildResult<{ write: false; metafile: true }> try { - result = await esbuild.build(options) + context ??= esbuild.context(options) + result = await (await context).rebuild() } catch (error) { throw createBundleError(input.entryPath, error, paths) } return readBundle(result, base.absWorkingDir, input.entryPath) }, - dispose: async () => {}, + dispose: async () => { + const current = context + context = null + await (await current?.catch(() => null))?.dispose() + }, } } +export function bundleInputs(metafile: esbuild.Metafile | undefined, workingDir: string): Array { + return Object.keys(metafile?.inputs ?? {}) + .filter((path) => !path.includes('node_modules') && !path.includes(':')) + .map((path) => resolve(workingDir, path)) +} + export function readBundle( result: esbuild.BuildResult<{ metafile: true; write: false }>, workingDir: string, @@ -59,12 +74,9 @@ export function readBundle( if (!js) { throw new BundleError(`Could not bundle ${entryPath}: esbuild produced no output.`, []) } - const inputs = Object.keys(result.metafile?.inputs ?? {}) - .filter((path) => !path.includes('node_modules') && !path.includes(':')) - .map((path) => resolve(workingDir, path)) return { code: js.text.replace(/\n\/\/# sourceMappingURL=\S+\s*$/, '\n'), sourceMap: map?.text ?? null, - inputs, + inputs: bundleInputs(result.metafile, workingDir), } } diff --git a/src/dev/dev-events.ts b/src/dev/dev-events.ts index 319a21a..0b24314 100644 --- a/src/dev/dev-events.ts +++ b/src/dev/dev-events.ts @@ -31,7 +31,7 @@ export type DevEvent = } | { type: 'log' - source: 'app' | 'workerd' + source: 'app' | 'workerd' | 'prebuild' level: DevLogLevel message: string request: DevRequestTag | null @@ -105,7 +105,7 @@ export function formatDevEvent(event: DevEvent): { stream: 'stdout' | 'stderr'; text: `${formatRequestTag(event.request)} Uncaught ${event.stack || event.message}`, } case 'log': { - const tag = event.source === 'workerd' ? '[workerd]' : formatRequestTag(event.request) + const tag = event.source === 'app' ? formatRequestTag(event.request) : `[${event.source}]` const level = event.level === 'log' || event.level === 'info' ? '' : `${event.level}: ` const stream = event.level === 'error' || event.level === 'warn' ? 'stderr' : 'stdout' return { stream, text: `${tag} ${level}${event.message}` } diff --git a/src/dev/dev-port.ts b/src/dev/dev-port.ts new file mode 100644 index 0000000..066feb7 --- /dev/null +++ b/src/dev/dev-port.ts @@ -0,0 +1,90 @@ +/** + * `kody dev` ports: each package gets a stable default (so bookmarks, OAuth + * redirect URIs, and agent notes keep working across restarts) and walks up to + * the next free port when that one is taken. An explicit `--port` is honored + * exactly and fails with an actionable message instead. + */ + +import { createHash } from 'node:crypto' +import type { Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { StructuredCliError } from '../structured-error.js' + +export const devPortRange = { min: 4100, max: 4999 } as const +const fallbackAttempts = 20 + +export function defaultDevPort(kodyId: string): number { + const span = devPortRange.max - devPortRange.min + 1 + const hash = createHash('sha256').update(kodyId).digest() + return devPortRange.min + (hash.readUInt32BE(0) % span) +} + +export function parseDevPort(raw: string): number { + const port = /^\d+$/.test(raw.trim()) ? Number(raw) : Number.NaN + if (!Number.isInteger(port) || port > 65_535) { + throw new StructuredCliError({ + code: 'invalid_port', + message: `--port must be an integer from 0 to 65535 (got "${raw}").`, + fix: 'Pass --port 0 for any free port, or omit --port to use this package’s stable default.', + }) + } + return port +} + +/** + * Listen on `port` exactly when `explicit`, otherwise on `port` or the next + * free port above it. Returns the bound port. + */ +export async function listenDevServer( + server: Server, + input: { host: string; port: number; explicit: boolean }, +): Promise { + const attempts = input.explicit || input.port === 0 ? 1 : fallbackAttempts + for (let attempt = 0; attempt < attempts; attempt += 1) { + const port = input.port === 0 ? 0 : wrapPort(input.port + attempt) + try { + await listenOnce(server, input.host, port) + return (server.address() as AddressInfo).port + } catch (error) { + if (!isAddressInUse(error)) throw error + if (input.explicit) throw portInUseError(input.host, port) + } + } + throw new StructuredCliError({ + code: 'dev_port_in_use', + message: `Ports ${input.port}–${wrapPort(input.port + attempts - 1)} on ${input.host} are all in use.`, + fix: 'Stop old `kody dev` processes, or pass --port 0 for any free port.', + }) +} + +function portInUseError(host: string, port: number): StructuredCliError { + return new StructuredCliError({ + code: 'dev_port_in_use', + message: `Port ${port} on ${host} is already in use.`, + fix: `Find the owner with \`lsof -nP -iTCP:${port} -sTCP:LISTEN\` and stop it, pass another --port, or omit --port to use this package’s default (it moves to the next free port automatically).`, + }) +} + +function wrapPort(port: number): number { + return port > 65_535 ? devPortRange.min + (port - 65_536) : port +} + +function listenOnce(server: Server, host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off('listening', onListening) + reject(error) + } + const onListening = () => { + server.off('error', onError) + resolve() + } + server.once('error', onError) + server.once('listening', onListening) + server.listen(port, host) + }) +} + +function isAddressInUse(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === 'EADDRINUSE' +} diff --git a/src/dev/prebuild.ts b/src/dev/prebuild.ts new file mode 100644 index 0000000..15b292d --- /dev/null +++ b/src/dev/prebuild.ts @@ -0,0 +1,58 @@ +/** + * `kody.dev.prebuild`: a shell command `kody dev` runs from the package root + * before every build, so generated assets (CSS, icons, data files) stay in + * sync with the source without a second watcher process. + */ + +import { spawn } from 'node:child_process' +import { createInterface } from 'node:readline' +import { apiTokenEnvVar } from '../defaults.js' + +const outputTailLines = 20 + +export class PrebuildError extends Error { + constructor(message: string) { + super(message) + this.name = 'PrebuildError' + } +} + +export async function runPrebuild(input: { + command: string + cwd: string + onSpawn?: (pid: number) => void + onExit?: (pid: number) => void + onLine?: (line: string, stream: 'stdout' | 'stderr') => void +}): Promise { + const env: NodeJS.ProcessEnv = { ...process.env, KODY_DEV: '1' } + delete env[apiTokenEnvVar] + const child = spawn('sh', ['-c', input.command], { + cwd: input.cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + if (child.pid) input.onSpawn?.(child.pid) + const tail: Array = [] + const collect = (stream: 'stdout' | 'stderr') => (line: string) => { + tail.push(line) + if (tail.length > outputTailLines) tail.shift() + input.onLine?.(line, stream) + } + createInterface({ input: child.stdout }).on('line', collect('stdout')) + createInterface({ input: child.stderr }).on('line', collect('stderr')) + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.once('error', reject) + child.once('close', (code, signal) => resolve({ code, signal })) + }, + ).finally(() => { + if (child.pid) input.onExit?.(child.pid) + }) + if (result.code === 0) return + const how = result.signal ? `signal ${result.signal}` : `exit ${result.code}` + throw new PrebuildError( + [`kody.dev.prebuild failed (${how}): ${input.command}`, ...tail.map((line) => ` ${line}`)].join( + '\n', + ), + ) +} diff --git a/src/help.ts b/src/help.ts index 4e916a0..309c282 100644 --- a/src/help.ts +++ b/src/help.ts @@ -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). + Stable default port per package (4100-4999, next free port + if taken); \`--port \` is exact (\`--port 0\` = any). + Serves \`kody.app.client\` at /_assets/client..js + (packageContext.clientModuleUrl), \`kody.app.assets\` under + /_assets/, and /_assets/__version.json. \`kody.dev.prebuild\` + (shell command, run before every build) regenerates assets; + \`kody.dev.ignore\` lists watcher path prefixes to skip. Agents: \`--json\` streams NDJSON events (ready, rebuild_start, rebuild_ok, build_error, runtime_error, log); GET /__kody/dev/status returns build id + last error; diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index fd76785..e0c17b9 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -21,15 +21,23 @@ import { } from 'node:http' import type { AddressInfo } from 'node:net' import { tmpdir } from 'node:os' -import { join, relative } from 'node:path' +import { join, relative, resolve, sep } from 'node:path' import { assertTokenSafeApiUrl, callCapabilityProxy, } from './capability-proxy.js' import { apiTokenEnvVar } from './defaults.js' +import { + createDevClientBundler, + parseDevAssetPath, + serveDevAsset, + type DevClientBundle, + type DevClientBundler, +} from './dev/dev-assets.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 { listenDevServer } from './dev/dev-port.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' @@ -47,6 +55,7 @@ import { startProcessReaper, sweepStaleDevDirs, } from './dev/process-reaper.js' +import { runPrebuild } from './dev/prebuild.js' import { createStackMapper, type StackMapper } from './dev/stack-trace.js' import { cleanWorkerdStderrLine, @@ -90,7 +99,13 @@ export type PackageAppDevOptions = { apiUrl: string target: KodyTarget host?: string + /** Listen on exactly this port (0 = any free port); EADDRINUSE is an error. */ port?: number + /** + * Used when `port` is unset: try this port, then the next free ones above + * it. Without either, any free port. + */ + defaultPort?: number /** Public mount path the Node proxy strips (default ''). */ appBasePath?: string /** User-owned token org selection (`?org=`), same as execute --local. */ @@ -153,6 +168,7 @@ type Generation = { dir: string workerd: WorkerdHandle code: string + client: DevClientBundle | null mapStack: StackMapper inflight: number retired: boolean @@ -164,7 +180,9 @@ type BuildFailure = { diagnostics: Array } -const defaultDebounceMs = 120 +const defaultDebounceMs = 30 +/** Watcher events this soon after a prebuild finishes are its own writes, not edits. */ +const prebuildSettleMs = 150 /** Longest a request waits for an in-progress rebuild before the old build answers. */ const rebuildGateMs = 10_000 /** Longest a retired generation may keep draining in-flight requests. */ @@ -250,18 +268,30 @@ export async function startPackageAppDev( const proxy = createServer((req, res) => { void handleRequest(req, res) }) - await new Promise((resolve, reject) => { - proxy.once('error', reject) - proxy.listen(options.port ?? 0, host, () => resolve()) - }) + try { + await listenDevServer(proxy, { + host, + port: options.port ?? options.defaultPort ?? 0, + explicit: options.port !== undefined, + }) + } catch (error) { + process.off('exit', killAllSync) + reaper.close() + await rm(workDir, { recursive: true, force: true }) + throw error + } const address = proxy.address() as AddressInfo const hostedUrl = `http://${host}:${address.port}` - const packageContext = buildLocalPackageContext({ - kodyId: options.manifest.kodyId, - packageId: `local:${options.manifest.kodyId}`, - hostedUrl, - appBasePath, - }) + const contextFor = (client: DevClientBundle | null) => + buildLocalPackageContext({ + kodyId: options.manifest.kodyId, + packageId: `local:${options.manifest.kodyId}`, + hostedUrl, + appBasePath, + clientModuleUrl: client ? clientModuleUrlFor(client) : null, + }) + const clientModuleUrlFor = (client: DevClientBundle) => + `${hostedUrl}${appBasePath}/_assets/${client.name}` const mockFetchGateway = await resolveMockFetchGateway({ target: options.target, @@ -289,8 +319,20 @@ export async function startPackageAppDev( }, }) - const entryPath = resolvePackagePath(options.manifest, options.manifest.app.entry) + const app = options.manifest.app + const entryPath = resolvePackagePath(options.manifest, app.entry) const bundler = createDevBundler({ entryPath, root }) + const clientBundler: DevClientBundler | null = app.client + ? createDevClientBundler({ + entryPath: resolvePackagePath(options.manifest, app.client), + root, + externals: app.clientExternals, + }) + : null + const assetsDir = app.assets ? resolve(root, app.assets) : null + const prebuild = options.manifest.dev?.prebuild + let prebuildRunning = false + let prebuildSettledAt = 0 const workerdEnv: NodeJS.ProcessEnv = { ...process.env, [runSecretEnvVar]: runSecret } delete workerdEnv[apiTokenEnvVar] @@ -316,13 +358,20 @@ export async function startPackageAppDev( async function buildOnce(files: Array): Promise { const started = performance.now() emit({ type: 'rebuild_start', files: files.map((file) => relative(root, file) || file) }) - let bundle: DevBundle + const assetsChanged = + assetsDir !== null && + files.some((file) => file === assetsDir || file.startsWith(`${assetsDir}${sep}`)) try { - bundle = await bundler.build() + if (prebuild) await runPrebuildStep(prebuild) + const [bundle, client] = await Promise.all([ + bundler.build(), + clientBundler ? clientBundler.build() : Promise.resolve(null), + ]) const midBuildEdits = - (await watcher?.track([...bundle.inputs, options.manifest.packageJsonPath], { - ignoreChanges: files, - })) ?? [] + (await watcher?.track( + [...bundle.inputs, ...(client?.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. @@ -330,7 +379,12 @@ export async function startPackageAppDev( for (const path of midBuildEdits) changedFiles.add(path) rebuildAgain = true } - if (active && !buildFailure && bundle.code === active.code) { + if ( + active && + !buildFailure && + bundle.code === active.code && + client?.name === active.client?.name + ) { // 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() } @@ -340,9 +394,12 @@ export async function startPackageAppDev( buildId: active.id, restarted: false, }) + if (assetsChanged || prebuild) { + liveReloadHub.broadcast({ event: 'reload', data: { buildId: active.id } }) + } return } - const generation = await startGeneration(bundle) + const generation = await startGeneration(bundle, client) if (closed) { await stopGeneration(generation) return @@ -372,13 +429,39 @@ export async function startPackageAppDev( } } - async function startGeneration(bundle: DevBundle): Promise { + async function runPrebuildStep(command: string): Promise { + prebuildRunning = true + try { + await runPrebuild({ + command, + cwd: root, + onSpawn: (pid) => reaper.track(pid), + onExit: (pid) => reaper.untrack(pid), + onLine: (line, stream) => + emit({ + type: 'log', + source: 'prebuild', + level: stream === 'stderr' ? 'warn' : 'log', + message: redact(line), + request: null, + }), + }) + } finally { + prebuildRunning = false + prebuildSettledAt = performance.now() + } + } + + async function startGeneration( + bundle: DevBundle, + client: DevClientBundle | null, + ): Promise { const id = ++generationCounter const dir = join(workDir, `g${id}`) await writeWorkerFiles({ workDir: dir, code: bundle.code, - packageContext, + packageContext: contextFor(client), packageRoot: root, mockStorage: options.target === 'mock', bridgePort: bridge.port, @@ -428,6 +511,7 @@ export async function startPackageAppDev( dir, workerd, code: bundle.code, + client, mapStack: createStackMapper({ sourceMap: bundle.sourceMap, userModule: localWorkerModuleNames.user, @@ -550,6 +634,22 @@ export async function startPackageAppDev( try { if (handleDevRoute(req, res)) return await waitForBuild() + const assetPath = parseDevAssetPath( + stripMount(new URL(req.url ?? '/', hostedUrl).pathname, appBasePath), + ) + if (assetPath !== null) { + const client = active?.client ?? null + await serveDevAsset({ + req, + res, + relativePath: assetPath, + assetsDir, + client, + clientModuleUrl: client ? clientModuleUrlFor(client) : null, + appBasePath, + }) + return + } if (buildFailure) { sendDevError(req, res, 500, buildErrorView(buildFailure), { htmlTail: errorPageTail, @@ -708,12 +808,25 @@ export async function startPackageAppDev( const pending = new Set() return createDevWatcher({ root, + ignore: options.manifest.dev?.ignore, onChange: (path) => { if (closed) return +<<<<<<< HEAD // 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 ( + prebuildRunning || + performance.now() - prebuildSettledAt < prebuildSettleMs + ) { + return + } + // Fold mid-build chokidar echoes into the active + // loop. Debouncing them instead schedules a second + // rebuild after buildLoop clears (often a no-op). +>>>>>>> a65008c (feat(dev): stable per-package ports, client bundle + /_assets, kody.dev.prebuild, faster rebuilds) if (buildLoop) { changedFiles.add(path) rebuildAgain = true @@ -757,6 +870,7 @@ export async function startPackageAppDev( await watcher?.close() await buildLoop?.catch(() => {}) await bundler.dispose() + await clientBundler?.dispose() await Promise.all([...generations].map((generation) => stopGeneration(generation))) reaper.close() await bridge.close() @@ -803,7 +917,9 @@ export async function startPackageAppDev( port: address.port, hostedUrl, appBasePath, - packageContext, + get packageContext() { + return contextFor(active?.client ?? null) + }, fetchPath, status, close, diff --git a/src/package-manifest.ts b/src/package-manifest.ts index 58d76a6..3792f6e 100644 --- a/src/package-manifest.ts +++ b/src/package-manifest.ts @@ -8,10 +8,21 @@ import { StructuredCliError } from './structured-error.js' export type PackageAppManifest = { entry: string + /** Browser entry (string form, or `entry` of the object form). */ client?: string + /** `client.externals` from the object form; left as bare imports for an import map. */ + clientExternals?: Array assets?: string } +/** Local-only `kody dev` settings (`package.json#kody.dev`); never published behavior. */ +export type PackageDevManifest = { + /** Shell command run from the package root before every `kody dev` build. */ + prebuild?: string + /** Root-relative path prefixes the watcher ignores (e.g. prebuild outputs). */ + ignore: Array +} + /** * Job schedule as authored in package.json. Prod accepts a cron string or the * object form `{ type: 'cron', expression }` (and optional timezone on the job). @@ -57,6 +68,7 @@ export type KodyPackageManifest = { description?: string exports: Record app?: PackageAppManifest + dev: PackageDevManifest jobs: Record webhooks: Record subscriptions: Record @@ -98,6 +110,7 @@ export async function loadPackageManifest( const exportsField = normalizeExports(pkg.exports) const app = parseApp(kody.app, packageJsonPath) + const dev = parseDev(kody.dev, packageJsonPath) const jobs = parseJobs(kody.jobs, packageJsonPath) const webhooks = parseWebhooks(kody.webhooks, packageJsonPath) const subscriptions = parseSubscriptions(kody.subscriptions, packageJsonPath) @@ -110,6 +123,7 @@ export async function loadPackageManifest( description: typeof kody.description === 'string' ? kody.description : undefined, exports: exportsField, app, + dev, jobs, webhooks, subscriptions, @@ -174,13 +188,58 @@ function parseApp(raw: unknown, where: string): PackageAppManifest | undefined { fix: 'Set `"kody": { "app": { "entry": "./app/router.ts" } }` in package.json.', }) } + const clientRecord = asRecord(record.client) + const client = + typeof record.client === 'string' + ? record.client + : typeof clientRecord?.entry === 'string' + ? clientRecord.entry + : undefined + const clientExternals = Array.isArray(clientRecord?.externals) + ? clientRecord.externals.filter((item): item is string => typeof item === 'string') + : undefined return { entry: record.entry, - client: typeof record.client === 'string' ? record.client : undefined, + client, + ...(clientExternals?.length ? { clientExternals } : {}), assets: typeof record.assets === 'string' ? record.assets : undefined, } } +function parseDev(raw: unknown, where: string): PackageDevManifest { + if (raw === undefined || raw === null) return { ignore: [] } + const record = asRecord(raw) + if (!record) { + throw new StructuredCliError({ + code: 'package_manifest_invalid', + message: 'kody.dev must be an object.', + where, + fix: 'Use `"kody": { "dev": { "prebuild": "node scripts/build-assets.mjs", "ignore": ["public/generated"] } }`.', + }) + } + if (record.prebuild !== undefined && (typeof record.prebuild !== 'string' || !record.prebuild.trim())) { + throw new StructuredCliError({ + code: 'package_manifest_invalid', + message: 'kody.dev.prebuild must be a non-empty shell command string.', + where, + }) + } + if ( + record.ignore !== undefined && + (!Array.isArray(record.ignore) || !record.ignore.every((item) => typeof item === 'string')) + ) { + throw new StructuredCliError({ + code: 'package_manifest_invalid', + message: 'kody.dev.ignore must be an array of root-relative path prefixes.', + where, + }) + } + return { + ...(typeof record.prebuild === 'string' ? { prebuild: record.prebuild.trim() } : {}), + ignore: (record.ignore as Array | undefined) ?? [], + } +} + function parseJobs(raw: unknown, where: string): Record { if (raw === undefined || raw === null) return {} const record = asRecord(raw) diff --git a/src/structured-error.ts b/src/structured-error.ts index 342c331..2488797 100644 --- a/src/structured-error.ts +++ b/src/structured-error.ts @@ -15,6 +15,9 @@ export type StructuredErrorCode = | 'dev_handler_missing' | 'dev_surface_missing' | 'invalid_target' + | 'invalid_port' + | 'dev_port_in_use' + | 'dev_already_running' export type StructuredErrorBody = { code: StructuredErrorCode | string diff --git a/test/dev-ports-assets.test.ts b/test/dev-ports-assets.test.ts new file mode 100644 index 0000000..0f768ec --- /dev/null +++ b/test/dev-ports-assets.test.ts @@ -0,0 +1,316 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { runDevServe } from '../src/dev-command.js' +import { + inferDevAssetContentType, + parseDevAssetPath, + resolveDevAssetFile, +} from '../src/dev/dev-assets.js' +import { + defaultDevPort, + devPortRange, + listenDevServer, + parseDevPort, +} from '../src/dev/dev-port.js' +import { writeDevRegistration } from '../src/dev/dev-registry.js' +import { loadPackageManifest } from '../src/package-manifest.js' +import { StructuredCliError } from '../src/structured-error.js' +import { waitFor, withDevServer } from './dev-server-harness.js' + +const clientEntryApp = `import { packageContext } from 'kody:runtime' + +export default { + async fetch(): Promise { + return Response.json({ + clientModuleUrl: packageContext.clientModuleUrl, + assetBasePath: packageContext.assetBasePath, + }) + }, +} +` + +async function editPackageJson(root: string, edit: (kody: Record) => void) { + const path = join(root, 'package.json') + const pkg = JSON.parse(await readFile(path, 'utf8')) + edit(pkg.kody) + await writeFile(path, `${JSON.stringify(pkg, null, 2)}\n`) +} + +async function occupy(port: number, host = '127.0.0.1'): Promise { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(port, host, () => resolve()) + }) + return server +} + +const closeServer = (server: Server) => + new Promise((resolve) => server.close(() => resolve())) + +test('defaultDevPort is stable per package and inside the dev range', () => { + const port = defaultDevPort('asteroids') + assert.equal(defaultDevPort('asteroids'), port) + assert.notEqual(defaultDevPort('slideshow'), port) + for (const id of ['a', 'b', 'asteroids', 'slideshow', 'x'.repeat(100)]) { + const value = defaultDevPort(id) + assert.ok(value >= devPortRange.min && value <= devPortRange.max, `${id} → ${value}`) + } +}) + +test('parseDevPort rejects non-integer and out-of-range values', () => { + assert.equal(parseDevPort('0'), 0) + assert.equal(parseDevPort('4321'), 4321) + for (const bad of ['abc', '-1', '65536', '1.5', '']) { + assert.throws(() => parseDevPort(bad), /--port must be an integer/) + } +}) + +test('listenDevServer walks to the next free port unless the port is explicit', async () => { + const blocker = await occupy(0) + const taken = (blocker.address() as AddressInfo).port + const fallback = createServer() + const exact = createServer() + try { + const bound = await listenDevServer(fallback, { + host: '127.0.0.1', + port: taken, + explicit: false, + }) + assert.notEqual(bound, taken) + assert.ok(bound > taken && bound <= taken + 20) + + await assert.rejects( + listenDevServer(exact, { host: '127.0.0.1', port: taken, explicit: true }), + (error: unknown) => { + assert.ok(error instanceof StructuredCliError) + assert.equal(error.code, 'dev_port_in_use') + assert.match(error.message, new RegExp(`Port ${taken} on 127\\.0\\.0\\.1 is already in use`)) + assert.match(error.message, /lsof -nP -iTCP:\d+ -sTCP:LISTEN/) + return true + }, + ) + } finally { + await closeServer(blocker) + if (fallback.listening) await closeServer(fallback) + if (exact.listening) await closeServer(exact) + } +}) + +test('dev asset paths are decoded and never escape the assets dir', () => { + assert.equal(parseDevAssetPath('/_assets/app.css'), 'app.css') + assert.equal(parseDevAssetPath('/_assets/a%20b.png'), 'a b.png') + assert.equal(parseDevAssetPath('/_assets/'), null) + assert.equal(parseDevAssetPath('/other'), null) + assert.equal(parseDevAssetPath('/_assets/%E0%A4%A'), null) + assert.equal(resolveDevAssetFile('/pkg/public', 'css/app.css'), '/pkg/public/css/app.css') + assert.equal(resolveDevAssetFile('/pkg/public', '../package.json'), null) + assert.equal(resolveDevAssetFile('/pkg/public', '/etc/passwd'), null) + assert.equal(resolveDevAssetFile('/pkg/public', 'a\0b'), null) + assert.equal(inferDevAssetContentType('x/app.CSS'), 'text/css; charset=utf-8') + assert.equal(inferDevAssetContentType('sw.js'), 'text/javascript; charset=utf-8') + assert.equal(inferDevAssetContentType('LICENSE'), 'application/octet-stream') +}) + +test('loadPackageManifest reads kody.dev and the object form of kody.app.client', async () => { + const root = await mkdtemp(join(tmpdir(), 'kody-dev-manifest-')) + try { + await writeFile( + join(root, 'package.json'), + JSON.stringify({ + name: 'm', + kody: { + id: 'm', + app: { + entry: './app/entry.ts', + client: { entry: './app/client.ts', externals: ['preact'] }, + assets: './public', + }, + dev: { prebuild: ' node gen.mjs ', ignore: ['public/generated'] }, + }, + }), + ) + const manifest = await loadPackageManifest(root) + assert.equal(manifest.app?.client, './app/client.ts') + assert.deepEqual(manifest.app?.clientExternals, ['preact']) + assert.deepEqual(manifest.dev, { prebuild: 'node gen.mjs', ignore: ['public/generated'] }) + + await writeFile( + join(root, 'package.json'), + JSON.stringify({ name: 'm', kody: { id: 'm', dev: { ignore: 'public' } } }), + ) + await assert.rejects(loadPackageManifest(root), /kody\.dev\.ignore must be an array/) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('kody dev serves kody.app.client, __version.json, and the assets dir like the platform', async () => { + await withDevServer( + async ({ server, root, events }) => { + const context = JSON.parse((await server.fetchPath('/')).body) as { + clientModuleUrl: string + assetBasePath: string + } + assert.equal(context.assetBasePath, '/_assets') + assert.match( + context.clientModuleUrl, + new RegExp(`^${server.hostedUrl}/_assets/client\\.[A-Za-z0-9_-]{16}\\.js$`), + ) + const clientPath = new URL(context.clientModuleUrl).pathname + const client = await server.fetchPath(clientPath) + assert.equal(client.status, 200) + assert.match(client.headers['content-type'] ?? '', /text\/javascript/) + assert.match(client.body, /client-v1/) + assert.match(client.body, /from "preact"/, 'declared externals stay bare imports') + assert.match(client.body, /sourceMappingURL=data:application\/json/) + + const version = JSON.parse((await server.fetchPath('/_assets/__version.json')).body) + assert.deepEqual(version, { + clientModuleUrl: context.clientModuleUrl, + assetBasePath: '/_assets', + publishedCommit: null, + }) + + const css = await server.fetchPath('/_assets/app.css') + assert.equal(css.status, 200) + assert.equal(css.headers['content-type'], 'text/css; charset=utf-8') + assert.equal(css.headers['cache-control'], 'no-store') + assert.equal(css.body, 'body { color: red }\n') + assert.equal((await server.fetchPath('/_assets/missing.css')).status, 404) + assert.equal((await server.fetchPath('/_assets/..%2Fpackage.json')).status, 404) + + await writeFile(join(root, 'app/client.ts'), clientSource('client-v2')) + const nextUrl = await waitFor(async () => { + const body = JSON.parse((await server.fetchPath('/')).body) as { + clientModuleUrl: string + } + return body.clientModuleUrl !== context.clientModuleUrl ? body.clientModuleUrl : undefined + }, 'client hash changes after editing the client entry') + const next = await server.fetchPath(new URL(nextUrl).pathname) + assert.match(next.body, /client-v2/) + assert.equal((await server.fetchPath(clientPath)).status, 404) + + const reloadsBefore = events.filter((event) => event.type === 'rebuild_ok').length + await writeFile(join(root, 'public/app.css'), 'body { color: blue }\n') + await waitFor(async () => { + const response = await server.fetchPath('/_assets/app.css') + return response.body.includes('blue') ? true : undefined + }, 'asset edits are served immediately') + await waitFor( + async () => + events.filter((event) => event.type === 'rebuild_ok').length > reloadsBefore + ? true + : undefined, + 'an asset edit runs a (no-op) rebuild so live reload fires', + ) + }, + { + prepare: async (root) => { + await writeFile(join(root, 'app/entry.ts'), clientEntryApp) + await writeFile(join(root, 'app/client.ts'), clientSource('client-v1')) + await mkdir(join(root, 'public'), { recursive: true }) + await writeFile(join(root, 'public/app.css'), 'body { color: red }\n') + await editPackageJson(root, (kody) => { + kody.app = { + entry: './app/entry.ts', + client: { entry: './app/client.ts', externals: ['preact'] }, + assets: './public', + } + }) + }, + }, + ) +}) + +function clientSource(marker: string): string { + return `import { h } from 'preact'\nconsole.log(${JSON.stringify(marker)}, h)\n` +} + +test('kody.dev.prebuild runs before every build, without retriggering itself', async () => { + await withDevServer( + async ({ server, root, events, writeMessage }) => { + const first = await server.fetchPath('/') + assert.match(first.body, /v0 gen-1/) + + await writeMessage(`export const message = 'v1'\n`) + const next = await waitFor(async () => { + const response = await server.fetchPath('/') + return response.body.includes('v1') ? response : undefined + }, 'rebuild after an edit') + assert.match(next.body, /v1 gen-2/) + await new Promise((resolve) => setTimeout(resolve, 400)) + assert.equal((await readFile(join(root, 'count.txt'), 'utf8')).trim(), '2') + assert.ok( + events.some( + (event) => event.type === 'log' && event.source === 'prebuild' && /generated/.test(event.message), + ), + ) + + await writeFile(join(root, 'gen.sh'), 'echo "boom from prebuild" >&2\nexit 3\n') + await writeMessage(`export const message = 'v2'\n`) + const failed = await waitFor(async () => { + const response = await server.fetchPath('/') + return response.status === 500 ? response : undefined + }, 'prebuild failure becomes a build error') + assert.match(failed.body, /kody\.dev\.prebuild failed \(exit 3\): sh gen\.sh/) + assert.match(failed.body, /boom from prebuild/) + }, + { + prepare: async (root) => { + await writeFile( + join(root, 'gen.sh'), + [ + 'n=$(cat count.txt 2>/dev/null || echo 0)', + 'n=$((n + 1))', + 'echo "$n" > count.txt', + 'echo "export const gen = \'gen-$n\'" > app/generated.ts', + 'echo "generated gen-$n (KODY_DEV=$KODY_DEV)"', + '', + ].join('\n'), + ) + await writeFile( + join(root, 'app/entry.ts'), + `import { message } from './message.ts'\nimport { gen } from './generated.ts'\n\nexport default {\n async fetch(): Promise {\n return new Response(message + ' ' + gen)\n },\n}\n`, + ) + await editPackageJson(root, (kody) => { + kody.dev = { prebuild: 'sh gen.sh' } + }) + }, + }, + ) +}) + +test('kody dev refuses to start a second server for the same package unless --port is passed', async () => { + await withDevServer(async ({ server, root }) => { + await writeDevRegistration({ + pid: process.pid, + origin: server.hostedUrl, + port: server.port, + basePath: server.appBasePath, + target: 'mock', + root, + }) + await assert.rejects( + runDevServe({ + target: 'mock', + auth: { token: 'mock', apiUrl: 'https://api.kody.codes' }, + cwd: root, + io: { stdout: () => {}, stderr: () => {} }, + serveOnce: true, + }), + (error: unknown) => { + assert.ok(error instanceof StructuredCliError) + assert.equal(error.code, 'dev_already_running') + assert.match(error.message, new RegExp(`already running .* at ${server.hostedUrl}/ \\(pid ${process.pid}\\)`)) + assert.match(error.message, /kody dev fetch/) + return true + }, + ) + }) +}) diff --git a/test/dev-server-harness.ts b/test/dev-server-harness.ts index ca1a799..1639d3b 100644 --- a/test/dev-server-harness.ts +++ b/test/dev-server-harness.ts @@ -36,8 +36,12 @@ export async function withDevServer( events: Array writeMessage: (source: string) => Promise }) => Promise, - options: Partial = {}, + options: Partial & { + /** Runs after the fixture is copied and before the manifest is loaded. */ + prepare?: (root: string) => Promise + } = {}, ): Promise { + const { prepare, ...devOptions } = options // Resolve workerd from the real cache before KODY_CACHE_DIR is pointed at scratch. const workerdPath = await ensureWorkerdBinary() const root = await mkdtemp(join(tmpdir(), 'kody-dev-server-test-')) @@ -53,6 +57,7 @@ export async function withDevServer( await rename(`${messagePath}.next`, messagePath) } await writeMessage(`export const message = 'v0'\n`) + await prepare?.(root) const auth = await resolveDevAuth({ target: 'mock', token: 'mock', @@ -69,7 +74,7 @@ export async function withDevServer( debounceMs: 20, workerdPath, onEvent: (event) => events.push(event), - ...options, + ...devOptions, }) try { await run({ server, root, events, writeMessage }) From ff594a1d7649c55c05b41104c59b9a5a15de8753 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 05:24:28 +0000 Subject: [PATCH 02/13] fix(dev): never rebuild after close, so no esbuild context outlives the server A debounce timer that fired after close() created a fresh incremental context that was never disposed and kept Node alive. --- src/dev/dev-assets.ts | 4 ++++ src/dev/dev-bundler.ts | 4 ++++ src/package-app-dev.ts | 1 + test/dev-server.test.ts | 14 ++++++++++++++ 4 files changed, 23 insertions(+) diff --git a/src/dev/dev-assets.ts b/src/dev/dev-assets.ts index 2aa876a..9c1e46f 100644 --- a/src/dev/dev-assets.ts +++ b/src/dev/dev-assets.ts @@ -37,6 +37,7 @@ export function createDevClientBundler(input: { }): DevClientBundler { const externals = input.externals ?? [] const workingDir = process.cwd() + let disposed = false let context: Promise> | null = null const createContext = () => esbuild.context({ @@ -56,6 +57,8 @@ export function createDevClientBundler(input: { return { build: async () => { try { + // A context created after dispose would never be released and keeps Node alive. + if (disposed) throw new Error('kody dev bundler was already disposed.') context ??= createContext() const result = await (await context).rebuild() const code = result.outputFiles.find((file) => file.path.endsWith('.js'))?.text ?? '' @@ -76,6 +79,7 @@ export function createDevClientBundler(input: { } }, dispose: async () => { + disposed = true const current = context context = null await (await current?.catch(() => null))?.dispose() diff --git a/src/dev/dev-bundler.ts b/src/dev/dev-bundler.ts index fddb620..7a73cd4 100644 --- a/src/dev/dev-bundler.ts +++ b/src/dev/dev-bundler.ts @@ -36,12 +36,15 @@ export function createDevBundler(input: { entryPath: string; root: string }): De outfile: join(input.root, '__kody_dev_bundle.js'), } satisfies esbuild.BuildOptions const paths = { workingDir: base.absWorkingDir, displayRoot: input.root } + let disposed = false let context: Promise | null = null return { build: async () => { let result: esbuild.BuildResult<{ write: false; metafile: true }> try { + // A context created after dispose would never be released and keeps Node alive. + if (disposed) throw new Error('kody dev bundler was already disposed.') context ??= esbuild.context(options) result = await (await context).rebuild() } catch (error) { @@ -50,6 +53,7 @@ export function createDevBundler(input: { entryPath: string; root: string }): De return readBundle(result, base.absWorkingDir, input.entryPath) }, dispose: async () => { + disposed = true const current = context context = null await (await current?.catch(() => null))?.dispose() diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index e0c17b9..0f1157c 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -337,6 +337,7 @@ export async function startPackageAppDev( delete workerdEnv[apiTokenEnvVar] function requestBuild(paths: Iterable): Promise { + if (closed) return Promise.resolve() for (const path of paths) changedFiles.add(path) if (buildLoop) { rebuildAgain = true diff --git a/test/dev-server.test.ts b/test/dev-server.test.ts index 50ec63b..77dc6f3 100644 --- a/test/dev-server.test.ts +++ b/test/dev-server.test.ts @@ -71,6 +71,20 @@ test('kody dev swaps workerd without a 503 and recovers from build errors', asyn }) }) +test('a save just before close never starts a rebuild after close', async () => { + await withDevServer( + async ({ server, events, writeMessage }) => { + await writeMessage(`export const message = 'late'\n`) + await sleep(120) + await server.close() + const seen = events.length + await sleep(500) + assert.deepEqual(events.slice(seen), [], 'the pending debounce must not rebuild') + }, + { debounceMs: 300 }, + ) +}) + test('kody dev streams tagged console logs and source-maps runtime errors', async () => { await withDevServer(async ({ server, events }) => { const html = await server.fetchPath('/boom', { headers: { accept: 'text/html' } }) From 88c8456235172b9ece980a91d727a5bec1f18cc2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 06:14:46 +0000 Subject: [PATCH 03/13] fix(dev): validate kody.app.client objects, abort hung prebuild, keep partial prebuild lines Co-authored-by: Kent C. Dodds --- src/dev/prebuild.ts | 71 +++++++++++++++++++++++++++-------- src/package-app-dev.ts | 5 +++ src/package-manifest.ts | 37 +++++++++++++----- test/dev-ports-assets.test.ts | 33 ++++++++++++++++ 4 files changed, 121 insertions(+), 25 deletions(-) diff --git a/src/dev/prebuild.ts b/src/dev/prebuild.ts index 15b292d..525d727 100644 --- a/src/dev/prebuild.ts +++ b/src/dev/prebuild.ts @@ -5,7 +5,7 @@ */ import { spawn } from 'node:child_process' -import { createInterface } from 'node:readline' +import type { Readable } from 'node:stream' import { apiTokenEnvVar } from '../defaults.js' const outputTailLines = 20 @@ -23,7 +23,12 @@ export async function runPrebuild(input: { onSpawn?: (pid: number) => void onExit?: (pid: number) => void onLine?: (line: string, stream: 'stdout' | 'stderr') => void + /** When aborted, the child is SIGKILLed so graceful `kody dev` shutdown is not blocked. */ + signal?: AbortSignal }): Promise { + if (input.signal?.aborted) { + throw new PrebuildError('kody.dev.prebuild aborted before start') + } const env: NodeJS.ProcessEnv = { ...process.env, KODY_DEV: '1' } delete env[apiTokenEnvVar] const child = spawn('sh', ['-c', input.command], { @@ -38,21 +43,55 @@ export async function runPrebuild(input: { if (tail.length > outputTailLines) tail.shift() input.onLine?.(line, stream) } - createInterface({ input: child.stdout }).on('line', collect('stdout')) - createInterface({ input: child.stderr }).on('line', collect('stderr')) - const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( - (resolve, reject) => { - child.once('error', reject) - child.once('close', (code, signal) => resolve({ code, signal })) - }, - ).finally(() => { + forEachLine(child.stdout, collect('stdout')) + forEachLine(child.stderr, collect('stderr')) + + const killChild = () => { + if (child.exitCode !== null || child.signalCode !== null) return + child.kill('SIGKILL') + } + if (input.signal) { + if (input.signal.aborted) killChild() + else input.signal.addEventListener('abort', killChild, { once: true }) + } + + try { + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.once('error', reject) + child.once('close', (code, signal) => resolve({ code, signal })) + }, + ) + if (input.signal?.aborted) { + throw new PrebuildError('kody.dev.prebuild aborted') + } + if (result.code === 0) return + const how = result.signal ? `signal ${result.signal}` : `exit ${result.code}` + throw new PrebuildError( + [`kody.dev.prebuild failed (${how}): ${input.command}`, ...tail.map((line) => ` ${line}`)].join( + '\n', + ), + ) + } finally { + input.signal?.removeEventListener('abort', killChild) if (child.pid) input.onExit?.(child.pid) + } +} + +/** Split stream chunks into lines, including a final incomplete line on end. */ +function forEachLine(stream: Readable | null, onLine: (line: string) => void): void { + if (!stream) return + let buffered = '' + stream.on('data', (chunk: Buffer | string) => { + buffered += typeof chunk === 'string' ? chunk : chunk.toString('utf8') + const lines = buffered.split('\n') + buffered = lines.pop() ?? '' + for (const line of lines) { + if (line.length > 0) onLine(line) + } + }) + stream.on('end', () => { + if (buffered.length > 0) onLine(buffered) + buffered = '' }) - if (result.code === 0) return - const how = result.signal ? `signal ${result.signal}` : `exit ${result.code}` - throw new PrebuildError( - [`kody.dev.prebuild failed (${how}): ${input.command}`, ...tail.map((line) => ` ${line}`)].join( - '\n', - ), - ) } diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index 0f1157c..91c2182 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -333,6 +333,7 @@ export async function startPackageAppDev( const prebuild = options.manifest.dev?.prebuild let prebuildRunning = false let prebuildSettledAt = 0 + let prebuildAbort: AbortController | undefined const workerdEnv: NodeJS.ProcessEnv = { ...process.env, [runSecretEnvVar]: runSecret } delete workerdEnv[apiTokenEnvVar] @@ -432,10 +433,12 @@ export async function startPackageAppDev( async function runPrebuildStep(command: string): Promise { prebuildRunning = true + prebuildAbort = new AbortController() try { await runPrebuild({ command, cwd: root, + signal: prebuildAbort.signal, onSpawn: (pid) => reaper.track(pid), onExit: (pid) => reaper.untrack(pid), onLine: (line, stream) => @@ -448,6 +451,7 @@ export async function startPackageAppDev( }), }) } finally { + prebuildAbort = undefined prebuildRunning = false prebuildSettledAt = performance.now() } @@ -868,6 +872,7 @@ export async function startPackageAppDev( resolveDebounceFlush = null debounceFlush = null resolve?.() + prebuildAbort?.abort() await watcher?.close() await buildLoop?.catch(() => {}) await bundler.dispose() diff --git a/src/package-manifest.ts b/src/package-manifest.ts index 3792f6e..b817afe 100644 --- a/src/package-manifest.ts +++ b/src/package-manifest.ts @@ -188,16 +188,35 @@ function parseApp(raw: unknown, where: string): PackageAppManifest | undefined { fix: 'Set `"kody": { "app": { "entry": "./app/router.ts" } }` in package.json.', }) } - const clientRecord = asRecord(record.client) - const client = - typeof record.client === 'string' - ? record.client - : typeof clientRecord?.entry === 'string' - ? clientRecord.entry + let client: string | undefined + let clientExternals: Array | undefined + if (record.client !== undefined && record.client !== null) { + if (typeof record.client === 'string') { + if (!record.client.trim()) { + throw new StructuredCliError({ + code: 'package_manifest_invalid', + message: 'kody.app.client must be a non-empty string path or { entry, externals? }.', + where, + fix: 'Set `"client": "./app/client.ts"` or `"client": { "entry": "./app/client.ts" }`.', + }) + } + client = record.client + } else { + const clientRecord = asRecord(record.client) + if (!clientRecord || typeof clientRecord.entry !== 'string' || !clientRecord.entry.trim()) { + throw new StructuredCliError({ + code: 'package_manifest_invalid', + message: 'kody.app.client.entry must be a non-empty string path.', + where, + fix: 'Set `"client": { "entry": "./app/client.ts", "externals": ["preact"] }`.', + }) + } + client = clientRecord.entry + clientExternals = Array.isArray(clientRecord.externals) + ? clientRecord.externals.filter((item): item is string => typeof item === 'string') : undefined - const clientExternals = Array.isArray(clientRecord?.externals) - ? clientRecord.externals.filter((item): item is string => typeof item === 'string') - : undefined + } + } return { entry: record.entry, client, diff --git a/test/dev-ports-assets.test.ts b/test/dev-ports-assets.test.ts index 0f768ec..a3888bc 100644 --- a/test/dev-ports-assets.test.ts +++ b/test/dev-ports-assets.test.ts @@ -18,6 +18,7 @@ import { parseDevPort, } from '../src/dev/dev-port.js' import { writeDevRegistration } from '../src/dev/dev-registry.js' +import { PrebuildError, runPrebuild } from '../src/dev/prebuild.js' import { loadPackageManifest } from '../src/package-manifest.js' import { StructuredCliError } from '../src/structured-error.js' import { waitFor, withDevServer } from './dev-server-harness.js' @@ -145,6 +146,15 @@ test('loadPackageManifest reads kody.dev and the object form of kody.app.client' JSON.stringify({ name: 'm', kody: { id: 'm', dev: { ignore: 'public' } } }), ) await assert.rejects(loadPackageManifest(root), /kody\.dev\.ignore must be an array/) + + await writeFile( + join(root, 'package.json'), + JSON.stringify({ + name: 'm', + kody: { id: 'm', app: { entry: './app/entry.ts', client: { file: './app/client.ts' } } }, + }), + ) + await assert.rejects(loadPackageManifest(root), /kody\.app\.client\.entry must be a non-empty string/) } finally { await rm(root, { recursive: true, force: true }) } @@ -286,6 +296,29 @@ test('kody.dev.prebuild runs before every build, without retriggering itself', a ) }) +test('runPrebuild keeps a final incomplete stderr line and aborts on signal', async () => { + const root = await mkdtemp(join(tmpdir(), 'kody-prebuild-unit-')) + try { + await writeFile(join(root, 'partial.sh'), 'printf "no-newline-error" >&2\nexit 2\n') + await assert.rejects( + runPrebuild({ command: 'sh partial.sh', cwd: root }), + (error: unknown) => { + assert.ok(error instanceof PrebuildError) + assert.match(error.message, /no-newline-error/) + return true + }, + ) + + await writeFile(join(root, 'slow.sh'), 'sleep 30\n') + const abort = new AbortController() + const pending = runPrebuild({ command: 'sh slow.sh', cwd: root, signal: abort.signal }) + abort.abort() + await assert.rejects(pending, /kody\.dev\.prebuild aborted/) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + test('kody dev refuses to start a second server for the same package unless --port is passed', async () => { await withDevServer(async ({ server, root }) => { await writeDevRegistration({ From 601910fee8f0a0e50516ccd8a7f9d8ec1ca0dad1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 06:21:58 +0000 Subject: [PATCH 04/13] fix(dev): abort prebuild by killing the whole process group Co-authored-by: Kent C. Dodds --- src/dev/prebuild.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/dev/prebuild.ts b/src/dev/prebuild.ts index 525d727..61ec76d 100644 --- a/src/dev/prebuild.ts +++ b/src/dev/prebuild.ts @@ -31,10 +31,12 @@ export async function runPrebuild(input: { } const env: NodeJS.ProcessEnv = { ...process.env, KODY_DEV: '1' } delete env[apiTokenEnvVar] + // New process group so abort can SIGKILL the whole tree (sh + npm/node/…). const child = spawn('sh', ['-c', input.command], { cwd: input.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], + detached: true, }) if (child.pid) input.onSpawn?.(child.pid) const tail: Array = [] @@ -48,7 +50,17 @@ export async function runPrebuild(input: { const killChild = () => { if (child.exitCode !== null || child.signalCode !== null) return - child.kill('SIGKILL') + const pid = child.pid + if (pid === undefined) return + try { + process.kill(-pid, 'SIGKILL') + } catch { + try { + child.kill('SIGKILL') + } catch { + // already gone + } + } } if (input.signal) { if (input.signal.aborted) killChild() From 716124e3d47c59db66e7ed714a237b84189abbfb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 06:29:34 +0000 Subject: [PATCH 05/13] fix(dev): reaper kills detached prebuild process groups Bugbot: detached prebuild trees survived hard CLI death because the reaper only SIGKILLed the shell pid. trackGroup/untrackGroup tell the reaper to kill -pid so sh and its children die together. Co-authored-by: Kent C. Dodds --- src/dev/process-reaper.ts | 12 +++++++++++- src/package-app-dev.ts | 4 ++-- test/dev-server.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/dev/process-reaper.ts b/src/dev/process-reaper.ts index 65c6e3d..9b5be5e 100644 --- a/src/dev/process-reaper.ts +++ b/src/dev/process-reaper.ts @@ -27,13 +27,21 @@ while IFS= read -r line; do -*) pids="$(printf '%s' "$pids" | sed "s/ \${line#-} / /g")" ;; esac done -for pid in $pids; do kill -9 "$pid" 2>/dev/null; done +for entry in $pids; do + case "$entry" in + g*) kill -9 "-\${entry#g}" 2>/dev/null ;; + *) kill -9 "$entry" 2>/dev/null ;; + esac +done rm -rf "$1" ` export type ProcessReaper = { track: (pid: number) => void untrack: (pid: number) => void + /** Track a detached process-group leader; hard CLI death SIGKILLs the whole tree. */ + trackGroup: (pid: number) => void + untrackGroup: (pid: number) => void close: () => void } @@ -63,6 +71,8 @@ export async function startProcessReaper(input: { workDir: string }): Promise send(`+${pid}`), untrack: (pid) => send(`-${pid}`), + trackGroup: (pid) => send(`+g${pid}`), + untrackGroup: (pid) => send(`-g${pid}`), close: () => { reaper?.stdin?.end() reaper = undefined diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index 91c2182..5bc3ddc 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -439,8 +439,8 @@ export async function startPackageAppDev( command, cwd: root, signal: prebuildAbort.signal, - onSpawn: (pid) => reaper.track(pid), - onExit: (pid) => reaper.untrack(pid), + onSpawn: (pid) => reaper.trackGroup(pid), + onExit: (pid) => reaper.untrackGroup(pid), onLine: (line, stream) => emit({ type: 'log', diff --git a/test/dev-server.test.ts b/test/dev-server.test.ts index 77dc6f3..1243baa 100644 --- a/test/dev-server.test.ts +++ b/test/dev-server.test.ts @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readdir, rm, utimes, writeFile } from 'node:fs/promises import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' +import psList from 'ps-list' import { formatDevEvent, parseWorkerdStdoutLine } from '../src/dev/dev-events.js' import { appLogMarker } from '../src/dev/dev-protocol.js' import { shouldIgnoreWatchPath } from '../src/dev/dev-watcher.js' @@ -214,6 +215,32 @@ test('process reaper kills tracked pids and removes the work dir when its pipe c ) }) +test('process reaper kills a detached process group on pipe close', async () => { + const workDir = await mkdtemp(join(tmpdir(), 'kody-dev-reaper-group-')) + // Nested sleep under detached sh — killing only the shell would leave the child. + const shell = spawn('sh', ['-c', 'sleep 30'], { stdio: 'ignore', detached: true }) + assert.ok(shell.pid) + const childPid = await waitFor(async () => { + const listed = await psList() + const child = listed.find((item) => item.ppid === shell.pid && /sleep/.test(item.name)) + return child?.pid + }, 'detached sleep grandchild') + const shellExited = new Promise((resolve) => shell.once('exit', () => resolve())) + const reaper = await startProcessReaper({ workDir }) + reaper.trackGroup(shell.pid) + reaper.close() + await Promise.race([ + shellExited, + new Promise((resolve) => setTimeout(resolve, 5000).unref()), + ]) + assert.equal(isProcessAlive(shell.pid), false) + assert.equal(isProcessAlive(childPid), false) + await waitFor( + async () => ((await readdir(tmpdir())).includes(workDir.split('/').pop()!) ? undefined : true), + 'work dir removal', + ) +}) + test('sweepStaleDevDirs reaps dirs of dead owners and legacy orphans only', async () => { const tmp = await mkdtemp(join(tmpdir(), 'kody-dev-sweep-')) try { From c35b2edd57e3bea99e21e146df5a9b3e1c8224a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 06:32:24 +0000 Subject: [PATCH 06/13] fix(dev): portable process-group kill in the reaper Use /bin/kill -s KILL -- -pid for tracked groups, and wait for both the shell and grandchild to die in the regression test (CI flake). Co-authored-by: Kent C. Dodds --- src/dev/process-reaper.ts | 4 ++-- test/dev-server.test.ts | 12 +++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/dev/process-reaper.ts b/src/dev/process-reaper.ts index 9b5be5e..90da43c 100644 --- a/src/dev/process-reaper.ts +++ b/src/dev/process-reaper.ts @@ -29,8 +29,8 @@ while IFS= read -r line; do done for entry in $pids; do case "$entry" in - g*) kill -9 "-\${entry#g}" 2>/dev/null ;; - *) kill -9 "$entry" 2>/dev/null ;; + g*) /bin/kill -s KILL -- -\${entry#g} 2>/dev/null ;; + *) /bin/kill -s KILL -- \$entry 2>/dev/null ;; esac done rm -rf "$1" diff --git a/test/dev-server.test.ts b/test/dev-server.test.ts index 1243baa..037c171 100644 --- a/test/dev-server.test.ts +++ b/test/dev-server.test.ts @@ -220,21 +220,19 @@ test('process reaper kills a detached process group on pipe close', async () => // Nested sleep under detached sh — killing only the shell would leave the child. const shell = spawn('sh', ['-c', 'sleep 30'], { stdio: 'ignore', detached: true }) assert.ok(shell.pid) + shell.unref() const childPid = await waitFor(async () => { const listed = await psList() const child = listed.find((item) => item.ppid === shell.pid && /sleep/.test(item.name)) return child?.pid }, 'detached sleep grandchild') - const shellExited = new Promise((resolve) => shell.once('exit', () => resolve())) const reaper = await startProcessReaper({ workDir }) reaper.trackGroup(shell.pid) reaper.close() - await Promise.race([ - shellExited, - new Promise((resolve) => setTimeout(resolve, 5000).unref()), - ]) - assert.equal(isProcessAlive(shell.pid), false) - assert.equal(isProcessAlive(childPid), false) + await waitFor(async () => { + if (isProcessAlive(shell.pid!) || isProcessAlive(childPid)) return undefined + return true + }, 'detached process group death') await waitFor( async () => ((await readdir(tmpdir())).includes(workDir.split('/').pop()!) ? undefined : true), 'work dir removal', From 4358bb38c529daf5f41f1ab21c7341889a908a9d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 06:45:53 +0000 Subject: [PATCH 07/13] fix(dev): rebaseline watcher after prebuild before bundling Prebuild rewrites generated inputs; absorbing those fingerprints before esbuild runs keeps track()'s mid-build detection from queuing an extra rebuild (gen-3) on every edit. Co-authored-by: Kent C. Dodds --- src/package-app-dev.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index 5bc3ddc..24b4d18 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -364,7 +364,12 @@ export async function startPackageAppDev( assetsDir !== null && files.some((file) => file === assetsDir || file.startsWith(`${assetsDir}${sep}`)) try { - if (prebuild) await runPrebuildStep(prebuild) + if (prebuild) { + await runPrebuildStep(prebuild) + // Absorb prebuild output into the baseline so track() does not + // treat generated inputs as mid-build edits. + await watcher?.rebaseline() + } const [bundle, client] = await Promise.all([ bundler.build(), clientBundler ? clientBundler.build() : Promise.resolve(null), From d1030fc2282a5c18f3f429fa85413af54e892be4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 07:08:15 +0000 Subject: [PATCH 08/13] fix(dev): register the attach server as soon as the port binds Bugbot: write the registry entry from onListening (before the first build) so a second kody dev during startup hits dev_already_running instead of claiming the next port and overwriting the registration. Co-authored-by: Kent C. Dodds --- src/dev-command.ts | 80 ++++++++++++++++++++++++++++++++++++++++++ src/package-app-dev.ts | 15 ++++++++ test/dev-agent.test.ts | 9 +++++ 3 files changed, 104 insertions(+) diff --git a/src/dev-command.ts b/src/dev-command.ts index 207fc99..a236c3b 100644 --- a/src/dev-command.ts +++ b/src/dev-command.ts @@ -145,6 +145,7 @@ export async function runDevServe(input: { const onEvent = input.json ? (event: DevEvent) => writeJsonEvent(input.io, event) : (event: DevEvent) => writeDevEvent(input.io, event) +<<<<<<< HEAD const server = await startPackageAppDev({ manifest, token: input.auth.token, @@ -166,6 +167,85 @@ export async function runDevServe(input: { onError: (message) => input.io.stderr(`${message}\n`), onEvent, }) +======= + let registered = false + let server: Awaited> + try { + server = await startPackageAppDev({ + manifest, + token: input.auth.token, + apiUrl: input.auth.apiUrl, + target: input.target, + port: input.port, + defaultPort: defaultDevPort(manifest.kodyId), + appBasePath: input.appBasePath, + ...(input.auth.org ? { org: input.auth.org } : {}), + ...(input.auth.depResolve ? { depResolve: input.auth.depResolve } : {}), + ...(input.passthroughHosts?.length + ? { passthroughHosts: input.passthroughHosts } + : input.auth.passthroughHosts?.length + ? { 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, + // Register as soon as the port is bound so a second `kody dev` + // during the first build hits `dev_already_running` instead of + // stealing the next free port and overwriting the registry. + onListening: async ({ port, hostedUrl, appBasePath }) => { + await writeDevRegistration({ + pid: process.pid, + origin: hostedUrl, + port, + basePath: appBasePath, + target: input.target, + root: manifest.root, + }) + registered = true + }, + }) + } catch (error) { + if (registered) { + await removeDevRegistration(manifest.root, process.pid, input.target).catch(() => {}) + } + throw error + } + const status = server.status() + const statusUrl = `${server.hostedUrl}${devStatusPath}` + const ready = { + url: status.url, + port: server.port, + basePath: server.appBasePath, + pid: process.pid, + statusUrl, + } + 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`, + ) + } +>>>>>>> 783cf33 (fix(dev): register the attach server as soon as the port binds) const shutdown = async () => { await server.close() await removeDevRegistration(manifest.root, process.pid, input.target).catch(() => {}) diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index 24b4d18..5bdad8e 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -144,6 +144,16 @@ export type PackageAppDevOptions = { onError?: (message: string) => void /** Rebuilds, build/runtime errors, and app logs. */ onEvent?: (event: DevEvent) => void + /** + * Fired as soon as the HTTP port is bound (before the first build). Used + * to advertise the server in the attach registry so a second `kody dev` + * cannot start during the initial build window. + */ + onListening?: (info: { + port: number + hostedUrl: string + appBasePath: string + }) => void | Promise } export type PackageAppDevServer = { @@ -282,6 +292,11 @@ export async function startPackageAppDev( } const address = proxy.address() as AddressInfo const hostedUrl = `http://${host}:${address.port}` + await options.onListening?.({ + port: address.port, + hostedUrl, + appBasePath, + }) const contextFor = (client: DevClientBundle | null) => buildLocalPackageContext({ kodyId: options.manifest.kodyId, diff --git a/test/dev-agent.test.ts b/test/dev-agent.test.ts index 5bdd321..75185f5 100644 --- a/test/dev-agent.test.ts +++ b/test/dev-agent.test.ts @@ -75,6 +75,7 @@ test('kody dev --json emits NDJSON events and --ready-file is written before rea }) const lines: Array> = [] let readyFileAtReady: Record | null = null + let registeredAtRebuildStart = false let registeredAtReady = false try { const code = await runDevServe({ @@ -89,6 +90,9 @@ test('kody dev --json emits NDJSON events and --ready-file is written before rea for (const line of text.split('\n').filter(Boolean)) { const event = JSON.parse(line) as Record lines.push(event) + if (event.type === 'rebuild_start') { + registeredAtRebuildStart = existsSync(devRegistryPath(root, 'mock', cache)) + } if (event.type === 'ready') { readyFileAtReady = JSON.parse(readFileSync(readyFile, 'utf8')) as Record< string, @@ -113,6 +117,11 @@ test('kody dev --json emits NDJSON events and --ready-file is written before rea assert.match(String(ready.statusUrl), /\/__kody\/dev\/status$/) assert.ok(readyFileAtReady) assert.equal((readyFileAtReady as Record).url, ready.url) + assert.equal( + registeredAtRebuildStart, + true, + 'registry is advertised as soon as the port binds, before the first build finishes', + ) assert.equal(registeredAtReady, true) assert.equal(existsSync(readyFile), false, 'ready file is removed on shutdown') assert.equal( From 5b405d65fee08a0265aba23a35049e9391c739f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 07:15:47 +0000 Subject: [PATCH 09/13] fix(dev): rebaseline watcher after failed prebuild too A failing kody.dev.prebuild still writes files; absorb them into the watcher baseline in runPrebuildStep's finally so the 1s poll cannot loop rebuilds off that output. Co-authored-by: Kent C. Dodds --- src/package-app-dev.ts | 6 +++--- test/dev-ports-assets.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index 5bdad8e..5bc036d 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -381,9 +381,6 @@ export async function startPackageAppDev( try { if (prebuild) { await runPrebuildStep(prebuild) - // Absorb prebuild output into the baseline so track() does not - // treat generated inputs as mid-build edits. - await watcher?.rebaseline() } const [bundle, client] = await Promise.all([ bundler.build(), @@ -474,6 +471,9 @@ export async function startPackageAppDev( prebuildAbort = undefined prebuildRunning = false prebuildSettledAt = performance.now() + // Absorb writes even when prebuild fails — otherwise the 1s poll + // treats partial/failed output as new edits and loops rebuilds. + await watcher?.rebaseline() } } diff --git a/test/dev-ports-assets.test.ts b/test/dev-ports-assets.test.ts index a3888bc..862a522 100644 --- a/test/dev-ports-assets.test.ts +++ b/test/dev-ports-assets.test.ts @@ -270,6 +270,14 @@ test('kody.dev.prebuild runs before every build, without retriggering itself', a }, 'prebuild failure becomes a build error') assert.match(failed.body, /kody\.dev\.prebuild failed \(exit 3\): sh gen\.sh/) assert.match(failed.body, /boom from prebuild/) + const rebuildStartsAfterFailure = events.filter((event) => event.type === 'rebuild_start').length + await new Promise((resolve) => setTimeout(resolve, 1200)) + assert.equal( + events.filter((event) => event.type === 'rebuild_start').length, + rebuildStartsAfterFailure, + 'failed prebuild output must not retrigger the rebuild loop', + ) + assert.equal(server.status().state, 'build_error') }, { prepare: async (root) => { From e595efa20ed1851d0eaf946f3b22783c7c607efb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 07:26:07 +0000 Subject: [PATCH 10/13] fix(dev): drop leftover merge conflict markers in runDevServe Rebase onto the ready-file shutdown fix left conflict markers; keep onListening early registration plus shutdown-on-ready-file-error. Co-authored-by: Kent C. Dodds --- src/dev-command.ts | 68 ++-------------------------------------------- 1 file changed, 2 insertions(+), 66 deletions(-) diff --git a/src/dev-command.ts b/src/dev-command.ts index a236c3b..de6470f 100644 --- a/src/dev-command.ts +++ b/src/dev-command.ts @@ -145,29 +145,6 @@ export async function runDevServe(input: { const onEvent = input.json ? (event: DevEvent) => writeJsonEvent(input.io, event) : (event: DevEvent) => writeDevEvent(input.io, event) -<<<<<<< HEAD - const server = await startPackageAppDev({ - manifest, - token: input.auth.token, - apiUrl: input.auth.apiUrl, - target: input.target, - port: input.port, - defaultPort: defaultDevPort(manifest.kodyId), - appBasePath: input.appBasePath, - ...(input.auth.org ? { org: input.auth.org } : {}), - ...(input.auth.depResolve ? { depResolve: input.auth.depResolve } : {}), - ...(input.passthroughHosts?.length - ? { passthroughHosts: input.passthroughHosts } - : input.auth.passthroughHosts?.length - ? { 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, - }) -======= let registered = false let server: Awaited> try { @@ -210,42 +187,9 @@ export async function runDevServe(input: { if (registered) { await removeDevRegistration(manifest.root, process.pid, input.target).catch(() => {}) } + await input.auth.closeMock?.() throw error } - const status = server.status() - const statusUrl = `${server.hostedUrl}${devStatusPath}` - const ready = { - url: status.url, - port: server.port, - basePath: server.appBasePath, - pid: process.pid, - statusUrl, - } - 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`, - ) - } ->>>>>>> 783cf33 (fix(dev): register the attach server as soon as the port binds) const shutdown = async () => { await server.close() await removeDevRegistration(manifest.root, process.pid, input.target).catch(() => {}) @@ -262,14 +206,6 @@ export async function runDevServe(input: { 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`) @@ -296,7 +232,7 @@ export async function runDevServe(input: { ) } } catch (error) { - // Ready-file / registration failures must not leave the listen handles up. + // Ready-file failures must not leave the listen handles up. await shutdown().catch(() => {}) throw error } From fb400c00cbb0bdc0745d1bb11baeaa3d1eaa3697 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Thu, 15 Oct 2026 07:57:06 +0000 Subject: [PATCH 11/13] fix(dev): drop leftover merge conflict markers in runDevServe onChange Keep prebuild settle gating and fold mid-build watcher events into the active rebuild loop. Co-authored-by: Kent C. Dodds --- src/package-app-dev.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index 5bc036d..4256d2c 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -836,22 +836,16 @@ export async function startPackageAppDev( ignore: options.manifest.dev?.ignore, onChange: (path) => { if (closed) return -<<<<<<< HEAD - // 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 ( prebuildRunning || performance.now() - prebuildSettledAt < prebuildSettleMs ) { return } - // Fold mid-build chokidar echoes into the active - // loop. Debouncing them instead schedules a second - // rebuild after buildLoop clears (often a no-op). ->>>>>>> a65008c (feat(dev): stable per-package ports, client bundle + /_assets, kody.dev.prebuild, faster rebuilds) + // 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 From bf2c9974dad5d56297c4026dcab18a7ab889139b Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Thu, 15 Oct 2026 08:05:33 +0000 Subject: [PATCH 12/13] fix(dev): absorb watcher echoes after a failed prebuild Clear changedFiles/rebuildAgain and the debounce queue after rebaseline on prebuild failure so chokidar events from the failed run cannot restart the rebuild loop (CI: failed prebuild must not retrigger). Co-authored-by: Kent C. Dodds --- src/package-app-dev.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index 4256d2c..34fa043 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -448,9 +448,12 @@ export async function startPackageAppDev( } } + let clearWatcherPending: (() => void) | null = null + async function runPrebuildStep(command: string): Promise { prebuildRunning = true prebuildAbort = new AbortController() + let failed = false try { await runPrebuild({ command, @@ -467,6 +470,9 @@ export async function startPackageAppDev( request: null, }), }) + } catch (error) { + failed = true + throw error } finally { prebuildAbort = undefined prebuildRunning = false @@ -474,6 +480,13 @@ export async function startPackageAppDev( // Absorb writes even when prebuild fails — otherwise the 1s poll // treats partial/failed output as new edits and loops rebuilds. await watcher?.rebaseline() + if (failed) { + // Drop chokidar echoes of the failed prebuild / trigger burst so + // they cannot restart the loop after buildLoop clears. + changedFiles.clear() + rebuildAgain = false + clearWatcherPending?.() + } } } @@ -831,6 +844,15 @@ export async function startPackageAppDev( ? null : (() => { const pending = new Set() + clearWatcherPending = () => { + pending.clear() + clearTimeout(debounceTimer) + debouncePending = false + const resolve = resolveDebounceFlush + resolveDebounceFlush = null + debounceFlush = null + resolve?.() + } return createDevWatcher({ root, ignore: options.manifest.dev?.ignore, From 64d07c868b4874959ae161ad4486110e3eeb50eb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 08:22:45 +0000 Subject: [PATCH 13/13] fix(dev): skip attach registry on --port; close server if onListening throws Explicit --port starts a second server and must not overwrite the primary attach registry. If onListening fails after the port is bound, close the HTTP listen handle so the process does not leak. Co-authored-by: Kent C. Dodds --- src/dev-command.ts | 3 ++ src/package-app-dev.ts | 23 +++++++-- test/dev-ports-assets.test.ts | 97 +++++++++++++++++++++++++++++++++-- 3 files changed, 113 insertions(+), 10 deletions(-) diff --git a/src/dev-command.ts b/src/dev-command.ts index de6470f..097f522 100644 --- a/src/dev-command.ts +++ b/src/dev-command.ts @@ -171,7 +171,10 @@ export async function runDevServe(input: { // Register as soon as the port is bound so a second `kody dev` // during the first build hits `dev_already_running` instead of // stealing the next free port and overwriting the registry. + // Explicit `--port` starts a second server on purpose — leave the + // primary attach registry alone so fetch keeps talking to it. onListening: async ({ port, hostedUrl, appBasePath }) => { + if (input.port !== undefined) return await writeDevRegistration({ pid: process.pid, origin: hostedUrl, diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index 34fa043..3836509 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -292,11 +292,24 @@ export async function startPackageAppDev( } const address = proxy.address() as AddressInfo const hostedUrl = `http://${host}:${address.port}` - await options.onListening?.({ - port: address.port, - hostedUrl, - appBasePath, - }) + try { + await options.onListening?.({ + port: address.port, + hostedUrl, + appBasePath, + }) + } catch (error) { + // Port is bound but startPackageAppDev has not returned yet — close the + // listen handle now or the process stays alive after the throw. + process.off('exit', killAllSync) + reaper.close() + proxy.closeAllConnections() + await new Promise((resolve) => { + proxy.close(() => resolve()) + }) + await rm(workDir, { recursive: true, force: true }) + throw error + } const contextFor = (client: DevClientBundle | null) => buildLocalPackageContext({ kodyId: options.manifest.kodyId, diff --git a/test/dev-ports-assets.test.ts b/test/dev-ports-assets.test.ts index 862a522..85369d4 100644 --- a/test/dev-ports-assets.test.ts +++ b/test/dev-ports-assets.test.ts @@ -1,11 +1,12 @@ import assert from 'node:assert/strict' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { createServer, type Server } from 'node:http' import type { AddressInfo } from 'node:net' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { test } from 'node:test' -import { runDevServe } from '../src/dev-command.js' +import { fileURLToPath } from 'node:url' +import { resolveDevAuth, runDevServe } from '../src/dev-command.js' import { inferDevAssetContentType, parseDevAssetPath, @@ -17,11 +18,15 @@ import { listenDevServer, parseDevPort, } from '../src/dev/dev-port.js' -import { writeDevRegistration } from '../src/dev/dev-registry.js' +import { devRegistryPath, writeDevRegistration } from '../src/dev/dev-registry.js' import { PrebuildError, runPrebuild } from '../src/dev/prebuild.js' +import { startPackageAppDev } from '../src/package-app-dev.js' import { loadPackageManifest } from '../src/package-manifest.js' import { StructuredCliError } from '../src/structured-error.js' -import { waitFor, withDevServer } from './dev-server-harness.js' +import { ensureWorkerdBinary } from '../src/workerd-binary.js' +import { appEntry, waitFor, withDevServer } from './dev-server-harness.js' + +const fixtureRoot = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/sample-package') const clientEntryApp = `import { packageContext } from 'kody:runtime' @@ -355,3 +360,85 @@ test('kody dev refuses to start a second server for the same package unless --po ) }) }) + +test('kody dev --port leaves the primary attach registry alone', async () => { + await withDevServer(async ({ server, root }) => { + // Own the registry under a different live pid so shutdown of the + // second server cannot delete it via removeDevRegistration's pid check. + await writeDevRegistration({ + pid: process.ppid, + origin: server.hostedUrl, + port: server.port, + basePath: server.appBasePath, + target: 'mock', + root, + }) + const registryPath = devRegistryPath(root, 'mock') + const before = await readFile(registryPath, 'utf8') + const code = await runDevServe({ + target: 'mock', + auth: { token: 'mock', apiUrl: 'https://api.kody.codes' }, + port: 0, + cwd: root, + io: { stdout: () => {}, stderr: () => {} }, + serveOnce: true, + }) + assert.equal(code, 0) + assert.equal(await readFile(registryPath, 'utf8'), before) + }) +}) + +test('onListening failure closes the bound HTTP server', async () => { + const workerdPath = await ensureWorkerdBinary() + const root = await mkdtemp(join(tmpdir(), 'kody-dev-onlistening-')) + const cache = await mkdtemp(join(tmpdir(), 'kody-dev-onlistening-cache-')) + const previousCache = process.env.KODY_CACHE_DIR + process.env.KODY_CACHE_DIR = cache + let boundPort: number | undefined + try { + await cp(fixtureRoot, root, { recursive: true }) + await writeFile(join(root, 'app/entry.ts'), appEntry) + await writeFile(join(root, 'app/message.ts'), `export const message = 'v0'\n`) + const auth = await resolveDevAuth({ + target: 'mock', + token: 'mock', + apiUrl: 'https://api.kody.codes', + resetStorage: true, + packageId: 'local:sample-dev-package', + }) + try { + await assert.rejects( + startPackageAppDev({ + manifest: await loadPackageManifest(root), + token: auth.token, + apiUrl: auth.apiUrl, + target: 'mock', + workerdPath, + onListening: ({ port }) => { + boundPort = port + throw new Error('registry boom') + }, + }), + /registry boom/, + ) + assert.ok(boundPort !== undefined) + const probe = createServer() + try { + await listenDevServer(probe, { + host: '127.0.0.1', + port: boundPort!, + explicit: true, + }) + } finally { + await closeServer(probe) + } + } finally { + await auth.closeMock?.() + } + } finally { + if (previousCache === undefined) delete process.env.KODY_CACHE_DIR + else process.env.KODY_CACHE_DIR = previousCache + await rm(root, { recursive: true, force: true }) + await rm(cache, { recursive: true, force: true }) + } +})