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()] +}