blob: 9c3b75c08fdb044fa123c7d2bf9273b938f4a637 [file] [view]
# Frame Protocol Conversion
**Audience:** contributors adding a protocol or understanding the internal
wire representation — the `EventMeshFrame` format, the `FrameAdaptor` SPI,
and end-to-end conversion chains per protocol. Part of the offset /
load-balancing / frame design trio; see also
[Offset management](offset-management.md).
---
## Architectural layering
```
┌─ external protocols (FrameAdaptor SPI) ─────────────────────────────┐
│ │
│ CloudEvents (HTTP/SSE/WS) → CloudEventsFrameAdaptor → EventMeshFrame │
│ MeshMessage (legacy TCP) → MeshMessageFrameAdaptor → EventMeshFrame │
│ A2A (JSON-RPC 2.0) → A2AFrameAdaptor → EventMeshFrame │
│ future protocol → new FrameAdaptor → EventMeshFrame │
│ │
│ The three protocols are peers; each converts directly to Frame — │
│ none of them passes through CloudEvent. │
└──────────────────────────────┬───────────────────────────────────────┘
│ external protocol → Frame, directly
┌──────────────────────────────▼───────────────────────────────────────┐
│ internal (runtime + storage): EventMeshFrame end to end │
│ │
│ ingress publish → Frame → storage.send(Frame) → MQ bytes │
│ MQ bytes → storage.poll() → Frame → dispatch/filter/TTL → Frame │
└──────────────────────────────┬───────────────────────────────────────┘
│ egress: Frame → the matching FrameAdaptor
┌──────────────────────────────▼───────────────────────────────────────┐
│ egress (FrameAdaptor SPI, per client connection protocol) │
│ │
│ Frame → CloudEvents-JSON (SSE / WS / HTTP poll) │
│ Frame → MeshMessage Package (legacy TCP) │
│ Frame → A2A JSON-RPC bytes (A2A callbacks) │
└────────────────────────────────────────────────────────────────────────┘
```
**CloudEvent is not the internal representation** — it is one of the
external ingress formats for CloudEvents clients, converted to Frame by
`CloudEventsFrameAdaptor` on entry. MeshMessage and A2A likewise each have
their own independent adaptor; they never pass through CloudEvent.
## EventMeshFrame wire format
```
fixed header, 14 bytes:
[magic:1=0xEF][ver:1=1][msgType:1][flags:1][seq:4][keyCount:2][dataLen:4]
KV attribute section (keyCount ×):
[nameLen:2][name:UTF-8][valLen:4][value:UTF-8]
data:
raw bytes (streaming chunk/prompt / event business payload)
msgType = STREAM_REQ(1) | STREAM_CHUNK(2) | EVENT(3)
flags = bit0 done | bit1 hasError | bit2 hasMeta (streaming use)
```
## Field mapping per msgType
| msgType | Fixed-header fields | KV attributes | data |
| --- | --- | --- | --- |
| **STREAM_REQ** | — | `sid`=sessionId, `replyTo`=reply address, `model`?, `conv`? | prompt text |
| **STREAM_CHUNK** | `seq`=in-stream sequence, `flags.done`=termination mark | `sid`, `etype`?, `err`?, `meta`?(JSON) | chunk text |
| **EVENT** | — | `id`/`type`/`source`/`subject`/`time`/`emttl`/`emcorrelationid`/`empopck`/user extensions | event payload |
## The FrameAdaptor SPI
Bidirectional conversion between external protocols and `EventMeshFrame` is
defined by the `FrameAdaptor` SPI
(`eventmesh-protocol-plugin/eventmesh-protocol-api/.../FrameAdaptor.java`):
```java
@EventMeshSPI(eventMeshExtensionType = EventMeshExtensionType.PROTOCOL)
public interface FrameAdaptor {
EventMeshFrame toFrame(ProtocolTransportObject proto); // ingress
ProtocolTransportObject fromFrame(EventMeshFrame frame); // egress
String getProtocolType();
}
```
The runtime never calls `EventMeshFrame.fromCloudEvent()` /
`.toCloudEvent()` / `MeshMessageFrameCodec` directly — everything goes
through `FrameAdaptors.get(<protocol name>)`. **Adding a new protocol is
just implementing `FrameAdaptor` + registering the SPI — no runtime code
changes.**
## Protocol plugin modules
| Module | FrameAdaptor | External protocol | Conversion |
| --- | --- | --- | --- |
| **protocol-api** | — (SPI interface + `FrameAdaptors` loader only) | — | zero implementations |
| **protocol-cloudevents** | `CloudEventsFrameAdaptor` | CloudEvents-JSON (HTTP/SSE/WS) | CE-JSON bytes ↔ Frame (field mapping via the CE object) |
| **protocol-meshmessage** | `MeshMessageFrameAdaptor` | MeshMessage Package (TCP) | Package ↔ Frame (direct field mapping, zero CE intermediary) |
| **protocol-a2a** | `A2AFrameAdaptor` | A2A JSON-RPC 2.0 | JSON-RPC bytes ↔ Frame (direct field mapping, zero CE intermediary) |
| ~~protocol-grpc~~ | — | — | **deleted** (empty shell, no sources) |
## The WireCodec SPI (internal MQ wire encoding)
Byte-level encode/decode for the internal MQ wire is defined by the
`WireCodec` SPI (`eventmesh-common/.../wire/`):
```java
public interface WireCodec {
byte[] encode(StreamRequest request);
StreamRequest decodeRequest(byte[] bytes);
byte[] encode(StreamChunk chunk);
StreamChunk decodeChunk(byte[] bytes);
byte[] encode(CloudEvent event);
CloudEvent decodeEvent(byte[] bytes);
}
```
- Default implementation: `EventMeshFrameCodec` (EventMeshFrame ↔ byte[]).
- Replaceable via `-Deventmesh.wire.codec=<fqcn>`.
## Full conversion chains per protocol
### CloudEvents (HTTP/SSE/WS clients)
**Ingress** (client → runtime):
```
SDK sends CloudEvents-JSON bytes
→ UniHttpServer.publish: body = CE-JSON bytes
→ UniIngressService.publish(topic, CloudEvent): CE → EventMeshFrame.fromCloudEvent(event)
→ storage.send(topic, frame): frame.encode() → MQ bytes
```
**Egress** (runtime → client):
```
storage.poll() → frame (EventMeshFrame)
→ SseConnection.send / WsConnection.send / UniHttpServer.poll
→ FrameAdaptors.toCloudEventsJson(frame): frame.toCloudEvent() → CE-JSON serialize
→ written as SSE data: / WS TextFrame / HTTP JSON
```
### MeshMessage (legacy TCP clients)
**Ingress**:
```
legacy TCP SDK sends Package (EventMeshMessage)
→ MeshMessagePackageRouter.route(pkg)
→ FrameAdaptors.get("meshmessage").toFrameSilent(pkg)
→ MeshMessageFrameAdaptor.toFrame:
topic → attributes["subject"]
body → data
header.seq → attributes["id"]
properties → attributes KV
→ TcpRequest.publish(topic, frame)
→ UniIngressService.publish(topic, frame) (Frame overload — skips CE conversion)
→ storage.send(topic, frame)
```
**Egress**:
```
ReliableDispatcher.deliver → NettyTcpPushChannel.deliver(deliveryId, frame, callback)
→ FrameAdaptors.get("meshmessage").fromFrameSilent(frame)
→ MeshMessageFrameAdaptor.fromFrame:
attributes["subject"] → topic
data → body
attributes KV → properties
→ Package(header=ASYNC_MESSAGE_TO_CLIENT, body=EventMeshMessage)
→ channel.writeAndFlush(pkg)
```
### A2A (JSON-RPC clients)
**Ingress**:
```
A2A client sends JSON-RPC 2.0 bytes
→ FrameAdaptors.get("a2a").toFrameSilent(new ByteTransport(jsonBytes))
→ A2AFrameAdaptor.toFrame:
parse JSON-RPC → extract method/params/_topic
method → attributes["ema2amethod"]
params._topic → attributes["subject"]
raw JSON → data
→ EventMeshFrame
```
**Egress**:
```
EventMeshA2ATransport receives frame
→ frame.data() = the original A2A JSON-RPC bytes (stored verbatim at ingress)
→ ByteTransport(jsonBytes) → A2A client
```
## Frame conversion in the storage SPI
Storage plugins (`MeshStoragePlugin` + `LiteTopicCapable`) carry
EventMeshFrame directly on send/poll:
```
send(topic, EventMeshFrame frame):
→ rocketmq5: frame.encode() → MQ message body bytes
→ kafka: frame.encode() → ProducerRecord value
→ rocketmq4: frame.encode() → MQ message body bytes
poll(topic, partition, ...):
→ MQ bytes → EventMeshFrame.decode(bytes)
→ with legacy CE-JSON fallback (old messages → EventMeshFrame.fromCloudEvent(deserialize))
```
## Where it landed
| Layer | Change | Status |
| --- | --- | --- |
| storage SPI | `MeshStoragePlugin.send/poll` + `LiteTopicCapable.sendLite/pullLite` all EventMeshFrame | ✅ |
| runtime dispatch | Delivery/BufferedEvent/PushChannel/Connection/DeadLetterSink/ReliableDispatcher/PushService + CloudEventFilter/SubscriptionManager fully Frame-typed | ✅ |
| streaming | Mode-1 (runtime↔agent cross-process) + Mode-2 (in-runtime pub/sub) all EventMeshFrame | ✅ |
| legacy connectors | Producer/Consumer SPI untouched (separate subsystem, speaks CE, boundary conversion) | ✅ kept |
| WireCodec SPI | default EventMeshFrameCodec, replaceable | ✅ |
| FrameAdaptor SPI | CloudEvents as an independent plugin + MeshMessage + A2A each with an independent adaptor | ✅ |