From 9cc74b6ae873abfd1089648704c37fb771907701 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 18 Aug 2026 03:31:33 +0000 Subject: [PATCH 1/3] feat(hub): resolve bare-specifier client scripts through the host runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dock client script's importFrom can now name an npm module (e.g. 'vite-plugin-vue-tracer/client/vite-devtools') instead of a served URL. Bare specifiers are a host-runtime capability: the host advertises a URL template as ConnectionMeta.configs.dock.clientModuleResolution (declared via initHub({ clientModuleResolution })), and every client-script loader applies it before the native import — createDevframeClientHost (which also gains a resolveClientModule override), hub-ui's setup-script loader, and the generated __client-imports.js module. @devframes/vite/hub declares '/@id/{specifier}' by default, so the import routes through Vite's own resolution and import-analysis: the script's transitive bare imports work too and share the app's module graph — the fix for vue-tracer's dock breaking once v0.9's middleware began serving hub assets verbatim, outside Vite's transform pipeline. Hosts without such a runtime (Next.js) keep the URL contract; a new DF8111 diagnostic warns at dock registration when a bare specifier is doomed there, and both browser loaders name the capability gap in their error. The reference hubs stay at parity through one shared demo package (examples/demo-dock-client): hub-vite consumes it by bare specifier, hub-next as a statically-mounted self-contained bundle, and its globalThis-anchored store documents the state pattern the docs' new protocol guarantee prescribes. --- docs/errors/DF8111.md | 43 ++++++++ docs/guide/client-context.md | 36 ++++++- examples/demo-dock-client/README.md | 16 +++ examples/demo-dock-client/package.json | 25 +++++ examples/demo-dock-client/src/index.ts | 50 +++++++++ examples/demo-dock-client/src/node.ts | 10 ++ examples/demo-dock-client/tsconfig.json | 10 ++ examples/demo-dock-client/tsdown.config.ts | 41 +++++++ examples/hub-next/README.md | 2 + examples/hub-next/package.json | 1 + examples/hub-next/src/client/app/page.tsx | 26 ++++- .../src/client/devframe/next-devframe-hub.ts | 58 ++++++++++ examples/hub-vite/README.md | 2 + examples/hub-vite/package.json | 1 + examples/hub-vite/src/client/main.ts | 24 ++++- examples/hub-vite/vite.config.ts | 16 +++ knip.jsonc | 14 +++ .../hub-ui/src/client/state/setup-script.ts | 23 +++- packages/hub/src/client-modules.test.ts | 102 ++++++++++++++++++ packages/hub/src/client-modules.ts | 93 ++++++++++++++++ packages/hub/src/client/host.ts | 39 ++++++- packages/hub/src/client/index.ts | 1 + .../hub/src/node/__tests__/host-docks.test.ts | 60 +++++++++++ .../hub/src/node/__tests__/initiate.test.ts | 49 +++++++++ packages/hub/src/node/diagnostics.ts | 4 + packages/hub/src/node/host-docks.ts | 22 ++++ packages/hub/src/node/initiate.ts | 38 ++++++- packages/hub/src/types/docks.ts | 39 ++++++- packages/vite/src/hub.ts | 30 ++++++ pnpm-lock.yaml | 63 +++++++---- pnpm-workspace.yaml | 1 + .../@devframes/hub/client.snapshot.d.ts | 10 ++ .../tsnapi/@devframes/hub/client.snapshot.js | 4 + .../tsnapi/@devframes/hub/index.snapshot.d.ts | 3 + .../@devframes/hub/initiate.snapshot.d.ts | 1 + .../tsnapi/@devframes/hub/node.snapshot.d.ts | 1 + .../tsnapi/@devframes/hub/types.snapshot.d.ts | 1 + .../tsnapi/@devframes/vite/hub.snapshot.d.ts | 5 + .../tsnapi/@devframes/vite/hub.snapshot.js | 4 + 39 files changed, 933 insertions(+), 35 deletions(-) create mode 100644 docs/errors/DF8111.md create mode 100644 examples/demo-dock-client/README.md create mode 100644 examples/demo-dock-client/package.json create mode 100644 examples/demo-dock-client/src/index.ts create mode 100644 examples/demo-dock-client/src/node.ts create mode 100644 examples/demo-dock-client/tsconfig.json create mode 100644 examples/demo-dock-client/tsdown.config.ts create mode 100644 packages/hub/src/client-modules.test.ts create mode 100644 packages/hub/src/client-modules.ts diff --git a/docs/errors/DF8111.md b/docs/errors/DF8111.md new file mode 100644 index 00000000..a209cb57 --- /dev/null +++ b/docs/errors/DF8111.md @@ -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`. diff --git a/docs/guide/client-context.md b/docs/guide/client-context.md index 902acece..2d323f6d 100644 --- a/docs/guide/client-context.md +++ b/docs/guide/client-context.md @@ -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, { @@ -146,6 +151,35 @@ await ctx.install(myDevframe, { Under Vite, `/@fs/` 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/` 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 `