309 lines
11 KiB
JavaScript
309 lines
11 KiB
JavaScript
import {
|
|
createEveCommandIntent,
|
|
emptyState,
|
|
renderEveSurface,
|
|
resolveRequiredPluginAdapters,
|
|
} from "../packages/eve-browser-lowering/dist/index.js";
|
|
import { compileEveDsl } from "./eve-dsl.js";
|
|
import { mergeProviderAdvertisement } from "./provider-advertisements.mjs";
|
|
import {
|
|
createHermodrProviderTargets,
|
|
selectInitialProviderTarget,
|
|
} from "./hermodr-provider-catalog.mjs";
|
|
|
|
const statusEl = document.querySelector("#status");
|
|
const app = document.querySelector("#app");
|
|
const surfaceId = document.querySelector("#surface-id");
|
|
const surfaceVersion = document.querySelector("#surface-version");
|
|
const providerSelect = document.querySelector("#provider-select");
|
|
const providerMeta = document.querySelector("#provider-meta");
|
|
|
|
let providers = [];
|
|
let currentProvider;
|
|
let liveHermodr = false;
|
|
let openProviderGeneration = 0;
|
|
let providerRetryTimer;
|
|
|
|
providerSelect.addEventListener("change", () => {
|
|
const provider = providers.find(candidate => (candidate.targetId || candidate.providerId) === providerSelect.value);
|
|
if (provider) void openProvider(provider);
|
|
});
|
|
|
|
void bootProviders();
|
|
|
|
async function bootProviders() {
|
|
clearTimeout(providerRetryTimer);
|
|
let liveProviders = [];
|
|
try {
|
|
liveProviders = await loadHermodrProviders();
|
|
} catch {
|
|
// The fallback below is deliberately visible; a failed live catalog must not strand the renderer at boot.
|
|
}
|
|
|
|
liveHermodr = liveProviders.length > 0;
|
|
try {
|
|
providers = liveHermodr
|
|
? liveProviders
|
|
: await Promise.all((await loadLocalProviderCatalog()).map(loadProviderAdvertisement));
|
|
} catch (error) {
|
|
showProviderLoadFailure(error);
|
|
providerRetryTimer = setTimeout(() => void bootProviders(), 3_000);
|
|
return;
|
|
}
|
|
|
|
if (!providers.length) {
|
|
showProviderLoadFailure(new Error("No provider targets are currently visible."));
|
|
providerRetryTimer = setTimeout(() => void bootProviders(), 3_000);
|
|
return;
|
|
}
|
|
|
|
providerSelect.disabled = false;
|
|
providerSelect.replaceChildren(...providers.map(provider => {
|
|
const option = document.createElement("option");
|
|
option.value = provider.targetId || provider.providerId;
|
|
option.textContent = provider.title || provider.providerId;
|
|
return option;
|
|
}));
|
|
const params = new URLSearchParams(location.search);
|
|
const requestedProviderId = params.get("surface") || params.get("provider") || "";
|
|
const firstProduct = liveHermodr
|
|
? selectInitialProviderTarget(providers, requestedProviderId)
|
|
: providers.find(provider => provider.providerId === requestedProviderId || provider.aliases?.includes(requestedProviderId)) || providers[0];
|
|
providerSelect.value = firstProduct.targetId || firstProduct.providerId;
|
|
await openProvider(firstProduct);
|
|
|
|
if (!liveHermodr) {
|
|
providerRetryTimer = setTimeout(() => void bootProviders(), 3_000);
|
|
}
|
|
}
|
|
|
|
function showProviderLoadFailure(error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
providerSelect.replaceChildren();
|
|
providerSelect.disabled = true;
|
|
providerMeta.textContent = "provider catalog unavailable; retrying";
|
|
statusEl.textContent = "provider catalog unavailable";
|
|
surfaceId.textContent = "surface none";
|
|
surfaceVersion.textContent = "retrying";
|
|
app.replaceChildren(emptyState(detail));
|
|
}
|
|
|
|
async function loadLocalProviderCatalog() {
|
|
const response = await fetch("./local-provider-catalog.json", { cache: "no-store" });
|
|
const catalog = await response.json();
|
|
return catalog.providers || [];
|
|
}
|
|
|
|
async function loadHermodrProviders() {
|
|
try {
|
|
const response = await fetch("/hermodr/verse/catalog", { cache: "no-store" });
|
|
if (!response.ok) return [];
|
|
const catalog = await response.json();
|
|
return createHermodrProviderTargets(catalog);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function loadProviderAdvertisement(provider) {
|
|
if (!provider.advertisement) return provider;
|
|
const response = await fetch(provider.advertisement);
|
|
const advertisement = await response.json();
|
|
return mergeProviderAdvertisement(provider, advertisement);
|
|
}
|
|
|
|
async function openProvider(provider) {
|
|
const generation = ++openProviderGeneration;
|
|
currentProvider = provider;
|
|
providerMeta.textContent = `${provider.kind || "provider"} | ${provider.providerId} | ${provider.freshness?.state || "unknown"}`;
|
|
surfaceId.textContent = provider.providerId;
|
|
surfaceVersion.textContent = "opening";
|
|
const surface = provider.surfaces?.[0];
|
|
if (!surface) {
|
|
statusEl.textContent = `${provider.title || provider.providerId} has no advertised surface`;
|
|
app.replaceChildren(emptyState("No advertised surface"));
|
|
return;
|
|
}
|
|
|
|
if (surface.transport === "hermodr-surface") {
|
|
try {
|
|
const state = await fetchHermodrSurface(surface.url);
|
|
if (generation !== openProviderGeneration) return;
|
|
renderSurface(state, "hermodr cultmesh");
|
|
} catch (error) {
|
|
if (generation !== openProviderGeneration) return;
|
|
statusEl.textContent = `${provider.title || provider.providerId} failed to load`;
|
|
surfaceVersion.textContent = "error";
|
|
app.replaceChildren(emptyState(error instanceof Error ? error.message : String(error)));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (surface.transport === "local-eve-dsl") {
|
|
const response = await fetch(surface.url);
|
|
const state = compileEveDsl(await response.text());
|
|
if (generation !== openProviderGeneration) return;
|
|
renderSurface(state, "local dsl");
|
|
return;
|
|
}
|
|
|
|
const response = await fetch(surface.url);
|
|
const state = await response.json();
|
|
if (generation !== openProviderGeneration) return;
|
|
renderSurface(state, "local fixture");
|
|
}
|
|
|
|
async function fetchHermodrSurface(url) {
|
|
let lastError;
|
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
try {
|
|
const response = await fetch(url, { cache: "no-store" });
|
|
const state = await response.json().catch(() => ({}));
|
|
if (!response.ok || state?.ok === false) {
|
|
throw new Error(state?.error || `Hermodr surface request failed with HTTP ${response.status}`);
|
|
}
|
|
if (!state?.surface?.root) {
|
|
throw new Error("Hermodr returned a surface document without a root.");
|
|
}
|
|
return state;
|
|
} catch (error) {
|
|
lastError = error;
|
|
await delay(250 * (attempt + 1));
|
|
}
|
|
}
|
|
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function renderSurface(state, source) {
|
|
surfaceId.textContent = state.providerId || "surface unknown";
|
|
surfaceVersion.textContent = `v${state.version ?? "?"}`;
|
|
const providerId = state.providerId || currentProvider?.providerId || "";
|
|
const advertisedSurface = currentProvider?.surfaces?.find(candidate => candidate.surfaceId === state.surface?.id)
|
|
|| currentProvider?.surfaces?.[0]
|
|
|| {};
|
|
const pluginAdapters = resolveRequiredPluginAdapters(advertisedSurface);
|
|
renderEveSurface(state, app, {
|
|
activeSurfaceId: state.surface?.id,
|
|
body: document.body,
|
|
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,
|
|
source,
|
|
statusElement: statusEl,
|
|
});
|
|
}
|
|
|
|
function createHermodrDocumentResolver(providerId) {
|
|
return async function resolveHermodrDocument(request) {
|
|
if (!providerId) return undefined;
|
|
const params = new URLSearchParams();
|
|
params.set("documentId", request.documentId);
|
|
if (request.schemaId) params.set("schemaId", request.schemaId);
|
|
if (request.slotId) params.set("slotId", request.slotId);
|
|
const response = await fetch(`/hermodr/document/${encodeURIComponent(providerId)}?${params.toString()}`, { cache: "no-store" });
|
|
if (!response.ok) throw new Error(await response.text());
|
|
return response.json();
|
|
};
|
|
}
|
|
|
|
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);
|
|
const acceptState = 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("snapshot", acceptState);
|
|
stream.addEventListener("update", acceptState);
|
|
stream.addEventListener("reconnected", acceptState);
|
|
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)}`;
|
|
}
|
|
return uri;
|
|
}
|
|
|
|
async function publishCommandIntent(intent, component) {
|
|
const props = component?.props || {};
|
|
const command = intent.command
|
|
? intent
|
|
: createEveCommandIntent(props.command || props.commandId || "invoke", props, {
|
|
clientId: "browser.reference",
|
|
provider: currentProvider,
|
|
});
|
|
statusEl.textContent = `command ${command.command}`;
|
|
if (!liveHermodr) {
|
|
console.info("Eve command intent", command);
|
|
return;
|
|
}
|
|
const response = await fetch("/hermodr/commands/eve", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(command),
|
|
});
|
|
const receipt = await response.json().catch(() => ({}));
|
|
if (!response.ok || receipt.ok === false) {
|
|
statusEl.textContent = `command failed ${command.command}`;
|
|
console.error("Eve command failed", receipt);
|
|
return;
|
|
}
|
|
if (
|
|
receipt.schema !== "gamecult.eve.command_receipt.v1" ||
|
|
!receipt.commandId ||
|
|
receipt.command !== command.command ||
|
|
receipt.state !== "reconciled"
|
|
) {
|
|
statusEl.textContent = `command failed ${command.command}`;
|
|
console.error("Eve command returned no correlated provider receipt", receipt);
|
|
return;
|
|
}
|
|
statusEl.textContent = `command ${receipt.state} ${command.command}`;
|
|
console.info("Eve command receipt", receipt);
|
|
}
|