Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/errors/DF8111.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
outline: deep
---

# DF8111: Bare-Specifier Client Script Without Host Resolution

## Message

> 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.

## Cause

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.

## Example

```ts
initHub({
base: '/__devframes/',
configure(ctx) {
ctx.docks.register({
type: 'action',
id: 'vue-tracer',
title: 'Vue Tracer',
icon: 'ph:crosshair-simple-duotone',
// ✗ Bare specifier on a host with no `clientModuleResolution`
action: { importFrom: 'vite-plugin-vue-tracer/client/vite-devtools' },
})
},
})
```

## Fix

Pick whichever side you control:

- **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.
- **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(...)`.
- **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.

## Source

- [`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`.
36 changes: 35 additions & 1 deletion docs/guide/client-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,12 @@ A script that fails to import is logged and retried on the next dock update.

### Shipping a client script

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:
`importFrom` accepts two shapes:

- **A URL the host serves** — a single self-contained ES module, loading outside any chunk graph. Works on every host.
- **A bare npm specifier** (`'vite-plugin-vue-tracer/client/vite-devtools'`) — resolved through the host runtime, where supported.

For the URL shape, attach the built bundle when mounting the devframe:

```ts
await ctx.install(myDevframe, {
Expand All @@ -146,6 +151,35 @@ await ctx.install(myDevframe, {

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

### Bare npm specifiers

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:

```ts
// A Vite host resolves bare specifiers through its own module graph.
// `@devframes/vite/hub` declares this by default.
initHub({ clientModuleResolution: '/@id/{specifier}' })
```

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:

```ts
ctx.docks.register({
type: 'action',
id: 'vue-tracer',
title: 'Vue Tracer',
icon: 'ph:crosshair-simple-duotone',
action: { importFrom: 'vite-plugin-vue-tracer/client/vite-devtools' },
})
```

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.

Two guarantees to design against:

- **Client scripts always execute in the inspected page's realm** — the same `window` as the app being inspected.
- **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.

### Dual boots

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.
Expand Down
16 changes: 16 additions & 0 deletions examples/demo-dock-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Demo Dock Client

The shared dock client script the two reference hubs consume in their two supported shapes — one package, both `importFrom` forms:

- **`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.
- **`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.

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.

## Entries

| Entry | Resolves to | Role |
|---|---|---|
| `demo-dock-client` | `src/index.ts` (source, deps bare) | Bare-specifier consumption through a host's module graph |
| — | `dist/bundle.mjs` (self-contained build) | URL consumption on hosts without bare-specifier resolution |
| `demo-dock-client/node` | `dist/node.mjs` (build) | Node helper exporting `demoDockClientBundlePath` for static mounting |
25 changes: 25 additions & 0 deletions examples/demo-dock-client/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "demo-dock-client",
"type": "module",
"version": "0.9.0",
"private": true,
"description": "Reference dock client script for the hub examples: bare npm imports and globalThis-anchored shared state.",
"homepage": "https://github.com/devframes/devframe/tree/main/examples/demo-dock-client",
"exports": {
".": "./src/index.ts",
"./node": "./dist/node.mjs",
"./package.json": "./package.json"
},
"scripts": {
"build": "tsdown",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"nanoevents": "catalog:frontend"
},
"devDependencies": {
"@devframes/hub": "workspace:*",
"@types/node": "catalog:types",
"tsdown": "catalog:build"
}
}
50 changes: 50 additions & 0 deletions examples/demo-dock-client/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { DockClientScriptContext } from '@devframes/hub/client'
import type { Emitter } from 'nanoevents'
import { createNanoEvents } from 'nanoevents'

interface DemoEvents {
activated: (count: number) => void
}

interface DemoStore {
/** How many times the demo dock has been activated on this page. */
activations: number
/** Shared emitter — every module instance converges on this one. */
events: Emitter<DemoEvents>
}

const KEY_GLOBAL = '__devframes_demo_dock_client__'

/**
* Shared state anchored on `globalThis`, the pattern
* `vite-plugin-vue-tracer`'s `__vue_tracer__` store establishes: the same
* script may load as a Vite-graph module on one host and as a self-contained
* bundle on another, so two module instances must converge on one store
* rather than rely on module identity. Realm identity (the inspected page's
* `window`) is the contract; module identity is best-effort.
*/
function getStore(): DemoStore {
const holder = globalThis as Record<string, unknown> & { [KEY_GLOBAL]?: DemoStore }
if (!holder[KEY_GLOBAL]) {
const store: DemoStore = { activations: 0, events: createNanoEvents<DemoEvents>() }
Object.defineProperty(holder, KEY_GLOBAL, { value: store, configurable: true, enumerable: false })
}
return holder[KEY_GLOBAL]!
}

/**
* The dock `action` client script: counts activations in the shared store and
* mirrors each one into the hub's messages feed, so both consumption modes
* (bare specifier through the host's module graph, self-contained bundle by
* URL) demonstrably run the same code against the same state.
*/
export default function setup(ctx: DockClientScriptContext): void {
const store = getStore()
ctx.current.events.on('entry:activated', () => {
store.activations += 1
store.events.emit('activated', store.activations)
void ctx.messages.info(`Demo client script activated (#${store.activations} this page)`, {
description: `Loaded from ${new URL(import.meta.url).pathname}`,
})
})
}
10 changes: 10 additions & 0 deletions examples/demo-dock-client/src/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { fileURLToPath } from 'node:url'

/**
* Absolute path of the prebuilt, self-contained client script
* (`dist/bundle.mjs`, nanoevents inlined). A host without bare-specifier
* resolution mounts this file's directory statically and passes the served
* URL as the dock's `importFrom` — the same pattern as
* `@devframes/plugin-a11y`'s `a11yAgentBundlePath`.
*/
export const demoDockClientBundlePath: string = fileURLToPath(new URL('./bundle.mjs', import.meta.url))
10 changes: 10 additions & 0 deletions examples/demo-dock-client/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["esnext", "dom"],
"types": ["node"],
"noEmit": true
},
"include": ["src", "tsdown.config.ts"],
"exclude": ["dist", "node_modules"]
}
33 changes: 33 additions & 0 deletions examples/demo-dock-client/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { defineConfig } from 'tsdown'

const tsconfig = '../../tsconfig.base.json'

// The bare-specifier consumption path needs no build at all: the package's
// `.` export points straight at `src/index.ts`, which a Vite host transforms
// like any linked workspace source (hub-vite imports `'demo-dock-client'`
// via the `/@id/{specifier}` template). What gets built here is only the
// **URL-shape** consumption path:
// 1. `dist/bundle.mjs` — self-contained (nanoevents inlined), for hosts
// without bare-specifier resolution (hub-next mounts it statically and
// passes the served URL as `importFrom`);
// 2. `dist/node.mjs` — the node-side path helper the Next host uses to
// locate the bundle.
export default defineConfig([
{
clean: true,
platform: 'browser',
tsconfig,
dts: false,
outExtensions: () => ({ js: '.mjs' }),
entry: { bundle: 'src/index.ts' },
deps: { alwaysBundle: ['nanoevents'] },
},
{
clean: false,
platform: 'node',
tsconfig,
dts: false,
outExtensions: () => ({ js: '.mjs' }),
entry: { node: 'src/node.ts' },
},
])
2 changes: 2 additions & 0 deletions examples/hub-next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ The instance is memoized on `globalThis`, so Next's dev-time module re-evaluatio
- `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
- 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.)
- 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
- 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

## Hosting built-in plugins in a bundler

Expand All @@ -58,6 +59,7 @@ The plugins run node-side (child processes, the native `zigpty` PTY backend) and
|---|---|
| `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 |
| `src/client/devframe/unrendered-dock.ts` | A dock type registered with no renderer on purpose - the missing-renderer fallback witness |
| `../demo-dock-client/` | The shared demo client script, consumed here as a statically-mounted self-contained bundle |
| `src/client/app/%5F_devframes/[[...path]]/route.ts` | The one catch-all - delegates every `/__devframes/*` request to the instance's `handler` |
| `src/client/app/page.tsx` | The browser UI that consumes the hub protocol, including the interactive-OTP authorization view |
| `src/client/app/icons.ts` | Offline Phosphor icons for the dock |
1 change: 1 addition & 0 deletions examples/hub-next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@devframes/plugin-terminals": "workspace:*",
"@json-render/react": "catalog:frontend",
"colorjs.io": "catalog:frontend",
"demo-dock-client": "workspace:*",
"devframe": "workspace:*",
"dompurify": "catalog:frontend",
"json-render": "workspace:*",
Expand Down
16 changes: 13 additions & 3 deletions examples/hub-next/src/client/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,8 @@ export default function Page() {
}, [])

const renderableDocks = useMemo(() => docks.filter(isRenderableDock), [docks])
// The rail also lists panel-less `action` docks as momentary buttons.
const railDocks = useMemo(() => docks.filter(d => isRenderableDock(d) || d.type === 'action'), [docks])

// Wire each dock's state once so a selection change - from a click, or from
// the frame-nav adapter reacting to in-frame navigation - updates the UI.
Expand Down Expand Up @@ -645,13 +647,21 @@ export default function Page() {
<aside className="flex flex-col gap-0.5 of-auto border-r border-base bg-secondary p2">
<h2 className="px2 py1 text-[0.68rem] uppercase tracking-wider color-muted">Docks</h2>
<ul className="m0 flex flex-col list-none gap-0.5 p0">
{renderableDocks.length === 0
{railDocks.length === 0
? <li className="op-mute px2 text-sm">No docks</li>
: renderableDocks.map(dock => (
: railDocks.map(dock => (
<li key={dock.id}>
<button
type="button"
onClick={() => void hostRef.current?.context.docks.switchEntry(dock.id)}
onClick={() => {
const ctx = hostRef.current?.context
// Momentary action dock: fire its client script, keep
// the panel selection as-is.
if (dock.type === 'action')
ctx?.docks.getStateById(dock.id)?.events.emit('entry:activated')
else
void ctx?.docks.switchEntry(dock.id)
}}
className={`relative inline-flex items-center gap-1.5 max-w-52 px-2 py-1 rounded-md border border-transparent text-sm op-fade select-none cursor-pointer transition hover:op100 hover:bg-active w-full! max-w-none! gap-2.5!${dock.id === selectedDockId ? ' op100! bg-active border-base! color-base' : ''}`}
title={dock.title}
>
Expand Down
Loading
Loading