From 8e0b4c15327049a906ba0d49369232f0d99e7f4d Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 18 Aug 2026 06:24:05 +0000 Subject: [PATCH 1/5] feat: @devframes/service-open and @devframes/service-shiki wire services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first two wire-service packages, under a new services/* workspace glob: - @devframes/service-open (devframes:service:open) — open-in-editor / open-in-finder shared by every plugin: absolute paths only, contained to the workspace root plus configured extra roots, editor commands gated to KNOWN_EDITORS; options { editor?, roots? } merge across installers (editor later-wins, roots union). Supersedes the per-plugin recipes registrations, now marked deprecated. - @devframes/service-shiki (devframes:service:shiki) — server-side syntax highlighting so plugins stop re-bundling highlighters: highlight (dual light/dark HTML), code-to-hast, code-to-tokens; all cacheable + LRU-cached per (code, lang, themes); unknown languages degrade to plain text; shiki loads lazily on first use; options { themes?, langs? } merge (themes later-wins, langs union). Both ship their factory as the default export and contribute typed declaration merges (RPC ids, node API, package→scope), so server and client consumers are fully typed with zero shipped client code. --- alias.ts | 3 + docs/guide/services.md | 6 + knip.jsonc | 6 +- .../src/recipes/common-rpc-functions.ts | 10 + pnpm-lock.yaml | 147 +++++++++++-- pnpm-workspace.yaml | 2 + services/open/package.json | 46 +++++ services/open/src/diagnostics.ts | 18 ++ services/open/src/index.ts | 137 ++++++++++++ services/open/test/service.test.ts | 102 +++++++++ services/open/tsconfig.json | 9 + services/open/tsdown.config.ts | 8 + services/shiki/package.json | 50 +++++ services/shiki/src/index.ts | 195 ++++++++++++++++++ services/shiki/test/service.test.ts | 88 ++++++++ services/shiki/tsconfig.json | 9 + services/shiki/tsdown.config.ts | 8 + .../service-open/index.snapshot.d.ts | 35 ++++ .../@devframes/service-open/index.snapshot.js | 16 ++ .../service-shiki/index.snapshot.d.ts | 45 ++++ .../service-shiki/index.snapshot.js | 17 ++ .../common-rpc-functions.snapshot.d.ts | 3 + .../recipes/common-rpc-functions.snapshot.js | 3 + tsconfig.base.json | 6 + turbo.json | 10 + vitest.config.ts | 2 + 26 files changed, 961 insertions(+), 20 deletions(-) create mode 100644 services/open/package.json create mode 100644 services/open/src/diagnostics.ts create mode 100644 services/open/src/index.ts create mode 100644 services/open/test/service.test.ts create mode 100644 services/open/tsconfig.json create mode 100644 services/open/tsdown.config.ts create mode 100644 services/shiki/package.json create mode 100644 services/shiki/src/index.ts create mode 100644 services/shiki/test/service.test.ts create mode 100644 services/shiki/tsconfig.json create mode 100644 services/shiki/tsdown.config.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/service-open/index.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/service-open/index.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/service-shiki/index.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/service-shiki/index.snapshot.js diff --git a/alias.ts b/alias.ts index 839d87a0..70c63c3b 100644 --- a/alias.ts +++ b/alias.ts @@ -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'), @@ -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; diff --git a/docs/guide/services.md b/docs/guide/services.md index 1fda7e79..753a85ea 100644 --- a/docs/guide/services.md +++ b/docs/guide/services.md @@ -143,6 +143,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`). Callers pass **absolute** paths; 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: diff --git a/knip.jsonc b/knip.jsonc index e0aeb1c6..da8168ec 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -16,9 +16,11 @@ // default export is its `createDevframe` 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 (`createService`) follow the same rule. "ignoreIssues": { - "plugins/*/src/index.ts": ["duplicates"] + "plugins/*/src/index.ts": ["duplicates"], + "services/*/src/index.ts": ["duplicates"] }, "workspaces": { ".": { diff --git a/packages/devframe/src/recipes/common-rpc-functions.ts b/packages/devframe/src/recipes/common-rpc-functions.ts index 60f8b9fd..87b5ce62 100644 --- a/packages/devframe/src/recipes/common-rpc-functions.ts +++ b/packages/devframe/src/recipes/common-rpc-functions.ts @@ -98,6 +98,10 @@ export const KNOWN_EDITORS: KnownEditor[] = [ * }, * }) * ``` + * + * @deprecated Use the `@devframes/service-open` wire service instead — one + * host-level installation shared by every plugin and feature-detectable from + * clients, with workspace-root path containment on top of the editor gating. */ export const openInEditor = defineRpcFunction({ name: 'devframe:open-in-editor', @@ -121,6 +125,10 @@ export const openInEditor = defineRpcFunction({ * * ctx.rpc.register(openInFinder) * ``` + * + * @deprecated Use the `@devframes/service-open` wire service instead — one + * host-level installation shared by every plugin and feature-detectable from + * clients, with workspace-root path containment. */ export const openInFinder = defineRpcFunction({ name: 'devframe:open-in-finder', @@ -143,5 +151,7 @@ export const openInFinder = defineRpcFunction({ * * commonRpcFunctions.forEach(fn => ctx.rpc.register(fn)) * ``` + * + * @deprecated Use the `@devframes/service-open` wire service instead. */ export const commonRpcFunctions = [openInEditor, openInFinder] as const diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26a8090d..2d86edab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ catalogs: perfect-debounce: specifier: ^2.1.0 version: 2.1.0 + shiki: + specifier: ^4.4.3 + version: 4.4.3 structured-clone-es: specifier: ^2.0.1 version: 2.0.1 @@ -1786,7 +1789,7 @@ importers: version: 1.2.17 '@pierre/diffs': specifier: catalog:frontend - version: 1.2.12(@shikijs/themes@4.3.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.2.12(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-scroll-area': specifier: catalog:frontend version: 1.2.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2141,6 +2144,40 @@ importers: plugins/terminals/assets-pkg: {} + services/open: + devDependencies: + '@types/node': + specifier: catalog:types + version: 26.2.0 + devframe: + specifier: workspace:* + version: link:../../packages/devframe + tsdown: + specifier: catalog:build + version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(oxc-resolver@11.24.2)(tsx@4.23.12)(typescript@6.0.3) + vitest: + specifier: catalog:testing + version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0)) + + services/shiki: + dependencies: + shiki: + specifier: catalog:deps + version: 4.4.3 + devDependencies: + '@types/node': + specifier: catalog:types + version: 26.2.0 + devframe: + specifier: workspace:* + version: link:../../packages/devframe + tsdown: + specifier: catalog:build + version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(oxc-resolver@11.24.2)(tsx@4.23.12)(typescript@6.0.3) + vitest: + specifier: catalog:testing + version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0)) + storybook: dependencies: '@antfu/design': @@ -4925,6 +4962,10 @@ packages: resolution: {integrity: sha512-StyzbAyxg2/tBGf78gwbBkGyeQ73lf8UiJArFaQhTQIDqQOCKPCQFanvrs4/Yv3Yfyc+ONInJM6K+FMIf+P+kA==} engines: {node: '>=20'} + '@shikijs/core@4.4.3': + resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} + engines: {node: '>=20'} + '@shikijs/engine-javascript@4.3.1': resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==} engines: {node: '>=20'} @@ -4933,6 +4974,10 @@ packages: resolution: {integrity: sha512-MnIkeqWdVPUWsxlx8gKLVCJFTsqrQJgpTPBPpQwaFeJ56lOnJxj5aN2LUFnfxUEcvOQuNocmbaMnVrCEln6rkw==} engines: {node: '>=20'} + '@shikijs/engine-javascript@4.4.3': + resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} + engines: {node: '>=20'} + '@shikijs/engine-oniguruma@4.3.1': resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==} engines: {node: '>=20'} @@ -4941,6 +4986,10 @@ packages: resolution: {integrity: sha512-GLhowz1+jixjz+wiZ3wMnOn1jTxiFCGl2PkXufivbnwPHKuyw1AYqu5/hbWhZZ2oAb0NP05WUJhYeigY14drnw==} engines: {node: '>=20'} + '@shikijs/engine-oniguruma@4.4.3': + resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} + engines: {node: '>=20'} + '@shikijs/langs@4.3.1': resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==} engines: {node: '>=20'} @@ -4949,6 +4998,10 @@ packages: resolution: {integrity: sha512-8DfeusD+Zdv/eYIDdXyJTUnSMHt+aAWjAOCXV20HNGAHRlInXpG8wh421v6B91WOm9TFwRLN+b/LG5F2NAIojg==} engines: {node: '>=20'} + '@shikijs/langs@4.4.3': + resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} + engines: {node: '>=20'} + '@shikijs/primitive@4.3.1': resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==} engines: {node: '>=20'} @@ -4957,6 +5010,10 @@ packages: resolution: {integrity: sha512-l6fQQKsOMlz72n38fztmSgZ76MO6KSWuw8o+GJ+FhmqrpC9pIOJNQNXGgbb5yX2AwpzlEHwsaLPnk/8o4Fm+rA==} engines: {node: '>=20'} + '@shikijs/primitive@4.4.3': + resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} + engines: {node: '>=20'} + '@shikijs/themes@4.3.1': resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==} engines: {node: '>=20'} @@ -4965,6 +5022,10 @@ packages: resolution: {integrity: sha512-H0CFoL07ddDC2Dd6EdrPYNkRhUR6YCkJlnuYFceYYUJJA5TIm2b5B33qqiDYryBExgbKMndFJPb2u1gTuqO37g==} engines: {node: '>=20'} + '@shikijs/themes@4.4.3': + resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} + engines: {node: '>=20'} + '@shikijs/transformers@4.3.1': resolution: {integrity: sha512-z6ir0bGDgWcF2FduktEfPgIsdOtIlDiLAjFBgBzE42Q9xHbkkIXZtORHzlLVB71iZP9elEcqKg6keajvOUwE2A==} engines: {node: '>=20'} @@ -4981,6 +5042,10 @@ packages: resolution: {integrity: sha512-PFYitV4vpDr/iPCIhnHp+Q4ftic5N5VeNJ3KQ1O8gn3h2ar8qgwMAXF7tq4m1CWaMS60fV4VqF6vfnWH4F7vqQ==} engines: {node: '>=20'} + '@shikijs/types@4.4.3': + resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==} + engines: {node: '>=20'} + '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -5362,9 +5427,6 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} @@ -9309,6 +9371,10 @@ packages: resolution: {integrity: sha512-P8F/dFhRevaw2uSdeIYlq/5SXZNY85DPtmXQ947gD1Zj2JqO5AkNvVVBar0Me9JkFx3uzVud/qOtP5ek9NEQGA==} engines: {node: '>=20'} + shiki@4.4.3: + resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} + engines: {node: '>=20'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -12616,10 +12682,10 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6 - '@pierre/diffs@1.2.12(@shikijs/themes@4.3.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@pierre/diffs@1.2.12(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@pierre/theme': 1.1.0 - '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.3.1) + '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.3.1) '@shikijs/transformers': 4.3.1 diff: 9.0.0 hast-util-to-html: 9.0.5 @@ -12632,10 +12698,10 @@ snapshots: '@pierre/theme@1.1.0': {} - '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.3.1)': + '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.3.1)': optionalDependencies: '@pierre/theme': 1.1.0 - '@shikijs/themes': 4.3.1 + '@shikijs/themes': 4.4.3 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) shiki: 4.3.1 @@ -13101,7 +13167,7 @@ snapshots: '@shikijs/primitive': 4.3.1 '@shikijs/types': 4.3.1 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 '@shikijs/core@4.4.2': @@ -13112,6 +13178,14 @@ snapshots: '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 + '@shikijs/core@4.4.3': + dependencies: + '@shikijs/primitive': 4.4.3 + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + '@shikijs/engine-javascript@4.3.1': dependencies: '@shikijs/types': 4.3.1 @@ -13124,6 +13198,12 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.6 + '@shikijs/engine-javascript@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + '@shikijs/engine-oniguruma@4.3.1': dependencies: '@shikijs/types': 4.3.1 @@ -13134,6 +13214,11 @@ snapshots: '@shikijs/types': 4.4.2 '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/engine-oniguruma@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/langs@4.3.1': dependencies: '@shikijs/types': 4.3.1 @@ -13142,6 +13227,10 @@ snapshots: dependencies: '@shikijs/types': 4.4.2 + '@shikijs/langs@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/primitive@4.3.1': dependencies: '@shikijs/types': 4.3.1 @@ -13154,6 +13243,12 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 + '@shikijs/primitive@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + '@shikijs/themes@4.3.1': dependencies: '@shikijs/types': 4.3.1 @@ -13162,6 +13257,10 @@ snapshots: dependencies: '@shikijs/types': 4.4.2 + '@shikijs/themes@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/transformers@4.3.1': dependencies: '@shikijs/core': 4.3.1 @@ -13175,13 +13274,18 @@ snapshots: '@shikijs/types@4.3.1': dependencies: '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@shikijs/types@4.4.2': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 + '@shikijs/types@4.4.3': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + '@shikijs/vscode-textmate@10.0.2': {} '@simple-git/args-pathspec@1.0.3': {} @@ -13649,10 +13753,6 @@ snapshots: '@types/geojson@7946.0.16': {} - '@types/hast@3.0.4': - dependencies: - '@types/unist': 3.0.3 - '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 @@ -16055,7 +16155,7 @@ snapshots: hast-util-to-html@9.0.5: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 ccount: 2.0.1 comma-separated-tokens: 2.0.3 @@ -16069,7 +16169,7 @@ snapshots: hast-util-whitespace@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 he@1.2.0: {} @@ -16655,7 +16755,7 @@ snapshots: mdast-util-to-hast@13.2.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@ungap/structured-clone': 1.3.1 devlop: 1.1.0 @@ -18450,7 +18550,7 @@ snapshots: '@shikijs/themes': 4.3.1 '@shikijs/types': 4.3.1 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 shiki@4.4.2: dependencies: @@ -18463,6 +18563,17 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 + shiki@4.4.3: + dependencies: + '@shikijs/core': 4.4.3 + '@shikijs/engine-javascript': 4.4.3 + '@shikijs/engine-oniguruma': 4.4.3 + '@shikijs/langs': 4.4.3 + '@shikijs/themes': 4.4.3 + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + siginfo@2.0.0: {} signal-exit@3.0.7: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f9d14f0a..2220e11e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -27,6 +27,7 @@ packages: - packages/* - plugins/* - plugins/*/assets-pkg + - services/* - examples/* - storybook - docs @@ -99,6 +100,7 @@ catalogs: parse5: ^8.0.1 pathe: ^2.0.3 perfect-debounce: ^2.1.0 + shiki: ^4.4.3 structured-clone-es: ^2.0.1 tinyexec: ^1.3.0 tinyglobby: ^0.2.17 diff --git a/services/open/package.json b/services/open/package.json new file mode 100644 index 00000000..a2464c10 --- /dev/null +++ b/services/open/package.json @@ -0,0 +1,46 @@ +{ + "name": "@devframes/service-open", + "type": "module", + "version": "0.9.0", + "description": "Devframe wire service that opens files in the user's editor or reveals them in the OS file explorer.", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe#readme", + "repository": { + "directory": "services/open", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devframe", + "devframe-service", + "devtools", + "open-in-editor" + ], + "sideEffects": false, + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "watch": "tsdown --watch", + "prepack": "turbo run build --filter=@devframes/service-open", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "devframe": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:types", + "devframe": "workspace:*", + "tsdown": "catalog:build", + "vitest": "catalog:testing" + } +} diff --git a/services/open/src/diagnostics.ts b/services/open/src/diagnostics.ts new file mode 100644 index 00000000..c755271c --- /dev/null +++ b/services/open/src/diagnostics.ts @@ -0,0 +1,18 @@ +import { defineDiagnostics } from 'devframe/utils/nostics' + +// Uses the service's own `DS_OPEN_` prefix per the built-in convention, +// keeping it collision-free with devframe core (`DF00xx`), the hub +// (`DF8xxx`), and the plugins (`DP__`). +export const diagnostics = defineDiagnostics({ + docsBase: 'https://devfra.me/errors', + codes: { + DS_OPEN_0001: { + why: (p: { path: string }) => `Refusing to open "${p.path}": the path is not absolute.`, + fix: 'Resolve the path on the caller side (e.g. against the workspace root or your plugin\'s managed directory) before calling the open service.', + }, + DS_OPEN_0002: { + why: (p: { path: string }) => `Refusing to open "${p.path}": the path is outside the workspace root and every configured extra root.`, + fix: 'The open service only touches files under the workspace root by default. Pass additional allowed directories via the service\'s `roots` option when your tool manages files elsewhere (e.g. a global storage dir).', + }, + }, +}) diff --git a/services/open/src/index.ts b/services/open/src/index.ts new file mode 100644 index 00000000..7f006104 --- /dev/null +++ b/services/open/src/index.ts @@ -0,0 +1,137 @@ +import type { KnownEditor } from 'devframe/recipes/common-rpc-functions' +import type { DevframeServiceDefinition } from 'devframe/types' +import { defineRpcFunction } from 'devframe' +import { KNOWN_EDITORS } from 'devframe/recipes/common-rpc-functions' +import { s } from 'devframe/utils/simple-schema' +import { isAbsolute, relative, resolve } from 'pathe' +import pkg from '../package.json' with { type: 'json' } +import { diagnostics } from './diagnostics' + +export const OPEN_SERVICE_PACKAGE = '@devframes/service-open' +export const OPEN_SERVICE_SCOPE = 'devframes:service:open' + +export interface OpenServiceOptions { + /** + * Preferred editor command — one of the `KNOWN_EDITORS` `launch-editor` + * recognizes. Auto-detected (via `LAUNCH_EDITOR` and common defaults) + * when omitted. On merge, the later installer's choice wins. + */ + editor?: KnownEditor + /** + * Additional directories files may be opened from, on top of the + * context's `workspaceRoot` — e.g. a plugin's managed storage dir that + * lives outside the workspace. Merged as a union across installers. + */ + roots?: string[] +} + +export interface OpenInEditorInput { + /** Absolute path of the file to open. */ + path: string + line?: number + column?: number + /** Per-call editor override (one of `KNOWN_EDITORS`). */ + editor?: KnownEditor +} + +export interface OpenServiceApi { + /** Open a file (optionally at a line/column) in the user's editor. */ + openInEditor: (input: OpenInEditorInput) => Promise + /** Reveal a path in the OS file explorer. */ + openInFinder: (input: { path: string }) => Promise +} + +declare module 'devframe' { + interface DevframeRpcServerFunctions { + 'devframes:service:open:open-in-editor': (input: OpenInEditorInput) => Promise + 'devframes:service:open:open-in-finder': (input: { path: string }) => Promise + } + interface DevframeServicesRegistry { + '@devframes/service-open': OpenServiceApi + } + interface DevframeServicesScopeRegistry { + '@devframes/service-open': 'devframes:service:open' + } +} + +/** + * The open wire service — `open-in-editor` / `open-in-finder` RPC shared by + * every plugin on the host, replacing per-plugin registrations of the + * (deprecated) `devframe/recipes/common-rpc-functions` recipes. Callers pass + * **absolute** paths; the service refuses paths outside the workspace root + * and the configured extra {@link OpenServiceOptions.roots} (`DS_OPEN_0002`), + * and gates editor commands to the `KNOWN_EDITORS` picklist so the RPC + * surface can't spawn arbitrary commands. + */ +export function createOpenService(options?: OpenServiceOptions): DevframeServiceDefinition { + return { + package: OPEN_SERVICE_PACKAGE, + version: pkg.version, + scope: OPEN_SERVICE_SCOPE, + options, + mergeOptions: sets => sets.reduce((merged, set) => ({ + ...merged, + ...set, + roots: [...new Set([...(merged.roots ?? []), ...(set.roots ?? [])])], + }), {}), + setup(ctx, { options }) { + const allowedRoots = [ctx.workspaceRoot, ...(options?.roots ?? [])].map(root => resolve(root)) + + /** Absolute + contained in one of the allowed roots, or throws. */ + function assertAllowedPath(path: string): string { + if (!isAbsolute(path)) + throw diagnostics.DS_OPEN_0001({ path }) + const resolved = resolve(path) + const contained = allowedRoots.some((root) => { + const rel = relative(root, resolved) + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) + }) + if (!contained) + throw diagnostics.DS_OPEN_0002({ path }) + return resolved + } + + const api: OpenServiceApi = { + async openInEditor(input) { + const path = assertAllowedPath(input.path) + const target = input.line != null + ? `${path}:${input.line}${input.column != null ? `:${input.column}` : ''}` + : path + const { launchEditor } = await import('devframe/utils/launch-editor') + launchEditor(target, input.editor ?? options?.editor) + }, + async openInFinder(input) { + const path = assertAllowedPath(input.path) + const { open } = await import('devframe/utils/open') + await open(path) + }, + } + + ctx.rpc.register(defineRpcFunction({ + name: 'open-in-editor', + type: 'action', + jsonSerializable: true, + args: [s.object({ + path: s.string(), + line: s.optional(s.number()), + column: s.optional(s.number()), + editor: s.optional(s.picklist(KNOWN_EDITORS)), + })], + returns: s.void(), + handler: input => api.openInEditor(input), + })) + ctx.rpc.register(defineRpcFunction({ + name: 'open-in-finder', + type: 'action', + jsonSerializable: true, + args: [s.object({ path: s.string() })], + returns: s.void(), + handler: input => api.openInFinder(input), + })) + + return api + }, + } +} + +export default createOpenService diff --git a/services/open/test/service.test.ts b/services/open/test/service.test.ts new file mode 100644 index 00000000..1608a1c4 --- /dev/null +++ b/services/open/test/service.test.ts @@ -0,0 +1,102 @@ +import type { DevframeHost } from 'devframe/types' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHostContext } from 'devframe/node' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createOpenService } from '../src/index' + +const launchEditor = vi.fn() +const open = vi.fn() +vi.mock('devframe/utils/launch-editor', () => ({ launchEditor: (...args: unknown[]) => launchEditor(...args) })) +vi.mock('devframe/utils/open', () => ({ open: async (...args: unknown[]) => open(...args) })) + +const tempDirs: string[] = [] + +afterEach(() => { + vi.clearAllMocks() + for (const dir of tempDirs.splice(0)) + rmSync(dir, { recursive: true, force: true }) +}) + +function createTestHost(dir: string): DevframeHost { + return { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost', + getStorageDir: scope => join(dir, scope), + } +} + +async function createCtx() { + const dir = mkdtempSync(join(tmpdir(), 'devframe-service-open-')) + tempDirs.push(dir) + const ctx = await createHostContext({ cwd: dir, mode: 'dev', host: createTestHost(dir) }) + return { ctx, dir } +} + +function invoke(ctx: Awaited>['ctx'], method: string, ...args: unknown[]) { + return (ctx.rpc.invokeLocal as (method: string, ...args: unknown[]) => Promise)(method, ...args) +} + +describe('@devframes/service-open', () => { + it('registers scoped RPC and opens contained files with line/column', async () => { + const { ctx, dir } = await createCtx() + const install = ctx.services.install(createOpenService()) + await ctx.services.ready() + const api = await install + + await invoke(ctx, 'devframes:service:open:open-in-editor', { path: join(dir, 'src/a.ts'), line: 3, column: 7 }) + expect(launchEditor).toHaveBeenCalledWith(`${join(dir, 'src/a.ts')}:3:7`, undefined) + + await api!.openInFinder({ path: join(dir, 'src') }) + expect(open).toHaveBeenCalledWith(join(dir, 'src')) + }) + + it('prefers the per-call editor over the merged option', async () => { + const { ctx, dir } = await createCtx() + void ctx.services.install(createOpenService({ editor: 'code' })) + await ctx.services.ready() + + await invoke(ctx, 'devframes:service:open:open-in-editor', { path: join(dir, 'a.ts') }) + expect(launchEditor).toHaveBeenLastCalledWith(join(dir, 'a.ts'), 'code') + + await invoke(ctx, 'devframes:service:open:open-in-editor', { path: join(dir, 'a.ts'), editor: 'zed' }) + expect(launchEditor).toHaveBeenLastCalledWith(join(dir, 'a.ts'), 'zed') + }) + + it('refuses relative paths and paths outside the allowed roots', async () => { + const { ctx } = await createCtx() + const install = ctx.services.install(createOpenService()) + await ctx.services.ready() + const api = await install + + await expect(api!.openInEditor({ path: 'src/a.ts' })).rejects.toThrowError(/not absolute/) + await expect(api!.openInFinder({ path: '/etc/passwd' })).rejects.toThrowError(/outside the workspace root/) + expect(launchEditor).not.toHaveBeenCalled() + expect(open).not.toHaveBeenCalled() + }) + + it('merges roots as a union so extra directories become openable', async () => { + const { ctx } = await createCtx() + const extra = mkdtempSync(join(tmpdir(), 'devframe-service-open-extra-')) + tempDirs.push(extra) + void ctx.services.install(createOpenService({ roots: [extra] })) + void ctx.services.install({ package: '@devframes/service-open', options: { editor: 'zed' } }) + await ctx.services.ready() + + await invoke(ctx, 'devframes:service:open:open-in-editor', { path: join(extra, 'b.ts') }) + // Union kept the first installer's roots; later editor option won. + expect(launchEditor).toHaveBeenCalledWith(join(extra, 'b.ts'), 'zed') + }) + + it('rejects unknown editor commands at the RPC boundary', async () => { + const { ctx, dir } = await createCtx() + void ctx.services.install(createOpenService()) + await ctx.services.ready() + + await expect( + invoke(ctx, 'devframes:service:open:open-in-editor', { path: join(dir, 'a.ts'), editor: 'rm -rf /' }), + ).rejects.toThrow() + expect(launchEditor).not.toHaveBeenCalled() + }) +}) diff --git a/services/open/tsconfig.json b/services/open/tsconfig.json new file mode 100644 index 00000000..25652292 --- /dev/null +++ b/services/open/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext", "dom"], + "types": ["node"] + }, + "include": ["src", "test", "tsdown.config.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/services/open/tsdown.config.ts b/services/open/tsdown.config.ts new file mode 100644 index 00000000..03714bc1 --- /dev/null +++ b/services/open/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + platform: 'node', + tsconfig: '../../tsconfig.base.json', + outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }), + entry: { index: 'src/index.ts' }, +}) diff --git a/services/shiki/package.json b/services/shiki/package.json new file mode 100644 index 00000000..e8ac370a --- /dev/null +++ b/services/shiki/package.json @@ -0,0 +1,50 @@ +{ + "name": "@devframes/service-shiki", + "type": "module", + "version": "0.9.0", + "description": "Devframe wire service that renders Shiki syntax highlighting on the server, so plugins stop re-bundling highlighters.", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe#readme", + "repository": { + "directory": "services/shiki", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devframe", + "devframe-service", + "devtools", + "shiki", + "syntax-highlighting" + ], + "sideEffects": false, + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "watch": "tsdown --watch", + "prepack": "turbo run build --filter=@devframes/service-shiki", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "devframe": "workspace:*" + }, + "dependencies": { + "shiki": "catalog:deps" + }, + "devDependencies": { + "@types/node": "catalog:types", + "devframe": "workspace:*", + "tsdown": "catalog:build", + "vitest": "catalog:testing" + } +} diff --git a/services/shiki/src/index.ts b/services/shiki/src/index.ts new file mode 100644 index 00000000..62d6450b --- /dev/null +++ b/services/shiki/src/index.ts @@ -0,0 +1,195 @@ +import type { DevframeServiceDefinition } from 'devframe/types' +import type { BundledLanguage, codeToHast, codeToTokens, SpecialLanguage } from 'shiki' +import { defineRpcFunction } from 'devframe' +import { hash } from 'devframe/utils/hash' +import { s } from 'devframe/utils/simple-schema' +import pkg from '../package.json' with { type: 'json' } + +export const SHIKI_SERVICE_PACKAGE = '@devframes/service-shiki' +export const SHIKI_SERVICE_SCOPE = 'devframes:service:shiki' + +/** Dual light/dark theme pair, rendered via Shiki's dual-theme CSS variables. */ +export interface ShikiThemes { + light: string + dark: string +} + +/** Defaults matching the `@antfu/design` light/dark surfaces. */ +export const SHIKI_DEFAULT_THEMES: ShikiThemes = { light: 'vitesse-light', dark: 'vitesse-dark' } + +export interface ShikiServiceOptions { + /** + * Theme pair every request uses unless it carries its own. On merge, the + * later installer's pair wins. + */ + themes?: ShikiThemes + /** + * Languages to eagerly load at setup (others load on demand, per request). + * Merged as a union across installers. + */ + langs?: string[] +} + +export interface ShikiHighlightInput { + code: string + /** Language id; unknown ids degrade to plain text instead of throwing. */ + lang?: string + /** Per-request theme override. */ + themes?: ShikiThemes +} + +export type ShikiHast = Awaited> +export type ShikiTokens = Awaited> + +export interface ShikiServiceApi { + /** Highlight to HTML (dual-theme: light values inline, dark via `--shiki-dark` vars). */ + highlight: (input: ShikiHighlightInput) => Promise<{ html: string }> + /** Highlight to a HAST tree, for surfaces that render their own DOM. */ + codeToHast: (input: ShikiHighlightInput) => Promise + /** Highlight to themed tokens, for line-oriented renderers (e.g. diff views). */ + codeToTokens: (input: ShikiHighlightInput) => Promise +} + +declare module 'devframe' { + interface DevframeRpcServerFunctions { + 'devframes:service:shiki:highlight': (input: ShikiHighlightInput) => Promise<{ html: string }> + 'devframes:service:shiki:code-to-hast': (input: ShikiHighlightInput) => Promise + 'devframes:service:shiki:code-to-tokens': (input: ShikiHighlightInput) => Promise + } + interface DevframeServicesRegistry { + '@devframes/service-shiki': ShikiServiceApi + } + interface DevframeServicesScopeRegistry { + '@devframes/service-shiki': 'devframes:service:shiki' + } +} + +/** Tiny insertion-order LRU — enough to absorb re-renders of the same code. */ +class Lru { + private map = new Map() + constructor(private max: number) {} + get(key: string): V | undefined { + const value = this.map.get(key) + if (value !== undefined) { + this.map.delete(key) + this.map.set(key, value) + } + return value + } + + set(key: string, value: V): void { + if (this.map.size >= this.max && !this.map.has(key)) + this.map.delete(this.map.keys().next().value!) + this.map.set(key, value) + } +} + +const inputSchema = s.object({ + code: s.string(), + lang: s.optional(s.string()), + themes: s.optional(s.object({ light: s.string(), dark: s.string() })), +}) + +/** + * The Shiki wire service — server-side syntax highlighting shared by every + * plugin on the host, so client bundles stop shipping their own grammars and + * themes. Shiki itself loads lazily on first use; results are LRU-cached per + * `(code, lang, themes)` and every RPC function is `cacheable` on the client + * side too. + */ +export function createShikiService(options?: ShikiServiceOptions): DevframeServiceDefinition { + return { + package: SHIKI_SERVICE_PACKAGE, + version: pkg.version, + scope: SHIKI_SERVICE_SCOPE, + options, + mergeOptions: sets => sets.reduce((merged, set) => ({ + ...merged, + ...set, + langs: [...new Set([...(merged.langs ?? []), ...(set.langs ?? [])])], + }), {}), + setup(ctx, { options }) { + const defaultThemes = options?.themes ?? SHIKI_DEFAULT_THEMES + + let shikiPromise: Promise | undefined + const shiki = () => shikiPromise ??= import('shiki').then(async (mod) => { + // Eagerly warm the declared languages alongside the default themes. + if (options?.langs?.length) { + await mod.getSingletonHighlighter({ + langs: options.langs.filter(lang => lang in mod.bundledLanguages), + themes: [defaultThemes.light, defaultThemes.dark], + }) + } + return mod + }) + + /** Unknown language ids degrade to plain text instead of throwing. */ + async function resolveLang(lang: string | undefined): Promise { + if (!lang) + return 'text' + const mod = await shiki() + return lang in mod.bundledLanguages || ['text', 'plaintext', 'txt', 'plain', 'ansi'].includes(lang) + ? lang as BundledLanguage | SpecialLanguage + : 'text' + } + + const cache = new Lru>(256) + function cached(kind: string, input: ShikiHighlightInput, compute: (lang: BundledLanguage | SpecialLanguage, themes: ShikiThemes) => Promise): Promise { + const themes = input.themes ?? defaultThemes + const key = hash([kind, input.lang, themes, input.code]) + let result = cache.get(key) as Promise | undefined + if (!result) { + result = resolveLang(input.lang).then(lang => compute(lang, themes)) + cache.set(key, result) + } + return result + } + + // The spread turns the `ShikiThemes` interface into an object-literal + // type with an implicit index signature, as shiki's `themes` record + // requires. + const api: ShikiServiceApi = { + highlight: input => cached('html', input, async (lang, themes) => + ({ html: await (await shiki()).codeToHtml(input.code, { lang, themes: { ...themes } }) })), + codeToHast: input => cached('hast', input, async (lang, themes) => + (await shiki()).codeToHast(input.code, { lang, themes: { ...themes } })), + codeToTokens: input => cached('tokens', input, async (lang, themes) => + (await shiki()).codeToTokens(input.code, { lang, themes: { ...themes } })), + } + + // `s.object({})` is guard-only (extra keys survive) — a permissive + // envelope for the structured HAST / tokens payloads. + ctx.rpc.register(defineRpcFunction({ + name: 'highlight', + type: 'query', + cacheable: true, + jsonSerializable: true, + args: [inputSchema], + returns: s.object({ html: s.string() }), + handler: (input: ShikiHighlightInput) => api.highlight(input), + })) + ctx.rpc.register(defineRpcFunction({ + name: 'code-to-hast', + type: 'query', + cacheable: true, + jsonSerializable: true, + args: [inputSchema], + returns: s.object({}), + handler: (input: ShikiHighlightInput) => api.codeToHast(input), + })) + ctx.rpc.register(defineRpcFunction({ + name: 'code-to-tokens', + type: 'query', + cacheable: true, + jsonSerializable: true, + args: [inputSchema], + returns: s.object({}), + handler: (input: ShikiHighlightInput) => api.codeToTokens(input), + })) + + return api + }, + } +} + +export default createShikiService diff --git a/services/shiki/test/service.test.ts b/services/shiki/test/service.test.ts new file mode 100644 index 00000000..e27a1621 --- /dev/null +++ b/services/shiki/test/service.test.ts @@ -0,0 +1,88 @@ +import type { DevframeHost } from 'devframe/types' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHostContext } from 'devframe/node' +import { afterEach, describe, expect, it } from 'vitest' +import { createShikiService, SHIKI_DEFAULT_THEMES } from '../src/index' + +const tempDirs: string[] = [] + +afterEach(() => { + for (const dir of tempDirs.splice(0)) + rmSync(dir, { recursive: true, force: true }) +}) + +function createTestHost(dir: string): DevframeHost { + return { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost', + getStorageDir: scope => join(dir, scope), + } +} + +async function createService(options?: Parameters[0]) { + const dir = mkdtempSync(join(tmpdir(), 'devframe-service-shiki-')) + tempDirs.push(dir) + const ctx = await createHostContext({ cwd: dir, mode: 'dev', host: createTestHost(dir) }) + const install = ctx.services.install(createShikiService(options)) + await ctx.services.ready() + return { ctx, api: (await install)! } +} + +describe('@devframes/service-shiki', () => { + it('highlights with dual light/dark themes by default', async () => { + const { ctx, api } = await createService() + const { html } = await api.highlight({ code: 'const a = 1', lang: 'ts' }) + expect(html).toContain(' Promise<{ html: string }>)( + 'devframes:service:shiki:highlight', + { code: 'const a = 1', lang: 'ts' }, + ) + expect(viaRpc.html).toBe(html) + }) + + it('degrades unknown languages to plain text instead of throwing', async () => { + const { api } = await createService() + const { html } = await api.highlight({ code: 'hello world', lang: 'not-a-language' }) + expect(html).toContain('hello world') + }) + + it('serves tokens and hast for renderers that own their DOM', async () => { + const { api } = await createService() + const tokens = await api.codeToTokens({ code: 'const a = 1', lang: 'ts' }) + expect(tokens.tokens.length).toBeGreaterThan(0) + const hast = await api.codeToHast({ code: 'const a = 1', lang: 'ts' }) + expect(hast.children.length).toBeGreaterThan(0) + }) + + it('caches per (code, lang, themes)', async () => { + const { api } = await createService() + const first = api.highlight({ code: 'let x = 2', lang: 'ts' }) + const second = api.highlight({ code: 'let x = 2', lang: 'ts' }) + expect(second).toBe(first) // same cached promise + const other = api.highlight({ code: 'let x = 2', lang: 'ts', themes: { light: 'github-light', dark: 'github-dark' } }) + expect(other).not.toBe(first) + await expect(other).resolves.toHaveProperty('html') + }) + + it('merges options: later themes win, langs union', async () => { + const dir = mkdtempSync(join(tmpdir(), 'devframe-service-shiki-')) + tempDirs.push(dir) + const ctx = await createHostContext({ cwd: dir, mode: 'dev', host: createTestHost(dir) }) + void ctx.services.install(createShikiService({ langs: ['ts'] })) + void ctx.services.install({ + package: '@devframes/service-shiki', + options: { langs: ['vue'], themes: SHIKI_DEFAULT_THEMES }, + }) + await ctx.services.ready() + const api = ctx.services.get('@devframes/service-shiki') + const { html } = await api!.highlight({ code: 'const a = 1', lang: 'ts' }) + expect(html).toContain('--shiki-dark') + }) +}) diff --git a/services/shiki/tsconfig.json b/services/shiki/tsconfig.json new file mode 100644 index 00000000..25652292 --- /dev/null +++ b/services/shiki/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext", "dom"], + "types": ["node"] + }, + "include": ["src", "test", "tsdown.config.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/services/shiki/tsdown.config.ts b/services/shiki/tsdown.config.ts new file mode 100644 index 00000000..03714bc1 --- /dev/null +++ b/services/shiki/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + platform: 'node', + tsconfig: '../../tsconfig.base.json', + outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }), + entry: { index: 'src/index.ts' }, +}) diff --git a/tests/__snapshots__/tsnapi/@devframes/service-open/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/service-open/index.snapshot.d.ts new file mode 100644 index 00000000..daabb332 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/service-open/index.snapshot.d.ts @@ -0,0 +1,35 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/service-open` + */ +// #region Interfaces +export interface OpenInEditorInput { + path: string; + line?: number; + column?: number; + editor?: KnownEditor; +} +export interface OpenServiceApi { + openInEditor: (_: OpenInEditorInput) => Promise; + openInFinder: (_: { + path: string; + }) => Promise; +} +export interface OpenServiceOptions { + editor?: KnownEditor; + roots?: string[]; +} +// #endregion + +// #region Functions +export declare function createOpenService(_?: OpenServiceOptions): DevframeServiceDefinition; +// #endregion + +// #region Variables +export declare const OPEN_SERVICE_PACKAGE: string; +export declare const OPEN_SERVICE_SCOPE: string; +// #endregion + +// #region Default Export +declare function _default(_?: OpenServiceOptions): DevframeServiceDefinition; +export default _default +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/service-open/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/service-open/index.snapshot.js new file mode 100644 index 00000000..36384b42 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/service-open/index.snapshot.js @@ -0,0 +1,16 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/service-open` + */ +// #region Functions +export function createOpenService(_) {} +// #endregion + +// #region Variables +export var OPEN_SERVICE_PACKAGE /* const */ +export var OPEN_SERVICE_SCOPE /* const */ +// #endregion + +// #region Default Export +function _default(_) {} +export default _default +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/service-shiki/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/service-shiki/index.snapshot.d.ts new file mode 100644 index 00000000..bd554349 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/service-shiki/index.snapshot.d.ts @@ -0,0 +1,45 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/service-shiki` + */ +// #region Interfaces +export interface ShikiHighlightInput { + code: string; + lang?: string; + themes?: ShikiThemes; +} +export interface ShikiServiceApi { + highlight: (_: ShikiHighlightInput) => Promise<{ + html: string; + }>; + codeToHast: (_: ShikiHighlightInput) => Promise; + codeToTokens: (_: ShikiHighlightInput) => Promise; +} +export interface ShikiServiceOptions { + themes?: ShikiThemes; + langs?: string[]; +} +export interface ShikiThemes { + light: string; + dark: string; +} +// #endregion + +// #region Types +export type ShikiHast = Awaited>; +export type ShikiTokens = Awaited>; +// #endregion + +// #region Functions +export declare function createShikiService(_?: ShikiServiceOptions): DevframeServiceDefinition; +// #endregion + +// #region Variables +export declare const SHIKI_DEFAULT_THEMES: ShikiThemes; +export declare const SHIKI_SERVICE_PACKAGE: string; +export declare const SHIKI_SERVICE_SCOPE: string; +// #endregion + +// #region Default Export +declare function _default(_?: ShikiServiceOptions): DevframeServiceDefinition; +export default _default +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/service-shiki/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/service-shiki/index.snapshot.js new file mode 100644 index 00000000..8c2e84f1 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/service-shiki/index.snapshot.js @@ -0,0 +1,17 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/service-shiki` + */ +// #region Functions +export function createShikiService(_) {} +// #endregion + +// #region Variables +export var SHIKI_DEFAULT_THEMES /* const */ +export var SHIKI_SERVICE_PACKAGE /* const */ +export var SHIKI_SERVICE_SCOPE /* const */ +// #endregion + +// #region Default Export +function _default(_) {} +export default _default +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.d.ts index e16219a9..bd3b8bd4 100644 --- a/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.d.ts @@ -6,6 +6,7 @@ export type KnownEditor = 'atom' | 'subl' | 'sublime' | 'sublime_text' | 'wstorm // #endregion // #region Variables +/** @deprecated */ export declare const commonRpcFunctions: readonly [{ name: "devframe:open-in-editor"; type?: "action" | undefined; @@ -36,6 +37,7 @@ export declare const commonRpcFunctions: readonly [{ __promise?: Thenable>> | undefined; }]; export declare const KNOWN_EDITORS: KnownEditor[]; +/** @deprecated */ export declare const openInEditor: { name: "devframe:open-in-editor"; type?: "action" | undefined; @@ -51,6 +53,7 @@ export declare const openInEditor: { __cache?: WeakMap>>> | undefined; __promise?: Thenable>> | undefined; }; +/** @deprecated */ export declare const openInFinder: { name: "devframe:open-in-finder"; type?: "action" | undefined; diff --git a/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.js b/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.js index 244b9591..3b2ac529 100644 --- a/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.js @@ -2,8 +2,11 @@ * Generated by tsnapi — public API snapshot of `devframe/recipes/common-rpc-functions` */ // #region Variables +/** @deprecated */ export var commonRpcFunctions /* const */ export var KNOWN_EDITORS /* const */ +/** @deprecated */ export var openInEditor /* const */ +/** @deprecated */ export var openInFinder /* const */ // #endregion \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index 62e621e2..42c821d2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -378,6 +378,12 @@ ], "@devframes/plugin-assets": [ "./plugins/assets/src/index.ts" + ], + "@devframes/service-open": [ + "./services/open/src/index.ts" + ], + "@devframes/service-shiki": [ + "./services/shiki/src/index.ts" ] }, "resolveJsonModule": true, diff --git a/turbo.json b/turbo.json index 3a7ae368..19208643 100644 --- a/turbo.json +++ b/turbo.json @@ -52,6 +52,16 @@ "dependsOn": ["@devframes/json-render#build"], "outputs": ["dist/**"] }, + "@devframes/service-open#build": { + "outputLogs": "new-only", + "dependsOn": ["devframe#build"], + "outputs": ["dist/**"] + }, + "@devframes/service-shiki#build": { + "outputLogs": "new-only", + "dependsOn": ["devframe#build"], + "outputs": ["dist/**"] + }, "@devframes/plugin-code-server#build": { "outputLogs": "new-only", "dependsOn": ["devframe#build", "@devframes/vite#build"], diff --git a/vitest.config.ts b/vitest.config.ts index fe703b86..c0f6a16f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,6 +29,8 @@ export default defineConfig({ 'plugins/a11y', 'plugins/messages', 'plugins/assets', + 'services/open', + 'services/shiki', 'examples/hub-next', 'packages/next', 'packages/vite', From fc59de5826da0fa45e1b0b8e789747909d35b9e5 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 18 Aug 2026 06:33:03 +0000 Subject: [PATCH 2/5] fix: normalize service-open test paths for windows The expectations built paths with node:path (backslashes on Windows) while the service resolves through pathe (forward slashes). Also declare the pathe runtime dependency the service was getting via hoisting. --- pnpm-lock.yaml | 4 ++++ services/open/package.json | 3 +++ services/open/test/service.test.ts | 4 +++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d86edab..862329f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2145,6 +2145,10 @@ importers: plugins/terminals/assets-pkg: {} services/open: + dependencies: + pathe: + specifier: catalog:deps + version: 2.0.3 devDependencies: '@types/node': specifier: catalog:types diff --git a/services/open/package.json b/services/open/package.json index a2464c10..f818b752 100644 --- a/services/open/package.json +++ b/services/open/package.json @@ -37,6 +37,9 @@ "peerDependencies": { "devframe": "workspace:*" }, + "dependencies": { + "pathe": "catalog:deps" + }, "devDependencies": { "@types/node": "catalog:types", "devframe": "workspace:*", diff --git a/services/open/test/service.test.ts b/services/open/test/service.test.ts index 1608a1c4..e5381df9 100644 --- a/services/open/test/service.test.ts +++ b/services/open/test/service.test.ts @@ -1,8 +1,10 @@ import type { DevframeHost } from 'devframe/types' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' import { createHostContext } from 'devframe/node' +// `pathe` (not `node:path`) so the expected paths use the same normalized +// forward-slash form the service resolves to on every OS. +import { join } from 'pathe' import { afterEach, describe, expect, it, vi } from 'vitest' import { createOpenService } from '../src/index' From 4f64284ea4be9f818c43ee7e12887eefdbc319ac Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 18 Aug 2026 07:13:49 +0000 Subject: [PATCH 3/5] feat: migrate messages and assets onto the wire services - messages: drops its commonRpcFunctions registration for a devframes:plugin:messages:open-file bridge that resolves workspace-relative file positions server-side and delegates to @devframes/service-open (declared in the definition's services); the panel gates its affordance on the service advertisement - assets: open-in-editor / reveal-in-folder delegate to service-open, installed at setup with the managed dir as an extra allowed root; the SPA hides both buttons until the service is advertised, and text-asset previews render server-highlighted through @devframes/service-shiki (declared optional) with the plain
 as fallback
- docs + skill point at the service over the deprecated recipes
---
 docs/helpers/common-rpc-functions.md          |  3 ++
 docs/plugins/assets.md                        |  4 +-
 plugins/assets/package.json                   |  6 +++
 plugins/assets/src/diagnostics.ts             |  4 ++
 plugins/assets/src/index.ts                   | 11 +++++
 .../src/rpc/functions/open-in-editor.ts       | 16 ++++---
 .../src/rpc/functions/reveal-in-folder.ts     | 16 ++++---
 .../src/spa/app/components/AssetDetails.vue   | 35 ++++++++++++++--
 .../src/spa/app/components/AssetPreview.vue   | 35 ++++++++++++++++
 plugins/assets/src/spa/app/utils/highlight.ts | 27 ++++++++++++
 plugins/assets/test/_utils.ts                 |  6 +++
 plugins/assets/test/assets.test.ts            |  4 ++
 plugins/messages/package.json                 |  1 +
 plugins/messages/src/client/App.vue           | 24 ++++++-----
 plugins/messages/src/diagnostics.ts           |  4 ++
 plugins/messages/src/index.ts                 |  3 ++
 plugins/messages/src/node/index.ts            | 15 ++-----
 .../messages/src/rpc/functions/open-file.ts   | 34 +++++++++++++++
 plugins/messages/src/rpc/index.ts             |  2 +
 plugins/messages/test/_utils.ts               |  5 +++
 plugins/messages/test/dev-server.test.ts      |  8 +++-
 pnpm-lock.yaml                                |  9 ++++
 skills/devframe/SKILL.md                      |  2 +-
 .../plugin-messages/node.snapshot.js          |  5 ++-
 .../plugin-messages/rpc.snapshot.d.ts         | 42 +++++++++++++++++++
 25 files changed, 278 insertions(+), 43 deletions(-)
 create mode 100644 plugins/assets/src/spa/app/utils/highlight.ts
 create mode 100644 plugins/messages/src/rpc/functions/open-file.ts

diff --git a/docs/helpers/common-rpc-functions.md b/docs/helpers/common-rpc-functions.md
index f9ce70d4..8aa5f97b 100644
--- a/docs/helpers/common-rpc-functions.md
+++ b/docs/helpers/common-rpc-functions.md
@@ -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
diff --git a/docs/plugins/assets.md b/docs/plugins/assets.md
index 831c8e46..d20339f0 100644
--- a/docs/plugins/assets.md
+++ b/docs/plugins/assets.md
@@ -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 `
`. |
 | `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.
 
diff --git a/plugins/assets/package.json b/plugins/assets/package.json
index 950ad2b1..a5233756 100644
--- a/plugins/assets/package.json
+++ b/plugins/assets/package.json
@@ -48,6 +48,7 @@
   },
   "peerDependencies": {
     "@devframes/plugin-assets--assets": "workspace:*",
+    "@devframes/service-shiki": "workspace:*",
     "devframe": "workspace:*",
     "vite": "^7.0.0 || ^8.0.0"
   },
@@ -55,11 +56,15 @@
     "@devframes/plugin-assets--assets": {
       "optional": true
     },
+    "@devframes/service-shiki": {
+      "optional": true
+    },
     "vite": {
       "optional": true
     }
   },
   "dependencies": {
+    "@devframes/service-open": "workspace:*",
     "cac": "catalog:deps",
     "chokidar": "catalog:deps",
     "image-meta": "catalog:deps",
@@ -71,6 +76,7 @@
   "devDependencies": {
     "@antfu/design": "catalog:frontend",
     "@devframes/plugin-assets--assets": "workspace:*",
+    "@devframes/service-shiki": "workspace:*",
     "@devframes/vite": "workspace:*",
     "@iconify-json/ph": "catalog:frontend",
     "@storybook/addon-a11y": "catalog:storybook",
diff --git a/plugins/assets/src/diagnostics.ts b/plugins/assets/src/diagnostics.ts
index 89627a06..989c2935 100644
--- a/plugins/assets/src/diagnostics.ts
+++ b/plugins/assets/src/diagnostics.ts
@@ -34,5 +34,9 @@ export const diagnostics = defineDiagnostics({
       why: 'The upload streaming channel is unavailable because this devframe was set up with `write: false`.',
       fix: 'This indicates an internal registration bug — `upload` should never be reachable without `write: true`. Please report it.',
     },
+    DP_ASSETS_0008: {
+      why: 'Cannot open the asset: the "@devframes/service-open" wire service is not installed on this host.',
+      fix: 'Install the service package next to the assets plugin (it installs it during setup), or install it host-side via `ctx.services.install(createOpenService())`.',
+    },
   },
 })
diff --git a/plugins/assets/src/index.ts b/plugins/assets/src/index.ts
index 648ad307..fd5d59e1 100644
--- a/plugins/assets/src/index.ts
+++ b/plugins/assets/src/index.ts
@@ -124,9 +124,20 @@ export function createAssetsDevframe(options: AssetsDevframeOptions = {}): Devfr
       },
     },
     dock: { category: '~builtin' },
+    // Server-highlighted text previews; the SPA falls back to a plain
+    // `
` when the service isn't advertised.
+    services: [{ package: '@devframes/service-shiki' }],
     async setup(ctx, info) {
       const readOnlyFlag = info?.flags?.readOnly === true
       const dir = options.dir ? resolve(ctx.cwd, options.dir) : resolve(ctx.cwd, 'public')
+      // Backs open-in-editor / reveal-in-folder. Installed here (not in the
+      // declarative `services` list) because the managed dir — contributed
+      // as an extra allowed root, since it may live outside the workspace —
+      // is only known at setup time.
+      void ctx.services.install(
+        { package: '@devframes/service-open', options: { roots: [dir] } },
+        { resolveFrom: pkg.name },
+      )
       await setupAssets(ctx, {
         dir,
         write: readOnlyFlag ? false : write,
diff --git a/plugins/assets/src/rpc/functions/open-in-editor.ts b/plugins/assets/src/rpc/functions/open-in-editor.ts
index 313c125a..9516edc5 100644
--- a/plugins/assets/src/rpc/functions/open-in-editor.ts
+++ b/plugins/assets/src/rpc/functions/open-in-editor.ts
@@ -1,16 +1,17 @@
 import type { DevframeNodeContext } from 'devframe'
+import { OPEN_SERVICE_PACKAGE } from '@devframes/service-open'
 import { createDefineWrapperWithContext } from 'devframe/rpc'
-import { launchEditor } from 'devframe/utils/launch-editor'
 import { s } from 'devframe/utils/simple-schema'
+import { diagnostics } from '../../diagnostics'
 import { getAssetsContext } from '../../node/context'
 
 const defineAssetsRpc = createDefineWrapperWithContext()
 
 /**
- * Reuses devframe's `launchEditor` utility (the same one backing the core
- * `devframe:open-in-editor` recipe) but resolves the path against the
- * managed directory first, so the client only ever sends a root-relative
- * path — never the server's absolute filesystem layout.
+ * Delegates to the `@devframes/service-open` wire service (installed by the
+ * plugin's setup with the managed dir as an allowed root), resolving the
+ * path against the managed directory first — so the client only ever sends
+ * a root-relative path, never the server's absolute filesystem layout.
  */
 export const openInEditor = defineAssetsRpc({
   name: 'devframes:plugin:assets:open-in-editor',
@@ -29,7 +30,10 @@ export const openInEditor = defineAssetsRpc({
     return {
       // See `list.ts` for why the async handler is cast.
       handler: (async (path: string): Promise => {
-        launchEditor(assets.resolvePath(path))
+        const open = ctx.services.get(OPEN_SERVICE_PACKAGE)
+        if (!open)
+          throw diagnostics.DP_ASSETS_0008()
+        await open.openInEditor({ path: assets.resolvePath(path) })
       }) as any,
     }
   },
diff --git a/plugins/assets/src/rpc/functions/reveal-in-folder.ts b/plugins/assets/src/rpc/functions/reveal-in-folder.ts
index 05902c45..b7922348 100644
--- a/plugins/assets/src/rpc/functions/reveal-in-folder.ts
+++ b/plugins/assets/src/rpc/functions/reveal-in-folder.ts
@@ -1,17 +1,18 @@
 import type { DevframeNodeContext } from 'devframe'
+import { OPEN_SERVICE_PACKAGE } from '@devframes/service-open'
 import { createDefineWrapperWithContext } from 'devframe/rpc'
-import { open } from 'devframe/utils/open'
 import { s } from 'devframe/utils/simple-schema'
 import { dirname } from 'pathe'
+import { diagnostics } from '../../diagnostics'
 import { getAssetsContext } from '../../node/context'
 
 const defineAssetsRpc = createDefineWrapperWithContext()
 
 /**
- * Reuses devframe's `open` utility (the same one backing the core
- * `devframe:open-in-finder` recipe), opening the asset's containing folder
- * so it's revealed in the OS file manager rather than launched with its
- * default app (which `download` already covers).
+ * Delegates to the `@devframes/service-open` wire service's `openInFinder`,
+ * opening the asset's containing folder so it's revealed in the OS file
+ * manager rather than launched with its default app (which `download`
+ * already covers).
  */
 export const revealInFolder = defineAssetsRpc({
   name: 'devframes:plugin:assets:reveal-in-folder',
@@ -30,7 +31,10 @@ export const revealInFolder = defineAssetsRpc({
     return {
       // See `list.ts` for why the async handler is cast.
       handler: (async (path: string): Promise => {
-        await open(dirname(assets.resolvePath(path)))
+        const open = ctx.services.get(OPEN_SERVICE_PACKAGE)
+        if (!open)
+          throw diagnostics.DP_ASSETS_0008()
+        await open.openInFinder({ path: dirname(assets.resolvePath(path)) })
       }) as any,
     }
   },
diff --git a/plugins/assets/src/spa/app/components/AssetDetails.vue b/plugins/assets/src/spa/app/components/AssetDetails.vue
index 37036930..651690be 100644
--- a/plugins/assets/src/spa/app/components/AssetDetails.vue
+++ b/plugins/assets/src/spa/app/components/AssetDetails.vue
@@ -7,6 +7,7 @@ import FormTextInput from '@antfu/design/components/Form/FormTextInput.vue'
 import OverlayModal from '@antfu/design/components/Overlay/OverlayModal.vue'
 import { computed, ref, watch } from 'vue'
 import { fileNameOf, formatFileSize, formatTimeAgo } from '../utils/format'
+import { highlightAsset } from '../utils/highlight'
 import { buildSnippets } from '../utils/snippets'
 import AssetPreview from './AssetPreview.vue'
 import CodeSnippets from './CodeSnippets.vue'
@@ -23,6 +24,7 @@ const SUPPORTS_PREVIEW = new Set(['image', 'text', 'video', 'audio', 'font'])
 
 const imageMeta = ref(null)
 const textContent = ref(null)
+const highlightedHtml = ref(null)
 const deleteOpen = ref(false)
 const renameOpen = ref(false)
 const newName = ref('')
@@ -32,14 +34,38 @@ const errorNotice = ref(null)
 watch(() => props.asset.path, (path) => {
   imageMeta.value = null
   textContent.value = null
+  highlightedHtml.value = null
   errorNotice.value = null
   const rpc = props.rpc
   if (!rpc)
     return
   if (props.asset.type === 'image')
     void rpc.call('devframes:plugin:assets:read-image-meta', path).then((m) => { imageMeta.value = m })
-  if (props.asset.type === 'text')
-    void rpc.call('devframes:plugin:assets:read-text', path, 5000).then((c) => { textContent.value = c })
+  if (props.asset.type === 'text') {
+    void rpc.call('devframes:plugin:assets:read-text', path, 5000).then(async (c) => {
+      textContent.value = c
+      // Server-highlighted preview via the shiki wire service; `null` when
+      // it isn't advertised — the preview keeps its plain `
`.
+      const html = c == null ? null : await highlightAsset(rpc, path, c)
+      if (props.asset.path === path)
+        highlightedHtml.value = html
+    })
+  }
+}, { immediate: true })
+
+// The open/reveal affordances delegate to the `@devframes/service-open`
+// wire service; hide them until the host advertises it.
+const openServiceAvailable = ref(false)
+watch(() => props.rpc, (rpc) => {
+  if (!rpc)
+    return
+  openServiceAvailable.value = rpc.services.has('@devframes/service-open')
+  rpc.services.state()
+    .then((state) => {
+      openServiceAvailable.value = rpc.services.has('@devframes/service-open')
+      state.on('updated', () => (openServiceAvailable.value = rpc.services.has('@devframes/service-open')))
+    })
+    .catch(() => {})
 }, { immediate: true })
 
 function gcd(a: number, b: number): number {
@@ -127,6 +153,7 @@ function openInBrowser(): void {
         :asset="asset"
         detail
         :text-content="textContent"
+        :highlighted-html="highlightedHtml"
         class="max-h-80 min-h-20 w-auto min-w-20 rounded border border-base"
       />
     
@@ -192,10 +219,10 @@ function openInBrowser(): void {
       
         Download
       
-      
+      
         Open in Editor
       
-      
+      
         Reveal in Folder
       
       
diff --git a/plugins/assets/src/spa/app/components/AssetPreview.vue b/plugins/assets/src/spa/app/components/AssetPreview.vue
index be410508..3a522f53 100644
--- a/plugins/assets/src/spa/app/components/AssetPreview.vue
+++ b/plugins/assets/src/spa/app/components/AssetPreview.vue
@@ -5,6 +5,11 @@ import FontPreview from './FontPreview.vue'
 defineProps<{
   asset: AssetInfo
   textContent?: string | null
+  /**
+   * Server-highlighted HTML for text assets (from the `@devframes/service-shiki`
+   * wire service); the plain `textContent` `
` is the fallback.
+   */
+  highlightedHtml?: string | null
   /** Larger, interactive preview (autoplay/controls) for the details panel. */
   detail?: boolean
 }>()
@@ -21,6 +26,15 @@ const BASE = 'flex items-center justify-center overflow-hidden bg-active p-1'
     
   
 
+  
+  
{{ textContent }}
@@ -41,3 +55,24 @@ const BASE = 'flex items-center justify-center overflow-hidden bg-active p-1'
+ + + diff --git a/plugins/assets/src/spa/app/utils/highlight.ts b/plugins/assets/src/spa/app/utils/highlight.ts new file mode 100644 index 00000000..c2017a86 --- /dev/null +++ b/plugins/assets/src/spa/app/utils/highlight.ts @@ -0,0 +1,27 @@ +// Types-only: loads the service's RPC/scope augmentations so the scoped +// `call('highlight', …)` below is fully typed. +import type {} from '@devframes/service-shiki' +import type { DevframeRpcClient } from 'devframe/client' + +const SHIKI_SERVICE = '@devframes/service-shiki' + +/** + * Server-highlight a text asset through the `@devframes/service-shiki` wire + * service, when the host advertises it. Resolves `null` when the service is + * absent or highlighting fails — the preview then falls back to a plain + * `
`. The language is inferred from the file extension; unknown ones
+ * degrade to plain text server-side.
+ */
+export async function highlightAsset(rpc: DevframeRpcClient, path: string, code: string): Promise {
+  const shiki = rpc.services.get(SHIKI_SERVICE)
+  if (!shiki)
+    return null
+  const lang = /\.([^./\\]+)$/.exec(path)?.[1]?.toLowerCase()
+  try {
+    const { html } = await shiki.rpc.call('highlight', { code, lang })
+    return html
+  }
+  catch {
+    return null
+  }
+}
diff --git a/plugins/assets/test/_utils.ts b/plugins/assets/test/_utils.ts
index 86367879..2d45ee04 100644
--- a/plugins/assets/test/_utils.ts
+++ b/plugins/assets/test/_utils.ts
@@ -49,7 +49,13 @@ export async function startAssetsServer(
   })
 
   const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: h3Host })
+  // Mirror the adapters: queue the definition's declared wire services
+  // before setup, then fire the collect-then-setup barrier (setup itself
+  // installs `@devframes/service-open` with the managed dir as a root).
+  for (const input of definition.services ?? [])
+    void ctx.services.install(input, { resolveFrom: definition.packageName })
   await definition.setup(ctx)
+  await ctx.services.ready()
 
   const server = await serveTestContext({ context: ctx, host, port, app, auth: false })
 
diff --git a/plugins/assets/test/assets.test.ts b/plugins/assets/test/assets.test.ts
index b5f23511..f705c8dc 100644
--- a/plugins/assets/test/assets.test.ts
+++ b/plugins/assets/test/assets.test.ts
@@ -175,6 +175,10 @@ describe('assets plugin', () => {
     expect(defs.has('devframes:plugin:assets:open-in-editor')).toBe(true)
     expect(defs.has('devframes:plugin:assets:reveal-in-folder')).toBe(true)
     expect(defs.has('devframes:plugin:assets:mkdir')).toBe(false)
+    // Both delegate to the open wire service the plugin installs during
+    // setup (with the managed dir as an allowed root).
+    expect(server.ctx.services.has('@devframes/service-open')).toBe(true)
+    expect(defs.has('devframes:service:open:open-in-editor')).toBe(true)
   })
 
   // The one test that needs the live watcher — every other test above opts
diff --git a/plugins/messages/package.json b/plugins/messages/package.json
index 7d5aa21b..63f7415e 100644
--- a/plugins/messages/package.json
+++ b/plugins/messages/package.json
@@ -63,6 +63,7 @@
     }
   },
   "dependencies": {
+    "@devframes/service-open": "workspace:*",
     "cac": "catalog:deps"
   },
   "devDependencies": {
diff --git a/plugins/messages/src/client/App.vue b/plugins/messages/src/client/App.vue
index 955ba2ab..b3b18164 100644
--- a/plugins/messages/src/client/App.vue
+++ b/plugins/messages/src/client/App.vue
@@ -49,10 +49,19 @@ function reload(): void {
   location.reload()
 }
 
-// The "open file" affordance rides on devframe's prebuilt recipe, which the
-// plugin registers server-side; static builds have no live server to open
-// an editor with.
-const canOpenFile = computed(() => props.rpc.connectionMeta.backend !== 'static')
+// The "open file" affordance delegates to the `@devframes/service-open`
+// wire service; hide it until the service is advertised (and always on
+// static builds, which have no live server to open an editor with).
+const openServiceAvailable = ref(props.rpc.services.has('@devframes/service-open'))
+onMounted(() => {
+  props.rpc.services.state()
+    .then((state) => {
+      openServiceAvailable.value = props.rpc.services.has('@devframes/service-open')
+      state.on('updated', () => (openServiceAvailable.value = props.rpc.services.has('@devframes/service-open')))
+    })
+    .catch(() => {})
+})
+const canOpenFile = computed(() => props.rpc.connectionMeta.backend !== 'static' && openServiceAvailable.value)
 
 // Message actions that navigate to another dock only work under a hub host
 // (the `hub:docks:activate` RPC + `devframe:docks` registry). Probe the docks
@@ -99,12 +108,7 @@ async function onOpenFile(entry: DevframeMessageEntry): Promise {
   if (!entry.filePosition)
     return
   const { file, line, column } = entry.filePosition
-  let path = file
-  if (line)
-    path += `:${line}`
-  if (column)
-    path += `:${column}`
-  await props.rpc.call('devframe:open-in-editor', path)
+  await props.rpc.call('devframes:plugin:messages:open-file', { file, line, column })
 }
 
 
diff --git a/plugins/messages/src/diagnostics.ts b/plugins/messages/src/diagnostics.ts
index 2ae922a7..4d88888f 100644
--- a/plugins/messages/src/diagnostics.ts
+++ b/plugins/messages/src/diagnostics.ts
@@ -14,5 +14,9 @@ export const diagnostics = defineDiagnostics({
         `"${p.id}" is mounted on a context without a hub messages host (\`ctx.messages\`) — its RPC surface stays registered but no-ops, so the panel will show an empty feed.`,
       fix: 'Mount this devframe through a hub host (`@devframes/hub`\'s `initHub`, or `createHubContext` + `ctx.install`) to get a live message feed.',
     },
+    DP_MESSAGES_0002: {
+      why: 'Cannot open the file: the "@devframes/service-open" wire service is not installed on this host.',
+      fix: 'Install the service package next to the messages plugin (it is declared in the plugin\'s `services`), or install it host-side via `ctx.services.install(createOpenService())`.',
+    },
   },
 })
diff --git a/plugins/messages/src/index.ts b/plugins/messages/src/index.ts
index 7386cfd3..6a362837 100644
--- a/plugins/messages/src/index.ts
+++ b/plugins/messages/src/index.ts
@@ -70,6 +70,9 @@ export function createMessagesDevframe(options: MessagesDevframeOptions = {}): D
     dock: {
       category: '~builtin',
     },
+    // Backs the detail panel's "open file" affordance; the panel hides it
+    // when the service isn't advertised.
+    services: [{ package: '@devframes/service-open' }],
     setup(ctx) {
       setupMessages(ctx)
     },
diff --git a/plugins/messages/src/node/index.ts b/plugins/messages/src/node/index.ts
index def331be..6a88ae7c 100644
--- a/plugins/messages/src/node/index.ts
+++ b/plugins/messages/src/node/index.ts
@@ -1,5 +1,4 @@
 import type { DevframeNodeContext } from 'devframe'
-import { commonRpcFunctions } from 'devframe/recipes/common-rpc-functions'
 import { PLUGIN_ID } from '../constants'
 import { diagnostics } from '../diagnostics'
 import { getMessagesHost } from '../rpc/functions/_define'
@@ -18,18 +17,12 @@ export function setupMessages(ctx: DevframeNodeContext): void {
   if (!getMessagesHost(ctx))
     diagnostics.DP_MESSAGES_0001({ id: PLUGIN_ID })
 
+  // The detail panel's "open file" affordance delegates to the
+  // `@devframes/service-open` wire service (declared in the definition's
+  // `services`) through `devframes:plugin:messages:open-file`, which
+  // resolves workspace-relative file positions server-side.
   for (const fn of serverFunctions)
     ctx.rpc.register(fn)
-
-  // The detail panel's "open file" affordance uses devframe's prebuilt
-  // `devframe:open-in-editor` recipe. Another tool on the same connection
-  // may have registered the helpers already — skip those. The recipes are
-  // context-free (`SetupContext = undefined`, plain handlers); widening to
-  // the node collector's context is safe.
-  for (const fn of commonRpcFunctions) {
-    if (!ctx.rpc.definitions.has(fn.name))
-      ctx.rpc.register(fn as unknown as Parameters[0])
-  }
 }
 
 export { serverFunctions }
diff --git a/plugins/messages/src/rpc/functions/open-file.ts b/plugins/messages/src/rpc/functions/open-file.ts
new file mode 100644
index 00000000..ff1547fe
--- /dev/null
+++ b/plugins/messages/src/rpc/functions/open-file.ts
@@ -0,0 +1,34 @@
+import { OPEN_SERVICE_PACKAGE } from '@devframes/service-open'
+import { s } from 'devframe/utils/simple-schema'
+import { isAbsolute, resolve } from 'pathe'
+import { diagnostics } from '../../diagnostics'
+import { defineMessagesRpc } from './_define'
+
+/**
+ * Open a message's file position in the user's editor, delegating to the
+ * `@devframes/service-open` wire service the plugin declares. Message
+ * producers often report workspace-relative files, so the plugin resolves
+ * them against `ctx.workspaceRoot` server-side — the client never needs the
+ * server's filesystem layout. The panel gates its affordance on the service
+ * advertisement (`rpc.services.has('@devframes/service-open')`).
+ */
+export const messagesOpenFile = defineMessagesRpc({
+  name: 'devframes:plugin:messages:open-file',
+  type: 'action',
+  jsonSerializable: true,
+  args: [s.object({
+    file: s.string(),
+    line: s.optional(s.number()),
+    column: s.optional(s.number()),
+  })],
+  returns: s.void(),
+  setup: ctx => ({
+    handler: (async (input: { file: string, line?: number, column?: number }): Promise => {
+      const open = ctx.services.get(OPEN_SERVICE_PACKAGE)
+      if (!open)
+        throw diagnostics.DP_MESSAGES_0002()
+      const path = isAbsolute(input.file) ? input.file : resolve(ctx.workspaceRoot, input.file)
+      await open.openInEditor({ path, line: input.line, column: input.column })
+    }) as any,
+  }),
+})
diff --git a/plugins/messages/src/rpc/index.ts b/plugins/messages/src/rpc/index.ts
index 6ff4bcc1..f0e1c8b2 100644
--- a/plugins/messages/src/rpc/index.ts
+++ b/plugins/messages/src/rpc/index.ts
@@ -2,6 +2,7 @@ import type { RpcDefinitionsToFunctions } from 'devframe/rpc'
 import { messagesAdd } from './functions/add'
 import { messagesClear } from './functions/clear'
 import { messagesList } from './functions/list'
+import { messagesOpenFile } from './functions/open-file'
 import { messagesRemove } from './functions/remove'
 import { messagesUpdate } from './functions/update'
 
@@ -16,6 +17,7 @@ export const serverFunctions = [
   messagesUpdate,
   messagesRemove,
   messagesClear,
+  messagesOpenFile,
 ] as const
 
 declare module 'devframe' {
diff --git a/plugins/messages/test/_utils.ts b/plugins/messages/test/_utils.ts
index 4347de58..1881692f 100644
--- a/plugins/messages/test/_utils.ts
+++ b/plugins/messages/test/_utils.ts
@@ -82,7 +82,12 @@ async function boot(options: BootOptions): Promise {
   const ctx = options.hub
     ? await createHubContext({ cwd: process.cwd(), mode: 'dev', host: h3Host })
     : await createHostContext({ cwd: process.cwd(), mode: 'dev', host: h3Host })
+  // Mirror the adapters: queue the definition's declared wire services
+  // before setup, then fire the collect-then-setup barrier.
+  for (const input of messagesDevframe.services ?? [])
+    void ctx.services.install(input, { resolveFrom: messagesDevframe.packageName })
   await messagesDevframe.setup(ctx)
+  await ctx.services.ready()
 
   const metaPath = `${basePath}${DEVFRAME_CONNECTION_META_FILENAME}`
   app.use(metaPath, () => ({ backend: 'websocket', websocket: port }))
diff --git a/plugins/messages/test/dev-server.test.ts b/plugins/messages/test/dev-server.test.ts
index c7fc7e43..524a456d 100644
--- a/plugins/messages/test/dev-server.test.ts
+++ b/plugins/messages/test/dev-server.test.ts
@@ -43,14 +43,18 @@ describe('messages dev-server (hub context)', () => {
     expect(meta.websocket).toBe(server.port)
   })
 
-  it('registers the open-in-editor recipe alongside the feed RPCs', () => {
+  it('registers the open-file bridge and the open service alongside the feed RPCs', () => {
     const names = Array.from(server.ctx.rpc.definitions.keys())
     expect(names).toContain('devframes:plugin:messages:list')
     expect(names).toContain('devframes:plugin:messages:add')
     expect(names).toContain('devframes:plugin:messages:update')
     expect(names).toContain('devframes:plugin:messages:remove')
     expect(names).toContain('devframes:plugin:messages:clear')
-    expect(names).toContain('devframe:open-in-editor')
+    expect(names).toContain('devframes:plugin:messages:open-file')
+    // The declared `@devframes/service-open` wire service is installed and
+    // registers its own scoped RPC.
+    expect(names).toContain('devframes:service:open:open-in-editor')
+    expect(server.ctx.services.has('@devframes/service-open')).toBe(true)
   })
 
   it('lists server-side entries and delta-syncs from a cursor', async () => {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 862329f9..efa9c9d5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1546,6 +1546,9 @@ importers:
 
   plugins/assets:
     dependencies:
+      '@devframes/service-open':
+        specifier: workspace:*
+        version: link:../../services/open
       cac:
         specifier: catalog:deps
         version: 7.0.0
@@ -1574,6 +1577,9 @@ importers:
       '@devframes/plugin-assets--assets':
         specifier: workspace:*
         version: link:assets-pkg
+      '@devframes/service-shiki':
+        specifier: workspace:*
+        version: link:../../services/shiki
       '@devframes/vite':
         specifier: workspace:*
         version: link:../../packages/vite
@@ -1933,6 +1939,9 @@ importers:
 
   plugins/messages:
     dependencies:
+      '@devframes/service-open':
+        specifier: workspace:*
+        version: link:../../services/open
       cac:
         specifier: catalog:deps
         version: 7.0.0
diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md
index af2f4124..f9f5a368 100644
--- a/skills/devframe/SKILL.md
+++ b/skills/devframe/SKILL.md
@@ -558,7 +558,7 @@ Devframe re-exports a curated set of helpers under `devframe/utils/*`. They are
 | `createStreamSink` / `createStreamReader` from `devframe/utils/streaming-channel` | - | Low-level streaming primitives |
 | `evaluateWhen` / `WhenExpression` from `devframe/utils/when` | `whenexpr` | When-clause expressions |
 
-For "open file in editor" + "reveal in finder", prefer the prebuilt `commonRpcFunctions` RPC recipe (`devframe/recipes/common-rpc-functions`) - it wires the two utilities into named RPC functions ready to register.
+For "open file in editor" + "reveal in finder", prefer the `@devframes/service-open` wire service (declare `services: [{ package: '@devframes/service-open' }]` on the definition, gate client UI on `rpc.services.has(...)`) - one host-level installation shared by every plugin, with workspace-root path containment. The older `commonRpcFunctions` recipe (`devframe/recipes/common-rpc-functions`) still works but is deprecated.
 
 ## Security (secure by default)
 
diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-messages/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-messages/node.snapshot.js
index 08909cd5..0c1ba411 100644
--- a/tests/__snapshots__/tsnapi/@devframes/plugin-messages/node.snapshot.js
+++ b/tests/__snapshots__/tsnapi/@devframes/plugin-messages/node.snapshot.js
@@ -1,7 +1,10 @@
 /**
  * Generated by tsnapi — public API snapshot of `@devframes/plugin-messages/node`
  */
+// #region Functions
+export function setupMessages(_) {}
+// #endregion
+
 // #region Other
 export { serverFunctions }
-export { setupMessages }
 // #endregion
\ No newline at end of file
diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-messages/rpc.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-messages/rpc.snapshot.d.ts
index 1d267a3d..cb4fdfdf 100644
--- a/tests/__snapshots__/tsnapi/@devframes/plugin-messages/rpc.snapshot.d.ts
+++ b/tests/__snapshots__/tsnapi/@devframes/plugin-messages/rpc.snapshot.d.ts
@@ -72,5 +72,47 @@ export declare const serverFunctions: readonly [{
   snapshot?: boolean;
   __cache?: WeakMap>>> | undefined;
   __promise?: import("devframe/rpc").Thenable>> | undefined;
+}, {
+  name: "devframes:plugin:messages:open-file";
+  type?: "action" | undefined;
+  cacheable?: boolean;
+  args: readonly [import("devframe/utils/simple-schema").SimpleSchema<{
+    file: string;
+    line?: number | undefined;
+    column?: number | undefined;
+  }, {
+    file: string;
+    line?: number | undefined;
+    column?: number | undefined;
+  }>];
+  returns: import("devframe/utils/simple-schema").SimpleSchema;
+  jsonSerializable?: boolean;
+  agent?: import("devframe").RpcFunctionAgentOptions;
+  setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined;
+  handler?: ((args_0: {
+    file: string;
+    line?: number | undefined;
+    column?: number | undefined;
+  }) => import("devframe/rpc").Thenable) | undefined;
+  dump?: import("devframe/rpc").RpcDump<[{
+    file: string;
+    line?: number | undefined;
+    column?: number | undefined;
+  }], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined;
+  snapshot?: boolean;
+  __cache?: WeakMap>>> | undefined;
+  __promise?: import("devframe/rpc").Thenable>> | undefined;
 }];
 // #endregion
\ No newline at end of file

From 2027b4b381736e1df5240e999362bc595ad161c8 Mon Sep 17 00:00:00 2001
From: "Anthony Fu (via agent)" 
Date: Wed, 19 Aug 2026 00:27:13 +0000
Subject: [PATCH 4/5] refactor: services ready before setup; assets fully
 declarative
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Invert the wire-services lifecycle so services are constructed and ready
BEFORE any setup() runs, and setup consumes them synchronously.

- adapters (initiate/build/mcp/embedded) install-loop -> ready() -> setup
- hub: two passes — dock+collect across all devframes (+ new
  initHub({ services }) host-level channel) -> ready() once -> setups ->
  configure -> ui.setup; installDevframe/prepareDevframe split accordingly
- ready() is internal; install() stays as the dynamic escape hatch
  (immediate post-ready construct)
- descriptor options deep-merge by default (objects recurse, arrays
  union-dedupe, scalars last-wins); both shipped services drop their
  custom mergeOptions (hook kept as override)
- drop the first-connect safety net + DF0071

Consumers:
- assets declares both services (service-open with the factory-computed
  managed dir as an allowed root) — no imperative install in setup;
  drops its open-in-editor/reveal-in-folder RPCs (+ DP_ASSETS_0008) and
  the client calls service-open directly with a new dev-only absolute
  AssetInfo.fsPath (reveal passes the parent dir)
- messages harness mirrors the new ordering

Docs: guide/services.md rewritten to the pre-setup lifecycle; DF0071
page removed; error pages reworded.
---
 docs/errors/DF0066.md                         |  20 ++-
 docs/errors/DF0067.md                         |   6 +-
 docs/errors/DF0068.md                         |   4 +-
 docs/errors/DF0069.md                         |   2 +-
 docs/errors/DF0070.md                         |   2 +-
 docs/errors/DF0071.md                         |  28 ----
 docs/guide/services.md                        |  21 ++-
 packages/devframe/src/adapters/build.ts       |   3 +-
 packages/devframe/src/adapters/embedded.ts    |   7 +-
 packages/devframe/src/adapters/initiate.ts    |   6 +-
 .../devframe/src/adapters/mcp/build-server.ts |   3 +-
 .../node/__tests__/services-install.test.ts   |  24 +++-
 .../src/node/__tests__/services.test.ts       |  21 ++-
 packages/devframe/src/node/diagnostics.ts     |   5 -
 packages/devframe/src/node/host-services.ts   |   9 +-
 packages/devframe/src/node/rpc-core.ts        |  29 ++--
 .../devframe/src/node/services-install.ts     |  30 +++-
 packages/devframe/src/types/services.ts       |  37 ++---
 .../node/__tests__/install-devframe.test.ts   |   6 +
 packages/hub/src/node/initiate.ts             |  38 +++--
 packages/hub/src/node/install-devframe.ts     |  47 +++++--
 plugins/assets/src/diagnostics.ts             |   4 -
 plugins/assets/src/index.ts                   |  28 ++--
 plugins/assets/src/node/index.ts              |   4 +-
 plugins/assets/src/node/scanner.ts            |  13 +-
 plugins/assets/src/rpc/functions/list.ts      |   4 +-
 .../src/rpc/functions/open-in-editor.ts       |  40 ------
 plugins/assets/src/rpc/functions/rename.ts    |   4 +-
 .../src/rpc/functions/reveal-in-folder.ts     |  41 ------
 plugins/assets/src/rpc/index.ts               |  12 +-
 .../src/spa/app/components/AssetDetails.vue   |  18 ++-
 plugins/assets/src/types.ts                   |   6 +
 plugins/assets/test/_utils.ts                 |   7 +-
 plugins/assets/test/assets.test.ts            |  30 ++--
 plugins/messages/test/_utils.ts               |   6 +-
 services/open/src/index.ts                    |   7 +-
 services/shiki/src/index.ts                   |   7 +-
 .../@devframes/hub/initiate.snapshot.d.ts     |   1 +
 .../plugin-assets/index.snapshot.d.ts         |   1 +
 .../plugin-assets/node.snapshot.d.ts          |   2 +-
 .../plugin-assets/rpc.snapshot.d.ts           | 131 ++++++------------
 .../@devframes/plugin-assets/rpc.snapshot.js  |   3 -
 .../tsnapi/devframe/internal.snapshot.d.ts    |   6 -
 43 files changed, 335 insertions(+), 388 deletions(-)
 delete mode 100644 docs/errors/DF0071.md
 delete mode 100644 plugins/assets/src/rpc/functions/open-in-editor.ts
 delete mode 100644 plugins/assets/src/rpc/functions/reveal-in-folder.ts

diff --git a/docs/errors/DF0066.md b/docs/errors/DF0066.md
index 38d4049f..e965bbee 100644
--- a/docs/errors/DF0066.md
+++ b/docs/errors/DF0066.md
@@ -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.
diff --git a/docs/errors/DF0067.md b/docs/errors/DF0067.md
index 15656090..3f3d5e55 100644
--- a/docs/errors/DF0067.md
+++ b/docs/errors/DF0067.md
@@ -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`.
 
@@ -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 },
   ],
 })
@@ -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.
diff --git a/docs/errors/DF0068.md b/docs/errors/DF0068.md
index 0e820e22..fbd503be 100644
--- a/docs/errors/DF0068.md
+++ b/docs/errors/DF0068.md
@@ -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).
 
@@ -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.
diff --git a/docs/errors/DF0069.md b/docs/errors/DF0069.md
index aeb8ef6a..b7b283b7 100644
--- a/docs/errors/DF0069.md
+++ b/docs/errors/DF0069.md
@@ -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.
diff --git a/docs/errors/DF0070.md b/docs/errors/DF0070.md
index f90441c8..19eb611a 100644
--- a/docs/errors/DF0070.md
+++ b/docs/errors/DF0070.md
@@ -33,4 +33,4 @@ A service package's default export must be its `createService` 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.
diff --git a/docs/errors/DF0071.md b/docs/errors/DF0071.md
deleted file mode 100644
index 847fb9d0..00000000
--- a/docs/errors/DF0071.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-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.
diff --git a/docs/guide/services.md b/docs/guide/services.md
index 753a85ea..509c3d60 100644
--- a/docs/guide/services.md
+++ b/docs/guide/services.md
@@ -101,29 +101,36 @@ 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**:
+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** — 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({
   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`:
diff --git a/packages/devframe/src/adapters/build.ts b/packages/devframe/src/adapters/build.ts
index 3f25857f..02c510e8 100644
--- a/packages/devframe/src/adapters/build.ts
+++ b/packages/devframe/src/adapters/build.ts
@@ -88,10 +88,11 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt
     mode: 'build',
     host,
   })
+  // Services ready before setup, so setup can consume them synchronously.
   for (const input of d.services ?? [])
     void ctx.services.install(input, { resolveFrom: d.packageName })
-  await d.setup(ctx)
   await ctx.services.ready()
+  await d.setup(ctx)
 
   await fs.mkdir(resolve(outDir, DEVFRAME_RPC_DUMP_DIRNAME), { recursive: true })
 
diff --git a/packages/devframe/src/adapters/embedded.ts b/packages/devframe/src/adapters/embedded.ts
index 0fbf7be2..11e05462 100644
--- a/packages/devframe/src/adapters/embedded.ts
+++ b/packages/devframe/src/adapters/embedded.ts
@@ -16,10 +16,11 @@ export interface CreateEmbeddedOptions {
  * effective default follows the hosted rule of `def.basePath ?? '/__/'`.
  */
 export async function createEmbedded(d: DevframeDefinition, options: CreateEmbeddedOptions): Promise {
-  // 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.packageName })
+  await options.ctx.services.ready()
   await d.setup(options.ctx)
 }
diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts
index f8567c26..4b3add71 100644
--- a/packages/devframe/src/adapters/initiate.ts
+++ b/packages/devframe/src/adapters/initiate.ts
@@ -289,12 +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.
+      // 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.packageName })
-      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 `__mcp` route wins, and advertised in
diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts
index 073b1315..4fe20c89 100644
--- a/packages/devframe/src/adapters/mcp/build-server.ts
+++ b/packages/devframe/src/adapters/mcp/build-server.ts
@@ -117,10 +117,11 @@ export async function createMcpServer(
     mode: 'dev',
     host,
   })
+  // Services ready before setup, so setup can consume them synchronously.
   for (const input of definition.services ?? [])
     void ctx.services.install(input, { resolveFrom: definition.packageName })
-  await definition.setup(ctx)
   await ctx.services.ready()
+  await definition.setup(ctx)
 
   const { server, dispose } = buildMcpServerFromContext(ctx, {
     serverName: options.serverName ?? `${definition.id} (devframe)`,
diff --git a/packages/devframe/src/node/__tests__/services-install.test.ts b/packages/devframe/src/node/__tests__/services-install.test.ts
index d1e70742..3d32c7ef 100644
--- a/packages/devframe/src/node/__tests__/services-install.test.ts
+++ b/packages/devframe/src/node/__tests__/services-install.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import { satisfiesVersionRange, shallowMergeOptionSets } from '../services-install'
+import { deepMergeOptionSets, satisfiesVersionRange } from '../services-install'
 
 describe('satisfiesVersionRange', () => {
   it('matches exact versions', () => {
@@ -57,13 +57,23 @@ describe('satisfiesVersionRange', () => {
   })
 })
 
-describe('shallowMergeOptionSets', () => {
-  it('merges plain objects in order, later wins', () => {
-    expect(shallowMergeOptionSets([{ a: 1, b: 1 }, { b: 2, c: 3 }])).toEqual({ a: 1, b: 2, c: 3 })
+describe('deepMergeOptionSets', () => {
+  it('merges plain objects in order, later scalars win', () => {
+    expect(deepMergeOptionSets([{ a: 1, b: 1 }, { b: 2, c: 3 }])).toEqual({ a: 1, b: 2, c: 3 })
   })
 
-  it('collapses to last-wins when a set is not a plain object', () => {
-    expect(shallowMergeOptionSets([{ a: 1 }, ['x']])).toEqual(['x'])
-    expect(shallowMergeOptionSets(['x', { a: 1 }])).toEqual({ a: 1 })
+  it('recurses into nested objects', () => {
+    expect(deepMergeOptionSets([{ nested: { x: 1, y: 1 } }, { nested: { y: 2, z: 3 } }]))
+      .toEqual({ nested: { x: 1, y: 2, z: 3 } })
+  })
+
+  it('unions + dedupes arrays', () => {
+    expect(deepMergeOptionSets([{ roots: ['a', 'b'] }, { roots: ['b', 'c'] }]))
+      .toEqual({ roots: ['a', 'b', 'c'] })
+  })
+
+  it('takes the later value for mismatched shapes', () => {
+    expect(deepMergeOptionSets([{ a: 1 }, ['x']])).toEqual(['x'])
+    expect(deepMergeOptionSets(['x', { a: 1 }])).toEqual({ a: 1 })
   })
 })
diff --git a/packages/devframe/src/node/__tests__/services.test.ts b/packages/devframe/src/node/__tests__/services.test.ts
index af45b662..3221835e 100644
--- a/packages/devframe/src/node/__tests__/services.test.ts
+++ b/packages/devframe/src/node/__tests__/services.test.ts
@@ -121,8 +121,8 @@ function writeFakeServicePackage(dir: string, name: string, version: string): vo
   ].join('\n'))
 }
 
-describe('wire services (install / ready barrier)', () => {
-  it('queues installs and constructs once at the barrier with merged options', async () => {
+describe('wire services (install / ready)', () => {
+  it('queues installs and constructs once at ready() with merged options', async () => {
     const { ctx } = await createCtx()
     const setup = vi.fn((_ctx: unknown, info: { options?: any }) => ({ options: info.options }))
     const def = defineTestService({ setup, options: { a: 1, b: 1 } })
@@ -131,17 +131,28 @@ describe('wire services (install / ready barrier)', () => {
     // A second install of the same package contributes its options to the merge.
     const second = ctx.services.install({ package: '@test/svc', options: { b: 2, c: 3 } })
 
+    // The service's own setup runs at ready(), not before.
     expect(setup).not.toHaveBeenCalled()
     await ctx.services.ready()
 
     expect(setup).toHaveBeenCalledTimes(1)
-    // Shallow merge in declaration order — later sets win.
+    // Deep merge in declaration order — later scalars win.
     await expect(first).resolves.toEqual({ options: { a: 1, b: 2, c: 3 } })
     await expect(second).resolves.toEqual({ options: { a: 1, b: 2, c: 3 } })
     // The node API is provided under the package name.
     expect(ctx.services.get('@test/svc')).toEqual({ options: { a: 1, b: 2, c: 3 } })
   })
 
+  it('deep-merges option sets: arrays union, nested objects recurse', async () => {
+    const { ctx } = await createCtx()
+    void ctx.services.install(defineTestService({ options: { roots: ['a'], nested: { x: 1 } } }))
+    void ctx.services.install({ package: '@test/svc', options: { roots: ['b', 'a'], nested: { y: 2 } } })
+    await ctx.services.ready()
+    expect(ctx.services.get('@test/svc')).toEqual({
+      options: { roots: ['a', 'b'], nested: { x: 1, y: 2 } },
+    })
+  })
+
   it('uses the definition mergeOptions when declared', async () => {
     const { ctx } = await createCtx()
     const def = defineTestService({
@@ -164,7 +175,7 @@ describe('wire services (install / ready barrier)', () => {
     })
   })
 
-  it('creates an empty advertisement state at the barrier when nothing installs', async () => {
+  it('creates an empty advertisement state at ready() when nothing installs', async () => {
     const { ctx } = await createCtx()
     await ctx.services.ready()
     expect(ctx.rpc.sharedState.keys()).toContain(DEVFRAME_SERVICES_STATE_KEY)
@@ -182,7 +193,7 @@ describe('wire services (install / ready barrier)', () => {
     await expect((ctx.rpc.invokeLocal as (method: string) => Promise)('test:svc:hello')).resolves.toBe('hi')
   })
 
-  it('post-barrier installs construct immediately; duplicates warn and return the first API', async () => {
+  it('post-ready installs construct immediately; duplicates warn and return the first API', async () => {
     const { ctx } = await createCtx()
     await ctx.services.ready()
     const api = await ctx.services.install(defineTestService({ options: { a: 1 } }))
diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts
index a6457538..70c15d7e 100644
--- a/packages/devframe/src/node/diagnostics.ts
+++ b/packages/devframe/src/node/diagnostics.ts
@@ -194,10 +194,5 @@ export const diagnostics = defineDiagnostics({
         `Invalid service "${p.package}": ${p.reason}`,
       fix: 'A service package\'s default export must be a factory returning a `DevframeServiceDefinition` — an object with `package`, `version`, `scope`, and a `setup` function.',
     },
-    DF0071: {
-      why: (p: { reason: string }) =>
-        `Deferred service installation failed while flushing on the first client connection: ${p.reason}`,
-      fix: 'Call `ctx.services.ready()` explicitly after every devframe\'s setup has run (the first-party adapters do) so installation errors surface at startup instead of at connect time.',
-    },
   },
 })
diff --git a/packages/devframe/src/node/host-services.ts b/packages/devframe/src/node/host-services.ts
index a5e4f98d..a565e31a 100644
--- a/packages/devframe/src/node/host-services.ts
+++ b/packages/devframe/src/node/host-services.ts
@@ -12,7 +12,7 @@ import process from 'node:process'
 import { DEVFRAME_SERVICES_STATE_KEY } from 'devframe/constants'
 import { createDebug } from 'obug'
 import { diagnostics } from './diagnostics'
-import { expandResolveFrom, importServicePackage, satisfiesVersionRange, shallowMergeOptionSets } from './services-install'
+import { deepMergeOptionSets, expandResolveFrom, importServicePackage, satisfiesVersionRange } from './services-install'
 
 const debug = createDebug('devframe:services')
 
@@ -225,15 +225,16 @@ export class DevframeServicesHostImpl implements DevframeServicesHost {
       diagnostics.DF0069({ package: pkg, required: descriptor.version, installed: def.version })
     }
 
-    // Merge every installer's option set in declaration order (later wins on
-    // the default shallow merge, so a host installing last takes precedence).
+    // Merge every installer's option set in declaration order (the default
+    // deep-merge unions arrays and lets later scalars win; a service may
+    // override with its own `mergeOptions`).
     const sets = entries
       .map(entry => entry.input.options)
       .filter(options => options !== undefined)
     const options = def.mergeOptions
       ? def.mergeOptions(sets)
       : sets.length > 0
-        ? shallowMergeOptionSets(sets)
+        ? deepMergeOptionSets(sets)
         : undefined
 
     if (!this.context)
diff --git a/packages/devframe/src/node/rpc-core.ts b/packages/devframe/src/node/rpc-core.ts
index 93006d30..a2a5d6b6 100644
--- a/packages/devframe/src/node/rpc-core.ts
+++ b/packages/devframe/src/node/rpc-core.ts
@@ -130,25 +130,16 @@ export function createContextRpcServer(options: CreateContextRpcServerOptions):
     })
   }
 
-  const onConnected = (connection: DevframeRpcConnection, meta: DevframeNodeRpcSessionMeta): void => {
-    // Safety net for the services collect-then-setup barrier: a host that
-    // never called `ctx.services.ready()` still flushes deferred service
-    // installs before the first client is served. Idempotent and cheap once
-    // fired; failures are reported (not thrown) since a connect hook is no
-    // place to crash — adapters that `await ready()` surface them at startup.
-    void Promise.resolve()
-      .then(() => context.services.ready?.())
-      .catch((error) => {
-        const reason = error instanceof Error ? error.message : String(error)
-        diagnostics.DF0071({ reason, cause: error }, { method: 'error' })
-      })
-    const session: DevframeNodeRpcSession = {
-      meta,
-      rpc: rpcGroup.clients.find(client => (client as any).$meta === meta) as any,
-    }
-    authHandler?.onConnect(connection, session)
-    options.onPeerConnect?.(connection, session)
-  }
+  const onConnected = (authHandler || options.onPeerConnect)
+    ? (connection: DevframeRpcConnection, meta: DevframeNodeRpcSessionMeta): void => {
+        const session: DevframeNodeRpcSession = {
+          meta,
+          rpc: rpcGroup.clients.find(client => (client as any).$meta === meta) as any,
+        }
+        authHandler?.onConnect(connection, session)
+        options.onPeerConnect?.(connection, session)
+      }
+    : undefined
 
   const onDisconnected = (connection: DevframeRpcConnection, meta: DevframeNodeRpcSessionMeta): void => {
     options.onPeerDisconnect?.(connection, meta)
diff --git a/packages/devframe/src/node/services-install.ts b/packages/devframe/src/node/services-install.ts
index 00223556..1a49cf17 100644
--- a/packages/devframe/src/node/services-install.ts
+++ b/packages/devframe/src/node/services-install.ts
@@ -180,13 +180,31 @@ export function satisfiesVersionRange(version: string, range: string): boolean {
   )
 }
 
+function isPlainObject(value: unknown): value is Record {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/** Deep-merge two values with the service option-set rules (see below). */
+function deepMergeTwo(a: unknown, b: unknown): unknown {
+  // Arrays union-dedupe, so multiple installers' `roots` / `langs` accumulate.
+  if (Array.isArray(a) && Array.isArray(b))
+    return [...new Set([...a, ...b])]
+  if (isPlainObject(a) && isPlainObject(b)) {
+    const out: Record = { ...a }
+    for (const key of Object.keys(b))
+      out[key] = key in a ? deepMergeTwo(a[key], b[key]) : b[key]
+    return out
+  }
+  // Scalars / mismatched shapes: later set wins (e.g. `themes` per key).
+  return b
+}
+
 /**
  * Default option-set merge when a service declares no `mergeOptions`:
- * shallow-merge plain objects in declaration order (later sets win); any
- * non-object set collapses the merge to "last one wins".
+ * deep-merge in declaration order — objects recurse, arrays union-dedupe,
+ * scalars take the later value. Covers the built-in services (`roots` /
+ * `langs` union, `themes` per-key last-wins) without a custom hook.
  */
-export function shallowMergeOptionSets(sets: Options[]): Options {
-  if (sets.some(set => typeof set !== 'object' || set === null || Array.isArray(set)))
-    return sets[sets.length - 1] as Options
-  return Object.assign({}, ...sets) as Options
+export function deepMergeOptionSets(sets: Options[]): Options {
+  return sets.reduce((merged, set) => deepMergeTwo(merged, set) as Options)
 }
diff --git a/packages/devframe/src/types/services.ts b/packages/devframe/src/types/services.ts
index c089b916..037492cf 100644
--- a/packages/devframe/src/types/services.ts
+++ b/packages/devframe/src/types/services.ts
@@ -210,15 +210,19 @@ export interface DevframeServicesHost {
   /** Ids of every currently-provided service. */
   keys: () => string[]
   /**
-   * Install a **wire service** (see {@link DevframeServiceDefinition}).
-   * Before {@link DevframeServicesHost.ready} fires, installs are queued —
-   * option sets from every installer accumulate and each service is
-   * constructed **once** at the barrier with the merged options; the
-   * returned promise resolves with the service's node API then (or
-   * `undefined` when an optional descriptor's package can't be imported).
-   * After the barrier, installs construct immediately; installing an
-   * already-installed package returns the existing API (a warning is
-   * emitted when the late install carried options, since they're ignored).
+   * Install a **wire service** (see {@link DevframeServiceDefinition}). The
+   * common path is declarative — list services on `DevframeDefinition.services`
+   * (or `initHub({ services })`) and the adapter installs them for you before
+   * `setup` runs. Call `install()` directly only for the dynamic escape hatch:
+   * a service configured at runtime from data unknown until then.
+   *
+   * Before the pre-setup ready fires, installs are queued and their option
+   * sets deep-merged, constructing each service **once**. After it, an install
+   * constructs immediately; installing an already-installed package returns
+   * the existing API (a warning — `DF0066` — when the late install carried
+   * options, since they're ignored). The returned promise resolves with the
+   * node API (or `undefined` when an optional descriptor's package can't be
+   * imported).
    *
    * `resolveFrom` is where a descriptor's package resolves **from**: a path
    * or file URL (e.g. `import.meta.url`), or an npm package name — typically
@@ -231,13 +235,14 @@ export interface DevframeServicesHost {
     options?: { resolveFrom?: string | null },
   ) => Promise
   /**
-   * Fire the collect-then-setup barrier: resolve every queued descriptor
-   * (importing its package), merge option sets per service, construct each
-   * service once, `provide()` its node API under the package name, and
-   * advertise it to clients via the `devframe:services` shared state.
-   * Idempotent — adapters call it once after every devframe's `setup` has
-   * run; a repeat returns the same promise. Rejects when a `required`
-   * service fails to import or misses its version range.
+   * Construct every queued service — importing descriptor packages, merging
+   * option sets, `provide()`-ing each node API under its package name, and
+   * advertising it to clients via the `devframe:services` shared state.
+   * Idempotent. **Internal**: the adapters call it once, before running any
+   * `setup`, so services are ready for `setup` to consume; application code
+   * uses declarative `services` (or `install()` for the dynamic case) and
+   * never calls this. Rejects when a `required` service fails to import or
+   * misses its version range.
    */
   ready: () => Promise
 }
diff --git a/packages/hub/src/node/__tests__/install-devframe.test.ts b/packages/hub/src/node/__tests__/install-devframe.test.ts
index 950c0d54..0fa31ec7 100644
--- a/packages/hub/src/node/__tests__/install-devframe.test.ts
+++ b/packages/hub/src/node/__tests__/install-devframe.test.ts
@@ -19,6 +19,12 @@ function createContext(): DevframeHubContext {
     views: {
       hostStatic: () => {},
     },
+    // Minimal stub — these tests drive dock/setup wiring, not the services
+    // lifecycle (the demo devframe declares none).
+    services: {
+      install: () => Promise.resolve(undefined),
+      ready: () => Promise.resolve(),
+    },
   } as unknown as DevframeHubContext
   context.docks = new DevframeDocksHost(context)
   // `createHubContext` wires this; the hand-built fake context here does the
diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts
index a1938095..49e55e2e 100644
--- a/packages/hub/src/node/initiate.ts
+++ b/packages/hub/src/node/initiate.ts
@@ -1,7 +1,7 @@
 import type { DevframeInstanceRecord } from 'devframe/internal'
 import type { DevframeAuthHandler } from 'devframe/node/auth'
 import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server'
-import type { ConnectionMeta, DevframeDefinition, DevframeSseOptions, DevframeStorageScope, DevframeWsOptions, McpRouteOptions } from 'devframe/types'
+import type { ConnectionMeta, DevframeDefinition, DevframeServiceInput, DevframeSseOptions, DevframeStorageScope, DevframeWsOptions, McpRouteOptions } from 'devframe/types'
 import type { Buffer } from 'node:buffer'
 import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http'
 import type { Duplex } from 'node:stream'
@@ -20,6 +20,7 @@ import { joinURL, withoutLeadingSlash, withTrailingSlash } from 'ufo'
 import { DEVFRAMES_HUB_BASE, DOCK_RENDERERS_STATE_KEY, normalizeHubBase } from '../constants'
 import { createHubContext } from './context'
 import { diagnostics } from './diagnostics'
+import { prepareDevframe } from './install-devframe'
 
 /** A `devframes` entry with per-mount dock customization. */
 export interface HubDevframeEntry {
@@ -170,6 +171,14 @@ export interface InitHubOptions {
    * (category, icon, a `clientScript` to run in the host page, …).
    */
   devframes?: DevframesInput
+  /**
+   * Host-level wire services to install, on top of whatever the mounted
+   * devframes declare. Constructed (option sets merged) at the pre-setup
+   * barrier, so every devframe's `setup` sees them ready. Reach for this to
+   * configure a shared service centrally — e.g.
+   * `services: [createShikiService({ themes })]`.
+   */
+  services?: DevframeServiceInput[]
   /**
    * Extra RPC declarations registered at context creation, alongside the
    * hub built-ins — forwarded to `createHubContext`'s
@@ -471,9 +480,14 @@ export function initHub(options: InitHubOptions): HubInstance {
       }
 
       const devframes = await resolveDevframesInput(options.devframes ?? [])
-      // Mount each devframe under `/` — its SPA, its meta, and its
-      // auto-registered iframe dock — after guarding the id against the
-      // reserved hub filenames that live directly under the base.
+      // Host-level services declared on `initHub` join the pre-setup
+      // collection alongside every devframe's own declared services.
+      for (const input of options.services ?? [])
+        void ctx.services.install(input)
+      // Pass 1 — mount each devframe under `/` (SPA, meta, iframe
+      // dock) and queue its declared services, guarding the id against the
+      // reserved hub filenames. No setup yet.
+      const setups: (() => Promise)[] = []
       for (const { devframe: def, dock } of devframes) {
         if ((RESERVED_HUB_PATHS as readonly string[]).includes(def.id))
           throw diagnostics.DF8000({ id: def.id })
@@ -483,10 +497,19 @@ export function initHub(options: InitHubOptions): HubInstance {
         if (!/^[\w.-]+$/.test(def.id))
           throw diagnostics.DF8004({ id: def.id })
         const frameBase = withTrailingSlash(joinURL(base, def.id))
-        await ctx.install(def, { base: frameBase, ...(dock ? { dock } : {}) })
+        const run = await prepareDevframe(ctx, def, { base: frameBase, ...(dock ? { dock } : {}) })
+        if (run)
+          setups.push(run)
         frames.push({ id: def.id, base: frameBase, title: def.name })
       }
 
+      // Construct every collected service once, then run the setups — so a
+      // devframe's setup consumes services (its own or another devframe's)
+      // synchronously via `ctx.services.get`.
+      await ctx.services.ready()
+      for (const run of setups)
+        await run()
+
       await options.configure?.(ctx)
 
       // The UI slot publishes its own static config (branding, dock
@@ -495,11 +518,6 @@ export function initHub(options: InitHubOptions): HubInstance {
       // into the connection meta right after this `init` returns.
       await options.ui?.setup?.(ctx)
 
-      // Wire-services barrier: every service declared by an installed
-      // devframe (or installed explicitly during `configure`) is constructed
-      // once here, with its option sets merged across declarers.
-      await ctx.services.ready()
-
       // Publish the renderer manifest — one `ClientScriptEntry` per dock
       // `type`, `importFrom` base-absolute so it resolves to the served module
       // from any page depth. Clients read it from shared state and import a
diff --git a/packages/hub/src/node/install-devframe.ts b/packages/hub/src/node/install-devframe.ts
index dc092b68..4be51d86 100644
--- a/packages/hub/src/node/install-devframe.ts
+++ b/packages/hub/src/node/install-devframe.ts
@@ -48,11 +48,22 @@ function nextAvailableDockId(views: DevframeHubContext['docks']['views'], baseId
  * machinery — e.g. `@vitejs/devtools-kit`'s `createPluginFromDevframe`
  * returns a Vite `Plugin` whose `devtools.setup` ultimately delegates here.
  */
-export async function installDevframe(
+/**
+ * Phase one of an install: run the duplication guard, serve the SPA + meta,
+ * register the iframe dock, and queue the definition's declarative wire
+ * services — everything up to (but not including) `setup(ctx)`. Returns a
+ * deferred setup thunk, or `null` when the devframe was deduplicated.
+ *
+ * The hub's initial batch uses this to collect every devframe's services
+ * across the whole hub, `ready()` them once, and only then run the setups —
+ * so services are ready before any setup, and a plugin can consume a service
+ * another plugin declared regardless of mount order.
+ */
+export async function prepareDevframe(
   ctx: DevframeHubContext,
   d: DevframeDefinition,
   options: InstallDevframeOptions = {},
-): Promise {
+): Promise<(() => Promise) | null> {
   const strategy = d.duplicationStrategy ?? 'warn'
   const isDuplicate = ctx.docks.views.has(d.id)
 
@@ -63,7 +74,7 @@ export async function installDevframe(
       diagnostics.DF8105({ id: d.id, name: d.name })
     // 'warn' and 'silent' both deduplicate: keep the first registration
     // and drop this later one.
-    return
+    return null
   }
 
   // The 'duplicate' strategy lets instances coexist, so the dock id (and,
@@ -107,11 +118,31 @@ export async function installDevframe(
     url: base,
   } as DevframeViewIframe)
 
-  // Queue the definition's declarative wire services ahead of its setup so
-  // their option sets precede setup-time installs in the merge order. The
-  // hub fires the `ctx.services.ready()` barrier once every devframe (and
-  // the host's own configuration) has installed.
+  // Queue the definition's declarative wire services. They're constructed at
+  // the `ctx.services.ready()` barrier the hub fires before running setups.
   for (const input of d.services ?? [])
     void ctx.services.install(input, { resolveFrom: d.packageName })
-  await d.setup(ctx)
+
+  return () => Promise.resolve(d.setup(ctx))
+}
+
+/**
+ * Install a {@link DevframeDefinition} into a hub in one call — serve its SPA,
+ * register its dock, ready its services, and run `setup(ctx)`. The imperative
+ * counterpart to the hub's declarative `devframes` list (which batches the
+ * phases via {@link prepareDevframe}); use it from `configure(ctx)` or
+ * wherever you hold the context to plug in an extra devframe after startup.
+ */
+export async function installDevframe(
+  ctx: DevframeHubContext,
+  d: DevframeDefinition,
+  options: InstallDevframeOptions = {},
+): Promise {
+  const run = await prepareDevframe(ctx, d, options)
+  if (!run)
+    return
+  // `ready()` is idempotent: after the hub's initial barrier this constructs
+  // the just-queued services immediately, before this devframe's setup.
+  await ctx.services.ready()
+  await run()
 }
diff --git a/plugins/assets/src/diagnostics.ts b/plugins/assets/src/diagnostics.ts
index 989c2935..89627a06 100644
--- a/plugins/assets/src/diagnostics.ts
+++ b/plugins/assets/src/diagnostics.ts
@@ -34,9 +34,5 @@ export const diagnostics = defineDiagnostics({
       why: 'The upload streaming channel is unavailable because this devframe was set up with `write: false`.',
       fix: 'This indicates an internal registration bug — `upload` should never be reachable without `write: true`. Please report it.',
     },
-    DP_ASSETS_0008: {
-      why: 'Cannot open the asset: the "@devframes/service-open" wire service is not installed on this host.',
-      fix: 'Install the service package next to the assets plugin (it installs it during setup), or install it host-side via `ctx.services.install(createOpenService())`.',
-    },
   },
 })
diff --git a/plugins/assets/src/index.ts b/plugins/assets/src/index.ts
index fd5d59e1..bcea5d89 100644
--- a/plugins/assets/src/index.ts
+++ b/plugins/assets/src/index.ts
@@ -1,4 +1,5 @@
 import type { DevframeDefinition, RemoteAssets } from 'devframe'
+import process from 'node:process'
 import { defineDevframe } from 'devframe'
 import { resolve } from 'pathe'
 import pkg from '../package.json' with { type: 'json' }
@@ -96,6 +97,11 @@ export interface AssetsDevframeOptions {
  */
 export function createAssetsDevframe(options: AssetsDevframeOptions = {}): DevframeDefinition {
   const id = options.id ?? DEFAULT_ID
+  // Resolve the managed dir at factory time (process.cwd() here equals the
+  // adapter's ctx.cwd — same process) so it can be declared as a service-open
+  // allowed root before any setup runs. Only matters when `dir` points
+  // outside the workspace; an in-workspace `public/` is already allowed.
+  const dir = options.dir ? resolve(process.cwd(), options.dir) : resolve(process.cwd(), 'public')
   const distDir = options.distDir ?? remoteAssets
   const write = options.write ?? true
   const serveStatic = options.serveStatic ?? false
@@ -124,20 +130,18 @@ export function createAssetsDevframe(options: AssetsDevframeOptions = {}): Devfr
       },
     },
     dock: { category: '~builtin' },
-    // Server-highlighted text previews; the SPA falls back to a plain
-    // `
` when the service isn't advertised.
-    services: [{ package: '@devframes/service-shiki' }],
+    // Both wire services are declared, not imperatively installed: devframe
+    // constructs them (deep-merging options across every declarer) before
+    // setup runs. `service-open` gets the managed dir as an allowed root so
+    // out-of-workspace dirs open; the client hits it directly with the
+    // dev-only absolute `fsPath`. `service-shiki` backs server-highlighted
+    // text previews, with a plain `
` fallback when it isn't advertised.
+    services: [
+      { package: '@devframes/service-open', options: { roots: [dir] } },
+      { package: '@devframes/service-shiki' },
+    ],
     async setup(ctx, info) {
       const readOnlyFlag = info?.flags?.readOnly === true
-      const dir = options.dir ? resolve(ctx.cwd, options.dir) : resolve(ctx.cwd, 'public')
-      // Backs open-in-editor / reveal-in-folder. Installed here (not in the
-      // declarative `services` list) because the managed dir — contributed
-      // as an extra allowed root, since it may live outside the workspace —
-      // is only known at setup time.
-      void ctx.services.install(
-        { package: '@devframes/service-open', options: { roots: [dir] } },
-        { resolveFrom: pkg.name },
-      )
       await setupAssets(ctx, {
         dir,
         write: readOnlyFlag ? false : write,
diff --git a/plugins/assets/src/node/index.ts b/plugins/assets/src/node/index.ts
index f14f9838..cf1c3416 100644
--- a/plugins/assets/src/node/index.ts
+++ b/plugins/assets/src/node/index.ts
@@ -2,7 +2,7 @@ import type { DevframeNodeContext } from 'devframe'
 import { existsSync } from 'node:fs'
 import fsp from 'node:fs/promises'
 import { UPLOAD_CHANNEL } from '../rpc/functions/upload'
-import { alwaysFunctions, readFunctions, writeFunctions } from '../rpc/index'
+import { readFunctions, writeFunctions } from '../rpc/index'
 import { configureAssets } from './context'
 import { watchAssetsDir } from './watcher'
 
@@ -64,8 +64,6 @@ export async function setupAssets(ctx: DevframeNodeContext, options: SetupAssets
 
   for (const fn of readFunctions)
     ctx.rpc.register(fn)
-  for (const fn of alwaysFunctions)
-    ctx.rpc.register(fn)
   if (options.write) {
     for (const fn of writeFunctions)
       ctx.rpc.register(fn)
diff --git a/plugins/assets/src/node/scanner.ts b/plugins/assets/src/node/scanner.ts
index e50b5821..01b21237 100644
--- a/plugins/assets/src/node/scanner.ts
+++ b/plugins/assets/src/node/scanner.ts
@@ -31,8 +31,12 @@ function toPublicPath(baseURL: string, posixPath: string): string {
   return joinURL(baseURL, encoded)
 }
 
-/** Builds an {@link AssetInfo} from an already-resolved `fs.Stats`. */
-export function statToAssetInfo(dir: string, baseURL: string, relPath: string, stat: Stats): AssetInfo {
+/**
+ * Builds an {@link AssetInfo} from an already-resolved `fs.Stats`. Pass
+ * `includeFsPath` (dev mode only) to attach the absolute `fsPath` the client
+ * hands to the open wire service.
+ */
+export function statToAssetInfo(dir: string, baseURL: string, relPath: string, stat: Stats, includeFsPath = false): AssetInfo {
   const posixPath = relPath.replace(/\\/g, '/')
   return {
     path: posixPath,
@@ -40,17 +44,18 @@ export function statToAssetInfo(dir: string, baseURL: string, relPath: string, s
     publicPath: toPublicPath(baseURL, posixPath),
     size: stat.size,
     mtime: stat.mtimeMs,
+    ...(includeFsPath ? { fsPath: join(dir, relPath) } : {}),
   }
 }
 
 /** Recursively lists every file under `dir`, sorted alphabetically by path. */
-export async function scanAssets(dir: string, baseURL: string): Promise {
+export async function scanAssets(dir: string, baseURL: string, includeFsPath = false): Promise {
   const files = await glob(['**/*'], { cwd: dir, onlyFiles: true, dot: false })
 
   const infos = await Promise.all(files.map(async (relPath): Promise => {
     try {
       const stat = await fsp.lstat(join(dir, relPath))
-      return statToAssetInfo(dir, baseURL, relPath, stat)
+      return statToAssetInfo(dir, baseURL, relPath, stat, includeFsPath)
     }
     catch {
       // Removed between the glob scan and the stat call — drop it silently,
diff --git a/plugins/assets/src/rpc/functions/list.ts b/plugins/assets/src/rpc/functions/list.ts
index 5c94a880..f7cceb39 100644
--- a/plugins/assets/src/rpc/functions/list.ts
+++ b/plugins/assets/src/rpc/functions/list.ts
@@ -13,6 +13,7 @@ export const assetInfoSchema = s.object({
   publicPath: s.string(),
   size: s.number(),
   mtime: s.number(),
+  fsPath: s.optional(s.string()),
 })
 
 export const list = defineAssetsRpc({
@@ -34,7 +35,8 @@ export const list = defineAssetsRpc({
       // The RPC runtime awaits handlers before validating `returns`; its
       // public setup type currently models schema-backed returns as
       // synchronous.
-      handler: (async (): Promise => scanAssets(assets.dir, assets.baseURL)) as any,
+      // `fsPath` is dev-only — never baked into a static build's dump.
+      handler: (async (): Promise => scanAssets(assets.dir, assets.baseURL, ctx.mode === 'dev')) as any,
     }
   },
 })
diff --git a/plugins/assets/src/rpc/functions/open-in-editor.ts b/plugins/assets/src/rpc/functions/open-in-editor.ts
deleted file mode 100644
index 9516edc5..00000000
--- a/plugins/assets/src/rpc/functions/open-in-editor.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import type { DevframeNodeContext } from 'devframe'
-import { OPEN_SERVICE_PACKAGE } from '@devframes/service-open'
-import { createDefineWrapperWithContext } from 'devframe/rpc'
-import { s } from 'devframe/utils/simple-schema'
-import { diagnostics } from '../../diagnostics'
-import { getAssetsContext } from '../../node/context'
-
-const defineAssetsRpc = createDefineWrapperWithContext()
-
-/**
- * Delegates to the `@devframes/service-open` wire service (installed by the
- * plugin's setup with the managed dir as an allowed root), resolving the
- * path against the managed directory first — so the client only ever sends
- * a root-relative path, never the server's absolute filesystem layout.
- */
-export const openInEditor = defineAssetsRpc({
-  name: 'devframes:plugin:assets:open-in-editor',
-  type: 'action',
-  jsonSerializable: true,
-  args: [s.string()],
-  returns: s.void(),
-  agent: {
-    title: 'Open an asset in the editor',
-    description: 'Open an asset in the user\'s configured editor.',
-    safety: 'action',
-    tags: ['assets'],
-  },
-  setup: (ctx) => {
-    const assets = getAssetsContext(ctx)
-    return {
-      // See `list.ts` for why the async handler is cast.
-      handler: (async (path: string): Promise => {
-        const open = ctx.services.get(OPEN_SERVICE_PACKAGE)
-        if (!open)
-          throw diagnostics.DP_ASSETS_0008()
-        await open.openInEditor({ path: assets.resolvePath(path) })
-      }) as any,
-    }
-  },
-})
diff --git a/plugins/assets/src/rpc/functions/rename.ts b/plugins/assets/src/rpc/functions/rename.ts
index 2857c11e..8abe2b25 100644
--- a/plugins/assets/src/rpc/functions/rename.ts
+++ b/plugins/assets/src/rpc/functions/rename.ts
@@ -52,7 +52,7 @@ export const rename = defineAssetsRpc({
 
         if (from === to) {
           const stat = await fsp.lstat(from)
-          return statToAssetInfo(assets.dir, assets.baseURL, path, stat)
+          return statToAssetInfo(assets.dir, assets.baseURL, path, stat, true)
         }
 
         const targetExists = await fsp.access(to).then(() => true).catch(() => false)
@@ -70,7 +70,7 @@ export const rename = defineAssetsRpc({
         }
 
         const stat = await fsp.lstat(to)
-        return statToAssetInfo(assets.dir, assets.baseURL, nextRelPath, stat)
+        return statToAssetInfo(assets.dir, assets.baseURL, nextRelPath, stat, true)
       }) as any,
     }
   },
diff --git a/plugins/assets/src/rpc/functions/reveal-in-folder.ts b/plugins/assets/src/rpc/functions/reveal-in-folder.ts
deleted file mode 100644
index b7922348..00000000
--- a/plugins/assets/src/rpc/functions/reveal-in-folder.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { DevframeNodeContext } from 'devframe'
-import { OPEN_SERVICE_PACKAGE } from '@devframes/service-open'
-import { createDefineWrapperWithContext } from 'devframe/rpc'
-import { s } from 'devframe/utils/simple-schema'
-import { dirname } from 'pathe'
-import { diagnostics } from '../../diagnostics'
-import { getAssetsContext } from '../../node/context'
-
-const defineAssetsRpc = createDefineWrapperWithContext()
-
-/**
- * Delegates to the `@devframes/service-open` wire service's `openInFinder`,
- * opening the asset's containing folder so it's revealed in the OS file
- * manager rather than launched with its default app (which `download`
- * already covers).
- */
-export const revealInFolder = defineAssetsRpc({
-  name: 'devframes:plugin:assets:reveal-in-folder',
-  type: 'action',
-  jsonSerializable: true,
-  args: [s.string()],
-  returns: s.void(),
-  agent: {
-    title: 'Reveal an asset in the file manager',
-    description: 'Open the OS file manager at the asset\'s containing folder.',
-    safety: 'action',
-    tags: ['assets'],
-  },
-  setup: (ctx) => {
-    const assets = getAssetsContext(ctx)
-    return {
-      // See `list.ts` for why the async handler is cast.
-      handler: (async (path: string): Promise => {
-        const open = ctx.services.get(OPEN_SERVICE_PACKAGE)
-        if (!open)
-          throw diagnostics.DP_ASSETS_0008()
-        await open.openInFinder({ path: dirname(assets.resolvePath(path)) })
-      }) as any,
-    }
-  },
-})
diff --git a/plugins/assets/src/rpc/index.ts b/plugins/assets/src/rpc/index.ts
index 2caeecac..8b6e98e3 100644
--- a/plugins/assets/src/rpc/index.ts
+++ b/plugins/assets/src/rpc/index.ts
@@ -3,26 +3,18 @@ import { capabilities } from './functions/capabilities'
 import { deleteAssets } from './functions/delete'
 import { list } from './functions/list'
 import { mkdir } from './functions/mkdir'
-import { openInEditor } from './functions/open-in-editor'
 import { readImageMeta } from './functions/read-image-meta'
 import { readText } from './functions/read-text'
 import { rename } from './functions/rename'
-import { revealInFolder } from './functions/reveal-in-folder'
 import { upload } from './functions/upload'
 
 /** Read-only RPC — always registered. */
 export const readFunctions = [list, readImageMeta, readText, capabilities] as const
 
-/**
- * Informational actions — launch external apps, never touch the managed
- * directory's contents. Always registered regardless of `write`.
- */
-export const alwaysFunctions = [openInEditor, revealInFolder] as const
-
 /** Mutating RPC — registered only when write actions are enabled. */
 export const writeFunctions = [upload, rename, deleteAssets, mkdir] as const
 
-export const serverFunctions = [...readFunctions, ...alwaysFunctions, ...writeFunctions] as const
+export const serverFunctions = [...readFunctions, ...writeFunctions] as const
 
 declare module 'devframe' {
   interface DevframeRpcServerFunctions extends RpcDefinitionsToFunctions {}
@@ -33,10 +25,8 @@ export { capabilities } from './functions/capabilities'
 export { deleteAssets } from './functions/delete'
 export { assetInfoSchema, list } from './functions/list'
 export { mkdir } from './functions/mkdir'
-export { openInEditor } from './functions/open-in-editor'
 export { readImageMeta } from './functions/read-image-meta'
 export { readText } from './functions/read-text'
 export type { RenameArgs } from './functions/rename'
 export { rename } from './functions/rename'
-export { revealInFolder } from './functions/reveal-in-folder'
 export { upload, UPLOAD_CHANNEL } from './functions/upload'
diff --git a/plugins/assets/src/spa/app/components/AssetDetails.vue b/plugins/assets/src/spa/app/components/AssetDetails.vue
index 651690be..61a1af3c 100644
--- a/plugins/assets/src/spa/app/components/AssetDetails.vue
+++ b/plugins/assets/src/spa/app/components/AssetDetails.vue
@@ -1,4 +1,7 @@
 
 
diff --git a/plugins/messages/src/diagnostics.ts b/plugins/messages/src/diagnostics.ts
index 4d88888f..2ae922a7 100644
--- a/plugins/messages/src/diagnostics.ts
+++ b/plugins/messages/src/diagnostics.ts
@@ -14,9 +14,5 @@ export const diagnostics = defineDiagnostics({
         `"${p.id}" is mounted on a context without a hub messages host (\`ctx.messages\`) — its RPC surface stays registered but no-ops, so the panel will show an empty feed.`,
       fix: 'Mount this devframe through a hub host (`@devframes/hub`\'s `initHub`, or `createHubContext` + `ctx.install`) to get a live message feed.',
     },
-    DP_MESSAGES_0002: {
-      why: 'Cannot open the file: the "@devframes/service-open" wire service is not installed on this host.',
-      fix: 'Install the service package next to the messages plugin (it is declared in the plugin\'s `services`), or install it host-side via `ctx.services.install(createOpenService())`.',
-    },
   },
 })
diff --git a/plugins/messages/src/node/index.ts b/plugins/messages/src/node/index.ts
index 6a88ae7c..60104df2 100644
--- a/plugins/messages/src/node/index.ts
+++ b/plugins/messages/src/node/index.ts
@@ -17,10 +17,10 @@ export function setupMessages(ctx: DevframeNodeContext): void {
   if (!getMessagesHost(ctx))
     diagnostics.DP_MESSAGES_0001({ id: PLUGIN_ID })
 
-  // The detail panel's "open file" affordance delegates to the
+  // The detail panel's "open file" affordance calls the
   // `@devframes/service-open` wire service (declared in the definition's
-  // `services`) through `devframes:plugin:messages:open-file`, which
-  // resolves workspace-relative file positions server-side.
+  // `services`) directly from the client — the service resolves the
+  // workspace-relative file position itself, so the plugin needs no bridge.
   for (const fn of serverFunctions)
     ctx.rpc.register(fn)
 }
diff --git a/plugins/messages/src/rpc/functions/open-file.ts b/plugins/messages/src/rpc/functions/open-file.ts
deleted file mode 100644
index ff1547fe..00000000
--- a/plugins/messages/src/rpc/functions/open-file.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { OPEN_SERVICE_PACKAGE } from '@devframes/service-open'
-import { s } from 'devframe/utils/simple-schema'
-import { isAbsolute, resolve } from 'pathe'
-import { diagnostics } from '../../diagnostics'
-import { defineMessagesRpc } from './_define'
-
-/**
- * Open a message's file position in the user's editor, delegating to the
- * `@devframes/service-open` wire service the plugin declares. Message
- * producers often report workspace-relative files, so the plugin resolves
- * them against `ctx.workspaceRoot` server-side — the client never needs the
- * server's filesystem layout. The panel gates its affordance on the service
- * advertisement (`rpc.services.has('@devframes/service-open')`).
- */
-export const messagesOpenFile = defineMessagesRpc({
-  name: 'devframes:plugin:messages:open-file',
-  type: 'action',
-  jsonSerializable: true,
-  args: [s.object({
-    file: s.string(),
-    line: s.optional(s.number()),
-    column: s.optional(s.number()),
-  })],
-  returns: s.void(),
-  setup: ctx => ({
-    handler: (async (input: { file: string, line?: number, column?: number }): Promise => {
-      const open = ctx.services.get(OPEN_SERVICE_PACKAGE)
-      if (!open)
-        throw diagnostics.DP_MESSAGES_0002()
-      const path = isAbsolute(input.file) ? input.file : resolve(ctx.workspaceRoot, input.file)
-      await open.openInEditor({ path, line: input.line, column: input.column })
-    }) as any,
-  }),
-})
diff --git a/plugins/messages/src/rpc/index.ts b/plugins/messages/src/rpc/index.ts
index f0e1c8b2..6ff4bcc1 100644
--- a/plugins/messages/src/rpc/index.ts
+++ b/plugins/messages/src/rpc/index.ts
@@ -2,7 +2,6 @@ import type { RpcDefinitionsToFunctions } from 'devframe/rpc'
 import { messagesAdd } from './functions/add'
 import { messagesClear } from './functions/clear'
 import { messagesList } from './functions/list'
-import { messagesOpenFile } from './functions/open-file'
 import { messagesRemove } from './functions/remove'
 import { messagesUpdate } from './functions/update'
 
@@ -17,7 +16,6 @@ export const serverFunctions = [
   messagesUpdate,
   messagesRemove,
   messagesClear,
-  messagesOpenFile,
 ] as const
 
 declare module 'devframe' {
diff --git a/plugins/messages/test/dev-server.test.ts b/plugins/messages/test/dev-server.test.ts
index 524a456d..58d968e8 100644
--- a/plugins/messages/test/dev-server.test.ts
+++ b/plugins/messages/test/dev-server.test.ts
@@ -43,16 +43,16 @@ describe('messages dev-server (hub context)', () => {
     expect(meta.websocket).toBe(server.port)
   })
 
-  it('registers the open-file bridge and the open service alongside the feed RPCs', () => {
+  it('installs the open service (called directly by the client) alongside the feed RPCs', () => {
     const names = Array.from(server.ctx.rpc.definitions.keys())
     expect(names).toContain('devframes:plugin:messages:list')
     expect(names).toContain('devframes:plugin:messages:add')
     expect(names).toContain('devframes:plugin:messages:update')
     expect(names).toContain('devframes:plugin:messages:remove')
     expect(names).toContain('devframes:plugin:messages:clear')
-    expect(names).toContain('devframes:plugin:messages:open-file')
-    // The declared `@devframes/service-open` wire service is installed and
-    // registers its own scoped RPC.
+    // The detail panel calls the declared `@devframes/service-open` wire
+    // service directly; the plugin registers no open-file bridge of its own.
+    expect(names).not.toContain('devframes:plugin:messages:open-file')
     expect(names).toContain('devframes:service:open:open-in-editor')
     expect(server.ctx.services.has('@devframes/service-open')).toBe(true)
   })
diff --git a/services/open/src/diagnostics.ts b/services/open/src/diagnostics.ts
index c755271c..447352d2 100644
--- a/services/open/src/diagnostics.ts
+++ b/services/open/src/diagnostics.ts
@@ -6,10 +6,6 @@ import { defineDiagnostics } from 'devframe/utils/nostics'
 export const diagnostics = defineDiagnostics({
   docsBase: 'https://devfra.me/errors',
   codes: {
-    DS_OPEN_0001: {
-      why: (p: { path: string }) => `Refusing to open "${p.path}": the path is not absolute.`,
-      fix: 'Resolve the path on the caller side (e.g. against the workspace root or your plugin\'s managed directory) before calling the open service.',
-    },
     DS_OPEN_0002: {
       why: (p: { path: string }) => `Refusing to open "${p.path}": the path is outside the workspace root and every configured extra root.`,
       fix: 'The open service only touches files under the workspace root by default. Pass additional allowed directories via the service\'s `roots` option when your tool manages files elsewhere (e.g. a global storage dir).',
diff --git a/services/open/src/index.ts b/services/open/src/index.ts
index 3d43d15b..b4017f2e 100644
--- a/services/open/src/index.ts
+++ b/services/open/src/index.ts
@@ -26,7 +26,11 @@ export interface OpenServiceOptions {
 }
 
 export interface OpenInEditorInput {
-  /** Absolute path of the file to open. */
+  /**
+   * File to open — absolute, or relative to the service's `workspaceRoot`
+   * (so a client with only a workspace-relative path, e.g. a message's file
+   * position, can call this directly without a server-side bridge).
+   */
   path: string
   line?: number
   column?: number
@@ -57,11 +61,12 @@ declare module 'devframe' {
 /**
  * The open wire service — `open-in-editor` / `open-in-finder` RPC shared by
  * every plugin on the host, replacing per-plugin registrations of the
- * (deprecated) `devframe/recipes/common-rpc-functions` recipes. Callers pass
- * **absolute** paths; the service refuses paths outside the workspace root
- * and the configured extra {@link OpenServiceOptions.roots} (`DS_OPEN_0002`),
- * and gates editor commands to the `KNOWN_EDITORS` picklist so the RPC
- * surface can't spawn arbitrary commands.
+ * (deprecated) `devframe/recipes/common-rpc-functions` recipes. Paths may be
+ * absolute or relative to the `workspaceRoot`; the service refuses paths
+ * outside the workspace root and the configured extra
+ * {@link OpenServiceOptions.roots} (`DS_OPEN_0002`), and gates editor
+ * commands to the `KNOWN_EDITORS` picklist so the RPC surface can't spawn an
+ * arbitrary command.
  */
 export function createOpenService(options?: OpenServiceOptions): DevframeServiceDefinition {
   return {
@@ -74,11 +79,12 @@ export function createOpenService(options?: OpenServiceOptions): DevframeService
     setup(ctx, { options }) {
       const allowedRoots = [ctx.workspaceRoot, ...(options?.roots ?? [])].map(root => resolve(root))
 
-      /** Absolute + contained in one of the allowed roots, or throws. */
+      /**
+       * Resolve `path` (relative paths against `workspaceRoot`) and assert it
+       * lands inside one of the allowed roots, or throw.
+       */
       function assertAllowedPath(path: string): string {
-        if (!isAbsolute(path))
-          throw diagnostics.DS_OPEN_0001({ path })
-        const resolved = resolve(path)
+        const resolved = isAbsolute(path) ? resolve(path) : resolve(ctx.workspaceRoot, path)
         const contained = allowedRoots.some((root) => {
           const rel = relative(root, resolved)
           return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
diff --git a/services/open/test/service.test.ts b/services/open/test/service.test.ts
index e5381df9..ff1857c6 100644
--- a/services/open/test/service.test.ts
+++ b/services/open/test/service.test.ts
@@ -66,15 +66,23 @@ describe('@devframes/service-open', () => {
     expect(launchEditor).toHaveBeenLastCalledWith(join(dir, 'a.ts'), 'zed')
   })
 
-  it('refuses relative paths and paths outside the allowed roots', async () => {
+  it('resolves relative paths against the workspace root', async () => {
+    const { ctx, dir } = await createCtx()
+    const install = ctx.services.install(createOpenService())
+    await ctx.services.ready()
+    const api = await install
+
+    await api!.openInEditor({ path: 'src/a.ts' })
+    expect(launchEditor).toHaveBeenCalledWith(join(dir, 'src/a.ts'), undefined)
+  })
+
+  it('refuses paths outside the allowed roots', async () => {
     const { ctx } = await createCtx()
     const install = ctx.services.install(createOpenService())
     await ctx.services.ready()
     const api = await install
 
-    await expect(api!.openInEditor({ path: 'src/a.ts' })).rejects.toThrowError(/not absolute/)
     await expect(api!.openInFinder({ path: '/etc/passwd' })).rejects.toThrowError(/outside the workspace root/)
-    expect(launchEditor).not.toHaveBeenCalled()
     expect(open).not.toHaveBeenCalled()
   })
 
diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-messages/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-messages/node.snapshot.js
index 0c1ba411..08909cd5 100644
--- a/tests/__snapshots__/tsnapi/@devframes/plugin-messages/node.snapshot.js
+++ b/tests/__snapshots__/tsnapi/@devframes/plugin-messages/node.snapshot.js
@@ -1,10 +1,7 @@
 /**
  * Generated by tsnapi — public API snapshot of `@devframes/plugin-messages/node`
  */
-// #region Functions
-export function setupMessages(_) {}
-// #endregion
-
 // #region Other
 export { serverFunctions }
+export { setupMessages }
 // #endregion
\ No newline at end of file
diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-messages/rpc.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-messages/rpc.snapshot.d.ts
index cb4fdfdf..1d267a3d 100644
--- a/tests/__snapshots__/tsnapi/@devframes/plugin-messages/rpc.snapshot.d.ts
+++ b/tests/__snapshots__/tsnapi/@devframes/plugin-messages/rpc.snapshot.d.ts
@@ -72,47 +72,5 @@ export declare const serverFunctions: readonly [{
   snapshot?: boolean;
   __cache?: WeakMap>>> | undefined;
   __promise?: import("devframe/rpc").Thenable>> | undefined;
-}, {
-  name: "devframes:plugin:messages:open-file";
-  type?: "action" | undefined;
-  cacheable?: boolean;
-  args: readonly [import("devframe/utils/simple-schema").SimpleSchema<{
-    file: string;
-    line?: number | undefined;
-    column?: number | undefined;
-  }, {
-    file: string;
-    line?: number | undefined;
-    column?: number | undefined;
-  }>];
-  returns: import("devframe/utils/simple-schema").SimpleSchema;
-  jsonSerializable?: boolean;
-  agent?: import("devframe").RpcFunctionAgentOptions;
-  setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined;
-  handler?: ((args_0: {
-    file: string;
-    line?: number | undefined;
-    column?: number | undefined;
-  }) => import("devframe/rpc").Thenable) | undefined;
-  dump?: import("devframe/rpc").RpcDump<[{
-    file: string;
-    line?: number | undefined;
-    column?: number | undefined;
-  }], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined;
-  snapshot?: boolean;
-  __cache?: WeakMap>>> | undefined;
-  __promise?: import("devframe/rpc").Thenable>> | undefined;
 }];
 // #endregion
\ No newline at end of file