Add TypeScript TCP peer helper

This commit is contained in:
Meta Vens 2026-06-16 12:15:23 +01:00
commit 805539384e
6 changed files with 105 additions and 17 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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);

View file

@ -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<ReturnType<typeof parseCultNetMessage>>((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",