Skip to content

Commit 94db4d7

Browse files
antfuclaude
andauthored
feat(devframe): expose createDevServer as a programmatic CLI building block (#304)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 39c1b6b commit 94db4d7

19 files changed

Lines changed: 475 additions & 144 deletions

File tree

alias.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const alias = {
2323
'devframe/utils/state': df('devframe/src/utils/state.ts'),
2424
'devframe/utils/when': df('devframe/src/utils/when.ts'),
2525
'devframe/adapters/cli': df('devframe/src/adapters/cli.ts'),
26+
'devframe/adapters/dev': df('devframe/src/adapters/dev.ts'),
2627
'devframe/adapters/build': df('devframe/src/adapters/build.ts'),
2728
'devframe/adapters/vite': df('devframe/src/adapters/vite.ts'),
2829
'devframe/adapters/kit': df('devframe/src/adapters/kit.ts'),

devframe/docs/guide/adapters.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ All adapter factories share the same shape: `createXxx(devtoolDef, options?)`.
1313
| Adapter | Entry | Factory | Best for |
1414
|---------|-------|---------|----------|
1515
| [`cli`](#cli) | `devframe/adapters/cli` | `createCli(def, options?)` | Standalone tools run via `node ./my-tool.js` |
16+
| [`dev`](#dev) | `devframe/adapters/dev` | `createDevServer(def, options?)` | Run the dev server programmatically — drive it from any CLI framework |
1617
| [`vite`](#vite) | `devframe/adapters/vite` | `createVitePlugin(def, options?)` | Mount a tool's UI inside an existing Vite dev server |
1718
| [`build`](#build) | `devframe/adapters/build` | `createBuild(def, options?)` | Offline reports, CI artifacts, deployable SPA snapshots |
1819
| [`kit`](#kit) | `devframe/adapters/kit` | `createKitPlugin(def, options?)` | Integrating into Vite DevTools Kit |
@@ -113,6 +114,65 @@ await createCli(devtool, {
113114

114115
Structured diagnostics (via `logs-sdk`) continue to surface through their normal reporters.
115116

117+
### Use your own CLI framework
118+
119+
When `createCli`'s baked-in `dev` / `build` / `mcp` triplet doesn't fit — e.g. integrating devframe into an existing commander/yargs program, or exposing a different command structure — drop down to the peer factories. Same `DevtoolDefinition`, different shell:
120+
121+
| Building block | Entry | Purpose |
122+
|----------------|-------|---------|
123+
| [`createDevServer(def, opts?)`](#dev) | `devframe/adapters/dev` | h3 + WebSocket RPC + SPA mount |
124+
| [`createBuild(def, opts?)`](#build) | `devframe/adapters/build` | Static deploy |
125+
| [`createMcpServer(def, opts?)`](#mcp) | `devframe/adapters/mcp` | stdio MCP server |
126+
| `parseCliFlags(schema, raw)` | `devframe/adapters/cli` | Validate a flag bag against a `CliFlagsSchema` |
127+
128+
See the [Standalone CLI guide](./standalone-cli#use-your-own-cli-framework) for a worked commander example.
129+
130+
## Dev
131+
132+
The `dev` adapter is the building block `createCli` uses internally — h3 + WebSocket RPC + the author's SPA mounted at the resolved base path. Reach for it directly when you want to mount the dev server inside an existing CLI program (commander, yargs, hand-rolled CAC) or attach custom middleware to the underlying h3 app.
133+
134+
```ts
135+
import { createDevServer } from 'devframe/adapters/dev'
136+
import devtool from './devtool'
137+
138+
const handle = await createDevServer(devtool, {
139+
port: 7777,
140+
onReady: ({ origin }) => console.log(`Ready at ${origin}`),
141+
})
142+
143+
// graceful shutdown — SIGINT, hot reload, test teardown
144+
process.on('SIGINT', () => handle.close().then(() => process.exit(0)))
145+
```
146+
147+
`createDevServer` returns the underlying `StartedServer` (origin, port, h3 app, WS server, RPC group, `close()`), so callers integrate cleanly into their own process lifecycle.
148+
149+
| Option | Default | Description |
150+
|--------|---------|-------------|
151+
| `host` | `def.cli?.host ?? 'localhost'` | Bind host. |
152+
| `port` | resolved via `resolveDevServerPort` | Port to listen on. |
153+
| `flags` | `{}` | Parsed flag bag forwarded to `setup(ctx, { flags })`. |
154+
| `distDir` | `def.cli?.distDir` | Required — throws when neither is set. |
155+
| `basePath` | `resolveBasePath(def, 'standalone')` | Mount path override. |
156+
| `app` | fresh h3 app | Pre-configured h3 app to mount onto (custom middleware, auth, extra static assets). |
157+
| `openBrowser` | resolves from `flags.open` / `def.cli?.open` | Explicit on/off override. `false` disables; a string opens that relative path. |
158+
| `onReady` || Callback when the WS server is bound. |
159+
160+
### Port resolution
161+
162+
`resolveDevServerPort(def, opts?)` is exposed separately so authors can resolve a port up-front (to print it, log it, etc.) before starting the server:
163+
164+
```ts
165+
import { resolveDevServerPort } from 'devframe/adapters/dev'
166+
167+
const port = await resolveDevServerPort(devtool, { host: '127.0.0.1' })
168+
// honors def.cli?.port / portRange / random
169+
```
170+
171+
| Option | Default | Description |
172+
|--------|---------|-------------|
173+
| `host` | `def.cli?.host ?? 'localhost'` | Bind host (passed to `get-port-please` for in-use detection). |
174+
| `defaultPort` | `def.cli?.port ?? 9999` | Override the preferred port. |
175+
116176
## Mount paths
117177

118178
The basePath where a devtool's SPA is mounted depends on the adapter it's running under:

devframe/docs/guide/standalone-cli.md

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,9 @@ const payload = await rpc.call('my-tool:get-payload')
127127
For flags that are specific to your tool, declare them as valibot schemas so they're validated at parse time and typed at the call site:
128128

129129
```ts
130-
import type { InferCliFlags } from 'devframe'
131-
import { defineCliFlags, defineDevtool } from 'devframe'
130+
import type { InferCliFlags } from 'devframe/adapters/cli'
131+
import { defineDevtool } from 'devframe'
132+
import { defineCliFlags } from 'devframe/adapters/cli'
132133
import * as v from 'valibot'
133134

134135
const appFlags = defineCliFlags({
@@ -255,6 +256,59 @@ const state = await rpc.sharedState.get('my-tool:version')
255256
state.on('updated', () => fetchPayload().then(setData))
256257
```
257258

259+
## Use your own CLI framework
260+
261+
`createCli` is a convenience wrapper around three lower-level factories — reach for them directly when you already own a CLI framework (commander, yargs, oclif, hand-rolled cac) or want a different command structure:
262+
263+
| Building block | Entry |
264+
|----------------|-------|
265+
| `createDevServer(def, opts?)` | `devframe/adapters/dev` |
266+
| `createBuild(def, opts?)` | `devframe/adapters/build` |
267+
| `createMcpServer(def, opts?)` | `devframe/adapters/mcp` |
268+
269+
Each one runs against the same `DevtoolDefinition` you'd pass to `createCli`. A commander example:
270+
271+
```ts [src/cli.ts]
272+
import process from 'node:process'
273+
import { Command } from 'commander'
274+
import { defineDevtool } from 'devframe'
275+
import { createBuild } from 'devframe/adapters/build'
276+
import { createDevServer } from 'devframe/adapters/dev'
277+
278+
const devtool = defineDevtool({
279+
id: 'my-tool',
280+
name: 'My Tool',
281+
cli: { distDir: './dist/public', port: 7777 },
282+
setup(ctx, { flags }) { /* ... */ },
283+
})
284+
285+
const program = new Command('my-tool')
286+
287+
program
288+
.command('dev', { isDefault: true })
289+
.option('-p, --port <port>', 'Port', '7777')
290+
.option('--config <file>', 'Config file path')
291+
.action(async (opts) => {
292+
const handle = await createDevServer(devtool, {
293+
port: Number(opts.port),
294+
flags: { config: opts.config },
295+
onReady: ({ origin }) => console.log(`Ready at ${origin}`),
296+
})
297+
process.on('SIGINT', () => handle.close().then(() => process.exit(0)))
298+
})
299+
300+
program
301+
.command('build')
302+
.option('--out-dir <dir>', 'Output directory', 'dist-static')
303+
.action(opts => createBuild(devtool, { outDir: opts.outDir }))
304+
305+
await program.parseAsync()
306+
```
307+
308+
`createDevServer` returns the underlying `StartedServer` handle (`origin`, `port`, `app`, `wss`, `rpcGroup`, `close()`) so the surrounding program can drive graceful shutdown — SIGINT, hot reload, integration tests.
309+
310+
For typed flag schemas, `parseCliFlags(schema, rawBag)` (from `devframe/adapters/cli`) validates a commander/yargs flag bag against a `CliFlagsSchema` (the same `defineCliFlags(...)` value you'd put on `cli.flags`). Typed-schema validation isn't tied to cac.
311+
258312
## Why this shape
259313

260314
- **One command, one binary.** `createCli` is a complete CLI — dev, build, spa, mcp all from a single `defineDevtool` value.
@@ -267,5 +321,6 @@ state.on('updated', () => fetchPayload().then(setData))
267321

268322
- [Devtool Definition](./devtool-definition) — field reference
269323
- [Adapters → CLI](./adapters#cli) — full CLI adapter reference including `configureCli` and mount-path rules
324+
- [Adapters → Dev](./adapters#dev)`createDevServer` reference for bring-your-own-CLI integration
270325
- [Client](./client)`connectDevtool`, shared state, caching
271326
- [Agent-Native](./agent-native) — exposing your tool to Claude Desktop, Cursor, etc.

devframe/packages/devframe/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
".": "./dist/index.mjs",
2323
"./adapters/build": "./dist/adapters/build.mjs",
2424
"./adapters/cli": "./dist/adapters/cli.mjs",
25+
"./adapters/dev": "./dist/adapters/dev.mjs",
2526
"./adapters/embedded": "./dist/adapters/embedded.mjs",
2627
"./adapters/kit": "./dist/adapters/kit.mjs",
2728
"./adapters/mcp": "./dist/adapters/mcp.mjs",
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { mkdtempSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { getPort } from 'get-port-please'
5+
import { describe, expect, it } from 'vitest'
6+
import { defineDevtool } from '../../types/devtool'
7+
import { createDevServer, resolveDevServerPort } from '../dev'
8+
9+
function makeTmpDist(): string {
10+
const dir = mkdtempSync(join(tmpdir(), 'devframe-dev-'))
11+
writeFileSync(join(dir, 'index.html'), '<!doctype html><title>test</title>', 'utf-8')
12+
return dir
13+
}
14+
15+
describe('adapters/dev', () => {
16+
it('createDevServer starts, exposes .connection.json, and closes', async () => {
17+
const distDir = makeTmpDist()
18+
const devtool = defineDevtool({
19+
id: 'devframe-test',
20+
name: 'Devframe Test',
21+
setup: () => {},
22+
})
23+
24+
const host = '127.0.0.1'
25+
const port = await getPort({ port: 19999, host })
26+
const handle = await createDevServer(devtool, {
27+
host,
28+
port,
29+
distDir,
30+
openBrowser: false,
31+
})
32+
33+
try {
34+
expect(handle.port).toBe(port)
35+
expect(handle.origin).toBe(`http://${host}:${port}`)
36+
37+
const res = await fetch(`http://${host}:${port}/.connection.json`)
38+
expect(res.ok).toBe(true)
39+
const meta = await res.json()
40+
expect(meta).toEqual({ backend: 'websocket', websocket: port })
41+
}
42+
finally {
43+
await handle.close()
44+
}
45+
})
46+
47+
it('createDevServer throws when no distDir is configured', async () => {
48+
const devtool = defineDevtool({
49+
id: 'devframe-test-nodist',
50+
name: 'No Dist',
51+
setup: () => {},
52+
})
53+
await expect(createDevServer(devtool, { openBrowser: false }))
54+
.rejects
55+
.toThrow(/no distDir/)
56+
})
57+
58+
it('resolveDevServerPort honors def.cli.port as the preferred default', async () => {
59+
const preferred = await getPort({ port: 19500, host: '127.0.0.1' })
60+
const devtool = defineDevtool({
61+
id: 'devframe-test-port',
62+
name: 'Port Test',
63+
setup: () => {},
64+
cli: { port: preferred },
65+
})
66+
const port = await resolveDevServerPort(devtool, { host: '127.0.0.1' })
67+
expect(port).toBe(preferred)
68+
})
69+
70+
it('resolveDevServerPort: defaultPort overrides def.cli.port', async () => {
71+
const override = await getPort({ port: 19600, host: '127.0.0.1' })
72+
const devtool = defineDevtool({
73+
id: 'devframe-test-port-override',
74+
name: 'Port Override',
75+
setup: () => {},
76+
cli: { port: 9999 },
77+
})
78+
const port = await resolveDevServerPort(devtool, {
79+
host: '127.0.0.1',
80+
defaultPort: override,
81+
})
82+
expect(port).toBe(override)
83+
})
84+
})

devframe/packages/devframe/src/adapters/_shared.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function resolveBasePath(def: DevtoolDefinition, kind: DevtoolDeploymentK
1414
return kind === 'standalone' ? '/' : `/.${def.id}/`
1515
}
1616

17-
function normalizeBasePath(base: string): string {
17+
export function normalizeBasePath(base: string): string {
1818
let out = base.startsWith('/') ? base : `/${base}`
1919
if (!out.endsWith('/'))
2020
out = `${out}/`

0 commit comments

Comments
 (0)