Skip to content

Commit b3b4134

Browse files
authored
feat(hub): resolve bare-specifier client scripts through the host runtime (#257)
1 parent ea78f24 commit b3b4134

35 files changed

Lines changed: 781 additions & 35 deletions

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-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.
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.0",
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))
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"lib": ["esnext", "dom"],
5+
"types": ["node"],
6+
"noEmit": true
7+
},
8+
"include": ["src", "tsdown.config.ts"],
9+
"exclude": ["dist", "node_modules"]
10+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { defineConfig } from 'tsdown'
2+
3+
const tsconfig = '../../tsconfig.base.json'
4+
5+
// The bare-specifier consumption path needs no build at all: the package's
6+
// `.` export points straight at `src/index.ts`, which a Vite host transforms
7+
// like any linked workspace source (hub-vite imports `'demo-dock-client'`
8+
// via the `/@id/{specifier}` template). What gets built here is only the
9+
// **URL-shape** consumption path:
10+
// 1. `dist/bundle.mjs` — self-contained (nanoevents inlined), for hosts
11+
// without bare-specifier resolution (hub-next mounts it statically and
12+
// passes the served URL as `importFrom`);
13+
// 2. `dist/node.mjs` — the node-side path helper the Next host uses to
14+
// locate the bundle.
15+
export default defineConfig([
16+
{
17+
clean: true,
18+
platform: 'browser',
19+
tsconfig,
20+
dts: false,
21+
outExtensions: () => ({ js: '.mjs' }),
22+
entry: { bundle: 'src/index.ts' },
23+
deps: { alwaysBundle: ['nanoevents'] },
24+
},
25+
{
26+
clean: false,
27+
platform: 'node',
28+
tsconfig,
29+
dts: false,
30+
outExtensions: () => ({ js: '.mjs' }),
31+
entry: { node: 'src/node.ts' },
32+
},
33+
])

examples/hub-next/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ The instance is memoized on `globalThis`, so Next's dev-time module re-evaluatio
4747
- `createDevframeClientHost()` boots the hub's framework-level client runtime in the host page: it publishes the shared client context and imports each dock's `clientScript` (here, the a11y agent) so plugins run code in the page being inspected
4848
- The **JSON Render** dock renders through a **local React renderer** (`src/client/json-render/react-renderer.tsx` - a compact React port of the base catalog) registered at `createDevframeClientHost({ renderers })`. The hub *also* publishes the reference Vue frontend through its renderer manifest (`renderers: [jsonRenderUiRenderer()]` on `initHub`), but local registration takes precedence - witnessing that any frontend implementing the `JsonRenderDockRenderer` contract can replace the reference one. Delete the local `renderers` option and the same dock renders via the manifest-served module instead. (The sibling `hub-vite` witness ships no local renderer and consumes the manifest directly - the other side of the swap seam.)
4949
- The **No Renderer** dock witnesses the missing-renderer path: its type is covered by nothing, so `renderers.mount()` resolves `{ status: 'missing-renderer' }` and the shell shows *No renderer for "demo-unrendered" in the current environment* instead of a dead panel
50+
- The **Client Script Demo** dock witnesses the **URL shape of client scripts**: this host declares no `clientModuleResolution` (Next's bundler exposes no browser-reachable on-demand module URL, so bare-specifier client scripts are unsupported here), so it mounts `demo-dock-client`'s prebuilt self-contained bundle statically and passes the served URL as `action.importFrom`. The sibling `hub-vite` host consumes the **same package** as a bare npm specifier through its `/@id/{specifier}` template - the two shapes of `importFrom` side by side
5051

5152
## Hosting built-in plugins in a bundler
5253

@@ -58,6 +59,7 @@ The plugins run node-side (child processes, the native `zigpty` PTY backend) and
5859
|---|---|
5960
| `src/client/devframe/next-devframe-hub.ts` | The Next host - one `initHub()` call: devframes (incl. the a11y agent's dock `clientScript`), hub RPCs, commands, the json-render dock + renderer manifest, instance-registry registration |
6061
| `src/client/devframe/unrendered-dock.ts` | A dock type registered with no renderer on purpose - the missing-renderer fallback witness |
62+
| `../demo-dock-client/` | The shared demo client script, consumed here as a statically-mounted self-contained bundle |
6163
| `src/client/app/%5F_devframes/[[...path]]/route.ts` | The one catch-all - delegates every `/__devframes/*` request to the instance's `handler` |
6264
| `src/client/app/page.tsx` | The browser UI that consumes the hub protocol, including the interactive-OTP authorization view |
6365
| `src/client/app/icons.ts` | Offline Phosphor icons for the dock |

examples/hub-next/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@devframes/plugin-terminals": "workspace:*",
2828
"@json-render/react": "catalog:frontend",
2929
"colorjs.io": "catalog:frontend",
30+
"demo-dock-client": "workspace:*",
3031
"devframe": "workspace:*",
3132
"dompurify": "catalog:frontend",
3233
"json-render": "workspace:*",

0 commit comments

Comments
 (0)