diff --git a/.env.example b/.env.example
index d6f44897..4c60ca67 100644
--- a/.env.example
+++ b/.env.example
@@ -12,7 +12,9 @@ MYSQL_PASSWORD=test_password
# Server
FASTIFY_CLOSE_GRACE_DELAY=1000
LOG_LEVEL=info
+FASTIFY_VITE_DEV_MODE=false
# Security
JWT_SECRET=
-RATE_LIMIT_MAX=
+RATE_LIMIT_MAX=4 # For tests
+
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8e040d3f..c781c3ef 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -50,6 +50,9 @@ jobs:
- name: Lint Code
run: npm run lint
+ - name: Build Vite
+ run: npm run build:client
+
- name: Generate JWT Secret
id: gen-jwt
run: |
@@ -61,11 +64,12 @@ jobs:
- name: Test
env:
+ # JWT_SECRET is dynamically generated and loaded from the environment
MYSQL_HOST: localhost
MYSQL_PORT: 3306
MYSQL_DATABASE: test_db
MYSQL_USER: test_user
MYSQL_PASSWORD: test_password
- # JWT_SECRET is dynamically generated and loaded from the environment
RATE_LIMIT_MAX: 4
+ FASTIFY_VITE_DEV_MODE: false
run: npm run db:migrate && npm run test
diff --git a/README.md b/README.md
index d5301a6e..5acefc0c 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,9 @@ Install the dependencies:
npm install
```
+Configure a `.env` file, see [.env.example](/.env.example) at root of the project.
+
+
### Database
You can run a MySQL instance with Docker:
```bash
diff --git a/client/App.tsx b/client/App.tsx
new file mode 100644
index 00000000..f5a76610
--- /dev/null
+++ b/client/App.tsx
@@ -0,0 +1,5 @@
+export function App () {
+ return (
+
Welcome to the official Fastify demo!
+ )
+}
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 00000000..ce2def89
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Fastify demo
+
+
+
+
+
+
diff --git a/client/mount.tsx b/client/mount.tsx
new file mode 100644
index 00000000..fd7d2078
--- /dev/null
+++ b/client/mount.tsx
@@ -0,0 +1,7 @@
+import { createRoot } from 'react-dom/client'
+import { App } from './App'
+
+const rootElement = document.getElementById('root')!
+
+const root = createRoot(rootElement)
+root.render()
diff --git a/client/public/favicon.ico b/client/public/favicon.ico
new file mode 100644
index 00000000..d0d9b4a3
Binary files /dev/null and b/client/public/favicon.ico differ
diff --git a/client/tsconfig.json b/client/tsconfig.json
new file mode 100644
index 00000000..cb394505
--- /dev/null
+++ b/client/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["**/*"],
+}
diff --git a/client/vite-env.d.ts b/client/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/client/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/docker-compose.yml b/docker-compose.yml
index 10830fc3..69bdfc7c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,13 +2,19 @@ services:
db:
image: mysql:8.4
environment:
- MYSQL_DATABASE: ${MYSQL_DATABASE}
- MYSQL_USER: ${MYSQL_USER}
- MYSQL_PASSWORD: ${MYSQL_PASSWORD}
+ MYSQL_ROOT_PASSWORD: root_password
+ MYSQL_DATABASE: ${MYSQL_DATABASE}
+ MYSQL_USER: ${MYSQL_USER}
+ MYSQL_PASSWORD: ${MYSQL_PASSWORD}
ports:
- 3306:3306
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-u${MYSQL_USER}", "-p${MYSQL_PASSWORD}"]
+ interval: 10s
+ timeout: 5s
+ retries: 3
volumes:
- db_data:/var/lib/mysql
-
+
volumes:
db_data:
diff --git a/package.json b/package.json
index 8384ea4f..9b8564d1 100644
--- a/package.json
+++ b/package.json
@@ -8,14 +8,15 @@
"test": "test"
},
"scripts": {
- "start": "npm run build && fastify start -l info dist/app.js",
+ "start": "npm run build && npm run build:client && fastify start -l info dist/app.js",
"build": "tsc",
+ "build:client": "vite build",
"watch": "tsc -w",
"dev": "npm run build && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"npm:watch\" \"npm:dev:start\"",
- "dev:start": "fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js",
+ "dev:start": "fastify start --ignore-watch=.ts$ -l info -P dist/app.js",
"test": "npm run db:seed && tap --jobs=1 test/**/*",
"standalone": "node --env-file=.env dist/server.js",
- "lint": "eslint --ignore-pattern=dist",
+ "lint": "eslint test src client vite.config.js",
"lint:fix": "npm run lint -- --fix",
"db:migrate": "node --env-file=.env scripts/migrate.js",
"db:seed": "node --env-file=.env scripts/seed-database.js"
@@ -36,12 +37,16 @@
"@fastify/swagger-ui": "^5.0.1",
"@fastify/type-provider-typebox": "^5.0.0",
"@fastify/under-pressure": "^9.0.1",
+ "@fastify/vite": "^7.0.1",
"@sinclair/typebox": "^0.33.12",
+ "@vitejs/plugin-react": "^4.3.1",
"concurrently": "^9.0.1",
"fastify": "^5.0.0",
"fastify-cli": "^7.0.0",
"fastify-plugin": "^5.0.1",
- "postgrator": "^7.3.0"
+ "postgrator": "^7.3.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.5.5",
diff --git a/src/app.ts b/src/app.ts
index b36f698e..23805d59 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -5,6 +5,7 @@
import path from 'node:path'
import fastifyAutoload from '@fastify/autoload'
import { FastifyInstance, FastifyPluginOptions } from 'fastify'
+import fastifyVite from '@fastify/vite'
export const options = {
ajv: {
@@ -19,29 +20,30 @@ export default async function serviceApp (
fastify: FastifyInstance,
opts: FastifyPluginOptions
) {
- delete opts.skipOverride // This option only serves testing purpose
+ const { registerVite = true } = opts
+
// This loads all external plugins defined in plugins/external
// those should be registered first as your custom plugins might depend on them
await fastify.register(fastifyAutoload, {
dir: path.join(import.meta.dirname, 'plugins/external'),
- options: { ...opts }
+ options: {}
})
// This loads all your custom plugins defined in plugins/custom
// those should be support plugins that are reused
// through your application
- fastify.register(fastifyAutoload, {
+ await fastify.register(fastifyAutoload, {
dir: path.join(import.meta.dirname, 'plugins/custom'),
- options: { ...opts }
+ options: {}
})
// This loads all plugins defined in routes
// define your routes in one of these
- fastify.register(fastifyAutoload, {
+ await fastify.register(fastifyAutoload, {
dir: path.join(import.meta.dirname, 'routes'),
autoHooks: true,
cascadeHooks: true,
- options: { ...opts }
+ options: {}
})
fastify.setErrorHandler((err, request, reply) => {
@@ -58,11 +60,12 @@ export default async function serviceApp (
'Unhandled error occurred'
)
- reply.code(err.statusCode ?? 500)
-
let message = 'Internal Server Error'
- if (err.statusCode === 401) {
- message = err.message
+ const statusCode = err.statusCode ?? 500
+ reply.code(statusCode)
+
+ if (statusCode === 429) {
+ message = 'Rate limit exceeded'
}
return { message }
@@ -92,5 +95,30 @@ export default async function serviceApp (
reply.code(404)
return { message: 'Not Found' }
+ }
+ )
+
+ if (registerVite) {
+ /* c8 ignore start - We don't launch the spa tests with the api tests */
+ // We setup the SPA
+ await fastify.register(fastifyVite, function (fastify) {
+ return {
+ root: path.resolve(import.meta.dirname, '../'),
+ dev: fastify.config.FASTIFY_VITE_DEV_MODE,
+ spa: true
+ }
+ })
+
+ // Route must match vite "base": https://vitejs.dev/config/shared-options.html#base
+ fastify.get('/', (req, reply) => {
+ return reply.html()
})
+
+ await fastify.vite.ready()
+ /* c8 ignore end */
+ } else {
+ fastify.get('/', () => {
+ return 'Vite is not registered.'
+ })
+ }
}
diff --git a/src/plugins/external/env.ts b/src/plugins/external/env.ts
index 68a7f533..55bd5d40 100644
--- a/src/plugins/external/env.ts
+++ b/src/plugins/external/env.ts
@@ -11,6 +11,7 @@ declare module 'fastify' {
MYSQL_DATABASE: string;
JWT_SECRET: string;
RATE_LIMIT_MAX: number;
+ FASTIFY_VITE_DEV_MODE: boolean;
};
}
}
@@ -52,6 +53,12 @@ const schema = {
RATE_LIMIT_MAX: {
type: 'number',
default: 100
+ },
+
+ // Frontend
+ FASTIFY_VITE_DEV_MODE: {
+ type: 'boolean',
+ default: true
}
}
}
diff --git a/src/routes/home.ts b/src/routes/home.ts
deleted file mode 100644
index a3c29513..00000000
--- a/src/routes/home.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import {
- FastifyPluginAsyncTypebox,
- Type
-} from '@fastify/type-provider-typebox'
-
-const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
- fastify.get(
- '/',
- {
- schema: {
- response: {
- 200: Type.Object({
- message: Type.String()
- })
- }
- }
- },
- async function () {
- return { message: 'Welcome to the official fastify demo!' }
- }
- )
-}
-
-export default plugin
diff --git a/test/app/rate-limit.test.ts b/test/app/rate-limit.test.ts
index fd5fc411..0bd8a7e9 100644
--- a/test/app/rate-limit.test.ts
+++ b/test/app/rate-limit.test.ts
@@ -11,6 +11,7 @@ it('should be rate limited', async (t) => {
url: '/'
})
+ assert.equal(res.body, 'Vite is not registered.')
assert.strictEqual(res.statusCode, 200)
}
diff --git a/test/helper.ts b/test/helper.ts
index bc935c5c..eb4eb9a2 100644
--- a/test/helper.ts
+++ b/test/helper.ts
@@ -17,7 +17,8 @@ const AppPath = path.join(import.meta.dirname, '../src/app.ts')
// needed for testing the application
export function config () {
return {
- skipOverride: 'true' // Register our application with fastify-plugin
+ skipOverride: true, // Register our application with fastify-plugin
+ registerVite: false
}
}
diff --git a/test/routes/api/api.test.ts b/test/routes/api/api.test.ts
index a4341d30..3eb758a0 100644
--- a/test/routes/api/api.test.ts
+++ b/test/routes/api/api.test.ts
@@ -9,9 +9,8 @@ test('GET /api without authorization header', async (t) => {
url: '/api'
})
- assert.deepStrictEqual(JSON.parse(res.payload), {
- message: 'No Authorization was found in request.headers'
- })
+ assert.equal(res.statusCode, 401)
+ assert.deepStrictEqual(JSON.parse(res.payload).message, 'No Authorization was found in request.headers')
})
test('GET /api without JWT Token', async (t) => {
@@ -25,9 +24,8 @@ test('GET /api without JWT Token', async (t) => {
}
})
- assert.deepStrictEqual(JSON.parse(res.payload), {
- message: 'Authorization token is invalid: The token is malformed.'
- })
+ assert.equal(res.statusCode, 401)
+ assert.deepStrictEqual(JSON.parse(res.payload).message, 'Authorization token is invalid: The token is malformed.')
})
test('GET /api with JWT Token', async (t) => {
diff --git a/test/routes/home.test.ts b/test/routes/home.test.ts
deleted file mode 100644
index 41fc3a1d..00000000
--- a/test/routes/home.test.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { test } from 'node:test'
-import assert from 'node:assert'
-import { build } from '../helper.js'
-
-test('GET /', async (t) => {
- const app = await build(t)
- const res = await app.inject({
- url: '/'
- })
-
- assert.deepStrictEqual(JSON.parse(res.payload), {
- message: 'Welcome to the official fastify demo!'
- })
-})
diff --git a/test/tsconfig.json b/test/tsconfig.json
index 66246dfe..7392ed0a 100644
--- a/test/tsconfig.json
+++ b/test/tsconfig.json
@@ -2,7 +2,7 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
- "noEmit": false
+ "noEmit": false,
},
"include": ["@types", "../src/**/*.ts", "**/*.ts"]
}
diff --git a/tsconfig.json b/tsconfig.json
index 6fe21f5a..9136c517 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -4,5 +4,5 @@
"outDir": "dist",
"rootDir": "src"
},
- "include": ["@types", "src/**/*.ts"]
+ "include": ["@types", "src/**/*.ts"],
}
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 00000000..eac35e7b
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,13 @@
+import { resolve } from 'node:path'
+import viteReact from '@vitejs/plugin-react'
+
+/** @type {import('vite').UserConfig} */
+export default {
+ base: '/',
+ root: resolve(import.meta.dirname, 'client'),
+ build: {
+ emptyOutDir: true,
+ outDir: resolve(import.meta.dirname, 'dist/client')
+ },
+ plugins: [viteReact()]
+}