node-turbo 1.2.7 → 1.3.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024-2025 Walter Krivanek
3
+ Copyright (c) 2024-2026 Walter Krivanek
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -55,11 +55,11 @@ node-turbo has been tested with 100% code coverage with the following engines/li
55
55
 
56
56
  | Name | Version(s) |
57
57
  | :--- | :--- |
58
- | [Hotwire Turbo](https://turbo.hotwired.dev/) | 7.3.0 - 8.0.20 |
58
+ | [Hotwire Turbo](https://turbo.hotwired.dev/) | 7.3.0 - 8.0.23 |
59
59
  | [Node.js](https://nodejs.org/) | 16.6 - 22.20.0 |
60
- | [Koa](https://koajs.com/) | 2.14.2 - 3.0.3 |
61
- | [Express](https://expressjs.com/) | 4.18.2 - 5.1.0 |
62
- | [ws](https://github.com/websockets/ws) | 8.15.1 - 8.18.3 |
60
+ | [Koa](https://koajs.com/) | 2.14.2 - 3.1.2 |
61
+ | [Express](https://expressjs.com/) | 4.18.2 - 5.2.1 |
62
+ | [ws](https://github.com/websockets/ws) | 8.15.1 - 8.19.0 |
63
63
 
64
64
  ## API docs
65
65
  See [`/docs/API.md`](./docs/API.md) for a documentation of all node-turbo classes and functions.
@@ -216,15 +216,15 @@ Result:
216
216
 
217
217
  ##### Using the Node.js streams API
218
218
  If you want to use the [Node.js streams API](https://nodejs.org/docs/latest/api/stream.html) with Turbo Streams, you can
219
- create a Readable stream instance which reads Turbo Stream messages.
219
+ create a `PassThrough` stream instance which reads Turbo Stream messages.
220
220
 
221
221
  ```javascript
222
222
  import { TurboStream } from 'node-turbo';
223
223
 
224
224
  const ts = new TurboStream();
225
- const readable = ts.createReadableStream();
225
+ const nStream = ts.createNodeStream();
226
226
 
227
- readable.pipe(process.stdout);
227
+ nStream.pipe(process.stdout);
228
228
 
229
229
  // These elements get piped immediately:
230
230
  ts
@@ -459,9 +459,9 @@ const wss = new WebSocketServer({ port: 8080 });
459
459
 
460
460
  wss.on('connection', function connection(ws) {
461
461
  const ts = new TurboStream();
462
- const readable = ts.createReadableStream();
462
+ const nStream = ts.createNodeStream();
463
463
  const wsStream = createWebSocketStream(ws, { encoding: 'utf8' });
464
- readable.pipe(wsStream);
464
+ nStream.pipe(wsStream);
465
465
 
466
466
  ts
467
467
  .append('target-id', '<p>My content</p>')
@@ -508,8 +508,8 @@ const httpServer = http.createServer((req, res) => {
508
508
 
509
509
  // You can also use the streams API.
510
510
  setTimeout(() => {
511
- const stream = ssets.createReadableStream();
512
- stream.pipe(res);
511
+ const nStream = ssets.createNodeStream();
512
+ nStream.pipe(res);
513
513
  ssets.prependAll('.stream', '<p>Prepend!</p>');
514
514
  }, 2000);
515
515
 
@@ -529,7 +529,7 @@ const httpServer = http.createServer((req, res) => {
529
529
  padding: 10px;
530
530
  }
531
531
  </style>
532
- <script type="module" src="https://unpkg.com/@hotwired/turbo@8.0.20/dist/turbo.es2017-esm.js"></script>
532
+ <script type="module" src="https://unpkg.com/@hotwired/turbo@8.0.23/dist/turbo.es2017-esm.js"></script>
533
533
  <script>
534
534
  var eventSource = new EventSource('/sse');
535
535
  eventSource.onmessage = function(event) {
@@ -616,7 +616,7 @@ app.use(async (ctx, next) => {
616
616
  padding: 10px;
617
617
  }
618
618
  </style>
619
- <script type="module" src="https://unpkg.com/@hotwired/turbo@8.0.20/dist/turbo.es2017-esm.js"></script>
619
+ <script type="module" src="https://unpkg.com/@hotwired/turbo@8.0.23/dist/turbo.es2017-esm.js"></script>
620
620
  <script>
621
621
  var eventSource = new EventSource('/sse');
622
622
  eventSource.onmessage = function(event) {
@@ -693,7 +693,7 @@ app.get('/', async (req, res) => {
693
693
  padding: 10px;
694
694
  }
695
695
  </style>
696
- <script type="module" src="https://unpkg.com/@hotwired/turbo@8.0.20/dist/turbo.es2017-esm.js"></script>
696
+ <script type="module" src="https://unpkg.com/@hotwired/turbo@8.0.23/dist/turbo.es2017-esm.js"></script>
697
697
  <script>
698
698
  var eventSource = new EventSource('/sse');
699
699
  eventSource.onmessage = function(event) {
@@ -720,4 +720,4 @@ app.listen(config.port);
720
720
  ```
721
721
  ## License
722
722
 
723
- node-turbo is © 2024-2025 Walter Krivanek and released under the [MIT license](https://mit-license.org).
723
+ node-turbo is © 2024-2026 Walter Krivanek and released under the [MIT license](https://mit-license.org).
package/docs/API.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # node-turbo API documentation
2
2
 
3
- Version 1.2.7
3
+ Version 1.3.0
4
4
 
5
5
  ## Table of Contents
6
6
 
@@ -26,7 +26,7 @@ Version 1.2.7
26
26
  - [turbostream.flush()](#turbostreamflush)
27
27
  - [turbostream.custom(action, target, content)](#turbostreamcustomaction-target-content)
28
28
  - [turbostream.customAll(action, targets, content)](#turbostreamcustomallaction-targets-content)
29
- - [turbostream.createReadableStream(opts, opts.continuous, streamOptions)](#turbostreamcreatereadablestreamopts-optscontinuous-streamoptions)
29
+ - [turbostream.createNodeStream(opts, streamOptions)](#turbostreamcreatenodestreamopts-streamoptions)
30
30
  - [turbostream.append(targetOrAttributes, content)](#turbostreamappendtargetorattributes-content)
31
31
  - [turbostream.appendAll(targetsOrAttributes, content)](#turbostreamappendalltargetsorattributes-content)
32
32
  - [turbostream.prepend(targetOrAttributes, content)](#turbostreamprependtargetorattributes-content)
@@ -54,7 +54,7 @@ Version 1.2.7
54
54
  - [turboelement.renderAttributesAsHtml()](#turboelementrenderattributesashtml)
55
55
  - [turboelement.validate()](#turboelementvalidate)
56
56
  - [turboelement.render()](#turboelementrender)
57
- - [Class: TurboReadable](#class-turboreadable)
57
+ - [Class: TurboReadable (deprecated)](#class-turboreadable)
58
58
  - [new TurboReadable(turboStream[, opts])](#new-turboreadableturbostream-opts)
59
59
  - [turboreadable._turboStream](#turboreadable_turbostream)
60
60
  - [turboreadable._boundPush](#turboreadable_boundpush)
@@ -92,7 +92,7 @@ Version 1.2.7
92
92
  - [sseturbostream.eventName](#sseturbostreameventname)
93
93
  - [sseturbostream.render()](#sseturbostreamrender)
94
94
  - [sseturbostream.renderSseEvent(raw)](#sseturbostreamrendersseeventraw)
95
- - [sseturbostream.createReadableStream()](#sseturbostreamcreatereadablestream)
95
+ - [sseturbostream.createNodeStream()](#sseturbostreamcreatenodestream)
96
96
  - [sseturbostream.event(eventName)](#sseturbostreameventeventname)
97
97
  - [node-turbo/errors](#node-turboerrors)
98
98
  - [Class: ValidationError](#class-validationerror)
@@ -271,16 +271,16 @@ Adds a Turbo Stream Element with a custom action, targeting multiple DOM element
271
271
 
272
272
  Returns: {TurboStream} The instance for chaining.
273
273
 
274
- #### turbostream.createReadableStream(opts, opts.continuous, streamOptions)
274
+ #### turbostream.createNodeStream(opts, streamOptions)
275
275
 
276
276
  - `opts` {Object<String, String>} The options for stream creation.
277
- - `opts.continuous` {Boolean} If true, a TurboReadable instance is returned.
278
- If false, a readable stream created from the buffered items is returned.
277
+ - `opts.continuous` {Boolean} If true, a {node:stream~PassThrough} instance is returned.
278
+ If false, a {node:stream~Readable} created from the buffered items is returned.
279
279
  - `streamOptions` {Object<String, String>} The options for the readable stream itself.
280
280
 
281
- Creates a readable stream.
281
+ Creates either a {node:stream~Readable} or {node:stream~PassThrough} stream instance.
282
282
 
283
- Returns: {stream.Readable | TurboReadable} Either a readable stream or a TurboReadable instance.
283
+ Returns: {node:stream~Readable | {node:stream~PassThrough}} Either a readable stream or a TurboReadable instance.
284
284
 
285
285
  #### turbostream.append(targetOrAttributes, content)
286
286
 
@@ -510,6 +510,9 @@ Render function to implement.
510
510
 
511
511
  ### Class: TurboReadable
512
512
 
513
+ > [!WARNING]
514
+ > Deprecated since v1.3.0!
515
+
513
516
  ```javascript
514
517
  import { TurboReadable } from 'node-turbo';
515
518
  ```
@@ -688,7 +691,7 @@ Adds the element to the buffer, if config.buffer === true.
688
691
  Fires the event 'element' with the added element.
689
692
  > - clear()
690
693
  Clears the buffer.
691
- > - createReadableStream(opts, opts.continuous, streamOptions)
694
+ > - createNodeStream(opts, streamOptions)
692
695
  Creates a readable stream.
693
696
  > - custom(action, target, content)
694
697
  Adds a Turbo Stream Element with a custom action.
@@ -752,7 +755,7 @@ Adds the element to the buffer, if config.buffer === true.
752
755
  Fires the event 'element' with the added element.
753
756
  > - clear()
754
757
  Clears the buffer.
755
- > - createReadableStream(opts, opts.continuous, streamOptions)
758
+ > - createNodeStream(opts, streamOptions)
756
759
  Creates a readable stream.
757
760
  > - custom(action, target, content)
758
761
  Adds a Turbo Stream Element with a custom action.
@@ -851,7 +854,7 @@ Adds the element to the buffer, if config.buffer === true.
851
854
  Fires the event 'element' with the added element.
852
855
  > - clear()
853
856
  Clears the buffer.
854
- > - createReadableStream(opts, opts.continuous, streamOptions)
857
+ > - createNodeStream(opts, streamOptions)
855
858
  Creates a readable stream.
856
859
  > - custom(action, target, content)
857
860
  Adds a Turbo Stream Element with a custom action.
@@ -942,15 +945,15 @@ Returns: {String | null} The rendered SSE or null if there were no elements in t
942
945
 
943
946
  - `raw` {String} The raw HTML string.
944
947
 
945
- Takes a HTML fragment string and converts it to an SSE event message.
948
+ Takes an HTML fragment string and converts it to an SSE event message.
946
949
 
947
950
  Returns: {String | null} The converted SSE event message or null if no string has been passed.
948
951
 
949
- #### sseturbostream.createReadableStream()
952
+ #### sseturbostream.createNodeStream()
950
953
 
951
- Creates a {TurboReadable} instance, which pipes to a {node:stream~Transform} to add SSE specific syntax.
954
+ Creates a {node:stream~PassThrough} instance, which passes Turbo Stream elements through as SSE messages.
952
955
 
953
- Returns: {node:stream.Transform} The Transform stream instance.
956
+ Returns: {node:stream~PassThrough} The PassThrough stream instance.
954
957
 
955
958
  #### sseturbostream.event(eventName)
956
959
 
@@ -1050,4 +1053,4 @@ Gets thrown when invalid attributes are discovered.
1050
1053
 
1051
1054
  ***
1052
1055
 
1053
- node-turbo is © 2024-2025 by Walter Krivanek and released under the [MIT license](https://mit-license.org).
1056
+ node-turbo is © 2024-2026 by Walter Krivanek and released under the [MIT license](https://mit-license.org).
@@ -7,7 +7,7 @@
7
7
  "uuidString" : "F605B793-549B-4143-ACBD-2C8AF906877B"
8
8
  }
9
9
  ],
10
- "creatorBuild" : "34680",
10
+ "creatorBuild" : "34892",
11
11
  "files" : {
12
12
  "\/API.md" : {
13
13
  "cB" : 0,
@@ -1327,8 +1327,6 @@
1327
1327
  "buildFolderActive" : 0,
1328
1328
  "buildFolderName" : "build",
1329
1329
  "cleanBuild" : 1,
1330
- "cssoForceMediaMerge" : 0,
1331
- "cssoRestructure" : 1,
1332
1330
  "environmentVariableEntries" : [
1333
1331
  "NODE_ENV:::production"
1334
1332
  ],
@@ -1965,6 +1963,9 @@
1965
1963
  "active" : 0,
1966
1964
  "optionString" : "{'skipBlankLines': false, 'ignoreComments': false}"
1967
1965
  },
1966
+ "no-unassigned-vars" : {
1967
+ "active" : 0
1968
+ },
1968
1969
  "no-undef" : {
1969
1970
  "active" : 1,
1970
1971
  "optionString" : "{'typeof': false}"
@@ -2045,7 +2046,8 @@
2045
2046
  "active" : 0
2046
2047
  },
2047
2048
  "no-useless-escape" : {
2048
- "active" : 1
2049
+ "active" : 1,
2050
+ "optionString" : "{'allowRegexCharacters': []}"
2049
2051
  },
2050
2052
  "no-useless-rename" : {
2051
2053
  "active" : 0,
@@ -40,7 +40,7 @@ export class ExpressTurboStream extends TurboStream {
40
40
  */
41
41
  send() {
42
42
  this.res.type(TurboStream.MIME_TYPE);
43
- this.res.send(this.render());
43
+ this.res.send(this.render() ?? '');
44
44
  }
45
45
 
46
46
  }
@@ -98,8 +98,9 @@ export function turbochargeExpress(expressApp, opts) {
98
98
 
99
99
  this.flushHeaders();
100
100
 
101
- const ssets = new SseTurboStream();
102
- const stream = ssets.createReadableStream();
101
+ const ssets = new SseTurboStream();
102
+ const stream = ssets.createNodeStream();
103
+ this.req.on('close', () => stream.destroy());
103
104
  stream.pipe(this);
104
105
 
105
106
  return ssets;
@@ -108,9 +108,11 @@ export function turbochargeKoa(koaApp, opts = {}) {
108
108
 
109
109
  // this.response.flushHeaders();
110
110
 
111
- const
111
+ const
112
112
  ssets = new SseTurboStream(),
113
- readable = ssets.createReadableStream();
113
+ readable = ssets.createNodeStream();
114
+
115
+ this.req.on('close', () => readable.destroy());
114
116
 
115
117
  this.status = 200;
116
118
  this.body = readable;
@@ -1,6 +1,7 @@
1
1
 
2
- import { TurboStream, TurboReadable } from '#core';
3
- import { Writable, Transform } from 'node:stream';
2
+ import { TurboStream } from '#core';
3
+ import { PassThrough } from 'node:stream';
4
+ import { deprecate } from 'node:util';
4
5
  import db from 'debug';
5
6
  const debug = db('node-turbo:sse');
6
7
 
@@ -70,7 +71,7 @@ export class SseTurboStream extends TurboStream {
70
71
 
71
72
 
72
73
  /**
73
- * Takes a HTML fragment string and converts it to an SSE event message.
74
+ * Takes an HTML fragment string and converts it to an SSE event message.
74
75
  *
75
76
  * @param {String} raw - The raw HTML string.
76
77
  * @returns {String|null} The converted SSE event message or null if no string has been passed.
@@ -92,33 +93,51 @@ export class SseTurboStream extends TurboStream {
92
93
 
93
94
 
94
95
  /**
95
- * Creates a {TurboReadable} instance, which pipes to a {node:stream~Transform} to add
96
- * SSE specific syntax.
96
+ * Creates a {node:stream.PassThrough} to which the the TurboStream elements
97
+ * are written as SSE event messages
97
98
  *
98
99
  * @returns {node:stream.Transform} The Transform stream instance.
99
- * @since 1.0.0
100
+ * @since 1.3.0
100
101
  */
101
- createReadableStream() {
102
- const readable = new TurboReadable(this);
103
- const eventName = this.eventName;
104
-
105
- const renderSseEvent = this.renderSseEvent.bind(this);
106
-
107
- const sseTransform = new Transform({
108
- transform(chunk, encoding, callback) {
109
- if (encoding === 'buffer') {
110
- chunk = chunk.toString();
111
- }
112
- const event = renderSseEvent(chunk);
113
- this.push(event);
114
- if (typeof callback === 'function') {
115
- callback();
116
- }
117
- }
102
+ createNodeStream() {
103
+ const stream = new PassThrough();
104
+
105
+ // Don't buffer Turbo Stream elements.
106
+ this.updateConfig({ buffer: false });
107
+
108
+ // Instead, immediately write them to the stream.
109
+ const elementListener = (el) => {
110
+ stream.write(this.renderSseEvent(el.render()));
111
+ };
112
+
113
+ this.on('element', elementListener);
114
+
115
+ stream.once('close', () => {
116
+ this.removeListener('element', elementListener)
118
117
  });
119
-
120
- return readable.pipe(sseTransform);
118
+
119
+ // If we have previously buffered Turbo Stream elements,
120
+ // write them immediately.
121
+ if (this.length > 0) {
122
+ this.elements.forEach((el) => this.emit('element', el));
123
+ this.clear();
124
+ }
125
+
126
+ return stream;
121
127
  }
128
+
129
+
130
+ /**
131
+ * Creates a {node:stream.PassThrough} to which the the TurboStream elements
132
+ * are written as SSE event messages
133
+ *
134
+ * @returns {node:stream.Transform} The Transform stream instance.
135
+ * @since 1.0.0
136
+ * @deprecated since 1.3.0
137
+ */
138
+ createReadableStream = deprecate(() => {
139
+ return this.createNodeStream();
140
+ }, 'sseTurboStream.createReadableStream() is deprecated. Use sseTurboStream.createNodeStream() instead.');
122
141
 
123
142
 
124
143
  /**
@@ -11,6 +11,7 @@ const debug = db('node-turbo:readable');
11
11
  *
12
12
  * @extends {node:stream~Readable}
13
13
  * @since 1.0.0
14
+ * @deprecated since 1.3.8
14
15
  */
15
16
  export class TurboReadable extends Readable {
16
17
 
@@ -101,19 +102,11 @@ export class TurboReadable extends Readable {
101
102
  * @param {Error} err - The error object, if thrown.
102
103
  * @since 1.0.0
103
104
  */
104
- _destroy(err) {
105
+ _destroy(err, callback) {
105
106
  debug('_destroy()', err);
106
107
  this._turboStream.removeListener('element', this._boundPush);
107
108
  this._turboStream.updateConfig({ buffer: true });
108
-
109
- // if (typeof callback === 'function') {
110
- // if (err) {
111
- // callback(err);
112
- // }
113
- // else {
114
- // callback();
115
- // }
116
- // }
109
+ callback(err);
117
110
  }
118
111
 
119
112
 
@@ -1,7 +1,7 @@
1
1
 
2
2
  import { EventEmitter } from 'node:events';
3
- import { TurboStreamElement, TurboReadable } from '#core';
4
- import { Writable, Readable } from 'node:stream';
3
+ import { TurboStreamElement } from '#core';
4
+ import { Readable, PassThrough } from 'node:stream';
5
5
  import { deprecate } from 'node:util';
6
6
  import db from 'debug';
7
7
  const debug = db('node-turbo:turbostream');
@@ -271,28 +271,67 @@ export class TurboStream extends EventEmitter {
271
271
 
272
272
 
273
273
  /**
274
- * Creates a readable stream.
275
- *
274
+ * Creates a {node:stream.PassThrough} stream to which the the TurboStream elements
275
+ * are written.
276
+ *
276
277
  * @param {Object<String, String>} opts - The options for stream creation.
277
- * @param {Boolean} opts.continuous - If true, a TurboReadable instance is returned.
278
- * If false, a readable stream created from the buffered items is returned.
278
+ * @param {Boolean} opts.continuous - If true, a {node:stream.PassThrough} is returned that
279
+ * receives elements as they are added. If false, a readable stream created from the buffered
280
+ * items is returned.
279
281
  * @param {Object<String, String>} streamOptions - The options for the readable stream itself.
280
- * @returns {stream.Readable|TurboReadable} Either a readable stream or a TurboReadable instance.
282
+ * @returns {node:stream.PassThrough|stream.Readable} Either a PassThrough stream or a Readable.from() instance.
281
283
  * @since 1.0.0
282
284
  */
283
- createReadableStream(opts, streamOptions = {}) {
285
+ createNodeStream(opts, streamOptions = {}) {
284
286
  opts = Object.assign({}, { continuous: true }, opts);
285
287
  debug('createReadableStream()', opts, streamOptions);
286
288
 
287
289
  if (opts.continuous === true) {
288
- debug(' returns new TurboStreamReadable()');
289
- return new TurboReadable(this, streamOptions);
290
+ debug(' returns PassThrough stream');
291
+
292
+ const stream = new PassThrough(streamOptions);
293
+
294
+ // Don't buffer Turbo Stream elements.
295
+ this.updateConfig({ buffer: false });
296
+
297
+ // Instead, immediately write them to the stream.
298
+ const elementListener = (el) => {
299
+ stream.write(el.render());
300
+ };
301
+
302
+ this.on('element', elementListener);
303
+
304
+ stream.once('close', () => {
305
+ this.removeListener('element', elementListener);
306
+ });
307
+
308
+ // If we have previously buffered Turbo Stream elements,
309
+ // write them immediately.
310
+ if (this.length > 0) {
311
+ this.elements.forEach((el) => this.emit('element', el));
312
+ this.clear();
313
+ }
314
+
315
+ return stream;
290
316
  }
291
317
  else {
292
318
  debug(' returns Readable.from()');
293
- return Readable.from(this.length > 0 ? this.render().split('\n') : [], streamOptions);
319
+ return Readable.from((this.length > 0) ? this.render().split('\n') : [], streamOptions);
294
320
  }
295
321
  }
322
+
323
+
324
+ /**
325
+ * Creates a {node:stream.PassThrough} stream to which the the TurboStream elements
326
+ * are written.
327
+ *
328
+ * @returns {node:stream.Transform} The Transform stream instance.
329
+ * @since 1.0.0
330
+ * @deprecated since 1.3.0
331
+ */
332
+ createReadableStream = deprecate((opts, streamOptions = {}) => {
333
+ return this.createNodeStream(opts, streamOptions);
334
+ }, 'turboStream.createReadableStream() is deprecated. Use turboStream.createNodeStream() instead.')
296
335
 
297
336
 
298
337
  /**
@@ -91,6 +91,7 @@ export class WsTurboStream extends TurboStream {
91
91
  if (this.listenerCount('render') > 0) {
92
92
  this.removeListener('render', this.handleRender);
93
93
  }
94
+
94
95
  this.on('element', this.handleElement);
95
96
  }
96
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-turbo",
3
- "version": "1.2.7",
3
+ "version": "1.3.0",
4
4
  "description": "A library for Node.js to assist with the server side of 37signals' Hotwire Turbo framework. It provides classes and functions for Web servers and also convenience functions for the frameworks Koa and Express as well as for WebSocket and SSE.",
5
5
  "keywords": [
6
6
  "node",
@@ -64,23 +64,23 @@
64
64
  "negotiator": "^1.0.0"
65
65
  },
66
66
  "devDependencies": {
67
- "@hotwired/turbo": "^8.0.20",
68
- "@koa/bodyparser": "^6.0.0",
69
- "@playwright/test": "^1.56.1",
70
- "c8": "^10.1.3",
71
- "chai": "^6.2.0",
67
+ "@hotwired/turbo": "^8.0.23",
68
+ "@koa/bodyparser": "^6.1.0",
69
+ "@playwright/test": "^1.58.2",
70
+ "c8": "^11.0.0",
71
+ "chai": "^6.2.2",
72
72
  "chai-spies": "^1.1.0",
73
73
  "colorette": "^2.0.20",
74
- "eventsource": "^4.0.0",
75
- "express": "^5.1.0",
74
+ "eventsource": "^4.1.0",
75
+ "express": "^5.2.1",
76
76
  "git-repo-info": "^2.1.1",
77
- "koa": "^3.0.3",
77
+ "koa": "^3.1.2",
78
78
  "koa-route": "^4.0.1",
79
79
  "koa-static": "^5.0.0",
80
- "mocha": "^11.7.4",
80
+ "mocha": "^11.7.5",
81
81
  "node-mocks-http": "^1.17.2",
82
82
  "nunjucks": "^3.2.4",
83
- "supertest": "^7.1.4",
84
- "ws": "^8.18.3"
83
+ "supertest": "^7.2.2",
84
+ "ws": "^8.19.0"
85
85
  }
86
86
  }
@@ -4,13 +4,14 @@ import route from 'koa-route';
4
4
  import serve from 'koa-static';
5
5
  import bodyParser from '@koa/bodyparser';
6
6
  import { turbochargeKoa } from '#koa';
7
- import { SseTurboStream } from '#sse';
8
7
 
9
8
  const app = new Koa();
10
9
  let ssets;
11
10
 
12
- app.on('error', (err, ctx) => {
13
- console.error('Error in Koa:', err);
11
+ app.on('error', (err) => {
12
+ if (err?.code !== 'ERR_STREAM_PREMATURE_CLOSE') {
13
+ console.error('Error in Koa:', err);
14
+ }
14
15
  });
15
16
 
16
17
  // Add node-turbo functions to Koa context.
@@ -1,22 +1,28 @@
1
1
 
2
2
  import { expect } from '../chai.js';
3
- import request from 'supertest';
4
3
  import Koa from 'koa';
5
4
  import { EventSource } from 'eventsource';
6
- import { TurboStream } from '#core';
7
5
  import { SseTurboStream } from '#sse';
8
- import { PassThrough } from 'node:stream';
9
6
 
7
+
10
8
  const port = 8888;
11
9
 
10
+
12
11
  describe('SSE integration', function() {
13
12
 
14
13
  before(function() {
14
+ this.readable = null;
15
+ this.eventSource = null;
16
+ this.server = null;
15
17
  this.app = new Koa();
16
18
  this.sseTurboStream = new SseTurboStream();
17
-
19
+
18
20
  this.app.on('error', (err, ctx) => {
19
- expect.fail(err);
21
+ // ERR_STREAM_PREMATURE_CLOSE is expected when the SSE client disconnects.
22
+ // Also @see: {@link https://github.com/koajs/koa/pull/612|Koa GitHub Issue 612}
23
+ if (err?.code !== 'ERR_STREAM_PREMATURE_CLOSE') {
24
+ console.error(err);
25
+ }
20
26
  });
21
27
 
22
28
  this.app.use(async (ctx, next) => {
@@ -29,52 +35,58 @@ describe('SSE integration', function() {
29
35
  ctx.req.socket.setKeepAlive(true);
30
36
 
31
37
  ctx.set({
38
+ 'Content-Type': SseTurboStream.MIME_TYPE,
32
39
  'Cache-Control': 'no-cache',
33
- 'Connection': 'keep-alive',
40
+ 'Connection': 'keep-alive'
34
41
  });
35
42
 
36
- this.readable = this.sseTurboStream.createReadableStream();
43
+ this.readable = this.sseTurboStream.createNodeStream();
44
+ ctx.req.on('close', () => this.readable.destroy());
37
45
 
38
- ctx.type = SseTurboStream.MIME_TYPE;
39
46
  ctx.status = 200;
40
47
  ctx.body = this.readable;
41
48
  });
42
-
49
+
43
50
  this.server = this.app.listen(port);
44
51
  });
45
52
 
46
53
 
47
54
  after(function() {
48
- this.readable.destroy();
49
- this.eventSource.close();
55
+ this.server.closeAllConnections();
50
56
  this.server.close();
51
57
  });
52
58
 
53
59
 
54
- it('Turbo Stream messages get sent to EventSource as SSE messages', function(done) {
55
- const messages = [];
56
-
57
- this.eventSource = new EventSource(`http://localhost:${port}/sse`);
58
-
59
- this.eventSource.addEventListener('error', e => {
60
- expect.fail('An error occurred while attempting to connect');
61
- });
62
-
63
- this.eventSource.addEventListener('message', e => {
60
+ it('Turbo Stream messages get sent to EventSource as SSE messages', function() {
61
+ return new Promise((resolve, reject) => {
62
+ const messages = [];
64
63
 
65
- messages.push(e.data);
66
-
67
- if (messages.length === 2) {
68
- expect(messages[0]).to.equal('<turbo-stream action="append" target="t1"><template>c1</template></turbo-stream>');
69
- expect(messages[1]).to.equal('<turbo-stream action="replace" target="t2"><template>c2</template></turbo-stream>');
70
- done();
71
- }
64
+ this.eventSource = new EventSource(`http://localhost:${port}/sse`);
65
+
66
+ this.eventSource.addEventListener('error', (e) => {
67
+ expect.fail(e.message);
68
+ reject(e);
69
+ });
70
+
71
+ this.eventSource.addEventListener('message', (e) => {
72
+ messages.push(e);
73
+
74
+ if (messages.length === 2) {
75
+ expect(messages[0].type).to.equal('message');
76
+ expect(messages[0].data).to.equal('<turbo-stream action="append" target="t1"><template>c1</template></turbo-stream>');
77
+ expect(messages[1].type).to.equal('message');
78
+ expect(messages[1].data).to.equal('<turbo-stream action="replace" target="t2"><template>c2</template></turbo-stream>');
79
+
80
+ this.eventSource.close();
81
+ resolve();
82
+ }
83
+ });
84
+
85
+ // Send Turbo Stream messages.
86
+ this.sseTurboStream
87
+ .append('t1', 'c1')
88
+ .replace('t2', 'c2');
72
89
  });
73
-
74
- // Send Turbo Stream messages.
75
- this.sseTurboStream.append('t1', 'c1');
76
- this.sseTurboStream.replace('t2', 'c2');
77
90
  });
78
91
 
79
-
80
92
  });
@@ -1,6 +1,5 @@
1
1
 
2
2
  import { expect } from '../chai.js';
3
- import request from 'supertest';
4
3
  import { TurboStream } from '#core';
5
4
  import { WsTurboStream } from '#ws';
6
5
  import { WebSocketServer, WebSocket, createWebSocketStream } from 'ws';
@@ -113,7 +112,7 @@ describe('WebSocket integration', function() {
113
112
  const messages = [];
114
113
 
115
114
  // We're expecting one message with two elements.
116
- ws.on('message', (data, isBinary) => {
115
+ ws.on('message', (data) => {
117
116
  messages.push(data.toString());
118
117
  if (messages.length === 2) {
119
118
  expect(messages[0]).to.equal('<turbo-stream action="append" target="t1"><template>c1</template></turbo-stream>');
@@ -127,7 +126,7 @@ describe('WebSocket integration', function() {
127
126
  ws.once('open', () => {
128
127
  this.wss.clients.forEach(ws => {
129
128
  const ts = new TurboStream();
130
- const readable = ts.createReadableStream();
129
+ const readable = ts.createNodeStream();
131
130
 
132
131
  readable.on('error', err => {
133
132
  return reject(err);
@@ -1,20 +1,17 @@
1
1
 
2
2
  import { expect, spy } from '../../chai.js';
3
- import { TurboStream, TurboElement, TurboStreamElement, TurboReadable } from '#core';
4
- import { Readable } from 'node:stream';
3
+ import { TurboStream, TurboStreamElement, TurboReadable } from '#core';
5
4
 
6
- const attr = {
7
- action: 'a',
8
- target: 't'
9
- };
10
-
11
- describe('TurboReadable', function() {
5
+ describe('TurboReadable (deprecated)', function() {
12
6
 
13
7
  before(function() {
14
8
  this.ts = new TurboStream()
15
9
  this.readable = new TurboReadable(this.ts);
16
10
  });
17
11
 
12
+ after(function() {
13
+ this.readable.destroy();
14
+ });
18
15
 
19
16
  beforeEach(function() {
20
17
  spy.on(this.readable, 'push');
@@ -68,11 +65,11 @@ describe('TurboReadable', function() {
68
65
  });
69
66
 
70
67
 
71
- it('_destroy() gets called when steam is destroyed', function() {
72
- const readable = this.ts.createReadableStream();
68
+ it('_destroy() gets called when stream is destroyed', function() {
69
+ const readable = new TurboReadable(new TurboStream());
70
+
73
71
  spy.on(readable, '_destroy');
74
72
  readable.destroy();
75
-
76
73
  expect(readable._destroy).to.have.been.called();
77
74
  spy.restore(readable, '_destroy');
78
75
  });
@@ -1,7 +1,7 @@
1
1
 
2
2
  import { expect } from '../../chai.js';
3
- import { TurboStream, TurboElement, TurboStreamElement, TurboReadable } from '#core';
4
- import { Readable } from 'node:stream';
3
+ import { TurboStream, TurboElement, TurboStreamElement } from '#core';
4
+ import { Readable, PassThrough } from 'node:stream';
5
5
 
6
6
  const attr = {
7
7
  action: 'a',
@@ -379,30 +379,100 @@ describe('TurboStream', function() {
379
379
  expect(ts.elements[0].attributes.action).to.equal('morph');
380
380
  });
381
381
 
382
+ it ('createReadableStream() warns about deprecation.', function() {
383
+ return new Promise(resolve => {
384
+ process.once('warning', err => {
385
+ expect(err).to.be.an('error');
386
+ expect(err.message).to.include('createReadableStream() is deprecated');
387
+ resolve();
388
+ });
389
+
390
+ const ts = new TurboStream();
391
+ ts.createReadableStream();
392
+ });
393
+ });
394
+
382
395
  });
383
396
  });
384
397
 
385
398
 
386
- describe('createReadableStream(opts)', function() {
399
+ describe('createNodeStream(opts)', function() {
387
400
 
388
- it('returns new TurboReadable() when opts.continuous = true', function() {
389
- const ts = new TurboStream();
390
- const readable = ts.createReadableStream();
391
- expect(readable).to.be.an.instanceof(TurboReadable);
392
- });
393
-
394
- it('returns new Readable() when opts.continuous = false', function() {
395
- const ts = new TurboStream().append('t', 'c');
396
- const readable = ts.createReadableStream({ continuous: false });
397
- expect(readable).not.to.be.an.instanceof(TurboReadable);
398
- expect(readable).to.be.an.instanceof(Readable);
401
+ describe('opts.continuous = true', function() {
402
+
403
+ it('returns PassThrough stream', function() {
404
+ const ts = new TurboStream();
405
+ const readable = ts.createNodeStream();
406
+ expect(readable).to.be.an.instanceof(PassThrough);
407
+ });
408
+
399
409
  });
410
+
411
+
412
+ describe('opts.continuous = false', function() {
413
+
414
+ it('returns new Readable()', function() {
415
+ const ts = new TurboStream();
416
+ const readable = ts.createNodeStream({ continuous: false });
417
+ expect(readable).not.to.be.an.instanceof(PassThrough);
418
+ expect(readable).to.be.an.instanceof(Readable);
419
+ readable.destroy();
420
+ });
421
+
422
+ it('emits event \'element\' for existing Turbo Stream elements', function() {
423
+ return new Promise(resolve => {
424
+ const ts = new TurboStream().append('t', 'c');
400
425
 
401
- it('returns new Readable() when opts.continuous = false even if empty', function() {
402
- const ts = new TurboStream();
403
- const readable = ts.createReadableStream({ continuous: false });
404
- expect(readable).not.to.be.an.instanceof(TurboReadable);
405
- expect(readable).to.be.an.instanceof(Readable);
426
+ ts.on('element', (el) => {
427
+ expect(el).to.be.an.instanceof(TurboStreamElement);
428
+
429
+ resolve();
430
+ });
431
+
432
+ const readable = ts.createNodeStream();
433
+ readable.destroy();
434
+ });
435
+ });
436
+
437
+ it('Readable stream includes previously added elements (rendered)', function() {
438
+ const ts = new TurboStream()
439
+ .append('t', 'c')
440
+ .append('t2', 'c2');
441
+
442
+ const readable = ts.createNodeStream({ continuous: false });
443
+ readable.setEncoding('utf8');
444
+ readable.pause();
445
+
446
+ let
447
+ el = '',
448
+ str = '';
449
+
450
+ while ((el = readable.read()) !== null) {
451
+ str += el;
452
+ }
453
+
454
+ readable.destroy();
455
+ expect(str).to.equal('<turbo-stream action="append" target="t"><template>c</template></turbo-stream><turbo-stream action="append" target="t2"><template>c2</template></turbo-stream>');
456
+ });
457
+
458
+ it('returns new Readable(), even if empty', function() {
459
+ const ts = new TurboStream();
460
+ const readable = ts.createNodeStream({ continuous: false });
461
+ expect(readable).not.to.be.an.instanceof(PassThrough);
462
+ expect(readable).to.be.an.instanceof(Readable);
463
+ readable.destroy();
464
+ });
465
+
466
+ it('Readable stream is empty, if TurboStream is empty', function() {
467
+ const ts = new TurboStream();
468
+
469
+ const readable = ts.createNodeStream({ continuous: false });
470
+ readable.setEncoding('utf8');
471
+ readable.pause();
472
+ expect(readable.read()).to.be.null;
473
+ readable.destroy();
474
+ });
475
+
406
476
  });
407
477
 
408
478
  });
@@ -15,7 +15,8 @@ describe('turbochargeExpress()', function() {
15
15
  req: {
16
16
  headers: {
17
17
  'turbo-frame': 'id'
18
- }
18
+ },
19
+ on: function() {}
19
20
  },
20
21
  send: function(content) {
21
22
  this.output = content;
@@ -15,7 +15,8 @@ describe('turbochargeKoa()', function() {
15
15
  req: {
16
16
  headers: {
17
17
  'turbo-frame': 'id'
18
- }
18
+ },
19
+ on: function() {}
19
20
  }
20
21
  }
21
22
  };
@@ -60,18 +60,26 @@ describe('SseTurboStream', function() {
60
60
  });
61
61
 
62
62
 
63
- it('createReadableStream() returns Transform', function() {
63
+ it('createNodeStream() returns Transform', function() {
64
64
  const sseSt = new SseTurboStream();
65
- const readable = sseSt.createReadableStream();
65
+ const readable = sseSt.createNodeStream();
66
66
 
67
67
  expect(readable).to.be.an.instanceof(Transform);
68
68
  });
69
+
70
+
71
+ it('createReadableStream() (deprecated) returns Transform', function() {
72
+ const sseSt = new SseTurboStream();
73
+
74
+ const readable = sseSt.createReadableStream();
75
+ expect(readable).to.be.an.instanceof(Transform);
76
+ });
69
77
 
70
78
 
71
79
  it('SSE message gets written to stream', function() {
72
80
  return new Promise((resolve, reject) => {
73
81
  const sseSt = new SseTurboStream();
74
- const readable = sseSt.createReadableStream();
82
+ const readable = sseSt.createNodeStream();
75
83
 
76
84
  readable.on('data', chunk => {
77
85
  expect(chunk.toString()).to.equal('data: <turbo-stream action="append" target="t"><template>c</template></turbo-stream>\n\n');
@@ -91,7 +99,7 @@ describe('SseTurboStream', function() {
91
99
  it('SSE message with event name gets written to stream', function() {
92
100
  return new Promise((resolve, reject) => {
93
101
  const sseSt = new SseTurboStream('my-event');
94
- const readable = sseSt.createReadableStream();
102
+ const readable = sseSt.createNodeStream();
95
103
 
96
104
  readable.on('data', chunk => {
97
105
  expect(chunk.toString()).to.equal('event: my-event\ndata: <turbo-stream action="append" target="t"><template>c</template></turbo-stream>\n\n');