Stream Connector — Common Spec¶
Spec table of contents | Previous: Session Actor Dispatch | Next: Location Runtime
This document is the language-neutral canonical document of the client stream connector. It owns the target execution environment, transport, wire contract, packet (a transport unit combining header information and payload) model, connection lifecycle, error meaning, and deployment deliverable.
The per-language public type and signature is fixed by
languages/<lang>/— cpp · dotnet · java · typescript. This document defines what is guaranteed, and the per-language spec defines what shape that meaning takes in that language (Public Contract Governance).
1. Purpose And Scope¶
Stream Connector is the client-side library that connects to the server framework's STREAM model. It lets the client send and receive the same packet (header + payload) the server session callback receives.
The Connector doesn't include a domain. The user composes their own protocol on top of it, such as chat, game, equipment control, or notification.
Dependency boundary:
- The connector package doesn't depend on the server framework package (ASP.NET Core adapter, SPOT, Stage wrapper, etc.).
- The dependency is limited only to client-side runtime needed for connector execution, such as transport/codec/compression.
- The reverse direction is the same. The server framework package doesn't reference the connector.
2. Target Execution Environment¶
This section is the starting point of this spec. Because the execution environment's constraint decides the contract. Which connector to use is decided not by language, but by "engine × build target".
2.1 Connector Responsible Per Engine/Environment¶
| Target | Native Build | Web Build (Browser · WASM) |
|---|---|---|
| Unity | .NET connector |
TypeScript connector — C# calls the JS layer through jslib interop |
| Godot | C++ connector (GDExtension) or .NET connector (Godot C#) |
TypeScript connector |
| Cocos | C++ connector (Axmol adapter) | TypeScript connector (Cocos Creator web) |
| Unreal | C++ connector (plugin) | (not applicable) |
| Browser web client | — | TypeScript connector |
| Desktop/server application | .NET / Java / C++ connector |
— |
Summarized in one rule — the moment you build for web (browser/WASM), you use the TypeScript connector regardless of language. Because no language can open an OS socket in a browser sandbox.
2.2 The Effect Of Environment Constraint On The Contract¶
| Environment | Constraint | Contract |
|---|---|---|
| Game engine (common) | An engine object can't be handled off the main thread | The default of dispatch mode, which decides the receive callback's execution context, is Manual. It's explicitly pumped on the main thread (§7). |
| Game engine (C++) | Some builds have exception/coroutine disabled | The C++ connector core is no-exception/no-coroutine. The public header doesn't expose <coroutine> |
| Browser · WASM | Can't open an OS socket (security sandbox) | tcp/tls unusable. Only ws/wss are used, running on top of the platform's native WebSocket API (§3.2) |
| Node.js | Not the TypeScript connector's product execution environment | Only handles the server process and browser test runner |
3. Transport¶
3.1 Endpoint Scheme → Transport¶
| URI Scheme | Transport |
|---|---|
tcp:// |
TCP |
tls:// |
TLS over TCP |
ws:// |
WebSocket |
wss:// |
WebSocket over TLS |
- If transport isn't specified, the endpoint scheme decides the
transport. The table above is that mapping, and an option's fixed
default doesn't override the scheme. A configuration that names only
a
ws://endpoint connects over WebSocket with no further option. - If the specified transport doesn't match the endpoint scheme, it
fails with
ConfigurationError(§9). When the two values name different transports, there's no way to decide which one to follow.
3.2 Per-Environment Transport Availability¶
| Environment | Available Transport |
|---|---|
| Browser family (web, Cocos web, Unity WebGL, Godot Web) | Only ws, wss |
Native (.NET/C++/Java) |
tcp, tls, ws, wss |
If the browser family receives a tcp:///tls:// endpoint, it
fails immediately with a configuration error. It doesn't silently
fail at runtime. This is a platform constraint, not an implementation
constraint.
In the browser family, ws/wss are implemented with the platform's
native WebSocket API. Since the platform performs the handshake and
framing, the connector doesn't implement it directly.
4. Wire Contract¶
4.1 Frame¶
The leading 2 bytes of a STREAM frame are header_size.
+----------------+----------------+----------------+----------------+
| u16 header_len | u32 payload_sz | header bytes | payload bytes |
+----------------+----------------+----------------+----------------+
4.2 Header¶
+----------------+---------+----------+----------+------------------+
| format_marker | kind u8 | codec u8 | flags u8 | request_seq u64? |
| u8 = 0xF2 | | | | |
+----------------+---------+----------+----------+------------------+
| name u8+n | meta u16+n? | corr u8+n? | flow_id 36B + origin u8? |
+-----------+-------------+------------+----------------------------+
| actor_slot u16? |
+-----------------+
- The header's first byte is
format_marker = 0xF2. A different value is a decode error. kind/codecare encoded as a 1-byte enum, not a string.- Packet name is
u8 name_len + UTF-8 bytes, at most 255 bytes. The packet name of aSend, aRequestand a push can't be empty or consist only of whitespace (characters with the UnicodeWhite_Spaceproperty), and every surface that takes a name rejects such a name.ResponseandErrordon't carry a packet name — encoded withname_len = 0. Since a response doesn't select a handler and correlation is already decided byrequest_seq, this field isn't used (see "reply correlation" in 03 Message Model). - Metadata continues as
u16 meta_len + metadata bytes, and correlation id asu8 len + bytes. - The flow field's 36-byte
flow_idand 1-byteflow_originalways exist together or are both absent. The meaning is owned by Message Flow Correlation §3. actor_slotis the Actor slot of the binding when the packet's server-side counterpart is an Actor bound to that session. The value0is never used.- Every multi-byte integer is network byte order.
Application code doesn't build or modify this header directly — the connector runtime owns it.
4.3 Flags¶
| Flag | Value | Meaning |
|---|---|---|
| has request seq | 0x01 |
The request_seq field is present |
| has metadata | 0x02 |
The meta field is present |
| payload compressed | 0x04 |
The payload is compressed |
| has correlation id | 0x08 |
The correlation id field is present |
| has flow id | 0x10 |
The flow_id/flow_origin fields are present |
| has actor slot | 0x20 |
The actor_slot field is present |
has flow id isn't set on a Control packet
(flow-correlation §3).
4.4 Metadata¶
+---------------+-------------+-------------+
| count u8 | entry... | entry... |
+---------------+-------------+-------------+
entry:
+-------------+-------------+-------------+-------------+
| key_len u8 | key bytes | val_len u16 | value bytes |
+-------------+-------------+-------------+-------------+
Key and value are UTF-8 strings.
key_lenmust be 1 or greater.- If the same key appears twice, it's a decode error.
countmust match the following entry count.
The size limit has two stages.
| Stage | Bound |
|---|---|
The wire meta_len field's representation limit |
65535 bytes |
| The bound the connector validates before sending | 1024 bytes — a validation error if exceeded. Not adjustable through a public option |
Metadata only carries small values, such as trace id, locale, tenant id.
4.5 Decode Error¶
The following are all decode errors.
- An unknown
kind/codec/flag bit - A mismatch between the
has request seq/has metadata/has actor slotflag and actual field presence ResponseorErrorwhosename_lenisn't0
4.6 Control Frame¶
Control kind is the connector-internal control frame. An
application packet name can't use the $zlink. prefix.
A control frame's namespace is separated by packet kind. Since a
control frame is only delivered as Control kind, even if the
application uses a string identical to a control name below as
Send/Request kind, dispatch isn't mixed up. Still, to avoid
confusion, don't use session-closing for an application packet. A
new control packet uses the $zlink. prefix.
A control frame has Raw codec and no request sequence, metadata, flow
field or flag, or Actor slot field or flag. The slot of
$zlink.actor.bound and $zlink.actor.unbound lives in the control
payload only. The payload differs per control packet.
| Control Packet | Payload |
|---|---|
$zlink.heartbeat.ping |
Empty |
$zlink.heartbeat.pong |
Empty |
session-closing |
Not empty — see below |
$zlink.actor.bound |
Not empty — see below |
$zlink.actor.unbound |
Not empty — see below |
session-closing is a control packet the server sends right before
closing a session, and the client reads it to confirm closeReason
(Complete Host Relocation Flow §9).
+------------+-------------------+----------------+--------------------+
| version u8 | close_reason u8 | diag_len u16 | diagnostic bytes |
| = 1 | 1..6 | 0..512 | UTF-8 |
+------------+-------------------+----------------+--------------------+
close_reason |
Value |
|---|---|
ClientClose |
1 |
IdleTimeout |
2 |
HeartbeatTimeout |
3 |
ServerDrain |
4 |
ProtocolError |
5 |
TransportError |
6 |
An unknown version/reason, or diag_len exceeding 512 or mismatching
the actual payload length, is a decode error.
$zlink.actor.bound is the control packet the server sends when it has
bound an Actor to this session, and $zlink.actor.unbound when that
binding ends. §5.6 owns the meaning; when they are sent and in what order
is owned by
Session–Actor Binding §5.
$zlink.actor.bound
+------------+---------------+--------------+-------------------+
| version u8 | actor_slot u16| id_len u8 | actor_id bytes |
| = 1 | 1..65535 | 1..255 | UTF-8 |
+------------+---------------+--------------+-------------------+
$zlink.actor.unbound
+------------+---------------+
| version u8 | actor_slot u16|
| = 1 | 1..65535 |
+------------+---------------+
An unknown version, actor_slot = 0, id_len = 0, or a mismatch with the
actual payload length is a decode error.
4.7 Payload Size Bound¶
Send and receive each have a payload bound. The default is 64KB (65536 bytes) for both, and unlike the metadata bound, it's adjusted with an option.
| Direction | Default Bound | On Violation |
|---|---|---|
| Send | 64KB | Fails as a validation error (§9) before the transport write |
| Receive | 64KB | FrameTooLarge (§9) |
The bound applies only to the payload bytes with the length prefix and encoded header subtracted. On receiving a compressed frame, both the wire's compressed payload and the decompression result are each compared against the same receive bound. If either exceeds it, it isn't delivered to the application handler or request completion. The send bound is based on the payload actually written to the transport, so a send that requested compression checks the compression result. An application that needs a payload larger than 64KB explicitly increases this value.
5. Packet Model¶
The user API doesn't handle raw header bytes.
- The default packet name is the payload type's simple name. No namespace or package qualifier is attached.
- A name that varies with the compiler or the runtime isn't used as a packet name. The packet name is a value agreed with the server, so a compiler-mangled name — anything that changes when the build environment changes — leaves the server unable to find a handler for the same type.
- All five languages provide a way to attach a packet name directly to the payload type. The form of that attachment (attribute, annotation, static member, and so on) is owned by the per-language document. A name attached to the type takes priority over the simple type name.
- If the caller specifies a name per operation, that takes the highest priority.
- Every surface that takes a name offers both forms: the caller names it, or it is decided from the payload type. This covers receive registration, send and request at the connector level and the Actor handle (§5.6) level, and the test wait surfaces (§10.1.1) at the connector level. Neither form is offered alone.
- If auxiliary information is needed, it's added as a metadata key-value.
- An API that handles arbitrary header bytes isn't put on the public surface.
5.1 Message Kind¶
| Kind | Value | Meaning |
|---|---|---|
| Send | 1 | A one-way packet that doesn't wait for a response |
| Request | 2 | A packet that waits for a response |
| Response | 3 | A request's success response |
| Error | 4 | A request's failure response, or a stream error unrelated to a request |
| Control | 5 | A connector-internal control frame (§4.6) |
5.2 Request Correlation¶
request_seq is a u64 correlation sequence the runtime manages, and
is put only in request/response/error response.
- Within the same connector instance,
request_seqmust not be duplicated among concurrently pending requests. - The value
0isn't used.
Matching rule:
| Situation | Behavior |
|---|---|
Send sent |
Sent with no request_seq. Not put in the pending map |
Request sent |
A new request_seq is assigned and registered in the pending map |
Response received — request_seq matches a pending request |
That request completes as success |
Response received — terminal request or no matching pending request |
Discarded without a second terminal |
Error received — request_seq matches a pending request |
That request completes as failure |
Error received — request_seq is absent or does not match a pending request |
Delivered to the error surface as a stream-level error unrelated to a pending request (§9) |
request_seqis canonical for pending request matching. SinceResponseandErrorhave no packet name field at all (name_len = 0), they also can't be matched by name. Which response belongs to which is already decided by sequence. The same terminal reply principle is used when relaying an Actor request in a STREAM session (Session Actor Dispatch §3).- When a request timeout, close, or disconnect occurs, pending requests complete as failure and are removed from the map. They are not automatically resent after reconnection (§6).
- When a Request ends by timeout or caller cancellation, its frame that hasn't started being written isn't sent.
- A Request timeout starts at operation acceptance and covers queue wait, frame write, and reply wait.
5.3 Error Payload¶
Error kind's payload is always a UTF-8 JSON object regardless of
codec configuration, and the header's codec is JSON.
For an Error the framework produces, code is the snake_case name of the framework error kind (for example invalid_operation, not_found).
To treat an application-domain error as a normal reply, use Response
kind with a user-defined payload, not Error.
5.4 Codec¶
| Codec | Value |
|---|---|
| Raw | 0 |
| JSON | 1 |
| MessagePack | 2 |
| Protobuf | 3 |
JSON is the default codec. Every language's connector takes one typed payload codec as a connector creation option, used together for typed send, request, and receive. MessagePack/Protobuf are provided by an optional package with that codec implementation. A public API for registering a codec per message type, or switching codec per send/request operation, isn't provided. The typed payload codec and the name resolver that decides a packet name from the payload type (§5) are both injected as connector creation options. Instead of a surface that switches them per operation, the two injection points sit in the creation options only. If neither is given, the JSON codec and §5's default name rule are used, and the injection point's type name is owned by the per-language document. A Raw encoded payload can use the codec number the payload specifies as is, for external protocol interworking.
The TypeScript package root exports a browser-safe
ZlinkStreamPayloadCodec, injected as the codec option when building
a connector. Node framework serializer registration uses the same
package's ./framework subpath. The two entry points use the same
codec number owned by stream-wire, but the browser module graph
mustn't reference the server framework runtime.
5.5 Flow¶
The connector does not create, send, expose or propagate flow. Outbound frames never carry
flow_id/flow_origin and never set flag 0x10. When an inbound frame has flow fields, only
their structural length is checked (§4.2) and the values are dropped. Received messages do not
expose flow values. The server starts the flow at STREAM ingress
(Message flow correlation §4).
A request's correlation id is protocol information, so it is still created and preserved. A
one-way Send creates no correlation id (flag 0x08 is not set).
5.6 Bound Actor¶
The server can bind several Actors to one STREAM session (Session–Actor Binding §4). The connector tells which Actor a packet's server-side counterpart is by a value the Framework provides — the application does not put an identifier into the payload for that.
- The connector consumes the
$zlink.actor.boundand$zlink.actor.unboundannouncements that Session–Actor Binding §5 defines and keeps theactor_slot ↔ actor_idtable from them. A bound announcement for an already open slot, a bound announcement for anactor_idanother open slot already uses, an unbound announcement for a slot missing from the table, and a packet carrying anactor_slotmissing from the table are allFrameDecodeFailedand end the connection per §9. - A received message exposes
actor_id. A frame withhas actor slotset carries theactor_idfound in the table; a frame without it carries none. - An Actor handle is the connector object that stands for one bound
Actor. The connector owns the handle's state, and the application can read
its reference and
actor_idafter it closes. The list of open handles is a read-only snapshot taken at the time of the call. - Handling a bound control, the connector updates the table and the list first and then queues the bound callback; a later packet callback for that Actor on the same connection is queued after it. An unbound control removes the entry, closes the handle, and queues the unbound callback, after which that handle's receive handlers no longer run. When the transport drops, the connector closes every open handle in issue order and queues their unbound callbacks before it queues the connection-state and disconnected callbacks.
- The handle's send and request carry that Actor's
actor_slot, and a receive registration on the handle is given only the messages whose counterpart is that Actor. Send and request on a closed handle areValidationFailed; otherwise the timeout and cancellation semantics match the connector-level builders. Actor lifecycle callbacks and a handle's receive registrations follow §7's dispatch mode, registration order, deregistration, callback failure and "doesn't wait for completion" rules as they are. - The connector-level send, request and receive registration work without a slot; an application that binds one Actor keeps using them as before, without handles. There is no surface that takes an Actor identifier as a string on every call — the handle keeps the Actor's address (Public Contract Governance §7).
5.7 Request Hooks¶
An application registers two hooks that add common handling (common metadata, logging) to every request. They are registered like receive handlers (§5): registration returns a release handle, and several hooks run in registration order. The request sending hook runs synchronously on the thread that calls the request, before the frame is built — it does not follow the dispatch mode (§7). The reply received hook follows the dispatch mode like any other callback (§7). Both hooks apply to every request at the connector level and at the Actor handle (§5.6) level. They do not apply to sends or received pushes.
| Hook | When it runs | What it receives |
|---|---|---|
| request sending | just before the request frame is built | the request packet name, the actor_id when sent through an Actor handle, a way to add metadata |
| reply received | when the connector settles the request result (reply, failure, timeout, connection end); not run for a request the caller cancelled | the request packet name, actor_id, whether it succeeded, the reply message on success, the error (§9) on failure, the elapsed time |
- Metadata the request sending hook adds is carried by that request. It is validated like any other metadata (§4.4).
- The reply received hook only reads the outcome. A hook cannot change or block the reply or the error.
- A hook failure does not change the request result. It is handled like any other callback failure (§7).
- The per-language document owns the names of the hooks and their contexts.
6. Connection Lifecycle¶
The following C# excerpt is a non-normative example to explain how connection, manual dispatch, and close look in one connector interface. It doesn't require the same signature in other languages, and the exact .NET signature is defined by the .NET Stream Connector contract.
public interface IZlinkStreamConnector : IAsyncDisposable
{
ZlinkStreamConnectionState State { get; }
IZlinkStreamLifecycleCall Connect { get; }
IZlinkStreamLifecycleCall Close { get; }
IZlinkStreamLifecycleCall Dispatch { get; }
IZlinkStreamRequestCall Request(ZlinkStreamEncodedPayload payload);
}
public interface IZlinkStreamLifecycleCall
{
ValueTask Async(CancellationToken cancellationToken = default);
}
public interface IZlinkStreamRequestCall
{
IZlinkStreamRequestCall PacketName(string name);
IZlinkStreamRequestCall Timeout(TimeSpan timeout);
ValueTask<ZlinkStreamEncodedPayload> Async(CancellationToken cancellationToken = default);
}
await connector.Connect.Async(cancellationToken); // waits until connection and receive-loop preparation finish.
var reply = await connector
.Request(payload)
.PacketName("inventory.get")
.Timeout(TimeSpan.FromSeconds(5))
.Async(cancellationToken); // waits for the terminal reply of the same request sequence.
await connector.Dispatch.Async(cancellationToken); // processes a pending callback in the current context, in Manual mode.
await connector.Close.Async(cancellationToken); // outside a callback, waits until the shared close work finishes.
| State | Meaning |
|---|---|
Created |
A Connector has been built but hasn't started connecting yet. |
Connecting |
The Connector is performing the initial connection. |
Connected |
The Connector has completed connection. |
Reconnecting |
The Connector is performing automatic reconnection. |
Disconnected |
The Connector's transport connection has dropped. |
Closed |
The Connector has been closed. The same connector object doesn't reconnect. |
A connection request behaves as follows depending on the current state.
| Current State | Behavior |
|---|---|
Created |
The Connector starts the initial connection. |
Disconnected |
The Connector starts a manual reconnect. |
Connecting |
The caller waits until the already-in-progress connection attempt finishes. |
Connected |
Since already connected, the call completes immediately as success. |
Reconnecting |
The caller waits for the result of the in-progress automatic reconnect. |
Closed |
Since a closed connector can't reconnect, the call fails with an error. |
Reconnect and pending request:
- Automatic reconnect is on by default.
- A send during reconnect isn't queued — it fails with a
Disconnectederror. - §5.2 defines the terminal result and retransmission policy for a disconnected request.
- The reconnect max attempt count must be able to express unlimited. The form of that expression (a null value, a negative number, a named constant, and so on) is owned by the per-language document. A client that keeps trying until the connection is restored specifies unlimited instead of writing a large number, which is what separates it from a configuration with a finite attempt count.
-
The delay between attempts carries randomness. The first base delay is the smaller of the initial delay and the maximum delay. Each later base delay is the smaller of the previous base delay times the backoff factor and the maximum delay. What is actually waited is a value drawn between 50% and 100% of that base delay.
With a deterministic delay, every client that was attached comes back at the same moment once the server drops them. That moment is the heaviest one for the server, so the timing is spread out.
-
When the attempts run out, the disconnect handler runs. After the last attempt fails the connection state becomes
Disconnectedand registered disconnect handlers run. A configuration set to unlimited never reaches this point.Whether the handler receives the close reason as an argument is settled by the language. Where it does not, the reason is read from the surface in §6.2. Either way there is a path to it.
Disconnect handlers run once when the transport drops and once when the attempts run out. The first says "there is no connection right now"; the second says "bringing it back has been given up." Without the first, a client that received
ServerDrainlearns nothing while a reconnect is still succeeding; without the second, the moment reconnection was abandoned is invisible. A successful reconnect means the second never happens.
Heartbeat:
- If on, sends a control ping at the specified interval.
- If no inbound frame arrives within the specified timeout, counted from the later of the connection establishment time and the last inbound frame, treats the transport as disconnected and applies the reconnect policy.
- Even with heartbeat off, it still replies with pong to an inbound ping.
6.1 Default Value¶
Even though the per-language name differs, the default value must be the same across every language.
| Item | Default |
|---|---|
| Connect timeout | 5 seconds |
| Request timeout | 30 seconds |
| Wait timeout (waiting for a specific packet) | 5 seconds |
| Heartbeat | On — interval 1 second, timeout 5 seconds |
| Reconnect | On — initial delay 250ms, max delay 5 seconds, backoff factor 2.0, max attempts 3 |
| Dispatch mode | Manual (§7) |
| Codec | JSON (§5.4) |
| Compression | Lz4 (§8) |
| Send/receive payload bound | §4.7 |
| TLS certificate validation | On — the default of the validation-skip option is off, used only for a test's self-signed certificate |
6.2 Close Reason¶
Once the connection drops, the connector exposes a close reason.
The value set is a closed set aligned with the server-side
close_reason
(runtime-metrics §6),
and the wire encoding is owned by §4.6's session-closing control
packet.
| Reason | Meaning |
|---|---|
ClientClose |
The client closed it |
IdleTimeout |
The server closed an idle session |
HeartbeatTimeout |
Disconnected because heartbeat didn't respond |
ServerDrain |
The server closed the session with graceful drain |
ProtocolError |
Disconnected due to a protocol violation |
TransportError |
Disconnected due to a transport-level failure |
A client that received ServerDrain looks at this value to decide
reconnection and backoff
(Complete Host Relocation Flow §9).
A capability for the server to specify a replacement endpoint isn't
included in this contract.
The close reason can be read from the connector's read surface at any
time. Code that didn't receive the disconnect event reads the same
value after the connection closed. If the connection has never dropped,
the value is empty. A failed first connect also leaves a reason —
the impact table in §9 settles it, so ConnectTimeout and
TlsValidationFailed give TransportError. Even where a connection was
never established, how the attempt ended is kept. The disconnect event carrying the reason as an
argument adds to this read surface rather than replacing it.
Reconnecting doesn't clear the value — the last close's reason is kept.
The per-language document only owns the type name and the shape of the read surface (whether a property or a method) expressing this reason.
6.3 Option Validation¶
Every option item is validated. Checking only some of them lets a bad value elsewhere pass unnoticed, and the caller cannot tell a configuration mistake from a connection failure.
-
The endpoint/transport match (§3.1), the connect/request/wait timeouts, the heartbeat interval and timeout, the reconnect delays, backoff factor, and max attempts, the send/receive payload bounds, the codec and compression settings, and the dispatch mode are all checked. The values checked are the ones left after §6.1's defaults are applied. Time values must be positive and the backoff factor a finite positive number. Values of a disabled heartbeat or reconnect are validated too. No ordering between time values is required.
-
Validation happens at the earliest point the language can report the failure. A language whose creation surface can return a failure validates when the connector is built; one whose creation surface cannot validates when the connection is attempted. Either way the rejection comes before a connection is made.
- A configuration that fails validation never reaches a connection. Only options that passed go on to connect.
- A single value outside its allowed range is
ValidationFailed; items that don't fit together areConfigurationError(§9). A conflict between the endpoint scheme and the transport, a transport the environment doesn't support, and a compression codec supplied with compression turned off belong to the latter. - Delivery follows §9.2. A build with exceptions disabled receives a value, and every other language receives an exception carrying the code. Throwing a language's standard exception as is leaves the caller unable to tell the two codes apart.
7. Dispatch Mode¶
| Mode | Behavior |
|---|---|
Manual (default) |
The receive loop doesn't directly call a handler/error/disconnect/request callback — it puts it in an internal queue. The user explicitly pumps it to run |
Immediate |
Runs directly on the receive path |
The internal queue of Manual never delays or refuses registering a callback because of capacity.
A registered callback runs, in registration order, when the user pumps.
The reason the default is Manual is a game engine constraint
(§2.2). Since an engine object can't be handled off the main thread, it
must be pumped on the main thread to be safe.
The waitFor/expectNone/waitForSequence family isn't a registered
callback. Since this surface directly observes and consumes an
unconsumed packet in the receive message queue in both dispatch modes,
it doesn't need a separate dispatch pump even in Manual. dispatch
only runs a registered push handler, error/disconnect handler, request
callback, and Actor lifecycle callback (§5.6).
Handler registration returns a value that can unregister it. This holds for the push handler and for the error/disconnect/connection state handlers alike. If a registration can only be removed by closing the connector, a client that registers and unregisters subscriptions with the lifetime of one screen has to build the connection again. An unregistered handler isn't run by later dispatches, and unregistering the same value twice isn't treated as an error. The returned type name is owned by the per-language document.
Whether the returned value's lifetime is the registration's lifetime is settled by the language. A language that expresses ownership as a value unregisters when that value goes away; there, the returned value is kept for as long as the registration must live. Elsewhere the value may be discarded and the registration stays until it is unregistered explicitly.
The connector does not wait for a handler to finish. Running a
registered handler — push handler, error handler, disconnect handler,
connection state handler, request callback and Actor lifecycle callback —
is the connector's work;
waiting for it to finish is not. No kind is an exception. Connection-state
and disconnect callbacks caused by close follow the dispatch mode. In
Manual, they run at a dispatch pump after close. close does not wait for
handler completion. Disconnecting after
the reconnect attempts are used up and disconnecting on a transport
error are the same (§6).
The connector finishes close after closing the transport and failing pending operations. It discards frames not yet written to the transport. Closing the transport does not wait for the peer to read or respond.
Where a handler returns a value, this means that value is not waited on; where a handler runs in place, running it is finishing it and there is no difference. Either way, one handler that never finishes cannot hold up the close.
Work that must finish is waited for outside the handler. The connector does not wait in its place.
8. Compression¶
- The supported algorithm is None and Lz4, and the default is Lz4.
- The compression algorithm isn't written in the header per packet. It's decided once as a connector option.
- The
payload compressedflag (§4.3) is just a mark that "this payload is compressed with that algorithm." - server → client: if the server turns on the flag and sends, the connector decompresses before calling the typed callback.
- client → server: only a send/request that explicitly requested compression is compressed. Turning on the option doesn't automatically compress.
- Compression only applies to the payload. The header isn't compressed.
- Setting
Nonedoesn't exchange a compressed frame. A send/ request that requested compression fails, and an inbound frame with thepayload compressedflag on is rejected withDecompressionFailed.
9. Error Meaning¶
| Error | Meaning |
|---|---|
Disconnected |
No connection, or dropped |
ConfigurationError |
Invalid configuration (scheme mismatch, a transport the environment doesn't support, etc.) |
ValidationFailed |
A validation failure — covers pre-send validation (metadata bound exceeded, send payload bound exceeded), option validation for a value outside its allowed range (§6.3), and a violation of a wait surface's observation condition (§10.1) |
RequestTimeout |
No reply within the request timeout |
ConnectTimeout |
Connect time exceeded |
FrameDecodeFailed |
Frame/header decode failure (§4.5), or a structurally valid Error frame's JSON payload doesn't satisfy §5.3 |
FrameTooLarge |
The payload exceeded the receive bound |
SendFailed |
Sending failed |
CompressionFailed |
Compression failure |
DecompressionFailed |
Decompression failure |
TlsValidationFailed |
TLS validation failure |
UserCallbackFailed |
A user callback failed |
RemoteError |
The server responded with an Error payload satisfying §5.3. §5.2 defines its recipient |
The effect an error has on the current operation and connection is below. The per-language document only owns the error name's expression — it doesn't change whether it's terminal, the close reason, or the reconnect condition.
| Error | Current Operation | Connection | Close Reason | Automatic Reconnect |
|---|---|---|---|---|
ConfigurationError, ValidationFailed |
Call failure | Kept, or the pre-connect-attempt state kept | None | Not done |
RequestTimeout |
Only that request fails | Kept | None | Not done |
ConnectTimeout, TlsValidationFailed |
Connect failure | Disconnected |
TransportError |
Applies the reconnect option's attempt policy |
Disconnected — transport dropped |
The in-progress operation fails | Disconnected |
TransportError |
Applied if the reconnect option is on |
SendFailed — transport write failure |
The operation of that write fails with SendFailed; other in-progress operations fail with Disconnected |
Ended | TransportError |
Applied if the reconnect option is on |
Disconnected — close |
The in-progress operation fails | Disconnected |
ClientClose |
Not done |
FrameDecodeFailed — frame/header |
That frame isn't delivered, and the pending request fails | Ended | ProtocolError |
Applied if the reconnect option is on |
FrameDecodeFailed — Error JSON payload |
The request_seq recipient defined by §5.2 |
Kept | None | Not done |
FrameTooLarge |
That frame isn't delivered, and the pending request fails | Ended | ProtocolError |
Applied if the reconnect option is on |
CompressionFailed |
Only that send operation fails | Kept | None | Not done |
DecompressionFailed |
Only that receive packet or pending request fails | Kept | None | Not done |
UserCallbackFailed, RemoteError |
Delivered as an error event or the related callback/request | Kept | None | Not done |
If the transport close fails during close handling, the connection is confirmed closed, the close reason and automatic reconnect policy of the cause that started the close are kept, and the close failure is delivered as a Disconnected error event. An explicit close call that finished its cleanup doesn't fail because of the close failure.
9.1 The Closed Error Code Set¶
The thirteen codes above are all of them. An implementation neither adds nor drops one. A per-language document owns only how the names are spelled.
One exception is stated explicitly — TlsValidationFailed does not occur in a
browser runtime, because the browser's WebSocket API does not distinguish a TLS
failure from an ordinary connection failure. The code stays in the set and simply
goes unused there. Varying the set per runtime would split the impact table above.
9.2 Delivery — The Receiver Must Be Able To Read The Code¶
The delivery method differs by surface, but the meaning is the same.
- An async (await) surface delivers the error on failure.
- A callback-based surface delivers the failure as a result object.
- A stream-level error with no request id is delivered as an error event.
Whichever the method, the receiver must be able to tell which of the thirteen it is. That is this section's requirement; what carries it is up to the platform.
| Target | Delivery | How the code is read |
|---|---|---|
| A build with exceptions disabled (game engines) | Returned as a value. Nothing is thrown | The result object's error code |
| Other C++ consumers (e2e, tooling) | A throwing adapter over the same surface | The code the exception carries |
| Every other language | An exception is thrown | The code the exception carries |
A language's standard exception type is not used as is. Types such as
IllegalArgumentException or InvalidOperationException have nowhere to put the
code, so the caller cannot tell ValidationFailed from ConfigurationError. A
language that delivers by exception defines a dedicated exception type that
carries the code.
The same requirement applies to option validation failures and to violations of the observation surfaces (§10.1). Test assertion helpers also read only this code: they don't infer a code from an exception's type or message, and they pass on a failure that carries no code unchanged.
10. Receive Message Queue¶
A Send packet the server sent stays in the receive message queue until it moves to a handler
(on family) or a wait surface (waitFor family).
- The client keeps receiving and processing what it is given. The queue has no bound, no message is discarded, and the connection is never closed over it.
- The connector applies no backpressure. It does not implement a socket of its own; it uses what the runtime provides (§2, §3.2). Browser and WASM builds run on the platform's native WebSocket API, which offers no surface for withholding reads. Flow control belongs to the server's STREAM socket and is outside this document.
- A response, error response, and heartbeat control frame do not pass through this queue. They are needed for request completion and connection keep-alive.
- The connector exposes the received count per packet name as
receivedCount(name), and all five languages provide it. The value is the number received under that name. Consuming does not lower it — the value stands whether a handler dispatched the message or a wait surface took it. The count is independent of the dispatch mode — whetherManualwaits for a pump orImmediateruns on the receive path, the value rises when the packet arrives (§7). This surface exists for scenario assertions, so "how many arrived under this name" must still be answerable after anonhandler has dispatched them. - The reference point is the moment the connection is established. The count starts at zero when the connection is established and counts what arrives on that connection. A reconnect is a new connection, so it starts at zero again. A name never received is zero.
- Establishing a connection also clears whatever the previous connection left
unconsumed. Resetting only the count and keeping the queue leaves the two
describing different connections, and
waitForwould hand back a packet from before the drop as if it belonged to the new connection. - The value is for scenario assertions and diagnostics, not as a basis for flow control — the queue has no bound, so a larger value changes nothing about what the connector does.
Messages do not pile up in a client that works. A handler dispatches them or a wait surface consumes them. If they do pile up it is a client bug, and neither discarding them nor closing the connection gains anything — the client has to be restarted either way. So the connector holds no policy for it.
10.1 Test Wait Surface¶
The connector provides a wait surface for observing a push in a
test as a public API. All five languages must provide the same
timeout, consumption order, and negative-observation meaning. A
general-purpose assertion unrelated to connector state, such as
condition checking, expected error, and timeout verification, isn't a
connector public contract. E2E owns that auxiliary code in each
language's Client/Support.
10.1.1 Push Observation Surface — The waitFor Family¶
Something that can only be judged by observing the receive message queue (§10). A method of the connector instance.
The surfaces below offer both packet name forms (§5). The exact argument and overload, and the
completion terminator (.Async/.submit/.run), are owned by each
language's document, and the remaining conditions are narrowed by
builder chaining.
- The predicate and the return value carry messages, not payloads.
A payload alone hides the metadata and the packet name from the
predicate.
Tis the type of the payload the message carries. -
A failure of the observed condition is
ValidationFailed— not arriving within the timeout, arriving when it should not have, arriving out of order.RequestTimeoutis not a code of this surface — that one is for a request whose reply did not arrive in time, as §9 defines. The two surfaces report the same "time passed" under different codes because the caller has to tell "the observation did not hold" from "the request got no reply". Where the connection has ended and the wait cannot continue, it isDisconnected. That covers a call left waiting when the connector is closed or the connection drops: the condition did not fail, the place to observe it went away.The release happens when the connection ends, not when the next one is established. Where reconnect is off or the attempts have run out, no next connection arrives; an implementation keyed to that moment leaves the wait hanging until its own timeout and then ends it as
ValidationFailed. The caller reads that as a failed observation when the connection is simply gone. How either reaches the caller is settled by §9.2 — as a value where exceptions are disabled, as an exception carrying the code everywhere else.
| Surface | Contract | Failure |
|---|---|---|
waitFor<T>(name or type) |
Waits until that packet arrives. Narrowed with .where(predicate)/.timeout(t). The default timeout is §6.1's wait timeout (5 seconds) |
Fails with an error if it doesn't arrive within the timeout (§10 specifies this surface consumes the queue) |
expectNone<T>(name or type) |
Confirms that packet doesn't arrive during .within(window) (negative). The symmetric of waitFor |
Fails with an error if it arrives within the window |
waitForSequence<T>(name or type) |
.expect(p1).expect(p2)….timeout(t) — confirms a push of the same name arrives in the given predicate order and returns the message list |
Fails with an error if the order is wrong or it times out. This surface exists to verify "arrived in order", not "N arrived" |
- A status wait doesn't have a separate surface. Since status is a
field of the payload, it's expressed as
waitFor<T>(name).where(p => p.status == …). The connector must not know which field is status. - Domain REST polling (
/orders/{id}, etc.) isn't this surface. That's the HTTP client's job, and isn't put in the connector contract.
11. Deployment Deliverable¶
This spec also owns which deliverable each target is distributed as. Because the deployment form reflects that environment's constraint.
| Target | Deliverable | Distribution Channel |
|---|---|---|
| Plain C++ client | zlink-stream-connector (zlink::stream_connector) |
CMake · vcpkg · Conan |
| Server e2e/perf (C++) | zlink-stream-e2e-client (zlink::stream_e2e_client) |
CMake · vcpkg · Conan |
| Unreal | zlink-unreal-stream-connector |
source plugin |
| Godot (C++) | zlink-godot-stream-connector |
source GDExtension |
| Cocos/Axmol | zlink-axmol-connector |
source package |
.NET (desktop/server) |
Zlink.Stream.Connector |
NuGet |
| Unity (native) | Uses the .NET package above as is (no dedicated package) |
NuGet |
| Godot C# | Uses the .NET package above as is |
NuGet |
| Java | systems.zlink:zlink-stream-connector |
Maven |
| Browser family (web/Cocos web/Unity WebGL/Godot Web) | @zlink-systems/stream-connector package root |
npm |
| Unity WebGL adapter | @zlink-systems/stream-connector's browser bundle and jslib/C# interop source |
com.zlink.stream-connector.webgl UPM source package |
| (common) wire layer | @zlink-systems/stream-wire |
npm |
Deployment principle:
- The web family shares one npm package root. Since browser/Cocos web/Unity WebGL/Godot Web are all a browser runtime, packages aren't multiplied per target.
- A native engine adapter is source-distributed (Unreal plugin, GDExtension, Axmol CMake). It's convention to be incorporated as source into the engine build system.
- Unity (native) and Godot C# don't have a separate package. They
use the
.NETconnector as is.
The Unity WebGL UPM package doesn't build a new wire runtime. It includes the npm package root's browser bundle and only provides the jslib/C# call boundary Unity requires as source. So browser and Unity WebGL use the same TypeScript connector protocol and codec.
12. Regression Test¶
The verification items this spec requires. Even if the per-language test name differs, the meaning must be the same.
| Item | Verification |
|---|---|
| Transport frame | Frame/header encoding/decoding follows §4 |
| Per-environment transport availability | The TypeScript package root rejects tcp:///tls:// as a configuration error |
| Browser bundle | The TypeScript package root bundle doesn't include a platform-only socket module |
| Typed request/reply | Correlation and matching rule follows §5.2 |
| Error response | The Error payload is §5.3's JSON object, and splits into pending failure / stream error depending on request_seq presence |
| Pending request cleanup | On timeout/close/disconnect, every pending fails and is removed (§5.2). A transport write failure ends the connection with close reason TransportError; the failed write's operation ends with SendFailed, and other in-progress operations end with Disconnected (§9) |
| Payload bound | The send bound applies before the transport write, and receive checks the wire payload and decompression result each (§4.7) |
| Metadata | Bound/duplicate/empty-key validation (§4.4) |
| Packet name | UTF-8 length limit (§4.2), $zlink. prefix reservation (§4.6), the per-language exact interface's default name/override rule |
| Codec | Connector option injection, codec number sharing, and browser/server dependency separation (§5.4) |
| Compression | Per-direction behavior (§8) |
| Error handling | Error meaning (§9) |
| Connection lifecycle | State transition/reconnect/heartbeat (§6) |
| Option validation | Every option is checked before a connection is made; a value out of range is rejected as ValidationFailed and a mismatch between options as ConfigurationError (§6.3) |
| Transport inference | An unspecified transport is settled by the endpoint scheme, and a specified one that conflicts with the scheme is ConfigurationError (§3.1) |
| Error delivery | The receiver can tell which of §9's thirteen it is. A language's standard exception type is not used as is (§9.2) |
| Reconnect delay | The wait between attempts falls between 50% and 100% of the base delay. When the attempts run out the state becomes Disconnected and disconnect handlers run (§6) |
| Unregistration | Every registration — connector and handle push, error, disconnect, connection state, Actor bound and unbound — returns a value that unregisters, and an unregistered handler is not run by later dispatches (§7) |
| Received count | receivedCount(name) counts what arrived, does not fall on consumption, and is independent of dispatch mode. It restarts at zero when the connection is established (§10) |
| Wait surfaces | Both the named path and the payload-type path exist, the predicate and the return value carry messages, a failed condition is ValidationFailed, and an ended connection is Disconnected (§10.1) |
| Actor slot wire | The actor_slot flag and field encode and decode both ways, a control header carries no slot, and a mismatch between the flag and field presence is a decode error (§4.2, §4.5, §4.6) |
| Actor lifecycle control | A malformed bound/unbound payload, a bound announcement for an already open slot, a bound announcement for an actor_id already in use, and an unbound announcement for a slot missing from the table all end the connection as FrameDecodeFailed (§4.6, §5.6, §9) |
| Actor table | $zlink.actor.bound arrives before the first packet carrying that slot, a received message's actor_id is resolved through the table, and a frame without a slot carries no actor_id (§5.6) |
| Actor handle | actors is a read-only snapshot taken at the call and a closed handle still reads its actor_id. The list and the lookup are updated before the bound callback, which runs before that Actor's first packet callback, and a dropped transport closes every open handle in issue order with its unbound callback before the disconnected callback (§5.6, §7) |
| Actor handle send and receive | A handle's send and request carry that slot and a handle's receive registration gets only that Actor's messages. Send/request on a closed handle are ValidationFailed, and an open handle gives the same timeout and cancellation results as the connector-level builders (§5.6) |
| Actor language projection | The .NET typed extensions, the Java named typed overload, the C++ templates and subscriptions, the TypeScript Disposable, and the Unity WebGL JSON boundary round trip are observable on the public surface (§5.6, language documents) |
| No flow sent | Outbound frames carry no flow field and no flag 0x10, inbound flow fields are dropped after the structural check, and a one-way Send has no correlation id (§5.5) |
| Request hooks | The request sending hook runs just before sending, in registration order, for both connector and Actor handle requests, and the metadata it adds is in the frame; the reply received hook runs once per success, failure, timeout and connection end and cannot change the outcome; a hook failure does not change the request result (§5.7) |
| Both name forms | Receive registration, send and request at the connector and Actor handle levels, and the wait surfaces at the connector level, offer the named form and the type form, and both reach the same packet name (§5) |
| Handlers and close | Push, error, disconnect, connection state, Actor bound and Actor unbound handlers and request callbacks all follow the registration order, callback failure and no-waiting rules, and the connector does not wait for a handler that never finishes. Connection-state and disconnect callbacks caused by close follow the dispatch mode. In Manual, they run at a dispatch pump after close; close does not wait for handler completion. Disconnecting after the reconnect attempts are used up and on a transport error runs them in the same order and does not wait (§7) |
| Close and unwritten frames | close returns even when the peer does not read. Pending Sends and Requests whose frames were not written to the transport fail with Disconnected (§7). |
| Close reason read surface | Code that did not receive the event reads the same value. A failed first connect still leaves a reason, and reconnecting does not clear it (§6.2) |