Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function guideItems(prefix: string) {
{ text: 'Introduction', link: `${prefix}/guide/` },
{ text: 'Devframe Definition', link: `${prefix}/guide/devframe-definition` },
{ text: 'Scoped Context', link: `${prefix}/guide/scoped-context` },
{ text: 'Cross-Plugin Services', link: `${prefix}/guide/services` },
{ text: 'RPC', link: `${prefix}/guide/rpc` },
{ text: 'Shared State', link: `${prefix}/guide/shared-state` },
{ text: 'Streaming', link: `${prefix}/guide/streaming` },
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.
25 changes: 25 additions & 0 deletions docs/guide/devframe-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,36 @@ 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 — see [Cross-Plugin Services](./services).

```ts
ctx.services.provide('my-plugin:sources', sources)

ctx.services.whenAvailable('my-plugin:sources', (sources) => {
sources.register(/* ... */)
})
```

### Storage scopes

`ctx.host.getStorageDir(scope)` places persisted state in one of three classes:

| Scope | Placement | For |
|-------|-----------|-----|
| `workspace` | committable, conventionally `<workspaceRoot>/.devframe/` | team-shared files: saved presets, shared configuration |
| `project` | per-checkout, conventionally `<cwd>/node_modules/.<app>/devframe/` | caches, personal settings |
| `global` | per-user, conventionally `~/.<app>/devframe/` | auth tokens, machine-wide preferences |

Scoped settings (`ctx.scope(id).settings`) persist their `project` scope through the `project` storage class and their `global` scope through `global`. Hosts implement the placement — see the host example in the [Hub guide](./hub).

`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
9 changes: 8 additions & 1 deletion docs/guide/hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,14 @@ const host: DevframeHost = {
// serve `${base}__connection.json` → { backend: 'websocket', websocket: port }
},
resolveOrigin() { /* … */ },
getStorageDir(scope) { /* … */ },
getStorageDir(scope) {
// workspace = committable, team-shared; project = per-checkout; global = per-user
if (scope === 'workspace')
return join(cwd, '.devframe')
if (scope === 'project')
return join(cwd, 'node_modules/.my-hub')
return join(homedir(), '.my-hub')
},
}
```

Expand Down
77 changes: 77 additions & 0 deletions docs/guide/services.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
outline: deep
---

# Cross-Plugin Services

`ctx.services` lets one integration expose a capability on the shared node context and others consume it — typed, namespaced, and free of package dependencies. Use it whenever two plugins that may or may not be installed together need to talk: a data-source registry other plugins contribute to, a shared cache, a host capability a kit provides.

Every devframe mounted into the same host shares one context, so services registered by one `setup(ctx)` are visible to every other.

## Providing a service

Augment the `DevframeServicesRegistry` interface with your service's id and type, then provide the implementation at setup time:

```ts
export interface SourcesService {
register: (entry: SourceEntry) => () => void
}

declare module 'devframe' {
interface DevframeServicesRegistry {
'my-plugin:sources': SourcesService
}
}

export function setup(ctx: DevframeNodeContext) {
ctx.services.provide('my-plugin:sources', createSourcesService())
}
```

Service ids follow the RPC naming rule: prefix with the providing plugin's id (`<plugin-id>:<service>`). Ids are unique per context — a second `provide()` under a taken id throws [`DF0037`](https://devfra.me/errors/DF0037). `provide()` returns a revoke function; revoke first to replace an implementation, and guard idempotent setup paths with `ctx.services.has(id)`.

## Consuming a service

The augmentation ships in the provider's published types, so a consumer gets full typing from a types-only import — no runtime dependency:

```ts
import type {} from '@my-org/my-plugin' // types only: loads the augmentation

export function setup(ctx: DevframeNodeContext) {
ctx.services.whenAvailable('my-plugin:sources', (sources) => {
sources.register({ id: 'other-plugin:state', data: () => state })
})
}
```

Prefer `whenAvailable` over `get`: it runs the callback immediately when the service is already provided and otherwise on `provide`, so the mount order of provider and consumer never matters. The callback re-fires if a service is revoked and provided again; the returned function unsubscribes.

`get(id)` returns the current implementation (or `undefined`) for one-shot lookups where absence is fine:

```ts
ctx.services.get('my-plugin:sources')?.register(entry)
```

Ids without a published augmentation still work — they type as `unknown`, and the consumer narrows with its own structural interface.

## The host surface

```ts
interface DevframeServicesHost {
provide: (id, service) => () => void // throws DF0037 on duplicates
get: (id) => service | undefined
has: (id) => boolean
whenAvailable: (id, callback) => () => void
keys: () => string[]
}
```

## Services, RPC, or shared state?

Each mechanism covers a different direction of travel:

- **Services** — node-to-node, in-process: one plugin hands another an object with methods. Values never cross a wire, so they can hold live references and functions.
- **[RPC](./rpc)** — browser-to-node: a client invokes a named function over the connection.
- **[Shared state](./shared-state)** — data synchronized between node and every connected client; values must serialize.

A capability meant for *other plugins* belongs in a service; a capability meant for *UIs or agents* belongs in RPC.
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
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
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