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
3 changes: 3 additions & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join, relative } from 'pathe'
const root = fileURLToPath(new URL('.', import.meta.url))
const r = (path: string) => fileURLToPath(new URL(`./packages/${path}`, import.meta.url))
const p = (path: string) => fileURLToPath(new URL(`./plugins/${path}`, import.meta.url))
const s = (path: string) => fileURLToPath(new URL(`./services/${path}`, import.meta.url))

export const alias = {
'devframe/rpc/transports/sse-client': r('devframe/src/rpc/transports/sse-client.ts'),
Expand Down Expand Up @@ -131,6 +132,8 @@ export const alias = {
'@devframes/plugin-assets/cli': p('assets/src/cli.ts'),
'@devframes/plugin-assets/vite': p('assets/src/vite.ts'),
'@devframes/plugin-assets': p('assets/src/index.ts'),
'@devframes/service-open': s('open/src/index.ts'),
'@devframes/service-shiki': s('shiki/src/index.ts'),
}

// update tsconfig.base.json - CSS aliases exist for Vite resolution only;
Expand Down
20 changes: 9 additions & 11 deletions docs/errors/DF0066.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,27 @@ outline: deep

## 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.
Wire services are deduplicated by npm package name: the first installation wins, and later installs of the same package return the existing node API. Declared services are constructed once **before setup runs**, deep-merging every declarer's options. Calling `ctx.services.install()` for an already-constructed package — the dynamic escape hatch used after that point — can no longer influence its configuration, so any options it carries 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 }))
// The package is already declared (and constructed pre-setup) elsewhere.
// ✗ This late install can't merge; { themes } is ignored.
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:
Declare the service so its options join the pre-setup merge — on the plugin's `DevframeDefinition.services`, or host-wide via `initHub({ services })`:

```ts
await initHub({
async configure(ctx) {
ctx.services.install(createShikiService({ themes })) // ✓ merges
},
initHub({
services: [createShikiService({ themes })], // ✓ merges before setup
devframes: [/* … */],
})
```

## 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.
- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — `installPackage` warns when an already-installed package is installed again.
6 changes: 3 additions & 3 deletions docs/errors/DF0067.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ outline: deep

## 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.
A service descriptor marked `required: true` names a package that could not be resolved and imported when services are constructed before setup. 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`.

Expand All @@ -19,7 +19,7 @@ Descriptors without `required` degrade instead: the missing service is skipped a
```ts
defineDevframe({
services: [
// ✗ Throws at the ready() barrier when the package isn't installed.
// ✗ Throws before setup when the package isn't installed.
{ package: '@devframes/service-shiki', required: true },
],
})
Expand All @@ -31,4 +31,4 @@ Install the service package next to whoever declares it — a plugin declaring i

## 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.
- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — the pre-setup construction throws when a `required` descriptor's package fails to import.
4 changes: 2 additions & 2 deletions docs/errors/DF0068.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ outline: deep

## 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`.
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 when services are constructed before setup against the resolved definition's own `version`.

Without `required`, the same mismatch installs the service anyway and warns with [`DF0069`](/errors/DF0069).

Expand All @@ -31,4 +31,4 @@ Align the installed service package with the declared range (update whichever si

## 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.
- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — the pre-setup construction checks each descriptor's `version` range against the resolved definition.
2 changes: 1 addition & 1 deletion docs/errors/DF0069.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@ Align the installed service package with the declared range to silence the warni

## 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.
- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — the pre-setup construction checks each descriptor's `version` range against the resolved definition.
2 changes: 1 addition & 1 deletion docs/errors/DF0070.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@ A service package's default export must be its `create<X>Service` factory, retur

## 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.
- [`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 pre-setup construction validates imported factories and the definitions they return.
28 changes: 0 additions & 28 deletions docs/errors/DF0071.md

This file was deleted.

27 changes: 20 additions & 7 deletions docs/guide/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,30 +101,37 @@ export default function createOpenService(options?: OpenServiceOptions): Devfram

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
### Declaring

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** the base for that resolution is the definition's [`importMetaUrl`](./devframe-definition#resolving-against-the-plugins-own-dependencies), so a plugin ships a service package as its own dependency and users install nothing extra:
Services are **declarative**. A plugin lists what it consumes on its definition; a host lists shared ones on `initHub`. The adapter resolves each package — for a plugin, **against the plugin's own dependencies** via the definition's [`importMetaUrl`](./devframe-definition#resolving-against-the-plugins-own-dependencies), so a plugin ships a service package as its own dependency and users install nothing extra — and constructs it:

```ts
// host side (e.g. inside initHub's configure)
ctx.services.install(createShikiService({ themes }))

// plugin side — declarative
// 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'] } },
],
})

// host side — shared services on initHub
initHub({
services: [createShikiService({ themes })],
devframes: [/* … */],
})
```

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.
### Lifecycle: ready before setup

Services are constructed and made ready **before any `setup(ctx)` runs**. The hub collects every declared service (across all devframes plus `initHub`), constructs each **once** — deep-merging the option sets from every declarer (objects recurse, arrays union-dedupe, scalars take the later value; a service may override with its own `mergeOptions`) — and only then runs the setups. So `setup(ctx)` can consume a service synchronously via `ctx.services.get(pkg)`, including one another devframe declared.

Server-side consumers get the node API from the same registry — `ctx.services.get('@devframes/service-open')` or `whenAvailable` — with no RPC hop.

Declarative covers the common case. For a service whose configuration is only known at runtime, `ctx.services.install(input)` is the dynamic escape hatch: after the pre-setup construction it builds immediately; re-installing an already-constructed package returns the existing API and warns ([`DF0066`](https://devfra.me/errors/DF0066)) if it carried options that can no longer merge.

### Feature-detecting on the client

Installed services are advertised through the `devframe:services` [shared state](./shared-state); the client mirrors it on `rpc.services`:
Expand All @@ -144,6 +151,12 @@ 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.

### Built-in services

**`@devframes/service-open`** (`devframes:service:open`) opens files in the user's editor (`open-in-editor`, with optional `line`/`column`) or reveals them in the OS file explorer (`open-in-finder`). Paths may be absolute or relative to the workspace root (so a client with only a workspace-relative path — a message's file position, say — calls it directly); the service refuses anything outside the workspace root and the configured extra `roots` (`DS_OPEN_0002`), and gates editor commands to the `KNOWN_EDITORS` picklist. Options: `{ editor?, roots? }` — the preferred editor (later installer wins) and additional openable directories (merged as a union). It supersedes the per-plugin `devframe/recipes/common-rpc-functions` registrations, now deprecated.

**`@devframes/service-shiki`** (`devframes:service:shiki`) renders [Shiki](https://shiki.style) syntax highlighting on the server, so plugin bundles stop shipping grammars and themes. Three RPC queries — `highlight` (dual-theme HTML), `code-to-hast`, and `code-to-tokens` (for renderers that own their DOM, e.g. diff views) — all client-`cacheable` and LRU-cached server-side per `(code, lang, themes)`. Unknown languages degrade to plain text. Options: `{ themes?, langs? }` — the default light/dark pair (defaults `vitesse-light`/`vitesse-dark`, matching the design system; later installer wins) and languages to eagerly load (merged as a union).

## Services, RPC, or shared state?

Each mechanism covers a different direction of travel:
Expand Down
3 changes: 3 additions & 0 deletions docs/helpers/common-rpc-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ outline: deep

# Common RPC Functions

> [!WARNING]
> Deprecated in favor of the [`@devframes/service-open` wire service](/guide/services#built-in-services) — one host-level installation shared by every plugin, feature-detectable from clients, with workspace-root path containment on top of the editor gating. The recipe keeps working; removal lands in a future major.

Prebuilt RPC actions for the two file-system actions every CLI devtool needs — opening a file in the editor, revealing a path in the OS file explorer. Use the recipe instead of re-implementing them so every devframe converges on the same registered names and payload shape.

```ts
Expand Down
4 changes: 2 additions & 2 deletions docs/plugins/assets.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,12 @@ All functions are namespaced `devframes:plugin:assets:*`:
| `list` | `query`, `snapshot: true` | Every file under the managed directory, with type, size, and last-modified time. |
| `capabilities` | `query`, `snapshot: true` | Whether write actions are enabled, and the upload allow-list — lets the UI gate itself proactively. |
| `read-image-meta` | `query` | Width, height, and orientation for an image asset. |
| `read-text` | `query` | Truncated text content, for preview. |
| `read-text` | `query` | Truncated text content, for preview. When the host advertises the [`@devframes/service-shiki` wire service](/guide/services#built-in-services), the panel renders it server-highlighted; otherwise it falls back to a plain `<pre>`. |
| `upload` | `action` | Allocates a streaming upload slot; the client pipes the file's bytes over the paired channel. |
| `rename` | `action` | Renames an asset within its folder, preserving its extension. |
| `delete` | `action` | Deletes one or more assets in a single call. |
| `mkdir` | `action` | Creates a folder, including missing parents. |
| `open-in-editor` / `reveal-in-folder` | `action` | Launch the asset in your editor, or reveal its containing folder in the OS file manager. Always registered, regardless of `write`. |
| `open-in-editor` / `reveal-in-folder` | `action` | Launch the asset in your editor, or reveal its containing folder in the OS file manager, delegating to the [`@devframes/service-open` wire service](/guide/services#built-in-services) (installed by the plugin with the managed dir as an allowed root). Always registered, regardless of `write`. |

`upload` / `rename` / `delete` / `mkdir` are registered only when `write` is enabled.

Expand Down
6 changes: 4 additions & 2 deletions knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
// default export is its `create<X>Devframe` factory") - `export default
// createXDevframe` right below `export function createXDevframe(...) {}`.
// Deliberate, not an accidental duplicate: the named export is for options,
// the default export is the conventional single-devframe import.
// the default export is the conventional single-devframe import. Service
// packages (`create<X>Service`) follow the same rule.
"ignoreIssues": {
"plugins/*/src/index.ts": ["duplicates"]
"plugins/*/src/index.ts": ["duplicates"],
"services/*/src/index.ts": ["duplicates"]
},
"workspaces": {
".": {
Expand Down
3 changes: 2 additions & 1 deletion packages/devframe/src/adapters/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,11 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt
host,
importMetaUrl: d.importMetaUrl,
})
// Services ready before setup, so setup can consume them synchronously.
for (const input of d.services ?? [])
void ctx.services.install(input, { resolveFrom: d.importMetaUrl })
await d.setup(ctx)
await ctx.services.ready()
await d.setup(ctx)

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

Expand Down
7 changes: 4 additions & 3 deletions packages/devframe/src/adapters/embedded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ 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).
// Services ready before setup. `ready()` is idempotent: on an
// already-running host it's a no-op and the fresh installs construct
// immediately; on a not-yet-started one it fires the initial barrier.
for (const input of d.services ?? [])
void options.ctx.services.install(input, { resolveFrom: d.importMetaUrl })
await options.ctx.services.ready()
await d.setup(options.ctx)
}
6 changes: 3 additions & 3 deletions packages/devframe/src/adapters/initiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,12 +290,12 @@ export function initDevframe(
importMetaUrl: def.importMetaUrl,
})
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.
// Wire services are constructed and made ready BEFORE setup, so
// `setup(ctx)` can consume them synchronously (`ctx.services.get`).
for (const input of def.services ?? [])
void context.services.install(input, { resolveFrom: def.importMetaUrl })
await def.setup(context, setupInfo)
await context.services.ready()
await def.setup(context, setupInfo)

// 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
Loading
Loading