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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 16 additions & 4 deletions src/dev-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` +
Expand All @@ -144,18 +146,17 @@ export async function runDevServe(input: {
return 0
}
await new Promise<void>((resolveWait) => {
const signals: Array<NodeJS.Signals> = ['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()
}
const onSig = () => {
void stop()
}
process.on('SIGINT', onSig)
process.on('SIGTERM', onSig)
for (const signal of signals) process.on(signal, onSig)
})
return 0
}
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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<string>): {
subcommand: 'serve' | 'fetch' | 'job' | 'webhook' | 'emit' | 'check'
Expand Down
70 changes: 70 additions & 0 deletions src/dev/dev-bundler.ts
Original file line number Diff line number Diff line change
@@ -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<string>
}

export type DevBundler = {
build: () => Promise<DevBundle>
dispose: () => Promise<void>
}

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<typeof options>
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,
}
}
118 changes: 118 additions & 0 deletions src/dev/dev-events.ts
Original file line number Diff line number Diff line change
@@ -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<string> }
| { 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<BundleDiagnostic>
}
| {
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<DevLogLevel>(['log', 'info', 'warn', 'error', 'debug'])

/** Parse one workerd stdout line; plain lines become untagged `log` events. */
export function parseWorkerdStdoutLine(line: string): Extract<DevEvent, { type: 'log' }> {
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<string, unknown>
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)}`)
}
}
}
16 changes: 16 additions & 0 deletions src/dev/dev-protocol.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading
Loading