Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,18 @@ export const alias = {
'@devframes/plugin-terminals/vite': p('terminals/src/vite.ts'),
'@devframes/plugin-terminals': p('terminals/src/index.ts'),
'@devframes/plugin-git': p('git/src/index.ts'),
'devframe/recipes/interactive-auth': r('devframe/src/recipes/interactive-auth.ts'),
'devframe/recipes/open-helpers': r('devframe/src/recipes/open-helpers.ts'),
'devframe/client': r('devframe/src/client/index.ts'),
'devframe': r('devframe/src'),
'@devframes/plugin-data-inspector/client': p('data-inspector/src/client/index.ts'),
'@devframes/plugin-data-inspector/node': p('data-inspector/src/node/index.ts'),
'@devframes/plugin-data-inspector/registry': p('data-inspector/src/registry/index.ts'),
'@devframes/plugin-data-inspector/engine': p('data-inspector/src/engine/index.ts'),
'@devframes/plugin-data-inspector/agent': p('data-inspector/src/agent/index.ts'),
'@devframes/plugin-data-inspector/cli': p('data-inspector/src/cli.ts'),
'@devframes/plugin-data-inspector/vite': p('data-inspector/src/vite.ts'),
'@devframes/plugin-data-inspector': p('data-inspector/src/index.ts'),
'@devframes/plugin-inspect/client': p('inspect/src/client/index.ts'),
'@devframes/plugin-inspect/node': p('inspect/src/node/index.ts'),
'@devframes/plugin-inspect/cli': p('inspect/src/cli.ts'),
Expand Down
36 changes: 36 additions & 0 deletions docs/errors/DF0037.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
outline: deep
---

# DF0037: Duplicate Service Provider

## Message

> A service is already provided under "`{id}`".

## Cause

`ctx.services` holds exactly one provider per service id. A second `provide()` under the same id throws instead of silently replacing a service another integration may already hold a reference to.

## Example

```ts
// ✗ Bad — provided twice
ctx.services.provide('my-plugin:sources', hostA)
ctx.services.provide('my-plugin:sources', hostB)

// ✓ Good — revoke the previous provider first
const revoke = ctx.services.provide('my-plugin:sources', hostA)
revoke()
ctx.services.provide('my-plugin:sources', hostB)
```

## Fix

- Revoke the existing provider first — `provide()` returns a revoke function.
- If the collision is between two unrelated integrations, namespace the id with your plugin id (`<plugin-id>:<service>`), the same rule RPC function names follow.
- Guard idempotent setup paths with `ctx.services.has(id)`.

## Source

- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — `provide()` throws this when the id is already taken.
26 changes: 26 additions & 0 deletions docs/guide/devframe-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,37 @@ interface DevframeNodeContext {
views: DevframeViewHost // static file hosting (`hostStatic`)
diagnostics: DevframeDiagnosticsHost
agent: DevframeAgentHost // experimental
services: DevframeServicesHost // typed cross-plugin service registry

scope: (id) => DevframeScopedNodeContext // namespaced view (preferred)
}
```

### Cross-plugin services

`ctx.services` is a typed, namespaced registry through which one integration exposes a capability and others consume it without a hard package dependency. The provider augments the `DevframeServicesRegistry` interface (so consumers get full typing from a types-only import) and provides the implementation at setup time; consumers use `whenAvailable`, which absorbs setup-order differences:

```ts
// provider
declare module 'devframe' {
interface DevframeServicesRegistry {
'my-plugin:sources': SourcesService
}
}
ctx.services.provide('my-plugin:sources', sources)

// consumer — types come from `import type`, no runtime dependency
ctx.services.whenAvailable('my-plugin:sources', (sources) => {
sources.register(/* ... */)
})
```

Service ids follow the RPC naming rule: prefix with the providing plugin's id. Duplicate ids throw [`DF0037`](https://devfra.me/errors/DF0037).

### Storage scopes

`ctx.host.getStorageDir(scope)` places persisted state in one of three classes: `'workspace'` (committable, shared with the team — conventionally `<workspaceRoot>/.devframe/`), `'project'` (per-checkout private, under `node_modules`), and `'global'` (per-user, under the home directory).

`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).

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,11 @@ export async function minimalNextDevframeHub(
return `http://${hostName}:3000`
},
getStorageDir(scope) {
return scope === 'workspace'
? join(cwd, 'node_modules/.minimal-next-devframe-hub')
: join(homedir(), '.minimal-next-devframe-hub')
if (scope === 'workspace')
return join(cwd, '.devframe')
if (scope === 'project')
return join(cwd, 'node_modules/.minimal-next-devframe-hub')
return join(homedir(), '.minimal-next-devframe-hub')
},
}

Expand Down
1 change: 1 addition & 0 deletions examples/minimal-vite-devframe-hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@devframes/hub": "workspace:*",
"@devframes/plugin-a11y": "workspace:*",
"@devframes/plugin-code-server": "workspace:*",
"@devframes/plugin-data-inspector": "workspace:*",
"@devframes/plugin-git": "workspace:*",
"@devframes/plugin-inspect": "workspace:*",
"@devframes/plugin-messages": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,11 @@ export function minimalViteDevframeHub(options: MinimalViteDevframeHubOptions =
return resolved ? new URL(resolved).origin : 'http://localhost:5173'
},
getStorageDir(scope) {
return scope === 'workspace'
? join(cwd, 'node_modules/.minimal-vite-devframe-hub')
: join(homedir(), '.minimal-vite-devframe-hub')
if (scope === 'workspace')
return join(cwd, '.devframe')
if (scope === 'project')
return join(cwd, 'node_modules/.minimal-vite-devframe-hub')
return join(homedir(), '.minimal-vite-devframe-hub')
},
}

Expand Down
31 changes: 31 additions & 0 deletions examples/minimal-vite-devframe-hub/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import a11yDevframe, { a11yAgentBundlePath } from '@devframes/plugin-a11y'
import codeServerDevframe from '@devframes/plugin-code-server'
import dataInspectorDevframe from '@devframes/plugin-data-inspector'
import { registerDataSource } from '@devframes/plugin-data-inspector/registry'
import gitDevframe from '@devframes/plugin-git'
import inspectDevframe from '@devframes/plugin-inspect'
import messagesDevframe from '@devframes/plugin-messages'
Expand All @@ -18,6 +20,34 @@ export default defineConfig({
server: { allowedHosts: true, strictPort: false },
plugins: [
UnoCSS(),
{
// The host registers its own live objects as data-inspector sources —
// the registry is process-global, so this works from any plugin hook.
name: 'minimal-vite-devframe-hub:data-sources',
configureServer(server) {
registerDataSource({
id: 'vite:server',
title: 'Vite Dev Server',
description: 'The live ViteDevServer instance serving this hub.',
icon: 'i-ph:lightning-duotone',
data: () => server,
queries: [
{ title: 'Plugin names', query: 'config.plugins.name' },
{
title: 'Module graph',
description: 'Client-environment modules with their importers',
query: 'environments.client.moduleGraph.idToModuleMap.mapEntries().value.({ url, type, importers: importers.fromSet().url })',
},
{
title: 'Resolved config (clean)',
query: 'config',
excludeFunctions: true,
excludeUnderscoreProps: true,
},
],
})
},
},
minimalViteDevframeHub({
devframes: [
demoDevframe,
Expand All @@ -28,6 +58,7 @@ export default defineConfig({
terminalsDevframe,
codeServerDevframe,
inspectDevframe,
dataInspectorDevframe,
a11yDevframe,
messagesDevframe,
],
Expand Down
8 changes: 5 additions & 3 deletions examples/storybook-hub/src/storybook-hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,11 @@ export function storybookHub(options: StorybookHubOptions = {}): Plugin {
return resolved ? new URL(resolved).origin : 'http://localhost:5173'
},
getStorageDir(scope) {
return scope === 'workspace'
? join(cwd, 'node_modules/.storybook-hub')
: join(homedir(), '.storybook-hub')
if (scope === 'workspace')
return join(cwd, '.devframe')
if (scope === 'project')
return join(cwd, 'node_modules/.storybook-hub')
return join(homedir(), '.storybook-hub')
},
}

Expand Down
10 changes: 7 additions & 3 deletions packages/devframe/src/adapters/mcp/build-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,13 @@ export async function createMcpServer(
const host: DevframeHost = {
mountStatic: () => { /* MCP has no static surface */ },
resolveOrigin: () => 'mcp://devframe',
getStorageDir: scope => scope === 'workspace'
? join(process.cwd(), `node_modules/.${definition.id}/devframe`)
: join(homedir(), `.${definition.id}/devframe`),
getStorageDir: (scope) => {
if (scope === 'workspace')
return join(process.cwd(), '.devframe')
if (scope === 'project')
return join(process.cwd(), `node_modules/.${definition.id}/devframe`)
return join(homedir(), `.${definition.id}/devframe`)
},
}

const ctx = await createHostContext({
Expand Down
7 changes: 5 additions & 2 deletions packages/devframe/src/node/__tests__/scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,15 +161,18 @@ describe('ctx.scope()', () => {
expect(ctx.rpc.sharedState.keys()).toContain('devframe:settings:project:my-plugin')
})

it('persists to the workspace and global storage dirs', async () => {
it('persists to the project and global storage dirs', async () => {
const { ctx, dir } = await createCtx()
const { settings } = ctx.scope('my-plugin')
await settings.project.set('theme', 'dark')
await settings.global.set('token', 'abc')

await sleep(250)

const projectFile = join(dir, 'workspace', 'settings', 'my-plugin.json')
// Project settings are per-checkout private state -> the host's
// ignored 'project' dir (the committable 'workspace' dir is for
// team-shared files).
const projectFile = join(dir, 'project', 'settings', 'my-plugin.json')
const globalFile = join(dir, 'global', 'settings', 'my-plugin.json')
expect(existsSync(projectFile)).toBe(true)
expect(existsSync(globalFile)).toBe(true)
Expand Down
60 changes: 60 additions & 0 deletions packages/devframe/src/node/__tests__/services.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from 'vitest'
import { DevframeServicesHostImpl } from '../host-services'

describe('devframeServicesHost', () => {
it('provides and gets a service', () => {
const services = new DevframeServicesHostImpl()
const impl = { register: () => {} }
services.provide('my-plugin:thing', impl)
expect(services.get('my-plugin:thing')).toBe(impl)
expect(services.has('my-plugin:thing')).toBe(true)
expect(services.keys()).toEqual(['my-plugin:thing'])
})

it('throws DF0037 on duplicate provide', () => {
const services = new DevframeServicesHostImpl()
services.provide('a:s', 1)
expect(() => services.provide('a:s', 2)).toThrowError(/already provided under "a:s"/)
})

it('revoke removes only the matching provider', () => {
const services = new DevframeServicesHostImpl()
const revoke = services.provide('a:s', 1)
revoke()
expect(services.has('a:s')).toBe(false)
// A stale revoke from a previous provider must not remove a newer one.
services.provide('a:s', 2)
revoke()
expect(services.get('a:s')).toBe(2)
})

it('whenAvailable fires immediately when already provided', () => {
const services = new DevframeServicesHostImpl()
services.provide('a:s', 41)
const spy = vi.fn()
services.whenAvailable('a:s', spy)
expect(spy).toHaveBeenCalledWith(41)
})

it('whenAvailable fires on later provide (order independence)', () => {
const services = new DevframeServicesHostImpl()
const spy = vi.fn()
services.whenAvailable('a:s', spy)
expect(spy).not.toHaveBeenCalled()
services.provide('a:s', 'late')
expect(spy).toHaveBeenCalledWith('late')
})

it('whenAvailable re-fires after revoke + re-provide, and unsubscribes', () => {
const services = new DevframeServicesHostImpl()
const spy = vi.fn()
const unsubscribe = services.whenAvailable('a:s', spy)
const revoke = services.provide('a:s', 1)
revoke()
services.provide('a:s', 2)
expect(spy).toHaveBeenCalledTimes(2)
unsubscribe()
services.get('a:s') // no-op
expect(spy).toHaveBeenCalledTimes(2)
})
})
3 changes: 3 additions & 0 deletions packages/devframe/src/node/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { diagnostics as devframeDiagnostics } from './diagnostics'
import { DevframeAgentHost } from './host-agent'
import { DevframeDiagnosticsHost } from './host-diagnostics'
import { RpcFunctionsHost } from './host-functions'
import { DevframeServicesHostImpl } from './host-services'
import { DevframeViewHost } from './host-views'
import { BUILTIN_AGENT_RPC } from './rpc'
import { createScopedNodeContext } from './scope'
Expand Down Expand Up @@ -42,6 +43,7 @@ export async function createHostContext(options: CreateHostContextOptions): Prom
views: undefined!,
diagnostics: undefined!,
agent: undefined!,
services: undefined!,
scope: undefined!,
} as unknown as DevframeNodeContext

Expand All @@ -51,6 +53,7 @@ export async function createHostContext(options: CreateHostContextOptions): Prom
context.rpc = rpcHost
context.views = viewsHost
context.diagnostics = diagnosticsHost
context.services = new DevframeServicesHostImpl()

// Agent host must be constructed after `rpcHost` so it can subscribe
// to `onChanged` — it auto-discovers RPC functions flagged with
Expand Down
4 changes: 4 additions & 0 deletions packages/devframe/src/node/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,9 @@ export const diagnostics = defineDiagnostics({
why: (p: { name: string }) => `RPC call to "${p.name}" was rejected: the caller is not authorized.`,
fix: 'Complete the auth handshake (or connect with a static/pre-shared token) before calling a trusted method. Untrusted callers may only call `anonymous:`-prefixed methods — see `isAnonymousRpcMethod`.',
},
DF0037: {
why: (p: { id: string }) => `A service is already provided under "${p.id}".`,
fix: 'Service ids are unique per context. Revoke the existing provider first (the `provide()` call returns a revoke function), or namespace the id with your plugin id to avoid collisions.',
},
},
})
13 changes: 8 additions & 5 deletions packages/devframe/src/node/host-h3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ export interface CreateH3DevframeHostOptions {
mount?: (base: string, distDir: string) => void | Promise<void>
/**
* Namespace for storage paths returned by `getStorageDir`. Workspace
* state lives under `${workspaceRoot}/node_modules/.<appName>/devframe/`
* and global state under `${homedir()}/.<appName>/devframe/`. Pick the
* state (committable) lives under `${workspaceRoot}/.devframe/`, project
* state under `${workspaceRoot}/node_modules/.<appName>/devframe/`, and
* global state under `${homedir()}/.<appName>/devframe/`. Pick the
* devtool's id (or another stable, filesystem-safe identifier) so the
* standalone host doesn't collide with other tools' storage.
*/
Expand All @@ -46,9 +47,11 @@ export function createH3DevframeHost(options: CreateH3DevframeHostOptions): Devf
},
getStorageDir(scope) {
const namespace = `.${options.appName}/devframe`
return scope === 'workspace'
? join(workspaceRoot, 'node_modules', namespace)
: join(homedir(), namespace)
if (scope === 'workspace')
return join(workspaceRoot, '.devframe')
if (scope === 'project')
return join(workspaceRoot, 'node_modules', namespace)
return join(homedir(), namespace)
},
}
}
Loading
Loading