From 5b43bcd190a67c2c499ba169371273a4ac0d4534 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 27 Aug 2026 11:56:03 +0000 Subject: [PATCH 1/3] feat(hub): serve a definition's absolute-path page script automatically Let a devframe declare its page script on `dock.clientScript.importFrom` as an absolute filesystem path. The hub install path mounts the file's directory under the devframe's mount base and rewrites `importFrom` to the served URL, so mounting the a11y inspector by package name works on every host with no per-host wiring. The a11y inspector now declares its own page script, and the reference hosts (hub-vite, hub-next, a11y-messages-playground) drop their manual mount + `clientScript` recipes. Closes #310 --- docs/content/1.guide/17.client-context.md | 5 +- docs/content/5.plugins/4.a11y.md | 10 ++- examples/a11y-messages-playground/README.md | 10 ++- .../src/a11y-messages-playground.ts | 21 ++---- .../a11y-messages-playground/vite.config.ts | 11 ++- examples/hub-next/README.md | 4 +- .../src/client/devframe/next-devframe-hub.ts | 68 +++---------------- examples/hub-vite/README.md | 4 +- examples/hub-vite/vite.config.ts | 14 ++-- knip.jsonc | 1 + packages/devframe/src/types/devframe.ts | 28 ++++++++ .../node/__tests__/install-devframe.test.ts | 57 +++++++++++++++- packages/hub/src/node/install-devframe.ts | 51 ++++++++++++-- plugins/a11y/README.md | 16 +++-- plugins/a11y/src/index.ts | 20 ++++-- .../tsnapi/devframe/index.snapshot.d.ts | 4 ++ 16 files changed, 204 insertions(+), 120 deletions(-) diff --git a/docs/content/1.guide/17.client-context.md b/docs/content/1.guide/17.client-context.md index be8ac3e8..8650b522 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: +- **An absolute filesystem path** to a built, self-contained ES module — declared on the definition's `dock.clientScript`. The hub install path serves the file's directory under the devframe's mount base (`__page-script/`) and rewrites `importFrom` to that served URL, so a definition names its own client script and mounting by package name works with no host wiring (this is how the [a11y inspector](/plugins/a11y) ships its page script). - **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. -For a URL, attach it via `ctx.install(myDevframe, { dock: { clientScript: { importFrom } } })`. Under Vite `/@fs/` serves it; other host frameworks mount the directory statically. +A definition declares its own by path on `dock: { clientScript: { importFrom } }`. 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..ef424b64 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 works everywhere with no extra wiring: + +```ts +initHub({ devframes: ['@devframes/plugin-a11y'] }) +``` + +The hub serves the page-script bundle same-origin under the a11y mount base and rewrites the dock's `clientScript` to that URL; a client runtime (`createDevframeClientRuntime()`) then imports it into the host page. Each scan also mirrors into the hub's messages feed — a summary plus one per rule. + +A host that mounts the module itself still can — for example, a Vite host serving it via `/@fs/` — by attaching `a11yPageScriptBundlePath` as the dock's `clientScript`: ```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..db559910 100644 --- a/examples/a11y-messages-playground/README.md +++ b/examples/a11y-messages-playground/README.md @@ -55,15 +55,13 @@ 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 as the a11y dock's `clientScript`, so +the hub serves it same-origin and boots it - the host passes only the devframes: ```ts a11yMessagesPlayground({ devframes: [a11yDevframe, messagesDevframe], - clientScripts: { - [a11yDevframe.id]: { importFrom: `/@fs/${a11yPageScriptBundlePath}` }, - }, }) ``` @@ -79,7 +77,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 (the a11y page script rides along as its dock's declared `clientScript`) | | `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..9a5110ac 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,17 @@ 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. The a11y inspector declares its own page script + * (its dock's `clientScript`), so the hub serves it same-origin and boots it - + * no client-script wiring here. */ export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions = {}): Plugin { const base = normalizeBase(options.base ?? '/__hub/') @@ -70,10 +64,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..7d0bf584 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' @@ -14,14 +14,11 @@ export default defineConfig({ optimizeDeps: { exclude: ['@antfu/design'] }, plugins: [ UnoCSS(), + // The a11y inspector ships its own page script (its dock's `clientScript`), + // so the hub serves it same-origin and boots it - the panel scans this page + // live with no wiring here. 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..128a9941 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 inside the hub namespace and rewrites the entry to that URL 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 (the a11y page script rides along automatically as its dock's declared `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` | 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..cfbf7aec 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). @@ -231,18 +186,18 @@ export async function nextDevframeHub( // way so its `import.meta.url` bundle path resolves to the published `dist`. const jsonRenderRenderer = await loadJsonRenderUiRenderer() - // Demo devframes alongside the dogfooded built-in plugin packages. The - // shared-iframe soft-navigation demo mounts as a `subTabs` anchor (a shared - // `frameId` + the postmessage protocol) so the client host attaches the - // frame-nav adapter, materializing one client-only dock per tab the SPA's - // shim reports - all sharing one iframe. + // Demo devframes alongside the dogfooded built-in plugin packages. The a11y + // inspector declares its own page script (its dock's `clientScript`) by path, + // so the hub serves it same-origin and the hub client runtime booted in + // `app/page.tsx` imports it into the host page automatically - it scans this + // hub live with no host-side wiring. The shared-iframe soft-navigation demo + // mounts as a `subTabs` anchor (a shared `frameId` + the postmessage + // protocol) so the client host attaches the frame-nav adapter, materializing + // one client-only dock per tab the SPA's 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 +282,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..ef38be12 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 (the a11y page script rides along automatically as its dock's declared `clientScript`); 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..0817907f 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,11 @@ 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}` }, - }, + // The a11y inspector declares its own page script (its dock's + // `clientScript`) by path, so the hub serves it same-origin and the hub + // client runtime (booted in src/client/main.ts) imports it into this page + // automatically - the docked panel scans the host live with no host-side + // wiring. // 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..9463cbfb 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -263,6 +263,34 @@ 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 for this devframe (its + * **page script**) — the mechanism a devframe like the a11y inspector uses to + * run its scan loop in the user app's page. `importFrom` is one of: + * + * - an **absolute filesystem path** to a built, self-contained ES module (e.g. + * the plugin's `dist/inject/inject.js`, named from its own `import.meta.url`). + * The hub install path serves the file's directory under the devframe's mount + * base and rewrites `importFrom` to that served URL, so a definition can name + * its page script itself and `devframes: ['@devframes/plugin-x']` works with + * no host-side wiring; + * - a **URL** the host already serves (`/@fs/` under Vite, a + * statically-mounted bundle path); or + * - a **bare npm specifier**, resolved through the host-advertised + * `clientModuleResolution` template. + * + * URL and bare-specifier values pass through untouched — only an absolute path + * is mounted and rewritten. + */ + clientScript?: { + /** What to import: 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..beffdc77 100644 --- a/packages/hub/src/node/__tests__/install-devframe.test.ts +++ b/packages/hub/src/node/__tests__/install-devframe.test.ts @@ -1,6 +1,6 @@ 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' @@ -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,59 @@ 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 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 } }, + })) + + expect(ctx.host.mountStatic).toHaveBeenCalledWith('/__demo/__page-script/', dir) + 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..7b14122c 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,34 @@ function nextAvailableDockId(views: DevframeHubContext['docks']['views'], baseId return `${baseId}-${n}` } +/** + * Resolve a dock's `clientScript` (its **page script**) into one importable by + * the host page. When `importFrom` names an **absolute filesystem path** to a + * built module, serve its directory under the devframe's mount base + * (`__page-script/`) and rewrite `importFrom` to that served URL — so a + * definition can name its page script by path and every host gets a URL for + * free. A URL or bare-specifier `importFrom` (or no client script) passes + * through untouched. + * + * The distinction between a filesystem path and an already-servable root path + * (`/@fs/…`, `/mounted/bundle.js`) is that only the former exists on disk, so + * that is the test used. + */ +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 +117,14 @@ export async function prepareDevframe( ? resolveBasePath(d, 'hosted') : resolveBasePath({ ...d, id, basePath: undefined }, 'hosted')) + // The effective dock defaults: definition-level `dock` beneath per-mount + // `options.dock`. Resolved here (before the SPA mount) so a page script + // declared as an absolute path can be 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 @@ -115,10 +153,11 @@ export async function prepareDevframe( 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, + // defaults; per-mount `options.dock` overrides them (both folded into + // `dockDefaults`, with an absolute-path page script already served and + // rewritten to a URL); `type`/`url` (and `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..f61e8f9d 100644 --- a/plugins/a11y/README.md +++ b/plugins/a11y/README.md @@ -56,11 +56,15 @@ 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 (`dock.clientScript.importFrom = a11yPageScriptBundlePath`): the hub install +path serves the bundle same-origin under the a11y mount base and rewrites the entry +to that URL, so `devframes: ['@devframes/plugin-a11y']` works everywhere 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 that prefers to serve the module +itself (e.g. via `/@fs/…` under Vite) can still attach `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 +121,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 by path; `a11yPageScriptBundlePath` — that page-script 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..fa9ddfe9 100644 --- a/plugins/a11y/src/index.ts +++ b/plugins/a11y/src/index.ts @@ -23,11 +23,13 @@ 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 path as its dock `clientScript`, so the + * hub install path serves it same-origin and rewrites it to a URL — mounting by + * package name (`devframes: ['@devframes/plugin-a11y']`) needs no host wiring. + * This export stays available for hosts that mount the module themselves (e.g. + * a Vite host serving it via `/@fs/${a11yPageScriptBundlePath}`). Resolves under + * `/dist/inject/inject.js` from both the source and the published entry. + * 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 +93,14 @@ 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 so any hub serves it same-origin and + // boots it automatically — no per-host `clientScript` wiring. The hub + // install path mounts the file's directory under the mount base and + // rewrites this to the served URL. + 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; From b32a0da851120ee9cf20be3c44f97c8a023c139b Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 27 Aug 2026 12:16:53 +0000 Subject: [PATCH 2/3] test: make page-script mount assertion cross-platform; trim comments Use pathe consistently in the install-devframe test so the served-dir assertion matches the implementation's normalized path on Windows. Trim the verbose comments/descriptions added across the change per review. --- examples/a11y-messages-playground/README.md | 5 ++-- .../src/a11y-messages-playground.ts | 4 +-- .../a11y-messages-playground/vite.config.ts | 3 -- examples/hub-next/README.md | 2 +- .../src/client/devframe/next-devframe-hub.ts | 14 ++++----- examples/hub-vite/README.md | 2 +- examples/hub-vite/vite.config.ts | 5 ---- packages/devframe/src/types/devframe.ts | 22 ++++---------- .../node/__tests__/install-devframe.test.ts | 7 ++--- packages/hub/src/node/install-devframe.ts | 30 +++++++------------ plugins/a11y/src/index.ts | 18 +++++------ 11 files changed, 37 insertions(+), 75 deletions(-) diff --git a/examples/a11y-messages-playground/README.md b/examples/a11y-messages-playground/README.md index db559910..3e58ef50 100644 --- a/examples/a11y-messages-playground/README.md +++ b/examples/a11y-messages-playground/README.md @@ -56,8 +56,7 @@ The window is split in two: `src/a11y-messages-playground.ts` is the entire host-framework integration - a ~120-line Vite plugin that runs `@devframes/hub` in the dev server and mounts the two devframes as docks. -The a11y inspector declares its own page script as the a11y dock's `clientScript`, so -the hub serves it same-origin and boots it - the host passes only the devframes: +The a11y inspector declares its own page script, so the host passes only the devframes: ```ts a11yMessagesPlayground({ @@ -77,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 (the a11y page script rides along as its dock's declared `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 9a5110ac..d9b431e4 100644 --- a/examples/a11y-messages-playground/src/a11y-messages-playground.ts +++ b/examples/a11y-messages-playground/src/a11y-messages-playground.ts @@ -21,9 +21,7 @@ export interface A11yMessagesPlaygroundOptions { * playground pairs (a11y + messages). One `initHub()` call assembles the whole * 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. The a11y inspector declares its own page script - * (its dock's `clientScript`), so the hub serves it same-origin and boots it - - * no client-script wiring here. + * global instance registry. */ export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions = {}): Plugin { const base = normalizeBase(options.base ?? '/__hub/') diff --git a/examples/a11y-messages-playground/vite.config.ts b/examples/a11y-messages-playground/vite.config.ts index 7d0bf584..50b8c219 100644 --- a/examples/a11y-messages-playground/vite.config.ts +++ b/examples/a11y-messages-playground/vite.config.ts @@ -14,9 +14,6 @@ export default defineConfig({ optimizeDeps: { exclude: ['@antfu/design'] }, plugins: [ UnoCSS(), - // The a11y inspector ships its own page script (its dock's `clientScript`), - // so the hub serves it same-origin and boots it - the panel scans this page - // live with no wiring here. a11yMessagesPlayground({ devframes: [a11yDevframe, messagesDevframe], }), diff --git a/examples/hub-next/README.md b/examples/hub-next/README.md index 128a9941..af9ded4b 100644 --- a/examples/hub-next/README.md +++ b/examples/hub-next/README.md @@ -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 (the a11y page script rides along automatically as its dock's declared `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 cfbf7aec..0b8761b5 100644 --- a/examples/hub-next/src/client/devframe/next-devframe-hub.ts +++ b/examples/hub-next/src/client/devframe/next-devframe-hub.ts @@ -186,15 +186,11 @@ export async function nextDevframeHub( // way so its `import.meta.url` bundle path resolves to the published `dist`. const jsonRenderRenderer = await loadJsonRenderUiRenderer() - // Demo devframes alongside the dogfooded built-in plugin packages. The a11y - // inspector declares its own page script (its dock's `clientScript`) by path, - // so the hub serves it same-origin and the hub client runtime booted in - // `app/page.tsx` imports it into the host page automatically - it scans this - // hub live with no host-side wiring. The shared-iframe soft-navigation demo - // mounts as a `subTabs` anchor (a shared `frameId` + the postmessage - // protocol) so the client host attaches the frame-nav adapter, materializing - // one client-only dock per tab the SPA's shim reports - all sharing one - // iframe. + // Demo devframes alongside the dogfooded built-in plugin packages. The + // shared-iframe soft-navigation demo mounts as a `subTabs` anchor (a shared + // `frameId` + the postmessage protocol) so the client host attaches the + // frame-nav adapter, materializing one client-only dock per tab the SPA's + // shim reports - all sharing one iframe. const devframes: (DevframeDefinition | HubDevframeEntry)[] = [ demoDevframe, ...await loadBuiltinPlugins(), diff --git a/examples/hub-vite/README.md b/examples/hub-vite/README.md index ef38be12..3523bc78 100644 --- a/examples/hub-vite/README.md +++ b/examples/hub-vite/README.md @@ -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 (the a11y page script rides along automatically as its dock's declared `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 0817907f..301b9a85 100644 --- a/examples/hub-vite/vite.config.ts +++ b/examples/hub-vite/vite.config.ts @@ -142,11 +142,6 @@ export default defineConfig({ }, }, ], - // The a11y inspector declares its own page script (its dock's - // `clientScript`) by path, so the hub serves it same-origin and the hub - // client runtime (booted in src/client/main.ts) imports it into this page - // automatically - the docked panel scans the host live with no host-side - // wiring. // 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/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 9463cbfb..278b1f13 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -265,22 +265,12 @@ export interface DevframeDockDefaults { groupId?: string /** * A client script the hub imports into the host page for this devframe (its - * **page script**) — the mechanism a devframe like the a11y inspector uses to - * run its scan loop in the user app's page. `importFrom` is one of: - * - * - an **absolute filesystem path** to a built, self-contained ES module (e.g. - * the plugin's `dist/inject/inject.js`, named from its own `import.meta.url`). - * The hub install path serves the file's directory under the devframe's mount - * base and rewrites `importFrom` to that served URL, so a definition can name - * its page script itself and `devframes: ['@devframes/plugin-x']` works with - * no host-side wiring; - * - a **URL** the host already serves (`/@fs/` under Vite, a - * statically-mounted bundle path); or - * - a **bare npm specifier**, resolved through the host-advertised - * `clientModuleResolution` template. - * - * URL and bare-specifier values pass through untouched — only an absolute path - * is mounted and rewritten. + * **page script**). `importFrom` is an **absolute filesystem path** to a built + * ES module, a **URL** the host serves, or a **bare npm specifier**. An + * absolute path is served by the hub install path (its directory mounted under + * the devframe's mount base, `importFrom` rewritten to the served URL), so a + * definition names its own page script and mounting by package name needs no + * host wiring; URL and bare-specifier values pass through untouched. */ clientScript?: { /** What to import: an absolute filesystem path, a served URL, or a bare npm specifier. */ diff --git a/packages/hub/src/node/__tests__/install-devframe.test.ts b/packages/hub/src/node/__tests__/install-devframe.test.ts index beffdc77..da4762e2 100644 --- a/packages/hub/src/node/__tests__/install-devframe.test.ts +++ b/packages/hub/src/node/__tests__/install-devframe.test.ts @@ -2,8 +2,8 @@ import type { DevframeDefinition, DevframeDuplicationStrategy } from 'devframe/t import type { DevframeHubContext } from '../context' 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' @@ -165,15 +165,14 @@ describe('ctx.install', () => { it('serves an absolute-path page script under the mount base and rewrites it to the served URL', async () => { const ctx = createContext() - const dir = mkdtempSync(join(tmpdir(), 'devframe-page-script-')) - const scriptPath = join(dir, 'inject.js') + 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/', dir) + expect(ctx.host.mountStatic).toHaveBeenCalledWith('/__demo/__page-script/', dirname(scriptPath)) expect(ctx.docks.views.get('demo')).toMatchObject({ clientScript: { importFrom: '/__demo/__page-script/inject.js' }, }) diff --git a/packages/hub/src/node/install-devframe.ts b/packages/hub/src/node/install-devframe.ts index 7b14122c..f7944e1c 100644 --- a/packages/hub/src/node/install-devframe.ts +++ b/packages/hub/src/node/install-devframe.ts @@ -40,17 +40,11 @@ function nextAvailableDockId(views: DevframeHubContext['docks']['views'], baseId } /** - * Resolve a dock's `clientScript` (its **page script**) into one importable by - * the host page. When `importFrom` names an **absolute filesystem path** to a - * built module, serve its directory under the devframe's mount base - * (`__page-script/`) and rewrite `importFrom` to that served URL — so a - * definition can name its page script by path and every host gets a URL for - * free. A URL or bare-specifier `importFrom` (or no client script) passes - * through untouched. - * - * The distinction between a filesystem path and an already-servable root path - * (`/@fs/…`, `/mounted/bundle.js`) is that only the former exists on disk, so - * that is the test used. + * When a dock's `clientScript.importFrom` names an **absolute filesystem path** + * to a built module, serve its directory under the devframe's mount base + * (`__page-script/`) and rewrite `importFrom` to that served URL. A URL or + * bare specifier (distinguished by not existing on disk) passes through + * untouched, as does an absent client script. */ async function resolvePageScriptClientScript( ctx: DevframeHubContext, @@ -117,9 +111,9 @@ export async function prepareDevframe( ? resolveBasePath(d, 'hosted') : resolveBasePath({ ...d, id, basePath: undefined }, 'hosted')) - // The effective dock defaults: definition-level `dock` beneath per-mount - // `options.dock`. Resolved here (before the SPA mount) so a page script - // declared as an absolute path can be served ahead of the SPA catch-all. + // Definition-level `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) @@ -152,11 +146,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 (both folded into - // `dockDefaults`, with an absolute-path page script already served and - // rewritten to a URL); `type`/`url` (and `id`) stay locked, derived from - // the definition. + // Dock defaults (definition + per-mount, folded into `dockDefaults`) sit + // above the name/icon-derived defaults; `type`/`url` (and `id`) stay locked, + // derived from the definition. ...dockDefaults, type: 'iframe', url: base, diff --git a/plugins/a11y/src/index.ts b/plugins/a11y/src/index.ts index fa9ddfe9..b4210cac 100644 --- a/plugins/a11y/src/index.ts +++ b/plugins/a11y/src/index.ts @@ -23,13 +23,11 @@ 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). * - * The definition already declares this path as its dock `clientScript`, so the - * hub install path serves it same-origin and rewrites it to a URL — mounting by - * package name (`devframes: ['@devframes/plugin-a11y']`) needs no host wiring. - * This export stays available for hosts that mount the module themselves (e.g. - * a Vite host serving it via `/@fs/${a11yPageScriptBundlePath}`). 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 path as its dock `clientScript`, so a + * hub serves it same-origin and boots it with no host wiring. Exported for + * hosts that mount the module themselves (e.g. via `/@fs/` under Vite). + * Resolves under `/dist/inject/inject.js` from both the source and the + * published entry. Requires the built bundle (`pnpm -C plugins/a11y build`). */ export const a11yPageScriptBundlePath: string = fileURLToPath(new URL('../dist/inject/inject.js', import.meta.url)) @@ -93,10 +91,8 @@ 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 so any hub serves it same-origin and - // boots it automatically — no per-host `clientScript` wiring. The hub - // install path mounts the file's directory under the mount base and - // rewrites this to the served URL. + // Declare the page script by path; the hub serves it same-origin and boots + // it with no per-host wiring. dock: { category: '~builtin', clientScript: { importFrom: a11yPageScriptBundlePath }, From 219ee1ff987152486628be001295913a889ccfce Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 27 Aug 2026 16:25:48 +0000 Subject: [PATCH 3/3] docs: tighten prose and jsdocs; reorder client-script shapes Move the absolute-path shape to the third bullet and drop the a11y parenthetical (review). Make the page-script docs, jsdocs, and comments as concise as possible. --- docs/content/1.guide/17.client-context.md | 4 ++-- docs/content/5.plugins/4.a11y.md | 6 +++--- examples/a11y-messages-playground/README.md | 4 ++-- examples/hub-next/README.md | 2 +- packages/devframe/src/types/devframe.ts | 13 +++++-------- packages/hub/src/node/install-devframe.ts | 18 +++++++----------- plugins/a11y/README.md | 16 +++++++--------- plugins/a11y/src/index.ts | 12 +++++------- 8 files changed, 32 insertions(+), 43 deletions(-) diff --git a/docs/content/1.guide/17.client-context.md b/docs/content/1.guide/17.client-context.md index 8650b522..b54292ba 100644 --- a/docs/content/1.guide/17.client-context.md +++ b/docs/content/1.guide/17.client-context.md @@ -78,11 +78,11 @@ A failed import retries on the next dock update. `importFrom` accepts three shapes: -- **An absolute filesystem path** to a built, self-contained ES module — declared on the definition's `dock.clientScript`. The hub install path serves the file's directory under the devframe's mount base (`__page-script/`) and rewrites `importFrom` to that served URL, so a definition names its own client script and mounting by package name works with no host wiring (this is how the [a11y inspector](/plugins/a11y) ships its page script). - **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. -A definition declares its own by path on `dock: { clientScript: { importFrom } }`. 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. +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 ef424b64..0875e925 100644 --- a/docs/content/5.plugins/4.a11y.md +++ b/docs/content/5.plugins/4.a11y.md @@ -31,15 +31,15 @@ The page script and the panel talk over the [in-page channel](/guide/in-page-cha ## In a hub -The definition declares the page script as its dock [client script](/guide/client-context), so mounting by package name works everywhere with no extra wiring: +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 page-script bundle same-origin under the a11y mount base and rewrites the dock's `clientScript` to that URL; a client runtime (`createDevframeClientRuntime()`) then imports it into the host page. Each scan also mirrors into the hub's messages feed — a summary plus one per rule. +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 that mounts the module itself still can — for example, a Vite host serving it via `/@fs/` — by attaching `a11yPageScriptBundlePath` as the dock's `clientScript`: +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 3e58ef50..acab15ef 100644 --- a/examples/a11y-messages-playground/README.md +++ b/examples/a11y-messages-playground/README.md @@ -55,8 +55,8 @@ 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 and mounts the two devframes as docks. -The a11y inspector declares its own page script, so the host passes only the devframes: +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({ diff --git a/examples/hub-next/README.md b/examples/hub-next/README.md index af9ded4b..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 devframe declares its own page-script module as the a11y dock's `clientScript`, so the hub serves it same-origin inside the hub namespace and rewrites the entry to that URL 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 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. diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 278b1f13..95fed918 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -264,16 +264,13 @@ export interface DevframeDockDefaults { /** Id of the dock group this entry collapses under, if any. */ groupId?: string /** - * A client script the hub imports into the host page for this devframe (its - * **page script**). `importFrom` is an **absolute filesystem path** to a built - * ES module, a **URL** the host serves, or a **bare npm specifier**. An - * absolute path is served by the hub install path (its directory mounted under - * the devframe's mount base, `importFrom` rewritten to the served URL), so a - * definition names its own page script and mounting by package name needs no - * host wiring; URL and bare-specifier values pass through untouched. + * 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?: { - /** What to import: an absolute filesystem path, a served URL, or a bare npm specifier. */ + /** An absolute filesystem path, a served URL, or a bare npm specifier. */ importFrom: string /** * The name to import the module as. diff --git a/packages/hub/src/node/install-devframe.ts b/packages/hub/src/node/install-devframe.ts index f7944e1c..72fdd0d2 100644 --- a/packages/hub/src/node/install-devframe.ts +++ b/packages/hub/src/node/install-devframe.ts @@ -40,11 +40,9 @@ function nextAvailableDockId(views: DevframeHubContext['docks']['views'], baseId } /** - * When a dock's `clientScript.importFrom` names an **absolute filesystem path** - * to a built module, serve its directory under the devframe's mount base - * (`__page-script/`) and rewrite `importFrom` to that served URL. A URL or - * bare specifier (distinguished by not existing on disk) passes through - * untouched, as does an absent client script. + * 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, @@ -111,9 +109,8 @@ export async function prepareDevframe( ? resolveBasePath(d, 'hosted') : resolveBasePath({ ...d, id, basePath: undefined }, 'hosted')) - // Definition-level `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. + // 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) @@ -146,9 +143,8 @@ export async function prepareDevframe( id, title: d.name, icon: d.icon, - // Dock defaults (definition + per-mount, folded into `dockDefaults`) sit - // above the name/icon-derived defaults; `type`/`url` (and `id`) stay locked, - // derived from the definition. + // `dockDefaults` sits above the name/icon defaults; `type`/`url`/`id` stay + // locked, derived from the definition. ...dockDefaults, type: 'iframe', url: base, diff --git a/plugins/a11y/README.md b/plugins/a11y/README.md index f61e8f9d..48697c3f 100644 --- a/plugins/a11y/README.md +++ b/plugins/a11y/README.md @@ -57,14 +57,12 @@ 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**, and the definition declares it by -path (`dock.clientScript.importFrom = a11yPageScriptBundlePath`): the hub install -path serves the bundle same-origin under the a11y mount base and rewrites the entry -to that URL, so `devframes: ['@devframes/plugin-a11y']` works everywhere 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 that prefers to serve the module -itself (e.g. via `/@fs/…` under Vite) can still attach `a11yPageScriptBundlePath` as -a per-mount `clientScript`. Booted that way, the page script also +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 @@ -121,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), declaring the page script as its dock's client script by path; `a11yPageScriptBundlePath` — that page-script module, for hosts that serve it themselves | +| `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 b4210cac..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). * - * The definition already declares this path as its dock `clientScript`, so a - * hub serves it same-origin and boots it with no host wiring. Exported for - * hosts that mount the module themselves (e.g. via `/@fs/` under Vite). - * 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,8 +90,7 @@ 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 same-origin and boots - // it with no per-host wiring. + // Declare the page script by path; the hub serves it with no host wiring. dock: { category: '~builtin', clientScript: { importFrom: a11yPageScriptBundlePath },