Subscribe Eve components to typed state

This commit is contained in:
Meta Vens 2026-07-15 01:41:25 +01:00
commit 85b0139289
5 changed files with 241 additions and 20 deletions

View file

@ -9,8 +9,22 @@ export interface EveSurfaceComponent {
layout?: Record<string, unknown>;
style?: Record<string, unknown>;
children?: EveSurfaceComponent[];
stateBindings?: EveStateBindingDescriptor[];
embeddedDocuments?: Array<Record<string, unknown>>;
}
export interface EveStateBindingDescriptor {
targetProp: string;
pointerId: string;
sourceId: string;
schemaId: string;
routeKind: string;
routeDescription?: string;
}
export interface EveStateBindingHandle {
latest(): Promise<unknown>;
watch(callback: (value: unknown) => void): () => void;
}
export type EveStateBindingResolver = (binding: EveStateBindingDescriptor, component: EveSurfaceComponent) => Promise<EveStateBindingHandle | undefined>;
export interface EveSurfaceDocument {
providerId?: string;
title?: string;
@ -89,6 +103,7 @@ export interface EveBrowserLoweringOptions {
documentResolver?: (request: EveEmbeddedDocumentRequest, component: EveSurfaceComponent) => Promise<EveResolvedDocument | EveSurfaceDocument | EveSurfaceDocument["surface"] | undefined>;
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[];

View file

@ -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);

View file

@ -11,9 +11,29 @@ export interface EveSurfaceComponent {
layout?: Record<string, unknown>;
style?: Record<string, unknown>;
children?: EveSurfaceComponent[];
stateBindings?: EveStateBindingDescriptor[];
embeddedDocuments?: Array<Record<string, unknown>>;
}
export interface EveStateBindingDescriptor {
targetProp: string;
pointerId: string;
sourceId: string;
schemaId: string;
routeKind: string;
routeDescription?: string;
}
export interface EveStateBindingHandle {
latest(): Promise<unknown>;
watch(callback: (value: unknown) => void): () => void;
}
export type EveStateBindingResolver = (
binding: EveStateBindingDescriptor,
component: EveSurfaceComponent,
) => Promise<EveStateBindingHandle | undefined>;
export interface EveSurfaceDocument {
providerId?: string;
title?: string;
@ -95,6 +115,7 @@ export interface EveBrowserLoweringOptions {
documentResolver?: (request: EveEmbeddedDocumentRequest, component: EveSurfaceComponent) => Promise<EveResolvedDocument | EveSurfaceDocument | EveSurfaceDocument["surface"] | undefined>;
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<HTMLElement, EveSurfaceBindingController>();
interface NormalizedStyles {
tokens: Record<string, unknown>;
@ -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<string, unknown> = 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<string, unknown>;
}
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<void> {
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<EveSurfaceComponent> {
yield root;
for (const child of root.children || []) yield* walkEveComponents(child);
}
export function renderEveComponent(
node: EveSurfaceComponent,
options: EveBrowserLoweringOptions = currentOptions,

View file

@ -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 = {

View file

@ -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);
}
}
}