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 `