From 85b0139289ab4382489a5eeb8de2687bad917b6c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 15 Jul 2026 01:41:25 +0100 Subject: [PATCH] Subscribe Eve components to typed state --- packages/eve-browser-lowering/dist/index.d.ts | 16 ++++ packages/eve-browser-lowering/dist/index.js | 73 +++++++++++++++ packages/eve-browser-lowering/src/index.ts | 92 +++++++++++++++++++ .../test/browser-lowering.test.mjs | 14 ++- web/surface.js | 66 +++++++++---- 5 files changed, 241 insertions(+), 20 deletions(-) diff --git a/packages/eve-browser-lowering/dist/index.d.ts b/packages/eve-browser-lowering/dist/index.d.ts index 60fc175..5e5c93d 100644 --- a/packages/eve-browser-lowering/dist/index.d.ts +++ b/packages/eve-browser-lowering/dist/index.d.ts @@ -9,8 +9,22 @@ export interface EveSurfaceComponent { layout?: Record; style?: Record; children?: EveSurfaceComponent[]; + stateBindings?: EveStateBindingDescriptor[]; embeddedDocuments?: Array>; } +export interface EveStateBindingDescriptor { + targetProp: string; + pointerId: string; + sourceId: string; + schemaId: string; + routeKind: string; + routeDescription?: string; +} +export interface EveStateBindingHandle { + latest(): Promise; + watch(callback: (value: unknown) => void): () => void; +} +export type EveStateBindingResolver = (binding: EveStateBindingDescriptor, component: EveSurfaceComponent) => Promise; export interface EveSurfaceDocument { providerId?: string; title?: string; @@ -89,6 +103,7 @@ export interface EveBrowserLoweringOptions { documentResolver?: (request: EveEmbeddedDocumentRequest, component: EveSurfaceComponent) => Promise; provider?: EveProviderAdvertisement; pluginAdapters?: readonly EveBrowserPluginAdapter[]; + stateBindingResolver?: EveStateBindingResolver; source?: string; statusElement?: HTMLElement; } @@ -150,6 +165,7 @@ export interface EveProjectedWorldEntity { source: EveSurfaceComponent; } export declare function renderEveSurface(surface: EveSurfaceDocument, host: HTMLElement, options?: EveBrowserLoweringOptions): HTMLElement; +export declare function applyEveStateBindingValue(component: EveSurfaceComponent, binding: EveStateBindingDescriptor, value: unknown): void; export declare function renderEveComponent(node: EveSurfaceComponent, options?: EveBrowserLoweringOptions): HTMLElement; export declare function projectSemanticListItem(node: EveSurfaceComponent): EveSemanticListItem; export declare function projectWorldScene(node: EveSurfaceComponent): EveProjectedWorldEntity[]; diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index 588735f..fc225db 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -126,7 +126,9 @@ let activeFontStylesheet; let currentSurfaceStyles = normalizeSurfaceStyles(undefined); let currentSurfaceDocument; let currentOptions = {}; +const activeSurfaceBindings = new WeakMap(); export function renderEveSurface(surface, host, options = {}) { + activeSurfaceBindings.get(host)?.dispose(); currentSurfaceDocument = surface; currentOptions = options; currentSurfaceStyles = normalizeSurfaceStyles(surface.surface?.styles); @@ -140,12 +142,83 @@ export function renderEveSurface(surface, host, options = {}) { } if (surface.surface?.root) { host.replaceChildren(renderEveComponent(surface.surface.root, options)); + if (options.stateBindingResolver) { + const controller = new EveSurfaceBindingController(surface, host, options); + activeSurfaceBindings.set(host, controller); + void controller.start(); + } } else { host.replaceChildren(emptyState("No surface root")); } return host; } +export function applyEveStateBindingValue(component, binding, value) { + const path = binding.targetProp.split(".").filter(Boolean); + if (path.length === 0) + throw new Error("Eve state binding targetProp must not be empty."); + component.props ||= {}; + let owner = component.props; + for (const segment of path.slice(0, -1)) { + const current = owner[segment]; + if (!current || typeof current !== "object" || Array.isArray(current)) + owner[segment] = {}; + owner = owner[segment]; + } + owner[path[path.length - 1]] = value; +} +class EveSurfaceBindingController { + surface; + host; + options; + disposed = false; + unsubscribers = []; + constructor(surface, host, options) { + this.surface = surface; + this.host = host; + this.options = options; + } + async start() { + const resolver = this.options.stateBindingResolver; + const root = this.surface.surface?.root; + if (!resolver || !root) + return; + for (const component of walkEveComponents(root)) { + for (const binding of component.stateBindings || []) { + if (this.disposed) + return; + const handle = await resolver(binding, component); + if (!handle || this.disposed) + continue; + const apply = (value) => { + if (this.disposed) + return; + applyEveStateBindingValue(component, binding, value); + this.renderCanonicalProjection(); + }; + apply(await handle.latest()); + if (this.disposed) + return; + this.unsubscribers.push(handle.watch(apply)); + } + } + } + dispose() { + this.disposed = true; + for (const unsubscribe of this.unsubscribers.splice(0)) + unsubscribe(); + } + renderCanonicalProjection() { + const root = this.surface.surface?.root; + if (root) + this.host.replaceChildren(renderEveComponent(root, this.options)); + } +} +function* walkEveComponents(root) { + yield root; + for (const child of root.children || []) + yield* walkEveComponents(child); +} export function renderEveComponent(node, options = currentOptions) { const kind = node.kind || "panel"; const props = objectProps(node.props); diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index b7e254f..3970bbf 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -11,9 +11,29 @@ export interface EveSurfaceComponent { layout?: Record; style?: Record; children?: EveSurfaceComponent[]; + stateBindings?: EveStateBindingDescriptor[]; embeddedDocuments?: Array>; } +export interface EveStateBindingDescriptor { + targetProp: string; + pointerId: string; + sourceId: string; + schemaId: string; + routeKind: string; + routeDescription?: string; +} + +export interface EveStateBindingHandle { + latest(): Promise; + watch(callback: (value: unknown) => void): () => void; +} + +export type EveStateBindingResolver = ( + binding: EveStateBindingDescriptor, + component: EveSurfaceComponent, +) => Promise; + export interface EveSurfaceDocument { providerId?: string; title?: string; @@ -95,6 +115,7 @@ export interface EveBrowserLoweringOptions { documentResolver?: (request: EveEmbeddedDocumentRequest, component: EveSurfaceComponent) => Promise; provider?: EveProviderAdvertisement; pluginAdapters?: readonly EveBrowserPluginAdapter[]; + stateBindingResolver?: EveStateBindingResolver; source?: string; statusElement?: HTMLElement; } @@ -280,6 +301,7 @@ let activeFontStylesheet: HTMLLinkElement | undefined; let currentSurfaceStyles: NormalizedStyles = normalizeSurfaceStyles(undefined); let currentSurfaceDocument: EveSurfaceDocument | undefined; let currentOptions: EveBrowserLoweringOptions = {}; +const activeSurfaceBindings = new WeakMap(); interface NormalizedStyles { tokens: Record; @@ -292,6 +314,7 @@ export function renderEveSurface( host: HTMLElement, options: EveBrowserLoweringOptions = {}, ): HTMLElement { + activeSurfaceBindings.get(host)?.dispose(); currentSurfaceDocument = surface; currentOptions = options; currentSurfaceStyles = normalizeSurfaceStyles(surface.surface?.styles); @@ -305,12 +328,81 @@ export function renderEveSurface( } if (surface.surface?.root) { host.replaceChildren(renderEveComponent(surface.surface.root, options)); + if (options.stateBindingResolver) { + const controller = new EveSurfaceBindingController(surface, host, options); + activeSurfaceBindings.set(host, controller); + void controller.start(); + } } else { host.replaceChildren(emptyState("No surface root")); } return host; } +export function applyEveStateBindingValue( + component: EveSurfaceComponent, + binding: EveStateBindingDescriptor, + value: unknown, +): void { + const path = binding.targetProp.split(".").filter(Boolean); + if (path.length === 0) throw new Error("Eve state binding targetProp must not be empty."); + component.props ||= {}; + let owner: Record = component.props; + for (const segment of path.slice(0, -1)) { + const current = owner[segment]; + if (!current || typeof current !== "object" || Array.isArray(current)) owner[segment] = {}; + owner = owner[segment] as Record; + } + owner[path[path.length - 1]] = value; +} + +class EveSurfaceBindingController { + private disposed = false; + private readonly unsubscribers: Array<() => void> = []; + + constructor( + private readonly surface: EveSurfaceDocument, + private readonly host: HTMLElement, + private readonly options: EveBrowserLoweringOptions, + ) {} + + async start(): Promise { + const resolver = this.options.stateBindingResolver; + const root = this.surface.surface?.root; + if (!resolver || !root) return; + for (const component of walkEveComponents(root)) { + for (const binding of component.stateBindings || []) { + if (this.disposed) return; + const handle = await resolver(binding, component); + if (!handle || this.disposed) continue; + const apply = (value: unknown) => { + if (this.disposed) return; + applyEveStateBindingValue(component, binding, value); + this.renderCanonicalProjection(); + }; + apply(await handle.latest()); + if (this.disposed) return; + this.unsubscribers.push(handle.watch(apply)); + } + } + } + + dispose(): void { + this.disposed = true; + for (const unsubscribe of this.unsubscribers.splice(0)) unsubscribe(); + } + + private renderCanonicalProjection(): void { + const root = this.surface.surface?.root; + if (root) this.host.replaceChildren(renderEveComponent(root, this.options)); + } +} + +function* walkEveComponents(root: EveSurfaceComponent): Generator { + yield root; + for (const child of root.children || []) yield* walkEveComponents(child); +} + export function renderEveComponent( node: EveSurfaceComponent, options: EveBrowserLoweringOptions = currentOptions, diff --git a/packages/eve-browser-lowering/test/browser-lowering.test.mjs b/packages/eve-browser-lowering/test/browser-lowering.test.mjs index 37020c0..602818f 100644 --- a/packages/eve-browser-lowering/test/browser-lowering.test.mjs +++ b/packages/eve-browser-lowering/test/browser-lowering.test.mjs @@ -1,7 +1,19 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { createEveCommandIntent, createWorldActionIntent, normalizeFieldsDocument, projectSemanticListItem, projectWorldScene, resolveRequiredPluginAdapters, selectAdvertisedSurface } from "../dist/index.js"; +import { applyEveStateBindingValue, createEveCommandIntent, createWorldActionIntent, normalizeFieldsDocument, projectSemanticListItem, projectWorldScene, resolveRequiredPluginAdapters, selectAdvertisedSurface } from "../dist/index.js"; + +test("applies provider state bindings to component props instead of receipt diagnostics", () => { + const component = { props: { value: 1.2 } }; + applyEveStateBindingValue(component, { + targetProp: "value", + pointerId: "voidbot.swarm.globalHeat", + sourceId: "voidbot.swarm_state_snapshot:voidbot-swarm", + schemaId: "voidbot.swarm_state_snapshot.v1", + routeKind: "cultmesh-rudp", + }, 0.35); + assert.equal(component.props.value, 0.35); +}); test("selects the requested surface from provider authority", () => { const provider = { diff --git a/web/surface.js b/web/surface.js index 600a606..d49fca6 100644 --- a/web/surface.js +++ b/web/surface.js @@ -192,6 +192,7 @@ function renderSurface(state, source) { clientId: liveHermodr ? "hermodr.browser" : "browser.reference", commandSink: publishCommandIntent, documentResolver: liveHermodr ? createHermodrDocumentResolver(providerId) : undefined, + stateBindingResolver: liveHermodr ? createHermodrStateBindingResolver(providerId) : undefined, assetUrlResolver: liveHermodr ? resolveHermodrAssetUrl : undefined, provider: currentProvider, pluginAdapters, @@ -213,6 +214,52 @@ function createHermodrDocumentResolver(providerId) { }; } +function createHermodrStateBindingResolver(providerId) { + const sources = new Map(); + return async function resolveHermodrStateBinding(binding) { + const sourceKey = `${binding.schemaId}:${binding.sourceId}`; + let source = sources.get(sourceKey); + if (!source) { + const params = new URLSearchParams({ sourceId: binding.sourceId, schemaId: binding.schemaId }); + const stream = new EventSource(`/hermodr/state/${encodeURIComponent(providerId)}?${params}`); + source = { stream, value: undefined, ready: [], watchers: new Set(), references: 0 }; + sources.set(sourceKey, source); + stream.addEventListener("state", event => { + const update = JSON.parse(event.data); + if (update?.state === "stale" || update?.value === undefined) return; + source.value = update.value; + for (const resolve of source.ready.splice(0)) resolve(update.value); + for (const callback of source.watchers) callback(update.value); + }); + stream.addEventListener("stale", event => { + const detail = JSON.parse(event.data || "{}"); + statusEl.textContent = detail.message || `${providerId} state is stale`; + }); + } + source.references += 1; + const select = document => binding.pointerId.split(".").filter(Boolean) + .reduce((value, segment) => value == null ? undefined : value[segment], document); + return { + latest() { + if (source.value !== undefined) return Promise.resolve(select(source.value)); + return new Promise(resolve => source.ready.push(value => resolve(select(value)))); + }, + watch(callback) { + const project = value => callback(select(value)); + source.watchers.add(project); + return () => { + source.watchers.delete(project); + source.references -= 1; + if (source.references <= 0) { + source.stream.close(); + sources.delete(sourceKey); + } + }; + }, + }; + }; +} + function resolveHermodrAssetUrl(uri) { if (/^(cultmesh:\/\/|resources:\/\/)/i.test(uri)) { return `/cultmesh-cdn/${encodeURIComponent(uri)}`; @@ -254,25 +301,6 @@ async function publishCommandIntent(intent, component) { console.error("Eve command returned no correlated provider receipt", receipt); return; } - applyCommandReceipt(receipt); statusEl.textContent = `command ${receipt.state} ${command.command}`; console.info("Eve command receipt", receipt); } - -function applyCommandReceipt(receipt) { - for (const diagnostic of receipt.diagnostics || []) { - if (!diagnostic?.binding || diagnostic.value === undefined) continue; - for (const control of document.querySelectorAll(`[data-bind="${CSS.escape(diagnostic.binding)}"]`)) { - const input = control.querySelector("input[type=range]"); - if (input) { - input.value = String(diagnostic.value); - const min = Number(input.min || 0); - const max = Number(input.max || 1); - const value = Number(diagnostic.value); - const percent = max === min ? 0 : ((value - min) / (max - min)) * 100; - control.style.setProperty("--cultui-slider-value", `${Math.max(0, Math.min(100, percent))}%`); - } - control.dataset.value = String(diagnostic.value); - } - } -}