Skip to main content

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 no id. Resend the last one you received to resume (see Reconnection recovery frames); never parse it
  • event — always message for broadcast messages
  • data — JSON message envelope (same format as WebSocket)
  • mid (inside the envelope, optional) — stable message identity: the same message carries the same mid on 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-ID and pos/from_pos remain 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's WS_MAX_REPLAY_MESSAGES cap (default 100); replayed messages 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_truncated surfaces as a recovery-interrupted signal (JavaScript recoveryInterrupted; Python/Go the SDK's recovery-interrupted error) — advisory and connection-level, carrying no channel.
  • no_replay surfaces as a per-channel possible_gap, except where the SDK already assumes one. The JavaScript SDK emits a possible_gap for every channel on each SSE reopen (a receive-only transport cannot confirm live replay), so it treats no_replay as already covered; the Python and Go SDKs trust the server's replay and surface a possible_gap for exactly the no_replay channels. Either way, no recovery loss is silent.

See each SDK's recovery guide for the exact event names.

Key Differences from WebSocket​

WebSocketSSE
DirectionBidirectionalReceive-only
PublishingYes (via WebSocket frame)No (use REST publish endpoint)
Channel changesSubscribe/unsubscribe mid-connectionClose and reconnect with new channels
Connection limitsCounts toward tenant limitCounts toward tenant limit
Auth refreshMid-connection via auth messageClose 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​

VariableDefaultDescription
SSE_KEEPALIVE_INTERVAL45sInterval between keepalive comments
GATEWAY_CORS_ORIGINS*Allowed CORS origins (comma-separated)
SERVER_GRPC_ADDRlocalhost:3006Core gRPC address (auto-wired in Helm)

Next Steps​