Add real browser CultMesh transport proof
This commit is contained in:
parent
4af7398372
commit
8eb89e38ec
29 changed files with 2936 additions and 47 deletions
10
samples/eve-browser-network/EveBrowserNetworkSample.csproj
Normal file
10
samples/eve-browser-network/EveBrowserNetworkSample.csproj
Normal 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>
|
||||
319
samples/eve-browser-network/Program.cs
Normal file
319
samples/eve-browser-network/Program.cs
Normal 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";
|
||||
}
|
||||
22
samples/eve-browser-network/README.md
Normal file
22
samples/eve-browser-network/README.md
Normal 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.
|
||||
13
samples/eve-browser-network/browser/index.html
Normal file
13
samples/eve-browser-network/browser/index.html
Normal 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>
|
||||
84
samples/eve-browser-network/browser/main.ts
Normal file
84
samples/eve-browser-network/browser/main.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue