Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Shared
NODE_ENV=development

# Backend
PORT=4000
API_BASE_URL=/api
LOG_LEVEL=info
TLS_CERT_PATH=
TLS_KEY_PATH=
PROMETHEUS_METRICS=true
ALLOWED_ORIGINS=http://localhost:5173

# Frontend
VITE_API_BASE_URL=http://localhost:4000/api
46 changes: 46 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
module.exports = {
root: true,
env: {
es2021: true,
node: true,
browser: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['@typescript-eslint', 'prettier', 'react', 'react-hooks', 'jsx-a11y', 'import'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:prettier/recommended',
],
settings: {
react: {
version: 'detect',
},
'import/resolver': {
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
rules: {
'prettier/prettier': 'error',
'react/react-in-jsx-scope': 'off',
'import/order': [
'warn',
{
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
'newlines-between': 'always',
},
],
},
ignorePatterns: ['dist', 'node_modules'],
};
20 changes: 20 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
* text=auto eol=lf

# Treat lockfiles and generated assets as union merges to reduce manual conflict resolution
package-lock.json merge=union
npm-shrinkwrap.json merge=union
yarn.lock merge=union
pnpm-lock.yaml merge=union

# Preserve binaries
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.eot binary
*.ttf binary
*.woff binary
*.woff2 binary
*.zip binary
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm run lint

test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm run test

build:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm run build
- name: Upload frontend build
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: frontend/dist
- name: Upload backend bundle
uses: actions/upload-artifact@v4
with:
name: backend-dist
path: backend/dist
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
node_modules
npm-debug.log*
dist
coverage
.env
.env.local
.DS_Store
/frontend/node_modules
/backend/node_modules
/frontend/dist
/backend/dist
.husky/_
6 changes: 6 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": true
}
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,34 @@
# EmotionExplorerGame
# Emotion Explorer Game

A monorepo with a React + TypeScript frontend and an Express + TypeScript backend. The project ships with ESLint/Prettier, Jest-based unit/integration tests, GitHub Actions CI, and deployment scripts for TLS-enabled hosting on AWS or Heroku.

## Getting Started

```bash
npm install
npm run lint
npm run test
npm run build
```

- Frontend dev server: `npm run dev --workspace frontend`
- Backend dev server: `npm run dev --workspace backend`

Environment variables live in `.env` (see `.env.example`). Frontend expects `VITE_API_BASE_URL`; backend supports TLS via `TLS_CERT_PATH` and `TLS_KEY_PATH`.

## Testing

- Frontend unit & integration tests: `npm run test --workspace frontend`
- Backend API tests: `npm run test --workspace backend`

## Deployment

- **Heroku:** Use `backend/scripts/heroku-deploy.sh` to push the Dockerized backend. TLS is handled via platform certificates; the app also supports direct HTTPS when `TLS_CERT_PATH`/`TLS_KEY_PATH` are provided.
- **AWS Elastic Beanstalk:** Deploy with `backend/deployment/aws/Dockerrun.aws.json`, terminating TLS at the load balancer (ACM). CloudWatch and the `/metrics` endpoint enable monitoring.

The frontend ships a service worker for caching and Vite code-splitting configuration with lazy-loaded UI components for better performance.

## Merge conflict tips
- The repo includes a `.gitattributes` that applies a union merge to common lockfiles to reduce churn when multiple contributors update dependencies.
- Keep environment files (`.env`, `.env.local`) untracked and regenerate build outputs instead of merging generated artifacts.
- If a conflict appears in compiled `dist` assets, prefer rebuilding locally from `ts`/`tsx` sources after resolving the merge.
14 changes: 14 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/package*.json ./
RUN npm install --omit=dev
COPY --from=build /app/dist ./dist
ENV PORT=4000
CMD ["node", "dist/index.js"]
1 change: 1 addition & 0 deletions backend/Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: node dist/index.js
23 changes: 23 additions & 0 deletions backend/deployment/aws/Dockerrun.aws.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"AWSEBDockerrunVersion": 2,
"containerDefinitions": [
{
"name": "emotion-explorer-api",
"image": "public.ecr.aws/docker/library/node:20-alpine",
"essential": true,
"memory": 256,
"portMappings": [
{
"containerPort": 4000,
"hostPort": 80
}
],
"environment": [
{ "name": "PORT", "value": "4000" },
{ "name": "API_BASE_URL", "value": "/api" },
{ "name": "PROMETHEUS_METRICS", "value": "true" }
],
"command": ["node", "dist/index.js"]
}
]
}
6 changes: 6 additions & 0 deletions backend/deployment/aws/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# AWS Deployment (Elastic Beanstalk)

1. Build and bundle the backend image using the provided `backend/Dockerfile` and push to ECR.
2. Provision an Application Load Balancer with an ACM certificate attached for TLS termination.
3. Deploy the generated image with `Dockerrun.aws.json` to Elastic Beanstalk. Ensure environment variables (see `.env.example`) are provided and health checks point to `/health`.
4. Enable CloudWatch Logs and Metrics for monitoring, scraping `/metrics` for Prometheus-compatible collectors.
9 changes: 9 additions & 0 deletions backend/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Config } from 'jest';

const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
};

export default config;
37 changes: 37 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "emotion-explorer-backend",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"main": "dist/index.js",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc -p tsconfig.json",
"lint": "eslint src --ext .ts",
"test": "jest",
"start": "node dist/index.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
"prom-client": "^15.1.3"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/morgan": "^1.9.9",
"@types/node": "^20.12.7",
"@typescript-eslint/eslint-plugin": "^7.7.1",
"@typescript-eslint/parser": "^7.7.1",
"jest": "^29.7.0",
"supertest": "^6.4.2",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.5"
}
}
15 changes: 15 additions & 0 deletions backend/scripts/heroku-deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail

# Build and deploy to Heroku using container registry.
# Requires HEROKU_APP_NAME and HEROKU_AUTH_TOKEN env vars.

if [[ -z "${HEROKU_APP_NAME:-}" || -z "${HEROKU_AUTH_TOKEN:-}" ]]; then
echo "HEROKU_APP_NAME and HEROKU_AUTH_TOKEN must be set" >&2
exit 1
fi

heroku container:login <<<"${HEROKU_AUTH_TOKEN}"
docker build -t registry.heroku.com/${HEROKU_APP_NAME}/web -f backend/Dockerfile backend
heroku container:push web --app ${HEROKU_APP_NAME}
heroku container:release web --app ${HEROKU_APP_NAME}
12 changes: 12 additions & 0 deletions backend/src/__tests__/emotions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import request from 'supertest';
import app from '../index';

describe('GET /api/emotions', () => {
it('returns seeded emotions and metrics are accessible', async () => {
const response = await request(app).get('/api/emotions');

expect(response.status).toBe(200);
expect(response.body.length).toBeGreaterThan(0);
expect(response.body[0]).toHaveProperty('name');
});
});
25 changes: 25 additions & 0 deletions backend/src/config/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import dotenv from 'dotenv';

dotenv.config();

export interface Environment {
port: number;
apiBasePath: string;
logLevel: string;
tlsKeyPath?: string;
tlsCertPath?: string;
allowedOrigins: string[];
enableMetrics: boolean;
}

const parseBoolean = (value?: string) => value === 'true' || value === '1';

export const env: Environment = {
port: parseInt(process.env.PORT || '4000', 10),
apiBasePath: process.env.API_BASE_URL || '/api',
logLevel: process.env.LOG_LEVEL || 'info',
tlsKeyPath: process.env.TLS_KEY_PATH,
tlsCertPath: process.env.TLS_CERT_PATH,
allowedOrigins: (process.env.ALLOWED_ORIGINS || '').split(',').filter(Boolean),
enableMetrics: parseBoolean(process.env.PROMETHEUS_METRICS || 'true'),
};
45 changes: 45 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import fs from 'fs';
import http from 'http';
import https from 'https';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import morgan from 'morgan';
import { env } from './config/environment';
import emotionsRouter from './routes/emotions';
import { metricsHandler } from './monitoring/metrics';

const app = express();

app.use(express.json());
app.use(helmet());
app.use(cors({ origin: env.allowedOrigins.length ? env.allowedOrigins : undefined }));
app.use(morgan(env.logLevel));

app.get('/health', (_req, res) => res.json({ status: 'ok' }));

if (env.enableMetrics) {
app.get('/metrics', metricsHandler);
}

app.use(env.apiBasePath, emotionsRouter);

const startServer = () => {
if (env.tlsCertPath && env.tlsKeyPath && fs.existsSync(env.tlsCertPath) && fs.existsSync(env.tlsKeyPath)) {
const key = fs.readFileSync(env.tlsKeyPath);
const cert = fs.readFileSync(env.tlsCertPath);
https.createServer({ key, cert }, app).listen(env.port, () => {
console.log(`HTTPS server listening on port ${env.port}`);
});
} else {
http.createServer(app).listen(env.port, () => {
console.log(`HTTP server listening on port ${env.port}`);
});
}
};

if (process.env.NODE_ENV !== 'test') {
startServer();
}

export default app;
Loading