Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions examples/hub-vite/src/client/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,20 @@ function renderTransportToggle(current: TransportPref) {
button.addEventListener('click', () => applyTransportPref(button.dataset.transport as TransportPref))
}

const renderedMarkup = new WeakMap<HTMLElement, string>()

function renderList<T>(host: HTMLElement, items: readonly T[], render: (item: T) => string) {
if (!items.length) {
host.innerHTML = '<li class="rounded-lg border border-base bg-base border-dashed px2.5 py1.5 text-xs font-mono op-mute">empty</li>'
const html = items.length
? items.map(render).join('')
: '<li class="rounded-lg border border-base bg-base border-dashed px2.5 py1.5 text-xs font-mono op-mute">empty</li>'
// Skip identical rewrites: an innerHTML assignment always recreates the
// nodes, which counts as a DOM mutation — and the a11y inspector's in-page
// agent watches the body for mutations to schedule rescans. Repainting an
// unchanged list every poll would keep it scanning forever.
if (renderedMarkup.get(host) === html)
return
}
host.innerHTML = items.map(render).join('')
renderedMarkup.set(host, html)
host.innerHTML = html
}

// Session-lifetime cache of resolved dock-icon SVGs, keyed by the icon id
Expand Down
22 changes: 21 additions & 1 deletion packages/devframe/src/adapters/__tests__/sse-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ describe('sse transport e2e — full client', () => {
}
})

it('shared state syncs over SSE', async () => {
it('shared state syncs over SSE without echoing server updates back as POSTs', async () => {
const { devframe, origin, base, close } = await bootServer('sse-client-state')
try {
const ctx = await devframe.context
Expand All @@ -232,19 +232,39 @@ describe('sse transport e2e — full client', () => {
})

stubLocation(origin, base)
let postCount = 0
const client = await getDevframeRpcClient({
baseURL: `${origin}${base}`,
transport: 'sse',
otpParam: false,
simpleAuth: false,
sseOptions: {
fetch: (input, init) => {
if (init?.method === 'POST')
postCount++
return fetch(input, init)
},
},
})
await client.ensureTrusted(5000)

const clientState = await client.sharedState.get<{ count: number }>('sse-test:counter')
expect(clientState.value().count).toBe(1)

// Server-side ticks stream down; none of them may reflect back up as
// a `server-state:set` POST — the echo the server would just discard.
const postsBeforeTicks = postCount
serverState.mutate(() => ({ count: 2 }))
await vi.waitFor(() => expect(clientState.value().count).toBe(2))
serverState.mutate(() => ({ count: 3 }))
serverState.mutate(() => ({ count: 4 }))
await vi.waitFor(() => expect(clientState.value().count).toBe(4))
expect(postCount).toBe(postsBeforeTicks)

// A local mutation still forwards to the server (exactly once).
clientState.mutate(() => ({ count: 5 }))
await vi.waitFor(() => expect(serverState.value().count).toBe(5))
expect(postCount).toBe(postsBeforeTicks + 1)
client.close?.()
}
finally {
Expand Down
28 changes: 28 additions & 0 deletions packages/devframe/src/client/rpc-shared-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,36 @@ import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state'
import type { DevframeRpcClient } from './rpc'
import { createSharedState } from 'devframe/utils/shared-state'

/**
* Upper bound on remembered server-originated syncIds. An update's own
* `updated` event fires synchronously after it is applied, so the set only
* needs to outlive the brief window between applying a server update and
* observing its emission — 100 comfortably covers a burst.
*/
const MAX_REMOTE_SYNC_IDS = 100

export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcSharedStateHost {
const sharedState = new Map<string, SharedState<any>>()
const stateDisposers = new Map<string, () => void>()
const initialValues = new Map<string, any>()
const keyAddedListeners = new Set<(key: string) => void>()
const isStaticBackend = rpc.connectionMeta.backend === 'static'

// Server-originated syncIds, so the forwarding listener below can tell a
// local mutation (forward it to the server) from an applied server update
// (already the server's own — forwarding it back would be a pure echo the
// server discards, at the cost of one wire message per update; over the
// SSE transport that's a whole HTTP POST per server-side state tick).
const remoteSyncIds = new Set<string>()
function rememberRemoteSyncId(syncId: string): void {
remoteSyncIds.add(syncId)
if (remoteSyncIds.size > MAX_REMOTE_SYNC_IDS) {
const oldest = remoteSyncIds.values().next().value
if (oldest !== undefined)
remoteSyncIds.delete(oldest)
}
}

function mergeWithInitialValue(key: string, serverState: any): any {
const initial = initialValues.get(key)
if (initial && typeof initial === 'object' && !Array.isArray(initial)
Expand All @@ -26,6 +49,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
const state = sharedState.get(key)
if (!state || state.syncIds.has(syncId))
return
rememberRemoteSyncId(syncId)
state.mutate(() => mergeWithInitialValue(key, fullState), syncId)
},
})
Expand All @@ -37,6 +61,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
const state = sharedState.get(key)
if (!state || state.syncIds.has(syncId))
return
rememberRemoteSyncId(syncId)
state.patch(patches, syncId)
},
})
Expand All @@ -46,6 +71,9 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
offs.push(state.on('updated', (fullState, patches, syncId) => {
if (isStaticBackend)
return
// An update the server just sent needs no reflection back to it.
if (remoteSyncIds.has(syncId))
return
if (patches) {
rpc.callEvent('devframe:rpc:server-state:patch', key, patches, syncId)
}
Expand Down
43 changes: 43 additions & 0 deletions packages/hub/src/node/__tests__/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,49 @@ describe('createHubContext shared state', () => {
})
})

describe('createHubContext docks state churn', () => {
it('message events republish the docks state only when the dock list changed', async () => {
const context = await createHubContext({
cwd: process.cwd(),
mode: 'build', // debounceMs = 0 — republishes settle synchronously-ish
host: createHost(),
})
context.docks.register({
type: 'iframe',
id: 'devframes_plugin_terminals',
title: 'Terminals',
icon: 'ph:terminal-window-duotone',
url: '/__devframes_plugin_terminals/',
})
const docks = await context.rpc.sharedState.get<DevframeDockEntry[]>('devframe:docks')
// Let the register's debounced publish settle first.
await vi.waitFor(() => expect(docks.value()).toHaveLength(1))

let publishes = 0
const off = docks.on('updated', () => void publishes++)

// The periodic-producer pattern: message adds/updates that leave the
// dock list untouched must not rebroadcast `devframe:docks`.
await context.messages.add({ id: 'scan', level: 'info', message: 'No issues found' })
await context.messages.add({ id: 'scan', level: 'info', message: '2 issues found' })
await context.messages.add({ id: 'scan', level: 'info', message: '3 issues found' })
await new Promise(resolve => setTimeout(resolve, 30))
expect(publishes).toBe(0)

// A real dock change still publishes.
context.docks.register({
type: 'iframe',
id: 'second',
title: 'Second',
icon: 'ph:cube-duotone',
url: '/__second/',
})
await vi.waitFor(() => expect(publishes).toBeGreaterThan(0))
expect(docks.value()).toHaveLength(2)
off()
})
})

describe('createHubContext dock activation', () => {
it('mirrors an activation into shared state and broadcasts it live', async () => {
const context = await createHubContext({
Expand Down
41 changes: 41 additions & 0 deletions packages/hub/src/node/__tests__/host-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,47 @@ describe('devframeMessagesHost', () => {
expect(host.removals.at(-1)?.id).toBe('message:1004')
})

it('dedupes identical re-adds: no update event, no clock tick', async () => {
const host = new DevframeMessagesHost({} as DevframeHubContext)
const updates: string[] = []
host.events.on('message:updated', entry => void updates.push(entry.id))

const input = { id: 'scan', level: 'info' as const, message: 'No issues found', labels: ['a11y'] }
await host.add(input)
const tickAfterAdd = host.lastModified.get('scan')

// The periodic-producer pattern: the same entry mirrored again and again.
await host.add({ ...input })
await host.add({ ...input, labels: ['a11y'] })
expect(updates).toEqual([])
expect(host.lastModified.get('scan')).toBe(tickAfterAdd)

// A real change still updates and emits.
await host.add({ ...input, message: '2 issues found' })
expect(updates).toEqual(['scan'])
expect(host.entries.get('scan')?.message).toBe('2 issues found')
expect(host.lastModified.get('scan')).not.toBe(tickAfterAdd)
})

it('an identical re-add carrying autoDelete still resets the keep-alive timer', async () => {
const host = new DevframeMessagesHost({} as DevframeHubContext)
const updates: string[] = []
host.events.on('message:updated', entry => void updates.push(entry.id))

await host.add({ id: 'alive', level: 'info', message: 'still here', autoDelete: 50 })
// Keep-alive re-adds: content identical, timer restarted each time.
for (let i = 0; i < 3; i++) {
await new Promise(resolve => setTimeout(resolve, 30))
await host.add({ id: 'alive', level: 'info', message: 'still here', autoDelete: 50 })
}
// 90ms elapsed — well past the 50ms window — yet the entry survives.
expect(host.entries.has('alive')).toBe(true)
expect(updates).toEqual([])
// Once the re-adds stop, the timer finally fires.
await new Promise(resolve => setTimeout(resolve, 80))
expect(host.entries.has('alive')).toBe(false)
})

it('provides per-level shortcuts that delegate to add()', async () => {
const host = new DevframeMessagesHost({} as DevframeHubContext)

Expand Down
23 changes: 19 additions & 4 deletions packages/hub/src/node/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { DevframeMessageEntry, DevframeMessageEntryInput, DevframeMessagesH
import type { DevframeTerminalsHost } from '../types/terminals'
import type { InstallDevframeOptions } from './install-devframe'
import { createHostContext } from 'devframe/node'
import { hash } from 'devframe/utils/hash'
import { debounce } from 'perfect-debounce'
import { DevframeCommandsHost as CommandsHostImpl } from './host-commands'
import { DevframeDocksHost as DocksHostImpl } from './host-docks'
Expand Down Expand Up @@ -151,11 +152,25 @@ export async function createHubContext(options: CreateHubContextOptions): Promis
const debounceMs = options.mode === 'build' ? 0 : 10

const docksSharedState = await context.rpc.sharedState.get('devframe:docks', { initialValue: [] })
// The docks state republishes on dock, terminal, *and* message events —
// most of which don't actually change the dock list. Publish only when
// the content really changed, so a chatty subsystem (e.g. a message feed
// being mirrored every scan) can't turn into a stream of identical
// `devframe:docks` broadcasts.
let publishedDocksHash: string | undefined
function publishDocks(): void {
const values = docks.values()
const digest = hash(values)
if (digest === publishedDocksHash)
return
publishedDocksHash = digest
docksSharedState.mutate(() => values)
}
const refreshDocks = debounce(() => {
docksSharedState.mutate(() => docks.values())
publishDocks()
}, debounceMs)
docks.events.on('dock:entry:updated', refreshDocks)
docksSharedState.mutate(() => docks.values())
publishDocks()

// Cross-iframe dock activation. A dock activation is a discrete user intent
// ("go to Terminals now"), so it fires immediately (no debounce, which could
Expand All @@ -181,7 +196,7 @@ export async function createHubContext(options: CreateHubContextOptions): Promis
method: 'devframe:terminals:updated',
args: [],
})
docksSharedState.mutate(() => docks.values())
publishDocks()
}, debounceMs)
terminals.events.on('terminal:session:updated', broadcastTerminals)

Expand All @@ -190,7 +205,7 @@ export async function createHubContext(options: CreateHubContextOptions): Promis
method: 'devframe:messages:updated',
args: [],
})
docksSharedState.mutate(() => docks.values())
publishDocks()
}, debounceMs)
messages.events.on('message:added', broadcastMessages)
messages.events.on('message:updated', broadcastMessages)
Expand Down
15 changes: 12 additions & 3 deletions packages/hub/src/node/host-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
} from '../types/messages'
import type { DevframeHubContext } from './context'
import { createEventEmitter } from 'devframe/utils/events'
import { hash } from 'devframe/utils/hash'
import { nanoid } from 'devframe/utils/nanoid'

const MAX_ENTRIES = 1000
Expand Down Expand Up @@ -93,9 +94,17 @@ export class DevframeMessagesHost implements DevframeMessagesHostType {
timestamp: existing.timestamp,
}

this.entries.set(id, updated)
this.lastModified.set(id, this._tick())
this.events.emit('message:updated', updated)
// Content dedupe: a re-add/update that changes nothing (a periodic
// producer mirroring the same entries — e.g. a scanner re-reporting an
// unchanged result) emits no event, so it can't fan out into broadcast
// and shared-state churn. An identical patch that carries `autoDelete`
// still falls through below to reset the keep-alive timer.
const unchanged = hash(updated) === hash(existing)
if (!unchanged) {
this.entries.set(id, updated)
this.lastModified.set(id, this._tick())
this.events.emit('message:updated', updated)
}

// Reset autoDelete timer if changed
if (patch.autoDelete !== undefined) {
Expand Down
12 changes: 9 additions & 3 deletions plugins/a11y/src/inject/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,12 @@ function start(context?: A11yAgentContext) {

function scheduleScan() {
clearTimeout(debounceTimer)
debounceTimer = window.setTimeout(runScan, 600)
// Observer/interaction-driven rescans are background refreshes: they
// update the report silently instead of flipping the messages-feed
// summary through loading → idle — that status churn is itself a page
// mutation (the host re-renders its feed), which would re-trigger the
// observer and turn the scan into a self-sustaining loop.
debounceTimer = window.setTimeout(() => void runScan({ background: true }), 600)
}

// Interaction-driven rescans, layered on top of the DOM observer. Bound only
Expand Down Expand Up @@ -176,15 +181,16 @@ function start(context?: A11yAgentContext) {
console.groupEnd()
}

async function runScan() {
async function runScan(options: { background?: boolean } = {}) {
if (scanning) {
rescanQueued = true
return
}
scanning = true
activeRoute = location.pathname
post({ type: 'a11y:scanning', route: activeRoute })
reporter?.scanning()
if (!options.background)
reporter?.scanning()
// Suspend observation so attribute-stamping during the scan doesn't
// retrigger us.
observer.disconnect()
Expand Down
18 changes: 16 additions & 2 deletions plugins/a11y/src/inject/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,23 @@ export function createMessagesReporter(
let reportedRules = new Set<string>()
// Fire-and-forget: the feed is a mirror, never a gate for the scan loop.
// Every entry is grouped under the short `a11y` category.
const send = (input: HubMessageInput) =>
//
// Re-scans routinely produce the identical report; sending it again would
// be one wire message per entry for zero feed change (and the feed
// re-render it causes on the host page can re-trigger the DOM observer —
// a scan loop). Remember what each entry last carried and send only diffs.
const lastSent = new Map<string, string>()
const send = (input: HubMessageInput & { id: string }) => {
const digest = JSON.stringify(input)
if (lastSent.get(input.id) === digest)
return
lastSent.set(input.id, digest)
void messages.add({ category: MESSAGE_CATEGORY, ...input }).catch(() => {})
const drop = (id: string) => void messages.remove(id).catch(() => {})
}
const drop = (id: string) => {
lastSent.delete(id)
void messages.remove(id).catch(() => {})
}
const dockId = () => options.dockId?.() ?? A11Y_DEFAULT_DOCK_ID

return {
Expand Down
Loading