Subscribe Eve components to typed state
This commit is contained in:
parent
ccb9d56424
commit
85b0139289
5 changed files with 241 additions and 20 deletions
16
packages/eve-browser-lowering/dist/index.d.ts
vendored
16
packages/eve-browser-lowering/dist/index.d.ts
vendored
|
|
@ -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[];
|
||||
|
|
|
|||
73
packages/eve-browser-lowering/dist/index.js
vendored
73
packages/eve-browser-lowering/dist/index.js
vendored
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue