diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..f5e3749c --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Must always set to production +# @see {@link https://www.youtube.com/watch?v=HMM7GJC5E2o} +NODE_ENV=production + +# Database +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_DATABASE=test_db +MYSQL_USER=test_user +MYSQL_PASSWORD=test_password + +# Server +FASTIFY_CLOSE_GRACE_DELAY=1000 +LOG_LEVEL=info + +# Security +JWT_SECRET= diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..dfa7fa6c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 00000000..d51ce639 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,21 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 15 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - "discussion" + - "feature request" + - "bug" + - "help wanted" + - "plugin suggestion" + - "good first issue" +# Label to use when marking an issue as stale +staleLabel: stale +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..66408af8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: + - main + - next + - "v*" + paths-ignore: + - "docs/**" + - "*.md" + pull_request: + paths-ignore: + - "docs/**" + - "*.md" + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [22] + + services: + mysql: + image: mysql:8.4 + ports: + - 3306:3306 + env: + MYSQL_ROOT_PASSWORD: root_password + MYSQL_DATABASE: test_db + MYSQL_USER: test_user + MYSQL_PASSWORD: test_password + options: >- + --health-cmd="mysqladmin ping -u$MYSQL_USER -p$MYSQL_PASSWORD" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm i + + - name: Lint Code + run: npm run lint + + - name: Generate JWT Secret + id: gen-jwt + run: | + JWT_SECRET=$(openssl rand -hex 32) + echo "JWT_SECRET=$JWT_SECRET" >> $GITHUB_ENV + + - name: Generate dummy .env for scripts using -env-file=.env flag + run: touch .env + + - name: Test + env: + 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 + run: npm run db:migrate && npm run test diff --git a/.gitignore b/.gitignore index c6bba591..e4d563a7 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ coverage # nyc test coverage .nyc_output +# tap test coverage +.tap + # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt @@ -128,3 +131,9 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +# lock files +bun.lockb +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/@types/fastify/fastify.d.ts b/@types/fastify/fastify.d.ts new file mode 100644 index 00000000..f85cbfd5 --- /dev/null +++ b/@types/fastify/fastify.d.ts @@ -0,0 +1,7 @@ +import { Auth } from "../../src/schemas/auth.ts"; + +declare module "fastify" { + export interface FastifyRequest { + user: Auth + } +} diff --git a/@types/node/environment.d.ts b/@types/node/environment.d.ts new file mode 100644 index 00000000..7105b751 --- /dev/null +++ b/@types/node/environment.d.ts @@ -0,0 +1,16 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + PORT: number; + LOG_LEVEL: string; + FASTIFY_CLOSE_GRACE_DELAY: number; + MYSQL_HOST: string + MYSQL_PORT: number + MYSQL_DATABASE: string + MYSQL_USER: string + MYSQL_PASSWORD: string + } + } +} + +export {}; \ No newline at end of file diff --git a/README.md b/README.md index 97be1367..39f1fe86 100644 --- a/README.md +++ b/README.md @@ -1 +1,39 @@ -# demo \ No newline at end of file +# Fastify Official Demo + +![CI](https://github.com/fastify/demo/workflows/CI/badge.svg) +[![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://standardjs.com/) + +> :warning: **Please note:** This repository is still under active development. + +The aim of this repository is to provide a concrete example of a Fastify application using what are considered best practices by the Fastify community. + +**Prerequisites:** You need to have Node.js version 22 or higher installed. + +## Getting started + +Install the dependencies: + +```bash +npm install +``` + +## Available Scripts + +In the project directory, you can run: + +### `npm run dev` + +To start the app in dev mode.\ +Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +### `npm start` + +For production mode + +### `npm run test` + +Run the test cases. + +## Learn More + +To learn Fastify, check out the [Fastify documentation](https://fastify.dev/docs/latest/). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..10830fc3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + db: + image: mysql:8.4 + environment: + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + ports: + - 3306:3306 + volumes: + - db_data:/var/lib/mysql + +volumes: + db_data: diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..b9f40bf5 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,20 @@ +'use strict' + +import neo from 'neostandard' + +export default [ + ...neo({ + ts: true + }), + { + rules: { + '@stylistic/comma-dangle': ['error', { + arrays: 'never', + objects: 'never', + imports: 'never', + exports: 'never', + functions: 'never' + }] + } + } +] diff --git a/migrations/001.do.users.sql b/migrations/001.do.users.sql new file mode 100644 index 00000000..66dcd900 --- /dev/null +++ b/migrations/001.do.users.sql @@ -0,0 +1,7 @@ +CREATE TABLE users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); diff --git a/migrations/001.undo.users.sql b/migrations/001.undo.users.sql new file mode 100644 index 00000000..c99ddcdc --- /dev/null +++ b/migrations/001.undo.users.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/migrations/002.do.tasks.sql b/migrations/002.do.tasks.sql new file mode 100644 index 00000000..9c08a4e2 --- /dev/null +++ b/migrations/002.do.tasks.sql @@ -0,0 +1,11 @@ +CREATE TABLE tasks ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + author_id INT NOT NULL, + assigned_user_id INT, + status VARCHAR(50) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (author_id) REFERENCES users(id), + FOREIGN KEY (assigned_user_id) REFERENCES users(id) +); diff --git a/migrations/002.undo.tasks.sql b/migrations/002.undo.tasks.sql new file mode 100644 index 00000000..2ff13806 --- /dev/null +++ b/migrations/002.undo.tasks.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tasks; diff --git a/migrations/003.do.user_tasks.sql b/migrations/003.do.user_tasks.sql new file mode 100644 index 00000000..6abc7512 --- /dev/null +++ b/migrations/003.do.user_tasks.sql @@ -0,0 +1,7 @@ +CREATE TABLE user_tasks ( + user_id INT NOT NULL, + task_id INT NOT NULL, + PRIMARY KEY (user_id, task_id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (task_id) REFERENCES tasks(id) +); diff --git a/migrations/003.undo.user_tasks.sql b/migrations/003.undo.user_tasks.sql new file mode 100644 index 00000000..bb7bc57c --- /dev/null +++ b/migrations/003.undo.user_tasks.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_tasks; diff --git a/package.json b/package.json new file mode 100644 index 00000000..bcabb572 --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name": "fastify-demo", + "version": "0.0.0", + "description": "The official Fastify demo!", + "main": "app.js", + "type": "module", + "directories": { + "test": "test" + }, + "scripts": { + "build": "rm -rf dist && tsc", + "watch": "npm run build -- --watch", + "test": "npm run db:seed && tap --jobs=1 test/**/*", + "start": "fastify start -l info dist/app.js", + "dev": "fastify start -w -l info -P dist/app.js", + "standalone": "node --env-file=.env dist/server.js", + "lint": "eslint --ignore-pattern=dist", + "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" + }, + "keywords": [], + "author": "Michelet Jean ", + "license": "MIT", + "dependencies": { + "@fastify/autoload": "^5.10.0", + "@fastify/cors": "^9.0.1", + "@fastify/env": "^4.3.0", + "@fastify/helmet": "^11.1.1", + "@fastify/jwt": "^8.0.1", + "@fastify/mysql": "^4.3.0", + "@fastify/sensible": "^5.0.0", + "@fastify/swagger": "^8.14.0", + "@fastify/swagger-ui": "^3.0.0", + "@fastify/type-provider-typebox": "^4.0.0", + "@fastify/under-pressure": "^8.3.0", + "@sinclair/typebox": "^0.32.31", + "fastify": "^4.26.1", + "fastify-cli": "^6.1.1", + "fastify-plugin": "^4.0.0", + "postgrator": "^7.2.0" + }, + "devDependencies": { + "@types/node": "^20.14.2", + "eslint": "^9.4.0", + "fastify-tsconfig": "^2.0.0", + "mysql2": "^3.10.1", + "neostandard": "^0.7.0", + "tap": "^19.2.2", + "typescript": "^5.4.5" + } +} diff --git a/scripts/migrate.js b/scripts/migrate.js new file mode 100644 index 00000000..1eab2ca4 --- /dev/null +++ b/scripts/migrate.js @@ -0,0 +1,39 @@ +import mysql from 'mysql2/promise' +import path from 'path' +import Postgrator from 'postgrator' + +async function doMigration () { + const connection = await mysql.createConnection({ + multipleStatements: true, + host: process.env.MYSQL_HOST, + port: process.env.MYSQL_PORT, + database: process.env.MYSQL_DATABASE, + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD + }) + + const postgrator = new Postgrator({ + migrationPattern: path.join(import.meta.dirname, '../migrations', '*'), + driver: 'mysql', + database: process.env.MYSQL_DATABASE, + execQuery: async (query) => { + const [rows, fields] = await connection.query(query) + + return { rows, fields } + }, + schemaTable: 'schemaversion' + }) + + await postgrator.migrate() + + await new Promise((resolve, reject) => { + connection.end((err) => { + if (err) { + return reject(err) + } + resolve() + }) + }) +} + +doMigration().catch(err => console.error(err)) diff --git a/scripts/seed-database.js b/scripts/seed-database.js new file mode 100644 index 00000000..1c5acc93 --- /dev/null +++ b/scripts/seed-database.js @@ -0,0 +1,60 @@ +import { createConnection } from 'mysql2/promise' + +async function seed () { + const connection = await createConnection({ + multipleStatements: true, + host: process.env.MYSQL_HOST, + port: Number(process.env.MYSQL_PORT), + database: process.env.MYSQL_DATABASE, + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD + }) + + try { + await truncateTables(connection) + await seedUsers(connection) + + /* c8 ignore start */ + } catch (error) { + console.error('Error seeding database:', error) + } finally { + /* c8 ignore end */ + await connection.end() + } +} + +async function truncateTables (connection) { + const [tables] = await connection.query('SHOW TABLES') + + if (tables.length > 0) { + const tableNames = tables.map((row) => row[`Tables_in_${process.env.MYSQL_DATABASE}`]) + const truncateQueries = tableNames.map((tableName) => `TRUNCATE TABLE \`${tableName}\``).join('; ') + + await connection.query('SET FOREIGN_KEY_CHECKS = 0') + try { + await connection.query(truncateQueries) + console.log('All tables have been truncated successfully.') + } finally { + await connection.query('SET FOREIGN_KEY_CHECKS = 1') + } + } +} + +async function seedUsers (connection) { + const usernames = ['basic', 'moderator', 'admin'] + + for (const username of usernames) { + // Generated hash for plain text 'password' + const hash = '918933f991bbf22eade96420811e46b4.b2e2105880b90b66bf6d6247a42a81368819a1c57c07165cf8b25df80b5752bb' + const insertUserQuery = ` + INSERT INTO users (username, password) + VALUES (?, ?) + ` + + await connection.execute(insertUserQuery, [username, hash]) + } + + console.log('Users have been seeded successfully.') +} + +seed() diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 00000000..4e9b57ca --- /dev/null +++ b/src/app.ts @@ -0,0 +1,78 @@ +/** + * If you would like to turn your application into a standalone executable, look at server.js file + */ + +import path from "node:path"; +import fastifyAutoload from "@fastify/autoload"; +import { FastifyInstance, FastifyPluginOptions } from "fastify"; + +export default async function serviceApp( + fastify: FastifyInstance, + opts: FastifyPluginOptions +) { + // This loads all external plugins defined in plugins/external + // those should be registered first as your custom plugins might depend on them + fastify.register(fastifyAutoload, { + dir: path.join(import.meta.dirname, "plugins/external"), + options: { ...opts } + }); + + // This loads all your custom plugins defined in plugins/custom + // those should be support plugins that are reused + // through your application + fastify.register(fastifyAutoload, { + dir: path.join(import.meta.dirname, "plugins/custom"), + options: { ...opts } + }); + + // This loads all plugins defined in routes + // define your routes in one of these + fastify.register(fastifyAutoload, { + dir: path.join(import.meta.dirname, "routes"), + autoHooks: true, + cascadeHooks: true, + options: { ...opts } + }); + + fastify.setErrorHandler((err, request, reply) => { + request.log.error( + { + err, + request: { + method: request.method, + url: request.url, + query: request.query, + params: request.params + } + }, + "Unhandled error occurred" + ); + + reply.code(err.statusCode ?? 500); + + let message = "Internal Server Error"; + if (err.statusCode === 401) { + message = err.message; + } + + return { message }; + }); + + fastify.setNotFoundHandler((request, reply) => { + request.log.warn( + { + request: { + method: request.method, + url: request.url, + query: request.query, + params: request.params + } + }, + "Resource not found" + ); + + reply.code(404); + + return { message: "Not Found" }; + }); +} diff --git a/src/plugins/README.md b/src/plugins/README.md new file mode 100644 index 00000000..1c0af335 --- /dev/null +++ b/src/plugins/README.md @@ -0,0 +1,11 @@ +# Plugins Folder + +Plugins define behavior that is common to all the routes in your application. Authentication, caching, templates, and all the other cross cutting concerns should be handled by plugins placed in this folder. + +Files in this folder are typically defined through the [`fastify-plugin`](https://github.com/fastify/fastify-plugin) module, making them non-encapsulated. They can define decorators and set hooks that will then be used in the rest of your application. + +Check out: + +- [The hitchhiker's guide to plugins](https://fastify.dev/docs/latest/Guides/Plugins-Guide/) +- [Fastify decorators](https://fastify.dev/docs/latest/Reference/Decorators/). +- [Fastify lifecycle](https://fastify.dev/docs/latest/Reference/Lifecycle/). diff --git a/src/plugins/custom/repository.ts b/src/plugins/custom/repository.ts new file mode 100644 index 00000000..707a7c49 --- /dev/null +++ b/src/plugins/custom/repository.ts @@ -0,0 +1,109 @@ +import { MySQLPromisePool } from "@fastify/mysql"; +import { FastifyInstance } from "fastify"; +import fp from "fastify-plugin"; +import { RowDataPacket, ResultSetHeader } from "mysql2"; + +declare module "fastify" { + export interface FastifyInstance { + repository: Repository; + } +} + +export type Repository = MySQLPromisePool & ReturnType; + +type QuerySeparator = 'AND' | ','; + +type QueryOptions = { + select?: string; + where?: Record; +}; + +type WriteOptions = { + data: Record; + where?: Record; +}; + +function createRepository(fastify: FastifyInstance) { + const processAssignmentRecord = (record: Record, separator: QuerySeparator) => { + const keys = Object.keys(record); + const values = Object.values(record); + const clause = keys.map((key) => `${key} = ?`).join(` ${separator} `); + + return [clause, values] as const; + }; + + const repository = { + ...fastify.mysql, + find: async (table: string, opts: QueryOptions): Promise => { + const { select = '*', where = {1:1} } = opts; + const [clause, values] = processAssignmentRecord(where, 'AND'); + + const query = `SELECT ${select} FROM ${table} WHERE ${clause} LIMIT 1`; + const [rows] = await fastify.mysql.query(query, values); + if (rows.length < 1) { + return null; + } + + return rows[0] as T; + }, + + findMany: async (table: string, opts: QueryOptions): Promise => { + const { select = '*', where = {1:1} } = opts; + const [clause, values] = processAssignmentRecord(where, 'AND'); + + const query = `SELECT ${select} FROM ${table} WHERE ${clause}`; + const [rows] = await fastify.mysql.query(query, values); + + return rows as T[]; + }, + + create: async (table: string, opts: WriteOptions): Promise => { + const { data } = opts; + const columns = Object.keys(data).join(', '); + const placeholders = Object.keys(data).map(() => '?').join(', '); + const values = Object.values(data); + + const query = `INSERT INTO ${table} (${columns}) VALUES (${placeholders})`; + const [result] = await fastify.mysql.query(query, values); + + return result.insertId; + }, + + update: async (table: string, opts: WriteOptions): Promise => { + const { data, where = {} } = opts; + const [dataClause, dataValues] = processAssignmentRecord(data, ','); + const [whereClause, whereValues] = processAssignmentRecord(where, 'AND'); + + const query = `UPDATE ${table} SET ${dataClause} WHERE ${whereClause}`; + const [result] = await fastify.mysql.query(query, [...dataValues, ...whereValues]); + + return result.affectedRows; + }, + + delete: async (table: string, where: Record): Promise => { + const [clause, values] = processAssignmentRecord(where, 'AND'); + + const query = `DELETE FROM ${table} WHERE ${clause}`; + const [result] = await fastify.mysql.query(query, values); + + return result.affectedRows; + } + }; + + return repository; +} + +/** + * The use of fastify-plugin is required to be able + * to export the decorators to the outer scope + * + * @see {@link https://github.com/fastify/fastify-plugin} + */ +export default fp( + async function (fastify) { + fastify.decorate("repository", createRepository(fastify)); + // You should name your plugins if you want to avoid name collisions + // and/or to perform dependency checks. + }, + { name: "repository", dependencies: ['mysql'] } +); diff --git a/src/plugins/custom/scrypt.ts b/src/plugins/custom/scrypt.ts new file mode 100644 index 00000000..f643377b --- /dev/null +++ b/src/plugins/custom/scrypt.ts @@ -0,0 +1,69 @@ +import fp from 'fastify-plugin'; +import { scrypt, timingSafeEqual, randomBytes } from 'crypto' + +declare module "fastify" { + export interface FastifyInstance { + hash: typeof scryptHash; + compare: typeof compare + } + } + +const SCRYPT_KEYLEN = 32 +const SCRYPT_COST = 65536 +const SCRYPT_BLOCK_SIZE = 8 +const SCRYPT_PARALLELIZATION = 2 +const SCRYPT_MAXMEM = 128 * SCRYPT_COST * SCRYPT_BLOCK_SIZE * 2 + +async function scryptHash(value: string): Promise { + return new Promise((resolve, reject) => { + const salt = randomBytes(Math.min(16, SCRYPT_KEYLEN / 2)) + + scrypt(value, salt, SCRYPT_KEYLEN, { + cost: SCRYPT_COST, + blockSize: SCRYPT_BLOCK_SIZE, + parallelization: SCRYPT_PARALLELIZATION, + maxmem: SCRYPT_MAXMEM + }, function (error, key) { + /* c8 ignore start - Requires extreme or impractical configuration values */ + if (error !== null) { + reject(error) + } + /* c8 ignore end */ + else { + resolve(`${salt.toString('hex')}.${key.toString('hex')}`) + } + }) + }) +} + +async function compare(value: string, hash: string): Promise { + const [salt, hashed] = hash.split('.') + const saltBuffer = Buffer.from(salt, 'hex'); + const hashedBuffer = Buffer.from(hashed, 'hex') + + return new Promise((resolve) => { + scrypt(value, saltBuffer, SCRYPT_KEYLEN, { + cost: SCRYPT_COST, + blockSize: SCRYPT_BLOCK_SIZE, + parallelization: SCRYPT_PARALLELIZATION, + maxmem: SCRYPT_MAXMEM + }, function (error, key) { + /* c8 ignore start - Requires extreme or impractical configuration values */ + if (error !== null) { + timingSafeEqual(hashedBuffer, hashedBuffer) + resolve(false) + } + /* c8 ignore end */ + else { + resolve(timingSafeEqual(key, hashedBuffer)) + } + }) + }) +} + +export default fp(async (fastify) => { + fastify.decorate('hash', scryptHash); + fastify.decorate('compare', compare); +}, { + name: 'scrypt' +}); diff --git a/src/plugins/external/1-env.ts b/src/plugins/external/1-env.ts new file mode 100644 index 00000000..c14221de --- /dev/null +++ b/src/plugins/external/1-env.ts @@ -0,0 +1,80 @@ +import env from "@fastify/env"; + +declare module "fastify" { + export interface FastifyInstance { + config: { + PORT: number; + MYSQL_HOST: string; + MYSQL_PORT: string; + MYSQL_USER: string; + MYSQL_PASSWORD: string; + MYSQL_DATABASE: string; + JWT_SECRET: string; + }; + } +} + +const schema = { + type: "object", + required: [ + "MYSQL_HOST", + "MYSQL_PORT", + "MYSQL_USER", + "MYSQL_PASSWORD", + "MYSQL_DATABASE", + "JWT_SECRET" + ], + properties: { + // Database + MYSQL_HOST: { + type: "string", + default: "localhost" + }, + MYSQL_PORT: { + type: "number", + default: 3306 + }, + MYSQL_USER: { + type: "string" + }, + MYSQL_PASSWORD: { + type: "string" + }, + MYSQL_DATABASE: { + type: "string" + }, + + // Security + JWT_SECRET: { + type: "string" + } + } +}; + +export const autoConfig = { + // Decorate Fastify instance with `config` key + // Optional, default: 'config' + confKey: "config", + + // Schema to validate + schema, + + // Needed to read .env in root folder + dotenv: true, + // or, pass config options available on dotenv module + // dotenv: { + // path: `${import.meta.dirname}/.env`, + // debug: true + // } + + // Source for the configuration data + // Optional, default: process.env + data: process.env +}; + +/** + * This plugins helps to check environment variables. + * + * @see {@link https://github.com/fastify/fastify-env} + */ +export default env; diff --git a/src/plugins/external/cors.ts b/src/plugins/external/cors.ts new file mode 100644 index 00000000..1b6906af --- /dev/null +++ b/src/plugins/external/cors.ts @@ -0,0 +1,12 @@ +import cors, { FastifyCorsOptions } from "@fastify/cors"; + +export const autoConfig: FastifyCorsOptions = { + methods: ['GET', 'POST', 'PUT', 'DELETE'] +}; + +/** + * This plugins enables the use of CORS. + * + * @see {@link https://github.com/fastify/fastify-cors} + */ +export default cors; diff --git a/src/plugins/external/helmet.ts b/src/plugins/external/helmet.ts new file mode 100644 index 00000000..86f7e5e8 --- /dev/null +++ b/src/plugins/external/helmet.ts @@ -0,0 +1,12 @@ +import helmet from "@fastify/helmet"; + +export const autoConfig = { + // Set plugin options here +}; + +/** + * This plugins sets the basic security headers. + * + * @see {@link https://github.com/fastify/fastify-helmet} + */ +export default helmet; diff --git a/src/plugins/external/jwt.ts b/src/plugins/external/jwt.ts new file mode 100644 index 00000000..60f4e34e --- /dev/null +++ b/src/plugins/external/jwt.ts @@ -0,0 +1,10 @@ +import fastifyJwt from "@fastify/jwt"; +import { FastifyInstance } from "fastify"; + +export const autoConfig = (fastify: FastifyInstance) => { + return { + secret: fastify.config.JWT_SECRET + }; +}; + +export default fastifyJwt; diff --git a/src/plugins/external/mysql.ts b/src/plugins/external/mysql.ts new file mode 100644 index 00000000..73051bf8 --- /dev/null +++ b/src/plugins/external/mysql.ts @@ -0,0 +1,24 @@ +import fp from "fastify-plugin"; +import fastifyMysql, { MySQLPromisePool } from "@fastify/mysql"; +import { FastifyInstance } from "fastify"; + +declare module "fastify" { + export interface FastifyInstance { + mysql: MySQLPromisePool; + } +} + +export const autoConfig = (fastify: FastifyInstance) => { + return { + promise: true, + host: fastify.config.MYSQL_HOST, + user: fastify.config.MYSQL_USER, + password: fastify.config.MYSQL_PASSWORD, + database: fastify.config.MYSQL_DATABASE, + port: Number(fastify.config.MYSQL_PORT) + }; +}; + +export default fp(fastifyMysql, { + name: "mysql" +}); diff --git a/src/plugins/external/sensible.ts b/src/plugins/external/sensible.ts new file mode 100644 index 00000000..fba13930 --- /dev/null +++ b/src/plugins/external/sensible.ts @@ -0,0 +1,12 @@ +import sensible from "@fastify/sensible"; + +export const autoConfig = { + // Set plugin options here +}; + +/** + * This plugin adds some utilities to handle http errors + * + * @see {@link https://github.com/fastify/fastify-sensible} + */ +export default sensible; diff --git a/src/plugins/external/swagger.ts b/src/plugins/external/swagger.ts new file mode 100644 index 00000000..ede64459 --- /dev/null +++ b/src/plugins/external/swagger.ts @@ -0,0 +1,30 @@ +import fp from "fastify-plugin"; +import fastifySwaggerUi from "@fastify/swagger-ui"; +import fastifySwagger from "@fastify/swagger"; + +export default fp(async function (fastify) { + /** + * A Fastify plugin for serving Swagger (OpenAPI v2) or OpenAPI v3 schemas + * + * @see {@link https://github.com/fastify/fastify-swagger} + */ + await fastify.register(fastifySwagger, { + hideUntagged: true, + openapi: { + info: { + title: "Fastify demo API", + description: "The official Fastify demo API", + version: "0.0.0" + } + } + }); + + /** + * A Fastify plugin for serving Swagger UI. + * + * @see {@link https://github.com/fastify/fastify-swagger-ui} + */ + await fastify.register(fastifySwaggerUi, { + // Set plugin options here + }); +}); diff --git a/src/plugins/external/under-pressure.ts b/src/plugins/external/under-pressure.ts new file mode 100644 index 00000000..e9efd064 --- /dev/null +++ b/src/plugins/external/under-pressure.ts @@ -0,0 +1,43 @@ +import { FastifyInstance } from "fastify"; +import fastifyUnderPressure from "@fastify/under-pressure"; +import fp from "fastify-plugin"; + +export const autoConfig = (fastify: FastifyInstance) => { + return { + maxEventLoopDelay: 1000, + maxHeapUsedBytes: 100_000_000, + maxRssBytes: 1_000_000_000, + maxEventLoopUtilization: 0.98, + message: "The server is under pressure, retry later!", + retryAfter: 50, + healthCheck: async () => { + let connection; + try { + connection = await fastify.mysql.getConnection(); + await connection.query("SELECT 1;"); + return true; + /* c8 ignore start */ + } catch (err) { + fastify.log.error(err, "healthCheck has failed"); + throw new Error("Database connection is not available"); + } finally { + connection?.release(); + } + /* c8 ignore stop */ + }, + healthCheckInterval: 5000 + }; +}; + +/** + * A Fastify plugin for mesuring process load and automatically + * handle of "Service Unavailable" + * + * @see {@link https://github.com/fastify/under-pressure} + * + * Video on the topic: Do not thrash the event loop + * @see {@link https://www.youtube.com/watch?v=VI29mUA8n9w} + */ +export default fp(fastifyUnderPressure, { + dependencies: ["mysql"] +}); diff --git a/src/routes/README.md b/src/routes/README.md new file mode 100644 index 00000000..0d4c6812 --- /dev/null +++ b/src/routes/README.md @@ -0,0 +1,30 @@ +# Routes Folder + +Routes define the pathways within your application. +Fastify's structure supports the modular monolith approach, where your +application is organized into distinct, self-contained modules. +This facilitates easier scaling and future transition to a microservice architecture. +Each module can evolve independently, and in the future, you might want to deploy +some of these modules separately. + +In this folder you should define all the routes that define the endpoints +of your web application. +Each service is a [Fastify +plugin](https://fastify.dev/docs/latest/Reference/Plugins/), it is +encapsulated (it can have its own independent plugins) and it is +typically stored in a file; be careful to group your routes logically, +e.g. all `/users` routes in a `users.js` file. We have added +a `root.js` file for you with a '/' root added. + +If a single file become too large, create a folder and add a `index.js` file there: +this file must be a Fastify plugin, and it will be loaded automatically +by the application. You can now add as many files as you want inside that folder. +In this way you can create complex routes within a single monolith, +and eventually extract them. + +If you need to share functionality between routes, place that +functionality into the `plugins` folder, and share it via +[decorators](https://fastify.dev/docs/latest/Reference/Decorators/). + +If you're a bit confused about using `async/await` to write routes, you would +better take a look at [Promise resolution](https://fastify.dev/docs/latest/Reference/Routes/#promise-resolution) for more details. diff --git a/src/routes/api/auth/index.ts b/src/routes/api/auth/index.ts new file mode 100644 index 00000000..174a9821 --- /dev/null +++ b/src/routes/api/auth/index.ts @@ -0,0 +1,48 @@ +import { + FastifyPluginAsyncTypebox, + Type +} from "@fastify/type-provider-typebox"; +import { CredentialsSchema, Auth } from "../../../schemas/auth.js"; + +const plugin: FastifyPluginAsyncTypebox = async (fastify) => { + fastify.post( + "/login", + { + schema: { + body: CredentialsSchema, + response: { + 200: Type.Object({ + token: Type.String() + }), + 401: Type.Object({ + message: Type.String() + }) + }, + tags: ["Authentication"] + } + }, + async function (request, reply) { + const { username, password } = request.body; + + const user = await fastify.repository.find('users', { + select: 'username, password', + where: { username } + }) + + if (user) { + const isPasswordValid = await fastify.compare(password, user.password); + if (isPasswordValid) { + const token = fastify.jwt.sign({ username: user.username }); + + return { token }; + } + } + + reply.status(401); + + return { message: "Invalid username or password." }; + } + ); +}; + +export default plugin; diff --git a/src/routes/api/autohooks.ts b/src/routes/api/autohooks.ts new file mode 100644 index 00000000..3a554d48 --- /dev/null +++ b/src/routes/api/autohooks.ts @@ -0,0 +1,10 @@ +import { FastifyInstance } from "fastify"; + + +export default async function (fastify: FastifyInstance) { + fastify.addHook("onRequest", async (request) => { + if (!request.url.startsWith("/api/auth/login")) { + await request.jwtVerify(); + } + }); +} diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts new file mode 100644 index 00000000..71cc0462 --- /dev/null +++ b/src/routes/api/index.ts @@ -0,0 +1,10 @@ +import { FastifyInstance } from "fastify"; + +export default async function (fastify: FastifyInstance) { + fastify.get("/", ({ user, protocol, hostname }) => { + return { + message: + `Hello ${user.username}! See documentation at ${protocol}://${hostname}/documentation` + }; + }); +} diff --git a/src/routes/api/tasks/index.ts b/src/routes/api/tasks/index.ts new file mode 100644 index 00000000..2d8f29b5 --- /dev/null +++ b/src/routes/api/tasks/index.ts @@ -0,0 +1,24 @@ +import { + FastifyPluginAsyncTypebox, + Type +} from "@fastify/type-provider-typebox"; +import { TaskSchema } from "../../../schemas/tasks.js"; + +const plugin: FastifyPluginAsyncTypebox = async (fastify) => { + fastify.get( + "/", + { + schema: { + response: { + 200: Type.Array(TaskSchema) + }, + tags: ["Tasks"] + } + }, + async function () { + return [{ id: 1, name: "Do something..." }]; + } + ); +}; + +export default plugin; diff --git a/src/routes/home.ts b/src/routes/home.ts new file mode 100644 index 00000000..9ab69595 --- /dev/null +++ b/src/routes/home.ts @@ -0,0 +1,25 @@ +import { + FastifyPluginAsyncTypebox, + Type +} from "@fastify/type-provider-typebox"; + +const plugin: FastifyPluginAsyncTypebox = async (fastify) => { + fastify.get( + "/", + { + schema: { + response: { + 200: Type.Object({ + message: Type.String() + }) + }, + tags: ["Home"] + } + }, + async function () { + return { message: "Welcome to the official fastify demo!" }; + } + ); +}; + +export default plugin; diff --git a/src/schemas/auth.ts b/src/schemas/auth.ts new file mode 100644 index 00000000..83ba0b0d --- /dev/null +++ b/src/schemas/auth.ts @@ -0,0 +1,8 @@ +import { Static, Type } from "@sinclair/typebox"; + +export const CredentialsSchema = Type.Object({ + username: Type.String(), + password: Type.String() +}); + +export interface Auth extends Static {} diff --git a/src/schemas/tasks.ts b/src/schemas/tasks.ts new file mode 100644 index 00000000..6f506c5c --- /dev/null +++ b/src/schemas/tasks.ts @@ -0,0 +1,6 @@ +import { Type } from "@sinclair/typebox"; + +export const TaskSchema = Type.Object({ + id: Type.Number(), + name: Type.String() +}); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 00000000..f45c8868 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,77 @@ +/** + * This file is here only to show you how to proceed if you would + * like to turn your application into a standalone executable. + * + * You can launch it with the command `npm run standalone` + */ + +import Fastify from "fastify"; +import fp from "fastify-plugin"; + +// Import library to exit fastify process, gracefully (if possible) +import closeWithGrace from "close-with-grace"; + +// Import your application as a normal plugin. +import serviceApp from "./app.js"; + +/** + * Do not use NODE_ENV to determine what logger (or any env related feature) to use + * @see {@link https://www.youtube.com/watch?v=HMM7GJC5E2o} + */ +function getLoggerOptions() { + // Only if the program is running in an interactive terminal + if (process.stdout.isTTY) { + return { + level: "info", + transport: { + target: "pino-pretty", + options: { + translateTime: "HH:MM:ss Z", + ignore: "pid,hostname" + } + } + }; + } + + return { level: process.env.LOG_LEVEL ?? "silent" }; +} + +const app = Fastify({ + logger: getLoggerOptions(), + ajv: { + customOptions: { + coerceTypes: "array", // change type of data to match type keyword + removeAdditional: "all" // Remove additional body properties + } + } +}); + +async function init() { + // Register your application as a normal plugin. + // fp must be used to override default error handler + app.register(fp(serviceApp)); + + // Delay is the number of milliseconds for the graceful close to finish + closeWithGrace( + { delay: process.env.FASTIFY_CLOSE_GRACE_DELAY ?? 500 }, + async ({ err }) => { + if (err != null) { + app.log.error(err); + } + + await app.close(); + } + ); + + await app.ready(); + + try { + // Start listening. + await app.listen({ port: process.env.PORT ?? 3000 }); + } catch (err) { + app.log.error(err); + process.exit(1); + } +} + +init(); diff --git a/test/app/cors.test.ts b/test/app/cors.test.ts new file mode 100644 index 00000000..9893ab81 --- /dev/null +++ b/test/app/cors.test.ts @@ -0,0 +1,20 @@ +import { it } from "node:test"; +import { build } from "../helper.js"; +import assert from "node:assert"; + +it("should correctly handle CORS preflight requests", async (t) => { + const app = await build(t); + + const res = await app.inject({ + method: "OPTIONS", + url: "/", + headers: { + "Origin": "http://example.com", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "Content-Type" + } + }); + + assert.strictEqual(res.statusCode, 204); + assert.strictEqual(res.headers['access-control-allow-methods'], 'GET, POST, PUT, DELETE'); +}); diff --git a/test/app/error-handler.test.ts b/test/app/error-handler.test.ts new file mode 100644 index 00000000..deed749e --- /dev/null +++ b/test/app/error-handler.test.ts @@ -0,0 +1,27 @@ +import { it } from "node:test"; +import assert from "node:assert"; +import fastify from "fastify"; +import serviceApp from "../../src/app.ts"; +import fp from "fastify-plugin"; + +it("should call errorHandler", async (t) => { + const app = fastify(); + await app.register(fp(serviceApp)); + + app.get("/error", () => { + throw new Error("Kaboom!"); + }); + + await app.ready(); + + t.after(() => app.close()); + + const res = await app.inject({ + method: "GET", + url: "/error" + }); + + assert.deepStrictEqual(JSON.parse(res.payload), { + message: "Internal Server Error" + }); +}); diff --git a/test/app/not-found-handler.test.ts b/test/app/not-found-handler.test.ts new file mode 100644 index 00000000..3e5d3adc --- /dev/null +++ b/test/app/not-found-handler.test.ts @@ -0,0 +1,15 @@ +import { it } from "node:test"; +import assert from "node:assert"; +import { build } from "../helper.js"; + +it("should call notFoundHandler", async (t) => { + const app = await build(t); + + const res = await app.inject({ + method: "GET", + url: "/this-route-does-not-exist" + }); + + assert.strictEqual(res.statusCode, 404); + assert.deepStrictEqual(JSON.parse(res.payload), { message: "Not Found" }); +}); diff --git a/test/helper.ts b/test/helper.ts new file mode 100644 index 00000000..ce16fe95 --- /dev/null +++ b/test/helper.ts @@ -0,0 +1,56 @@ +// This file contains code that we reuse +// between our tests. + +import { InjectOptions } from "fastify"; +import { build as buildApplication } from "fastify-cli/helper.js"; +import path from "node:path"; +import { TestContext } from "node:test"; + +const AppPath = path.join(import.meta.dirname, "../src/app.ts"); + +// Fill in this config with all the configurations +// needed for testing the application +export function config() { + return {}; +} + +// We will create different users with different roles +async function login(username: string) { + const res = await this.inject({ + method: "POST", + url: "/api/auth/login", + payload: { + username, + password: "password" + } + }); + + return JSON.parse(res.payload).token; +} + +// automatically build and tear down our instance +export async function build(t: TestContext) { + // you can set all the options supported by the fastify CLI command + const argv = [AppPath]; + + // fastify-plugin ensures that all decorators + // are exposed for testing purposes, this is + // different from the production setup + const app = await buildApplication(argv, config()); + + app.login = login; + + app.injectWithLogin = async (username: string, opts: InjectOptions) => { + opts.headers = { + ...opts.headers, + Authorization: `Bearer ${await app.login(username)}` + }; + + return app.inject(opts); + }; + + // close the app after we are done + t.after(() => app.close()); + + return app; +} diff --git a/test/plugins/repository.test.ts b/test/plugins/repository.test.ts new file mode 100644 index 00000000..2f143a78 --- /dev/null +++ b/test/plugins/repository.test.ts @@ -0,0 +1,65 @@ +import { test } from "tap"; +import assert from "node:assert"; +import { execSync } from "child_process"; +import Fastify from "fastify"; +import repository from "../../src/plugins/custom/repository.js"; +import * as envPlugin from "../../src/plugins/external/1-env.js"; +import * as mysqlPlugin from "../../src/plugins/external/mysql.js"; +import { Auth } from '../../src/schemas/auth.js'; + +test("repository works standalone", async (t) => { + const app = Fastify(); + + t.after(() => { + app.close(); + // Run the seed script again to clean up after tests + execSync('npm run db:seed'); + }); + + app.register(envPlugin.default, envPlugin.autoConfig); + app.register(mysqlPlugin.default, mysqlPlugin.autoConfig); + app.register(repository); + + await app.ready(); + + // Test find method + const user = await app.repository.find('users', { select: 'username', where: { username: 'basic' } }); + assert.deepStrictEqual(user, { username: 'basic' }); + + const firstUser = await app.repository.find('users', { select: 'username' }); + assert.deepStrictEqual(firstUser, { username: 'basic' }); + + const nullUser = await app.repository.find('users', { select: 'username', where: { username: 'unknown' } }); + assert.equal(nullUser, null); + + // Test findMany method + const users = await app.repository.findMany('users', { select: 'username', where: { username: 'basic' } }); + assert.deepStrictEqual(users, [ + { username: 'basic' } + ]); + + // Test findMany method + const allUsers = await app.repository.findMany('users', { select: 'username' }); + assert.deepStrictEqual(allUsers, [ + { username: 'basic' }, + { username: 'moderator' }, + { username: 'admin' } + ]); + + // Test create method + const newUserId = await app.repository.create('users', { data: { username: 'new_user', password: 'new_password' } }); + const newUser = await app.repository.find('users', { select: 'username', where: { id: newUserId } }); + assert.deepStrictEqual(newUser, { username: 'new_user' }); + + // Test update method + const updateCount = await app.repository.update('users', { data: { password: 'updated_password' }, where: { username: 'new_user' } }); + assert.equal(updateCount, 1); + const updatedUser = await app.repository.find('users', { select: 'password', where: { username: 'new_user' } }); + assert.deepStrictEqual(updatedUser, { password: 'updated_password' }); + + // Test delete method + const deleteCount = await app.repository.delete('users', { username: 'new_user' }); + assert.equal(deleteCount, 1); + const deletedUser = await app.repository.find('users', { select: 'username', where: { username: 'new_user' } }); + assert.equal(deletedUser, null); +}); diff --git a/test/plugins/scrypt.test.ts b/test/plugins/scrypt.test.ts new file mode 100644 index 00000000..5b8e6ac7 --- /dev/null +++ b/test/plugins/scrypt.test.ts @@ -0,0 +1,28 @@ +import { test } from "tap"; +import Fastify from "fastify"; +import scryptPlugin from "../../src/plugins/custom/scrypt.ts"; + +test("scrypt works standalone", async t => { + const app = Fastify(); + + t.teardown(() => app.close()); + + app.register(scryptPlugin); + + await app.ready(); + + const password = "test_password"; + const hash = await app.hash(password); + t.type(hash, 'string'); + + const isValid = await app.compare(password, hash); + t.ok(isValid, 'compare should return true for correct password'); + + const isInvalid = await app.compare("wrong_password", hash); + t.notOk(isInvalid, 'compare should return false for incorrect password'); + + await t.rejects( + () => app.compare(password, "malformed_hash"), + 'compare should throw an error for malformed hash' + ); +}); diff --git a/test/routes/api/api.test.ts b/test/routes/api/api.test.ts new file mode 100644 index 00000000..b41ac46f --- /dev/null +++ b/test/routes/api/api.test.ts @@ -0,0 +1,42 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { build } from "../../helper.js"; + +test("GET /api without authorization header", async (t) => { + const app = await build(t); + + const res = await app.inject({ + url: "/api" + }); + + assert.deepStrictEqual(JSON.parse(res.payload), { + message: "No Authorization was found in request.headers" + }); +}); + +test("GET /api without JWT Token", async (t) => { + const app = await build(t); + + const res = await app.inject({ + method: "GET", + url: "/api", + headers: { + Authorization: "Bearer invalidtoken" + } + }); + + assert.deepStrictEqual(JSON.parse(res.payload), { + message: "Authorization token is invalid: The token is malformed." + }); +}); + +test("GET /api with JWT Token", async (t) => { + const app = await build(t); + + const res = await app.injectWithLogin("basic", { + url: "/api" + }); + + assert.equal(res.statusCode, 200); + assert.ok(JSON.parse(res.payload).message.startsWith("Hello basic!")); +}); diff --git a/test/routes/api/auth/auth.test.ts b/test/routes/api/auth/auth.test.ts new file mode 100644 index 00000000..fbb2f725 --- /dev/null +++ b/test/routes/api/auth/auth.test.ts @@ -0,0 +1,62 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { build } from "../../../helper.js"; + +test("POST /api/auth/login with valid credentials", async (t) => { + const app = await build(t); + + const res = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { + username: "basic", + password: "password" + } + }); + + assert.strictEqual(res.statusCode, 200); + assert.ok(JSON.parse(res.payload).token); +}); + +test("POST /api/auth/login with invalid credentials", async (t) => { + const app = await build(t); + + const testCases = [ + { + username: "invalid_user", + password: "password", + description: "invalid username" + }, + { + username: "basic", + password: "wrong_password", + description: "invalid password" + }, + { + username: "invalid_user", + password: "wrong_password", + description: "both invalid" + } + ]; + + for (const testCase of testCases) { + const res = await app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { + username: testCase.username, + password: testCase.password + } + }); + + assert.strictEqual( + res.statusCode, + 401, + `Failed for case: ${testCase.description}` + ); + + assert.deepStrictEqual(JSON.parse(res.payload), { + message: "Invalid username or password." + }); + } +}); diff --git a/test/routes/api/tasks/tasks.test.ts b/test/routes/api/tasks/tasks.test.ts new file mode 100644 index 00000000..8dc2d33e --- /dev/null +++ b/test/routes/api/tasks/tasks.test.ts @@ -0,0 +1,17 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { build } from "../../../helper.js"; + +test("GET /api/tasks with valid JWT Token should return 200", async (t) => { + const app = await build(t); + + const res = await app.injectWithLogin("basic", { + method: "GET", + url: "/api/tasks" + }); + + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(JSON.parse(res.payload), [ + { id: 1, name: "Do something..." } + ]); +}); diff --git a/test/routes/home.test.ts b/test/routes/home.test.ts new file mode 100644 index 00000000..6132997c --- /dev/null +++ b/test/routes/home.test.ts @@ -0,0 +1,15 @@ +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/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..6fe21f5a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "fastify-tsconfig", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["@types", "src/**/*.ts"] +}