@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.
- package/LICENSE +21 -21
- package/README.md +2 -2
- package/index.d.ts +1 -1
- package/index.js +6 -5
- package/lib/debug.js +372 -0
- package/lib/sse/index.js +8 -0
- package/lib/sse/stream.js +349 -0
- package/lib/ws/connection.js +451 -0
- package/lib/ws/handshake.js +125 -0
- package/lib/ws/index.js +14 -0
- package/lib/ws/room.js +223 -0
- package/package.json +9 -3
|
@@ -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;
|