better-sse 0.14.0 → 0.15.0

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/build/index.js CHANGED
@@ -26,15 +26,62 @@ __export(src_exports, {
26
26
  SseError: () => SseError,
27
27
  createChannel: () => createChannel,
28
28
  createEventBuffer: () => createEventBuffer,
29
+ createResponse: () => createResponse,
29
30
  createSession: () => createSession
30
31
  });
31
32
  module.exports = __toCommonJS(src_exports);
32
33
 
33
34
  // src/Session.ts
34
- var import_http = require("http");
35
+ var import_node_http = require("http");
36
+ var import_node_http2 = require("http2");
37
+ var import_node_timers = require("timers");
35
38
 
36
- // src/lib/serialize.ts
37
- var serialize = (data) => JSON.stringify(data);
39
+ // src/lib/createPushFromIterable.ts
40
+ var createPushFromIterable = (push) => async (iterable, options = {}) => {
41
+ const { eventName = "iteration" } = options;
42
+ for await (const data of iterable) {
43
+ push(data, eventName);
44
+ }
45
+ };
46
+
47
+ // src/lib/createPushFromStream.ts
48
+ var import_node_stream = require("stream");
49
+ var createPushFromStream = (push) => async (stream, options = {}) => {
50
+ const { eventName = "stream" } = options;
51
+ if (stream instanceof import_node_stream.Readable) {
52
+ return await new Promise((resolve, reject) => {
53
+ stream.on("data", (chunk) => {
54
+ let data;
55
+ if (Buffer.isBuffer(chunk)) {
56
+ data = chunk.toString();
57
+ } else {
58
+ data = chunk;
59
+ }
60
+ push(data, eventName);
61
+ });
62
+ stream.once("end", () => resolve(true));
63
+ stream.once("close", () => resolve(true));
64
+ stream.once("error", (err) => reject(err));
65
+ });
66
+ }
67
+ for await (const chunk of stream) {
68
+ if (Buffer.isBuffer(chunk)) {
69
+ push(chunk.toString(), eventName);
70
+ } else {
71
+ push(chunk, eventName);
72
+ }
73
+ }
74
+ return true;
75
+ };
76
+
77
+ // src/lib/generateId.ts
78
+ var import_node_crypto = require("crypto");
79
+ var generateId;
80
+ if (import_node_crypto.randomUUID) {
81
+ generateId = () => (0, import_node_crypto.randomUUID)();
82
+ } else {
83
+ generateId = () => (0, import_node_crypto.randomBytes)(4).toString("hex");
84
+ }
38
85
 
39
86
  // src/lib/sanitize.ts
40
87
  var newlineVariantsRegex = /(\r\n|\r|\n)/g;
@@ -46,41 +93,8 @@ var sanitize = (text) => {
46
93
  return sanitized;
47
94
  };
48
95
 
49
- // src/lib/generateId.ts
50
- var import_crypto = require("crypto");
51
- var generateId;
52
- if (import_crypto.randomUUID) {
53
- generateId = () => (0, import_crypto.randomUUID)();
54
- } else {
55
- generateId = () => (0, import_crypto.randomBytes)(4).toString("hex");
56
- }
57
-
58
- // src/lib/createPushFromStream.ts
59
- var createPushFromStream = (push) => async (stream, options = {}) => {
60
- const { eventName = "stream" } = options;
61
- return await new Promise((resolve, reject) => {
62
- stream.on("data", (chunk) => {
63
- let data;
64
- if (Buffer.isBuffer(chunk)) {
65
- data = chunk.toString();
66
- } else {
67
- data = chunk;
68
- }
69
- push(data, eventName);
70
- });
71
- stream.once("end", () => resolve(true));
72
- stream.once("close", () => resolve(true));
73
- stream.once("error", (err) => reject(err));
74
- });
75
- };
76
-
77
- // src/lib/createPushFromIterable.ts
78
- var createPushFromIterable = (push) => async (iterable, options = {}) => {
79
- const { eventName = "iteration" } = options;
80
- for await (const data of iterable) {
81
- push(data, eventName);
82
- }
83
- };
96
+ // src/lib/serialize.ts
97
+ var serialize = (data) => JSON.stringify(data);
84
98
 
85
99
  // src/EventBuffer.ts
86
100
  var EventBuffer = class {
@@ -214,9 +228,181 @@ var EventBuffer = class {
214
228
  read = () => this.buffer;
215
229
  };
216
230
 
231
+ // src/lib/applyHeaders.ts
232
+ var applyHeaders = (from, to) => {
233
+ const fromMap = from instanceof Headers ? Object.fromEntries(from) : from;
234
+ for (const [key, value] of Object.entries(fromMap)) {
235
+ if (Array.isArray(value)) {
236
+ to.delete(key);
237
+ for (const item of value) {
238
+ to.append(key, item);
239
+ }
240
+ } else if (value === void 0) {
241
+ to.delete(key);
242
+ } else {
243
+ to.set(key, value);
244
+ }
245
+ }
246
+ };
247
+
248
+ // src/lib/constants.ts
249
+ var DEFAULT_REQUEST_HOST = "localhost";
250
+ var DEFAULT_REQUEST_METHOD = "GET";
251
+ var DEFAULT_RESPONSE_CODE = 200;
252
+ var DEFAULT_RESPONSE_HEADERS = {
253
+ "Content-Type": "text/event-stream",
254
+ "Cache-Control": "private, no-cache, no-store, no-transform, must-revalidate, max-age=0",
255
+ Connection: "keep-alive",
256
+ Pragma: "no-cache",
257
+ "X-Accel-Buffering": "no"
258
+ };
259
+
260
+ // src/adapters/FetchConnection.ts
261
+ var FetchConnection = class _FetchConnection {
262
+ static encoder = new TextEncoder();
263
+ writer;
264
+ url;
265
+ request;
266
+ response;
267
+ constructor(request, response, options = {}) {
268
+ this.url = new URL(request.url);
269
+ this.request = request;
270
+ const { readable, writable } = new TransformStream();
271
+ this.writer = writable.getWriter();
272
+ this.response = new Response(readable, {
273
+ status: options.statusCode ?? response?.status ?? DEFAULT_RESPONSE_CODE,
274
+ headers: DEFAULT_RESPONSE_HEADERS
275
+ });
276
+ if (response) {
277
+ applyHeaders(response.headers, this.response.headers);
278
+ }
279
+ }
280
+ sendHead = () => {
281
+ };
282
+ sendChunk = (chunk) => {
283
+ const encoded = _FetchConnection.encoder.encode(chunk);
284
+ this.writer.write(encoded);
285
+ };
286
+ cleanup = () => {
287
+ this.writer.close();
288
+ };
289
+ };
290
+
291
+ // src/adapters/NodeHttp1Connection.ts
292
+ var NodeHttp1Connection = class {
293
+ constructor(req, res, options = {}) {
294
+ this.req = req;
295
+ this.res = res;
296
+ this.url = new URL(
297
+ `http://${req.headers.host ?? DEFAULT_REQUEST_HOST}${req.url}`
298
+ );
299
+ this.controller = new AbortController();
300
+ req.once("close", this.onClose);
301
+ res.once("close", this.onClose);
302
+ this.request = new Request(this.url, {
303
+ method: req.method ?? DEFAULT_REQUEST_METHOD,
304
+ signal: this.controller.signal
305
+ });
306
+ applyHeaders(req.headers, this.request.headers);
307
+ this.response = new Response(null, {
308
+ status: options.statusCode ?? res.statusCode ?? DEFAULT_RESPONSE_CODE,
309
+ headers: DEFAULT_RESPONSE_HEADERS
310
+ });
311
+ if (res) {
312
+ applyHeaders(
313
+ res.getHeaders(),
314
+ this.response.headers
315
+ );
316
+ }
317
+ }
318
+ controller;
319
+ url;
320
+ request;
321
+ response;
322
+ onClose = () => {
323
+ this.controller.abort();
324
+ };
325
+ sendHead = () => {
326
+ this.res.writeHead(
327
+ this.response.status,
328
+ Object.fromEntries(this.response.headers)
329
+ );
330
+ };
331
+ sendChunk = (chunk) => {
332
+ this.res.write(chunk);
333
+ };
334
+ cleanup = () => {
335
+ this.req.removeListener("close", this.onClose);
336
+ this.res.removeListener("close", this.onClose);
337
+ };
338
+ };
339
+
340
+ // src/adapters/NodeHttp2CompatConnection.ts
341
+ var NodeHttp2CompatConnection = class {
342
+ constructor(req, res, options = {}) {
343
+ this.req = req;
344
+ this.res = res;
345
+ this.url = new URL(
346
+ `http://${req.headers.host ?? DEFAULT_REQUEST_HOST}${req.url}`
347
+ );
348
+ this.controller = new AbortController();
349
+ req.once("close", this.onClose);
350
+ res.once("close", this.onClose);
351
+ this.request = new Request(this.url, {
352
+ method: req.method ?? DEFAULT_REQUEST_METHOD,
353
+ signal: this.controller.signal
354
+ });
355
+ const allowedHeaders = { ...req.headers };
356
+ for (const header of Object.keys(allowedHeaders)) {
357
+ if (header.startsWith(":")) {
358
+ delete allowedHeaders[header];
359
+ }
360
+ }
361
+ applyHeaders(allowedHeaders, this.request.headers);
362
+ this.response = new Response(null, {
363
+ status: options.statusCode ?? res.statusCode ?? DEFAULT_RESPONSE_CODE,
364
+ headers: DEFAULT_RESPONSE_HEADERS
365
+ });
366
+ if (res) {
367
+ applyHeaders(
368
+ res.getHeaders(),
369
+ this.response.headers
370
+ );
371
+ }
372
+ }
373
+ controller;
374
+ url;
375
+ request;
376
+ response;
377
+ onClose = () => {
378
+ this.controller.abort();
379
+ };
380
+ sendHead = () => {
381
+ this.res.writeHead(
382
+ this.response.status,
383
+ Object.fromEntries(this.response.headers)
384
+ );
385
+ };
386
+ sendChunk = (chunk) => {
387
+ this.res.write(chunk);
388
+ };
389
+ cleanup = () => {
390
+ this.req.removeListener("close", this.onClose);
391
+ this.res.removeListener("close", this.onClose);
392
+ };
393
+ };
394
+
395
+ // src/lib/SseError.ts
396
+ var SseError = class extends Error {
397
+ constructor(message) {
398
+ super(message);
399
+ this.message = `better-sse: ${message}`;
400
+ }
401
+ };
402
+
217
403
  // src/lib/TypedEmitter.ts
218
- var import_events = require("events");
219
- var TypedEmitter = class extends import_events.EventEmitter {
404
+ var import_node_events = require("events");
405
+ var TypedEmitter = class extends import_node_events.EventEmitter {
220
406
  addListener(event, listener) {
221
407
  return super.addListener(event, listener);
222
408
  }
@@ -243,14 +429,6 @@ var TypedEmitter = class extends import_events.EventEmitter {
243
429
  }
244
430
  };
245
431
 
246
- // src/lib/SseError.ts
247
- var SseError = class extends Error {
248
- constructor(message) {
249
- super(message);
250
- this.message = `better-sse: ${message}`;
251
- }
252
- };
253
-
254
432
  // src/Session.ts
255
433
  var Session = class extends TypedEmitter {
256
434
  /**
@@ -279,70 +457,78 @@ var Session = class extends TypedEmitter {
279
457
  */
280
458
  state;
281
459
  buffer;
282
- /**
283
- * Raw HTTP request.
284
- */
285
- req;
286
- /**
287
- * Raw HTTP response that is the minimal interface needed and forms the
288
- * intersection between the HTTP/1.1 and HTTP/2 server response interfaces.
289
- */
290
- res;
291
- serialize;
460
+ connection;
292
461
  sanitize;
293
- trustClientEventId;
462
+ serialize;
294
463
  initialRetry;
295
464
  keepAliveInterval;
296
465
  keepAliveTimer;
297
- statusCode;
298
- headers;
299
- constructor(req, res, options = {}) {
466
+ constructor(req, res, options) {
300
467
  super();
301
- this.req = req;
302
- this.res = res;
303
- const serializer = options.serializer ?? serialize;
304
- const sanitizer = options.sanitizer ?? sanitize;
305
- this.serialize = serializer;
306
- this.sanitize = sanitizer;
307
- this.buffer = new EventBuffer({ serializer, sanitizer });
308
- this.trustClientEventId = options.trustClientEventId ?? true;
309
- this.initialRetry = options.retry === null ? null : options.retry ?? 2e3;
310
- this.keepAliveInterval = options.keepAlive === null ? null : options.keepAlive ?? 1e4;
311
- this.statusCode = options.statusCode ?? 200;
312
- this.headers = options.headers ?? {};
313
- this.state = options.state ?? {};
314
- this.req.once("close", this.onDisconnected);
315
- this.res.once("close", this.onDisconnected);
316
- setImmediate(this.initialize);
317
- }
318
- initialize = () => {
319
- const url = `http://${this.req.headers.host}${this.req.url}`;
320
- const params = new URL(url).searchParams;
321
- if (this.trustClientEventId) {
322
- const givenLastEventId = this.req.headers["last-event-id"] ?? params.get("lastEventId") ?? params.get("evs_last_event_id") ?? "";
323
- this.lastId = givenLastEventId;
324
- }
325
- const headers = {};
326
- if (this.res instanceof import_http.ServerResponse) {
327
- headers["Content-Type"] = "text/event-stream";
328
- headers["Cache-Control"] = "private, no-cache, no-store, no-transform, must-revalidate, max-age=0";
329
- headers["Connection"] = "keep-alive";
330
- headers["Pragma"] = "no-cache";
331
- headers["X-Accel-Buffering"] = "no";
468
+ let givenOptions = options ?? {};
469
+ if (req instanceof Request) {
470
+ let givenRes = null;
471
+ if (res) {
472
+ if (res instanceof Response) {
473
+ givenRes = res;
474
+ } else {
475
+ if (options) {
476
+ throw new SseError(
477
+ "When providing a Fetch Request object but no Response object, you may pass options as the second OR third argument to the session constructor, but not to both."
478
+ );
479
+ }
480
+ givenOptions = res;
481
+ }
482
+ }
483
+ this.connection = new FetchConnection(req, givenRes, givenOptions);
484
+ } else if (req instanceof import_node_http.IncomingMessage) {
485
+ if (res instanceof import_node_http.ServerResponse) {
486
+ this.connection = new NodeHttp1Connection(req, res, givenOptions);
487
+ } else {
488
+ throw new SseError(
489
+ "When providing a Node IncomingMessage object, a corresponding ServerResponse object must also be provided."
490
+ );
491
+ }
492
+ } else if (req instanceof import_node_http2.Http2ServerRequest) {
493
+ if (res instanceof import_node_http2.Http2ServerResponse) {
494
+ this.connection = new NodeHttp2CompatConnection(req, res, givenOptions);
495
+ } else {
496
+ throw new SseError(
497
+ "When providing a Node HTTP2ServerRequest object, a corresponding HTTP2ServerResponse object must also be provided."
498
+ );
499
+ }
332
500
  } else {
333
- headers["content-type"] = "text/event-stream";
334
- headers["cache-control"] = "private, no-cache, no-store, no-transform, must-revalidate, max-age=0";
335
- headers["pragma"] = "no-cache";
336
- headers["x-accel-buffering"] = "no";
501
+ throw new SseError(
502
+ "Malformed request or response objects given to session constructor. Must be one of IncomingMessage/ServerResponse from the Node HTTP/1 API, HTTP2ServerRequest/HTTP2ServerResponse from the Node HTTP/2 Compatibility API, or Request/Response from the Fetch API."
503
+ );
504
+ }
505
+ if (givenOptions.headers) {
506
+ applyHeaders(givenOptions.headers, this.connection.response.headers);
337
507
  }
338
- for (const [name, value] of Object.entries(this.headers)) {
339
- headers[name] = value ?? "";
508
+ if (givenOptions.trustClientEventId !== false) {
509
+ this.lastId = this.connection.request.headers.get("last-event-id") ?? this.connection.url.searchParams.get("lastEventId") ?? this.connection.url.searchParams.get("evs_last_event_id") ?? "";
340
510
  }
341
- this.res.writeHead(this.statusCode, headers);
342
- if (params.has("padding")) {
511
+ this.state = givenOptions.state ?? {};
512
+ this.initialRetry = givenOptions.retry === null ? null : givenOptions.retry ?? 2e3;
513
+ this.keepAliveInterval = givenOptions.keepAlive === null ? null : givenOptions.keepAlive ?? 1e4;
514
+ this.serialize = givenOptions.serializer ?? serialize;
515
+ this.sanitize = givenOptions.sanitizer ?? sanitize;
516
+ this.buffer = new EventBuffer({
517
+ serializer: this.serialize,
518
+ sanitizer: this.sanitize
519
+ });
520
+ this.connection.request.signal.addEventListener(
521
+ "abort",
522
+ this.onDisconnected
523
+ );
524
+ (0, import_node_timers.setImmediate)(this.initialize);
525
+ }
526
+ initialize = () => {
527
+ this.connection.sendHead();
528
+ if (this.connection.url.searchParams.has("padding")) {
343
529
  this.buffer.comment(" ".repeat(2049)).dispatch();
344
530
  }
345
- if (params.has("evs_preamble")) {
531
+ if (this.connection.url.searchParams.has("evs_preamble")) {
346
532
  this.buffer.comment(" ".repeat(2056)).dispatch();
347
533
  }
348
534
  if (this.initialRetry !== null) {
@@ -350,80 +536,57 @@ var Session = class extends TypedEmitter {
350
536
  }
351
537
  this.flush();
352
538
  if (this.keepAliveInterval !== null) {
353
- this.keepAliveTimer = setInterval(
354
- this.keepAlive,
355
- this.keepAliveInterval
356
- );
539
+ this.keepAliveTimer = setInterval(this.keepAlive, this.keepAliveInterval);
357
540
  }
358
541
  this.isConnected = true;
359
542
  this.emit("connected");
360
543
  };
361
544
  onDisconnected = () => {
362
- this.req.removeListener("close", this.onDisconnected);
363
- this.res.removeListener("close", this.onDisconnected);
545
+ this.connection.request.signal.removeEventListener(
546
+ "abort",
547
+ this.onDisconnected
548
+ );
549
+ this.connection.cleanup();
364
550
  if (this.keepAliveTimer) {
365
551
  clearInterval(this.keepAliveTimer);
366
552
  }
367
553
  this.isConnected = false;
368
554
  this.emit("disconnected");
369
555
  };
556
+ /**
557
+ * Write an empty comment and flush it to the client.
558
+ */
370
559
  keepAlive = () => {
371
560
  this.buffer.comment().dispatch();
372
561
  this.flush();
373
562
  };
374
563
  /**
375
- * @deprecated see https://github.com/MatthewWid/better-sse/issues/52
376
- */
377
- event(type) {
378
- this.buffer.event(type);
379
- return this;
380
- }
381
- /**
382
- * @deprecated see https://github.com/MatthewWid/better-sse/issues/52
383
- */
384
- data = (data) => {
385
- this.buffer.data(data);
386
- return this;
387
- };
388
- /**
389
- * @deprecated see https://github.com/MatthewWid/better-sse/issues/52
390
- */
391
- id = (id = "") => {
392
- this.buffer.id(id);
393
- this.lastId = id;
394
- return this;
395
- };
396
- /**
397
- * @deprecated see https://github.com/MatthewWid/better-sse/issues/52
398
- */
399
- retry = (time) => {
400
- this.buffer.retry(time);
401
- return this;
402
- };
403
- /**
404
- * @deprecated see https://github.com/MatthewWid/better-sse/issues/52
564
+ * Flush the contents of the internal buffer to the client and clear the buffer.
405
565
  */
406
- comment = (text) => {
407
- this.buffer.comment(text);
408
- return this;
566
+ flush = () => {
567
+ const contents = this.buffer.read();
568
+ this.buffer.clear();
569
+ this.connection.sendChunk(contents);
409
570
  };
410
571
  /**
411
- * @deprecated see https://github.com/MatthewWid/better-sse/issues/52
572
+ * Get a Request object representing the request of the underlying connection this session manages.
573
+ *
574
+ * When using the Fetch API, this will be the original Request object passed to the session constructor.
575
+ *
576
+ * When using the Node HTTP APIs, this will be a new Request object with status code and headers copied from the original request.
577
+ * When the originally given request or response is closed, the abort signal attached to this Request will be triggered.
412
578
  */
413
- dispatch = () => {
414
- this.buffer.dispatch();
415
- return this;
416
- };
579
+ getRequest = () => this.connection.request;
417
580
  /**
418
- * Flush the contents of the internal buffer to the client and clear the buffer.
581
+ * Get a Response object representing the response of the underlying connection this session manages.
582
+ *
583
+ * When using the Fetch API, this will be a new Response object with status code and headers copied from the original response if given.
584
+ * Its body will be a ReadableStream that should begin being consumed for the session to consider itself connected.
419
585
  *
420
- * @deprecated see https://github.com/MatthewWid/better-sse/issues/52
586
+ * When using the Node HTTP APIs, this will be a new Response object with status code and headers copied from the original response.
587
+ * Its body will be `null`, as data is instead written to the stream of the originally given response object.
421
588
  */
422
- flush = () => {
423
- this.res.write(this.buffer.read());
424
- this.buffer.clear();
425
- return this;
426
- };
589
+ getResponse = () => this.connection.response;
427
590
  /**
428
591
  * Push an event to the client.
429
592
  *
@@ -431,6 +594,8 @@ var Session = class extends TypedEmitter {
431
594
  *
432
595
  * If no event ID is given, the event ID (and thus the `lastId` property) is set to a unique string generated using a cryptographic pseudorandom number generator.
433
596
  *
597
+ * If the session has disconnected, an `SseError` will be thrown.
598
+ *
434
599
  * Emits the `push` event with the given data, event name and event ID in that order.
435
600
  *
436
601
  * @param data - Data to write.
@@ -439,7 +604,9 @@ var Session = class extends TypedEmitter {
439
604
  */
440
605
  push = (data, eventName = "message", eventId = generateId()) => {
441
606
  if (!this.isConnected) {
442
- throw new SseError("Cannot push data to a non-active session.");
607
+ throw new SseError(
608
+ "Cannot push data to a non-active session. Ensure the session is connected before attempting to push events. If using the Fetch API, the response stream must begin being consumed before the session is considered connected."
609
+ );
443
610
  }
444
611
  this.buffer.push(data, eventName, eventId);
445
612
  this.flush();
@@ -488,25 +655,54 @@ var Session = class extends TypedEmitter {
488
655
  */
489
656
  batch = async (batcher) => {
490
657
  if (batcher instanceof EventBuffer) {
491
- this.res.write(batcher.read());
658
+ this.connection.sendChunk(batcher.read());
492
659
  } else {
493
660
  const buffer = new EventBuffer({
494
661
  serializer: this.serialize,
495
662
  sanitizer: this.sanitize
496
663
  });
497
664
  await batcher(buffer);
498
- this.res.write(buffer.read());
665
+ this.connection.sendChunk(buffer.read());
499
666
  }
500
667
  };
501
668
  };
502
669
 
503
670
  // src/createSession.ts
504
- var createSession = (...args) => new Promise((resolve) => {
671
+ function createSession(req, res, options) {
672
+ return new Promise((resolve) => {
673
+ const session = new Session(req, res, options);
674
+ if (req instanceof Request) {
675
+ resolve(session);
676
+ } else {
677
+ session.once("connected", () => {
678
+ resolve(session);
679
+ });
680
+ }
681
+ });
682
+ }
683
+
684
+ // src/createResponse.ts
685
+ function createResponse(request, response, options, callback) {
686
+ const args = [request, response, options, callback];
687
+ let givenCallback;
688
+ for (let index = args.length - 1; index >= 0; --index) {
689
+ const arg = args.pop();
690
+ if (arg) {
691
+ givenCallback = arg;
692
+ break;
693
+ }
694
+ }
695
+ if (typeof givenCallback !== "function") {
696
+ throw new SseError(
697
+ "Last argument given to createResponse must be a callback function."
698
+ );
699
+ }
505
700
  const session = new Session(...args);
506
701
  session.once("connected", () => {
507
- resolve(session);
702
+ givenCallback(session);
508
703
  });
509
- });
704
+ return session.getResponse();
705
+ }
510
706
 
511
707
  // src/Channel.ts
512
708
  var Channel = class extends TypedEmitter {
@@ -606,5 +802,6 @@ var createEventBuffer = (...args) => new EventBuffer(...args);
606
802
  SseError,
607
803
  createChannel,
608
804
  createEventBuffer,
805
+ createResponse,
609
806
  createSession
610
807
  });