diff --git a/package.json b/package.json index 371db6f..66417aa 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,10 @@ "types": "./src/index.d.ts", "import": "./src/index.mjs" }, + "./provider-client": { + "types": "./src/cultmesh-provider-client.d.ts", + "import": "./src/cultmesh-provider-client.mjs" + }, "./preload": "./src/eve-provider-preload.cjs" }, "files": ["src", "capture", "scripts", "eve-runtime-capability.json", "README.md", "LICENSE"], diff --git a/src/cultmesh-provider-client.d.ts b/src/cultmesh-provider-client.d.ts new file mode 100644 index 0000000..a64346a --- /dev/null +++ b/src/cultmesh-provider-client.d.ts @@ -0,0 +1,31 @@ +export interface EveCultMeshProviderTarget { + providerId?: string; + advertisementRecordRef: string; + peerId?: string; + verseId?: string; + role?: string; + endpoints?: string[]; +} + +export interface EveCommandSubmission extends Record { + commandId: string; + providerId: string; + surfaceId: string; + state: "submitted"; + receiptSchema: string; + receiptRecordRef: string; +} + +export declare class EveCultMeshProviderClient { + constructor(target: EveCultMeshProviderTarget, dependencies: { CultMesh: object; encode: (value: unknown) => Uint8Array }, options?: { runtimeId?: string; timeoutMs?: number }); + close(): Promise; + providerAdvertisement(): Promise>; + surface(surfaceId?: string): Promise>; + document(request: { schemaId: string; recordRef: string }): Promise; + submitCommand(request: Record): Promise; + receipt(submission: Record): Promise; +} + +export declare function normalizeProviderAdvertisement(value: unknown): Record; +export declare function normalizeSurfaceDocument(value: unknown): Record; +export declare function normalizeCommandReceipt(value: unknown): Record; diff --git a/src/cultmesh-provider-client.mjs b/src/cultmesh-provider-client.mjs index 92fc655..e85295c 100644 --- a/src/cultmesh-provider-client.mjs +++ b/src/cultmesh-provider-client.mjs @@ -1,6 +1,6 @@ const providerSchema = "gamecult.eve.provider_advertisement.v1"; -const commandSchema = "gamecult.eve.command.v1"; -const connectionId = 0x45564545; +const commandSchema = "gamecult.eve.command_invocation.v1"; +const connectionId = 0x43554c54; export class EveCultMeshProviderClient { #peer = null; @@ -59,7 +59,17 @@ export class EveCultMeshProviderClient { 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 payload = [ + commandSchema, + advertisement.providerId, + surface.surfaceId, + [command, "", "Network", interaction.commandBoundary, commandId], + stringRecord(request.payload), + [new Date(issuedAtUtc), 0], + request.clientId || this.runtimeId, + interaction.commandBoundary, + interaction.receiptSchema, + ]; const peer = await this.#getPeer(); peer.send({ schemaVersion: "cultnet.document_put_raw.v0", @@ -80,6 +90,7 @@ export class EveCultMeshProviderClient { providerId: advertisement.providerId, surfaceId: surface.surfaceId, state: "submitted", + route: { kind: "network", description: interaction.commandBoundary }, receiptSchema: interaction.receiptSchema, receiptRecordRef: interaction.receiptRecordRef ? childRecordRef(interaction.receiptRecordRef, commandId) @@ -91,7 +102,7 @@ export class EveCultMeshProviderClient { if (!submission?.receiptSchema || !submission?.receiptRecordRef) { throw new Error("The command submission has no advertised receipt location."); } - return this.#document(submission.receiptSchema, submission.receiptRecordRef, "receipt"); + return normalizeCommandReceipt(await this.#document(submission.receiptSchema, submission.receiptRecordRef, "receipt")); } async #document(schemaId, recordRef, kind) { @@ -154,8 +165,49 @@ export function normalizeSurfaceDocument(value) { 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), + surface: { + id, + root: normalizeComponent(field(surface, "root", 1), `${id}.root`), + styles: arrayField(surface, "styles", 2).map(token => ({ name: stringField(token, "name", 0), value: stringField(token, "value", 1) })), + }, + commands: arrayField(value, "commands", portable ? 8 : 6).map(normalizeCommandDescriptor).filter(command => command.command), + }; +} + +export function normalizeCommandReceipt(value) { + return { + schema: stringField(value, "schema", 0), receiptId: stringField(value, "receiptId", 1), + commandId: stringField(value, "commandId", 2), command: stringField(value, "command", 3), + state: stringField(value, "state", 4), ownerRepo: stringField(value, "ownerRepo", 5), + authority: stringField(value, "authority", 6), providerId: stringField(value, "providerId", 7), + surfaceId: stringField(value, "surfaceId", 8), message: stringField(value, "message", 9), + issuedAtUtc: stringField(value, "issuedAtUtc", 10), sourceVersion: numberField(value, "sourceVersion", 11), + }; +} + +function normalizeComponent(value, fallbackId) { + return { + id: stringField(value, "id", 0) || fallbackId, + kind: stringField(value, "kind", 1) || "surface", + props: recordField(value, "props", 2), + children: arrayField(value, "children", 3).map((child, index) => normalizeComponent(child, `${fallbackId}.${index}`)), + stateBindings: arrayField(value, "stateBindings", 4), + embeddedDocuments: arrayField(value, "embeddedDocuments", 5).map(slot => ({ + slotId: stringField(slot, "slotId", 0), documentId: stringField(slot, "documentId", 1), + schemaId: stringField(slot, "schemaId", 2), presentationKind: stringField(slot, "presentationKind", 3), + })), + layout: recordField(value, "layout", 6), + style: recordField(value, "style", 7), + }; +} + +function normalizeCommandDescriptor(value) { + const operation = field(value, "operation", 0); + const routeHint = field(operation, "routeHint", 3); + return { + command: stringField(operation, "operationId", 0), + label: stringField(operation, "label", 1), + transport: stringField(routeHint, "description", 1) || "cultmesh", }; } @@ -195,5 +247,7 @@ function field(value, name, slot) { return value && typeof value === "object" ? 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 recordField(value, name, slot) { const result = field(value, name, slot); return result && typeof result === "object" && !Array.isArray(result) ? result : {}; } function objectValue(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } +function stringRecord(value) { return Object.fromEntries(Object.entries(objectValue(value)).map(([key, entry]) => [key, entry == null ? "" : String(entry)])); } 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; } diff --git a/src/index.d.ts b/src/index.d.ts index ba42331..f58ef65 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,17 +1,6 @@ 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; - providerAdvertisement(): Promise; - surface(surfaceId?: string): Promise; - document(request: { schemaId: string; recordRef: string }): Promise; - submitCommand(request: object): Promise; - receipt(submission: object): Promise; -} - -export declare function normalizeProviderAdvertisement(value: unknown): object; -export declare function normalizeSurfaceDocument(value: unknown): object; +export * from "./cultmesh-provider-client.js"; export interface EveElectronWindowOptions extends BrowserWindowConstructorOptions { browserWindow?: BrowserWindowConstructorOptions; diff --git a/test/cultmesh-provider-client.test.mjs b/test/cultmesh-provider-client.test.mjs index c120498..2852e34 100644 --- a/test/cultmesh-provider-client.test.mjs +++ b/test/cultmesh-provider-client.test.mjs @@ -10,15 +10,17 @@ const advertisement = [ ["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: [] }, []], []]; +const surface = ["surface-state", "gamecult.eve.surface.v1", "test-provider", "daemon", "World", 3, "now", ["world", ["root", "world.scene", { title: "World" }, [["child", "text", { value: "Ready" }, [], [], [], {}, {}]], [], [], {}, {}], []], []]; function harness() { const sent = []; - const records = new Map([["records/provider", advertisement], ["records/world", surface], ["records/receipts:cmd-1", { state: "accepted" }]]); + const connections = []; + const encoded = []; + const records = new Map([["records/provider", advertisement], ["records/world", surface], ["records/receipts:cmd-1", ["gamecult.eve.command_receipt.v1", "receipt-1", "cmd-1", "move", "accepted", "Provider", "daemon", "test-provider", "world", "moved", "now", 4]]]); const peer = { send: message => sent.push(message), close() {}, on() {} }; const mesh = { createPeerCatalog: () => ({ upsert() {} }), createAuthorityLeaseCatalog: () => ({ upsert() {} }), - createRudpPeerForAuthorizedPeer: async () => peer, + createRudpPeerForAuthorizedPeer: async (...args) => { connections.push(args); return peer; }, documentFromPublication: (_source, _schema, recordRef) => ({ latest: async () => { if (!records.has(recordRef)) throw new Error(`missing ${recordRef}`); return records.get(recordRef); @@ -28,8 +30,8 @@ function harness() { 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 }; + }, { CultMesh: mesh, encode: value => { encoded.push(value); return Uint8Array.from([value.length]); } }, { runtimeId: "test-runtime" }); + return { client, connections, encoded, sent }; } test("follows provider-advertised record references", async () => { @@ -37,15 +39,25 @@ test("follows provider-advertised record references", async () => { const provider = await client.providerAdvertisement(); assert.equal(provider.surfaces[0].recordRef, "records/world"); assert.equal((await client.surface("world")).surface.id, "world"); + assert.equal((await client.surface("world")).surface.root.children[0].props.value, "Ready"); }); test("submits at the advertised command boundary and reads the advertised receipt", async () => { - const { client, sent } = harness(); + const { client, connections, encoded, 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.schemaId, "gamecult.eve.command_invocation.v1"); + assert.deepEqual(encoded[0][3], ["move", "", "Network", "commands", "cmd-1"]); + assert.equal(encoded[0][5][1], 0); + assert.deepEqual(encoded[0][4], { x: "1" }); assert.equal(sent[0].document.sourceRole, "eve-runtime"); + assert.equal(connections[0][1], 0x43554c54); assert.equal(submission.receiptRecordRef, "records/receipts:cmd-1"); - assert.deepEqual(await client.receipt(submission), { state: "accepted" }); + assert.deepEqual(await client.receipt(submission), { + schema: "gamecult.eve.command_receipt.v1", receiptId: "receipt-1", commandId: "cmd-1", command: "move", + state: "accepted", ownerRepo: "Provider", authority: "daemon", providerId: "test-provider", surfaceId: "world", + message: "moved", issuedAtUtc: "now", sourceVersion: 4, + }); }); test("refuses unadvertised surfaces", async () => {