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

@ -35,6 +35,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Quic", "src\G
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Quic.Tests", "tests\GameCult.Mesh.Quic.Tests\GameCult.Mesh.Quic.Tests.csproj", "{21F26329-2119-4037-93CC-DD1801A1AB78}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Quic.Tests", "tests\GameCult.Mesh.Quic.Tests\GameCult.Mesh.Quic.Tests.csproj", "{21F26329-2119-4037-93CC-DD1801A1AB78}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSockets", "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj", "{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSockets.Tests", "tests\GameCult.Networking.WebSockets.Tests\GameCult.Networking.WebSockets.Tests.csproj", "{B9A441EC-DADE-4500-B092-C635F3EB296A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EveBrowserNetworkSample", "samples\eve-browser-network\EveBrowserNetworkSample.csproj", "{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -213,6 +221,42 @@ Global
{21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x64.Build.0 = Release|Any CPU {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x64.Build.0 = Release|Any CPU
{21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x86.ActiveCfg = Release|Any CPU {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x86.ActiveCfg = Release|Any CPU
{21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x86.Build.0 = Release|Any CPU {21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x86.Build.0 = Release|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x64.ActiveCfg = Debug|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x64.Build.0 = Debug|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x86.ActiveCfg = Debug|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x86.Build.0 = Debug|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|Any CPU.Build.0 = Release|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x64.ActiveCfg = Release|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x64.Build.0 = Release|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x86.ActiveCfg = Release|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x86.Build.0 = Release|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x64.ActiveCfg = Debug|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x64.Build.0 = Debug|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x86.ActiveCfg = Debug|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x86.Build.0 = Debug|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|Any CPU.Build.0 = Release|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x64.ActiveCfg = Release|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x64.Build.0 = Release|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.ActiveCfg = Release|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.Build.0 = Release|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.ActiveCfg = Debug|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.Build.0 = Debug|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.ActiveCfg = Debug|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.Build.0 = Debug|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.Build.0 = Release|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.ActiveCfg = Release|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.Build.0 = Release|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.ActiveCfg = Release|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -232,5 +276,8 @@ Global
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} {F8A5060A-FB0B-4B8B-AE06-5147BB4CA127} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{B67042B8-1135-4E54-B1EB-D45342C720D7} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {B67042B8-1135-4E54-B1EB-D45342C720D7} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{21F26329-2119-4037-93CC-DD1801A1AB78} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} {21F26329-2119-4037-93CC-DD1801A1AB78} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{B9A441EC-DADE-4500-B092-C635F3EB296A} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View file

@ -11,8 +11,9 @@ The stack is less a single product than a set of capabilities:
- logging primitives and implementations - logging primitives and implementations
- CultCache typed documents, `.cc` persistence, and SoA memory access - CultCache typed documents, `.cc` persistence, and SoA memory access
- CultNet schema-v0 messages over TCP, WebSocket, LiteNetLib adapters, and - CultNet schema-v0 messages over TCP and LiteNetLib adapters, a bounded
CultLib's native cross-runtime RUDP pipe ASP.NET Core WebSocket host adapter, and CultLib's native cross-runtime RUDP
pipe
- CultMesh distributed realtime database, Verse discovery, authority leases, - CultMesh distributed realtime database, Verse discovery, authority leases,
shard replication, and witness-authoritative simulation consensus shard replication, and witness-authoritative simulation consensus
- typed geometry domain/chunk documents for distributed CSG and LOD streaming - typed geometry domain/chunk documents for distributed CSG and LOD streaming
@ -38,7 +39,7 @@ and pretending the label makes it furniture.
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| Local typed state | `GameCult.Caching` / CultCache | Document identity, schema compatibility, record keys, indexes, globals, and local persistence | You need a typed cache, file-compatible save data, local reactive reads, or a stable domain document model | Peer discovery, transport security, shard routing, or mesh consensus | | Local typed state | `GameCult.Caching` / CultCache | Document identity, schema compatibility, record keys, indexes, globals, and local persistence | You need a typed cache, file-compatible save data, local reactive reads, or a stable domain document model | Peer discovery, transport security, shard routing, or mesh consensus |
| Procedural geometry state | `GameCult.Geometry` | CultCache-native domain trees, LOD build requests, selected-cut diagnostics, and chunk artifact payloads | Rust, Unity, or remote workers need to share CSG/LOD geometry and graph metadata as typed state | Transport policy, peer discovery, or gameplay authority | | Procedural geometry state | `GameCult.Geometry` | CultCache-native domain trees, LOD build requests, selected-cut diagnostics, and chunk artifact payloads | Rust, Unity, or remote workers need to share CSG/LOD geometry and graph metadata as typed state | Transport policy, peer discovery, or gameplay authority |
| Network transport and database plumbing | `GameCult.Networking` / CultNet | Native RUDP sessions, LiteNetLib/TCP/WebSocket adapters, authentication, schema-v0 wire contracts, shard authority, raw document replication, snapshots, and subscriptions | You need a client/server pipe, login/session flow, schema discovery, reliable UDP, or a low-level distributed CultCache lane | Gameplay-facing mesh ergonomics, Verse policy, mod branches, or simulation consensus composition | | Network transport and database plumbing | `GameCult.Networking` / CultNet | Native RUDP sessions, LiteNetLib/TCP adapters, an authenticated WebSocket host adapter, schema-v0 wire contracts, shard authority, raw document replication, snapshots, and subscriptions | You need a client/server pipe, login/session flow, schema discovery, reliable UDP, or a low-level distributed CultCache lane | Gameplay-facing mesh ergonomics, Verse policy, mod branches, or simulation consensus composition |
| Distributed realtime gameplay state | `GameCult.Mesh` / CultMesh | Public mesh entrypoints, Verse discovery, peer exchange, shard replication defaults, authority leases, client prediction, and witness consensus | You want the game to treat clients and servers as one reactive database for persistent state, input state, and simulation facts | A tiny local-only tool, a bare transport client, or a storage format contract | | Distributed realtime gameplay state | `GameCult.Mesh` / CultMesh | Public mesh entrypoints, Verse discovery, peer exchange, shard replication defaults, authority leases, client prediction, and witness consensus | You want the game to treat clients and servers as one reactive database for persistent state, input state, and simulation facts | A tiny local-only tool, a bare transport client, or a storage format contract |
| Realtime media/frame streams | `GameCult.Mesh` / CultMesh streaming mode | Stream identity, authority, clock metadata, body transport negotiation, frame cursors, and backpressure state | Audio/video/tensor frames need to move between runtimes through shared memory, GPU handles, platform buffers, or CultCache page refs | Durable document mutation, mesh consensus facts, or pretending inline bytes are zero-copy | | Realtime media/frame streams | `GameCult.Mesh` / CultMesh streaming mode | Stream identity, authority, clock metadata, body transport negotiation, frame cursors, and backpressure state | Audio/video/tensor frames need to move between runtimes through shared memory, GPU handles, platform buffers, or CultCache page refs | Durable document mutation, mesh consensus facts, or pretending inline bytes are zero-copy |
| Daemon-published UI | Eve / CultUI | Typed operator and user-facing surfaces over CultMesh state | A daemon should expose controls, inspection, or workflow UI that can lower into GUI, TUI, web, native, overlay, or agent clients | One-off vendor dashboards, untyped debug panels, or runtime-local UI that cannot travel with the daemon | | Daemon-published UI | Eve / CultUI | Typed operator and user-facing surfaces over CultMesh state | A daemon should expose controls, inspection, or workflow UI that can lower into GUI, TUI, web, native, overlay, or agent clients | One-off vendor dashboards, untyped debug panels, or runtime-local UI that cannot travel with the daemon |
@ -67,7 +68,10 @@ start with the maintained
[`CultMesh And Eve Getting Started`](src/GameCult.Mesh/docs/getting-started/README.md) [`CultMesh And Eve Getting Started`](src/GameCult.Mesh/docs/getting-started/README.md)
series. Its first executable package-consumer checkpoint is series. Its first executable package-consumer checkpoint is
[`samples/eve-two-runtime`](samples/eve-two-runtime/README.md). [`samples/eve-two-runtime`](samples/eve-two-runtime/README.md).
The missing real-browser/network boundary is mapped explicitly in The real-process browser continuation is
[`samples/eve-browser-network`](samples/eve-browser-network/README.md).
The implemented browser client boundary and remaining real-network gate are
mapped explicitly in
[`browser-verse-transport.md`](src/GameCult.Mesh/docs/browser-verse-transport.md). [`browser-verse-transport.md`](src/GameCult.Mesh/docs/browser-verse-transport.md).
If you want only the smallest durable "open node -> put typed doc -> get typed doc" If you want only the smallest durable "open node -> put typed doc -> get typed doc"
@ -89,6 +93,7 @@ The solution includes:
- `GameCult.Caching.MessagePack.Analyzers`: packaging project that delivers the generator to consuming projects - `GameCult.Caching.MessagePack.Analyzers`: packaging project that delivers the generator to consuming projects
- `GameCult.Geometry`: CultCache-native geometry domain, selected-cut, and chunk artifact documents for VibeGeometry/Fensalir-style pipelines - `GameCult.Geometry`: CultCache-native geometry domain, selected-cut, and chunk artifact documents for VibeGeometry/Fensalir-style pipelines
- `GameCult.Networking`: encrypted login/register/verify flows, schema-v0 contracts, transport adapters, and native RUDP sessions - `GameCult.Networking`: encrypted login/register/verify flows, schema-v0 contracts, transport adapters, and native RUDP sessions
- `GameCult.Networking.WebSockets`: bounded ASP.NET Core binary WebSocket host adapter for browser CultMesh clients
- `GameCult.Mesh`: CultMesh package home for distributed realtime database, shard replication, client prediction, Verse discovery, and mesh witness consensus - `GameCult.Mesh`: CultMesh package home for distributed realtime database, shard replication, client prediction, Verse discovery, and mesh witness consensus
- `GameCult.Caching.Tests`: NUnit tests for cache and backing-store behavior - `GameCult.Caching.Tests`: NUnit tests for cache and backing-store behavior
- `GameCult.Networking.Tests`: NUnit tests for networking behavior - `GameCult.Networking.Tests`: NUnit tests for networking behavior
@ -96,6 +101,7 @@ The solution includes:
- `packages/cultcache-ts`: TypeScript CultCache with MessagePack persistence and inspector tooling - `packages/cultcache-ts`: TypeScript CultCache with MessagePack persistence and inspector tooling
- `packages/cultnet-ts`: TypeScript CultNet schema-v0 contracts, framing, discovery, raw document replication, and interop tests - `packages/cultnet-ts`: TypeScript CultNet schema-v0 contracts, framing, discovery, raw document replication, and interop tests
- `packages/cultmesh-ts`: TypeScript CultMesh local node and mesh catalog surface for local runtimes such as VoidBot - `packages/cultmesh-ts`: TypeScript CultMesh local node and mesh catalog surface for local runtimes such as VoidBot
- `packages/cultmesh-browser`: browser-safe, read-only CultMesh document leases and typed operations over an Odin-resolved WebSocket route
- `packages/cultcache-py`: Python CultCache/CultNet/CultMesh package with CultCache v1 wire parity - `packages/cultcache-py`: Python CultCache/CultNet/CultMesh package with CultCache v1 wire parity
- `packages/cultcache-rs`: Rust CultCache and derive macro - `packages/cultcache-rs`: Rust CultCache and derive macro
- `packages/cultnet-rs`: Rust CultNet contracts, framing, discovery, and interop peer - `packages/cultnet-rs`: Rust CultNet contracts, framing, discovery, and interop peer
@ -114,6 +120,7 @@ src/
GameCult.Caching.MessagePack.Analyzers/ GameCult.Caching.MessagePack.Analyzers/
GameCult.Geometry/ GameCult.Geometry/
GameCult.Networking/ GameCult.Networking/
GameCult.Networking.WebSockets/
GameCult.Mesh/ GameCult.Mesh/
GameCult.Unity/ GameCult.Unity/
tests/ tests/
@ -124,6 +131,7 @@ packages/
cultcache-ts/ cultcache-ts/
cultnet-ts/ cultnet-ts/
cultmesh-ts/ cultmesh-ts/
cultmesh-browser/
cultmesh-kotlin/ cultmesh-kotlin/
cultcache-py/ cultcache-py/
cultcache-rs/ cultcache-rs/
@ -149,6 +157,7 @@ npm test --workspace packages/cultcache-ts
npm test --workspace packages/cultnet-ts npm test --workspace packages/cultnet-ts
npm run test:interop --workspace packages/cultnet-ts npm run test:interop --workspace packages/cultnet-ts
npm test --workspace packages/cultmesh-ts npm test --workspace packages/cultmesh-ts
npm test --workspace packages/cultmesh-browser
``` ```
Rust package tests: Rust package tests:

539
package-lock.json generated
View file

@ -8,8 +8,15 @@
"workspaces": [ "workspaces": [
"packages/cultcache-ts", "packages/cultcache-ts",
"packages/cultnet-ts", "packages/cultnet-ts",
"packages/cultmesh-ts" "packages/cultmesh-ts",
] "packages/cultmesh-browser"
],
"devDependencies": {
"esbuild": "^0.28.1",
"fast-uri": "3.1.5",
"js-yaml": "4.3.1",
"playwright-core": "^1.62.1"
}
}, },
"node_modules/@apidevtools/json-schema-ref-parser": { "node_modules/@apidevtools/json-schema-ref-parser": {
"version": "11.9.3", "version": "11.9.3",
@ -29,6 +36,448 @@
"url": "https://github.com/sponsors/philsturgeon" "url": "https://github.com/sponsors/philsturgeon"
} }
}, },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@jsdevtools/ono": { "node_modules/@jsdevtools/ono": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
@ -96,6 +545,10 @@
"resolved": "packages/cultcache-ts", "resolved": "packages/cultcache-ts",
"link": true "link": true
}, },
"node_modules/cultmesh-browser": {
"resolved": "packages/cultmesh-browser",
"link": true
},
"node_modules/cultmesh-ts": { "node_modules/cultmesh-ts": {
"resolved": "packages/cultmesh-ts", "resolved": "packages/cultmesh-ts",
"link": true "link": true
@ -104,6 +557,48 @@
"resolved": "packages/cultnet-ts", "resolved": "packages/cultnet-ts",
"link": true "link": true
}, },
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -111,9 +606,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.3", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -168,9 +663,9 @@
} }
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -250,6 +745,19 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.9.5", "version": "3.9.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
@ -337,6 +845,21 @@
"node": ">=18" "node": ">=18"
} }
}, },
"packages/cultmesh-browser": {
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"cultnet-ts": "^0.1.1"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
},
"engines": {
"node": ">=18"
}
},
"packages/cultmesh-ts": { "packages/cultmesh-ts": {
"version": "0.1.1", "version": "0.1.1",
"license": "MIT", "license": "MIT",

View file

@ -4,11 +4,19 @@
"workspaces": [ "workspaces": [
"packages/cultcache-ts", "packages/cultcache-ts",
"packages/cultnet-ts", "packages/cultnet-ts",
"packages/cultmesh-ts" "packages/cultmesh-ts",
"packages/cultmesh-browser"
], ],
"scripts": { "scripts": {
"build:ts": "node scripts/run-typescript-workspaces.mjs build", "build:ts": "node scripts/run-typescript-workspaces.mjs build",
"test:ts": "node scripts/run-typescript-workspaces.mjs test", "test:ts": "node scripts/run-typescript-workspaces.mjs test",
"test:pack:ts": "node scripts/test-typescript-package-closure.mjs" "test:pack:ts": "node scripts/test-typescript-package-closure.mjs",
"test:eve-browser-network": "node scripts/verify-eve-browser-network.mjs"
},
"devDependencies": {
"esbuild": "^0.28.1",
"fast-uri": "3.1.5",
"js-yaml": "4.3.1",
"playwright-core": "^1.62.1"
} }
} }

View file

@ -0,0 +1,40 @@
# CultMesh Browser
`cultmesh-browser` is the browser-safe CultMesh client boundary. It owns one
WebSocket session for a stable Verse/provider identity, retains explicit raw
document leases across route replacement, and correlates typed CultNet
operations with provider receipts.
It does not own application state and it does not expose direct document
writes. A renderer watches provider-owned records and invokes provider-owned
operations.
```ts
const mesh = await CultMeshBrowserClient.connect({
verseId: "sample.counter",
providerId: "sample.counter-provider",
runtimeId: "counter-browser",
rendezvous: {
resolve: async identity => discoverWebSocketRoute(identity),
},
});
const counter = await mesh.leaseRawDocument({
schemaId: "sample.counter_state.v1",
recordKey: "counter:main",
});
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
provider identity.
The browser handshake carries authentication through the host application's
ordinary secure cookie or equivalent upgrade credential. The C# host adapter
rejects endpoints without an authorization predicate unless anonymous local
development is explicitly enabled.
Run `scripts/verify-eve-browser-network.mjs --eve-root <path-to-Eve>` for the
real Chromium + C# headless chronology.

View file

@ -0,0 +1,50 @@
{
"name": "cultmesh-browser",
"version": "0.1.0",
"description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
"prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false",
"test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js"
},
"keywords": [
"cultmesh",
"cultnet",
"browser",
"websocket",
"gamecult"
],
"author": "metacratic",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/GameCult/CultLib.git",
"directory": "packages/cultmesh-browser"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"cultnet-ts": "^0.1.1"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
}
}

View file

@ -0,0 +1,545 @@
import { decode, encode } from "@msgpack/msgpack";
import {
encodeCultNetMessageForWire,
parseCultNetMessage,
type CultNetDatabaseChangeRawMessage,
type CultNetDatabaseSubscribeMessage,
type CultNetDatabaseUnsubscribeMessage,
type CultNetMessage,
type CultNetOperationRequestMessage,
type CultNetOperationResponseMessage,
type CultNetRawDocumentRecord,
type CultNetSnapshotResponseRawMessage,
} from "cultnet-ts/contracts";
export interface CultMeshBrowserIdentity {
verseId: string;
providerId: string;
}
export interface CultMeshBrowserRoute extends CultMeshBrowserIdentity {
endpoint: string;
generation?: string;
}
export interface CultMeshBrowserRendezvous {
resolve(identity: CultMeshBrowserIdentity): Promise<CultMeshBrowserRoute>;
}
export interface CultMeshBrowserSocket {
binaryType: BinaryType;
readonly readyState: number;
onopen: ((event: Event) => void) | null;
onmessage: ((event: MessageEvent) => void) | null;
onerror: ((event: Event) => void) | null;
onclose: ((event: CloseEvent) => void) | null;
send(data: ArrayBuffer | ArrayBufferView): void;
close(code?: number, reason?: string): void;
}
export interface CultMeshBrowserClientOptions extends CultMeshBrowserIdentity {
runtimeId: string;
rendezvous: CultMeshBrowserRendezvous;
socketFactory?: (endpoint: string) => CultMeshBrowserSocket;
reconnectDelayMs?: number;
connectTimeoutMs?: number;
requestTimeoutMs?: number;
maxFrameBytes?: number;
createId?: () => string;
}
export interface CultMeshRawDocumentLeaseOptions {
schemaId: string;
recordKey: string;
subscriptionId?: string;
}
export interface CultMeshOperationOptions {
serviceId: string;
operation: string;
payloadSchema: string;
payload: unknown;
idempotencyKey?: string;
targetRuntimeId?: string;
}
export type CultMeshConnectionState = "connecting" | "connected" | "reconnecting" | "disposed";
type DocumentWatcher = (record: CultNetRawDocumentRecord | undefined) => void;
interface PendingSnapshot {
lease: CultMeshRawDocumentLease;
resolve: () => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
interface PendingOperation {
resolve: (response: CultNetOperationResponseMessage) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
export class CultMeshRawDocumentLease implements Disposable {
readonly schemaId: string;
readonly recordKey: string;
readonly subscriptionId: string;
#client: CultMeshBrowserClient | undefined;
#current: CultNetRawDocumentRecord | undefined;
#watchers = new Set<DocumentWatcher>();
constructor(
client: CultMeshBrowserClient,
options: Required<CultMeshRawDocumentLeaseOptions>,
) {
this.#client = client;
this.schemaId = options.schemaId;
this.recordKey = options.recordKey;
this.subscriptionId = options.subscriptionId;
}
get current(): CultNetRawDocumentRecord | undefined {
return this.#current;
}
watch(watcher: DocumentWatcher): () => void {
if (!this.#client) throw new Error("CultMesh document lease is disposed.");
this.#watchers.add(watcher);
watcher(this.#current);
return () => this.#watchers.delete(watcher);
}
dispose(): void {
this.#client?.releaseLease(this);
this.#client = undefined;
this.#watchers.clear();
}
[Symbol.dispose](): void {
this.dispose();
}
apply(record: CultNetRawDocumentRecord | undefined): void {
this.#current = record;
for (const watcher of this.#watchers) watcher(record);
}
}
export class CultMeshBrowserClient implements AsyncDisposable {
readonly identity: CultMeshBrowserIdentity;
readonly runtimeId: string;
#options: Required<Omit<CultMeshBrowserClientOptions, "socketFactory">> & {
socketFactory: (endpoint: string) => CultMeshBrowserSocket;
};
#socket: CultMeshBrowserSocket | undefined;
#socketGeneration = 0;
#state: CultMeshConnectionState = "connecting";
#disposed = false;
#reconnectTimer: ReturnType<typeof setTimeout> | undefined;
#connectPromise: Promise<void> | undefined;
#leases = new Map<string, CultMeshRawDocumentLease>();
#openingLeases = new Map<string, CultMeshRawDocumentLease>();
#pendingSnapshots = new Map<string, PendingSnapshot>();
#pendingOperations = new Map<string, PendingOperation>();
#stateWatchers = new Set<(state: CultMeshConnectionState) => void>();
private constructor(options: CultMeshBrowserClientOptions) {
requireText(options.verseId, "verseId");
requireText(options.providerId, "providerId");
requireText(options.runtimeId, "runtimeId");
this.identity = { verseId: options.verseId, providerId: options.providerId };
this.runtimeId = options.runtimeId;
this.#options = {
...options,
reconnectDelayMs: options.reconnectDelayMs ?? 250,
connectTimeoutMs: options.connectTimeoutMs ?? 10_000,
requestTimeoutMs: options.requestTimeoutMs ?? 10_000,
maxFrameBytes: options.maxFrameBytes ?? 4 * 1024 * 1024,
createId: options.createId ?? (() => crypto.randomUUID()),
socketFactory: options.socketFactory ?? (endpoint => new WebSocket(endpoint)),
};
}
static async connect(options: CultMeshBrowserClientOptions): Promise<CultMeshBrowserClient> {
const client = new CultMeshBrowserClient(options);
await client.connectSocket();
return client;
}
get state(): CultMeshConnectionState {
return this.#state;
}
watchState(watcher: (state: CultMeshConnectionState) => void): () => void {
this.#stateWatchers.add(watcher);
watcher(this.#state);
return () => this.#stateWatchers.delete(watcher);
}
async leaseRawDocument(options: CultMeshRawDocumentLeaseOptions): Promise<CultMeshRawDocumentLease> {
this.throwIfDisposed();
requireText(options.schemaId, "schemaId");
requireText(options.recordKey, "recordKey");
const subscriptionId = options.subscriptionId ?? `lease:${this.#options.createId()}`;
if (this.#leases.has(subscriptionId) || this.#openingLeases.has(subscriptionId)) {
throw new Error(`CultMesh subscription '${subscriptionId}' is already leased.`);
}
const lease = new CultMeshRawDocumentLease(this, { ...options, subscriptionId });
this.#openingLeases.set(subscriptionId, lease);
try {
await this.subscribe(lease);
this.#openingLeases.delete(subscriptionId);
this.#leases.set(subscriptionId, lease);
return lease;
} catch (error) {
this.#openingLeases.delete(subscriptionId);
throw error;
}
}
async invoke(options: CultMeshOperationOptions): Promise<CultNetOperationResponseMessage> {
this.throwIfDisposed();
requireText(options.serviceId, "serviceId");
requireText(options.operation, "operation");
requireText(options.payloadSchema, "payloadSchema");
await this.ensureConnected();
const messageId = options.idempotencyKey ?? this.#options.createId();
requireText(messageId, "operation idempotencyKey");
if (this.#pendingOperations.has(messageId)) {
throw new Error(`CultMesh operation '${messageId}' is already in flight.`);
}
const request: CultNetOperationRequestMessage = {
schemaVersion: "cultnet.operation_request.v0",
messageId,
serviceId: options.serviceId,
operation: options.operation,
payloadSchema: options.payloadSchema,
payloadEncoding: "messagepack-base64",
payload: bytesToBase64(encode(options.payload)),
sourceRuntimeId: this.runtimeId,
...(options.targetRuntimeId ? { targetRuntimeId: options.targetRuntimeId } : {}),
};
const response = new Promise<CultNetOperationResponseMessage>((resolve, reject) => {
const timer = setTimeout(() => {
if (!this.#pendingOperations.delete(messageId)) return;
reject(new Error(`CultMesh operation '${messageId}' timed out.`));
}, this.#options.requestTimeoutMs);
this.#pendingOperations.set(messageId, { resolve, reject, timer });
});
try {
this.send(request);
} catch (error) {
const pending = this.#pendingOperations.get(messageId);
if (pending) clearTimeout(pending.timer);
this.#pendingOperations.delete(messageId);
throw error;
}
return response;
}
async refreshRoute(): Promise<void> {
this.throwIfDisposed();
this.#socketGeneration++;
const socket = this.#socket;
this.#socket = undefined;
socket?.close(1012, "CultMesh route refresh");
await this.connectSocket();
}
releaseLease(lease: CultMeshRawDocumentLease): void {
if (this.#leases.get(lease.subscriptionId) !== lease) return;
this.#leases.delete(lease.subscriptionId);
for (const [messageId, pending] of this.#pendingSnapshots) {
if (pending.lease !== lease) continue;
this.#pendingSnapshots.delete(messageId);
clearTimeout(pending.timer);
pending.reject(new Error("CultMesh document lease was disposed before its snapshot arrived."));
}
if (this.#state === "connected") {
const message: CultNetDatabaseUnsubscribeMessage = {
schemaVersion: "cultnet.database_unsubscribe.v0",
messageId: this.#options.createId(),
subscriptionId: lease.subscriptionId,
};
this.send(message);
}
}
async dispose(): Promise<void> {
if (this.#disposed) return;
this.#disposed = true;
this.setState("disposed");
if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer);
this.#reconnectTimer = undefined;
const error = new Error("CultMesh browser client was disposed.");
for (const pending of this.#pendingSnapshots.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
for (const pending of this.#pendingOperations.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.#pendingSnapshots.clear();
this.#pendingOperations.clear();
this.#leases.clear();
this.#openingLeases.clear();
const socket = this.#socket;
this.#socket = undefined;
socket?.close(1000, "CultMesh client disposed");
}
async [Symbol.asyncDispose](): Promise<void> {
await this.dispose();
}
private async subscribe(lease: CultMeshRawDocumentLease): Promise<void> {
await this.ensureConnected();
const messageId = this.#options.createId();
const ready = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
if (!this.#pendingSnapshots.delete(messageId)) return;
reject(new Error(`CultMesh subscription '${lease.subscriptionId}' timed out.`));
}, this.#options.requestTimeoutMs);
this.#pendingSnapshots.set(messageId, { lease, resolve, reject, timer });
});
const request: CultNetDatabaseSubscribeMessage = {
schemaVersion: "cultnet.database_subscribe.v0",
messageId,
subscriptionId: lease.subscriptionId,
schemaIds: [lease.schemaId],
recordKeys: [lease.recordKey],
includeSnapshot: true,
consumerRuntimeId: this.runtimeId,
};
try {
this.send(request);
} catch (error) {
const pending = this.#pendingSnapshots.get(messageId);
if (pending) clearTimeout(pending.timer);
this.#pendingSnapshots.delete(messageId);
throw error;
}
return ready;
}
private ensureConnected(): Promise<void> {
if (this.#state === "connected" && this.#socket?.readyState === 1) return Promise.resolve();
return this.connectSocket();
}
private connectSocket(): Promise<void> {
this.throwIfDisposed();
if (this.#connectPromise) return this.#connectPromise;
const generation = ++this.#socketGeneration;
this.setState(this.#socket ? "reconnecting" : "connecting");
this.#connectPromise = this.openSocket(generation).finally(() => {
this.#connectPromise = undefined;
});
return this.#connectPromise;
}
private async openSocket(generation: number): Promise<void> {
const route = await this.#options.rendezvous.resolve(this.identity);
if (route.verseId !== this.identity.verseId || route.providerId !== this.identity.providerId) {
throw new Error("CultMesh rendezvous returned a route for the wrong stable identity.");
}
const endpoint = new URL(route.endpoint);
if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") {
throw new Error(`CultMesh browser route must use ws:// or wss://, got '${route.endpoint}'.`);
}
const socket = this.#options.socketFactory(route.endpoint);
socket.binaryType = "arraybuffer";
try {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`CultMesh WebSocket open timed out for '${route.endpoint}'.`)),
this.#options.connectTimeoutMs,
);
socket.onopen = () => { clearTimeout(timer); resolve(); };
socket.onerror = () => { clearTimeout(timer); reject(new Error(`CultMesh WebSocket could not open '${route.endpoint}'.`)); };
socket.onclose = () => { clearTimeout(timer); reject(new Error(`CultMesh WebSocket closed while opening '${route.endpoint}'.`)); };
});
} catch (error) {
socket.close(1000, "CultMesh route open failed");
throw error;
}
if (this.#disposed || generation !== this.#socketGeneration) {
socket.close(1000, "Stale CultMesh route");
return;
}
this.#socket = socket;
socket.onmessage = event => { void this.receive(event, generation); };
socket.onerror = () => undefined;
socket.onclose = () => this.handleClosed(generation);
this.setState("connected");
try {
this.send({
schemaVersion: "cultnet.hello.v0",
runtimeId: this.runtimeId,
runtimeKind: "browser",
supportedMessageVersions: [
"cultnet.database_subscribe.v0",
"cultnet.database_unsubscribe.v0",
"cultnet.database_change_raw.v0",
"cultnet.operation_request.v0",
"cultnet.operation_response.v0",
],
});
await Promise.all([...this.#leases.values()].map(lease => this.subscribe(lease)));
} catch (error) {
if (this.#socket === socket) this.#socket = undefined;
socket.onclose = null;
socket.close(1000, "CultMesh session initialization failed");
throw error;
}
}
private handleClosed(generation: number): void {
if (this.#disposed || generation !== this.#socketGeneration) return;
this.#socket = undefined;
const error = new Error("CultMesh route closed before the provider response arrived.");
for (const pending of this.#pendingSnapshots.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
for (const pending of this.#pendingOperations.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.#pendingSnapshots.clear();
this.#pendingOperations.clear();
this.setState("reconnecting");
this.#reconnectTimer = setTimeout(() => {
this.#reconnectTimer = undefined;
void this.connectSocket().catch(() => this.handleClosed(this.#socketGeneration));
}, this.#options.reconnectDelayMs);
}
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");
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);
return;
case "cultnet.database_change_raw.v0":
this.applyChange(message);
return;
case "cultnet.operation_response.v0": {
const pending = this.#pendingOperations.get(message.messageId);
if (!pending) return;
this.#pendingOperations.delete(message.messageId);
clearTimeout(pending.timer);
pending.resolve(message);
return;
}
case "cultnet.error.v0":
this.rejectCorrelated(message.error);
return;
default:
return;
}
}
private applySnapshot(message: CultNetSnapshotResponseRawMessage): void {
const pending = this.#pendingSnapshots.get(message.messageId);
if (!pending) return;
this.#pendingSnapshots.delete(message.messageId);
clearTimeout(pending.timer);
const matches = message.documents.filter(record =>
record.schemaId === pending.lease.schemaId && record.recordKey === pending.lease.recordKey);
if (matches.length > 1) {
pending.reject(new Error(`CultMesh snapshot returned duplicate record '${pending.lease.recordKey}'.`));
return;
}
pending.lease.apply(matches[0]);
pending.resolve();
}
private applyChange(message: CultNetDatabaseChangeRawMessage): void {
const lease = this.#leases.get(message.subscriptionId) ?? this.#openingLeases.get(message.subscriptionId);
if (!lease) return;
if (message.changeKind === "removed") {
if (message.recordKey === lease.recordKey && message.schemaId === lease.schemaId) lease.apply(undefined);
return;
}
const record = message.document;
if (record?.recordKey === lease.recordKey && record.schemaId === lease.schemaId) lease.apply(record);
}
private rejectCorrelated(reason: string): void {
if (this.#pendingOperations.size === 1) {
const [messageId, pending] = this.#pendingOperations.entries().next().value!;
this.#pendingOperations.delete(messageId);
clearTimeout(pending.timer);
pending.reject(new Error(reason));
}
}
private send(message: CultNetMessage): void {
const socket = this.#socket;
if (!socket || socket.readyState !== 1) throw new Error("CultMesh browser route is not connected.");
const payload = encode(encodeCultNetMessageForWire(message));
if (payload.byteLength > this.#options.maxFrameBytes) {
throw new Error(`CultMesh message exceeds the ${this.#options.maxFrameBytes}-byte limit.`);
}
socket.send(payload);
}
private setState(state: CultMeshConnectionState): void {
if (this.#state === state) return;
this.#state = state;
for (const watcher of this.#stateWatchers) watcher(state);
}
private throwIfDisposed(): void {
if (this.#disposed) throw new Error("CultMesh browser client is disposed.");
}
}
export function decodeCultNetPayload<T>(record: CultNetRawDocumentRecord): T {
if (record.payloadEncoding !== "messagepack") {
throw new Error(`Unsupported CultNet document payload '${record.payloadEncoding}'.`);
}
return decode(record.payload) as T;
}
export function decodeCultNetOperationPayload<T>(response: CultNetOperationResponseMessage): T {
if (response.payloadEncoding !== "messagepack-base64") {
throw new Error(`Unsupported CultNet operation payload '${response.payloadEncoding ?? "unspecified"}'.`);
}
return decode(base64ToBytes(response.payload)) as T;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
return Uint8Array.from(binary, character => character.charCodeAt(0));
}
function requireText(value: string, field: string): void {
if (!value || value.trim().length === 0) throw new Error(`CultMesh browser ${field} is required.`);
}

View file

@ -0,0 +1,304 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decode, encode } from "@msgpack/msgpack";
import {
encodeCultNetMessageForWire,
parseCultNetMessage,
type CultNetDatabaseSubscribeMessage,
type CultNetMessage,
type CultNetOperationRequestMessage,
type CultNetRawDocumentRecord,
} from "cultnet-ts/contracts";
import {
CultMeshBrowserClient,
decodeCultNetOperationPayload,
decodeCultNetPayload,
type CultMeshBrowserRoute,
type CultMeshBrowserSocket,
} from "../src/index.js";
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);
let route = first.route;
let id = 0;
const client = await CultMeshBrowserClient.connect({
verseId: route.verseId,
providerId: route.providerId,
runtimeId: "browser-test",
rendezvous: { resolve: async () => route },
createId: () => `id-${++id}`,
socketFactory: endpoint => endpoint === first.route.endpoint ? first.open() : second.open(),
});
const lease = await client.leaseRawDocument({
schemaId: "sample.counter_state.v1",
recordKey: "counter:main",
});
assert.deepEqual(decodeCultNetPayload<{ count: number }>(lease.current!).count, 4);
const observed: number[] = [];
const unwatch = lease.watch(record => {
if (record) observed.push(decodeCultNetPayload<{ count: number }>(record).count);
});
const response = await client.invoke({
serviceId: "sample.counter",
operation: "increment",
payloadSchema: "sample.increment.v1",
payload: { amount: 2 },
});
assert.equal(response.status, "accepted");
assert.deepEqual(decodeCultNetOperationPayload<{ count: number }>(response), { count: 6 });
assert.deepEqual(observed, [4, 6]);
route = second.route;
await client.refreshRoute();
assert.equal(client.state, "connected");
assert.deepEqual(decodeCultNetPayload<{ count: number }>(lease.current!).count, 9);
assert.deepEqual(observed, [4, 6, 9]);
assert.equal(first.activeSubscriptionCount, 0);
assert.equal(second.activeSubscriptionCount, 1);
unwatch();
lease.dispose();
assert.equal(second.activeSubscriptionCount, 0);
await client.dispose();
});
test("browser client rejects wrong rendezvous identity and has no document write API", async () => {
const provider = new FakeProvider("ws://127.0.0.1:4201/mesh", 1);
await assert.rejects(
CultMeshBrowserClient.connect({
verseId: "sample.counter",
providerId: "sample.counter-provider",
runtimeId: "browser-test",
rendezvous: {
resolve: async () => ({
...provider.route,
providerId: "intruder-provider",
}),
},
socketFactory: () => provider.open(),
}),
/wrong stable identity/,
);
assert.equal("putDocument" in CultMeshBrowserClient.prototype, false);
assert.equal("setDocument" in CultMeshBrowserClient.prototype, false);
});
test("browser client bounds unanswered provider operations", async () => {
const route = {
verseId: "sample.counter",
providerId: "sample.counter-provider",
endpoint: "ws://127.0.0.1:4301/mesh",
};
const client = await CultMeshBrowserClient.connect({
...route,
runtimeId: "browser-timeout-test",
rendezvous: { resolve: async () => route },
requestTimeoutMs: 5,
socketFactory: () => new FakeSocket(() => undefined, () => undefined),
});
await assert.rejects(
client.invoke({
serviceId: "sample.counter",
operation: "increment",
payloadSchema: "sample.increment.v1",
payload: { amount: 1 },
idempotencyKey: "timeout-1",
}),
/timed out/,
);
await client.dispose();
});
test("browser client rejects oversized outbound schema messages", async () => {
const route = {
verseId: "sample.counter",
providerId: "sample.counter-provider",
endpoint: "ws://127.0.0.1:4401/mesh",
};
await assert.rejects(
CultMeshBrowserClient.connect({
...route,
runtimeId: "browser-size-test",
rendezvous: { resolve: async () => route },
maxFrameBytes: 8,
socketFactory: () => new FakeSocket(() => undefined, () => undefined),
}),
/exceeds the 8-byte limit/,
);
});
test("browser client reserves subscription identity while the initial lease opens", async () => {
const provider = new FakeProvider("ws://127.0.0.1:4501/mesh", 3);
const client = await CultMeshBrowserClient.connect({
...provider.route,
runtimeId: "browser-lease-race-test",
rendezvous: { resolve: async () => provider.route },
socketFactory: () => provider.open(),
});
const first = client.leaseRawDocument({
schemaId: "sample.counter_state.v1",
recordKey: "counter:main",
subscriptionId: "one-owner",
});
await assert.rejects(
client.leaseRawDocument({
schemaId: "sample.counter_state.v1",
recordKey: "counter:main",
subscriptionId: "one-owner",
}),
/already leased/,
);
(await first).dispose();
await client.dispose();
});
class FakeProvider {
readonly route: CultMeshBrowserRoute;
#count: number;
#subscriptions = new Map<FakeSocket, Map<string, CultNetDatabaseSubscribeMessage>>();
constructor(endpoint: string, count: number) {
this.route = {
verseId: "sample.counter",
providerId: "sample.counter-provider",
endpoint,
generation: endpoint,
};
this.#count = count;
}
get activeSubscriptionCount(): number {
let count = 0;
for (const subscriptions of this.#subscriptions.values()) count += subscriptions.size;
return count;
}
open(): FakeSocket {
const socket = new FakeSocket(
wire => this.receive(socket, wire),
() => this.#subscriptions.delete(socket),
);
this.#subscriptions.set(socket, new Map());
return socket;
}
private receive(socket: FakeSocket, wire: Uint8Array): void {
const message = parseCultNetMessage(decode(wire));
switch (message.schemaVersion) {
case "cultnet.database_subscribe.v0":
this.subscribe(socket, message);
return;
case "cultnet.database_unsubscribe.v0":
this.#subscriptions.get(socket)?.delete(message.subscriptionId);
return;
case "cultnet.operation_request.v0":
this.invoke(socket, message);
return;
default:
return;
}
}
private subscribe(socket: FakeSocket, message: CultNetDatabaseSubscribeMessage): void {
this.#subscriptions.get(socket)!.set(message.subscriptionId, message);
socket.deliver({
schemaVersion: "cultnet.snapshot_response_raw.v0",
messageId: message.messageId,
documents: [this.record()],
});
}
private invoke(socket: FakeSocket, message: CultNetOperationRequestMessage): void {
const payload = decode(base64ToBytes(message.payload)) as { amount: number };
this.#count += payload.amount;
for (const [peer, subscriptions] of this.#subscriptions) {
for (const subscriptionId of subscriptions.keys()) {
peer.deliver({
schemaVersion: "cultnet.database_change_raw.v0",
messageId: `change-${message.messageId}`,
subscriptionId,
changeKind: "updated",
document: this.record(),
});
}
}
socket.deliver({
schemaVersion: "cultnet.operation_response.v0",
messageId: message.messageId,
serviceId: message.serviceId,
operation: message.operation,
status: "accepted",
payloadSchema: "sample.increment_receipt.v1",
payloadEncoding: "messagepack-base64",
payload: bytesToBase64(encode({ count: this.#count })),
sourceRuntimeId: "sample.counter-provider",
});
}
private record(): CultNetRawDocumentRecord {
return {
schemaId: "sample.counter_state.v1",
recordKey: "counter:main",
storedAt: "2026-08-17T00:00:00Z",
payloadEncoding: "messagepack",
payload: encode({ count: this.#count }),
sourceRuntimeId: "sample.counter-provider",
};
}
}
class FakeSocket implements CultMeshBrowserSocket {
binaryType: BinaryType = "arraybuffer";
readyState = 0;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
#receive: (wire: Uint8Array) => void;
#closed: () => void;
constructor(receive: (wire: Uint8Array) => void, closed: () => void) {
this.#receive = receive;
this.#closed = closed;
queueMicrotask(() => {
this.readyState = 1;
this.onopen?.({} as Event);
});
}
send(data: ArrayBuffer | ArrayBufferView): void {
if (this.readyState !== 1) throw new Error("Fake socket is not open.");
const wire = data instanceof ArrayBuffer
? new Uint8Array(data)
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
this.#receive(wire);
}
close(code = 1000, reason = ""): void {
if (this.readyState === 3) return;
this.readyState = 3;
this.#closed();
this.onclose?.({ code, reason } as CloseEvent);
}
deliver(message: CultNetMessage): void {
const wire = encode(encodeCultNetMessageForWire(message));
queueMicrotask(() => this.onmessage?.({ data: wire.slice().buffer } as MessageEvent));
}
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
return Uint8Array.from(binary, character => character.charCodeAt(0));
}

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View file

@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["node"],
"declaration": false,
"declarationMap": false,
"sourceMap": false,
"outDir": "dist-test",
"rootDir": "."
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}

View file

@ -9,6 +9,11 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"require": "./dist/index.js", "require": "./dist/index.js",
"default": "./dist/index.js" "default": "./dist/index.js"
},
"./contracts": {
"types": "./dist/contracts.d.ts",
"require": "./dist/contracts.js",
"default": "./dist/contracts.js"
} }
}, },
"files": [ "files": [

View file

@ -951,8 +951,11 @@ function encodeLegacySchemaDescriptor(schema: CultNetSchemaDescriptor): Record<s
} }
function encodeBase64Url(input: Uint8Array): string { function encodeBase64Url(input: Uint8Array): string {
return Buffer.from(input) let binary = "";
.toString("base64") for (let offset = 0; offset < input.length; offset += 0x8000) {
binary += String.fromCharCode(...input.subarray(offset, offset + 0x8000));
}
return btoa(binary)
.replace(/\+/g, "-") .replace(/\+/g, "-")
.replace(/\//g, "_") .replace(/\//g, "_")
.replace(/=+$/u, ""); .replace(/=+$/u, "");
@ -971,7 +974,13 @@ function decodeBase64Url(input: string, fieldName: string): Uint8Array {
? normalized ? normalized
: normalized + "=".repeat(4 - remainder); : normalized + "=".repeat(4 - remainder);
return Buffer.from(padded, "base64"); let binary: string;
try {
binary = atob(padded);
} catch (error) {
throw new Error(`${fieldName} must be valid base64url data.`, { cause: error });
}
return Uint8Array.from(binary, character => character.charCodeAt(0));
} }
function validateDocumentPutRawMessage(input: unknown): asserts input is CultNetDocumentPutRawMessage { function validateDocumentPutRawMessage(input: unknown): asserts input is CultNetDocumentPutRawMessage {
@ -1127,6 +1136,12 @@ function normalizeCultNetOptionalNulls(
"supportedBodyTransports", "supportedBodyTransports",
]); ]);
return; return;
case "cultnet.operation_request.v0":
stripNullProperties(candidate, ["payloadEncoding", "sourceRuntimeId", "targetRuntimeId"]);
return;
case "cultnet.operation_response.v0":
stripNullProperties(candidate, ["payloadEncoding", "diagnostics", "sourceRuntimeId"]);
return;
case "cultnet.schema_catalog_request.v0": case "cultnet.schema_catalog_request.v0":
stripNullProperties(candidate, ["schemaIds", "kinds"]); stripNullProperties(candidate, ["schemaIds", "kinds"]);
return; return;

View file

@ -821,9 +821,9 @@ test("CultNet can round-trip gamecult.networking.v0 auth messages through the ex
assert.deepEqual(wireValue, [ assert.deepEqual(wireValue, [
0, 0,
[ [
Buffer.from("nonce", "utf8"), new TextEncoder().encode("nonce"),
Buffer.from("auth", "utf8"), new TextEncoder().encode("auth"),
Buffer.from("password", "utf8"), new TextEncoder().encode("password"),
], ],
]); ]);
@ -1261,6 +1261,24 @@ test("operation envelopes preserve typed service routing and payload correlation
assert.equal(response.messageId, request.messageId); assert.equal(response.messageId, request.messageId);
}); });
test("CultNet contracts encode legacy bytes without Node Buffer authority", () => {
const originalBuffer = globalThis.Buffer;
try {
Object.defineProperty(globalThis, "Buffer", { configurable: true, value: undefined });
const message = parseCultNetMessage({
schemaVersion: "cultnet.login.v0",
nonce: "AQID",
auth: "BAUG",
password: "BwgJ",
});
const wire = encodeCultNetMessageForWire(message, "gamecult.networking.v0") as [number, Uint8Array[]];
assert.equal(wire[0], 0);
assert.deepEqual([...wire[1][0]], [1, 2, 3]);
} finally {
Object.defineProperty(globalThis, "Buffer", { configurable: true, value: originalBuffer });
}
});
test("database subscription contracts match the C# live document lane", () => { test("database subscription contracts match the C# live document lane", () => {
const subscribe = parseCultNetMessage({ const subscribe = parseCultNetMessage({
schemaVersion: "cultnet.database_subscribe.v0", schemaVersion: "cultnet.database_subscribe.v0",

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

View file

@ -6,7 +6,7 @@ if (!command) throw new Error("Usage: node scripts/run-typescript-workspaces.mjs
const npmCli = process.env.npm_execpath ?? const npmCli = process.env.npm_execpath ??
join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"); join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
const workspaces = ["cultcache-ts", "cultnet-ts", "cultmesh-ts"]; const workspaces = ["cultcache-ts", "cultnet-ts", "cultmesh-ts", "cultmesh-browser"];
const childEnv = process.platform === "win32" const childEnv = process.platform === "win32"
? { ? {
...process.env, ...process.env,

View file

@ -6,7 +6,7 @@ import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const workspacePackages = ["cultcache-ts", "cultnet-ts", "cultmesh-ts"]; const workspacePackages = ["cultcache-ts", "cultnet-ts", "cultmesh-ts", "cultmesh-browser"];
const npmCli = process.env.npm_execpath ?? const npmCli = process.env.npm_execpath ??
join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"); join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
const childEnv = process.platform === "win32" const childEnv = process.platform === "win32"
@ -69,6 +69,17 @@ assert.deepEqual(mesh.CultMesh.vec2(3, 4), { x: 3, y: 4 });
`); `);
execFileSync(process.execPath, ["runtime-smoke.cjs"], { cwd: consumerRoot, stdio: "inherit" }); execFileSync(process.execPath, ["runtime-smoke.cjs"], { cwd: consumerRoot, stdio: "inherit" });
writeFileSync(join(consumerRoot, "browser-runtime-smoke.mjs"), `
import assert from "node:assert/strict";
import { CultMeshBrowserClient, decodeCultNetPayload } from "cultmesh-browser";
import { parseCultNetMessage } from "cultnet-ts/contracts";
assert.equal(typeof CultMeshBrowserClient.connect, "function");
assert.equal(typeof decodeCultNetPayload, "function");
assert.equal(typeof parseCultNetMessage, "function");
assert.equal("putDocument" in CultMeshBrowserClient.prototype, false);
`);
execFileSync(process.execPath, ["browser-runtime-smoke.mjs"], { cwd: consumerRoot, stdio: "inherit" });
writeFileSync(join(consumerRoot, "types-smoke.ts"), ` writeFileSync(join(consumerRoot, "types-smoke.ts"), `
import { defineDocumentType } from "cultcache-ts"; import { defineDocumentType } from "cultcache-ts";
import { CultNetPeer } from "cultnet-ts"; import { CultNetPeer } from "cultnet-ts";
@ -111,6 +122,37 @@ void providerTransport;
"types-smoke.ts", "types-smoke.ts",
], { cwd: consumerRoot, stdio: "inherit" }); ], { cwd: consumerRoot, stdio: "inherit" });
writeFileSync(join(consumerRoot, "browser-types-smoke.mts"), `
import {
CultMeshBrowserClient,
type CultMeshBrowserIdentity,
type CultMeshRawDocumentLeaseOptions,
} from "cultmesh-browser";
const identity: CultMeshBrowserIdentity = {
verseId: "smoke",
providerId: "smoke.provider",
};
const lease: CultMeshRawDocumentLeaseOptions = {
schemaId: "smoke.document.v1",
recordKey: "smoke:main",
};
const clientType: typeof CultMeshBrowserClient = CultMeshBrowserClient;
void identity;
void lease;
void clientType;
`);
execFileSync(process.execPath, [
tsc,
"--noEmit",
"--strict",
"--target", "ES2022",
"--module", "NodeNext",
"--moduleResolution", "NodeNext",
"--skipLibCheck",
"browser-types-smoke.mts",
], { cwd: consumerRoot, stdio: "inherit" });
console.log("TypeScript package closure smoke: passed"); console.log("TypeScript package closure smoke: passed");
for (const tarball of tarballs) console.log(`Packed: ${tarball}`); for (const tarball of tarballs) console.log(`Packed: ${tarball}`);
} finally { } finally {

View file

@ -0,0 +1,198 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { createServer as createHttpServer } from "node:http";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { createServer as createNetServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
import { chromium } from "playwright-core";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const eveFlag = process.argv.indexOf("--eve-root");
const eveRoot = resolve(eveFlag >= 0 ? process.argv[eveFlag + 1] : join(repoRoot, "..", "Eve"));
const sampleRoot = join(repoRoot, "samples", "eve-browser-network");
const workRoot = await mkdtemp(join(tmpdir(), "cultmesh-browser-network-"));
const statePath = join(workRoot, "counter.cc");
const bundlePath = join(workRoot, "bundle.js");
const token = "sample-session";
let provider;
let headless;
let httpServer;
let browser;
try {
const tsc = join(repoRoot, "node_modules", "typescript", "bin", "tsc");
await run(process.execPath, [tsc, "-p", join(repoRoot, "packages", "cultnet-ts", "tsconfig.json"), "--pretty", "false"]);
await run(process.execPath, [tsc, "-p", join(repoRoot, "packages", "cultmesh-browser", "tsconfig.json"), "--pretty", "false"]);
await build({
entryPoints: [join(sampleRoot, "browser", "main.ts")],
outfile: bundlePath,
bundle: true,
format: "esm",
platform: "browser",
target: "es2022",
sourcemap: true,
alias: {
"@gamecult/eve-contracts": join(eveRoot, "packages", "eve-contracts", "src", "index.ts"),
"@gamecult/eve-browser-lowering": join(eveRoot, "packages", "eve-browser-lowering", "src", "index.ts"),
},
logLevel: "warning",
});
const bundle = await readFile(bundlePath, "utf8");
assert.doesNotMatch(bundle, /(?:from\s*["']node:|require\(["']node:)/u, "browser bundle contains a Node builtin import");
await run("dotnet", ["build", join(sampleRoot, "EveBrowserNetworkSample.csproj"), "--verbosity", "quiet"]);
const providerPort = await freePort();
const httpPort = await freePort();
provider = await startProvider(providerPort);
const endpoint = await provider.waitFor("PROVIDER_READY ");
headless = startDotnet(["headless", "--endpoint", endpoint, "--token", token]);
await headless.waitFor("HEADLESS_READY ");
httpServer = await serve(httpPort, sampleRoot, bundlePath);
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}`;
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), 0);
await page.locator("button").click();
await page.waitForFunction(() => window.__sampleReceipt?.count === 1 && window.__sampleCount === 1);
const firstReceipt = await page.evaluate(() => window.__sampleReceipt?.receiptId);
assert.equal(JSON.parse(await headless.waitFor("HEADLESS_UPDATE ")).count, 1);
await page.locator("button").click();
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);
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");
console.log(JSON.stringify({
providerEndpoint: endpoint,
browserRuntime: "chromium",
headlessRuntime: "csharp",
receiptId: firstReceipt,
count: 1,
restartCount: 1,
}));
} finally {
if (browser) await browser.close().catch(() => undefined);
if (headless) await stop(headless.process);
if (provider) await stop(provider.process);
if (httpServer) await new Promise(resolve => httpServer.close(resolve));
await rm(workRoot, { recursive: true, force: true });
}
async function startProvider(port) {
return startDotnet(["provider", "--port", String(port), "--state", statePath, "--token", token]);
}
function startDotnet(arguments_) {
const process = spawn("dotnet", [
"run", "--no-build", "--project", join(sampleRoot, "EveBrowserNetworkSample.csproj"), "--", ...arguments_,
], { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] });
let output = "";
let error = "";
const waiters = [];
process.stdout.setEncoding("utf8");
process.stderr.setEncoding("utf8");
process.stdout.on("data", chunk => { output += chunk; settle(); });
process.stderr.on("data", chunk => { error += chunk; });
process.on("exit", code => {
for (const waiter of waiters.splice(0)) {
clearTimeout(waiter.timer);
waiter.reject(new Error(`dotnet sample exited ${code}\n${output}\n${error}`));
}
});
function settle() {
for (let index = waiters.length - 1; index >= 0; index--) {
const waiter = waiters[index];
const line = output.split(/\r?\n/u).find(value => value.startsWith(waiter.prefix));
if (!line) continue;
waiters.splice(index, 1);
clearTimeout(waiter.timer);
waiter.resolve(line.slice(waiter.prefix.length));
}
}
return {
process,
waitFor(prefix, timeoutMs = 30_000) {
const existing = output.split(/\r?\n/u).find(value => value.startsWith(prefix));
if (existing) return Promise.resolve(existing.slice(prefix.length));
return new Promise((resolve, reject) => {
const waiter = {
prefix,
resolve,
reject,
timer: setTimeout(() => {
const index = waiters.indexOf(waiter);
if (index >= 0) waiters.splice(index, 1);
reject(new Error(`Timed out waiting for '${prefix}'.\n${output}\n${error}`));
}, timeoutMs),
};
waiters.push(waiter);
});
},
};
}
function run(command, arguments_) {
return new Promise((resolvePromise, reject) => {
const process = spawn(command, arguments_, { cwd: repoRoot, stdio: "inherit" });
process.on("error", reject);
process.on("exit", code => code === 0 ? resolvePromise() : reject(new Error(`${command} exited ${code}`)));
});
}
async function serve(port, root, builtBundle) {
const index = await readFile(join(root, "browser", "index.html"));
const server = createHttpServer(async (request, response) => {
if (request.url?.startsWith("/bundle.js")) {
response.setHeader("Content-Type", "text/javascript; charset=utf-8");
response.end(await readFile(builtBundle));
return;
}
response.setHeader("Content-Type", "text/html; charset=utf-8");
response.end(index);
});
await new Promise((resolvePromise, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", resolvePromise);
});
return server;
}
async function freePort() {
const server = createNetServer();
await new Promise((resolvePromise, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolvePromise);
});
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
await new Promise(resolvePromise => server.close(resolvePromise));
return port;
}
async function stop(process) {
if (!process || process.exitCode !== null) return;
process.kill();
await Promise.race([
new Promise(resolvePromise => process.once("exit", resolvePromise)),
new Promise(resolvePromise => setTimeout(resolvePromise, 2_000)),
]);
if (process.exitCode === null) process.kill("SIGKILL");
}

View file

@ -4,23 +4,38 @@ Date: 2026-08-17
## Status ## Status
This is the implementation map for the missing real-browser checkpoint. The This is the implementation map for the browser Verse boundary.
current `samples/eve-two-runtime` proof uses jsdom and one local TypeScript `samples/eve-two-runtime` remains the clean package-consumer/jsdom checkpoint.
CultMesh node. It is a clean package-consumer and contract-validation proof, `samples/eve-browser-network` runs a durable C# provider, a real Chromium Eve
not browser networking. 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.
The current substrate is incomplete: The current substrate has a browser client boundary and local live proof, but
not a complete public Verse proof:
- `cultmesh-ts` is a Node package and imports `node:dgram` at its root; - `cultmesh-ts` is a Node package and imports `node:dgram` at its root;
- `cultnet-ts` mixes browser-safe schema contracts with Node transport, - `cultnet-ts/contracts` is the browser-safe contract entrypoint; the package
filesystem, crypto, stream, and UDP modules; root still includes Node transport, filesystem, crypto, stream, and UDP
modules and must not enter browser bundles;
- C# and TypeScript now share `cultnet.database_subscribe.v0`, - C# and TypeScript now share `cultnet.database_subscribe.v0`,
`cultnet.database_unsubscribe.v0`, and `cultnet.database_change_raw.v0`; `cultnet.database_unsubscribe.v0`, and `cultnet.database_change_raw.v0`;
the browser-safe transport package that consumes them does not exist yet; `cultmesh-browser` consumes those messages as explicit document leases;
- Kotlin has a channel-aware WebSocket transport adapter; C# and TypeScript do - `cultmesh-browser` owns stable identity, route refresh, resubscription, and
not expose the equivalent browser/server pair; typed operation correlation without exposing direct document writes;
- 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 - Hermodr can lower Odin state into a browser, but it is an infrastructure
runtime, not the missing clean-consumer game API. 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 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
as an independently consumable Eve artifact; Unity must not be that contract's
package owner.
## Authority Map ## Authority Map
@ -83,12 +98,13 @@ Shared paths:
Deletion line: Deletion line:
- stop calling jsdom a browser runtime; - the jsdom checkpoint remains labeled as a Node-hosted DOM proof;
- split browser-safe CultNet contracts from the Node transport package; - browser applications import `cultmesh-browser` and
- add the missing subscription messages to the shared generated contract `cultnet-ts/contracts`, never the Node-only package roots;
catalog instead of retyping them in a browser adapter; - subscription messages remain one shared schema contract across the C# and
- do not retain a sample-only WebSocket protocol after the canonical adapter TypeScript catalogs;
exists. - the real provider adapter must consume the canonical WebSocket message lane;
no sample-only WebSocket protocol is permitted.
## Required Runtime Shape ## Required Runtime Shape
@ -96,16 +112,24 @@ The browser-facing package must be importable by a standards-mode browser or a
normal bundler without Node polyfills. It exposes a narrow API shaped like: normal bundler without Node polyfills. It exposes a narrow API shaped like:
```ts ```ts
const mesh = await CultMeshBrowser.connect({ const mesh = await CultMeshBrowserClient.connect({
rendezvous: odin, rendezvous: odin,
verseId: "sample.counter", verseId: "sample.counter",
providerId: "sample.counter-provider", providerId: "sample.counter-provider",
runtimeId: "sample.browser",
}); });
using surface = await mesh.leaseDocument(eveSurface, "eve:surface:counter"); using surface = await mesh.leaseRawDocument({
surface.watch(render); schemaId: "gamecult.eve.surface.v1",
recordKey: "eve:surface:counter",
});
surface.watch(record => render(decodeCultNetPayload(record)));
const receipt = await mesh.invoke(incrementCounter, {}, { const receipt = await mesh.invoke({
serviceId: "sample.counter",
operation: "increment",
payloadSchema: "sample.increment.v1",
payload: {},
idempotencyKey: crypto.randomUUID(), idempotencyKey: crypto.randomUUID(),
}); });
``` ```
@ -136,6 +160,20 @@ identity, authority, and idempotency validation happen before provider code.
## Acceptance Gate ## Acceptance Gate
Current executable coverage from `scripts/verify-eve-browser-network.mjs`:
- authenticated binary C# WebSocket provider;
- 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;
- 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.
From an empty temporary consumer using packed/released artifacts only: From an empty temporary consumer using packed/released artifacts only:
1. start a provider daemon with one durable counter document and one validated 1. start a provider daemon with one durable counter document and one validated

View file

@ -61,11 +61,17 @@ cd E:\Projects\EveUnity
powershell -ExecutionPolicy Bypass -File .\scripts\run-release-consumer-tests.ps1 powershell -ExecutionPolicy Bypass -File .\scripts\run-release-consumer-tests.ps1
``` ```
This checkpoint proves clean package consumption, canonical Eve contract This first checkpoint proves clean package consumption, canonical Eve contract
validation, and two independent lowering/observation consumers inside Node. It validation, and two independent lowering/observation consumers inside Node. It
does **not** claim a real browser process, an Odin-discovered network hop, or does **not** claim a real browser process, an Odin-discovered network hop, or
cross-language command execution. Those are the next public Verse conformance cross-language command execution. Do not cite it as evidence for those layers.
gates; do not cite this sample as evidence that they exist. Their owner map and
executable acceptance contract are recorded in 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
[`browser-verse-transport.md`](../browser-verse-transport.md). [`browser-verse-transport.md`](../browser-verse-transport.md).

View file

@ -0,0 +1,166 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using GameCult.Networking;
namespace GameCult.Networking.WebSockets;
/// <summary>Schema-v0 client over a binary WebSocket route.</summary>
public sealed class CultNetWebSocketSchemaClient : ICultNetSchemaClient, ICultNetSchemaClientHealth
{
private readonly ConcurrentDictionary<Type, List<Delegate>> _handlers = new();
private readonly object _handlerGate = new();
private readonly SemaphoreSlim _sendGate = new(1, 1);
private readonly CancellationTokenSource _shutdown = new();
private readonly TaskCompletionSource<Exception> _backgroundFailure = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private readonly Action<ClientWebSocketOptions>? _configure;
private readonly int _maxMessageBytes;
private ClientWebSocket? _socket;
private bool _disposed;
/// <summary>Creates a binary WebSocket schema client.</summary>
public CultNetWebSocketSchemaClient(
Action<ClientWebSocketOptions>? configure = null,
int maxMessageBytes = 4 * 1024 * 1024)
{
if (maxMessageBytes <= 0) throw new ArgumentOutOfRangeException(nameof(maxMessageBytes));
_configure = configure;
_maxMessageBytes = maxMessageBytes;
}
/// <inheritdoc />
public bool Connected => !_disposed && _socket?.State == WebSocketState.Open;
/// <inheritdoc />
public Task<Exception> BackgroundFailure => _backgroundFailure.Task;
/// <inheritdoc />
public void Connect(string host, int port)
{
ArgumentException.ThrowIfNullOrWhiteSpace(host);
if (port <= 0 || port > 65535) throw new ArgumentOutOfRangeException(nameof(port));
ConnectAsync(new Uri($"ws://{host}:{port}/cultmesh"), CancellationToken.None)
.GetAwaiter().GetResult();
}
/// <summary>Connects to an exact WebSocket route.</summary>
public async Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(endpoint);
if (endpoint.Scheme is not ("ws" or "wss"))
throw new ArgumentException("CultNet WebSocket endpoint must use ws:// or wss://.", nameof(endpoint));
ThrowIfDisposed();
if (_socket != null) throw new InvalidOperationException("CultNet WebSocket client is already connected.");
var socket = new ClientWebSocket();
_configure?.Invoke(socket.Options);
try
{
await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false);
_socket = socket;
_ = ReceiveLoopAsync(socket, _shutdown.Token);
}
catch
{
socket.Dispose();
throw;
}
}
/// <inheritdoc />
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage
{
ArgumentNullException.ThrowIfNull(message);
ThrowIfDisposed();
var socket = _socket;
if (socket?.State != WebSocketState.Open)
throw new InvalidOperationException("CultNet WebSocket client is not connected.");
var payload = CultNetSchemaMessageSerialization.Serialize(message);
if (payload.Length > _maxMessageBytes)
throw new InvalidOperationException(
$"CultNet message exceeds the {_maxMessageBytes}-byte WebSocket limit.");
_sendGate.Wait();
try
{
CultNetWebSocketMessageIO.SendAsync(socket, payload, _shutdown.Token).GetAwaiter().GetResult();
}
finally
{
_sendGate.Release();
}
}
/// <inheritdoc />
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
{
ArgumentNullException.ThrowIfNull(callback);
ThrowIfDisposed();
lock (_handlerGate)
{
if (!_handlers.TryGetValue(typeof(T), out var handlers))
_handlers[typeof(T)] = handlers = new List<Delegate>();
handlers.Add(callback);
}
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_shutdown.Cancel();
var socket = _socket;
_socket = null;
try
{
if (socket?.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
using var closeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(1));
socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "CultNet client disposed", closeTimeout.Token)
.GetAwaiter().GetResult();
}
}
catch (Exception error) when (error is WebSocketException or OperationCanceledException)
{
socket?.Abort();
}
socket?.Dispose();
_sendGate.Dispose();
_shutdown.Dispose();
}
private async Task ReceiveLoopAsync(WebSocket socket, CancellationToken cancellationToken)
{
try
{
while (!_disposed && socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
var payload = await CultNetWebSocketMessageIO.ReceiveAsync(socket, _maxMessageBytes, cancellationToken)
.ConfigureAwait(false);
if (payload == null) return;
Dispatch(CultNetSchemaMessageSerialization.Deserialize(payload));
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception error) when (!_disposed)
{
_backgroundFailure.TrySetResult(error);
}
}
private void Dispatch(ICultNetSchemaMessage message)
{
Delegate[] handlers;
lock (_handlerGate)
handlers = _handlers.TryGetValue(message.GetType(), out var registered)
? registered.ToArray()
: Array.Empty<Delegate>();
foreach (var handler in handlers) handler.DynamicInvoke(message);
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
}

View file

@ -0,0 +1,260 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.WebSockets;
using GameCult.Networking;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace GameCult.Networking.WebSockets;
/// <summary>Controls the bounded CultNet schema-v0 WebSocket endpoint.</summary>
public sealed class CultNetWebSocketEndpointOptions
{
/// <summary>Maximum accepted binary CultNet message size.</summary>
public int MaxMessageBytes { get; init; } = 4 * 1024 * 1024;
/// <summary>
/// Authorizes the HTTP upgrade before it can become a CultNet peer. Production hosts
/// should bind this to their authenticated session middleware.
/// </summary>
public Func<HttpContext, ValueTask<bool>>? AuthorizeAsync { get; init; }
/// <summary>Allows an unauthenticated endpoint for explicit local development only.</summary>
public bool AllowAnonymousDevelopment { get; init; }
}
/// <summary>
/// Adapts one binary WebSocket endpoint to the transport-neutral CultNet schema server port.
/// The host owns HTTP, TLS, and authentication; this adapter owns bounded binary message
/// assembly, schema validation, handler dispatch, peer lifetime, and serialized sends.
/// </summary>
public sealed class CultNetWebSocketSchemaServer :
ICultNetSchemaServer,
ICultNetSchemaServerPeerLifecycle,
IAsyncDisposable
{
private readonly ConcurrentDictionary<Type, Delegate> _handlers = new();
private readonly ConcurrentDictionary<WebSocketPeer, byte> _peers = new();
private bool _disposed;
/// <inheritdoc />
public event Action<ICultNetSchemaServerPeer>? PeerDisconnected;
/// <summary>Gets the active upgraded peer count.</summary>
public int PeerCount => _peers.Count;
/// <inheritdoc />
public void OnCultNet<TMessage>(Func<TMessage, ICultNetSchemaServerPeer, Task> callback)
where TMessage : ICultNetSchemaMessage
{
ArgumentNullException.ThrowIfNull(callback);
ThrowIfDisposed();
_handlers[typeof(TMessage)] = callback;
}
/// <inheritdoc />
public void RemoveCultNetMessageListener<TMessage>(Delegate callback)
where TMessage : ICultNetSchemaMessage
{
if (_handlers.TryGetValue(typeof(TMessage), out var existing) && existing == callback)
_handlers.TryRemove(typeof(TMessage), out _);
}
/// <summary>Serves one already-authorized WebSocket until it closes.</summary>
public async Task AcceptAsync(
WebSocket socket,
EndPoint? remoteEndPoint = null,
int maxMessageBytes = 4 * 1024 * 1024,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(socket);
ThrowIfDisposed();
if (maxMessageBytes <= 0) throw new ArgumentOutOfRangeException(nameof(maxMessageBytes));
var peer = new WebSocketPeer(socket, remoteEndPoint, maxMessageBytes);
if (!_peers.TryAdd(peer, 0)) throw new InvalidOperationException("CultNet WebSocket peer already exists.");
try
{
while (!_disposed && socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
var payload = await CultNetWebSocketMessageIO.ReceiveAsync(socket, maxMessageBytes, cancellationToken)
.ConfigureAwait(false);
if (payload == null) break;
var message = CultNetSchemaMessageSerialization.Deserialize(payload);
if (!_handlers.TryGetValue(message.GetType(), out var handler)) continue;
if (handler.DynamicInvoke(message, peer) is Task task) await task.ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
finally
{
if (_peers.TryRemove(peer, out _)) PeerDisconnected?.Invoke(peer);
await peer.DisposeAsync().ConfigureAwait(false);
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
var peers = _peers.Keys.ToArray();
_peers.Clear();
foreach (var peer in peers)
{
PeerDisconnected?.Invoke(peer);
await peer.DisposeAsync().ConfigureAwait(false);
}
_handlers.Clear();
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
private sealed class WebSocketPeer :
ICultNetSchemaServerPeer,
ICultNetSchemaServerPeerLocation,
IAsyncDisposable
{
private readonly WebSocket _socket;
private readonly SemaphoreSlim _sendGate = new(1, 1);
private readonly int _maxMessageBytes;
private bool _disposed;
public WebSocketPeer(WebSocket socket, EndPoint? remoteEndPoint, int maxMessageBytes)
{
_socket = socket;
RemoteEndPoint = remoteEndPoint;
_maxMessageBytes = maxMessageBytes;
}
public EndPoint? RemoteEndPoint { get; }
public void SendCultNet<TMessage>(TMessage message) where TMessage : ICultNetSchemaMessage
{
ArgumentNullException.ThrowIfNull(message);
if (_disposed || _socket.State != WebSocketState.Open)
throw new InvalidOperationException("CultNet WebSocket peer is not open.");
var payload = CultNetSchemaMessageSerialization.Serialize(message);
if (payload.Length > _maxMessageBytes)
throw new InvalidOperationException(
$"CultNet message exceeds the {_maxMessageBytes}-byte WebSocket limit.");
_sendGate.Wait();
try
{
CultNetWebSocketMessageIO.SendAsync(_socket, payload, CancellationToken.None)
.GetAwaiter().GetResult();
}
finally
{
_sendGate.Release();
}
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
try
{
if (_socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
using var closeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(1));
await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "CultNet peer closed", closeTimeout.Token)
.ConfigureAwait(false);
}
}
catch (Exception error) when (error is WebSocketException or OperationCanceledException)
{
_socket.Abort();
}
_socket.Dispose();
_sendGate.Dispose();
}
}
}
internal static class CultNetWebSocketMessageIO
{
public static Task SendAsync(WebSocket socket, byte[] payload, CancellationToken cancellationToken) =>
socket.SendAsync(payload, WebSocketMessageType.Binary, true, cancellationToken);
public static async Task<byte[]?> ReceiveAsync(
WebSocket socket,
int maxMessageBytes,
CancellationToken cancellationToken)
{
var segment = new byte[Math.Min(16 * 1024, maxMessageBytes)];
using var message = new MemoryStream();
while (true)
{
var result = await socket.ReceiveAsync(segment, cancellationToken).ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close) return null;
if (result.MessageType != WebSocketMessageType.Binary)
{
await socket.CloseAsync(
WebSocketCloseStatus.InvalidMessageType,
"CultNet requires binary MessagePack WebSocket messages.",
cancellationToken).ConfigureAwait(false);
return null;
}
if (message.Length + result.Count > maxMessageBytes)
{
await socket.CloseAsync(
WebSocketCloseStatus.MessageTooBig,
"CultNet message exceeds the configured maximum.",
cancellationToken).ConfigureAwait(false);
return null;
}
message.Write(segment, 0, result.Count);
if (result.EndOfMessage) return message.ToArray();
}
}
}
/// <summary>ASP.NET Core endpoint mapping for the CultNet WebSocket schema server.</summary>
public static class CultNetWebSocketEndpointRouteBuilderExtensions
{
/// <summary>Maps one authenticated binary CultNet WebSocket endpoint.</summary>
public static IEndpointConventionBuilder MapCultNetWebSocket(
this IEndpointRouteBuilder endpoints,
string pattern,
CultNetWebSocketSchemaServer server,
CultNetWebSocketEndpointOptions options)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentException.ThrowIfNullOrWhiteSpace(pattern);
ArgumentNullException.ThrowIfNull(server);
ArgumentNullException.ThrowIfNull(options);
if (options.MaxMessageBytes <= 0) throw new ArgumentOutOfRangeException(nameof(options.MaxMessageBytes));
if (options.AuthorizeAsync == null && !options.AllowAnonymousDevelopment)
throw new InvalidOperationException(
"CultNet WebSocket endpoints require AuthorizeAsync unless AllowAnonymousDevelopment is explicitly enabled.");
return endpoints.Map(pattern, async context =>
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status426UpgradeRequired;
return;
}
if (options.AuthorizeAsync != null && !await options.AuthorizeAsync(context).ConfigureAwait(false))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
using var socket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
await server.AcceptAsync(
socket,
context.Connection.RemoteIpAddress == null
? null
: new IPEndPoint(context.Connection.RemoteIpAddress, context.Connection.RemotePort),
options.MaxMessageBytes,
context.RequestAborted).ConfigureAwait(false);
});
}
}

View file

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Description>CultNet schema-v0 WebSocket host adapter for browser and web runtimes</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GameCult.Networking\GameCult.Networking.csproj" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>

View file

@ -390,6 +390,21 @@ peer lookup and handshake before returning the same RUDP transport. Packet
semantics, schema-channel send/receive helpers, and reconnect state still live semantics, schema-channel send/receive helpers, and reconnect state still live
in `GameCult.Networking`. in `GameCult.Networking`.
## Browser WebSocket Host
`GameCult.Networking.WebSockets` keeps ASP.NET Core hosting out of the
netstandard CultNet core while adapting an authenticated binary WebSocket to
the same `ICultNetSchemaServer` and `ICultNetSchemaClient` ports. A host calls
`UseWebSockets()` and maps `MapCultNetWebSocket(...)` with an authorization
predicate. Anonymous use requires the explicit `AllowAnonymousDevelopment`
opt-in. Text frames and messages over the configured size bound are closed
before schema dispatch.
The matching `CultNetWebSocketSchemaClient` is suitable for C# headless and
service consumers. Browser applications use `cultmesh-browser`, which owns
stable Verse/provider identity, document leases, resubscription, and operation
correlation above the raw WebSocket route.
## Important Constraints ## Important Constraints
- `Server` currently centers on `PlayerData` as the built-in account model. - `Server` currently centers on `PlayerData` as the built-in account model.

View file

@ -0,0 +1,95 @@
using System.Net;
using GameCult.Networking.WebSockets;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
namespace GameCult.Networking.Tests;
[TestFixture]
public sealed class CultNetWebSocketSchemaServerTests
{
[Test]
public async Task BinarySchemaMessage_RoundTrips_ThroughAuthenticatedHostAdapter()
{
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0));
await using var server = new CultNetWebSocketSchemaServer();
server.OnCultNet<CultNetHelloMessage>((message, peer) =>
{
peer.SendCultNet(new CultNetHelloMessage
{
RuntimeId = "csharp-websocket-provider",
RuntimeKind = "provider",
DisplayName = message.RuntimeId
});
return Task.CompletedTask;
});
var app = builder.Build();
app.UseWebSockets();
app.MapCultNetWebSocket("/cultmesh", server, new CultNetWebSocketEndpointOptions
{
AuthorizeAsync = context => ValueTask.FromResult(
string.Equals(context.Request.Headers.Authorization, "Bearer test-session", StringComparison.Ordinal))
});
await app.StartAsync();
try
{
var address = app.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>()!.Addresses.Single();
var endpoint = new Uri(address.Replace("http://", "ws://", StringComparison.Ordinal) + "/cultmesh");
using var client = new CultNetWebSocketSchemaClient(options =>
options.SetRequestHeader("Authorization", "Bearer test-session"));
var received = new TaskCompletionSource<CultNetHelloMessage>(
TaskCreationOptions.RunContinuationsAsynchronously);
client.OnCultNet<CultNetHelloMessage>(message => received.TrySetResult(message));
await client.ConnectAsync(endpoint);
client.SendCultNet(new CultNetHelloMessage
{
RuntimeId = "browser-consumer",
RuntimeKind = "browser"
});
var response = await received.Task.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Multiple(() =>
{
Assert.That(response.RuntimeId, Is.EqualTo("csharp-websocket-provider"));
Assert.That(response.DisplayName, Is.EqualTo("browser-consumer"));
Assert.That(server.PeerCount, Is.EqualTo(1));
});
client.Dispose();
await WaitUntilAsync(() => server.PeerCount == 0);
}
finally
{
await app.StopAsync();
await app.DisposeAsync();
}
}
[Test]
public void Endpoint_Requires_Explicit_Authentication_Or_Development_OptIn()
{
var builder = WebApplication.CreateSlimBuilder();
var app = builder.Build();
using var server = new AsyncDisposableScope(new CultNetWebSocketSchemaServer());
Assert.That(
() => app.MapCultNetWebSocket("/cultmesh", server.Value, new CultNetWebSocketEndpointOptions()),
Throws.InvalidOperationException.With.Message.Contains("AuthorizeAsync"));
}
private static async Task WaitUntilAsync(Func<bool> condition)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
while (!condition()) await Task.Delay(10, timeout.Token);
}
private sealed class AsyncDisposableScope : IDisposable
{
public AsyncDisposableScope(CultNetWebSocketSchemaServer value) => Value = value;
public CultNetWebSocketSchemaServer Value { get; }
public void Dispose() => Value.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
}

View file

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="NUnit" Version="4.*" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
</ItemGroup>
</Project>