From 60de2c7071cffd680e50099c5fc0bbec7108024b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 04:22:44 +0000 Subject: [PATCH 1/4] fix(dev): zero-downtime rebuilds, reliable watching, no orphans, real logs and errors - Start the new workerd and swap only once it listens; drain the old one. Requests during a rebuild wait for it instead of getting a 503. - Watch with chokidar plus a stat poll over the bundle graph so replaced files (sed -i, cp, atomic saves) can never leave a stale bundle serving. - Detached sh reaper kills workerd and removes the work dir if the CLI dies; startup sweeps kody-dev-* dirs and orphaned workerd from dead CLIs. - Stream app console.* (workerd stdout) tagged by request. - Build errors carry file:line:col + code frame, drop the 'for local execute' wording, and are served as a 500 page until fixed. - Uncaught handler errors return a dev-only 500 with the stack mapped to .ts sources; native frames and workerd C++ addresses are stripped. --- README.md | 26 ++ package.json | 2 + src/dev-command.ts | 20 +- src/dev/dev-bundler.ts | 70 ++++ src/dev/dev-events.ts | 118 ++++++ src/dev/dev-protocol.ts | 16 + src/dev/dev-watcher.ts | 113 +++++ src/dev/error-page.ts | 86 ++++ src/dev/process-reaper.ts | 164 ++++++++ src/dev/stack-trace.ts | 65 +++ src/dev/workerd-process.ts | 140 +++++++ src/help.ts | 5 + src/local-bundle.ts | 162 +++++-- src/package-app-dev.ts | 626 +++++++++++++++++++--------- src/package-local-runtime-source.ts | 74 +++- test/dev-server.test.ts | 321 ++++++++++++++ test/local-bundle.test.ts | 31 +- test/local-execute.test.ts | 2 +- 18 files changed, 1786 insertions(+), 255 deletions(-) create mode 100644 src/dev/dev-bundler.ts create mode 100644 src/dev/dev-events.ts create mode 100644 src/dev/dev-protocol.ts create mode 100644 src/dev/dev-watcher.ts create mode 100644 src/dev/error-page.ts create mode 100644 src/dev/process-reaper.ts create mode 100644 src/dev/stack-trace.ts create mode 100644 src/dev/workerd-process.ts create mode 100644 test/dev-server.test.ts diff --git a/README.md b/README.md index 08ce65e..9f4cf93 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,32 @@ kody execute --local --invoke kody:@scope/pkg/export --params '{}' kody search --target mock local ``` +### What `kody dev` does while it runs + +- **Rebuilds never drop requests.** A save starts a new workerd, and the proxy + switches to it only once it is listening. The old one finishes its in-flight + requests and then stops. Requests that arrive mid-rebuild wait for it, so you + never get a `503` or the previous bundle after a save. +- **Watching survives editors and scripts.** chokidar watches the package + (skipping `node_modules`, dotfiles, and `*.map`). A 1s stat poll over the + files in the current bundle graph backs it up, so replaced files (`sed -i`, + `cp`, atomic saves) are always picked up. +- **Build errors** print `file:line:col: message` with a code frame. Until the + next good build, every request gets a `500` error page with the same text + (HTML for browsers, plain text otherwise, header `x-kody-dev-error: build`). + The last good build does not keep answering `200`. +- **Runtime errors**: an uncaught throw from the fetch handler returns a + dev-only `500` page with the stack mapped back to your `.ts` sources + (`x-kody-dev-error: runtime`). Runtime wrapper and native frames are + dropped. The terminal prints the same stack. +- **Logs**: app `console.*` output streams to the terminal tagged with the + request, e.g. `[GET /scores #12] saved 3 rows`. `warn` and `error` go to + stderr. +- **Cleanup**: Ctrl-C, `SIGTERM`, and `SIGHUP` stop workerd and remove the + `kody-dev-*` temp dir. If the CLI is killed outright, a small reaper process + does the same. Each start also sweeps dirs and orphaned workerd processes + left behind by dead CLI runs, including older CLI versions. + ## Local execute `kody execute --local` runs the same execute module (default export called diff --git a/package.json b/package.json index 4d20e13..c3c579f 100644 --- a/package.json +++ b/package.json @@ -49,9 +49,11 @@ }, "dependencies": { "@inquirer/checkbox": "^5.2.2", + "@jridgewell/trace-mapping": "^0.3.31", "@modelcontextprotocol/client": "2.0.0", "@napi-rs/keyring": "^2.1.0", "add-mcp": "^2.4.0", + "chokidar": "^4.0.3", "es-module-lexer": "^3.0.3", "esbuild": "^0.25.12", "ps-list": "^9.0.0" diff --git a/src/dev-command.ts b/src/dev-command.ts index 0dda79e..08cabaf 100644 --- a/src/dev-command.ts +++ b/src/dev-command.ts @@ -25,6 +25,7 @@ import { StructuredCliError } from './structured-error.js' 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' export type DevIo = { stdout: (text: string) => void @@ -131,6 +132,7 @@ export async function runDevServe(input: { allowPrivateNetwork: input.allowPrivateNetwork, onStatus: (message) => input.io.stderr(`${message}\n`), onError: (message) => input.io.stderr(`${message}\n`), + onEvent: (event) => writeDevEvent(input.io, event), }) input.io.stdout( `kody dev listening on ${server.hostedUrl}${server.appBasePath || '/'}\n` + @@ -144,9 +146,9 @@ export async function runDevServe(input: { return 0 } await new Promise((resolveWait) => { + const signals: Array = ['SIGINT', 'SIGTERM', 'SIGHUP'] const stop = async () => { - process.off('SIGINT', onSig) - process.off('SIGTERM', onSig) + for (const signal of signals) process.off(signal, onSig) await server.close() await input.auth.closeMock?.() resolveWait() @@ -154,8 +156,7 @@ export async function runDevServe(input: { const onSig = () => { void stop() } - process.on('SIGINT', onSig) - process.on('SIGTERM', onSig) + for (const signal of signals) process.on(signal, onSig) }) return 0 } @@ -190,6 +191,12 @@ export async function runDevFetch(input: { watch: false, onStatus: (message) => input.io.stderr(`${message}\n`), onError: (message) => input.io.stderr(`${message}\n`), + onEvent: (event) => { + // stdout is reserved for the fetch result; logs and errors go to stderr. + if (event.type === 'log' || event.type === 'runtime_error' || event.type === 'build_error') { + input.io.stderr(`${formatDevEvent(event).text}\n`) + } + }, }) try { const result = await server.fetchPath(input.path, { @@ -638,6 +645,11 @@ async function printDevDryRun(input: { } } +function writeDevEvent(io: DevIo, event: DevEvent): void { + const { stream, text } = formatDevEvent(event) + io[stream](`${text}\n`) +} + /** Parse `kody dev` argv after the `dev` token. */ export function parseDevSubcommand(positionals: Array): { subcommand: 'serve' | 'fetch' | 'job' | 'webhook' | 'emit' | 'check' diff --git a/src/dev/dev-bundler.ts b/src/dev/dev-bundler.ts new file mode 100644 index 0000000..1611d1f --- /dev/null +++ b/src/dev/dev-bundler.ts @@ -0,0 +1,70 @@ +/** + * `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). + */ + +import * as esbuild from 'esbuild' +import { join, resolve } from 'node:path' +import { BundleError, createBundleError, localBundleOptions } from '../local-bundle.js' + +export type DevBundle = { + code: string + /** Source map JSON with sources relative to the package root. */ + sourceMap: string | null + /** Absolute paths of every non-node_modules input in the graph. */ + inputs: Array +} + +export type DevBundler = { + build: () => Promise + dispose: () => Promise +} + +export function createDevBundler(input: { entryPath: string; root: string }): DevBundler { + const entry = resolve(input.entryPath) + const base = localBundleOptions(entry) + const options = { + ...base, + sourcemap: 'external', + sourcesContent: false, + metafile: true, + // Never written (write: false); only anchors source map paths at the root. + outfile: join(input.root, '__kody_dev_bundle.js'), + } satisfies esbuild.BuildOptions + const paths = { workingDir: base.absWorkingDir, displayRoot: input.root } + + return { + build: async () => { + let result: esbuild.BuildResult + try { + result = await esbuild.build(options) + } catch (error) { + throw createBundleError(input.entryPath, error, paths) + } + return readBundle(result, base.absWorkingDir, input.entryPath) + }, + dispose: async () => {}, + } +} + +export function readBundle( + result: esbuild.BuildResult<{ metafile: true; write: false }>, + workingDir: string, + entryPath: string, +): DevBundle { + const outputs = result.outputFiles ?? [] + const js = outputs.find((file) => file.path.endsWith('.js')) + const map = outputs.find((file) => file.path.endsWith('.js.map')) + 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, + } +} diff --git a/src/dev/dev-events.ts b/src/dev/dev-events.ts new file mode 100644 index 0000000..319a21a --- /dev/null +++ b/src/dev/dev-events.ts @@ -0,0 +1,118 @@ +/** + * Events a running `kody dev` server reports: rebuilds, build and runtime + * errors, and app logs. Humans get {@link formatDevEvent}; agents get the + * same objects as NDJSON. + */ + +import { formatBundleDiagnostic, type BundleDiagnostic } from '../local-bundle.js' +import { appLogMarker } from './dev-protocol.js' + +export type DevRequestTag = { id: string; method: string; path: string } + +export type DevLogLevel = 'log' | 'info' | 'warn' | 'error' | 'debug' + +export type DevEvent = + | { type: 'rebuild_start'; files: Array } + | { type: 'rebuild_ok'; ms: number; buildId: number; restarted: boolean } + | { + type: 'build_error' + ms: number + message: string + file: string | null + line: number | null + col: number | null + diagnostics: Array + } + | { + type: 'runtime_error' + message: string + stack: string + request: DevRequestTag | null + } + | { + type: 'log' + source: 'app' | 'workerd' + level: DevLogLevel + message: string + request: DevRequestTag | null + } + +const logLevels = new Set(['log', 'info', 'warn', 'error', 'debug']) + +/** Parse one workerd stdout line; plain lines become untagged `log` events. */ +export function parseWorkerdStdoutLine(line: string): Extract { + if (line.startsWith(appLogMarker)) { + try { + const parsed = JSON.parse(line.slice(appLogMarker.length)) as { + level?: unknown + message?: unknown + request?: unknown + } + const level = + typeof parsed.level === 'string' && logLevels.has(parsed.level as DevLogLevel) + ? (parsed.level as DevLogLevel) + : 'log' + return { + type: 'log', + source: 'app', + level, + message: typeof parsed.message === 'string' ? parsed.message : String(parsed.message), + request: readRequestTag(parsed.request), + } + } catch {} + } + return { type: 'log', source: 'app', level: 'log', message: line, request: null } +} + +function readRequestTag(value: unknown): DevRequestTag | null { + if (!value || typeof value !== 'object') return null + const record = value as Record + if (typeof record.id !== 'string') return null + return { + id: record.id, + method: typeof record.method === 'string' ? record.method : 'GET', + path: typeof record.path === 'string' ? record.path : '/', + } +} + +export function formatRequestTag(request: DevRequestTag | null): string { + return request ? `[${request.method} ${request.path} #${request.id}]` : '[app]' +} + +/** One human-readable block for terminals. */ +export function formatDevEvent(event: DevEvent): { stream: 'stdout' | 'stderr'; text: string } { + switch (event.type) { + case 'rebuild_start': + return { + stream: 'stderr', + text: event.files.length > 0 ? `Rebuilding (${event.files.join(', ')})…` : 'Building…', + } + case 'rebuild_ok': + return { + stream: 'stderr', + text: event.restarted + ? `Ready in ${event.ms}ms (build ${event.buildId})` + : `No bundle changes (${event.ms}ms)`, + } + case 'build_error': + return { + stream: 'stderr', + text: `Build failed${event.diagnostics.length > 0 ? `:\n${event.diagnostics.map(formatBundleDiagnostic).join('\n')}` : `: ${event.message}`}\nThe last good build is paused until this is fixed; requests get the error page.`, + } + case 'runtime_error': + return { + stream: 'stderr', + text: `${formatRequestTag(event.request)} Uncaught ${event.stack || event.message}`, + } + case 'log': { + const tag = event.source === 'workerd' ? '[workerd]' : formatRequestTag(event.request) + 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}` } + } + default: { + const exhaustive: never = event + throw new Error(`Unhandled dev event: ${JSON.stringify(exhaustive)}`) + } + } +} diff --git a/src/dev/dev-protocol.ts b/src/dev/dev-protocol.ts new file mode 100644 index 0000000..1ee591a --- /dev/null +++ b/src/dev/dev-protocol.ts @@ -0,0 +1,16 @@ +/** + * Wire constants shared by the `kody dev` proxy (Node) and the dev entry that + * runs inside workerd. + */ + +/** + * Prefix the dev entry puts on every captured `console.*` line it writes to + * workerd stdout. Control characters keep it from colliding with app output. + */ +export const appLogMarker = '\u001fkody-dev-log\u001f' + +/** Proxy → workerd: short request id used to tag logs and errors. */ +export const devRequestIdHeader = 'x-kody-dev-request' + +/** workerd → proxy: the response carries a serialized uncaught handler error. */ +export const devErrorHeader = 'x-kody-dev-error' diff --git a/src/dev/dev-watcher.ts b/src/dev/dev-watcher.ts new file mode 100644 index 0000000..97b8284 --- /dev/null +++ b/src/dev/dev-watcher.ts @@ -0,0 +1,113 @@ +/** + * File watching for `kody dev`. chokidar watches the package tree (it re-arms + * per-directory watchers when editors or `sed -i` / `cp` replace a file with a + * new inode). A low-frequency stat poll over the files in the current bundle + * graph backs it up, so a missed event can never leave a stale bundle serving. + */ + +import { watch, type FSWatcher } from 'chokidar' +import { stat } from 'node:fs/promises' +import { relative, sep } from 'node:path' + +const defaultPollIntervalMs = 1000 + +export type DevWatcher = { + ready: Promise + /** Replace the set of absolute paths the stat poll keeps an eye on. */ + track: (paths: ReadonlyArray) => void + close: () => Promise +} + +export function createDevWatcher(input: { + root: string + onChange: (path: string) => void + /** Extra root-relative path prefixes to ignore. */ + ignore?: ReadonlyArray + pollIntervalMs?: number +}): DevWatcher { + const ignorePrefixes = (input.ignore ?? []).map(normalizePrefix).filter(Boolean) + const ignored = (path: string) => + shouldIgnoreWatchPath(relative(input.root, path), ignorePrefixes) + + const watcher: FSWatcher = watch(input.root, { + ignoreInitial: true, + ignored, + atomic: true, + }) + watcher.on('all', (_event, path) => { + if (!ignored(path)) input.onChange(path) + }) + watcher.on('error', () => {}) + const ready = new Promise((resolve) => watcher.once('ready', () => resolve())) + + type Fingerprint = string + let tracked = new Map() + let polling = false + const poll = async () => { + if (polling) return + polling = true + try { + for (const [path, previous] of tracked) { + const next = await fingerprint(path) + if (!tracked.has(path)) continue + if (previous !== null && next !== previous) { + tracked.set(path, next) + input.onChange(path) + } else if (previous === null) { + tracked.set(path, next) + } + } + } finally { + polling = false + } + } + const timer = setInterval(() => void poll(), input.pollIntervalMs ?? defaultPollIntervalMs) + timer.unref() + + return { + ready, + track: (paths) => { + const next = new Map() + for (const path of paths) { + if (ignored(path)) continue + next.set(path, tracked.get(path) ?? null) + } + tracked = next + void poll() + }, + close: async () => { + clearInterval(timer) + tracked = new Map() + await watcher.close() + }, + } +} + +async function fingerprint(path: string): Promise { + try { + const info = await stat(path) + return `${info.ino}:${info.size}:${info.mtimeMs}` + } catch { + return 'missing' + } +} + +function normalizePrefix(prefix: string): string { + return prefix.replace(/^\.\//, '').replace(/\/+$/, '').split('/').join(sep) +} + +export function shouldIgnoreWatchPath( + relativePath: string, + ignorePrefixes: ReadonlyArray = [], +): boolean { + if (!relativePath || relativePath === '.') return false + if (relativePath.startsWith('..')) return true + const segments = relativePath.split(sep) + if (segments.some((segment) => segment === 'node_modules' || segment.startsWith('.'))) { + return true + } + if (relativePath.endsWith('.map')) return true + return ignorePrefixes.some( + (prefix) => relativePath === prefix || relativePath.startsWith(`${prefix}${sep}`), + ) +} diff --git a/src/dev/error-page.ts b/src/dev/error-page.ts new file mode 100644 index 0000000..f6072f6 --- /dev/null +++ b/src/dev/error-page.ts @@ -0,0 +1,86 @@ +/** + * Dev-only error responses served by the `kody dev` proxy for build failures + * and uncaught handler errors. HTML for browsers, plain text for everyone else + * (curl, agents, `kody dev fetch`). + */ + +import type { IncomingMessage, ServerResponse } from 'node:http' + +export type DevErrorKind = 'build' | 'runtime' + +export type DevErrorView = { + kind: DevErrorKind + title: string + /** Preformatted body: diagnostics with code frames, or a mapped stack. */ + detail: string + /** `METHOD /path` for runtime errors. */ + request?: string +} + +export function wantsHtml(req: IncomingMessage): boolean { + const accept = req.headers.accept ?? '' + return /\btext\/html\b/.test(accept) +} + +export function sendDevError( + req: IncomingMessage, + res: ServerResponse, + status: number, + view: DevErrorView, + options: { htmlTail?: string } = {}, +): void { + if (res.headersSent) { + res.end() + return + } + const headers = { + 'cache-control': 'no-store', + 'x-kody-dev-error': view.kind, + } + if (wantsHtml(req)) { + res.writeHead(status, { ...headers, 'content-type': 'text/html; charset=utf-8' }) + res.end(renderDevErrorPage(view) + (options.htmlTail ?? '')) + return + } + res.writeHead(status, { ...headers, 'content-type': 'text/plain; charset=utf-8' }) + res.end(`kody dev ${view.kind} error${view.request ? ` (${view.request})` : ''}\n\n${view.detail}\n`) +} + +export function renderDevErrorPage(view: DevErrorView): string { + const label = view.kind === 'build' ? 'Build error' : 'Runtime error' + return ` + + + + +${escapeHtml(label)} · kody dev + + + +
+${escapeHtml(label)} +

${escapeHtml(view.title)}

${view.request ? `\n

${escapeHtml(view.request)}

` : ''} +
${escapeHtml(view.detail)}
+

Served by kody dev only; published apps never see this page.

+
+ + +` +} + +export function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} diff --git a/src/dev/process-reaper.ts b/src/dev/process-reaper.ts new file mode 100644 index 0000000..65c6e3d --- /dev/null +++ b/src/dev/process-reaper.ts @@ -0,0 +1,164 @@ +/** + * Keeps `kody dev` from leaking workerd processes and `/tmp/kody-dev-*` dirs. + * + * - A tiny detached `sh` reaper holds a pipe from this process. It learns + * workerd pids over that pipe and, when the pipe closes for any reason + * (including SIGKILL of the CLI), kills those pids and removes the work dir. + * - On startup, {@link sweepStaleDevDirs} removes dirs and workerd processes + * left behind by dead CLI processes (including CLIs that predate the reaper). + */ + +import { spawn, type ChildProcess } from 'node:child_process' +import { readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import psList from 'ps-list' + +export const devWorkDirPrefix = 'kody-dev-' +const ownerFileName = 'owner.json' +/** Legacy dirs without an owner file are only removed once this old. */ +const legacyDirMinAgeMs = 10 * 60_000 + +const reaperScript = ` +pids=" " +while IFS= read -r line; do + case "$line" in + +*) pids="$pids\${line#+} " ;; + -*) pids="$(printf '%s' "$pids" | sed "s/ \${line#-} / /g")" ;; + esac +done +for pid in $pids; do kill -9 "$pid" 2>/dev/null; done +rm -rf "$1" +` + +export type ProcessReaper = { + track: (pid: number) => void + untrack: (pid: number) => void + close: () => void +} + +export async function startProcessReaper(input: { workDir: string }): Promise { + await writeFile( + join(input.workDir, ownerFileName), + JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), + ) + let reaper: ChildProcess | undefined + try { + reaper = spawn('/bin/sh', ['-c', reaperScript, 'kody-dev-reaper', input.workDir], { + stdio: ['pipe', 'ignore', 'ignore'], + detached: true, + }) + reaper.on('error', () => { + reaper = undefined + }) + reaper.stdin?.on('error', () => {}) + reaper.unref() + ;(reaper.stdin as unknown as { unref?: () => void } | null)?.unref?.() + } catch { + reaper = undefined + } + const send = (line: string) => { + if (reaper?.stdin?.writable) reaper.stdin.write(`${line}\n`) + } + return { + track: (pid) => send(`+${pid}`), + untrack: (pid) => send(`-${pid}`), + close: () => { + reaper?.stdin?.end() + reaper = undefined + }, + } +} + +type ListedProcess = { pid: number; ppid?: number; name: string; cmd?: string } + +/** + * Remove `kody-dev-*` work dirs whose owner is gone and kill workerd processes + * still serving from them. Best effort; never throws. + */ +export async function sweepStaleDevDirs( + input: { + tmp?: string + now?: number + listProcesses?: () => Promise> + isAlive?: (pid: number) => boolean + kill?: (pid: number) => void + } = {}, +): Promise<{ removed: Array; killed: Array }> { + const tmp = input.tmp ?? tmpdir() + const now = input.now ?? Date.now() + const isAlive = input.isAlive ?? isProcessAlive + const kill = input.kill ?? ((pid: number) => process.kill(pid, 'SIGKILL')) + const removed: Array = [] + const killed: Array = [] + let entries: Array + try { + entries = (await readdir(tmp)).filter((name) => name.startsWith(devWorkDirPrefix)) + } catch { + return { removed, killed } + } + if (entries.length === 0) return { removed, killed } + let processes: Array = [] + try { + processes = await (input.listProcesses ?? psList)() + } catch { + processes = [] + } + const byPid = new Map(processes.map((item) => [item.pid, item])) + for (const name of entries) { + const dir = join(tmp, name) + try { + const owner = await readOwnerPid(dir) + if (owner !== null && isAlive(owner)) continue + const workerds = processes.filter( + (item) => /workerd/.test(item.name) && (item.cmd ?? '').includes(`${dir}/`), + ) + if (owner === null) { + const info = await stat(dir) + if (now - info.mtimeMs < legacyDirMinAgeMs) continue + // Legacy CLI without an owner file: only reap workerds whose parent died. + if (workerds.some((item) => !isOrphaned(item, byPid, isAlive))) continue + } + for (const item of workerds) { + try { + kill(item.pid) + killed.push(item.pid) + } catch {} + } + await rm(dir, { recursive: true, force: true }) + removed.push(dir) + } catch {} + } + return { removed, killed } +} + +function isOrphaned( + item: ListedProcess, + byPid: Map, + isAlive: (pid: number) => boolean, +): boolean { + if (item.ppid === undefined) return false + if (item.ppid <= 1 || !isAlive(item.ppid)) return true + const parent = byPid.get(item.ppid) + return parent !== undefined && !/node|kody/i.test(`${parent.name} ${parent.cmd ?? ''}`) +} + +async function readOwnerPid(dir: string): Promise { + try { + const parsed = JSON.parse(await readFile(join(dir, ownerFileName), 'utf8')) as { + pid?: unknown + } + return typeof parsed.pid === 'number' ? parsed.pid : null + } catch { + return null + } +} + +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} diff --git a/src/dev/stack-trace.ts b/src/dev/stack-trace.ts new file mode 100644 index 0000000..b30d74e --- /dev/null +++ b/src/dev/stack-trace.ts @@ -0,0 +1,65 @@ +/** + * Turn a workerd V8 stack (frames point into the bundled `main.js`) into one + * that points at the author's `.ts` sources, without runtime wrapper frames. + */ + +import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping' + +const internalModules = new Set([ + '__kody_local_entry.js', + 'kody:runtime', + '.__kody_runtime__.js', + 'runtime.js', + 'entry.js', +]) + +const framePattern = /^(\s*at\s+)(?:(.*?)\s+\()?([^()\s]+?):(\d+):(\d+)\)?\s*$/ + +export type StackMapper = (stack: string) => string + +export function createStackMapper(input: { + /** esbuild source map JSON for the user module, sources relative to the package root. */ + sourceMap: string | null + /** Module name of the bundled user code inside workerd. */ + userModule: string +}): StackMapper { + let map: TraceMap | null = null + if (input.sourceMap) { + try { + map = new TraceMap(input.sourceMap) + } catch { + map = null + } + } + return (stack) => { + const out: Array = [] + for (const line of stack.split('\n')) { + if (!/^\s*at\s/.test(line)) { + out.push(line) + continue + } + const match = framePattern.exec(line) + if (!match) continue + const [, lead, fn, file, lineText, colText] = match + const module = file!.replace(/^file:\/\/\//, '').replace(/^\//, '') + if (internalModules.has(module)) continue + const fnPart = fn ? `${fn} (` : '' + const close = fn ? ')' : '' + if (map && module === input.userModule) { + const original = originalPositionFor(map, { + line: Number(lineText), + column: Number(colText) - 1, + }) + if (original.source && original.line !== null) { + const name = fn ?? original.name + out.push( + `${lead}${name ? `${name} (` : ''}${original.source}:${original.line}:${(original.column ?? 0) + 1}${name ? ')' : ''}`, + ) + continue + } + } + out.push(`${lead}${fnPart}${file}:${lineText}:${colText}${close}`) + } + return out.join('\n') + } +} diff --git a/src/dev/workerd-process.ts b/src/dev/workerd-process.ts new file mode 100644 index 0000000..1a02275 --- /dev/null +++ b/src/dev/workerd-process.ts @@ -0,0 +1,140 @@ +/** + * One workerd `serve` process for `kody dev`: spawn, wait for the control-fd + * `listen` event, stream stdout/stderr line by line, and stop it. + */ + +import { spawn, type ChildProcess } from 'node:child_process' +import { createInterface } from 'node:readline' +import type { Readable } from 'node:stream' +import { redact } from '../redact.js' + +const workerdStartTimeoutMs = 30_000 + +export type WorkerdHandle = { + pid: number + port: number + /** Resolves with a short exit description once the process is gone. */ + exited: Promise + kill: () => Promise +} + +export async function startWorkerd(input: { + workerdPath: string + configPath: string + cwd: string + env: NodeJS.ProcessEnv + /** Called synchronously after spawn so callers can track the pid before listen. */ + onSpawn?: (pid: number) => void + onStdoutLine: (line: string) => void + onStderrLine: (line: string) => void +}): Promise { + const child = spawn(input.workerdPath, ['serve', input.configPath, '--control-fd=3'], { + cwd: input.cwd, + env: input.env, + stdio: ['ignore', 'pipe', 'pipe', 'pipe'], + }) + if (child.pid !== undefined) input.onSpawn?.(child.pid) + + let startupStderr = '' + let listening = false + forEachLine(child.stdout, input.onStdoutLine) + forEachLine(child.stderr, (line) => { + if (!listening) startupStderr += `${line}\n` + input.onStderrLine(line) + }) + + const exited = new Promise((resolve) => { + child.once('exit', (code, signal) => resolve(`workerd exit ${signal ?? code}`)) + child.once('error', (error) => resolve(`workerd error: ${error.message}`)) + }) + + const port = await waitForListen(child, exited, () => { + const trimmed = redact(startupStderr.trim()) + return trimmed ? `\n${trimmed}` : '' + }) + listening = true + return { + pid: child.pid ?? -1, + port, + exited, + kill: async () => { + if (child.exitCode !== null || child.signalCode !== null) return + child.kill('SIGKILL') + await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 1000))]) + }, + } +} + +function forEachLine(stream: Readable | null, onLine: (line: string) => void): void { + if (!stream) return + const lines = createInterface({ input: stream, crlfDelay: Infinity }) + lines.on('line', (line) => { + if (line.length > 0) onLine(line) + }) +} + +function waitForListen( + child: ChildProcess, + exited: Promise, + stderr: () => string, +): Promise { + return new Promise((resolve, reject) => { + const control = child.stdio[3] as Readable | null + let buffered = '' + let settled = false + const settle = (fn: () => void) => { + if (settled) return + settled = true + clearTimeout(timer) + fn() + } + const timer = setTimeout(() => { + settle(() => { + child.kill('SIGKILL') + reject( + new Error(`workerd did not start within ${workerdStartTimeoutMs / 1000}s.${stderr()}`), + ) + }) + }, workerdStartTimeoutMs) + control?.on('data', (chunk: Buffer) => { + buffered += chunk.toString('utf8') + const lines = buffered.split('\n') + buffered = lines.pop() ?? '' + for (const line of lines) { + const port = parseListenPort(line) + if (port !== null) settle(() => resolve(port)) + } + }) + void exited.then((detail) => { + settle(() => reject(new Error(`workerd failed to start (${detail})${stderr()}`))) + }) + }) +} + +export function parseListenPort(line: string): number | null { + if (!line.trim()) return null + try { + const event = JSON.parse(line) as { event?: string; socket?: string; port?: number } + return event.event === 'listen' && event.socket === 'http' && typeof event.port === 'number' + ? event.port + : null + } catch { + return null + } +} + +/** + * Make workerd stderr readable: drop native `stack: …workerd@0x…` address + * lines and the C++ source prefix on uncaught-exception lines. Returns null + * for lines that carry nothing for a JS developer. + */ +export function cleanWorkerdStderrLine(line: string): string | null { + const trimmed = line.trim() + if (!trimmed) return null + if (/^stack:(\s+\S+@[0-9a-fx]+)+\s*$/i.test(trimmed)) return null + const prefixed = /^workerd\/\S+:\d+: (?:error|warning|info|debug): (.*)$/.exec(trimmed) + const body = prefixed ? prefixed[1]! : trimmed + return body + .replace(/\S+\.(?:c\+\+|h):\d+: failed: /g, '') + .replace(/(?:remote\.)?jsg\.(\w*Error)(?=:)/g, '$1') +} diff --git a/src/help.ts b/src/help.ts index 0b8c038..404d3ef 100644 --- a/src/help.ts +++ b/src/help.ts @@ -282,6 +282,11 @@ ${orgBlock} kody dev Local testing for unpublished packages. Serves \`kody.app.entry\` on localhost via workerd with hot reload (mount-stripped path; packageContext.hostedUrl / appBasePath point at localhost). + Rebuilds swap to a new workerd only once it listens (requests + wait; no 503 or stale bundle). Build errors (file:line:col + + code frame) and uncaught handler errors (stack mapped to your + .ts sources) are served as dev-only 500 pages until fixed. App + console.* streams to the terminal tagged by request. Bundles with the same esbuild path as publish (\`local-bundle\`). Four surfaces: exports (\`execute --invoke ./export\` or \`--file\` against the local package), webhooks diff --git a/src/local-bundle.ts b/src/local-bundle.ts index bcb9b2a..6857884 100644 --- a/src/local-bundle.ts +++ b/src/local-bundle.ts @@ -1,67 +1,161 @@ import * as esbuild from 'esbuild' -import { resolve } from 'node:path' +import { relative, resolve } from 'node:path' + +/** One esbuild diagnostic with a 1-based line/column and a code frame. */ +export type BundleDiagnostic = { + /** Path relative to the working directory, or null for location-less errors. */ + file: string | null + line: number | null + col: number | null + message: string + /** Source line plus caret, or null when esbuild gave no location. */ + frame: string | null +} + +/** Thrown when esbuild fails; `diagnostics` keeps the structured locations. */ +export class BundleError extends Error { + readonly diagnostics: Array + constructor(message: string, diagnostics: Array) { + super(message) + this.name = 'BundleError' + this.diagnostics = diagnostics + } +} + +/** + * esbuild options shared by one-shot bundles and `kody dev` rebuilds: follow + * relative imports and npm packages, keep `kody:*` external for package-graph + * + runtime binding, and leave `node:*` external for workerd `nodejs_compat`. + */ +export function localBundleOptions(entry: string) { + return { + absWorkingDir: process.cwd(), + entryPoints: [entry], + bundle: true, + write: false, + format: 'esm', + platform: 'neutral', + target: 'esnext', + logLevel: 'silent', + packages: 'bundle', + external: ['kody:*', 'node:*'], + } satisfies esbuild.BuildOptions +} /** * Bundle a `--file` entry for local workerd the same way hosted execute / - * publish resolves code: follow relative imports and npm packages, keep - * `kody:*` external for package-graph + runtime binding, and leave `node:*` - * external for workerd `nodejs_compat` (same as unbundled `--code`). + * publish resolves code. */ export async function bundleLocalUserModule(modulePath: string): Promise { const entry = resolve(modulePath) let result: esbuild.BuildResult try { - result = await esbuild.build({ - absWorkingDir: process.cwd(), - entryPoints: [entry], - bundle: true, - write: false, - format: 'esm', - platform: 'neutral', - target: 'esnext', - logLevel: 'silent', - packages: 'bundle', - external: ['kody:*', 'node:*'], - }) + result = await esbuild.build(localBundleOptions(entry)) } catch (error) { - throw new Error(formatBundleError(modulePath, error)) + throw createBundleError(modulePath, error) } const output = result.outputFiles?.[0] if (output === undefined) { - throw new Error( - `Could not bundle ${modulePath} for local execute: esbuild produced no output.`, - ) + throw new BundleError(`Could not bundle ${modulePath}: esbuild produced no output.`, []) } if (result.errors.length > 0) { - throw new Error(formatBundleError(modulePath, { errors: result.errors })) + throw createBundleError(modulePath, { errors: result.errors }) } return output.text } -function formatBundleError(modulePath: string, error: unknown): string { - const details = bundleErrorDetails(error) - const header = `Could not bundle ${modulePath} for local execute.` +/** + * `workingDir` is esbuild's `absWorkingDir` (locations are relative to it); + * `displayRoot` is what reported paths are relative to. + */ +export function createBundleError( + modulePath: string, + error: unknown, + paths: { workingDir: string; displayRoot: string } = { + workingDir: process.cwd(), + displayRoot: process.cwd(), + }, +): BundleError { + const diagnostics = bundleDiagnostics(error, paths) + const details = diagnostics.map(formatBundleDiagnostic).join('\n') + const header = `Could not bundle ${modulePath}.` const body = details.length > 0 ? `${header}\n${details}` : header + const messages = diagnostics.map((item) => item.message).join('\n') // Only when the unresolved specifier itself is kody:@ (not a path that happens // to contain that substring). - if (/Could not resolve ["']kody:@/i.test(details)) { - return `${body}\nfix: Map the dependency in .kody/deps.json (or package.json#kody.localDeps), keep it in this package's exports, or log in so published package-graph can supply it.` + if (/Could not resolve ["']kody:@/i.test(messages)) { + return new BundleError( + `${body}\nfix: Map the dependency in .kody/deps.json (or package.json#kody.localDeps), keep it in this package's exports, or log in so published package-graph can supply it.`, + diagnostics, + ) } if (/Could not resolve|Cannot find module|ENOENT/i.test(body)) { - return `${body}\nfix: Run \`npm install\` in the package directory (and any local deps), then retry.` + return new BundleError( + `${body}\nfix: Run \`npm install\` in the package directory (and any local deps), then retry.`, + diagnostics, + ) } - return body + return new BundleError(body, diagnostics) +} + +/** `file:line:col: message` followed by an indented code frame. */ +export function formatBundleDiagnostic(item: BundleDiagnostic): string { + const where = + item.file !== null + ? `${item.file}${item.line !== null ? `:${item.line}` : ''}${item.col !== null ? `:${item.col}` : ''}: ` + : '' + return `${where}${item.message}${item.frame ? `\n${item.frame}` : ''}` } -function bundleErrorDetails(error: unknown): string { +function bundleDiagnostics( + error: unknown, + paths: { workingDir: string; displayRoot: string }, +): Array { if (isEsbuildFailure(error)) { return error.errors - .map((item) => item.text.trim()) - .filter((text) => text.length > 0) - .join('\n') + .filter((item) => item.text.trim().length > 0) + .map((item) => toDiagnostic(item, paths)) + } + return [ + { + file: null, + line: null, + col: null, + message: error instanceof Error ? error.message : String(error), + frame: null, + }, + ] +} + +function toDiagnostic( + item: esbuild.Message, + paths: { workingDir: string; displayRoot: string }, +): BundleDiagnostic { + const location = item.location + if (!location) { + return { file: null, line: null, col: null, message: item.text.trim(), frame: null } + } + const absolute = resolve(paths.workingDir, location.file) + const fromRoot = relative(paths.displayRoot, absolute) + const file = fromRoot && !fromRoot.startsWith('..') ? fromRoot : absolute + return { + file, + line: location.line, + col: location.column + 1, + message: item.text.trim(), + frame: codeFrame(location), } - if (error instanceof Error) return error.message - return String(error) +} + +function codeFrame(location: esbuild.Location): string | null { + if (!location.lineText) return null + const gutter = String(location.line) + const pad = ' '.repeat(gutter.length) + const caretWidth = Math.max(1, Math.min(location.length || 1, location.lineText.length)) + return ( + ` ${gutter} | ${location.lineText}\n` + + ` ${pad} | ${' '.repeat(location.column)}${'^'.repeat(caretWidth)}` + ) } function isEsbuildFailure(error: unknown): error is esbuild.BuildFailure { diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index 610c5d6..4ace706 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -1,12 +1,16 @@ /** * Long-running `kody dev` app server: bundles `kody.app.entry` the same way - * local-bundle/publish does for esbuild, serves via workerd, strips the mount - * at the Node proxy, and rebuilds on file changes. + * local-bundle/publish does for esbuild, serves via workerd behind a Node + * proxy that strips the mount, and rebuilds on file changes. + * + * Each successful rebuild starts a new workerd "generation" and only swaps the + * proxy over once it is listening; the previous generation drains in-flight + * requests and is then stopped. Requests that arrive mid-rebuild wait for it, + * so a save is never answered by a 503 or a stale bundle. */ -import { spawn, type ChildProcess } from 'node:child_process' import { randomBytes } from 'node:crypto' -import { watch } from 'node:fs' +import { rmSync } from 'node:fs' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { createServer, @@ -17,14 +21,29 @@ import { } from 'node:http' import type { AddressInfo } from 'node:net' import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { Readable } from 'node:stream' +import { join, relative } from 'node:path' import { assertTokenSafeApiUrl, callCapabilityProxy, } from './capability-proxy.js' import { apiTokenEnvVar } from './defaults.js' -import { bundleLocalUserModule } from './local-bundle.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 { createDevWatcher, type DevWatcher } from './dev/dev-watcher.js' +import { sendDevError, type DevErrorView } from './dev/error-page.js' +import { + devWorkDirPrefix, + startProcessReaper, + sweepStaleDevDirs, +} from './dev/process-reaper.js' +import { createStackMapper, type StackMapper } from './dev/stack-trace.js' +import { + cleanWorkerdStderrLine, + startWorkerd, + type WorkerdHandle, +} from './dev/workerd-process.js' +import { BundleError, formatBundleDiagnostic, type BundleDiagnostic } from './local-bundle.js' import { localExecuteGatewayFetchShimModuleName, resolveLocalPackageGraph, @@ -84,10 +103,17 @@ export type PackageAppDevOptions = { passthroughHosts?: ReadonlyArray workerdPath?: string fetchFn?: typeof fetch - /** Hot reload via fs.watch (default true). One-shot `dev fetch` turns this off. */ + /** + * Rebuild on file changes (default true). One-shot `dev fetch` turns this + * off, which also makes an initial build failure throw. + */ watch?: boolean + /** Quiet period after the last file event before rebuilding. */ + debounceMs?: number onStatus?: (message: string) => void onError?: (message: string) => void + /** Rebuilds, build/runtime errors, and app logs. */ + onEvent?: (event: DevEvent) => void } export type PackageAppDevServer = { @@ -105,7 +131,27 @@ export type PackageAppDevServer = { type Bridge = { port: number; close: () => Promise } -const workerdStartTimeoutMs = 30_000 +type Generation = { + id: number + dir: string + workerd: WorkerdHandle + code: string + mapStack: StackMapper + inflight: number + retired: boolean + stopped: boolean +} + +type BuildFailure = { + message: string + diagnostics: Array +} + +const defaultDebounceMs = 120 +/** 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. */ +const drainTimeoutMs = 10_000 export async function startPackageAppDev( options: PackageAppDevOptions, @@ -119,8 +165,12 @@ export async function startPackageAppDev( const host = options.host ?? '127.0.0.1' const appBasePath = normalizeBasePath(options.appBasePath ?? '') + const root = options.manifest.root const runSecret = randomBytes(32).toString('hex') - const workDir = await mkdtemp(join(tmpdir(), 'kody-dev-')) + const emit = (event: DevEvent) => options.onEvent?.(event) + void sweepStaleDevDirs().catch(() => {}) + const workDir = await mkdtemp(join(tmpdir(), devWorkDirPrefix)) + const reaper = await startProcessReaper({ workDir }) const workerdPath = options.workerdPath ?? (await ensureWorkerdBinary({ @@ -134,18 +184,35 @@ export async function startPackageAppDev( ...(options.org ? { org: options.org } : {}), } - let bridge: Bridge | undefined - let child: ChildProcess | undefined - let workerdPort: number | undefined - let reloadTimer: NodeJS.Timeout | undefined + const generations = new Set() + let active: Generation | undefined + let buildFailure: BuildFailure | null = null + let generationCounter = 0 + let requestCounter = 0 let closed = false - /** Last workerd stderr chunk(s) — surfaced when the app returns HTTP 5xx. */ - let lastWorkerdStderr = '' + let buildLoop: Promise | null = null + let rebuildAgain = false + const changedFiles = new Set() + let markStarted: () => void = () => {} + const started = new Promise((resolve) => { + markStarted = resolve + }) + + const killAllSync = () => { + for (const generation of generations) { + try { + process.kill(generation.workerd.pid, 'SIGKILL') + } catch {} + } + try { + rmSync(workDir, { recursive: true, force: true }) + } catch {} + } + process.once('exit', killAllSync) const proxy = createServer((req, res) => { - void proxyRequest(req, res) + void handleRequest(req, res) }) - await new Promise((resolve, reject) => { proxy.once('error', reject) proxy.listen(options.port ?? 0, host, () => resolve()) @@ -161,13 +228,13 @@ export async function startPackageAppDev( const mockFetchGateway = await resolveMockFetchGateway({ target: options.target, - packageRoot: options.manifest.root, + packageRoot: root, passthroughHosts: options.passthroughHosts, allowPrivateNetwork: options.allowPrivateNetwork, onStatus: options.onStatus, }) - bridge = await startBridge({ + const bridge: Bridge = await startBridge({ runSecret, forward: async (call, signal) => { const mocked = tryHandleMockGatewayFetchCall({ @@ -186,17 +253,80 @@ export async function startPackageAppDev( }) const entryPath = resolvePackagePath(options.manifest, options.manifest.app.entry) + const bundler = createDevBundler({ entryPath, root }) + const workerdEnv: NodeJS.ProcessEnv = { ...process.env, [runSecretEnvVar]: runSecret } + delete workerdEnv[apiTokenEnvVar] + + function requestBuild(paths: Iterable): Promise { + for (const path of paths) changedFiles.add(path) + if (buildLoop) { + rebuildAgain = true + return buildLoop + } + buildLoop = (async () => { + do { + rebuildAgain = false + const files = [...changedFiles] + changedFiles.clear() + await buildOnce(files) + } while (rebuildAgain && !closed) + })().finally(() => { + buildLoop = null + }) + return buildLoop + } + + 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 + try { + bundle = await bundler.build() + watcher?.track([...bundle.inputs, options.manifest.packageJsonPath]) + if (active && !buildFailure && bundle.code === active.code) { + emit({ + type: 'rebuild_ok', + ms: elapsed(started), + buildId: active.id, + restarted: false, + }) + return + } + const generation = await startGeneration(bundle) + if (closed) { + await stopGeneration(generation) + return + } + const previous = active + active = generation + buildFailure = null + if (previous) retireGeneration(previous) + emit({ type: 'rebuild_ok', ms: elapsed(started), buildId: generation.id, restarted: true }) + } catch (error) { + buildFailure = toBuildFailure(error) + const [first] = buildFailure.diagnostics + emit({ + type: 'build_error', + ms: elapsed(started), + message: buildFailure.message, + file: first?.file ?? null, + line: first?.line ?? null, + col: first?.col ?? null, + diagnostics: buildFailure.diagnostics, + }) + } + } - async function rebuildAndServe(): Promise { - options.onStatus?.(`Bundling ${entryPath}…`) - const code = await bundleLocalUserModule(entryPath) + async function startGeneration(bundle: DevBundle): Promise { + const id = ++generationCounter + const dir = join(workDir, `g${id}`) await writeWorkerFiles({ - workDir, - code, + workDir: dir, + code: bundle.code, packageContext, - packageRoot: options.manifest.root, + packageRoot: root, mockStorage: options.target === 'mock', - bridgePort: bridge!.port, + bridgePort: bridge.port, allowPrivateNetwork: options.allowPrivateNetwork, apiUrl: options.apiUrl, token: options.token, @@ -206,124 +336,251 @@ export async function startPackageAppDev( mockFetchGateway, onStatus: options.onStatus, }) - await restartWorkerd() - } - - async function restartWorkerd(): Promise { - if (child) { - child.kill('SIGKILL') - child = undefined - workerdPort = undefined - } - const env: NodeJS.ProcessEnv = { ...process.env, [runSecretEnvVar]: runSecret } - delete env[apiTokenEnvVar] - const configPath = join(workDir, 'config.capnp') - lastWorkerdStderr = '' - child = spawn(workerdPath, ['serve', configPath, '--control-fd=3'], { - cwd: workDir, - env, - stdio: ['ignore', 'pipe', 'pipe', 'pipe'], + const workerd = await startWorkerd({ + workerdPath, + configPath: join(dir, 'config.capnp'), + cwd: dir, + env: workerdEnv, + onSpawn: (pid) => reaper.track(pid), + onStdoutLine: (line) => emit(parseWorkerdStdoutLine(line)), + onStderrLine: (line) => { + const cleaned = cleanWorkerdStderrLine(line) + if (cleaned === null) return + emit({ + type: 'log', + source: 'workerd', + level: 'error', + message: redact(cleaned), + request: null, + }) + }, }) - child.stderr?.on('data', (chunk: Buffer) => { - lastWorkerdStderr += chunk.toString('utf8') - if (lastWorkerdStderr.length > 32_000) { - lastWorkerdStderr = lastWorkerdStderr.slice(-24_000) + const generation: Generation = { + id, + dir, + workerd, + code: bundle.code, + mapStack: createStackMapper({ + sourceMap: bundle.sourceMap, + userModule: localWorkerModuleNames.user, + }), + inflight: 0, + retired: false, + stopped: false, + } + generations.add(generation) + void workerd.exited.then((detail) => { + reaper.untrack(workerd.pid) + if (closed || generation.retired || active !== generation) return + active = undefined + buildFailure = { + message: `workerd stopped unexpectedly (${detail}). Save a file to restart it.`, + diagnostics: [], } + options.onError?.(buildFailure.message) }) - const workerd = watchWorkerd(child) - const started = await workerd.listening - if ('exitDetail' in started) { - throw new Error(`workerd failed to start${started.exitDetail}`) + return generation + } + + function retireGeneration(generation: Generation): void { + generation.retired = true + if (generation.inflight === 0) { + void stopGeneration(generation) + return } - workerdPort = started.port - options.onStatus?.(`App ready at ${hostedUrl}${appBasePath || '/'}`) + setTimeout(() => void stopGeneration(generation), drainTimeoutMs).unref() + } + + async function stopGeneration(generation: Generation): Promise { + if (generation.stopped) return + generation.stopped = true + generation.retired = true + await generation.workerd.kill() + reaper.untrack(generation.workerd.pid) + generations.delete(generation) + await rm(generation.dir, { recursive: true, force: true }) } - async function proxyRequest(req: IncomingMessage, res: ServerResponse): Promise { + async function waitForBuild(): Promise { + const pending = buildLoop ?? (active || buildFailure ? null : started) + if (!pending) return + await Promise.race([ + pending.catch(() => {}), + new Promise((resolve) => setTimeout(resolve, rebuildGateMs).unref()), + ]) + } + + async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise { try { - if (!workerdPort) { - res.writeHead(503, { 'content-type': 'text/plain' }) - res.end('Dev server is still starting.') + await waitForBuild() + if (buildFailure) { + sendDevError(req, res, 500, buildErrorView(buildFailure)) return } - const incomingUrl = new URL(req.url ?? '/', hostedUrl) - const restPath = stripMount(incomingUrl.pathname, appBasePath) - const forwardUrl = new URL(restPath + incomingUrl.search, `http://127.0.0.1:${workerdPort}`) - - const chunks: Array = [] - for await (const chunk of req) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + const generation = active + if (!generation) { + res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' }) + res.end(closed ? 'kody dev is shutting down.\n' : 'kody dev is still starting.\n') + return } - const body = Buffer.concat(chunks) - - const headers: Record = { - ...req.headers, - host: `127.0.0.1:${workerdPort}`, - [runSecretHeader]: runSecret, + generation.inflight += 1 + try { + await forwardRequest(generation, req, res) + } finally { + generation.inflight -= 1 + if (generation.retired && generation.inflight === 0) { + void stopGeneration(generation) + } } - delete headers['content-length'] - - await new Promise((resolve, reject) => { - const upstream = httpRequest( - forwardUrl, - { method: req.method, headers }, - (upstreamRes) => { - const status = upstreamRes.statusCode ?? 502 - const chunks: Array = [] - upstreamRes.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) - }) - upstreamRes.on('end', () => { - const responseBody = Buffer.concat(chunks) - if (status >= 500) { - const preview = redact(responseBody.toString('utf8').slice(0, 2000)) - const workerdLogs = redact(lastWorkerdStderr.trim()) - options.onError?.( - `App handler HTTP ${status}${workerdLogs ? `\nworkerd stderr:\n${workerdLogs}` : ''}${preview ? `\nbody:\n${preview}` : ''}`, - ) - } - if (!res.headersSent) { - res.writeHead(status, upstreamRes.headers) - } - res.end(responseBody) - resolve() - }) - upstreamRes.on('error', reject) - }, - ) - upstream.on('error', reject) - if (body.length > 0) upstream.write(body) - upstream.end() - }) } catch (error) { const message = error instanceof Error ? error.message : String(error) - const workerdLogs = redact(lastWorkerdStderr.trim()) - options.onError?.( - workerdLogs ? `${message}\nworkerd stderr:\n${workerdLogs}` : message, - ) + options.onError?.(`Dev proxy error: ${message}`) if (!res.headersSent) { - res.writeHead(502, { 'content-type': 'text/plain' }) - res.end(`Dev proxy error: ${message}`) + res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }) } + res.end(`Dev proxy error: ${message}\n`) } } - await rebuildAndServe() + function forwardRequest( + generation: Generation, + req: IncomingMessage, + res: ServerResponse, + ): Promise { + const incomingUrl = new URL(req.url ?? '/', hostedUrl) + const restPath = stripMount(incomingUrl.pathname, appBasePath) + const forwardUrl = new URL( + restPath + incomingUrl.search, + `http://127.0.0.1:${generation.workerd.port}`, + ) + const tag: DevRequestTag = { + id: String(++requestCounter), + method: req.method ?? 'GET', + path: restPath + incomingUrl.search, + } + const headers: Record = { + ...req.headers, + host: `127.0.0.1:${generation.workerd.port}`, + [runSecretHeader]: runSecret, + [devRequestIdHeader]: tag.id, + } + return new Promise((resolve, reject) => { + const upstream = httpRequest( + forwardUrl, + { method: req.method, headers }, + (upstreamRes) => { + if (upstreamRes.headers[devErrorHeader]) { + void readStream(upstreamRes) + .then((raw) => { + respondWithRuntimeError(generation, req, res, tag, raw) + resolve() + }) + .catch(reject) + return + } + const status = upstreamRes.statusCode ?? 502 + if (status >= 500) { + emit({ + type: 'log', + source: 'app', + level: 'warn', + message: `responded HTTP ${status}`, + request: tag, + }) + } + res.writeHead(status, upstreamRes.headers) + upstreamRes.pipe(res) + upstreamRes.on('end', () => resolve()) + upstreamRes.on('error', reject) + }, + ) + upstream.on('error', reject) + res.on('close', () => { + if (!res.writableFinished) upstream.destroy() + resolve() + }) + req.pipe(upstream) + }) + } + + function respondWithRuntimeError( + generation: Generation, + req: IncomingMessage, + res: ServerResponse, + tag: DevRequestTag, + raw: string, + ): void { + let message = 'Uncaught error' + let stack = '' + try { + const parsed = JSON.parse(raw) as { name?: string; message?: string; stack?: string } + message = `${parsed.name ?? 'Error'}: ${parsed.message ?? ''}` + stack = parsed.stack ?? message + } catch { + stack = raw + } + const mapped = redact(generation.mapStack(stack)) + emit({ type: 'runtime_error', message: redact(message), stack: mapped, request: tag }) + sendDevError(req, res, 500, { + kind: 'runtime', + title: redact(message), + detail: mapped, + request: `${tag.method} ${tag.path}`, + }) + } - const watcher = + const watcher: DevWatcher | null = options.watch === false ? null - : watch(options.manifest.root, { recursive: true }, (_event, filename) => { - if (closed) return - if (filename && shouldIgnoreWatchPath(filename)) return - clearTimeout(reloadTimer) - reloadTimer = setTimeout(() => { - void rebuildAndServe().catch((error) => { - const message = error instanceof Error ? error.message : String(error) - options.onError?.(message) - }) - }, 120) - }) + : (() => { + let timer: NodeJS.Timeout | undefined + const pending = new Set() + return createDevWatcher({ + root, + onChange: (path) => { + if (closed) return + pending.add(path) + clearTimeout(timer) + timer = setTimeout(() => { + const paths = [...pending] + pending.clear() + void requestBuild(paths) + }, options.debounceMs ?? defaultDebounceMs) + }, + }) + })() + + const close = async () => { + if (closed) return + closed = true + process.off('exit', killAllSync) + await watcher?.close() + await buildLoop?.catch(() => {}) + await bundler.dispose() + await Promise.all([...generations].map((generation) => stopGeneration(generation))) + reaper.close() + await bridge.close() + proxy.closeAllConnections() + await new Promise((resolve) => { + proxy.close(() => resolve()) + }) + await rm(workDir, { recursive: true, force: true }) + } + + try { + await Promise.all([requestBuild([]), watcher?.ready]) + } catch (error) { + await close() + throw error + } finally { + markStarted() + } + if (options.watch === false && buildFailure) { + const message = (buildFailure as BuildFailure).message + await close() + throw new Error(message) + } + if (active) options.onStatus?.(`App ready at ${hostedUrl}${appBasePath || '/'}`) const fetchPath: PackageAppDevServer['fetchPath'] = async (path, init = {}) => { const url = new URL(joinUrlPath(appBasePath, path), hostedUrl) @@ -347,27 +604,44 @@ export async function startPackageAppDev( appBasePath, packageContext, fetchPath, - close: async () => { - closed = true - clearTimeout(reloadTimer) - watcher?.close() - const stopping = child - if (stopping) { - const exited = new Promise((resolve) => { - stopping.once('exit', () => resolve()) - stopping.once('error', () => resolve()) - }) - stopping.kill('SIGKILL') - await Promise.race([exited, new Promise((r) => setTimeout(r, 1000))]) - child = undefined - } - await bridge?.close() - await new Promise((resolve) => { - proxy.close(() => resolve()) - }) - await rm(workDir, { recursive: true, force: true }) - }, + close, + } +} + +function elapsed(started: number): number { + return Math.round(performance.now() - started) +} + +function toBuildFailure(error: unknown): BuildFailure { + if (error instanceof BundleError) { + return { message: error.message, diagnostics: error.diagnostics } + } + return { + message: redact(error instanceof Error ? error.message : String(error)), + diagnostics: [], + } +} + +function buildErrorView(failure: BuildFailure): DevErrorView { + const [first] = failure.diagnostics + return { + kind: 'build', + title: first + ? `${first.file ?? 'bundle'}${first.line !== null ? `:${first.line}:${first.col}` : ''} ${first.message}` + : (failure.message.split('\n')[0] ?? 'Build failed'), + detail: + failure.diagnostics.length > 0 + ? failure.diagnostics.map(formatBundleDiagnostic).join('\n\n') + : failure.message, + } +} + +async function readStream(stream: IncomingMessage): Promise { + const chunks: Array = [] + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) } + return Buffer.concat(chunks).toString('utf8') } async function writeWorkerFiles(input: { @@ -464,70 +738,6 @@ function joinUrlPath(base: string, path: string): string { return `${base}${rest === '/' ? '' : rest}` || base } -function shouldIgnoreWatchPath(filename: string): boolean { - return ( - filename.includes('node_modules') || - filename.includes('.git') || - filename.endsWith('.map') || - filename.startsWith('.') - ) -} - -function watchWorkerd(child: ChildProcess) { - let stderrText = '' - child.stderr?.on('data', (chunk: Buffer) => { - stderrText += chunk.toString('utf8') - }) - child.stdout?.resume() - const stderr = () => { - const trimmed = redact(stderrText.trim()) - return trimmed ? `\n${trimmed}` : '' - } - const exited = new Promise((resolve) => { - child.once('exit', (code, signal) => { - resolve(` (workerd exit ${signal ?? code})${stderr()}`) - }) - }) - const listening = new Promise<{ port: number } | { exitDetail: string }>((resolve, reject) => { - const control = child.stdio[3] as Readable | null - let buffered = '' - const timer = setTimeout(() => { - reject(new Error(`workerd did not start within ${workerdStartTimeoutMs / 1000}s.${stderr()}`)) - }, workerdStartTimeoutMs) - control?.on('data', (chunk: Buffer) => { - buffered += chunk.toString('utf8') - for (const line of buffered.split('\n')) { - const port = parseListenPort(line) - if (port !== null) { - clearTimeout(timer) - resolve({ port }) - } - } - }) - child.once('error', (error) => { - clearTimeout(timer) - reject(new Error(`Could not start workerd: ${error.message}`)) - }) - void exited.then((exitDetail) => { - clearTimeout(timer) - resolve({ exitDetail }) - }) - }) - return { listening, exited, stderr } -} - -function parseListenPort(line: string): number | null { - if (!line.trim()) return null - try { - const event = JSON.parse(line) as { event?: string; socket?: string; port?: number } - return event.event === 'listen' && event.socket === 'http' && typeof event.port === 'number' - ? event.port - : null - } catch { - return null - } -} - async function startBridge(input: { runSecret: string forward: ( diff --git a/src/package-local-runtime-source.ts b/src/package-local-runtime-source.ts index f3d8f15..0b60dea 100644 --- a/src/package-local-runtime-source.ts +++ b/src/package-local-runtime-source.ts @@ -13,6 +13,7 @@ import { runSecretEnvVar, runSecretHeader, } from './local-runtime-source.js' +import { appLogMarker, devErrorHeader, devRequestIdHeader } from './dev/dev-protocol.js' export type PackageLocalContext = { packageId: string @@ -390,6 +391,14 @@ export default { * `{ fetch }` export. Mount is already stripped by the Node proxy before this * request reaches workerd (pathname is the rest path). */ +/** + * workerd entry for `kody dev` apps. Beyond resolving the fetch handler it: + * tags every `console.*` call with the proxy's request id (written to stdout + * behind {@link appLogMarker} so the CLI can stream it), strips the internal + * run-secret / request-id headers before the app sees the request, and turns + * uncaught handler errors into a {@link devErrorHeader} response carrying the + * raw stack for the CLI to source-map. + */ export function createPackageAppEntrySource( input: { sideEffectModules?: ReadonlyArray } = {}, ): string { @@ -399,12 +408,40 @@ export function createPackageAppEntrySource( .join('\n') const sideEffectBlock = sideEffectImports ? `${sideEffectImports}\n` : '' return ` -${sideEffectBlock}import { +${sideEffectBlock}import { AsyncLocalStorage } from 'node:async_hooks'; +import { formatWithOptions } from 'node:util'; +import { __kodyInstallLocalBridge, __kodyRunInLocalRuntime, } from ${JSON.stringify(localWorkerModuleNames.runtime)}; const __kodyRunSecretHeader = ${JSON.stringify(runSecretHeader)}; +const __kodyRequestIdHeader = ${JSON.stringify(devRequestIdHeader)}; +const __kodyDevErrorHeader = ${JSON.stringify(devErrorHeader)}; +const __kodyLogMarker = ${JSON.stringify(appLogMarker)}; +const __kodyDevRequest = new AsyncLocalStorage(); +const __kodyNativeConsole = globalThis.console; + +function __kodyFormatArgs(args) { + try { + return formatWithOptions({ colors: false, depth: 6 }, ...args); + } catch { + return args.map((value) => String(value)).join(' '); + } +} + +function __kodyDevLog(level, args) { + const request = __kodyDevRequest.getStore() ?? null; + __kodyNativeConsole.log( + __kodyLogMarker + JSON.stringify({ level, message: __kodyFormatArgs(args), request }), + ); +} + +const __kodyDevConsole = Object.create(__kodyNativeConsole); +for (const level of ['log', 'info', 'warn', 'error', 'debug']) { + __kodyDevConsole[level] = (...args) => __kodyDevLog(level, args); +} +globalThis.console = __kodyDevConsole; function __kodyResolveFetch(module) { if (typeof module?.default === 'function') return module.default.bind(module); @@ -417,6 +454,17 @@ function __kodyResolveFetch(module) { ); } +function __kodyErrorResponse(error) { + const value = error instanceof Error ? error : new Error(String(error)); + return new Response( + JSON.stringify({ name: value.name, message: value.message, stack: value.stack ?? String(value) }), + { + status: 500, + headers: { 'content-type': 'application/json', [__kodyDevErrorHeader]: '1' }, + }, + ); +} + export default { async fetch(request, env) { const secret = env.${runSecretEnvVar}; @@ -433,10 +481,26 @@ export default { if (data.error) throw new Error(data.error); return data.result; }); - return await __kodyRunInLocalRuntime(async () => { - const module = await import(${JSON.stringify(`./${localWorkerModuleNames.user}`)}); - const fetchHandler = __kodyResolveFetch(module); - return await fetchHandler(request, env); + const url = new URL(request.url); + const tag = { + id: request.headers.get(__kodyRequestIdHeader) ?? '-', + method: request.method, + path: url.pathname + url.search, + }; + const headers = new Headers(request.headers); + headers.delete(__kodyRunSecretHeader); + headers.delete(__kodyRequestIdHeader); + const appRequest = new Request(request, { headers }); + return await __kodyDevRequest.run(tag, async () => { + try { + return await __kodyRunInLocalRuntime(async () => { + const module = await import(${JSON.stringify(`./${localWorkerModuleNames.user}`)}); + const fetchHandler = __kodyResolveFetch(module); + return await fetchHandler(appRequest, env); + }); + } catch (error) { + return __kodyErrorResponse(error); + } }); }, }; diff --git a/test/dev-server.test.ts b/test/dev-server.test.ts new file mode 100644 index 0000000..cb8f040 --- /dev/null +++ b/test/dev-server.test.ts @@ -0,0 +1,321 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { cp, mkdir, mkdtemp, readdir, rename, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { resolveDevAuth } from '../src/dev-command.js' +import { formatDevEvent, parseWorkerdStdoutLine, type DevEvent } from '../src/dev/dev-events.js' +import { appLogMarker } from '../src/dev/dev-protocol.js' +import { shouldIgnoreWatchPath } from '../src/dev/dev-watcher.js' +import { + isProcessAlive, + startProcessReaper, + sweepStaleDevDirs, +} from '../src/dev/process-reaper.js' +import { createStackMapper } from '../src/dev/stack-trace.js' +import { cleanWorkerdStderrLine } from '../src/dev/workerd-process.js' +import { startPackageAppDev, type PackageAppDevServer } from '../src/package-app-dev.js' +import { loadPackageManifest } from '../src/package-manifest.js' + +const fixtureRoot = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/sample-package') +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +const appEntry = `import { message } from './message.ts' + +export default { + async fetch(request: Request): Promise { + const url = new URL(request.url) + console.log('handling', url.pathname, { n: 1 }) + if (url.pathname === '/boom') throw new Error('kaboom') + return new Response('' + message + '', { + headers: { 'content-type': 'text/html; charset=utf-8' }, + }) + }, +} +` + +async function withDevServer( + run: (input: { + server: PackageAppDevServer + root: string + events: Array + writeMessage: (source: string) => Promise + }) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'kody-dev-server-test-')) + const cache = await mkdtemp(join(tmpdir(), 'kody-dev-server-cache-')) + const previousCache = process.env.KODY_CACHE_DIR + process.env.KODY_CACHE_DIR = cache + await cp(fixtureRoot, root, { recursive: true }) + await writeFile(join(root, 'app/entry.ts'), appEntry) + const messagePath = join(root, 'app/message.ts') + // Replace via rename so every write is a new inode (editor / sed -i style). + const writeMessage = async (source: string) => { + await writeFile(`${messagePath}.next`, source) + await rename(`${messagePath}.next`, messagePath) + } + await writeMessage(`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', + }) + const events: Array = [] + const server = await startPackageAppDev({ + manifest: await loadPackageManifest(root), + token: auth.token, + apiUrl: auth.apiUrl, + target: 'mock', + debounceMs: 20, + onEvent: (event) => events.push(event), + }) + try { + await run({ server, root, events, writeMessage }) + } finally { + await server.close() + await auth.closeMock?.() + 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 }) + } +} + +async function waitFor( + probe: () => Promise, + label: string, + timeoutMs = 15_000, +): Promise { + const started = Date.now() + while (Date.now() - started < timeoutMs) { + const value = await probe() + if (value !== undefined) return value + await sleep(15) + } + throw new Error(`Timed out waiting for ${label}`) +} + +test('kody dev swaps workerd without a 503 and recovers from build errors', async () => { + await withDevServer(async ({ server, events, writeMessage }) => { + const first = await server.fetchPath('/') + assert.equal(first.status, 200) + assert.match(first.body, /v0/) + + let polling = true + const statuses: Array = [] + const poller = (async () => { + while (polling) { + statuses.push((await server.fetchPath('/')).status) + } + })() + for (let i = 1; i <= 3; i += 1) { + await writeMessage(`export const message = 'v${i}'\n`) + await waitFor(async () => { + const response = await server.fetchPath('/') + return response.body.includes(`v${i}`) ? true : undefined + }, `v${i}`) + } + polling = false + await poller + assert.ok(statuses.length > 0) + assert.deepEqual( + statuses.filter((status) => status !== 200), + [], + 'no request may fail while a rebuild swaps workerd', + ) + + await writeMessage(`export const message = = 'broken'\n`) + const broken = await waitFor(async () => { + const response = await server.fetchPath('/') + return response.status === 500 ? response : undefined + }, 'build error page') + assert.equal(broken.headers['x-kody-dev-error'], 'build') + assert.match(broken.body, /app\/message\.ts:1:24: Unexpected "="/) + assert.match(broken.body, /\| export const message = = 'broken'/) + assert.doesNotMatch(broken.body, /for local execute/) + const buildError = events.find((event) => event.type === 'build_error') + assert.ok(buildError && buildError.type === 'build_error') + assert.equal(buildError.file, 'app/message.ts') + assert.equal(buildError.line, 1) + assert.equal(buildError.col, 24) + + await writeMessage(`export const message = 'fixed'\n`) + await waitFor(async () => { + const response = await server.fetchPath('/') + return response.status === 200 && response.body.includes('fixed') ? true : undefined + }, 'recovery after a build error') + assert.ok(events.some((event) => event.type === 'rebuild_ok' && event.restarted)) + }) +}) + +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' } }) + assert.equal(html.status, 500) + assert.match(html.headers['content-type'] ?? '', /text\/html/) + assert.match(html.body, /Runtime error/) + assert.match(html.body, /app\/entry\.ts:7:/) + + const text = await server.fetchPath('/boom') + assert.equal(text.status, 500) + assert.match(text.body, /^kody dev runtime error \(GET \/boom\)/) + assert.match(text.body, /Error: kaboom\n\s+at .*app\/entry\.ts:7:\d+/) + assert.doesNotMatch(text.body, /main\.js|__kody_local_entry|workerd@/) + + const runtimeError = await waitFor( + async () => events.find((event) => event.type === 'runtime_error'), + 'runtime_error event', + ) + assert.ok(runtimeError.type === 'runtime_error') + assert.equal(runtimeError.request?.path, '/boom') + + const log = await waitFor( + async () => + events.find( + (event) => event.type === 'log' && event.message.includes('handling /boom'), + ), + 'app console.log', + ) + assert.ok(log.type === 'log') + assert.equal(log.source, 'app') + assert.match(log.message, /handling \/boom \{ n: 1 \}/) + assert.equal(log.request?.method, 'GET') + assert.equal(log.request?.path, '/boom') + assert.match(formatDevEvent(log).text, /^\[GET \/boom #\d+\] handling/) + }) +}) + +test('createStackMapper maps bundle frames to sources and drops runtime frames', () => { + // Line 1 of main.js maps to app/entry.ts:7:11 (VLQ "AAMU" = [0,0,6,10]). + const sourceMap = JSON.stringify({ + version: 3, + sources: ['app/entry.ts'], + names: [], + mappings: 'AAMU', + }) + const mapper = createStackMapper({ sourceMap, userModule: 'main.js' }) + const mapped = mapper( + [ + 'Error: kaboom', + ' at Object.fetch (main.js:1:1)', + ' at __kodyRunInLocalRuntime (kody:runtime:12:3)', + ' at Object.fetch (__kody_local_entry.js:40:5)', + ' at async Promise.all (index 0)', + ' at kody:@scope/pkg/export:3:9', + ].join('\n'), + ) + assert.equal( + mapped, + ['Error: kaboom', ' at Object.fetch (app/entry.ts:7:11)', ' at kody:@scope/pkg/export:3:9'].join( + '\n', + ), + ) +}) + +test('cleanWorkerdStderrLine strips native addresses and C++ prefixes', () => { + assert.equal( + cleanWorkerdStderrLine( + 'stack: /x/workerd@33a76d0 /x/workerd@33a836c /x/workerd@58b85c8', + ), + null, + ) + assert.equal( + cleanWorkerdStderrLine( + 'workerd/server/server.c++:6557: error: Uncaught exception: workerd/jsg/value.h:1477: failed: remote.jsg.Error: probe boom', + ), + 'Uncaught exception: Error: probe boom', + ) + assert.equal(cleanWorkerdStderrLine('plain stderr'), 'plain stderr') +}) + +test('parseWorkerdStdoutLine reads tagged app logs and passes plain lines through', () => { + const tagged = parseWorkerdStdoutLine( + `${appLogMarker}${JSON.stringify({ level: 'warn', message: 'careful', request: { id: '3', method: 'POST', path: '/x' } })}`, + ) + assert.deepEqual(tagged, { + type: 'log', + source: 'app', + level: 'warn', + message: 'careful', + request: { id: '3', method: 'POST', path: '/x' }, + }) + assert.deepEqual(parseWorkerdStdoutLine('hello'), { + type: 'log', + source: 'app', + level: 'log', + message: 'hello', + request: null, + }) +}) + +test('shouldIgnoreWatchPath skips dependencies, dotfiles, maps, and custom prefixes', () => { + assert.equal(shouldIgnoreWatchPath('app/entry.ts'), false) + assert.equal(shouldIgnoreWatchPath('node_modules/x/index.js'), true) + assert.equal(shouldIgnoreWatchPath('.git/HEAD'), true) + assert.equal(shouldIgnoreWatchPath('app/.cache/file'), true) + assert.equal(shouldIgnoreWatchPath('dist/app.js.map'), true) + assert.equal(shouldIgnoreWatchPath('public/generated/a.css', ['public/generated']), true) + assert.equal(shouldIgnoreWatchPath('public/generator.ts', ['public/generated']), false) +}) + +test('process reaper kills tracked pids and removes the work dir when its pipe closes', async () => { + const workDir = await mkdtemp(join(tmpdir(), 'kody-dev-reaper-test-')) + const sleeper = spawn('sleep', ['30'], { stdio: 'ignore' }) + assert.ok(sleeper.pid) + const exited = new Promise((resolve) => sleeper.once('exit', () => resolve())) + const reaper = await startProcessReaper({ workDir }) + reaper.track(sleeper.pid) + reaper.close() + await Promise.race([ + exited, + new Promise((resolve) => setTimeout(resolve, 5000).unref()), + ]) + assert.equal(isProcessAlive(sleeper.pid), 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 { + const make = async (name: string, owner?: number, ageMs = 0) => { + const dir = join(tmp, name) + await mkdir(dir) + if (owner !== undefined) await writeFile(join(dir, 'owner.json'), JSON.stringify({ pid: owner })) + const when = new Date(Date.now() - ageMs) + await utimes(dir, when, when) + return dir + } + const live = await make('kody-dev-live', 100) + const dead = await make('kody-dev-dead', 200) + const legacyOrphan = await make('kody-dev-legacy-orphan', undefined, 3_600_000) + const legacyActive = await make('kody-dev-legacy-active', undefined, 3_600_000) + const legacyFresh = await make('kody-dev-legacy-fresh') + const killed: Array = [] + const result = await sweepStaleDevDirs({ + tmp, + isAlive: (pid) => pid === 100 || pid === 500, + kill: (pid) => killed.push(pid), + listProcesses: async () => [ + { pid: 11, ppid: 100, name: 'workerd', cmd: `workerd serve ${live}/g1/config.capnp` }, + { pid: 12, ppid: 1, name: 'workerd', cmd: `workerd serve ${dead}/g1/config.capnp` }, + { pid: 13, ppid: 1, name: 'workerd', cmd: `workerd serve ${legacyOrphan}/config.capnp` }, + { pid: 14, ppid: 500, name: 'workerd', cmd: `workerd serve ${legacyActive}/config.capnp` }, + { pid: 500, ppid: 1, name: 'node', cmd: 'node kody dev' }, + ], + }) + assert.deepEqual(killed.sort(), [12, 13]) + assert.deepEqual(result.removed.sort(), [dead, legacyOrphan].sort()) + const left = (await readdir(tmp)).sort() + assert.deepEqual(left, ['kody-dev-legacy-active', 'kody-dev-legacy-fresh', 'kody-dev-live']) + void legacyFresh + } finally { + await rm(tmp, { recursive: true, force: true }) + } +}) diff --git a/test/local-bundle.test.ts b/test/local-bundle.test.ts index f9eae2b..63c399a 100644 --- a/test/local-bundle.test.ts +++ b/test/local-bundle.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' -import { bundleLocalUserModule } from '../src/local-bundle.js' +import { BundleError, bundleLocalUserModule } from '../src/local-bundle.js' import { listSavedPackageImports } from '../src/local-package-graph.js' function writeFixture(files: Record): string { @@ -90,7 +90,7 @@ export default () => missing () => bundleLocalUserModule(join(dir, 'entry.mjs')), (error: unknown) => { assert.ok(error instanceof Error) - assert.match(error.message, /Could not bundle .+entry\.mjs for local execute/) + assert.match(error.message, /Could not bundle .+entry\.mjs\./) assert.match(error.message, /Could not resolve|nope\.js/i) return true }, @@ -107,7 +107,7 @@ export default () => missing () => bundleLocalUserModule(join(dir, 'entry.mjs')), (error: unknown) => { assert.ok(error instanceof Error) - assert.match(error.message, /Could not bundle .+entry\.mjs for local execute/) + assert.match(error.message, /Could not bundle .+entry\.mjs\./) assert.match(error.message, /definitely-not-installed-kody-dep/) assert.match(error.message, /Run `npm install`/) return true @@ -132,3 +132,28 @@ export default () => missing }, ) }) + +test('bundleLocalUserModule syntax errors carry file:line:col and a code frame', async () => { + const dir = writeFixture({ + 'entry.ts': `export default function main() { + const x y = 1 + return x +} +`, + }) + await assert.rejects( + () => bundleLocalUserModule(join(dir, 'entry.ts')), + (error: unknown) => { + assert.ok(error instanceof BundleError) + assert.doesNotMatch(error.message, /for local execute/) + const [first] = error.diagnostics + assert.ok(first) + assert.match(first.file ?? '', /entry\.ts$/) + assert.equal(first.line, 2) + assert.equal(first.col, 11) + assert.match(error.message, /entry\.ts:2:11: Expected ";" but found "y"/) + assert.match(error.message, / 2 \| const x y = 1\n {4}\| {11}\^/) + return true + }, + ) +}) diff --git a/test/local-execute.test.ts b/test/local-execute.test.ts index 85355e8..374f2c7 100644 --- a/test/local-execute.test.ts +++ b/test/local-execute.test.ts @@ -735,7 +735,7 @@ export default () => missing }), (error: unknown) => { assert.ok(error instanceof Error) - assert.match(error.message, /Could not bundle .+entry\.mjs for local execute/) + assert.match(error.message, /Could not bundle .+entry\.mjs\./) assert.match(error.message, /Could not resolve|nope\.js/i) return true }, From 92be347423c216639adf7d8e4783539e63fb4bd0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 06:09:13 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(dev):=20address=20Bugbot=20=E2=80=94=20?= =?UTF-8?q?untrack=20failed=20workerd=20starts,=20safe=20proxy=20errors,?= =?UTF-8?q?=20watcher=20ready=20rejects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kent C. Dodds --- src/dev/dev-watcher.ts | 12 +++++++-- src/package-app-dev.ts | 55 +++++++++++++++++++++++++++--------------- 2 files changed, 45 insertions(+), 22 deletions(-) diff --git a/src/dev/dev-watcher.ts b/src/dev/dev-watcher.ts index 97b8284..9273778 100644 --- a/src/dev/dev-watcher.ts +++ b/src/dev/dev-watcher.ts @@ -37,8 +37,16 @@ export function createDevWatcher(input: { watcher.on('all', (_event, path) => { if (!ignored(path)) input.onChange(path) }) - watcher.on('error', () => {}) - const ready = new Promise((resolve) => watcher.once('ready', () => resolve())) + const ready = new Promise((resolve, reject) => { + watcher.once('ready', () => resolve()) + watcher.once('error', (error) => { + reject(error instanceof Error ? error : new Error(String(error))) + }) + }) + // After ready, keep the process from crashing on transient watch errors. + void ready.then(() => { + watcher.on('error', () => {}) + }) type Fingerprint = string let tracked = new Map() diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index 4ace706..cb75d9d 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -336,25 +336,38 @@ export async function startPackageAppDev( mockFetchGateway, onStatus: options.onStatus, }) - const workerd = await startWorkerd({ - workerdPath, - configPath: join(dir, 'config.capnp'), - cwd: dir, - env: workerdEnv, - onSpawn: (pid) => reaper.track(pid), - onStdoutLine: (line) => emit(parseWorkerdStdoutLine(line)), - onStderrLine: (line) => { - const cleaned = cleanWorkerdStderrLine(line) - if (cleaned === null) return - emit({ - type: 'log', - source: 'workerd', - level: 'error', - message: redact(cleaned), - request: null, - }) - }, - }) + let trackedPid: number | undefined + let workerd: Awaited> + try { + workerd = await startWorkerd({ + workerdPath, + configPath: join(dir, 'config.capnp'), + cwd: dir, + env: workerdEnv, + onSpawn: (pid) => { + trackedPid = pid + reaper.track(pid) + }, + onStdoutLine: (line) => emit(parseWorkerdStdoutLine(line)), + onStderrLine: (line) => { + const cleaned = cleanWorkerdStderrLine(line) + if (cleaned === null) return + emit({ + type: 'log', + source: 'workerd', + level: 'error', + message: redact(cleaned), + request: null, + }) + }, + }) + } catch (error) { + // onSpawn tracks before listen; a failed start never reaches the + // exited/untrack path, so drop the pid here before pid reuse. + if (trackedPid !== undefined) reaper.untrack(trackedPid) + await rm(dir, { recursive: true, force: true }).catch(() => {}) + throw error + } const generation: Generation = { id, dir, @@ -437,8 +450,10 @@ export async function startPackageAppDev( options.onError?.(`Dev proxy error: ${message}`) if (!res.headersSent) { res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }) + res.end(`Dev proxy error: ${message}\n`) + } else if (!res.writableEnded) { + res.destroy() } - res.end(`Dev proxy error: ${message}\n`) } } From e859af742abed34db2ff5e5ed958f81cc2554975 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 06:09:21 +0000 Subject: [PATCH 3/4] fix(dev): use WorkerdHandle type for failed-start tracking Co-authored-by: Kent C. Dodds --- src/package-app-dev.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index cb75d9d..78b4a0f 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -337,7 +337,7 @@ export async function startPackageAppDev( onStatus: options.onStatus, }) let trackedPid: number | undefined - let workerd: Awaited> + let workerd: WorkerdHandle try { workerd = await startWorkerd({ workerdPath, From 755198d856965b047509133419d4a827358cb892 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 06:18:32 +0000 Subject: [PATCH 4/4] fix(dev): swallow post-ready watcher errors without an unhandled ready rejection Co-authored-by: Kent C. Dodds --- src/dev/dev-watcher.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/dev/dev-watcher.ts b/src/dev/dev-watcher.ts index 9273778..d4d5ed3 100644 --- a/src/dev/dev-watcher.ts +++ b/src/dev/dev-watcher.ts @@ -37,16 +37,20 @@ export function createDevWatcher(input: { watcher.on('all', (_event, path) => { if (!ignored(path)) input.onChange(path) }) + // Init errors reject `ready` (awaited by start). Later errors are swallowed + // so a transient watch glitch cannot crash the process. + let readySettled = false const ready = new Promise((resolve, reject) => { - watcher.once('ready', () => resolve()) - watcher.once('error', (error) => { + watcher.once('ready', () => { + readySettled = true + resolve() + }) + watcher.on('error', (error) => { + if (readySettled) return + readySettled = true reject(error instanceof Error ? error : new Error(String(error))) }) }) - // After ready, keep the process from crashing on transient watch errors. - void ready.then(() => { - watcher.on('error', () => {}) - }) type Fingerprint = string let tracked = new Map()