Tina4

Chapter 8: SSE / NDJSON Streaming#

Streaming Without WebSocket#

An AI chatbot types back one token at a time. A dashboard counter ticks up as orders arrive. A notification feed shows events the moment they happen. The data flows from server to client, but you don't need the complexity of WebSocket for it.

Server-Sent Events (SSE) is the browser's built-in streaming protocol. One-directional: server pushes, client listens. It reconnects automatically. It works over HTTP/2. It's simpler than WebSocket for every case where you don't need to send data back.

NDJSON (Newline-Delimited JSON) is the same idea over a regular fetch response. One JSON object per line, streamed as the server generates them. This is how most AI APIs stream tokens, and it supports POST requests and custom headers, which SSE's EventSource does not.

tina4-js wraps both in a single module. One function call opens the stream. Six reactive signals track the state. A pipe function accumulates messages into your signals with a reducer. Auto-reconnect handles dropped connections.


1. The SSE Client#

The tina4-js SSE client provides:

  1. Dual mode - native EventSource for SSE, fetch+ReadableStream for NDJSON
  2. Reactive signals - status, connected, lastMessage, lastEvent, error, reconnectCount
  3. Auto-reconnect - exponential backoff, configurable attempts
  4. Signal piping - stream messages into signals with a reducer
typescript
import { sse } from 'tina4js';

2. EventSource Mode (Default)#

typescript
const stream = sse.connect('/api/events');

One line. The browser opens an EventSource connection. Messages arrive as the server sends them.

typescript
import { sse, effect } from 'tina4js';โ€‹const stream = sse.connect('/api/events');โ€‹effect(() => {  console.log('Status:', stream.status.value);});โ€‹effect(() => {  if (stream.lastMessage.value) {    console.log('Received:', stream.lastMessage.value);  }});

3. Fetch Mode (NDJSON)#

EventSource only supports GET requests with no custom headers. For POST requests, Bearer tokens, or NDJSON streaming, use fetch mode:

typescript
const stream = sse.connect('/api/chat', {  mode: 'fetch',  method: 'POST',  headers: { 'Authorization': 'Bearer eyJ...' },  body: { prompt: 'Explain signals in tina4-js' },});

The client reads the response body as a stream, splits on newlines, and parses each line as JSON. Every parsed object becomes a message.


4. Options#

typescript
const stream = sse.connect('/api/events', {  mode: 'eventsource',    // 'eventsource' (default) or 'fetch'  method: 'GET',           // HTTP method (fetch mode only)  headers: {},             // Custom headers (fetch mode only)  body: undefined,         // Request body (fetch mode only, auto-stringified)  reconnect: true,         // Auto-reconnect on disconnect  reconnectDelay: 1000,    // Initial delay (ms)  reconnectMaxDelay: 30000,// Max delay after backoff (ms)  reconnectAttempts: Infinity, // Max attempts  events: [],              // Named SSE events (eventsource mode only)  json: true,              // Auto-parse JSON (default: true)});
OptionDefaultDescription
mode'eventsource'Transport: native EventSource or fetch+ReadableStream
method'GET'HTTP method (fetch mode only)
headers{}Custom headers (fetch mode only)
bodyundefinedRequest body (fetch mode, auto JSON.stringify)
reconnecttrueAuto-reconnect on disconnect
reconnectDelay1000Initial reconnect delay in ms
reconnectMaxDelay30000Max delay for exponential backoff
reconnectAttemptsInfinityMax reconnect attempts
events[]Named SSE events to listen for
jsontrueAuto-parse messages as JSON

5. Reactive Signals#

Every stream exposes six reactive signals:

typescript
const stream = sse.connect('/api/events');โ€‹stream.status         // Signal<'connecting' | 'open' | 'closed' | 'reconnecting'>stream.connected      // Signal<boolean>stream.lastMessage    // Signal<unknown>  - last parsed messagestream.lastEvent      // Signal<string | null>  - SSE event name or nullstream.error          // Signal<Event | Error | null>stream.reconnectCount // Signal<number>

Use them in effects, computed values, or html templates:

typescript
effect(() => {  if (stream.connected.value) {    console.log('Stream is live');  }});

6. Event Handlers#

Register listeners for stream events. Every handler returns an unsubscribe function:

typescript
const unsub = stream.on('message', (data, event?) => {  console.log('Data:', data);  console.log('Event name:', event); // SSE event name or undefined});โ€‹stream.on('open', () => console.log('Connected'));stream.on('close', () => console.log('Disconnected'));stream.on('error', (err) => console.error('Error:', err));โ€‹// Stop listeningunsub();

7. Named Events (EventSource Mode)#

SSE supports named events. By default, EventSource only listens for unnamed message events. Pass event names in the options to listen for specific types:

typescript
const stream = sse.connect('/api/feed', {  events: ['user_joined', 'message', 'user_left'],});โ€‹stream.on('message', (data, event) => {  switch (event) {    case 'user_joined':      console.log(`${data.name} joined`);      break;    case 'message':      console.log(`${data.author}: ${data.text}`);      break;    case 'user_left':      console.log(`${data.name} left`);      break;  }});โ€‹// The lastEvent signal tracks the most recent event nameeffect(() => console.log('Last event type:', stream.lastEvent.value));

8. Pipe to Signal#

The pipe pattern streams messages into a signal through a reducer. This is the same pattern as the WebSocket module:

typescript
import { sse, signal } from 'tina4js';โ€‹const messages = signal([]);โ€‹const stream = sse.connect('/api/notifications');stream.pipe(messages, (msg, current) => [...current, msg]);โ€‹// messages.value grows as notifications arriveeffect(() => {  console.log(`${messages.value.length} notifications`);});

Pipe returns an unsubscribe function:

typescript
const unsub = stream.pipe(messages, (msg, current) => [...current, msg]);โ€‹// Stop pipingunsub();

9. Auto-Reconnect#

In EventSource mode, the browser handles reconnection natively. If the connection is fully closed, tina4-js schedules manual reconnection with exponential backoff.

In fetch mode, tina4-js handles all reconnection:

typescript
const stream = sse.connect('/api/stream', {  mode: 'fetch',  reconnect: true,  reconnectDelay: 1000,       // Start at 1s  reconnectMaxDelay: 30000,   // Cap at 30s  reconnectAttempts: 10,      // Give up after 10 tries});โ€‹effect(() => {  if (stream.status.value === 'reconnecting') {    console.log(`Reconnect attempt ${stream.reconnectCount.value}`);  }});

10. Closing#

typescript
stream.close();

This stops the connection and prevents reconnection. In EventSource mode it calls source.close(). In fetch mode it aborts the fetch request.


11. SSE vs WebSocket#

SSEWebSocket
DirectionServer โ†’ ClientBidirectional
ProtocolHTTPWS/WSS
ReconnectBuilt-in (EventSource)Manual (tina4-js handles it)
HeadersNo (EventSource) / Yes (fetch mode)Subprotocols only
POST bodyNo (EventSource) / Yes (fetch mode)N/A
Binary dataNoYes
HTTP/2 multiplexingYesNo
Use caseNotifications, feeds, AI streamingChat, gaming, live collaboration

Rule of thumb: If the client only needs to receive, use SSE. If the client needs to send too, use WebSocket.


12. Real-World Example: AI Chat Streaming#

typescript
import { sse, signal, html } from 'tina4js';โ€‹const messages = signal([]);const input = signal('');const streaming = signal(false);โ€‹async function sendMessage() {  const prompt = input.value.trim();  if (!prompt) return;โ€‹  // Add user message  messages.value = [...messages.value, { role: 'user', text: prompt }];  input.value = '';โ€‹  // Add empty assistant message  messages.value = [...messages.value, { role: 'assistant', text: '' }];  streaming.value = true;โ€‹  // Stream tokens  const stream = sse.connect('/api/chat', {    mode: 'fetch',    method: 'POST',    headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },    body: { prompt },  });โ€‹  stream.on('message', (data) => {    const token = data.token || data;    const msgs = [...messages.value];    const last = msgs[msgs.length - 1];    msgs[msgs.length - 1] = { ...last, text: last.text + token };    messages.value = msgs;  });โ€‹  stream.on('close', () => {    streaming.value = false;  });}โ€‹const view = html`  <div class="chat">    ${() => messages.value.map(m => html`      <div class="message ${m.role}">        <strong>${m.role}:</strong> ${m.text}      </div>    `)}    <div class="input-bar">      <input        type="text"        .value=${input}        @input=${(e) => { input.value = e.target.value; }}        @keydown=${(e) => { if (e.key === 'Enter') sendMessage(); }}        ?disabled=${streaming}      />      <button @click=${sendMessage} ?disabled=${streaming}>Send</button>    </div>  </div>`;

13. Real-World Example: Live Notification Feed#

typescript
import { sse, signal, html } from 'tina4js';โ€‹const notifications = signal([]);โ€‹const stream = sse.connect('/api/notifications', {  events: ['info', 'warning', 'error'],});โ€‹stream.pipe(notifications, (msg, current) => {  return [{ ...msg, event: stream.lastEvent.value, time: new Date() }, ...current].slice(0, 50);});โ€‹const view = html`  <div class="feed">    <h2>Notifications ${() => stream.connected.value ? '(live)' : '(disconnected)'}</h2>    ${() => notifications.value.map(n => html`      <div class="notification ${n.event}">        <span class="badge">${n.event}</span>        ${n.message}        <small>${n.time.toLocaleTimeString()}</small>      </div>    `)}  </div>`;

Bundle Size#

ModuleRawGzipped
SSE3.42 KB1.30 KB

Import only what you need:

typescript
import { sse } from 'tina4js/sse';  // 1.30 KB gzip

Summary#

TaskCode
Connect (EventSource)sse.connect('/events')
Connect (NDJSON/POST)sse.connect('/api', { mode: 'fetch', method: 'POST', body: {...} })
Read statusstream.status.value
Listen for messagesstream.on('message', (data, event?) => { ... })
Named eventssse.connect(url, { events: ['update', 'delete'] })
Pipe to signalstream.pipe(signal, (msg, current) => [...current, msg])
Closestream.close()