Add real browser CultMesh transport proof
This commit is contained in:
parent
4af7398372
commit
8eb89e38ec
29 changed files with 2936 additions and 47 deletions
47
CultLib.sln
47
CultLib.sln
|
|
@ -35,6 +35,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Quic", "src\G
|
|||
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}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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|x86.ActiveCfg = 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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
|
@ -232,5 +276,8 @@ Global
|
|||
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
|
||||
{B67042B8-1135-4E54-B1EB-D45342C720D7} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
|
||||
{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
|
||||
EndGlobal
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -11,8 +11,9 @@ The stack is less a single product than a set of capabilities:
|
|||
|
||||
- logging primitives and implementations
|
||||
- CultCache typed documents, `.cc` persistence, and SoA memory access
|
||||
- CultNet schema-v0 messages over TCP, WebSocket, LiteNetLib adapters, and
|
||||
CultLib's native cross-runtime RUDP pipe
|
||||
- CultNet schema-v0 messages over TCP and LiteNetLib adapters, a bounded
|
||||
ASP.NET Core WebSocket host adapter, and CultLib's native cross-runtime RUDP
|
||||
pipe
|
||||
- CultMesh distributed realtime database, Verse discovery, authority leases,
|
||||
shard replication, and witness-authoritative simulation consensus
|
||||
- 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
|
@ -67,7 +68,10 @@ start with the maintained
|
|||
[`CultMesh And Eve Getting Started`](src/GameCult.Mesh/docs/getting-started/README.md)
|
||||
series. Its first executable package-consumer checkpoint is
|
||||
[`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).
|
||||
|
||||
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.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.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.Caching.Tests`: NUnit tests for cache and backing-store 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/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-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-rs`: Rust CultCache and derive macro
|
||||
- `packages/cultnet-rs`: Rust CultNet contracts, framing, discovery, and interop peer
|
||||
|
|
@ -114,6 +120,7 @@ src/
|
|||
GameCult.Caching.MessagePack.Analyzers/
|
||||
GameCult.Geometry/
|
||||
GameCult.Networking/
|
||||
GameCult.Networking.WebSockets/
|
||||
GameCult.Mesh/
|
||||
GameCult.Unity/
|
||||
tests/
|
||||
|
|
@ -124,6 +131,7 @@ packages/
|
|||
cultcache-ts/
|
||||
cultnet-ts/
|
||||
cultmesh-ts/
|
||||
cultmesh-browser/
|
||||
cultmesh-kotlin/
|
||||
cultcache-py/
|
||||
cultcache-rs/
|
||||
|
|
@ -149,6 +157,7 @@ npm test --workspace packages/cultcache-ts
|
|||
npm test --workspace packages/cultnet-ts
|
||||
npm run test:interop --workspace packages/cultnet-ts
|
||||
npm test --workspace packages/cultmesh-ts
|
||||
npm test --workspace packages/cultmesh-browser
|
||||
```
|
||||
|
||||
Rust package tests:
|
||||
|
|
|
|||
539
package-lock.json
generated
539
package-lock.json
generated
|
|
@ -8,8 +8,15 @@
|
|||
"workspaces": [
|
||||
"packages/cultcache-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": {
|
||||
"version": "11.9.3",
|
||||
|
|
@ -29,6 +36,448 @@
|
|||
"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": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
|
||||
|
|
@ -96,6 +545,10 @@
|
|||
"resolved": "packages/cultcache-ts",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/cultmesh-browser": {
|
||||
"resolved": "packages/cultmesh-browser",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/cultmesh-ts": {
|
||||
"resolved": "packages/cultmesh-ts",
|
||||
"link": true
|
||||
|
|
@ -104,6 +557,48 @@
|
|||
"resolved": "packages/cultnet-ts",
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
|
@ -111,9 +606,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -168,9 +663,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -250,6 +745,19 @@
|
|||
"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": {
|
||||
"version": "3.9.5",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
|
||||
|
|
@ -337,6 +845,21 @@
|
|||
"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": {
|
||||
"version": "0.1.1",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -4,11 +4,19 @@
|
|||
"workspaces": [
|
||||
"packages/cultcache-ts",
|
||||
"packages/cultnet-ts",
|
||||
"packages/cultmesh-ts"
|
||||
"packages/cultmesh-ts",
|
||||
"packages/cultmesh-browser"
|
||||
],
|
||||
"scripts": {
|
||||
"build:ts": "node scripts/run-typescript-workspaces.mjs build",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
40
packages/cultmesh-browser/README.md
Normal file
40
packages/cultmesh-browser/README.md
Normal 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.
|
||||
50
packages/cultmesh-browser/package.json
Normal file
50
packages/cultmesh-browser/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
545
packages/cultmesh-browser/src/index.ts
Normal file
545
packages/cultmesh-browser/src/index.ts
Normal 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.`);
|
||||
}
|
||||
304
packages/cultmesh-browser/test/cultmesh-browser.test.ts
Normal file
304
packages/cultmesh-browser/test/cultmesh-browser.test.ts
Normal 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));
|
||||
}
|
||||
16
packages/cultmesh-browser/tsconfig.json
Normal file
16
packages/cultmesh-browser/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/cultmesh-browser/tsconfig.test.json
Normal file
12
packages/cultmesh-browser/tsconfig.test.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -9,6 +9,11 @@
|
|||
"types": "./dist/index.d.ts",
|
||||
"require": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./contracts": {
|
||||
"types": "./dist/contracts.d.ts",
|
||||
"require": "./dist/contracts.js",
|
||||
"default": "./dist/contracts.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
|
|
|||
|
|
@ -951,8 +951,11 @@ function encodeLegacySchemaDescriptor(schema: CultNetSchemaDescriptor): Record<s
|
|||
}
|
||||
|
||||
function encodeBase64Url(input: Uint8Array): string {
|
||||
return Buffer.from(input)
|
||||
.toString("base64")
|
||||
let binary = "";
|
||||
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(/=+$/u, "");
|
||||
|
|
@ -971,7 +974,13 @@ function decodeBase64Url(input: string, fieldName: string): Uint8Array {
|
|||
? normalized
|
||||
: 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 {
|
||||
|
|
@ -1127,6 +1136,12 @@ function normalizeCultNetOptionalNulls(
|
|||
"supportedBodyTransports",
|
||||
]);
|
||||
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":
|
||||
stripNullProperties(candidate, ["schemaIds", "kinds"]);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -821,9 +821,9 @@ test("CultNet can round-trip gamecult.networking.v0 auth messages through the ex
|
|||
assert.deepEqual(wireValue, [
|
||||
0,
|
||||
[
|
||||
Buffer.from("nonce", "utf8"),
|
||||
Buffer.from("auth", "utf8"),
|
||||
Buffer.from("password", "utf8"),
|
||||
new TextEncoder().encode("nonce"),
|
||||
new TextEncoder().encode("auth"),
|
||||
new TextEncoder().encode("password"),
|
||||
],
|
||||
]);
|
||||
|
||||
|
|
@ -1261,6 +1261,24 @@ test("operation envelopes preserve typed service routing and payload correlation
|
|||
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", () => {
|
||||
const subscribe = parseCultNetMessage({
|
||||
schemaVersion: "cultnet.database_subscribe.v0",
|
||||
|
|
|
|||
10
samples/eve-browser-network/EveBrowserNetworkSample.csproj
Normal file
10
samples/eve-browser-network/EveBrowserNetworkSample.csproj
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
319
samples/eve-browser-network/Program.cs
Normal file
319
samples/eve-browser-network/Program.cs
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using GameCult.Networking;
|
||||
using GameCult.Networking.WebSockets;
|
||||
using MessagePack;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
const string counterKey = "counter:main";
|
||||
const string surfaceKey = "sample.counter";
|
||||
var arguments = Args.Parse(args);
|
||||
if (arguments.Mode == "provider")
|
||||
{
|
||||
await RunProviderAsync(arguments);
|
||||
return;
|
||||
}
|
||||
if (arguments.Mode == "headless")
|
||||
{
|
||||
await RunHeadlessAsync(arguments);
|
||||
return;
|
||||
}
|
||||
throw new InvalidOperationException("Use 'provider' or 'headless'.");
|
||||
|
||||
static async Task RunProviderAsync(Args arguments)
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes([typeof(CounterState), typeof(CounterSurfaceDocument)]);
|
||||
using var cache = await CultCacheMessagePack.OpenAsync(arguments.StatePath, new CultCacheOpenOptions
|
||||
{
|
||||
Registry = registry,
|
||||
FlushOnDispose = true,
|
||||
StoreFlushOnDispose = true
|
||||
});
|
||||
var documents = new CultNetDocumentRegistry(registry)
|
||||
.Register(CultNetDocumentBinding.ForDocument<CounterState>(registry, "sample.counter_state.v1"))
|
||||
.Register(CultNetDocumentBinding.ForDocument<CounterSurfaceDocument>(registry, "gamecult.eve.surface.v1"));
|
||||
using var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
|
||||
{
|
||||
RuntimeId = "sample.counter-provider",
|
||||
DocumentRegistry = documents
|
||||
});
|
||||
if (cache.Get<CounterState>(new CultRecordKey(counterKey)) == null)
|
||||
await database.PutAsync(new CultRecordKey(counterKey), CounterState.Initial(counterKey));
|
||||
if (cache.Get<CounterSurfaceDocument>(new CultRecordKey(surfaceKey)) == null)
|
||||
await database.PutAsync(new CultRecordKey(surfaceKey), CounterSurfaceDocument.Create());
|
||||
await cache.FlushAsync();
|
||||
|
||||
await using var schemaServer = new CultNetWebSocketSchemaServer();
|
||||
using var subscriptions = new CultNetDatabaseSubscriptionServer(schemaServer, database);
|
||||
var operationGate = new SemaphoreSlim(1, 1);
|
||||
schemaServer.OnCultNet<CultNetOperationRequestMessage>(async (request, peer) =>
|
||||
{
|
||||
if (request.ServiceId != "sample.counter" || request.Operation != "sample.counter.increment")
|
||||
{
|
||||
peer.SendCultNet(new CultNetErrorMessage { Error = "Unsupported sample operation." });
|
||||
return;
|
||||
}
|
||||
await operationGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
var current = cache.Get<CounterState>(new CultRecordKey(counterKey))
|
||||
?? throw new InvalidOperationException("Counter state is unavailable.");
|
||||
if (!current.Receipts.TryGetValue(request.MessageId, out var receipt))
|
||||
{
|
||||
var input = MessagePackSerializer.Deserialize<IncrementRequest>(
|
||||
Convert.FromBase64String(request.Payload));
|
||||
receipt = IncrementReceipt.Accepted(request.MessageId, current.Count + input.Amount);
|
||||
var receipts = new Dictionary<string, IncrementReceipt>(current.Receipts, StringComparer.Ordinal)
|
||||
{
|
||||
[request.MessageId] = receipt
|
||||
};
|
||||
await database.PutAsync(new CultRecordKey(counterKey), new CounterState
|
||||
{
|
||||
CounterId = counterKey,
|
||||
Count = receipt.Count,
|
||||
Receipts = receipts
|
||||
});
|
||||
await cache.FlushAsync();
|
||||
}
|
||||
peer.SendCultNet(new CultNetOperationResponseMessage
|
||||
{
|
||||
MessageId = request.MessageId,
|
||||
ServiceId = request.ServiceId,
|
||||
Operation = request.Operation,
|
||||
Status = "accepted",
|
||||
PayloadSchema = "gamecult.eve.command_receipt.v1",
|
||||
Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(receipt)),
|
||||
SourceRuntimeId = "sample.counter-provider"
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
operationGate.Release();
|
||||
}
|
||||
});
|
||||
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, arguments.Port));
|
||||
var app = builder.Build();
|
||||
app.UseWebSockets();
|
||||
app.MapCultNetWebSocket("/cultmesh", schemaServer, new CultNetWebSocketEndpointOptions
|
||||
{
|
||||
AuthorizeAsync = context => ValueTask.FromResult(
|
||||
context.Request.Cookies.TryGetValue("cultnet_session", out var token) &&
|
||||
string.Equals(token, arguments.Token, StringComparison.Ordinal))
|
||||
});
|
||||
await app.StartAsync();
|
||||
var address = app.Services.GetRequiredService<IServer>()
|
||||
.Features.Get<IServerAddressesFeature>()!.Addresses.Single();
|
||||
Console.WriteLine("PROVIDER_READY " + address.Replace("http://", "ws://", StringComparison.Ordinal) + "/cultmesh");
|
||||
await app.WaitForShutdownAsync();
|
||||
await app.DisposeAsync();
|
||||
}
|
||||
|
||||
static async Task RunHeadlessAsync(Args arguments)
|
||||
{
|
||||
var registry = CultDocumentRegistry.ForTypes([typeof(CounterState)]);
|
||||
using var cache = new CultCache(registry);
|
||||
var documents = new CultNetDocumentRegistry(registry)
|
||||
.Register(CultNetDocumentBinding.ForDocument<CounterState>(registry, "sample.counter_state.v1"));
|
||||
using var transport = new CultNetWebSocketSchemaClient(options =>
|
||||
options.SetRequestHeader("Cookie", $"cultnet_session={arguments.Token}"));
|
||||
using var subscriptions = new CultNetDatabaseSubscriptionClient(transport, cache, documents);
|
||||
var changed = new TaskCompletionSource<CounterState>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
subscriptions.Changed += change =>
|
||||
{
|
||||
if (change.Document is CounterState counter) changed.TrySetResult(counter);
|
||||
};
|
||||
await transport.ConnectAsync(new Uri(arguments.Endpoint));
|
||||
var initial = await subscriptions.SubscribeAsync(
|
||||
"headless-counter",
|
||||
recordKeys: [counterKey],
|
||||
schemaIds: ["sample.counter_state.v1"],
|
||||
deliveryMode: CultNetDatabaseSubscriptionDeliveryMode.Live);
|
||||
var counter = initial.OfType<CounterState>().Single();
|
||||
Console.WriteLine("HEADLESS_READY " + JsonSerializer.Serialize(new { count = counter.Count }));
|
||||
var update = await changed.Task.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
Console.WriteLine("HEADLESS_UPDATE " + JsonSerializer.Serialize(new { count = update.Count }));
|
||||
}
|
||||
|
||||
sealed class Args
|
||||
{
|
||||
public string Mode { get; private init; } = "";
|
||||
public int Port { get; private init; }
|
||||
public string StatePath { get; private init; } = "sample-counter.cc";
|
||||
public string Endpoint { get; private init; } = "";
|
||||
public string Token { get; private init; } = "sample-session";
|
||||
|
||||
public static Args Parse(string[] values)
|
||||
{
|
||||
if (values.Length == 0) return new Args();
|
||||
var named = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
for (var index = 1; index + 1 < values.Length; index += 2)
|
||||
named[values[index]] = values[index + 1];
|
||||
return new Args
|
||||
{
|
||||
Mode = values[0],
|
||||
Port = named.TryGetValue("--port", out var port) ? int.Parse(port) : 0,
|
||||
StatePath = named.GetValueOrDefault("--state", "sample-counter.cc"),
|
||||
Endpoint = named.GetValueOrDefault("--endpoint", ""),
|
||||
Token = named.GetValueOrDefault("--token", "sample-session")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
[CultDocument("sample.counter_state", "sample.counter_state.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class CounterState
|
||||
{
|
||||
[Key("counterId")] public string CounterId { get; set; } = "";
|
||||
[Key("count")] public int Count { get; set; }
|
||||
[Key("receipts")] public Dictionary<string, IncrementReceipt> Receipts { get; set; } = new(StringComparer.Ordinal);
|
||||
public static CounterState Initial(string counterId) => new() { CounterId = counterId };
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class IncrementRequest
|
||||
{
|
||||
[Key("amount")] public int Amount { get; set; } = 1;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class IncrementReceipt
|
||||
{
|
||||
[Key("receiptId")] public string ReceiptId { get; set; } = "";
|
||||
[Key("schema")] public string Schema { get; set; } = "gamecult.eve.command_receipt.v1";
|
||||
[Key("commandId")] public string CommandId { get; set; } = "";
|
||||
[Key("command")] public string Command { get; set; } = "sample.counter.increment";
|
||||
[Key("state")] public string State { get; set; } = "accepted";
|
||||
[Key("ownerRepo")] public string OwnerRepo { get; set; } = "CultLib";
|
||||
[Key("authority")] public string Authority { get; set; } = "provider-daemon";
|
||||
[Key("providerId")] public string ProviderId { get; set; } = "sample.counter-provider";
|
||||
[Key("surfaceId")] public string SurfaceId { get; set; } = "sample.counter";
|
||||
[Key("issuedAtUtc")] public string IssuedAtUtc { get; set; } = "";
|
||||
[Key("sourceVersion")] public int SourceVersion { get; set; }
|
||||
[Key("idempotencyKey")] public string IdempotencyKey { get; set; } = "";
|
||||
[Key("count")] public int Count { get; set; }
|
||||
|
||||
public static IncrementReceipt Accepted(string id, int count) => new()
|
||||
{
|
||||
ReceiptId = $"receipt:{id}",
|
||||
CommandId = id,
|
||||
IssuedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
|
||||
SourceVersion = count,
|
||||
IdempotencyKey = id,
|
||||
Count = count
|
||||
};
|
||||
}
|
||||
|
||||
[CultDocument("gamecult.eve.surface", "gamecult.eve.surface.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class CounterSurfaceDocument
|
||||
{
|
||||
[Key("type")] public string Type { get; set; } = "surface-state";
|
||||
[Key("schema")] public string Schema { get; set; } = "gamecult.eve.surface.v1";
|
||||
[Key("providerId")] public string ProviderId { get; set; } = "sample.counter-provider";
|
||||
[Key("providerKind")] public string ProviderKind { get; set; } = "sample.daemon";
|
||||
[Key("title")] public string Title { get; set; } = "CultMesh browser counter";
|
||||
[Key("version")] public int Version { get; set; } = 1;
|
||||
[Key("updatedAtUtc")] public string UpdatedAtUtc { get; set; } = "2026-08-17T00:00:00Z";
|
||||
[Key("surface")] public EveSurface Surface { get; set; } = new();
|
||||
[Key("commands")] public EveCommand[] Commands { get; set; } = [];
|
||||
|
||||
public static CounterSurfaceDocument Create() => new()
|
||||
{
|
||||
Surface = new EveSurface
|
||||
{
|
||||
Id = "sample.counter",
|
||||
Title = "Counter",
|
||||
Root = new EveComponent
|
||||
{
|
||||
Id = "counter.root",
|
||||
Kind = "column",
|
||||
Children =
|
||||
[
|
||||
new EveComponent
|
||||
{
|
||||
Id = "counter.value",
|
||||
Kind = "metric",
|
||||
Props = new Dictionary<string, object> { ["label"] = "Canonical count", ["value"] = 0 },
|
||||
StateBindings =
|
||||
[
|
||||
new EveStateBinding
|
||||
{
|
||||
TargetProp = "value",
|
||||
PointerId = "sample.counter.count",
|
||||
SourceId = "sample.counter_state:counter:main",
|
||||
SchemaId = "sample.counter_state.v1",
|
||||
RouteKind = "cultmesh"
|
||||
}
|
||||
]
|
||||
},
|
||||
new EveComponent
|
||||
{
|
||||
Id = "counter.increment",
|
||||
Kind = "control.button",
|
||||
Props = new Dictionary<string, object>
|
||||
{
|
||||
["label"] = "Increment",
|
||||
["command"] = "sample.counter.increment"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
Commands =
|
||||
[
|
||||
new EveCommand
|
||||
{
|
||||
Command = "sample.counter.increment",
|
||||
Label = "Increment",
|
||||
SurfaceId = "sample.counter"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class EveSurface
|
||||
{
|
||||
[Key("id")] public string Id { get; set; } = "";
|
||||
[Key("title")] public string Title { get; set; } = "";
|
||||
[Key("root")] public EveComponent Root { get; set; } = new();
|
||||
[Key("styles")] public Dictionary<string, object> Styles { get; set; } = [];
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class EveComponent
|
||||
{
|
||||
[Key("id")] public string Id { get; set; } = "";
|
||||
[Key("kind")] public string Kind { get; set; } = "";
|
||||
[Key("props")] public Dictionary<string, object> Props { get; set; } = [];
|
||||
[Key("children")] public EveComponent[] Children { get; set; } = [];
|
||||
[Key("stateBindings")] public EveStateBinding[] StateBindings { get; set; } = [];
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class EveStateBinding
|
||||
{
|
||||
[Key("targetProp")] public string TargetProp { get; set; } = "";
|
||||
[Key("pointerId")] public string PointerId { get; set; } = "";
|
||||
[Key("sourceId")] public string SourceId { get; set; } = "";
|
||||
[Key("schemaId")] public string SchemaId { get; set; } = "";
|
||||
[Key("routeKind")] public string RouteKind { get; set; } = "";
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class EveCommand
|
||||
{
|
||||
[Key("schema")] public string Schema { get; set; } = "gamecult.eve.command.v1";
|
||||
[Key("command")] public string Command { get; set; } = "";
|
||||
[Key("label")] public string Label { get; set; } = "";
|
||||
[Key("surfaceId")] public string SurfaceId { get; set; } = "";
|
||||
[Key("transport")] public string Transport { get; set; } = "cultmesh";
|
||||
[Key("authority")] public string Authority { get; set; } = "provider-daemon";
|
||||
[Key("result")] public string Result { get; set; } = "gamecult.eve.command_receipt.v1";
|
||||
}
|
||||
22
samples/eve-browser-network/README.md
Normal file
22
samples/eve-browser-network/README.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Eve Browser + Headless Counter
|
||||
|
||||
This is the real-process continuation of `samples/eve-two-runtime`:
|
||||
|
||||
- one C# provider owns a durable `.cc` counter, the Eve surface, command
|
||||
idempotency, and canonical receipts;
|
||||
- one real Chromium page leases both documents through `cultmesh-browser` and
|
||||
lowers the provider-owned Eve surface;
|
||||
- one independent C# headless client observes the same typed counter;
|
||||
- the browser and C# clients speak the same binary CultNet schema-v0 WebSocket
|
||||
messages through the authenticated host adapter.
|
||||
|
||||
Run the proof from the CultLib repository:
|
||||
|
||||
```powershell
|
||||
node scripts/verify-eve-browser-network.mjs --eve-root ..\Eve
|
||||
```
|
||||
|
||||
The verifier clicks the Eve button twice with one idempotency key, requires one
|
||||
state transition, restarts the provider, and requires the browser to rehydrate
|
||||
the persisted count. Odin route discovery is represented by the browser
|
||||
client's rendezvous port; a live Odin process remains the next discovery gate.
|
||||
13
samples/eve-browser-network/browser/index.html
Normal file
13
samples/eve-browser-network/browser/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CultMesh Eve browser counter</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="surface">Connecting to the Verse…</main>
|
||||
<output id="receipt" aria-live="polite"></output>
|
||||
<script type="module" src="/bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
84
samples/eve-browser-network/browser/main.ts
Normal file
84
samples/eve-browser-network/browser/main.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { parseEveCommandReceipt, parseEveSurfaceDocument } from "@gamecult/eve-contracts";
|
||||
import { renderEveSurface } from "@gamecult/eve-browser-lowering";
|
||||
import {
|
||||
CultMeshBrowserClient,
|
||||
decodeCultNetOperationPayload,
|
||||
decodeCultNetPayload,
|
||||
} from "cultmesh-browser";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__sampleReady?: boolean;
|
||||
__sampleCount?: number;
|
||||
__sampleReceipt?: { receiptId: string; count: number };
|
||||
__sampleError?: string;
|
||||
}
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const endpoint = params.get("endpoint");
|
||||
const token = params.get("token") || "sample-session";
|
||||
if (!endpoint) throw new Error("The sample requires an endpoint query parameter.");
|
||||
document.cookie = `cultnet_session=${encodeURIComponent(token)}; Path=/; SameSite=Strict`;
|
||||
|
||||
const host = document.querySelector<HTMLElement>("#surface")!;
|
||||
const receiptOutput = document.querySelector<HTMLOutputElement>("#receipt")!;
|
||||
|
||||
try {
|
||||
const mesh = await CultMeshBrowserClient.connect({
|
||||
verseId: "sample.counter",
|
||||
providerId: "sample.counter-provider",
|
||||
runtimeId: "sample.chromium",
|
||||
rendezvous: {
|
||||
resolve: async identity => ({ ...identity, endpoint }),
|
||||
},
|
||||
});
|
||||
const counter = await mesh.leaseRawDocument({
|
||||
schemaId: "sample.counter_state.v1",
|
||||
recordKey: "counter:main",
|
||||
subscriptionId: "browser-counter",
|
||||
});
|
||||
const surface = await mesh.leaseRawDocument({
|
||||
schemaId: "gamecult.eve.surface.v1",
|
||||
recordKey: "sample.counter",
|
||||
subscriptionId: "browser-surface",
|
||||
});
|
||||
const currentSurface = parseEveSurfaceDocument(decodeCultNetPayload(surface.current!));
|
||||
renderEveSurface(currentSurface, host, {
|
||||
activeSurfaceId: "sample.counter",
|
||||
clientId: "sample.chromium",
|
||||
commandSink: async intent => {
|
||||
const response = await mesh.invoke({
|
||||
serviceId: "sample.counter",
|
||||
operation: intent.command,
|
||||
payloadSchema: "sample.increment.v1",
|
||||
payload: { amount: 1 },
|
||||
idempotencyKey: "browser-click-1",
|
||||
});
|
||||
const receipt = parseEveCommandReceipt(decodeCultNetOperationPayload(response)) as {
|
||||
receiptId: string;
|
||||
count: number;
|
||||
};
|
||||
window.__sampleReceipt = receipt;
|
||||
receiptOutput.textContent = receipt.receiptId;
|
||||
},
|
||||
stateBindingResolver: async binding => {
|
||||
if (binding.pointerId !== "sample.counter.count") return undefined;
|
||||
return {
|
||||
latest: async () => decodeCultNetPayload<{ count: number }>(counter.current!).count,
|
||||
watch: callback => counter.watch(record => {
|
||||
if (!record) return;
|
||||
const count = decodeCultNetPayload<{ count: number }>(record).count;
|
||||
window.__sampleCount = count;
|
||||
callback(count);
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
window.__sampleCount = decodeCultNetPayload<{ count: number }>(counter.current!).count;
|
||||
window.__sampleReady = true;
|
||||
} catch (error) {
|
||||
window.__sampleError = error instanceof Error ? error.stack || error.message : String(error);
|
||||
host.textContent = window.__sampleError;
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ if (!command) throw new Error("Usage: node scripts/run-typescript-workspaces.mjs
|
|||
|
||||
const npmCli = process.env.npm_execpath ??
|
||||
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"
|
||||
? {
|
||||
...process.env,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { dirname, join, resolve } from "node:path";
|
|||
import { fileURLToPath } from "node: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 ??
|
||||
join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
|
||||
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" });
|
||||
|
||||
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"), `
|
||||
import { defineDocumentType } from "cultcache-ts";
|
||||
import { CultNetPeer } from "cultnet-ts";
|
||||
|
|
@ -111,6 +122,37 @@ void providerTransport;
|
|||
"types-smoke.ts",
|
||||
], { 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");
|
||||
for (const tarball of tarballs) console.log(`Packed: ${tarball}`);
|
||||
} finally {
|
||||
|
|
|
|||
198
scripts/verify-eve-browser-network.mjs
Normal file
198
scripts/verify-eve-browser-network.mjs
Normal 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");
|
||||
}
|
||||
|
|
@ -4,23 +4,38 @@ Date: 2026-08-17
|
|||
|
||||
## Status
|
||||
|
||||
This is the implementation map for the missing real-browser checkpoint. The
|
||||
current `samples/eve-two-runtime` proof uses jsdom and one local TypeScript
|
||||
CultMesh node. It is a clean package-consumer and contract-validation proof,
|
||||
not browser networking.
|
||||
This is the implementation map for the browser Verse boundary.
|
||||
`samples/eve-two-runtime` remains the clean package-consumer/jsdom checkpoint.
|
||||
`samples/eve-browser-network` runs a durable C# provider, a real Chromium Eve
|
||||
lowerer, and an independent C# headless observer over the same authenticated
|
||||
binary CultNet WebSocket route. It proves one canonical command effect,
|
||||
idempotent retry, shared observation, and provider restart.
|
||||
|
||||
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;
|
||||
- `cultnet-ts` mixes browser-safe schema contracts with Node transport,
|
||||
filesystem, crypto, stream, and UDP modules;
|
||||
- `cultnet-ts/contracts` is the browser-safe contract entrypoint; the package
|
||||
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`,
|
||||
`cultnet.database_unsubscribe.v0`, and `cultnet.database_change_raw.v0`;
|
||||
the browser-safe transport package that consumes them does not exist yet;
|
||||
- Kotlin has a channel-aware WebSocket transport adapter; C# and TypeScript do
|
||||
not expose the equivalent browser/server pair;
|
||||
`cultmesh-browser` consumes those messages as explicit document leases;
|
||||
- `cultmesh-browser` owns stable identity, route refresh, resubscription, and
|
||||
typed operation correlation without exposing direct document writes;
|
||||
- Kotlin has a channel-aware WebSocket transport adapter, TypeScript has the
|
||||
browser client, and `GameCult.Networking.WebSockets` provides the bounded C#
|
||||
schema server host adapter;
|
||||
- Hermodr can lower Odin state into a browser, but it is an infrastructure
|
||||
runtime, not the 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
|
||||
|
||||
|
|
@ -83,12 +98,13 @@ Shared paths:
|
|||
|
||||
Deletion line:
|
||||
|
||||
- stop calling jsdom a browser runtime;
|
||||
- split browser-safe CultNet contracts from the Node transport package;
|
||||
- add the missing subscription messages to the shared generated contract
|
||||
catalog instead of retyping them in a browser adapter;
|
||||
- do not retain a sample-only WebSocket protocol after the canonical adapter
|
||||
exists.
|
||||
- the jsdom checkpoint remains labeled as a Node-hosted DOM proof;
|
||||
- browser applications import `cultmesh-browser` and
|
||||
`cultnet-ts/contracts`, never the Node-only package roots;
|
||||
- subscription messages remain one shared schema contract across the C# and
|
||||
TypeScript catalogs;
|
||||
- the real provider adapter must consume the canonical WebSocket message lane;
|
||||
no sample-only WebSocket protocol is permitted.
|
||||
|
||||
## 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:
|
||||
|
||||
```ts
|
||||
const mesh = await CultMeshBrowser.connect({
|
||||
const mesh = await CultMeshBrowserClient.connect({
|
||||
rendezvous: odin,
|
||||
verseId: "sample.counter",
|
||||
providerId: "sample.counter-provider",
|
||||
runtimeId: "sample.browser",
|
||||
});
|
||||
|
||||
using surface = await mesh.leaseDocument(eveSurface, "eve:surface:counter");
|
||||
surface.watch(render);
|
||||
using surface = await mesh.leaseRawDocument({
|
||||
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(),
|
||||
});
|
||||
```
|
||||
|
|
@ -136,6 +160,20 @@ identity, authority, and idempotency validation happen before provider code.
|
|||
|
||||
## 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:
|
||||
|
||||
1. start a provider daemon with one durable counter document and one validated
|
||||
|
|
|
|||
|
|
@ -61,11 +61,17 @@ cd E:\Projects\EveUnity
|
|||
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
|
||||
does **not** claim a real browser process, an Odin-discovered network hop, or
|
||||
cross-language command execution. Those are the next public Verse conformance
|
||||
gates; do not cite this sample as evidence that they exist. Their owner map and
|
||||
executable acceptance contract are recorded in
|
||||
cross-language command execution. Do not cite it as evidence for those layers.
|
||||
|
||||
The next executable checkpoint is
|
||||
[`samples/eve-browser-network`](../../../../samples/eve-browser-network/README.md).
|
||||
It runs a durable C# provider, a real Chromium Eve lowerer, and an independent
|
||||
C# headless observer over the same authenticated binary CultNet WebSocket lane.
|
||||
It proves command idempotency and provider restart. Live Odin discovery and
|
||||
route rotation remain separate gates. Their owner map and full acceptance
|
||||
contract are recorded in
|
||||
[`browser-verse-transport.md`](../browser-verse-transport.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
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
|
||||
|
||||
- `Server` currently centers on `PlayerData` as the built-in account model.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in a new issue