├── .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 |
12 | {/if}
13 |
14 |
--------------------------------------------------------------------------------
/example/views/message.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 | {message.user}: {message.text}
7 |
8 |
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 |
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 | [](https://github.com/fastify/fastify-hotwire/actions/workflows/ci.yml)
4 | [](https://www.npmjs.com/package/@fastify/hotwire)
5 | [](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 |
62 | ${content}
63 |
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(), ` foobar: hello world
`)
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, ` foobar: hello world
`)
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 |
--------------------------------------------------------------------------------