Skip to content

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:

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

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

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

Service ids 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:

ts
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:

ts
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.

ts
// 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:

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 })
}

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.

Released under the MIT License.