Complete generic Eve command wire contract
This commit is contained in:
parent
63a8a1039b
commit
8f658932b5
5 changed files with 115 additions and 25 deletions
|
|
@ -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"],
|
||||
|
|
|
|||
31
src/cultmesh-provider-client.d.ts
vendored
Normal file
31
src/cultmesh-provider-client.d.ts
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export interface EveCultMeshProviderTarget {
|
||||
providerId?: string;
|
||||
advertisementRecordRef: string;
|
||||
peerId?: string;
|
||||
verseId?: string;
|
||||
role?: string;
|
||||
endpoints?: string[];
|
||||
}
|
||||
|
||||
export interface EveCommandSubmission extends Record<string, unknown> {
|
||||
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<void>;
|
||||
providerAdvertisement(): Promise<Record<string, unknown>>;
|
||||
surface(surfaceId?: string): Promise<Record<string, unknown>>;
|
||||
document(request: { schemaId: string; recordRef: string }): Promise<unknown>;
|
||||
submitCommand(request: Record<string, unknown>): Promise<EveCommandSubmission>;
|
||||
receipt(submission: Record<string, unknown>): Promise<unknown>;
|
||||
}
|
||||
|
||||
export declare function normalizeProviderAdvertisement(value: unknown): Record<string, unknown>;
|
||||
export declare function normalizeSurfaceDocument(value: unknown): Record<string, unknown>;
|
||||
export declare function normalizeCommandReceipt(value: unknown): Record<string, unknown>;
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
13
src/index.d.ts
vendored
13
src/index.d.ts
vendored
|
|
@ -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<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 * from "./cultmesh-provider-client.js";
|
||||
|
||||
export interface EveElectronWindowOptions extends BrowserWindowConstructorOptions {
|
||||
browserWindow?: BrowserWindowConstructorOptions;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Reference in a new issue