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,36 @@
1
+ /** @module node-turbo/errors */
2
+
3
+ /**
4
+ * Parent class for all validation errors.
5
+ *
6
+ * @extends {node:Error}
7
+ * @since 1.0.0
8
+ */
9
+ export class ValidationError extends Error {}
10
+
11
+
12
+ /**
13
+ * Gets thrown when mandatory attributes are missing.
14
+ *
15
+ * @extends {ValidationError}
16
+ * @since 1.0.0
17
+ */
18
+ export class AttributeMissingError extends ValidationError {}
19
+
20
+
21
+ /**
22
+ * Gets thrown when mandatory attributes are malformed.
23
+ *
24
+ * @extends {ValidationError}
25
+ * @since 1.0.0
26
+ */
27
+ export class AttributeMalformedError extends ValidationError {}
28
+
29
+
30
+ /**
31
+ * Gets thrown when invalid attributes are discovered.
32
+ *
33
+ * @extends {ValidationError}
34
+ * @since 1.0.0
35
+ */
36
+ export class AttributeInvalidError extends ValidationError {}
@@ -0,0 +1,41 @@
1
+
2
+ import { TurboStream } from '#core';
3
+
4
+
5
+ /**
6
+ * This class represents a Turbo Stream message for Express.
7
+ * Introduces the function `send()` to send the rendered message
8
+ * as HTTP response with the correct MIME type.
9
+ *
10
+ * @extends {TurboStream}
11
+ * @since 1.0.0
12
+ */
13
+ export class ExpressTurboStream extends TurboStream {
14
+
15
+ /**
16
+ * Express' response object to send to.
17
+ *
18
+ * @type {Object}
19
+ */
20
+ res;
21
+
22
+ /**
23
+ * Stores Express' response object and creates a `TurboStream` instance.
24
+ *
25
+ * @param {Object} res - Express' response object to send to.
26
+ * @param {Object} [attributes] - Attributes of the added element.
27
+ * @param {String} [content] - The HTML content of the added element.
28
+ */
29
+ constructor(res, ...args) {
30
+ super(...args);
31
+ this.res = res;
32
+ }
33
+
34
+ /**
35
+ * Sends the rendered message as HTTP response with the correct MIME type.
36
+ */
37
+ send() {
38
+ this.res.type(TurboStream.MIME_TYPE);
39
+ this.res.send(this.render());
40
+ }
41
+ }
@@ -0,0 +1,4 @@
1
+ /** @module node-turbo/express */
2
+
3
+ export * from './express-turbo-stream.js';
4
+ export * from './turbocharge-express.js';
@@ -0,0 +1,108 @@
1
+
2
+ import { TurboStream, TurboFrame, isTurboStreamRequest, isTurboFrameRequest, getTurboFrameId } from '#core';
3
+ import { ExpressTurboStream } from '#express';
4
+ import { SseTurboStream } from '#sse';
5
+
6
+
7
+ /**
8
+ * Default options.
9
+ *
10
+ * @type {Object}
11
+ * @property {boolean} autoRender - Should the Turbo Frame be automatically sent as HTTP response? Default: true
12
+ */
13
+ const defaultOptions = {
14
+ autoSend: true
15
+ };
16
+
17
+
18
+ /**
19
+ * Adds the following functions to Express' request object:
20
+ *
21
+ * - `isTurboStreamRequest()`
22
+ * Checks if the request is a Turbo Stream request by looking for the MIME type in the `accept` headers.
23
+ * Returns `true`/`false`.
24
+ * - `isTurboFrameRequest()`
25
+ * Checks if the request is a Turbo Frame request by looking for the `turbo-frame` header.
26
+ * Returns `true`/`false`.
27
+ * - `getTurboFrameId()`
28
+ * Returns the contents of the `turbo-frame` header.
29
+ *
30
+ * Also adds the following functions to Express' `response` object:
31
+ *
32
+ * - `turboStream()`
33
+ * Returns a chainable Turbo Stream instance which introduces the function `send()` which sends the rendered Turbo Stream message as HTTP response with the correct MIME type.
34
+ * - `turboFrame(id, content)`
35
+ * Returns a Turbo Frame instance which directly sends the rendered Turbo Frame message as HTTP response.
36
+ * - `turboFrame(content)`
37
+ * If you omit the `id` attribute, it is automatically added by using the ID from the `turbo-frame` header.
38
+ * - `sseTurboStream()`
39
+ * *Experimental*. Configures Express to keep the connection open and use a stream to pipe to `res`.
40
+ *
41
+ * @param {Object} expressApp - The Express application object.
42
+ * @param {Object} opts - The options to override.
43
+ * @since 1.0.0
44
+ */
45
+ export function turbochargeExpress(expressApp, opts) {
46
+
47
+ opts = Object.assign({}, defaultOptions, opts);
48
+
49
+ expressApp.request.isTurboStreamRequest = function() {
50
+ return isTurboStreamRequest(this);
51
+ }
52
+
53
+ expressApp.request.isTurboFrameRequest = function() {
54
+ return isTurboFrameRequest(this);
55
+ }
56
+
57
+ expressApp.request.getTurboFrameId = function() {
58
+ return getTurboFrameId(this);
59
+ }
60
+
61
+ expressApp.response.turboFrame = function(...args) {
62
+ let
63
+ idOrAttributes,
64
+ content;
65
+
66
+ // Just the content.
67
+ if (args.length === 1 && typeof args[0] === 'string') {
68
+ idOrAttributes = this.req.getTurboFrameId();
69
+ content = args[0];
70
+ }
71
+ else if (args.length === 2) {
72
+ idOrAttributes = args[0];
73
+ content = args[1];
74
+ }
75
+
76
+ const tf = new TurboFrame(idOrAttributes, content);
77
+
78
+ if (opts.autoSend === true) {
79
+ this.send(tf.render());
80
+ return tf;
81
+ }
82
+ else {
83
+ return tf.render();
84
+ }
85
+ }
86
+
87
+ expressApp.response.turboStream = function(attributes, content) {
88
+ return new ExpressTurboStream(this, ...arguments);
89
+ }
90
+
91
+ expressApp.response.sseTurboStream = function() {
92
+
93
+ this.set({
94
+ 'Content-Type': SseTurboStream.MIME_TYPE,
95
+ 'Cache-Control': 'no-cache',
96
+ 'Connection': 'keep-alive'
97
+ });
98
+
99
+ this.flushHeaders();
100
+
101
+ const ssets = new SseTurboStream();
102
+ const stream = ssets.createReadableStream();
103
+ stream.pipe(this);
104
+
105
+ return ssets;
106
+ }
107
+
108
+ }
package/lib/index.js ADDED
@@ -0,0 +1,8 @@
1
+ /** @module node-turbo */
2
+
3
+ export { TurboElement } from './turbo-element.js';
4
+ export { TurboStreamElement } from './turbo-stream-element.js';
5
+ export { TurboStream } from './turbo-stream.js';
6
+ export { TurboFrame } from './turbo-frame.js';
7
+ export * from './request-helpers.js';
8
+ export * from './turbo-readable.js';
@@ -0,0 +1,4 @@
1
+ /** @module node-turbo/koa */
2
+
3
+ export * from './koa-turbo-stream.js';
4
+ export * from './turbocharge-koa.js';
@@ -0,0 +1,44 @@
1
+
2
+ import { TurboStream } from '#core';
3
+
4
+
5
+ /**
6
+ * This class represents a Turbo Stream message with added functionality
7
+ * for Koa. Renders directly to Koa's `ctx.body` whenever a Turbo Stream
8
+ * element gets added.
9
+ *
10
+ * @extends {TurboStream}
11
+ * @since 1.0.0
12
+ */
13
+ export class KoaTurboStream extends TurboStream {
14
+
15
+ /**
16
+ * Koa's context object.
17
+ *
18
+ * @type {Object}
19
+ * @see https://koajs.com/#context
20
+ */
21
+ koaCtx;
22
+
23
+
24
+ /**
25
+ * @param {Object} koaCtx - Koa's context object.
26
+ */
27
+ constructor(koaCtx) {
28
+ super();
29
+ this.updateConfig({ buffer: false });
30
+
31
+ this.koaCtx = koaCtx;
32
+ this.koaCtx.type = TurboStream.MIME_TYPE;
33
+ this.koaCtx.status = 200;
34
+
35
+ if (typeof this.koaCtx.body !== 'string') {
36
+ this.koaCtx.body = '';
37
+ }
38
+
39
+ this.on('element', el => {
40
+ this.koaCtx.body += el.render() + '\n';
41
+ });
42
+ }
43
+
44
+ }
@@ -0,0 +1,122 @@
1
+
2
+ import { TurboStream, TurboFrame, isTurboStreamRequest, isTurboFrameRequest, getTurboFrameId } from '#core';
3
+ import { KoaTurboStream } from '#koa';
4
+ import { SseTurboStream } from '#sse';
5
+ import { PassThrough } from 'node:stream';
6
+
7
+ /**
8
+ * Default options.
9
+ *
10
+ * @type {Object}
11
+ * @property {Boolean} autoRender - Should elements directly render to ctx.body? (Default: `true`)
12
+ */
13
+ const defaultOptions = {
14
+ autoRender: true
15
+ };
16
+
17
+
18
+ /**
19
+ * Adds the following functions to Koa's context object:
20
+ *
21
+ * - `turboStream()`
22
+ * Returns a chainable Turbo Stream instance which directly writes to `ctx.body` whenever an element is added. Also sets the correct `Content-Type` header.
23
+ * - `turboFrame()`
24
+ * Returns a Turbo Frame instance which directly writes to `ctx.body`.
25
+ * - `isTurboStreamRequest()`
26
+ * Checks if the request is a Turbo Stream request by looking for the MIME type in the `accept` headers.
27
+ * Returns `true`/`false`.
28
+ * - `isTurboFrameRequest()`
29
+ * Checks if the request is a Turbo Frame request by looking for the `turbo-frame` header.
30
+ * Returns `true`/`false`.
31
+ * - `getTurboFrameId()`
32
+ * Returns the contents of the `turbo-frame` header.
33
+ * - `sseTurboStream()`
34
+ * *Experimental*. Configures Koa to keep the connection open and use a stream to pipe to `ctx.res`.
35
+ *
36
+ * @param {Object} koaApp - The Koa application object.
37
+ * @param {Object} opts - The options.
38
+ * @param {Boolean} opts.autoRender - Should Turbo Stream elements automatically be rendered and sent? (Default: `true`)
39
+ * @since 1.0.0
40
+ */
41
+ export function turbochargeKoa(koaApp, opts = {}) {
42
+
43
+ opts = Object.assign({}, defaultOptions, opts);
44
+
45
+ koaApp.context = Object.assign(koaApp.context, {
46
+
47
+ getTurboFrameId: function() {
48
+ return getTurboFrameId(this.req);
49
+ },
50
+
51
+
52
+ isTurboFrameRequest: function() {
53
+ return isTurboFrameRequest(this.req);
54
+ },
55
+
56
+
57
+ isTurboStreamRequest: function() {
58
+ return isTurboStreamRequest(this.req);
59
+ },
60
+
61
+
62
+ turboFrame: function(idOrAttributes, content) {
63
+ // Just the content.
64
+ if (arguments.length === 1 && typeof idOrAttributes === 'string') {
65
+ content = idOrAttributes;
66
+ idOrAttributes = this.getTurboFrameId();
67
+ }
68
+
69
+ const tf = new TurboFrame(idOrAttributes, content);
70
+
71
+ if (opts.autoRender === true) {
72
+ this.status = 200;
73
+ this.type = TurboFrame.MIME_TYPE;
74
+ this.body = tf.render();
75
+ }
76
+ else {
77
+ return tf.render();
78
+ }
79
+ },
80
+
81
+ turboStream: function(...args) {
82
+ if (opts.autoRender === true) {
83
+ const kts = new KoaTurboStream(this);
84
+ return kts;
85
+ }
86
+ else {
87
+ return new TurboStream(...args);
88
+ }
89
+ },
90
+
91
+
92
+ /**
93
+ * @since 1.0
94
+ * @inner
95
+ * @experimental
96
+ */
97
+ sseTurboStream: function() {
98
+ // Disable koa-compress.
99
+ // @todo Adjust compression for SSE.
100
+ this.compress = false;
101
+
102
+ // Set connection to keep-alive.
103
+ this.set({
104
+ 'Content-Type': SseTurboStream.MIME_TYPE,
105
+ 'Cache-Control': 'no-cache',
106
+ 'Connection': 'keep-alive'
107
+ });
108
+
109
+ // this.response.flushHeaders();
110
+
111
+ const
112
+ ssets = new SseTurboStream(),
113
+ readable = ssets.createReadableStream();
114
+
115
+ this.status = 200;
116
+ this.body = readable;
117
+
118
+ return ssets;
119
+ }
120
+
121
+ });
122
+ }
@@ -0,0 +1,53 @@
1
+
2
+ import { IncomingMessage } from 'node:http';
3
+ import Negotiator from 'negotiator';
4
+ import { TurboStream, TurboFrame } from '#core';
5
+
6
+ /**
7
+ * Checks if the request is a Turbo Stream request.
8
+ *
9
+ * @param {Object} request - The request object. Expects an object like an {http.ClientRequest} instance.
10
+ * @returns {Boolean} `true`, if the request has been identified as a Turbo Stream request. `false` otherwise.
11
+ * @since 1.0.0
12
+ */
13
+ export function isTurboStreamRequest(request) {
14
+ if (request?.headers?.accept) {
15
+ const negotiator = new Negotiator(request);
16
+ return negotiator.mediaTypes().includes(TurboStream.MIME_TYPE);
17
+ }
18
+
19
+ return false;
20
+ }
21
+
22
+
23
+ /**
24
+ * Checks if the request is a Turbo Frame request.
25
+ *
26
+ * @param {Object} request - The request object. Expects an object like an {http.ClientRequest} instance.
27
+ * @returns {Boolean} `true`, if the request has been identified as a Turbo Frame request. false otherwise.
28
+ * @since 1.0.0
29
+ */
30
+ export function isTurboFrameRequest(request) {
31
+ if (request?.headers) {
32
+ return (typeof request.headers[TurboFrame.HEADER_KEY] !== 'undefined');
33
+ }
34
+
35
+ return false;
36
+ }
37
+
38
+
39
+ /**
40
+ * Returns the content of the 'turbo-frame' header, which is the ID of the requesting Turbo
41
+ * Frame.
42
+ *
43
+ * @param {Object} request - The request object. Expects an object like an {http.ClientRequest} instance.
44
+ * @returns {String|null} The Turbo Frame ID or `null` if not found.
45
+ * @since 1.0.0
46
+ */
47
+ export function getTurboFrameId(request) {
48
+ if (request?.headers) {
49
+ return request.headers[TurboFrame.HEADER_KEY];
50
+ }
51
+
52
+ return null;
53
+ }
@@ -0,0 +1,3 @@
1
+ /** @module turbo-node/sse */
2
+
3
+ export * from './sse-turbo-stream.js';
@@ -0,0 +1,137 @@
1
+
2
+ import { TurboStream, TurboReadable } from '#core';
3
+ import { Writable, Transform } from 'node:stream';
4
+ import db from 'debug';
5
+ const debug = db('node-turbo:sse');
6
+
7
+
8
+ /**
9
+ * This class represents a Turbo Stream message for SSE.
10
+ * **Note**: This class is in __experimental__ stage.
11
+ *
12
+ * @experimental
13
+ * @extends {TurboStream}
14
+ * @since 1.0.0
15
+ */
16
+ export class SseTurboStream extends TurboStream {
17
+
18
+ /**
19
+ * MIME type for an SSE message (`text/event-stream`).
20
+ *
21
+ * @type {String}
22
+ * @static
23
+ * @since 1.0.0
24
+ */
25
+ static MIME_TYPE = 'text/event-stream';
26
+
27
+
28
+ /**
29
+ * The optional name to send the event under.
30
+ *
31
+ * @type {String}
32
+ * @since 1.0.0
33
+ */
34
+ eventName;
35
+
36
+
37
+ /**
38
+ * Creates a Turbo Stream message instance which automatically writes to the
39
+ * SSE stream as soon as a Turbo Stream element is added.
40
+ *
41
+ * @param {String} [eventname] - The SSE event name to send the message under.
42
+ * @since 1.0.0
43
+ */
44
+ constructor(eventName) {
45
+ debug('new SseTurboStream()', eventName);
46
+ super();
47
+ this.eventName = eventName;
48
+ }
49
+
50
+
51
+ /**
52
+ * Renders the Turbo Stream message and adds SSE specific syntax.
53
+ *
54
+ * @returns {String|null} The rendered SSE or null if there were no elements in the buffer.
55
+ * @since 1.0.0
56
+ */
57
+ render() {
58
+ const html = super.render();
59
+
60
+ if (html === null) {
61
+ debug('render() null');
62
+ return null;
63
+ }
64
+ debug('render()');
65
+
66
+ const event = this.renderSseEvent(html);
67
+
68
+ return event;
69
+ }
70
+
71
+
72
+ /**
73
+ * Takes a HTML fragment string and converts it to an SSE event message.
74
+ *
75
+ * @param {String} raw - The raw HTML string.
76
+ * @returns {String|null} The converted SSE event message or null if no string has been passed.
77
+ * @since 1.0.0
78
+ */
79
+ renderSseEvent(raw) {
80
+ if (typeof raw !== 'string') {
81
+ return null;
82
+ }
83
+
84
+ debug('createSseEvent() raw:', raw);
85
+ const lines = raw.split('\n');
86
+ const data = lines.map(line => `data: ${line}`).join('\n');
87
+ const event = `${this.eventName ? `event: ${this.eventName}\n` : ''}${data}\n\n`;
88
+ debug('createSseEvent() event:', event);
89
+
90
+ return event;
91
+ }
92
+
93
+
94
+ /**
95
+ * Creates a {TurboReadable} instance, which pipes to a {node:stream~Transform} to add
96
+ * SSE specific syntax.
97
+ *
98
+ * @returns {node:stream.Transform} The Transform stream instance.
99
+ * @since 1.0.0
100
+ */
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
+ }
118
+ });
119
+
120
+ return readable.pipe(sseTransform);
121
+ }
122
+
123
+
124
+ /**
125
+ * Set the event name for the SSE data.
126
+ *
127
+ * @param {String} eventName - The event name to send the message under.
128
+ * @returns {SseTurboStream} The instance for chaining.
129
+ * @since 1.0.0
130
+ */
131
+ event(eventName) {
132
+ this.eventName = eventName;
133
+
134
+ return this;
135
+ }
136
+
137
+ }
@@ -0,0 +1,71 @@
1
+
2
+ /**
3
+ * Base class with common functionality for Turbo Stream elements and Turbo Frames.
4
+ * Not to be used directly.
5
+ *
6
+ * @private
7
+ * @since 1.0.0
8
+ */
9
+ export class TurboElement {
10
+
11
+ /**
12
+ * The attribute object.
13
+ * @type {Object<String, String>}
14
+ */
15
+ attributes = {};
16
+
17
+
18
+ /**
19
+ * The HTML content.
20
+ * @type {String}
21
+ */
22
+ content = '';
23
+
24
+
25
+ /**
26
+ * Automatically calls validate().
27
+ *
28
+ * @param {Object} attributes - The attributes of this element.
29
+ * @param {String} content - The HTML content of this element.
30
+ */
31
+ constructor(attributes, content) {
32
+ this.attributes = attributes;
33
+ this.content = content;
34
+
35
+ this.validate();
36
+ }
37
+
38
+
39
+ /**
40
+ * Converts the attributes object to a string in the form
41
+ * of HTML attributes ({ name: value } -> 'name="value"').
42
+ *
43
+ * @returns {String} The HTML attribute string.
44
+ */
45
+ renderAttributesAsHtml() {
46
+ return Object.entries(this.attributes)
47
+ .map(([name, value]) => `${name}="${value}"`)
48
+ .join(' ');
49
+ }
50
+
51
+
52
+ /* c8 ignore next 17 */
53
+
54
+ /**
55
+ * Validation function to implement.
56
+ * @abstract
57
+ */
58
+ validate() {
59
+ throw new Error('validate() not implemented.');
60
+ }
61
+
62
+
63
+ /**
64
+ * Render function to implement.
65
+ * @abstract
66
+ */
67
+ render() {
68
+ throw new Error('render() not implemented.');
69
+ }
70
+
71
+ }