Skip to content

Commit 3e991b1

Browse files
authored
feat(devframe): bundle deps into utils/* with stable minimal API (#331)
* refactor(devframe): bundle deps and expose them as minimal utils/* Bundle open, launch-editor, ohash, immer, ansis, and structured-clone-es into devframe and expose them through stable utils/* subpaths with minimal, swap-friendly wrappers. Audit the existing utils to wrap bare re-exports (human-id, whenexpr) and stop leaking immer's Patch/Objectish types from shared-state. Drop the now-unused utils/state.ts duplicate. Migrate all internal call sites to the new util paths and drop the now-transitive deps from packages/{core,kit,vite,rolldown}. * docs(devframe): document bundled utils/* helpers Add a dedicated Utilities page listing every `devframe/utils/*` export (colors, open, launch-editor, hash, structured-clone, human-id, nanoid, promise, events, shared-state, streaming-channel, when), wired into the sidebar and the guide landing page. Drop install-separately language from the standalone-cli recipe (the `launch-editor` peer-dep note) and soften the immer references in shared-state.md, since both are now internal implementation details. Add a Bundled utilities section to the devframe agent skill so agents know which helpers ship in-box.
1 parent c6e0789 commit 3e991b1

74 files changed

Lines changed: 912 additions & 364 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

alias.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,18 @@ export const alias = {
1616
'devframe/node': df('devframe/src/node/index.ts'),
1717
'devframe/constants': df('devframe/src/constants.ts'),
1818
'devframe/internal': df('devframe/src/internal/index.ts'),
19+
'devframe/utils/colors': df('devframe/src/utils/colors.ts'),
1920
'devframe/utils/events': df('devframe/src/utils/events.ts'),
21+
'devframe/utils/hash': df('devframe/src/utils/hash.ts'),
2022
'devframe/utils/human-id': df('devframe/src/utils/human-id.ts'),
23+
'devframe/utils/launch-editor': df('devframe/src/utils/launch-editor.ts'),
2124
'devframe/utils/nanoid': df('devframe/src/utils/nanoid.ts'),
25+
'devframe/utils/open': df('devframe/src/utils/open.ts'),
2226
'devframe/utils/promise': df('devframe/src/utils/promise.ts'),
2327
'devframe/utils/serve-static': df('devframe/src/utils/serve-static.ts'),
2428
'devframe/utils/shared-state': df('devframe/src/utils/shared-state.ts'),
25-
'devframe/utils/state': df('devframe/src/utils/state.ts'),
2629
'devframe/utils/streaming-channel': df('devframe/src/utils/streaming-channel.ts'),
30+
'devframe/utils/structured-clone': df('devframe/src/utils/structured-clone.ts'),
2731
'devframe/utils/when': df('devframe/src/utils/when.ts'),
2832
'devframe/adapters/cli': df('devframe/src/adapters/cli.ts'),
2933
'devframe/adapters/dev': df('devframe/src/adapters/dev.ts'),

devframe/docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ function guideItems(prefix: string): DefaultTheme.NavItemWithLink[] {
2222
{ text: 'Streaming', link: `${prefix}/guide/streaming` },
2323
{ text: 'When Clauses', link: `${prefix}/guide/when-clauses` },
2424
{ text: 'Structured Diagnostics', link: `${prefix}/guide/diagnostics` },
25+
{ text: 'Utilities', link: `${prefix}/guide/utilities` },
2526
{ text: 'Client', link: `${prefix}/guide/client` },
2627
{ text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` },
2728
{ text: 'Nuxt Helper', link: `${prefix}/guide/nuxt` },

devframe/docs/guide/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Devframe keeps its surface small and pushes hub-level UX to the kit consuming it
3232
| **[Diagnostics](./diagnostics)** | Coded warnings/errors via `logs-sdk` — registered into the host logger so adapters and consumers share the same surface. |
3333
| **[Streaming](./streaming)** | One-way (RPC streaming) and two-way (uploads) channel primitives for long-running data. |
3434
| **[When Clauses](./when-clauses)** | VS Code-style conditional expressions for docks, commands, and custom UI. |
35+
| **[Utilities](./utilities)** | Bundled helpers under `devframe/utils/*` — terminal colors, hashing, editor launch, structured-clone serialization, and more. |
3536
| **[Client](./client)** | Browser-side RPC client (`connectDevframe`) with auto-auth and WebSocket / static modes. |
3637
| **[Agent-Native](./agent-native)** | Opt-in exposure of your tool's surface to coding agents over MCP. |
3738

devframe/docs/guide/shared-state.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ outline: deep
44

55
# Shared State
66

7-
Shared state is observable, immutable-by-default state synced between the server and every connected client. It's built on [`immer`](https://immerjs.github.io/immer/): you mutate a draft, Devframe computes the patches to broadcast.
7+
Shared state is observable, immutable-by-default state synced between the server and every connected client. Mutate a draft, and Devframe computes the patches to broadcast.
88

99
Shared state survives reconnects — a newly connected client receives the current snapshot before any further updates. Use it for anything that should stay reactive.
1010

@@ -73,8 +73,8 @@ state.mutate((draft) => {
7373

7474
Under the hood, Devframe:
7575

76-
1. Applies the recipe to the current state via `immer.produce`.
77-
2. Emits an `updated` event with the new state (and patches, if enabled).
76+
1. Applies the recipe to a draft of the current state, producing a new immutable snapshot.
77+
2. Emits an `updated` event with the new state (and `SharedStatePatch[]`, if enabled).
7878
3. Broadcasts the update to all connected clients.
7979

8080
Mutations are idempotent across replay — Devframe tracks a `syncIds` set internally so a patch round-tripped back from a client applies once.

devframe/docs/guide/standalone-cli.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ my-tool/
2828

2929
```ts [src/cli.ts]
3030
import process from 'node:process'
31-
import c from 'ansis'
3231
import { defineDevframe, defineRpcFunction } from 'devframe'
3332
import { createCli } from 'devframe/adapters/cli'
33+
import { colors as c } from 'devframe/utils/colors'
3434
import { resolve } from 'pathe'
3535

3636
const distDir = resolve(import.meta.dirname, '../dist/public')
@@ -171,7 +171,7 @@ defineDevframe({
171171
})
172172
```
173173

174-
This registers `devframe:open-in-editor` (backed by [`launch-editor`](https://www.npmjs.com/package/launch-editor)) and `devframe:open-in-finder` (backed by [`open`](https://www.npmjs.com/package/open)). `launch-editor` is an optional peer dependency — install it in your tool's `package.json`.
174+
This registers `devframe:open-in-editor` and `devframe:open-in-finder`. Both helpers reuse the bundled [`launchEditor`](./utilities#devframe-utils-launch-editor) and [`open`](./utilities#devframe-utils-open) utilities, so there's nothing extra to install.
175175

176176
## Snapshot queries for static builds
177177

devframe/docs/guide/utilities.md

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# Utilities
6+
7+
Devframe ships a set of small, stable helpers under the `devframe/utils/*` subpaths. They cover the most common ancillary tasks a devtool needs — colorising terminal output, hashing arbitrary values, opening files in an editor — without forcing every author to pick (and install) their own library.
8+
9+
Each helper is bundled inside devframe. Importing from `devframe/utils/*` is enough — there's no separate `npm install` for these dependencies.
10+
11+
## Reference
12+
13+
### `devframe/utils/colors`
14+
15+
Terminal ANSI colors. Each entry is callable as a plain function or as a tagged template.
16+
17+
```ts
18+
import { colors as c } from 'devframe/utils/colors'
19+
20+
console.log(c.green('Server ready'))
21+
console.log(c.cyan`listening on port ${port}`)
22+
console.log(`${c.bold(c.red('fatal:'))} something went wrong`)
23+
```
24+
25+
Exports `colors` (`blue`, `cyan`, `gray`, `green`, `red`, `yellow`, `bold`, `dim`, `reset`, `underline`).
26+
27+
### `devframe/utils/open`
28+
29+
Open a URL, file, or other target in the OS default handler.
30+
31+
```ts
32+
import { open } from 'devframe/utils/open'
33+
34+
await open('https://localhost:7777')
35+
await open('./report.html', { wait: true })
36+
```
37+
38+
### `devframe/utils/launch-editor`
39+
40+
Open a file in the user's editor. Target accepts `file`, `file:line`, or `file:line:column`. Pass an optional editor command (e.g. `'code'`, `'subl'`) to override the auto-detected editor.
41+
42+
```ts
43+
import { launchEditor } from 'devframe/utils/launch-editor'
44+
45+
launchEditor('src/main.ts:42:7')
46+
launchEditor('src/main.ts:42:7', 'code')
47+
```
48+
49+
The auto-detection reads the `LAUNCH_EDITOR` environment variable and falls back to common defaults. Most devframes consume this through the prebuilt `openInEditor` recipe — see [Open helpers](./standalone-cli#open-helpers).
50+
51+
### `devframe/utils/hash`
52+
53+
Stable, deterministic hash of any structured-cloneable value. Useful for cache keys and dedup.
54+
55+
```ts
56+
import { hash } from 'devframe/utils/hash'
57+
58+
const key = hash({ functionName, args })
59+
```
60+
61+
### `devframe/utils/structured-clone`
62+
63+
JSON-safe serialization for the structured-clone algorithm — round-trips `Map`, `Set`, `Date`, `BigInt`, cycles, and class instances. Used internally by the RPC wire format; exposed for tools that need the same encoding.
64+
65+
```ts
66+
import {
67+
structuredCloneDeserialize,
68+
structuredCloneParse,
69+
structuredCloneSerialize,
70+
structuredCloneStringify,
71+
} from 'devframe/utils/structured-clone'
72+
73+
const wire = structuredCloneStringify(new Map([['a', 1]]))
74+
const value = structuredCloneParse<Map<string, number>>(wire)
75+
```
76+
77+
### `devframe/utils/human-id`
78+
79+
Generate a human-readable, lowercase, dash-separated random ID.
80+
81+
```ts
82+
import { humanId } from 'devframe/utils/human-id'
83+
84+
humanId() // 'bright-orange-tiger'
85+
```
86+
87+
### `devframe/utils/nanoid`
88+
89+
Tiny URL-safe random ID generator (vendored, no runtime dependency).
90+
91+
```ts
92+
import { nanoid } from 'devframe/utils/nanoid'
93+
94+
nanoid() // 21 chars
95+
nanoid(10) // 10 chars
96+
```
97+
98+
### `devframe/utils/promise`
99+
100+
Promise constructor with externally-controlled resolution.
101+
102+
```ts
103+
import { promiseWithResolver } from 'devframe/utils/promise'
104+
105+
const { promise, resolve, reject } = promiseWithResolver<number>()
106+
```
107+
108+
### `devframe/utils/events`
109+
110+
Generic typed event emitter — `on(event, cb)` returns an unsubscribe function. Used as the eventing primitive across devframe's hosts.
111+
112+
```ts
113+
import { createEventEmitter } from 'devframe/utils/events'
114+
115+
const events = createEventEmitter<{ change: (n: number) => void }>()
116+
const off = events.on('change', n => console.log(n))
117+
events.emit('change', 42)
118+
off()
119+
```
120+
121+
### `devframe/utils/shared-state`
122+
123+
Underlying immutable state container used by `ctx.rpc.sharedState`. Most devframes interact with it indirectly — see [Shared State](./shared-state). Available directly when you need a state hub outside the RPC host.
124+
125+
```ts
126+
import { createSharedState } from 'devframe/utils/shared-state'
127+
128+
const state = createSharedState({ initialValue: { count: 0 } })
129+
state.mutate((draft) => {
130+
draft.count += 1
131+
})
132+
state.value() // { count: 1 }
133+
```
134+
135+
### `devframe/utils/streaming-channel`
136+
137+
Low-level sink/reader primitives for streamed RPC payloads. Most devframes consume these through `ctx.rpc.streaming` — see [Streaming](./streaming).
138+
139+
### `devframe/utils/when`
140+
141+
Statically-validated when-clause expressions for conditional UI visibility. The runtime + types ship from here; the consumer fields (`when` on docks and commands) are kit-side. See [When Clauses](./when-clauses).
142+
143+
## Why a `utils/*` subpath
144+
145+
The utilities are exposed as **stable wrappers over their underlying libraries** rather than bare re-exports. Two consequences:
146+
147+
- **One install.** Consumers do not list these libraries in their own `package.json`. Bundling them inside devframe means version drift across devtools is impossible.
148+
- **Swappable internals.** The wrapper signatures are deliberately narrower than upstream. Devframe can change the implementation (`ansis``picocolors`, `ohash``crypto.subtle.digest`, …) without a breaking change to dependent devtools.
149+
150+
When you need a feature outside the wrapper's minimal surface, prefer extending the wrapper inside devframe over bypassing it.

devframe/packages/devframe/package.json

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,18 @@
3737
"./rpc/transports/ws-client": "./dist/rpc/transports/ws-client.mjs",
3838
"./rpc/transports/ws-server": "./dist/rpc/transports/ws-server.mjs",
3939
"./types": "./dist/types/index.mjs",
40+
"./utils/colors": "./dist/utils/colors.mjs",
4041
"./utils/events": "./dist/utils/events.mjs",
42+
"./utils/hash": "./dist/utils/hash.mjs",
4143
"./utils/human-id": "./dist/utils/human-id.mjs",
44+
"./utils/launch-editor": "./dist/utils/launch-editor.mjs",
4245
"./utils/nanoid": "./dist/utils/nanoid.mjs",
46+
"./utils/open": "./dist/utils/open.mjs",
4347
"./utils/promise": "./dist/utils/promise.mjs",
4448
"./utils/serve-static": "./dist/utils/serve-static.mjs",
4549
"./utils/shared-state": "./dist/utils/shared-state.mjs",
46-
"./utils/state": "./dist/utils/state.mjs",
4750
"./utils/streaming-channel": "./dist/utils/streaming-channel.mjs",
51+
"./utils/structured-clone": "./dist/utils/structured-clone.mjs",
4852
"./utils/when": "./dist/utils/when.mjs",
4953
"./package.json": "./package.json"
5054
},
@@ -68,43 +72,50 @@
6872
},
6973
"dependencies": {
7074
"@valibot/to-json-schema": "catalog:deps",
71-
"ansis": "catalog:deps",
7275
"birpc": "catalog:deps",
7376
"cac": "catalog:deps",
7477
"h3": "catalog:deps",
75-
"immer": "catalog:deps",
76-
"launch-editor": "catalog:deps",
7778
"logs-sdk": "catalog:deps",
7879
"mrmime": "catalog:deps",
79-
"ohash": "catalog:deps",
8080
"pathe": "catalog:deps",
81-
"structured-clone-es": "catalog:deps",
8281
"valibot": "catalog:deps",
8382
"ws": "catalog:deps"
8483
},
8584
"devDependencies": {
8685
"@modelcontextprotocol/sdk": "catalog:deps",
86+
"ansis": "catalog:deps",
87+
"immer": "catalog:deps",
8788
"launch-editor": "catalog:deps",
89+
"ohash": "catalog:deps",
90+
"open": "catalog:deps",
91+
"structured-clone-es": "catalog:deps",
8892
"tsdown": "catalog:build",
8993
"whenexpr": "catalog:deps"
9094
},
9195
"inlinedDependencies": {
96+
"ansis": "4.2.0",
9297
"bundle-name": "4.1.0",
9398
"default-browser": "5.4.0",
9499
"default-browser-id": "5.0.1",
95100
"define-lazy-prop": "3.0.0",
96101
"get-port-please": "3.2.0",
97102
"human-id": "4.1.3",
103+
"immer": "11.1.7",
98104
"is-docker": "3.0.0",
99105
"is-in-ssh": "1.0.0",
100106
"is-inside-container": "1.0.0",
101107
"is-wsl": "3.1.0",
108+
"launch-editor": "2.13.2",
102109
"obug": "2.1.1",
110+
"ohash": "2.0.11",
103111
"open": "11.0.0",
104112
"p-limit": "7.3.0",
105113
"perfect-debounce": "2.1.0",
114+
"picocolors": "1.1.1",
106115
"powershell-utils": "0.1.0",
107116
"run-applescript": "7.1.0",
117+
"shell-quote": "1.8.3",
118+
"structured-clone-es": "2.0.0",
108119
"ua-parser-modern": "0.1.1",
109120
"whenexpr": "0.1.2",
110121
"wsl-utils": "0.3.1",

devframe/packages/devframe/src/adapters/build.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type { DevframeDefinition } from '../types/devframe'
33
import { existsSync } from 'node:fs'
44
import fs from 'node:fs/promises'
55
import process from 'node:process'
6-
import c from 'ansis'
76
import { dirname, resolve } from 'pathe'
87
import {
98
DEVTOOLS_CONNECTION_META_FILENAME,
@@ -13,7 +12,9 @@ import {
1312
import { createHostContext } from '../node/context'
1413
import { createH3DevToolsHost } from '../node/host-h3'
1514
import { collectStaticRpcDump } from '../node/static-dump'
16-
import { strictJsonStringify, structuredCloneStringify } from '../rpc/serialization'
15+
import { strictJsonStringify } from '../rpc/serialization'
16+
import { colors as c } from '../utils/colors'
17+
import { structuredCloneStringify } from '../utils/structured-clone'
1718
import { resolveBasePath } from './_shared'
1819

1920
export interface CreateBuildOptions {

devframe/packages/devframe/src/adapters/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import type { CAC } from 'cac'
22
import type { App } from 'h3'
33
import type { DevframeDefinition } from '../types/devframe'
44
import process from 'node:process'
5-
import c from 'ansis'
65
import cac from 'cac'
6+
import { colors as c } from '../utils/colors'
77
import { createBuild } from './build'
88
import { createDevServer, resolveDevServerPort } from './dev'
99
import { flagKeyToOption, isBooleanFlag, parseCliFlags } from './flags'

devframe/packages/devframe/src/adapters/dev.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { DEVTOOLS_CONNECTION_META_FILENAME } from '../constants'
99
import { createHostContext } from '../node/context'
1010
import { createH3DevToolsHost } from '../node/host-h3'
1111
import { startHttpAndWs } from '../node/server'
12+
import { open } from '../utils/open'
1213
import { serveStaticHandler } from '../utils/serve-static'
1314
import { normalizeBasePath, resolveBasePath } from './_shared'
1415

@@ -185,12 +186,11 @@ async function maybeOpenBrowser(
185186
? resolveOpenTarget(origin, resolved)
186187
: origin
187188
try {
188-
const { default: open } = await import('open')
189189
await open(target)
190190
}
191191
catch {
192-
// `open` is optional; failing to launch a browser shouldn't break
193-
// the dev server. The user can navigate manually.
192+
// Failing to launch a browser shouldn't break the dev server.
193+
// The user can navigate manually.
194194
}
195195
}
196196

0 commit comments

Comments
 (0)