From 805539384e44fdfce464de67dcabae4e546d7013 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 16 Jun 2026 12:15:23 +0100 Subject: [PATCH] Add TypeScript TCP peer helper --- docs/cultnet-transport-parity.md | 17 ++++++---- docs/runtime-parity-scope.md | 10 +++--- packages/cultnet-ts/README.md | 12 ++++--- packages/cultnet-ts/src/index.ts | 2 +- packages/cultnet-ts/src/peer.ts | 38 +++++++++++++++++++-- packages/cultnet-ts/test/cultnet.test.ts | 43 ++++++++++++++++++++++++ 6 files changed, 105 insertions(+), 17 deletions(-) diff --git a/docs/cultnet-transport-parity.md b/docs/cultnet-transport-parity.md index e81fa375..d785cc29 100644 --- a/docs/cultnet-transport-parity.md +++ b/docs/cultnet-transport-parity.md @@ -167,7 +167,10 @@ Current progress: over either the compatibility `Duplex` stream path or the transport connection path, while the TypeScript interop peer now accepts and dials TCP peers through `TcpFramedTransportConnection` so the parity harness exercises - the shared port instead of raw socket framing. + the shared port instead of raw socket framing. `createTcpFramedCultNetPeer` + gives application callers the same transport-first path without hand-building + the profile and adapter; raw `Duplex` peer construction is now explicitly the + compatibility lane. - Python now has the same `tcp_framed` transport profile helper plus a synchronous `TcpFramedTransportConnection` with `send`, `receive`, `close`, and stats. `CultNetRawClient`, database subscriptions, and the local @@ -315,11 +318,13 @@ Current progress: contact hints. The facade delegates to the existing Rust RUDP socket transport; discovery contact remains separate from trust, which is still gated by the authority lease catalog. -- The remaining parity work is to move the remaining LiteNetLib/WebSocket - service bodies behind their shared transport ports, then wire any remaining - daemon/socket reconnect loops through the shared controller. The core - schema-message RUDP matrix is live in the TypeScript interop gate; broader - service adoption remains the unclaimed layer. +- The remaining parity work is to finish production service-body adoption + behind the shared transport ports, then wire any remaining daemon/socket + reconnect loops through the shared controller. C# built-in LiteNetLib service + wrappers now route through `CultNetServerPeer`, TypeScript exposes a + transport-first TCP peer helper, and the core schema-message RUDP matrix is + live in the TypeScript interop gate; broader service adoption remains the + unclaimed layer. ## RUDP Packet Contract V0 diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md index b57d8024..b5e3c6d0 100644 --- a/docs/runtime-parity-scope.md +++ b/docs/runtime-parity-scope.md @@ -32,7 +32,7 @@ CultMesh. | --- | --- | --- | --- | | C# | Reference implementation and production game/server substrate | CultCache typed documents and SoA, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, CultMesh node/session/catalog/stream/simulation surfaces | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption | | Python | Package runtime, local daemon endpoint, raw interop peer, typed state participant | CultCache/CultNet/CultMesh package surface, schema catalogs, shard catalogs/logs, local TCP CultMesh server, session/prediction/simulation fact helpers, authority leases, streams, RUDP codec/session/socket transport, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, benchmark evidence against C# cache paths | Universal C# throughput, full C# daemon/server stack, and RUDP as the only public service pipe | -| TypeScript | Node/browser-adjacent projection and interop runtime | CultCache parity tests, CultNet schema-v0 peer, transport profiles, TCP-framed transport port, shared RUDP socket transport, RUDP reconnect policy/controller, RUDP reconnect-loop helper, local CultMesh node/catalog/authority/stream ergonomics | Full C# game session/server stack | +| TypeScript | Node/browser-adjacent projection and interop runtime | CultCache parity tests, CultNet schema-v0 peer, transport profiles, TCP-framed transport port and peer helper, shared RUDP socket transport, RUDP reconnect policy/controller, RUDP reconnect-loop helper, local CultMesh node/catalog/authority/stream ergonomics | Full C# game session/server stack | | Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for peer cards, authority leases, RUDP endpoint parsing, and peer-card RUDP client construction | Full CultMesh game-session/server facade, stream catalog, or production game-session server | | Kotlin | JVM/Android client substrate | Typed MessagePack cache, schema/shard catalogs, authority leases, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store, full C# server daemon, or Unity hot-loop memory layout | @@ -57,9 +57,11 @@ CultMesh. ## Remaining Work Before A Full Completion Claim -- Broaden service adoption so remaining daemon and production paths use the - shared transport port consistently instead of keeping LiteNetLib/WebSocket - ownership in individual bodies. +- Broaden production service adoption so remaining daemon paths use the shared + transport port consistently. The C# built-in LiteNetLib service wrappers now + route through transport-aware peer context, and TypeScript TCP peer creation + has a transport-first helper; broader daemon adoption remains the unclaimed + layer. - Extend RUDP adoption through daemon/socket reconnect loops. RUDP profiles now advertise the portable reconnect policy, runtime-local tests prove the shared controller across the targeted runtimes, and the C# LiteNetLib client plus diff --git a/packages/cultnet-ts/README.md b/packages/cultnet-ts/README.md index d9b96e6c..af982089 100644 --- a/packages/cultnet-ts/README.md +++ b/packages/cultnet-ts/README.md @@ -181,7 +181,7 @@ polymorphic attack surface. import { CultCache, defineDocumentType } from "cultcache-ts"; import { CultNetDocumentRegistry, - CultNetPeer, + createTcpFramedCultNetPeer, defineCultNetDocumentBinding, ghostlightAgentStateGeneratedContract, } from "cultnet-ts"; @@ -198,8 +198,10 @@ const registry = new CultNetDocumentRegistry([ }), ]); -const peer = new CultNetPeer(stream, { +const peer = createTcpFramedCultNetPeer(stream, { + runtimeId: "node-worker", wireContract: "cultnet.schema.v0", + transportId: "worker-tcp", }); ``` @@ -231,8 +233,10 @@ maps them back to the exact C# union payload shape at the wire boundary. - The Ghostlight agent-state mirror is an exact copy of Ghostlight's current canonical schema. If Ghostlight changes the contract, this mirror must be updated in lockstep instead of playing coy with partial validation. -- The direct transport is any Node `Duplex`; named-pipe/socket server wrappers - can be added without changing the message contract. +- The preferred direct transport path is `CultNetTransportConnection`. + `createTcpFramedCultNetPeer(...)` wraps a Node `Duplex` in the shared + `tcp_framed` profile before schema messages reach `CultNetPeer`. Passing a + raw `Duplex` to `CultNetPeer` remains as compatibility for older callers. ## Development diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 6b9d1bb8..9a4eb739 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -55,7 +55,7 @@ export { type GhostlightWorldState, } from "./contracts"; export { encodeFrame, LengthPrefixedMessageFramer } from "./framing"; -export { CultNetPeer, type CultNetPeerEvents } from "./peer"; +export { CultNetPeer, createTcpFramedCultNetPeer, type CultNetPeerEvents, type TcpFramedCultNetPeerOptions } from "./peer"; export { CultNetReconnectController, TcpFramedTransportConnection, diff --git a/packages/cultnet-ts/src/peer.ts b/packages/cultnet-ts/src/peer.ts index 9f00a516..4d3f064a 100644 --- a/packages/cultnet-ts/src/peer.ts +++ b/packages/cultnet-ts/src/peer.ts @@ -3,9 +3,9 @@ import type { Duplex } from "node:stream"; import { decode, encode } from "@msgpack/msgpack"; -import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetDocumentDeleteMessage, type CultNetDocumentPutMessage, type CultNetErrorMessage, type CultNetHelloMessage, type CultNetLoginMessage, type CultNetLoginSuccessMessage, type CultNetMessage, type CultNetRegisterMessage, type CultNetSampleChangeNameMessage, type CultNetSampleChatMessage, type CultNetSchemaCatalogRequestMessage, type CultNetSchemaCatalogResponseMessage, type CultNetSnapshotRequestMessage, type CultNetSnapshotResponseMessage, type CultNetVerifyMessage, type CultNetWireContract } from "./contracts"; +import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetDocumentDeleteMessage, type CultNetDocumentPutMessage, type CultNetErrorMessage, type CultNetHelloMessage, type CultNetLoginMessage, type CultNetLoginSuccessMessage, type CultNetMessage, type CultNetRegisterMessage, type CultNetSampleChangeNameMessage, type CultNetSampleChatMessage, type CultNetSchemaCatalogRequestMessage, type CultNetSchemaCatalogResponseMessage, type CultNetSnapshotRequestMessage, type CultNetSnapshotResponseMessage, type CultNetTransportProfile, type CultNetVerifyMessage, type CultNetWireContract } from "./contracts"; import { encodeFrame, LengthPrefixedMessageFramer } from "./framing"; -import type { CultNetTransportConnection } from "./transport"; +import { createTcpFramedTransportProfile, TcpFramedTransportConnection, type CultNetTransportConnection, type TcpFramedTransportProfileOptions } from "./transport"; export interface CultNetPeerEvents { message: (message: CultNetMessage) => void; @@ -18,6 +18,36 @@ export interface CultNetPeerOptions { wireContract: CultNetWireContract; } +export interface TcpFramedCultNetPeerOptions extends CultNetPeerOptions, TcpFramedTransportProfileOptions { + runtimeId: string; +} + +export function createTcpFramedCultNetPeer( + stream: Duplex, + options: TcpFramedCultNetPeerOptions, +): CultNetPeer { + const { + runtimeId, + wireContract, + transportId, + host, + port, + maxPayloadBytes, + maxFragmentBytes, + } = options; + const transport = new TcpFramedTransportConnection( + stream, + createTcpFramedTransportProfile(runtimeId, { + transportId, + host, + port, + maxPayloadBytes, + maxFragmentBytes, + }), + ); + return new CultNetPeer(transport, { wireContract }); +} + export class CultNetPeer extends EventEmitter { readonly #stream?: Duplex; readonly #transport?: CultNetTransportConnection; @@ -49,6 +79,10 @@ export class CultNetPeer extends EventEmitter { } } + get transportProfile(): CultNetTransportProfile | undefined { + return this.#transport?.profile; + } + send(message: CultNetMessage): void { const wireValue = encodeCultNetMessageForWire(message, this.#wireContract); const payload = encode(wireValue); diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index c9a75b7f..b5e14438 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -30,6 +30,7 @@ import { cultNetBuiltinSchemaRegistry, computeCultNetReconnectDelayMs, createCultNetReconnectPolicy, + createTcpFramedCultNetPeer, createTcpFramedTransportProfile, createRudpTransportProfile, defineCultNetDocumentBinding, @@ -734,6 +735,48 @@ test("CultNet peer can speak through a transport connection", async () => { receiver.close(); }); +test("CultNet TCP helper constructs a transport-backed peer with profile metadata", async () => { + const { a, b } = createDuplexPair(); + const sender = createTcpFramedCultNetPeer(a, { + runtimeId: "tcp-helper-left", + wireContract: "cultnet.schema.v0", + transportId: "helper-left", + host: "127.0.0.1", + port: 17777, + maxPayloadBytes: 64 * 1024, + }); + const receiver = createTcpFramedCultNetPeer(b, { + runtimeId: "tcp-helper-right", + wireContract: "cultnet.schema.v0", + }); + + assert.equal(sender.transportProfile?.runtimeId, "tcp-helper-left"); + assert.equal(sender.transportProfile?.transports[0]?.transportId, "helper-left"); + assert.equal(sender.transportProfile?.transports[0]?.protocol, "tcp_framed"); + assert.equal(sender.transportProfile?.transports[0]?.host, "127.0.0.1"); + assert.equal(sender.transportProfile?.transports[0]?.port, 17777); + assert.equal(sender.transportProfile?.transports[0]?.channels[0]?.channelId, "schema"); + assert.equal(sender.transportProfile?.transports[0]?.channels[0]?.maxPayloadBytes, 64 * 1024); + + const message = await new Promise>((resolve, reject) => { + receiver.once("message", resolve); + receiver.once("invalidMessage", reject); + sender.sendHello({ + schemaVersion: "cultnet.hello.v0", + runtimeId: "tcp-helper-sender", + runtimeKind: "node-worker", + }); + }); + + assert.equal(message.schemaVersion, "cultnet.hello.v0"); + if (message.schemaVersion === "cultnet.hello.v0") { + assert.equal(message.runtimeId, "tcp-helper-sender"); + } + + sender.close(); + receiver.close(); +}); + test("CultNet can round-trip gamecult.networking.v0 auth messages through the explicit legacy contract", () => { const message: CultNetLoginMessage = { schemaVersion: "cultnet.login.v0",