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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,38 @@ kody search --target mock local
There is no HMR. Only requests that accept `text/html` (browser page loads)
get the script; compressed HTML, agent requests, and `kody dev fetch`
responses are left untouched.
- **Fast rebuilds.** File events are debounced for 30ms, and esbuild reuses
an incremental context, so a save usually serves the new code in about
60ms on a small app.
- **Ports.** Each package gets a stable default port in 4100–4999 (derived
from `kody.id`), so URLs survive restarts. If it is taken, `kody dev` moves
to the next free port. An explicit `--port <n>` is used exactly and fails
with a fix when the port is busy (`--port 0` picks any free port). Starting
a second `kody dev` for the same package and target without `--port` fails
and names the running server's URL and pid.
- **Client bundle and assets**, same URLs as a published app:
`kody.app.client` is bundled for the browser and served at
`<appBasePath>/_assets/client.<hash>.js` (inline source map;
`client.externals` stay bare imports). `packageContext.clientModuleUrl`
points at it, and `/_assets/__version.json` reports it. Files in
`kody.app.assets` are served under `/_assets/`. Both are `no-store` and
rebuild on save; editing an asset reloads open pages.
- **Prebuild**: `kody.dev.prebuild` runs a shell command from the package
root before every build (with `KODY_DEV=1`), for generated CSS, icons, or
data files. Its output is tagged `[prebuild]`. A non-zero exit is shown as a
build error. File changes made while it runs do not trigger another build.
`kody.dev.ignore` lists root-relative path prefixes the watcher skips:

```json
{
"kody": {
"app": { "entry": "./app/router.ts", "client": "./app/client.ts", "assets": "./public" },
"dev": { "prebuild": "node scripts/build-css.mjs", "ignore": ["public/generated"] }
}
}
```

`kody.dev` only affects `kody dev`; publish ignores it.
- **Cleanup**: Ctrl-C, `SIGTERM`, and `SIGHUP` stop workerd and remove the
`kody-dev-*` temp dir. If the CLI is killed outright, a small reaper process
does the same. Each start also sweeps dirs and orphaned workerd processes
Expand Down
3 changes: 2 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,7 @@ async function dispatch(
case 'dev': {
const { resolveTarget } = await import('./target.js')
const {
parseDevPort,
parseDevSubcommand,
resolveDevAuth,
runDevCheck,
Expand Down Expand Up @@ -1227,7 +1228,7 @@ async function dispatch(
...(org && target === 'mock' ? { orgSlug: org } : {}),
})
const port =
typeof parsed.values.port === 'string' ? Number(parsed.values.port) : undefined
typeof parsed.values.port === 'string' ? parseDevPort(parsed.values.port) : undefined
const appBasePath =
typeof parsed.values['app-base-path'] === 'string'
? parsed.values['app-base-path']
Expand Down
93 changes: 64 additions & 29 deletions src/dev-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,16 @@ import type { KodyTarget } from './target.js'
import { formatToolResult } from './tool-result.js'
import { bundleLocalUserModule } from './local-bundle.js'
import { formatDevEvent, type DevEvent } from './dev/dev-events.js'
import { defaultDevPort } from './dev/dev-port.js'
import {
devStatusPath,
findRunningDevServer,
removeDevRegistration,
writeDevRegistration,
} from './dev/dev-registry.js'

export { parseDevPort } from './dev/dev-port.js'

export type DevIo = {
stdout: (text: string) => void
stderr: (text: string) => void
Expand Down Expand Up @@ -127,29 +130,69 @@ export async function runDevServe(input: {
serveOnce?: boolean
}): Promise<number> {
const manifest = await loadPackageManifest(input.cwd)
if (input.port === undefined) {
const running = await findRunningDevServer({ root: manifest.root, target: input.target })
if (running) {
const { registration } = running
throw new StructuredCliError({
code: 'dev_already_running',
target: input.target,
message: `kody dev is already running for ${manifest.name} at ${registration.origin}${registration.basePath || '/'} (pid ${registration.pid}).`,
fix: `Reuse it (\`kody dev fetch <path>\` attaches automatically), stop it with \`kill ${registration.pid}\`, or pass --port to start a second server.`,
})
}
}
Comment thread
kody-bot marked this conversation as resolved.
const onEvent = input.json
? (event: DevEvent) => writeJsonEvent(input.io, event)
: (event: DevEvent) => writeDevEvent(input.io, event)
const server = await startPackageAppDev({
manifest,
token: input.auth.token,
apiUrl: input.auth.apiUrl,
target: input.target,
port: input.port,
appBasePath: input.appBasePath,
...(input.auth.org ? { org: input.auth.org } : {}),
...(input.auth.depResolve ? { depResolve: input.auth.depResolve } : {}),
...(input.passthroughHosts?.length
? { passthroughHosts: input.passthroughHosts }
: input.auth.passthroughHosts?.length
? { passthroughHosts: input.auth.passthroughHosts }
: {}),
allowPrivateNetwork: input.allowPrivateNetwork,
liveReload: input.liveReload ?? true,
onStatus: (message) => input.io.stderr(`${message}\n`),
onError: (message) => input.io.stderr(`${message}\n`),
onEvent,
})
let registered = false
let server: Awaited<ReturnType<typeof startPackageAppDev>>
try {
server = await startPackageAppDev({
manifest,
token: input.auth.token,
apiUrl: input.auth.apiUrl,
target: input.target,
port: input.port,
defaultPort: defaultDevPort(manifest.kodyId),
appBasePath: input.appBasePath,
...(input.auth.org ? { org: input.auth.org } : {}),
...(input.auth.depResolve ? { depResolve: input.auth.depResolve } : {}),
...(input.passthroughHosts?.length
? { passthroughHosts: input.passthroughHosts }
: input.auth.passthroughHosts?.length
? { passthroughHosts: input.auth.passthroughHosts }
: {}),
allowPrivateNetwork: input.allowPrivateNetwork,
liveReload: input.liveReload ?? true,
onStatus: (message) => input.io.stderr(`${message}\n`),
onError: (message) => input.io.stderr(`${message}\n`),
onEvent,
// Register as soon as the port is bound so a second `kody dev`
// during the first build hits `dev_already_running` instead of
// stealing the next free port and overwriting the registry.
// Explicit `--port` starts a second server on purpose — leave the
// primary attach registry alone so fetch keeps talking to it.
onListening: async ({ port, hostedUrl, appBasePath }) => {
if (input.port !== undefined) return
await writeDevRegistration({
pid: process.pid,
origin: hostedUrl,
port,
basePath: appBasePath,
target: input.target,
root: manifest.root,
})
registered = true
},
Comment thread
kody-bot marked this conversation as resolved.
})
} catch (error) {
if (registered) {
await removeDevRegistration(manifest.root, process.pid, input.target).catch(() => {})
}
await input.auth.closeMock?.()
throw error
}
const shutdown = async () => {
await server.close()
await removeDevRegistration(manifest.root, process.pid, input.target).catch(() => {})
Expand All @@ -166,14 +209,6 @@ export async function runDevServe(input: {
pid: process.pid,
statusUrl,
}
await writeDevRegistration({
pid: process.pid,
origin: server.hostedUrl,
port: server.port,
basePath: server.appBasePath,
target: input.target,
root: manifest.root,
}).catch(() => {})
if (input.readyFile) {
await mkdir(dirname(input.readyFile), { recursive: true })
await writeFile(input.readyFile, `${JSON.stringify(ready, null, 2)}\n`)
Expand All @@ -200,7 +235,7 @@ export async function runDevServe(input: {
)
}
} catch (error) {
// Ready-file / registration failures must not leave the listen handles up.
// Ready-file failures must not leave the listen handles up.
await shutdown().catch(() => {})
throw error
}
Expand Down
Loading
Loading