Skip to content

Commit ccf0a42

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/service-packages
# Conflicts: # docs/guide/services.md # packages/devframe/src/adapters/build.ts # packages/devframe/src/adapters/embedded.ts # packages/devframe/src/adapters/initiate.ts # packages/devframe/src/adapters/mcp/build-server.ts # packages/hub/src/node/install-devframe.ts
2 parents 2027b4b + 162e616 commit ccf0a42

115 files changed

Lines changed: 1019 additions & 141 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/errors/DF8111.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8111: Bare-Specifier Client Script Without Host Resolution
6+
7+
## Message
8+
9+
> Dock "`{id}`" declares the bare-specifier client script "`{specifier}`", but this host advertises no client-module resolution — the browser cannot resolve a bare npm specifier natively, so the script will fail to load.
10+
11+
## Cause
12+
13+
A dock entry's client script (`clientScript` on iframe docks, `action`, `renderer`) names an npm module (`'vite-plugin-vue-tracer/client/vite-devtools'`) as its `importFrom`. Client scripts load with a native browser `import()`, and a browser only resolves URL specifiers — bare specifiers work when the **host runtime** resolves them, advertised as `ConnectionMeta.configs.dock.clientModuleResolution` (a URL template whose `{specifier}` token is replaced with the specifier). This host declared none, so every client-script loader will throw `TypeError: Failed to resolve module specifier` for this entry.
14+
15+
## Example
16+
17+
```ts
18+
initHub({
19+
base: '/__devframes/',
20+
configure(ctx) {
21+
ctx.docks.register({
22+
type: 'action',
23+
id: 'vue-tracer',
24+
title: 'Vue Tracer',
25+
icon: 'ph:crosshair-simple-duotone',
26+
// ✗ Bare specifier on a host with no `clientModuleResolution`
27+
action: { importFrom: 'vite-plugin-vue-tracer/client/vite-devtools' },
28+
})
29+
},
30+
})
31+
```
32+
33+
## Fix
34+
35+
Pick whichever side you control:
36+
37+
- **Run under a host that resolves bare specifiers.** A Vite host serves any npm module through its own module graph — declare `initHub({ clientModuleResolution: '/@id/{specifier}' })`. `@devframes/vite/hub` declares this by default, so the example above is fine there; the script's transitive bare imports work too and share the app's module graph.
38+
- **Ship the script as a self-contained bundle** and pass a URL the host serves as `importFrom` (the a11y inspector pattern): `{ importFrom: '/__devframes/my-agent/inject.js' }` after mounting the bundle's directory with `ctx.host.mountStatic(...)`.
39+
- **Resolve it in the viewer.** A custom viewer may pass `createDevframeClientHost({ resolveClientModule })` (or ship a page import map); the warning is then safe to disregard — it fires because the *server* can't know a viewer will cover the gap.
40+
41+
## Source
42+
43+
- [`packages/hub/src/node/host-docks.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-docks.ts)`DevframeDocksHost.register()` warns when a bare-specifier client script registers on a host whose `staticConfig.dock` declares no `clientModuleResolution`.

docs/guide/client-assets.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,13 @@ import pkg from '../package.json' with { type: 'json' }
4646
const distDir: RemoteAssets = {
4747
package: '@acme/my-tool-assets',
4848
version: pkg.version,
49-
resolveFrom: import.meta.url,
5049
}
5150

5251
export default defineDevframe({
5352
id: 'my-tool',
5453
version: pkg.version,
5554
packageName: pkg.name,
55+
importMetaUrl: import.meta.url,
5656
cli: { distDir },
5757
setup(ctx) {
5858
//
@@ -62,11 +62,13 @@ export default defineDevframe({
6262

6363
The UI mounts as usual — the first request for each file is streamed from a CDN and written to a local cache; subsequent requests are served from disk.
6464

65+
The definition's [`importMetaUrl`](./devframe-definition#resolving-against-the-plugins-own-dependencies) supplies the resolution base, so a remote source needs only its `package` and `version`. A per-source `resolveFrom` overrides that base for one source, and an explicit `resolveFrom: null` opts a source out of the installed-copy lookup entirely.
66+
6567
### How assets resolve
6668

6769
For each request the source resolves in order:
6870

69-
1. **Locally installed package** — resolved from `resolveFrom` (`import.meta.url`). If `@acme/my-tool-assets` is installed next to your tool, it's served directly with no network. This is the offline path.
71+
1. **Locally installed package** — resolved from `resolveFrom`, which defaults to the definition's `importMetaUrl`. If `@acme/my-tool-assets` is installed next to your tool, it's served directly with no network. This is the offline path.
7072
2. **On-disk cache** — files already fetched, under the project's storage directory.
7173
3. **CDN back-proxy**[jsDelivr](https://www.jsdelivr.com/) by default, mirroring npm. Each file streams to the browser and is cached on the way past.
7274

@@ -78,7 +80,7 @@ Exact-version URLs are immutable, so a cached file never goes stale.
7880
|-------|---------|
7981
| `package` | npm package holding the built assets. |
8082
| `version` | Exact version to serve — usually your tool's own `pkg.version`. |
81-
| `resolveFrom` | `import.meta.url` of the declaring module; enables the zero-network path from a locally installed copy. Omit to skip straight to cache + CDN. |
83+
| `resolveFrom` | Resolution base for the zero-network path from a locally installed copy. Defaults to the definition's `importMetaUrl`; set it to override that for one source, or to `null` to skip straight to cache + CDN. |
8284
| `path` | Subpath inside the package the assets live under. Defaults to `dist`. |
8385
| `provider` | `'jsdelivr'` (default), `'unpkg'`, or a custom provider for an internal mirror. |
8486
| `offline` | `true` serves only from a local install or the cache — never the network. |
@@ -109,7 +111,6 @@ A custom provider supplies the file URL, and optionally a file listing (used for
109111
const distDir: RemoteAssets = {
110112
package: '@acme/my-tool-assets',
111113
version: pkg.version,
112-
resolveFrom: import.meta.url,
113114
provider: {
114115
fileUrl: (name, version, file) =>
115116
`https://npm.internal.acme.com/${name}@${version}/${file}`,
@@ -119,7 +120,7 @@ const distDir: RemoteAssets = {
119120

120121
### Publishing the assets
121122

122-
The assets package is an ordinary npm package that ships the built UI under `path` (default `dist`) and exposes its `package.json` so `resolveFrom` can locate it:
123+
The assets package is an ordinary npm package that ships the built UI under `path` (default `dist`) and exposes its `package.json` so the resolver can locate it:
123124

124125
```json
125126
{

docs/guide/client-context.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,12 @@ A script that fails to import is logged and retried on the next dock update.
136136

137137
### Shipping a client script
138138

139-
Build the script as a single self-contained ES module — it loads outside any chunk graph or import map. Attach it when mounting the devframe:
139+
`importFrom` accepts two shapes:
140+
141+
- **A URL the host serves** — a single self-contained ES module, loading outside any chunk graph. Works on every host.
142+
- **A bare npm specifier** (`'vite-plugin-vue-tracer/client/vite-devtools'`) — resolved through the host runtime, where supported.
143+
144+
For the URL shape, attach the built bundle when mounting the devframe:
140145

141146
```ts
142147
await ctx.install(myDevframe, {
@@ -146,6 +151,35 @@ await ctx.install(myDevframe, {
146151

147152
Under Vite, `/@fs/<absolute path>` serves the built bundle directly; other hosts mount the bundle's directory statically and pass that URL instead.
148153

154+
### Bare npm specifiers
155+
156+
Bare specifiers are a **host-runtime capability**. A host that can serve npm modules to the browser advertises a resolution template as `ConnectionMeta.configs.dock.clientModuleResolution` — the `{specifier}` token is replaced with the specifier, and every client-script loader (the client host, the hub-ui viewers, `__client-imports.js`) applies it before importing:
157+
158+
```ts
159+
// A Vite host resolves bare specifiers through its own module graph.
160+
// `@devframes/vite/hub` declares this by default.
161+
initHub({ clientModuleResolution: '/@id/{specifier}' })
162+
```
163+
164+
On a Vite host, `/@id/<specifier>` routes the import through Vite's own resolution and import-analysis, so the script's transitive bare imports work too and resolve in the same module graph as the inspected app — a plugin whose injected app-side code and dock client script import the same modules shares their instances. A plugin can then declare its dock with just the specifier:
165+
166+
```ts
167+
ctx.docks.register({
168+
type: 'action',
169+
id: 'vue-tracer',
170+
title: 'Vue Tracer',
171+
icon: 'ph:crosshair-simple-duotone',
172+
action: { importFrom: 'vite-plugin-vue-tracer/client/vite-devtools' },
173+
})
174+
```
175+
176+
A host that declares no template (Next.js today) supports the URL shape only — registering a bare specifier there warns [`DF8111`](/errors/DF8111). A viewer can also resolve bare specifiers itself with `createDevframeClientHost({ resolveClientModule })`, which wins over the host template.
177+
178+
Two guarantees to design against:
179+
180+
- **Client scripts always execute in the inspected page's realm** — the same `window` as the app being inspected.
181+
- **Module identity is best-effort, realm identity is the contract.** On Vite hosts a bare specifier shares the app's module graph; elsewhere a script ships as its own bundle. A plugin keeping shared state between its injected app code and its dock script should anchor that state on `globalThis` (vue-tracer's `__vue_tracer__` store is the reference pattern) rather than rely on both sides importing one module instance.
182+
149183
### Dual boots
150184

151185
The [a11y inspector](/plugins/a11y)'s in-page agent is the canonical client script, and it boots both ways from one bundle: the default export accepts the client-script context (mirroring each scan into the hub's messages feed), while a deferred, globally-guarded self-boot lets a plain `<script type="module">` start the same agent outside a hub. The context-ful call wins because the hub invokes the default export before the deferred self-boot runs.

docs/guide/devframe-definition.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export default defineDevframe({
1717
name: 'My Devframe',
1818
version: '1.0.0',
1919
packageName: 'my-devframe',
20+
importMetaUrl: import.meta.url,
2021
homepage: 'https://github.com/me/my-devframe',
2122
description: 'A one-line summary of what the tool does.',
2223
icon: 'ph:gauge-duotone',
@@ -45,6 +46,7 @@ export default defineDevframe({
4546
| `name` | `string` | **Required.** Display name shown in the dock and agent manifests. |
4647
| `version` | `string` | **Required.** Semver of the tool, surfaced in hub UIs and diagnostics. |
4748
| `packageName` | `string` | **Required.** npm package name the devframe ships in (e.g. `@scope/my-tool`). |
49+
| `importMetaUrl` | `string` | **Recommended.** Always pass `import.meta.url`. The resolution base for the tool's own dependency graph: it becomes the default `resolveFrom` for any [remote assets](./client-assets) the devframe hosts, and the base the host resolves declared [services](./services#wire-services) from — so a plugin ships an assets or service package as its own dependency instead of asking users to install it. See [Resolving against the plugin's own dependencies](#resolving-against-the-plugins-own-dependencies). |
4850
| `homepage` | `string` | **Required.** Project homepage or documentation URL. |
4951
| `description` | `string` | **Required.** One-line summary of what the tool does. |
5052
| `icon` | `string \| { light, dark }` | Optional Iconify name or URL; supports light/dark pairs. |
@@ -67,6 +69,7 @@ export default defineDevframe({
6769
name: 'My Devframe', // display label
6870
version: pkg.version,
6971
packageName: pkg.name,
72+
importMetaUrl: import.meta.url,
7073
homepage: pkg.homepage,
7174
description: pkg.description,
7275
setup(ctx) { /**/ },
@@ -75,6 +78,36 @@ export default defineDevframe({
7578

7679
The default import with a `with { type: 'json' }` attribute resolves under both bundlers and Node's native TypeScript execution. Bundlers also support the destructured `import { version } from '../package.json'` form when the devframe is always bundled before it runs.
7780

81+
### Resolving against the plugin's own dependencies
82+
83+
A devframe often ships companion packages — a separate `--assets` package holding its built SPA, or a service package it consumes. `importMetaUrl` lets the host resolve those against the plugin's **own** installed dependencies rather than the consuming app's, so the plugin declares them as its dependencies and users install nothing extra.
84+
85+
```ts
86+
import pkg from '../package.json' with { type: 'json' }
87+
88+
export default defineDevframe({
89+
id: 'my-devframe',
90+
name: 'My Devframe',
91+
version: pkg.version,
92+
packageName: pkg.name,
93+
importMetaUrl: import.meta.url,
94+
homepage: pkg.homepage,
95+
description: pkg.description,
96+
cli: {
97+
// Served from the locally installed `my-devframe--assets` — resolved via
98+
// `importMetaUrl`, so it works under pnpm's strict layout with zero network.
99+
distDir: { package: `${pkg.name}--assets`, version: pkg.version },
100+
},
101+
services: [
102+
// Imported from `my-devframe`'s own dependency graph.
103+
{ package: '@scope/my-service', version: pkg.version },
104+
],
105+
setup(ctx) { /**/ },
106+
})
107+
```
108+
109+
For a remote assets source, `importMetaUrl` is the default `resolveFrom`; a per-source `resolveFrom` still wins, and an explicit `resolveFrom: null` opts out of the installed-copy lookup. See [Client Assets](./client-assets) and [Cross-Plugin Services](./services#wire-services) for the full resolution order.
110+
78111
### Runtime flags
79112

80113
The `ctx.mode` field is either `'dev'` or `'build'`. Use it to gate work that should only run in one runtime:

docs/guide/services.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,12 @@ Two declaration merges make it fully typed for consumers: the fully-qualified RP
103103

104104
### Declaring
105105

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

108108
```ts
109109
// plugin side — on the definition
110110
defineDevframe({
111+
importMetaUrl: import.meta.url, // resolution base for the declared packages
111112
services: [
112113
{ package: '@devframes/service-open' },
113114
{ package: '@devframes/service-shiki', version: '^1', options: { langs: ['vue'] } },

examples/a11y-messages-playground/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "a11y-messages-playground",
33
"type": "module",
4-
"version": "0.9.0",
4+
"version": "0.9.1",
55
"private": true,
66
"description": "Hub playground that pairs the a11y and messages plugins over a demo app full of accessibility issues.",
77
"homepage": "https://github.com/devframes/devframe/tree/main/examples/a11y-messages-playground",
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Demo Dock Client
2+
3+
The shared dock client script the two reference hubs consume in their two supported shapes — one package, both `importFrom` forms:
4+
5+
- **`hub-vite`** registers it by **bare specifier** (`action: { importFrom: 'demo-dock-client' }`). The Vite host advertises `clientModuleResolution: '/@id/{specifier}'` (the `@devframes/vite/hub` default), so the client host imports `src/index.ts` through Vite's own module graph — Vite transforms the linked source directly (no build needed on this path) and resolves its bare `nanoevents` import there too.
6+
- **`hub-next`** mounts the prebuilt **self-contained bundle** (`dist/bundle.mjs`, nanoevents inlined) statically and passes the served URL. Next declares no `clientModuleResolution`, so the URL shape is the supported one there.
7+
8+
The script itself demonstrates the state pattern bare-specifier plugins should follow: shared state anchored on `globalThis` (`__devframes_demo_dock_client__`), the same design as `vite-plugin-vue-tracer`'s `__vue_tracer__` store — realm identity is the contract, module identity is best-effort. On each dock activation it bumps the shared counter and reports into the hub's messages feed, naming the URL it was loaded from.
9+
10+
## Entries
11+
12+
| Entry | Resolves to | Role |
13+
|---|---|---|
14+
| `demo-dock-client` | `src/index.ts` (source, deps bare) | Bare-specifier consumption through a host's module graph |
15+
|| `dist/bundle.mjs` (self-contained build) | URL consumption on hosts without bare-specifier resolution |
16+
| `demo-dock-client/node` | `dist/node.mjs` (build) | Node helper exporting `demoDockClientBundlePath` for static mounting |
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "demo-dock-client",
3+
"type": "module",
4+
"version": "0.9.1",
5+
"private": true,
6+
"description": "Reference dock client script for the hub examples: bare npm imports and globalThis-anchored shared state.",
7+
"homepage": "https://github.com/devframes/devframe/tree/main/examples/demo-dock-client",
8+
"exports": {
9+
".": "./src/index.ts",
10+
"./node": "./dist/node.mjs",
11+
"./package.json": "./package.json"
12+
},
13+
"scripts": {
14+
"build": "tsdown",
15+
"typecheck": "tsc --noEmit"
16+
},
17+
"dependencies": {
18+
"nanoevents": "catalog:frontend"
19+
},
20+
"devDependencies": {
21+
"@devframes/hub": "workspace:*",
22+
"@types/node": "catalog:types",
23+
"tsdown": "catalog:build"
24+
}
25+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { DockClientScriptContext } from '@devframes/hub/client'
2+
import type { Emitter } from 'nanoevents'
3+
import { createNanoEvents } from 'nanoevents'
4+
5+
interface DemoEvents {
6+
activated: (count: number) => void
7+
}
8+
9+
interface DemoStore {
10+
/** How many times the demo dock has been activated on this page. */
11+
activations: number
12+
/** Shared emitter — every module instance converges on this one. */
13+
events: Emitter<DemoEvents>
14+
}
15+
16+
const KEY_GLOBAL = '__devframes_demo_dock_client__'
17+
18+
/**
19+
* Shared state anchored on `globalThis`, the pattern
20+
* `vite-plugin-vue-tracer`'s `__vue_tracer__` store establishes: the same
21+
* script may load as a Vite-graph module on one host and as a self-contained
22+
* bundle on another, so two module instances must converge on one store
23+
* rather than rely on module identity. Realm identity (the inspected page's
24+
* `window`) is the contract; module identity is best-effort.
25+
*/
26+
function getStore(): DemoStore {
27+
const holder = globalThis as Record<string, unknown> & { [KEY_GLOBAL]?: DemoStore }
28+
if (!holder[KEY_GLOBAL]) {
29+
const store: DemoStore = { activations: 0, events: createNanoEvents<DemoEvents>() }
30+
Object.defineProperty(holder, KEY_GLOBAL, { value: store, configurable: true, enumerable: false })
31+
}
32+
return holder[KEY_GLOBAL]!
33+
}
34+
35+
/**
36+
* The dock `action` client script: counts activations in the shared store and
37+
* mirrors each one into the hub's messages feed, so both consumption modes
38+
* (bare specifier through the host's module graph, self-contained bundle by
39+
* URL) demonstrably run the same code against the same state.
40+
*/
41+
export default function setup(ctx: DockClientScriptContext): void {
42+
const store = getStore()
43+
ctx.current.events.on('entry:activated', () => {
44+
store.activations += 1
45+
store.events.emit('activated', store.activations)
46+
void ctx.messages.info(`Demo client script activated (#${store.activations} this page)`, {
47+
description: `Loaded from ${new URL(import.meta.url).pathname}`,
48+
})
49+
})
50+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { fileURLToPath } from 'node:url'
2+
3+
/**
4+
* Absolute path of the prebuilt, self-contained client script
5+
* (`dist/bundle.mjs`, nanoevents inlined). A host without bare-specifier
6+
* resolution mounts this file's directory statically and passes the served
7+
* URL as the dock's `importFrom` — the same pattern as
8+
* `@devframes/plugin-a11y`'s `a11yAgentBundlePath`.
9+
*/
10+
export const demoDockClientBundlePath: string = fileURLToPath(new URL('./bundle.mjs', import.meta.url))

0 commit comments

Comments
 (0)