node-turbo 1.0.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.
Files changed (44) hide show
  1. package/.c8rc.json +5 -0
  2. package/.esdoc.json +83 -0
  3. package/.eslintrc.json +10 -0
  4. package/.mocharc.json +7 -0
  5. package/LICENSE +21 -0
  6. package/README.md +620 -0
  7. package/docs/API.md +857 -0
  8. package/lib/errors/index.js +36 -0
  9. package/lib/express/express-turbo-stream.js +41 -0
  10. package/lib/express/index.js +4 -0
  11. package/lib/express/turbocharge-express.js +108 -0
  12. package/lib/index.js +8 -0
  13. package/lib/koa/index.js +4 -0
  14. package/lib/koa/koa-turbo-stream.js +44 -0
  15. package/lib/koa/turbocharge-koa.js +122 -0
  16. package/lib/request-helpers.js +53 -0
  17. package/lib/sse/index.js +3 -0
  18. package/lib/sse/sse-turbo-stream.js +137 -0
  19. package/lib/turbo-element.js +71 -0
  20. package/lib/turbo-frame.js +79 -0
  21. package/lib/turbo-readable.js +125 -0
  22. package/lib/turbo-stream-element.js +67 -0
  23. package/lib/turbo-stream.js +350 -0
  24. package/lib/ws/index.js +4 -0
  25. package/lib/ws/ws-turbo-stream.js +112 -0
  26. package/package.json +75 -0
  27. package/test/hooks.js +46 -0
  28. package/test/integration/express.test.js +137 -0
  29. package/test/integration/koa.test.js +125 -0
  30. package/test/integration/sse.test.js +80 -0
  31. package/test/integration/ws.test.js +155 -0
  32. package/test/package.test.js +68 -0
  33. package/test/unit/core/request-helpers.test.js +97 -0
  34. package/test/unit/core/turbo-element.test.js +15 -0
  35. package/test/unit/core/turbo-frame.test.js +63 -0
  36. package/test/unit/core/turbo-readable.test.js +93 -0
  37. package/test/unit/core/turbo-stream-element.test.js +76 -0
  38. package/test/unit/core/turbo-stream.test.js +308 -0
  39. package/test/unit/express/express-turbo-stream.test.js +39 -0
  40. package/test/unit/express/turbocharge-express.test.js +123 -0
  41. package/test/unit/koa/koa-turbo-stream.test.js +56 -0
  42. package/test/unit/koa/turbocharge-koa.test.js +141 -0
  43. package/test/unit/sse/sse-turbo-stream.test.js +109 -0
  44. package/test/unit/ws/ws-turbo-stream.test.js +46 -0
@@ -0,0 +1,79 @@
1
+
2
+ import { TurboElement } from './turbo-element.js';
3
+ import { AttributeMissingError, AttributeMalformedError } from '#errors';
4
+ import { isPlainObject } from 'is-plain-object';
5
+
6
+ /**
7
+ * This class represents a Turbo Frame message.
8
+ *
9
+ * @extends {TurboElement}
10
+ * @since 1.0.0
11
+ */
12
+ export class TurboFrame extends TurboElement {
13
+
14
+ /**
15
+ * The key which is added to the HTTP headers when the request
16
+ * is made by a Turbo Frame.
17
+ *
18
+ * @type {String}
19
+ * @static
20
+ */
21
+ static HEADER_KEY = 'turbo-frame';
22
+
23
+
24
+ /**
25
+ * MIME type of a Turbo Frame HTTP response, which is just `text/html`.
26
+ *
27
+ * @type {String}
28
+ * @static
29
+ */
30
+ static MIME_TYPE = 'text/html';
31
+
32
+
33
+ /**
34
+ * @param {String|Object} idOrAttributes - Either the ID as string or an object
35
+ * containing all attributes (including `id`).
36
+ * @param {String} content - The HTML content of this Turbo Frame message.
37
+ */
38
+ constructor(idOrAttributes, content) {
39
+ if (typeof idOrAttributes === 'string') {
40
+ super({ id: idOrAttributes }, content);
41
+ }
42
+ else {
43
+ super(idOrAttributes, content);
44
+ }
45
+ }
46
+
47
+
48
+ /**
49
+ * Validates the attributes. `attributes.id` is mandatory.
50
+ * Gets called automatically by the constructor.
51
+ *
52
+ * @throws {AttributeMissingError} when mandatory attributes are missing.
53
+ * @throws {AttributeMalformedError} when mandatory attributes are malformed.
54
+ */
55
+ validate() {
56
+ if (typeof this.attributes === 'undefined' || !isPlainObject(this.attributes) || !('id' in this.attributes)) {
57
+ throw new AttributeMissingError('TurboFrame: Attribute "id" is missing.');
58
+ }
59
+
60
+ if (typeof this.attributes.id !== 'string') {
61
+ throw new AttributeMalformedError('TurboFrame: Attribute "id" must be a string.');
62
+ }
63
+
64
+ if (this.attributes.id.length === 0) {
65
+ throw new AttributeMalformedError('TurboFrame: Attribute "id" must be a string with non-zero length.');
66
+ }
67
+ }
68
+
69
+
70
+ /**
71
+ * Renders the Turbo Frame message as HTML string and returns it.
72
+ *
73
+ * @returns {String} The rendered HTML.
74
+ */
75
+ render() {
76
+ return `<turbo-frame ${this.renderAttributesAsHtml()}>${this.content}</turbo-frame>`;
77
+ }
78
+
79
+ }
@@ -0,0 +1,125 @@
1
+
2
+ import { Readable } from 'node:stream';
3
+ import { TurboStream } from '#core';
4
+ import db from 'debug';
5
+ const debug = db('node-turbo:readable');
6
+
7
+
8
+ /**
9
+ * This class represents a readable stream which reads
10
+ * messages/elements from a Turbo Stream instance.
11
+ *
12
+ * @extends {stream~Readable}
13
+ * @since 1.0.0
14
+ */
15
+ export class TurboReadable extends Readable {
16
+
17
+ /**
18
+ * The Turbo Stream instance to create the readable stream for.
19
+ *
20
+ * @type {TurboStream}
21
+ */
22
+ _turboStream;
23
+
24
+
25
+ /**
26
+ * Creates the readable stream instance.
27
+ * Updates the Turbo Stream's configuration to not buffer elements
28
+ * and adds an event listener for `element` events to it, which get handled
29
+ * by `_boundPush(el)`.
30
+ *
31
+ * If there are already buffered elements, they get pushed into into the read
32
+ * queue immediately and the buffer is cleared afterwards.
33
+ *
34
+ * @param {TurboStream} turboStream - The Turbo Stream instance to create the readable stream for.
35
+ * @param {Object} [opts] - The options for the readable stream.
36
+ * @throws {Error} if `turboStream` is not a TurboStream instance
37
+ */
38
+ constructor(turboStream, opts) {
39
+ debug('new TurboReadable()', opts);
40
+
41
+ if (!(turboStream instanceof TurboStream)) {
42
+ throw new Error('TurboReadable(): Not a TurboStream instance.');
43
+ }
44
+
45
+ super(Object.assign({ encoding: 'utf8' }, opts));
46
+
47
+ this._turboStream = turboStream;
48
+ this._turboStream.updateConfig({ buffer: false });
49
+
50
+ // If we have Turbo Stream elements, push them immediately.
51
+ if (this._turboStream.length > 0) {
52
+ this._turboStream.elements.forEach(el => this._pushElement(el));
53
+ this._turboStream.clear();
54
+ }
55
+
56
+ this._turboStream.on('element', this._boundPush);
57
+ }
58
+
59
+
60
+ /**
61
+ * Pushes a Turbo Stream element into the read queue.
62
+ *
63
+ * @param {TurboStreamElement} el - The Turbo Stream element.
64
+ */
65
+ _pushElement(el) {
66
+ debug('_pushElement()');
67
+ this.push(el.render());
68
+ }
69
+
70
+
71
+ /**
72
+ * This is the bound variant of `_pushElement(el)`. This function serves
73
+ * as handler for the `element` event.
74
+ *
75
+ * @type {Function}
76
+ */
77
+ _boundPush = this._pushElement.bind(this);
78
+
79
+
80
+ /**
81
+ * Gets called when data is available for reading.
82
+ * This implementation does nothing.
83
+ * (Normally, push data would be pushed into the read queue here.)
84
+ *
85
+ * @todo Do we need backpressure handling?
86
+ */
87
+ _read() {
88
+ debug('_read()');
89
+ }
90
+
91
+
92
+ /**
93
+ * Gets called when the stream is being destroyed. The event listener
94
+ * for the event `element` is removed and the configuration restored.
95
+ *
96
+ * @param {Error} err - The error object, if thrown.
97
+ */
98
+ _destroy(err) {
99
+ debug('_destroy()', err);
100
+ this._turboStream.removeListener('element', this._boundPush);
101
+ this._turboStream.updateConfig({ buffer: true });
102
+
103
+ // if (typeof callback === 'function') {
104
+ // if (err) {
105
+ // callback(err);
106
+ // }
107
+ // else {
108
+ // callback();
109
+ // }
110
+ // }
111
+ }
112
+
113
+
114
+ /**
115
+ * Pushes `null` to the readable buffer to signal the end of the input.
116
+ *
117
+ * @todo Should we call this end() or is this confusing because normally
118
+ * only writable streams have this function.
119
+ */
120
+ done() {
121
+ debug('done()');
122
+ this.push(null);
123
+ }
124
+
125
+ }
@@ -0,0 +1,67 @@
1
+
2
+ import { TurboElement } from './turbo-element.js';
3
+ import { isPlainObject } from 'is-plain-object';
4
+ import { AttributeMissingError, AttributeMalformedError, AttributeInvalidError } from '#errors';
5
+
6
+ /**
7
+ * A Turbo Stream element. A Turbo Stream message consists of one or several Turbo Stream
8
+ * elements.
9
+ *
10
+ * @extends {TurboElement}
11
+ * @since 1.0.0
12
+ */
13
+ export class TurboStreamElement extends TurboElement {
14
+
15
+ /**
16
+ * Validates the attributes. `attributes.target` (or `attributes.targets`) and `attributes.action`
17
+ * are mandatory. Gets called by the constructor.
18
+ *
19
+ * @throws {AttributeMissingError} when mandatory attributes are missing.
20
+ * @throws {AttributeMalformedError} when mandatory attributes are malformed.
21
+ * @throws {AttributeInvalidError} when attributes are invalid.
22
+ */
23
+ validate() {
24
+ if (typeof this.attributes === 'undefined' || !isPlainObject(this.attributes)) {
25
+ throw new AttributeMissingError('TurboStream: Attributes are missing.');
26
+ }
27
+
28
+ if (!('target' in this.attributes) && !('targets' in this.attributes)) {
29
+ throw new AttributeMissingError('TurboStream: Attribute "target" or "targets" is missing.');
30
+ }
31
+
32
+ if ('target' in this.attributes && 'targets' in this.attributes) {
33
+ throw new AttributeInvalidError('TurboStream: Attributes "target" and "targets" exclude each other.');
34
+ }
35
+
36
+ if ((typeof this.attributes.target !== 'string' || this.attributes.target.length === 0) &&
37
+ (typeof this.attributes.targets !== 'string' || this.attributes.targets.length === 0)) {
38
+ throw new AttributeMalformedError('TurboStream: Attribute "target"/"targets" must be a string with non-zero length.');
39
+ }
40
+
41
+ if (!('action' in this.attributes)) {
42
+ throw new AttributeMissingError('TurboStream: Attribute "action" is missing.');
43
+ }
44
+
45
+ if (typeof this.attributes.action !== 'string' || this.attributes.action.length === 0) {
46
+ throw new AttributeMalformedError('TurboStream: Attribute "action" must be a string with non-zero length.');
47
+ }
48
+ }
49
+
50
+
51
+ /**
52
+ * Renders this Turbo Stream element as HTML string. Omits `<template>[content]<template>` when the attribute
53
+ * `action` is 'remove'.
54
+ *
55
+ * @returns {String} The rendered HTML fragment.
56
+ * @see https://turbo.hotwired.dev/handbook/streams#stream-messages-and-actions
57
+ */
58
+ render() {
59
+ if (this.attributes.action === 'remove') {
60
+ return `<turbo-stream ${this.renderAttributesAsHtml()}></turbo-stream>`;
61
+ }
62
+ else {
63
+ return `<turbo-stream ${this.renderAttributesAsHtml()}><template>${this.content}</template></turbo-stream>`;
64
+ }
65
+ }
66
+
67
+ }
@@ -0,0 +1,350 @@
1
+
2
+ import { EventEmitter } from 'node:events';
3
+ import { TurboStreamElement, TurboReadable } from '#core';
4
+ import { Writable, Readable } from 'node:stream';
5
+ import db from 'debug';
6
+ const debug = db('node-turbo:turbostream');
7
+
8
+
9
+ /**
10
+ * A Turbo Stream message.
11
+ *
12
+ * @extends {events~EventEmitter}
13
+ * @since 1.0.0
14
+ */
15
+ export class TurboStream extends EventEmitter {
16
+
17
+ /**
18
+ * Default configuration.
19
+ *
20
+ * @type {Object<String, String>}
21
+ * @property {Boolean} buffer - Should elements be added to the buffer (default: true)?
22
+ */
23
+ config = {
24
+ buffer: true
25
+ };
26
+
27
+
28
+ /**
29
+ * Array of buffered elements. Gets filled if `config.buffer` is `true`.
30
+ *
31
+ * @type {Array}
32
+ */
33
+ elements = [];
34
+
35
+
36
+ /**
37
+ * MIME type for Turbo Stream messages.
38
+ *
39
+ * @see https://turbo.hotwired.dev/handbook/streams#streaming-from-http-responses
40
+ * @type {String}
41
+ * @static
42
+ */
43
+ static MIME_TYPE = 'text/vnd.turbo-stream.html';
44
+
45
+
46
+ /**
47
+ * List of all supported actions:
48
+ * 'append', 'prepend', 'replace', 'update', 'remove', 'before' and 'after'.
49
+ *
50
+ * @see https://turbo.hotwired.dev/handbook/streams#stream-messages-and-actions
51
+ * @type {Array}
52
+ * @static
53
+ */
54
+ static ACTIONS = [
55
+ 'append',
56
+ 'prepend',
57
+ 'replace',
58
+ 'update',
59
+ 'remove',
60
+ 'before',
61
+ 'after'
62
+ ];
63
+
64
+
65
+ /**
66
+ * The number of buffered Turbo Stream elements.
67
+ *
68
+ * @type {Number}
69
+ */
70
+ get length() {
71
+ return this.elements.length;
72
+ }
73
+
74
+
75
+ /**
76
+ * If `attributes` and `content` are available, a Turbo Stream element is added to the buffer,
77
+ * pending validation.
78
+ *
79
+ * @param {Object<String, String>} [attributes] - The attributes of this element.
80
+ * @param {String} [content] - The HTML content of this element.
81
+ */
82
+ constructor(attributes, content) {
83
+ super();
84
+
85
+ if (typeof attributes !== 'undefined') {
86
+ return this.addElement(attributes, content);
87
+ }
88
+ }
89
+
90
+
91
+ /**
92
+ * Extends/Overwrites the configuration.
93
+ *
94
+ * @param {Object} config - New configuration.
95
+ * @emits config
96
+ * @returns {TurboStream} The instance for chaining.
97
+ */
98
+ updateConfig(config) {
99
+ if (config) {
100
+ this.config = Object.assign(this.config, config);
101
+ this.emit('config', this.config);
102
+ }
103
+
104
+ return this;
105
+ }
106
+
107
+
108
+ /**
109
+ * Adds a Turbo Stream element to the message.
110
+ * Adds the element to the buffer, if config.buffer === true.
111
+ * Fires the event 'element' with the added element.
112
+ *
113
+ * @param {Object<String, String>|TurboFrameElement} attributesOrElement -
114
+ * @param {String} content - The HTML content of the element.
115
+ * @emits element
116
+ * @returns {TurboStream} The instance for chaining.
117
+ */
118
+ addElement(attributesOrElement, content) {
119
+ let el;
120
+
121
+ if (attributesOrElement instanceof TurboStreamElement) {
122
+ el = attributesOrElement;
123
+ }
124
+ else {
125
+ el = new TurboStreamElement(attributesOrElement, content);
126
+ }
127
+
128
+ if (this?.config?.buffer === true) {
129
+ this.elements.push(el);
130
+ }
131
+
132
+ this.emit('element', el);
133
+
134
+ return this;
135
+ }
136
+
137
+
138
+ /**
139
+ * Clears the buffer.
140
+ *
141
+ * @emits clear
142
+ * @returns {TurboStream} The instance for chaining.
143
+ */
144
+ clear() {
145
+ this.elements = [];
146
+ this.emit('clear');
147
+
148
+ return this;
149
+ }
150
+
151
+
152
+ /**
153
+ * Renders this Turbo Stream message if there are buffered elements.
154
+ *
155
+ * @returns {String|null} The rendered Turbo Stream HTML fragment or null if there were no buffered elements.
156
+ * @emits render
157
+ */
158
+ render() {
159
+ const arr = this.renderElements();
160
+ if (arr !== null) {
161
+ const html = arr.join('\n');
162
+ this.emit('render', html);
163
+
164
+ return html;
165
+ }
166
+
167
+ return null;
168
+ }
169
+
170
+
171
+ /**
172
+ * If there are buffered elements, renders them and returns an array with the HTML fragments.
173
+ *
174
+ * @returns {Array|null} The rendered Turbo Stream HTML fragments as array or null if there were no buffered elements.
175
+ */
176
+ renderElements() {
177
+ if (this.elements.length > 0) {
178
+ return this.elements.map(el => el.render());
179
+ }
180
+
181
+ return null;
182
+ }
183
+
184
+
185
+ /**
186
+ * Renders this Turbo Stream message and clears the buffer.
187
+ *
188
+ * @returns {String|null} The rendered Turbo Stream HTML fragment or null if there were no buffered elements.
189
+ * @emits {render}
190
+ * @emits {clear}
191
+ */
192
+ flush() {
193
+ const html = this.render();
194
+ this.clear();
195
+
196
+ return html;
197
+ }
198
+
199
+
200
+ /**
201
+ * Adds a Turbo Stream Element with a custom action.
202
+ *
203
+ * @param {String} action - The name of the custom action.
204
+ * @param {String} target - The target ID.
205
+ * @param {String} content - The HTML content of the element.
206
+ * @returns {TurboStream} The instance for chaining.
207
+ */
208
+ custom(action, target, content) {
209
+ return this.addElement({ action, target }, content);
210
+ }
211
+
212
+
213
+ /**
214
+ * Adds a Turbo Stream Element with a custom action, targeting multiple DOM elements.
215
+ *
216
+ * @param {String} action - The name of the custom action.
217
+ * @param {String} targets - The query string targeting multiple DOM elements.
218
+ * @param {String} content - The HTML content of the element.
219
+ * @returns {TurboStream} The instance for chaining.
220
+ */
221
+ customAll(action, targets, content) {
222
+ return this.addElement({ action, targets }, content);
223
+ }
224
+
225
+
226
+ /**
227
+ *
228
+ */
229
+ createReadableStream(opts, streamOptions = {}) {
230
+ opts = Object.assign({}, { continuous: true }, opts);
231
+ debug('createReadableStream()', opts, streamOptions);
232
+
233
+ if (opts.continuous === true) {
234
+ debug(' returns new TurboStreamReadable()');
235
+ return new TurboReadable(this, streamOptions);
236
+ }
237
+ else {
238
+ debug(' returns Readable.from()');
239
+ return Readable.from(this.length > 0 ? this.render().split('\n') : [], streamOptions);
240
+ }
241
+ }
242
+ }
243
+
244
+
245
+ // Add convenience functions for all supported actions.
246
+ TurboStream.ACTIONS.forEach(action => {
247
+ TurboStream.prototype[action] = function(targetOrAttributes, content) {
248
+ if (typeof targetOrAttributes === 'string') {
249
+ return this.addElement({ action: action, target: targetOrAttributes }, content);
250
+ }
251
+ else {
252
+ return this.addElement(Object.assign({ action: action }, targetOrAttributes), content);
253
+ }
254
+ };
255
+
256
+ TurboStream.prototype[`${action}All`] = function(targets, content) {
257
+ return this.addElement({ action: action, targets }, content);
258
+ };
259
+ });
260
+
261
+ // Dynamic functions:
262
+
263
+ /**
264
+ * @name #append
265
+ * @function
266
+ * @description Adds a Turbo Stream element with the action 'append' to the message.
267
+ * @param {String|Object<String, String>} targetOrAttributes - Either the target ID as string or all attributes as object.
268
+ * @param {String} content - The HTML content of the element.
269
+ */
270
+
271
+ /**
272
+ * @name #prepend
273
+ * @function
274
+ * @description Adds a Turbo Stream element with the action 'prepend' to the message.
275
+ * @param {(String|Object<String, String>)} targetOrAttributes - Either the target ID as string or all attributes as object.
276
+ * @param {String} content - The HTML content of the element.
277
+ */
278
+
279
+ /**
280
+ * @name #replace
281
+ * @function
282
+ * @description Adds a Turbo Stream element with the action 'replace' to the message.
283
+ * @param {(String|Object<String, String>)} targetOrAttributes - Either the target ID as string or all attributes as object.
284
+ * @param {String} content - The HTML content of the element.
285
+ */
286
+
287
+ /**
288
+ * @name #update
289
+ * @function
290
+ * @description Adds a Turbo Stream element with the action 'update' to the message.
291
+ * @param {String|Object<String, String>} targetOrAttributes - Either the target ID as string or all attributes as object.
292
+ * @param {String} content - The HTML content of the element.
293
+ */
294
+
295
+ /**
296
+ * @name #remove
297
+ * @function
298
+ * @description Adds a Turbo Stream element with the action 'remove' to the message.
299
+ * @param {String|Object<String, String>} targetOrAttributes - Either the target ID as string or all attributes as object.
300
+ * @param {String} content - The HTML content of the element.
301
+ */
302
+
303
+ /**
304
+ * @name #before
305
+ * @function
306
+ * @description Adds a Turbo Stream element with the action 'before' to the message.
307
+ * @param {String|Object<String, String>} targetOrAttributes - Either the target ID as string or all attributes as object.
308
+ * @param {String} content - The HTML content of the element.
309
+ */
310
+
311
+ /**
312
+ * @name after
313
+ * @function
314
+ * @description Adds a Turbo Stream element with the action 'after' to the message.
315
+ * @param {String|Object<String, String>} targetOrAttributes - Either the target ID as string or all attributes as object.
316
+ * @param {String} content - The HTML content of the element.
317
+ */
318
+
319
+ // Events:
320
+
321
+ /**
322
+ * Event element.
323
+ * Gets fired when a Turbo Stream element has been added to a message.
324
+ *
325
+ * @event element
326
+ * @param {TurboStreamElement} - The added element.
327
+ */
328
+
329
+ /**
330
+ * Event render.
331
+ * Gets fired when a Turbo Stream message has been rendered.
332
+ *
333
+ * @event render
334
+ * @param {String} - The rendered HTML fragment.
335
+ */
336
+
337
+ /**
338
+ * Event config.
339
+ * Gets fired when the config has been updated.
340
+ *
341
+ * @event config
342
+ * @param {Object} - The updated config object.
343
+ */
344
+
345
+ /**
346
+ * Event clear.
347
+ * Gets fired when the buffer is cleared.
348
+ *
349
+ * @event clear
350
+ */
@@ -0,0 +1,4 @@
1
+ /** @module node-turbo/ws */
2
+
3
+ export * from './ws-turbo-stream.js';
4
+