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.
Files changed (84) hide show
  1. package/dist/{_shared-CUFqO4kJ.mjs → _shared-bWRzeSa0.mjs} +2 -3
  2. package/dist/adapters/build.d.mts +1 -1
  3. package/dist/adapters/build.mjs +7 -7
  4. package/dist/adapters/cli.d.mts +1 -1
  5. package/dist/adapters/cli.mjs +2 -2
  6. package/dist/adapters/dev.d.mts +8 -2
  7. package/dist/adapters/dev.mjs +1 -1
  8. package/dist/adapters/embedded.d.mts +1 -1
  9. package/dist/adapters/mcp.d.mts +1 -1
  10. package/dist/adapters/mcp.mjs +4 -4
  11. package/dist/client/index.d.mts +148 -6
  12. package/dist/client/index.mjs +257 -44
  13. package/dist/constants.d.mts +17 -1
  14. package/dist/constants.mjs +17 -1
  15. package/dist/context-3x_bgBgZ.mjs +48 -0
  16. package/dist/{context-DTRcO_UH.d.mts → context-BEVByy_Z.d.mts} +1 -1
  17. package/dist/{dev-Cv43GfqM.mjs → dev-BaX8fLH2.mjs} +39 -13
  18. package/dist/{devframe-BuR6n9ZD.d.mts → devframe-D-gr9sBx.d.mts} +365 -15
  19. package/dist/{diagnostics-GitRGKbr.mjs → diagnostics-BXwBQmoN.mjs} +1 -1
  20. package/dist/{dump-9lKIJTLh.mjs → dump-DoXkj4ku.mjs} +1 -1
  21. package/dist/helpers/vite.d.mts +1 -1
  22. package/dist/helpers/vite.mjs +11 -11
  23. package/dist/{host-h3-Dgpgr1Ul.mjs → host-h3-C5SZlk03.mjs} +134 -6
  24. package/dist/{index-C7M1hnvL.d.mts → index-CxaPKDXX.d.mts} +2 -2
  25. package/dist/{index-DH2sBIwd.d.mts → index-DXHWuwJk.d.mts} +1 -1
  26. package/dist/index.d.mts +4 -4
  27. package/dist/index.mjs +1 -1
  28. package/dist/{launch-editor-BbNhtg7b.mjs → launch-editor-DPfltGA4.mjs} +88 -13
  29. package/dist/node/auth.d.mts +43 -23
  30. package/dist/node/auth.mjs +84 -37
  31. package/dist/node/hub-internals.d.mts +2 -2
  32. package/dist/node/hub-internals.mjs +2 -2
  33. package/dist/node/index.d.mts +22 -6
  34. package/dist/node/index.mjs +4 -4
  35. package/dist/recipes/open-helpers.d.mts +1 -1
  36. package/dist/recipes/open-helpers.mjs +3 -3
  37. package/dist/revoke-ENfxWkFK.mjs +79 -0
  38. package/dist/rpc/dump.d.mts +1 -1
  39. package/dist/rpc/dump.mjs +1 -1
  40. package/dist/rpc/index.d.mts +3 -3
  41. package/dist/rpc/index.mjs +4 -4
  42. package/dist/rpc/transports/ws-client.d.mts +1 -1
  43. package/dist/rpc/transports/ws-client.mjs +2 -2
  44. package/dist/rpc/transports/ws-server.d.mts +2 -2
  45. package/dist/rpc/transports/ws-server.mjs +154 -61
  46. package/dist/{serialization-BD_qXGjd.mjs → serialization-DpLXCy13.mjs} +1 -1
  47. package/dist/{server-BBaBJaUL.mjs → server-Dl6TU5LE.mjs} +19 -14
  48. package/dist/server-rX781kz-.d.mts +90 -0
  49. package/dist/{shared-state-u0y123fi.mjs → shared-state-D4PPieA0.mjs} +4 -4
  50. package/dist/{shared-state-BlBNYziY.mjs → storage-l2ezeend.mjs} +128 -6
  51. package/dist/types/index.d.mts +4 -4
  52. package/dist/{types-BkkQ0Txg.d.mts → types-DZEx4ffs.d.mts} +9 -1
  53. package/dist/utils/crypto-token.d.mts +24 -0
  54. package/dist/utils/crypto-token.mjs +45 -0
  55. package/dist/utils/events.d.mts +1 -1
  56. package/dist/utils/hash.mjs +1 -1
  57. package/dist/utils/launch-editor.mjs +1 -1
  58. package/dist/utils/open.mjs +1 -1
  59. package/dist/utils/scope.d.mts +11 -0
  60. package/dist/utils/scope.mjs +15 -0
  61. package/dist/utils/shared-state.d.mts +1 -1
  62. package/dist/utils/shared-state.mjs +1 -1
  63. package/dist/utils/streaming-channel.d.mts +1 -1
  64. package/dist/utils/structured-clone.mjs +1 -1
  65. package/dist/{ws-client-CVYX9niP.d.mts → ws-client-D0z0pOhn.d.mts} +1 -1
  66. package/dist/ws-server-Bc2wBHl-.d.mts +119 -0
  67. package/package.json +12 -7
  68. package/skills/devframe/SKILL.md +123 -23
  69. package/skills/devframe/templates/counter-devframe.ts +14 -4
  70. package/skills/devframe/templates/spa-devframe.ts +13 -3
  71. package/skills/devframe/templates/vite-client.ts +3 -2
  72. package/dist/context-DaKmhhHY.mjs +0 -164
  73. package/dist/revoke-CL0LSAN9.mjs +0 -803
  74. package/dist/server-wHlpcdZ9.d.mts +0 -55
  75. package/dist/utils/human-id.d.mts +0 -8
  76. package/dist/utils/human-id.mjs +0 -769
  77. package/dist/ws-server-C1LjmRnp.d.mts +0 -61
  78. /package/dist/{define-CW9gLnyG.mjs → define-BLWPsH6y.mjs} +0 -0
  79. /package/dist/{diagnostics-reporter-CBBZwoMv.mjs → diagnostics-reporter-CsIG85Q5.mjs} +0 -0
  80. /package/dist/{hash-bwOD8RgU.mjs → hash-DFc5WwhO.mjs} +0 -0
  81. /package/dist/{open-DiQn6zCH.mjs → open-Dusa2Zzd.mjs} +0 -0
  82. /package/dist/{structured-clone-PdCZwt7F.mjs → structured-clone-CeZOHvkd.mjs} +0 -0
  83. /package/dist/{structured-clone-jegjz0hM.mjs → structured-clone-XpHLZ8nr.mjs} +0 -0
  84. /package/dist/{transports-DTFoMUbE.mjs → transports-BmDVn4SF.mjs} +0 -0
@@ -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.rpc.register(defineRpcFunction({
57
- name: 'my-inspector:get-stats',
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` that feeds the type-safe client registry recipe (`RpcDefinitionsToFunctions<typeof serverFunctions>`).
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').RpcDefinitionsToFunctions<typeof serverFunctions> {}
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 => ctx.rpc.register(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: 'my-inspector:get-modules',
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
- `ctx.rpc.broadcast({ method, args, optional?, event?, filter? })` pushes to every connected client. `ctx.rpc.invokeLocal(name, ...args)` calls a server function without going through transport (useful for cross-function composition).
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 state = await ctx.rpc.sharedState.get('my-inspector:state', {
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 channel = ctx.rpc.streaming.create<string>('my-inspector:tokens', {
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>('my-inspector:tokens', streamId)
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
- ctx.rpc.register(defineRpcFunction({
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('my-inspector:upload-file', { name: 'foo' })
284
- const upload = rpc.streaming.upload<Uint8Array>('my-inspector:files', uploadId)
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 rpc = await connectDevframe()
415
- // await rpc.ensureTrusted() // WS mode only — blocks until server accepts
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('my-inspector:get-stats', { limit: 10 })
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.get(key)` for observable state, `rpc.client.register(defineRpcFunction(...))` to receive server broadcasts, and `rpc.callOptional(...)` when a missing handler should resolve to `undefined` instead of throwing.
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
- ctx.rpc.register(defineRpcFunction({
14
- name: 'counter:get',
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
- ctx.rpc.register(defineRpcFunction({
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.rpc.register(defineRpcFunction({
13
- name: 'my-inspector:analyze',
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
- handler: async ({ url }: { url: string }) => {
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
- const rpc = await connectDevframe()
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('my-inspector:getStats' as any)
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 };