diff --git a/CultLib.sln b/CultLib.sln index 7bd7ae93..c40b6b41 100644 --- a/CultLib.sln +++ b/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 diff --git a/README.md b/README.md index aee7644d..6df5d940 100644 --- a/README.md +++ b/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: diff --git a/package-lock.json b/package-lock.json index bc42f617..0b8aae8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2723b934..c9b79e47 100644 --- a/package.json +++ b/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" } } diff --git a/packages/cultmesh-browser/README.md b/packages/cultmesh-browser/README.md new file mode 100644 index 00000000..4866196c --- /dev/null +++ b/packages/cultmesh-browser/README.md @@ -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 ` for the +real Chromium + C# headless chronology. diff --git a/packages/cultmesh-browser/package.json b/packages/cultmesh-browser/package.json new file mode 100644 index 00000000..7f110506 --- /dev/null +++ b/packages/cultmesh-browser/package.json @@ -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" + } +} diff --git a/packages/cultmesh-browser/src/index.ts b/packages/cultmesh-browser/src/index.ts new file mode 100644 index 00000000..1f259422 --- /dev/null +++ b/packages/cultmesh-browser/src/index.ts @@ -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; +} + +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; +} + +interface PendingOperation { + resolve: (response: CultNetOperationResponseMessage) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +export class CultMeshRawDocumentLease implements Disposable { + readonly schemaId: string; + readonly recordKey: string; + readonly subscriptionId: string; + #client: CultMeshBrowserClient | undefined; + #current: CultNetRawDocumentRecord | undefined; + #watchers = new Set(); + + constructor( + client: CultMeshBrowserClient, + options: Required, + ) { + 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> & { + socketFactory: (endpoint: string) => CultMeshBrowserSocket; + }; + #socket: CultMeshBrowserSocket | undefined; + #socketGeneration = 0; + #state: CultMeshConnectionState = "connecting"; + #disposed = false; + #reconnectTimer: ReturnType | undefined; + #connectPromise: Promise | undefined; + #leases = new Map(); + #openingLeases = new Map(); + #pendingSnapshots = new Map(); + #pendingOperations = new Map(); + #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 { + 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 { + 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 { + 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((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 { + 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 { + 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 { + await this.dispose(); + } + + private async subscribe(lease: CultMeshRawDocumentLease): Promise { + await this.ensureConnected(); + const messageId = this.#options.createId(); + const ready = new Promise((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 { + if (this.#state === "connected" && this.#socket?.readyState === 1) return Promise.resolve(); + return this.connectSocket(); + } + + private connectSocket(): Promise { + 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 { + 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((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 { + 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(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(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.`); +} diff --git a/packages/cultmesh-browser/test/cultmesh-browser.test.ts b/packages/cultmesh-browser/test/cultmesh-browser.test.ts new file mode 100644 index 00000000..8c3ae1b5 --- /dev/null +++ b/packages/cultmesh-browser/test/cultmesh-browser.test.ts @@ -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>(); + + 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)); +} diff --git a/packages/cultmesh-browser/tsconfig.json b/packages/cultmesh-browser/tsconfig.json new file mode 100644 index 00000000..b9b7caba --- /dev/null +++ b/packages/cultmesh-browser/tsconfig.json @@ -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"] +} diff --git a/packages/cultmesh-browser/tsconfig.test.json b/packages/cultmesh-browser/tsconfig.test.json new file mode 100644 index 00000000..4d148683 --- /dev/null +++ b/packages/cultmesh-browser/tsconfig.test.json @@ -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"] +} diff --git a/packages/cultnet-ts/package.json b/packages/cultnet-ts/package.json index f0a3e396..6ff29340 100644 --- a/packages/cultnet-ts/package.json +++ b/packages/cultnet-ts/package.json @@ -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": [ diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index 56ece383..c97a0d8d 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -951,8 +951,11 @@ function encodeLegacySchemaDescriptor(schema: CultNetSchemaDescriptor): Record 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; diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index 1a2ba805..c5ca69da 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -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", diff --git a/samples/eve-browser-network/EveBrowserNetworkSample.csproj b/samples/eve-browser-network/EveBrowserNetworkSample.csproj new file mode 100644 index 00000000..4fe900dd --- /dev/null +++ b/samples/eve-browser-network/EveBrowserNetworkSample.csproj @@ -0,0 +1,10 @@ + + + net10.0 + enable + enable + + + + + diff --git a/samples/eve-browser-network/Program.cs b/samples/eve-browser-network/Program.cs new file mode 100644 index 00000000..962baee3 --- /dev/null +++ b/samples/eve-browser-network/Program.cs @@ -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(registry, "sample.counter_state.v1")) + .Register(CultNetDocumentBinding.ForDocument(registry, "gamecult.eve.surface.v1")); + using var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + RuntimeId = "sample.counter-provider", + DocumentRegistry = documents + }); + if (cache.Get(new CultRecordKey(counterKey)) == null) + await database.PutAsync(new CultRecordKey(counterKey), CounterState.Initial(counterKey)); + if (cache.Get(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(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(new CultRecordKey(counterKey)) + ?? throw new InvalidOperationException("Counter state is unavailable."); + if (!current.Receipts.TryGetValue(request.MessageId, out var receipt)) + { + var input = MessagePackSerializer.Deserialize( + Convert.FromBase64String(request.Payload)); + receipt = IncrementReceipt.Accepted(request.MessageId, current.Count + input.Amount); + var receipts = new Dictionary(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() + .Features.Get()!.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(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(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().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(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 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 { ["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 + { + ["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 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 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"; +} diff --git a/samples/eve-browser-network/README.md b/samples/eve-browser-network/README.md new file mode 100644 index 00000000..2d6c7034 --- /dev/null +++ b/samples/eve-browser-network/README.md @@ -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. diff --git a/samples/eve-browser-network/browser/index.html b/samples/eve-browser-network/browser/index.html new file mode 100644 index 00000000..d23ad8ab --- /dev/null +++ b/samples/eve-browser-network/browser/index.html @@ -0,0 +1,13 @@ + + + + + + CultMesh Eve browser counter + + +
Connecting to the Verse…
+ + + + diff --git a/samples/eve-browser-network/browser/main.ts b/samples/eve-browser-network/browser/main.ts new file mode 100644 index 00000000..7bcb6887 --- /dev/null +++ b/samples/eve-browser-network/browser/main.ts @@ -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("#surface")!; +const receiptOutput = document.querySelector("#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; +} diff --git a/scripts/run-typescript-workspaces.mjs b/scripts/run-typescript-workspaces.mjs index f2cf74bc..933079da 100644 --- a/scripts/run-typescript-workspaces.mjs +++ b/scripts/run-typescript-workspaces.mjs @@ -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, diff --git a/scripts/test-typescript-package-closure.mjs b/scripts/test-typescript-package-closure.mjs index c56a13a6..e5648c9c 100644 --- a/scripts/test-typescript-package-closure.mjs +++ b/scripts/test-typescript-package-closure.mjs @@ -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 { diff --git a/scripts/verify-eve-browser-network.mjs b/scripts/verify-eve-browser-network.mjs new file mode 100644 index 00000000..d4e9fa2c --- /dev/null +++ b/scripts/verify-eve-browser-network.mjs @@ -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"); +} diff --git a/src/GameCult.Mesh/docs/browser-verse-transport.md b/src/GameCult.Mesh/docs/browser-verse-transport.md index c8586021..81934139 100644 --- a/src/GameCult.Mesh/docs/browser-verse-transport.md +++ b/src/GameCult.Mesh/docs/browser-verse-transport.md @@ -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 diff --git a/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md b/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md index d16f21c1..e38aa8c3 100644 --- a/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md +++ b/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md @@ -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). diff --git a/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaClient.cs b/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaClient.cs new file mode 100644 index 00000000..2e778a0a --- /dev/null +++ b/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaClient.cs @@ -0,0 +1,166 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using GameCult.Networking; + +namespace GameCult.Networking.WebSockets; + +/// Schema-v0 client over a binary WebSocket route. +public sealed class CultNetWebSocketSchemaClient : ICultNetSchemaClient, ICultNetSchemaClientHealth +{ + private readonly ConcurrentDictionary> _handlers = new(); + private readonly object _handlerGate = new(); + private readonly SemaphoreSlim _sendGate = new(1, 1); + private readonly CancellationTokenSource _shutdown = new(); + private readonly TaskCompletionSource _backgroundFailure = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Action? _configure; + private readonly int _maxMessageBytes; + private ClientWebSocket? _socket; + private bool _disposed; + + /// Creates a binary WebSocket schema client. + public CultNetWebSocketSchemaClient( + Action? configure = null, + int maxMessageBytes = 4 * 1024 * 1024) + { + if (maxMessageBytes <= 0) throw new ArgumentOutOfRangeException(nameof(maxMessageBytes)); + _configure = configure; + _maxMessageBytes = maxMessageBytes; + } + + /// + public bool Connected => !_disposed && _socket?.State == WebSocketState.Open; + + /// + public Task BackgroundFailure => _backgroundFailure.Task; + + /// + 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(); + } + + /// Connects to an exact WebSocket route. + 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; + } + } + + /// + public void SendCultNet(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(); + } + } + + /// + public void OnCultNet(Action callback) where T : ICultNetSchemaMessage + { + ArgumentNullException.ThrowIfNull(callback); + ThrowIfDisposed(); + lock (_handlerGate) + { + if (!_handlers.TryGetValue(typeof(T), out var handlers)) + _handlers[typeof(T)] = handlers = new List(); + handlers.Add(callback); + } + } + + /// + 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(); + foreach (var handler in handlers) handler.DynamicInvoke(message); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } +} diff --git a/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaServer.cs b/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaServer.cs new file mode 100644 index 00000000..240311a1 --- /dev/null +++ b/src/GameCult.Networking.WebSockets/CultNetWebSocketSchemaServer.cs @@ -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; + +/// Controls the bounded CultNet schema-v0 WebSocket endpoint. +public sealed class CultNetWebSocketEndpointOptions +{ + /// Maximum accepted binary CultNet message size. + public int MaxMessageBytes { get; init; } = 4 * 1024 * 1024; + + /// + /// Authorizes the HTTP upgrade before it can become a CultNet peer. Production hosts + /// should bind this to their authenticated session middleware. + /// + public Func>? AuthorizeAsync { get; init; } + + /// Allows an unauthenticated endpoint for explicit local development only. + public bool AllowAnonymousDevelopment { get; init; } +} + +/// +/// 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. +/// +public sealed class CultNetWebSocketSchemaServer : + ICultNetSchemaServer, + ICultNetSchemaServerPeerLifecycle, + IAsyncDisposable +{ + private readonly ConcurrentDictionary _handlers = new(); + private readonly ConcurrentDictionary _peers = new(); + private bool _disposed; + + /// + public event Action? PeerDisconnected; + + /// Gets the active upgraded peer count. + public int PeerCount => _peers.Count; + + /// + public void OnCultNet(Func callback) + where TMessage : ICultNetSchemaMessage + { + ArgumentNullException.ThrowIfNull(callback); + ThrowIfDisposed(); + _handlers[typeof(TMessage)] = callback; + } + + /// + public void RemoveCultNetMessageListener(Delegate callback) + where TMessage : ICultNetSchemaMessage + { + if (_handlers.TryGetValue(typeof(TMessage), out var existing) && existing == callback) + _handlers.TryRemove(typeof(TMessage), out _); + } + + /// Serves one already-authorized WebSocket until it closes. + 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); + } + } + + /// + 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 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 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(); + } + } +} + +/// ASP.NET Core endpoint mapping for the CultNet WebSocket schema server. +public static class CultNetWebSocketEndpointRouteBuilderExtensions +{ + /// Maps one authenticated binary CultNet WebSocket endpoint. + 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); + }); + } +} diff --git a/src/GameCult.Networking.WebSockets/GameCult.Networking.WebSockets.csproj b/src/GameCult.Networking.WebSockets/GameCult.Networking.WebSockets.csproj new file mode 100644 index 00000000..40214247 --- /dev/null +++ b/src/GameCult.Networking.WebSockets/GameCult.Networking.WebSockets.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + CultNet schema-v0 WebSocket host adapter for browser and web runtimes + + + + + + diff --git a/src/GameCult.Networking/README.md b/src/GameCult.Networking/README.md index c7c4792f..cf463e72 100644 --- a/src/GameCult.Networking/README.md +++ b/src/GameCult.Networking/README.md @@ -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. diff --git a/tests/GameCult.Networking.WebSockets.Tests/CultNetWebSocketSchemaServerTests.cs b/tests/GameCult.Networking.WebSockets.Tests/CultNetWebSocketSchemaServerTests.cs new file mode 100644 index 00000000..74fc2f59 --- /dev/null +++ b/tests/GameCult.Networking.WebSockets.Tests/CultNetWebSocketSchemaServerTests.cs @@ -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((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() + .Features.Get()!.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( + TaskCreationOptions.RunContinuationsAsynchronously); + client.OnCultNet(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 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(); + } +} diff --git a/tests/GameCult.Networking.WebSockets.Tests/GameCult.Networking.WebSockets.Tests.csproj b/tests/GameCult.Networking.WebSockets.Tests/GameCult.Networking.WebSockets.Tests.csproj new file mode 100644 index 00000000..eec44ecd --- /dev/null +++ b/tests/GameCult.Networking.WebSockets.Tests/GameCult.Networking.WebSockets.Tests.csproj @@ -0,0 +1,17 @@ + + + net10.0 + false + enable + enable + + + + + + + + + + +