Skip to content

Commit 09f382a

Browse files
authored
feat: introduce scoped context and settings store (#41)
1 parent 885ec30 commit 09f382a

69 files changed

Lines changed: 1563 additions & 189 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const alias = {
2525
'devframe/utils/nanoid': r('devframe/src/utils/nanoid.ts'),
2626
'devframe/utils/open': r('devframe/src/utils/open.ts'),
2727
'devframe/utils/promise': r('devframe/src/utils/promise.ts'),
28+
'devframe/utils/scope': r('devframe/src/utils/scope.ts'),
2829
'devframe/utils/serve-static': r('devframe/src/utils/serve-static.ts'),
2930
'devframe/utils/shared-state': r('devframe/src/utils/shared-state.ts'),
3031
'devframe/utils/streaming-channel': r('devframe/src/utils/streaming-channel.ts'),

docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ function guideItems(prefix: string): DefaultTheme.NavItemWithLink[] {
2121
{ text: 'Introduction', link: `${prefix}/guide/` },
2222
{ text: 'Built with Devframe', link: `${prefix}/guide/built-with` },
2323
{ text: 'Devframe Definition', link: `${prefix}/guide/devframe-definition` },
24+
{ text: 'Scoped Context', link: `${prefix}/guide/scoped-context` },
2425
{ text: 'RPC', link: `${prefix}/guide/rpc` },
2526
{ text: 'Shared State', link: `${prefix}/guide/shared-state` },
2627
{ text: 'Streaming', link: `${prefix}/guide/streaming` },

docs/errors/DF0034.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0034: Already-Namespaced Scoped Registration
6+
7+
## Message
8+
9+
> Scoped RPC registration for namespace "`{namespace}`" received an already-namespaced function name "`{name}`".
10+
11+
## Cause
12+
13+
A [scoped context](../guide/scoped-context) auto-namespaces the ids you pass it. `ctx.scope('my-plugin').rpc.register(...)` therefore expects a **bare** function name and stores it as `my-plugin:<name>`. Passing a name that already contains a `:` separator would produce a double-prefixed id, so registration throws instead.
14+
15+
## Example
16+
17+
```ts
18+
const my = ctx.scope('my-plugin')
19+
20+
// ✗ Bad — already namespaced
21+
my.rpc.register(defineRpcFunction({ name: 'my-plugin:get-cwd', type: 'query', handler }))
22+
23+
// ✓ Good — bare name, stored as `my-plugin:get-cwd`
24+
my.rpc.register(defineRpcFunction({ name: 'get-cwd', type: 'query', handler }))
25+
```
26+
27+
## Fix
28+
29+
- Pass a bare name (no `:` separator) to the scoped `register`.
30+
- To register a fully-qualified name on purpose, use the unscoped host: `ctx.base.rpc.register(...)` (server) or `client.scope(...).base.client.register(...)` (browser).
31+
32+
## Source
33+
34+
- [`packages/devframe/src/node/scope.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/scope.ts)`register()` / `update()` throw this when the supplied definition name is already namespaced.

docs/guide/client.md

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -93,30 +93,32 @@ const ok = await rpc.requestTrustWithToken('another-token')
9393

9494
## Calling functions
9595

96+
Derive a [scoped client](./scoped-context) so ids are namespaced for you:
97+
9698
```ts
97-
const rpc = await connectDevframe()
99+
const my = (await connectDevframe()).scope('my-devframe')
98100

99101
// Standard call — awaits a response or throws.
100-
const modules = await rpc.call('my-devframe:get-modules', { limit: 10 })
102+
const modules = await my.rpc.call('get-modules', { limit: 10 })
101103

102104
// Optional — returns undefined when no handler responds (useful while HMR is restarting).
103-
const maybe = await rpc.callOptional('my-devframe:get-modules', { limit: 10 })
105+
const maybe = await my.rpc.callOptional('get-modules', { limit: 10 })
104106

105107
// Event — fire-and-forget, no response expected.
106-
rpc.callEvent('my-devframe:notify', { message: 'hello' })
108+
my.rpc.callEvent('notify', { message: 'hello' })
107109
```
108110

109-
TypeScript types flow through from the server's `defineRpcFunction` definitions, so argument and return shapes are known at the call site.
111+
The unscoped `rpc.call('my-devframe:get-modules', ...)` works too. Either way, TypeScript types flow through from the server's `defineRpcFunction` definitions, so argument and return shapes are known at the call site.
110112

111113
## Registering client functions
112114

113-
The client can register functions that the server calls via `ctx.rpc.broadcast`:
115+
The client can register functions that the server calls via `rpc.broadcast`:
114116

115117
```ts
116118
import { defineRpcFunction } from 'devframe'
117119

118-
rpc.client.register(defineRpcFunction({
119-
name: 'my-devframe:on-file-changed',
120+
my.rpc.register(defineRpcFunction({
121+
name: 'on-file-changed', // -> my-devframe:on-file-changed
120122
type: 'event',
121123
setup: () => ({
122124
handler: async ({ file }: { file: string }) => {
@@ -131,7 +133,7 @@ That's how the server pushes live updates into the UI — file-watcher events, s
131133
## Shared state
132134

133135
```ts
134-
const state = await rpc.sharedState.get('my-devframe:state')
136+
const state = await my.rpc.sharedState('state') // -> my-devframe:state
135137

136138
console.log(state.value())
137139

@@ -146,6 +148,17 @@ state.on('updated', (next) => {
146148

147149
Client-side mutations round-trip through the server before reappearing locally. See [Shared State](./shared-state) for the full API.
148150

151+
## Settings
152+
153+
A scoped client also exposes a top-level persisted `settings` store, synced from the server. Read and write per-user (`global`) or per-workspace (`project`) values:
154+
155+
```ts
156+
await my.settings.project.set('theme', 'dark')
157+
const theme = await my.settings.project.get('theme')
158+
```
159+
160+
See [Scoped Context](./scoped-context#settings) for the full API.
161+
149162
## Caching
150163

151164
Set `cacheOptions: true` (or an options object) when constructing the client:

docs/guide/devframe-definition.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ export default defineDevframe({
2121
description: 'A one-line summary of what the tool does.',
2222
icon: 'ph:gauge-duotone',
2323
setup(ctx) {
24+
// A scoped context auto-namespaces ids with your devframe `id`.
25+
const my = ctx.scope('my-devframe')
26+
2427
// Register your RPC functions, shared state, etc. here.
25-
ctx.rpc.register(defineRpcFunction({
26-
name: 'my-devframe:hello',
28+
my.rpc.register(defineRpcFunction({
29+
name: 'hello', // stored as `my-devframe:hello`
2730
type: 'static',
2831
jsonSerializable: true,
2932
handler: () => ({ message: 'hello' }),
@@ -32,7 +35,7 @@ export default defineDevframe({
3235
})
3336
```
3437

35-
Host adapters (such as the [`vite` adapter](/adapters/vite) for Vite DevTools) derive their mount entry from `id`, `name`, `icon`, and `basePath` automatically.
38+
`ctx.scope(id)` is the preferred way to consume the context — see [Scoped Context](./scoped-context). Host adapters (such as the [`vite` adapter](/adapters/vite) for Vite DevTools) derive their mount entry from `id`, `name`, `icon`, and `basePath` automatically.
3639

3740
## Definition fields
3841

@@ -109,12 +112,17 @@ interface DevframeNodeContext {
109112
views: DevframeViewHost // static file hosting (`hostStatic`)
110113
diagnostics: DevframeDiagnosticsHost
111114
agent: DevframeAgentHost // experimental
115+
116+
scope: (id) => DevframeScopedNodeContext // namespaced view (preferred)
112117
}
113118
```
114119

120+
`ctx.scope(id)` returns a namespace-scoped view that auto-prefixes every RPC id, shared-state key, and streaming channel and adds a persisted top-level `settings` store. It's the recommended entry point from a single tool's setup code — see [Scoped Context](./scoped-context).
121+
115122
Host adapters can augment `ctx` with additional surfaces. For example, the [`vite` adapter](/adapters/vite) exposes Vite DevTools' dock, command, message, and terminal hosts via an optional `setup` hook on `createPluginFromDevframe` — consult the host's docs for those extras.
116123

117124
Each devframe-level host has a dedicated page:
125+
- [Scoped Context](./scoped-context)`ctx.scope(id)`, `settings`
118126
- [RPC](./rpc)`ctx.rpc`
119127
- [Shared State](./shared-state)`ctx.rpc.sharedState`
120128
- [Diagnostics](./diagnostics)`ctx.diagnostics`

docs/guide/rpc.md

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,20 @@ import { defineRpcFunction } from 'devframe'
2525
import * as v from 'valibot'
2626

2727
export const getModules = defineRpcFunction({
28-
name: 'my-devframe:get-modules',
28+
name: 'get-modules', // bare — the scope namespaces it to `my-devframe:get-modules`
2929
type: 'query',
3030
args: [v.object({ limit: v.number() })],
3131
returns: v.array(v.object({ id: v.string(), size: v.number() })),
3232
setup: ctx => ({
3333
handler: async ({ limit }) => {
34-
// `ctx` is the DevframeNodeContext.
34+
// `ctx` is the full DevframeNodeContext.
3535
return loadModules().slice(0, limit)
3636
},
3737
}),
3838
})
3939
```
4040

41-
Register it in `setup`:
41+
Register it in `setup` through a [scoped context](./scoped-context)`ctx.scope(id)` auto-namespaces ids, so you register and call by bare name:
4242

4343
```ts
4444
import { defineDevframe } from 'devframe'
@@ -48,16 +48,19 @@ export default defineDevframe({
4848
id: 'my-devframe',
4949
name: 'My Devframe',
5050
setup(ctx) {
51-
ctx.rpc.register(getModules)
51+
const my = ctx.scope('my-devframe')
52+
my.rpc.register(getModules)
5253
},
5354
})
5455
```
5556

57+
The unscoped `ctx.rpc.register(getModules)` works too — it's the underlying primitive the scoped surface wraps.
58+
5659
Place each function in its own file under `src/rpc/functions/`, and barrel them in `src/rpc/index.ts` as `const serverFunctions = [...] as const`. The same array feeds the [type-safe client registry](#type-safe-client-registry) and keeps registration order explicit. When per-file functions need to share setup-time state (channels, shared state handles, loaders), expose it through a `WeakMap<DevframeNodeContext, T>` in a sibling `src/context.ts`.
5760

5861
### Naming convention
5962

60-
Scope with your devframe id and use kebab-case for the action: `my-devframe:get-modules`, `my-devframe:read-file`, `my-devframe:trigger-rebuild`.
63+
Scope with your devframe id and use kebab-case for the action: `my-devframe:get-modules`, `my-devframe:read-file`, `my-devframe:trigger-rebuild`. A scoped context applies this prefix for you: `ctx.scope('my-devframe').rpc.register({ name: 'get-modules' })` stores `my-devframe:get-modules`. Define each function with a bare name and let the scope namespace it.
6164

6265
### Function types
6366

@@ -76,7 +79,7 @@ Handlers accept any serializable arguments. With `args` valibot schemas, argumen
7679

7780
```ts
7881
defineRpcFunction({
79-
name: 'my-devframe:get-file',
82+
name: 'get-file',
8083
type: 'query',
8184
args: [v.object({ path: v.string(), includeSource: v.optional(v.boolean()) })],
8285
returns: v.object({ path: v.string(), source: v.optional(v.string()) }),
@@ -101,7 +104,7 @@ Two ways to wire a handler:
101104
```ts
102105
// With setup:
103106
defineRpcFunction({
104-
name: 'my-devframe:count',
107+
name: 'count',
105108
type: 'query',
106109
setup: ctx => ({
107110
handler: async () => ctx.rpc.sharedState.keys().length,
@@ -110,24 +113,25 @@ defineRpcFunction({
110113

111114
// Shorthand:
112115
defineRpcFunction({
113-
name: 'my-devframe:echo',
116+
name: 'echo',
114117
type: 'query',
115118
handler: (msg: string) => msg,
116119
})
117120
```
118121

119122
## Broadcasting
120123

121-
`ctx.rpc.broadcast` sends a message from the server to every connected client:
124+
`rpc.broadcast` sends a message from the server to every connected client. Through a scoped context the client method name is namespaced for you:
122125

123126
```ts
124127
defineDevframe({
125128
id: 'my-devframe',
126129
name: 'My Devframe',
127130
setup(ctx) {
131+
const my = ctx.scope('my-devframe')
128132
watcher.on('change', (file) => {
129-
void ctx.rpc.broadcast({
130-
method: 'my-devframe:on-file-changed',
133+
void my.rpc.broadcast({
134+
method: 'on-file-changed', // -> my-devframe:on-file-changed
131135
args: [{ file }],
132136
})
133137
})
@@ -159,52 +163,58 @@ See the [Streaming guide](./streaming) for the full API.
159163

160164
## Local invocation
161165

162-
`ctx.rpc.invokeLocal` calls a registered server function directly, skipping the transport — useful for cross-function composition on the server side:
166+
A scoped `rpc.call` invokes a registered server function directly, skipping the transport — useful for cross-function composition on the server side. Bare names resolve within the namespace:
163167

164168
```ts
165-
const modules = await ctx.rpc.invokeLocal('my-devframe:get-modules', { limit: 10 })
169+
const my = ctx.scope('my-devframe')
170+
const modules = await my.rpc.call('get-modules', { limit: 10 })
166171
```
167172

173+
This wraps `ctx.rpc.invokeLocal('my-devframe:get-modules', { limit: 10 })`. Pass a fully-qualified name (containing `:`) to call another tool's function.
174+
168175
## Client-side calls
169176

170-
From the browser, [`connectDevframe`](./client) (or `getDevframeRpcClient`) returns a client for calling registered functions:
177+
From the browser, [`connectDevframe`](./client) (or `getDevframeRpcClient`) returns a client. Scope it the same way to call registered functions by bare name:
171178

172179
```ts
173180
import { connectDevframe } from 'devframe/client'
174181

175-
const rpc = await connectDevframe()
182+
const client = await connectDevframe()
183+
const my = client.scope('my-devframe')
176184

177-
const modules = await rpc.call('my-devframe:get-modules', { limit: 10 })
185+
const modules = await my.rpc.call('get-modules', { limit: 10 })
178186
```
179187

180-
Client-side registration (for server→client calls) goes through `rpc.client.register()` — the mirror API of `ctx.rpc.register()`.
188+
Client-side registration (for server→client calls) goes through `my.rpc.register()` — the mirror API of the server-side scoped `rpc.register()`.
181189

182190
## Type-safe client registry
183191

184192
Devframe exposes two augmentable interfaces — `DevframeRpcServerFunctions` (client→server calls) and `DevframeRpcClientFunctions` (server→client calls) — so each registered RPC name shows up on the typed client. Augment them once per devframe via `declare module 'devframe'`.
185193

186-
The recommended pattern collects every server-side definition into a const array and feeds it through `RpcDefinitionsToFunctions`:
194+
The recommended pattern collects every server-side definition into a const array and feeds it through `RpcDefinitionsToFunctionsWithNamespace` — it prefixes each bare definition name with your devframe id, matching the ids the scoped `register` stores at runtime:
187195

188196
```ts
189-
import type { RpcDefinitionsToFunctions } from 'devframe/rpc'
197+
import type { RpcDefinitionsToFunctionsWithNamespace } from 'devframe/rpc'
190198
import { getFile, getModules } from './rpc'
191199

192200
const serverFunctions = [getModules, getFile] as const
193201

194202
declare module 'devframe' {
195203
interface DevframeRpcServerFunctions
196-
extends RpcDefinitionsToFunctions<typeof serverFunctions> {}
204+
extends RpcDefinitionsToFunctionsWithNamespace<'my-devframe', typeof serverFunctions> {}
197205
}
198206
```
199207

208+
If you define functions with full namespaced names instead, use `RpcDefinitionsToFunctions<typeof serverFunctions>` (no namespace argument) and register them through the unscoped `ctx.rpc.register`.
209+
200210
Now `connectDevframe()` returns a client where every registered name is autocompletable and argument-typed:
201211

202212
```ts
203213
import { connectDevframe } from 'devframe/client'
204214

205-
const rpc = await connectDevframe()
206-
const modules = await rpc.call('my-devframe:get-modules', { limit: 10 })
207-
// ^? typed from the augmentation above
215+
const my = (await connectDevframe()).scope('my-devframe')
216+
const modules = await my.rpc.call('get-modules', { limit: 10 })
217+
// ^? typed from the augmentation above
208218
```
209219

210220
For one-off augmentations, declare a single key with `RpcFunctionDefinitionToFunction`:
@@ -227,7 +237,7 @@ For `static` functions, Devframe records the handler's output during `createBuil
227237

228238
```ts
229239
defineRpcFunction({
230-
name: 'my-devframe:build-meta',
240+
name: 'build-meta',
231241
type: 'static',
232242
args: [],
233243
returns: v.object({ version: v.string(), builtAt: v.number() }),
@@ -241,7 +251,7 @@ For `query` functions, provide an explicit `dump` to enumerate which argument se
241251

242252
```ts
243253
defineRpcFunction({
244-
name: 'my-devframe:get-session',
254+
name: 'get-session',
245255
type: 'query',
246256
setup: ctx => ({
247257
handler: async (id: string) => loadSession(id),
@@ -253,7 +263,7 @@ defineRpcFunction({
253263
})
254264
```
255265

256-
At runtime, static clients resolve `rpc.call('my-devframe:get-session', 'session-a')` from the baked dump; unmatched arguments resolve to `dump.fallback` (or throw without one).
266+
At runtime, static clients resolve `my.rpc.call('get-session', 'session-a')` from the baked dump; unmatched arguments resolve to `dump.fallback` (or throw without one).
257267

258268
## JSON-serializable declaration
259269

@@ -272,7 +282,7 @@ The wire stays plain JSON when every participating function is JSON-flagged —
272282

273283
```ts
274284
defineRpcFunction({
275-
name: 'my-devframe:graph',
285+
name: 'graph',
276286
jsonSerializable: true,
277287
// ⚠ throws DF0020 because Map cannot round-trip through JSON
278288
handler: () => ({ nodes: new Map([['a', 1]]) }),
@@ -291,7 +301,7 @@ Add an `agent` field to surface the function to coding agents over MCP. Agent ex
291301

292302
```ts
293303
defineRpcFunction({
294-
name: 'my-devframe:get-modules',
304+
name: 'get-modules',
295305
type: 'query',
296306
jsonSerializable: true,
297307
args: [v.object({ limit: v.number() })],

0 commit comments

Comments
 (0)