refactor: flatten monorepo structure to backend/ frontend/ devops/

Rename subdirectories for a cleaner single-repo layout:
- website-monitoring-backend/  → backend/
- website-monitoring-frontend/ → frontend/
- website-monitoring-devops/   → devops/

Update all references in package.json scripts, CI workflows,
docker-compose, pre-commit hooks, and documentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Dennis
2026-03-07 00:25:29 +01:00
parent 4607af8def
commit 50e25e3ee8
253 changed files with 54 additions and 51 deletions
+11
View File
@@ -0,0 +1,11 @@
# Server
PORT=5000
# Database (PostgreSQL)
DATABASE_URL=postgresql://user:password@localhost:5432/monitoring
# CORS
CORS_ORIGIN=http://localhost:3000
# Chrome (for Docker/CI)
CHROME_PATH=/usr/bin/chromium
+130
View File
@@ -0,0 +1,130 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2
}
+36
View File
@@ -0,0 +1,36 @@
# --- Stage 1: Build ---
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# --- Stage 2: Production ---
FROM node:20-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends chromium \
&& rm -rf /var/lib/apt/lists/*
ENV CHROME_BIN=/usr/bin/chromium
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
WORKDIR /app
RUN groupadd -r app && useradd -r -g app -d /app app
COPY --from=builder --chown=app:app /app/dist ./dist
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
COPY --from=builder --chown=app:app /app/package.json ./
USER app
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "const h=require('http');h.get('http://localhost:5000/health',(r)=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"
CMD ["node", "dist/index.js"]
+57
View File
@@ -0,0 +1,57 @@
# Website Monitoring Backend
Express.js API server that runs Google Lighthouse audits on websites and streams real-time progress via Server-Sent Events.
## Tech Stack
- **Runtime**: Node.js 18+
- **Framework**: Express.js
- **Language**: TypeScript
- **Auditing**: Google Lighthouse + Chrome Headless
- **Database**: PostgreSQL (via `pg`)
## Quick Start
```bash
cp .env.example .env
npm install
npm run build
npm start
```
## Scripts
| Script | Description |
|--------|-------------|
| `npm run build` | Compile TypeScript to `dist/` |
| `npm start` | Run the production server |
| `npm run dev` | Watch mode for development |
| `npm test` | Run Jest tests |
| `npm run test:coverage` | Run tests with coverage report |
| `npm run lint` | Run ESLint |
| `npm run format` | Format code with Prettier |
## API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/` | API info |
| `GET` | `/health` | Health check |
| `POST` | `/api/lighthouse` | Start Lighthouse audit (body: `{ "url": "https://example.com" }`) |
| `GET` | `/api/lighthouse/status/:id` | SSE stream for audit progress |
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `5000` | Server port |
| `DATABASE_URL` | — | PostgreSQL connection string |
| `CORS_ORIGIN` | `*` | Allowed CORS origin |
| `CHROME_PATH` | — | Path to Chrome binary (Docker) |
## Docker
```bash
docker build -t website-monitoring-backend .
docker run -p 5000:5000 website-monitoring-backend
```
+23
View File
@@ -0,0 +1,23 @@
import eslint from "@typescript-eslint/eslint-plugin";
import parser from "@typescript-eslint/parser";
export default [
{
files: ["src/**/*.ts"],
languageOptions: {
parser,
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
},
},
plugins: {
"@typescript-eslint": eslint,
},
rules: {
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
"no-console": "off",
},
},
];
+21
View File
@@ -0,0 +1,21 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
preset: "ts-jest/presets/default-esm",
testEnvironment: "node",
roots: ["<rootDir>/src"],
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
useESM: true,
},
],
},
extensionsToTreatAsEsm: [".ts"],
collectCoverageFrom: ["src/**/*.ts", "!src/**/*.test.ts"],
coverageDirectory: "coverage",
coverageReporters: ["text", "lcov", "clover"],
};
+9368
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
{
"name": "website-monitoring-backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"test": "NODE_OPTIONS='--experimental-vm-modules' jest --passWithNoTests",
"test:coverage": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write src/"
},
"repository": {
"type": "git",
"url": "git+https://github.com/denshooter/website-monitoring-backend.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/denshooter/website-monitoring-backend/issues"
},
"homepage": "https://github.com/denshooter/website-monitoring-backend#readme",
"dependencies": {
"chrome-launcher": "^1.1.2",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"lighthouse": "^12.4.0",
"lighthouse-logger": "^2.0.1",
"pg": "^8.13.3",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^30.0.0",
"@types/node": "^22.13.9",
"@types/supertest": "^7.2.0",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"jest": "^30.2.0",
"prettier": "^3.8.1",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
"tsx": "^4.21.0"
}
}
+32
View File
@@ -0,0 +1,32 @@
import request from "supertest";
import { app } from "../index.js";
describe("API Server", () => {
describe("GET /health", () => {
it("should return 200 with status ok", async () => {
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body.status).toBe("ok");
expect(res.body.timestamp).toBeDefined();
});
});
describe("GET /", () => {
it("should return API info", async () => {
const res = await request(app).get("/");
expect(res.status).toBe(200);
expect(res.body.name).toBe("Website Monitoring API");
expect(res.body.version).toBe("1.0.0");
expect(res.body.endpoints).toContain("/health");
expect(res.body.endpoints).toContain("/api/lighthouse");
});
});
describe("POST /api/lighthouse", () => {
it("should return 400 when no URL provided", async () => {
const res = await request(app).post("/api/lighthouse").send({});
expect(res.status).toBe(400);
expect(res.body.error).toBe("Missing URL");
});
});
});
+79
View File
@@ -0,0 +1,79 @@
import request from "supertest";
import { app } from "../index.js";
describe("Lighthouse API", () => {
describe("POST /api/lighthouse", () => {
it("should return 400 when body is empty", async () => {
const res = await request(app).post("/api/lighthouse").send({});
expect(res.status).toBe(400);
expect(res.body.error).toBe("Missing URL");
});
it("should return 400 when URL is missing from body", async () => {
const res = await request(app)
.post("/api/lighthouse")
.send({ notUrl: "something" });
expect(res.status).toBe(400);
expect(res.body.error).toBe("Missing URL");
});
it("should accept a valid URL and return a clientId", async () => {
// This test will get a clientId back but Chrome won't be available in test
// The endpoint returns 200 with clientId before starting Chrome
const res = await request(app)
.post("/api/lighthouse")
.send({ url: "https://example.com" });
expect(res.status).toBe(200);
expect(res.body.clientId).toBeDefined();
expect(typeof res.body.clientId).toBe("string");
});
});
describe("GET /api/lighthouse/status/:id", () => {
it("should be a valid SSE endpoint", () => {
// SSE endpoints keep connections open, so we just verify the route exists
// Full SSE testing would require a dedicated SSE test client
expect(true).toBe(true);
});
});
});
describe("Health & Info Endpoints", () => {
describe("GET /health", () => {
it("should return status ok with timestamp", async () => {
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
status: "ok",
});
expect(res.body.timestamp).toBeDefined();
// Timestamp should be valid ISO string
expect(new Date(res.body.timestamp).toISOString()).toBe(res.body.timestamp);
});
});
describe("GET /", () => {
it("should return API metadata", async () => {
const res = await request(app).get("/");
expect(res.status).toBe(200);
expect(res.body.name).toBe("Website Monitoring API");
expect(res.body.version).toMatch(/^\d+\.\d+\.\d+$/);
expect(Array.isArray(res.body.endpoints)).toBe(true);
});
});
describe("404 handling", () => {
it("should return 404 for unknown routes", async () => {
const res = await request(app).get("/api/unknown");
expect(res.status).toBe(404);
});
});
describe("CORS", () => {
it("should include CORS headers", async () => {
const res = await request(app).get("/health");
// CORS middleware is enabled
expect(res.status).toBe(200);
});
});
});
+58
View File
@@ -0,0 +1,58 @@
import express, { Request, Response } from "express";
import cors from "cors";
import dotenv from "dotenv";
import lighthouseRouter from "./routes/lighthouse.js";
dotenv.config();
// Rate limiting (simple in-memory for single instance)
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT_WINDOW = 60_000; // 1 minute
const RATE_LIMIT_MAX = 30; // requests per window
function rateLimit(req: Request, res: Response, next: () => void) {
const ip = req.ip || req.headers["x-forwarded-for"] || "unknown";
const key = String(ip);
const now = Date.now();
const entry = rateLimitMap.get(key);
if (!entry || now > entry.resetAt) {
rateLimitMap.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
return next();
}
if (entry.count >= RATE_LIMIT_MAX) {
res.status(429).json({ error: "Too many requests" });
return;
}
entry.count++;
next();
}
const app = express();
app.use(cors({ origin: process.env.CORS_ORIGIN || "*" }));
app.use(express.json());
app.use(rateLimit);
app.get("/health", (_req: Request, res: Response) => {
res.status(200).json({ status: "ok", timestamp: new Date().toISOString() });
});
app.get("/", (_req: Request, res: Response) => {
res.status(200).json({
name: "Website Monitoring API",
version: "1.0.0",
endpoints: ["/health", "/api/lighthouse"],
});
});
app.use("/api/lighthouse", lighthouseRouter);
export { app };
if (process.env.NODE_ENV !== "test") {
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
}
+174
View File
@@ -0,0 +1,174 @@
import express, { Request, Response } from "express";
import { v4 as uuidv4 } from "uuid";
const router = express.Router();
const progressClients = new Map<string, Response>();
/** Send SSE progress data to the browser */
function sendProgress(clientId: string, data: any) {
const client = progressClients.get(clientId);
if (client) {
client.write(`data: ${JSON.stringify(data)}\n\n`);
}
}
// SSE endpoint for progress
router.get("/status/:id", (req: Request, res: Response) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("Access-Control-Allow-Origin", "*");
const clientId = req.params.id;
progressClients.set(clientId, res);
// Send an initial event
sendProgress(clientId, {
status: "Connected",
progress: 0,
stage: "setup",
});
req.on("close", () => {
progressClients.delete(clientId);
});
});
router.post("/", async (req: Request, res: Response) => {
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: "Missing URL" });
}
const clientId = uuidv4(); // Generate a UUID
console.log(`New client ID: ${clientId}`);
let currentProgress = 0;
let auditCount = 0;
let totalAudits = 0;
try {
// Immediately tell the frontend were starting (0%)
sendProgress(clientId, {
status: "Starting analysis...",
progress: 0,
stage: "setup",
});
// Return clientId so the frontend can open the SSE channel
res.status(200).json({ clientId });
// Dynamically import chrome-launcher and lighthouse
const { launch } = await import("chrome-launcher");
const lighthouse = (await import("lighthouse")).default;
const log = (await import("lighthouse-logger")).default;
// Launch Chrome
const chrome = await launch({
chromeFlags: ["--headless", "--no-sandbox", "--disable-dev-shm-usage"],
});
// Update progress to 10% after launching Chrome
currentProgress = 10;
sendProgress(clientId, {
status: "Chrome launched",
progress: currentProgress,
stage: "setup",
});
// Turn on Lighthouse logs at "info" level
log.setLevel("info");
// Listen for Lighthouse status events
log.events.addListener("status", (lhStatus) => {
let msg = lhStatus[1].toLowerCase();
let newProgress = currentProgress;
let newStage = "analyzing";
if (msg.includes("initialize config")) {
newProgress = Math.max(newProgress, 15);
newStage = "setup";
} else if (msg.includes("resolve artifact definitions")) {
newProgress = Math.max(newProgress, 20);
newStage = "setup";
} else if (msg.includes("gather phase")) {
newProgress = Math.max(newProgress, 25);
newStage = "analyzing";
} else if (msg.includes("connecting to browser")) {
newProgress = Math.max(newProgress, 28);
newStage = "analyzing";
} else if (msg.includes("navigating to")) {
newProgress = Math.max(newProgress, 30);
newStage = "analyzing";
} else if (msg.includes("benchmarking machine")) {
newProgress = Math.max(newProgress, 35);
newStage = "analyzing";
} else if (msg.includes("getting artifact")) {
newProgress = Math.max(newProgress, 40);
newStage = "analyzing";
} else if (msg.includes("computing artifact")) {
newProgress = Math.max(newProgress, 50);
newStage = "analyzing";
} else if (msg.includes("audit phase")) {
newProgress = Math.max(newProgress, 60);
newStage = "auditing";
} else if (msg.includes("auditing:")) {
auditCount++;
totalAudits = Math.max(totalAudits, auditCount);
newProgress = Math.max(
newProgress,
60 + (auditCount / totalAudits) * 30,
);
newStage = "auditing";
} else if (msg.includes("generating results")) {
newProgress = Math.max(newProgress, 95);
newStage = "finishing";
}
// Add some randomness to the progress increments
newProgress += Math.random() * 2;
// Only send progress if it actually increased
if (newProgress > currentProgress) {
currentProgress = newProgress;
sendProgress(clientId, {
status: lhStatus.message || "Processing...",
progress: currentProgress,
stage: newStage,
});
}
});
// Run Lighthouse
const runnerResult = await lighthouse(url, {
port: chrome.port,
output: "json",
logLevel: "info",
onlyCategories: ["performance", "seo", "accessibility", "best-practices"],
});
if (!runnerResult) {
throw new Error("Lighthouse returned no result");
}
// Finish progress
sendProgress(clientId, {
status: "Analysis complete",
progress: 100,
stage: "complete",
report: runnerResult.lhr,
});
await chrome.kill();
} catch (error) {
console.error("Lighthouse error:", error);
sendProgress(clientId, {
status: "Error occurred",
progress: 0,
stage: "error",
error:
error instanceof Error ? error.message : "An unknown error occurred",
});
}
});
export default router;
+14
View File
@@ -0,0 +1,14 @@
// filepath: /c:/Users/denni/OneDrive/Dokumente/code/website-monitoring/backend/tsconfig.json
{
"compilerOptions": {
"module": "ESNext",
"target": "es2018",
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist"
},
"include": ["src"]
}