From c7857c9f0611e90668292dbac4a4221ef30d1b3c Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Sun, 3 May 2026 06:15:59 +0900 Subject: [PATCH] feat(devframe): expose createDevServer as a programmatic CLI building block Extract the dev-server logic out of `createCli` into a peer factory at `devframe/adapters/dev` so authors can drive it from their own CLI framework (commander, yargs, oclif). `createCli` becomes a thin cac wrapper that orchestrates `createDevServer`, `createBuild`, and `createMcpServer`. Co-Authored-By: Claude Opus 4.7 (1M context) --- alias.ts | 1 + devframe/docs/guide/adapters.md | 60 ++++++ devframe/docs/guide/standalone-cli.md | 59 +++++- devframe/packages/devframe/package.json | 1 + .../src/adapters/__tests__/dev.test.ts | 84 ++++++++ .../packages/devframe/src/adapters/_shared.ts | 2 +- .../packages/devframe/src/adapters/cli.ts | 123 ++--------- .../packages/devframe/src/adapters/dev.ts | 194 ++++++++++++++++++ devframe/packages/devframe/src/index.ts | 2 - devframe/packages/devframe/tsdown.config.ts | 1 + .../devframe/adapters/cli.snapshot.d.ts | 7 + .../tsnapi/devframe/adapters/cli.snapshot.js | 2 + .../devframe/adapters/dev.snapshot.d.ts | 28 +++ .../tsnapi/devframe/adapters/dev.snapshot.js | 7 + .../tsnapi/devframe/index.snapshot.d.ts | 3 - .../tsnapi/devframe/index.snapshot.js | 12 +- .../tsnapi/devframe/node.snapshot.d.ts | 27 +-- skills/devframe/SKILL.md | 3 + tsconfig.base.json | 3 + 19 files changed, 475 insertions(+), 144 deletions(-) create mode 100644 devframe/packages/devframe/src/adapters/__tests__/dev.test.ts create mode 100644 devframe/packages/devframe/src/adapters/dev.ts create mode 100644 devframe/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts create mode 100644 devframe/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.js diff --git a/alias.ts b/alias.ts index ee74fd93..7ee1b8be 100644 --- a/alias.ts +++ b/alias.ts @@ -23,6 +23,7 @@ export const alias = { 'devframe/utils/state': df('devframe/src/utils/state.ts'), 'devframe/utils/when': df('devframe/src/utils/when.ts'), 'devframe/adapters/cli': df('devframe/src/adapters/cli.ts'), + 'devframe/adapters/dev': df('devframe/src/adapters/dev.ts'), 'devframe/adapters/build': df('devframe/src/adapters/build.ts'), 'devframe/adapters/vite': df('devframe/src/adapters/vite.ts'), 'devframe/adapters/kit': df('devframe/src/adapters/kit.ts'), diff --git a/devframe/docs/guide/adapters.md b/devframe/docs/guide/adapters.md index 5d41a758..4880fb70 100644 --- a/devframe/docs/guide/adapters.md +++ b/devframe/docs/guide/adapters.md @@ -13,6 +13,7 @@ All adapter factories share the same shape: `createXxx(devtoolDef, options?)`. | Adapter | Entry | Factory | Best for | |---------|-------|---------|----------| | [`cli`](#cli) | `devframe/adapters/cli` | `createCli(def, options?)` | Standalone tools run via `node ./my-tool.js` | +| [`dev`](#dev) | `devframe/adapters/dev` | `createDevServer(def, options?)` | Run the dev server programmatically — drive it from any CLI framework | | [`vite`](#vite) | `devframe/adapters/vite` | `createVitePlugin(def, options?)` | Mount a tool's UI inside an existing Vite dev server | | [`build`](#build) | `devframe/adapters/build` | `createBuild(def, options?)` | Offline reports, CI artifacts, deployable SPA snapshots | | [`kit`](#kit) | `devframe/adapters/kit` | `createKitPlugin(def, options?)` | Integrating into Vite DevTools Kit | @@ -113,6 +114,65 @@ await createCli(devtool, { Structured diagnostics (via `logs-sdk`) continue to surface through their normal reporters. +### Use your own CLI framework + +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: + +| Building block | Entry | Purpose | +|----------------|-------|---------| +| [`createDevServer(def, opts?)`](#dev) | `devframe/adapters/dev` | h3 + WebSocket RPC + SPA mount | +| [`createBuild(def, opts?)`](#build) | `devframe/adapters/build` | Static deploy | +| [`createMcpServer(def, opts?)`](#mcp) | `devframe/adapters/mcp` | stdio MCP server | +| `parseCliFlags(schema, raw)` | `devframe/adapters/cli` | Validate a flag bag against a `CliFlagsSchema` | + +See the [Standalone CLI guide](./standalone-cli#use-your-own-cli-framework) for a worked commander example. + +## Dev + +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. + +```ts +import { createDevServer } from 'devframe/adapters/dev' +import devtool from './devtool' + +const handle = await createDevServer(devtool, { + port: 7777, + onReady: ({ origin }) => console.log(`Ready at ${origin}`), +}) + +// graceful shutdown — SIGINT, hot reload, test teardown +process.on('SIGINT', () => handle.close().then(() => process.exit(0))) +``` + +`createDevServer` returns the underlying `StartedServer` (origin, port, h3 app, WS server, RPC group, `close()`), so callers integrate cleanly into their own process lifecycle. + +| Option | Default | Description | +|--------|---------|-------------| +| `host` | `def.cli?.host ?? 'localhost'` | Bind host. | +| `port` | resolved via `resolveDevServerPort` | Port to listen on. | +| `flags` | `{}` | Parsed flag bag forwarded to `setup(ctx, { flags })`. | +| `distDir` | `def.cli?.distDir` | Required — throws when neither is set. | +| `basePath` | `resolveBasePath(def, 'standalone')` | Mount path override. | +| `app` | fresh h3 app | Pre-configured h3 app to mount onto (custom middleware, auth, extra static assets). | +| `openBrowser` | resolves from `flags.open` / `def.cli?.open` | Explicit on/off override. `false` disables; a string opens that relative path. | +| `onReady` | — | Callback when the WS server is bound. | + +### Port resolution + +`resolveDevServerPort(def, opts?)` is exposed separately so authors can resolve a port up-front (to print it, log it, etc.) before starting the server: + +```ts +import { resolveDevServerPort } from 'devframe/adapters/dev' + +const port = await resolveDevServerPort(devtool, { host: '127.0.0.1' }) +// honors def.cli?.port / portRange / random +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `host` | `def.cli?.host ?? 'localhost'` | Bind host (passed to `get-port-please` for in-use detection). | +| `defaultPort` | `def.cli?.port ?? 9999` | Override the preferred port. | + ## Mount paths The basePath where a devtool's SPA is mounted depends on the adapter it's running under: diff --git a/devframe/docs/guide/standalone-cli.md b/devframe/docs/guide/standalone-cli.md index fd9be307..f66e0c56 100644 --- a/devframe/docs/guide/standalone-cli.md +++ b/devframe/docs/guide/standalone-cli.md @@ -127,8 +127,9 @@ const payload = await rpc.call('my-tool:get-payload') 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: ```ts -import type { InferCliFlags } from 'devframe' -import { defineCliFlags, defineDevtool } from 'devframe' +import type { InferCliFlags } from 'devframe/adapters/cli' +import { defineDevtool } from 'devframe' +import { defineCliFlags } from 'devframe/adapters/cli' import * as v from 'valibot' const appFlags = defineCliFlags({ @@ -255,6 +256,59 @@ const state = await rpc.sharedState.get('my-tool:version') state.on('updated', () => fetchPayload().then(setData)) ``` +## Use your own CLI framework + +`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: + +| Building block | Entry | +|----------------|-------| +| `createDevServer(def, opts?)` | `devframe/adapters/dev` | +| `createBuild(def, opts?)` | `devframe/adapters/build` | +| `createMcpServer(def, opts?)` | `devframe/adapters/mcp` | + +Each one runs against the same `DevtoolDefinition` you'd pass to `createCli`. A commander example: + +```ts [src/cli.ts] +import process from 'node:process' +import { Command } from 'commander' +import { defineDevtool } from 'devframe' +import { createBuild } from 'devframe/adapters/build' +import { createDevServer } from 'devframe/adapters/dev' + +const devtool = defineDevtool({ + id: 'my-tool', + name: 'My Tool', + cli: { distDir: './dist/public', port: 7777 }, + setup(ctx, { flags }) { /* ... */ }, +}) + +const program = new Command('my-tool') + +program + .command('dev', { isDefault: true }) + .option('-p, --port ', 'Port', '7777') + .option('--config ', 'Config file path') + .action(async (opts) => { + const handle = await createDevServer(devtool, { + port: Number(opts.port), + flags: { config: opts.config }, + onReady: ({ origin }) => console.log(`Ready at ${origin}`), + }) + process.on('SIGINT', () => handle.close().then(() => process.exit(0))) + }) + +program + .command('build') + .option('--out-dir ', 'Output directory', 'dist-static') + .action(opts => createBuild(devtool, { outDir: opts.outDir })) + +await program.parseAsync() +``` + +`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. + +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. + ## Why this shape - **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)) - [Devtool Definition](./devtool-definition) — field reference - [Adapters → CLI](./adapters#cli) — full CLI adapter reference including `configureCli` and mount-path rules +- [Adapters → Dev](./adapters#dev) — `createDevServer` reference for bring-your-own-CLI integration - [Client](./client) — `connectDevtool`, shared state, caching - [Agent-Native](./agent-native) — exposing your tool to Claude Desktop, Cursor, etc. diff --git a/devframe/packages/devframe/package.json b/devframe/packages/devframe/package.json index 7d59ddc7..0dd6b685 100644 --- a/devframe/packages/devframe/package.json +++ b/devframe/packages/devframe/package.json @@ -22,6 +22,7 @@ ".": "./dist/index.mjs", "./adapters/build": "./dist/adapters/build.mjs", "./adapters/cli": "./dist/adapters/cli.mjs", + "./adapters/dev": "./dist/adapters/dev.mjs", "./adapters/embedded": "./dist/adapters/embedded.mjs", "./adapters/kit": "./dist/adapters/kit.mjs", "./adapters/mcp": "./dist/adapters/mcp.mjs", diff --git a/devframe/packages/devframe/src/adapters/__tests__/dev.test.ts b/devframe/packages/devframe/src/adapters/__tests__/dev.test.ts new file mode 100644 index 00000000..af3a0609 --- /dev/null +++ b/devframe/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -0,0 +1,84 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getPort } from 'get-port-please' +import { describe, expect, it } from 'vitest' +import { defineDevtool } from '../../types/devtool' +import { createDevServer, resolveDevServerPort } from '../dev' + +function makeTmpDist(): string { + const dir = mkdtempSync(join(tmpdir(), 'devframe-dev-')) + writeFileSync(join(dir, 'index.html'), 'test', 'utf-8') + return dir +} + +describe('adapters/dev', () => { + it('createDevServer starts, exposes .connection.json, and closes', async () => { + const distDir = makeTmpDist() + const devtool = defineDevtool({ + id: 'devframe-test', + name: 'Devframe Test', + setup: () => {}, + }) + + const host = '127.0.0.1' + const port = await getPort({ port: 19999, host }) + const handle = await createDevServer(devtool, { + host, + port, + distDir, + openBrowser: false, + }) + + try { + expect(handle.port).toBe(port) + expect(handle.origin).toBe(`http://${host}:${port}`) + + const res = await fetch(`http://${host}:${port}/.connection.json`) + expect(res.ok).toBe(true) + const meta = await res.json() + expect(meta).toEqual({ backend: 'websocket', websocket: port }) + } + finally { + await handle.close() + } + }) + + it('createDevServer throws when no distDir is configured', async () => { + const devtool = defineDevtool({ + id: 'devframe-test-nodist', + name: 'No Dist', + setup: () => {}, + }) + await expect(createDevServer(devtool, { openBrowser: false })) + .rejects + .toThrow(/no distDir/) + }) + + it('resolveDevServerPort honors def.cli.port as the preferred default', async () => { + const preferred = await getPort({ port: 19500, host: '127.0.0.1' }) + const devtool = defineDevtool({ + id: 'devframe-test-port', + name: 'Port Test', + setup: () => {}, + cli: { port: preferred }, + }) + const port = await resolveDevServerPort(devtool, { host: '127.0.0.1' }) + expect(port).toBe(preferred) + }) + + it('resolveDevServerPort: defaultPort overrides def.cli.port', async () => { + const override = await getPort({ port: 19600, host: '127.0.0.1' }) + const devtool = defineDevtool({ + id: 'devframe-test-port-override', + name: 'Port Override', + setup: () => {}, + cli: { port: 9999 }, + }) + const port = await resolveDevServerPort(devtool, { + host: '127.0.0.1', + defaultPort: override, + }) + expect(port).toBe(override) + }) +}) diff --git a/devframe/packages/devframe/src/adapters/_shared.ts b/devframe/packages/devframe/src/adapters/_shared.ts index c7761321..0de05815 100644 --- a/devframe/packages/devframe/src/adapters/_shared.ts +++ b/devframe/packages/devframe/src/adapters/_shared.ts @@ -14,7 +14,7 @@ export function resolveBasePath(def: DevtoolDefinition, kind: DevtoolDeploymentK return kind === 'standalone' ? '/' : `/.${def.id}/` } -function normalizeBasePath(base: string): string { +export function normalizeBasePath(base: string): string { let out = base.startsWith('/') ? base : `/${base}` if (!out.endsWith('/')) out = `${out}/` diff --git a/devframe/packages/devframe/src/adapters/cli.ts b/devframe/packages/devframe/src/adapters/cli.ts index fc0fc1c1..e11fcef8 100644 --- a/devframe/packages/devframe/src/adapters/cli.ts +++ b/devframe/packages/devframe/src/adapters/cli.ts @@ -1,21 +1,16 @@ import type { CAC } from 'cac' import type { App } from 'h3' -import type { DevtoolDefinition, DevtoolSetupInfo } from '../types/devtool' +import type { DevtoolDefinition } from '../types/devtool' import process from 'node:process' import c from 'ansis' import cac from 'cac' -import { getPort } from 'get-port-please' -import { createApp, eventHandler, fromNodeMiddleware } from 'h3' -import { resolve } from 'pathe' -import sirv from 'sirv' -import { DEVTOOLS_CONNECTION_META_FILENAME } from '../constants' -import { createHostContext } from '../node/context' -import { createH3DevToolsHost } from '../node/host-h3' -import { startHttpAndWs } from '../node/server' -import { resolveBasePath } from './_shared' import { createBuild } from './build' +import { createDevServer, resolveDevServerPort } from './dev' import { flagKeyToOption, isBooleanFlag, parseCliFlags } from './flags' +export { defineCliFlags, parseCliFlags } from './flags' +export type { CliFlagsSchema, InferCliFlags } from './flags' + export interface CreateCliOptions { /** Default port for `dev` (default: 9999). */ defaultPort?: number @@ -71,22 +66,15 @@ export function createCli(d: DevtoolDefinition, options: CreateCliOptions = {}): } devCommand.action(async (_args: unknown, rawFlags: CliFlags) => { - const flags = resolveTypedFlags(d, rawFlags) - const host = flags.host as string ?? defaultHost - // Only include optional fields when set — `get-port-please` spreads - // `_userOptions` over its defaults, so `portRange: undefined` wipes - // out the internal `[]` and crashes when the function tries to - // iterate it. - const portOptions: Parameters[0] = { - port: defaultPort, + const flags = resolveTypedFlags(d, rawFlags) as CliFlags + const host = (flags.host as string | undefined) ?? defaultHost + const port = (flags.port as number | undefined) ?? await resolveDevServerPort(d, { host, defaultPort }) + await createDevServer(d, { host, - } - if (d.cli?.portRange) - portOptions.portRange = d.cli.portRange - if (d.cli?.random) - portOptions.random = d.cli.random - const port = flags.port as number ?? await getPort(portOptions) - await runDevServer(d, { host, port, flags: flags as CliFlags }, options) + port, + flags, + onReady: options.onReady, + }) }) cli @@ -148,88 +136,3 @@ function resolveTypedFlags(d: DevtoolDefinition, raw: Record): } return flags } - -interface DevServerOptions { - host: string - port: number - flags: CliFlags -} - -async function runDevServer(d: DevtoolDefinition, serverOptions: DevServerOptions, cliOptions: CreateCliOptions): Promise { - const distDir = d.cli?.distDir - if (!distDir) - throw new Error(`[devframe] dev: no cli.distDir for "${d.id}". Set \`cli.distDir\` on the definition.`) - - const app = createApp() - const origin = `http://${serverOptions.host}:${serverOptions.port}` - const basePath = resolveBasePath(d, 'standalone') - - const host = createH3DevToolsHost({ - origin, - mount: (base, dir) => { - app.use(base, fromNodeMiddleware(sirv(dir, { dev: true, single: true }))) - }, - }) - - const ctx = await createHostContext({ - cwd: process.cwd(), - mode: 'dev', - host, - }) - const setupInfo: DevtoolSetupInfo = { flags: serverOptions.flags } - await d.setup(ctx, setupInfo) - - // Connection meta — the SPA fetches this to discover the RPC backend. - // In dev the WS endpoint shares the HTTP port, so the client only needs - // to know it's a websocket backend bound to that same port. The path - // sits at the SPA root (next to index.html) so the deployed SPA can - // discover it via a relative `./.connection.json` fetch. - const connectionMetaPath = `${basePath}${DEVTOOLS_CONNECTION_META_FILENAME}` - app.use(connectionMetaPath, eventHandler((event) => { - event.node.res.setHeader('Content-Type', 'application/json') - return event.node.res.end(JSON.stringify({ backend: 'websocket', websocket: serverOptions.port })) - })) - - app.use(basePath, fromNodeMiddleware(sirv(resolve(distDir), { dev: true, single: true }))) - - await startHttpAndWs({ - context: ctx, - host: serverOptions.host, - port: serverOptions.port, - app, - auth: d.cli?.auth, - onReady: async (info) => { - await cliOptions.onReady?.(info) - await maybeOpenBrowser(d, serverOptions.flags, `${info.origin}${basePath}`) - }, - }) -} - -async function maybeOpenBrowser(d: DevtoolDefinition, flags: CliFlags, origin: string): Promise { - const cliOpen = d.cli?.open - // `--no-open` sets flags.open to `false`; `--open` to `true`; unset is undefined. - const shouldOpen = flags.open ?? (cliOpen !== undefined && cliOpen !== false) - if (!shouldOpen) - return - const target = typeof flags.open === 'string' - ? resolveOpenTarget(origin, flags.open) - : typeof cliOpen === 'string' - ? resolveOpenTarget(origin, cliOpen) - : origin - try { - const { default: open } = await import('open') - await open(target) - } - catch { - // `open` is optional; failing to launch a browser shouldn't break - // the dev server. The user can navigate manually. - } -} - -function resolveOpenTarget(origin: string, target: string): string { - if (/^https?:/.test(target)) - return target - if (target.startsWith('/')) - return origin.replace(/\/$/, '') + target - return origin.replace(/\/$/, '') + (target ? `/${target}` : '') -} diff --git a/devframe/packages/devframe/src/adapters/dev.ts b/devframe/packages/devframe/src/adapters/dev.ts new file mode 100644 index 00000000..b3c71b8d --- /dev/null +++ b/devframe/packages/devframe/src/adapters/dev.ts @@ -0,0 +1,194 @@ +import type { App } from 'h3' +import type { StartedServer } from '../node/server' +import type { DevtoolDefinition, DevtoolSetupInfo } from '../types/devtool' +import process from 'node:process' +import { getPort } from 'get-port-please' +import { createApp, eventHandler, fromNodeMiddleware } from 'h3' +import { resolve } from 'pathe' +import sirv from 'sirv' +import { DEVTOOLS_CONNECTION_META_FILENAME } from '../constants' +import { createHostContext } from '../node/context' +import { createH3DevToolsHost } from '../node/host-h3' +import { startHttpAndWs } from '../node/server' +import { normalizeBasePath, resolveBasePath } from './_shared' + +const DEFAULT_PORT = 9999 + +export interface CreateDevServerOptions { + /** Bind host. Default: `def.cli?.host ?? 'localhost'`. */ + host?: string + /** + * Port to listen on. When omitted, falls back to + * {@link resolveDevServerPort}, which respects `def.cli?.port` / + * `portRange` / `random`. + */ + port?: number + /** + * Parsed flag bag forwarded to `setup(ctx, { flags })`. The dev + * server itself only reads `flags.open` from this bag, and only when + * {@link CreateDevServerOptions.openBrowser} is left undefined. + */ + flags?: Record + /** + * Override `def.cli?.distDir`. Throws when neither is set — the dev + * server has nothing to mount otherwise. + */ + distDir?: string + /** + * Override the SPA mount path. Defaults to + * `resolveBasePath(def, 'standalone')` (i.e. `def.basePath` or `/`). + */ + basePath?: string + /** + * h3 app to mount the SPA + connection-meta routes on. When omitted + * a fresh app is created. Pass a pre-configured app to attach custom + * middleware (auth, logging, extra static assets) before devframe's + * own handlers. + */ + app?: App + /** + * Auto-open the browser. When `undefined` the resolution falls + * through to `flags.open` (incl. string path) and finally + * `def.cli?.open`. `false` disables the open regardless of the other + * sources; a string opens that relative path. + */ + openBrowser?: boolean | string + /** + * Called once the WS server is bound. Devframe stays headless + * otherwise — wire this if you want a startup banner. + */ + onReady?: (info: { origin: string, port: number, app: App }) => void | Promise +} + +export interface ResolveDevServerPortOptions { + /** Bind host (passed to `get-port-please` for in-use detection). */ + host?: string + /** Override the preferred port. Default: `def.cli?.port ?? 9999`. */ + defaultPort?: number +} + +/** + * Resolve the listening port for {@link createDevServer}, honoring the + * definition's `cli.port` / `cli.portRange` / `cli.random` settings. + * Exposed separately so authors who run their own argv parsing can + * resolve a port up-front (to print it, log it, etc.) before starting + * the server. + */ +export async function resolveDevServerPort( + def: DevtoolDefinition, + options: ResolveDevServerPortOptions = {}, +): Promise { + const host = options.host ?? def.cli?.host ?? 'localhost' + const port = options.defaultPort ?? def.cli?.port ?? DEFAULT_PORT + // Only include optional fields when set — `get-port-please` spreads + // user options over its defaults, so `portRange: undefined` would + // wipe out the internal `[]` and crash on iteration. + const portOptions: Parameters[0] = { port, host } + if (def.cli?.portRange) + portOptions.portRange = def.cli.portRange + if (def.cli?.random) + portOptions.random = def.cli.random + return getPort(portOptions) +} + +/** + * Start a devframe dev server for a {@link DevtoolDefinition} — + * h3 + WebSocket RPC + the author's SPA mounted at the resolved base + * path. + * + * Returns the underlying {@link StartedServer} handle so callers can + * close it gracefully (SIGINT, hot-reload, test teardown). + * + * Use this directly when integrating devframe into an existing CLI + * framework (commander, yargs, hand-rolled CAC). For the all-in-one + * `dev` / `build` / `mcp` shell, reach for {@link createCli} instead. + */ +export async function createDevServer( + def: DevtoolDefinition, + options: CreateDevServerOptions = {}, +): Promise { + const distDir = options.distDir ?? def.cli?.distDir + if (!distDir) + throw new Error(`[devframe] createDevServer: no distDir for "${def.id}". Set \`cli.distDir\` on the definition or pass it as an option.`) + + const host = options.host ?? def.cli?.host ?? 'localhost' + const port = options.port ?? await resolveDevServerPort(def, { host }) + const flags = options.flags ?? {} + const basePath = options.basePath ? normalizeBasePath(options.basePath) : resolveBasePath(def, 'standalone') + const app = options.app ?? createApp() + const origin = `http://${host}:${port}` + + const h3Host = createH3DevToolsHost({ + origin, + mount: (base, dir) => { + app.use(base, fromNodeMiddleware(sirv(dir, { dev: true, single: true }))) + }, + }) + + const ctx = await createHostContext({ + cwd: process.cwd(), + mode: 'dev', + host: h3Host, + }) + const setupInfo: DevtoolSetupInfo = { flags } + await def.setup(ctx, setupInfo) + + // Connection meta — the SPA fetches this to discover the RPC backend. + // In dev the WS endpoint shares the HTTP port, so the client only needs + // to know it's a websocket backend bound to that same port. The path + // sits at the SPA root (next to index.html) so the deployed SPA can + // discover it via a relative `./.connection.json` fetch. + const connectionMetaPath = `${basePath}${DEVTOOLS_CONNECTION_META_FILENAME}` + app.use(connectionMetaPath, eventHandler((event) => { + event.node.res.setHeader('Content-Type', 'application/json') + return event.node.res.end(JSON.stringify({ backend: 'websocket', websocket: port })) + })) + + app.use(basePath, fromNodeMiddleware(sirv(resolve(distDir), { dev: true, single: true }))) + + return startHttpAndWs({ + context: ctx, + host, + port, + app, + auth: def.cli?.auth, + onReady: async (info) => { + await options.onReady?.(info) + await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser) + }, + }) +} + +async function maybeOpenBrowser( + def: DevtoolDefinition, + flags: Record, + origin: string, + override: boolean | string | undefined, +): Promise { + const flagsOpen = flags.open as boolean | string | undefined + const cliOpen = def.cli?.open + // Explicit override wins; otherwise CLI flag (`--open` / `--no-open` + // / `--open path`); finally the definition default. + const resolved = override ?? flagsOpen ?? cliOpen + if (resolved === undefined || resolved === false) + return + const target = typeof resolved === 'string' + ? resolveOpenTarget(origin, resolved) + : origin + try { + const { default: open } = await import('open') + await open(target) + } + catch { + // `open` is optional; failing to launch a browser shouldn't break + // the dev server. The user can navigate manually. + } +} + +function resolveOpenTarget(origin: string, target: string): string { + if (/^https?:/.test(target)) + return target + if (target.startsWith('/')) + return origin.replace(/\/$/, '') + target + return origin.replace(/\/$/, '') + (target ? `/${target}` : '') +} diff --git a/devframe/packages/devframe/src/index.ts b/devframe/packages/devframe/src/index.ts index 04053cd9..13b71c09 100644 --- a/devframe/packages/devframe/src/index.ts +++ b/devframe/packages/devframe/src/index.ts @@ -1,5 +1,3 @@ // Public API. The full defineDevtool + adapter surface lands in later commits. -export { defineCliFlags } from './adapters/flags' -export type { CliFlagsSchema, InferCliFlags } from './adapters/flags' export * from './define' export type * from './types' diff --git a/devframe/packages/devframe/tsdown.config.ts b/devframe/packages/devframe/tsdown.config.ts index 4ea7e039..a95b2abb 100644 --- a/devframe/packages/devframe/tsdown.config.ts +++ b/devframe/packages/devframe/tsdown.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ 'utils/state': 'src/utils/state.ts', 'utils/when': 'src/utils/when.ts', 'adapters/cli': 'src/adapters/cli.ts', + 'adapters/dev': 'src/adapters/dev.ts', 'adapters/build': 'src/adapters/build.ts', 'adapters/vite': 'src/adapters/vite.ts', 'adapters/kit': 'src/adapters/kit.ts', diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/adapters/cli.snapshot.d.ts b/devframe/tests/__snapshots__/tsnapi/devframe/adapters/cli.snapshot.d.ts index 837f07a0..847c9793 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/adapters/cli.snapshot.d.ts +++ b/devframe/tests/__snapshots__/tsnapi/devframe/adapters/cli.snapshot.d.ts @@ -19,4 +19,11 @@ export interface CreateCliOptions { // #region Functions export declare function createCli(_: DevtoolDefinition, _?: CreateCliOptions): CliHandle; +// #endregion + +// #region Other +export { CliFlagsSchema } +export { defineCliFlags } +export { InferCliFlags } +export { parseCliFlags } // #endregion \ No newline at end of file diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/adapters/cli.snapshot.js b/devframe/tests/__snapshots__/tsnapi/devframe/adapters/cli.snapshot.js index cd937db5..e2e54c74 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/adapters/cli.snapshot.js +++ b/devframe/tests/__snapshots__/tsnapi/devframe/adapters/cli.snapshot.js @@ -3,4 +3,6 @@ */ // #region Functions export function createCli(_, _) {} +export function defineCliFlags(_) {} +export function parseCliFlags(_, _) {} // #endregion \ No newline at end of file diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts b/devframe/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts new file mode 100644 index 00000000..aa80665d --- /dev/null +++ b/devframe/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts @@ -0,0 +1,28 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/adapters/dev` + */ +// #region Interfaces +export interface CreateDevServerOptions { + host?: string; + port?: number; + flags?: Record; + distDir?: string; + basePath?: string; + app?: App; + openBrowser?: boolean | string; + onReady?: (_: { + origin: string; + port: number; + app: App; + }) => void | Promise; +} +export interface ResolveDevServerPortOptions { + host?: string; + defaultPort?: number; +} +// #endregion + +// #region Functions +export declare function createDevServer(_: DevtoolDefinition, _?: CreateDevServerOptions): Promise; +export declare function resolveDevServerPort(_: DevtoolDefinition, _?: ResolveDevServerPortOptions): Promise; +// #endregion \ No newline at end of file diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.js b/devframe/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.js new file mode 100644 index 00000000..6d6b4945 --- /dev/null +++ b/devframe/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.js @@ -0,0 +1,7 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/adapters/dev` + */ +// #region Other +export { createDevServer } +export { resolveDevServerPort } +// #endregion \ No newline at end of file diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/devframe/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index a35a180b..ba549f25 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/devframe/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -19,9 +19,7 @@ export { AgentResourceInput } export { AgentTool } export { AgentToolInput } export { ClientScriptEntry } -export { CliFlagsSchema } export { ConnectionMeta } -export { defineCliFlags } export { defineDevtool } export { DevtoolBrowserContext } export { DevtoolCliOptions } @@ -99,7 +97,6 @@ export { EntriesToObject } export { EventEmitter } export { EventsMap } export { EventUnsubscribe } -export { InferCliFlags } export { JsonRenderElement } export { JsonRenderer } export { JsonRenderSpec } diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/index.snapshot.js b/devframe/tests/__snapshots__/tsnapi/devframe/index.snapshot.js index 6e788127..75eacbd2 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/index.snapshot.js +++ b/devframe/tests/__snapshots__/tsnapi/devframe/index.snapshot.js @@ -1,9 +1,11 @@ /** * Generated by tsnapi — public API snapshot of `devframe` */ -// #region Other -export { defineCliFlags } -export { defineCommand } -export { defineJsonRenderSpec } -export { defineRpcFunction } +// #region Functions +export function defineCommand(_) {} +export function defineJsonRenderSpec(_) {} +// #endregion + +// #region Variables +export var defineRpcFunction /* const */ // #endregion \ No newline at end of file diff --git a/devframe/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/devframe/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index afffc99a..0f476b64 100644 --- a/devframe/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/devframe/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -58,26 +58,6 @@ export interface RemoteTokenRecord { origin: string; originLock: boolean; } -export interface StartedServer { - origin: string; - port: number; - app: App; - wss: WebSocketServer; - rpcGroup: BirpcGroup; - close: () => Promise; -} -export interface StartHttpAndWsOptions { - context: DevToolsNodeContext; - host?: string; - port: number; - app?: App; - auth?: boolean; - onReady?: (_: { - origin: string; - port: number; - app: App; - }) => void | Promise; -} export interface StaticRpcDumpCollection { manifest: StaticRpcDumpManifest; files: Record; @@ -242,7 +222,6 @@ export declare function refreshTempAuthToken(): string; export declare function revokeActiveConnectionsForToken(_: DevToolsNodeContext, _: string): Promise; export declare function revokeAuthToken(_: DevToolsNodeContext, _: SharedState, _: string): Promise; export declare function setPendingAuth(_: PendingAuthRequest | null): void; -export declare function startHttpAndWs(_: StartHttpAndWsOptions): Promise; // #endregion // #region Variables @@ -250,4 +229,10 @@ export declare const ContextUtils: { createSimpleClientScript(fn: string | ((ctx: any) => void)): ClientScriptEntry; }; export declare const internalContextMap: WeakMap; +// #endregion + +// #region Other +export { StartedServer } +export { startHttpAndWs } +export { StartHttpAndWsOptions } // #endregion \ No newline at end of file diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 84df24fd..8b96ccf1 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -25,6 +25,7 @@ All adapter factories share the shape `createXxx(devtoolDef, options?)`. | Author goal | Factory | Entry | |-------------|---------|-------| | Standalone CLI for local use | `createCli(def, options?)` | `devframe/adapters/cli` | +| Run the dev server programmatically (any CLI framework) | `createDevServer(def, options?)` | `devframe/adapters/dev` | | Mount a SPA in an existing Vite dev server | `createVitePlugin(def, options?)` | `devframe/adapters/vite` | | Self-contained static deploy with baked data | `createBuild(def, options?)` | `devframe/adapters/build` | | Integrate into Vite DevTools | `createKitPlugin(def, options?)` | `devframe/adapters/kit` | @@ -333,6 +334,8 @@ At runtime, static clients look up the argument hash in the dump; misses resolve | `spa` | Deployable SPA → `./dist-spa/` | | `mcp` | stdio MCP server (experimental) | +**Bring your own CLI framework?** `createCli` is just a cac wrapper around three peer factories — `createDevServer` (`devframe/adapters/dev`), `createBuild` (`devframe/adapters/build`), and `createMcpServer` (`devframe/adapters/mcp`). Use them directly with commander/yargs/oclif when `createCli`'s baked-in command structure doesn't fit. `createDevServer` returns a `StartedServer` handle (`origin`, `port`, `app`, `wss`, `close()`) so you can wire SIGINT / hot-reload teardown into the surrounding program. `parseCliFlags(schema, raw)` and `defineCliFlags(...)` (both from `devframe/adapters/cli`) validate an arbitrary flag bag against a `CliFlagsSchema` — typed flags aren't tied to cac. + ## Testing - Unit-test host classes with fake contexts. diff --git a/tsconfig.base.json b/tsconfig.base.json index 039db98c..b19c0810 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -57,6 +57,9 @@ "devframe/adapters/cli": [ "./devframe/packages/devframe/src/adapters/cli.ts" ], + "devframe/adapters/dev": [ + "./devframe/packages/devframe/src/adapters/dev.ts" + ], "devframe/adapters/build": [ "./devframe/packages/devframe/src/adapters/build.ts" ],