Discover browser providers through Odin

This commit is contained in:
Meta Vens 2026-08-18 00:34:44 +02:00
commit d563a1ac64
15 changed files with 567 additions and 75 deletions

View file

@ -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

View file

@ -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<CultMeshBrowserRoute>;
}
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<Omit<CultMeshBrowserOdinRendezvousOptions, "transportVersion" | "socketFactory">> & {
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<CultMeshBrowserRoute> {
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<CultMeshBrowserRoute> {
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<CultMeshBrowserRoute>((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<void> {
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<CultNetMessage> {
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.`);
}

View file

@ -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;

View file

@ -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 }
}
}

View file

@ -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" } }
}
}

View file

@ -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<CultNetSchemaVersion, ValidateFunction>();
@ -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<string, unknown>, ["parentVerseId", "description"]);
}
}
return;
case "cultnet.schema_catalog_request.v0":
stripNullProperties(candidate, ["schemaIds", "kinds"]);
return;

View file

@ -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;

View file

@ -6,5 +6,6 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj" />
<ProjectReference Include="..\..\src\GameCult.Mesh\GameCult.Mesh.csproj" />
</ItemGroup>
</Project>

View file

@ -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<CultMeshVerseCatalogRequestMessage>((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<IServer>()
.Features.Get<IServerAddressesFeature>()!.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")
};
}

View file

@ -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.

View file

@ -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<HTMLElement>("#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;

View file

@ -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_,

View file

@ -65,6 +65,33 @@ namespace GameCult.Mesh
message.ParentVerseId,
message.Description);
}
/// <summary>Creates the canonical filtered Verse catalog response for any CultNet transport adapter.</summary>
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<string>(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()
};
}
}
/// <summary>
@ -93,24 +120,7 @@ namespace GameCult.Mesh
/// </summary>
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<string>(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);
}
/// <inheritdoc />

View file

@ -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:

View file

@ -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).