Discover browser providers through Odin
This commit is contained in:
parent
8eb89e38ec
commit
d563a1ac64
15 changed files with 567 additions and 75 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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" } }
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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_,
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue