Cross-Plugin Services
ctx.services lets one integration expose a typed, namespaced capability visible to every devframe. Two tiers: in-process services (provide/get) share live objects between plugins; wire services also register RPC and advertise to clients.
Providing a service
Augment DevframeServicesRegistry with your id and type, then provide in setup:
export interface SourcesService {
register: (entry: SourceEntry) => () => void
}
declare module 'devframe' {
interface DevframeServicesRegistry {
'my-plugin:sources': SourcesService
}
}
export function setup(ctx: DevframeNodeContext) {
ctx.services.provide('my-plugin:sources', createSourcesService())
}Service ids prefix the provider's id (<plugin-id>:<service>), unique per context — a second provide() under a taken id throws DF0037. provide() returns a revoke; guard idempotent setup with has(id).
Consuming a service
A consumer loads it with a types-only import (import type {} from '@my-org/my-plugin'), then reaches it in setup:
ctx.services.whenAvailable('my-plugin:sources', (sources) => {
sources.register({ id: 'other-plugin:state', data: () => state })
})Prefer whenAvailable over get: it fires immediately if provided, else on provide, and re-fires on revoke/re-provide. get(id) returns the implementation or undefined (ids without an augmentation as unknown).
The host surface
DevframeServicesHost exposes provide(id, service) => revoke, get(id), has(id), whenAvailable(id, cb) => unsubscribe, and keys(); plus a wire tier install(input, options?) => Promise<api | undefined> and ready() => Promise<void>.
Wire services
A wire service is a shared server-side capability, an npm module.
Shipping one
A service package's default export is a factory returning DevframeServiceDefinition:
export default function createOpenService(options?: OpenServiceOptions): DevframeServiceDefinition<OpenServiceApi, OpenServiceOptions> {
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 type it: RPC ids into DevframeRpcServerFunctions, package → scope into DevframeServicesScopeRegistry.
Declaring
Services are declarative: a plugin lists what it consumes; a host, shared ones on initHub. The adapter resolves each package — for a plugin, against its own dependencies via importMetaUrl.
// plugin side — on the definition
defineDevframe({
importMetaUrl: import.meta.url, // resolution base for the declared packages
services: [
{ package: '@devframes/service-open' },
{ package: '@devframes/service-shiki', version: '^1', options: { langs: ['vue'] } },
],
})A host lists shared, constructed services on initHub({ services: [createShikiService(opts)] }).
Entries are optional; uninstalled packages are skipped (has() === false). Mark one required: true to fail hard: DF0067 on a missing package, DF0068 on an unsatisfied version range (otherwise a range mismatch only warns, DF0069).
Lifecycle: ready before setup
The hub constructs every declared service (all devframes plus initHub) once — deep-merging option sets (objects recurse, arrays union-dedupe, scalars later-win; override with mergeOptions) — before any setup(ctx) runs, so setup consumes services synchronously via ctx.services.get(pkg).
For a runtime-only service, ctx.services.install(input) builds immediately; re-installing a constructed package returns the existing API, warning DF0066 if options can't merge.
Feature-detecting on the client
Installed services are advertised via devframe:services shared state, mirrored on the client's rpc.services:
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 })
}A reactive UI subscribes via rpc.services.state(). has()/get()/keys() are synchronous snapshots, empty before the first sync; each handle carries the version and meta.
Built-in services
@devframes/service-open (devframes:service:open) opens files in an editor (open-in-editor, optional line/column) or OS explorer (open-in-finder), refusing paths outside the workspace root plus extra roots (DS_OPEN_0002), gating editors to KNOWN_EDITORS. Options { editor?, roots? } (later wins; dirs union-merged).
@devframes/service-git (devframes:service:git) runs typed git ops — status, log, show, readFile, diff, branches, tags, stage, unstage, commit — on one repo fixed at install ({ cwd? }). Write ops are exposed; authorization is the host's boundary.
@devframes/service-shiki (devframes:service:shiki) renders Shiki highlighting server-side via three RPC queries — highlight (dual-theme HTML), code-to-hast, code-to-tokens — all client-cacheable, LRU-cached per (code, lang, themes). Options { themes?, langs? } — light/dark pair (defaults vitesse-light/vitesse-dark; later wins) and preloaded languages (union-merged).
Services, RPC, or shared state?
- Services — node-to-node in-process live references, never crossing a wire.
- RPC — browser-to-node: a client calls a named function.
- Shared state — serializable data synced node↔clients.
A service serves other plugins, RPC UIs or agents, a wire service both.