Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 29 additions & 26 deletions example/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const fastify = require('fastify')({
}
}
})
const superheroes = require('superheroes')
const { randomSuperhero } = require('superheroes')
const shortid = require('shortid')
// you can use any queue system for delivering a message
// across multiple server instances, see https://www.npmjs.com/package/mqemitter.
Expand Down Expand Up @@ -47,21 +47,27 @@ emitter.on('delete-message', (message, _cb) => {

// render the initial page and populate it
// with the current content
fastify.get('/', async (_req, reply) => {
fastify.get('/', async (req, reply) => {
const messages = []
for (const [id, message] of db.entries()) {
messages.push({ id, text: message.text, user: message.user })
}

// generate the user
const username = `${superheroes.random()}-${shortid.generate()}`
reply.setCookie('user', username, {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: true,
path: '/',
signed: true
})
const existing = req.cookies.user && req.unsignCookie(req.cookies.user)
const username =
existing && existing.valid
? existing.value
: `${randomSuperhero()}-${shortid.generate()}`

if (!existing || !existing.valid) {
reply.setCookie('user', username, {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: true,
path: '/',
signed: true
})
}

return reply.render('index.svelte', { messages, username })
})
Expand Down Expand Up @@ -99,7 +105,7 @@ async function onCreateMessage (req, reply) {
payload
})

return { acknowledged: true }
return reply.redirect('/')
}

// delete a message, users can only delete
Expand All @@ -116,20 +122,16 @@ async function onDeleteMessage (req, reply) {
const { id } = req.params
req.log.info(`deleting message ${id}`)
if (!db.has(id)) {
return reply.turboStream.replace(
'toast.svelte',
'toast',
{ text: `The message with id ${id} does not exists` }
)
return reply.turboStream.replace('toast.svelte', 'toast', {
text: `The message with id ${id} does not exists`
})
}

const message = db.get(id)
if (message.user !== req.user) {
return reply.turboStream.replace(
'toast.svelte',
'toast',
{ text: 'You can\'t delete a message from another user' }
)
return reply.turboStream.replace('toast.svelte', 'toast', {
text: "You can't delete a message from another user"
})
}

db.delete(id)
Expand Down Expand Up @@ -160,9 +162,10 @@ async function onAckToast (_req, reply) {
return reply.turboStream.remove('toast.svelte', 'toast')
}

// websocket handler used by turbo for handling realtime communications
fastify.get('/ws', { websocket: true }, (_connection, req) => {
req.log.info('new websocket connection')
fastify.register(async function (fastify) {
fastify.get('/ws', { websocket: true }, (_socket, req) => {
req.log.info('new websocket connection')
})
})

// authenticate client requests
Expand All @@ -182,4 +185,4 @@ async function authorize (req, reply) {
req.user = cookie.value
}

fastify.listen({ port: 3000 }, console.log)
fastify.listen({ port: 3000 })
26 changes: 26 additions & 0 deletions example/svelte-loader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { compile } from 'svelte/compiler'

// Svelte 5 dropped the `svelte/register` CJS require-hook and its compiler
// only emits ESM, so `.svelte` files can no longer be `require()`-d directly.
// This module customization hook (see Node's `module.register()`) compiles
// `.svelte` files to server-side JS on the fly, keeping their original file
// URLs so relative imports between components (e.g. `./message.svelte`)
// keep resolving normally.
export function load (url, context, nextLoad) {
const [bareUrl] = url.split('?')
if (!bareUrl.endsWith('.svelte')) {
return nextLoad(url, context)
}

const filename = fileURLToPath(bareUrl)
const source = readFileSync(filename, 'utf8')
const { js } = compile(source, { filename, generate: 'server' })

return {
format: 'module',
source: js.code,
shortCircuit: true
}
}
53 changes: 46 additions & 7 deletions example/worker.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,62 @@
'use strict'

require('svelte/register')
const { readFileSync, statSync } = require('node:fs')
const { register } = require('node:module')
const { pathToFileURL } = require('node:url')
const { compile } = require('svelte/compiler')
const { render } = require('svelte/server')

// Compiles .svelte files to server-side JS on the fly (see svelte-loader.mjs
// for why this is needed under Svelte 5), then lets Node's normal ESM
// resolution take over so relative imports between components keep working.
register('./svelte-loader.mjs', pathToFileURL(__filename))

// Svelte 5's SSR output wraps blocks in hydration marker comments
// (e.g. `<!--[-->`, `<!--]-->`, `<!--[0-->`). They're only useful when the
// same component is later hydrated client-side; since Hotwire only ever
// swaps static HTML via Turbo, we strip them out.
const HYDRATION_MARKERS = /<!--\[-?\d*-->|<!--\]-->/g

const cache = new Map()

async function loadComponent (file) {
const { mtimeMs } = statSync(file)
const cached = cache.get(file)
if (cached && cached.mtimeMs === mtimeMs) {
return cached
}

// dynamic import is cached by Node per URL, so bust it with a query
// string whenever the file changes on disk
const url = `${pathToFileURL(file).href}?v=${mtimeMs}`
const { default: Component } = await import(url)

const source = readFileSync(file, 'utf8')
Comment thread
Tony133 marked this conversation as resolved.
Dismissed
const { css } = compile(source, { filename: file, generate: 'server' })

const entry = { Component, css: css?.code ?? '', mtimeMs }
cache.set(file, entry)
return entry
}

module.exports = async ({ file, data, fragment }) => {
const { Component, css } = await loadComponent(file)
const { head, body } = render(Component, { props: data })
const html = body.replace(HYDRATION_MARKERS, '')
Comment thread
Tony133 marked this conversation as resolved.
Dismissed

module.exports = ({ file, data, fragment }) => {
const App = require(file).default
const { head, css, html } = App.render(data)
if (fragment) {
return html
} else {
return buildHtmlPage(head, css, html)
}

return buildHtmlPage(head, css, html)
}

function buildHtmlPage (head, css, html) {
return `<!DOCTYPE html>
<html lang="en">
<head>
${head}
${css.code}
<style>${css}</style>
</head>
<body>
${html}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
"pino-pretty": "^13.0.0",
"shortid": "^2.2.16",
"superheroes": "^4.0.0",
"svelte": "^3.44.0",
"svelte": "^5.0.0",
"tstyche": "^7.0.0"
},
"publishConfig": {
Expand Down
58 changes: 31 additions & 27 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const { test } = require('node:test')
const Fastify = require('fastify')
const Hotwire = require('..')

test('Should render the entire page', async t => {
test('Should render the entire page', async (t) => {
const fastify = Fastify()
await fastify.register(Hotwire, {
templates: join(__dirname, '..', 'example', 'views'),
Expand All @@ -22,30 +22,29 @@ test('Should render the entire page', async t => {
})

t.assert.strictEqual(response.statusCode, 200)
t.assert.strictEqual(response.headers['content-type'], 'text/html; charset=utf-8')
t.assert.strictEqual(
response.headers['content-type'],
'text/html; charset=utf-8'
)
t.assert.ok(response.payload.includes('foobar'))
})

function runTurboStream (action) {
test(`Should return a turbo fragment (${action})`, async t => {
test(`Should return a turbo fragment (${action})`, async (t) => {
const fastify = Fastify()
await fastify.register(Hotwire, {
templates: join(__dirname, '..', 'example', 'views'),
filename: join(__dirname, '..', 'example', 'worker.js')
})

fastify.get('/', async (_req, reply) => {
return reply.turboStream[action](
'message.svelte',
'messages',
{
message: {
id: 'unique',
text: 'hello world',
user: 'foobar'
}
return reply.turboStream[action]('message.svelte', 'messages', {
message: {
id: 'unique',
text: 'hello world',
user: 'foobar'
}
)
})
})

const response = await fastify.inject({
Expand All @@ -54,13 +53,19 @@ function runTurboStream (action) {
})

t.assert.strictEqual(response.statusCode, 200)
t.assert.strictEqual(response.headers['content-type'], 'text/vnd.turbo-stream.html; charset=utf-8')
t.assert.strictEqual(response.payload.replace(/\n/g, '').trim(), `<turbo-stream action="${action}" target="messages"> <template> <turbo-frame id="message_frame_unique"><p><strong>foobar:</strong> hello world</p> <form action="/message/unique/delete" method="POST"><button type="submit">Remove</button></form></turbo-frame> </template> </turbo-stream>`)
t.assert.strictEqual(
response.headers['content-type'],
'text/vnd.turbo-stream.html; charset=utf-8'
)
t.assert.strictEqual(
response.payload.replace(/\n/g, '').trim(),
`<turbo-stream action="${action}" target="messages"> <template> <turbo-frame id="message_frame_unique"><p><strong>foobar:</strong> hello world</p> <form action="/message/unique/delete" method="POST"><button type="submit">Remove</button></form></turbo-frame> </template> </turbo-stream>`
)
})
}

function runTurboGenerate (action) {
test(`Should generate a turbo fragment (${action})`, async t => {
test(`Should generate a turbo fragment (${action})`, async (t) => {
const fastify = Fastify()
await fastify.register(Hotwire, {
templates: join(__dirname, '..', 'example', 'views'),
Expand All @@ -69,17 +74,13 @@ function runTurboGenerate (action) {

fastify.get('/', async (_req, reply) => {
reply.type('text/plain')
return reply.turboGenerate[action](
'message.svelte',
'messages',
{
message: {
id: 'unique',
text: 'hello world',
user: 'foobar'
}
return reply.turboGenerate[action]('message.svelte', 'messages', {
message: {
id: 'unique',
text: 'hello world',
user: 'foobar'
}
)
})
})

const response = await fastify.inject({
Expand All @@ -89,7 +90,10 @@ function runTurboGenerate (action) {

t.assert.strictEqual(response.statusCode, 200)
t.assert.strictEqual(response.headers['content-type'], 'text/plain')
t.assert.strictEqual(response.payload, `<turbo-stream action="${action}" target="messages"> <template> <turbo-frame id="message_frame_unique"><p><strong>foobar:</strong> hello world</p> <form action="/message/unique/delete" method="POST"><button type="submit">Remove</button></form></turbo-frame> </template> </turbo-stream>`)
t.assert.strictEqual(
response.payload,
`<turbo-stream action="${action}" target="messages"> <template> <turbo-frame id="message_frame_unique"><p><strong>foobar:</strong> hello world</p> <form action="/message/unique/delete" method="POST"><button type="submit">Remove</button></form></turbo-frame> </template> </turbo-stream>`
)
})
}

Expand Down
Loading