Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/errors/DF0066.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions docs/errors/DF0067.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions docs/errors/DF0068.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions docs/errors/DF0069.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions docs/errors/DF0070.md
Original file line number Diff line number Diff line change
@@ -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 `create<X>Service` 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.
28 changes: 28 additions & 0 deletions docs/errors/DF0071.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions docs/guide/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/guide/devframe-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export default defineDevframe({
| `basePath` | `string` | Optional mount path override. Defaults depend on the adapter: `/` for standalone (`cli` / `build`), `/.<id>/` 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<void>` | **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. |

Expand Down
79 changes: 78 additions & 1 deletion docs/guide/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -63,9 +65,84 @@ interface DevframeServicesHost {
has: (id) => boolean
whenAvailable: (id, callback) => () => void
keys: () => string[]
// wire-service tier
install: (input, options?) => Promise<api | undefined>
ready: () => Promise<void>
}
```

## 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<void>
}

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 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:
Expand All @@ -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.
3 changes: 3 additions & 0 deletions packages/devframe/src/adapters/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,10 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt
mode: 'build',
host,
})
for (const input of d.services ?? [])
void ctx.services.install(input, { resolveFrom: d.packageName })
await d.setup(ctx)
await ctx.services.ready()

await fs.mkdir(resolve(outDir, DEVFRAME_RPC_DUMP_DIRNAME), { recursive: true })

Expand Down
5 changes: 5 additions & 0 deletions packages/devframe/src/adapters/embedded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,10 @@ export interface CreateEmbeddedOptions {
* effective default follows the hosted rule of `def.basePath ?? '/__<id>/'`.
*/
export async function createEmbedded(d: DevframeDefinition, options: CreateEmbeddedOptions): Promise<void> {
// Declarative services queue before setup; the owning host fires the
// `ctx.services.ready()` barrier (post-barrier registration installs
// immediately).
for (const input of d.services ?? [])
void options.ctx.services.install(input, { resolveFrom: d.packageName })
await d.setup(options.ctx)
}
5 changes: 5 additions & 0 deletions packages/devframe/src/adapters/initiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,12 @@ export function initDevframe(
host: hostImpl,
})
const setupInfo: DevframeSetupInfo = { flags: options.flags ?? {} }
// 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()

// Route-based MCP server (opt-in). Mounted before the SPA static
// catch-all so the exact `<base>__mcp` route wins, and advertised in
Expand Down
3 changes: 3 additions & 0 deletions packages/devframe/src/adapters/mcp/build-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ export async function createMcpServer(
mode: 'dev',
host,
})
for (const input of definition.services ?? [])
void ctx.services.install(input, { resolveFrom: definition.packageName })
await definition.setup(ctx)
await ctx.services.ready()

const { server, dispose } = buildMcpServerFromContext(ctx, {
serverName: options.serverName ?? `${definition.id} (devframe)`,
Expand Down
1 change: 1 addition & 0 deletions packages/devframe/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getDevframeRpcClient } from './rpc'
export * from './connection'
export * from './otp'
export * from './rpc'
export type { DevframeServiceClientHandle, DevframeServicesClient } from './rpc-services'
export { resolveSseUrl } from './rpc-sse'
export * from './rpc-streaming'
export { resolveWsUrl, type WsUrlLocation } from './rpc-ws'
Expand Down
Loading
Loading