@zero-server/realtime 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ /**
2
+ * @module sse/stream
3
+ * @description SSE (Server-Sent Events) stream controller.
4
+ * Wraps a raw HTTP response and provides the full SSE text protocol.
5
+ * Tracks connection state, event counts, and bytes sent.
6
+ * Emits `'close'` when the client disconnects and `'error'` on write failures.
7
+ *
8
+ * @example
9
+ * app.get('/events', (req, res) => {
10
+ * const stream = res.sse(); // opens SSE connection
11
+ *
12
+ * stream.send({ hello: 'world' }); // unnamed event
13
+ * stream.event('update', { id: 1 }); // named event
14
+ * stream.retry(3000); // set client reconnect delay
15
+ * stream.keepAlive(15000); // auto-ping every 15s
16
+ *
17
+ * stream.on('close', () => {
18
+ * console.log('client disconnected after', stream.uptime, 'ms');
19
+ * });
20
+ * });
21
+ */
22
+ class SSEStream
23
+ {
24
+ /**
25
+ * @constructor
26
+ * @param {import('http').ServerResponse} raw - Raw HTTP response stream.
27
+ * @param {object} [opts] - Configuration options.
28
+ * @param {boolean} [opts.secure] - Whether the connection is over TLS.
29
+ * @param {boolean} [opts.autoId] - Auto-increment event IDs.
30
+ * @param {number} [opts.startId] - Starting value for auto-ID (default 1).
31
+ * @param {string} [opts.lastEventId] - Last-Event-ID from the client reconnection header.
32
+ * @param {number} [opts.keepAlive] - Interval (ms) for automatic keep-alive pings. 0 to disable.
33
+ * @param {string} [opts.keepAliveComment] - Comment text for keep-alive pings (default `'ping'`).
34
+ */
35
+ constructor(raw, opts = {})
36
+ {
37
+ this._raw = raw;
38
+ this._closed = false;
39
+ this._log = require('../debug')('zero:sse');
40
+
41
+ /** `true` when the underlying connection is over TLS (HTTPS). */
42
+ this.secure = !!opts.secure;
43
+
44
+ /** Auto-increment counter for event IDs. */
45
+ this._autoId = opts.autoId || false;
46
+ this._nextId = opts.startId || 1;
47
+
48
+ /** The Last-Event-ID sent by the client on reconnection. */
49
+ this.lastEventId = opts.lastEventId || null;
50
+
51
+ /** Total number of events pushed. */
52
+ this.eventCount = 0;
53
+
54
+ /** Total bytes written to the stream. */
55
+ this.bytesSent = 0;
56
+
57
+ /** Timestamp when the stream was opened. */
58
+ this.connectedAt = Date.now();
59
+
60
+ /** Arbitrary user-data store. */
61
+ this.data = {};
62
+
63
+ /** @type {Object<string, Function[]>} */
64
+ this._listeners = {};
65
+
66
+ /** @private */
67
+ this._keepAliveTimer = null;
68
+
69
+ // Auto keep-alive
70
+ if (opts.keepAlive && opts.keepAlive > 0)
71
+ {
72
+ const commentText = opts.keepAliveComment || 'ping';
73
+ this._keepAliveTimer = setInterval(() => this.comment(commentText), opts.keepAlive);
74
+ if (this._keepAliveTimer.unref) this._keepAliveTimer.unref();
75
+ }
76
+
77
+ raw.on('close', () =>
78
+ {
79
+ this._closed = true;
80
+ this._clearKeepAlive();
81
+ this._log.debug('stream closed, %d events sent', this.eventCount);
82
+ this._emit('close');
83
+ });
84
+
85
+ raw.on('error', (err) => { this._log.error('stream error: %s', err.message); this._emit('error', err); });
86
+ }
87
+
88
+ // -- Event Emitter ---------------------------------
89
+
90
+ /**
91
+ * Register an event listener.
92
+ * @param {'close'|'error'} event - Event name.
93
+ * @param {Function} fn - Callback function.
94
+ * @returns {SSEStream} this
95
+ */
96
+ on(event, fn)
97
+ {
98
+ if (!this._listeners[event]) this._listeners[event] = [];
99
+ this._listeners[event].push(fn);
100
+ return this;
101
+ }
102
+
103
+ /**
104
+ * Register a one-time listener.
105
+ * @param {'close'|'error'} event - Event name.
106
+ * @param {Function} fn - Callback function.
107
+ * @returns {SSEStream} this
108
+ */
109
+ once(event, fn)
110
+ {
111
+ const wrapper = (...args) => { this.off(event, wrapper); fn(...args); };
112
+ wrapper._original = fn;
113
+ return this.on(event, wrapper);
114
+ }
115
+
116
+ /**
117
+ * Remove a listener.
118
+ * @param {string} event - Event name.
119
+ * @param {Function} fn - Callback function.
120
+ * @returns {SSEStream} this
121
+ */
122
+ off(event, fn)
123
+ {
124
+ const list = this._listeners[event];
125
+ if (!list) return this;
126
+ this._listeners[event] = list.filter(f => f !== fn && f._original !== fn);
127
+ return this;
128
+ }
129
+
130
+ /**
131
+ * Remove all listeners for an event (or all events).
132
+ * @param {string} [event] - Event name.
133
+ * @returns {SSEStream} this
134
+ */
135
+ removeAllListeners(event)
136
+ {
137
+ if (event) delete this._listeners[event];
138
+ else this._listeners = {};
139
+ return this;
140
+ }
141
+
142
+ /**
143
+ * Count listeners for an event.
144
+ * @param {string} event - Event name.
145
+ * @returns {number} Number of registered listeners.
146
+ */
147
+ listenerCount(event)
148
+ {
149
+ return (this._listeners[event] || []).length;
150
+ }
151
+
152
+ /** @private */
153
+ _emit(event, ...args)
154
+ {
155
+ const fns = this._listeners[event];
156
+ if (fns) fns.slice().forEach(fn => { try { fn(...args); } catch (e) { } });
157
+ }
158
+
159
+ // -- Writing Helpers -------------------------------
160
+
161
+ /**
162
+ * Write a raw string to the underlying response.
163
+ * @private
164
+ * @param {string} str - String to write.
165
+ */
166
+ _write(str)
167
+ {
168
+ if (this._closed) return;
169
+ try
170
+ {
171
+ this._raw.write(str);
172
+ this.bytesSent += Buffer.byteLength(str, 'utf8');
173
+ }
174
+ catch (e) { }
175
+ }
176
+
177
+ /**
178
+ * Format a payload into `data:` lines per the SSE spec.
179
+ * Objects are JSON-serialised automatically.
180
+ * @private
181
+ * @param {string|object} data - Record data object.
182
+ * @returns {string} Formatted string.
183
+ */
184
+ _formatData(data)
185
+ {
186
+ let payload;
187
+ if (typeof data === 'object')
188
+ {
189
+ try { payload = JSON.stringify(data); }
190
+ catch (e) { payload = '[Serialization Error]'; }
191
+ }
192
+ else { payload = String(data); }
193
+ return payload.split('\n').map(line => `data: ${line}\n`).join('');
194
+ }
195
+
196
+ // -- Public API ------------------------------------
197
+
198
+ /**
199
+ * Send an unnamed data event.
200
+ * Objects are automatically JSON-serialised.
201
+ *
202
+ * @param {string|object} data - Payload to send.
203
+ * @param {string|number} [id] - Optional event ID (overrides auto-ID).
204
+ * @returns {SSEStream} this
205
+ */
206
+ send(data, id)
207
+ {
208
+ if (this._closed) return this;
209
+ let msg = '';
210
+ const eventId = id !== undefined ? id : (this._autoId ? this._nextId++ : undefined);
211
+ if (eventId !== undefined) msg += `id: ${eventId}\n`;
212
+ msg += this._formatData(data);
213
+ msg += '\n';
214
+ this._write(msg);
215
+ this.eventCount++;
216
+ return this;
217
+ }
218
+
219
+ /**
220
+ * Convenience: send an object as JSON data (same as `.send(obj)`).
221
+ * @param {*} obj - Data object to send.
222
+ * @param {string|number} [id] - Unique identifier.
223
+ * @returns {SSEStream} this
224
+ */
225
+ sendJSON(obj, id)
226
+ {
227
+ return this.send(obj, id);
228
+ }
229
+
230
+ /**
231
+ * Send a named event with data.
232
+ *
233
+ * @param {string} eventName - Event type (appears as `event:` field).
234
+ * @param {string|object} data - Payload.
235
+ * @param {string|number} [id] - Optional event ID (overrides auto-ID).
236
+ * @returns {SSEStream} this
237
+ */
238
+ event(eventName, data, id)
239
+ {
240
+ if (this._closed) return this;
241
+ let msg = `event: ${eventName}\n`;
242
+ const eventId = id !== undefined ? id : (this._autoId ? this._nextId++ : undefined);
243
+ if (eventId !== undefined) msg += `id: ${eventId}\n`;
244
+ msg += this._formatData(data);
245
+ msg += '\n';
246
+ this._write(msg);
247
+ this.eventCount++;
248
+ return this;
249
+ }
250
+
251
+ /**
252
+ * Send a comment line. Comments are ignored by EventSource clients
253
+ * but useful as a keep-alive mechanism.
254
+ *
255
+ * @param {string} text - Comment text.
256
+ * @returns {SSEStream} this
257
+ */
258
+ comment(text)
259
+ {
260
+ if (this._closed) return this;
261
+ // Escape newlines to prevent SSE frame injection
262
+ const safe = String(text).split('\n').join('\n: ');
263
+ this._write(`: ${safe}\n\n`);
264
+ return this;
265
+ }
266
+
267
+ /**
268
+ * Send (or update) the retry interval hint.
269
+ * The client's EventSource will use this value for reconnection delay.
270
+ *
271
+ * @param {number} ms - Retry interval in milliseconds.
272
+ * @returns {SSEStream} this
273
+ */
274
+ retry(ms)
275
+ {
276
+ if (this._closed) return this;
277
+ this._write(`retry: ${ms}\n\n`);
278
+ return this;
279
+ }
280
+
281
+ /**
282
+ * Start or restart an automatic keep-alive timer that sends comment
283
+ * pings at the given interval.
284
+ *
285
+ * @param {number} intervalMs - Interval in ms. Pass `0` to stop.
286
+ * @param {string} [comment='ping'] - Comment text to send.
287
+ * @returns {SSEStream} this
288
+ */
289
+ keepAlive(intervalMs, comment)
290
+ {
291
+ this._clearKeepAlive();
292
+ if (intervalMs && intervalMs > 0)
293
+ {
294
+ const text = comment || 'ping';
295
+ this._keepAliveTimer = setInterval(() => this.comment(text), intervalMs);
296
+ if (this._keepAliveTimer.unref) this._keepAliveTimer.unref();
297
+ }
298
+ return this;
299
+ }
300
+
301
+ /**
302
+ * Flush the response (hint to Node to push buffered data to the network).
303
+ * Useful when piping through reverse proxies that buffer.
304
+ *
305
+ * @returns {SSEStream} this
306
+ */
307
+ flush()
308
+ {
309
+ if (this._closed) return this;
310
+ try
311
+ {
312
+ if (typeof this._raw.flushHeaders === 'function') this._raw.flushHeaders();
313
+ }
314
+ catch (e) { }
315
+ return this;
316
+ }
317
+
318
+ /**
319
+ * Close the SSE connection from the server side.
320
+ * @returns {void}
321
+ */
322
+ close()
323
+ {
324
+ if (this._closed) return;
325
+ this._closed = true;
326
+ this._clearKeepAlive();
327
+ try { this._raw.end(); } catch (e) { }
328
+ }
329
+
330
+ /**
331
+ * Whether the connection is still open.
332
+ * @returns {boolean} `true` if the stream has not been closed.
333
+ */
334
+ get connected() { return !this._closed; }
335
+
336
+ /**
337
+ * How long this stream has been open (ms).
338
+ * @returns {number} Milliseconds since the stream was opened.
339
+ */
340
+ get uptime() { return Date.now() - this.connectedAt; }
341
+
342
+ /** @private */
343
+ _clearKeepAlive()
344
+ {
345
+ if (this._keepAliveTimer) { clearInterval(this._keepAliveTimer); this._keepAliveTimer = null; }
346
+ }
347
+ }
348
+
349
+ module.exports = SSEStream;