Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ MYSQL_PASSWORD=test_password
# Server
FASTIFY_CLOSE_GRACE_DELAY=1000
LOG_LEVEL=info
FASTIFY_VITE_DEV_MODE=false

# Security
JWT_SECRET=
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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
15 changes: 11 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 src --ignore-pattern='src/client/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"
Expand All @@ -36,15 +37,21 @@
"@fastify/swagger-ui": "^4.0.1",
"@fastify/type-provider-typebox": "^4.0.0",
"@fastify/under-pressure": "^8.3.0",
"@fastify/vite": "^6.0.7",
"@sinclair/typebox": "^0.33.7",
"@vitejs/plugin-react": "^4.3.1",
"concurrently": "^8.2.2",
"fastify": "^4.26.1",
"fastify-cli": "^6.1.1",
"fastify-plugin": "^4.0.0",
"postgrator": "^7.2.0"
"postgrator": "^7.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
"eslint": "^9.4.0",
"fastify-tsconfig": "^2.0.0",
"mysql2": "^3.10.1",
Expand Down
99 changes: 66 additions & 33 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
import path from "node:path";
import fastifyAutoload from "@fastify/autoload";
import { FastifyInstance, FastifyPluginOptions } from "fastify";
import fastifyVite from "@fastify/vite";

export const options = {
ajv: {
customOptions: {
coerceTypes: "array",
coerceTypes: "array",
removeAdditional: "all"
}
}
Expand All @@ -19,29 +20,30 @@ export default async function serviceApp(
fastify: FastifyInstance,
opts: FastifyPluginOptions
) {
delete opts.skipOverride // This option only serves testing purpose
const { avoidViteRegistration = 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) => {
Expand All @@ -58,40 +60,71 @@ export default async function serviceApp(
"Unhandled error occurred"
);

reply.code(err.statusCode ?? 500);
const statusCode = err.statusCode ?? 500;
reply.code(statusCode);

let message = "Internal Server Error";
if (err.statusCode === 401) {
message = err.message;
}

return { message };
return { message: "Internal Server Error" };
});

// An attacker could search for valid URLs if your 404 error handling is not rate limited.
fastify.setNotFoundHandler(
{
preHandler: fastify.rateLimit({
max: 3,
timeWindow: 500
})
},
{
preHandler: fastify.rateLimit({
max: 3,
timeWindow: 500
})
},
(request, reply) => {
request.log.warn(
{
request: {
method: request.method,
url: request.url,
query: request.query,
params: request.params
}
},
"Resource not found"
);

request.log.warn(
{
request: {
method: request.method,
url: request.url,
query: request.query,
params: request.params
}
},
"Resource not found"
);
reply.code(404);

reply.code(404);
return { message: "Not Found" };
}
);

return { message: "Not Found" };
await handleVite(fastify, {
register: !avoidViteRegistration
});
}

async function handleVite(
fastify: FastifyInstance,
{ register }: { register: boolean }
) {
if (!register) {
// Route must match vite "base": https://vitejs.dev/config/shared-options.html#base
fastify.get("/", () => {
return "Vite is not registered.";
});

return;
}
/* 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 */
}
5 changes: 5 additions & 0 deletions src/client/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function App () {
return (
<p>Welcome to the official Fastify demo!</p>
)
}
13 changes: 13 additions & 0 deletions src/client/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link data-rh="true" rel="icon" href="/favicon.ico" />
<title>Fastify demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/mount.tsx"></script>
</body>
</html>
8 changes: 8 additions & 0 deletions src/client/mount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createRoot } from "react-dom/client";
import { App } from "./App";

const rootElement =
document.getElementById("root") || document.createElement("div");

const root = createRoot(rootElement);
root.render(<App />);
Binary file added src/client/public/favicon.ico
Binary file not shown.
24 changes: 24 additions & 0 deletions src/client/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"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": ["**/*"],
"exclude": ["dist"]
}
1 change: 1 addition & 0 deletions src/client/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
7 changes: 7 additions & 0 deletions src/plugins/external/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ declare module "fastify" {
MYSQL_DATABASE: string;
JWT_SECRET: string;
RATE_LIMIT_MAX: number;
FASTIFY_VITE_DEV_MODE: boolean;
};
}
}
Expand Down Expand Up @@ -52,6 +53,12 @@ const schema = {
RATE_LIMIT_MAX: {
type: "number",
default: 100
},

// Frontend
FASTIFY_VITE_DEV_MODE: {
type: "boolean",
default: true
}
}
};
Expand Down
24 changes: 0 additions & 24 deletions src/routes/home.ts

This file was deleted.

3 changes: 2 additions & 1 deletion test/app/rate-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ it("should be rate limited", async (t) => {
url: "/"
});

assert.strictEqual(res.statusCode, 200);
assert.equal(res.body, "Vite is not registered.")
assert.strictEqual(res.statusCode, 200);
}

const res = await app.inject({
Expand Down
3 changes: 2 additions & 1 deletion test/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,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
avoidViteRegistration : true
};
}

Expand Down
10 changes: 4 additions & 6 deletions test/routes/api/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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) => {
Expand Down
14 changes: 0 additions & 14 deletions test/routes/home.test.ts

This file was deleted.

3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
"outDir": "dist",
"rootDir": "src"
},
"include": ["@types", "src/**/*.ts"]
"include": ["@types", "src/**/*.ts"],
"exclude": ["src/client/**/*"]
}
Loading