diff --git a/example/app.js b/example/app.js index f51b2b1..71fd98e 100644 --- a/example/app.js +++ b/example/app.js @@ -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. @@ -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 }) }) @@ -99,7 +105,7 @@ async function onCreateMessage (req, reply) { payload }) - return { acknowledged: true } + return reply.redirect('/') } // delete a message, users can only delete @@ -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) @@ -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 @@ -182,4 +185,4 @@ async function authorize (req, reply) { req.user = cookie.value } -fastify.listen({ port: 3000 }, console.log) +fastify.listen({ port: 3000 }) diff --git a/example/svelte-loader.mjs b/example/svelte-loader.mjs new file mode 100644 index 0000000..cf8ebd7 --- /dev/null +++ b/example/svelte-loader.mjs @@ -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 + } +} diff --git a/example/worker.js b/example/worker.js index a584c93..1a5527d 100644 --- a/example/worker.js +++ b/example/worker.js @@ -1,15 +1,54 @@ '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. ``, ``, ``). 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 = /|/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') + 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, '') -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) { @@ -17,7 +56,7 @@ function buildHtmlPage (head, css, html) { ${head} - ${css.code} + ${html} diff --git a/package.json b/package.json index 826a7b1..c64de55 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/test/index.test.js b/test/index.test.js index c7bae5b..8ec4e42 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -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'), @@ -22,12 +22,15 @@ 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'), @@ -35,17 +38,13 @@ function runTurboStream (action) { }) 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({ @@ -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(), ` `) + t.assert.strictEqual( + response.headers['content-type'], + 'text/vnd.turbo-stream.html; charset=utf-8' + ) + t.assert.strictEqual( + response.payload.replace(/\n/g, '').trim(), + ` ` + ) }) } 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'), @@ -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({ @@ -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, ` `) + t.assert.strictEqual( + response.payload, + ` ` + ) }) }