From d563a1ac645aadeb87cce23b264b64ef8c821ebc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 18 Aug 2026 00:34:44 +0200 Subject: [PATCH] Discover browser providers through Odin --- packages/cultmesh-browser/README.md | 15 +- packages/cultmesh-browser/src/index.ts | 204 ++++++++++++++++-- .../test/cultmesh-browser.test.ts | 62 ++++++ ...cultmesh.verse-catalog-request.schema.json | 14 ++ ...ultmesh.verse-catalog-response.schema.json | 42 ++++ packages/cultnet-ts/src/contracts.ts | 54 ++++- packages/cultnet-ts/test/cultnet.test.ts | 36 ++++ .../EveBrowserNetworkSample.csproj | 1 + samples/eve-browser-network/Program.cs | 48 ++++- samples/eve-browser-network/README.md | 10 +- samples/eve-browser-network/browser/main.ts | 20 +- scripts/verify-eve-browser-network.mjs | 47 +++- src/GameCult.Mesh/CultMeshVerseDiscovery.cs | 46 ++-- .../docs/browser-verse-transport.md | 31 ++- .../03-publish-an-eve-surface.md | 12 +- 15 files changed, 567 insertions(+), 75 deletions(-) create mode 100644 packages/cultnet-ts/contracts/cultmesh.verse-catalog-request.schema.json create mode 100644 packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json diff --git a/packages/cultmesh-browser/README.md b/packages/cultmesh-browser/README.md index 4866196c..acf349e6 100644 --- a/packages/cultmesh-browser/README.md +++ b/packages/cultmesh-browser/README.md @@ -10,13 +10,16 @@ writes. A renderer watches provider-owned records and invokes provider-owned operations. ```ts +const odin = new CultMeshBrowserOdinRendezvous({ + endpoints: ["wss://odin.example/verse"], + runtimeId: "counter-browser.discovery", +}); + const mesh = await CultMeshBrowserClient.connect({ verseId: "sample.counter", providerId: "sample.counter-provider", runtimeId: "counter-browser", - rendezvous: { - resolve: async identity => discoverWebSocketRoute(identity), - }, + rendezvous: odin, }); const counter = await mesh.leaseRawDocument({ @@ -27,8 +30,10 @@ const counter = await mesh.leaseRawDocument({ counter.watch(record => renderCounter(decodeCultNetPayload(record))); ``` -The rendezvous port may call Odin, but application code does not own reconnect -or resubscription. The physical socket endpoint is a replaceable route, not the +`CultMeshBrowserOdinRendezvous` speaks the canonical CultNet Verse-catalog +messages to one or more configured Odin WebSocket endpoints. Application code +supplies stable identity; it does not own endpoint selection, reconnect, or +resubscription. The physical provider socket is a replaceable route, not the provider identity. The browser handshake carries authentication through the host application's diff --git a/packages/cultmesh-browser/src/index.ts b/packages/cultmesh-browser/src/index.ts index 1f259422..717f89a9 100644 --- a/packages/cultmesh-browser/src/index.ts +++ b/packages/cultmesh-browser/src/index.ts @@ -10,6 +10,8 @@ import { type CultNetOperationResponseMessage, type CultNetRawDocumentRecord, type CultNetSnapshotResponseRawMessage, + type CultMeshVerseCatalogRequestMessage, + type CultMeshVerseCatalogResponseMessage, } from "cultnet-ts/contracts"; export interface CultMeshBrowserIdentity { @@ -26,6 +28,16 @@ export interface CultMeshBrowserRendezvous { resolve(identity: CultMeshBrowserIdentity): Promise; } +export interface CultMeshBrowserOdinRendezvousOptions { + endpoints: readonly string[]; + runtimeId: string; + transportVersion?: string; + timeoutMs?: number; + maxFrameBytes?: number; + createId?: () => string; + socketFactory?: (endpoint: string) => CultMeshBrowserSocket; +} + export interface CultMeshBrowserSocket { binaryType: BinaryType; readonly readyState: number; @@ -37,6 +49,107 @@ export interface CultMeshBrowserSocket { close(code?: number, reason?: string): void; } +/** Resolves stable Verse/provider identity through Odin's canonical CultNet Verse catalog. */ +export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous { + #options: Required> & { + transportVersion?: string; + socketFactory: (endpoint: string) => CultMeshBrowserSocket; + }; + + constructor(options: CultMeshBrowserOdinRendezvousOptions) { + requireText(options.runtimeId, "runtimeId"); + const endpoints = [...new Set(options.endpoints.map(value => value.trim()).filter(Boolean))]; + if (endpoints.length === 0) throw new Error("At least one Odin WebSocket endpoint is required."); + for (const endpoint of endpoints) requireWebSocketEndpoint(endpoint, "Odin rendezvous endpoint"); + this.#options = { + ...options, + endpoints, + timeoutMs: options.timeoutMs ?? 10_000, + maxFrameBytes: options.maxFrameBytes ?? 4 * 1024 * 1024, + createId: options.createId ?? (() => crypto.randomUUID()), + socketFactory: options.socketFactory ?? (endpoint => new WebSocket(endpoint)), + }; + if (this.#options.timeoutMs <= 0) throw new Error("Odin rendezvous timeoutMs must be positive."); + if (this.#options.maxFrameBytes <= 0) throw new Error("Odin rendezvous maxFrameBytes must be positive."); + } + + async resolve(identity: CultMeshBrowserIdentity): Promise { + requireText(identity.verseId, "verseId"); + requireText(identity.providerId, "providerId"); + const failures: Error[] = []; + for (const endpoint of this.#options.endpoints) { + try { + return await this.resolveFrom(endpoint, identity); + } catch (error) { + failures.push(error instanceof Error ? error : new Error(String(error))); + } + } + throw new AggregateError( + failures, + `Odin could not resolve Verse '${identity.verseId}' provider '${identity.providerId}'.`, + ); + } + + private resolveFrom(endpoint: string, identity: CultMeshBrowserIdentity): Promise { + const socket = this.#options.socketFactory(endpoint); + socket.binaryType = "arraybuffer"; + const messageId = this.#options.createId(); + const request: CultMeshVerseCatalogRequestMessage = { + schemaVersion: "cultmesh.verse_catalog_request.v0", + messageId, + verseIds: [identity.verseId], + ...(this.#options.transportVersion ? { transportVersion: this.#options.transportVersion } : {}), + }; + return new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: Error, route?: CultMeshBrowserRoute) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.onopen = null; + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + socket.close(1000, error ? "Odin resolution failed" : "Odin resolution complete"); + if (error) reject(error); + else resolve(route!); + }; + const timer = setTimeout( + () => finish(new Error(`Odin resolution timed out at '${endpoint}'.`)), + this.#options.timeoutMs, + ); + socket.onopen = () => { + try { + sendSocketMessage(socket, { + schemaVersion: "cultnet.hello.v0", + runtimeId: this.#options.runtimeId, + runtimeKind: "browser", + supportedMessageVersions: [ + "cultmesh.verse_catalog_request.v0", + "cultmesh.verse_catalog_response.v0", + ], + }, this.#options.maxFrameBytes); + sendSocketMessage(socket, request, this.#options.maxFrameBytes); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }; + socket.onmessage = event => { + void decodeSocketMessage(event, this.#options.maxFrameBytes).then(message => { + if (message.schemaVersion === "cultnet.error.v0") { + finish(new Error(message.error)); + return; + } + if (message.schemaVersion !== "cultmesh.verse_catalog_response.v0" || message.messageId !== messageId) return; + finish(undefined, selectOdinRoute(message, identity)); + }).catch(error => finish(error instanceof Error ? error : new Error(String(error)))); + }; + socket.onerror = () => finish(new Error(`Odin WebSocket could not open '${endpoint}'.`)); + socket.onclose = () => finish(new Error(`Odin WebSocket closed before resolving '${identity.verseId}'.`)); + }); + } +} + export interface CultMeshBrowserClientOptions extends CultMeshBrowserIdentity { runtimeId: string; rendezvous: CultMeshBrowserRendezvous; @@ -418,22 +531,17 @@ export class CultMeshBrowserClient implements AsyncDisposable { private async receive(event: MessageEvent, generation: number): Promise { if (this.#disposed || generation !== this.#socketGeneration) return; - let payload: Uint8Array; - if (event.data instanceof ArrayBuffer) { - payload = new Uint8Array(event.data); - } else if (ArrayBuffer.isView(event.data)) { - payload = new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength); - } else if (event.data instanceof Blob) { - payload = new Uint8Array(await event.data.arrayBuffer()); - } else { - this.#socket?.close(1003, "CultNet requires binary WebSocket frames"); + let message: CultNetMessage; + try { + message = await decodeSocketMessage(event, this.#options.maxFrameBytes); + } catch (error) { + const oversized = error instanceof Error && error.message.includes("exceeds"); + this.#socket?.close( + oversized ? 1009 : 1003, + oversized ? "CultNet frame exceeds configured maximum" : "CultNet requires binary WebSocket frames", + ); return; } - if (payload.byteLength > this.#options.maxFrameBytes) { - this.#socket?.close(1009, "CultNet frame exceeds configured maximum"); - return; - } - const message = parseCultNetMessage(decode(payload)); switch (message.schemaVersion) { case "cultnet.snapshot_response_raw.v0": this.applySnapshot(message); @@ -540,6 +648,74 @@ function base64ToBytes(value: string): Uint8Array { return Uint8Array.from(binary, character => character.charCodeAt(0)); } +function requireWebSocketEndpoint(value: string, field: string): URL { + let endpoint: URL; + try { + endpoint = new URL(value); + } catch { + throw new Error(`${field} '${value}' is not a valid URL.`); + } + if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") { + throw new Error(`${field} must use ws:// or wss://, got '${value}'.`); + } + return endpoint; +} + +function sendSocketMessage(socket: CultMeshBrowserSocket, message: CultNetMessage, maxFrameBytes: number): void { + const payload = encode(encodeCultNetMessageForWire(message)); + if (payload.byteLength > maxFrameBytes) { + throw new Error(`CultNet message exceeds the ${maxFrameBytes}-byte limit.`); + } + socket.send(payload); +} + +async function decodeSocketMessage(event: MessageEvent, maxFrameBytes: number): Promise { + let payload: Uint8Array; + if (event.data instanceof ArrayBuffer) { + payload = new Uint8Array(event.data); + } else if (ArrayBuffer.isView(event.data)) { + payload = new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength); + } else if (event.data instanceof Blob) { + payload = new Uint8Array(await event.data.arrayBuffer()); + } else { + throw new Error("CultNet requires binary WebSocket frames."); + } + if (payload.byteLength > maxFrameBytes) { + throw new Error(`CultNet frame exceeds the ${maxFrameBytes}-byte limit.`); + } + return parseCultNetMessage(decode(payload)); +} + +function selectOdinRoute( + response: CultMeshVerseCatalogResponseMessage, + identity: CultMeshBrowserIdentity, +): CultMeshBrowserRoute { + const matchingVerses = response.verses.filter(verse => + verse.verseId === identity.verseId && verse.authorityRuntimeIds.includes(identity.providerId)); + if (matchingVerses.length !== 1) { + throw new Error( + `Odin returned ${matchingVerses.length} routes for Verse '${identity.verseId}' provider '${identity.providerId}'.`, + ); + } + const verse = matchingVerses[0]; + const endpoints = verse.discoveryEndpoints.filter(endpoint => { + try { + requireWebSocketEndpoint(endpoint, "Odin provider route"); + return true; + } catch { + return false; + } + }); + if (endpoints.length === 0) { + throw new Error(`Odin advertised no browser-compatible route for '${identity.providerId}'.`); + } + return { + ...identity, + endpoint: endpoints[0], + generation: [verse.compatibility.transportVersion, verse.compatibility.rulesHash, endpoints[0]].join(":"), + }; +} + function requireText(value: string, field: string): void { if (!value || value.trim().length === 0) throw new Error(`CultMesh browser ${field} is required.`); } diff --git a/packages/cultmesh-browser/test/cultmesh-browser.test.ts b/packages/cultmesh-browser/test/cultmesh-browser.test.ts index 8c3ae1b5..3836790d 100644 --- a/packages/cultmesh-browser/test/cultmesh-browser.test.ts +++ b/packages/cultmesh-browser/test/cultmesh-browser.test.ts @@ -13,12 +13,40 @@ import { import { CultMeshBrowserClient, + CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload, type CultMeshBrowserRoute, type CultMeshBrowserSocket, } from "../src/index.js"; +test("Odin rendezvous resolves stable provider identity and observes route rotation", async () => { + const odin = new FakeOdin("ws://127.0.0.1:4050/mesh"); + let id = 0; + const rendezvous = new CultMeshBrowserOdinRendezvous({ + endpoints: ["ws://127.0.0.1:4040/odin"], + runtimeId: "browser-odin-test", + createId: () => `odin-${++id}`, + socketFactory: () => odin.open(), + }); + + assert.equal((await rendezvous.resolve({ + verseId: "sample.counter", + providerId: "sample.counter-provider", + })).endpoint, "ws://127.0.0.1:4050/mesh"); + + odin.providerEndpoint = "ws://127.0.0.1:4060/mesh"; + assert.equal((await rendezvous.resolve({ + verseId: "sample.counter", + providerId: "sample.counter-provider", + })).endpoint, "ws://127.0.0.1:4060/mesh"); + + await assert.rejects( + rendezvous.resolve({ verseId: "sample.counter", providerId: "other-provider" }), + /could not resolve/, + ); +}); + test("browser client leases provider state, invokes an operation, and follows route replacement", async () => { const first = new FakeProvider("ws://127.0.0.1:4101/mesh", 4); const second = new FakeProvider("ws://127.0.0.1:4102/mesh", 9); @@ -252,6 +280,40 @@ class FakeProvider { } } +class FakeOdin { + providerEndpoint: string; + + constructor(providerEndpoint: string) { + this.providerEndpoint = providerEndpoint; + } + + open(): FakeSocket { + const socket = new FakeSocket(wire => { + const message = parseCultNetMessage(decode(wire)); + if (message.schemaVersion !== "cultmesh.verse_catalog_request.v0") return; + socket.deliver({ + schemaVersion: "cultmesh.verse_catalog_response.v0", + messageId: message.messageId, + verses: [{ + verseId: "sample.counter", + displayName: "Counter", + authorityModel: "ServerAuthoritative", + compatibility: { + transportVersion: "cultmesh.v1", + rulesHash: "counter-v1", + compatibleVerseIds: [], + requiredPluginIds: [], + optionalPluginIds: [], + }, + discoveryEndpoints: [this.providerEndpoint], + authorityRuntimeIds: ["sample.counter-provider"], + }], + }); + }, () => undefined); + return socket; + } +} + class FakeSocket implements CultMeshBrowserSocket { binaryType: BinaryType = "arraybuffer"; readyState = 0; diff --git a/packages/cultnet-ts/contracts/cultmesh.verse-catalog-request.schema.json b/packages/cultnet-ts/contracts/cultmesh.verse-catalog-request.schema.json new file mode 100644 index 00000000..e99b9dea --- /dev/null +++ b/packages/cultnet-ts/contracts/cultmesh.verse-catalog-request.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.verse-catalog-request.schema.json", + "title": "CultMesh Verse Catalog Request", + "type": "object", + "required": ["schemaVersion", "messageId"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": "cultmesh.verse_catalog_request.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "verseIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "transportVersion": { "type": "string", "minLength": 1 } + } +} diff --git a/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json b/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json new file mode 100644 index 00000000..21367d09 --- /dev/null +++ b/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json", + "title": "CultMesh Verse Catalog Response", + "type": "object", + "required": ["schemaVersion", "messageId", "verses"], + "additionalProperties": false, + "$defs": { + "compatibility": { + "type": "object", + "required": ["transportVersion", "rulesHash", "compatibleVerseIds", "requiredPluginIds", "optionalPluginIds"], + "additionalProperties": false, + "properties": { + "transportVersion": { "type": "string", "minLength": 1 }, + "rulesHash": { "type": "string", "minLength": 1 }, + "compatibleVerseIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "requiredPluginIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "optionalPluginIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true } + } + }, + "verse": { + "type": "object", + "required": ["verseId", "displayName", "authorityModel", "compatibility", "discoveryEndpoints", "authorityRuntimeIds"], + "additionalProperties": false, + "properties": { + "verseId": { "type": "string", "minLength": 1 }, + "displayName": { "type": "string", "minLength": 1 }, + "authorityModel": { "type": "string", "minLength": 1 }, + "compatibility": { "$ref": "#/$defs/compatibility" }, + "discoveryEndpoints": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "authorityRuntimeIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, + "parentVerseId": { "type": "string", "minLength": 1 }, + "description": { "type": "string" } + } + } + }, + "properties": { + "schemaVersion": { "const": "cultmesh.verse_catalog_response.v0" }, + "messageId": { "type": "string", "minLength": 1 }, + "verses": { "type": "array", "items": { "$ref": "#/$defs/verse" } } + } +} diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index c97a0d8d..ffdbcf6e 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -28,6 +28,8 @@ import shardCatalogRequestSchema from "../contracts/cultnet.shard-catalog-reques import shardCatalogResponseSchema from "../contracts/cultnet.shard-catalog-response.schema.json"; import operationRequestSchema from "../contracts/cultnet.operation-request.schema.json"; import operationResponseSchema from "../contracts/cultnet.operation-response.schema.json"; +import verseCatalogRequestSchema from "../contracts/cultmesh.verse-catalog-request.schema.json"; +import verseCatalogResponseSchema from "../contracts/cultmesh.verse-catalog-response.schema.json"; import ghostlightAgentStateSchema from "../contracts/ghostlight.agent-state.schema.json"; export type CultNetWireContract = "cultnet.schema.v0" | "gamecult.networking.v0"; @@ -55,7 +57,9 @@ export type CultNetSchemaVersion = | "cultnet.shard_catalog_request.v0" | "cultnet.shard_catalog_response.v0" | "cultnet.operation_request.v0" - | "cultnet.operation_response.v0"; + | "cultnet.operation_response.v0" + | "cultmesh.verse_catalog_request.v0" + | "cultmesh.verse_catalog_response.v0"; export type CultNetSchemaKind = "wire_message" | "document_payload" | "shared_contract"; export type CultNetRawPayloadEncoding = "messagepack"; @@ -328,6 +332,38 @@ export interface CultNetOperationResponseMessage { sourceRuntimeId?: string; } +export interface CultMeshVerseCompatibilityMessage { + transportVersion: string; + rulesHash: string; + compatibleVerseIds: string[]; + requiredPluginIds: string[]; + optionalPluginIds: string[]; +} + +export interface CultMeshVerseDescriptorMessage { + verseId: string; + displayName: string; + authorityModel: string; + compatibility: CultMeshVerseCompatibilityMessage; + discoveryEndpoints: string[]; + authorityRuntimeIds: string[]; + parentVerseId?: string; + description?: string; +} + +export interface CultMeshVerseCatalogRequestMessage { + schemaVersion: "cultmesh.verse_catalog_request.v0"; + messageId: string; + verseIds?: string[]; + transportVersion?: string; +} + +export interface CultMeshVerseCatalogResponseMessage { + schemaVersion: "cultmesh.verse_catalog_response.v0"; + messageId: string; + verses: CultMeshVerseDescriptorMessage[]; +} + export interface GhostlightPressure { pressure_id: string; label: string; @@ -577,7 +613,9 @@ export type CultNetMessage = | CultNetShardCatalogRequestMessage | CultNetShardCatalogResponseMessage | CultNetOperationRequestMessage - | CultNetOperationResponseMessage; + | CultNetOperationResponseMessage + | CultMeshVerseCatalogRequestMessage + | CultMeshVerseCatalogResponseMessage; const ajv = new Ajv2020({ allErrors: true, @@ -608,6 +646,8 @@ const CULTNET_MESSAGE_SCHEMAS = [ shardCatalogResponseSchema, operationRequestSchema, operationResponseSchema, + verseCatalogRequestSchema, + verseCatalogResponseSchema, ] as const; const cultNetValidators = new Map(); @@ -1142,6 +1182,16 @@ function normalizeCultNetOptionalNulls( case "cultnet.operation_response.v0": stripNullProperties(candidate, ["payloadEncoding", "diagnostics", "sourceRuntimeId"]); return; + case "cultmesh.verse_catalog_request.v0": + stripNullProperties(candidate, ["verseIds", "transportVersion"]); + return; + case "cultmesh.verse_catalog_response.v0": + for (const verse of Array.isArray(candidate.verses) ? candidate.verses : []) { + if (verse && typeof verse === "object") { + stripNullProperties(verse as Record, ["parentVerseId", "description"]); + } + } + return; case "cultnet.schema_catalog_request.v0": stripNullProperties(candidate, ["schemaIds", "kinds"]); return; diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index c5ca69da..945e0a3b 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -58,6 +58,42 @@ import { type InteropNote, } from "./interop/cultnet-interop-shared"; +test("CultNet validates the canonical CultMesh Verse catalog messages", () => { + const request = parseCultNetMessage({ + schemaVersion: "cultmesh.verse_catalog_request.v0", + messageId: "catalog-1", + verseIds: ["sample.counter"], + transportVersion: "cultmesh.v1", + }); + assert.equal(request.schemaVersion, "cultmesh.verse_catalog_request.v0"); + + const response = parseCultNetMessage({ + schemaVersion: "cultmesh.verse_catalog_response.v0", + messageId: "catalog-1", + verses: [{ + verseId: "sample.counter", + displayName: "Counter", + authorityModel: "OperatorCluster", + compatibility: { + transportVersion: "cultmesh.v1", + rulesHash: "counter-v1", + compatibleVerseIds: [], + requiredPluginIds: [], + optionalPluginIds: [], + }, + discoveryEndpoints: ["wss://provider.example/cultmesh"], + authorityRuntimeIds: ["sample.counter-provider"], + }], + }); + assert.equal(response.schemaVersion, "cultmesh.verse_catalog_response.v0"); + assert.equal(response.verses[0].authorityRuntimeIds[0], "sample.counter-provider"); + + assert.throws(() => parseCultNetMessage({ + ...response, + verses: [{ ...response.verses[0], compatibility: { ...response.verses[0].compatibility, rulesHash: "" } }], + }), /rulesHash/); +}); + class FakeRudpReconnectTransport extends EventEmitter { public connectCalls = 0; public closeCalls = 0; diff --git a/samples/eve-browser-network/EveBrowserNetworkSample.csproj b/samples/eve-browser-network/EveBrowserNetworkSample.csproj index 4fe900dd..a0a0bf32 100644 --- a/samples/eve-browser-network/EveBrowserNetworkSample.csproj +++ b/samples/eve-browser-network/EveBrowserNetworkSample.csproj @@ -6,5 +6,6 @@ + diff --git a/samples/eve-browser-network/Program.cs b/samples/eve-browser-network/Program.cs index 962baee3..9686b36c 100644 --- a/samples/eve-browser-network/Program.cs +++ b/samples/eve-browser-network/Program.cs @@ -2,6 +2,7 @@ using System.Net; using System.Text.Json; using GameCult.Caching; using GameCult.Caching.MessagePack; +using GameCult.Mesh; using GameCult.Networking; using GameCult.Networking.WebSockets; using MessagePack; @@ -22,7 +23,50 @@ if (arguments.Mode == "headless") await RunHeadlessAsync(arguments); return; } -throw new InvalidOperationException("Use 'provider' or 'headless'."); +if (arguments.Mode == "odin") +{ + await RunOdinAsync(arguments); + return; +} +throw new InvalidOperationException("Use 'provider', 'headless', or 'odin'."); + +static async Task RunOdinAsync(Args arguments) +{ + if (string.IsNullOrWhiteSpace(arguments.ProviderEndpoint)) + throw new ArgumentException("The Odin fixture requires --provider-endpoint."); + using var catalog = new CultMeshVerseCatalog(); + catalog.Upsert(new CultMeshVerseDescriptor( + "sample.counter", + "CultMesh browser counter", + CultMeshVerseAuthorityModel.OperatorCluster, + new CultMeshVerseCompatibility("cultmesh.v1", "sample-counter-v1"), + discoveryEndpoints: [arguments.ProviderEndpoint], + authorityRuntimeIds: ["sample.counter-provider"], + description: "Local executable Odin fixture for the browser/network onboarding witness.")); + + await using var schemaServer = new CultNetWebSocketSchemaServer(); + schemaServer.OnCultNet((request, peer) => + { + peer.SendCultNet(CultMeshVerseMessages.CreateCatalogResponse(catalog, request)); + return Task.CompletedTask; + }); + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, arguments.Port)); + var app = builder.Build(); + app.UseWebSockets(); + app.MapCultNetWebSocket("/odin", schemaServer, new CultNetWebSocketEndpointOptions + { + AuthorizeAsync = context => ValueTask.FromResult( + context.Request.Cookies.TryGetValue("cultnet_session", out var token) && + string.Equals(token, arguments.Token, StringComparison.Ordinal)) + }); + await app.StartAsync(); + var address = app.Services.GetRequiredService() + .Features.Get()!.Addresses.Single(); + Console.WriteLine("ODIN_READY " + address.Replace("http://", "ws://", StringComparison.Ordinal) + "/odin"); + await app.WaitForShutdownAsync(); + await app.DisposeAsync(); +} static async Task RunProviderAsync(Args arguments) { @@ -146,6 +190,7 @@ sealed class Args public int Port { get; private init; } public string StatePath { get; private init; } = "sample-counter.cc"; public string Endpoint { get; private init; } = ""; + public string ProviderEndpoint { get; private init; } = ""; public string Token { get; private init; } = "sample-session"; public static Args Parse(string[] values) @@ -160,6 +205,7 @@ sealed class Args Port = named.TryGetValue("--port", out var port) ? int.Parse(port) : 0, StatePath = named.GetValueOrDefault("--state", "sample-counter.cc"), Endpoint = named.GetValueOrDefault("--endpoint", ""), + ProviderEndpoint = named.GetValueOrDefault("--provider-endpoint", ""), Token = named.GetValueOrDefault("--token", "sample-session") }; } diff --git a/samples/eve-browser-network/README.md b/samples/eve-browser-network/README.md index 2d6c7034..33de2dbe 100644 --- a/samples/eve-browser-network/README.md +++ b/samples/eve-browser-network/README.md @@ -7,6 +7,8 @@ This is the real-process continuation of `samples/eve-two-runtime`: - one real Chromium page leases both documents through `cultmesh-browser` and lowers the provider-owned Eve surface; - one independent C# headless client observes the same typed counter; +- one separate local Odin fixture answers the canonical CultMesh Verse catalog + over binary CultNet WebSocket messages; - the browser and C# clients speak the same binary CultNet schema-v0 WebSocket messages through the authenticated host adapter. @@ -17,6 +19,8 @@ node scripts/verify-eve-browser-network.mjs --eve-root ..\Eve ``` The verifier clicks the Eve button twice with one idempotency key, requires one -state transition, restarts the provider, and requires the browser to rehydrate -the persisted count. Odin route discovery is represented by the browser -client's rendezvous port; a live Odin process remains the next discovery gate. +state transition, restarts the provider on a different physical port, updates +the local Odin fixture, and requires the still-open browser to rediscover the +route, resubscribe, execute a second command, and preserve the durable count. +The fixture speaks the production Verse-catalog contract; a deployment smoke +against the full Odin daemon remains a separate infrastructure gate. diff --git a/samples/eve-browser-network/browser/main.ts b/samples/eve-browser-network/browser/main.ts index 7bcb6887..9c7f1cd1 100644 --- a/samples/eve-browser-network/browser/main.ts +++ b/samples/eve-browser-network/browser/main.ts @@ -2,6 +2,7 @@ import { parseEveCommandReceipt, parseEveSurfaceDocument } from "@gamecult/eve-c import { renderEveSurface } from "@gamecult/eve-browser-lowering"; import { CultMeshBrowserClient, + CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload, } from "cultmesh-browser"; @@ -11,14 +12,16 @@ declare global { __sampleReady?: boolean; __sampleCount?: number; __sampleReceipt?: { receiptId: string; count: number }; + __sampleCommandId?: string; + __sampleConnectionStates?: string[]; __sampleError?: string; } } const params = new URLSearchParams(location.search); -const endpoint = params.get("endpoint"); +const odinEndpoint = params.get("odin"); const token = params.get("token") || "sample-session"; -if (!endpoint) throw new Error("The sample requires an endpoint query parameter."); +if (!odinEndpoint) throw new Error("The sample requires an Odin endpoint query parameter."); document.cookie = `cultnet_session=${encodeURIComponent(token)}; Path=/; SameSite=Strict`; const host = document.querySelector("#surface")!; @@ -29,10 +32,15 @@ try { verseId: "sample.counter", providerId: "sample.counter-provider", runtimeId: "sample.chromium", - rendezvous: { - resolve: async identity => ({ ...identity, endpoint }), - }, + rendezvous: new CultMeshBrowserOdinRendezvous({ + endpoints: [odinEndpoint], + runtimeId: "sample.chromium.discovery", + transportVersion: "cultmesh.v1", + }), }); + window.__sampleConnectionStates = []; + mesh.watchState(state => window.__sampleConnectionStates!.push(state)); + window.__sampleCommandId = "browser-click-1"; const counter = await mesh.leaseRawDocument({ schemaId: "sample.counter_state.v1", recordKey: "counter:main", @@ -53,7 +61,7 @@ try { operation: intent.command, payloadSchema: "sample.increment.v1", payload: { amount: 1 }, - idempotencyKey: "browser-click-1", + idempotencyKey: window.__sampleCommandId, }); const receipt = parseEveCommandReceipt(decodeCultNetOperationPayload(response)) as { receiptId: string; diff --git a/scripts/verify-eve-browser-network.mjs b/scripts/verify-eve-browser-network.mjs index d4e9fa2c..9e047930 100644 --- a/scripts/verify-eve-browser-network.mjs +++ b/scripts/verify-eve-browser-network.mjs @@ -19,6 +19,7 @@ const statePath = join(workRoot, "counter.cc"); const bundlePath = join(workRoot, "bundle.js"); const token = "sample-session"; let provider; +let odin; let headless; let httpServer; let browser; @@ -46,9 +47,13 @@ try { await run("dotnet", ["build", join(sampleRoot, "EveBrowserNetworkSample.csproj"), "--verbosity", "quiet"]); const providerPort = await freePort(); + const replacementProviderPort = await freePort(); + const odinPort = await freePort(); const httpPort = await freePort(); provider = await startProvider(providerPort); const endpoint = await provider.waitFor("PROVIDER_READY "); + odin = await startOdin(odinPort, endpoint); + const odinEndpoint = await odin.waitFor("ODIN_READY "); headless = startDotnet(["headless", "--endpoint", endpoint, "--token", token]); await headless.waitFor("HEADLESS_READY "); httpServer = await serve(httpPort, sampleRoot, bundlePath); @@ -56,7 +61,7 @@ try { const executablePath = process.env.CHROME_PATH || "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"; browser = await chromium.launch({ executablePath, headless: true }); let page = await browser.newPage(); - const url = `http://127.0.0.1:${httpPort}/?endpoint=${encodeURIComponent(endpoint)}&token=${token}`; + const url = `http://127.0.0.1:${httpPort}/?odin=${encodeURIComponent(odinEndpoint)}&token=${token}`; await page.goto(url); await page.waitForFunction(() => window.__sampleReady || window.__sampleError); assert.equal(await page.evaluate(() => window.__sampleError), undefined); @@ -69,29 +74,40 @@ try { await page.waitForFunction(receipt => window.__sampleReceipt?.receiptId === receipt, firstReceipt); assert.equal(await page.evaluate(() => window.__sampleCount), 1, "duplicate idempotency key changed canonical state"); - await page.close(); await stop(provider.process); - provider = await startProvider(providerPort); + await stop(odin.process); + provider = await startProvider(replacementProviderPort); const restartedEndpoint = await provider.waitFor("PROVIDER_READY "); - assert.equal(restartedEndpoint, endpoint); - page = await browser.newPage(); - await page.goto(url); - await page.waitForFunction(() => window.__sampleReady || window.__sampleError); - assert.equal(await page.evaluate(() => window.__sampleError), undefined); - assert.equal(await page.evaluate(() => window.__sampleCount), 1, "provider restart lost durable counter state"); + assert.notEqual(restartedEndpoint, endpoint); + odin = await startOdin(odinPort, restartedEndpoint); + assert.equal(await odin.waitFor("ODIN_READY "), odinEndpoint); + headless = startDotnet(["headless", "--endpoint", restartedEndpoint, "--token", token]); + await headless.waitFor("HEADLESS_READY "); + await page.evaluate(() => { window.__sampleCommandId = "browser-click-2"; }); + await page.locator("button").click(); + await page.waitForFunction(() => window.__sampleReceipt?.count === 2 && window.__sampleCount === 2); + assert.equal(JSON.parse(await headless.waitFor("HEADLESS_UPDATE ")).count, 2); + const connectionStates = await page.evaluate(() => window.__sampleConnectionStates); + assert.equal(connectionStates[0], "connected"); + assert.equal(connectionStates.at(-1), "connected"); + assert.ok(connectionStates.includes("reconnecting")); + assert.ok(!connectionStates.includes("disposed")); console.log(JSON.stringify({ providerEndpoint: endpoint, + replacementProviderEndpoint: restartedEndpoint, + odinEndpoint, browserRuntime: "chromium", headlessRuntime: "csharp", receiptId: firstReceipt, - count: 1, - restartCount: 1, + count: 2, + routeRotationCount: 1, })); } finally { if (browser) await browser.close().catch(() => undefined); if (headless) await stop(headless.process); if (provider) await stop(provider.process); + if (odin) await stop(odin.process); if (httpServer) await new Promise(resolve => httpServer.close(resolve)); await rm(workRoot, { recursive: true, force: true }); } @@ -100,6 +116,15 @@ async function startProvider(port) { return startDotnet(["provider", "--port", String(port), "--state", statePath, "--token", token]); } +async function startOdin(port, providerEndpoint) { + return startDotnet([ + "odin", + "--port", String(port), + "--provider-endpoint", providerEndpoint, + "--token", token, + ]); +} + function startDotnet(arguments_) { const process = spawn("dotnet", [ "run", "--no-build", "--project", join(sampleRoot, "EveBrowserNetworkSample.csproj"), "--", ...arguments_, diff --git a/src/GameCult.Mesh/CultMeshVerseDiscovery.cs b/src/GameCult.Mesh/CultMeshVerseDiscovery.cs index 69cc9e12..ca3655e9 100644 --- a/src/GameCult.Mesh/CultMeshVerseDiscovery.cs +++ b/src/GameCult.Mesh/CultMeshVerseDiscovery.cs @@ -65,6 +65,33 @@ namespace GameCult.Mesh message.ParentVerseId, message.Description); } + + /// Creates the canonical filtered Verse catalog response for any CultNet transport adapter. + public static CultMeshVerseCatalogResponseMessage CreateCatalogResponse( + CultMeshVerseCatalog catalog, + CultMeshVerseCatalogRequestMessage request) + { + if (catalog == null) throw new ArgumentNullException(nameof(catalog)); + if (request == null) throw new ArgumentNullException(nameof(request)); + var verseIds = request.VerseIds == null || request.VerseIds.Length == 0 + ? null + : new HashSet(request.VerseIds, StringComparer.Ordinal); + return new CultMeshVerseCatalogResponseMessage + { + MessageId = string.IsNullOrWhiteSpace(request.MessageId) + ? Guid.NewGuid().ToString("N") + : request.MessageId, + Verses = catalog.Verses + .Where(verse => verseIds == null || verseIds.Contains(verse.VerseId)) + .Where(verse => string.IsNullOrWhiteSpace(request.TransportVersion) || + string.Equals( + verse.Compatibility.TransportVersion, + request.TransportVersion, + StringComparison.Ordinal)) + .Select(verse => verse.ToMessage()) + .ToArray() + }; + } } /// @@ -93,24 +120,7 @@ namespace GameCult.Mesh /// public CultMeshVerseCatalogResponseMessage CreateResponse(CultMeshVerseCatalogRequestMessage request) { - if (request == null) throw new ArgumentNullException(nameof(request)); - var verseIds = request.VerseIds == null || request.VerseIds.Length == 0 - ? null - : new HashSet(request.VerseIds, StringComparer.Ordinal); - var verses = _catalog.Verses - .Where(verse => verseIds == null || verseIds.Contains(verse.VerseId)) - .Where(verse => string.IsNullOrWhiteSpace(request.TransportVersion) || - string.Equals(verse.Compatibility.TransportVersion, request.TransportVersion, StringComparison.Ordinal)) - .Select(verse => verse.ToMessage()) - .ToArray(); - - return new CultMeshVerseCatalogResponseMessage - { - MessageId = string.IsNullOrWhiteSpace(request.MessageId) - ? Guid.NewGuid().ToString("N") - : request.MessageId, - Verses = verses - }; + return CultMeshVerseMessages.CreateCatalogResponse(_catalog, request); } /// diff --git a/src/GameCult.Mesh/docs/browser-verse-transport.md b/src/GameCult.Mesh/docs/browser-verse-transport.md index 81934139..c6748b28 100644 --- a/src/GameCult.Mesh/docs/browser-verse-transport.md +++ b/src/GameCult.Mesh/docs/browser-verse-transport.md @@ -7,9 +7,11 @@ Date: 2026-08-17 This is the implementation map for the browser Verse boundary. `samples/eve-two-runtime` remains the clean package-consumer/jsdom checkpoint. `samples/eve-browser-network` runs a durable C# provider, a real Chromium Eve -lowerer, and an independent C# headless observer over the same authenticated -binary CultNet WebSocket route. It proves one canonical command effect, -idempotent retry, shared observation, and provider restart. +lowerer, an independent C# headless observer, and a separate local Odin fixture +over authenticated binary CultNet WebSocket routes. It proves one canonical +command effect, idempotent retry, shared observation, provider restart on a new +physical route, browser rediscovery, retained lease resubscription, and a second +canonical command after reconnection. The current substrate has a browser client boundary and local live proof, but not a complete public Verse proof: @@ -23,14 +25,18 @@ not a complete public Verse proof: `cultmesh-browser` consumes those messages as explicit document leases; - `cultmesh-browser` owns stable identity, route refresh, resubscription, and typed operation correlation without exposing direct document writes; +- `CultMeshBrowserOdinRendezvous` resolves stable Verse/provider identity with + `cultmesh.verse_catalog_request.v0` and + `cultmesh.verse_catalog_response.v0`; application callbacks no longer need to + smuggle provider URLs into the browser client; - Kotlin has a channel-aware WebSocket transport adapter, TypeScript has the browser client, and `GameCult.Networking.WebSockets` provides the bounded C# schema server host adapter; - Hermodr can lower Odin state into a browser, but it is an infrastructure runtime, not the clean-consumer game API; -- the sample rendezvous port currently returns one configured local route; - discovery from a live Odin and automatic route rotation are not yet in the - executable cross-runtime gate. +- the sample's separate local Odin fixture serves the production Verse-catalog + contract and is restarted with a changed provider route; the same Chromium + client automatically rediscovers that route. - the sample validates its surface against Eve's canonical TypeScript contract, but its provider still carries a minimal local C# DTO mirror. The released-artifact gate requires the renderer-neutral C# Eve surface contract @@ -152,6 +158,8 @@ implementation must support: `cultnet.database_change_raw.v0`; - `cultnet.operation_request.v0` and `cultnet.operation_response.v0`; +- `cultmesh.verse_catalog_request.v0` and + `cultmesh.verse_catalog_response.v0` on the Odin route; - `cultnet.error.v0`. The WebSocket frame is a transport body, not a second message schema. Payload @@ -163,16 +171,19 @@ identity, authority, and idempotency validation happen before provider code. Current executable coverage from `scripts/verify-eve-browser-network.mjs`: - authenticated binary C# WebSocket provider; +- separate local Odin fixture using the canonical Verse-catalog messages; - real Chromium and independent C# headless consumers; - shared surface and counter document leases; - Eve button to typed operation to provider receipt to shared state; - duplicate idempotency key produces one effect; -- provider restart rehydrates the `.cc` state; +- provider restart on a different physical port rehydrates the `.cc` state; +- the retained browser lease rediscovers through Odin, resubscribes, and + commits a second provider-authored receipt; - generated browser bundle contains no `node:` builtin import. -The full public Verse gate still requires live Odin discovery, physical route -rotation during retained leases, released-artifact-only .NET consumption, and -Windows/Linux CI. +The full public Verse gate still requires the deployed Odin daemon rather than +the local contract fixture, retained C# lease reconnection through the same +identity path, released-artifact-only .NET consumption, and Windows/Linux CI. From an empty temporary consumer using packed/released artifacts only: diff --git a/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md b/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md index e38aa8c3..65a2895b 100644 --- a/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md +++ b/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md @@ -68,10 +68,12 @@ cross-language command execution. Do not cite it as evidence for those layers. The next executable checkpoint is [`samples/eve-browser-network`](../../../../samples/eve-browser-network/README.md). -It runs a durable C# provider, a real Chromium Eve lowerer, and an independent -C# headless observer over the same authenticated binary CultNet WebSocket lane. -It proves command idempotency and provider restart. Live Odin discovery and -route rotation remain separate gates. Their owner map and full acceptance -contract are recorded in +It runs a durable C# provider, a separate local Odin fixture, a real Chromium +Eve lowerer, and an independent C# headless observer over authenticated binary +CultNet WebSocket lanes. The still-open browser resolves stable identity through +the canonical Verse catalog, survives a provider restart on a different +physical route, resubscribes, and commits another canonical receipt. A smoke +against the deployed Odin daemon and retained C# lease reconnection remain +separate infrastructure gates. Their owner map and full acceptance contract are recorded in [`browser-verse-transport.md`](../browser-verse-transport.md).