devframe 0.5.4 → 0.6.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{_shared-CUFqO4kJ.mjs → _shared-bWRzeSa0.mjs} +2 -3
- package/dist/adapters/build.d.mts +1 -1
- package/dist/adapters/build.mjs +7 -7
- package/dist/adapters/cli.d.mts +1 -1
- package/dist/adapters/cli.mjs +2 -2
- package/dist/adapters/dev.d.mts +8 -2
- package/dist/adapters/dev.mjs +1 -1
- package/dist/adapters/embedded.d.mts +1 -1
- package/dist/adapters/mcp.d.mts +1 -1
- package/dist/adapters/mcp.mjs +4 -4
- package/dist/client/index.d.mts +148 -6
- package/dist/client/index.mjs +257 -44
- package/dist/constants.d.mts +17 -1
- package/dist/constants.mjs +17 -1
- package/dist/context-3x_bgBgZ.mjs +48 -0
- package/dist/{context-DTRcO_UH.d.mts → context-BEVByy_Z.d.mts} +1 -1
- package/dist/{dev-Cv43GfqM.mjs → dev-BaX8fLH2.mjs} +39 -13
- package/dist/{devframe-BuR6n9ZD.d.mts → devframe-D-gr9sBx.d.mts} +365 -15
- package/dist/{diagnostics-GitRGKbr.mjs → diagnostics-BXwBQmoN.mjs} +1 -1
- package/dist/{dump-9lKIJTLh.mjs → dump-DoXkj4ku.mjs} +1 -1
- package/dist/helpers/vite.d.mts +1 -1
- package/dist/helpers/vite.mjs +11 -11
- package/dist/{host-h3-Dgpgr1Ul.mjs → host-h3-C5SZlk03.mjs} +134 -6
- package/dist/{index-C7M1hnvL.d.mts → index-CxaPKDXX.d.mts} +2 -2
- package/dist/{index-DH2sBIwd.d.mts → index-DXHWuwJk.d.mts} +1 -1
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +1 -1
- package/dist/{launch-editor-BbNhtg7b.mjs → launch-editor-DPfltGA4.mjs} +88 -13
- package/dist/node/auth.d.mts +43 -23
- package/dist/node/auth.mjs +84 -37
- package/dist/node/hub-internals.d.mts +2 -2
- package/dist/node/hub-internals.mjs +2 -2
- package/dist/node/index.d.mts +22 -6
- package/dist/node/index.mjs +4 -4
- package/dist/recipes/open-helpers.d.mts +1 -1
- package/dist/recipes/open-helpers.mjs +3 -3
- package/dist/revoke-ENfxWkFK.mjs +79 -0
- package/dist/rpc/dump.d.mts +1 -1
- package/dist/rpc/dump.mjs +1 -1
- package/dist/rpc/index.d.mts +3 -3
- package/dist/rpc/index.mjs +4 -4
- package/dist/rpc/transports/ws-client.d.mts +1 -1
- package/dist/rpc/transports/ws-client.mjs +2 -2
- package/dist/rpc/transports/ws-server.d.mts +2 -2
- package/dist/rpc/transports/ws-server.mjs +154 -61
- package/dist/{serialization-BD_qXGjd.mjs → serialization-DpLXCy13.mjs} +1 -1
- package/dist/{server-BBaBJaUL.mjs → server-Dl6TU5LE.mjs} +19 -14
- package/dist/server-rX781kz-.d.mts +90 -0
- package/dist/{shared-state-u0y123fi.mjs → shared-state-D4PPieA0.mjs} +4 -4
- package/dist/{shared-state-BlBNYziY.mjs → storage-l2ezeend.mjs} +128 -6
- package/dist/types/index.d.mts +4 -4
- package/dist/{types-BkkQ0Txg.d.mts → types-DZEx4ffs.d.mts} +9 -1
- package/dist/utils/crypto-token.d.mts +24 -0
- package/dist/utils/crypto-token.mjs +45 -0
- package/dist/utils/events.d.mts +1 -1
- package/dist/utils/hash.mjs +1 -1
- package/dist/utils/launch-editor.mjs +1 -1
- package/dist/utils/open.mjs +1 -1
- package/dist/utils/scope.d.mts +11 -0
- package/dist/utils/scope.mjs +15 -0
- package/dist/utils/shared-state.d.mts +1 -1
- package/dist/utils/shared-state.mjs +1 -1
- package/dist/utils/streaming-channel.d.mts +1 -1
- package/dist/utils/structured-clone.mjs +1 -1
- package/dist/{ws-client-CVYX9niP.d.mts → ws-client-D0z0pOhn.d.mts} +1 -1
- package/dist/ws-server-Bc2wBHl-.d.mts +119 -0
- package/package.json +12 -7
- package/skills/devframe/SKILL.md +123 -23
- package/skills/devframe/templates/counter-devframe.ts +14 -4
- package/skills/devframe/templates/spa-devframe.ts +13 -3
- package/skills/devframe/templates/vite-client.ts +3 -2
- package/dist/context-DaKmhhHY.mjs +0 -164
- package/dist/revoke-CL0LSAN9.mjs +0 -803
- package/dist/server-wHlpcdZ9.d.mts +0 -55
- package/dist/utils/human-id.d.mts +0 -8
- package/dist/utils/human-id.mjs +0 -769
- package/dist/ws-server-C1LjmRnp.d.mts +0 -61
- /package/dist/{define-CW9gLnyG.mjs → define-BLWPsH6y.mjs} +0 -0
- /package/dist/{diagnostics-reporter-CBBZwoMv.mjs → diagnostics-reporter-CsIG85Q5.mjs} +0 -0
- /package/dist/{hash-bwOD8RgU.mjs → hash-DFc5WwhO.mjs} +0 -0
- /package/dist/{open-DiQn6zCH.mjs → open-Dusa2Zzd.mjs} +0 -0
- /package/dist/{structured-clone-PdCZwt7F.mjs → structured-clone-CeZOHvkd.mjs} +0 -0
- /package/dist/{structured-clone-jegjz0hM.mjs → structured-clone-XpHLZ8nr.mjs} +0 -0
- /package/dist/{transports-DTFoMUbE.mjs → transports-BmDVn4SF.mjs} +0 -0
package/skills/devframe/SKILL.md
CHANGED
|
@@ -50,11 +50,16 @@ import { defineDevframe, defineRpcFunction } from 'devframe'
|
|
|
50
50
|
export default defineDevframe({
|
|
51
51
|
id: 'my-inspector',
|
|
52
52
|
name: 'My Inspector',
|
|
53
|
+
version: '1.0.0',
|
|
54
|
+
packageName: 'my-inspector',
|
|
55
|
+
homepage: 'https://github.com/me/my-inspector',
|
|
56
|
+
description: 'Inspects things and reports stats.',
|
|
53
57
|
icon: 'ph:magnifying-glass-duotone',
|
|
54
58
|
cli: { distDir: './client/dist' },
|
|
55
59
|
setup(ctx) {
|
|
56
|
-
ctx.
|
|
57
|
-
|
|
60
|
+
const my = ctx.scope('my-inspector') // preferred — auto-namespaces ids
|
|
61
|
+
my.rpc.register(defineRpcFunction({
|
|
62
|
+
name: 'get-stats', // stored as `my-inspector:get-stats`
|
|
58
63
|
type: 'static',
|
|
59
64
|
handler: () => ({ count: 42 }),
|
|
60
65
|
}))
|
|
@@ -62,13 +67,77 @@ export default defineDevframe({
|
|
|
62
67
|
})
|
|
63
68
|
```
|
|
64
69
|
|
|
70
|
+
**Recommended:** keep `version` / `packageName` / `homepage` / `description` in sync with your published package by sourcing them from `package.json` rather than hardcoding. The package's `name` maps to `packageName`; the devframe `name` is a separate display label. Use the JSON import-attribute form so it resolves under both bundlers and Node's native TypeScript execution:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import pkg from '../package.json' with { type: 'json' }
|
|
74
|
+
|
|
75
|
+
export default defineDevframe({
|
|
76
|
+
id: 'my-inspector',
|
|
77
|
+
name: 'My Inspector',
|
|
78
|
+
version: pkg.version,
|
|
79
|
+
packageName: pkg.name,
|
|
80
|
+
homepage: pkg.homepage,
|
|
81
|
+
description: pkg.description,
|
|
82
|
+
// …
|
|
83
|
+
})
|
|
84
|
+
```
|
|
85
|
+
|
|
65
86
|
`setup(ctx)` registers RPC functions, shared state, diagnostics, and any other devframe-level wiring. Host adapters can augment `ctx` with extra surfaces — for example, mounting into Vite DevTools via `createPluginFromDevframe(d)` exposes `docks`, `terminals`, `messages`, and `commands` on the augmented context, and the kit auto-derives an iframe dock entry from `id` / `name` / `icon` / `basePath`. For richer host-side behaviour (custom-render, terminals, palette commands) pass `options.setup` to `createPluginFromDevframe`.
|
|
66
87
|
|
|
67
88
|
See `templates/counter-devframe.ts` for a runnable counter example, `templates/spa-devframe.ts` for an SPA-ready shape, and `templates/vite-client.ts` for the author's client entry.
|
|
68
89
|
|
|
90
|
+
## Scoped context (preferred)
|
|
91
|
+
|
|
92
|
+
`ctx.scope(id)` (server) and `client.scope(id)` (browser) return a namespace-scoped view that auto-prefixes every RPC id, shared-state key, and streaming channel with `id:`, and adds a top-level persisted `settings` store. Prefer it over the raw `ctx.rpc` / client — you name the namespace once and register / call by bare name.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// server — setup(ctx)
|
|
96
|
+
const my = ctx.scope('my-inspector')
|
|
97
|
+
my.rpc.register(getStats) // -> my-inspector:get-stats
|
|
98
|
+
await my.rpc.call('get-stats') // invokeLocal, namespaced
|
|
99
|
+
const state = await my.rpc.sharedState('view') // -> my-inspector:view
|
|
100
|
+
await my.settings.project.set('theme', 'dark')
|
|
101
|
+
|
|
102
|
+
// browser — connectDevframe()
|
|
103
|
+
const my = (await connectDevframe()).scope('my-inspector')
|
|
104
|
+
const stats = await my.rpc.call('get-stats')
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- **Auto-namespacing.** Bare names get `id:` prepended; a name already containing `:` is treated as fully-qualified and passed through (so `my.rpc.call('other-tool:fn')` works). `register` only accepts bare names — passing a namespaced one throws `DF0034`.
|
|
108
|
+
- **Typed bare calls.** Define functions with bare names and augment the registry with `RpcDefinitionsToFunctionsWithNamespace<'my-inspector', typeof serverFunctions>` so the registry keys match the namespaced runtime ids; scoped `call('get-stats')` then stays typed.
|
|
109
|
+
- **`base`.** The scoped context keeps the raw context as `my.base` (and re-exposes `views` / `diagnostics` / `agent` / `host` / `cwd` / `mode` on the server).
|
|
110
|
+
|
|
111
|
+
### Settings
|
|
112
|
+
|
|
113
|
+
`my.settings` is a persisted key-value store at the **top level** of the scoped context (a sibling of `my.rpc`, not under it). Two scopes:
|
|
114
|
+
|
|
115
|
+
- `project` — per-workspace, persisted under the host's `workspace` storage dir.
|
|
116
|
+
- `global` — per-user, persisted under the host's `global` storage dir.
|
|
117
|
+
|
|
118
|
+
Both are file-backed on the server and synced to clients over the shared-state protocol, so a `set` on either side propagates everywhere and survives restarts. All methods are async.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
await my.settings.project.set('theme', 'dark')
|
|
122
|
+
await my.settings.project.get('theme') // 'dark'
|
|
123
|
+
await my.settings.global.all()
|
|
124
|
+
await my.settings.project.delete('theme')
|
|
125
|
+
const off = await my.settings.global.onChange(value => apply(value))
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Type a namespace's settings shape by augmenting `DevframeSettingsRegistry`:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
declare module 'devframe' {
|
|
132
|
+
interface DevframeSettingsRegistry {
|
|
133
|
+
'my-inspector': { theme: 'light' | 'dark', recentFiles: string[] }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
69
138
|
## Project layout
|
|
70
139
|
|
|
71
|
-
Once a devframe grows past a handful of RPC functions, split them out — one file per function under `src/rpc/functions/`, with `src/rpc/index.ts` as the barrel. The `functions/` subdirectory leaves room for sibling files like `src/rpc/utils.ts` (helpers, type aliases) as the surface grows. Each function file exports a named const; the barrel collects them into a `const serverFunctions = [...] as const`
|
|
140
|
+
Once a devframe grows past a handful of RPC functions, split them out — one file per function under `src/rpc/functions/`, with `src/rpc/index.ts` as the barrel. The `functions/` subdirectory leaves room for sibling files like `src/rpc/utils.ts` (helpers, type aliases) as the surface grows. Each function file exports a named const with a **bare** name; the barrel collects them into a `const serverFunctions = [...] as const` and feeds the type-safe client registry recipe with the namespace-aware helper `RpcDefinitionsToFunctionsWithNamespace<'my-tool', typeof serverFunctions>` (it prefixes each bare name to match the namespaced runtime id the scope registers).
|
|
72
141
|
|
|
73
142
|
```ts
|
|
74
143
|
// src/rpc/functions/list-files.ts
|
|
@@ -76,7 +145,7 @@ import { defineRpcFunction } from 'devframe'
|
|
|
76
145
|
import { getMyToolContext } from '../../context'
|
|
77
146
|
|
|
78
147
|
export const listFiles = defineRpcFunction({
|
|
79
|
-
name: 'my-tool:list-files
|
|
148
|
+
name: 'list-files', // bare — the scope namespaces it to `my-tool:list-files`
|
|
80
149
|
type: 'query',
|
|
81
150
|
jsonSerializable: true,
|
|
82
151
|
setup: (ctx) => {
|
|
@@ -95,25 +164,34 @@ export const serverFunctions = [getCwd, listFiles] as const
|
|
|
95
164
|
|
|
96
165
|
declare module 'devframe' {
|
|
97
166
|
interface DevframeRpcServerFunctions
|
|
98
|
-
extends import('devframe/rpc').
|
|
167
|
+
extends import('devframe/rpc').RpcDefinitionsToFunctionsWithNamespace<'my-tool', typeof serverFunctions> {}
|
|
99
168
|
}
|
|
100
169
|
```
|
|
101
170
|
|
|
102
171
|
```ts
|
|
103
172
|
// src/devframe.ts
|
|
104
173
|
import { defineDevframe } from 'devframe/types'
|
|
174
|
+
import pkg from '../package.json' with { type: 'json' }
|
|
105
175
|
import { setMyToolContext } from './context'
|
|
106
176
|
import { serverFunctions } from './rpc'
|
|
107
177
|
|
|
108
178
|
export default defineDevframe({
|
|
109
179
|
id: 'my-tool',
|
|
180
|
+
name: 'My Tool',
|
|
181
|
+
version: pkg.version,
|
|
182
|
+
packageName: pkg.name,
|
|
183
|
+
homepage: pkg.homepage,
|
|
184
|
+
description: pkg.description,
|
|
110
185
|
setup(ctx) {
|
|
186
|
+
const my = ctx.scope('my-tool')
|
|
111
187
|
setMyToolContext(ctx, { loaders: createLoaders() })
|
|
112
|
-
serverFunctions.forEach(fn =>
|
|
188
|
+
serverFunctions.forEach(fn => my.rpc.register(fn))
|
|
113
189
|
},
|
|
114
190
|
})
|
|
115
191
|
```
|
|
116
192
|
|
|
193
|
+
Note `setMyToolContext(ctx, …)` keys off the raw `ctx` (the same object the function `setup(ctx)` receives) — store the per-tool context on `ctx`, register through `my.rpc`.
|
|
194
|
+
|
|
117
195
|
### Sharing setup-time state via `src/context.ts`
|
|
118
196
|
|
|
119
197
|
When per-file RPCs need access to runtime values that `setup(ctx)` constructs once — streaming channels, shared state handles, watchers, loaders, caches — expose them through a `WeakMap<DevframeNodeContext, T>` in a sibling `src/context.ts`. This mirrors the framework's own `internalContextMap` in `packages/devframe/src/node/hub-internals/context.ts`. The WeakMap keys off the existing `DevframeNodeContext` so contexts are garbage-collected automatically when the host tears down.
|
|
@@ -153,12 +231,15 @@ Stateless RPCs and tiny demos can keep the inline shorthand inside `setup(ctx)`
|
|
|
153
231
|
'get-modules' // ✗ — may collide with other devframes sharing the host
|
|
154
232
|
```
|
|
155
233
|
|
|
234
|
+
A [scoped context](#scoped-context-preferred) applies this prefix for you — `ctx.scope('my-inspector').rpc.register({ name: 'get-modules' })` registers `my-inspector:get-modules`. Define and call by bare name through the scope; reach for full ids only via `ctx.base` or when targeting another tool. Dock / command IDs are host-level (not part of the scoped `rpc` surface) — prefix those by hand.
|
|
235
|
+
|
|
156
236
|
## DevframeNodeContext at a glance
|
|
157
237
|
|
|
158
238
|
`setup(ctx)` receives the framework-neutral server-side surface. Each host corresponds to a [docs](https://devfra.me/) page:
|
|
159
239
|
|
|
160
240
|
| Host | Purpose |
|
|
161
241
|
|------|---------|
|
|
242
|
+
| `ctx.scope(id)` | **Preferred** namespace-scoped view — auto-prefixed `rpc` + top-level `settings` store |
|
|
162
243
|
| `ctx.rpc` | Register RPC functions, broadcast, shared state, streaming channels |
|
|
163
244
|
| `ctx.views` | Serve static files via `hostStatic(base, distDir)` |
|
|
164
245
|
| `ctx.diagnostics` | Structured diagnostics host (nostics) — register custom error codes |
|
|
@@ -175,7 +256,7 @@ import { defineRpcFunction } from 'devframe'
|
|
|
175
256
|
import * as v from 'valibot'
|
|
176
257
|
|
|
177
258
|
const getModules = defineRpcFunction({
|
|
178
|
-
name: '
|
|
259
|
+
name: 'get-modules', // bare — registered via `ctx.scope('my-inspector').rpc.register`
|
|
179
260
|
type: 'query',
|
|
180
261
|
jsonSerializable: true,
|
|
181
262
|
args: [v.object({ limit: v.number() })],
|
|
@@ -208,12 +289,13 @@ Set `jsonSerializable: true` when your handler returns plain JSON shapes — the
|
|
|
208
289
|
|
|
209
290
|
`agent: {...}` requires `jsonSerializable: true` (registration throws `DF0019` otherwise). MCP tools speak JSON — opting into the agent surface is also opting into JSON-only data.
|
|
210
291
|
|
|
211
|
-
`
|
|
292
|
+
Through the scope, `my.rpc.broadcast({ method, args, optional?, event?, filter? })` pushes to every connected client (method name namespaced), and `my.rpc.call(name, ...args)` invokes a server function locally without going through transport (the scoped form of `ctx.rpc.invokeLocal`, useful for cross-function composition).
|
|
212
293
|
|
|
213
294
|
## Shared state
|
|
214
295
|
|
|
215
296
|
```ts
|
|
216
|
-
const
|
|
297
|
+
const my = ctx.scope('my-inspector')
|
|
298
|
+
const state = await my.rpc.sharedState('state', { // -> my-inspector:state
|
|
217
299
|
initialValue: { count: 0, items: [] as string[] },
|
|
218
300
|
})
|
|
219
301
|
|
|
@@ -232,7 +314,8 @@ state.mutate((draft) => {
|
|
|
232
314
|
For chunk-style data flowing in either direction — LLM deltas, log tails, build progress, file uploads, mic / screen-share frames — use a streaming channel instead of inventing `action + delta/end` events. The same `channel` object handles both directions:
|
|
233
315
|
|
|
234
316
|
```ts
|
|
235
|
-
const
|
|
317
|
+
const my = ctx.scope('my-inspector')
|
|
318
|
+
const channel = my.rpc.streaming.create<string>('tokens', { // -> my-inspector:tokens
|
|
236
319
|
replayWindow: 256, // server keeps last N chunks per stream id
|
|
237
320
|
closedStreamRetention: 30_000, // ms to hold finished streams for late subscribers
|
|
238
321
|
})
|
|
@@ -252,8 +335,8 @@ stream.writable // WritableStream<T> for `pipeTo`-style consumption
|
|
|
252
335
|
// Convenience — start + pipe in one call:
|
|
253
336
|
await channel.pipeFrom(sourceReadable, { id: 'optional' })
|
|
254
337
|
|
|
255
|
-
// Client
|
|
256
|
-
const reader = rpc.streaming.subscribe<string>('
|
|
338
|
+
// Client — my = (await connectDevframe()).scope('my-inspector')
|
|
339
|
+
const reader = my.rpc.streaming.subscribe<string>('tokens', streamId) // -> my-inspector:tokens
|
|
257
340
|
for await (const token of reader) renderToken(token)
|
|
258
341
|
// Or: reader.readable.pipeTo(domWritable)
|
|
259
342
|
reader.cancel() // server `stream.signal` aborts
|
|
@@ -264,9 +347,9 @@ reader.cancel() // server `stream.signal` aborts
|
|
|
264
347
|
The same channel exposes `openInbound()` for the server side of a client→server upload. Pair it with a normal action that returns the id:
|
|
265
348
|
|
|
266
349
|
```ts
|
|
267
|
-
// Server
|
|
268
|
-
|
|
269
|
-
name: 'my-inspector:upload-file
|
|
350
|
+
// Server — my = ctx.scope('my-inspector')
|
|
351
|
+
my.rpc.register(defineRpcFunction({
|
|
352
|
+
name: 'upload-file', // -> my-inspector:upload-file
|
|
270
353
|
type: 'action',
|
|
271
354
|
args: [v.object({ name: v.string() })],
|
|
272
355
|
returns: v.object({ uploadId: v.string() }),
|
|
@@ -279,9 +362,9 @@ ctx.rpc.register(defineRpcFunction({
|
|
|
279
362
|
},
|
|
280
363
|
}))
|
|
281
364
|
|
|
282
|
-
// Client
|
|
283
|
-
const { uploadId } = await rpc.call('
|
|
284
|
-
const upload = rpc.streaming.upload<Uint8Array>('
|
|
365
|
+
// Client — my = (await connectDevframe()).scope('my-inspector')
|
|
366
|
+
const { uploadId } = await my.rpc.call('upload-file', { name: 'foo' })
|
|
367
|
+
const upload = my.rpc.streaming.upload<Uint8Array>('files', uploadId) // -> my-inspector:files
|
|
285
368
|
fileReadable.pipeTo(upload.writable, { signal: upload.signal })
|
|
286
369
|
```
|
|
287
370
|
|
|
@@ -411,10 +494,11 @@ Authors bring their own SPA (any framework or plain HTML). Client entry:
|
|
|
411
494
|
```ts
|
|
412
495
|
import { connectDevframe } from 'devframe/client'
|
|
413
496
|
|
|
414
|
-
const
|
|
415
|
-
// await
|
|
497
|
+
const client = await connectDevframe()
|
|
498
|
+
// await client.ensureTrusted() // WS mode only — blocks until server accepts
|
|
499
|
+
const my = client.scope('my-inspector') // preferred — namespaced calls
|
|
416
500
|
|
|
417
|
-
const data = await rpc.call('
|
|
501
|
+
const data = await my.rpc.call('get-stats', { limit: 10 })
|
|
418
502
|
```
|
|
419
503
|
|
|
420
504
|
`connectDevframe` auto-detects the backend via `/.devframe/.connection.json`:
|
|
@@ -422,7 +506,7 @@ const data = await rpc.call('my-inspector:get-stats', { limit: 10 })
|
|
|
422
506
|
- **websocket** (dev mode) — full read/write, requires auth handshake. Listen for token updates on the `devframe-auth` BroadcastChannel.
|
|
423
507
|
- **static** (build / spa output) — read-only, resolves calls from the baked RPC dump.
|
|
424
508
|
|
|
425
|
-
Use `rpc.sharedState
|
|
509
|
+
Use `my.rpc.sharedState(key)` for observable state, `my.rpc.register(defineRpcFunction(...))` to receive server broadcasts, `my.rpc.callOptional(...)` when a missing handler should resolve to `undefined` instead of throwing, and `my.settings.{project,global}` for persisted per-workspace / per-user settings synced from the server.
|
|
426
510
|
|
|
427
511
|
## Build dumps
|
|
428
512
|
|
|
@@ -468,8 +552,8 @@ Devframe re-exports a curated set of helpers under `devframe/utils/*`. They are
|
|
|
468
552
|
| `launchEditor` from `devframe/utils/launch-editor` | `launch-editor` | Open `file:line:column` in the user's editor (optional `editor` arg) |
|
|
469
553
|
| `hash` from `devframe/utils/hash` | `ohash` | Stable structural hash — cache keys, dedup |
|
|
470
554
|
| `structuredClone{Serialize,Deserialize,Stringify,Parse}` from `devframe/utils/structured-clone` | `structured-clone-es` | JSON-safe round-trip of `Map` / `Set` / `Date` / `BigInt` / cycles |
|
|
471
|
-
| `humanId` from `devframe/utils/human-id` | `human-id` | Human-readable IDs (`bright-orange-tiger`) |
|
|
472
555
|
| `nanoid` from `devframe/utils/nanoid` | (vendored) | URL-safe random IDs |
|
|
556
|
+
| `randomToken` / `randomDigits` / `timingSafeEqual` from `devframe/utils/crypto-token` | (native WebCrypto) | CSPRNG bearer tokens, one-time codes, constant-time compare |
|
|
473
557
|
| `promiseWithResolver` from `devframe/utils/promise` | — | Externally-controlled `Promise` |
|
|
474
558
|
| `createEventEmitter` from `devframe/utils/events` | — | Typed event bus |
|
|
475
559
|
| `createSharedState` from `devframe/utils/shared-state` | (immer internal) | Immutable state container (see `ctx.rpc.sharedState`) |
|
|
@@ -478,6 +562,20 @@ Devframe re-exports a curated set of helpers under `devframe/utils/*`. They are
|
|
|
478
562
|
|
|
479
563
|
For "open file in editor" + "reveal in finder", prefer the prebuilt `openHelpers` RPC recipe — it wires the two utilities into named RPC functions ready to register.
|
|
480
564
|
|
|
565
|
+
## Security (secure by default)
|
|
566
|
+
|
|
567
|
+
RPC handlers run with the full privileges of the host process, so the boundary that matters is who may connect. Keep that boundary tight:
|
|
568
|
+
|
|
569
|
+
- **`auth` defaults to `true`** — dev-mode connections must authenticate before calls are accepted. Devframe ships the node primitives (`exchangeTempAuthCode`, `verifyAuthToken` in `devframe/node/auth`); the host adapter (e.g. Vite DevTools) provides the interactive `devframe:anonymous:auth` + `devframe:auth:exchange` handlers and auth UI.
|
|
570
|
+
- **`auth: false` trusts every reachable connection.** Use it only for single-user `localhost` tools. Never combine it with a non-loopback bind host, a tunnel, or a shared/CI environment. The default bind host is already `localhost`.
|
|
571
|
+
- **Authentication** exchanges a 6-digit one-time code (shown in the developer's terminal) for a node-issued bearer token via `requestTrustWithCode(code)`. The code is single-use, expires in 5 min, compared in constant time, and rotates after repeated failures — show it only in the terminal, never over the network.
|
|
572
|
+
- **Magic-link (optional):** print `buildOtpAuthUrl(origin)` — `<origin>/?devframe_otp=<code>`. `connectDevframe` reads the code, exchanges it, and strips it from the URL. Integrations can opt out (`otpParam: false`) and drive it via the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` client utilities. Only the single-use code rides the URL, never the bearer; treat the printed link like the code itself.
|
|
573
|
+
- **Tokens are secrets.** The bearer token rides the WS URL (`?devframe_auth_token=…`) — serve over `wss://`/`https://` beyond loopback. Never log the token or code, never bake them into build output. Revoke via `revokeAuthToken(...)`; clients drop to untrusted on `devframe:auth:revoked`.
|
|
574
|
+
- **Authorize handlers.** Any trusted client can call any registered function — validate inputs, and mark state-changing functions `type: 'destructive'` so MCP/agent clients prompt first.
|
|
575
|
+
- **Origin-lock remote docks** (`originLock`) so a dock token is honored only from its expected origin.
|
|
576
|
+
|
|
577
|
+
See [Security](https://devfra.me/security) for the full reference.
|
|
578
|
+
|
|
481
579
|
## Testing
|
|
482
580
|
|
|
483
581
|
- Unit-test host classes with fake contexts.
|
|
@@ -489,6 +587,7 @@ For "open file in editor" + "reveal in finder", prefer the prebuilt `openHelpers
|
|
|
489
587
|
Devframe-level pages (one-tool, portable surface):
|
|
490
588
|
|
|
491
589
|
- [Devframe Definition](https://devfra.me/devframe-definition) — fields, runtime flags, multi-adapter wiring
|
|
590
|
+
- [Scoped Context](https://devfra.me/scoped-context) — `ctx.scope(id)`, auto-namespacing, the `settings` store
|
|
492
591
|
- [Adapters](https://devfra.me/adapters) — full reference for all deployment adapters
|
|
493
592
|
- [RPC](https://devfra.me/rpc) — types, schema, broadcasts, dumps
|
|
494
593
|
- [Shared State](https://devfra.me/shared-state) — patches, events, client-side mutation
|
|
@@ -497,6 +596,7 @@ Devframe-level pages (one-tool, portable surface):
|
|
|
497
596
|
- [Structured Diagnostics](https://devfra.me/diagnostics) — coded errors via `ctx.diagnostics`, register custom codes
|
|
498
597
|
- [Utilities](https://devfra.me/utilities) — bundled `devframe/utils/*` helpers (colors, hash, launchEditor, structured-clone, …)
|
|
499
598
|
- [Client](https://devfra.me/client) — auth handshake, modes, discovery
|
|
599
|
+
- [Security](https://devfra.me/security) — trust model, authentication, secure-by-default practices
|
|
500
600
|
- [Agent-Native](https://devfra.me/agent-native) — agent field, tools/resources, MCP + Claude Desktop
|
|
501
601
|
|
|
502
602
|
Host-specific extras (when mounting into Vite DevTools — other hosts have their own equivalents):
|
|
@@ -2,21 +2,31 @@
|
|
|
2
2
|
// `id` / `name` / `icon` when this definition is mounted into Vite
|
|
3
3
|
// DevTools via `createPluginFromDevframe(devframe)`.
|
|
4
4
|
import { defineDevframe, defineRpcFunction } from 'devframe'
|
|
5
|
+
// Recommended: source version/packageName/homepage/description from your
|
|
6
|
+
// package.json so the published metadata stays in sync. The import-attribute
|
|
7
|
+
// form resolves under both bundlers and Node's native TypeScript execution.
|
|
8
|
+
import pkg from '../package.json' with { type: 'json' }
|
|
5
9
|
|
|
6
10
|
let counter = 0
|
|
7
11
|
|
|
8
12
|
export default defineDevframe({
|
|
9
13
|
id: 'counter',
|
|
10
14
|
name: 'Counter',
|
|
15
|
+
version: pkg.version,
|
|
16
|
+
packageName: pkg.name,
|
|
17
|
+
homepage: pkg.homepage,
|
|
18
|
+
description: pkg.description,
|
|
11
19
|
icon: 'ph:counter-duotone',
|
|
12
20
|
setup(ctx) {
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
// Scoped context — auto-namespaces ids with `counter:`.
|
|
22
|
+
const my = ctx.scope('counter')
|
|
23
|
+
my.rpc.register(defineRpcFunction({
|
|
24
|
+
name: 'get', // -> counter:get
|
|
15
25
|
type: 'static',
|
|
16
26
|
handler: () => ({ count: counter }),
|
|
17
27
|
}))
|
|
18
|
-
|
|
19
|
-
name: 'counter:bump
|
|
28
|
+
my.rpc.register(defineRpcFunction({
|
|
29
|
+
name: 'bump', // -> counter:bump
|
|
20
30
|
type: 'action',
|
|
21
31
|
handler: () => ({ count: ++counter }),
|
|
22
32
|
}))
|
|
@@ -2,18 +2,28 @@
|
|
|
2
2
|
// Host adapters (e.g. the `vite` adapter for Vite DevTools) auto-derive their
|
|
3
3
|
// mount entry from `id` / `name` / `icon`.
|
|
4
4
|
import { defineDevframe, defineRpcFunction } from 'devframe'
|
|
5
|
+
// Recommended: source version/packageName/homepage/description from your
|
|
6
|
+
// package.json so the published metadata stays in sync. The import-attribute
|
|
7
|
+
// form resolves under both bundlers and Node's native TypeScript execution.
|
|
8
|
+
import pkg from '../package.json' with { type: 'json' }
|
|
5
9
|
import * as v from 'valibot'
|
|
6
10
|
|
|
7
11
|
export default defineDevframe({
|
|
8
12
|
id: 'my-inspector',
|
|
9
13
|
name: 'My Inspector',
|
|
14
|
+
version: pkg.version,
|
|
15
|
+
packageName: pkg.name,
|
|
16
|
+
homepage: pkg.homepage,
|
|
17
|
+
description: pkg.description,
|
|
10
18
|
icon: 'ph:magnifying-glass-duotone',
|
|
11
19
|
setup(ctx) {
|
|
12
|
-
ctx.
|
|
13
|
-
|
|
20
|
+
const my = ctx.scope('my-inspector')
|
|
21
|
+
my.rpc.register(defineRpcFunction({
|
|
22
|
+
name: 'analyze', // -> my-inspector:analyze
|
|
14
23
|
type: 'query',
|
|
15
24
|
args: [v.object({ url: v.string() })],
|
|
16
|
-
|
|
25
|
+
returns: v.object({ url: v.string(), verdict: v.literal('ok') }),
|
|
26
|
+
handler: ({ url }: { url: string }) => {
|
|
17
27
|
// Server-side implementation (used by CLI/build adapters).
|
|
18
28
|
return { url, verdict: 'ok' as const }
|
|
19
29
|
},
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
import { connectDevframe } from 'devframe/client'
|
|
3
3
|
|
|
4
4
|
async function main() {
|
|
5
|
-
|
|
5
|
+
// Scope the client to your devframe id — calls are namespaced for you.
|
|
6
|
+
const my = (await connectDevframe()).scope('my-inspector')
|
|
6
7
|
// The method names below are just examples — replace with your own.
|
|
7
|
-
const data = await rpc.call('
|
|
8
|
+
const data = await my.rpc.call('getStats' as any)
|
|
8
9
|
document.getElementById('root')!.textContent = JSON.stringify(data)
|
|
9
10
|
}
|
|
10
11
|
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
import { i as diagnostics, t as createSharedState } from "./shared-state-BlBNYziY.mjs";
|
|
2
|
-
import { n as revokeAuthToken, r as humanId, t as revokeActiveConnectionsForToken } from "./revoke-CL0LSAN9.mjs";
|
|
3
|
-
import fs from "node:fs";
|
|
4
|
-
import { dirname, join } from "pathe";
|
|
5
|
-
//#region ../../node_modules/.pnpm/perfect-debounce@2.1.0/node_modules/perfect-debounce/dist/index.mjs
|
|
6
|
-
const DEBOUNCE_DEFAULTS = { trailing: true };
|
|
7
|
-
/**
|
|
8
|
-
Debounce functions
|
|
9
|
-
@param fn - Promise-returning/async function to debounce.
|
|
10
|
-
@param wait - Milliseconds to wait before calling `fn`. Default value is 25ms
|
|
11
|
-
@returns A function that delays calling `fn` until after `wait` milliseconds have elapsed since the last time it was called.
|
|
12
|
-
@example
|
|
13
|
-
```
|
|
14
|
-
import { debounce } from 'perfect-debounce';
|
|
15
|
-
const expensiveCall = async input => input;
|
|
16
|
-
const debouncedFn = debounce(expensiveCall, 200);
|
|
17
|
-
for (const number of [1, 2, 3]) {
|
|
18
|
-
console.log(await debouncedFn(number));
|
|
19
|
-
}
|
|
20
|
-
//=> 1
|
|
21
|
-
//=> 2
|
|
22
|
-
//=> 3
|
|
23
|
-
```
|
|
24
|
-
*/
|
|
25
|
-
function debounce(fn, wait = 25, options = {}) {
|
|
26
|
-
options = {
|
|
27
|
-
...DEBOUNCE_DEFAULTS,
|
|
28
|
-
...options
|
|
29
|
-
};
|
|
30
|
-
if (!Number.isFinite(wait)) throw new TypeError("Expected `wait` to be a finite number");
|
|
31
|
-
let leadingValue;
|
|
32
|
-
let timeout;
|
|
33
|
-
let resolveList = [];
|
|
34
|
-
let currentPromise;
|
|
35
|
-
let trailingArgs;
|
|
36
|
-
const applyFn = (_this, args) => {
|
|
37
|
-
currentPromise = _applyPromised(fn, _this, args);
|
|
38
|
-
currentPromise.finally(() => {
|
|
39
|
-
currentPromise = null;
|
|
40
|
-
if (options.trailing && trailingArgs && !timeout) {
|
|
41
|
-
const promise = applyFn(_this, trailingArgs);
|
|
42
|
-
trailingArgs = null;
|
|
43
|
-
return promise;
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
return currentPromise;
|
|
47
|
-
};
|
|
48
|
-
const debounced = function(...args) {
|
|
49
|
-
if (options.trailing) trailingArgs = args;
|
|
50
|
-
if (currentPromise) return currentPromise;
|
|
51
|
-
return new Promise((resolve) => {
|
|
52
|
-
const shouldCallNow = !timeout && options.leading;
|
|
53
|
-
clearTimeout(timeout);
|
|
54
|
-
timeout = setTimeout(() => {
|
|
55
|
-
timeout = null;
|
|
56
|
-
const promise = options.leading ? leadingValue : applyFn(this, args);
|
|
57
|
-
trailingArgs = null;
|
|
58
|
-
for (const _resolve of resolveList) _resolve(promise);
|
|
59
|
-
resolveList = [];
|
|
60
|
-
}, wait);
|
|
61
|
-
if (shouldCallNow) {
|
|
62
|
-
leadingValue = applyFn(this, args);
|
|
63
|
-
resolve(leadingValue);
|
|
64
|
-
} else resolveList.push(resolve);
|
|
65
|
-
});
|
|
66
|
-
};
|
|
67
|
-
const _clearTimeout = (timer) => {
|
|
68
|
-
if (timer) {
|
|
69
|
-
clearTimeout(timer);
|
|
70
|
-
timeout = null;
|
|
71
|
-
}
|
|
72
|
-
};
|
|
73
|
-
debounced.isPending = () => !!timeout;
|
|
74
|
-
debounced.cancel = () => {
|
|
75
|
-
_clearTimeout(timeout);
|
|
76
|
-
resolveList = [];
|
|
77
|
-
trailingArgs = null;
|
|
78
|
-
};
|
|
79
|
-
debounced.flush = () => {
|
|
80
|
-
_clearTimeout(timeout);
|
|
81
|
-
if (!trailingArgs || currentPromise) return;
|
|
82
|
-
const args = trailingArgs;
|
|
83
|
-
trailingArgs = null;
|
|
84
|
-
return applyFn(this, args);
|
|
85
|
-
};
|
|
86
|
-
return debounced;
|
|
87
|
-
}
|
|
88
|
-
async function _applyPromised(fn, _this, args) {
|
|
89
|
-
return await fn.apply(_this, args);
|
|
90
|
-
}
|
|
91
|
-
//#endregion
|
|
92
|
-
//#region src/node/storage.ts
|
|
93
|
-
function createStorage(options) {
|
|
94
|
-
const { mergeInitialValue = (initialValue, savedValue) => ({
|
|
95
|
-
...initialValue,
|
|
96
|
-
...savedValue
|
|
97
|
-
}), debounce: debounceTime = 100 } = options;
|
|
98
|
-
let initialValue = options.initialValue;
|
|
99
|
-
if (fs.existsSync(options.filepath)) try {
|
|
100
|
-
const savedValue = JSON.parse(fs.readFileSync(options.filepath, "utf-8"));
|
|
101
|
-
initialValue = mergeInitialValue ? mergeInitialValue(options.initialValue, savedValue) : savedValue;
|
|
102
|
-
} catch (error) {
|
|
103
|
-
diagnostics.DF0012({
|
|
104
|
-
filepath: options.filepath,
|
|
105
|
-
cause: error
|
|
106
|
-
}, { method: "warn" });
|
|
107
|
-
initialValue = options.initialValue;
|
|
108
|
-
}
|
|
109
|
-
const state = createSharedState({
|
|
110
|
-
initialValue,
|
|
111
|
-
enablePatches: false
|
|
112
|
-
});
|
|
113
|
-
state.on("updated", debounce((newState) => {
|
|
114
|
-
fs.mkdirSync(dirname(options.filepath), { recursive: true });
|
|
115
|
-
fs.writeFileSync(options.filepath, `${JSON.stringify(newState, null, 2)}\n`);
|
|
116
|
-
}, debounceTime));
|
|
117
|
-
return state;
|
|
118
|
-
}
|
|
119
|
-
//#endregion
|
|
120
|
-
//#region src/node/hub-internals/context.ts
|
|
121
|
-
const internalContextMap = /* @__PURE__ */ new WeakMap();
|
|
122
|
-
function getInternalContext(context) {
|
|
123
|
-
if (!internalContextMap.has(context)) {
|
|
124
|
-
const storage = createStorage({
|
|
125
|
-
filepath: join(context.host.getStorageDir("global"), "auth.json"),
|
|
126
|
-
initialValue: { trusted: {} }
|
|
127
|
-
});
|
|
128
|
-
const remoteTokens = /* @__PURE__ */ new Map();
|
|
129
|
-
function revokeRemoteToken(token) {
|
|
130
|
-
if (!remoteTokens.delete(token)) return;
|
|
131
|
-
revokeActiveConnectionsForToken(context, token);
|
|
132
|
-
}
|
|
133
|
-
const internalContext = {
|
|
134
|
-
storage: { auth: storage },
|
|
135
|
-
revokeAuthToken: (token) => revokeAuthToken(context, storage, token),
|
|
136
|
-
remoteTokens,
|
|
137
|
-
allocateRemoteToken(dockId, origin, originLock) {
|
|
138
|
-
const token = humanId();
|
|
139
|
-
remoteTokens.set(token, {
|
|
140
|
-
dockId,
|
|
141
|
-
origin,
|
|
142
|
-
originLock
|
|
143
|
-
});
|
|
144
|
-
return token;
|
|
145
|
-
},
|
|
146
|
-
revokeRemoteToken,
|
|
147
|
-
revokeRemoteTokensForDock(dockId) {
|
|
148
|
-
const tokensToRevoke = [];
|
|
149
|
-
for (const [token, record] of remoteTokens) if (record.dockId === dockId) tokensToRevoke.push(token);
|
|
150
|
-
for (const token of tokensToRevoke) revokeRemoteToken(token);
|
|
151
|
-
},
|
|
152
|
-
isRemoteTokenTrusted(token, requestOrigin) {
|
|
153
|
-
const record = remoteTokens.get(token);
|
|
154
|
-
if (!record) return false;
|
|
155
|
-
if (!record.originLock) return true;
|
|
156
|
-
return !!requestOrigin && record.origin === requestOrigin;
|
|
157
|
-
}
|
|
158
|
-
};
|
|
159
|
-
internalContextMap.set(context, internalContext);
|
|
160
|
-
}
|
|
161
|
-
return internalContextMap.get(context);
|
|
162
|
-
}
|
|
163
|
-
//#endregion
|
|
164
|
-
export { internalContextMap as n, createStorage as r, getInternalContext as t };
|