From b49fe610944519d36be753d71f4e2e111564dcd8 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 17 Jul 2026 03:54:26 +0000 Subject: [PATCH] feat: cross-plugin services and three-class storage scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two context-level primitives for plugin interop: - ctx.services — a typed, namespaced cross-plugin service registry (provide/get/whenAvailable, augmentable DevframeServicesRegistry, DF0037 on duplicate providers). Providers ship a types-only augmentation so consumers get full typing without a runtime dependency, and whenAvailable absorbs setup-order differences. - getStorageDir scopes become workspace/project/global: 'workspace' is the committable /.devframe/ dir (team-shared, new), 'project' is the per-checkout node_modules dir (the previous 'workspace' semantics), 'global' stays per-user. All hosts and call sites migrated: scoped project settings and the hub's dock/user settings persist under 'project'. Docs: a Cross-Plugin Services guide page, storage-scope reference on the definition page, the hub host example implements all three scopes, and a DF0037 error page. API snapshots updated. --- docs/.vitepress/config.ts | 1 + docs/errors/DF0037.md | 36 +++++++++ docs/guide/devframe-definition.md | 25 ++++++ docs/guide/hub.md | 9 ++- docs/guide/services.md | 77 +++++++++++++++++++ .../devframe/minimal-next-devframe-hub.ts | 8 +- .../src/minimal-vite-devframe-hub.ts | 8 +- examples/storybook-hub/src/storybook-hub.ts | 8 +- .../devframe/src/adapters/mcp/build-server.ts | 10 ++- .../devframe/src/node/__tests__/scope.test.ts | 7 +- .../src/node/__tests__/services.test.ts | 60 +++++++++++++++ packages/devframe/src/node/context.ts | 3 + packages/devframe/src/node/diagnostics.ts | 4 + packages/devframe/src/node/host-h3.ts | 13 ++-- packages/devframe/src/node/host-services.ts | 56 ++++++++++++++ packages/devframe/src/node/index.ts | 1 + packages/devframe/src/node/settings.ts | 4 +- packages/devframe/src/types/context.ts | 10 +++ packages/devframe/src/types/host.ts | 19 ++++- packages/devframe/src/types/index.ts | 1 + packages/devframe/src/types/services.ts | 67 ++++++++++++++++ packages/hub/src/node/host-docks.ts | 3 +- .../tsnapi/devframe/index.snapshot.d.ts | 5 ++ .../tsnapi/devframe/node.snapshot.d.ts | 9 +++ .../tsnapi/devframe/node.snapshot.js | 1 + .../tsnapi/devframe/types.snapshot.d.ts | 5 ++ 26 files changed, 425 insertions(+), 25 deletions(-) create mode 100644 docs/errors/DF0037.md create mode 100644 docs/guide/services.md create mode 100644 packages/devframe/src/node/__tests__/services.test.ts create mode 100644 packages/devframe/src/node/host-services.ts create mode 100644 packages/devframe/src/types/services.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 8f7a7d59..30d89e23 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -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` }, diff --git a/docs/errors/DF0037.md b/docs/errors/DF0037.md new file mode 100644 index 00000000..27930ce7 --- /dev/null +++ b/docs/errors/DF0037.md @@ -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 (`:`), 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. diff --git a/docs/guide/devframe-definition.md b/docs/guide/devframe-definition.md index 32804192..1cdd985d 100644 --- a/docs/guide/devframe-definition.md +++ b/docs/guide/devframe-definition.md @@ -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 `/.devframe/` | team-shared files: saved presets, shared configuration | +| `project` | per-checkout, conventionally `/node_modules/./devframe/` | caches, personal settings | +| `global` | per-user, conventionally `~/./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. diff --git a/docs/guide/hub.md b/docs/guide/hub.md index 7516b4a1..e2964d02 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -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') + }, } ``` diff --git a/docs/guide/services.md b/docs/guide/services.md new file mode 100644 index 00000000..66fabbe4 --- /dev/null +++ b/docs/guide/services.md @@ -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 (`:`). 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. diff --git a/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts b/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts index 21285b3c..55e1902d 100644 --- a/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts +++ b/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts @@ -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') }, } diff --git a/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts b/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts index 015b172f..9d53c85b 100644 --- a/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts +++ b/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts @@ -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') }, } diff --git a/examples/storybook-hub/src/storybook-hub.ts b/examples/storybook-hub/src/storybook-hub.ts index 100bc9e8..18ba29f9 100644 --- a/examples/storybook-hub/src/storybook-hub.ts +++ b/examples/storybook-hub/src/storybook-hub.ts @@ -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') }, } diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 1888571d..12de0a30 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -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({ diff --git a/packages/devframe/src/node/__tests__/scope.test.ts b/packages/devframe/src/node/__tests__/scope.test.ts index 9fef72ec..47c2c73f 100644 --- a/packages/devframe/src/node/__tests__/scope.test.ts +++ b/packages/devframe/src/node/__tests__/scope.test.ts @@ -161,7 +161,7 @@ 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') @@ -169,7 +169,10 @@ describe('ctx.scope()', () => { 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) diff --git a/packages/devframe/src/node/__tests__/services.test.ts b/packages/devframe/src/node/__tests__/services.test.ts new file mode 100644 index 00000000..8089f6de --- /dev/null +++ b/packages/devframe/src/node/__tests__/services.test.ts @@ -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) + }) +}) diff --git a/packages/devframe/src/node/context.ts b/packages/devframe/src/node/context.ts index e59e833f..4aa20263 100644 --- a/packages/devframe/src/node/context.ts +++ b/packages/devframe/src/node/context.ts @@ -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' @@ -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 @@ -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 diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index 5c047003..58d4be24 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -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.', + }, }, }) diff --git a/packages/devframe/src/node/host-h3.ts b/packages/devframe/src/node/host-h3.ts index 6b48b563..dcd39d40 100644 --- a/packages/devframe/src/node/host-h3.ts +++ b/packages/devframe/src/node/host-h3.ts @@ -19,8 +19,9 @@ export interface CreateH3DevframeHostOptions { mount?: (base: string, distDir: string) => void | Promise /** * Namespace for storage paths returned by `getStorageDir`. Workspace - * state lives under `${workspaceRoot}/node_modules/./devframe/` - * and global state under `${homedir()}/./devframe/`. Pick the + * state (committable) lives under `${workspaceRoot}/.devframe/`, project + * state under `${workspaceRoot}/node_modules/./devframe/`, and + * global state under `${homedir()}/./devframe/`. Pick the * devtool's id (or another stable, filesystem-safe identifier) so the * standalone host doesn't collide with other tools' storage. */ @@ -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) }, } } diff --git a/packages/devframe/src/node/host-services.ts b/packages/devframe/src/node/host-services.ts new file mode 100644 index 00000000..f20fe62a --- /dev/null +++ b/packages/devframe/src/node/host-services.ts @@ -0,0 +1,56 @@ +import type { DevframeServiceId, DevframeServiceOf, DevframeServicesHost } from 'devframe/types' +import { diagnostics } from './diagnostics' + +/** + * Cross-plugin service registry (see `types/services.ts` for the contract). + * Values are held per context instance; `whenAvailable` subscriptions make + * the mechanism robust against setup ordering between provider and consumer. + */ +export class DevframeServicesHostImpl implements DevframeServicesHost { + private services = new Map() + private listeners = new Map void>>() + + provide(id: ID, service: DevframeServiceOf): () => void { + const key = id as string + if (this.services.has(key)) + throw diagnostics.DF0037({ id: key }) + this.services.set(key, service) + for (const listener of this.listeners.get(key) ?? []) + listener(service) + return () => { + if (this.services.get(key) === service) + this.services.delete(key) + } + } + + get(id: ID): DevframeServiceOf | undefined { + return this.services.get(id as string) as DevframeServiceOf | undefined + } + + has(id: DevframeServiceId): boolean { + return this.services.has(id as string) + } + + whenAvailable( + id: ID, + callback: (service: DevframeServiceOf) => void, + ): () => void { + const key = id as string + if (this.services.has(key)) + callback(this.services.get(key) as DevframeServiceOf) + let set = this.listeners.get(key) + if (!set) { + set = new Set() + this.listeners.set(key, set) + } + const listener = callback as (service: unknown) => void + set.add(listener) + return () => { + set.delete(listener) + } + } + + keys(): string[] { + return Array.from(this.services.keys()) + } +} diff --git a/packages/devframe/src/node/index.ts b/packages/devframe/src/node/index.ts index 974bb823..c904d4f2 100644 --- a/packages/devframe/src/node/index.ts +++ b/packages/devframe/src/node/index.ts @@ -4,6 +4,7 @@ export * from './host-agent' export * from './host-diagnostics' export * from './host-functions' export * from './host-h3' +export * from './host-services' export * from './host-views' export * from './rpc-shared-state' export * from './rpc-streaming' diff --git a/packages/devframe/src/node/settings.ts b/packages/devframe/src/node/settings.ts index 07ee9b81..20379787 100644 --- a/packages/devframe/src/node/settings.ts +++ b/packages/devframe/src/node/settings.ts @@ -4,7 +4,9 @@ import { join } from 'pathe' import { createStorage } from './storage' // Map a settings scope to the host storage scope it persists under. -const STORAGE_SCOPE = { global: 'global', project: 'workspace' } as const +// Project settings are per-checkout private state, so they live in the +// host's ignored `project` dir (not the committable `workspace` one). +const STORAGE_SCOPE = { global: 'global', project: 'project' } as const function createNodeSettingsStore>( context: DevframeNodeContext, diff --git a/packages/devframe/src/types/context.ts b/packages/devframe/src/types/context.ts index 1d693e2b..50d8a272 100644 --- a/packages/devframe/src/types/context.ts +++ b/packages/devframe/src/types/context.ts @@ -2,6 +2,7 @@ import type { DevframeAgentHost } from './agent' import type { DevframeDiagnosticsHost } from './diagnostics' import type { DevframeHost } from './host' import type { DevframeScopedNodeContext, SettingsForNamespace } from './scope' +import type { DevframeServicesHost } from './services' import type { DevframeViewHost } from './views' export interface DevframeCapabilities { @@ -52,6 +53,15 @@ export interface DevframeNodeContext { * @experimental */ agent: DevframeAgentHost + /** + * Cross-plugin services — a typed, namespaced registry through which one + * integration exposes a capability (e.g. a data-source registry) and + * others consume it without a hard package dependency. Ids follow the RPC + * namespacing rule (`:`); types come from augmenting + * the `DevframeServicesRegistry` interface. `whenAvailable` subscriptions + * absorb setup-order differences between provider and consumer. + */ + services: DevframeServicesHost /** * Create a namespace-scoped view of this context. The returned * `ctx.scope('my-plugin')` auto-namespaces every RPC id, shared-state diff --git a/packages/devframe/src/types/host.ts b/packages/devframe/src/types/host.ts index 0c2798e9..c5406a1f 100644 --- a/packages/devframe/src/types/host.ts +++ b/packages/devframe/src/types/host.ts @@ -51,8 +51,14 @@ export interface DevframeHost { * collide between, say, the Vite host (`.vite/devframe`) and a * standalone CLI host (`./devframe`). * - * - `workspace` — per-project state (settings, caches). Typically - * under `${workspaceRoot}/node_modules/./devframe/`. + * - `workspace` — state shared with the whole team through version + * control (saved queries, shared presets). Conventionally + * `${workspaceRoot}/.devframe/`; hosts must place it somewhere + * committable. + * - `project` — per-checkout private state (caches, personal + * settings). Typically under + * `${cwd}/node_modules/./devframe/`, which version + * control ignores. * - `global` — per-user state (auth tokens, machine-wide * preferences). Typically under * `${homedir()}/./devframe/`. @@ -61,5 +67,12 @@ export interface DevframeHost { * pass to a downstream `createStorage(...)` call that creates it * lazily. */ - getStorageDir: (scope: 'workspace' | 'global') => string + getStorageDir: (scope: DevframeStorageScope) => string } + +/** + * Storage placement classes for {@link DevframeHost.getStorageDir}: + * `workspace` is committable and team-shared, `project` is per-checkout + * private, `global` is per-user. + */ +export type DevframeStorageScope = 'workspace' | 'project' | 'global' diff --git a/packages/devframe/src/types/index.ts b/packages/devframe/src/types/index.ts index 56abde21..85028ab8 100644 --- a/packages/devframe/src/types/index.ts +++ b/packages/devframe/src/types/index.ts @@ -7,5 +7,6 @@ export * from './host' export * from './rpc' export * from './rpc-augments' export * from './scope' +export * from './services' export * from './utils' export * from './views' diff --git a/packages/devframe/src/types/services.ts b/packages/devframe/src/types/services.ts new file mode 100644 index 00000000..615a3d7d --- /dev/null +++ b/packages/devframe/src/types/services.ts @@ -0,0 +1,67 @@ +/** + * Cross-plugin services — a typed, namespaced registry on the shared node + * context through which one integration exposes a capability and others + * consume it without a hard package dependency. + * + * A provider ships a *types-only* augmentation of {@link DevframeServicesRegistry} + * (so consumers get full typing from `import type`), then provides the + * implementation at setup time: + * + * ```ts + * // provider (e.g. the data-inspector plugin) + * declare module 'devframe' { + * interface DevframeServicesRegistry { + * 'devframes:plugin:data-inspector:sources': DataSourcesService + * } + * } + * ctx.services.provide('devframes:plugin:data-inspector:sources', host) + * + * // consumer (another plugin) — no runtime dependency on the provider + * ctx.services.whenAvailable('devframes:plugin:data-inspector:sources', (sources) => { + * sources.register({ id: 'my-plugin:state', title: 'My state', data: () => state }) + * }) + * ``` + * + * Service ids follow the same namespacing rule as RPC functions: prefix with + * the providing plugin's id. + */ + +/** + * Augmentation point mapping service ids to their implementation types. + * Providers extend this via `declare module 'devframe'`. + */ +export interface DevframeServicesRegistry {} + +/** + * A known (augmented) service id, or any namespaced string for services + * without a published type augmentation. + */ +export type DevframeServiceId = keyof DevframeServicesRegistry | (string & {}) + +/** Resolved service type for an id: augmented type, or `unknown`. */ +export type DevframeServiceOf = ID extends keyof DevframeServicesRegistry + ? DevframeServicesRegistry[ID] + : unknown + +export interface DevframeServicesHost { + /** + * Publish a service under a namespaced id. Throws `DF0037` when the id is + * already provided (revoke first to replace). Returns a revoke function. + */ + provide: (id: ID, service: DevframeServiceOf) => () => void + /** The service currently provided under `id`, or `undefined`. */ + get: (id: ID) => DevframeServiceOf | undefined + has: (id: DevframeServiceId) => boolean + /** + * Run `callback` with the service as soon as it is available — immediately + * when already provided, otherwise on `provide`. Survives provider/consumer + * setup-order differences. The callback also re-fires if the service is + * revoked and provided again. Returns an unsubscribe function. + */ + whenAvailable: ( + id: ID, + callback: (service: DevframeServiceOf) => void, + ) => () => void + /** Ids of every currently-provided service. */ + keys: () => string[] +} diff --git a/packages/hub/src/node/host-docks.ts b/packages/hub/src/node/host-docks.ts index f926c72c..b3116207 100644 --- a/packages/hub/src/node/host-docks.ts +++ b/packages/hub/src/node/host-docks.ts @@ -96,7 +96,8 @@ export class DevframeDocksHost implements DevframeDocksHostType { async init() { this.userSettings = await this.context.rpc.sharedState.get('devframe:user-settings', { sharedState: createStorage({ - filepath: join(this.context.host.getStorageDir('workspace'), 'settings.json'), + // Personal dock layout/preferences: per-checkout private state. + filepath: join(this.context.host.getStorageDir('project'), 'settings.json'), initialValue: DEFAULT_STATE_USER_SETTINGS(), }), }) diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index f2b30d78..eecb9002 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -40,11 +40,16 @@ export { DevframeRuntime } export { DevframeScopedNodeContext } export { DevframeScopedNodeRpc } export { DevframeScopedStreamingHost } +export { DevframeServiceId } +export { DevframeServiceOf } +export { DevframeServicesHost } +export { DevframeServicesRegistry } export { DevframeSettings } export { DevframeSettingsRegistry } export { DevframeSettingsStore } export { DevframeSetupInfo } export { DevframeSpaOptions } +export { DevframeStorageScope } export { DevframeViewHost } export { DevframeWsOptions } export { EntriesToObject } diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index 7eac313d..1b6ad6e2 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -56,6 +56,15 @@ export declare class DevframeDiagnosticsHost implements DevframeDiagnosticsHost$ constructor(_: DevframeNodeContext, _?: Array>); register(_: Record): void; } +export declare class DevframeServicesHostImpl implements DevframeServicesHost { + private services; + private listeners; + provide(_: ID, _: DevframeServiceOf): () => void; + get(_: ID): DevframeServiceOf | undefined; + has(_: DevframeServiceId): boolean; + whenAvailable(_: ID, _: (_: DevframeServiceOf) => void): () => void; + keys(): string[]; +} export declare class DevframeViewHost implements DevframeViewHost$1 { readonly context: DevframeNodeContext; buildStaticDirs: { diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js index a6f40864..11afe678 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js @@ -11,6 +11,7 @@ export { createScopedNodeContext } export { createStorage } export { DevframeAgentHost } export { DevframeDiagnosticsHost } +export { DevframeServicesHostImpl } export { DevframeViewHost } export { formatHostForUrl } export { isObject } diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index 399512d0..0f78abc0 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -36,11 +36,16 @@ export { DevframeRuntime } export { DevframeScopedNodeContext } export { DevframeScopedNodeRpc } export { DevframeScopedStreamingHost } +export { DevframeServiceId } +export { DevframeServiceOf } +export { DevframeServicesHost } +export { DevframeServicesRegistry } export { DevframeSettings } export { DevframeSettingsRegistry } export { DevframeSettingsStore } export { DevframeSetupInfo } export { DevframeSpaOptions } +export { DevframeStorageScope } export { DevframeViewHost } export { DevframeWsOptions } export { EntriesToObject }