Add real browser CultMesh transport proof

This commit is contained in:
Meta Vens 2026-08-18 00:05:57 +02:00
commit 8eb89e38ec
29 changed files with 2936 additions and 47 deletions

View file

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,319 @@
using System.Net;
using System.Text.Json;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using GameCult.Networking;
using GameCult.Networking.WebSockets;
using MessagePack;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
const string counterKey = "counter:main";
const string surfaceKey = "sample.counter";
var arguments = Args.Parse(args);
if (arguments.Mode == "provider")
{
await RunProviderAsync(arguments);
return;
}
if (arguments.Mode == "headless")
{
await RunHeadlessAsync(arguments);
return;
}
throw new InvalidOperationException("Use 'provider' or 'headless'.");
static async Task RunProviderAsync(Args arguments)
{
var registry = CultDocumentRegistry.ForTypes([typeof(CounterState), typeof(CounterSurfaceDocument)]);
using var cache = await CultCacheMessagePack.OpenAsync(arguments.StatePath, new CultCacheOpenOptions
{
Registry = registry,
FlushOnDispose = true,
StoreFlushOnDispose = true
});
var documents = new CultNetDocumentRegistry(registry)
.Register(CultNetDocumentBinding.ForDocument<CounterState>(registry, "sample.counter_state.v1"))
.Register(CultNetDocumentBinding.ForDocument<CounterSurfaceDocument>(registry, "gamecult.eve.surface.v1"));
using var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
{
RuntimeId = "sample.counter-provider",
DocumentRegistry = documents
});
if (cache.Get<CounterState>(new CultRecordKey(counterKey)) == null)
await database.PutAsync(new CultRecordKey(counterKey), CounterState.Initial(counterKey));
if (cache.Get<CounterSurfaceDocument>(new CultRecordKey(surfaceKey)) == null)
await database.PutAsync(new CultRecordKey(surfaceKey), CounterSurfaceDocument.Create());
await cache.FlushAsync();
await using var schemaServer = new CultNetWebSocketSchemaServer();
using var subscriptions = new CultNetDatabaseSubscriptionServer(schemaServer, database);
var operationGate = new SemaphoreSlim(1, 1);
schemaServer.OnCultNet<CultNetOperationRequestMessage>(async (request, peer) =>
{
if (request.ServiceId != "sample.counter" || request.Operation != "sample.counter.increment")
{
peer.SendCultNet(new CultNetErrorMessage { Error = "Unsupported sample operation." });
return;
}
await operationGate.WaitAsync();
try
{
var current = cache.Get<CounterState>(new CultRecordKey(counterKey))
?? throw new InvalidOperationException("Counter state is unavailable.");
if (!current.Receipts.TryGetValue(request.MessageId, out var receipt))
{
var input = MessagePackSerializer.Deserialize<IncrementRequest>(
Convert.FromBase64String(request.Payload));
receipt = IncrementReceipt.Accepted(request.MessageId, current.Count + input.Amount);
var receipts = new Dictionary<string, IncrementReceipt>(current.Receipts, StringComparer.Ordinal)
{
[request.MessageId] = receipt
};
await database.PutAsync(new CultRecordKey(counterKey), new CounterState
{
CounterId = counterKey,
Count = receipt.Count,
Receipts = receipts
});
await cache.FlushAsync();
}
peer.SendCultNet(new CultNetOperationResponseMessage
{
MessageId = request.MessageId,
ServiceId = request.ServiceId,
Operation = request.Operation,
Status = "accepted",
PayloadSchema = "gamecult.eve.command_receipt.v1",
Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(receipt)),
SourceRuntimeId = "sample.counter-provider"
});
}
finally
{
operationGate.Release();
}
});
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, arguments.Port));
var app = builder.Build();
app.UseWebSockets();
app.MapCultNetWebSocket("/cultmesh", 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("PROVIDER_READY " + address.Replace("http://", "ws://", StringComparison.Ordinal) + "/cultmesh");
await app.WaitForShutdownAsync();
await app.DisposeAsync();
}
static async Task RunHeadlessAsync(Args arguments)
{
var registry = CultDocumentRegistry.ForTypes([typeof(CounterState)]);
using var cache = new CultCache(registry);
var documents = new CultNetDocumentRegistry(registry)
.Register(CultNetDocumentBinding.ForDocument<CounterState>(registry, "sample.counter_state.v1"));
using var transport = new CultNetWebSocketSchemaClient(options =>
options.SetRequestHeader("Cookie", $"cultnet_session={arguments.Token}"));
using var subscriptions = new CultNetDatabaseSubscriptionClient(transport, cache, documents);
var changed = new TaskCompletionSource<CounterState>(TaskCreationOptions.RunContinuationsAsynchronously);
subscriptions.Changed += change =>
{
if (change.Document is CounterState counter) changed.TrySetResult(counter);
};
await transport.ConnectAsync(new Uri(arguments.Endpoint));
var initial = await subscriptions.SubscribeAsync(
"headless-counter",
recordKeys: [counterKey],
schemaIds: ["sample.counter_state.v1"],
deliveryMode: CultNetDatabaseSubscriptionDeliveryMode.Live);
var counter = initial.OfType<CounterState>().Single();
Console.WriteLine("HEADLESS_READY " + JsonSerializer.Serialize(new { count = counter.Count }));
var update = await changed.Task.WaitAsync(TimeSpan.FromSeconds(15));
Console.WriteLine("HEADLESS_UPDATE " + JsonSerializer.Serialize(new { count = update.Count }));
}
sealed class Args
{
public string Mode { get; private init; } = "";
public int Port { get; private init; }
public string StatePath { get; private init; } = "sample-counter.cc";
public string Endpoint { get; private init; } = "";
public string Token { get; private init; } = "sample-session";
public static Args Parse(string[] values)
{
if (values.Length == 0) return new Args();
var named = new Dictionary<string, string>(StringComparer.Ordinal);
for (var index = 1; index + 1 < values.Length; index += 2)
named[values[index]] = values[index + 1];
return new Args
{
Mode = values[0],
Port = named.TryGetValue("--port", out var port) ? int.Parse(port) : 0,
StatePath = named.GetValueOrDefault("--state", "sample-counter.cc"),
Endpoint = named.GetValueOrDefault("--endpoint", ""),
Token = named.GetValueOrDefault("--token", "sample-session")
};
}
}
[CultDocument("sample.counter_state", "sample.counter_state.v1")]
[MessagePackObject]
public sealed class CounterState
{
[Key("counterId")] public string CounterId { get; set; } = "";
[Key("count")] public int Count { get; set; }
[Key("receipts")] public Dictionary<string, IncrementReceipt> Receipts { get; set; } = new(StringComparer.Ordinal);
public static CounterState Initial(string counterId) => new() { CounterId = counterId };
}
[MessagePackObject]
public sealed class IncrementRequest
{
[Key("amount")] public int Amount { get; set; } = 1;
}
[MessagePackObject]
public sealed class IncrementReceipt
{
[Key("receiptId")] public string ReceiptId { get; set; } = "";
[Key("schema")] public string Schema { get; set; } = "gamecult.eve.command_receipt.v1";
[Key("commandId")] public string CommandId { get; set; } = "";
[Key("command")] public string Command { get; set; } = "sample.counter.increment";
[Key("state")] public string State { get; set; } = "accepted";
[Key("ownerRepo")] public string OwnerRepo { get; set; } = "CultLib";
[Key("authority")] public string Authority { get; set; } = "provider-daemon";
[Key("providerId")] public string ProviderId { get; set; } = "sample.counter-provider";
[Key("surfaceId")] public string SurfaceId { get; set; } = "sample.counter";
[Key("issuedAtUtc")] public string IssuedAtUtc { get; set; } = "";
[Key("sourceVersion")] public int SourceVersion { get; set; }
[Key("idempotencyKey")] public string IdempotencyKey { get; set; } = "";
[Key("count")] public int Count { get; set; }
public static IncrementReceipt Accepted(string id, int count) => new()
{
ReceiptId = $"receipt:{id}",
CommandId = id,
IssuedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
SourceVersion = count,
IdempotencyKey = id,
Count = count
};
}
[CultDocument("gamecult.eve.surface", "gamecult.eve.surface.v1")]
[MessagePackObject]
public sealed class CounterSurfaceDocument
{
[Key("type")] public string Type { get; set; } = "surface-state";
[Key("schema")] public string Schema { get; set; } = "gamecult.eve.surface.v1";
[Key("providerId")] public string ProviderId { get; set; } = "sample.counter-provider";
[Key("providerKind")] public string ProviderKind { get; set; } = "sample.daemon";
[Key("title")] public string Title { get; set; } = "CultMesh browser counter";
[Key("version")] public int Version { get; set; } = 1;
[Key("updatedAtUtc")] public string UpdatedAtUtc { get; set; } = "2026-08-17T00:00:00Z";
[Key("surface")] public EveSurface Surface { get; set; } = new();
[Key("commands")] public EveCommand[] Commands { get; set; } = [];
public static CounterSurfaceDocument Create() => new()
{
Surface = new EveSurface
{
Id = "sample.counter",
Title = "Counter",
Root = new EveComponent
{
Id = "counter.root",
Kind = "column",
Children =
[
new EveComponent
{
Id = "counter.value",
Kind = "metric",
Props = new Dictionary<string, object> { ["label"] = "Canonical count", ["value"] = 0 },
StateBindings =
[
new EveStateBinding
{
TargetProp = "value",
PointerId = "sample.counter.count",
SourceId = "sample.counter_state:counter:main",
SchemaId = "sample.counter_state.v1",
RouteKind = "cultmesh"
}
]
},
new EveComponent
{
Id = "counter.increment",
Kind = "control.button",
Props = new Dictionary<string, object>
{
["label"] = "Increment",
["command"] = "sample.counter.increment"
}
}
]
}
},
Commands =
[
new EveCommand
{
Command = "sample.counter.increment",
Label = "Increment",
SurfaceId = "sample.counter"
}
]
};
}
[MessagePackObject]
public sealed class EveSurface
{
[Key("id")] public string Id { get; set; } = "";
[Key("title")] public string Title { get; set; } = "";
[Key("root")] public EveComponent Root { get; set; } = new();
[Key("styles")] public Dictionary<string, object> Styles { get; set; } = [];
}
[MessagePackObject]
public sealed class EveComponent
{
[Key("id")] public string Id { get; set; } = "";
[Key("kind")] public string Kind { get; set; } = "";
[Key("props")] public Dictionary<string, object> Props { get; set; } = [];
[Key("children")] public EveComponent[] Children { get; set; } = [];
[Key("stateBindings")] public EveStateBinding[] StateBindings { get; set; } = [];
}
[MessagePackObject]
public sealed class EveStateBinding
{
[Key("targetProp")] public string TargetProp { get; set; } = "";
[Key("pointerId")] public string PointerId { get; set; } = "";
[Key("sourceId")] public string SourceId { get; set; } = "";
[Key("schemaId")] public string SchemaId { get; set; } = "";
[Key("routeKind")] public string RouteKind { get; set; } = "";
}
[MessagePackObject]
public sealed class EveCommand
{
[Key("schema")] public string Schema { get; set; } = "gamecult.eve.command.v1";
[Key("command")] public string Command { get; set; } = "";
[Key("label")] public string Label { get; set; } = "";
[Key("surfaceId")] public string SurfaceId { get; set; } = "";
[Key("transport")] public string Transport { get; set; } = "cultmesh";
[Key("authority")] public string Authority { get; set; } = "provider-daemon";
[Key("result")] public string Result { get; set; } = "gamecult.eve.command_receipt.v1";
}

View file

@ -0,0 +1,22 @@
# Eve Browser + Headless Counter
This is the real-process continuation of `samples/eve-two-runtime`:
- one C# provider owns a durable `.cc` counter, the Eve surface, command
idempotency, and canonical receipts;
- 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;
- the browser and C# clients speak the same binary CultNet schema-v0 WebSocket
messages through the authenticated host adapter.
Run the proof from the CultLib repository:
```powershell
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.

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CultMesh Eve browser counter</title>
</head>
<body>
<main id="surface">Connecting to the Verse…</main>
<output id="receipt" aria-live="polite"></output>
<script type="module" src="/bundle.js"></script>
</body>
</html>

View file

@ -0,0 +1,84 @@
import { parseEveCommandReceipt, parseEveSurfaceDocument } from "@gamecult/eve-contracts";
import { renderEveSurface } from "@gamecult/eve-browser-lowering";
import {
CultMeshBrowserClient,
decodeCultNetOperationPayload,
decodeCultNetPayload,
} from "cultmesh-browser";
declare global {
interface Window {
__sampleReady?: boolean;
__sampleCount?: number;
__sampleReceipt?: { receiptId: string; count: number };
__sampleError?: string;
}
}
const params = new URLSearchParams(location.search);
const endpoint = params.get("endpoint");
const token = params.get("token") || "sample-session";
if (!endpoint) throw new Error("The sample requires an endpoint query parameter.");
document.cookie = `cultnet_session=${encodeURIComponent(token)}; Path=/; SameSite=Strict`;
const host = document.querySelector<HTMLElement>("#surface")!;
const receiptOutput = document.querySelector<HTMLOutputElement>("#receipt")!;
try {
const mesh = await CultMeshBrowserClient.connect({
verseId: "sample.counter",
providerId: "sample.counter-provider",
runtimeId: "sample.chromium",
rendezvous: {
resolve: async identity => ({ ...identity, endpoint }),
},
});
const counter = await mesh.leaseRawDocument({
schemaId: "sample.counter_state.v1",
recordKey: "counter:main",
subscriptionId: "browser-counter",
});
const surface = await mesh.leaseRawDocument({
schemaId: "gamecult.eve.surface.v1",
recordKey: "sample.counter",
subscriptionId: "browser-surface",
});
const currentSurface = parseEveSurfaceDocument(decodeCultNetPayload(surface.current!));
renderEveSurface(currentSurface, host, {
activeSurfaceId: "sample.counter",
clientId: "sample.chromium",
commandSink: async intent => {
const response = await mesh.invoke({
serviceId: "sample.counter",
operation: intent.command,
payloadSchema: "sample.increment.v1",
payload: { amount: 1 },
idempotencyKey: "browser-click-1",
});
const receipt = parseEveCommandReceipt(decodeCultNetOperationPayload(response)) as {
receiptId: string;
count: number;
};
window.__sampleReceipt = receipt;
receiptOutput.textContent = receipt.receiptId;
},
stateBindingResolver: async binding => {
if (binding.pointerId !== "sample.counter.count") return undefined;
return {
latest: async () => decodeCultNetPayload<{ count: number }>(counter.current!).count,
watch: callback => counter.watch(record => {
if (!record) return;
const count = decodeCultNetPayload<{ count: number }>(record).count;
window.__sampleCount = count;
callback(count);
}),
};
},
});
window.__sampleCount = decodeCultNetPayload<{ count: number }>(counter.current!).count;
window.__sampleReady = true;
} catch (error) {
window.__sampleError = error instanceof Error ? error.stack || error.message : String(error);
host.textContent = window.__sampleError;
throw error;
}