Skip to content

Commit ea78f24

Browse files
antfubotantfu
andauthored
feat: services - installable, client-advertised shared capabilities (#256)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent f9577fd commit ea78f24

36 files changed

Lines changed: 1524 additions & 16 deletions

docs/errors/DF0066.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0066: Service Already Installed
6+
7+
## Message
8+
9+
> Service "`{package}`" is already installed — keeping the first installation and ignoring this one's options.
10+
11+
## Cause
12+
13+
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.
14+
15+
## Example
16+
17+
```ts
18+
await ctx.services.ready()
19+
20+
// ✗ The service is already constructed; { themes } is ignored.
21+
await ctx.services.install(createShikiService({ themes }))
22+
```
23+
24+
## Fix
25+
26+
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:
27+
28+
```ts
29+
await initHub({
30+
async configure(ctx) {
31+
ctx.services.install(createShikiService({ themes })) // ✓ merges
32+
},
33+
})
34+
```
35+
36+
## Source
37+
38+
- [`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.

docs/errors/DF0067.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0067: Required Service Package Not Importable
6+
7+
## Message
8+
9+
> Failed to import the required service package "`{package}`": `{reason}`
10+
11+
## Cause
12+
13+
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.
14+
15+
Descriptors without `required` degrade instead: the missing service is skipped and clients observe `services.has(pkg) === false`.
16+
17+
## Example
18+
19+
```ts
20+
defineDevframe({
21+
services: [
22+
// ✗ Throws at the ready() barrier when the package isn't installed.
23+
{ package: '@devframes/service-shiki', required: true },
24+
],
25+
})
26+
```
27+
28+
## Fix
29+
30+
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.
31+
32+
## Source
33+
34+
- [`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.

docs/errors/DF0068.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0068: Required Service Version Range Not Satisfied
6+
7+
## Message
8+
9+
> The installed service "`{package}`@`{installed}`" does not satisfy the required range "`{required}`".
10+
11+
## Cause
12+
13+
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`.
14+
15+
Without `required`, the same mismatch installs the service anyway and warns with [`DF0069`](/errors/DF0069).
16+
17+
## Example
18+
19+
```ts
20+
defineDevframe({
21+
services: [
22+
// ✗ Throws when @devframes/service-shiki@2.x is what's installed.
23+
{ package: '@devframes/service-shiki', version: '^1', required: true },
24+
],
25+
})
26+
```
27+
28+
## Fix
29+
30+
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.
31+
32+
## Source
33+
34+
- [`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.

docs/errors/DF0069.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0069: Service Version Range Not Satisfied
6+
7+
## Message
8+
9+
> The installed service "`{package}`@`{installed}`" does not satisfy the declared range "`{required}`" — installing it anyway.
10+
11+
## Cause
12+
13+
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.
14+
15+
The `required: true` variant of the same mismatch throws [`DF0068`](/errors/DF0068) instead.
16+
17+
## Example
18+
19+
```ts
20+
defineDevframe({
21+
services: [
22+
// Installed: @devframes/service-shiki@2.0.0 → warns, still installs.
23+
{ package: '@devframes/service-shiki', version: '^1' },
24+
],
25+
})
26+
```
27+
28+
## Fix
29+
30+
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.
31+
32+
## Source
33+
34+
- [`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.

docs/errors/DF0070.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0070: Invalid Service
6+
7+
## Message
8+
9+
> Invalid service "`{package}`": `{reason}`
10+
11+
## Cause
12+
13+
A wire service failed structural validation at install time. The `reason` names the specific gap:
14+
15+
- the install input has no `package` name,
16+
- a definition is missing its `version` or its RPC `scope` namespace,
17+
- an imported service package's default export is not a factory function,
18+
- the factory didn't return a definition with a `setup` function.
19+
20+
## Example
21+
22+
```ts
23+
// ✗ A pre-built instance as the default export — not a factory.
24+
export default createShikiService()
25+
26+
// ✓ The factory itself.
27+
export default createShikiService
28+
```
29+
30+
## Fix
31+
32+
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.
33+
34+
## Source
35+
36+
- [`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.

docs/errors/DF0071.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0071: Deferred Service Installation Failed On Connect
6+
7+
## Message
8+
9+
> Deferred service installation failed while flushing on the first client connection: `{reason}`
10+
11+
## Cause
12+
13+
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.
14+
15+
## Fix
16+
17+
Call `ctx.services.ready()` explicitly after every devframe's setup has run, so installation errors throw at startup where they can be acted on:
18+
19+
```ts
20+
await devframe.setup(ctx)
21+
await ctx.services.ready()
22+
```
23+
24+
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.
25+
26+
## Source
27+
28+
- [`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.

docs/guide/client.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,17 @@ state.on('updated', (next) => {
206206

207207
Client-side mutations round-trip through the server before reappearing locally. See [Shared State](./shared-state) for the full API.
208208

209+
## Services
210+
211+
`rpc.services` mirrors the server's wire-service advertisements, so a UI feature-detects a shared capability and degrades when it is absent:
212+
213+
```ts
214+
if (rpc.services.has('@devframes/service-open'))
215+
await rpc.services.get('@devframes/service-open')!.rpc.call('open-in-editor', { path })
216+
```
217+
218+
See [Cross-Plugin Services](./services#wire-services).
219+
209220
## Settings
210221

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

docs/guide/devframe-definition.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export default defineDevframe({
5151
| `basePath` | `string` | Optional mount path override. Defaults depend on the adapter: `/` for standalone (`cli` / `build`), `/.<id>/` for hosted (`vite` / `embedded`). |
5252
| `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. |
5353
| `capabilities` | `{ dev?, build? }` | Per-runtime feature flags. A `boolean` applies to the runtime as a whole; an object enables individual features. |
54+
| `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). |
5455
| `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`. |
5556
| `cli` | `DevframeCliOptions` | Defaults for the CLI adapter. See [CLI options](#cli-options) below. |
5657

docs/guide/services.md

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ outline: deep
88

99
Every devframe mounted into the same host shares one context, so services registered by one `setup(ctx)` are visible to every other.
1010

11+
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.
12+
1113
## Providing a service
1214

1315
Augment the `DevframeServicesRegistry` interface with your service's id and type, then provide the implementation at setup time:
@@ -63,9 +65,84 @@ interface DevframeServicesHost {
6365
has: (id) => boolean
6466
whenAvailable: (id, callback) => () => void
6567
keys: () => string[]
68+
// wire-service tier
69+
install: (input, options?) => Promise<api | undefined>
70+
ready: () => Promise<void>
71+
}
72+
```
73+
74+
## Wire services
75+
76+
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).
77+
78+
### Shipping one
79+
80+
A service package's default export is its factory, returning a `DevframeServiceDefinition`:
81+
82+
```ts
83+
export interface OpenServiceApi {
84+
openInEditor: (input: { path: string, line?: number, column?: number }) => Promise<void>
85+
}
86+
87+
export default function createOpenService(options?: OpenServiceOptions): DevframeServiceDefinition<OpenServiceApi, OpenServiceOptions> {
88+
return {
89+
package: '@devframes/service-open', // the registry key
90+
version: '1.0.0', // advertised; checked against declared ranges
91+
scope: 'devframes:service:open', // RPC namespace
92+
options,
93+
setup(ctx, { options }) {
94+
// `ctx` is pre-scoped: this registers `devframes:service:open:open-in-editor`
95+
ctx.rpc.register({ name: 'open-in-editor', handler: input => api.openInEditor(input) })
96+
return api // the node API served from ctx.services.get(package)
97+
},
98+
}
99+
}
100+
```
101+
102+
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).
103+
104+
### Installing
105+
106+
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**:
107+
108+
```ts
109+
// host side (e.g. inside initHub's configure)
110+
ctx.services.install(createShikiService({ themes }))
111+
112+
// plugin side — declarative
113+
defineDevframe({
114+
services: [
115+
{ package: '@devframes/service-open' },
116+
{ package: '@devframes/service-shiki', version: '^1', options: { langs: ['vue'] } },
117+
],
118+
})
119+
```
120+
121+
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)).
122+
123+
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.
124+
125+
Server-side consumers get the node API from the same registry — `ctx.services.get('@devframes/service-open')` or `whenAvailable` — with no RPC hop.
126+
127+
### Feature-detecting on the client
128+
129+
Installed services are advertised through the `devframe:services` [shared state](./shared-state); the client mirrors it on `rpc.services`:
130+
131+
```ts
132+
const rpc = await connectDevframe()
133+
134+
if (rpc.services.has('@devframes/service-open')) {
135+
const open = rpc.services.get('@devframes/service-open')!
136+
await open.rpc.call('open-in-editor', { path })
66137
}
138+
139+
// reactive UI: subscribe to the underlying shared state
140+
const state = await rpc.services.state()
141+
state.on('updated', render)
67142
```
68143

144+
`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.
145+
69146
## Services, RPC, or shared state?
70147

71148
Each mechanism covers a different direction of travel:
@@ -74,4 +151,4 @@ Each mechanism covers a different direction of travel:
74151
- **[RPC](./rpc)** — browser-to-node: a client invokes a named function over the connection.
75152
- **[Shared state](./shared-state)** — data synchronized between node and every connected client; values must serialize.
76153

77-
A capability meant for *other plugins* belongs in a service; a capability meant for *UIs or agents* belongs in RPC.
154+
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.

packages/devframe/src/adapters/build.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt
8888
mode: 'build',
8989
host,
9090
})
91+
for (const input of d.services ?? [])
92+
void ctx.services.install(input, { resolveFrom: d.packageName })
9193
await d.setup(ctx)
94+
await ctx.services.ready()
9295

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

0 commit comments

Comments
 (0)