Add generic CultMesh provider client
This commit is contained in:
parent
51a1691c36
commit
d8fdf6a275
5 changed files with 273 additions and 0 deletions
|
|
@ -8,6 +8,12 @@ Providers supply advertisements, surface and embedded-document reads, command
|
|||
submission, asset resolution, and any product daemon lifecycle. EveElectron
|
||||
does not import provider state or plugin semantic implementations.
|
||||
|
||||
`EveCultMeshProviderClient` takes an Odin-resolved peer target and advertisement
|
||||
record reference, then follows only provider-published references for surfaces,
|
||||
commands, receipts, and embedded documents. CultMesh and MessagePack are
|
||||
constructor dependencies supplied from the application's installed CultLib
|
||||
packages; the runtime owns protocol flow without pinning a sibling checkout.
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm test
|
||||
|
|
|
|||
199
src/cultmesh-provider-client.mjs
Normal file
199
src/cultmesh-provider-client.mjs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
const providerSchema = "gamecult.eve.provider_advertisement.v1";
|
||||
const commandSchema = "gamecult.eve.command.v1";
|
||||
const connectionId = 0x45564545;
|
||||
|
||||
export class EveCultMeshProviderClient {
|
||||
#peer = null;
|
||||
|
||||
constructor(target, dependencies, options = {}) {
|
||||
this.target = normalizeTarget(target);
|
||||
this.mesh = requireFunctionSet(dependencies?.CultMesh, [
|
||||
"createAuthorityLeaseCatalog",
|
||||
"createPeerCatalog",
|
||||
"createRudpPeerForAuthorizedPeer",
|
||||
"documentFromPublication",
|
||||
"verse",
|
||||
], "CultMesh");
|
||||
if (typeof dependencies?.encode !== "function") throw new Error("A MessagePack encode function is required.");
|
||||
this.encode = dependencies.encode;
|
||||
this.runtimeId = options.runtimeId || "eve-electron";
|
||||
this.timeoutMs = options.timeoutMs ?? 5000;
|
||||
this.context = this.mesh.verse("eve.remote", this.runtimeId).context;
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.#peer?.close();
|
||||
this.#peer = null;
|
||||
}
|
||||
|
||||
async providerAdvertisement() {
|
||||
const value = await this.#document(providerSchema, this.target.advertisementRecordRef, "provider");
|
||||
const advertisement = normalizeProviderAdvertisement(value);
|
||||
if (this.target.providerId && advertisement.providerId !== this.target.providerId) {
|
||||
throw new Error(`Resolved provider ${advertisement.providerId} does not match ${this.target.providerId}.`);
|
||||
}
|
||||
return advertisement;
|
||||
}
|
||||
|
||||
async surface(surfaceId = "") {
|
||||
const advertisement = await this.providerAdvertisement();
|
||||
const advertised = selectSurface(advertisement, surfaceId);
|
||||
const document = normalizeSurfaceDocument(await this.#document(advertised.schema, advertised.recordRef, "surface"));
|
||||
if (document.surface.id !== advertised.surfaceId) {
|
||||
throw new Error(`Surface record ${advertised.recordRef} published ${document.surface.id}, expected ${advertised.surfaceId}.`);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
async document({ schemaId, recordRef }) {
|
||||
if (!schemaId || !recordRef) throw new Error("Embedded documents require schemaId and recordRef.");
|
||||
return this.#document(schemaId, recordRef, "embedded-document");
|
||||
}
|
||||
|
||||
async submitCommand(request) {
|
||||
const advertisement = await this.providerAdvertisement();
|
||||
const surface = selectSurface(advertisement, request?.surfaceId);
|
||||
const interaction = surface.worldInteraction;
|
||||
if (!interaction?.commandRecordRef) throw new Error(`Surface ${surface.surfaceId} advertises no command record reference.`);
|
||||
const command = String(request?.command || "").trim();
|
||||
if (!command) throw new Error("Command is required.");
|
||||
const issuedAtUtc = request.issuedAtUtc || new Date().toISOString();
|
||||
const commandId = request.commandId || `${this.runtimeId}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}`;
|
||||
const payload = [commandSchema, commandId, advertisement.providerId, surface.surfaceId, command, issuedAtUtc, request.clientId || this.runtimeId, objectValue(request.payload)];
|
||||
const peer = await this.#getPeer();
|
||||
peer.send({
|
||||
schemaVersion: "cultnet.document_put_raw.v0",
|
||||
messageId: commandId,
|
||||
document: {
|
||||
schemaId: commandSchema,
|
||||
recordKey: childRecordRef(interaction.commandRecordRef, commandId),
|
||||
storedAt: issuedAtUtc,
|
||||
payloadEncoding: "messagepack",
|
||||
payload: this.encode(payload),
|
||||
sourceRuntimeId: this.runtimeId,
|
||||
sourceRole: "eve-runtime",
|
||||
tags: ["eve", "surface-command"],
|
||||
},
|
||||
});
|
||||
return {
|
||||
commandId,
|
||||
providerId: advertisement.providerId,
|
||||
surfaceId: surface.surfaceId,
|
||||
state: "submitted",
|
||||
receiptSchema: interaction.receiptSchema,
|
||||
receiptRecordRef: interaction.receiptRecordRef
|
||||
? childRecordRef(interaction.receiptRecordRef, commandId)
|
||||
: "",
|
||||
};
|
||||
}
|
||||
|
||||
async receipt(submission) {
|
||||
if (!submission?.receiptSchema || !submission?.receiptRecordRef) {
|
||||
throw new Error("The command submission has no advertised receipt location.");
|
||||
}
|
||||
return this.#document(submission.receiptSchema, submission.receiptRecordRef, "receipt");
|
||||
}
|
||||
|
||||
async #document(schemaId, recordRef, kind) {
|
||||
const handle = this.mesh.documentFromPublication(
|
||||
{ kind: "peer-snapshot", peer: () => this.#getPeer(), endpoint: this.#endpoint() },
|
||||
schemaId,
|
||||
recordRef,
|
||||
{
|
||||
documentId: recordRef,
|
||||
routeHint: this.context.routeHint,
|
||||
sourceId: recordRef,
|
||||
timeoutMs: this.timeoutMs,
|
||||
pollMs: 100,
|
||||
messageIdPrefix: `${this.runtimeId}:${kind}`,
|
||||
},
|
||||
);
|
||||
return handle.latest(this.context);
|
||||
}
|
||||
|
||||
async #getPeer() {
|
||||
if (this.#peer) return this.#peer;
|
||||
const peers = this.mesh.createPeerCatalog();
|
||||
const leases = this.mesh.createAuthorityLeaseCatalog();
|
||||
const leaseId = `${this.target.peerId}:authority`;
|
||||
peers.upsert({ peerId: this.target.peerId, verseId: this.target.verseId, endpoints: this.target.endpoints, roles: [this.target.role], authorityLeaseId: leaseId });
|
||||
leases.upsert({ leaseId, verseId: this.target.verseId, peerId: this.target.peerId, roles: [this.target.role], validFrom: new Date(Date.now() - 60000), expiresAt: new Date(Date.now() + 60000) });
|
||||
this.#peer = await this.mesh.createRudpPeerForAuthorizedPeer(this.runtimeId, connectionId, peers, leases, this.target.verseId, this.target.role, { connectTimeoutMs: this.timeoutMs, maxFragmentBytes: 1200, maxPendingReliablePackets: 512 });
|
||||
this.#peer.on?.("close", () => { this.#peer = null; });
|
||||
return this.#peer;
|
||||
}
|
||||
|
||||
#endpoint() {
|
||||
const endpoint = this.target.endpoints.find(value => value.toLowerCase().startsWith("rudp://"));
|
||||
if (!endpoint) throw new Error(`Provider ${this.target.providerId || this.target.peerId} has no Odin-resolved RUDP endpoint.`);
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProviderAdvertisement(value) {
|
||||
const providerId = stringField(value, "providerId", 1);
|
||||
if (!providerId) throw new Error("Provider advertisement is missing providerId.");
|
||||
const surfaces = arrayField(value, "surfaces", 11).map(normalizeAdvertisedSurface).filter(surface => surface.surfaceId);
|
||||
return {
|
||||
schema: stringField(value, "schema", 0), providerId,
|
||||
serviceId: stringField(value, "serviceId", 2), verseId: stringField(value, "verseId", 3),
|
||||
title: stringField(value, "title", 4), kind: stringField(value, "kind", 5),
|
||||
cultMeshAddress: stringField(value, "cultMeshAddress", 6), surfaces,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSurfaceDocument(value) {
|
||||
const portable = stringField(value, "schema", 1) === "gamecult.eve.surface.v1";
|
||||
const surface = field(value, "surface", portable ? 7 : 5);
|
||||
const id = stringField(surface, "id", 0);
|
||||
if (!id) throw new Error("Eve surface document is missing surface.id.");
|
||||
return {
|
||||
schema: "gamecult.eve.surface.v1",
|
||||
providerId: stringField(value, "providerId", portable ? 2 : 0),
|
||||
providerKind: stringField(value, "providerKind", portable ? 3 : 1),
|
||||
title: stringField(value, "title", portable ? 4 : 2),
|
||||
version: numberField(value, "version", portable ? 5 : 3),
|
||||
updatedAtUtc: stringField(value, "updatedAtUtc", portable ? 6 : 4),
|
||||
surface: { id, root: field(surface, "root", 1), styles: arrayField(surface, "styles", 2) },
|
||||
commands: arrayField(value, "commands", portable ? 8 : 6),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAdvertisedSurface(value) {
|
||||
const interaction = field(value, "worldInteraction", 6);
|
||||
return {
|
||||
surfaceId: stringField(value, "surfaceId", 0), schema: stringField(value, "schema", 1),
|
||||
recordRef: stringField(value, "recordRef", 2), transport: stringField(value, "transport", 3),
|
||||
status: stringField(value, "status", 4), surfaceKind: stringField(value, "surfaceKind", 5),
|
||||
worldInteraction: interaction ? {
|
||||
projectionKind: stringField(interaction, "projectionKind", 0),
|
||||
stateSchemas: arrayField(interaction, "stateSchemas", 1),
|
||||
commandBoundary: stringField(interaction, "commandBoundary", 2),
|
||||
commandRecordRef: stringField(interaction, "commandRecordRef", 3),
|
||||
receiptSchema: stringField(interaction, "receiptSchema", 4),
|
||||
receiptRecordRef: stringField(interaction, "receiptRecordRef", 5),
|
||||
assetManifestRecordRef: stringField(interaction, "assetManifestRecordRef", 6),
|
||||
loweringTargets: arrayField(interaction, "loweringTargets", 7), ownership: stringField(interaction, "ownership", 8),
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectSurface(advertisement, surfaceId) {
|
||||
const selected = surfaceId ? advertisement.surfaces.find(value => value.surfaceId === surfaceId) : advertisement.surfaces[0];
|
||||
if (!selected) throw new Error(`Provider ${advertisement.providerId} does not advertise surface ${surfaceId || "(first)"}.`);
|
||||
if (selected.transport !== "cultmesh-record" || !selected.recordRef) throw new Error(`Surface ${selected.surfaceId} is not a readable CultMesh record.`);
|
||||
return selected;
|
||||
}
|
||||
|
||||
function normalizeTarget(value) {
|
||||
if (!value?.advertisementRecordRef) throw new Error("An Odin-resolved advertisementRecordRef is required.");
|
||||
return { providerId: String(value.providerId || ""), advertisementRecordRef: String(value.advertisementRecordRef), peerId: String(value.peerId || "provider"), verseId: String(value.verseId || "provider"), role: String(value.role || "provider"), endpoints: Array.isArray(value.endpoints) ? value.endpoints.map(String) : [] };
|
||||
}
|
||||
|
||||
function childRecordRef(parent, child) { return `${String(parent).replace(/\/$/u, "")}/${encodeURIComponent(child)}`; }
|
||||
function field(value, name, slot) { return value && typeof value === "object" ? (value[name] ?? value[slot]) : undefined; }
|
||||
function stringField(value, name, slot) { const result = field(value, name, slot); return typeof result === "string" ? result : ""; }
|
||||
function numberField(value, name, slot) { const result = field(value, name, slot); return typeof result === "number" && Number.isFinite(result) ? result : 0; }
|
||||
function arrayField(value, name, slot) { const result = field(value, name, slot); return Array.isArray(result) ? result : []; }
|
||||
function objectValue(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; }
|
||||
function requireFunctionSet(value, names, label) { if (!value) throw new Error(`${label} is required.`); for (const name of names) if (typeof value[name] !== "function") throw new Error(`${label}.${name} is required.`); return value; }
|
||||
13
src/index.d.ts
vendored
13
src/index.d.ts
vendored
|
|
@ -1,5 +1,18 @@
|
|||
import type { BrowserWindow, BrowserWindowConstructorOptions, IpcMain, Shell } from "electron";
|
||||
|
||||
export declare class EveCultMeshProviderClient {
|
||||
constructor(target: object, dependencies: { CultMesh: object; encode: (value: unknown) => Uint8Array }, options?: { runtimeId?: string; timeoutMs?: number });
|
||||
close(): Promise<void>;
|
||||
providerAdvertisement(): Promise<object>;
|
||||
surface(surfaceId?: string): Promise<object>;
|
||||
document(request: { schemaId: string; recordRef: string }): Promise<unknown>;
|
||||
submitCommand(request: object): Promise<object>;
|
||||
receipt(submission: object): Promise<unknown>;
|
||||
}
|
||||
|
||||
export declare function normalizeProviderAdvertisement(value: unknown): object;
|
||||
export declare function normalizeSurfaceDocument(value: unknown): object;
|
||||
|
||||
export interface EveElectronWindowOptions extends BrowserWindowConstructorOptions {
|
||||
browserWindow?: BrowserWindowConstructorOptions;
|
||||
openExternal?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from "./eve-electron-shell.mjs";
|
||||
export * from "./cultmesh-provider-client.mjs";
|
||||
export * from "./window-host.mjs";
|
||||
|
|
|
|||
54
test/cultmesh-provider-client.test.mjs
Normal file
54
test/cultmesh-provider-client.test.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { EveCultMeshProviderClient } from "../src/cultmesh-provider-client.mjs";
|
||||
|
||||
const advertisement = [
|
||||
"gamecult.eve.provider_advertisement.v1", "test-provider", "test.service", "test.verse",
|
||||
"Test Provider", "game.runtime", "cultmesh://test/provider", "2026-07-10T00:00:00Z",
|
||||
["live", "2026-07-10T00:00:00Z", 15000], [], [], [[
|
||||
"world", "gamecult.eve.surface.v1", "records/world", "cultmesh-record", "available", "interactive-world",
|
||||
["provider-world", [], "commands", "records/commands", "gamecult.eve.command_receipt.v1", "records/receipts", "records/assets", ["electron-shell"], "provider owns truth"],
|
||||
]], [],
|
||||
];
|
||||
const surface = ["surface-state", "gamecult.eve.surface.v1", "test-provider", "daemon", "World", 3, "now", ["world", { id: "root", kind: "world.scene", children: [] }, []], []];
|
||||
|
||||
function harness() {
|
||||
const sent = [];
|
||||
const records = new Map([["records/provider", advertisement], ["records/world", surface], ["records/receipts/cmd-1", { state: "accepted" }]]);
|
||||
const peer = { send: message => sent.push(message), close() {}, on() {} };
|
||||
const mesh = {
|
||||
createPeerCatalog: () => ({ upsert() {} }), createAuthorityLeaseCatalog: () => ({ upsert() {} }),
|
||||
createRudpPeerForAuthorizedPeer: async () => peer,
|
||||
documentFromPublication: (_source, _schema, recordRef) => ({ latest: async () => {
|
||||
if (!records.has(recordRef)) throw new Error(`missing ${recordRef}`);
|
||||
return records.get(recordRef);
|
||||
} }),
|
||||
verse: () => ({ context: { routeHint: { transport: "network" } } }),
|
||||
};
|
||||
const client = new EveCultMeshProviderClient({
|
||||
providerId: "test-provider", advertisementRecordRef: "records/provider", peerId: "test-peer",
|
||||
verseId: "test.verse", role: "provider", endpoints: ["rudp://127.0.0.1:7777"],
|
||||
}, { CultMesh: mesh, encode: value => Uint8Array.from([value.length]) }, { runtimeId: "test-runtime" });
|
||||
return { client, sent };
|
||||
}
|
||||
|
||||
test("follows provider-advertised record references", async () => {
|
||||
const { client } = harness();
|
||||
const provider = await client.providerAdvertisement();
|
||||
assert.equal(provider.surfaces[0].recordRef, "records/world");
|
||||
assert.equal((await client.surface("world")).surface.id, "world");
|
||||
});
|
||||
|
||||
test("submits at the advertised command boundary and reads the advertised receipt", async () => {
|
||||
const { client, sent } = harness();
|
||||
const submission = await client.submitCommand({ surfaceId: "world", command: "move", commandId: "cmd-1", payload: { x: 1 } });
|
||||
assert.equal(sent[0].document.recordKey, "records/commands/cmd-1");
|
||||
assert.equal(sent[0].document.sourceRole, "eve-runtime");
|
||||
assert.equal(submission.receiptRecordRef, "records/receipts/cmd-1");
|
||||
assert.deepEqual(await client.receipt(submission), { state: "accepted" });
|
||||
});
|
||||
|
||||
test("refuses unadvertised surfaces", async () => {
|
||||
const { client } = harness();
|
||||
await assert.rejects(() => client.surface("missing"), /does not advertise/);
|
||||
});
|
||||
Reference in a new issue