diff --git a/docs/content/1.guide/17.client-context.md b/docs/content/1.guide/17.client-context.md
index be8ac3e8..b54292ba 100644
--- a/docs/content/1.guide/17.client-context.md
+++ b/docs/content/1.guide/17.client-context.md
@@ -76,12 +76,13 @@ A failed import retries on the next dock update.
### Shipping a client script
-`importFrom` accepts two shapes:
+`importFrom` accepts three shapes:
- **A URL served by the host framework** — a self-contained ES module; works on every host framework.
- **A bare npm specifier** (`'vite-plugin-vue-tracer/client/vite-devtools'`) — resolved through the host framework.
+- **An absolute filesystem path**, declared on the definition's `dock.clientScript`. The hub serves its directory under `__page-script/` and rewrites `importFrom` to that URL, so mounting by package name needs no host wiring.
-For a URL, attach it via `ctx.install(myDevframe, { dock: { clientScript: { importFrom } } })`. Under Vite `/@fs/` serves it; other host frameworks mount the directory statically.
+Per-mount, attach a URL via `ctx.install(myDevframe, { dock: { clientScript: { importFrom } } })`; under Vite `/@fs/` serves it, and other host frameworks mount the directory statically.
### Bare npm specifiers
diff --git a/docs/content/5.plugins/4.a11y.md b/docs/content/5.plugins/4.a11y.md
index ad5dc17a..0875e925 100644
--- a/docs/content/5.plugins/4.a11y.md
+++ b/docs/content/5.plugins/4.a11y.md
@@ -31,7 +31,15 @@ The page script and the panel talk over the [in-page channel](/guide/in-page-cha
## In a hub
-The page script is the a11y dock's [client script](/guide/client-context): attach `a11yPageScriptBundlePath` as the dock's `clientScript` and the hub imports it into the page. It also mirrors each scan into the hub's messages feed — a summary plus one per rule:
+The definition declares the page script as its dock [client script](/guide/client-context), so mounting by package name just works:
+
+```ts
+initHub({ devframes: ['@devframes/plugin-a11y'] })
+```
+
+The hub serves the bundle same-origin and a client runtime imports it into the host page. Each scan also mirrors into the hub's messages feed — a summary plus one per rule.
+
+A host can also mount the module itself — e.g. a Vite host via `/@fs/`:
```ts
import createA11yDevframe, { a11yPageScriptBundlePath } from '@devframes/plugin-a11y'
diff --git a/examples/a11y-messages-playground/README.md b/examples/a11y-messages-playground/README.md
index 39980c55..acab15ef 100644
--- a/examples/a11y-messages-playground/README.md
+++ b/examples/a11y-messages-playground/README.md
@@ -55,15 +55,12 @@ The window is split in two:
## How it's wired
`src/a11y-messages-playground.ts` is the entire host-framework integration - a ~120-line Vite plugin
-that runs `@devframes/hub` in the dev server, mounts the two devframes as docks,
-and attaches the a11y page script as the a11y dock's `clientScript`:
+that runs `@devframes/hub` in the dev server and mounts the two devframes as docks
+(the a11y inspector declares its own page script):
```ts
a11yMessagesPlayground({
devframes: [a11yDevframe, messagesDevframe],
- clientScripts: {
- [a11yDevframe.id]: { importFrom: `/@fs/${a11yPageScriptBundlePath}` },
- },
})
```
@@ -79,7 +76,7 @@ the focused dock - the same path a manual dock click takes.
| File | Role |
|---|---|
| `src/a11y-messages-playground.ts` | The Vite host - hub context, static + connection-meta mounts, side-car WS, instance-registry registration |
-| `vite.config.ts` | Mounts a11y + messages; attaches the a11y page script as its dock's `clientScript` |
+| `vite.config.ts` | Mounts a11y + messages |
| `src/client/main.ts` | Boots the client runtime, renders the dock rail + iframe stage |
| `src/client/app-under-test.ts` | The intentionally-broken, multi-route app the page script scans |
| `src/client/icons.ts` | Offline Phosphor icons for the dock rail |
diff --git a/examples/a11y-messages-playground/src/a11y-messages-playground.ts b/examples/a11y-messages-playground/src/a11y-messages-playground.ts
index 115d9e22..d9b431e4 100644
--- a/examples/a11y-messages-playground/src/a11y-messages-playground.ts
+++ b/examples/a11y-messages-playground/src/a11y-messages-playground.ts
@@ -1,5 +1,4 @@
import type { HubInstance } from '@devframes/hub/initiate'
-import type { ClientScriptEntry } from '@devframes/hub/types'
import type { DevframeDefinition } from 'devframe'
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
import { Server as NodeHttpServer } from 'node:http'
@@ -14,22 +13,15 @@ export interface A11yMessagesPlaygroundOptions {
port?: number
/** Devframes to mount as docks (here: a11y + messages). */
devframes?: DevframeDefinition[]
- /**
- * Per-dock client scripts, keyed by devframe id. Attached to the mounted
- * iframe dock so the hub client runtime imports them into the host page -
- * this is how the a11y inspector's in-page agent gets into the page it scans.
- */
- clientScripts?: Record
}
/**
* A tiny Vite plugin that runs `@devframes/hub` inside the Vite dev server -
* the same shape as `examples/hub-vite`, trimmed to the two plugins this
* playground pairs (a11y + messages). One `initHub()` call assembles the whole
- * hub: it mounts each devframe as a dock (attaching the a11y agent as its
- * client script), shares the WebSocket with Vite's own server, serves the
- * discovery endpoints, and registers the playground in the global instance
- * registry.
+ * hub: it mounts each devframe as a dock, shares the WebSocket with Vite's own
+ * server, serves the discovery endpoints, and registers the playground in the
+ * global instance registry.
*/
export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions = {}): Plugin {
const base = normalizeBase(options.base ?? '/__hub/')
@@ -70,10 +62,7 @@ export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions =
return join(cwd, 'node_modules/.a11y-messages-playground')
return join(homedir(), '.a11y-messages-playground')
},
- devframes: (options.devframes ?? []).map((def) => {
- const clientScript = options.clientScripts?.[def.id]
- return clientScript ? { devframe: def, dock: { clientScript } } : def
- }),
+ devframes: options.devframes ?? [],
// List the playground alongside standalone devframes in discovery
// tooling (`devframe connect`, the inspector's Instances tab).
register: {
diff --git a/examples/a11y-messages-playground/vite.config.ts b/examples/a11y-messages-playground/vite.config.ts
index 4733c5b7..50b8c219 100644
--- a/examples/a11y-messages-playground/vite.config.ts
+++ b/examples/a11y-messages-playground/vite.config.ts
@@ -1,4 +1,4 @@
-import createA11yDevframe, { a11yPageScriptBundlePath } from '@devframes/plugin-a11y'
+import createA11yDevframe from '@devframes/plugin-a11y'
import createMessagesDevframe from '@devframes/plugin-messages'
import UnoCSS from 'unocss/vite'
import { defineConfig } from 'vite'
@@ -16,12 +16,6 @@ export default defineConfig({
UnoCSS(),
a11yMessagesPlayground({
devframes: [a11yDevframe, messagesDevframe],
- // Attach the a11y page script as the a11y dock's client script - served
- // over Vite's `/@fs/` so it shares this page's origin (the in-page
- // channel the page script and panel talk over is same-origin).
- clientScripts: {
- [a11yDevframe.id]: { importFrom: `/@fs/${a11yPageScriptBundlePath}` },
- },
}),
],
})
diff --git a/examples/hub-next/README.md b/examples/hub-next/README.md
index eb9123e7..af4b6749 100644
--- a/examples/hub-next/README.md
+++ b/examples/hub-next/README.md
@@ -20,7 +20,7 @@ Open the printed URL. The dock rail on the left lists every mounted tool with it
Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's **Commands**, **Messages**, and **Terminals** subsystems, plus a button that dispatches a command through `hub:commands:execute`, and a **Transport** section showing which RPC transport the connection runs on (`websocket` or `sse`) with a segmented Auto / WS / SSE toggle - the choice rides a `?transport=` URL param and reconnects the whole client runtime on the pinned transport.
-The A11y Inspector shows a live axe-core report of this hub's own page: the hub serves the devframe's page-script module (`a11yPageScriptBundlePath`) same-origin inside the hub namespace and attaches it as the a11y dock's `clientScript` (the `{ devframe, dock }` entry form); the hub client runtime - `createDevframeClientRuntime()` booted in `app/page.tsx` - imports it into the page, so the docked panel and the page script share the origin and tab their in-page channel handshakes across.
+The A11y Inspector shows a live axe-core report of this hub's own page: the devframe declares its own page-script module as the a11y dock's `clientScript`, so the hub serves it same-origin with no host wiring; the hub client runtime - `createDevframeClientRuntime()` booted in `app/page.tsx` - imports it into the page, so the docked panel and the page script share the origin and tab their in-page channel handshakes across.
The **RPC & State Inspector** carries an **Instances** tab that lists every devframe dev server running on your machine. The hub registers itself in the shared registry (`~/.devframe/instances/`) on startup via `registerDevframeInstance()`, so it shows up as "this instance"; start another example (e.g. `pnpm --filter hub-vite dev`, or any `node bin.mjs` CLI example) in a second terminal and it appears there too, each linking to its own SPA.
@@ -57,7 +57,7 @@ The built-in devframes run node-side (child processes, the native `zigpty` PTY b
| File | Role |
|---|---|
-| `src/client/devframe/next-devframe-hub.ts` | The Next host - one `initHub()` call: devframes (incl. the a11y page script as its dock's `clientScript`), hub RPCs, commands, the json-render dock + renderer manifest, instance-registry registration |
+| `src/client/devframe/next-devframe-hub.ts` | The Next host - one `initHub()` call: devframes, 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` |
diff --git a/examples/hub-next/src/client/devframe/next-devframe-hub.ts b/examples/hub-next/src/client/devframe/next-devframe-hub.ts
index d7eb9283..0b8761b5 100644
--- a/examples/hub-next/src/client/devframe/next-devframe-hub.ts
+++ b/examples/hub-next/src/client/devframe/next-devframe-hub.ts
@@ -88,44 +88,6 @@ async function loadJsonRenderUiRenderer(): Promise {
return (mod.jsonRenderUiRenderer as typeof JsonRenderUiRenderer)()
}
-/**
- * URL base the a11y agent module is served under - inside the hub namespace,
- * so the one catch-all route reaches it.
- */
-const A11Y_AGENT_MOUNT_BASE = `${DEVFRAMES_HUB_BASE}df-a11y-agent/`
-
-interface A11yAgentMount {
- /** The a11y devframe's dock id - the dock the client script attaches to. */
- dockId: string
- /** On-disk directory holding the built agent module. */
- dir: string
- /** Same-origin URL of the agent module, importable by the hub client runtime. */
- importFrom: string
-}
-
-/**
- * Locate the a11y inspector's in-page **agent** module so the hub can serve it
- * same-origin and attach it to the a11y dock as its client script - the hub
- * client runtime (booted in `app/page.tsx`) imports it into the host page,
- * where it scans this hub live. Loaded through the same bundler-ignored dynamic
- * `import()` as the plugins, since the package resolves its `dist` via
- * `import.meta.url`. Returns `null` if unavailable.
- */
-async function loadA11yAgentMount(): Promise {
- try {
- const mod = await import(/* webpackIgnore: true */ /* turbopackIgnore: true */ '@devframes/plugin-a11y')
- const bundle = mod.a11yPageScriptBundlePath as string
- return {
- dockId: (mod.default as () => DevframeDefinition)().id,
- dir: dirname(bundle),
- importFrom: `${A11Y_AGENT_MOUNT_BASE}inject.js`,
- }
- }
- catch {
- return null
- }
-}
-
/**
* URL base the demo dock-client bundle is served under - inside the hub
* namespace, so the one catch-all route reaches it.
@@ -215,13 +177,6 @@ export async function nextDevframeHub(
const nextPort = Number(process.env.PORT ?? 3000)
const origin = `http://${hostName}:${nextPort}`
- // Serve the a11y inspector's in-page agent same-origin (inside the hub
- // namespace, via the catch-all route) and attach it to the a11y dock as its
- // client script. The hub client runtime booted in `app/page.tsx` imports it
- // into the host page, where it scans this hub live; the panel iframe shares
- // the origin, so their BroadcastChannel connects.
- const a11yAgent = await loadA11yAgentMount()
-
// The shared demo dock-client script, served as a prebuilt self-contained
// bundle (see loadDemoDockClientMount above for why this host uses the
// URL shape rather than a bare specifier).
@@ -238,11 +193,7 @@ export async function nextDevframeHub(
// shim reports - all sharing one iframe.
const devframes: (DevframeDefinition | HubDevframeEntry)[] = [
demoDevframe,
- ...(await loadBuiltinPlugins()).map(def =>
- a11yAgent && def.id === a11yAgent.dockId
- ? { devframe: def, dock: { clientScript: { importFrom: a11yAgent.importFrom } } }
- : def,
- ),
+ ...await loadBuiltinPlugins(),
await loadDataInspectorDevframe(),
await loadAssetsDevframe(),
{
@@ -327,9 +278,6 @@ export async function nextDevframeHub(
category: '~builtin',
})
- if (a11yAgent)
- await ctx.host.mountStatic(A11Y_AGENT_MOUNT_BASE, a11yAgent.dir)
-
// The demo dock-client script - the same package the Vite reference
// host loads via a bare specifier - mounted statically and attached as
// a momentary `action` dock by its served URL.
diff --git a/examples/hub-vite/README.md b/examples/hub-vite/README.md
index 7bc106b5..3523bc78 100644
--- a/examples/hub-vite/README.md
+++ b/examples/hub-vite/README.md
@@ -20,7 +20,7 @@ Open the printed URL. The dock rail on the left lists every mounted tool with it
Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's **Commands**, **Messages**, and **Terminals** subsystems, plus a button that dispatches a command through `hub:commands:execute`, and a **Transport** section showing which RPC transport the connection runs on (`websocket` or `sse`) with a segmented Auto / WS / SSE toggle - the choice rides a `?transport=` URL param and reconnects the whole client runtime on the pinned transport.
-The A11y Inspector shows a live axe-core report of this hub's own page. `vite.config.ts` attaches the devframe's page script as the a11y dock's `clientScript` (served via `/@fs/`), and the hub client runtime - `createDevframeClientRuntime()` booted in `src/client/main.ts` - imports it into the host page. Panel and page script share the Vite origin and tab their in-page channel handshakes across; hover a violation to ring the offending element in the hub UI.
+The A11y Inspector shows a live axe-core report of this hub's own page. The devframe declares its own page script as the a11y dock's `clientScript`, so the hub serves it same-origin and the hub client runtime - `createDevframeClientRuntime()` booted in `src/client/main.ts` - imports it into the host page automatically (no wiring in `vite.config.ts`). Panel and page script share the Vite origin and tab their in-page channel handshakes across; hover a violation to ring the offending element in the hub UI.
The **RPC & State Inspector** carries an **Instances** tab that lists every devframe dev server running on your machine. The hub registers itself in the shared registry (`~/.devframe/instances/`) on startup via `registerDevframeInstance()`, so it shows up as "this instance"; start another example (`pnpm --filter a11y-messages-playground dev`, or any `node bin.mjs` CLI example) in a second terminal and it appears there too, each linking to its own SPA.
@@ -45,7 +45,7 @@ The dock UI is plain DOM in `src/client/`. To skin your own hub UI provider, rea
| File | Role |
|---|---|
| `src/vite-devframe-hub.ts` | The Vite host - one `initHub()` call mounted as connect middleware, plus instance-registry registration |
-| `vite.config.ts` | Passes the built-in and demo devframes to the hub's `devframes` option; attaches the a11y page script as its dock's `clientScript`; composes the json-render frontend via `renderers` |
+| `vite.config.ts` | Passes the built-in and demo devframes to the hub's `devframes` option; composes the json-render frontend via `renderers` |
| `src/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 via bare specifier (`action: { importFrom: 'demo-dock-client' }`) |
| `src/client/main.ts` | The browser UI that consumes the hub protocol, including the interactive-OTP authorization view |
diff --git a/examples/hub-vite/vite.config.ts b/examples/hub-vite/vite.config.ts
index abbd1b9e..301b9a85 100644
--- a/examples/hub-vite/vite.config.ts
+++ b/examples/hub-vite/vite.config.ts
@@ -2,7 +2,7 @@ import type { DevframeHubContext } from '@devframes/hub/node'
import { defineHubRpcFunction } from '@devframes/hub'
import { jsonRenderUiRenderer } from '@devframes/json-render-ui/hub'
import { toJsonRenderDockEntry } from '@devframes/json-render/hub'
-import createA11yDevframe, { a11yPageScriptBundlePath } from '@devframes/plugin-a11y'
+import createA11yDevframe from '@devframes/plugin-a11y'
import createAssetsDevframe from '@devframes/plugin-assets'
import createCodeServerDevframe from '@devframes/plugin-code-server'
import { createDataInspectorDevframe } from '@devframes/plugin-data-inspector'
@@ -142,13 +142,6 @@ export default defineConfig({
},
},
],
- // Attach the a11y inspector's in-page agent as its dock's client script.
- // The hub client runtime (booted in src/client/main.ts) imports it into
- // this page so the docked panel scans the host live - no bespoke
- // injection plugin needed. `/@fs/` lets Vite serve the built module.
- clientScripts: {
- [a11yDevframe.id]: { importFrom: `/@fs/${a11yPageScriptBundlePath}` },
- },
// Serve the reference json-render frontend as a prebuilt renderer
// module: the hub publishes it in the renderer manifest and the client
// (src/client/main.ts) imports it lazily the first time a
diff --git a/knip.jsonc b/knip.jsonc
index b51714cf..d8f926c6 100644
--- a/knip.jsonc
+++ b/knip.jsonc
@@ -66,6 +66,7 @@
// deliberately invisible to bundlers (and knip) so Next never inlines
// their node-only code. They're genuinely used at runtime.
"ignoreDependencies": [
+ "@devframes/plugin-a11y",
"@devframes/plugin-code-server",
"@devframes/plugin-git",
"@devframes/plugin-inspect",
diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts
index 44c5ff7f..95fed918 100644
--- a/packages/devframe/src/types/devframe.ts
+++ b/packages/devframe/src/types/devframe.ts
@@ -263,6 +263,21 @@ export interface DevframeDockDefaults {
badge?: string
/** Id of the dock group this entry collapses under, if any. */
groupId?: string
+ /**
+ * A client script the hub imports into the host page (this devframe's **page
+ * script**). An absolute-path `importFrom` is served by the hub under the
+ * mount base and rewritten to that URL, so mounting by package name needs no
+ * host wiring; a URL or bare specifier passes through untouched.
+ */
+ clientScript?: {
+ /** An absolute filesystem path, a served URL, or a bare npm specifier. */
+ importFrom: string
+ /**
+ * The name to import the module as.
+ * @default 'default'
+ */
+ importName?: string
+ }
}
/**
diff --git a/packages/hub/src/node/__tests__/install-devframe.test.ts b/packages/hub/src/node/__tests__/install-devframe.test.ts
index a6cf7122..da4762e2 100644
--- a/packages/hub/src/node/__tests__/install-devframe.test.ts
+++ b/packages/hub/src/node/__tests__/install-devframe.test.ts
@@ -1,9 +1,9 @@
import type { DevframeDefinition, DevframeDuplicationStrategy } from 'devframe/types'
import type { DevframeHubContext } from '../context'
-import { mkdtempSync } from 'node:fs'
+import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
-import { join } from 'node:path'
import { defineDevframe } from 'devframe'
+import { dirname, join } from 'pathe'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DevframeDocksHost } from '../host-docks'
import { installDevframe } from '../install-devframe'
@@ -12,7 +12,7 @@ function createContext(): DevframeHubContext {
const storageDir = mkdtempSync(join(tmpdir(), 'devframe-hub-install-'))
const context = {
host: {
- mountStatic: () => {},
+ mountStatic: vi.fn(),
resolveOrigin: () => 'http://localhost:5173',
getStorageDir: () => storageDir,
},
@@ -163,6 +163,58 @@ describe('ctx.install', () => {
expect(warn.mock.calls[0].join(' ')).toContain('DF8106')
})
+ it('serves an absolute-path page script under the mount base and rewrites it to the served URL', async () => {
+ const ctx = createContext()
+ const scriptPath = join(mkdtempSync(join(tmpdir(), 'devframe-page-script-')), 'inject.js')
+ writeFileSync(scriptPath, 'export default () => {}')
+
+ await ctx.install(makeDevframe({
+ dock: { clientScript: { importFrom: scriptPath } },
+ }))
+
+ expect(ctx.host.mountStatic).toHaveBeenCalledWith('/__demo/__page-script/', dirname(scriptPath))
+ expect(ctx.docks.views.get('demo')).toMatchObject({
+ clientScript: { importFrom: '/__demo/__page-script/inject.js' },
+ })
+ })
+
+ it('leaves a URL or bare-specifier page script untouched (no mount)', async () => {
+ const ctx = createContext()
+
+ await ctx.install(makeDevframe({
+ dock: { clientScript: { importFrom: '/@fs/abs/inject.js' } },
+ }))
+ await ctx.install(makeDevframe({
+ id: 'bare',
+ dock: { clientScript: { importFrom: 'some-pkg/client' } },
+ }))
+
+ expect(ctx.host.mountStatic).not.toHaveBeenCalled()
+ expect(ctx.docks.views.get('demo')).toMatchObject({
+ clientScript: { importFrom: '/@fs/abs/inject.js' },
+ })
+ expect(ctx.docks.views.get('bare')).toMatchObject({
+ clientScript: { importFrom: 'some-pkg/client' },
+ })
+ })
+
+ it('lets a per-mount clientScript override the definition default before serving', async () => {
+ const ctx = createContext()
+ const dir = mkdtempSync(join(tmpdir(), 'devframe-page-script-'))
+ const scriptPath = join(dir, 'inject.js')
+ writeFileSync(scriptPath, 'export default () => {}')
+
+ await ctx.install(
+ makeDevframe({ dock: { clientScript: { importFrom: scriptPath } } }),
+ { dock: { clientScript: { importFrom: '/@fs/override/inject.js' } } },
+ )
+
+ expect(ctx.host.mountStatic).not.toHaveBeenCalled()
+ expect(ctx.docks.views.get('demo')).toMatchObject({
+ clientScript: { importFrom: '/@fs/override/inject.js' },
+ })
+ })
+
it('lets instances coexist under disambiguated ids when "duplicate"', async () => {
const ctx = createContext()
const setup = vi.fn()
diff --git a/packages/hub/src/node/install-devframe.ts b/packages/hub/src/node/install-devframe.ts
index 7b4fadf6..72fdd0d2 100644
--- a/packages/hub/src/node/install-devframe.ts
+++ b/packages/hub/src/node/install-devframe.ts
@@ -1,9 +1,11 @@
import type { DevframeDefinition } from 'devframe/types'
-import type { DevframeViewIframe } from '../types/docks'
+import type { ClientScriptEntry, DevframeViewIframe } from '../types/docks'
import type { DevframeHubContext } from './context'
+import { existsSync } from 'node:fs'
import { resolveClientAssets } from 'devframe'
import { resolveBasePath } from 'devframe/node/hub-internals'
-import { resolve } from 'pathe'
+import { basename, dirname, isAbsolute, resolve } from 'pathe'
+import { joinURL, withTrailingSlash } from 'ufo'
import { diagnostics } from './diagnostics'
export interface InstallDevframeOptions {
@@ -37,6 +39,26 @@ function nextAvailableDockId(views: DevframeHubContext['docks']['views'], baseId
return `${baseId}-${n}`
}
+/**
+ * When a dock's `clientScript.importFrom` is an absolute filesystem path, serve
+ * its directory under `__page-script/` and rewrite `importFrom` to that
+ * URL. A URL or bare specifier (not existing on disk) passes through untouched.
+ */
+async function resolvePageScriptClientScript(
+ ctx: DevframeHubContext,
+ clientScript: ClientScriptEntry | undefined,
+ base: string,
+): Promise {
+ if (!clientScript?.importFrom)
+ return clientScript
+ const { importFrom } = clientScript
+ if (!isAbsolute(importFrom) || !existsSync(importFrom))
+ return clientScript
+ const scriptBase = withTrailingSlash(joinURL(base, '__page-script'))
+ await ctx.host.mountStatic(scriptBase, dirname(importFrom))
+ return { ...clientScript, importFrom: joinURL(scriptBase, basename(importFrom)) }
+}
+
/**
* Framework-neutral primitive backing {@link DevframeHubContext.install} —
* installs a {@link DevframeDefinition} as a dock inside a hub-aware context:
@@ -87,6 +109,13 @@ export async function prepareDevframe(
? resolveBasePath(d, 'hosted')
: resolveBasePath({ ...d, id, basePath: undefined }, 'hosted'))
+ // Definition `dock` beneath per-mount `options.dock`. Resolved before the SPA
+ // mount so an absolute-path page script is served ahead of the SPA catch-all.
+ const dockDefaults = { ...d.dock, ...options.dock }
+ const clientScript = await resolvePageScriptClientScript(ctx, dockDefaults.clientScript, base)
+ if (clientScript)
+ dockDefaults.clientScript = clientScript
+
const clientAssets = resolveClientAssets(d)
if (clientAssets) {
// Serve the hub's connection meta under the devframe's base so its SPA
@@ -114,11 +143,9 @@ export async function prepareDevframe(
id,
title: d.name,
icon: d.icon,
- // Definition-level `dock` defaults sit above the name/icon-derived
- // defaults; per-mount `options.dock` overrides them; `type`/`url`
- // (and `id`) stay locked, derived from the definition.
- ...d.dock,
- ...options.dock,
+ // `dockDefaults` sits above the name/icon defaults; `type`/`url`/`id` stay
+ // locked, derived from the definition.
+ ...dockDefaults,
type: 'iframe',
url: base,
} as DevframeViewIframe)
diff --git a/plugins/a11y/README.md b/plugins/a11y/README.md
index fa8ef654..48697c3f 100644
--- a/plugins/a11y/README.md
+++ b/plugins/a11y/README.md
@@ -56,11 +56,13 @@ page script itself free of any RPC dependency.
devframe deliberately provides no access to the user app's DOM, so the
page script is the author-provided bridge into the user app's page. In a hub, the
-page script is the a11y dock's **client script**: attach `a11yPageScriptBundlePath` as the
-dock's `clientScript` (resolved to an importable URL — `/@fs/…` under Vite, or a
-statically-served path) and the hub's client runtime (`createDevframeClientRuntime`
-from `@devframes/hub/client`) imports it into the host page and calls its
-default export with the client-script context. Booted that way, the page script also
+page script is the a11y dock's **client script**, and the definition declares it by
+path, so the hub serves it same-origin and `devframes: ['@devframes/plugin-a11y']`
+works with no host wiring. The hub's client runtime (`createDevframeClientRuntime`
+from `@devframes/hub/client`) then imports it into the host page and calls its default
+export with the client-script context. A host can also serve the module itself (e.g.
+via `/@fs/…` under Vite) by attaching `a11yPageScriptBundlePath` as a per-mount
+`clientScript`. Booted that way, the page script also
mirrors the active route's scan into the hub's **messages feed** — a summary entry
driven through the loading → idle lifecycle plus one entry per violated rule,
carrying the impact-mapped level, WCAG tags as labels, and the first offending
@@ -117,7 +119,7 @@ pnpm -C plugins/a11y dev # from source: same, at /__devframes_plugin_a11
| Path | Export | Purpose |
|------|--------|---------|
-| `src/index.ts` | `.` | `createA11yDevframe()` (also the default export); `a11yPageScriptBundlePath` — the page-script module a hub attaches as this dock's client script |
+| `src/index.ts` | `.` | `createA11yDevframe()` (also the default export), declaring the page script as its dock's client script; `a11yPageScriptBundlePath` — that module, for hosts that serve it themselves |
| `src/node/index.ts` | `/node` | `setupA11y(ctx, options?)` — registers the RPC functions with the runtime config |
| `src/cli.ts` | `/cli` | `createA11yCli()` — backs the `devframes_plugin_a11y` bin |
| `src/client/index.ts` | `/client` | `connectA11y()` — typed browser RPC client wrapper |
diff --git a/plugins/a11y/src/index.ts b/plugins/a11y/src/index.ts
index db0bcfc4..9cf836fa 100644
--- a/plugins/a11y/src/index.ts
+++ b/plugins/a11y/src/index.ts
@@ -23,11 +23,10 @@ const distDir: RemoteAssets = {
* — the dock **client script** the client runtime imports into the host page to
* scan it (its default export boots the page script; importing it does too).
*
- * A hub attaches this as the a11y dock's `clientScript`, resolved to a URL the
- * page can import: `/@fs/${a11yPageScriptBundlePath}` for a Vite host, or a
- * statically-served path for others (see the minimal hub examples). Resolves
- * under `/dist/inject/inject.js` from both the source and the published
- * entry. Requires the built bundle (`pnpm -C plugins/a11y build`).
+ * The definition already declares this as its dock `clientScript`, so a hub
+ * serves it with no host wiring. Exported for hosts that mount the module
+ * themselves (e.g. via `/@fs/` under Vite). Requires the built bundle
+ * (`pnpm -C plugins/a11y build`).
*/
export const a11yPageScriptBundlePath: string = fileURLToPath(new URL('../dist/inject/inject.js', import.meta.url))
@@ -91,6 +90,11 @@ export function createA11yDevframe(options: A11yDevframeOptions = {}): DevframeD
description: pkg.description,
icon: options.icon ?? 'ph:person-simple-circle-duotone',
basePath: options.basePath ?? BASE_PATH,
+ // Declare the page script by path; the hub serves it with no host wiring.
+ dock: {
+ category: '~builtin',
+ clientScript: { importFrom: a11yPageScriptBundlePath },
+ },
cli: {
command: id,
port: options.port ?? 9899,
diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts
index 7cab9b7e..97830b1b 100644
--- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts
+++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts
@@ -169,6 +169,10 @@ export interface DevframeDockDefaults {
visibility?: string;
badge?: string;
groupId?: string;
+ clientScript?: {
+ importFrom: string;
+ importName?: string;
+ };
}
export interface DevframeHost {
mountStatic: (_: string, _: string | RemoteAssetsStore) => void | Promise;