diff --git a/README.md b/README.md index 48fc920..402d820 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,38 @@ kody search --target mock local There is no HMR. Only requests that accept `text/html` (browser page loads) get the script; compressed HTML, agent requests, and `kody dev fetch` responses are left untouched. +- **Fast rebuilds.** File events are debounced for 30ms, and esbuild reuses + an incremental context, so a save usually serves the new code in about + 60ms on a small app. +- **Ports.** Each package gets a stable default port in 4100–4999 (derived + from `kody.id`), so URLs survive restarts. If it is taken, `kody dev` moves + to the next free port. An explicit `--port ` is used exactly and fails + with a fix when the port is busy (`--port 0` picks any free port). Starting + a second `kody dev` for the same package and target without `--port` fails + and names the running server's URL and pid. +- **Client bundle and assets**, same URLs as a published app: + `kody.app.client` is bundled for the browser and served at + `/_assets/client..js` (inline source map; + `client.externals` stay bare imports). `packageContext.clientModuleUrl` + points at it, and `/_assets/__version.json` reports it. Files in + `kody.app.assets` are served under `/_assets/`. Both are `no-store` and + rebuild on save; editing an asset reloads open pages. +- **Prebuild**: `kody.dev.prebuild` runs a shell command from the package + root before every build (with `KODY_DEV=1`), for generated CSS, icons, or + data files. Its output is tagged `[prebuild]`. A non-zero exit is shown as a + build error. File changes made while it runs do not trigger another build. + `kody.dev.ignore` lists root-relative path prefixes the watcher skips: + + ```json + { + "kody": { + "app": { "entry": "./app/router.ts", "client": "./app/client.ts", "assets": "./public" }, + "dev": { "prebuild": "node scripts/build-css.mjs", "ignore": ["public/generated"] } + } + } + ``` + + `kody.dev` only affects `kody dev`; publish ignores it. - **Cleanup**: Ctrl-C, `SIGTERM`, and `SIGHUP` stop workerd and remove the `kody-dev-*` temp dir. If the CLI is killed outright, a small reaper process does the same. Each start also sweeps dirs and orphaned workerd processes diff --git a/src/cli.ts b/src/cli.ts index 6cb55e3..eb1cea0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1060,6 +1060,7 @@ async function dispatch( case 'dev': { const { resolveTarget } = await import('./target.js') const { + parseDevPort, parseDevSubcommand, resolveDevAuth, runDevCheck, @@ -1227,7 +1228,7 @@ async function dispatch( ...(org && target === 'mock' ? { orgSlug: org } : {}), }) const port = - typeof parsed.values.port === 'string' ? Number(parsed.values.port) : undefined + typeof parsed.values.port === 'string' ? parseDevPort(parsed.values.port) : undefined const appBasePath = typeof parsed.values['app-base-path'] === 'string' ? parsed.values['app-base-path'] diff --git a/src/dev-command.ts b/src/dev-command.ts index 805b074..097f522 100644 --- a/src/dev-command.ts +++ b/src/dev-command.ts @@ -26,6 +26,7 @@ import type { KodyTarget } from './target.js' import { formatToolResult } from './tool-result.js' import { bundleLocalUserModule } from './local-bundle.js' import { formatDevEvent, type DevEvent } from './dev/dev-events.js' +import { defaultDevPort } from './dev/dev-port.js' import { devStatusPath, findRunningDevServer, @@ -33,6 +34,8 @@ import { writeDevRegistration, } from './dev/dev-registry.js' +export { parseDevPort } from './dev/dev-port.js' + export type DevIo = { stdout: (text: string) => void stderr: (text: string) => void @@ -127,29 +130,69 @@ export async function runDevServe(input: { serveOnce?: boolean }): Promise { const manifest = await loadPackageManifest(input.cwd) + if (input.port === undefined) { + const running = await findRunningDevServer({ root: manifest.root, target: input.target }) + if (running) { + const { registration } = running + throw new StructuredCliError({ + code: 'dev_already_running', + target: input.target, + message: `kody dev is already running for ${manifest.name} at ${registration.origin}${registration.basePath || '/'} (pid ${registration.pid}).`, + fix: `Reuse it (\`kody dev fetch \` attaches automatically), stop it with \`kill ${registration.pid}\`, or pass --port to start a second server.`, + }) + } + } const onEvent = input.json ? (event: DevEvent) => writeJsonEvent(input.io, event) : (event: DevEvent) => writeDevEvent(input.io, event) - 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> + 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 + }, + }) + } 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(() => {}) @@ -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`) @@ -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 } diff --git a/src/dev/dev-assets.ts b/src/dev/dev-assets.ts new file mode 100644 index 0000000..9c1e46f --- /dev/null +++ b/src/dev/dev-assets.ts @@ -0,0 +1,241 @@ +/** + * Local copy of the platform's `/_assets/` surface for `kody dev`: + * the browser bundle of `kody.app.client` (fingerprinted `client..js`), + * `__version.json`, and files from the `kody.app.assets` directory. Answered + * by the Node proxy before the app's fetch handler, like the platform does. + * Everything is `no-store` so a rebuild is always picked up. + */ + +import * as esbuild from 'esbuild' +import { createHash } from 'node:crypto' +import { readFile, stat } from 'node:fs/promises' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { join, resolve, sep } from 'node:path' +import { createBundleError } from '../local-bundle.js' +import { bundleInputs } from './dev-bundler.js' + +export const devAssetsPathPrefix = '/_assets/' +export const devVersionAssetName = '__version.json' +const clientModuleHashLength = 16 + +export type DevClientBundle = { + /** `client.<16-char base64url sha256>.js`, same shape as published apps. */ + name: string + code: string + inputs: Array +} + +export type DevClientBundler = { + build: () => Promise + dispose: () => Promise +} + +export function createDevClientBundler(input: { + entryPath: string + root: string + externals?: ReadonlyArray +}): DevClientBundler { + const externals = input.externals ?? [] + const workingDir = process.cwd() + let disposed = false + let context: Promise> | null = null + const createContext = () => + esbuild.context({ + absWorkingDir: workingDir, + entryPoints: [resolve(input.entryPath)], + bundle: true, + write: false, + metafile: true, + format: 'esm', + platform: 'browser', + target: 'es2022', + sourcemap: 'inline', + logLevel: 'silent', + outfile: join(input.root, '_assets', 'client.js'), + plugins: externals.length > 0 ? [clientExternalsPlugin(externals)] : [], + }) + return { + build: async () => { + try { + // A context created after dispose would never be released and keeps Node alive. + if (disposed) throw new Error('kody dev bundler was already disposed.') + context ??= createContext() + const result = await (await context).rebuild() + const code = result.outputFiles.find((file) => file.path.endsWith('.js'))?.text ?? '' + const hash = createHash('sha256') + .update(code) + .digest('base64url') + .slice(0, clientModuleHashLength) + return { + name: `client.${hash}.js`, + code, + inputs: bundleInputs(result.metafile, workingDir), + } + } catch (error) { + throw createBundleError(input.entryPath, error, { + workingDir, + displayRoot: input.root, + }) + } + }, + dispose: async () => { + disposed = true + const current = context + context = null + await (await current?.catch(() => null))?.dispose() + }, + } +} + +/** + * Exact specifier or a subpath of it, so `preact` does not also externalize + * `preact-render-to-string` (esbuild's own `external` matches by prefix). + */ +function clientExternalsPlugin(externals: ReadonlyArray): esbuild.Plugin { + return { + name: 'kody-dev-client-externals', + setup(build) { + build.onResolve({ filter: /^[^./]/ }, (args) => + externals.some( + (external) => args.path === external || args.path.startsWith(`${external}/`), + ) + ? { path: args.path, external: true } + : undefined, + ) + }, + } +} + +/** `/_assets/` → `` (percent-decoded), or null when not an asset path. */ +export function parseDevAssetPath(pathname: string): string | null { + if (!pathname.startsWith(devAssetsPathPrefix)) return null + const encoded = pathname.slice(devAssetsPathPrefix.length) + if (!encoded) return null + try { + return decodeURIComponent(encoded) + } catch { + return null + } +} + +/** Absolute file under `assetsDir`, or null for traversal / absolute paths. */ +export function resolveDevAssetFile(assetsDir: string, relativePath: string): string | null { + if (relativePath.includes('\0') || relativePath.startsWith('/')) return null + const root = resolve(assetsDir) + const file = resolve(root, relativePath) + return file.startsWith(`${root}${sep}`) ? file : null +} + +export async function serveDevAsset(input: { + req: IncomingMessage + res: ServerResponse + relativePath: string + assetsDir: string | null + client: DevClientBundle | null + clientModuleUrl: string | null + /** Origin-relative app mount ('' at the root). */ + appBasePath: string +}): Promise { + const { req, res } = input + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405, { allow: 'GET, HEAD', 'cache-control': 'no-store' }) + res.end('Method not allowed\n') + return + } + if (input.relativePath === devVersionAssetName) { + send(req, res, 'application/json; charset=utf-8', JSON.stringify({ + clientModuleUrl: input.clientModuleUrl, + assetBasePath: `${input.appBasePath}/_assets`, + publishedCommit: null, + })) + return + } + if (input.client && input.relativePath === input.client.name) { + send(req, res, 'text/javascript; charset=utf-8', input.client.code) + return + } + const file = input.assetsDir ? resolveDevAssetFile(input.assetsDir, input.relativePath) : null + const body = file ? await readAssetFile(file) : null + if (!file || !body) { + res.writeHead(404, { + 'content-type': 'text/plain; charset=utf-8', + 'cache-control': 'no-store', + }) + res.end('Not found\n') + return + } + const contentType = inferDevAssetContentType(file) + send(req, res, contentType, body, contentType.startsWith('text/javascript') + ? { 'service-worker-allowed': `${input.appBasePath}/` } + : {}) +} + +async function readAssetFile(file: string): Promise { + try { + if (!(await stat(file)).isFile()) return null + return await readFile(file) + } catch { + return null + } +} + +function send( + req: IncomingMessage, + res: ServerResponse, + contentType: string, + body: string | Buffer, + extraHeaders: Record = {}, +): void { + const bytes = typeof body === 'string' ? Buffer.from(body, 'utf8') : body + res.writeHead(200, { + 'content-type': contentType, + 'content-length': String(bytes.length), + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + ...extraHeaders, + }) + res.end(req.method === 'HEAD' ? undefined : bytes) +} + +const contentTypesByExtension: Record = { + avif: 'image/avif', + css: 'text/css; charset=utf-8', + csv: 'text/csv; charset=utf-8', + gif: 'image/gif', + htm: 'text/html; charset=utf-8', + html: 'text/html; charset=utf-8', + ico: 'image/x-icon', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + js: 'text/javascript; charset=utf-8', + json: 'application/json; charset=utf-8', + map: 'application/json; charset=utf-8', + md: 'text/markdown; charset=utf-8', + mjs: 'text/javascript; charset=utf-8', + mp3: 'audio/mpeg', + mp4: 'video/mp4', + oga: 'audio/ogg', + ogg: 'audio/ogg', + ogv: 'video/ogg', + otf: 'font/otf', + pdf: 'application/pdf', + png: 'image/png', + svg: 'image/svg+xml; charset=utf-8', + ttf: 'font/ttf', + txt: 'text/plain; charset=utf-8', + wasm: 'application/wasm', + wav: 'audio/wav', + webm: 'video/webm', + webmanifest: 'application/manifest+json; charset=utf-8', + webp: 'image/webp', + woff: 'font/woff', + woff2: 'font/woff2', + xml: 'application/xml; charset=utf-8', +} + +export function inferDevAssetContentType(path: string): string { + const name = path.split(/[\\/]/).pop() ?? '' + const separator = name.lastIndexOf('.') + const extension = separator <= 0 ? '' : name.slice(separator + 1).toLowerCase() + return contentTypesByExtension[extension] ?? 'application/octet-stream' +} diff --git a/src/dev/dev-bundler.ts b/src/dev/dev-bundler.ts index 1611d1f..7a73cd4 100644 --- a/src/dev/dev-bundler.ts +++ b/src/dev/dev-bundler.ts @@ -1,7 +1,8 @@ /** * `kody dev` bundles of `kody.app.entry`: same esbuild options as one-shot * local bundles, plus an in-memory source map (for mapping workerd stacks back - * to `.ts`) and the list of input files (for the watcher's stat poll). + * to `.ts`) and the list of input files (for the watcher's stat poll). Uses an + * incremental esbuild context so rebuilds only reparse changed files. */ import * as esbuild from 'esbuild' @@ -21,6 +22,8 @@ export type DevBundler = { dispose: () => Promise } +type DevBuildContext = esbuild.BuildContext<{ write: false; metafile: true }> + export function createDevBundler(input: { entryPath: string; root: string }): DevBundler { const entry = resolve(input.entryPath) const base = localBundleOptions(entry) @@ -33,21 +36,37 @@ export function createDevBundler(input: { entryPath: string; root: string }): De outfile: join(input.root, '__kody_dev_bundle.js'), } satisfies esbuild.BuildOptions const paths = { workingDir: base.absWorkingDir, displayRoot: input.root } + let disposed = false + let context: Promise | null = null return { build: async () => { - let result: esbuild.BuildResult + let result: esbuild.BuildResult<{ write: false; metafile: true }> try { - result = await esbuild.build(options) + // A context created after dispose would never be released and keeps Node alive. + if (disposed) throw new Error('kody dev bundler was already disposed.') + context ??= esbuild.context(options) + result = await (await context).rebuild() } catch (error) { throw createBundleError(input.entryPath, error, paths) } return readBundle(result, base.absWorkingDir, input.entryPath) }, - dispose: async () => {}, + dispose: async () => { + disposed = true + const current = context + context = null + await (await current?.catch(() => null))?.dispose() + }, } } +export function bundleInputs(metafile: esbuild.Metafile | undefined, workingDir: string): Array { + return Object.keys(metafile?.inputs ?? {}) + .filter((path) => !path.includes('node_modules') && !path.includes(':')) + .map((path) => resolve(workingDir, path)) +} + export function readBundle( result: esbuild.BuildResult<{ metafile: true; write: false }>, workingDir: string, @@ -59,12 +78,9 @@ export function readBundle( if (!js) { throw new BundleError(`Could not bundle ${entryPath}: esbuild produced no output.`, []) } - const inputs = Object.keys(result.metafile?.inputs ?? {}) - .filter((path) => !path.includes('node_modules') && !path.includes(':')) - .map((path) => resolve(workingDir, path)) return { code: js.text.replace(/\n\/\/# sourceMappingURL=\S+\s*$/, '\n'), sourceMap: map?.text ?? null, - inputs, + inputs: bundleInputs(result.metafile, workingDir), } } diff --git a/src/dev/dev-events.ts b/src/dev/dev-events.ts index 319a21a..0b24314 100644 --- a/src/dev/dev-events.ts +++ b/src/dev/dev-events.ts @@ -31,7 +31,7 @@ export type DevEvent = } | { type: 'log' - source: 'app' | 'workerd' + source: 'app' | 'workerd' | 'prebuild' level: DevLogLevel message: string request: DevRequestTag | null @@ -105,7 +105,7 @@ export function formatDevEvent(event: DevEvent): { stream: 'stdout' | 'stderr'; text: `${formatRequestTag(event.request)} Uncaught ${event.stack || event.message}`, } case 'log': { - const tag = event.source === 'workerd' ? '[workerd]' : formatRequestTag(event.request) + const tag = event.source === 'app' ? formatRequestTag(event.request) : `[${event.source}]` const level = event.level === 'log' || event.level === 'info' ? '' : `${event.level}: ` const stream = event.level === 'error' || event.level === 'warn' ? 'stderr' : 'stdout' return { stream, text: `${tag} ${level}${event.message}` } diff --git a/src/dev/dev-port.ts b/src/dev/dev-port.ts new file mode 100644 index 0000000..066feb7 --- /dev/null +++ b/src/dev/dev-port.ts @@ -0,0 +1,90 @@ +/** + * `kody dev` ports: each package gets a stable default (so bookmarks, OAuth + * redirect URIs, and agent notes keep working across restarts) and walks up to + * the next free port when that one is taken. An explicit `--port` is honored + * exactly and fails with an actionable message instead. + */ + +import { createHash } from 'node:crypto' +import type { Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { StructuredCliError } from '../structured-error.js' + +export const devPortRange = { min: 4100, max: 4999 } as const +const fallbackAttempts = 20 + +export function defaultDevPort(kodyId: string): number { + const span = devPortRange.max - devPortRange.min + 1 + const hash = createHash('sha256').update(kodyId).digest() + return devPortRange.min + (hash.readUInt32BE(0) % span) +} + +export function parseDevPort(raw: string): number { + const port = /^\d+$/.test(raw.trim()) ? Number(raw) : Number.NaN + if (!Number.isInteger(port) || port > 65_535) { + throw new StructuredCliError({ + code: 'invalid_port', + message: `--port must be an integer from 0 to 65535 (got "${raw}").`, + fix: 'Pass --port 0 for any free port, or omit --port to use this package’s stable default.', + }) + } + return port +} + +/** + * Listen on `port` exactly when `explicit`, otherwise on `port` or the next + * free port above it. Returns the bound port. + */ +export async function listenDevServer( + server: Server, + input: { host: string; port: number; explicit: boolean }, +): Promise { + const attempts = input.explicit || input.port === 0 ? 1 : fallbackAttempts + for (let attempt = 0; attempt < attempts; attempt += 1) { + const port = input.port === 0 ? 0 : wrapPort(input.port + attempt) + try { + await listenOnce(server, input.host, port) + return (server.address() as AddressInfo).port + } catch (error) { + if (!isAddressInUse(error)) throw error + if (input.explicit) throw portInUseError(input.host, port) + } + } + throw new StructuredCliError({ + code: 'dev_port_in_use', + message: `Ports ${input.port}–${wrapPort(input.port + attempts - 1)} on ${input.host} are all in use.`, + fix: 'Stop old `kody dev` processes, or pass --port 0 for any free port.', + }) +} + +function portInUseError(host: string, port: number): StructuredCliError { + return new StructuredCliError({ + code: 'dev_port_in_use', + message: `Port ${port} on ${host} is already in use.`, + fix: `Find the owner with \`lsof -nP -iTCP:${port} -sTCP:LISTEN\` and stop it, pass another --port, or omit --port to use this package’s default (it moves to the next free port automatically).`, + }) +} + +function wrapPort(port: number): number { + return port > 65_535 ? devPortRange.min + (port - 65_536) : port +} + +function listenOnce(server: Server, host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off('listening', onListening) + reject(error) + } + const onListening = () => { + server.off('error', onError) + resolve() + } + server.once('error', onError) + server.once('listening', onListening) + server.listen(port, host) + }) +} + +function isAddressInUse(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === 'EADDRINUSE' +} diff --git a/src/dev/prebuild.ts b/src/dev/prebuild.ts new file mode 100644 index 0000000..61ec76d --- /dev/null +++ b/src/dev/prebuild.ts @@ -0,0 +1,109 @@ +/** + * `kody.dev.prebuild`: a shell command `kody dev` runs from the package root + * before every build, so generated assets (CSS, icons, data files) stay in + * sync with the source without a second watcher process. + */ + +import { spawn } from 'node:child_process' +import type { Readable } from 'node:stream' +import { apiTokenEnvVar } from '../defaults.js' + +const outputTailLines = 20 + +export class PrebuildError extends Error { + constructor(message: string) { + super(message) + this.name = 'PrebuildError' + } +} + +export async function runPrebuild(input: { + command: string + cwd: string + onSpawn?: (pid: number) => void + onExit?: (pid: number) => void + onLine?: (line: string, stream: 'stdout' | 'stderr') => void + /** When aborted, the child is SIGKILLed so graceful `kody dev` shutdown is not blocked. */ + signal?: AbortSignal +}): Promise { + if (input.signal?.aborted) { + throw new PrebuildError('kody.dev.prebuild aborted before start') + } + const env: NodeJS.ProcessEnv = { ...process.env, KODY_DEV: '1' } + delete env[apiTokenEnvVar] + // New process group so abort can SIGKILL the whole tree (sh + npm/node/…). + const child = spawn('sh', ['-c', input.command], { + cwd: input.cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }) + if (child.pid) input.onSpawn?.(child.pid) + const tail: Array = [] + const collect = (stream: 'stdout' | 'stderr') => (line: string) => { + tail.push(line) + if (tail.length > outputTailLines) tail.shift() + input.onLine?.(line, stream) + } + forEachLine(child.stdout, collect('stdout')) + forEachLine(child.stderr, collect('stderr')) + + const killChild = () => { + if (child.exitCode !== null || child.signalCode !== null) return + const pid = child.pid + if (pid === undefined) return + try { + process.kill(-pid, 'SIGKILL') + } catch { + try { + child.kill('SIGKILL') + } catch { + // already gone + } + } + } + if (input.signal) { + if (input.signal.aborted) killChild() + else input.signal.addEventListener('abort', killChild, { once: true }) + } + + try { + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.once('error', reject) + child.once('close', (code, signal) => resolve({ code, signal })) + }, + ) + if (input.signal?.aborted) { + throw new PrebuildError('kody.dev.prebuild aborted') + } + if (result.code === 0) return + const how = result.signal ? `signal ${result.signal}` : `exit ${result.code}` + throw new PrebuildError( + [`kody.dev.prebuild failed (${how}): ${input.command}`, ...tail.map((line) => ` ${line}`)].join( + '\n', + ), + ) + } finally { + input.signal?.removeEventListener('abort', killChild) + if (child.pid) input.onExit?.(child.pid) + } +} + +/** Split stream chunks into lines, including a final incomplete line on end. */ +function forEachLine(stream: Readable | null, onLine: (line: string) => void): void { + if (!stream) return + let buffered = '' + stream.on('data', (chunk: Buffer | string) => { + buffered += typeof chunk === 'string' ? chunk : chunk.toString('utf8') + const lines = buffered.split('\n') + buffered = lines.pop() ?? '' + for (const line of lines) { + if (line.length > 0) onLine(line) + } + }) + stream.on('end', () => { + if (buffered.length > 0) onLine(buffered) + buffered = '' + }) +} diff --git a/src/dev/process-reaper.ts b/src/dev/process-reaper.ts index 65c6e3d..90da43c 100644 --- a/src/dev/process-reaper.ts +++ b/src/dev/process-reaper.ts @@ -27,13 +27,21 @@ while IFS= read -r line; do -*) pids="$(printf '%s' "$pids" | sed "s/ \${line#-} / /g")" ;; esac done -for pid in $pids; do kill -9 "$pid" 2>/dev/null; done +for entry in $pids; do + case "$entry" in + g*) /bin/kill -s KILL -- -\${entry#g} 2>/dev/null ;; + *) /bin/kill -s KILL -- \$entry 2>/dev/null ;; + esac +done rm -rf "$1" ` export type ProcessReaper = { track: (pid: number) => void untrack: (pid: number) => void + /** Track a detached process-group leader; hard CLI death SIGKILLs the whole tree. */ + trackGroup: (pid: number) => void + untrackGroup: (pid: number) => void close: () => void } @@ -63,6 +71,8 @@ export async function startProcessReaper(input: { workDir: string }): Promise send(`+${pid}`), untrack: (pid) => send(`-${pid}`), + trackGroup: (pid) => send(`+g${pid}`), + untrackGroup: (pid) => send(`-g${pid}`), close: () => { reaper?.stdin?.end() reaper = undefined diff --git a/src/help.ts b/src/help.ts index 4e916a0..309c282 100644 --- a/src/help.ts +++ b/src/help.ts @@ -290,6 +290,13 @@ ${orgBlock} console.* streams to the terminal tagged by request. HTML pages live-reload after each rebuild and show an error overlay (SSE at /__kody/dev/events; \`--no-live-reload\` disables). + Stable default port per package (4100-4999, next free port + if taken); \`--port \` is exact (\`--port 0\` = any). + Serves \`kody.app.client\` at /_assets/client..js + (packageContext.clientModuleUrl), \`kody.app.assets\` under + /_assets/, and /_assets/__version.json. \`kody.dev.prebuild\` + (shell command, run before every build) regenerates assets; + \`kody.dev.ignore\` lists watcher path prefixes to skip. Agents: \`--json\` streams NDJSON events (ready, rebuild_start, rebuild_ok, build_error, runtime_error, log); GET /__kody/dev/status returns build id + last error; diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index 99d6300..71b4c4a 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -21,12 +21,19 @@ import { } from 'node:http' import type { AddressInfo } from 'node:net' import { tmpdir } from 'node:os' -import { join, relative } from 'node:path' +import { join, relative, resolve, sep } from 'node:path' import { assertTokenSafeApiUrl, callCapabilityProxy, } from './capability-proxy.js' import { apiTokenEnvVar } from './defaults.js' +import { + createDevClientBundler, + parseDevAssetPath, + serveDevAsset, + type DevClientBundle, + type DevClientBundler, +} from './dev/dev-assets.js' import { forwardLocalGatewayFetchStream } from './local-gateway-fetch-forward.js' import { startLocalRuntimeBridge, @@ -35,6 +42,7 @@ import { import { createDevBundler, type DevBundle } from './dev/dev-bundler.js' import { parseWorkerdStdoutLine, type DevEvent, type DevRequestTag } from './dev/dev-events.js' import { devErrorHeader, devRequestIdHeader } from './dev/dev-protocol.js' +import { listenDevServer } from './dev/dev-port.js' import { devStatusPath, type DevServerStatus } from './dev/dev-registry.js' import { createDevWatcher, type DevWatcher } from './dev/dev-watcher.js' import { sendDevError, type DevErrorView } from './dev/error-page.js' @@ -52,6 +60,7 @@ import { startProcessReaper, sweepStaleDevDirs, } from './dev/process-reaper.js' +import { runPrebuild } from './dev/prebuild.js' import { createStackMapper, type StackMapper } from './dev/stack-trace.js' import { cleanWorkerdStderrLine, @@ -95,7 +104,13 @@ export type PackageAppDevOptions = { apiUrl: string target: KodyTarget host?: string + /** Listen on exactly this port (0 = any free port); EADDRINUSE is an error. */ port?: number + /** + * Used when `port` is unset: try this port, then the next free ones above + * it. Without either, any free port. + */ + defaultPort?: number /** Public mount path the Node proxy strips (default ''). */ appBasePath?: string /** User-owned token org selection (`?org=`), same as execute --local. */ @@ -134,6 +149,16 @@ export type PackageAppDevOptions = { onError?: (message: string) => void /** Rebuilds, build/runtime errors, and app logs. */ onEvent?: (event: DevEvent) => void + /** + * Fired as soon as the HTTP port is bound (before the first build). Used + * to advertise the server in the attach registry so a second `kody dev` + * cannot start during the initial build window. + */ + onListening?: (info: { + port: number + hostedUrl: string + appBasePath: string + }) => void | Promise } export type PackageAppDevServer = { @@ -156,6 +181,7 @@ type Generation = { dir: string workerd: WorkerdHandle code: string + client: DevClientBundle | null mapStack: StackMapper inflight: number retired: boolean @@ -167,7 +193,9 @@ type BuildFailure = { diagnostics: Array } -const defaultDebounceMs = 120 +const defaultDebounceMs = 30 +/** Watcher events this soon after a prebuild finishes are its own writes, not edits. */ +const prebuildSettleMs = 150 /** Longest a request waits for an in-progress rebuild before the old build answers. */ const rebuildGateMs = 10_000 /** Longest a retired generation may keep draining in-flight requests. */ @@ -253,18 +281,48 @@ export async function startPackageAppDev( const proxy = createServer((req, res) => { void handleRequest(req, res) }) - await new Promise((resolve, reject) => { - proxy.once('error', reject) - proxy.listen(options.port ?? 0, host, () => resolve()) - }) + try { + await listenDevServer(proxy, { + host, + port: options.port ?? options.defaultPort ?? 0, + explicit: options.port !== undefined, + }) + } catch (error) { + process.off('exit', killAllSync) + reaper.close() + await rm(workDir, { recursive: true, force: true }) + throw error + } const address = proxy.address() as AddressInfo const hostedUrl = `http://${host}:${address.port}` - const packageContext = buildLocalPackageContext({ - kodyId: options.manifest.kodyId, - packageId: `local:${options.manifest.kodyId}`, - hostedUrl, - appBasePath, - }) + try { + await options.onListening?.({ + port: address.port, + hostedUrl, + appBasePath, + }) + } catch (error) { + // Port is bound but startPackageAppDev has not returned yet — close the + // listen handle now or the process stays alive after the throw. + process.off('exit', killAllSync) + reaper.close() + proxy.closeAllConnections() + await new Promise((resolve) => { + proxy.close(() => resolve()) + }) + await rm(workDir, { recursive: true, force: true }) + throw error + } + const contextFor = (client: DevClientBundle | null) => + buildLocalPackageContext({ + kodyId: options.manifest.kodyId, + packageId: `local:${options.manifest.kodyId}`, + hostedUrl, + appBasePath, + clientModuleUrl: client ? clientModuleUrlFor(client) : null, + }) + const clientModuleUrlFor = (client: DevClientBundle) => + `${hostedUrl}${appBasePath}/_assets/${client.name}` const mockFetchGateway = await resolveMockFetchGateway({ target: options.target, @@ -301,12 +359,26 @@ export async function startPackageAppDev( }, }) - const entryPath = resolvePackagePath(options.manifest, options.manifest.app.entry) + const app = options.manifest.app + const entryPath = resolvePackagePath(options.manifest, app.entry) const bundler = createDevBundler({ entryPath, root }) + const clientBundler: DevClientBundler | null = app.client + ? createDevClientBundler({ + entryPath: resolvePackagePath(options.manifest, app.client), + root, + externals: app.clientExternals, + }) + : null + const assetsDir = app.assets ? resolve(root, app.assets) : null + const prebuild = options.manifest.dev?.prebuild + let prebuildRunning = false + let prebuildSettledAt = 0 + let prebuildAbort: AbortController | undefined const workerdEnv: NodeJS.ProcessEnv = { ...process.env, [runSecretEnvVar]: runSecret } delete workerdEnv[apiTokenEnvVar] function requestBuild(paths: Iterable): Promise { + if (closed) return Promise.resolve() for (const path of paths) changedFiles.add(path) if (buildLoop) { rebuildAgain = true @@ -328,13 +400,22 @@ export async function startPackageAppDev( async function buildOnce(files: Array): Promise { const started = performance.now() emit({ type: 'rebuild_start', files: files.map((file) => relative(root, file) || file) }) - let bundle: DevBundle + const assetsChanged = + assetsDir !== null && + files.some((file) => file === assetsDir || file.startsWith(`${assetsDir}${sep}`)) try { - bundle = await bundler.build() + if (prebuild) { + await runPrebuildStep(prebuild) + } + const [bundle, client] = await Promise.all([ + bundler.build(), + clientBundler ? clientBundler.build() : Promise.resolve(null), + ]) const midBuildEdits = - (await watcher?.track([...bundle.inputs, options.manifest.packageJsonPath], { - ignoreChanges: files, - })) ?? [] + (await watcher?.track( + [...bundle.inputs, ...(client?.inputs ?? []), options.manifest.packageJsonPath], + { ignoreChanges: files }, + )) ?? [] // Queue mid-build edits for the next loop iteration directly — do not // call onChange (debounced); chokidar may already have the same paths // pending and would double-schedule a rebuild after debounce. @@ -342,7 +423,12 @@ export async function startPackageAppDev( for (const path of midBuildEdits) changedFiles.add(path) rebuildAgain = true } - if (active && !buildFailure && bundle.code === active.code) { + if ( + active && + !buildFailure && + bundle.code === active.code && + client?.name === active.client?.name + ) { // Still stamp lastRebuild so agents polling lastRebuild.at see // the no-op rebuild finish (status already flipped building→ready). lastRebuild = { ms: elapsed(started), at: new Date().toISOString() } @@ -352,9 +438,12 @@ export async function startPackageAppDev( buildId: active.id, restarted: false, }) + if (assetsChanged || prebuild) { + liveReloadHub.broadcast({ event: 'reload', data: { buildId: active.id } }) + } return } - const generation = await startGeneration(bundle) + const generation = await startGeneration(bundle, client) if (closed) { await stopGeneration(generation) return @@ -384,13 +473,58 @@ export async function startPackageAppDev( } } - async function startGeneration(bundle: DevBundle): Promise { + let clearWatcherPending: (() => void) | null = null + + async function runPrebuildStep(command: string): Promise { + prebuildRunning = true + prebuildAbort = new AbortController() + let failed = false + try { + await runPrebuild({ + command, + cwd: root, + signal: prebuildAbort.signal, + onSpawn: (pid) => reaper.trackGroup(pid), + onExit: (pid) => reaper.untrackGroup(pid), + onLine: (line, stream) => + emit({ + type: 'log', + source: 'prebuild', + level: stream === 'stderr' ? 'warn' : 'log', + message: redact(line), + request: null, + }), + }) + } catch (error) { + failed = true + throw error + } finally { + prebuildAbort = undefined + prebuildRunning = false + prebuildSettledAt = performance.now() + // Absorb writes even when prebuild fails — otherwise the 1s poll + // treats partial/failed output as new edits and loops rebuilds. + await watcher?.rebaseline() + if (failed) { + // Drop chokidar echoes of the failed prebuild / trigger burst so + // they cannot restart the loop after buildLoop clears. + changedFiles.clear() + rebuildAgain = false + clearWatcherPending?.() + } + } + } + + async function startGeneration( + bundle: DevBundle, + client: DevClientBundle | null, + ): Promise { const id = ++generationCounter const dir = join(workDir, `g${id}`) await writeWorkerFiles({ workDir: dir, code: bundle.code, - packageContext, + packageContext: contextFor(client), packageRoot: root, mockStorage: options.target === 'mock', bridgePort: bridge.port, @@ -440,6 +574,7 @@ export async function startPackageAppDev( dir, workerd, code: bundle.code, + client, mapStack: createStackMapper({ sourceMap: bundle.sourceMap, userModule: localWorkerModuleNames.user, @@ -562,6 +697,22 @@ export async function startPackageAppDev( try { if (handleDevRoute(req, res)) return await waitForBuild() + const assetPath = parseDevAssetPath( + stripMount(new URL(req.url ?? '/', hostedUrl).pathname, appBasePath), + ) + if (assetPath !== null) { + const client = active?.client ?? null + await serveDevAsset({ + req, + res, + relativePath: assetPath, + assetsDir, + client, + clientModuleUrl: client ? clientModuleUrlFor(client) : null, + appBasePath, + }) + return + } if (buildFailure) { sendDevError(req, res, 500, buildErrorView(buildFailure), { htmlTail: errorPageTail, @@ -718,10 +869,26 @@ export async function startPackageAppDev( ? null : (() => { const pending = new Set() + clearWatcherPending = () => { + pending.clear() + clearTimeout(debounceTimer) + debouncePending = false + const resolve = resolveDebounceFlush + resolveDebounceFlush = null + debounceFlush = null + resolve?.() + } return createDevWatcher({ root, + ignore: options.manifest.dev?.ignore, onChange: (path) => { if (closed) return + if ( + prebuildRunning || + performance.now() - prebuildSettledAt < prebuildSettleMs + ) { + return + } // Fold mid-build watcher events into the active loop // (including another save of a file already building). // Debouncing them instead schedules a second rebuild @@ -766,9 +933,11 @@ export async function startPackageAppDev( resolveDebounceFlush = null debounceFlush = null resolve?.() + prebuildAbort?.abort() await watcher?.close() await buildLoop?.catch(() => {}) await bundler.dispose() + await clientBundler?.dispose() await Promise.all([...generations].map((generation) => stopGeneration(generation))) reaper.close() await bridge.close() @@ -815,7 +984,9 @@ export async function startPackageAppDev( port: address.port, hostedUrl, appBasePath, - packageContext, + get packageContext() { + return contextFor(active?.client ?? null) + }, fetchPath, status, close, diff --git a/src/package-manifest.ts b/src/package-manifest.ts index 58d76a6..b817afe 100644 --- a/src/package-manifest.ts +++ b/src/package-manifest.ts @@ -8,10 +8,21 @@ import { StructuredCliError } from './structured-error.js' export type PackageAppManifest = { entry: string + /** Browser entry (string form, or `entry` of the object form). */ client?: string + /** `client.externals` from the object form; left as bare imports for an import map. */ + clientExternals?: Array assets?: string } +/** Local-only `kody dev` settings (`package.json#kody.dev`); never published behavior. */ +export type PackageDevManifest = { + /** Shell command run from the package root before every `kody dev` build. */ + prebuild?: string + /** Root-relative path prefixes the watcher ignores (e.g. prebuild outputs). */ + ignore: Array +} + /** * Job schedule as authored in package.json. Prod accepts a cron string or the * object form `{ type: 'cron', expression }` (and optional timezone on the job). @@ -57,6 +68,7 @@ export type KodyPackageManifest = { description?: string exports: Record app?: PackageAppManifest + dev: PackageDevManifest jobs: Record webhooks: Record subscriptions: Record @@ -98,6 +110,7 @@ export async function loadPackageManifest( const exportsField = normalizeExports(pkg.exports) const app = parseApp(kody.app, packageJsonPath) + const dev = parseDev(kody.dev, packageJsonPath) const jobs = parseJobs(kody.jobs, packageJsonPath) const webhooks = parseWebhooks(kody.webhooks, packageJsonPath) const subscriptions = parseSubscriptions(kody.subscriptions, packageJsonPath) @@ -110,6 +123,7 @@ export async function loadPackageManifest( description: typeof kody.description === 'string' ? kody.description : undefined, exports: exportsField, app, + dev, jobs, webhooks, subscriptions, @@ -174,13 +188,77 @@ function parseApp(raw: unknown, where: string): PackageAppManifest | undefined { fix: 'Set `"kody": { "app": { "entry": "./app/router.ts" } }` in package.json.', }) } + let client: string | undefined + let clientExternals: Array | undefined + if (record.client !== undefined && record.client !== null) { + if (typeof record.client === 'string') { + if (!record.client.trim()) { + throw new StructuredCliError({ + code: 'package_manifest_invalid', + message: 'kody.app.client must be a non-empty string path or { entry, externals? }.', + where, + fix: 'Set `"client": "./app/client.ts"` or `"client": { "entry": "./app/client.ts" }`.', + }) + } + client = record.client + } else { + const clientRecord = asRecord(record.client) + if (!clientRecord || typeof clientRecord.entry !== 'string' || !clientRecord.entry.trim()) { + throw new StructuredCliError({ + code: 'package_manifest_invalid', + message: 'kody.app.client.entry must be a non-empty string path.', + where, + fix: 'Set `"client": { "entry": "./app/client.ts", "externals": ["preact"] }`.', + }) + } + client = clientRecord.entry + clientExternals = Array.isArray(clientRecord.externals) + ? clientRecord.externals.filter((item): item is string => typeof item === 'string') + : undefined + } + } return { entry: record.entry, - client: typeof record.client === 'string' ? record.client : undefined, + client, + ...(clientExternals?.length ? { clientExternals } : {}), assets: typeof record.assets === 'string' ? record.assets : undefined, } } +function parseDev(raw: unknown, where: string): PackageDevManifest { + if (raw === undefined || raw === null) return { ignore: [] } + const record = asRecord(raw) + if (!record) { + throw new StructuredCliError({ + code: 'package_manifest_invalid', + message: 'kody.dev must be an object.', + where, + fix: 'Use `"kody": { "dev": { "prebuild": "node scripts/build-assets.mjs", "ignore": ["public/generated"] } }`.', + }) + } + if (record.prebuild !== undefined && (typeof record.prebuild !== 'string' || !record.prebuild.trim())) { + throw new StructuredCliError({ + code: 'package_manifest_invalid', + message: 'kody.dev.prebuild must be a non-empty shell command string.', + where, + }) + } + if ( + record.ignore !== undefined && + (!Array.isArray(record.ignore) || !record.ignore.every((item) => typeof item === 'string')) + ) { + throw new StructuredCliError({ + code: 'package_manifest_invalid', + message: 'kody.dev.ignore must be an array of root-relative path prefixes.', + where, + }) + } + return { + ...(typeof record.prebuild === 'string' ? { prebuild: record.prebuild.trim() } : {}), + ignore: (record.ignore as Array | undefined) ?? [], + } +} + function parseJobs(raw: unknown, where: string): Record { if (raw === undefined || raw === null) return {} const record = asRecord(raw) diff --git a/src/structured-error.ts b/src/structured-error.ts index 342c331..2488797 100644 --- a/src/structured-error.ts +++ b/src/structured-error.ts @@ -15,6 +15,9 @@ export type StructuredErrorCode = | 'dev_handler_missing' | 'dev_surface_missing' | 'invalid_target' + | 'invalid_port' + | 'dev_port_in_use' + | 'dev_already_running' export type StructuredErrorBody = { code: StructuredErrorCode | string diff --git a/test/dev-agent.test.ts b/test/dev-agent.test.ts index 5bdd321..75185f5 100644 --- a/test/dev-agent.test.ts +++ b/test/dev-agent.test.ts @@ -75,6 +75,7 @@ test('kody dev --json emits NDJSON events and --ready-file is written before rea }) const lines: Array> = [] let readyFileAtReady: Record | null = null + let registeredAtRebuildStart = false let registeredAtReady = false try { const code = await runDevServe({ @@ -89,6 +90,9 @@ test('kody dev --json emits NDJSON events and --ready-file is written before rea for (const line of text.split('\n').filter(Boolean)) { const event = JSON.parse(line) as Record lines.push(event) + if (event.type === 'rebuild_start') { + registeredAtRebuildStart = existsSync(devRegistryPath(root, 'mock', cache)) + } if (event.type === 'ready') { readyFileAtReady = JSON.parse(readFileSync(readyFile, 'utf8')) as Record< string, @@ -113,6 +117,11 @@ test('kody dev --json emits NDJSON events and --ready-file is written before rea assert.match(String(ready.statusUrl), /\/__kody\/dev\/status$/) assert.ok(readyFileAtReady) assert.equal((readyFileAtReady as Record).url, ready.url) + assert.equal( + registeredAtRebuildStart, + true, + 'registry is advertised as soon as the port binds, before the first build finishes', + ) assert.equal(registeredAtReady, true) assert.equal(existsSync(readyFile), false, 'ready file is removed on shutdown') assert.equal( diff --git a/test/dev-ports-assets.test.ts b/test/dev-ports-assets.test.ts new file mode 100644 index 0000000..85369d4 --- /dev/null +++ b/test/dev-ports-assets.test.ts @@ -0,0 +1,444 @@ +import assert from 'node:assert/strict' +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { resolveDevAuth, runDevServe } from '../src/dev-command.js' +import { + inferDevAssetContentType, + parseDevAssetPath, + resolveDevAssetFile, +} from '../src/dev/dev-assets.js' +import { + defaultDevPort, + devPortRange, + listenDevServer, + parseDevPort, +} from '../src/dev/dev-port.js' +import { devRegistryPath, writeDevRegistration } from '../src/dev/dev-registry.js' +import { PrebuildError, runPrebuild } from '../src/dev/prebuild.js' +import { startPackageAppDev } from '../src/package-app-dev.js' +import { loadPackageManifest } from '../src/package-manifest.js' +import { StructuredCliError } from '../src/structured-error.js' +import { ensureWorkerdBinary } from '../src/workerd-binary.js' +import { appEntry, waitFor, withDevServer } from './dev-server-harness.js' + +const fixtureRoot = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/sample-package') + +const clientEntryApp = `import { packageContext } from 'kody:runtime' + +export default { + async fetch(): Promise { + return Response.json({ + clientModuleUrl: packageContext.clientModuleUrl, + assetBasePath: packageContext.assetBasePath, + }) + }, +} +` + +async function editPackageJson(root: string, edit: (kody: Record) => void) { + const path = join(root, 'package.json') + const pkg = JSON.parse(await readFile(path, 'utf8')) + edit(pkg.kody) + await writeFile(path, `${JSON.stringify(pkg, null, 2)}\n`) +} + +async function occupy(port: number, host = '127.0.0.1'): Promise { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(port, host, () => resolve()) + }) + return server +} + +const closeServer = (server: Server) => + new Promise((resolve) => server.close(() => resolve())) + +test('defaultDevPort is stable per package and inside the dev range', () => { + const port = defaultDevPort('asteroids') + assert.equal(defaultDevPort('asteroids'), port) + assert.notEqual(defaultDevPort('slideshow'), port) + for (const id of ['a', 'b', 'asteroids', 'slideshow', 'x'.repeat(100)]) { + const value = defaultDevPort(id) + assert.ok(value >= devPortRange.min && value <= devPortRange.max, `${id} → ${value}`) + } +}) + +test('parseDevPort rejects non-integer and out-of-range values', () => { + assert.equal(parseDevPort('0'), 0) + assert.equal(parseDevPort('4321'), 4321) + for (const bad of ['abc', '-1', '65536', '1.5', '']) { + assert.throws(() => parseDevPort(bad), /--port must be an integer/) + } +}) + +test('listenDevServer walks to the next free port unless the port is explicit', async () => { + const blocker = await occupy(0) + const taken = (blocker.address() as AddressInfo).port + const fallback = createServer() + const exact = createServer() + try { + const bound = await listenDevServer(fallback, { + host: '127.0.0.1', + port: taken, + explicit: false, + }) + assert.notEqual(bound, taken) + assert.ok(bound > taken && bound <= taken + 20) + + await assert.rejects( + listenDevServer(exact, { host: '127.0.0.1', port: taken, explicit: true }), + (error: unknown) => { + assert.ok(error instanceof StructuredCliError) + assert.equal(error.code, 'dev_port_in_use') + assert.match(error.message, new RegExp(`Port ${taken} on 127\\.0\\.0\\.1 is already in use`)) + assert.match(error.message, /lsof -nP -iTCP:\d+ -sTCP:LISTEN/) + return true + }, + ) + } finally { + await closeServer(blocker) + if (fallback.listening) await closeServer(fallback) + if (exact.listening) await closeServer(exact) + } +}) + +test('dev asset paths are decoded and never escape the assets dir', () => { + assert.equal(parseDevAssetPath('/_assets/app.css'), 'app.css') + assert.equal(parseDevAssetPath('/_assets/a%20b.png'), 'a b.png') + assert.equal(parseDevAssetPath('/_assets/'), null) + assert.equal(parseDevAssetPath('/other'), null) + assert.equal(parseDevAssetPath('/_assets/%E0%A4%A'), null) + assert.equal(resolveDevAssetFile('/pkg/public', 'css/app.css'), '/pkg/public/css/app.css') + assert.equal(resolveDevAssetFile('/pkg/public', '../package.json'), null) + assert.equal(resolveDevAssetFile('/pkg/public', '/etc/passwd'), null) + assert.equal(resolveDevAssetFile('/pkg/public', 'a\0b'), null) + assert.equal(inferDevAssetContentType('x/app.CSS'), 'text/css; charset=utf-8') + assert.equal(inferDevAssetContentType('sw.js'), 'text/javascript; charset=utf-8') + assert.equal(inferDevAssetContentType('LICENSE'), 'application/octet-stream') +}) + +test('loadPackageManifest reads kody.dev and the object form of kody.app.client', async () => { + const root = await mkdtemp(join(tmpdir(), 'kody-dev-manifest-')) + try { + await writeFile( + join(root, 'package.json'), + JSON.stringify({ + name: 'm', + kody: { + id: 'm', + app: { + entry: './app/entry.ts', + client: { entry: './app/client.ts', externals: ['preact'] }, + assets: './public', + }, + dev: { prebuild: ' node gen.mjs ', ignore: ['public/generated'] }, + }, + }), + ) + const manifest = await loadPackageManifest(root) + assert.equal(manifest.app?.client, './app/client.ts') + assert.deepEqual(manifest.app?.clientExternals, ['preact']) + assert.deepEqual(manifest.dev, { prebuild: 'node gen.mjs', ignore: ['public/generated'] }) + + await writeFile( + join(root, 'package.json'), + JSON.stringify({ name: 'm', kody: { id: 'm', dev: { ignore: 'public' } } }), + ) + await assert.rejects(loadPackageManifest(root), /kody\.dev\.ignore must be an array/) + + await writeFile( + join(root, 'package.json'), + JSON.stringify({ + name: 'm', + kody: { id: 'm', app: { entry: './app/entry.ts', client: { file: './app/client.ts' } } }, + }), + ) + await assert.rejects(loadPackageManifest(root), /kody\.app\.client\.entry must be a non-empty string/) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('kody dev serves kody.app.client, __version.json, and the assets dir like the platform', async () => { + await withDevServer( + async ({ server, root, events }) => { + const context = JSON.parse((await server.fetchPath('/')).body) as { + clientModuleUrl: string + assetBasePath: string + } + assert.equal(context.assetBasePath, '/_assets') + assert.match( + context.clientModuleUrl, + new RegExp(`^${server.hostedUrl}/_assets/client\\.[A-Za-z0-9_-]{16}\\.js$`), + ) + const clientPath = new URL(context.clientModuleUrl).pathname + const client = await server.fetchPath(clientPath) + assert.equal(client.status, 200) + assert.match(client.headers['content-type'] ?? '', /text\/javascript/) + assert.match(client.body, /client-v1/) + assert.match(client.body, /from "preact"/, 'declared externals stay bare imports') + assert.match(client.body, /sourceMappingURL=data:application\/json/) + + const version = JSON.parse((await server.fetchPath('/_assets/__version.json')).body) + assert.deepEqual(version, { + clientModuleUrl: context.clientModuleUrl, + assetBasePath: '/_assets', + publishedCommit: null, + }) + + const css = await server.fetchPath('/_assets/app.css') + assert.equal(css.status, 200) + assert.equal(css.headers['content-type'], 'text/css; charset=utf-8') + assert.equal(css.headers['cache-control'], 'no-store') + assert.equal(css.body, 'body { color: red }\n') + assert.equal((await server.fetchPath('/_assets/missing.css')).status, 404) + assert.equal((await server.fetchPath('/_assets/..%2Fpackage.json')).status, 404) + + await writeFile(join(root, 'app/client.ts'), clientSource('client-v2')) + const nextUrl = await waitFor(async () => { + const body = JSON.parse((await server.fetchPath('/')).body) as { + clientModuleUrl: string + } + return body.clientModuleUrl !== context.clientModuleUrl ? body.clientModuleUrl : undefined + }, 'client hash changes after editing the client entry') + const next = await server.fetchPath(new URL(nextUrl).pathname) + assert.match(next.body, /client-v2/) + assert.equal((await server.fetchPath(clientPath)).status, 404) + + const reloadsBefore = events.filter((event) => event.type === 'rebuild_ok').length + await writeFile(join(root, 'public/app.css'), 'body { color: blue }\n') + await waitFor(async () => { + const response = await server.fetchPath('/_assets/app.css') + return response.body.includes('blue') ? true : undefined + }, 'asset edits are served immediately') + await waitFor( + async () => + events.filter((event) => event.type === 'rebuild_ok').length > reloadsBefore + ? true + : undefined, + 'an asset edit runs a (no-op) rebuild so live reload fires', + ) + }, + { + prepare: async (root) => { + await writeFile(join(root, 'app/entry.ts'), clientEntryApp) + await writeFile(join(root, 'app/client.ts'), clientSource('client-v1')) + await mkdir(join(root, 'public'), { recursive: true }) + await writeFile(join(root, 'public/app.css'), 'body { color: red }\n') + await editPackageJson(root, (kody) => { + kody.app = { + entry: './app/entry.ts', + client: { entry: './app/client.ts', externals: ['preact'] }, + assets: './public', + } + }) + }, + }, + ) +}) + +function clientSource(marker: string): string { + return `import { h } from 'preact'\nconsole.log(${JSON.stringify(marker)}, h)\n` +} + +test('kody.dev.prebuild runs before every build, without retriggering itself', async () => { + await withDevServer( + async ({ server, root, events, writeMessage }) => { + const first = await server.fetchPath('/') + assert.match(first.body, /v0 gen-1/) + + await writeMessage(`export const message = 'v1'\n`) + const next = await waitFor(async () => { + const response = await server.fetchPath('/') + return response.body.includes('v1') ? response : undefined + }, 'rebuild after an edit') + assert.match(next.body, /v1 gen-2/) + await new Promise((resolve) => setTimeout(resolve, 400)) + assert.equal((await readFile(join(root, 'count.txt'), 'utf8')).trim(), '2') + assert.ok( + events.some( + (event) => event.type === 'log' && event.source === 'prebuild' && /generated/.test(event.message), + ), + ) + + await writeFile(join(root, 'gen.sh'), 'echo "boom from prebuild" >&2\nexit 3\n') + await writeMessage(`export const message = 'v2'\n`) + const failed = await waitFor(async () => { + const response = await server.fetchPath('/') + return response.status === 500 ? response : undefined + }, 'prebuild failure becomes a build error') + assert.match(failed.body, /kody\.dev\.prebuild failed \(exit 3\): sh gen\.sh/) + assert.match(failed.body, /boom from prebuild/) + const rebuildStartsAfterFailure = events.filter((event) => event.type === 'rebuild_start').length + await new Promise((resolve) => setTimeout(resolve, 1200)) + assert.equal( + events.filter((event) => event.type === 'rebuild_start').length, + rebuildStartsAfterFailure, + 'failed prebuild output must not retrigger the rebuild loop', + ) + assert.equal(server.status().state, 'build_error') + }, + { + prepare: async (root) => { + await writeFile( + join(root, 'gen.sh'), + [ + 'n=$(cat count.txt 2>/dev/null || echo 0)', + 'n=$((n + 1))', + 'echo "$n" > count.txt', + 'echo "export const gen = \'gen-$n\'" > app/generated.ts', + 'echo "generated gen-$n (KODY_DEV=$KODY_DEV)"', + '', + ].join('\n'), + ) + await writeFile( + join(root, 'app/entry.ts'), + `import { message } from './message.ts'\nimport { gen } from './generated.ts'\n\nexport default {\n async fetch(): Promise {\n return new Response(message + ' ' + gen)\n },\n}\n`, + ) + await editPackageJson(root, (kody) => { + kody.dev = { prebuild: 'sh gen.sh' } + }) + }, + }, + ) +}) + +test('runPrebuild keeps a final incomplete stderr line and aborts on signal', async () => { + const root = await mkdtemp(join(tmpdir(), 'kody-prebuild-unit-')) + try { + await writeFile(join(root, 'partial.sh'), 'printf "no-newline-error" >&2\nexit 2\n') + await assert.rejects( + runPrebuild({ command: 'sh partial.sh', cwd: root }), + (error: unknown) => { + assert.ok(error instanceof PrebuildError) + assert.match(error.message, /no-newline-error/) + return true + }, + ) + + await writeFile(join(root, 'slow.sh'), 'sleep 30\n') + const abort = new AbortController() + const pending = runPrebuild({ command: 'sh slow.sh', cwd: root, signal: abort.signal }) + abort.abort() + await assert.rejects(pending, /kody\.dev\.prebuild aborted/) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('kody dev refuses to start a second server for the same package unless --port is passed', async () => { + await withDevServer(async ({ server, root }) => { + await writeDevRegistration({ + pid: process.pid, + origin: server.hostedUrl, + port: server.port, + basePath: server.appBasePath, + target: 'mock', + root, + }) + await assert.rejects( + runDevServe({ + target: 'mock', + auth: { token: 'mock', apiUrl: 'https://api.kody.codes' }, + cwd: root, + io: { stdout: () => {}, stderr: () => {} }, + serveOnce: true, + }), + (error: unknown) => { + assert.ok(error instanceof StructuredCliError) + assert.equal(error.code, 'dev_already_running') + assert.match(error.message, new RegExp(`already running .* at ${server.hostedUrl}/ \\(pid ${process.pid}\\)`)) + assert.match(error.message, /kody dev fetch/) + return true + }, + ) + }) +}) + +test('kody dev --port leaves the primary attach registry alone', async () => { + await withDevServer(async ({ server, root }) => { + // Own the registry under a different live pid so shutdown of the + // second server cannot delete it via removeDevRegistration's pid check. + await writeDevRegistration({ + pid: process.ppid, + origin: server.hostedUrl, + port: server.port, + basePath: server.appBasePath, + target: 'mock', + root, + }) + const registryPath = devRegistryPath(root, 'mock') + const before = await readFile(registryPath, 'utf8') + const code = await runDevServe({ + target: 'mock', + auth: { token: 'mock', apiUrl: 'https://api.kody.codes' }, + port: 0, + cwd: root, + io: { stdout: () => {}, stderr: () => {} }, + serveOnce: true, + }) + assert.equal(code, 0) + assert.equal(await readFile(registryPath, 'utf8'), before) + }) +}) + +test('onListening failure closes the bound HTTP server', async () => { + const workerdPath = await ensureWorkerdBinary() + const root = await mkdtemp(join(tmpdir(), 'kody-dev-onlistening-')) + const cache = await mkdtemp(join(tmpdir(), 'kody-dev-onlistening-cache-')) + const previousCache = process.env.KODY_CACHE_DIR + process.env.KODY_CACHE_DIR = cache + let boundPort: number | undefined + try { + await cp(fixtureRoot, root, { recursive: true }) + await writeFile(join(root, 'app/entry.ts'), appEntry) + await writeFile(join(root, 'app/message.ts'), `export const message = 'v0'\n`) + const auth = await resolveDevAuth({ + target: 'mock', + token: 'mock', + apiUrl: 'https://api.kody.codes', + resetStorage: true, + packageId: 'local:sample-dev-package', + }) + try { + await assert.rejects( + startPackageAppDev({ + manifest: await loadPackageManifest(root), + token: auth.token, + apiUrl: auth.apiUrl, + target: 'mock', + workerdPath, + onListening: ({ port }) => { + boundPort = port + throw new Error('registry boom') + }, + }), + /registry boom/, + ) + assert.ok(boundPort !== undefined) + const probe = createServer() + try { + await listenDevServer(probe, { + host: '127.0.0.1', + port: boundPort!, + explicit: true, + }) + } finally { + await closeServer(probe) + } + } finally { + await auth.closeMock?.() + } + } finally { + if (previousCache === undefined) delete process.env.KODY_CACHE_DIR + else process.env.KODY_CACHE_DIR = previousCache + await rm(root, { recursive: true, force: true }) + await rm(cache, { recursive: true, force: true }) + } +}) diff --git a/test/dev-server-harness.ts b/test/dev-server-harness.ts index ca1a799..1639d3b 100644 --- a/test/dev-server-harness.ts +++ b/test/dev-server-harness.ts @@ -36,8 +36,12 @@ export async function withDevServer( events: Array writeMessage: (source: string) => Promise }) => Promise, - options: Partial = {}, + options: Partial & { + /** Runs after the fixture is copied and before the manifest is loaded. */ + prepare?: (root: string) => Promise + } = {}, ): Promise { + const { prepare, ...devOptions } = options // Resolve workerd from the real cache before KODY_CACHE_DIR is pointed at scratch. const workerdPath = await ensureWorkerdBinary() const root = await mkdtemp(join(tmpdir(), 'kody-dev-server-test-')) @@ -53,6 +57,7 @@ export async function withDevServer( await rename(`${messagePath}.next`, messagePath) } await writeMessage(`export const message = 'v0'\n`) + await prepare?.(root) const auth = await resolveDevAuth({ target: 'mock', token: 'mock', @@ -69,7 +74,7 @@ export async function withDevServer( debounceMs: 20, workerdPath, onEvent: (event) => events.push(event), - ...options, + ...devOptions, }) try { await run({ server, root, events, writeMessage }) diff --git a/test/dev-server.test.ts b/test/dev-server.test.ts index 50ec63b..037c171 100644 --- a/test/dev-server.test.ts +++ b/test/dev-server.test.ts @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readdir, rm, utimes, writeFile } from 'node:fs/promises import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' +import psList from 'ps-list' import { formatDevEvent, parseWorkerdStdoutLine } from '../src/dev/dev-events.js' import { appLogMarker } from '../src/dev/dev-protocol.js' import { shouldIgnoreWatchPath } from '../src/dev/dev-watcher.js' @@ -71,6 +72,20 @@ test('kody dev swaps workerd without a 503 and recovers from build errors', asyn }) }) +test('a save just before close never starts a rebuild after close', async () => { + await withDevServer( + async ({ server, events, writeMessage }) => { + await writeMessage(`export const message = 'late'\n`) + await sleep(120) + await server.close() + const seen = events.length + await sleep(500) + assert.deepEqual(events.slice(seen), [], 'the pending debounce must not rebuild') + }, + { debounceMs: 300 }, + ) +}) + test('kody dev streams tagged console logs and source-maps runtime errors', async () => { await withDevServer(async ({ server, events }) => { const html = await server.fetchPath('/boom', { headers: { accept: 'text/html' } }) @@ -200,6 +215,30 @@ test('process reaper kills tracked pids and removes the work dir when its pipe c ) }) +test('process reaper kills a detached process group on pipe close', async () => { + const workDir = await mkdtemp(join(tmpdir(), 'kody-dev-reaper-group-')) + // Nested sleep under detached sh — killing only the shell would leave the child. + const shell = spawn('sh', ['-c', 'sleep 30'], { stdio: 'ignore', detached: true }) + assert.ok(shell.pid) + shell.unref() + const childPid = await waitFor(async () => { + const listed = await psList() + const child = listed.find((item) => item.ppid === shell.pid && /sleep/.test(item.name)) + return child?.pid + }, 'detached sleep grandchild') + const reaper = await startProcessReaper({ workDir }) + reaper.trackGroup(shell.pid) + reaper.close() + await waitFor(async () => { + if (isProcessAlive(shell.pid!) || isProcessAlive(childPid)) return undefined + return true + }, 'detached process group death') + await waitFor( + async () => ((await readdir(tmpdir())).includes(workDir.split('/').pop()!) ? undefined : true), + 'work dir removal', + ) +}) + test('sweepStaleDevDirs reaps dirs of dead owners and legacy orphans only', async () => { const tmp = await mkdtemp(join(tmpdir(), 'kody-dev-sweep-')) try {