├── .npmrc ├── .gitattributes ├── eslint.config.js ├── example ├── views │ ├── toast.svelte │ ├── message.svelte │ └── index.svelte ├── worker.js └── app.js ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── types ├── index.test-d.ts └── index.d.ts ├── LICENSE ├── package.json ├── README.md ├── index.js ├── .gitignore └── test └── index.test.js /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically convert line endings 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({ 4 | ignores: require('neostandard').resolveIgnoresFromGitignore(), 5 | ts: true 6 | }) 7 | -------------------------------------------------------------------------------- /example/views/toast.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | {#if text} 7 |

{text}

8 | 9 |
10 | 11 |
12 | {/if} 13 |
14 | -------------------------------------------------------------------------------- /example/views/message.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 |

{message.user}: {message.text}

7 | 8 |
9 | 10 |
11 |
12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /example/worker.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | require('svelte/register') 4 | 5 | module.exports = ({ file, data, fragment }) => { 6 | const App = require(file).default 7 | const { head, css, html } = App.render(data) 8 | if (fragment) { 9 | return html 10 | } else { 11 | return buildHtmlPage(head, css, html) 12 | } 13 | } 14 | 15 | function buildHtmlPage (head, css, html) { 16 | return ` 17 | 18 | 19 | ${head} 20 | ${css.code} 21 | 22 | 23 | ${html} 24 | 25 | ` 26 | } 27 | -------------------------------------------------------------------------------- /types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import fastify, { FastifyInstance } from 'fastify' 2 | import hotwire from '..' 3 | import { join } from 'node:path' 4 | import { expectType } from 'tsd' 5 | 6 | const app: FastifyInstance = fastify() 7 | app.register(hotwire, { 8 | templates: join(__dirname, 'example', 'views'), 9 | filename: join(__dirname, 'example', 'worker.js') 10 | }) 11 | 12 | app.get('/stream', async (_req, reply) => { 13 | return reply.turboStream.append('file', 'target', { hello: 'world' }) 14 | }) 15 | 16 | app.get('/generate', async (_req, reply) => { 17 | const fragment = await reply.turboGenerate.append('file', 'target', { hello: 'world' }) 18 | expectType(fragment) 19 | }) 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | - 'v*' 9 | paths-ignore: 10 | - 'docs/**' 11 | - '*.md' 12 | pull_request: 13 | paths-ignore: 14 | - 'docs/**' 15 | - '*.md' 16 | 17 | # This allows a subsequently queued workflow run to interrupt previous runs 18 | concurrency: 19 | group: "${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" 20 | cancel-in-progress: true 21 | 22 | permissions: 23 | contents: read 24 | 25 | jobs: 26 | test: 27 | permissions: 28 | contents: write 29 | pull-requests: write 30 | uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 31 | with: 32 | license-check: true 33 | lint: true 34 | -------------------------------------------------------------------------------- /example/views/index.svelte: -------------------------------------------------------------------------------- 1 | 2 | demo app 3 | 4 | 7 | 8 | 9 | 16 | 17 |
18 |

Welcome, {username}

19 | 20 |

Current messages

21 |
22 | {#each messages as message} 23 | 24 | {/each} 25 |
26 | 27 | 28 |
29 | 30 | 31 |
32 |
33 |
34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-present Tomas Della Vedova 4 | Copyright (c) 2021-present The Fastify team 5 | 6 | The Fastify team members are listed at https://github.com/fastify/fastify#team. 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { FastifyPluginAsync } from 'fastify' 4 | 5 | declare module 'fastify' { 6 | interface FastifyReply { 7 | render(file: string, data: any): FastifyReply 8 | 9 | turboGenerate: { 10 | append(file: string, target: string, data: any): Promise 11 | prepend(file: string, target: string, data: any): Promise 12 | replace(file: string, target: string, data: any): Promise 13 | update(file: string, target: string, data: any): Promise 14 | remove(file: string, target: string, data: any): Promise 15 | } 16 | 17 | turboStream: { 18 | append(file: string, target: string, data: any): FastifyReply 19 | prepend(file: string, target: string, data: any): FastifyReply 20 | replace(file: string, target: string, data: any): FastifyReply 21 | update(file: string, target: string, data: any): FastifyReply 22 | remove(file: string, target: string, data: any): FastifyReply 23 | } 24 | } 25 | } 26 | 27 | type FastifyHotwire = FastifyPluginAsync> 28 | 29 | declare namespace fastifyHotwire { 30 | export interface FastifyHotwireOptions { 31 | templates: string 32 | filename: string 33 | } 34 | 35 | export const fastifyHotwire: FastifyHotwire 36 | export { fastifyHotwire as default } 37 | } 38 | 39 | declare function fastifyHotwire (...params: Parameters): ReturnType 40 | export = fastifyHotwire 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fastify/hotwire", 3 | "version": "3.0.2", 4 | "description": "Use the Hotwire pattern with Fastify", 5 | "main": "index.js", 6 | "type": "commonjs", 7 | "types": "types/index.d.ts", 8 | "scripts": { 9 | "demo": "node example/app.js", 10 | "lint": "eslint", 11 | "lint:fix": "eslint --fix", 12 | "test": "npm run test:unit && npm run test:typescript", 13 | "test:typescript": "tsd", 14 | "test:unit": "c8 -100 node --test" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/fastify/fastify-hotwire.git" 19 | }, 20 | "keywords": [ 21 | "fastify", 22 | "hotwire", 23 | "turbo", 24 | "html", 25 | "websocket" 26 | ], 27 | "author": "Tomas Della Vedova", 28 | "contributors": [ 29 | { 30 | "name": "James Sumners", 31 | "url": "https://james.sumners.info" 32 | }, 33 | { 34 | "name": "Manuel Spigolon", 35 | "email": "behemoth89@gmail.com" 36 | }, 37 | { 38 | "name": "Aras Abbasi", 39 | "email": "aras.abbasi@gmail.com" 40 | }, 41 | { 42 | "name": "Frazer Smith", 43 | "email": "frazer.dev@icloud.com", 44 | "url": "https://github.com/fdawgs" 45 | } 46 | ], 47 | "license": "MIT", 48 | "bugs": { 49 | "url": "https://github.com/fastify/fastify-hotwire/issues" 50 | }, 51 | "homepage": "https://github.com/fastify/fastify-hotwire#readme", 52 | "funding": [ 53 | { 54 | "type": "github", 55 | "url": "https://github.com/sponsors/fastify" 56 | }, 57 | { 58 | "type": "opencollective", 59 | "url": "https://opencollective.com/fastify" 60 | } 61 | ], 62 | "dependencies": { 63 | "fastify-plugin": "^5.0.0", 64 | "piscina": "^4.0.0" 65 | }, 66 | "devDependencies": { 67 | "@fastify/cookie": "^11.0.1", 68 | "@fastify/formbody": "^8.0.0", 69 | "@fastify/websocket": "^11.0.1", 70 | "@hotwired/turbo": "^8.0.10", 71 | "@types/node": "^24.0.8", 72 | "c8": "^10.1.2", 73 | "eslint": "^9.17.0", 74 | "fastify": "^5.0.0", 75 | "mqemitter": "^7.0.0", 76 | "neostandard": "^0.12.0", 77 | "pino-pretty": "^13.0.0", 78 | "shortid": "^2.2.16", 79 | "superheroes": "^4.0.0", 80 | "svelte": "^3.44.0", 81 | "tsd": "^0.33.0" 82 | }, 83 | "publishConfig": { 84 | "access": "public" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @fastify/hotwire 2 | 3 | [![CI](https://github.com/fastify/fastify-hotwire/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/fastify-hotwire/actions/workflows/ci.yml) 4 | [![NPM version](https://img.shields.io/npm/v/@fastify/hotwire.svg?style=flat)](https://www.npmjs.com/package/@fastify/hotwire) 5 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) 6 | 7 | Do you enjoy writing applications with the [hotwire](http://hotwire.dev) pattern? 8 | We have got you covered! 9 | 10 | This plugin adds all the necessary utilities to Fastify for creating a fullstack application 11 | with Hotwire. Take a look at the [example](./example) folder to see it in action! 12 | 13 | ## Install 14 | 15 | ``` 16 | npm i @fastify/hotwire 17 | ``` 18 | 19 | ## Usage 20 | 21 | Add the plugin to Fastify, with at least two options: 22 | 23 | - `templates`: the location of your folder with your templates 24 | - `filename`: the location of your HTML generator, any templating language is supported! 25 | 26 | ```js 27 | // in your fastify app 28 | fastify.register(require('@fastify/hotwire'), { 29 | templates: join(__dirname, 'views'), 30 | filename: join(__dirname, 'worker.js') 31 | }) 32 | ``` 33 | 34 | ```js 35 | // worker.js 36 | module.exports = ({ file, data, fragment }) => { 37 | // your favorite templating library 38 | return 'generated html' 39 | } 40 | ``` 41 | 42 | ## API 43 | 44 | ### `reply.render(filename, data)` 45 | 46 | Generates the entire initial page, it calls the worker with `fragment: false` 47 | 48 | ```js 49 | fastify.get('/', async (req, reply) => { 50 | return reply.render('filename', { data }) 51 | }) 52 | ``` 53 | 54 | ### `reply.turboGenerate.*(filename, target, data)` 55 | 56 | Every turbo stream action is supported: `append`, `prepend`, `replace`, `update`, `remove`. 57 | It generates and returns a turbo compatible fragment. 58 | 59 | ```js 60 | fastify.get('/', async (req, reply) => { 61 | const fragment = await reply.turboGenerate.append('filename', 'target', { data }) 62 | // send it via SSE or websockets 63 | }) 64 | ``` 65 | 66 | ### `reply.turboStream.*(filename, target, data)` 67 | 68 | Every turbo stream action is supported: `append`, `prepend`, `replace`, `update`, `remove`. 69 | It generates and send a turbo compatible fragment and configures the appropriate content type. 70 | 71 | ```js 72 | fastify.get('/', async (req, reply) => { 73 | return reply.turboStream.append('filename', 'target', { data }) 74 | }) 75 | ``` 76 | 77 | ## License 78 | 79 | Licensed under [MIT](./LICENSE). 80 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { join } = require('node:path') 4 | const fp = require('fastify-plugin') 5 | const Piscina = require('piscina') 6 | 7 | async function fastifyHotwire (fastify, opts) { 8 | const { templates } = opts 9 | delete opts.templates 10 | 11 | const pool = new Piscina(opts) 12 | 13 | fastify.decorateReply('render', render) 14 | fastify.decorateReply('turboStream', { 15 | getter () { 16 | return { 17 | append: (file, target, data) => turboSend(this, 'append', file, target, data), 18 | prepend: (file, target, data) => turboSend(this, 'prepend', file, target, data), 19 | replace: (file, target, data) => turboSend(this, 'replace', file, target, data), 20 | update: (file, target, data) => turboSend(this, 'update', file, target, data), 21 | remove: (file, target, data) => turboSend(this, 'remove', file, target, data) 22 | } 23 | } 24 | }) 25 | fastify.decorateReply('turboGenerate', { 26 | getter () { 27 | return { 28 | append: (file, target, data) => generate(this, 'append', file, target, data), 29 | prepend: (file, target, data) => generate(this, 'prepend', file, target, data), 30 | replace: (file, target, data) => generate(this, 'replace', file, target, data), 31 | update: (file, target, data) => generate(this, 'update', file, target, data), 32 | remove: (file, target, data) => generate(this, 'remove', file, target, data) 33 | } 34 | } 35 | }) 36 | 37 | async function render (file, data) { 38 | file = join(templates, file) 39 | const html = await pool.runTask({ file, data, fragment: false }) 40 | this.type('text/html; charset=utf-8') 41 | this.send(html) 42 | return this 43 | } 44 | 45 | async function turboSend (that, action, file, target, data) { 46 | const html = await pool.runTask({ file: join(templates, file), data, fragment: true }) 47 | that.type('text/vnd.turbo-stream.html; charset=utf-8') 48 | that.send(buildStream(action, target, html.trim())) 49 | return that 50 | } 51 | 52 | async function generate (_that, action, file, target, data) { 53 | const html = await pool.runTask({ file: join(templates, file), data, fragment: true }) 54 | return buildStream(action, target, html).replace(/\n/g, '').trim() 55 | } 56 | } 57 | 58 | function buildStream (action, target, content) { 59 | return ` 60 | 61 | 64 | 65 | ` 66 | } 67 | 68 | module.exports = fp(fastifyHotwire, { 69 | name: '@fastify/hotwire' 70 | }) 71 | module.exports.default = fastifyHotwire 72 | module.exports.fastifyHotwire = fastifyHotwire 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Vim swap files 133 | *.swp 134 | 135 | # macOS files 136 | .DS_Store 137 | 138 | # Clinic 139 | .clinic 140 | 141 | # lock files 142 | bun.lockb 143 | package-lock.json 144 | pnpm-lock.yaml 145 | yarn.lock 146 | 147 | # editor files 148 | .vscode 149 | .idea 150 | 151 | #tap files 152 | .tap/ 153 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { join } = require('node:path') 4 | const { test } = require('node:test') 5 | const Fastify = require('fastify') 6 | const Hotwire = require('..') 7 | 8 | test('Should render the entire page', async t => { 9 | const fastify = Fastify() 10 | await fastify.register(Hotwire, { 11 | templates: join(__dirname, '..', 'example', 'views'), 12 | filename: join(__dirname, '..', 'example', 'worker.js') 13 | }) 14 | 15 | fastify.get('/', async (_req, reply) => { 16 | return reply.render('index.svelte', { messages: [], username: 'foobar' }) 17 | }) 18 | 19 | const response = await fastify.inject({ 20 | method: 'GET', 21 | path: '/' 22 | }) 23 | 24 | t.assert.strictEqual(response.statusCode, 200) 25 | t.assert.strictEqual(response.headers['content-type'], 'text/html; charset=utf-8') 26 | t.assert.ok(response.payload.includes('foobar')) 27 | }) 28 | 29 | function runTurboStream (action) { 30 | test(`Should return a turbo fragment (${action})`, async t => { 31 | const fastify = Fastify() 32 | await fastify.register(Hotwire, { 33 | templates: join(__dirname, '..', 'example', 'views'), 34 | filename: join(__dirname, '..', 'example', 'worker.js') 35 | }) 36 | 37 | fastify.get('/', async (_req, reply) => { 38 | return reply.turboStream[action]( 39 | 'message.svelte', 40 | 'messages', 41 | { 42 | message: { 43 | id: 'unique', 44 | text: 'hello world', 45 | user: 'foobar' 46 | } 47 | } 48 | ) 49 | }) 50 | 51 | const response = await fastify.inject({ 52 | method: 'GET', 53 | path: '/' 54 | }) 55 | 56 | t.assert.strictEqual(response.statusCode, 200) 57 | t.assert.strictEqual(response.headers['content-type'], 'text/vnd.turbo-stream.html; charset=utf-8') 58 | t.assert.strictEqual(response.payload.replace(/\n/g, '').trim(), ` `) 59 | }) 60 | } 61 | 62 | function runTurboGenerate (action) { 63 | test(`Should generate a turbo fragment (${action})`, async t => { 64 | const fastify = Fastify() 65 | await fastify.register(Hotwire, { 66 | templates: join(__dirname, '..', 'example', 'views'), 67 | filename: join(__dirname, '..', 'example', 'worker.js') 68 | }) 69 | 70 | fastify.get('/', async (_req, reply) => { 71 | reply.type('text/plain') 72 | return reply.turboGenerate[action]( 73 | 'message.svelte', 74 | 'messages', 75 | { 76 | message: { 77 | id: 'unique', 78 | text: 'hello world', 79 | user: 'foobar' 80 | } 81 | } 82 | ) 83 | }) 84 | 85 | const response = await fastify.inject({ 86 | method: 'GET', 87 | path: '/' 88 | }) 89 | 90 | t.assert.strictEqual(response.statusCode, 200) 91 | t.assert.strictEqual(response.headers['content-type'], 'text/plain') 92 | t.assert.strictEqual(response.payload, ` `) 93 | }) 94 | } 95 | 96 | const actions = ['append', 'prepend', 'replace', 'update', 'remove'] 97 | for (const action of actions) { 98 | runTurboStream(action) 99 | runTurboGenerate(action) 100 | } 101 | -------------------------------------------------------------------------------- /example/app.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { join } = require('node:path') 4 | const fastify = require('fastify')({ 5 | logger: { 6 | transport: { 7 | target: 'pino-pretty', 8 | options: { 9 | colorize: true 10 | } 11 | } 12 | } 13 | }) 14 | const superheroes = require('superheroes') 15 | const shortid = require('shortid') 16 | // you can use any queue system for delivering a message 17 | // across multiple server instances, see http://npm.im/mqemitter. 18 | const mq = require('mqemitter') 19 | const emitter = mq({ concurrency: 5 }) 20 | 21 | // our database 22 | const db = new Map() 23 | 24 | fastify 25 | // remember to configure the content security policy as well! 26 | .decorateRequest('user', null) 27 | .register(require('@fastify/cookie'), { secret: 'supersecret' }) 28 | .register(require('@fastify/formbody')) 29 | .register(require('@fastify/websocket'), { clientTracking: true }) 30 | .register(require('..'), { 31 | templates: join(__dirname, 'views'), 32 | filename: join(__dirname, 'worker.js') 33 | }) 34 | 35 | // every time we have a new message, let's broadcast it 36 | emitter.on('new-message', (message, _cb) => { 37 | for (const socket of fastify.websocketServer.clients.values()) { 38 | socket.send(message.payload) 39 | } 40 | }) 41 | 42 | emitter.on('delete-message', (message, _cb) => { 43 | for (const socket of fastify.websocketServer.clients.values()) { 44 | socket.send(message.payload) 45 | } 46 | }) 47 | 48 | // render the initial page and populate it 49 | // with the current content 50 | fastify.get('/', async (_req, reply) => { 51 | const messages = [] 52 | for (const [id, message] of db.entries()) { 53 | messages.push({ id, text: message.text, user: message.user }) 54 | } 55 | 56 | // generate the user 57 | const username = `${superheroes.random()}-${shortid.generate()}` 58 | reply.setCookie('user', username, { 59 | secure: process.env.NODE_ENV === 'production', 60 | httpOnly: true, 61 | sameSite: true, 62 | path: '/', 63 | signed: true 64 | }) 65 | 66 | return reply.render('index.svelte', { messages, username }) 67 | }) 68 | 69 | // post a new message! 70 | fastify.route({ 71 | method: 'POST', 72 | path: '/message', 73 | onRequest: authorize, 74 | handler: onCreateMessage 75 | }) 76 | 77 | async function onCreateMessage (req, reply) { 78 | const id = shortid.generate() 79 | req.log.info(`creating new message with id ${id} from user ${req.user}`) 80 | 81 | db.set(id, { 82 | text: req.body.content, 83 | user: req.user 84 | }) 85 | const payload = await reply.turboGenerate.append( 86 | 'message.svelte', 87 | 'messages', 88 | { 89 | message: { 90 | id, 91 | text: req.body.content, 92 | user: req.user 93 | } 94 | } 95 | ) 96 | 97 | emitter.emit({ 98 | topic: 'new-message', 99 | payload 100 | }) 101 | 102 | return { acknowledged: true } 103 | } 104 | 105 | // delete a message, users can only delete 106 | // their own messages 107 | fastify.route({ 108 | method: 'POST', 109 | path: '/message/:id/delete', 110 | onRequest: authorize, 111 | handler: onDeleteMessage 112 | }) 113 | 114 | async function onDeleteMessage (req, reply) { 115 | // in production ensure to validate or sanitize the user provided id 116 | const { id } = req.params 117 | req.log.info(`deleting message ${id}`) 118 | if (!db.has(id)) { 119 | return reply.turboStream.replace( 120 | 'toast.svelte', 121 | 'toast', 122 | { text: `The message with id ${id} does not exists` } 123 | ) 124 | } 125 | 126 | const message = db.get(id) 127 | if (message.user !== req.user) { 128 | return reply.turboStream.replace( 129 | 'toast.svelte', 130 | 'toast', 131 | { text: 'You can\'t delete a message from another user' } 132 | ) 133 | } 134 | 135 | db.delete(id) 136 | 137 | const payload = await reply.turboGenerate.remove( 138 | 'message.svelte', 139 | `message_frame_${id}`, 140 | { message: { id } } 141 | ) 142 | 143 | emitter.emit({ 144 | topic: 'delete-message', 145 | payload 146 | }) 147 | 148 | return { acknowledged: true } 149 | } 150 | 151 | // remove a toast 152 | fastify.route({ 153 | method: 'POST', 154 | path: '/toast/ack', 155 | onRequest: authorize, 156 | handler: onAckToast 157 | }) 158 | 159 | async function onAckToast (_req, reply) { 160 | return reply.turboStream.remove('toast.svelte', 'toast') 161 | } 162 | 163 | // websocket handler used by turbo for handling realtime communications 164 | fastify.get('/ws', { websocket: true }, (_connection, req) => { 165 | req.log.info('new websocket connection') 166 | }) 167 | 168 | // authenticate client requests 169 | async function authorize (req, reply) { 170 | const { user } = req.cookies 171 | if (!user) { 172 | reply.code(401) 173 | throw new Error('Missing session cookie') 174 | } 175 | 176 | const cookie = req.unsignCookie(user) 177 | if (!cookie.valid) { 178 | reply.code(401) 179 | throw new Error('Invalid cookie signature') 180 | } 181 | 182 | req.user = cookie.value 183 | } 184 | 185 | fastify.listen({ port: 3000 }, console.log) 186 | --------------------------------------------------------------------------------