Skip to content

Document list | Previous: Java STREAM

Java spec table of contents

Java Bundle | STREAM | Samples

Java/Kotlin Stream Connector

This document is the Java/Kotlin projection of the Stream Connector Common Spec. Transport/wire/lifecycle/error meaning is owned by the common spec, and this document fixes the exact public surface that meaning has in Java/Kotlin.

1. Goal

Stream Connector is a module separate from the server framework. It lets an external client build and interpret the same framework-header-based STREAM packet the server's ZLinkSession receives.

This module doesn't depend on a Spring Boot server adapter, SPOT, or Registry. It only has the dependency needed for client execution, such as transport, codec, compression, reconnect, and dispatch queue.

1.1 Target Execution Environment

The connector responsible per engine × build target is owned by Common Spec §2. Per that assignment, what the Java/Kotlin connector is responsible for is a JVM application (server tool/E2E test/bot), and it isn't responsible for a game engine or browser.

Since there's only one target, this assignment leaves no effect on the Java/Kotlin surface. The usage guide only separately describes the per-language async usage in the Java guide and the Kotlin guide.

2. Module

The Java connector's Maven coordinate is systems.zlink:zlink-stream-connector.

Module Role
zlink-stream-connector TCP/TLS/WS/WSS transport, frame codec, send/request, dispatch
zlink-framework-kotlin coroutine, Flow, DSL extension
zlink-framework-codec-protobuf The Protobuf codec extension shared across framework/connector/http-client
zlink-framework-codec-msgpack The MessagePack codec extension shared across framework/connector/http-client

JSON is the framework default codec. A Protobuf and MessagePack payload isn't a connector-dedicated package — it's used by applying the zlink-framework-codec-protobuf, zlink-framework-codec-msgpack framework codec extension to the connector too.

3. Public API

public interface ZLinkStreamConnector {
    boolean isConnected();
    ZLinkStreamConnectionState state();
    ZLinkStreamConnectorOptions options();

    // Runtime read/write of the diagnostics level (§4.1, server spec 26 §4.1).
    // Changes the level without recreating the connector. options().diagnosticsLevel()
    // always agrees with this value.
    ZLinkStreamDiagnosticsLevel diagnosticsLevel();
    void setDiagnosticsLevel(ZLinkStreamDiagnosticsLevel level);

    int pendingDispatchCount();
    int receivedCount(String name);

    // there are only three lifecycle surfaces. Manual reconnect is a state
    // transition of connect(), and automatic reconnect is handled by options
    // (Common Spec 32 §6).
    ZLinkStreamLifecycleCall connect();
    ZLinkStreamLifecycleCall close();
    ZLinkStreamLifecycleCall dispatch();

    ZLinkStreamSendCall send(ZLinkStreamEncodedPayload payload);
    ZLinkStreamRequestCall request(ZLinkStreamEncodedPayload payload);
    ZLinkTypedStreamSendCall send(Object payload);
    ZLinkTypedStreamRequestCall request(Object payload);
    ZLinkStreamWaitCall waitFor(String name);
    ZLinkStreamWaitCall waitFor(Class<?> payloadType);
    ZLinkStreamExpectNoneCall expectNone(String name);
    ZLinkStreamExpectNoneCall expectNone(Class<?> payloadType);
    ZLinkStreamSequenceCall waitForSequence(String name);
    ZLinkStreamSequenceCall waitForSequence(Class<?> payloadType);

    AutoCloseable on(
        String name,
        ZLinkStreamMessageHandler<ZLinkStreamEncodedPayload> handler);
    <TPayload> AutoCloseable on(
        Class<TPayload> payloadType,
        ZLinkStreamMessageHandler<TPayload> handler);
    <TPayload> AutoCloseable on(
        String name,
        Class<TPayload> payloadType,
        ZLinkStreamMessageHandler<TPayload> handler);
    AutoCloseable onErrorReceived(ZLinkStreamErrorHandler handler);
    AutoCloseable onDisconnected(ZLinkStreamDisconnectedHandler handler);
    AutoCloseable onConnectionStateChanged(ZLinkStreamConnectionStateHandler handler);
    AutoCloseable observeInbound(ZLinkStreamInboundObserver observer);
}

public interface ZLinkStreamLifecycleCall {
    CompletionStage<Void> submit();
}

public final class ZLinkStreamConnectorFactory {
    public static ZLinkStreamConnector create(ZLinkStreamConnectorOptions options);
}

Java exposes an event as an on... registration. The meaning is the same as .NET's event. Deregistration is done through the returned AutoCloseable.

Session close reason. The value set and meaning is owned by Common Spec §6.3. Java expresses this as the closed enum ZLinkStreamCloseReason (CLIENT_CLOSE, IDLE_TIMEOUT, HEARTBEAT_TIMEOUT, SERVER_DRAIN, PROTOCOL_ERROR, TRANSPORT_ERROR), and exposes it as the disconnect event's ZLinkStreamCloseReason closeReason(), which ZLinkStreamDisconnectedHandler receives. waitFor(...) returns a call builder that waits once for a server push of a specific packet name. When only a specific message is needed, use the builder's where(...). Once the timeout passes, the returned CompletionStage ends with a timeout failure. If a separate timeout isn't specified, the connector options' waitTimeout() value is used. Since waitFor(...) directly consumes an unconsumed receive packet in both dispatch modes, dispatch().submit() isn't needed in MANUAL either. dispatch().submit() runs a registered push handler, error/ disconnect handler, and request callback.

In the Java API, submit(...) starts an async operation. A one-way send's submit() returns CompletionStage<Void>. This stage only delivers completion and failure, and doesn't include transport result or admission status. A request/wait/lifecycle's submit() returns a CompletionStage carrying each operation's result (04 §1). The Java connector doesn't provide a separate blocking terminator that waits for the same operation on the current thread. Lifecycle also follows the same call builder convention, like connect().submit(), dispatch().submit(). The Kotlin wrapper waits for the CompletionStage obtained from submit() as a coroutine suspension. This execution meaning follows the framework common policy.

4. Options

The default value is owned by Common Spec §6.1. Java expresses this as a record with a flat field (heartbeat/reconnect aren't put as a nested object). A default instance is built with createDefault(URI endpoint).

// transport (TCP/TLS/WS/WSS) is decided by the endpoint URI scheme. Heartbeat/reconnect
// configuration is a flat field, not a separate nested object. A default instance is built
// with `createDefault(URI endpoint)`.
public record ZLinkStreamConnectorOptions(
    URI endpoint,
    ZLinkStreamDispatchMode dispatchMode,      // default MANUAL
    Duration requestTimeout,                   // default 30s
    Duration waitTimeout,                      // default 5s
    int maxReconnectAttempts,                  // default 3
    Duration connectTimeout,                   // default 5s
    int maxSendPayloadSize,                    // default 64 * 1024
    int maxReceivePayloadSize,                 // default 64 * 1024
    int maxReceivedMessages,                   // default 1024 (the receive message queue bound)
    int maxInboundObserverNotifications,       // default 1024
    int maxInboundObserverPayloadPreviewBytes, // default 0
    boolean heartbeatEnabled,                  // default true
    Duration heartbeatInterval,                // default 1s
    Duration heartbeatTimeout,                 // default 5s
    boolean reconnectEnabled,                  // default true
    Duration reconnectInitialDelay,            // default 250ms
    Duration reconnectMaxDelay,                // default 5s
    double reconnectBackoffFactor,             // default 2.0
    boolean skipServerCertificateValidation,
    ZLinkStreamCompression compression,
    ZLinkStreamCompressionCodec compressionCodec,
    ZLinkStreamPacketNameResolver nameResolver,
    ZLinkStreamTypedCodec typedCodec,
    ZLinkStreamDiagnosticsLevel diagnosticsLevel) { // default ERRORS (§4.1)
}

4.1 Diagnostics Level

The contract is owned by Common spec §13. The Java surface is:

public enum ZLinkStreamDiagnosticsLevel { OFF, ERRORS, NORMAL, DETAILED }

// record component. The compact constructor normalizes null to ERRORS. This is only
// the construction-time initial value.
public ZLinkStreamDiagnosticsLevel diagnosticsLevel();
public ZLinkStreamConnectorOptions withDiagnosticsLevel(ZLinkStreamDiagnosticsLevel level);

At OFF, outbound frames create no flow pair (0x10 not set), and inbound flow fields keep only the structural length check — value validation and handler delivery are skipped. The request correlation is kept regardless of the level.

Runtime change. Per the rule required by Common spec §13, which follows server spec 26 §4.1, ZLinkStreamConnectorOptions.diagnosticsLevel() is only the construction-time initial value; the connector itself owns a runtime read/write API.

// Declared on ZLinkStreamConnector (§3). The application reads and changes the level
// without recreating the connector.
ZLinkStreamDiagnosticsLevel diagnosticsLevel();
void setDiagnosticsLevel(ZLinkStreamDiagnosticsLevel level);

Internally, the current level is held in an atomic cell (AtomicReference). options().diagnosticsLevel() always agrees with the cell's current value. Each processing point (one outbound submit, one inbound frame dispatch) reads the cell exactly once when it starts processing, and uses that single value consistently throughout that processing (header encode/decode, whether the flow pair is attached or validated, the flow fields delivered to a handler). The cell is never re-read mid-processing, so a level change landing in the middle of one processing does not desynchronize that processing — it runs to completion using the value it read at the start. A change applies starting with processing points that begin after the change; frames already built are never retroactively changed.

skipServerCertificateValidation is used only for a test's self-signed certificate. The production default is false. Setting this value to true passes both TLS transport and WSS transport without trusting the server certificate, so it must not be used in a production environment.

5. Transport And Codec

The scheme → transport mapping and TLS validation rule is owned by Common Spec §3. Java infers the transport from the endpoint URI scheme instead of choosing it as a separate enum option.

public enum ZLinkStreamTransport { TCP, TLS, WEB_SOCKET, WEB_SOCKET_SECURE }
public enum ZLinkStreamCodec { RAW, JSON, MESSAGE_PACK, PROTOBUF }
public enum ZLinkStreamCompression { NONE, LZ4 }

Hostname validation uses the HTTPS endpoint identification rule.

6. Packet Model

public record ZLinkStreamEncodedPayload(
    String packetName,
    Message payload,
    Map<String, String> metadata,
    ZLinkStreamCodec codec) {
}

public enum ZLinkFlowOrigin {
    INBOUND,
    TIMER,
    APPLICATION,
    LIFECYCLE
}

public interface ZLinkStreamFlow {
    String flowId();
    ZLinkFlowOrigin flowOrigin();
}

public record ZLinkStreamMessage<TPayload>(
    String packetName,
    TPayload payload,
    Map<String, String> metadata,
    String flowId,
    ZLinkFlowOrigin flowOrigin) implements ZLinkStreamFlow {
}

A typed object's packet identity prioritizes the payload type's @ZLinkStreamPacketName, and uses the type's SimpleName if absent. If the caller specifies it with packetName(...), that name takes priority (Common Spec 32 §5). The identity of an already-encoded raw payload is specified in ZLinkStreamEncodedPayload.packetName().

Metadata only carries a small key-value. Large work data is sent as payload. The STREAM wire header is a runtime-internal type. The connector user and server session don't build or pass a header object — they only handle the packet name and a metadata snapshot in the public model.

7. Send And Request

public interface ZLinkStreamSendCall {
    ZLinkStreamSendCall packetName(String name);   // per-call override. If specified, this name takes priority
    ZLinkStreamSendCall metadata(String key, String value);
    ZLinkStreamSendCall metadata(Map<String, String> metadata);
    ZLinkStreamSendCall compress();
    CompletionStage<Void> submit();
}

public interface ZLinkStreamRequestCall {
    ZLinkStreamRequestCall packetName(String name);   // per-call override
    ZLinkStreamRequestCall metadata(String key, String value);
    ZLinkStreamRequestCall metadata(Map<String, String> metadata);
    ZLinkStreamRequestCall timeout(Duration timeout);
    ZLinkStreamRequestCall compress();
    CompletionStage<ZLinkStreamEncodedPayload> submit();
    <TReply> CompletionStage<TReply> submit(Class<TReply> replyType);
}

public interface ZLinkTypedStreamSendCall {
    ZLinkTypedStreamSendCall packetName(String name);   // per-call override
    ZLinkTypedStreamSendCall metadata(String key, String value);
    ZLinkTypedStreamSendCall metadata(Map<String, String> metadata);
    ZLinkTypedStreamSendCall compress();
    CompletionStage<Void> submit();
}

public interface ZLinkTypedStreamRequestCall {
    ZLinkTypedStreamRequestCall packetName(String name);   // per-call override
    ZLinkTypedStreamRequestCall metadata(String key, String value);
    ZLinkTypedStreamRequestCall metadata(Map<String, String> metadata);
    ZLinkTypedStreamRequestCall timeout(Duration timeout);
    ZLinkTypedStreamRequestCall compress();
    <TReply> CompletionStage<TReply> submit(Class<TReply> replyType);
}

public interface ZLinkStreamWaitCall {
    ZLinkStreamWaitCall timeout(Duration timeout);
    ZLinkStreamWaitCall where(
        Predicate<ZLinkStreamMessage<ZLinkStreamEncodedPayload>> predicate);
    <TPayload> ZLinkStreamWaitCall where(
        Class<TPayload> payloadType,
        Predicate<ZLinkStreamMessage<TPayload>> predicate);
    CompletionStage<ZLinkStreamMessage<ZLinkStreamEncodedPayload>> submit();
    <TPayload> CompletionStage<ZLinkStreamMessage<TPayload>> submit(
        Class<TPayload> payloadType);
}

Once the request timeout ends, the pending request is removed and the returned CompletionStage completes with a timeout failure. Even if the removed request's response arrives late, that stage isn't completed again.

7.1 Flow Correlation

An outbound operation the Connector starts generates a UUIDv7 flow_id once. The connector runtime sets the current flow context for the inbound handler's execution scope. A related outbound started from that handler reuses the same flowId and flowOrigin with no separate public argument, and restores the previous context at the handler's terminal completion. The flow isn't propagated to an unrelated next callback or a separate executor the Framework doesn't manage, and an outbound started there starts a new flow with APPLICATION origin.

The current flow isn't guessed from a connector instance's mutable field or thread ID. The wire format and async context boundary is owned by Stream Connector §4.2 and Flow Correlation §6.

7.2 Test Wait Surface

The contract is owned by Common Spec §10.2. The Java surface is below.

Push observation — connector method (the same spot as waitFor). Each returns a builder.

ZLinkStreamWaitCall       waitFor(String name);          // waits until it arrives
ZLinkStreamExpectNoneCall expectNone(String name);       // whether it doesn't arrive during .within(window)
ZLinkStreamSequenceCall   waitForSequence(String name);  // .expect(p).expect(p)… in order
  • expectNone(name).within(Duration).submit()throws an exception if it arrives within the window. The symmetric of waitFor.
  • waitForSequence(name).expect(p1).expect(p2)….timeout(t).submit() — confirms a push of the same name arrives in predicate order, and returns the payload list. Verifies "arrived in order", not "N arrived."
  • A status-only surface isn't provided. Since status is a payload field, it's expressed as waitFor(T.class).where(p -> p.status() == …).

  • Domain REST polling isn't this surface. That's ZLinkHttpClient's job.

8. Typed Payload Codec

The base connector keeps the wire payload as ZLinkStreamEncodedPayload. The typed surface uses options' single typedCodec to encode/ decode the work DTO (default is JSON). Application code generally doesn't directly handle a raw Message or codec helper.

The ZLinkStreamConnector.send(Object), request(Object), on(Class<TPayload>, ...), waitFor(...) above are the typed payload surface.

The packet name the typed surface builds directly uses the core connector's name resolver as is. A payload that can't be expressed by the codec fails as a configuration error. When waiting for a server push, the base connector's wait builder is used. If a payload condition is needed, the core wait builder's where is used, like connector.waitFor(name).where(payloadType, predicate).submit(payloadType). A sample client waits for a server push using the connector member waitFor(...).where(...).submit(...) or the Kotlin wrapper waitFor<T>(...).where { ... }.await() form. The typed surface operates based on a work object payload the registry can encode/decode. A raw payload, such as String, byte[], Message, is only handled on a connector sub-path or explicit raw use.

The Kotlin surface doesn't directly expose the Java call to the application — it wraps it with a dedicated wrapper. Reply type is fixed when building the request wrapper, so type or operation name isn't repeated in the terminal.

// Reply type is fixed on the wrapper when building the request, and the result is awaited with await().
val reply: LoginReply = connector
    .request<LoginReply>(LoginRequest("user-1"))
    .await()

// Server push is also awaited with the Kotlin-dedicated wait wrapper's await().
val pushed: ZLinkStreamMessage<Notice> = connector
    .waitFor<Notice>()
    .where { it.payload.important }
    .await()

9. Dispatch Mode

public enum ZLinkStreamDispatchMode {
    MANUAL,     // default
    IMMEDIATE   // runs inline on the receive path (Common Spec 32 §7)
}

The default is MANUAL. The receive loop, reconnect loop, and request callback task don't directly call a user handler — they put it in the dispatch queue. The application calls dispatch().submit() on the thread of its choice.

IMMEDIATE runs the callback inline on the receive path, so a slow handler blocks the receive loop and backpressure applies accordingly. A client sample with a UI thread or game loop keeps MANUAL.

10. Connection State

The state's meaning and transition is owned by Common Spec §6. Java expresses this as a closed enum.

public enum ZLinkStreamConnectionState {
    CREATED,
    DISCONNECTED,
    CONNECTING,
    CONNECTED,
    RECONNECTING,
    CLOSED
}

Once close().submit() completes, it's CLOSED and a new connect() fails.

CREATED is the initial state before the first connection attempt. Once a connection attempt fails, it switches to DISCONNECTED, so "never connected" and "disconnected" are distinguished.

11. Error Code

The error's meaning is owned by Common Spec §9. Java expresses this as a closed enum.

public enum ZLinkStreamErrorCode {
    DISCONNECTED,
    CONFIGURATION_ERROR,
    VALIDATION_FAILED,
    REQUEST_TIMEOUT,
    CONNECT_TIMEOUT,
    FRAME_DECODE_FAILED,
    FRAME_TOO_LARGE,
    SEND_FAILED,
    COMPRESSION_FAILED,
    TLS_VALIDATION_FAILED,
    DECOMPRESSION_FAILED,
    USER_CALLBACK_FAILED,
    OBSERVER_FAILED,
    OBSERVER_DROPPED,
    RECEIVED_MESSAGE_DROPPED,   // receive message queue overflow (Common Spec 32 §10)
    REMOTE_ERROR
}

12. Inbound Observer

Observation meaning and the isolation/overflow rule is owned by Common Spec §10. Java registers only before connection starts and deregisters with AutoCloseable.

try (AutoCloseable log = connector.observeInbound(observation -> {
    System.out.printf(
        "stream-inbound kind=%s name=%s seq=%s bytes=%d%n",
        observation.kind(),
        observation.packetName(),
        observation.requestSeq(),
        observation.payloadLength());
})) {
    connector.connect().submit(); // connection completion is observed through the returned CompletionStage.
}

12.1 Metric

The Java connector publishes Common Spec §6.2's zlink.stream.reconnects and its closed tag to the Micrometer global registry. The application and E2E register a public MeterRegistry with Metrics.addRegistry(...) and read the counter from the same registry. The Kotlin wrapper also uses the same registry as the Java connector. A registry or listener failure doesn't change send, request, or connection state.

13. Kotlin Surface

The Kotlin module is a thin wrapper on top of the Java connector. An operation with a completion value, such as lifecycle and request, is awaited with the Kotlin wrapper's suspend await(). This await() waits for the Java CompletionStage as a coroutine suspension. A one-way send also waits for completion and failure with await(), but doesn't receive a transport result or admission status.

fun ZLinkStreamConnector.kotlin(): ZLinkKotlinStreamConnector

fun ZLinkStreamConnectorOptions.withDefaultStreamCompression(): ZLinkStreamConnectorOptions
fun ZLinkStreamConnectorOptions.withLz4StreamCompression(): ZLinkStreamConnectorOptions
fun ZLinkStreamConnectorOptions.withStreamCompression(
    codec: ZLinkStreamCompressionCodec,
): ZLinkStreamConnectorOptions
fun ZLinkStreamConnectorOptions.withoutStreamCompression(): ZLinkStreamConnectorOptions

class ZLinkKotlinStreamConnector {
    fun receivedCount(name: String): Int
    fun connect(): ZLinkKotlinLifecycleCall
    fun close(): ZLinkKotlinLifecycleCall
    fun dispatch(): ZLinkKotlinLifecycleCall
    fun send(payload: ZLinkStreamEncodedPayload): ZLinkKotlinSendCall
    fun send(payload: Any): ZLinkKotlinSendCall
    fun request(
        payload: ZLinkStreamEncodedPayload,
    ): ZLinkKotlinRawRequestCall
    fun <TReply : Any> request(
        payload: Any,
        replyType: KClass<TReply>,
    ): ZLinkKotlinRequestCall<TReply>
    fun <TPayload> waitFor(): ZLinkStreamTypedWaitCall<TPayload>
    fun <TPayload> waitFor(name: String): ZLinkStreamTypedWaitCall<TPayload>
    fun <TPayload> expectNone(name: String): ZLinkStreamTypedExpectNoneCall<TPayload>
    fun <TPayload> waitForSequence(name: String): ZLinkStreamTypedSequenceCall<TPayload>
    fun messages(packetName: String): Flow<ZLinkStreamMessage<ZLinkStreamEncodedPayload>>
    fun errors(): Flow<ZLinkStreamError>
}

class ZLinkKotlinLifecycleCall {
    suspend fun await()
}

class ZLinkKotlinSendCall {
    suspend fun await(): Unit
}

class ZLinkKotlinRawRequestCall {
    fun packetName(name: String): ZLinkKotlinRawRequestCall
    fun metadata(key: String, value: String): ZLinkKotlinRawRequestCall
    fun timeout(timeout: Duration): ZLinkKotlinRawRequestCall
    fun compress(): ZLinkKotlinRawRequestCall
    suspend fun await(): ZLinkStreamEncodedPayload
}

class ZLinkKotlinRequestCall<TReply : Any> {
    fun packetName(name: String): ZLinkKotlinRequestCall<TReply>
    fun metadata(key: String, value: String): ZLinkKotlinRequestCall<TReply>
    fun timeout(timeout: Duration): ZLinkKotlinRequestCall<TReply>
    fun compress(): ZLinkKotlinRequestCall<TReply>
    suspend fun await(): TReply
}

inline fun <reified TReply : Any> ZLinkKotlinStreamConnector.request(
    payload: Any,
): ZLinkKotlinRequestCall<TReply> =
    request(payload, TReply::class)

class ZLinkStreamTypedWaitCall<TPayload> {
    fun timeout(timeout: Duration): ZLinkStreamTypedWaitCall<TPayload>
    fun where(predicate: (ZLinkStreamMessage<TPayload>) -> Boolean): ZLinkStreamTypedWaitCall<TPayload>
    suspend fun await(): ZLinkStreamMessage<TPayload>
}

class ZLinkStreamTypedExpectNoneCall<TPayload> {
    fun within(window: Duration): ZLinkStreamTypedExpectNoneCall<TPayload>
    suspend fun await()   // exception if it arrives within the window
}

class ZLinkStreamTypedSequenceCall<TPayload> {
    fun expect(predicate: (ZLinkStreamMessage<TPayload>) -> Boolean): ZLinkStreamTypedSequenceCall<TPayload>
    fun timeout(timeout: Duration): ZLinkStreamTypedSequenceCall<TPayload>
    suspend fun await(): List<ZLinkStreamMessage<TPayload>>   // arrives in predicate order
}

The Kotlin wrapper must not build a different state transition or buffering policy from the Java connector. The extension copying options must preserve every option value, including the receive message bound. messages(...) and errors() wrap the Java connector's on(...), onErrorReceived(...) handler with callbackFlow. So in manual dispatch mode, just like Java, the Kotlin wrapper's dispatch().await() must be called for the collector to receive a message or error event.

14. Verification Standard

The Java connector has the tests below as a separate suite.

  • Public API export test
  • Transport scheme inference and mismatch validation
  • Header encode/decode roundtrip
  • Metadata validation
  • Send frame size limit
  • Request timeout pending cleanup
  • Manual dispatch queue and pendingDispatchCount
  • Immediate dispatch callback
  • Heartbeat ping/pong and timeout
  • Reconnect backoff and max attempts
  • Typed handler registry add/remove
  • JSON, MessagePack, Protobuf codec smoke
  • Typed helper packet name resolver and codec selection
  • Typed request/reply decode
  • Inbound observer response/send/control observation, callback failure, queue overflow
  • Kotlin coroutine/Flow wrapper smoke

Document list | Previous: Java STREAM