ExpressJS middleware for sending turbo-stream HTML fragments to a hotwire Turbo client. It aims to perform a subset of functionality that turbo-rails provides with ERB templates, but with EJS templates.
- Node 14.x or newer
npm i hotwire-turbo-express
Per the Turbo Streams docs, When Turbo encounters a <turbo-stream>
element in an HTML fragment delivered by a server over a "WebSocket, SSE or other transport", the DOM element with an id that matches the target
attribute will be modified with the updates inside the <turbo-stream>
.
Here are a few response transport scenarios:
In this scenario, a client submits a form.
- Turbo includes
text/vnd.turbo-stream.html
in the HTTPAccept
header. - The server detects the above header and responds with
Content-Type: text/vnd.turbo-stream.html
, and includes a HTML fragment with one or more<turbo-stream>
elements. - On the client,
Turbo
detects theContent-Type
header, which signals to it to process the above response and update the matching DOM elements.
Here, a stimulus controller connected to an HTML element will open a Websocket or SSE connection to a server. Whenever a message comes in which has <turbo-stream>
tags, Turbo will process its contents in the same fashion as in the HTTP Response Stream scenario.
-
turboStream
- Middleware function for express.Options:
mimeType
- The Turbo stream MIME type. Defaults totext/vnd.turbo-stream.html
.
import turboStream from 'hotwire-turbo-express'; const app = express(); app.use(turboStream());
The middleware will add a res.turboStream
property with some functions:
-
append
,prepend
,replace
, andupdate
- These functions are the equivalent of the turbo-railsturbo_stream.append
,turbo_stream.prepend
, etc, methods, with a slightly different arguments to more closely match how EJS works in express:-
turboStream.append(view, locals, stream, onlyFormat)
arguments:
view
andlocals
are the same arguments that would be passed tores.render
.stream
is an object of which attributes will be added to theturbo-stream
HTML element, with the exception ofaction
, which will be set to the value matching the append/prepend/replace/update function.onlyFormat
- seesendStream
Given the
MessagesController
rails example in the turbo docs, this would be the equivalent here:const upload = multer(); app.post('/messages/create', upload.none(), async (req, res, next) => { const message = createMessage(...); const locals = { message }; const view = 'messages/partials/message'; const stream = { target: 'list' } return res.turboStream.append(view, locals, stream); });
-
-
renderViews
- The append/prepend/replace/update functions send a single<turbo-stream>
element in the response. However, you can "render any number of stream elements in a single stream message".renderViews
providers this ability by accepting an array of objects, each which will result in a<turbo-stream>
element with its own properties. Each entry is tied to a given EJS view to be rendered.turboStream.renderViews(<array of stream spec objects>, <onlyFormat>)
Stream spec array attributes:
view
andlocals
accept the same arguments that would be passed tores.render
.stream
is an object of which attributes will be added to theturbo-stream
HTML element
router.post('/page', upload.none(), async (req, res) => { const { hasMore, items } = await getItems(); return res.turboStream.renderViews([ { stream: { action: 'append', target: 'item-list', }, locals: { items }, view: 'item-list/partials/item-list', }, { stream: { action: 'replace', target: 'item-list-more-button', }, locals: { hasMore }, view: 'item-list/partials/item-list-more-button', }, ], true); });
onlyFormat
- seesendStream
-
TurboStream
- A simple class for creating<turbo-stream>
HTML fragments.- constructor:
new TurboStream(attributes, content)
attributes
- An object of attributes to set in the<turbo-stream>
tag.content
- A string with the content to place as the child element of the tag.
- Instance methods:
toHtml()
- Returns an HTML fragment string.
> tag = new turboStream.TurboStream({ action: 'append' }, "hi there") > console.log(tag.toHtml()) <turbo-stream action="append"> <template> hi there </template> </turbo-stream>
toSseMessage()
- Returns an HTML fragment string suitable for sending in a server sent event message. The Turbo client looks for the<turbo-stream>
in thedata
attribute. The message will include two newline characters at the end, to signal a flush of the SSE response.
> tag = new turboStream.TurboStream({ action: 'append' }, "hi there") > console.log(tag.toSseMessage()) data: <turbo-stream action="append"> <template> hi there </template> </turbo-stream>
toWebSocketMessage()
- Returns an HTML fragment string suitable for sending in a WebSocket message.
While this will work, consider expanding the scope of these messages, e.g. to include signing messages to ensure they are not tampered with, as is done in turbo-rails.> tag = new turboStream.TurboStream({ action: 'append' }, "hi there") > console.log(tag.toWebSocketMessage()) > <turbo-stream action="append"> <template> hi there </template> </turbo-stream>
- constructor:
-
compileViews
- Same asrenderView
but returns the compiled HTML fragment instead of sending it to the client. -
compileView
- Same ascompileViews
but accepts a single stream spec object instead of an array of them. -
sendStream
- Convenience function that sends an HTML snippet string with the turbo-stream MIME type. args:res
- The express response object.html
- The rendered html.onlyFormat
(boolean, defaults tofalse
) - Iftrue
, the response will be configured to only respond to requests which have the correct Turbo MIME type, otherwise, a HTTP 406 (Not Acceptable) response will be sent. If false, the stream response will be sent regardless of what is specified in the request'sAccept
HTTP header.
--
TurboStream
is also a named export, so it can be used outside of the middleware. Here is an example of sending a turbo stream message over a WebSocket:
import { TurboStream } from 'hotwire-turbo-express';
import WebSocket from 'ws';
/**
* Send a message to the WS server
* with a turbo stream of the given html.
*/
const sendItemWsMessage = (url, stream, html) => {
const tag = new TurboStream(stream, html);
const ws = new WebSocket(url);
ws.on('open', async () => {
ws.send(tag.toWebSocketMessage());
return ws.close();
});
};
The example app has complete implementations showing how to use this library to work with <turbo-stream>
s. Explanation of the use cases are shown in the app itself.
-
Action initiated in one browser is reflected in other browsers connected via SSE/WebSocket:
-
Action initiated from an external source, in this case a CLI tool that sends a message via WebSocket, is reflected in browsers connected to the same WebSocket endpoint:
# builds the NPM, installs it in the app
npm run example:setup
# calls npm start in the app
npm run example:start
Browse to http://localhost:3000
Turbo is integrated with SSE or WebSockets by way of the connectStreamSource
and disconnectStreamSource
functions.
- Make Turbo a client listening to WebSocket messages at a given endpoint:
connectStreamSource(new WebSocket('ws://foo/bar');
- Make turbo a client listening to SSE messages at a given endpoint:
connectStreamSource(new EventSource('http://foo/bar');
Once connected, messages with <turbo-stream>
HTML snippets will be processed by Turbo.
There is an example using stimulus in the example app, in src/controllers/stream-controller
.
Payload format is: data:
{html with turbo stream HTML in one line}:
data: <turbo-stream action='append' target='item-list'> <template> <p>My new Message</p> </template> </turbo-stream>
Express response:
# must be in one line, to conform to EventSource message format.
res.write("data: <turbo-stream action='append'...>")
See example in the /items/actions/stream
route in example-app/app.mjs
.
Payload format is just the HTML in one line.
const ws = new WebSocket('ws://localhost:3000');
ws.on('open', async () => {
ws.send("<turbo-stream><template>My new message</template></turbo-stream>");
return ws.close();
});
See example server at the bottom of example-app/bin/www.mjs
and client in example-app/lib/send-item-ws-message.mjs
.
npm run release
Seems that np
's contents
flag does not work how I expected, and packito
seems to not have publishing working yet,
so the relase will run both packito
and np
without publishing, then delegate to npm publish ./dist
.