diff --git a/alias.ts b/alias.ts index 37f4a275..d916b2c4 100644 --- a/alias.ts +++ b/alias.ts @@ -23,6 +23,7 @@ export const alias = { 'devframe/node/hub-internals': r('devframe/src/node/hub-internals/index.ts'), 'devframe/node': r('devframe/src/node/index.ts'), 'devframe/internal': r('devframe/src/internal/index.ts'), + 'devframe/in-page-channel': r('devframe/src/in-page-channel/index.ts'), 'devframe/constants': r('devframe/src/constants.ts'), 'devframe/utils/agent-tool-name': r('devframe/src/utils/agent-tool-name.ts'), 'devframe/utils/colors': r('devframe/src/utils/colors.ts'), diff --git a/docs/content/1.guide/12.in-page-channel.md b/docs/content/1.guide/12.in-page-channel.md new file mode 100644 index 00000000..934b03b9 --- /dev/null +++ b/docs/content/1.guide/12.in-page-channel.md @@ -0,0 +1,193 @@ +--- +title: 'In-Page Channel' +description: 'The in-page channel connects a devframe''s page script to its panels entirely in the browser — typed events, calls, and page-script-authoritative shared state, with no server involved.' +--- + +The in-page channel (`devframe/in-page-channel`) connects a devframe's page script to its panels entirely in the browser — typed events, calls, and page-script-authoritative shared state, with no server involved. It is how a live inspect-the-page loop (like the [a11y inspector](/plugins/a11y)'s scan/highlight cycle) works identically in dev and in a static build. + +## Overview + +```mermaid +flowchart LR + subgraph Host["User app's page"] + PS["Page script
createPageScriptChannel()"] + end + subgraph Dock["Dock iframe"] + PA["Panel
connectPanelChannel()"] + end + subgraph PiP["Popup / Document PiP"] + PB["Panel
connectPanelChannel()"] + end + PA <-->|"MessageChannel port"| PS + PB <-->|"MessageChannel port"| PS +``` + +The panel finds the page script with a same-origin `postMessage` handshake: it posts a versioned hello to its ancestor chain and `opener`, retrying with backoff until the page script answers by transferring a dedicated `MessageChannel` port. Boot order never matters, a reload of either side is just a re-handshake, and each connected panel gets its own port — a dock iframe and a picture-in-picture window can watch the same page script at once. + +## The protocol + +Declare the contract once, in a shared file both sides import — a pure type plus the channel-name constant: + +```ts +// shared/protocol.ts +import type { InPageChannelProtocol } from 'devframe/in-page-channel' + +export const MY_CHANNEL = 'devframes:plugin:my-tool' + +export interface MyChannelProtocol extends InPageChannelProtocol { + pageScript: { // implemented by the page script, called by panels + highlight: (selector: string) => void + measure: (selector: string) => { width: number, height: number } + } + panel: { // implemented by panels, called by the page script + flash: (message: string) => void + } + sharedStates: { + state: { selections: string[] } + } +} +``` + +Channel names are namespaced with the devframe id, like RPC ids. Function names stay bare — the channel name already scopes them. + +## The page script endpoint + +Functions are defined with `defineChannelFunction` — the same authoring shape as `defineRpcFunction` (`name`, `type`, Standard-Schema `args`/`returns`, `jsonSerializable`, `handler`), narrowed to the browser. Define each side's functions in that side's source files; the shared protocol file carries only types. + +```ts +import type { MyChannelProtocol } from '../shared/protocol' +// inject/index.ts — runs in the user app's page +import { createPageScriptChannel, defineChannelFunction } from 'devframe/in-page-channel' +import { MY_CHANNEL } from '../shared/protocol' + +const channel = createPageScriptChannel({ + name: MY_CHANNEL, + functions: [ + defineChannelFunction({ + name: 'highlight', + type: 'event', // fire-and-forget + jsonSerializable: true, + handler: (selector: string) => drawRing(document.querySelector(selector)), + }), + defineChannelFunction({ + name: 'measure', // request/response (the default `query` type) + handler: (selector: string) => { + const rect = document.querySelector(selector)!.getBoundingClientRect() + return { width: rect.width, height: rect.height } + }, + }), + ], +}) + +channel.callEvent('flash', 'scanning…') // fans out to every connected panel +channel.events.on('panel:connected', panel => console.log(panel.id)) +channel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches()) +``` + +`callEvent` on the page script is 1:N — it fans out to every connected panel, and panels that don't implement the function ignore it. Request/response *to* a panel goes through an explicit peer handle: `channel.panels[0].call('flash', '…')`. + +## The panel endpoint + +```ts +import type { MyChannelProtocol } from '../shared/protocol' +// spa/main.ts — the devtools SPA (dock iframe, popup, or PiP) +import { connectPanelChannel } from 'devframe/in-page-channel' +import { MY_CHANNEL } from '../shared/protocol' + +const channel = connectPanelChannel({ name: MY_CHANNEL }) + +channel.callEvent('highlight', '.hero') // buffered until connected +const size = await channel.call('measure', '.hero') +``` + +## Shared state + +The channel's shared-state layer mirrors [`rpc.sharedState`](/guide/shared-state) — same `SharedState` handle, same accessor — with the page script playing the server's role as rendezvous and authority. Its first `get` of a key must provide the initial value; panels are seeded automatically on connect (including late joiners and re-connects) and converge through syncId-deduplicated patches. + +```ts +// Page script — the authority: +const state = await channel.sharedState.get('state', { initialValue: { selections: [] } }) +state.mutate((draft) => { + draft.selections.push('.hero') +}) + +// Panel — a live mirror: +const state = await channel.sharedState.get('state') +state.on('updated', fullState => render(fullState)) +state.value() // Immutable snapshot +``` + +Without an `initialValue`, a panel's `get` resolves once the first replay arrives — so `render(state.value())` never sees a half-initialized value. Keep values serializable: they cross a structured-clone boundary on every sync. + +## Errors and fallbacks + +Every failure mode is a coded `InPageChannelError` (`error.code`) with a message that explains itself: + +| Code | When | What to do | +|------|------|------------| +| `timeout` | A call outlived `callTimeoutMs` (default 15s), or `whenConnected(ms)` expired | The message carries the endpoint status — `connecting` usually means the page script isn't loaded in this context | +| `closed` | The endpoint was closed with calls pending | Expected during teardown | +| `not-serializable` | A `jsonSerializable: true` payload contained a non-JSON value | The message names the offending path (e.g. `its arguments[0].nodes[2]` is a Map) | +| `not-cloneable` | The port refused to clone a payload (`DataCloneError`) | Strip functions/DOM nodes/reactivity proxies — or declare `jsonSerializable: true` for the precise error above | +| `invalid-args` | Incoming arguments failed their Standard-Schema validation | The message lists the schema issues | +| `state-uninitialized` | The page script read a shared state before providing its `initialValue` | Initialize on first access | + +The panel endpoint's connection lifecycle is explicit, so a panel renders a useful fallback instead of hanging: + +- `channel.status` is `connecting` → `connected` → (`connecting` on port loss) → `closed`, with `events.on('status:updated', …)` for reactivity. +- While `connecting`, `call()` is queued (and still subject to its deadline) and `callEvent()` is buffered (up to `eventBufferLimit`, oldest dropped with a warning) — both flush on connect. +- A page script may legitimately never appear (the panel opened standalone, the user app not instrumented). Race `whenConnected(timeoutMs)` to show a "load the page script" empty state: + +```ts +try { + await channel.whenConnected(3000) +} +catch { + renderEmptyState('Add the page script to your app to see live data.') +} +``` + +Recovery is automatic: a dead port (detected by the port's `close` event or the built-in heartbeat) returns the panel to `connecting` and resumes the handshake, so a host-page reload reconnects a popup panel by itself. + +## Reactivity and serialization + +Payloads cross the port with structured clone. Framework reactivity wrappers don't survive it — unwrap them before sending, either in handlers or once per endpoint with the `serialize`/`deserialize` hooks: + +```ts +import { toRaw } from 'vue' + +const channel = connectPanelChannel({ + name: MY_CHANNEL, + serialize: value => toRawDeep(value), // applied to every outgoing argument and result +}) +``` + +Declaring a function `jsonSerializable: true` additionally enforces strict JSON on its payloads at the receiving endpoint, turning a would-be silent coercion or cryptic `DataCloneError` into a coded error naming the offending path. + +## Multiple tabs + +The same app open in two tabs means two page scripts on one origin. Each page script carries a per-tab instance id (persisted in `sessionStorage`), and handshakes are targeted `postMessage` — so a dock panel always pairs with its own tab's page script. A panel can also pin explicitly: + +```ts +connectPanelChannel({ name: MY_CHANNEL, instanceId }) +``` + +## Custom transports + +Both endpoints accept a pre-established `MessagePort`, bypassing the handshake — for custom topologies and tests: + +```ts +const { port1, port2 } = new MessageChannel() +pageScript.addPanelPort(port1) +const panel = connectPanelChannel({ name: MY_CHANNEL, transport: port2 }) +``` + +## When to use the in-page channel vs RPC + +| Use the in-page channel for | Use [RPC](/guide/rpc) for | +|----------------------------|---------------------------| +| Page script ↔ panel loops (highlight, scan, measure) | Anything involving the node side (files, processes, storage) | +| Working identically in dev and static builds | Data that must survive the tab (server owns it) | +| Same-tab, same-origin surfaces | Cross-origin external viewers, remote panels | + +The a11y inspector uses both: the scan/highlight loop rides the in-page channel, while `get-config` is a `static` RPC resolved over WebSocket in dev and from the baked dump in a static build. diff --git a/docs/content/1.guide/12.transports.md b/docs/content/1.guide/13.transports.md similarity index 100% rename from docs/content/1.guide/12.transports.md rename to docs/content/1.guide/13.transports.md diff --git a/docs/content/1.guide/13.security.md b/docs/content/1.guide/14.security.md similarity index 100% rename from docs/content/1.guide/13.security.md rename to docs/content/1.guide/14.security.md diff --git a/docs/content/1.guide/14.agent-native.md b/docs/content/1.guide/15.agent-native.md similarity index 100% rename from docs/content/1.guide/14.agent-native.md rename to docs/content/1.guide/15.agent-native.md diff --git a/docs/content/1.guide/15.hub.md b/docs/content/1.guide/16.hub.md similarity index 100% rename from docs/content/1.guide/15.hub.md rename to docs/content/1.guide/16.hub.md diff --git a/docs/content/1.guide/16.client-context.md b/docs/content/1.guide/17.client-context.md similarity index 98% rename from docs/content/1.guide/16.client-context.md rename to docs/content/1.guide/17.client-context.md index 87eb4375..44f90b2c 100644 --- a/docs/content/1.guide/16.client-context.md +++ b/docs/content/1.guide/17.client-context.md @@ -93,7 +93,7 @@ One bundle can serve as both a client script (default export) and, via a globall ## Iframe panels -Dock iframes are their own documents: the panel calls `connectDevframe()`, discovering `./__connection.json` from its base. A client script and an iframe panel share the node side via RPC and shared state, or a same-origin `BroadcastChannel` for static builds. +Dock iframes are their own documents: the panel calls `connectDevframe()`, discovering `./__connection.json` from its base. A client script and an iframe panel share the node side via RPC and shared state, or talk directly — server-free, static-build-friendly — over the [in-page channel](/guide/in-page-channel). ## Shared-iframe soft navigation diff --git a/docs/content/1.guide/17.hub-initiate.md b/docs/content/1.guide/18.hub-initiate.md similarity index 100% rename from docs/content/1.guide/17.hub-initiate.md rename to docs/content/1.guide/18.hub-initiate.md diff --git a/docs/content/1.guide/18.services.md b/docs/content/1.guide/19.services.md similarity index 100% rename from docs/content/1.guide/18.services.md rename to docs/content/1.guide/19.services.md diff --git a/docs/content/1.guide/19.deep-linking.md b/docs/content/1.guide/20.deep-linking.md similarity index 100% rename from docs/content/1.guide/19.deep-linking.md rename to docs/content/1.guide/20.deep-linking.md diff --git a/docs/content/1.guide/20.build-your-own-json-render-frontend.md b/docs/content/1.guide/21.build-your-own-json-render-frontend.md similarity index 100% rename from docs/content/1.guide/20.build-your-own-json-render-frontend.md rename to docs/content/1.guide/21.build-your-own-json-render-frontend.md diff --git a/docs/content/1.guide/21.build-your-own-hub-ui.md b/docs/content/1.guide/22.build-your-own-hub-ui.md similarity index 100% rename from docs/content/1.guide/21.build-your-own-hub-ui.md rename to docs/content/1.guide/22.build-your-own-hub-ui.md diff --git a/docs/content/1.guide/22.built-with.md b/docs/content/1.guide/23.built-with.md similarity index 100% rename from docs/content/1.guide/22.built-with.md rename to docs/content/1.guide/23.built-with.md diff --git a/docs/content/5.plugins/4.a11y.md b/docs/content/5.plugins/4.a11y.md index dfa59b3e..69eff446 100644 --- a/docs/content/5.plugins/4.a11y.md +++ b/docs/content/5.plugins/4.a11y.md @@ -21,11 +21,11 @@ Three pieces, two browser-side: | Piece | Runs in | Role | |-------|---------|------| -| **Page script** | the user app's page | runs axe-core, broadcasts the report, draws the highlight ring | +| **Page script** | the user app's page | runs axe-core, owns the report state, draws the highlight ring | | **Panel** | the devtools iframe | Solid SPA: lists violations, highlights on hover | | **Node side** | the Node process | the `get-config` RPC (impact taxonomy), baked in static builds | -The page script and the panel talk over the in-page channel (a same-origin `BroadcastChannel`), so the loop works live or static. The page script is the author-provided bridge (the panel has no reach into the user app's DOM) — one module script scans, reports, and highlights. +The page script and the panel talk over the [in-page channel](/guide/in-page-channel), so the loop works live or static. The page script is the author-provided bridge (the panel has no reach into the user app's DOM) — one module script scans, reports, and highlights. ## In a hub @@ -86,7 +86,7 @@ Namespaced `devframes:plugin:a11y:*`: | Function | Type | Returns | |----------|------|---------| -| `get-config` | `static` | The impact taxonomy and the in-page channel's `BroadcastChannel` coordinates. | +| `get-config` | `static` | The impact taxonomy and the in-page channel coordinates. | ## Run the demo diff --git a/docs/content/8.references/1.terms.md b/docs/content/8.references/1.terms.md index 170b0f14..d6cd7bd3 100644 --- a/docs/content/8.references/1.terms.md +++ b/docs/content/8.references/1.terms.md @@ -69,4 +69,4 @@ Three distinct paths connect the pieces; each has its own name. |------|---------|-----------| | **RPC** | browser side ↔ node side | WebSocket or static snapshot, via `connectDevframe()` | | **client context** | client scripts ↔ client runtime | a shared object inside the host page | -| **in-page channel** | page script ↔ panel | same-origin, entirely in-browser (e.g. a `MessageChannel` or `BroadcastChannel`) | +| **in-page channel** | page script ↔ panel | same-origin, entirely in-browser — a handshaken `MessageChannel` port per panel, via [`devframe/in-page-channel`](/guide/in-page-channel) | diff --git a/docs/content/8.references/3.events.md b/docs/content/8.references/3.events.md index 070cd8c9..aaf499c6 100644 --- a/docs/content/8.references/3.events.md +++ b/docs/content/8.references/3.events.md @@ -92,4 +92,18 @@ Pushed to subscribed RPC clients, wired by the core node side. | `devframe:streaming:end` | A streaming terminator (optionally an error). | | `devframe:streaming:upload-cancel` | Server-side cancel of an in-flight upload. | -Plus one `postMessage` channel, `devframe:remote-assets-error`, posted by the remote-assets fallback page to `window.parent` so an embedding hub UI provider can replace the 502 page. +### In-page channel notifications — page script → panel + +Pushed over each panel's [in-page channel](/guide/in-page-channel) port; the paired request methods (`devframe:in-page:page-state:subscribe`/`set`/`patch`) are call endpoints defined at their handlers, not events. + +| Name | Carries | +|---|---| +| `devframe:in-page:panel-state:updated` | Full channel shared-state snapshot for a key. | +| `devframe:in-page:panel-state:patch` | Incremental channel shared-state patch for a key. | + +### `postMessage` channels + +| Name | Posted by | Carries | +|---|---|---| +| `devframe:remote-assets-error` | the remote-assets fallback page, to `window.parent` | The failed package/version/reason, so an embedding hub UI provider can replace the 502 page. | +| `devframe:in-page-channel` | both [in-page channel](/guide/in-page-channel) endpoints, across window boundaries | The versioned handshake envelope (panel hello, page-script port grant). | diff --git a/examples/a11y-messages-playground/src/client/main.ts b/examples/a11y-messages-playground/src/client/main.ts index 8dfc2a4c..1908436f 100644 --- a/examples/a11y-messages-playground/src/client/main.ts +++ b/examples/a11y-messages-playground/src/client/main.ts @@ -49,7 +49,7 @@ async function main() { const docks = await rpc.sharedState.get('devframe:docks', { initialValue: [] }) // Keep-alive iframe pool: one iframe per dock, toggled by visibility so the - // a11y panel keeps its BroadcastChannel connection while you switch docks. + // a11y panel keeps its in-page channel connection while you switch docks. const iframePool = new Map() function ensureIframe(entry: DevframeDockEntry & { url: string }): HTMLIFrameElement { diff --git a/examples/a11y-messages-playground/vite.config.ts b/examples/a11y-messages-playground/vite.config.ts index a02c89a5..4733c5b7 100644 --- a/examples/a11y-messages-playground/vite.config.ts +++ b/examples/a11y-messages-playground/vite.config.ts @@ -16,9 +16,9 @@ export default defineConfig({ UnoCSS(), a11yMessagesPlayground({ devframes: [a11yDevframe, messagesDevframe], - // Attach the a11y agent as the a11y dock's client script - served over - // Vite's `/@fs/` so it shares this page's origin (the BroadcastChannel the - // agent and panel talk over rides that origin). + // 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 ddef149f..eb9123e7 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 their in-page channel (a `BroadcastChannel`) rides. +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 **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/examples/hub-vite/README.md b/examples/hub-vite/README.md index e2f487b5..7bc106b5 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 their in-page channel (a `BroadcastChannel`) rides; 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. `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 **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. diff --git a/knip.jsonc b/knip.jsonc index f8fa6bc7..2af8dc31 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -126,6 +126,7 @@ "src/adapters/{build,cac,dev,embedded,initiate}.ts", "src/adapters/mcp/index.ts", "src/client/index.ts", + "src/in-page-channel/index.ts", "src/internal/index.ts", "src/node/index.ts", "src/node/{auth,hub-internals}/index.ts", diff --git a/packages/devframe/package.json b/packages/devframe/package.json index 0749099b..485e2e7a 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -27,6 +27,7 @@ "./adapters/mcp": "./dist/adapters/mcp.mjs", "./client": "./dist/client/index.mjs", "./constants": "./dist/constants.mjs", + "./in-page-channel": "./dist/in-page-channel/index.mjs", "./initiate": "./dist/adapters/initiate.mjs", "./internal": "./dist/internal/index.mjs", "./node": "./dist/node/index.mjs", diff --git a/packages/devframe/src/events.ts b/packages/devframe/src/events.ts index 87b3d4ef..d6dedc1c 100644 --- a/packages/devframe/src/events.ts +++ b/packages/devframe/src/events.ts @@ -57,8 +57,20 @@ export const DEVFRAME_EVENTS = { streamingEnd: 'devframe:streaming:end', streamingUploadCancel: 'devframe:streaming:upload-cancel', }, + /** + * In-page channel notifications the page script pushes to its panels + * (page script → panel), `devframe:` prefix. The paired request methods + * (`devframe:in-page:page-state:subscribe`/`set`/`patch`) are call + * endpoints, not events, and are defined at their handlers + * (`in-page-channel/state.ts`). + */ + inPageChannel: { + panelStateUpdated: 'devframe:in-page:panel-state:updated', + panelStatePatch: 'devframe:in-page:panel-state:patch', + }, /** `postMessage` channels the runtime posts across window boundaries. */ postMessage: { remoteAssetsError: 'devframe:remote-assets-error', + inPageChannel: 'devframe:in-page-channel', }, } as const diff --git a/packages/devframe/src/in-page-channel/in-page-channel.test.ts b/packages/devframe/src/in-page-channel/in-page-channel.test.ts new file mode 100644 index 00000000..173e9351 --- /dev/null +++ b/packages/devframe/src/in-page-channel/in-page-channel.test.ts @@ -0,0 +1,664 @@ +import type { InPageChannelProtocol, PageScriptChannel, PanelChannel } from './types' +import { describe, expect, it, vi } from 'vitest' +import { defineChannelFunction } from './index' +import { InPageChannelError } from './internal' +import { createPageScriptChannel } from './page-script' +import { connectPanelChannel } from './panel' +import { IN_PAGE_CHANNEL_TAG, IN_PAGE_CHANNEL_VERSION } from './protocol' + +interface TestProtocol extends InPageChannelProtocol { + pageScript: { + echo: (value: string) => string + sum: (a: number, b: number) => number + boom: () => void + strict: (payload: unknown) => unknown + note: (value: string) => void + } + panel: { + 'ping-panel': (value: string) => string + 'notify': (value: string) => void + } + sharedStates: { + doc: { count: number, label?: string } + } +} + +function until(predicate: () => boolean, timeoutMs = 2000): Promise { + return new Promise((resolve, reject) => { + const started = Date.now() + const tick = (): void => { + if (predicate()) + return resolve() + if (Date.now() - started > timeoutMs) + return reject(new Error('until(): condition not met in time')) + setTimeout(tick, 5) + } + tick() + }) +} + +const noHandshake = { window: false as const, heartbeat: false as const } + +function createLinkedPair(options?: { + pageScript?: Partial[0]> + panel?: Partial[0]> +}): { pageScript: PageScriptChannel, panel: PanelChannel, dispose: () => void } { + const { port1, port2 } = new MessageChannel() + const pageScript = createPageScriptChannel({ + name: 'devframes:test', + ...noHandshake, + functions: [ + defineChannelFunction({ name: 'echo', handler: (value: string) => value }), + defineChannelFunction({ name: 'sum', type: 'query', handler: (a: number, b: number) => a + b }), + defineChannelFunction({ name: 'boom', handler: () => { + throw new Error('exploded') + } }), + defineChannelFunction({ name: 'strict', jsonSerializable: true, handler: (payload: unknown) => payload }), + ], + ...options?.pageScript, + }) + pageScript.addPanelPort(port1) + const panel = connectPanelChannel({ + name: 'devframes:test', + ...noHandshake, + transport: port2, + ...options?.panel, + }) + return { + pageScript, + panel, + dispose: () => { + panel.close() + pageScript.close() + }, + } +} + +describe('in-page channel over bring-your-own ports', () => { + it('round-trips calls, arguments, and results', async () => { + const { pageScript, panel, dispose } = createLinkedPair() + try { + expect(panel.status).toBe('connected') + expect(pageScript.panels).toHaveLength(1) + await expect(panel.call('echo', 'hello')).resolves.toBe('hello') + await expect(panel.call('sum', 2, 40)).resolves.toBe(42) + } + finally { + dispose() + } + }) + + it('propagates handler errors to the caller', async () => { + const { panel, dispose } = createLinkedPair() + try { + await expect(panel.call('boom')).rejects.toThrow('exploded') + } + finally { + dispose() + } + }) + + it('rejects calls to unknown functions', async () => { + const { panel, dispose } = createLinkedPair() + try { + await expect(panel.call('missing' as any)).rejects.toThrow(/not found/) + } + finally { + dispose() + } + }) + + it('enforces jsonSerializable payloads with a coded error', async () => { + const { panel, dispose } = createLinkedPair() + try { + // A Map survives structured clone, so it reaches the receiving side — + // where the jsonSerializable contract rejects it, naming the path. + const rejection = await panel.call('strict', { nested: new Map() }).catch(error => error) as Error + expect(rejection.message).toContain('jsonSerializable') + expect(rejection.message).toContain('nested') + // Plain JSON passes. + await expect(panel.call('strict', { ok: [1, 'two', null] })).resolves.toEqual({ ok: [1, 'two', null] }) + } + finally { + dispose() + } + }) + + it('wraps DataCloneError with a helpful coded error', async () => { + const { panel, dispose } = createLinkedPair() + try { + const rejection = await panel.call('echo', (() => {}) as any).catch(error => error) + expect(rejection).toBeInstanceOf(InPageChannelError) + expect(rejection.code).toBe('not-cloneable') + } + finally { + dispose() + } + }) + + it('validates arguments against Standard Schemas', async () => { + const { s } = await import('devframe/utils/simple-schema') + const { port1, port2 } = new MessageChannel() + const pageScript = createPageScriptChannel({ + name: 'devframes:test', + ...noHandshake, + functions: [ + defineChannelFunction({ + name: 'note', + args: [s.string()] as const, + returns: s.void(), + handler: () => {}, + }), + ], + }) + pageScript.addPanelPort(port1) + const panel = connectPanelChannel({ + name: 'devframes:test', + ...noHandshake, + transport: port2, + }) + try { + await expect(panel.call('note', 'fine')).resolves.toBeUndefined() + const rejection = await panel.call('note', 42 as any).catch(error => error) + expect(rejection.message).toContain('rejected argument 0') + } + finally { + panel.close() + pageScript.close() + } + }) + + it('fans events out to every panel; panels without the handler ignore them', async () => { + const a = new MessageChannel() + const b = new MessageChannel() + const pageScript = createPageScriptChannel({ + name: 'devframes:test', + ...noHandshake, + }) + pageScript.addPanelPort(a.port1) + pageScript.addPanelPort(b.port1) + const received: string[] = [] + const panelA = connectPanelChannel({ + name: 'devframes:test', + ...noHandshake, + transport: a.port2, + functions: [ + defineChannelFunction({ name: 'notify', type: 'event', handler: (value: string) => { + received.push(`a:${value}`) + } }), + ], + }) + // Panel B deliberately implements nothing. + const panelB = connectPanelChannel({ + name: 'devframes:test', + ...noHandshake, + transport: b.port2, + }) + try { + expect(pageScript.panels).toHaveLength(2) + pageScript.callEvent('notify', 'scan') + await until(() => received.length === 1) + expect(received).toEqual(['a:scan']) + } + finally { + panelA.close() + panelB.close() + pageScript.close() + } + }) + + it('lets the page script call one panel through its peer handle', async () => { + const { port1, port2 } = new MessageChannel() + const pageScript = createPageScriptChannel({ + name: 'devframes:test', + ...noHandshake, + }) + pageScript.addPanelPort(port1) + const panel = connectPanelChannel({ + name: 'devframes:test', + ...noHandshake, + transport: port2, + functions: [ + defineChannelFunction({ name: 'ping-panel', handler: (value: string) => `pong:${value}` }), + ], + }) + try { + const peer = pageScript.panels[0]! + await expect(peer.call('ping-panel', 'x')).resolves.toBe('pong:x') + } + finally { + panel.close() + pageScript.close() + } + }) + + it('applies serialize/deserialize hooks to arguments and results', async () => { + const { port1, port2 } = new MessageChannel() + const pageScript = createPageScriptChannel({ + name: 'devframes:test', + ...noHandshake, + functions: [ + defineChannelFunction({ name: 'echo', handler: (value: any) => value }), + ], + }) + pageScript.addPanelPort(port1) + const panel = connectPanelChannel({ + name: 'devframes:test', + ...noHandshake, + transport: port2, + // Unwrap a fake reactivity wrapper on the way out, tag on the way in. + serialize: value => (value && typeof value === 'object' && '__wrapped' in (value as any)) + ? (value as any).__wrapped + : value, + deserialize: value => typeof value === 'string' ? `in:${value}` : value, + }) + try { + await expect(panel.call('echo', { __wrapped: 'raw' } as any)).resolves.toBe('in:raw') + } + finally { + panel.close() + pageScript.close() + } + }) + + it('notifies the page script of panel lifecycle', async () => { + const { port1, port2 } = new MessageChannel() + const pageScript = createPageScriptChannel({ + name: 'devframes:test', + ...noHandshake, + }) + const connected: string[] = [] + const disconnected: string[] = [] + pageScript.events.on('panel:connected', peer => connected.push(peer.id)) + pageScript.events.on('panel:disconnected', peer => disconnected.push(peer.id)) + pageScript.addPanelPort(port1) + const panel = connectPanelChannel({ + name: 'devframes:test', + ...noHandshake, + transport: port2, + }) + try { + expect(connected).toHaveLength(1) + panel.close() + await until(() => disconnected.length === 1) + expect(disconnected).toEqual(connected) + expect(pageScript.panels).toHaveLength(0) + } + finally { + pageScript.close() + } + }) +}) + +describe('in-page channel shared state', () => { + it('replays the authority snapshot and streams patches to panels', async () => { + const { pageScript, panel, dispose } = createLinkedPair() + try { + const authority = await pageScript.sharedState.get('doc', { initialValue: { count: 1 } }) + const mirror = await panel.sharedState.get('doc') + expect(mirror.value()).toEqual({ count: 1 }) + + authority.mutate((draft) => { + draft.count = 2 + draft.label = 'updated' + }) + await until(() => mirror.value().count === 2) + expect(mirror.value()).toEqual({ count: 2, label: 'updated' }) + } + finally { + dispose() + } + }) + + it('applies panel mutations at the authority and converges other panels', async () => { + const a = new MessageChannel() + const b = new MessageChannel() + const pageScript = createPageScriptChannel({ + name: 'devframes:test', + ...noHandshake, + }) + pageScript.addPanelPort(a.port1) + pageScript.addPanelPort(b.port1) + const panelA = connectPanelChannel({ name: 'devframes:test', ...noHandshake, transport: a.port2 }) + const panelB = connectPanelChannel({ name: 'devframes:test', ...noHandshake, transport: b.port2 }) + try { + const authority = await pageScript.sharedState.get('doc', { initialValue: { count: 0 } }) + const mirrorA = await panelA.sharedState.get('doc') + const mirrorB = await panelB.sharedState.get('doc') + + mirrorA.mutate((draft) => { + draft.count = 7 + }) + await until(() => authority.value().count === 7) + await until(() => mirrorB.value().count === 7) + } + finally { + panelA.close() + panelB.close() + pageScript.close() + } + }) + + it('seeds a late-joining panel with the current value', async () => { + const { port1, port2 } = new MessageChannel() + const pageScript = createPageScriptChannel({ name: 'devframes:test', ...noHandshake }) + const authority = await pageScript.sharedState.get('doc', { initialValue: { count: 0 } }) + authority.mutate((draft) => { + draft.count = 41 + }) + authority.mutate((draft) => { + draft.count += 1 + }) + + pageScript.addPanelPort(port1) + const panel = connectPanelChannel({ name: 'devframes:test', ...noHandshake, transport: port2 }) + try { + const mirror = await panel.sharedState.get('doc') + expect(mirror.value()).toEqual({ count: 42 }) + } + finally { + panel.close() + pageScript.close() + } + }) + + it('requires the authority to initialize a state', async () => { + const { pageScript, dispose } = createLinkedPair() + try { + await expect(pageScript.sharedState.get('doc')).rejects.toMatchObject({ code: 'state-uninitialized' }) + } + finally { + dispose() + } + }) +}) + +// ── handshake over fake windows ────────────────────────────────────────── + +interface FakeWindow { + location: { origin: string } + sessionStorage: { getItem: (key: string) => string | null, setItem: (key: string, value: string) => void } + parent: FakeWindow + opener: FakeWindow | null + addEventListener: (type: string, fn: (event: MessageEvent) => void) => void + removeEventListener: (type: string, fn: (event: MessageEvent) => void) => void + postMessage: (data: unknown, targetOrigin: string, transfer?: unknown[]) => void + /** Test-harness sender identity stamped on delivered events. */ + __sender: { origin: string, source: FakeWindow | null } + /** Deliver a synthetic message event directly. */ + __dispatch: (event: Partial) => void +} + +function createFakeWindow(origin: string): FakeWindow { + const listeners: ((event: MessageEvent) => void)[] = [] + const storage = new Map() + const win: FakeWindow = { + location: { origin }, + sessionStorage: { + getItem: key => storage.get(key) ?? null, + setItem: (key, value) => void storage.set(key, value), + }, + parent: undefined as unknown as FakeWindow, + opener: null, + addEventListener: (type, fn) => { + if (type === 'message') + listeners.push(fn) + }, + removeEventListener: (type, fn) => { + const index = listeners.indexOf(fn) + if (index >= 0) + listeners.splice(index, 1) + }, + postMessage: (data, _targetOrigin, transfer) => { + win.__dispatch({ data, origin: win.__sender.origin, source: win.__sender.source as any, ports: (transfer ?? []) as any }) + }, + __sender: { origin, source: null }, + __dispatch: (event) => { + queueMicrotask(() => { + for (const fn of [...listeners]) + fn(event as MessageEvent) + }) + }, + } + win.parent = win + return win +} + +/** A same-origin host page + embedded panel window pair. */ +function createWindowPair(origin = 'https://app.test'): { hostWin: FakeWindow, panelWin: FakeWindow } { + const hostWin = createFakeWindow(origin) + const panelWin = createFakeWindow(origin) + panelWin.parent = hostWin + hostWin.__sender = { origin, source: panelWin } + panelWin.__sender = { origin, source: hostWin } + return { hostWin, panelWin } +} + +const fastHello = { helloIntervalMs: 5, heartbeat: false as const } + +describe('in-page channel handshake', () => { + it('connects a panel to the page script and survives page-script restarts', async () => { + const { hostWin, panelWin } = createWindowPair() + const pageScript = createPageScriptChannel({ + name: 'devframes:test', + window: hostWin as unknown as Window, + heartbeat: false, + functions: [defineChannelFunction({ name: 'echo', handler: (value: string) => value })], + }) + const panel = connectPanelChannel({ + name: 'devframes:test', + window: panelWin as unknown as Window, + targets: [hostWin as unknown as Window], + ...fastHello, + }) + try { + await panel.whenConnected(2000) + expect(panel.pageScript?.instanceId).toBe(pageScript.instanceId) + await expect(panel.call('echo', 'hi')).resolves.toBe('hi') + + // The page script goes away (host page reload) … + pageScript.close() + await until(() => panel.status === 'connecting') + + // … and a fresh one boots in the same window: the panel re-handshakes. + const revived = createPageScriptChannel({ + name: 'devframes:test', + window: hostWin as unknown as Window, + heartbeat: false, + functions: [defineChannelFunction({ name: 'echo', handler: (value: string) => `revived:${value}` })], + }) + try { + await panel.whenConnected(2000) + await expect(panel.call('echo', 'hi')).resolves.toBe('revived:hi') + } + finally { + revived.close() + } + } + finally { + panel.close() + pageScript.close() + } + }) + + it('buffers calls and events made while connecting and flushes on connect', async () => { + const { hostWin, panelWin } = createWindowPair() + const noted: string[] = [] + const panel = connectPanelChannel({ + name: 'devframes:test', + window: panelWin as unknown as Window, + targets: [hostWin as unknown as Window], + ...fastHello, + }) + const early = panel.call('echo', 'early') + panel.callEvent('note', 'buffered') + + const pageScript = createPageScriptChannel({ + name: 'devframes:test', + window: hostWin as unknown as Window, + heartbeat: false, + functions: [ + defineChannelFunction({ name: 'echo', handler: (value: string) => value }), + defineChannelFunction({ name: 'note', type: 'event', handler: (value: string) => { + noted.push(value) + } }), + ], + }) + try { + await expect(early).resolves.toBe('early') + await until(() => noted.length === 1) + expect(noted).toEqual(['buffered']) + } + finally { + panel.close() + pageScript.close() + } + }) + + it('ignores hellos from disallowed origins', async () => { + const { hostWin, panelWin } = createWindowPair() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const pageScript = createPageScriptChannel({ + name: 'devframes:test-origin', + window: hostWin as unknown as Window, + heartbeat: false, + }) + try { + hostWin.__dispatch({ + data: { + channel: IN_PAGE_CHANNEL_TAG, + v: IN_PAGE_CHANNEL_VERSION, + kind: 'hello', + name: 'devframes:test-origin', + panelId: 'evil-panel', + }, + origin: 'https://evil.test', + source: panelWin as any, + }) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(pageScript.panels).toHaveLength(0) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('disallowed origin')) + } + finally { + warn.mockRestore() + pageScript.close() + } + }) + + it('ignores handshakes with a different protocol version', async () => { + const { hostWin, panelWin } = createWindowPair() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const pageScript = createPageScriptChannel({ + name: 'devframes:test-version', + window: hostWin as unknown as Window, + heartbeat: false, + }) + try { + hostWin.__dispatch({ + data: { + channel: IN_PAGE_CHANNEL_TAG, + v: IN_PAGE_CHANNEL_VERSION + 1, + kind: 'hello', + name: 'devframes:test-version', + panelId: 'future-panel', + }, + origin: hostWin.location.origin, + source: panelWin as any, + }) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(pageScript.panels).toHaveLength(0) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('protocol version')) + } + finally { + warn.mockRestore() + pageScript.close() + } + }) + + it('honors an instance pin', async () => { + const { hostWin, panelWin } = createWindowPair() + const pageScript = createPageScriptChannel({ + name: 'devframes:test', + window: hostWin as unknown as Window, + heartbeat: false, + }) + const pinnedElsewhere = connectPanelChannel({ + name: 'devframes:test', + window: panelWin as unknown as Window, + targets: [hostWin as unknown as Window], + instanceId: 'some-other-tab', + ...fastHello, + }) + try { + await expect(pinnedElsewhere.whenConnected(100)).rejects.toMatchObject({ code: 'timeout' }) + + const pinnedHere = connectPanelChannel({ + name: 'devframes:test', + window: panelWin as unknown as Window, + targets: [hostWin as unknown as Window], + instanceId: pageScript.instanceId, + ...fastHello, + }) + try { + await pinnedHere.whenConnected(2000) + } + finally { + pinnedHere.close() + } + } + finally { + pinnedElsewhere.close() + pageScript.close() + } + }) + + it('stays connecting and warns when the panel has nowhere to handshake', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const lonely = connectPanelChannel({ + name: `devframes:test-lonely-${Math.random()}`, + window: false, + heartbeat: false, + }) + try { + expect(lonely.status).toBe('connecting') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no handshake targets')) + await expect(lonely.whenConnected(50)).rejects.toMatchObject({ code: 'timeout' }) + } + finally { + warn.mockRestore() + lonely.close() + } + }) + + it('rejects buffered calls with a status-aware timeout', async () => { + const lonely = connectPanelChannel({ + name: `devframes:test-lonely-${Math.random()}`, + window: false, + heartbeat: false, + callTimeoutMs: 50, + }) + try { + const rejection = await lonely.call('echo', 'nobody').catch(error => error) + expect(rejection).toBeInstanceOf(InPageChannelError) + expect(rejection.code).toBe('timeout') + expect(rejection.message).toContain('is the page script loaded?') + } + finally { + lonely.close() + } + }) + + it('rejects pending work when the channel closes', async () => { + const lonely = connectPanelChannel({ + name: `devframes:test-lonely-${Math.random()}`, + window: false, + heartbeat: false, + }) + const pending = lonely.call('echo', 'never') + const waiting = lonely.whenConnected() + lonely.close() + await expect(pending).rejects.toMatchObject({ code: 'closed' }) + await expect(waiting).rejects.toMatchObject({ code: 'closed' }) + expect(lonely.status).toBe('closed') + }) +}) diff --git a/packages/devframe/src/in-page-channel/index.ts b/packages/devframe/src/in-page-channel/index.ts new file mode 100644 index 00000000..f053b940 --- /dev/null +++ b/packages/devframe/src/in-page-channel/index.ts @@ -0,0 +1,44 @@ +import type { RpcArgsSchema, RpcReturnSchema } from '../rpc/types' +import type { InPageFunctionDefinition, InPageFunctionType } from './types' + +/** + * `devframe/in-page-channel` — the browser-only communication path between + * a devframe's page script and its panels: a same-origin, server-free, + * typed channel of fire-and-forget events, request/response calls, and + * page-script-authoritative shared state, over one handshaken + * `MessageChannel` port per panel. + */ +export { InPageChannelError, type InPageChannelErrorCode } from './internal' +export { createPageScriptChannel } from './page-script' +export { connectPanelChannel } from './panel' +export type { + ConnectPanelChannelOptions, + CreatePageScriptChannelOptions, + InPageChannelProtocol, + InPageChannelStatus, + InPageFunctionDefinition, + PageScriptChannel, + PanelChannel, + PanelPeer, +} from './types' + +/** + * Define one in-page channel function — the `defineRpcFunction` authoring + * shape narrowed to the browser (see {@link InPageFunctionDefinition}). + * Pure identity: it only types the definition. Functions live in + * side-specific files and are passed to their endpoint via `functions`; the + * shared protocol file carries only the contract type and the channel-name + * constant. + */ +export function defineChannelFunction< + NAME extends string, + TYPE extends InPageFunctionType, + ARGS extends any[], + RETURN = void, + const AS extends RpcArgsSchema | undefined = undefined, + const RS extends RpcReturnSchema | undefined = undefined, +>( + definition: InPageFunctionDefinition, +): InPageFunctionDefinition { + return definition +} diff --git a/packages/devframe/src/in-page-channel/internal.ts b/packages/devframe/src/in-page-channel/internal.ts new file mode 100644 index 00000000..ef080b15 --- /dev/null +++ b/packages/devframe/src/in-page-channel/internal.ts @@ -0,0 +1,325 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' +import type { BirpcReturn } from 'birpc' +import type { RpcArgsSchema } from '../rpc/types' +import type { InPageChannelControlFrame } from './protocol' +import type { InPageFunctionDefinitionAny } from './types' +import { createBirpc } from 'birpc' +import { isControlFrame } from './protocol' + +/** + * Shared internals of the two endpoints: the coded error surface (browser + * code, so plain coded `Error`s — `nostics` diagnostics are node-side only), + * the local function table with its receive pipeline, and the birpc wiring + * of one `MessagePort`. + */ + +export const DEFAULT_CALL_TIMEOUT_MS = 15_000 +const DEFAULT_HEARTBEAT = { intervalMs: 5_000, timeoutMs: 12_000 } + +/** Resolve the heartbeat option against its defaults. */ +export function resolveHeartbeat( + option: { intervalMs?: number, timeoutMs?: number } | false | undefined, +): { intervalMs: number, timeoutMs: number } | undefined { + return option === false ? undefined : { ...DEFAULT_HEARTBEAT, ...option } +} + +/** Stable failure codes of the in-page channel. */ +export type InPageChannelErrorCode + /** A request/response call did not settle within `callTimeoutMs`. */ + = | 'timeout' + /** The endpoint was closed (or closed while calls were pending). */ + | 'closed' + /** A `jsonSerializable` payload contained a non-JSON value. */ + | 'not-serializable' + /** The port refused to clone a payload (`DataCloneError`). */ + | 'not-cloneable' + /** Standard-Schema validation of incoming arguments failed. */ + | 'invalid-args' + /** A shared-state key was first accessed without its initial value. */ + | 'state-uninitialized' + +/** A coded in-page channel error; every failure mode carries a stable `code`. */ +export class InPageChannelError extends Error { + override name = 'InPageChannelError' + constructor( + public readonly code: InPageChannelErrorCode, + message: string, + options?: { cause?: unknown }, + ) { + super(message, options) + } +} + +const warned = new Set() + +/** + * `console.warn` once per distinct message — handshake noise (foreign + * origins, version mismatches, missing targets) repeats on every retry tick. + */ +export function warnOnce(message: string): void { + if (!warned.has(message)) { + warned.add(message) + console.warn(`[devframe] ${message}`) + } +} + +function jsonViolation(value: unknown, path: string, seen: Set): string | undefined { + if (value === null || typeof value === 'boolean' || typeof value === 'string') + return undefined + if (typeof value === 'number') + return Number.isFinite(value) ? undefined : `${path} is ${value}` + if (typeof value !== 'object') + return `${path} is ${value === undefined ? '`undefined`' : `a ${typeof value}`}` + if (seen.has(value)) + return `${path} is circular` + seen.add(value) + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const violation = jsonViolation(value[i], `${path}[${i}]`, seen) + if (violation) + return violation + } + return undefined + } + const proto = Object.getPrototypeOf(value) + if (proto !== Object.prototype && proto !== null) + return `${path} is an instance of ${value.constructor?.name ?? 'an exotic class'}` + for (const [key, entry] of Object.entries(value)) { + const violation = jsonViolation(entry, `${path}.${key}`, seen) + if (violation) + return violation + } + return undefined +} + +/** + * Enforce a `jsonSerializable: true` contract: throws code + * `not-serializable` naming the offending path when the value contains + * anything strict JSON can't represent — surfacing the bug at the offending + * call instead of a silent coercion later. + */ +function assertJsonSerializable(value: unknown, what: string, functionName: string): void { + const violation = jsonViolation(value, what, new Set()) + if (violation) { + throw new InPageChannelError( + 'not-serializable', + `in-page function "${functionName}" is declared jsonSerializable, but ${violation}`, + ) + } +} + +function formatIssues(issues: readonly StandardSchemaV1.Issue[]): string { + return issues + .map((issue) => { + const path = issue.path?.map(s => (typeof s === 'object' ? s.key : s)).join('.') + return path ? `${path}: ${issue.message}` : issue.message + }) + .join('; ') +} + +/** + * Validate positional arguments against their Standard Schemas (mirrors the + * RPC layer's `validateRpcArgs`): only indices with a schema are checked, + * values pass through unchanged, the first failure rejects with code + * `invalid-args`. + */ +async function validateArgs(name: string, schemas: RpcArgsSchema, args: readonly unknown[]): Promise { + for (let index = 0; index < schemas.length; index++) { + const schema = schemas[index] + if (!schema) + continue + const result = await schema['~standard'].validate(args[index]) + if (result.issues) { + throw new InPageChannelError( + 'invalid-args', + `in-page function "${name}" rejected argument ${index}: ${formatIssues(result.issues)}`, + ) + } + } +} + +/** Serialization hooks applied per value at each endpoint. */ +export interface InPageChannelSerialization { + serialize?: (value: unknown) => unknown + deserialize?: (value: unknown) => unknown +} + +export function serializeArgs(codec: InPageChannelSerialization, args: unknown[]): unknown[] { + return codec.serialize ? args.map(codec.serialize) : args +} +export function deserializeResult(codec: InPageChannelSerialization, result: unknown): unknown { + return codec.deserialize && result !== undefined ? codec.deserialize(result) : result +} + +/** + * An endpoint's local function table, resolved by name when the remote side + * calls in. Each handler is wrapped with the receive pipeline: deserialize + * hook, `jsonSerializable` enforcement, Standard-Schema argument validation, + * then serialize hook + `jsonSerializable` enforcement on the result. + */ +export function createLocalFunctionRegistry(codec: InPageChannelSerialization): { + register: (definition: InPageFunctionDefinitionAny) => void + resolve: (name: string) => ((...args: unknown[]) => unknown) | undefined +} { + const wrapped = new Map unknown>() + return { + register(definition) { + wrapped.set(definition.name, async (...rawArgs: unknown[]) => { + const args = codec.deserialize ? rawArgs.map(codec.deserialize) : rawArgs + if (definition.jsonSerializable) + assertJsonSerializable(args, 'its arguments', definition.name) + if (definition.args?.length) + await validateArgs(definition.name, definition.args, args) + const result = await definition.handler(...args) + if (definition.jsonSerializable) + assertJsonSerializable(result, 'its return value', definition.name) + return codec.serialize && result !== undefined ? codec.serialize(result) : result + }) + }, + resolve: name => wrapped.get(name), + } +} + +type RemoteFunctions = Record any> + +export interface AttachChannelPortOptions { + /** Resolve a locally-registered handler by name (internal + user functions). */ + resolveLocal: (name: string) => ((...args: unknown[]) => unknown) | undefined + /** A liveness control frame arrived (`bye` routes to `onPeerClosed`). */ + onControl: (kind: 'ping' | 'pong') => void + /** The peer went away: graceful `bye`, or the port's `close` event fired. */ + onPeerClosed: () => void +} + +/** + * One live `MessagePort` wired into birpc, with the channel's control frames + * (ping/pong/bye) filtered off the stream before birpc sees it. Both + * endpoints attach every port through this seam — a future transport only + * has to produce something port-shaped. + */ +export interface AttachedChannelPort { + rpc: BirpcReturn, false> + /** Epoch ms of the last frame received — liveness input for heartbeats. */ + lastActivity: number + postControl: (kind: InPageChannelControlFrame['__dfIpc']) => void + /** Detach: optionally send `bye`, reject pending calls, close the port. */ + dispose: (options?: { bye?: boolean, reason?: string }) => void +} + +function wrapPostError(error: unknown): unknown { + if (error instanceof DOMException && error.name === 'DataCloneError') { + return new InPageChannelError( + 'not-cloneable', + `a payload could not be structured-cloned across the in-page channel: ${error.message}. ` + + `Strip non-cloneable values (functions, DOM nodes, framework reactivity proxies) before sending — ` + + `declare the function \`jsonSerializable: true\` for a precise error, or provide a \`serialize\` hook.`, + { cause: error }, + ) + } + return error +} + +export function attachChannelPort(port: MessagePort, options: AttachChannelPortOptions): AttachedChannelPort { + let birpcHandler: ((data: unknown) => void) | undefined + let disposed = false + + const attached: AttachedChannelPort = { + rpc: undefined as unknown as AttachedChannelPort['rpc'], + lastActivity: Date.now(), + postControl(kind) { + try { + port.postMessage({ __dfIpc: kind } satisfies InPageChannelControlFrame) + } + catch { + // A dead or detached port — the close/heartbeat paths handle it. + } + }, + dispose(disposeOptions) { + if (disposed) + return + disposed = true + if (disposeOptions?.bye) + attached.postControl('bye') + const reason = disposeOptions?.reason ?? 'the in-page channel port was closed' + const error = new InPageChannelError('closed', `in-page channel call dropped: ${reason}`) + attached.rpc.$rejectPendingCalls(({ reject }) => reject(error)) + attached.rpc.$close() + port.removeEventListener('message', onMessage) + port.removeEventListener('close', onClose) + try { + port.close() + } + catch { + // Already closed. + } + }, + } + + function onMessage(event: MessageEvent): void { + attached.lastActivity = Date.now() + const data: unknown = event.data + if (isControlFrame(data)) { + if (data.__dfIpc === 'bye') + options.onPeerClosed() + else + options.onControl(data.__dfIpc) + return + } + birpcHandler?.(data) + } + function onClose(): void { + options.onPeerClosed() + } + + attached.rpc = createBirpc, false>({}, { + post: (data) => { + try { + port.postMessage(data) + } + catch (error) { + throw wrapPostError(error) + } + }, + on: (fn) => { + birpcHandler = fn as (data: unknown) => void + }, + off: () => { + birpcHandler = undefined + }, + // Deadlines live at the endpoint layer (`withCallDeadline`) with + // status-aware errors; birpc's own timer stays off. + timeout: -1, + proxify: false, + resolver: (name, resolved) => (resolved as ((...args: unknown[]) => unknown) | undefined) ?? options.resolveLocal(name), + }) + + port.addEventListener('message', onMessage) + // Instant peer-death detection where the port supports the `close` event + // (modern browsers, Node); the heartbeat is the fallback elsewhere. + port.addEventListener('close', onClose) + port.start?.() + + return attached +} + +/** + * Race a call against its deadline, rejecting with a status-aware error + * (code `timeout`). `ms <= 0` disables the deadline. + */ +export function withCallDeadline(promise: Promise, ms: number, describeTimeout: () => string): Promise { + if (ms <= 0) + return promise + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new InPageChannelError('timeout', describeTimeout())), ms) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error) => { + clearTimeout(timer) + reject(error) + }, + ) + }) +} diff --git a/packages/devframe/src/in-page-channel/page-script.ts b/packages/devframe/src/in-page-channel/page-script.ts new file mode 100644 index 00000000..871ea536 --- /dev/null +++ b/packages/devframe/src/in-page-channel/page-script.ts @@ -0,0 +1,206 @@ +import type { AttachedChannelPort } from './internal' +import type { + CreatePageScriptChannelOptions, + InPageChannelProtocol, + PageScriptChannel, + PageScriptChannelEvents, + PanelPeer, +} from './types' +import { createEventEmitter } from 'devframe/utils/events' +import { nanoid } from 'devframe/utils/nanoid' +import { + attachChannelPort, + createLocalFunctionRegistry, + DEFAULT_CALL_TIMEOUT_MS, + deserializeResult, + resolveHeartbeat, + serializeArgs, + warnOnce, + withCallDeadline, +} from './internal' +import { + IN_PAGE_CHANNEL_TAG, + IN_PAGE_CHANNEL_VERSION, + isHandshakeMessage, + resolveAllowedOrigins, + resolveInstanceId, +} from './protocol' +import { createPageScriptStateHost } from './state' + +interface PeerInternal

{ + id: string + attached: AttachedChannelPort + subscribedStates: Set + internalHandlers: Record unknown> + peer: PanelPeer

+} + +/** + * Create the page-script endpoint of an in-page channel. + * + * It listens for panel hellos on the host page's window and answers each + * with a dedicated `MessageChannel` port (same-origin enforced both ways), + * keeping one live peer per panel — dock iframe, popup, and Document-PiP + * panels all handshake the same way, and a panel reload is simply a new + * handshake. No server is involved at any point. + */ +export function createPageScriptChannel

( + options: CreatePageScriptChannelOptions, +): PageScriptChannel

{ + const { name } = options + const callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS + const win = options.window === false + ? undefined + : options.window ?? (typeof window === 'undefined' ? undefined : window) + const allowedOrigins = resolveAllowedOrigins(options.allowedOrigins, win) + const instanceId = resolveInstanceId(win) + const heartbeat = resolveHeartbeat(options.heartbeat) + const codec = { serialize: options.serialize, deserialize: options.deserialize } + + const events = createEventEmitter>() + const peers = new Map>() + let closed = false + let heartbeatTimer: ReturnType | undefined + + const registry = createLocalFunctionRegistry(codec) + for (const definition of options.functions ?? []) + registry.register(definition) + + const stateHost = createPageScriptStateHost

(function* () { + for (const peer of peers.values()) { + yield { + subscribedStates: peer.subscribedStates, + callEventRaw: (method: string, args: unknown[]) => { + void peer.attached.rpc.$callRaw({ method, args, event: true, optional: true }).catch(() => {}) + }, + } + } + }) + + function removePeer(id: string, disposeOptions?: { bye?: boolean, reason?: string }): void { + const peer = peers.get(id) + if (!peer) + return + peers.delete(id) + peer.attached.dispose(disposeOptions) + if (peers.size === 0 && heartbeatTimer) { + clearInterval(heartbeatTimer) + heartbeatTimer = undefined + } + events.emit('panel:disconnected', peer.peer) + } + + function addPeer(port: MessagePort, id: string): PanelPeer

{ + // A repeated hello from the same panel (a retry that raced the first + // grant) replaces the previous port. + removePeer(id, { reason: 'the panel re-connected' }) + + const internal = { id, subscribedStates: new Set() } as PeerInternal

+ internal.internalHandlers = stateHost.createPeerHandlers({ + subscribedStates: internal.subscribedStates, + callEventRaw: (method, args) => { + void internal.attached.rpc.$callRaw({ method, args, event: true, optional: true }).catch(() => {}) + }, + }) + internal.attached = attachChannelPort(port, { + resolveLocal: fnName => internal.internalHandlers[fnName] ?? registry.resolve(fnName), + onControl: (kind) => { + if (kind === 'ping') + internal.attached.postControl('pong') + }, + onPeerClosed: () => removePeer(id, { reason: 'the panel disconnected' }), + }) + internal.peer = { + id, + call: (fnName, ...args) => withCallDeadline( + internal.attached.rpc.$call(fnName, ...serializeArgs(codec, args)).then(result => deserializeResult(codec, result)) as Promise, + callTimeoutMs, + () => `in-page channel "${name}": call "${fnName}" to panel "${id}" timed out after ${callTimeoutMs}ms`, + ), + close: () => removePeer(id, { bye: true, reason: 'the page script closed this panel' }), + } + peers.set(id, internal) + if (heartbeat && !heartbeatTimer) { + heartbeatTimer = setInterval(() => { + const now = Date.now() + for (const peer of [...peers.values()]) { + if (now - peer.attached.lastActivity > heartbeat.timeoutMs) + removePeer(peer.id, { reason: 'the panel went silent (heartbeat timeout)' }) + } + }, heartbeat.intervalMs) + } + events.emit('panel:connected', internal.peer) + return internal.peer + } + + const onWindowMessage = (event: MessageEvent): void => { + if (closed) + return + const data: unknown = event.data + if (!isHandshakeMessage(data) || data.kind !== 'hello' || data.name !== name) + return + if (data.v !== IN_PAGE_CHANNEL_VERSION) { + warnOnce(`in-page channel "${name}": ignoring a hello with protocol version ${data.v} (this side speaks ${IN_PAGE_CHANNEL_VERSION}) — align the devframe versions of the page script and the panel`) + return + } + if (!allowedOrigins.includes('*') && !allowedOrigins.includes(event.origin)) { + warnOnce(`in-page channel "${name}": ignoring a hello from disallowed origin "${event.origin}"`) + return + } + if (data.instanceId && data.instanceId !== instanceId) + return + const source = event.source as Window | null + if (!source || typeof source.postMessage !== 'function') + return + + const messageChannel = new MessageChannel() + addPeer(messageChannel.port1, data.panelId) + const grant = { + channel: IN_PAGE_CHANNEL_TAG, + v: IN_PAGE_CHANNEL_VERSION, + kind: 'grant' as const, + name, + panelId: data.panelId, + instanceId, + } + try { + source.postMessage(grant, allowedOrigins.includes('*') ? '*' : event.origin, [messageChannel.port2]) + } + catch (error) { + console.warn(`[devframe] in-page channel "${name}": failed to grant a port`, error) + removePeer(data.panelId, { reason: 'the grant could not be delivered' }) + } + } + + win?.addEventListener('message', onWindowMessage) + + return { + name, + instanceId, + get panels() { + return [...peers.values()].map(peer => peer.peer) + }, + events: { on: events.on, once: events.once }, + callEvent: (fnName, ...args) => { + const wireArgs = serializeArgs(codec, args) + for (const peer of peers.values()) { + void peer.attached.rpc.$callRaw({ + method: fnName, + args: wireArgs, + event: true, + optional: true, + }).catch(() => {}) + } + }, + sharedState: stateHost, + addPanelPort: port => addPeer(port, `transport:${nanoid(8)}`), + close: () => { + if (closed) + return + closed = true + win?.removeEventListener('message', onWindowMessage) + for (const id of [...peers.keys()]) + removePeer(id, { bye: true, reason: 'the page script closed the channel' }) + }, + } +} diff --git a/packages/devframe/src/in-page-channel/panel.ts b/packages/devframe/src/in-page-channel/panel.ts new file mode 100644 index 00000000..4bf17af2 --- /dev/null +++ b/packages/devframe/src/in-page-channel/panel.ts @@ -0,0 +1,306 @@ +import type { AttachedChannelPort } from './internal' +import type { + ConnectPanelChannelOptions, + InPageChannelProtocol, + InPageChannelStatus, + PanelChannel, + PanelChannelEvents, +} from './types' +import { createEventEmitter } from 'devframe/utils/events' +import { nanoid } from 'devframe/utils/nanoid' +import { + attachChannelPort, + createLocalFunctionRegistry, + DEFAULT_CALL_TIMEOUT_MS, + deserializeResult, + InPageChannelError, + resolveHeartbeat, + serializeArgs, + warnOnce, + withCallDeadline, +} from './internal' +import { + defaultHandshakeTargets, + IN_PAGE_CHANNEL_TAG, + IN_PAGE_CHANNEL_VERSION, + isHandshakeMessage, + resolveAllowedOrigins, +} from './protocol' +import { createPanelStateHost } from './state' + +const DEFAULT_HELLO_INTERVAL_MS = 300 +const HELLO_INTERVAL_CAP_MS = 3_000 +const DEFAULT_EVENT_BUFFER_LIMIT = 64 + +/** + * Connect the panel endpoint of an in-page channel. + * + * The panel initiates: it posts a versioned hello to every window a + * same-tab page script can live in (its ancestor chain and its `opener`), + * retrying with backoff until one answers with a dedicated port — so boot + * order never matters, and a reload of either side is just a re-handshake + * (`WindowProxy` references survive navigations). While `connecting`, + * outgoing calls and events are buffered; when no page script exists at all + * (e.g. the panel opened standalone), the endpoint stays `connecting` and + * the UI can key a fallback state off `status` / `whenConnected()`. + */ +export function connectPanelChannel

( + options: ConnectPanelChannelOptions, +): PanelChannel

{ + const { name } = options + const callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS + const eventBufferLimit = options.eventBufferLimit ?? DEFAULT_EVENT_BUFFER_LIMIT + const win = options.window === false + ? undefined + : options.window ?? (typeof window === 'undefined' ? undefined : window) + const allowedOrigins = resolveAllowedOrigins(options.allowedOrigins, win) + const heartbeat = resolveHeartbeat(options.heartbeat) + const codec = { serialize: options.serialize, deserialize: options.deserialize } + const panelId = nanoid() + const targets = options.targets ?? (win ? defaultHandshakeTargets(win) : []) + const canHandshake = !!win && targets.length > 0 + + const events = createEventEmitter() + const registry = createLocalFunctionRegistry(codec) + for (const definition of options.functions ?? []) + registry.register(definition) + + let status: InPageChannelStatus = 'connecting' + let attached: AttachedChannelPort | undefined + let pageScriptInfo: { instanceId: string } | undefined + let helloTimer: ReturnType | undefined + let heartbeatTimer: ReturnType | undefined + let droppedEventsWarned = false + const pendingCalls: { run: () => void, reject: (error: unknown) => void }[] = [] + const eventBuffer: { method: string, args: unknown[] }[] = [] + const connectedWaiters: { resolve: () => void, reject: (error: unknown) => void }[] = [] + + function setStatus(next: InPageChannelStatus): void { + if (status !== next) { + status = next + events.emit('status:updated', next) + } + } + + const stateHost = createPanelStateHost

({ + isConnected: () => status === 'connected', + callEvent: (method, args) => sendEvent(method, args), + call: (method, args) => enqueueCall(method, args), + }) + + function sendEventNow(method: string, args: unknown[]): void { + void attached?.rpc.$callRaw({ method, args, event: true, optional: true }).catch(() => {}) + } + + function sendEvent(method: string, args: unknown[]): void { + if (status === 'closed') + return + if (status === 'connected' && attached) { + sendEventNow(method, args) + return + } + if (eventBuffer.length >= eventBufferLimit) { + eventBuffer.shift() + if (!droppedEventsWarned) { + droppedEventsWarned = true + console.warn(`[devframe] in-page channel "${name}": event buffer overflowed while connecting — oldest events are being dropped (limit ${eventBufferLimit})`) + } + } + eventBuffer.push({ method, args }) + } + + function enqueueCall(method: string, args: unknown[]): Promise { + if (status === 'closed') { + return Promise.reject(new InPageChannelError( + 'closed', + `in-page channel "${name}": call "${method}" rejected — the channel is closed`, + )) + } + const attempt = new Promise((resolve, reject) => { + const run = (): void => { + attached!.rpc.$call(method, ...args) + .then(result => resolve(deserializeResult(codec, result)), reject) + } + if (status === 'connected' && attached) + run() + else + pendingCalls.push({ run, reject }) + }) + return withCallDeadline( + attempt, + callTimeoutMs, + () => `in-page channel "${name}": call "${method}" timed out after ${callTimeoutMs}ms (status: ${status}${status === 'connecting' ? ' — is the page script loaded?' : ''})`, + ) + } + + function adoptPort(port: MessagePort, info?: { instanceId: string }): void { + // Most recent grant wins — a fresh page script (after a host reload, or + // another instance the user pinned to) replaces the previous port. + attached?.dispose({ bye: true, reason: 'the panel adopted a newer port' }) + attached = attachChannelPort(port, { + resolveLocal: fnName => stateHost.handlers[fnName] ?? registry.resolve(fnName), + onControl: (kind) => { + if (kind === 'ping') + attached?.postControl('pong') + }, + onPeerClosed: () => handleDisconnect('the page script went away'), + }) + pageScriptInfo = info + stopTimers() + setStatus('connected') + if (heartbeat) { + heartbeatTimer = setInterval(() => { + if (!attached) + return + if (Date.now() - attached.lastActivity > heartbeat.timeoutMs) + handleDisconnect('the page script went silent (heartbeat timeout)') + else + attached.postControl('ping') + }, heartbeat.intervalMs) + } + for (const call of pendingCalls.splice(0)) + call.run() + for (const { method, args } of eventBuffer.splice(0)) + sendEventNow(method, args) + stateHost.resubscribe() + for (const waiter of connectedWaiters.splice(0)) + waiter.resolve() + } + + function stopTimers(): void { + if (helloTimer) { + clearTimeout(helloTimer) + helloTimer = undefined + } + if (heartbeatTimer) { + clearInterval(heartbeatTimer) + heartbeatTimer = undefined + } + } + + function handleDisconnect(reason: string): void { + if (status === 'closed') + return + attached?.dispose({ reason }) + attached = undefined + pageScriptInfo = undefined + stopTimers() + setStatus('connecting') + if (canHandshake) + startHelloLoop() + else + warnOnce(`in-page channel "${name}": transport lost (${reason}) and the panel has no handshake targets — staying disconnected`) + } + + function startHelloLoop(): void { + if (helloTimer || !canHandshake || status !== 'connecting') + return + let delay = options.helloIntervalMs ?? DEFAULT_HELLO_INTERVAL_MS + const tick = (): void => { + const hello = { + channel: IN_PAGE_CHANNEL_TAG, + v: IN_PAGE_CHANNEL_VERSION, + kind: 'hello' as const, + name, + panelId, + instanceId: options.instanceId, + } + for (const target of targets) { + for (const origin of allowedOrigins) { + try { + target.postMessage(hello, origin) + } + catch { + // Unreachable target/origin pair — the loop keeps retrying. + } + } + } + delay = Math.min(delay * 1.5, HELLO_INTERVAL_CAP_MS) + helloTimer = setTimeout(tick, delay) + } + tick() + } + + const onWindowMessage = (event: MessageEvent): void => { + if (status === 'closed') + return + const data: unknown = event.data + if (!isHandshakeMessage(data) || data.kind !== 'grant' || data.name !== name || data.panelId !== panelId) + return + if (data.v !== IN_PAGE_CHANNEL_VERSION) { + warnOnce(`in-page channel "${name}": ignoring a grant with protocol version ${data.v} (this side speaks ${IN_PAGE_CHANNEL_VERSION}) — align the devframe versions of the page script and the panel`) + return + } + if (!allowedOrigins.includes('*') && !allowedOrigins.includes(event.origin)) { + warnOnce(`in-page channel "${name}": ignoring a grant from disallowed origin "${event.origin}"`) + return + } + if (options.instanceId && data.instanceId !== options.instanceId) + return + const port = event.ports?.[0] + if (port) + adoptPort(port, { instanceId: data.instanceId! }) + } + + win?.addEventListener('message', onWindowMessage) + + if (options.transport) + adoptPort(options.transport) + else if (canHandshake) + startHelloLoop() + else + warnOnce(`in-page channel "${name}": the panel has no handshake targets (not embedded, no opener) and no transport — calls will buffer until a transport appears or the channel is closed`) + + return { + name, + get status() { + return status + }, + get pageScript() { + return pageScriptInfo + }, + events: { on: events.on, once: events.once }, + whenConnected: (timeoutMs?: number) => { + if (status === 'connected') + return Promise.resolve() + if (status === 'closed') + return Promise.reject(new InPageChannelError('closed', `in-page channel "${name}" is closed`)) + return new Promise((resolve, reject) => { + const waiter = { resolve, reject } + connectedWaiters.push(waiter) + if (timeoutMs !== undefined && timeoutMs > 0) { + setTimeout(() => { + const index = connectedWaiters.indexOf(waiter) + if (index >= 0) { + connectedWaiters.splice(index, 1) + reject(new InPageChannelError( + 'timeout', + `in-page channel "${name}": no page script answered within ${timeoutMs}ms — ` + + `it may not be loaded in this context (render a fallback state)`, + )) + } + }, timeoutMs) + } + }) + }, + call: (fnName, ...args) => enqueueCall(fnName, serializeArgs(codec, args)) as Promise, + callEvent: (fnName, ...args) => sendEvent(fnName, serializeArgs(codec, args)), + sharedState: stateHost, + close: () => { + if (status === 'closed') + return + setStatus('closed') + stopTimers() + win?.removeEventListener('message', onWindowMessage) + attached?.dispose({ bye: true, reason: 'the panel closed the channel' }) + attached = undefined + pageScriptInfo = undefined + const closedError = new InPageChannelError('closed', `in-page channel "${name}" was closed`) + for (const call of pendingCalls.splice(0)) + call.reject(closedError) + eventBuffer.length = 0 + for (const waiter of connectedWaiters.splice(0)) + waiter.reject(closedError) + }, + } +} diff --git a/packages/devframe/src/in-page-channel/protocol.ts b/packages/devframe/src/in-page-channel/protocol.ts new file mode 100644 index 00000000..460ec6b6 --- /dev/null +++ b/packages/devframe/src/in-page-channel/protocol.ts @@ -0,0 +1,123 @@ +import { nanoid } from 'devframe/utils/nanoid' +import { DEVFRAME_EVENTS } from '../events' + +/** + * Wire protocol of the in-page channel handshake and its port-level control + * frames. The envelope is transport-neutral by design — it identifies the + * protocol (`channel` tag + `v`), the user channel (`name`), and the peers + * (`panelId`, `instanceId`) — so a future cross-tab transport (e.g. a + * `BroadcastChannel`) can reuse it unchanged. + */ + +/** `postMessage` tag every handshake message carries. */ +export const IN_PAGE_CHANNEL_TAG = DEVFRAME_EVENTS.postMessage.inPageChannel + +/** Envelope version — bump on breaking wire changes. */ +export const IN_PAGE_CHANNEL_VERSION = 1 + +/** + * The handshake envelope. A panel posts a `hello` ("grant me a port for + * channel `name`"); the page script answers with a `grant`, the dedicated + * `MessagePort` transferred alongside. + */ +export interface InPageChannelHandshakeMessage { + channel: typeof IN_PAGE_CHANNEL_TAG + v: number + kind: 'hello' | 'grant' + /** User channel name (e.g. `devframes:plugin:a11y`). */ + name: string + /** The asking panel's id (grants echo it, so a panel matches its own hello). */ + panelId: string + /** Hello: optional instance pin. Grant: the answering page script's instance id. */ + instanceId?: string +} + +/** + * Port-level control frames, filtered out before birpc sees the stream: + * liveness pings/pongs and the graceful `bye` a closing endpoint sends so + * its peer reacts immediately instead of waiting for the heartbeat window. + */ +export interface InPageChannelControlFrame { + __dfIpc: 'ping' | 'pong' | 'bye' +} + +export function isControlFrame(data: unknown): data is InPageChannelControlFrame { + return !!data && typeof data === 'object' && '__dfIpc' in data +} + +export function isHandshakeMessage(data: unknown): data is InPageChannelHandshakeMessage { + if (!data || typeof data !== 'object') + return false + const message = data as Partial + return message.channel === IN_PAGE_CHANNEL_TAG + && typeof message.name === 'string' + && typeof message.panelId === 'string' + && (message.kind === 'hello' || message.kind === 'grant') +} + +const INSTANCE_STORAGE_KEY = 'devframe:in-page-channel:instance' +let memoryInstanceId: string | undefined + +/** + * The page context's instance id: one nanoid per browser tab, persisted in + * `sessionStorage` so it survives page-script reloads. It scopes handshakes + * when the same app is open in several tabs — a panel pinned to an instance + * id ignores grants from every other tab's page script. + */ +export function resolveInstanceId(win: Window | undefined): string { + try { + const storage = win?.sessionStorage + if (storage) { + let id = storage.getItem(INSTANCE_STORAGE_KEY) + if (!id) { + id = nanoid() + storage.setItem(INSTANCE_STORAGE_KEY, id) + } + return id + } + } + catch { + // Storage unavailable (sandboxed iframe, disabled cookies) — fall through. + } + memoryInstanceId ??= nanoid() + return memoryInstanceId +} + +/** Resolve the origins accepted during the handshake (default: same-origin). */ +export function resolveAllowedOrigins(allowedOrigins: string[] | undefined, win: Window | undefined): string[] { + if (allowedOrigins?.length) + return allowedOrigins + const origin = win?.location?.origin + return origin && origin !== 'null' ? [origin] : ['*'] +} + +/** + * Default handshake targets of a panel: its ancestor chain plus its + * `opener` — every same-tab window a page script can live in. `WindowProxy` + * references stay valid across navigations, so hellos posted to these reach + * a page script even after the host page reloads. + */ +export function defaultHandshakeTargets(win: Window): Window[] { + const targets: Window[] = [] + try { + let current: Window = win + // `.parent`/`.opener` are accessible across origins; same-origin is + // enforced by the handshake origin check, not by this walk. + while (current.parent && current.parent !== current) { + targets.push(current.parent) + current = current.parent + } + } + catch { + // Walking stopped by the browser — keep what we have. + } + try { + const opener = win.opener as Window | null + if (opener && opener !== win) + targets.push(opener) + } + catch { + // Inaccessible opener — ignore. + } + return targets +} diff --git a/packages/devframe/src/in-page-channel/state.ts b/packages/devframe/src/in-page-channel/state.ts new file mode 100644 index 00000000..52f82059 --- /dev/null +++ b/packages/devframe/src/in-page-channel/state.ts @@ -0,0 +1,209 @@ +import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state' +import type { InPageChannelProtocol, InPageSharedStateHost } from './types' +import { nanoid } from 'devframe/utils/nanoid' +import { createSharedState } from 'devframe/utils/shared-state' +import { DEVFRAME_EVENTS } from '../events' +import { InPageChannelError } from './internal' + +/** + * The channel shared-state layer — `createSharedState` pumped over the + * in-page channel, mirroring the RPC shared-state wire design with the page + * script playing the server's role: it owns the canonical value, replays a + * snapshot to every (re)subscribing panel, and fans syncId-deduplicated + * updates out to the panels subscribed to each key. + * + * Request endpoints (panel → page script) are defined here at their + * handlers, like the RPC layer's `devframe:rpc:server-state:*`; the + * page-script → panel notifications live in `DEVFRAME_EVENTS.inPageChannel`. + */ +const IN_PAGE_STATE_RPC = { + /** Subscribe to a key; returns the authority's current snapshot. */ + subscribe: 'devframe:in-page:page-state:subscribe', + /** Replace a key's value (panel mutation forwarded up). */ + set: 'devframe:in-page:page-state:set', + /** Patch a key's value (panel mutation forwarded up). */ + patch: 'devframe:in-page:page-state:patch', +} as const + +type InternalHandlers = Record unknown> + +export interface PageScriptStatePeer { + /** Keys this panel subscribed to. */ + readonly subscribedStates: Set + /** Fire-and-forget raw send to this panel (missing handlers are ignored). */ + callEventRaw: (method: string, args: unknown[]) => void +} + +export interface PageScriptStateHost

extends InPageSharedStateHost

{ + /** State handlers bound to one panel peer (its subscribe/set/patch). */ + createPeerHandlers: (peer: PageScriptStatePeer) => InternalHandlers +} + +/** The page-script (authority) half of the channel shared-state layer. */ +export function createPageScriptStateHost

( + peers: () => Iterable, +): PageScriptStateHost

{ + const states = new Map>() + + return { + get: async (key: string, options?: { initialValue?: T }) => { + const existing = states.get(key) + if (existing) + return existing as SharedState + if (options?.initialValue === undefined) { + throw new InPageChannelError( + 'state-uninitialized', + `in-page shared state "${key}" was accessed before initialization — the page script is the authority, so its first \`sharedState.get("${key}")\` must provide \`initialValue\``, + ) + } + const state = createSharedState({ + initialValue: options.initialValue, + enablePatches: true, + }) + states.set(key, state) + state.on('updated', (fullState, patches, syncId) => { + for (const peer of peers()) { + if (!peer.subscribedStates.has(key)) + continue + if (patches) + peer.callEventRaw(DEVFRAME_EVENTS.inPageChannel.panelStatePatch, [key, patches, syncId]) + else + peer.callEventRaw(DEVFRAME_EVENTS.inPageChannel.panelStateUpdated, [key, fullState, syncId]) + } + }) + return state + }, + createPeerHandlers(peer) { + return { + [IN_PAGE_STATE_RPC.subscribe]: (key: string) => { + peer.subscribedStates.add(key) + return states.get(key)?.value() + }, + [IN_PAGE_STATE_RPC.set]: (key: string, fullState: object, syncId: string) => { + const state = states.get(key) + if (state && !state.syncIds.has(syncId)) + state.mutate(() => fullState as any, syncId) + }, + [IN_PAGE_STATE_RPC.patch]: (key: string, patches: SharedStatePatch[], syncId: string) => { + states.get(key)?.patch(patches, syncId) + }, + } + }, + } +} + +export interface PanelStateHostOptions { + /** Buffered fire-and-forget to the page script. */ + callEvent: (method: string, args: unknown[]) => void + /** Request/response to the page script (buffered while connecting). */ + call: (method: string, args: unknown[]) => Promise + isConnected: () => boolean +} + +export interface PanelStateHost

extends InPageSharedStateHost

{ + /** Handlers for the authority's update notifications. */ + readonly handlers: InternalHandlers + /** (Re)subscribe every known key — call on each `connected` transition. */ + resubscribe: () => void +} + +/** The panel (mirror) half of the channel shared-state layer. */ +export function createPanelStateHost

( + options: PanelStateHostOptions, +): PanelStateHost

{ + const states = new Map>() + const seeded = new Set() + const seedWaiters = new Map void)[]>() + /** + * SyncIds of snapshot adoptions. Adopting the authority's replay must not + * echo straight back up as a `set` — the authority already has the value. + */ + const adoptedSyncIds = new Set() + + function markSeeded(key: string): void { + seeded.add(key) + const waiters = seedWaiters.get(key) + if (waiters) { + seedWaiters.delete(key) + for (const resolve of waiters) + resolve() + } + } + + function adopt(key: string, snapshot: unknown): void { + const state = states.get(key) + if (state && snapshot !== undefined) { + const syncId = nanoid() + adoptedSyncIds.add(syncId) + state.mutate(() => snapshot as any, syncId) + } + markSeeded(key) + } + + function subscribeNow(key: string): void { + options.call(IN_PAGE_STATE_RPC.subscribe, [key]) + .then(snapshot => adopt(key, snapshot)) + .catch((error) => { + console.warn(`[devframe] in-page shared state "${key}": subscribe failed`, error) + }) + } + + return { + handlers: { + [DEVFRAME_EVENTS.inPageChannel.panelStateUpdated]: (key: string, fullState: object, syncId: string) => { + const state = states.get(key) + if (state && !state.syncIds.has(syncId)) + state.mutate(() => fullState as any, syncId) + markSeeded(key) + }, + [DEVFRAME_EVENTS.inPageChannel.panelStatePatch]: (key: string, patches: SharedStatePatch[], syncId: string) => { + states.get(key)?.patch(patches, syncId) + markSeeded(key) + }, + }, + resubscribe() { + for (const key of states.keys()) + subscribeNow(key) + }, + get: (key: string, getOptions?: { initialValue?: T }) => { + const existing = states.get(key) + if (existing) { + return Promise.resolve(existing as SharedState) + } + const state = createSharedState({ + // Without an initial value the state stays empty until the + // authority's first replay resolves the returned promise. + initialValue: getOptions?.initialValue as T, + enablePatches: true, + }) + states.set(key, state) + state.on('updated', (fullState, patches, syncId) => { + if (adoptedSyncIds.delete(syncId)) + return + // Mutations while disconnected are local-only; on reconnect the + // panel re-adopts the authority's snapshot. + if (!options.isConnected()) + return + if (patches) + options.callEvent(IN_PAGE_STATE_RPC.patch, [key, patches, syncId]) + else + options.callEvent(IN_PAGE_STATE_RPC.set, [key, fullState, syncId]) + }) + // While connecting, the endpoint's `resubscribe()` on the next + // `connected` transition performs the initial subscribe instead. + if (options.isConnected()) + subscribeNow(key) + if (getOptions?.initialValue !== undefined) + return Promise.resolve(state as SharedState) + return new Promise>((resolve) => { + if (seeded.has(key)) { + resolve(state) + return + } + const waiters = seedWaiters.get(key) ?? [] + waiters.push(() => resolve(state)) + seedWaiters.set(key, waiters) + }) + }, + } +} diff --git a/packages/devframe/src/in-page-channel/types.ts b/packages/devframe/src/in-page-channel/types.ts new file mode 100644 index 00000000..59965dd5 --- /dev/null +++ b/packages/devframe/src/in-page-channel/types.ts @@ -0,0 +1,279 @@ +import type { EventEmitter } from 'devframe/types' +import type { SharedState } from 'devframe/utils/shared-state' +import type { RpcArgsSchema, RpcReturnSchema, Thenable } from '../rpc/types' +import type { InferArgsType, InferReturnType } from '../rpc/utils' + +/** + * The shared contract of one in-page channel, declared once (usually in a + * `shared/protocol.ts` both sides import) and passed to both endpoints as a + * type parameter. Purely a type — the only runtime companion is the + * channel-name constant declared next to it. + */ +export interface InPageChannelProtocol { + /** Functions implemented by the page script, callable by panels. */ + pageScript?: Record any> + /** Functions implemented by panels, callable by the page script. */ + panel?: Record any> + /** + * Shared-state slots. The page script is the authority: it owns the + * canonical value; panels are seeded on connect and converge through + * syncId-deduplicated patches. + */ + sharedStates?: Record +} + +type SideFunctions = S extends Record any> ? S : Record +type PageScriptFunctions

= SideFunctions> +type PanelFunctions

= SideFunctions> +type SharedStates

+ = P['sharedStates'] extends Record ? P['sharedStates'] : Record + +type FnArgs = F extends (...args: infer A) => any ? A : never +type FnReturn = F extends (...args: any[]) => infer R ? Awaited : never + +/** + * Types of an in-page channel function — `RpcFunctionType` minus the + * server-only `static`: `event` is fire-and-forget (the only type valid for + * fan-out), `action` performs, `query` requests data (the default). + */ +export type InPageFunctionType = 'action' | 'event' | 'query' + +/** + * An in-page channel function definition — the `defineRpcFunction` authoring + * shape (`name`, `type`, Standard-Schema `args`/`returns`, + * `jsonSerializable`, `handler`) narrowed to the browser: there is no + * `dump`/`snapshot`/`cacheable`/`agent`. When `jsonSerializable` is `true`, + * payloads are strictly validated at the receiving endpoint and misshapen + * values reject the call with a descriptive `InPageChannelError` instead of + * a cryptic `DataCloneError` in the port. + */ +export type InPageFunctionDefinition< + NAME extends string, + TYPE extends InPageFunctionType = 'query', + ARGS extends any[] = [], + RETURN = void, + AS extends RpcArgsSchema | undefined = undefined, + RS extends RpcReturnSchema | undefined = undefined, +> + = [AS, RS] extends [undefined, undefined] + ? { + name: NAME + type?: TYPE + args?: AS + returns?: RS + jsonSerializable?: boolean + handler: (...args: ARGS) => RETURN + } + : { + name: NAME + type?: TYPE + /** Standard Schema array validating (and typing) the arguments. */ + args: AS + /** Standard Schema typing the resolved return value. */ + returns: RS + jsonSerializable?: boolean + handler: (...args: InferArgsType) => Thenable> + } + +/** Loosely-typed definition — the registration unit both endpoints accept. */ +export type InPageFunctionDefinitionAny = InPageFunctionDefinition + +/** + * Connection lifecycle of a panel endpoint: `connecting` (handshake retry + * loop running, outgoing traffic buffered) → `connected` → back to + * `connecting` on port loss, or `closed` after `close()` (permanent). + */ +export type InPageChannelStatus = 'connecting' | 'connected' | 'closed' + +interface InPageChannelCommonOptions { + /** + * Channel name, namespaced with the devframe id by convention + * (e.g. `devframes:plugin:a11y`). Both endpoints must use the same name. + */ + name: string + /** Implementations of this endpoint's side of the protocol. */ + functions?: readonly InPageFunctionDefinitionAny[] + /** + * Origins accepted during the handshake (and used as `targetOrigin` when + * posting handshake messages). The in-page channel is same-origin by + * definition, so this defaults to `[location.origin]`. + */ + allowedOrigins?: string[] + /** + * Timeout for request/response calls, in milliseconds; `-1` disables. + * Rejections are `InPageChannelError`s (code `timeout`) carrying the + * endpoint status, so a hanging call explains itself. + * @default 15000 + */ + callTimeoutMs?: number + /** + * Liveness heartbeat guarding against silently dead ports. Defaults to a + * 5s ping / 12s silence window; pass `false` to disable — on both + * endpoints together. + */ + heartbeat?: { intervalMs?: number, timeoutMs?: number } | false + /** + * Applied to each outgoing argument and handler result before posting — + * the place to unwrap framework reactivity (Vue `toRaw`, Solid `unwrap`) + * into plain structured-cloneable values. + */ + serialize?: (value: unknown) => unknown + /** Applied to each incoming argument and call result. */ + deserialize?: (value: unknown) => unknown +} + +/** Options for {@link createPageScriptChannel}. */ +export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions { + /** + * Window whose `message` events carry panel hellos. Defaults to the + * global `window`; pass `false` to skip the handshake listener entirely + * (bring-your-own ports via `addPanelPort` only). + */ + window?: Window | false +} + +/** Options for {@link connectPanelChannel}. */ +export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions { + /** + * The panel's own window (listens for the handshake grant). Defaults to + * the global `window`; pass `false` with `transport` to skip the handshake. + */ + window?: Window | false + /** + * Windows the hello is posted to. Defaults to the panel's ancestor chain + * plus its `opener` — the places a same-tab page script can live. When + * empty and no `transport` is given, the endpoint stays `connecting` and + * warns once. + */ + targets?: Window[] + /** Pre-established port to the page script, bypassing the handshake. */ + transport?: MessagePort + /** + * Pin this panel to one page-script instance id. By default the panel + * auto-pairs with the most recent page script that answers — almost + * always "my own tab's page script". + */ + instanceId?: string + /** + * Base interval between handshake hello retries, in milliseconds; each + * retry backs off ×1.5 up to a 3s cap, forever (the page script may load + * later than the panel). + * @default 300 + */ + helloIntervalMs?: number + /** + * Maximum `callEvent` payloads buffered while `connecting`, flushed on + * connect; when full, the oldest is dropped with a console warning. + * @default 64 + */ + eventBufferLimit?: number +} + +/** + * The channel shared-state accessor — mirrors `rpc.sharedState`, with the + * page script playing the server's role as rendezvous and authority. The + * page script's first `get` of a key must provide `initialValue`; a panel's + * `get` without one resolves once the authority's first replay arrives. + */ +export interface InPageSharedStateHost

{ + get: & string>( + key: K, + options?: { initialValue?: SharedStates

[K] }, + ) => Promise[K]>> +} + +/** Emitter events of a page-script endpoint. */ +export interface PageScriptChannelEvents

{ + 'panel:connected': (panel: PanelPeer

) => void + 'panel:disconnected': (panel: PanelPeer

) => void +} + +/** One connected panel, as seen from the page script. */ +export interface PanelPeer

{ + /** Unique id of the panel endpoint (stable across its lifetime, not reloads). */ + readonly id: string + /** Call one panel's function and await the result. */ + call: & string>( + name: K, + ...args: FnArgs[K]> + ) => Promise[K]>> + /** Disconnect this panel. */ + close: () => void +} + +/** + * The page-script endpoint of an in-page channel: answers panel handshakes, + * holds one dedicated port per connected panel, fans events out to all of + * them, and is the authority for the channel's shared states. + */ +export interface PageScriptChannel

{ + readonly name: string + /** + * This page context's instance id (persisted per tab in sessionStorage), + * carried in every handshake so panels can pin to one instance when the + * same app is open in several tabs. + */ + readonly instanceId: string + /** Currently connected panels. */ + readonly panels: readonly PanelPeer

[] + readonly events: Pick>, 'on' | 'once'> + /** + * Fan a fire-and-forget event out to every connected panel; panels that + * don't implement the function ignore it. + */ + callEvent: & string>( + name: K, + ...args: FnArgs[K]> + ) => void + /** Page-script-authoritative shared states, replayed to joining panels. */ + readonly sharedState: InPageSharedStateHost

+ /** Adopt a pre-established port as a panel peer (bring-your-own transport). */ + addPanelPort: (port: MessagePort) => PanelPeer

+ /** Tear the endpoint down: disconnect every panel, stop answering hellos. */ + close: () => void +} + +/** Emitter events of a panel endpoint. */ +export interface PanelChannelEvents { + 'status:updated': (status: InPageChannelStatus) => void +} + +/** + * The panel endpoint of an in-page channel: finds the page script with a + * retrying same-origin handshake, survives reloads on either side by + * re-handshaking, and buffers outgoing traffic while `connecting`. + */ +export interface PanelChannel

{ + readonly name: string + readonly status: InPageChannelStatus + /** The paired page script's instance id, once connected. */ + readonly pageScript: { instanceId: string } | undefined + readonly events: Pick, 'on' | 'once'> + /** + * Resolves once connected. With `timeoutMs`, rejects with an + * `InPageChannelError` (code `timeout`) when no page script answered in + * time — the hook for a "no page script found" fallback UI. + */ + whenConnected: (timeoutMs?: number) => Promise + /** + * Call a page-script function and await the result. While `connecting` + * the call is buffered and sent on connect; it rejects with code + * `timeout` when `callTimeoutMs` elapses first. + */ + call: & string>( + name: K, + ...args: FnArgs[K]> + ) => Promise[K]>> + /** + * Fire-and-forget to the page script. While `connecting` the event is + * buffered (up to `eventBufferLimit`) and flushed on connect. + */ + callEvent: & string>( + name: K, + ...args: FnArgs[K]> + ) => void + /** Shared states mirrored from the page-script authority. */ + readonly sharedState: InPageSharedStateHost

+ /** Tear the endpoint down permanently. */ + close: () => void +} diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index 36ac4568..5ee2389a 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -71,6 +71,7 @@ const nodeDeps = { // Shared by the runtime client build and the combined dts build below. const clientEntries = { 'client/index': 'src/client/index.ts', + 'in-page-channel/index': 'src/in-page-channel/index.ts', 'utils/agent-tool-name': 'src/utils/agent-tool-name.ts', 'utils/colors': 'src/utils/colors.ts', 'utils/crypto-token': 'src/utils/crypto-token.ts', @@ -151,6 +152,7 @@ export default defineConfig([ await checkClientDist({ entries: [ resolve(distDir, 'client/index.mjs'), + resolve(distDir, 'in-page-channel/index.mjs'), resolve(distDir, 'utils/agent-tool-name.mjs'), resolve(distDir, 'utils/colors.mjs'), resolve(distDir, 'utils/crypto-token.mjs'), diff --git a/plugins/a11y/README.md b/plugins/a11y/README.md index 93b0deb8..fa8ef654 100644 --- a/plugins/a11y/README.md +++ b/plugins/a11y/README.md @@ -37,16 +37,18 @@ Three pieces, two of them browser-side: | Piece | Runs in | Role | |-------|---------|------| -| **Page script** (`src/inject`) | the user app's page | runs axe-core, tracks routes, broadcasts the aggregate state, draws the preview + pinned rings | +| **Page script** (`src/inject`) | the user app's page | runs axe-core, tracks routes, owns the aggregate state, draws the preview + pinned rings | | **Panel** (`src/spa`) | the devtools iframe | Solid SPA: Dashboard + grouped violations, fires preview/pin/rescan | | **Node** (`src/index.ts`, `src/node`, `src/rpc`) | the node side | `get-config` RPC (impact taxonomy + runtime config) — live in dev, baked in a static build | -The page script and panel talk over the in-page channel (a same-origin -[`BroadcastChannel`](src/shared/protocol.ts)), not the devframe RPC backend. That -is what keeps the live loop working in **both modes**: neither half needs a -server to reach the other, only a shared browser origin (host page + panel -iframe). The page script owns the authoritative route → report map and broadcasts the -whole aggregate on every change, so the panel stays a pure render of it. devframe +The page script and panel talk over devframe's in-page channel +([`devframe/in-page-channel`](../../packages/devframe/src/in-page-channel/index.ts), +contract in [`src/shared/protocol.ts`](src/shared/protocol.ts)), not the devframe +RPC backend. That is what keeps the live loop working in **both modes**: neither +half needs a server to reach the other, only a same-origin handshake in the same +tab. The page script owns the authoritative route → report map as the channel's +shared state — replayed to late-joining panels, streamed as patches on every +change — so the panel stays a pure render of it. devframe RPC carries the data model on top — `get-config` is a `static` function, so it resolves over WebSocket in dev and from the baked dump in a static build; the panel forwards its runtime-config slice to the page script over the channel, keeping the @@ -120,7 +122,7 @@ pnpm -C plugins/a11y dev # from source: same, at /__devframes_plugin_a11 | `src/cli.ts` | `/cli` | `createA11yCli()` — backs the `devframes_plugin_a11y` bin | | `src/client/index.ts` | `/client` | `connectA11y()` — typed browser RPC client wrapper | | `src/rpc/` | — | `get-config` static RPC + the type-safe client registry | -| `src/shared/protocol.ts` | — | the page script ↔ panel in-page channel (`BroadcastChannel`) contract | +| `src/shared/protocol.ts` | — | the page script ↔ panel in-page channel contract (`A11yChannelProtocol`) | | `src/inject/` | — | the page script (axe scan, highlight overlay, hub messages mirror) → `dist/inject/inject.js` | | `src/spa/` | — | the Solid panel SPA → `assets-pkg/dist` (ships in `@devframes/plugin-a11y--assets`) | | `demo/` | — | same-origin host page + server (dev + static modes) | diff --git a/plugins/a11y/demo/index.html b/plugins/a11y/demo/index.html index 6d0a5697..998c5dc9 100644 --- a/plugins/a11y/demo/index.html +++ b/plugins/a11y/demo/index.html @@ -155,7 +155,7 @@

This week's roasts

- + diff --git a/plugins/a11y/demo/server.mjs b/plugins/a11y/demo/server.mjs index 3198b50e..a87e6429 100644 --- a/plugins/a11y/demo/server.mjs +++ b/plugins/a11y/demo/server.mjs @@ -3,7 +3,7 @@ * Same-origin demo host for the a11y inspector. * * Serves three things off one origin so the page script (host page) and the - * panel (devtools iframe) share a BroadcastChannel: + * panel (devtools iframe) can handshake their in-page channel: * * GET / → the demo page (intentional a11y bugs) * GET /__df-inject/inject.js → the page script bundle @@ -14,7 +14,7 @@ * node demo/server.mjs dev — live WebSocket RPC (`assets-pkg/dist`) * node demo/server.mjs build static — baked RPC dump, (`dist/static`) * - * The scan/highlight loop is identical in both: it rides the BroadcastChannel, + * The scan/highlight loop is identical in both: it rides the in-page channel, * not the devframe backend. */ import { existsSync } from 'node:fs' diff --git a/plugins/a11y/src/client/index.ts b/plugins/a11y/src/client/index.ts index 0de91349..38e7883a 100644 --- a/plugins/a11y/src/client/index.ts +++ b/plugins/a11y/src/client/index.ts @@ -8,7 +8,7 @@ export type { Impact, ScanReport, Violation, ViolationNode } from '../shared/pro * Connect to the a11y inspector's devframe backend. A thin, typed wrapper * around devframe's {@link connectDevframe}; the panel derives its base from * `document.baseURI`, so no options are required in the common case. The - * live scan/highlight loop itself rides a same-origin BroadcastChannel, + * live scan/highlight loop itself rides the same-origin in-page channel, * independent of this connection. */ export function connectA11y(options?: DevframeRpcClientOptions): Promise { diff --git a/plugins/a11y/src/index.ts b/plugins/a11y/src/index.ts index caefadce..db0bcfc4 100644 --- a/plugins/a11y/src/index.ts +++ b/plugins/a11y/src/index.ts @@ -73,7 +73,7 @@ export interface A11yDevframeOptions { * Build a {@link DevframeDefinition} for the a11y inspector. The same * definition runs standalone (`/cli`, `/build`) and mounts into a host * (`/vite`, hub). The panel talks to the page script over the in-page channel - * (a same-origin BroadcastChannel), so the scan/highlight loop works identically in dev + * (`devframe/in-page-channel`), so the scan/highlight loop works identically in dev * (live WebSocket RPC) and in a baked static build. * * @experimental This plugin is experimental and may change without a major diff --git a/plugins/a11y/src/inject/index.ts b/plugins/a11y/src/inject/index.ts index 19086639..73c7c4a5 100644 --- a/plugins/a11y/src/inject/index.ts +++ b/plugins/a11y/src/inject/index.ts @@ -1,11 +1,13 @@ /** * The a11y inspector **page script** — injected into the user app's page. * - * It runs axe-core against the live DOM, tracks violations per route, broadcasts - * the whole {@link A11yState} aggregate to the panel, and draws transient + - * pinned highlight rings around elements the panel asks about. It talks to the - * panel purely over a same-origin BroadcastChannel, so it needs no server — the - * loop works the same in dev and in a static build. + * It runs axe-core against the live DOM, tracks violations per route, owns + * the whole {@link A11yState} aggregate as the in-page channel's shared + * state, and draws transient + pinned highlight rings around elements the + * panel asks about. It talks to the panel purely over devframe's in-page + * channel (`devframe/in-page-channel`), so it needs no server — the loop + * works the same in dev and in a static build, and every connected panel + * (dock iframe, popup, Document PiP) converges on the same state. * * Load it from the host page with a single module script, e.g. * `` — or let a @@ -13,15 +15,10 @@ * export receives the hub's client-script context and additionally mirrors * each scan into the hub's messages feed. */ -import type { - A11yMessage, - A11yState, - PageScriptConfig, - PinTarget, - ScanReport, -} from '../shared/protocol.ts' +import type { A11yChannelProtocol, PageScriptConfig, PinTarget, ScanReport } from '../shared/protocol.ts' import type { A11yPageScriptContext } from './messages.ts' import type { PinInfo } from './overlay.ts' +import { createPageScriptChannel, defineChannelFunction } from 'devframe/in-page-channel' import { A11Y_CHANNEL, A11Y_DEFAULT_DOCK_ID, @@ -34,13 +31,12 @@ import { resolveElement, scan } from './scanner.ts' const GLOBAL_FLAG = '__DF_A11Y_PAGE_SCRIPT__' -function start(context?: A11yPageScriptContext) { +async function start(context?: A11yPageScriptContext): Promise { const w = window as unknown as Record if (w[GLOBAL_FLAG]) return w[GLOBAL_FLAG] = true - const channel = new BroadcastChannel(A11Y_CHANNEL) const overlay = createOverlay() document.documentElement.appendChild(overlay.root) @@ -77,8 +73,6 @@ function start(context?: A11yPageScriptContext) { let rescanQueued = false let debounceTimer = 0 - const post = (message: A11yMessage) => channel.postMessage(message) - function loadRoutes(): [string, ScanReport][] { try { const raw = sessionStorage.getItem(A11Y_STORAGE_KEY) @@ -100,11 +94,104 @@ function start(context?: A11yPageScriptContext) { } } - function buildState(): A11yState { - return { engine, activeRoute, routes: [...routes.values()] } - } - function broadcastState() { - post({ type: 'a11y:state', state: buildState() }) + const channel = createPageScriptChannel({ + name: A11Y_CHANNEL, + functions: [ + defineChannelFunction({ + name: 'highlight', + type: 'event', + jsonSerializable: true, + handler: (nodeId: string, target: string[]) => { + const el = document.querySelector(`[${A11Y_NODE_ATTR}="${CSS.escape(nodeId)}"]`) + ?? resolveElement(target) + if (el) { + const active = routes.get(activeRoute) ?? null + const impact = findImpact(active, nodeId) ?? 'minor' + const ruleId = findRule(active, nodeId) ?? 'element' + overlay.preview(el, { impact, ruleId }) + } + else { + overlay.clearPreview() + } + }, + }), + defineChannelFunction({ + name: 'clear-highlight', + type: 'event', + handler: () => overlay.clearPreview(), + }), + defineChannelFunction({ + name: 'set-pins', + type: 'event', + jsonSerializable: true, + handler: (pins: PinTarget[]) => { + const infos: PinInfo[] = [] + pins.forEach((pin, i) => { + const info = resolvePin(pin, i + 1) + if (info) + infos.push(info) + }) + overlay.setPins(infos) + }, + }), + defineChannelFunction({ + name: 'rescan', + type: 'event', + handler: () => void runScan(), + }), + defineChannelFunction({ + name: 'set-config', + type: 'event', + handler: (next: PageScriptConfig) => applyConfig(next), + }), + defineChannelFunction({ + name: 'set-autoscan', + type: 'event', + jsonSerializable: true, + handler: (enabled: boolean) => { + config.autoScan = enabled + if (enabled) + bindInteractions() + else + unbindInteractions() + }, + }), + defineChannelFunction({ + name: 'clear-route', + type: 'event', + jsonSerializable: true, + handler: (route: string) => { + routes.delete(route) + loggedRules.delete(route) + saveRoutes() + publishState() + }, + }), + defineChannelFunction({ + name: 'clear-all', + type: 'event', + handler: () => { + routes.clear() + loggedRules.clear() + saveRoutes() + publishState() + }, + }), + ], + }) + + // The page script is the authority for the aggregate; connected panels are + // seeded on handshake and converge through patches. + const state = await channel.sharedState.get('state', { + initialValue: { engine, activeRoute, scanning: false, routes: [...routes.values()] }, + }) + function publishState() { + state.mutate((draft) => { + draft.engine = engine + draft.activeRoute = activeRoute + draft.scanning = scanning + draft.routes = [...routes.values()] + }) } const observer = new MutationObserver((records) => { @@ -183,7 +270,7 @@ function start(context?: A11yPageScriptContext) { } scanning = true activeRoute = location.pathname - post({ type: 'a11y:scanning', route: activeRoute }) + publishState() reporter?.scanning() // Suspend observation so attribute-stamping during the scan doesn't // retrigger us. @@ -195,7 +282,6 @@ function start(context?: A11yPageScriptContext) { activeRoute = report.route saveRoutes() logNewIssues(report) - broadcastState() reporter?.report(report) } catch (error) { @@ -205,6 +291,7 @@ function start(context?: A11yPageScriptContext) { finally { observe() scanning = false + publishState() if (rescanQueued) { rescanQueued = false scheduleScan() @@ -220,7 +307,7 @@ function start(context?: A11yPageScriptContext) { return activeRoute = location.pathname overlay.setPins([]) - broadcastState() + publishState() scheduleScan() } const origPush = history.pushState @@ -247,71 +334,6 @@ function start(context?: A11yPageScriptContext) { return { el, impact: pin.impact, ruleId: pin.ruleId, number } } - channel.addEventListener('message', (event: MessageEvent) => { - const message = event.data - switch (message.type) { - case 'a11y:panel-ready': - post({ type: 'a11y:page-script-ready', url: location.href, route: activeRoute }) - if (routes.size > 0) - broadcastState() - else - void runScan() - break - case 'a11y:config': - applyConfig(message.config) - break - case 'a11y:highlight': { - const el = document.querySelector(`[${A11Y_NODE_ATTR}="${CSS.escape(message.nodeId)}"]`) - ?? resolveElement(message.target) - if (el) { - const active = routes.get(activeRoute) ?? null - const impact = findImpact(active, message.nodeId) ?? 'minor' - const ruleId = findRule(active, message.nodeId) ?? 'element' - overlay.preview(el, { impact, ruleId }) - } - else { - overlay.clearPreview() - } - break - } - case 'a11y:clear': - overlay.clearPreview() - break - case 'a11y:pins': { - const infos: PinInfo[] = [] - message.pins.forEach((pin, i) => { - const info = resolvePin(pin, i + 1) - if (info) - infos.push(info) - }) - overlay.setPins(infos) - break - } - case 'a11y:rescan': - void runScan() - break - case 'a11y:set-autoscan': - config.autoScan = message.enabled - if (message.enabled) - bindInteractions() - else - unbindInteractions() - break - case 'a11y:clear-route': - routes.delete(message.route) - loggedRules.delete(message.route) - saveRoutes() - broadcastState() - break - case 'a11y:clear-all': - routes.clear() - loggedRules.clear() - saveRoutes() - broadcastState() - break - } - }) - function applyConfig(next: PageScriptConfig) { config.logIssues = next.logIssues config.axeTags = next.axeTags @@ -324,12 +346,16 @@ function start(context?: A11yPageScriptContext) { unbindInteractions() } + // A panel with nothing to show yet triggers the first scan; state replay to + // late joiners is the channel's job. + channel.events.on('panel:connected', () => { + if (routes.size === 0 && !scanning) + void runScan() + }) + bindInteractions() - // Announce ourselves and run the first scan once the page has settled. - post({ type: 'a11y:page-script-ready', url: location.href, route: activeRoute }) - if (routes.size > 0) - broadcastState() + // Run the first scan once the page has settled. if (document.readyState === 'complete') void runScan() else @@ -346,13 +372,13 @@ function findRule(report: ScanReport | null, nodeId: string) { /** * Client-script entry the hub runtime calls after importing this module, * passing its `DockClientScriptContext`. The live scan/highlight loop rides - * the same-origin BroadcastChannel either way; when the context carries a - * `messages` client (duck-typed — see {@link A11yPageScriptContext}), the page script + * the in-page channel either way; when the context carries a `messages` + * client (duck-typed — see {@link A11yPageScriptContext}), the page script * additionally mirrors each scan into the hub's messages feed. `start()` is * idempotent. */ export default function runA11yPageScript(context?: A11yPageScriptContext): void { - start(context) + void start(context) } // Also self-boot so a plain `