hotwire-turbo-express v0.0.8
hotwire-turbo-express
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.

Requirements
- Node 14.x or newer
Installation
npm i hotwire-turbo-expressOverview
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:
HTTP Response Stream
In this scenario, a client submits a form.
- Turbo includes
text/vnd.turbo-stream.htmlin the HTTPAcceptheader. - 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,
Turbodetects theContent-Typeheader, which signals to it to process the above response and update the matching DOM elements.
WebSocket & SSE
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.
Usage
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:
viewandlocalsare the same arguments that would be passed tores.render.streamis an object of which attributes will be added to theturbo-streamHTML element, with the exception ofaction, which will be set to the value matching the append/prepend/replace/update function.onlyFormat- seesendStream
Given the
MessagesControllerrails 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".renderViewsproviders 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:
viewandlocalsaccept the same arguments that would be passed tores.render.streamis an object of which attributes will be added to theturbo-streamHTML 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 thedataattribute. 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 asrenderViewbut returns the compiled HTML fragment instead of sending it to the client.compileView- Same ascompileViewsbut 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'sAcceptHTTP 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();
});
};example-app
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:

Setup and Run
# builds the NPM, installs it in the app
npm run example:setup
# calls npm start in the app
npm run example:startBrowse to http://localhost:3000
Turbo Stream Protocol Notes
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.
Server Sent Events (SSE) Payload Format
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.
WebSocket Payload Format
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.
Development
Publishing
npm run releaseSeems 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.