SSE Transport PRO
Server-Sent Events (SSE) provides a receive-only transport for clients that don't need bidirectional communication — dashboards, data feeds, monitoring widgets, or environments where WebSocket isn't available.
SSE clients receive the same messages as WebSocket clients on the same channels, through the same broadcast bus.
Connecting
GET /sse?channels=demo.general.chat,demo.announce.updates&token=<JWT>
Or with an API key (public channels only):
GET /sse?channels=demo.general.chat&api_key=<pk_live_...>
Authentication can also be passed via headers:
GET /sse?channels=demo.general.chat
Authorization: Bearer <JWT>
Browser Example
const url = new URL('https://gateway.example.com/sse');
url.searchParams.set('channels', 'demo.general.chat,demo.announce.updates');
url.searchParams.set('token', jwt);
const source = new EventSource(url);
source.addEventListener('message', (event) => {
const msg = JSON.parse(event.data);
console.log(`[${msg.channel}]`, msg.data);
});
source.onerror = () => {
console.log('Connection lost, reconnecting...');
};
Using the Sukko SDK
The @sukko/sdk package ships an SseTransport so you get the same SukkoClient API as WebSocket — plus automatic recovery, idle-timeout detection, and Last-Event-ID resume — without hand-rolling EventSource. On a receive-only transport the client transparently adapts: subscribe/unsubscribe reconnect the stream with the new channel set, publish routes over the REST endpoint, and an expired token is refreshed via getToken and reconnected on a 401.
import { SukkoClient, SseTransport } from '@sukko/sdk';
const client = new SukkoClient({
transport: new SseTransport({
url: 'https://gateway.example.com',
channels: ['demo.general.chat'],
}),
getToken: async () => (await fetch('/api/token')).text(),
});
// On a receive-only transport, subscribing opens/updates the stream.
client.subscribe(['demo.general.chat']);
client.on('message', (msg) => console.log(msg.channel, msg.data));
// publish() transparently routes over REST; a failure surfaces on `publishError`.
client.publish('demo.general.chat', { text: 'Hello from SSE' });
Where an SSE reconnect can't confirm a replay, the client emits a per-channel possible_gap on its messages() stream rather than dropping silently — see Automatic Recovery.
Event Format
id: v1:eyJkZW1vLmdlbmVyYWwuY2hhdCI6IjItNSJ9
event: message
data: {"type":"message","seq":1234,"ts":1706000000000,"channel":"demo.general.chat","data":{"text":"Hello"},"mid":"9c5b1f0a3d2e6c47-2-1234"}
id— the opaque, versioned reconnect cursor (Last-Event-ID). It is not a per-message id: the gateway emits it only for messages backed by Kafka history, and then only at a bounded cadence (not on every event), so many frames carry noid. Resend the last one you received to resume (see Reconnection recovery frames); never parse itevent— alwaysmessagefor broadcast messagesdata— JSON message envelope (same format as WebSocket)mid(inside the envelope, optional) — stable message identity: the same message carries the samemidon every copy delivered (live, replay, history), so consumers can deduplicate across reconnects. It is an opaque string (at most 64 characters) — never parse it, and never use it as a resume cursor (Last-Event-IDandpos/from_posremain the cursors)
Keepalive comments (: keepalive) are sent periodically (default: every 45 seconds) to prevent proxy timeouts on idle connections.
Reconnection recovery frames
When you reconnect with a Last-Event-ID, the server replays the messages you missed and then reports the outcome of that replay with two control frames — delivered on the same event: message channel, discriminated by data.type (exactly like a delivery message or a gap):
event: message
data: {"type":"no_replay","channels":["demo.orders","demo.trades"]}
event: message
data: {"type":"replay_truncated","replayed":100}
no_replay— the server could not replay the listed channels (unauthorized, no history on a direct backend, or the replay failed). Treat each as a possible gap: if continuity matters, re-fetch via the history API.replay_truncated— the replay hit the server'sWS_MAX_REPLAY_MESSAGEScap (default100);replayedmessages were delivered and a gap remains before the live stream resumes.
Both are advisory — the live stream continues regardless, and a client that doesn't recognize a frame type ignores it safely.
How the SDKs surface this
The Sukko SDKs translate these frames onto their existing recovery surfaces rather than exposing the raw frame:
replay_truncatedsurfaces as a recovery-interrupted signal (JavaScriptrecoveryInterrupted; Python/Go the SDK's recovery-interrupted error) — advisory and connection-level, carrying no channel.no_replaysurfaces as a per-channelpossible_gap, except where the SDK already assumes one. The JavaScript SDK emits apossible_gapfor every channel on each SSE reopen (a receive-only transport cannot confirm live replay), so it treatsno_replayas already covered; the Python and Go SDKs trust the server's replay and surface apossible_gapfor exactly theno_replaychannels. Either way, no recovery loss is silent.
See each SDK's recovery guide for the exact event names.
Key Differences from WebSocket
| WebSocket | SSE | |
|---|---|---|
| Direction | Bidirectional | Receive-only |
| Publishing | Yes (via WebSocket frame) | No (use REST publish endpoint) |
| Channel changes | Subscribe/unsubscribe mid-connection | Close and reconnect with new channels |
| Connection limits | Counts toward tenant limit | Counts toward tenant limit |
| Auth refresh | Mid-connection via auth message | Close and reconnect with new token |
REST Publish
SSE clients can publish via the stateless REST endpoint:
curl -X POST https://gateway.example.com/api/v1/publish \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"channel": "demo.general.chat", "data": {"text": "Hello from REST"}}'
REST publish requires a JWT — API keys cannot publish. Same rate limiting and channel permissions as WebSocket publish. REST publish is available in every edition (SSE itself remains Pro); on the kafka backend, publishing additionally requires a matching per-tenant routing rule (a Pro feature) — without one the publish is rejected with 409 PUBLISH_NOT_ROUTABLE.
The response includes the message's stable identity (mid) — the same mid subscribers receive on the delivered envelope (omitted when a routing rule fans the publish out to multiple topics):
{"status": "accepted", "channel": "demo.general.chat", "mid": "9c5b1f0a3d2e6c47-2-1235"}
CORS
For browser EventSource connections from a different origin, configure CORS:
GATEWAY_CORS_ORIGINS=https://app.example.com,https://dashboard.example.com
Default is * (allow all origins) for development. Set specific origins in production.
Configuration
| Variable | Default | Description |
|---|---|---|
SSE_KEEPALIVE_INTERVAL | 45s | Interval between keepalive comments |
GATEWAY_CORS_ORIGINS | * | Allowed CORS origins (comma-separated) |
SERVER_GRPC_ADDR | localhost:3006 | Core gRPC address (auto-wired in Helm) |
Next Steps
- Channels — Channel types and access patterns
- Authentication — JWT and API key setup
- Configuration Reference — All SSE-related env vars