From 74e144a95573e1f6431a8117813272642e29b567 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 18 Aug 2026 02:30:24 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20wire=20services=20=E2=80=94=20insta?= =?UTF-8?q?llable,=20client-advertised=20shared=20capabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the wire-service layer on top of the existing ctx.services registry: npm-packaged server-side capabilities (open-in-editor, shiki highlighting, …) that a host installs once and every plugin/client consumes without re-implementing or re-bundling them. - ServiceDefinition / ServiceDescriptor types, keyed by npm package name, with RPC functions namespaced under a service scope - ctx.services.install() + collect-then-setup ready() barrier: option sets from every declarer merge (mergeOptions or shallow, later wins) and each service constructs once; its node API is provided under the package name for in-process consumers - DevframeDefinition.services declarative list, resolved relative to the declaring plugin's own dependencies; required entries throw on missing package or unsatisfied version range, optional ones degrade silently - advertisement over the reactive devframe:services shared state; client.services.has()/get()/keys() with scoped, typed RPC handles - adapters (initiate/build/embedded/mcp) and the hub fire the barrier; a first-connection safety net covers hosts that forget - diagnostics DF0066–DF0071, tests, API snapshots --- packages/devframe/src/adapters/build.ts | 3 + packages/devframe/src/adapters/embedded.ts | 5 + packages/devframe/src/adapters/initiate.ts | 5 + .../devframe/src/adapters/mcp/build-server.ts | 4 +- packages/devframe/src/client/index.ts | 1 + .../devframe/src/client/rpc-services.test.ts | 74 ++++++ packages/devframe/src/client/rpc-services.ts | 89 ++++++++ packages/devframe/src/client/rpc.ts | 11 + packages/devframe/src/constants.ts | 7 + .../node/__tests__/services-install.test.ts | 69 ++++++ .../src/node/__tests__/services.test.ts | 194 +++++++++++++++- packages/devframe/src/node/context.ts | 2 +- .../devframe/src/node/definition-services.ts | 42 ++++ packages/devframe/src/node/diagnostics.ts | 30 +++ packages/devframe/src/node/host-services.ts | 214 +++++++++++++++++- packages/devframe/src/node/index.ts | 1 + packages/devframe/src/node/rpc-core.ts | 29 ++- packages/devframe/src/node/scope.ts | 1 + .../devframe/src/node/services-install.ts | 169 ++++++++++++++ packages/devframe/src/types/devframe.ts | 14 ++ packages/devframe/src/types/rpc-augments.ts | 11 +- packages/devframe/src/types/scope.ts | 3 + packages/devframe/src/types/services.ts | 193 ++++++++++++++++ packages/hub/src/node/initiate.ts | 5 + packages/hub/src/node/install-devframe.ts | 6 + .../tsnapi/devframe/client.snapshot.d.ts | 12 + .../tsnapi/devframe/client.snapshot.js | 1 + .../tsnapi/devframe/constants.snapshot.d.ts | 1 + .../tsnapi/devframe/constants.snapshot.js | 1 + .../tsnapi/devframe/index.snapshot.d.ts | 40 +++- .../tsnapi/devframe/internal.snapshot.d.ts | 42 ++++ .../tsnapi/devframe/node.snapshot.d.ts | 1 + .../tsnapi/devframe/node.snapshot.js | 1 + .../tsnapi/devframe/types.snapshot.d.ts | 9 + 34 files changed, 1274 insertions(+), 16 deletions(-) create mode 100644 packages/devframe/src/client/rpc-services.test.ts create mode 100644 packages/devframe/src/client/rpc-services.ts create mode 100644 packages/devframe/src/node/__tests__/services-install.test.ts create mode 100644 packages/devframe/src/node/definition-services.ts create mode 100644 packages/devframe/src/node/services-install.ts diff --git a/packages/devframe/src/adapters/build.ts b/packages/devframe/src/adapters/build.ts index fd1268db..b3af3ccf 100644 --- a/packages/devframe/src/adapters/build.ts +++ b/packages/devframe/src/adapters/build.ts @@ -14,6 +14,7 @@ import { DEVFRAME_RPC_DUMP_MANIFEST_FILENAME, } from '../constants' import { createHostContext } from '../node/context' +import { installDefinitionServices } from '../node/definition-services' import { diagnostics } from '../node/diagnostics' import { createH3DevframeHost } from '../node/host-h3' import { collectStaticRpcDump } from '../rpc/dump/static' @@ -88,7 +89,9 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt mode: 'build', host, }) + installDefinitionServices(ctx, d) await d.setup(ctx) + await ctx.services.ready() await fs.mkdir(resolve(outDir, DEVFRAME_RPC_DUMP_DIRNAME), { recursive: true }) diff --git a/packages/devframe/src/adapters/embedded.ts b/packages/devframe/src/adapters/embedded.ts index ae36dc8d..879440af 100644 --- a/packages/devframe/src/adapters/embedded.ts +++ b/packages/devframe/src/adapters/embedded.ts @@ -1,5 +1,6 @@ import type { DevframeNodeContext } from '../types/context' import type { DevframeDefinition } from '../types/devframe' +import { installDefinitionServices } from '../node/definition-services' export interface CreateEmbeddedOptions { /** Target context the devframe is registered into. Required. */ @@ -16,5 +17,9 @@ export interface CreateEmbeddedOptions { * effective default follows the hosted rule of `def.basePath ?? '/__/'`. */ export async function createEmbedded(d: DevframeDefinition, options: CreateEmbeddedOptions): Promise { + // Declarative services queue before setup; the owning host fires the + // `ctx.services.ready()` barrier (post-barrier registration installs + // immediately). + installDefinitionServices(options.ctx, d) await d.setup(options.ctx) } diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index bbdf1e3a..7980968b 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -16,6 +16,7 @@ import { resolve } from 'pathe' import { joinURL } from 'ufo' import { DEVFRAME_CONNECTION_META_FILENAME } from '../constants' import { createHostContext } from '../node/context' +import { installDefinitionServices } from '../node/definition-services' import { diagnostics } from '../node/diagnostics' import { createH3DevframeHost } from '../node/host-h3' import { createInstanceShell, resolveInstanceRegister } from '../node/instance-shell' @@ -289,7 +290,11 @@ export function initDevframe( host: hostImpl, }) const setupInfo: DevframeSetupInfo = { flags: options.flags ?? {} } + installDefinitionServices(context, def) await def.setup(context, setupInfo) + // Collect-then-setup barrier: every declared/queued wire service is + // constructed once, with its option sets merged across declarers. + await context.services.ready() // Route-based MCP server (opt-in). Mounted before the SPA static // catch-all so the exact `__mcp` route wins, and advertised in diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 81548a2c..7a31cdc4 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -5,7 +5,7 @@ import type { AgentTool, DevframeDefinition, DevframeHost, DevframeNodeContext } import { homedir } from 'node:os' import process from 'node:process' import { Server } from '@modelcontextprotocol/server' -import { createHostContext } from 'devframe/node' +import { createHostContext, installDefinitionServices } from 'devframe/node' import { toAgentToolName } from 'devframe/utils/agent-tool-name' import { join } from 'pathe' import { diagnostics } from '../../node/diagnostics' @@ -116,7 +116,9 @@ export async function createMcpServer( mode: 'dev', host, }) + installDefinitionServices(ctx, definition) await definition.setup(ctx) + await ctx.services.ready() const { server, dispose } = buildMcpServerFromContext(ctx, { serverName: options.serverName ?? `${definition.id} (devframe)`, diff --git a/packages/devframe/src/client/index.ts b/packages/devframe/src/client/index.ts index 00af1bbf..ca9b656d 100644 --- a/packages/devframe/src/client/index.ts +++ b/packages/devframe/src/client/index.ts @@ -3,6 +3,7 @@ import { getDevframeRpcClient } from './rpc' export * from './connection' export * from './otp' export * from './rpc' +export * from './rpc-services' export { resolveSseUrl } from './rpc-sse' export * from './rpc-streaming' export { resolveWsUrl, type WsUrlLocation } from './rpc-ws' diff --git a/packages/devframe/src/client/rpc-services.test.ts b/packages/devframe/src/client/rpc-services.test.ts new file mode 100644 index 00000000..f2cde4c0 --- /dev/null +++ b/packages/devframe/src/client/rpc-services.test.ts @@ -0,0 +1,74 @@ +import type { DevframeServicesState } from 'devframe/types' +import { createEventEmitter } from 'devframe/utils/events' +import { describe, expect, it } from 'vitest' +import { createDevframeServicesClient } from './rpc-services' +import { createRpcSharedStateClientHost } from './rpc-shared-state' + +const sleep = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms)) + +function makeFakeRpc(serverState: DevframeServicesState) { + const events = createEventEmitter() + const rpc = { + connectionMeta: { backend: 'websocket' }, + isTrusted: true, + events, + client: { register: () => {} }, + callEvent: () => {}, + call: async (name: string, key: string) => { + if (name === 'devframe:rpc:server-state:get' && key === 'devframe:services') + return serverState + return undefined + }, + scope: (namespace: string) => ({ rpc: { namespace } }), + } as any + rpc.sharedState = createRpcSharedStateClientHost(rpc) + return rpc +} + +describe('client services', () => { + it('mirrors the advertisement and exposes sync accessors', async () => { + const rpc = makeFakeRpc({ + '@devframes/service-open': { + package: '@devframes/service-open', + version: '1.0.0', + scope: 'devframes:service:open', + }, + }) + const services = createDevframeServicesClient(rpc) + + // Before the snapshot lands the accessors read as empty, never throw. + expect(services.has('@devframes/service-open')).toBe(false) + expect(services.get('@devframes/service-open')).toBeUndefined() + + await services.state() + await sleep() + + expect(services.has('@devframes/service-open')).toBe(true) + expect(services.keys()).toEqual(['@devframes/service-open']) + const handle = services.get('@devframes/service-open')! + expect(handle.version).toBe('1.0.0') + expect(handle.scope).toBe('devframes:service:open') + // The RPC surface is scoped to the service's namespace. + expect((handle.rpc as any).namespace).toBe('devframes:service:open') + // Handles are stable across reads while the advertisement is unchanged. + expect(services.get('@devframes/service-open')).toBe(handle) + }) + + it('tracks services appearing after the first snapshot', async () => { + const rpc = makeFakeRpc({}) + const services = createDevframeServicesClient(rpc) + const state = await services.state() + await sleep() + expect(services.has('@devframes/service-shiki')).toBe(false) + + state.mutate((value: any) => { + value['@devframes/service-shiki'] = { + package: '@devframes/service-shiki', + version: '2.1.0', + scope: 'devframes:service:shiki', + } + }) + expect(services.has('@devframes/service-shiki')).toBe(true) + expect(services.get('@devframes/service-shiki')?.version).toBe('2.1.0') + }) +}) diff --git a/packages/devframe/src/client/rpc-services.ts b/packages/devframe/src/client/rpc-services.ts new file mode 100644 index 00000000..0e8f1f03 --- /dev/null +++ b/packages/devframe/src/client/rpc-services.ts @@ -0,0 +1,89 @@ +import type { DevframeServiceMeta, DevframeServiceScopeOf, DevframeServicesState } from 'devframe/types' +import type { SharedState } from 'devframe/utils/shared-state' +import type { DevframeRpcClient } from './rpc' +import type { DevframeScopedClientRpc } from './scope' +import { DEVFRAME_SERVICES_STATE_KEY } from 'devframe/constants' + +/** + * A typed handle on one advertised wire service — the service's + * advertisement meta plus an RPC surface scoped to its namespace, so + * `handle.rpc.call('fn-name', …)` targets `:fn-name`. Service + * packages type the calls by augmenting `DevframeRpcServerFunctions` with + * their fully-qualified ids and `DevframeServicesScopeRegistry` with their + * package → scope mapping. + */ +export interface DevframeServiceClientHandle extends DevframeServiceMeta { + readonly scope: NS + /** RPC surface scoped to the service's namespace. */ + readonly rpc: DevframeScopedClientRpc +} + +/** + * Client-side view of the server's wire-service registry, mirrored through + * the reactive `devframe:services` shared state. The accessors are + * synchronous snapshots — before the first sync lands (or on a server with + * no services) they read as empty. For reactive UI (e.g. hiding an + * "open in editor" button until the service appears), subscribe to the + * shared state itself via {@link DevframeServicesClient.state}. + */ +export interface DevframeServicesClient { + /** Whether the service package is advertised as installed. */ + has: (pkg: string) => boolean + /** + * A typed handle on an advertised service — its meta plus a scoped RPC + * surface — or `undefined` while it isn't available (never throws). + */ + get: (pkg: PKG) => DevframeServiceClientHandle> | undefined + /** Package names of every advertised service. */ + keys: () => string[] + /** + * The mirrored `devframe:services` shared state — subscribe to its + * `updated` event for reactivity. + */ + state: () => Promise> +} + +export function createDevframeServicesClient(rpc: DevframeRpcClient): DevframeServicesClient { + let current: DevframeServicesState = {} + const handles = new Map() + + let statePromise: Promise> | undefined + const state = () => { + statePromise ??= rpc.sharedState + .get(DEVFRAME_SERVICES_STATE_KEY, { initialValue: {} }) + .then((shared) => { + current = shared.value() as DevframeServicesState + shared.on('updated', (value) => { + current = value as DevframeServicesState + }) + return shared + }) + return statePromise + } + // Warm the mirror eagerly so the synchronous accessors work as soon as the + // first snapshot lands, without every consumer having to await `state()`. + void state() + + return { + state, + has: pkg => pkg in current, + keys: () => Object.keys(current), + get: (pkg: PKG) => { + const entry = current[pkg] + if (!entry) + return undefined + let cached = handles.get(pkg) + if (!cached || cached.entry !== entry) { + cached = { + entry, + handle: { + ...entry, + rpc: rpc.scope(entry.scope).rpc, + }, + } + handles.set(pkg, cached) + } + return cached.handle as DevframeServiceClientHandle> + }, + } +} diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index a80bedf4..8e2806b0 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -4,6 +4,7 @@ import type { SseRpcChannelOptions } from 'devframe/rpc/transports/sse-client' import type { WsRpcChannelOptions } from 'devframe/rpc/transports/ws-client' import type { ConnectionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions, EventEmitter, RpcSharedStateHost, SettingsForNamespace } from 'devframe/types' import type { DevframeConnection, DevframeConnectionStatus, SetupDevframeConnectionOptions } from './connection' +import type { DevframeServicesClient } from './rpc-services' import type { RpcStreamingClientHost } from './rpc-streaming' import type { DevframeScopedClientContext } from './scope' import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants' @@ -13,6 +14,7 @@ import { withBase } from 'ufo' import { setupDevframeConnection } from './connection' import { storeAuthToken } from './connection-storage' import { authenticateWithUrlOtp } from './otp' +import { createDevframeServicesClient } from './rpc-services' import { createRpcSharedStateClientHost } from './rpc-shared-state' import { createSseRpcClientMode } from './rpc-sse' import { createStaticRpcClientMode } from './rpc-static' @@ -196,6 +198,13 @@ export interface DevframeRpcClient { * The shared state host */ sharedState: RpcSharedStateHost + /** + * The server's advertised wire services (mirrored `devframe:services` + * shared state) — feature-detect a capability with + * `rpc.services.has('@devframes/service-x')` and get a scoped, typed RPC + * handle with `rpc.services.get(...)`. See {@link DevframeServicesClient}. + */ + services: DevframeServicesClient /** * The streaming channel host. Subscribe to a server-side stream by * channel + id; the returned reader is both `AsyncIterable` and @@ -467,6 +476,7 @@ export async function getDevframeRpcClient( callOptional: gateOnBootstrapAuth(mode.callOptional), client: clientRpc, sharedState: undefined!, + services: undefined!, streaming: undefined!, cacheManager, scope: undefined!, @@ -475,6 +485,7 @@ export async function getDevframeRpcClient( rpc.sharedState = createRpcSharedStateClientHost(rpc) rpc.streaming = createRpcStreamingClientHost(rpc) + rpc.services = createDevframeServicesClient(rpc) // Namespace-scoped views are memoized per namespace so repeated // `client.scope('my-plugin')` calls return a stable object. diff --git a/packages/devframe/src/constants.ts b/packages/devframe/src/constants.ts index b35bd57a..bd07d153 100644 --- a/packages/devframe/src/constants.ts +++ b/packages/devframe/src/constants.ts @@ -46,6 +46,13 @@ export const DEVFRAME_RPC_DUMP_MANIFEST_FILENAME = '__rpc-dump/index.json' export const DEVFRAME_DOCK_IMPORTS_FILENAME = '__client-imports.js' export const DEVFRAME_RPC_DUMP_DIRNAME = '__rpc-dump' +/** + * Shared-state key carrying the wire-service advertisements (package name → + * `DevframeServiceMeta`). Written by the node services host; mirrored to + * clients for feature-detection via `client.services`. + */ +export const DEVFRAME_SERVICES_STATE_KEY = 'devframe:services' + /** * URL fragment / query parameter name carrying the remote dock * connection descriptor (defined as `RemoteConnectionInfo` in diff --git a/packages/devframe/src/node/__tests__/services-install.test.ts b/packages/devframe/src/node/__tests__/services-install.test.ts new file mode 100644 index 00000000..d1e70742 --- /dev/null +++ b/packages/devframe/src/node/__tests__/services-install.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { satisfiesVersionRange, shallowMergeOptionSets } from '../services-install' + +describe('satisfiesVersionRange', () => { + it('matches exact versions', () => { + expect(satisfiesVersionRange('1.2.3', '1.2.3')).toBe(true) + expect(satisfiesVersionRange('1.2.3', '=1.2.3')).toBe(true) + expect(satisfiesVersionRange('1.2.4', '1.2.3')).toBe(false) + }) + + it('treats partial versions as x-ranges', () => { + expect(satisfiesVersionRange('1.2.3', '1')).toBe(true) + expect(satisfiesVersionRange('1.9.0', '1.x')).toBe(true) + expect(satisfiesVersionRange('1.2.3', '1.2')).toBe(true) + expect(satisfiesVersionRange('1.3.0', '1.2')).toBe(false) + expect(satisfiesVersionRange('2.0.0', '1')).toBe(false) + }) + + it('supports caret ranges with npm zero-major semantics', () => { + expect(satisfiesVersionRange('1.9.9', '^1.2.3')).toBe(true) + expect(satisfiesVersionRange('1.2.2', '^1.2.3')).toBe(false) + expect(satisfiesVersionRange('2.0.0', '^1.2.3')).toBe(false) + expect(satisfiesVersionRange('0.2.5', '^0.2.3')).toBe(true) + expect(satisfiesVersionRange('0.3.0', '^0.2.3')).toBe(false) + expect(satisfiesVersionRange('0.0.3', '^0.0.3')).toBe(true) + expect(satisfiesVersionRange('0.0.4', '^0.0.3')).toBe(false) + expect(satisfiesVersionRange('2.1.0', '^2')).toBe(true) + }) + + it('supports tilde ranges', () => { + expect(satisfiesVersionRange('1.2.9', '~1.2.3')).toBe(true) + expect(satisfiesVersionRange('1.3.0', '~1.2.3')).toBe(false) + expect(satisfiesVersionRange('1.9.0', '~1')).toBe(true) + }) + + it('supports ordered comparators and AND clauses', () => { + expect(satisfiesVersionRange('2.0.0', '>=1.5')).toBe(true) + expect(satisfiesVersionRange('1.4.9', '>=1.5')).toBe(false) + expect(satisfiesVersionRange('2.5.0', '>=2 <3')).toBe(true) + expect(satisfiesVersionRange('3.0.0', '>=2 <3')).toBe(false) + }) + + it('supports OR alternatives and wildcards', () => { + expect(satisfiesVersionRange('3.1.0', '^2 || ^3')).toBe(true) + expect(satisfiesVersionRange('4.0.0', '^2 || ^3')).toBe(false) + expect(satisfiesVersionRange('9.9.9', '*')).toBe(true) + expect(satisfiesVersionRange('9.9.9', 'x')).toBe(true) + }) + + it('sorts prereleases before their release', () => { + expect(satisfiesVersionRange('1.0.0-beta.1', '>=1.0.0')).toBe(false) + expect(satisfiesVersionRange('1.0.0-beta.1', '<1.0.0')).toBe(true) + }) + + it('reads unparseable versions as unsatisfied', () => { + expect(satisfiesVersionRange('not-a-version', '^1')).toBe(false) + }) +}) + +describe('shallowMergeOptionSets', () => { + it('merges plain objects in order, later wins', () => { + expect(shallowMergeOptionSets([{ a: 1, b: 1 }, { b: 2, c: 3 }])).toEqual({ a: 1, b: 2, c: 3 }) + }) + + it('collapses to last-wins when a set is not a plain object', () => { + expect(shallowMergeOptionSets([{ a: 1 }, ['x']])).toEqual(['x']) + expect(shallowMergeOptionSets(['x', { a: 1 }])).toEqual({ a: 1 }) + }) +}) diff --git a/packages/devframe/src/node/__tests__/services.test.ts b/packages/devframe/src/node/__tests__/services.test.ts index 8089f6de..56b874b0 100644 --- a/packages/devframe/src/node/__tests__/services.test.ts +++ b/packages/devframe/src/node/__tests__/services.test.ts @@ -1,4 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' +import type { DevframeHost, DevframeServiceDefinition, DevframeServicesState } from 'devframe/types' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DEVFRAME_SERVICES_STATE_KEY } from 'devframe/constants' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createHostContext } from '../context' import { DevframeServicesHostImpl } from '../host-services' describe('devframeServicesHost', () => { @@ -58,3 +64,189 @@ describe('devframeServicesHost', () => { expect(spy).toHaveBeenCalledTimes(2) }) }) + +const tempDirs: string[] = [] + +afterEach(() => { + vi.restoreAllMocks() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function createTestHost(dir: string): DevframeHost { + return { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost', + getStorageDir: scope => join(dir, scope), + } +} + +async function createCtx() { + const dir = mkdtempSync(join(tmpdir(), 'devframe-services-')) + tempDirs.push(dir) + const ctx = await createHostContext({ cwd: dir, mode: 'dev', host: createTestHost(dir) }) + return { ctx, dir } +} + +function defineTestService(overrides: Partial = {}): DevframeServiceDefinition { + return { + package: '@test/svc', + version: '1.2.3', + scope: 'test:svc', + setup: (_ctx, info) => ({ options: info.options }), + ...overrides, + } +} + +/** Write a fake installed service package under `/node_modules`. */ +function writeFakeServicePackage(dir: string, name: string, version: string): void { + const pkgDir = join(dir, 'node_modules', ...name.split('/')) + mkdirSync(pkgDir, { recursive: true }) + writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ + name, + version, + type: 'module', + main: 'index.mjs', + })) + writeFileSync(join(pkgDir, 'index.mjs'), [ + `export default function createService() {`, + ` return {`, + ` package: ${JSON.stringify(name)},`, + ` version: ${JSON.stringify(version)},`, + ` scope: 'test:imported',`, + ` setup: (_ctx, info) => ({ imported: true, options: info.options }),`, + ` }`, + `}`, + ].join('\n')) +} + +describe('wire services (install / ready barrier)', () => { + it('queues installs and constructs once at the barrier with merged options', async () => { + const { ctx } = await createCtx() + const setup = vi.fn((_ctx: unknown, info: { options?: any }) => ({ options: info.options })) + const def = defineTestService({ setup, options: { a: 1, b: 1 } }) + + const first = ctx.services.install(def) + // A second install of the same package contributes its options to the merge. + const second = ctx.services.install({ package: '@test/svc', options: { b: 2, c: 3 } }) + + expect(setup).not.toHaveBeenCalled() + expect(ctx.services.isReady).toBe(false) + await ctx.services.ready() + + expect(setup).toHaveBeenCalledTimes(1) + // Shallow merge in declaration order — later sets win. + await expect(first).resolves.toEqual({ options: { a: 1, b: 2, c: 3 } }) + await expect(second).resolves.toEqual({ options: { a: 1, b: 2, c: 3 } }) + // The node API is provided under the package name. + expect(ctx.services.get('@test/svc')).toEqual({ options: { a: 1, b: 2, c: 3 } }) + }) + + it('uses the definition mergeOptions when declared', async () => { + const { ctx } = await createCtx() + const def = defineTestService({ + options: { langs: ['ts'] }, + mergeOptions: sets => ({ langs: sets.flatMap((s: any) => s.langs) }), + }) + void ctx.services.install(def) + void ctx.services.install({ package: '@test/svc', options: { langs: ['vue'] } }) + await ctx.services.ready() + expect(ctx.services.get('@test/svc')).toEqual({ options: { langs: ['ts', 'vue'] } }) + }) + + it('advertises installed services on the devframe:services shared state', async () => { + const { ctx } = await createCtx() + void ctx.services.install(defineTestService({ meta: { features: ['x'] } })) + await ctx.services.ready() + const state = await ctx.rpc.sharedState.get(DEVFRAME_SERVICES_STATE_KEY) + expect(state.value()).toEqual({ + '@test/svc': { package: '@test/svc', version: '1.2.3', scope: 'test:svc', meta: { features: ['x'] } }, + }) + }) + + it('creates an empty advertisement state at the barrier when nothing installs', async () => { + const { ctx } = await createCtx() + await ctx.services.ready() + expect(ctx.rpc.sharedState.keys()).toContain(DEVFRAME_SERVICES_STATE_KEY) + }) + + it('setup receives a context scoped to the service namespace', async () => { + const { ctx } = await createCtx() + void ctx.services.install(defineTestService({ + setup: (scoped) => { + scoped.rpc.register({ name: 'hello', handler: () => 'hi' }) + return {} + }, + })) + await ctx.services.ready() + await expect((ctx.rpc.invokeLocal as (method: string) => Promise)('test:svc:hello')).resolves.toBe('hi') + }) + + it('post-barrier installs construct immediately; duplicates warn and return the first API', async () => { + const { ctx } = await createCtx() + await ctx.services.ready() + const api = await ctx.services.install(defineTestService({ options: { a: 1 } })) + expect(api).toEqual({ options: { a: 1 } }) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const again = await ctx.services.install(defineTestService({ options: { a: 2 } })) + expect(again).toBe(api) + expect(warn.mock.calls.flat().join('\n')).toContain('DF0066') + }) + + it('skips an optional descriptor whose package cannot be imported', async () => { + const { ctx } = await createCtx() + const install = ctx.services.install({ package: '@test/does-not-exist' }) + await ctx.services.ready() + await expect(install).resolves.toBeUndefined() + expect(ctx.services.has('@test/does-not-exist')).toBe(false) + }) + + it('rejects the barrier when a required descriptor cannot be imported', async () => { + const { ctx } = await createCtx() + void ctx.services.install({ package: '@test/does-not-exist', required: true }) + await expect(ctx.services.ready()).rejects.toThrowError(/Failed to import the required service package/) + }) + + it('imports a descriptor package relative to resolveFrom and installs its factory', async () => { + const { ctx, dir } = await createCtx() + writeFakeServicePackage(dir, '@test/imported-svc', '2.0.0') + const install = ctx.services.install( + { package: '@test/imported-svc', version: '^2', options: { x: 1 } }, + { resolveFrom: join(dir, '_resolver.js') }, + ) + await ctx.services.ready() + await expect(install).resolves.toEqual({ imported: true, options: { x: 1 } }) + const state = await ctx.rpc.sharedState.get(DEVFRAME_SERVICES_STATE_KEY) + expect(state.value()['@test/imported-svc']).toEqual({ + package: '@test/imported-svc', + version: '2.0.0', + scope: 'test:imported', + }) + }) + + it('throws on a required version-range mismatch, warns on an optional one', async () => { + const { ctx, dir } = await createCtx() + writeFakeServicePackage(dir, '@test/versioned-svc', '2.0.0') + const resolveFrom = join(dir, '_resolver.js') + + void ctx.services.install({ package: '@test/versioned-svc', version: '^1', required: true }, { resolveFrom }) + await expect(ctx.services.ready()).rejects.toThrowError(/does not satisfy the required range/) + + // A fresh context: the optional mismatch installs anyway with a warning. + const { ctx: ctx2 } = await createCtx() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const install = ctx2.services.install({ package: '@test/versioned-svc', version: '^1' }, { resolveFrom }) + await ctx2.services.ready() + await expect(install).resolves.toEqual({ imported: true, options: undefined }) + expect(warn.mock.calls.flat().join('\n')).toContain('DF0069') + expect(ctx2.services.has('@test/versioned-svc')).toBe(true) + }) + + it('rejects invalid inputs with DF0070', async () => { + const { ctx } = await createCtx() + expect(() => ctx.services.install({} as never)).toThrowError(/has no `package` name/) + expect(() => ctx.services.install({ package: '@test/x', scope: '', version: '1.0.0', setup: () => ({}) })).toThrowError(/no RPC `scope` namespace/) + }) +}) diff --git a/packages/devframe/src/node/context.ts b/packages/devframe/src/node/context.ts index 6cab7897..671e2b9f 100644 --- a/packages/devframe/src/node/context.ts +++ b/packages/devframe/src/node/context.ts @@ -54,7 +54,7 @@ export async function createHostContext(options: CreateHostContextOptions): Prom context.rpc = rpcHost context.views = viewsHost context.diagnostics = diagnosticsHost - context.services = new DevframeServicesHostImpl() + context.services = new DevframeServicesHostImpl(context) // 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/definition-services.ts b/packages/devframe/src/node/definition-services.ts new file mode 100644 index 00000000..19180920 --- /dev/null +++ b/packages/devframe/src/node/definition-services.ts @@ -0,0 +1,42 @@ +import type { DevframeNodeContext } from '../types/context' +import type { DevframeDefinition } from '../types/devframe' +import { createRequire } from 'node:module' +import { join } from 'pathe' + +/** + * Resolve the base path service imports should resolve **from** for a + * definition: the declaring plugin's own package (so a plugin-declared + * service resolves against the plugin's dependencies). Falls back to + * `undefined` when the plugin package isn't resolvable (e.g. an inline, + * unpublished definition) — the services host then resolves from the + * workspace root. + */ +function resolveDefinitionResolveFrom(def: DevframeDefinition, cwd: string): string | undefined { + if (!def.packageName) + return undefined + const require = createRequire(join(cwd, '_devframe_resolve.js')) + try { + return require.resolve(`${def.packageName}/package.json`) + } + catch {} + try { + return require.resolve(def.packageName) + } + catch {} + return undefined +} + +/** + * Queue a definition's declarative `services` on the context — called by + * every adapter (and a hub's install path) **before** `def.setup(ctx)` runs, + * so declarative option sets precede setup-time installs in the merge order. + * Installation itself happens at the `ctx.services.ready()` barrier the + * adapter fires once every devframe's setup has run. + */ +export function installDefinitionServices(context: DevframeNodeContext, def: DevframeDefinition): void { + if (!def.services || def.services.length === 0) + return + const resolveFrom = resolveDefinitionResolveFrom(def, context.cwd) + for (const input of def.services) + void context.services.install(input, resolveFrom ? { resolveFrom } : {}) +} diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index c4fed343..a6457538 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -169,5 +169,35 @@ export const diagnostics = defineDiagnostics({ `Invalid remote-assets ${p.field} "${p.value}".`, fix: 'A remote-assets `package` must be a valid npm package name and `version` an exact semver version (e.g. `1.2.3`) — they are interpolated into CDN URLs and the cache path.', }, + DF0066: { + why: (p: { package: string }) => + `Service "${p.package}" is already installed — keeping the first installation and ignoring this one's options.`, + fix: 'Option sets only merge before `ctx.services.ready()` fires. Install the service (or declare it in `DevframeDefinition.services`) before the barrier so its options join the merge.', + }, + DF0067: { + why: (p: { package: string, reason: string }) => + `Failed to import the required service package "${p.package}": ${p.reason}`, + fix: 'Install the service package next to whoever declares it (a plugin declaring it in `services` should list it in its own dependencies), or drop `required: true` to degrade gracefully when it is absent.', + }, + DF0068: { + why: (p: { package: string, required: string, installed: string }) => + `The installed service "${p.package}@${p.installed}" does not satisfy the required range "${p.required}".`, + fix: 'Align the installed service package with the range its declarer requires, or drop `required: true` to downgrade the mismatch to a warning.', + }, + DF0069: { + why: (p: { package: string, required: string, installed: string }) => + `The installed service "${p.package}@${p.installed}" does not satisfy the declared range "${p.required}" — installing it anyway.`, + fix: 'The advertised meta carries the real version, so clients can gate on it. Align the installed service package with the declared range to silence this warning.', + }, + DF0070: { + why: (p: { package: string, reason: string }) => + `Invalid service "${p.package}": ${p.reason}`, + fix: 'A service package\'s default export must be a factory returning a `DevframeServiceDefinition` — an object with `package`, `version`, `scope`, and a `setup` function.', + }, + DF0071: { + why: (p: { reason: string }) => + `Deferred service installation failed while flushing on the first client connection: ${p.reason}`, + fix: 'Call `ctx.services.ready()` explicitly after every devframe\'s setup has run (the first-party adapters do) so installation errors surface at startup instead of at connect time.', + }, }, }) diff --git a/packages/devframe/src/node/host-services.ts b/packages/devframe/src/node/host-services.ts index f20fe62a..0b4c81a8 100644 --- a/packages/devframe/src/node/host-services.ts +++ b/packages/devframe/src/node/host-services.ts @@ -1,14 +1,66 @@ -import type { DevframeServiceId, DevframeServiceOf, DevframeServicesHost } from 'devframe/types' +import type { + DevframeNodeContext, + DevframeServiceDefinition, + DevframeServiceDescriptor, + DevframeServiceId, + DevframeServiceInput, + DevframeServiceInstallOptions, + DevframeServiceOf, + DevframeServicesHost, + DevframeServicesState, +} from 'devframe/types' +import { DEVFRAME_SERVICES_STATE_KEY } from 'devframe/constants' +import { createDebug } from 'obug' import { diagnostics } from './diagnostics' +import { importServicePackage, satisfiesVersionRange, shallowMergeOptionSets } from './services-install' + +const debug = createDebug('devframe:services') + +interface PendingServiceEntry { + input: DevframeServiceInput + resolveFrom?: string | null + resolve: (api: unknown) => void + reject: (error: unknown) => void +} + +function isServiceDefinition(input: DevframeServiceInput): input is DevframeServiceDefinition { + return typeof (input as DevframeServiceDefinition).setup === 'function' +} + +function validateServiceInput(input: DevframeServiceInput): void { + if (!input || typeof input.package !== 'string' || input.package.length === 0) + throw diagnostics.DF0070({ package: String((input as any)?.package ?? input), reason: 'the input has no `package` name' }) + if (isServiceDefinition(input)) + validateServiceDefinition(input) +} + +function validateServiceDefinition(def: DevframeServiceDefinition): void { + if (typeof def.version !== 'string' || def.version.length === 0) + throw diagnostics.DF0070({ package: def.package, reason: 'the definition has no `version`' }) + if (typeof def.scope !== 'string' || def.scope.length === 0) + throw diagnostics.DF0070({ package: def.package, reason: 'the definition has no RPC `scope` namespace' }) +} /** * 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. + * + * On top of the in-process `provide`/`get` tier, this host implements the + * **wire-service** lifecycle: `install()` queues definitions/descriptors, + * `ready()` fires the collect-then-setup barrier — importing descriptor + * packages, merging option sets per service, constructing each service once, + * providing its node API under the package name, and advertising it to + * clients through the `devframe:services` shared state. */ export class DevframeServicesHostImpl implements DevframeServicesHost { private services = new Map() private listeners = new Map void>>() + private pending = new Map() + private installed = new Map() + private readyPromise: Promise | undefined + + constructor(private context?: DevframeNodeContext) {} provide(id: ID, service: DevframeServiceOf): () => void { const key = id as string @@ -53,4 +105,164 @@ export class DevframeServicesHostImpl implements DevframeServicesHost { keys(): string[] { return Array.from(this.services.keys()) } + + get isReady(): boolean { + return this.readyPromise !== undefined + } + + install( + input: DevframeServiceInput, + options?: DevframeServiceInstallOptions, + ): Promise { + validateServiceInput(input as DevframeServiceInput) + const promise = new Promise((resolve, reject) => { + const entry: PendingServiceEntry = { + input: input as DevframeServiceInput, + resolveFrom: options?.resolveFrom, + resolve, + reject, + } + if (this.readyPromise) { + // Post-barrier: construct immediately (merged with nothing but its + // own option set). + void this.flushPackage(input.package, [entry]).catch(() => {}) + } + else { + let entries = this.pending.get(input.package) + if (!entries) { + entries = [] + this.pending.set(input.package, entries) + } + entries.push(entry) + } + }) + // Mark a rejection as handled on this branch so a fire-and-forget + // `install()` never crashes the process — awaiting callers (and the + // adapter's awaited `ready()`) still observe it. + promise.catch(() => {}) + return promise as Promise + } + + ready(): Promise { + if (this.readyPromise) + return this.readyPromise + this.readyPromise = this.flushAll() + return this.readyPromise + } + + private async flushAll(): Promise { + // Materialize the advertisement state even when no service installs, so + // build-mode dumps always carry the key and static clients read `{}` + // instead of erroring on a missing snapshot. + if (this.context) + await this.advertisementState() + const groups = Array.from(this.pending.entries()) + this.pending.clear() + for (const [pkg, entries] of groups) + await this.flushPackage(pkg, entries) + } + + private async flushPackage(pkg: string, entries: PendingServiceEntry[]): Promise { + try { + const api = await this.installPackage(pkg, entries) + for (const entry of entries) + entry.resolve(api) + return api + } + catch (error) { + for (const entry of entries) + entry.reject(error) + throw error + } + } + + private async installPackage(pkg: string, entries: PendingServiceEntry[]): Promise { + // Dedup: first installation wins; a later install's options are ignored. + if (this.installed.has(pkg)) { + diagnostics.DF0066({ package: pkg }) + return this.installed.get(pkg) + } + + const definitions = entries.filter(entry => isServiceDefinition(entry.input)) + let def = definitions[0]?.input as DevframeServiceDefinition | undefined + + if (!def) { + const descriptors = entries.map(entry => entry.input as DevframeServiceDescriptor) + const required = descriptors.some(descriptor => descriptor.required === true) + const resolveFroms = [ + ...entries.map(entry => entry.resolveFrom), + this.context?.workspaceRoot, + this.context?.cwd, + ] + let mod: unknown + try { + mod = await importServicePackage(pkg, resolveFroms) + } + catch (error) { + const reason = error instanceof Error ? error.message : String(error) + if (required) + throw diagnostics.DF0067({ package: pkg, reason, cause: error }) + debug('optional service %s not importable, skipping: %s', pkg, reason) + return undefined + } + const factory = (mod as { default?: unknown }).default + if (typeof factory !== 'function') + throw diagnostics.DF0070({ package: pkg, reason: 'its default export is not a factory function' }) + def = await (factory as () => DevframeServiceDefinition | Promise)() + if (!def || typeof def.setup !== 'function') + throw diagnostics.DF0070({ package: pkg, reason: 'its factory did not return a definition with a `setup` function' }) + if (typeof def.package !== 'string' || def.package.length === 0) + def = { ...def, package: pkg } + validateServiceDefinition(def) + } + + // Version-range checks against the resolved definition's real version. + for (const entry of entries) { + const descriptor = entry.input as DevframeServiceDescriptor + if (isServiceDefinition(entry.input) || typeof descriptor.version !== 'string') + continue + if (satisfiesVersionRange(def.version, descriptor.version)) + continue + if (descriptor.required === true) + throw diagnostics.DF0068({ package: pkg, required: descriptor.version, installed: def.version }) + diagnostics.DF0069({ package: pkg, required: descriptor.version, installed: def.version }) + } + + // Merge every installer's option set in declaration order (later wins on + // the default shallow merge, so a host installing last takes precedence). + const sets = entries + .map(entry => entry.input.options) + .filter(options => options !== undefined) + const options = def.mergeOptions + ? def.mergeOptions(sets) + : sets.length > 0 + ? shallowMergeOptionSets(sets) + : undefined + + if (!this.context) + throw diagnostics.DF0070({ package: pkg, reason: 'this services host has no node context to install into' }) + + debug('installing service %s@%s (scope %s)', def.package, def.version, def.scope) + const scoped = this.context.scope(def.scope) + const api = await def.setup(scoped, options === undefined ? {} : { options }) + this.installed.set(def.package, api) + this.provide(def.package, api as DevframeServiceOf) + await this.advertise(def) + return api + } + + private advertisementState() { + return this.context!.rpc.sharedState.get( + DEVFRAME_SERVICES_STATE_KEY, + { initialValue: {} }, + ) + } + + private async advertise(def: DevframeServiceDefinition): Promise { + const state = await this.advertisementState() + const { package: pkg, version, scope, meta } = def + state.mutate((value) => { + value[pkg] = { package: pkg, version, scope, ...(meta ? { meta } : {}) } + }) + } } diff --git a/packages/devframe/src/node/index.ts b/packages/devframe/src/node/index.ts index c5fb151a..56fe6bbc 100644 --- a/packages/devframe/src/node/index.ts +++ b/packages/devframe/src/node/index.ts @@ -11,6 +11,7 @@ // host-URL helpers stay fully internal (relative imports only). // `toAgentToolName` lives at `devframe/utils/agent-tool-name`. export * from './context' +export * from './definition-services' // `RpcFunctionsHostImpl` stays internal; expose only the structural // `RpcFunctionsHost` type so consumers can type/cast `ctx.rpc` without // pulling in the implementation's `@internal` members. diff --git a/packages/devframe/src/node/rpc-core.ts b/packages/devframe/src/node/rpc-core.ts index 66002f72..93006d30 100644 --- a/packages/devframe/src/node/rpc-core.ts +++ b/packages/devframe/src/node/rpc-core.ts @@ -130,16 +130,25 @@ export function createContextRpcServer(options: CreateContextRpcServerOptions): }) } - const onConnected = (authHandler || options.onPeerConnect) - ? (connection: DevframeRpcConnection, meta: DevframeNodeRpcSessionMeta) => { - const session: DevframeNodeRpcSession = { - meta, - rpc: rpcGroup.clients.find(client => (client as any).$meta === meta) as any, - } - authHandler?.onConnect(connection, session) - options.onPeerConnect?.(connection, session) - } - : undefined + const onConnected = (connection: DevframeRpcConnection, meta: DevframeNodeRpcSessionMeta): void => { + // Safety net for the services collect-then-setup barrier: a host that + // never called `ctx.services.ready()` still flushes deferred service + // installs before the first client is served. Idempotent and cheap once + // fired; failures are reported (not thrown) since a connect hook is no + // place to crash — adapters that `await ready()` surface them at startup. + void Promise.resolve() + .then(() => context.services.ready?.()) + .catch((error) => { + const reason = error instanceof Error ? error.message : String(error) + diagnostics.DF0071({ reason, cause: error }, { method: 'error' }) + }) + const session: DevframeNodeRpcSession = { + meta, + rpc: rpcGroup.clients.find(client => (client as any).$meta === meta) as any, + } + authHandler?.onConnect(connection, session) + options.onPeerConnect?.(connection, session) + } const onDisconnected = (connection: DevframeRpcConnection, meta: DevframeNodeRpcSessionMeta): void => { options.onPeerDisconnect?.(connection, meta) diff --git a/packages/devframe/src/node/scope.ts b/packages/devframe/src/node/scope.ts index 7888606e..5cb22d29 100644 --- a/packages/devframe/src/node/scope.ts +++ b/packages/devframe/src/node/scope.ts @@ -56,6 +56,7 @@ export function createScopedNodeContext( views: context.views, diagnostics: context.diagnostics, agent: context.agent, + services: context.services, scope: context.scope, } } diff --git a/packages/devframe/src/node/services-install.ts b/packages/devframe/src/node/services-install.ts new file mode 100644 index 00000000..5920fa47 --- /dev/null +++ b/packages/devframe/src/node/services-install.ts @@ -0,0 +1,169 @@ +import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' +import { join } from 'pathe' + +/** + * Turn a `resolveFrom` value (a file path, a file URL like `import.meta.url`, + * or a directory) into something `createRequire` accepts — a directory gets a + * synthetic filename appended so resolution starts inside it. + */ +function toRequireBase(resolveFrom: string): string { + if (resolveFrom.startsWith('file://')) + return resolveFrom + // A path with an extension in its last segment is already file-like; + // anything else is treated as a directory. + const lastSegment = resolveFrom.split(/[/\\]/).pop() ?? '' + if (lastSegment.includes('.')) + return resolveFrom + return join(resolveFrom, '_devframe_resolve.js') +} + +/** + * Import a service package's module, trying each `resolveFrom` candidate in + * order (so a plugin-declared service resolves against the plugin's own + * dependency tree first, then the workspace fallback). Throws the last + * resolution error when no candidate succeeds. + */ +export async function importServicePackage( + pkg: string, + resolveFroms: readonly (string | null | undefined)[], +): Promise { + const candidates = [...new Set(resolveFroms.filter((x): x is string => typeof x === 'string' && x.length > 0))] + let lastError: unknown = new Error(`no resolution base available for "${pkg}"`) + for (const from of candidates) { + let resolved: string + try { + resolved = createRequire(toRequireBase(from)).resolve(pkg) + } + catch (error) { + lastError = error + continue + } + return await import(pathToFileURL(resolved).href) + } + throw lastError +} + +interface ParsedVersion { + parts: number[] + prerelease?: string +} + +function parseVersion(input: string): ParsedVersion | undefined { + const trimmed = input.trim().replace(/^v/, '') + const [core, ...prerelease] = trimmed.split('-') + if (!core) + return undefined + const parts = core.split('.').map(part => Number.parseInt(part, 10)) + if (parts.length === 0 || parts.some(part => Number.isNaN(part) || part < 0)) + return undefined + while (parts.length < 3) + parts.push(0) + return { parts, ...(prerelease.length ? { prerelease: prerelease.join('-') } : {}) } +} + +function compareVersions(a: ParsedVersion, b: ParsedVersion): number { + for (let i = 0; i < 3; i++) { + const diff = (a.parts[i] ?? 0) - (b.parts[i] ?? 0) + if (diff !== 0) + return diff + } + // A prerelease sorts before its release (1.0.0-beta < 1.0.0). + if (a.prerelease && !b.prerelease) + return -1 + if (!a.prerelease && b.prerelease) + return 1 + if (a.prerelease && b.prerelease) + return a.prerelease < b.prerelease ? -1 : a.prerelease > b.prerelease ? 1 : 0 + return 0 +} + +function satisfiesComparator(version: ParsedVersion, comparator: string): boolean { + const raw = comparator.trim() + if (!raw || raw === '*' || raw === 'x') + return true + + const operatorMatch = raw.match(/^([\^~]|>=|<=|[><=])?(.+)$/) + if (!operatorMatch) + return false + const operator = operatorMatch[1] + const rest = operatorMatch[2]!.trim() + + // Partial versions (`1`, `1.2`, `1.2.x`) — prefix (x-range) semantics for + // the bare / `=` forms; padded with zeros for the ordered comparators. + const segments = rest.replace(/\.[x*]/gi, '').split('.').filter(Boolean) + const base = parseVersion(rest.replace(/[x*]/gi, '0')) + if (!base) + return false + + switch (operator) { + case '>': + return compareVersions(version, base) > 0 + case '>=': + return compareVersions(version, base) >= 0 + case '<': + return compareVersions(version, base) < 0 + case '<=': + return compareVersions(version, base) <= 0 + case '^': { + if (compareVersions(version, base) < 0) + return false + // Left-most non-zero element is fixed (npm caret semantics). + const fixedIndex = base.parts.findIndex(part => part !== 0) + const lockUpTo = fixedIndex === -1 ? base.parts.length - 1 : fixedIndex + for (let i = 0; i <= lockUpTo; i++) { + if ((version.parts[i] ?? 0) !== (base.parts[i] ?? 0)) + return false + } + return true + } + case '~': { + if (compareVersions(version, base) < 0) + return false + // Same major (and same minor when the range specifies one). + const lockUpTo = segments.length >= 2 ? 1 : 0 + for (let i = 0; i <= lockUpTo; i++) { + if ((version.parts[i] ?? 0) !== (base.parts[i] ?? 0)) + return false + } + return true + } + default: { + // Bare / `=` — exact for full versions, prefix match for partials. + for (let i = 0; i < Math.max(segments.length, 3); i++) { + if (i < segments.length && (version.parts[i] ?? 0) !== (base.parts[i] ?? 0)) + return false + } + return segments.length >= 3 ? compareVersions(version, base) === 0 : true + } + } +} + +/** + * Pragmatic semver range check for service version declarations — supports + * the common forms (`1.2.3`, `^1.2.3`, `~1.2`, `>=1 <3`, `1.x`, `*`, and + * `||`-joined alternatives) without pulling in a semver dependency. An + * unparseable version or range reads as **not satisfied**. + */ +export function satisfiesVersionRange(version: string, range: string): boolean { + const parsed = parseVersion(version) + if (!parsed) + return false + const alternatives = range.split('||').map(alt => alt.trim()).filter(Boolean) + if (alternatives.length === 0) + return true + return alternatives.some(alternative => + alternative.split(/\s+/).every(comparator => satisfiesComparator(parsed, comparator)), + ) +} + +/** + * Default option-set merge when a service declares no `mergeOptions`: + * shallow-merge plain objects in declaration order (later sets win); any + * non-object set collapses the merge to "last one wins". + */ +export function shallowMergeOptionSets(sets: Options[]): Options { + if (sets.some(set => typeof set !== 'object' || set === null || Array.isArray(set))) + return sets[sets.length - 1] as Options + return Object.assign({}, ...sets) as Options +} diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index fa1367ec..5ae5652a 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -3,6 +3,7 @@ import type { CliFlagsSchema } from '../adapters/flags' import type { DevframeAuthHandler } from '../node/auth/handler' import type { DevframeNodeContext } from './context' import type { StaticAssetsSource } from './remote-assets' +import type { DevframeServiceInput } from './services' /** * Classification of how a devframe is being deployed. Hosted adapters @@ -326,6 +327,19 @@ export interface DevframeDefinition { dev?: boolean build?: boolean } + /** + * Wire services this devframe consumes (see `DevframeServiceDefinition`). + * Each entry is either a declarative descriptor + * (`{ package, version?, required?, options? }` — the host imports the + * package's default-export factory, resolving it against **this plugin's + * own dependencies**) or a ready `DevframeServiceDefinition` (the factory + * was already called). The adapter queues these before `setup(ctx)` runs + * and constructs each service once at the `ctx.services.ready()` barrier, + * merging option sets across every declarer. Missing services are skipped + * unless marked `required` — clients feature-detect via + * `client.services.has(pkg)` and degrade. + */ + services?: DevframeServiceInput[] /** Server-side setup — the primary entrypoint. Runs in every runtime. */ setup: (ctx: DevframeNodeContext, info?: DevframeSetupInfo) => void | Promise cli?: DevframeCliOptions diff --git a/packages/devframe/src/types/rpc-augments.ts b/packages/devframe/src/types/rpc-augments.ts index af4a121e..89f08beb 100644 --- a/packages/devframe/src/types/rpc-augments.ts +++ b/packages/devframe/src/types/rpc-augments.ts @@ -152,4 +152,13 @@ export interface DevframeRpcServerFunctions { /** * To be extended */ -export interface DevframeRpcSharedStates {} +export interface DevframeRpcSharedStates { + /** + * Wire-service advertisements — package name → `{ package, version, + * scope, meta }` for every installed {@link import('./services').DevframeServiceDefinition}. + * Written by the node services host at the `ready()` barrier; clients read + * it through `client.services` (or subscribe to it directly for + * reactivity). Read-only from the browser. + */ + 'devframe:services': import('./services').DevframeServicesState +} diff --git a/packages/devframe/src/types/scope.ts b/packages/devframe/src/types/scope.ts index 238875c3..598ddddf 100644 --- a/packages/devframe/src/types/scope.ts +++ b/packages/devframe/src/types/scope.ts @@ -11,6 +11,7 @@ import type { RpcStreamingChannelOptions, } from './rpc' import type { DevframeRpcClientFunctions, DevframeRpcServerFunctions, DevframeRpcSharedStates } from './rpc-augments' +import type { DevframeServicesHost } from './services' import type { DevframeViewHost } from './views' // Callable guard so `Parameters` / `ReturnType` always have a function to @@ -218,6 +219,8 @@ export interface DevframeScopedNodeContext = ID extends keyof DevframeServicesRegistry ? DevframeServicesRegistry[ID] : unknown +/** + * Augmentation point mapping a service's npm package name to the RPC scope + * namespace it registers its functions under, so a client's + * `services.get('@devframes/service-x')` returns a scoped RPC surface typed + * against that namespace. Service packages contribute their entry via + * declaration merging: + * + * ```ts + * declare module 'devframe' { + * interface DevframeServicesScopeRegistry { + * '@devframes/service-open': 'devframes:service:open' + * } + * } + * ``` + */ +export interface DevframeServicesScopeRegistry {} + +/** Resolved RPC scope namespace for a service package name, or `string`. */ +export type DevframeServiceScopeOf = PKG extends keyof DevframeServicesScopeRegistry + ? DevframeServicesScopeRegistry[PKG] & string + : string + +/** + * Runtime information threaded into a service definition's `setup`. + */ +export interface DevframeServiceSetupInfo { + /** + * The merged option sets contributed by every installer of this service + * (declarative descriptors and explicit definitions alike), merged at the + * `ready()` barrier — via the definition's own {@link DevframeServiceDefinition.mergeOptions} + * when present, otherwise shallow-merged in declaration order (later sets + * win). `undefined` when no installer passed options. + */ + options?: Options +} + +/** + * A **wire service** — a shared server-side capability (e.g. open-in-editor, + * syntax highlighting) packaged so any devframe host can install it once and + * every plugin/client can consume it without re-implementing or re-bundling + * it. Contrast with plain {@link DevframeServicesHost.provide}, which shares + * an in-process object between plugins on the node side only: a + * `DevframeServiceDefinition` additionally registers RPC functions under its + * {@link DevframeServiceDefinition.scope} and is **advertised to clients** + * through the reactive `devframe:services` shared state, so browser UIs can + * feature-detect it (`ctx.services.has(pkg)`) and degrade gracefully. + * + * Ship one per npm package (`@devframes/service-` for first-party), + * with the package's default export being the `createService` factory — + * never a pre-built instance. + */ +export interface DevframeServiceDefinition { + /** + * The npm package name this service ships in — also its registry key + * (`ctx.services.has('@devframes/service-x')` on both node and client). + */ + package: string + /** Semver of the service — advertised to clients, checked against descriptor ranges. */ + version: string + /** + * RPC namespace the service's functions register under, following the + * plugin id grammar (e.g. `devframes:service:open`). `setup` receives a + * context pre-scoped to it, so functions register with bare names. + */ + scope: string + /** + * Extra advertised metadata (feature flags, defaults, …). Must be + * JSON-serializable — it is mirrored to every client. + */ + meta?: Record + /** + * This instance's own option set (usually baked in by the factory that + * created the definition). Joins the merge alongside every declarative + * descriptor's `options`. + */ + options?: Options + /** + * Merge the option sets contributed by multiple installers (in declaration + * order) into the one bag passed to `setup`. Defaults to a shallow merge + * where later sets win. + */ + mergeOptions?: (sets: Options[]) => Options + /** + * Construct the service: register its RPC functions on the pre-scoped + * context and return its **node API** — the in-process surface other + * plugins get from `ctx.services.get(package)` (no RPC hop server-side). + */ + setup: (ctx: DevframeScopedNodeContext, info: DevframeServiceSetupInfo) => API | Promise +} + +/** + * Declarative reference to a service package — the form a + * {@link DevframeServiceInput} takes when the installer doesn't hold the + * factory itself (e.g. `DevframeDefinition.services`). The host imports the + * package's default-export factory and installs the resulting definition at + * the `ready()` barrier. + */ +export interface DevframeServiceDescriptor { + /** npm package name of the service (its default export is the factory). */ + package: string + /** + * Accepted semver range for the installed service. An unsatisfied range + * warns (`DF0069`) — or throws (`DF0068`) when {@link required} — while the + * service still installs; the advertised meta carries the real version. + */ + version?: string + /** + * Fail hard when the service can't be imported or its version range isn't + * satisfied. By default a missing service is skipped silently — clients see + * `has() === false` and degrade. + * + * @default false + */ + required?: boolean + /** Option set this installer contributes to the merge. */ + options?: Options +} + +/** + * What can be passed to `ctx.services.install()` (and listed in + * `DevframeDefinition.services`): a declarative {@link DevframeServiceDescriptor} + * (the host imports the factory) or a ready {@link DevframeServiceDefinition} + * (the installer already called the factory — its `options` join the merge). + */ +export type DevframeServiceInput + = DevframeServiceDescriptor | DevframeServiceDefinition + +export interface DevframeServiceInstallOptions { + /** + * Path or file URL (e.g. `import.meta.url`, or a resolved + * `/package.json` path) the descriptor's package is resolved + * **from** — so a plugin-declared service resolves against the plugin's + * own dependencies. Falls back to the context's `workspaceRoot`. + */ + resolveFrom?: string | null +} + +/** + * One service's advertisement entry, mirrored to clients through the + * `devframe:services` shared state. + */ +export interface DevframeServiceMeta { + /** npm package name — the registry key. */ + package: string + /** Installed version of the service. */ + version: string + /** RPC namespace its functions live under. */ + scope: string + /** Extra service-declared metadata. */ + meta?: Record +} + +/** + * Shape of the `devframe:services` shared state: package name → advertisement. + */ +export type DevframeServicesState = Record + export interface DevframeServicesHost { /** * Publish a service under a namespaced id. Throws `DF0037` when the id is @@ -64,4 +230,31 @@ export interface DevframeServicesHost { ) => () => void /** Ids of every currently-provided service. */ keys: () => string[] + /** + * Install a **wire service** (see {@link DevframeServiceDefinition}). + * Before {@link DevframeServicesHost.ready} fires, installs are queued — + * option sets from every installer accumulate and each service is + * constructed **once** at the barrier with the merged options; the + * returned promise resolves with the service's node API then (or + * `undefined` when an optional descriptor's package can't be imported). + * After the barrier, installs construct immediately; installing an + * already-installed package returns the existing API (a warning is + * emitted when the late install carried options, since they're ignored). + */ + install: ( + input: DevframeServiceInput, + options?: DevframeServiceInstallOptions, + ) => Promise + /** + * Fire the collect-then-setup barrier: resolve every queued descriptor + * (importing its package), merge option sets per service, construct each + * service once, `provide()` its node API under the package name, and + * advertise it to clients via the `devframe:services` shared state. + * Idempotent — adapters call it once after every devframe's `setup` has + * run; a repeat returns the same promise. Rejects when a `required` + * service fails to import or misses its version range. + */ + ready: () => Promise + /** Whether the {@link ready} barrier has fired. */ + readonly isReady: boolean } diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index 9c175d43..a1938095 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -495,6 +495,11 @@ export function initHub(options: InitHubOptions): HubInstance { // into the connection meta right after this `init` returns. await options.ui?.setup?.(ctx) + // Wire-services barrier: every service declared by an installed + // devframe (or installed explicitly during `configure`) is constructed + // once here, with its option sets merged across declarers. + await ctx.services.ready() + // Publish the renderer manifest — one `ClientScriptEntry` per dock // `type`, `importFrom` base-absolute so it resolves to the served module // from any page depth. Clients read it from shared state and import a diff --git a/packages/hub/src/node/install-devframe.ts b/packages/hub/src/node/install-devframe.ts index 9eac2c48..c1e109df 100644 --- a/packages/hub/src/node/install-devframe.ts +++ b/packages/hub/src/node/install-devframe.ts @@ -1,6 +1,7 @@ import type { DevframeDefinition } from 'devframe/types' import type { DevframeViewIframe } from '../types/docks' import type { DevframeHubContext } from './context' +import { installDefinitionServices } from 'devframe/node' import { resolveBasePath } from 'devframe/node/hub-internals' import { resolve } from 'pathe' import { diagnostics } from './diagnostics' @@ -107,5 +108,10 @@ export async function installDevframe( url: base, } as DevframeViewIframe) + // Queue the definition's declarative wire services ahead of its setup so + // their option sets precede setup-time installs in the merge order. The + // hub fires the `ctx.services.ready()` barrier once every devframe (and + // the host's own configuration) has installed. + installDefinitionServices(ctx, d) await d.setup(ctx) } diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index be0c625d..f4003420 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -24,6 +24,7 @@ export interface DevframeRpcClient { callOptional: DevframeRpcClientCallOptional; client: DevframeClientRpcHost; sharedState: RpcSharedStateHost; + services: DevframeServicesClient; streaming: RpcStreamingClientHost; cacheManager: RpcCacheManager; scope: { @@ -95,6 +96,16 @@ export interface DevframeScopedClientStreamingHost { subscribe: (_: string, _: string, _?: StreamingSubscribeOptions) => StreamReader; upload: (_: string, _: string) => StreamSink; } +export interface DevframeServiceClientHandle extends DevframeServiceMeta { + readonly scope: NS; + readonly rpc: DevframeScopedClientRpc; +} +export interface DevframeServicesClient { + has: (_: string) => boolean; + get: (_: PKG) => DevframeServiceClientHandle> | undefined; + keys: () => string[]; + state: () => Promise>; +} export interface RpcClientEvents { 'rpc:is-trusted:updated': (_: boolean) => void; 'connection:status': (_: DevframeConnectionStatus, _: DevframeConnectionStatus) => void; @@ -147,6 +158,7 @@ export declare function authenticateWithUrlOtp(_: Pick; export declare function consumeOtpFromUrl(_?: string): string | undefined; export declare function createClientSettings = Record>(_: DevframeRpcClient, _: string): DevframeSettings; +export declare function createDevframeServicesClient(_: DevframeRpcClient): DevframeServicesClient; export declare function createRpcStreamingClientHost(_: DevframeRpcClient): RpcStreamingClientHost; export declare function createScopedClientContext(_: DevframeRpcClient, _: NS): DevframeScopedClientContext; export declare function getDevframeConnection(): DevframeConnection | undefined; diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js index 2e8597bd..2b7c6eec 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js @@ -13,6 +13,7 @@ export class DevframeConnectionError extends Error { export async function authenticateWithUrlOtp(_, _) {} export function consumeOtpFromUrl(_) {} export function createClientSettings(_, _) {} +export function createDevframeServicesClient(_) {} export function createRpcStreamingClientHost(_) {} export function createScopedClientContext(_, _) {} export function getDevframeConnection() {} diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts index 9938da9f..89e5ec60 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts @@ -16,6 +16,7 @@ export declare const DEVFRAME_OTP_URL_PARAM: string; export declare const DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE: string; export declare const DEVFRAME_RPC_DUMP_DIRNAME: string; export declare const DEVFRAME_RPC_DUMP_MANIFEST_FILENAME: string; +export declare const DEVFRAME_SERVICES_STATE_KEY: string; export declare const DEVFRAME_SSE_ROUTE: string; export declare const DEVFRAME_SSE_SESSION_HEADER: string; export declare const DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM: string; diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js index db97b2c1..300390a0 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js @@ -16,6 +16,7 @@ export var DEVFRAME_OTP_URL_PARAM /* const */ export var DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE /* const */ export var DEVFRAME_RPC_DUMP_DIRNAME /* const */ export var DEVFRAME_RPC_DUMP_MANIFEST_FILENAME /* const */ +export var DEVFRAME_SERVICES_STATE_KEY /* const */ export var DEVFRAME_SSE_ROUTE /* const */ export var DEVFRAME_SSE_SESSION_HEADER /* const */ export var DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM /* const */ diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index cfae43f1..7614e9b0 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -145,6 +145,7 @@ export interface DevframeDefinition { dev?: boolean; build?: boolean; }; + services?: DevframeServiceInput[]; setup: (_: DevframeNodeContext, _?: DevframeSetupInfo) => void | Promise; cli?: DevframeCliOptions; } @@ -257,7 +258,9 @@ export interface DevframeRpcServerFunctions { message: string; }) => Promise; } -export interface DevframeRpcSharedStates {} +export interface DevframeRpcSharedStates { + 'devframe:services': DevframeServicesState; +} export interface DevframeScopedNodeContext = Record> { readonly namespace: NS; readonly base: DevframeNodeContext; @@ -270,6 +273,7 @@ export interface DevframeScopedNodeContext { @@ -295,14 +299,45 @@ export interface DevframeScopedNodeRpc { export interface DevframeScopedStreamingHost { create: (_: string, _?: RpcStreamingChannelOptions) => RpcStreamingChannel; } +export interface DevframeServiceDefinition { + package: string; + version: string; + scope: string; + meta?: Record; + options?: Options; + mergeOptions?: (_: Options[]) => Options; + setup: (_: DevframeScopedNodeContext, _: DevframeServiceSetupInfo) => API | Promise; +} +export interface DevframeServiceDescriptor { + package: string; + version?: string; + required?: boolean; + options?: Options; +} +export interface DevframeServiceInstallOptions { + resolveFrom?: string | null; +} +export interface DevframeServiceMeta { + package: string; + version: string; + scope: string; + meta?: Record; +} +export interface DevframeServiceSetupInfo { + options?: Options; +} export interface DevframeServicesHost { provide: (_: ID, _: DevframeServiceOf) => () => void; get: (_: ID) => DevframeServiceOf | undefined; has: (_: DevframeServiceId) => boolean; whenAvailable: (_: ID, _: (_: DevframeServiceOf) => void) => () => void; keys: () => string[]; + install: (_: DevframeServiceInput, _?: DevframeServiceInstallOptions) => Promise; + ready: () => Promise; + readonly isReady: boolean; } export interface DevframeServicesRegistry {} +export interface DevframeServicesScopeRegistry {} export interface DevframeSettings = Record> { global: DevframeSettingsStore; project: DevframeSettingsStore; @@ -442,7 +477,10 @@ export type DevframeDiagnosticsLogger = Record; export type DevframeDuplicationStrategy = 'warn' | 'silent' | 'throw' | 'duplicate'; export type DevframeRpcTransportKind = 'websocket' | 'sse'; export type DevframeServiceId = keyof DevframeServicesRegistry | (string & {}); +export type DevframeServiceInput = DevframeServiceDescriptor | DevframeServiceDefinition; export type DevframeServiceOf = ID extends keyof DevframeServicesRegistry ? DevframeServicesRegistry[ID] : unknown; +export type DevframeServiceScopeOf = PKG extends keyof DevframeServicesScopeRegistry ? DevframeServicesScopeRegistry[PKG] & string : string; +export type DevframeServicesState = Record; export type DevframeStorageScope = 'workspace' | 'project' | 'global'; export type RemoteAssetsProvider = 'jsdelivr' | 'unpkg' | RemoteAssetsProviderCustom; export type RpcFunctionsHost = RpcFunctionsCollectorBase & { diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index 3769beec..226c9c74 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -305,6 +305,48 @@ export declare const diagnostics: import("nostics").Diagnostics<{ }) => string; readonly fix: "A remote-assets `package` must be a valid npm package name and `version` an exact semver version (e.g. `1.2.3`) — they are interpolated into CDN URLs and the cache path."; }; + readonly DF0066: { + readonly why: (p: { + package: string; + }) => string; + readonly fix: "Option sets only merge before `ctx.services.ready()` fires. Install the service (or declare it in `DevframeDefinition.services`) before the barrier so its options join the merge."; + }; + readonly DF0067: { + readonly why: (p: { + package: string; + reason: string; + }) => string; + readonly fix: "Install the service package next to whoever declares it (a plugin declaring it in `services` should list it in its own dependencies), or drop `required: true` to degrade gracefully when it is absent."; + }; + readonly DF0068: { + readonly why: (p: { + package: string; + required: string; + installed: string; + }) => string; + readonly fix: "Align the installed service package with the range its declarer requires, or drop `required: true` to downgrade the mismatch to a warning."; + }; + readonly DF0069: { + readonly why: (p: { + package: string; + required: string; + installed: string; + }) => string; + readonly fix: "The advertised meta carries the real version, so clients can gate on it. Align the installed service package with the declared range to silence this warning."; + }; + readonly DF0070: { + readonly why: (p: { + package: string; + reason: string; + }) => string; + readonly fix: "A service package's default export must be a factory returning a `DevframeServiceDefinition` — an object with `package`, `version`, `scope`, and a `setup` function."; + }; + readonly DF0071: { + readonly why: (p: { + reason: string; + }) => string; + readonly fix: "Call `ctx.services.ready()` explicitly after every devframe's setup has run (the first-party adapters do) so installation errors surface at startup instead of at connect time."; + }; }, readonly [(d: import("nostics").Diagnostic, { method }?: { method?: "log" | "warn" | "error"; }) => void]>; diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index 3e3e13bb..1b9fd4dd 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -20,6 +20,7 @@ export interface CreateStorageOptions { // #region Functions export declare function createHostContext(_: CreateHostContextOptions): Promise; export declare function createStorage(_: CreateStorageOptions): SharedState; +export declare function installDefinitionServices(_: DevframeNodeContext, _: DevframeDefinition): void; // #endregion // #region Other diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js index faf693f7..5436bcd6 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js @@ -4,4 +4,5 @@ // #region Other export { createHostContext } export { createStorage } +export { installDefinitionServices } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index b87ce07b..c24cefe1 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -39,10 +39,19 @@ export { DevframeRpcTransportKind } export { DevframeScopedNodeContext } export { DevframeScopedNodeRpc } export { DevframeScopedStreamingHost } +export { DevframeServiceDefinition } +export { DevframeServiceDescriptor } export { DevframeServiceId } +export { DevframeServiceInput } +export { DevframeServiceInstallOptions } +export { DevframeServiceMeta } export { DevframeServiceOf } +export { DevframeServiceScopeOf } +export { DevframeServiceSetupInfo } export { DevframeServicesHost } export { DevframeServicesRegistry } +export { DevframeServicesScopeRegistry } +export { DevframeServicesState } export { DevframeSettings } export { DevframeSettingsRegistry } export { DevframeSettingsStore } From 927bb866dcd814f7856d3aa647617ffcaf2cae9a Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 18 Aug 2026 03:05:33 +0000 Subject: [PATCH 2/4] refactor: trim the services public surface, document wire services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fold DevframeServiceSetupInfo / DevframeServiceInstallOptions into inline types; drop isReady and the scoped-context services passthrough - move installDefinitionServices to devframe/internal (unstable surface) and keep createDevframeServicesClient internal to the client - docs: wire-services guide section, client + definition-field mentions, error pages DF0066–DF0071 --- docs/errors/DF0066.md | 38 +++++++++ docs/errors/DF0067.md | 34 ++++++++ docs/errors/DF0068.md | 34 ++++++++ docs/errors/DF0069.md | 34 ++++++++ docs/errors/DF0070.md | 36 +++++++++ docs/errors/DF0071.md | 28 +++++++ docs/guide/client.md | 11 +++ docs/guide/devframe-definition.md | 1 + docs/guide/services.md | 79 ++++++++++++++++++- .../devframe/src/adapters/mcp/build-server.ts | 3 +- packages/devframe/src/client/index.ts | 2 +- packages/devframe/src/client/rpc-services.ts | 21 +++-- packages/devframe/src/internal/index.ts | 4 + .../src/node/__tests__/services.test.ts | 1 - packages/devframe/src/node/host-services.ts | 7 +- packages/devframe/src/node/index.ts | 1 - packages/devframe/src/node/scope.ts | 1 - packages/devframe/src/types/scope.ts | 3 - packages/devframe/src/types/services.ts | 38 +++------ packages/hub/src/node/install-devframe.ts | 2 +- .../tsnapi/devframe/client.snapshot.d.ts | 1 - .../tsnapi/devframe/client.snapshot.js | 1 - .../tsnapi/devframe/index.snapshot.d.ts | 16 ++-- .../tsnapi/devframe/internal.snapshot.d.ts | 1 + .../tsnapi/devframe/internal.snapshot.js | 1 + .../tsnapi/devframe/node.snapshot.d.ts | 1 - .../tsnapi/devframe/node.snapshot.js | 1 - .../tsnapi/devframe/types.snapshot.d.ts | 2 - 28 files changed, 330 insertions(+), 72 deletions(-) create mode 100644 docs/errors/DF0066.md create mode 100644 docs/errors/DF0067.md create mode 100644 docs/errors/DF0068.md create mode 100644 docs/errors/DF0069.md create mode 100644 docs/errors/DF0070.md create mode 100644 docs/errors/DF0071.md diff --git a/docs/errors/DF0066.md b/docs/errors/DF0066.md new file mode 100644 index 00000000..38d4049f --- /dev/null +++ b/docs/errors/DF0066.md @@ -0,0 +1,38 @@ +--- +outline: deep +--- + +# DF0066: Service Already Installed + +## Message + +> Service "`{package}`" is already installed — keeping the first installation and ignoring this one's options. + +## Cause + +Wire services are deduplicated by npm package name: the first installation wins, and later installs of the same package return the existing node API. Option sets from multiple installers only merge **before** the `ctx.services.ready()` barrier fires — an install that arrives after the service was constructed can no longer influence its configuration, so any options it carried are dropped with this warning. + +## Example + +```ts +await ctx.services.ready() + +// ✗ The service is already constructed; { themes } is ignored. +await ctx.services.install(createShikiService({ themes })) +``` + +## Fix + +Install the service (or declare it in `DevframeDefinition.services`) before the barrier — a host's explicit installs during setup/`configure` naturally run before the adapter fires `ready()`, so its options join the merge: + +```ts +await initHub({ + async configure(ctx) { + ctx.services.install(createShikiService({ themes })) // ✓ merges + }, +}) +``` + +## Source + +- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — `install()`/the barrier flush warn when an already-installed package is installed again. diff --git a/docs/errors/DF0067.md b/docs/errors/DF0067.md new file mode 100644 index 00000000..15656090 --- /dev/null +++ b/docs/errors/DF0067.md @@ -0,0 +1,34 @@ +--- +outline: deep +--- + +# DF0067: Required Service Package Not Importable + +## Message + +> Failed to import the required service package "`{package}`": `{reason}` + +## Cause + +A service descriptor marked `required: true` names a package that could not be resolved and imported at the `ctx.services.ready()` barrier. Descriptors resolve against the declaring plugin's own dependencies first (then the workspace root), so this usually means the service package is missing from the declarer's `dependencies`, or isn't installed. + +Descriptors without `required` degrade instead: the missing service is skipped and clients observe `services.has(pkg) === false`. + +## Example + +```ts +defineDevframe({ + services: [ + // ✗ Throws at the ready() barrier when the package isn't installed. + { package: '@devframes/service-shiki', required: true }, + ], +}) +``` + +## Fix + +Install the service package next to whoever declares it — a plugin declaring it in `services` lists it in its own `dependencies` (or `peerDependencies`) — or drop `required: true` and let the consuming UI fall back when the service is absent. + +## Source + +- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — the barrier flush throws when a `required` descriptor's package fails to import. diff --git a/docs/errors/DF0068.md b/docs/errors/DF0068.md new file mode 100644 index 00000000..0e820e22 --- /dev/null +++ b/docs/errors/DF0068.md @@ -0,0 +1,34 @@ +--- +outline: deep +--- + +# DF0068: Required Service Version Range Not Satisfied + +## Message + +> The installed service "`{package}`@`{installed}`" does not satisfy the required range "`{required}`". + +## Cause + +A service descriptor marked `required: true` declares a `version` range, and the version of the service that actually resolved falls outside it. The range is checked at the `ctx.services.ready()` barrier against the resolved definition's own `version`. + +Without `required`, the same mismatch installs the service anyway and warns with [`DF0069`](/errors/DF0069). + +## Example + +```ts +defineDevframe({ + services: [ + // ✗ Throws when @devframes/service-shiki@2.x is what's installed. + { package: '@devframes/service-shiki', version: '^1', required: true }, + ], +}) +``` + +## Fix + +Align the installed service package with the declared range (update whichever side is stale), or drop `required: true` to downgrade the mismatch to a warning — the advertised meta carries the real version, so clients can gate on it. + +## Source + +- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — the barrier flush checks each descriptor's `version` range against the resolved definition. diff --git a/docs/errors/DF0069.md b/docs/errors/DF0069.md new file mode 100644 index 00000000..aeb8ef6a --- /dev/null +++ b/docs/errors/DF0069.md @@ -0,0 +1,34 @@ +--- +outline: deep +--- + +# DF0069: Service Version Range Not Satisfied + +## Message + +> The installed service "`{package}`@`{installed}`" does not satisfy the declared range "`{required}`" — installing it anyway. + +## Cause + +A service descriptor declares a `version` range, and the version of the service that actually resolved falls outside it. Since the descriptor isn't marked `required`, the service still installs — the range acts as a compatibility hint, and this warning surfaces the drift. The advertised meta carries the real version, so client UIs can gate features on it. + +The `required: true` variant of the same mismatch throws [`DF0068`](/errors/DF0068) instead. + +## Example + +```ts +defineDevframe({ + services: [ + // Installed: @devframes/service-shiki@2.0.0 → warns, still installs. + { package: '@devframes/service-shiki', version: '^1' }, + ], +}) +``` + +## Fix + +Align the installed service package with the declared range to silence the warning, or widen the declared range when the newer service is actually fine. + +## Source + +- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — the barrier flush checks each descriptor's `version` range against the resolved definition. diff --git a/docs/errors/DF0070.md b/docs/errors/DF0070.md new file mode 100644 index 00000000..f90441c8 --- /dev/null +++ b/docs/errors/DF0070.md @@ -0,0 +1,36 @@ +--- +outline: deep +--- + +# DF0070: Invalid Service + +## Message + +> Invalid service "`{package}`": `{reason}` + +## Cause + +A wire service failed structural validation at install time. The `reason` names the specific gap: + +- the install input has no `package` name, +- a definition is missing its `version` or its RPC `scope` namespace, +- an imported service package's default export is not a factory function, +- the factory didn't return a definition with a `setup` function. + +## Example + +```ts +// ✗ A pre-built instance as the default export — not a factory. +export default createShikiService() + +// ✓ The factory itself. +export default createShikiService +``` + +## Fix + +A service package's default export must be its `createService` factory, returning a `DevframeServiceDefinition` — an object with `package`, `version`, `scope`, and a `setup` function. See [Cross-Plugin Services](/guide/services#wire-services) for the full shape. + +## Source + +- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — `install()` validates its input; the barrier flush validates imported factories and the definitions they return. diff --git a/docs/errors/DF0071.md b/docs/errors/DF0071.md new file mode 100644 index 00000000..847fb9d0 --- /dev/null +++ b/docs/errors/DF0071.md @@ -0,0 +1,28 @@ +--- +outline: deep +--- + +# DF0071: Deferred Service Installation Failed On Connect + +## Message + +> Deferred service installation failed while flushing on the first client connection: `{reason}` + +## Cause + +Queued wire-service installs are normally flushed by the host calling `ctx.services.ready()` once every devframe's setup has run — the first-party adapters (`initDevframe`, `createBuild`, `createCac`, `initHub`) all do. As a safety net, a host that never calls it still gets the flush right before the first client RPC connection is served. When that deferred flush fails (a `required` service missing, an unsatisfied version range, a throwing `setup`), the error can only be reported — a connection hook is no place to crash — so it surfaces as this diagnostic instead of a startup failure. + +## Fix + +Call `ctx.services.ready()` explicitly after every devframe's setup has run, so installation errors throw at startup where they can be acted on: + +```ts +await devframe.setup(ctx) +await ctx.services.ready() +``` + +The `reason` carries the underlying error (typically [`DF0067`](/errors/DF0067), [`DF0068`](/errors/DF0068), or a service `setup` failure) — fix that root cause as its own page describes. + +## Source + +- [`packages/devframe/src/node/rpc-core.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/rpc-core.ts) — `createContextRpcServer()`'s connect hook reports a failing deferred flush. diff --git a/docs/guide/client.md b/docs/guide/client.md index 8b101874..39e608f4 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -206,6 +206,17 @@ state.on('updated', (next) => { Client-side mutations round-trip through the server before reappearing locally. See [Shared State](./shared-state) for the full API. +## Services + +`rpc.services` mirrors the server's wire-service advertisements, so a UI feature-detects a shared capability and degrades when it is absent: + +```ts +if (rpc.services.has('@devframes/service-open')) + await rpc.services.get('@devframes/service-open')!.rpc.call('open-in-editor', { path }) +``` + +See [Cross-Plugin Services](./services#wire-services). + ## Settings 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: diff --git a/docs/guide/devframe-definition.md b/docs/guide/devframe-definition.md index 57038828..2a2bb31b 100644 --- a/docs/guide/devframe-definition.md +++ b/docs/guide/devframe-definition.md @@ -51,6 +51,7 @@ export default defineDevframe({ | `basePath` | `string` | Optional mount path override. Defaults depend on the adapter: `/` for standalone (`cli` / `build`), `/./` for hosted (`vite` / `embedded`). | | `duplicationStrategy` | `'warn' \| 'silent' \| 'throw' \| 'duplicate'` | How a hub reacts when another devframe sharing this `id` is mounted onto the same hub. Defaults to `'warn'`. See [Hub](./hub). Hub adapters consult it; standalone adapters ignore it. | | `capabilities` | `{ dev?, build? }` | Per-runtime feature flags. A `boolean` applies to the runtime as a whole; an object enables individual features. | +| `services` | `DevframeServiceInput[]` | Wire services this devframe consumes — descriptors (`{ package, version?, required?, options? }`) the adapter imports against the plugin's own dependencies, or ready definitions. See [Cross-Plugin Services](./services#wire-services). | | `setup` | `(ctx, info?) => void \| Promise` | **Required.** Server-side entry point. Runs in every runtime. The optional second argument carries runtime metadata — most notably the parsed CLI `flags` when running under `createCac`. | | `cli` | `DevframeCliOptions` | Defaults for the CLI adapter. See [CLI options](#cli-options) below. | diff --git a/docs/guide/services.md b/docs/guide/services.md index 66fabbe4..1fda7e79 100644 --- a/docs/guide/services.md +++ b/docs/guide/services.md @@ -8,6 +8,8 @@ outline: deep Every devframe mounted into the same host shares one context, so services registered by one `setup(ctx)` are visible to every other. +The registry has two tiers: in-process services (`provide`/`get`, this page's first half) hand live objects between plugins on the node side, and [wire services](#wire-services) additionally register RPC functions and advertise themselves to browser clients, so UIs can feature-detect a capability and degrade when it is absent. + ## Providing a service Augment the `DevframeServicesRegistry` interface with your service's id and type, then provide the implementation at setup time: @@ -63,9 +65,84 @@ interface DevframeServicesHost { has: (id) => boolean whenAvailable: (id, callback) => () => void keys: () => string[] + // wire-service tier + install: (input, options?) => Promise + ready: () => Promise +} +``` + +## Wire services + +A **wire service** is a shared server-side capability packaged as its own npm module — open-in-editor, syntax highlighting, anything several plugins would otherwise re-implement and re-bundle. A host installs it once; every plugin calls it in-process, every client calls it over RPC, and client UIs feature-detect it to fall back gracefully (hide the "open in editor" button, render un-highlighted code). + +### Shipping one + +A service package's default export is its factory, returning a `DevframeServiceDefinition`: + +```ts +export interface OpenServiceApi { + openInEditor: (input: { path: string, line?: number, column?: number }) => Promise +} + +export default function createOpenService(options?: OpenServiceOptions): DevframeServiceDefinition { + return { + package: '@devframes/service-open', // the registry key + version: '1.0.0', // advertised; checked against declared ranges + scope: 'devframes:service:open', // RPC namespace + options, + setup(ctx, { options }) { + // `ctx` is pre-scoped: this registers `devframes:service:open:open-in-editor` + ctx.rpc.register({ name: 'open-in-editor', handler: input => api.openInEditor(input) }) + return api // the node API served from ctx.services.get(package) + }, + } +} +``` + +Two declaration merges make it fully typed for consumers: the fully-qualified RPC ids go into `DevframeRpcServerFunctions`, and the package → scope mapping into `DevframeServicesScopeRegistry` (so a client's `services.get()` returns a scoped, typed RPC handle). + +### Installing + +A host with the factory at hand installs explicitly; a plugin declares what it consumes on its definition and the adapter resolves the package **against the plugin's own dependencies**: + +```ts +// host side (e.g. inside initHub's configure) +ctx.services.install(createShikiService({ themes })) + +// plugin side — declarative +defineDevframe({ + services: [ + { package: '@devframes/service-open' }, + { package: '@devframes/service-shiki', version: '^1', options: { langs: ['vue'] } }, + ], +}) +``` + +Entries are optional by default — a package that isn't installed is skipped and clients see `has() === false`. Mark an entry `required: true` to fail hard instead ([`DF0067`](https://devfra.me/errors/DF0067) on a missing package, [`DF0068`](https://devfra.me/errors/DF0068) on an unsatisfied `version` range; without it a range mismatch only warns with [`DF0069`](https://devfra.me/errors/DF0069)). + +Installs queue until the adapter fires the `ctx.services.ready()` barrier after every devframe's setup has run. There each service is constructed **once**, with the option sets from every declarer merged — through the definition's `mergeOptions` when it declares one, otherwise shallow-merged in declaration order, so a host installing last wins. After the barrier, installing an already-installed package returns the existing API and warns ([`DF0066`](https://devfra.me/errors/DF0066)) when its options had to be ignored. + +Server-side consumers get the node API from the same registry — `ctx.services.get('@devframes/service-open')` or `whenAvailable` — with no RPC hop. + +### Feature-detecting on the client + +Installed services are advertised through the `devframe:services` [shared state](./shared-state); the client mirrors it on `rpc.services`: + +```ts +const rpc = await connectDevframe() + +if (rpc.services.has('@devframes/service-open')) { + const open = rpc.services.get('@devframes/service-open')! + await open.rpc.call('open-in-editor', { path }) } + +// reactive UI: subscribe to the underlying shared state +const state = await rpc.services.state() +state.on('updated', render) ``` +`has()`/`get()`/`keys()` are synchronous snapshots of the advertisement — before the first sync lands they read as empty, and `get()` returns `undefined` rather than throwing, so the natural shape of consuming code is "render the fallback until the service appears". Each handle carries the advertised `version` and `meta` for finer gating. + ## Services, RPC, or shared state? Each mechanism covers a different direction of travel: @@ -74,4 +151,4 @@ Each mechanism covers a different direction of travel: - **[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. +A capability meant for *other plugins* belongs in a service; a capability meant for *UIs or agents* belongs in RPC. A capability meant for both — and shared across many plugins — is a [wire service](#wire-services), which combines all three: a node API for plugins, scoped RPC for clients, and a shared-state advertisement for feature-detection. diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 7a31cdc4..0a0b1dc0 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -5,9 +5,10 @@ import type { AgentTool, DevframeDefinition, DevframeHost, DevframeNodeContext } import { homedir } from 'node:os' import process from 'node:process' import { Server } from '@modelcontextprotocol/server' -import { createHostContext, installDefinitionServices } from 'devframe/node' +import { createHostContext } from 'devframe/node' import { toAgentToolName } from 'devframe/utils/agent-tool-name' import { join } from 'pathe' +import { installDefinitionServices } from '../../node/definition-services' import { diagnostics } from '../../node/diagnostics' import { formatMcpError, stringifyForMcp } from './stringify' import { argsToJsonSchema, returnToJsonSchema } from './to-json-schema' diff --git a/packages/devframe/src/client/index.ts b/packages/devframe/src/client/index.ts index ca9b656d..f16eb5bb 100644 --- a/packages/devframe/src/client/index.ts +++ b/packages/devframe/src/client/index.ts @@ -3,7 +3,7 @@ import { getDevframeRpcClient } from './rpc' export * from './connection' export * from './otp' export * from './rpc' -export * from './rpc-services' +export type { DevframeServiceClientHandle, DevframeServicesClient } from './rpc-services' export { resolveSseUrl } from './rpc-sse' export * from './rpc-streaming' export { resolveWsUrl, type WsUrlLocation } from './rpc-ws' diff --git a/packages/devframe/src/client/rpc-services.ts b/packages/devframe/src/client/rpc-services.ts index 0e8f1f03..8b3ec4cd 100644 --- a/packages/devframe/src/client/rpc-services.ts +++ b/packages/devframe/src/client/rpc-services.ts @@ -43,9 +43,12 @@ export interface DevframeServicesClient { state: () => Promise> } +/** @internal */ export function createDevframeServicesClient(rpc: DevframeRpcClient): DevframeServicesClient { let current: DevframeServicesState = {} - const handles = new Map() + // Handles are cached per advertisement entry so repeated `get()` reads + // return a stable object (immer only replaces an entry when it changed). + const handles = new WeakMap() let statePromise: Promise> | undefined const state = () => { @@ -72,18 +75,12 @@ export function createDevframeServicesClient(rpc: DevframeRpcClient): DevframeSe const entry = current[pkg] if (!entry) return undefined - let cached = handles.get(pkg) - if (!cached || cached.entry !== entry) { - cached = { - entry, - handle: { - ...entry, - rpc: rpc.scope(entry.scope).rpc, - }, - } - handles.set(pkg, cached) + let handle = handles.get(entry) + if (!handle) { + handle = { ...entry, rpc: rpc.scope(entry.scope).rpc } + handles.set(entry, handle) } - return cached.handle as DevframeServiceClientHandle> + return handle as DevframeServiceClientHandle> }, } } diff --git a/packages/devframe/src/internal/index.ts b/packages/devframe/src/internal/index.ts index bdcf5f5a..e7088884 100644 --- a/packages/devframe/src/internal/index.ts +++ b/packages/devframe/src/internal/index.ts @@ -32,9 +32,13 @@ // - `diagnostics` — devframe core's structured diagnostics instance // (`DF00xx`), so a first-party integration built outside this package can // report against the same registered codes instead of minting its own. +// - `installDefinitionServices` — queues a definition's declarative wire +// services ahead of its `setup`; a host that installs devframes itself +// (the hub's `installDevframe`) calls it exactly like the adapters do. export { normalizeBasePath, resolveBasePath } from '../adapters/_shared' export { coerceAgentPositionalArgs } from '../node/agent-args' export type { AgentArgsFallback } from '../node/agent-args' +export { installDefinitionServices } from '../node/definition-services' export { diagnostics } from '../node/diagnostics' export { DevframeAgentHost } from '../node/host-agent' export * from '../node/host-h3' diff --git a/packages/devframe/src/node/__tests__/services.test.ts b/packages/devframe/src/node/__tests__/services.test.ts index 56b874b0..ea4f50a3 100644 --- a/packages/devframe/src/node/__tests__/services.test.ts +++ b/packages/devframe/src/node/__tests__/services.test.ts @@ -132,7 +132,6 @@ describe('wire services (install / ready barrier)', () => { const second = ctx.services.install({ package: '@test/svc', options: { b: 2, c: 3 } }) expect(setup).not.toHaveBeenCalled() - expect(ctx.services.isReady).toBe(false) await ctx.services.ready() expect(setup).toHaveBeenCalledTimes(1) diff --git a/packages/devframe/src/node/host-services.ts b/packages/devframe/src/node/host-services.ts index 0b4c81a8..22d70384 100644 --- a/packages/devframe/src/node/host-services.ts +++ b/packages/devframe/src/node/host-services.ts @@ -4,7 +4,6 @@ import type { DevframeServiceDescriptor, DevframeServiceId, DevframeServiceInput, - DevframeServiceInstallOptions, DevframeServiceOf, DevframeServicesHost, DevframeServicesState, @@ -106,13 +105,9 @@ export class DevframeServicesHostImpl implements DevframeServicesHost { return Array.from(this.services.keys()) } - get isReady(): boolean { - return this.readyPromise !== undefined - } - install( input: DevframeServiceInput, - options?: DevframeServiceInstallOptions, + options?: { resolveFrom?: string | null }, ): Promise { validateServiceInput(input as DevframeServiceInput) const promise = new Promise((resolve, reject) => { diff --git a/packages/devframe/src/node/index.ts b/packages/devframe/src/node/index.ts index 56fe6bbc..c5fb151a 100644 --- a/packages/devframe/src/node/index.ts +++ b/packages/devframe/src/node/index.ts @@ -11,7 +11,6 @@ // host-URL helpers stay fully internal (relative imports only). // `toAgentToolName` lives at `devframe/utils/agent-tool-name`. export * from './context' -export * from './definition-services' // `RpcFunctionsHostImpl` stays internal; expose only the structural // `RpcFunctionsHost` type so consumers can type/cast `ctx.rpc` without // pulling in the implementation's `@internal` members. diff --git a/packages/devframe/src/node/scope.ts b/packages/devframe/src/node/scope.ts index 5cb22d29..7888606e 100644 --- a/packages/devframe/src/node/scope.ts +++ b/packages/devframe/src/node/scope.ts @@ -56,7 +56,6 @@ export function createScopedNodeContext( views: context.views, diagnostics: context.diagnostics, agent: context.agent, - services: context.services, scope: context.scope, } } diff --git a/packages/devframe/src/types/scope.ts b/packages/devframe/src/types/scope.ts index 598ddddf..238875c3 100644 --- a/packages/devframe/src/types/scope.ts +++ b/packages/devframe/src/types/scope.ts @@ -11,7 +11,6 @@ import type { RpcStreamingChannelOptions, } from './rpc' import type { DevframeRpcClientFunctions, DevframeRpcServerFunctions, DevframeRpcSharedStates } from './rpc-augments' -import type { DevframeServicesHost } from './services' import type { DevframeViewHost } from './views' // Callable guard so `Parameters` / `ReturnType` always have a function to @@ -219,8 +218,6 @@ export interface DevframeScopedNodeContext = PKG extends keyof DevframeServicesScop ? DevframeServicesScopeRegistry[PKG] & string : string -/** - * Runtime information threaded into a service definition's `setup`. - */ -export interface DevframeServiceSetupInfo { - /** - * The merged option sets contributed by every installer of this service - * (declarative descriptors and explicit definitions alike), merged at the - * `ready()` barrier — via the definition's own {@link DevframeServiceDefinition.mergeOptions} - * when present, otherwise shallow-merged in declaration order (later sets - * win). `undefined` when no installer passed options. - */ - options?: Options -} - /** * A **wire service** — a shared server-side capability (e.g. open-in-editor, * syntax highlighting) packaged so any devframe host can install it once and @@ -138,8 +124,11 @@ export interface DevframeServiceDefinition { * Construct the service: register its RPC functions on the pre-scoped * context and return its **node API** — the in-process surface other * plugins get from `ctx.services.get(package)` (no RPC hop server-side). + * `info.options` carries every installer's option sets merged at the + * `ready()` barrier (via {@link mergeOptions} when present, otherwise a + * shallow merge in declaration order — later sets win). */ - setup: (ctx: DevframeScopedNodeContext, info: DevframeServiceSetupInfo) => API | Promise + setup: (ctx: DevframeScopedNodeContext, info: { options?: Options }) => API | Promise } /** @@ -179,16 +168,6 @@ export interface DevframeServiceDescriptor { export type DevframeServiceInput = DevframeServiceDescriptor | DevframeServiceDefinition -export interface DevframeServiceInstallOptions { - /** - * Path or file URL (e.g. `import.meta.url`, or a resolved - * `/package.json` path) the descriptor's package is resolved - * **from** — so a plugin-declared service resolves against the plugin's - * own dependencies. Falls back to the context's `workspaceRoot`. - */ - resolveFrom?: string | null -} - /** * One service's advertisement entry, mirrored to clients through the * `devframe:services` shared state. @@ -240,10 +219,15 @@ export interface DevframeServicesHost { * After the barrier, installs construct immediately; installing an * already-installed package returns the existing API (a warning is * emitted when the late install carried options, since they're ignored). + * + * `resolveFrom` is the path or file URL (e.g. `import.meta.url`) a + * descriptor's package resolves **from**, so a plugin-declared service + * resolves against the plugin's own dependencies; it falls back to the + * context's `workspaceRoot`. */ install: ( input: DevframeServiceInput, - options?: DevframeServiceInstallOptions, + options?: { resolveFrom?: string | null }, ) => Promise /** * Fire the collect-then-setup barrier: resolve every queued descriptor @@ -255,6 +239,4 @@ export interface DevframeServicesHost { * service fails to import or misses its version range. */ ready: () => Promise - /** Whether the {@link ready} barrier has fired. */ - readonly isReady: boolean } diff --git a/packages/hub/src/node/install-devframe.ts b/packages/hub/src/node/install-devframe.ts index c1e109df..69a0138f 100644 --- a/packages/hub/src/node/install-devframe.ts +++ b/packages/hub/src/node/install-devframe.ts @@ -1,7 +1,7 @@ import type { DevframeDefinition } from 'devframe/types' import type { DevframeViewIframe } from '../types/docks' import type { DevframeHubContext } from './context' -import { installDefinitionServices } from 'devframe/node' +import { installDefinitionServices } from 'devframe/internal' import { resolveBasePath } from 'devframe/node/hub-internals' import { resolve } from 'pathe' import { diagnostics } from './diagnostics' diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index f4003420..089db729 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -158,7 +158,6 @@ export declare function authenticateWithUrlOtp(_: Pick; export declare function consumeOtpFromUrl(_?: string): string | undefined; export declare function createClientSettings = Record>(_: DevframeRpcClient, _: string): DevframeSettings; -export declare function createDevframeServicesClient(_: DevframeRpcClient): DevframeServicesClient; export declare function createRpcStreamingClientHost(_: DevframeRpcClient): RpcStreamingClientHost; export declare function createScopedClientContext(_: DevframeRpcClient, _: NS): DevframeScopedClientContext; export declare function getDevframeConnection(): DevframeConnection | undefined; diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js index 2b7c6eec..2e8597bd 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js @@ -13,7 +13,6 @@ export class DevframeConnectionError extends Error { export async function authenticateWithUrlOtp(_, _) {} export function consumeOtpFromUrl(_) {} export function createClientSettings(_, _) {} -export function createDevframeServicesClient(_) {} export function createRpcStreamingClientHost(_) {} export function createScopedClientContext(_, _) {} export function getDevframeConnection() {} diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 7614e9b0..dae2c311 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -273,7 +273,6 @@ export interface DevframeScopedNodeContext { @@ -306,7 +305,9 @@ export interface DevframeServiceDefinition { meta?: Record; options?: Options; mergeOptions?: (_: Options[]) => Options; - setup: (_: DevframeScopedNodeContext, _: DevframeServiceSetupInfo) => API | Promise; + setup: (_: DevframeScopedNodeContext, _: { + options?: Options; + }) => API | Promise; } export interface DevframeServiceDescriptor { package: string; @@ -314,27 +315,22 @@ export interface DevframeServiceDescriptor { required?: boolean; options?: Options; } -export interface DevframeServiceInstallOptions { - resolveFrom?: string | null; -} export interface DevframeServiceMeta { package: string; version: string; scope: string; meta?: Record; } -export interface DevframeServiceSetupInfo { - options?: Options; -} export interface DevframeServicesHost { provide: (_: ID, _: DevframeServiceOf) => () => void; get: (_: ID) => DevframeServiceOf | undefined; has: (_: DevframeServiceId) => boolean; whenAvailable: (_: ID, _: (_: DevframeServiceOf) => void) => () => void; keys: () => string[]; - install: (_: DevframeServiceInput, _?: DevframeServiceInstallOptions) => Promise; + install: (_: DevframeServiceInput, _?: { + resolveFrom?: string | null; + }) => Promise; ready: () => Promise; - readonly isReady: boolean; } export interface DevframeServicesRegistry {} export interface DevframeServicesScopeRegistry {} diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index 226c9c74..763d9ac8 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -50,6 +50,7 @@ export declare class DevframeAgentHost implements DevframeAgentHost$1 { export declare function coerceAgentPositionalArgs(_: unknown, _: readonly unknown[] | undefined, _?: AgentArgsFallback): unknown[]; export declare function createH3DevframeHost(_: CreateH3DevframeHostOptions): DevframeHost; export declare function createRpcWireCodec(_?: ReadonlyMap>): RpcWireCodec; +export declare function installDefinitionServices(_: DevframeNodeContext, _: DevframeDefinition): void; export declare function normalizeHttpServerUrl(_: string, _: number | string): string; export declare function peekRpcWireFrame(_: string): { t?: string; diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js index 861bcf1e..b8b80f05 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js @@ -9,6 +9,7 @@ export { createInstanceShell } export { createRpcWireCodec } export { DevframeAgentHost } export { diagnostics } +export { installDefinitionServices } export { listLiveDevframeInstances } export { normalizeBasePath } export { normalizeHttpServerUrl } diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index 1b9fd4dd..3e3e13bb 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -20,7 +20,6 @@ export interface CreateStorageOptions { // #region Functions export declare function createHostContext(_: CreateHostContextOptions): Promise; export declare function createStorage(_: CreateStorageOptions): SharedState; -export declare function installDefinitionServices(_: DevframeNodeContext, _: DevframeDefinition): void; // #endregion // #region Other diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js index 5436bcd6..faf693f7 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js @@ -4,5 +4,4 @@ // #region Other export { createHostContext } export { createStorage } -export { installDefinitionServices } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index c24cefe1..e7d177e4 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -43,11 +43,9 @@ export { DevframeServiceDefinition } export { DevframeServiceDescriptor } export { DevframeServiceId } export { DevframeServiceInput } -export { DevframeServiceInstallOptions } export { DevframeServiceMeta } export { DevframeServiceOf } export { DevframeServiceScopeOf } -export { DevframeServiceSetupInfo } export { DevframeServicesHost } export { DevframeServicesRegistry } export { DevframeServicesScopeRegistry } From dd7f48698243f70d00b2e6ba598a9ad36006b71c Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Tue, 18 Aug 2026 12:56:35 +0900 Subject: [PATCH 3/4] chore: update --- packages/devframe/src/adapters/build.ts | 2 +- packages/devframe/src/adapters/embedded.ts | 2 +- packages/devframe/src/adapters/initiate.ts | 4 +--- packages/devframe/src/adapters/mcp/build-server.ts | 2 +- packages/devframe/src/node/definition-services.ts | 5 ++--- packages/hub/src/node/install-devframe.ts | 2 +- 6 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/devframe/src/adapters/build.ts b/packages/devframe/src/adapters/build.ts index b3af3ccf..7d1f815d 100644 --- a/packages/devframe/src/adapters/build.ts +++ b/packages/devframe/src/adapters/build.ts @@ -89,7 +89,7 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt mode: 'build', host, }) - installDefinitionServices(ctx, d) + await installDefinitionServices(ctx, d) await d.setup(ctx) await ctx.services.ready() diff --git a/packages/devframe/src/adapters/embedded.ts b/packages/devframe/src/adapters/embedded.ts index 879440af..5598eec0 100644 --- a/packages/devframe/src/adapters/embedded.ts +++ b/packages/devframe/src/adapters/embedded.ts @@ -20,6 +20,6 @@ export async function createEmbedded(d: DevframeDefinition, options: CreateEmbed // Declarative services queue before setup; the owning host fires the // `ctx.services.ready()` barrier (post-barrier registration installs // immediately). - installDefinitionServices(options.ctx, d) + await installDefinitionServices(options.ctx, d) await d.setup(options.ctx) } diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 7980968b..6c2dc863 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -290,10 +290,8 @@ export function initDevframe( host: hostImpl, }) const setupInfo: DevframeSetupInfo = { flags: options.flags ?? {} } - installDefinitionServices(context, def) + await installDefinitionServices(context, def) await def.setup(context, setupInfo) - // Collect-then-setup barrier: every declared/queued wire service is - // constructed once, with its option sets merged across declarers. await context.services.ready() // Route-based MCP server (opt-in). Mounted before the SPA static diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 0a0b1dc0..74517e63 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -117,7 +117,7 @@ export async function createMcpServer( mode: 'dev', host, }) - installDefinitionServices(ctx, definition) + await installDefinitionServices(ctx, definition) await definition.setup(ctx) await ctx.services.ready() diff --git a/packages/devframe/src/node/definition-services.ts b/packages/devframe/src/node/definition-services.ts index 19180920..32174a59 100644 --- a/packages/devframe/src/node/definition-services.ts +++ b/packages/devframe/src/node/definition-services.ts @@ -33,10 +33,9 @@ function resolveDefinitionResolveFrom(def: DevframeDefinition, cwd: string): str * Installation itself happens at the `ctx.services.ready()` barrier the * adapter fires once every devframe's setup has run. */ -export function installDefinitionServices(context: DevframeNodeContext, def: DevframeDefinition): void { +export async function installDefinitionServices(context: DevframeNodeContext, def: DevframeDefinition): Promise { if (!def.services || def.services.length === 0) return const resolveFrom = resolveDefinitionResolveFrom(def, context.cwd) - for (const input of def.services) - void context.services.install(input, resolveFrom ? { resolveFrom } : {}) + await Promise.all(def.services.map(input => context.services.install(input, resolveFrom ? { resolveFrom } : {}))) } diff --git a/packages/hub/src/node/install-devframe.ts b/packages/hub/src/node/install-devframe.ts index 69a0138f..704737ab 100644 --- a/packages/hub/src/node/install-devframe.ts +++ b/packages/hub/src/node/install-devframe.ts @@ -112,6 +112,6 @@ export async function installDevframe( // their option sets precede setup-time installs in the merge order. The // hub fires the `ctx.services.ready()` barrier once every devframe (and // the host's own configuration) has installed. - installDefinitionServices(ctx, d) + await installDefinitionServices(ctx, d) await d.setup(ctx) } From 6ead8b60ae1bff6a792590309bd62f7b32fbd572 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 18 Aug 2026 04:06:26 +0000 Subject: [PATCH 4/4] refactor: fold definition-services into ctx.services.install() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install()'s resolveFrom now also accepts an npm package name (the declaring plugin's packageName), expanded to that package's location so its declared services resolve against the plugin's own dependencies — adapters and the hub queue def.services with a plain loop, and the installDefinitionServices helper (whose awaited form could deadlock pre-barrier) is gone. --- packages/devframe/src/adapters/build.ts | 4 +- packages/devframe/src/adapters/embedded.ts | 4 +- packages/devframe/src/adapters/initiate.ts | 6 ++- .../devframe/src/adapters/mcp/build-server.ts | 4 +- packages/devframe/src/internal/index.ts | 4 -- .../src/node/__tests__/services.test.ts | 14 +++++++ .../devframe/src/node/definition-services.ts | 41 ------------------- packages/devframe/src/node/host-services.ts | 8 ++-- .../devframe/src/node/services-install.ts | 25 ++++++++++- packages/devframe/src/types/services.ts | 9 ++-- packages/hub/src/node/install-devframe.ts | 4 +- .../tsnapi/devframe/internal.snapshot.d.ts | 1 - .../tsnapi/devframe/internal.snapshot.js | 1 - 13 files changed, 60 insertions(+), 65 deletions(-) delete mode 100644 packages/devframe/src/node/definition-services.ts diff --git a/packages/devframe/src/adapters/build.ts b/packages/devframe/src/adapters/build.ts index 7d1f815d..3f25857f 100644 --- a/packages/devframe/src/adapters/build.ts +++ b/packages/devframe/src/adapters/build.ts @@ -14,7 +14,6 @@ import { DEVFRAME_RPC_DUMP_MANIFEST_FILENAME, } from '../constants' import { createHostContext } from '../node/context' -import { installDefinitionServices } from '../node/definition-services' import { diagnostics } from '../node/diagnostics' import { createH3DevframeHost } from '../node/host-h3' import { collectStaticRpcDump } from '../rpc/dump/static' @@ -89,7 +88,8 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt mode: 'build', host, }) - await installDefinitionServices(ctx, d) + for (const input of d.services ?? []) + void ctx.services.install(input, { resolveFrom: d.packageName }) await d.setup(ctx) await ctx.services.ready() diff --git a/packages/devframe/src/adapters/embedded.ts b/packages/devframe/src/adapters/embedded.ts index 5598eec0..0fbf7be2 100644 --- a/packages/devframe/src/adapters/embedded.ts +++ b/packages/devframe/src/adapters/embedded.ts @@ -1,6 +1,5 @@ import type { DevframeNodeContext } from '../types/context' import type { DevframeDefinition } from '../types/devframe' -import { installDefinitionServices } from '../node/definition-services' export interface CreateEmbeddedOptions { /** Target context the devframe is registered into. Required. */ @@ -20,6 +19,7 @@ export async function createEmbedded(d: DevframeDefinition, options: CreateEmbed // Declarative services queue before setup; the owning host fires the // `ctx.services.ready()` barrier (post-barrier registration installs // immediately). - await installDefinitionServices(options.ctx, d) + for (const input of d.services ?? []) + void options.ctx.services.install(input, { resolveFrom: d.packageName }) await d.setup(options.ctx) } diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 6c2dc863..f8567c26 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -16,7 +16,6 @@ import { resolve } from 'pathe' import { joinURL } from 'ufo' import { DEVFRAME_CONNECTION_META_FILENAME } from '../constants' import { createHostContext } from '../node/context' -import { installDefinitionServices } from '../node/definition-services' import { diagnostics } from '../node/diagnostics' import { createH3DevframeHost } from '../node/host-h3' import { createInstanceShell, resolveInstanceRegister } from '../node/instance-shell' @@ -290,7 +289,10 @@ export function initDevframe( host: hostImpl, }) const setupInfo: DevframeSetupInfo = { flags: options.flags ?? {} } - await installDefinitionServices(context, def) + // Declarative services queue ahead of setup (their promises resolve at + // the ready() barrier below), resolving against the plugin's own deps. + for (const input of def.services ?? []) + void context.services.install(input, { resolveFrom: def.packageName }) await def.setup(context, setupInfo) await context.services.ready() diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 74517e63..37bd4bc5 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -8,7 +8,6 @@ import { Server } from '@modelcontextprotocol/server' import { createHostContext } from 'devframe/node' import { toAgentToolName } from 'devframe/utils/agent-tool-name' import { join } from 'pathe' -import { installDefinitionServices } from '../../node/definition-services' import { diagnostics } from '../../node/diagnostics' import { formatMcpError, stringifyForMcp } from './stringify' import { argsToJsonSchema, returnToJsonSchema } from './to-json-schema' @@ -117,7 +116,8 @@ export async function createMcpServer( mode: 'dev', host, }) - await installDefinitionServices(ctx, definition) + for (const input of definition.services ?? []) + void ctx.services.install(input, { resolveFrom: definition.packageName }) await definition.setup(ctx) await ctx.services.ready() diff --git a/packages/devframe/src/internal/index.ts b/packages/devframe/src/internal/index.ts index e7088884..bdcf5f5a 100644 --- a/packages/devframe/src/internal/index.ts +++ b/packages/devframe/src/internal/index.ts @@ -32,13 +32,9 @@ // - `diagnostics` — devframe core's structured diagnostics instance // (`DF00xx`), so a first-party integration built outside this package can // report against the same registered codes instead of minting its own. -// - `installDefinitionServices` — queues a definition's declarative wire -// services ahead of its `setup`; a host that installs devframes itself -// (the hub's `installDevframe`) calls it exactly like the adapters do. export { normalizeBasePath, resolveBasePath } from '../adapters/_shared' export { coerceAgentPositionalArgs } from '../node/agent-args' export type { AgentArgsFallback } from '../node/agent-args' -export { installDefinitionServices } from '../node/definition-services' export { diagnostics } from '../node/diagnostics' export { DevframeAgentHost } from '../node/host-agent' export * from '../node/host-h3' diff --git a/packages/devframe/src/node/__tests__/services.test.ts b/packages/devframe/src/node/__tests__/services.test.ts index ea4f50a3..af45b662 100644 --- a/packages/devframe/src/node/__tests__/services.test.ts +++ b/packages/devframe/src/node/__tests__/services.test.ts @@ -208,6 +208,20 @@ describe('wire services (install / ready barrier)', () => { await expect(ctx.services.ready()).rejects.toThrowError(/Failed to import the required service package/) }) + it('resolves a package-name resolveFrom through that package\'s own dependencies', async () => { + const { ctx, dir } = await createCtx() + // A "plugin" package whose own node_modules carries the service — the + // declarative flow passes the plugin's packageName as resolveFrom. + const pluginDir = join(dir, 'node_modules', '@test', 'plugin') + mkdirSync(pluginDir, { recursive: true }) + writeFileSync(join(pluginDir, 'package.json'), JSON.stringify({ name: '@test/plugin', version: '0.0.0' })) + writeFakeServicePackage(pluginDir, '@test/nested-svc', '1.0.0') + + const install = ctx.services.install({ package: '@test/nested-svc' }, { resolveFrom: '@test/plugin' }) + await ctx.services.ready() + await expect(install).resolves.toEqual({ imported: true, options: undefined }) + }) + it('imports a descriptor package relative to resolveFrom and installs its factory', async () => { const { ctx, dir } = await createCtx() writeFakeServicePackage(dir, '@test/imported-svc', '2.0.0') diff --git a/packages/devframe/src/node/definition-services.ts b/packages/devframe/src/node/definition-services.ts deleted file mode 100644 index 32174a59..00000000 --- a/packages/devframe/src/node/definition-services.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { DevframeNodeContext } from '../types/context' -import type { DevframeDefinition } from '../types/devframe' -import { createRequire } from 'node:module' -import { join } from 'pathe' - -/** - * Resolve the base path service imports should resolve **from** for a - * definition: the declaring plugin's own package (so a plugin-declared - * service resolves against the plugin's dependencies). Falls back to - * `undefined` when the plugin package isn't resolvable (e.g. an inline, - * unpublished definition) — the services host then resolves from the - * workspace root. - */ -function resolveDefinitionResolveFrom(def: DevframeDefinition, cwd: string): string | undefined { - if (!def.packageName) - return undefined - const require = createRequire(join(cwd, '_devframe_resolve.js')) - try { - return require.resolve(`${def.packageName}/package.json`) - } - catch {} - try { - return require.resolve(def.packageName) - } - catch {} - return undefined -} - -/** - * Queue a definition's declarative `services` on the context — called by - * every adapter (and a hub's install path) **before** `def.setup(ctx)` runs, - * so declarative option sets precede setup-time installs in the merge order. - * Installation itself happens at the `ctx.services.ready()` barrier the - * adapter fires once every devframe's setup has run. - */ -export async function installDefinitionServices(context: DevframeNodeContext, def: DevframeDefinition): Promise { - if (!def.services || def.services.length === 0) - return - const resolveFrom = resolveDefinitionResolveFrom(def, context.cwd) - await Promise.all(def.services.map(input => context.services.install(input, resolveFrom ? { resolveFrom } : {}))) -} diff --git a/packages/devframe/src/node/host-services.ts b/packages/devframe/src/node/host-services.ts index 22d70384..a5e4f98d 100644 --- a/packages/devframe/src/node/host-services.ts +++ b/packages/devframe/src/node/host-services.ts @@ -8,10 +8,11 @@ import type { DevframeServicesHost, DevframeServicesState, } from 'devframe/types' +import process from 'node:process' import { DEVFRAME_SERVICES_STATE_KEY } from 'devframe/constants' import { createDebug } from 'obug' import { diagnostics } from './diagnostics' -import { importServicePackage, satisfiesVersionRange, shallowMergeOptionSets } from './services-install' +import { expandResolveFrom, importServicePackage, satisfiesVersionRange, shallowMergeOptionSets } from './services-install' const debug = createDebug('devframe:services') @@ -184,10 +185,11 @@ export class DevframeServicesHostImpl implements DevframeServicesHost { if (!def) { const descriptors = entries.map(entry => entry.input as DevframeServiceDescriptor) const required = descriptors.some(descriptor => descriptor.required === true) + const cwd = this.context?.cwd ?? process.cwd() const resolveFroms = [ - ...entries.map(entry => entry.resolveFrom), + ...entries.map(entry => entry.resolveFrom && expandResolveFrom(entry.resolveFrom, cwd)), this.context?.workspaceRoot, - this.context?.cwd, + cwd, ] let mod: unknown try { diff --git a/packages/devframe/src/node/services-install.ts b/packages/devframe/src/node/services-install.ts index 5920fa47..00223556 100644 --- a/packages/devframe/src/node/services-install.ts +++ b/packages/devframe/src/node/services-install.ts @@ -1,6 +1,6 @@ import { createRequire } from 'node:module' import { pathToFileURL } from 'node:url' -import { join } from 'pathe' +import { isAbsolute, join } from 'pathe' /** * Turn a `resolveFrom` value (a file path, a file URL like `import.meta.url`, @@ -18,6 +18,29 @@ function toRequireBase(resolveFrom: string): string { return join(resolveFrom, '_devframe_resolve.js') } +/** + * Normalize an `install()` `resolveFrom` into a resolution base. Paths and + * file URLs pass through; a bare npm package name (the common case: the + * declaring plugin's `packageName`) resolves to that package's location from + * `cwd`, so a service it declares resolves against the plugin's own + * dependencies. An unresolvable package name reads as no base (the caller's + * workspace fallbacks apply). + */ +export function expandResolveFrom(resolveFrom: string, cwd: string): string | undefined { + if (resolveFrom.startsWith('file://') || resolveFrom.startsWith('.') || isAbsolute(resolveFrom)) + return resolveFrom + const require = createRequire(join(cwd, '_devframe_resolve.js')) + try { + return require.resolve(`${resolveFrom}/package.json`) + } + catch {} + try { + return require.resolve(resolveFrom) + } + catch {} + return undefined +} + /** * Import a service package's module, trying each `resolveFrom` candidate in * order (so a plugin-declared service resolves against the plugin's own diff --git a/packages/devframe/src/types/services.ts b/packages/devframe/src/types/services.ts index e71ccc48..c089b916 100644 --- a/packages/devframe/src/types/services.ts +++ b/packages/devframe/src/types/services.ts @@ -220,10 +220,11 @@ export interface DevframeServicesHost { * already-installed package returns the existing API (a warning is * emitted when the late install carried options, since they're ignored). * - * `resolveFrom` is the path or file URL (e.g. `import.meta.url`) a - * descriptor's package resolves **from**, so a plugin-declared service - * resolves against the plugin's own dependencies; it falls back to the - * context's `workspaceRoot`. + * `resolveFrom` is where a descriptor's package resolves **from**: a path + * or file URL (e.g. `import.meta.url`), or an npm package name — typically + * the declaring plugin's `packageName`, so its declared services resolve + * against the plugin's own dependencies. Falls back to the context's + * `workspaceRoot`. */ install: ( input: DevframeServiceInput, diff --git a/packages/hub/src/node/install-devframe.ts b/packages/hub/src/node/install-devframe.ts index 704737ab..dc092b68 100644 --- a/packages/hub/src/node/install-devframe.ts +++ b/packages/hub/src/node/install-devframe.ts @@ -1,7 +1,6 @@ import type { DevframeDefinition } from 'devframe/types' import type { DevframeViewIframe } from '../types/docks' import type { DevframeHubContext } from './context' -import { installDefinitionServices } from 'devframe/internal' import { resolveBasePath } from 'devframe/node/hub-internals' import { resolve } from 'pathe' import { diagnostics } from './diagnostics' @@ -112,6 +111,7 @@ export async function installDevframe( // their option sets precede setup-time installs in the merge order. The // hub fires the `ctx.services.ready()` barrier once every devframe (and // the host's own configuration) has installed. - await installDefinitionServices(ctx, d) + for (const input of d.services ?? []) + void ctx.services.install(input, { resolveFrom: d.packageName }) await d.setup(ctx) } diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index 763d9ac8..226c9c74 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -50,7 +50,6 @@ export declare class DevframeAgentHost implements DevframeAgentHost$1 { export declare function coerceAgentPositionalArgs(_: unknown, _: readonly unknown[] | undefined, _?: AgentArgsFallback): unknown[]; export declare function createH3DevframeHost(_: CreateH3DevframeHostOptions): DevframeHost; export declare function createRpcWireCodec(_?: ReadonlyMap>): RpcWireCodec; -export declare function installDefinitionServices(_: DevframeNodeContext, _: DevframeDefinition): void; export declare function normalizeHttpServerUrl(_: string, _: number | string): string; export declare function peekRpcWireFrame(_: string): { t?: string; diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js index b8b80f05..861bcf1e 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js @@ -9,7 +9,6 @@ export { createInstanceShell } export { createRpcWireCodec } export { DevframeAgentHost } export { diagnostics } -export { installDefinitionServices } export { listLiveDevframeInstances } export { normalizeBasePath } export { normalizeHttpServerUrl }