1
0

Updated docker and server configs

This commit is contained in:
James Ketr 2025-09-23 12:26:34 -07:00
parent eb39a175df
commit b553cdc656
17 changed files with 511 additions and 149 deletions

View File

@ -1,5 +1,7 @@
*
!server
server/node_modules
!server/
!client/
server/node_modules/
client/node_modules/
!Dockerfile
!.env

48
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,48 @@
Do not run npm or create local node_modules in this repository
============================================================
This repository is designed to run inside containers. Do NOT run `npm install`,
`npm ci`, or otherwise modify `node_modules` or lockfiles locally on your
machine. Doing so will create environment drift and can break container-based
builds and CI.
Guidelines for contributors and automated assistants
- Use the provided Docker and docker-compose workflows for development and
building. The repository's `Dockerfile` and `docker-compose.yml` are the
canonical way to install dependencies and run the app.
- If you need to run a command locally for quick checks, prefer run commands in
the project's container environment. Example (copy-paste):
```bash
docker compose run --rm peddlers-of-ketran bash
# inside container
npm install
npm run build
```
- For lightweight commands, you can run one-off containers:
```bash
docker run --rm -v "$PWD":/app -w /app node:20 bash -lc 'cd client && npm ci && npm run type-check'
```
- Do not suggest or perform `npm install` or `npm ci` on the host machine in
repository files, PRs, or automated edits. If a change requires dependency
updates, update package.json and lockfiles via a CI job or in-container run,
and record the change in a PR.
Copilot / Automation specific hints
- When editing package.json, also update the appropriate lockfile using the
container workflow and include lockfile changes in the same PR.
- If a change requires running tests or builds, prefer to add a GitHub Action
that runs the command inside the standard container image rather than
instructing contributors to run commands locally.
Why this matters
- Containers provide a reproducible environment and prevent developer
machines from diverging. They ensure CI and production builds are consistent.
If you need a recommended local development setup or a dev container (VS Code
Remote - Containers), create a `.devcontainer/` configuration and include it in
the repo. That provides a supported local dev workflow that still uses
containerized tooling.

32
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,32 @@
name: CI
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
server-build:
runs-on: ubuntu-latest
name: Server build and type-check (container)
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Server: Type-check and Build (inside node:20)
run: |
docker run --rm -v "$PWD":/work -w /work node:20 bash -lc '
cd server && npm ci --no-audit --no-fund && npm run type-check && npm run build'
client-build:
runs-on: ubuntu-latest
name: Client type-check and build (container)
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Client: Type-check and Build (inside node:20)
run: |
docker run --rm -v "$PWD":/work -w /work node:20 bash -lc '
cd client && npm ci --no-audit --no-fund && npm run type-check && npm run build'

10
.gitignore vendored
View File

@ -1,4 +1,12 @@
!.gitignore
/.ssh/
/client/node_modules/
/server/node_modules/
/node_modules/
/dist/
/build/
.env
.DS_Store
/.vscode/!.gitignore
!package.json
node_modules
package-lock.json

View File

@ -1,4 +1,4 @@
FROM ubuntu:jammy
FROM ubuntu:noble
RUN apt-get -q update \
&& DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \
@ -10,7 +10,7 @@ RUN apt-get -q update \
RUN mkdir -p /etc/apt/keyrings
RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
ENV NODE_MAJOR=20
ENV NODE_MAJOR=22
RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
RUN apt-get -q update \
@ -25,12 +25,26 @@ RUN apt-get -q update \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/{apt,dpkg,cache,log}
COPY /server /server
COPY server /server
WORKDIR /server
RUN npm install -s sqlite3
RUN npm install
RUN npm run build
# prepare client deps in the image so lint/type-check can run inside the container
# copy client sources and install dependencies during the image build (container-first)
COPY client /client
WORKDIR /client
ENV PUBLIC_URL="/ketr.ketran"
ENV REACT_APP_API_BASE="/ketr.ketran"
# prefer npm ci when lockfile present, otherwise fall back to npm install
RUN rm -f package-lock.json
RUN npm install --legacy-peer-deps --no-audit --no-fund
RUN npm run build
# return to server working dir for default run
WORKDIR /server
COPY /Dockerfile /Dockerfile
COPY /.env /.env
ENTRYPOINT [ "npm", "start" ]
CMD ["npm", "start"]

26
Dockerfile.server Normal file
View File

@ -0,0 +1,26 @@
## Multi-stage Dockerfile for building the TypeScript server
FROM node:20-alpine AS builder
WORKDIR /
# Copy package files and install (in container only)
COPY server/package*.json ./server/
WORKDIR /server
RUN npm ci --silent
# Copy server source and build
COPY server/ ./
RUN npm run build
## Production image
FROM node:20-alpine AS runtime
WORKDIR /
# Copy built server
COPY --from=builder /server/dist ./server/dist
COPY server/package*.json /server/
WORKDIR /server
ENV NODE_ENV=production
EXPOSE 8930
CMD ["npm", "start"]

239
README.md
View File

@ -1,39 +1,115 @@
# Peddlers of Ketran
This project consists of both the front-end and back-end game
API server.
This project consists of both the front-end React client and back-end Node.js game API server for the Settlers of Catan-style board game.
The front-end is launched from the 'client' directory in development mode via 'npm start'. In production, you build
it via 'npm build' and deploy the public front-end.
The application is designed to run inside Docker containers. Do not run `npm install` or modify `node_modules` locally, as this can create environment drift. All development and building should be done through the provided Docker workflows.
The back-end is launched out of the 'server' directory via 'npm start' and will
bind to the default port 8930.
## Development
If you change the default port of the REST API server, you will need to change
client/package.json's "proxy" value to reflect the new port change.
For live development with hot reloading, React DevTools support, and TypeScript compilation on-the-fly.
NOTE:
### Prerequisites
Board.js currently hard codes assetsPath and gamesPath to be absolute as the
dynamic router and resource / asset loading isn't working correctly.
- Docker
- Docker Compose
## Building
### Running the Server (Backend)
### Native
#### Prerequisites
The server uses TypeScript with hot reloading via `ts-node-dev`.
```bash
sudo apt-get install -y nodejs npm python
sudo -E npm install --global npm@latest
docker compose -f docker-compose.dev.yml up
```
### In container
This will:
- Build the server container using `Dockerfile.server`
- Mount the `server/` directory for live code changes
- Run `npm run start:dev` which uses `ts-node-dev` for hot reloading
- Expose the server on port 8930
### Testing the Server
The server uses Jest for unit testing. Tests are located in `server/tests/`.
To run tests:
```bash
docker compose run --rm server npm test
```
# Architecture
This will run all test files matching `*.test.ts` in the `server/tests/` directory.
To run tests in watch mode:
```bash
docker compose run --rm server npm run test:watch
```
Note: If you add `test:watch` script, but for now, it's just `test`.
### Running the Client (Frontend)
The client is a React application with hot reloading and debug support.
```bash
docker compose -f docker-compose.client.dev.yml up
```
This will:
- Mount the `client/` directory for live code changes
- Run `npm start` in development mode
- Enable React DevTools and hot module replacement
- Expose the client on port 3001 (HTTPS)
- Proxy API calls to the server at `http://host.docker.internal:8930`
### Full Development Stack
To run both server and client together:
```bash
# Terminal 1: Server
docker compose -f docker-compose.dev.yml up
# Terminal 2: Client
docker compose -f docker-compose.client.dev.yml up
```
Open `https://localhost:3001` in your browser. The client will hot-reload on code changes, and the server will restart automatically on backend changes.
## Production
For production deployment, the client is built as static files and served directly by the Node.js server.
### Building
```bash
docker compose build
```
This builds the production image using `Dockerfile`, which:
- Installs server dependencies and builds TypeScript to `dist/`
- Installs client dependencies and builds static files to `client/build/`
- Copies the built client files to be served by the server
### Running
```bash
docker compose up
```
This will:
- Start the server on port 8930
- Serve the built client static files
- Mount the `db/` directory for persistent database storage
- Mount `server/routes/` for dynamic route files
The application will be available at `http://localhost:8930`.
### Environment Variables
Create a `.env` file in the project root with any required environment variables. The server start script loads these via `export $(cat ../.env | xargs)`.
## Architecture
```plantuml
skinparam componentStyle rectangle
@ -48,99 +124,40 @@ package "Game" as game {
component Players as players
}
server <-> resource : serves to client
client <-> server
server <-> resource : serves static files to client
client <-> server : API calls
player <-> client
players -r-> player
server -> game
```
# Ketr.Ketran REST API
## API Documentation
## POST /api/v1/game
### POST /api/v1/game
### Request
#### Request
```json
{}
```
### Response
#### Response
```json
{
gameId: id
gameState: {
tiles: []
"gameId": "id",
"gameState": {
"tiles": []
}
}
```
# Configuring / installing
## Configuration
## Build
Add security tokens in `server/config/local.json`:
```bash
git clone git.ketrenos.com:jketreno/peddlers-of-ketran.git
cd server
npm install
npm start &
cd ../client
npm install
npm start
```
## Install
```bash
export BASEPATH=${PWD}
# Ensure sudo has password ready
sudo -l
sudo rsync -avprl install/ /etc/
sudo touch /var/log/ketr-ketran.log
sudo chown system:adm /var/log/ketr-ketran.log
sudo systemctl daemon-reload
sudo systemctl restart rsyslogd
sudo systemctl restart logrotate
sudo systemctl restart ketr.ketran
```
Install the following into your nginx server configuration:
```nginx
location /ketr.ketran {
root /var/www/ketrenos.com;
index unresolvable-file-html.html;
try_files $uri @index;
}
# This seperate location is so the no cache policy only applies to the index and nothing else.
location @index {
root /var/www/ketrenos.com/ketr.ketran;
add_header Cache-Control no-cache;
expires 0;
try_files /index.html =404;
}
location /ketr.ketran/api {
proxy_pass http://192.168.1.78:8930;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true;
proxy_pass_header Set-Cookie;
proxy_pass_header P3P;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```
Add security tokens in ketr.ketran/config/local.json:
```bash
cat << EOF > config/local.json
cat << EOF > server/config/local.json
{
"tokens": [ {
"$(whoami)": "$(< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c${1:-32};echo;)"
@ -149,45 +166,25 @@ cat << EOF > config/local.json
EOF
```
## Launch
## Testing
### Create New Game
```bash
sudo systemctl start ketr.ketran
curl -X POST http://localhost:8930/api/v1/games/
```
## To test
### New game
### Get Game Status
```bash
curl -k -s -X POST http://localhost:8930/ketr.ketran/api/v1/games/
curl -X GET http://localhost:8930/api/v1/games/:id
```
### Game status
## Game States
```bash
curl -k -s -X GET http://localhost:8930/ketr.ketran/api/v1/games/:id
```
- **Lobby**: Players register, choose colors, roll for position, set ready
- **Active**: Gameplay phase
# States
Chat is available at all times for registered players.
Chat is available at all times by registered players
## License Attribution
## Lobby
* Register session+name
* Register session with color
* Unregister player+name from color
* Roll dice for player position
* Shuffle board
* Set "Ready" for player
* All ready? state == active
## License attribution
The dice faces (dice-six-faces-*.svg) are Copyright https://delapouite.com/ and licensed
as [CC-BY-3.0](https://creativecommons.org/licenses/by/3.0/).
## Active
*
The dice faces (dice-six-faces-*.svg) are Copyright [https://delapouite.com/](https://delapouite.com/) and licensed as [CC-BY-3.0](https://creativecommons.org/licenses/by/3.0/).

View File

@ -0,0 +1,22 @@
services:
peddlers-client:
build:
context: .
dockerfile: Dockerfile
container_name: peddlers-client
working_dir: /client
volumes:
- ./client:/client:rw
ports:
- 3001:3000
environment:
- BROWSER=none
- HTTPS=true
- HOST=0.0.0.0
command: ["bash", "-c", "cd /client && npm install --legacy-peer-deps --silent --no-audit --no-fund && npm start"]
networks:
- peddlers-network
networks:
peddlers-network:
driver: bridge

21
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,21 @@
services:
peddlers-of-ketran:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- ./server:/server:rw
- ./db:/db:rw
#- ./server/node_modules:app/server/node_modules:rw
command: ["sh", "-c", "cd /server && npm install --no-audit --no-fund --silent && npm run start:dev"]
ports:
- 8930:8930
environment:
- NODE_ENV=development
tty: true
networks:
- peddlers-network
networks:
peddlers-network:
driver: bridge

25
docker-compose.yml Normal file
View File

@ -0,0 +1,25 @@
services:
peddlers-of-ketran:
container_name: ketr.ketran
build:
context: .
dockerfile: Dockerfile
restart: always
ports:
- 8930:8930
volumes:
- ./db:/db:rw
- ./server/routes:/server/routes:ro
working_dir: /server
peddlers-client:
container_name: ketr.client
build:
context: .
dockerfile: Dockerfile
restart: 'no'
working_dir: /client
volumes:
- ./client:/client:rw
#- ./client:/client/node_modules:rw
tty: true
stdin_open: true

View File

@ -1,4 +1,4 @@
let basePath = process.env.REACT_APP_basePath;
let basePath = process.env.REACT_APP_basePath || "";
basePath = "/" + basePath.replace(/^\/+/, "").replace(/\/+$/, "") + "/";
if (basePath == "//") {
basePath = "/";

View File

@ -1 +1,3 @@
{}
{
"frontendPath": "/client/build"
}

View File

@ -3,7 +3,12 @@
"version": "1.0.0",
"main": "app.js",
"scripts": {
"start": "export $(cat ../.env | xargs) && node app.js"
"start": "export $(cat ../.env | xargs) && node dist/app.js",
"start:legacy": "export $(cat ../.env | xargs) && node app.js",
"build": "tsc -p tsconfig.json",
"start:dev": "ts-node-dev --respawn --transpile-only src/app.ts",
"test": "jest",
"type-check": "tsc -p tsconfig.json --noEmit"
},
"author": "James Ketrenos <james_settlers@ketrenos.com>",
"license": "MIT",
@ -30,6 +35,19 @@
"typeface-roboto": "0.0.75",
"ws": "^8.5.0"
},
"devDependencies": {
"@types/jest": "^29.5.0",
"@types/node": "^20.0.0",
"@types/supertest": "^2.0.12",
"jest": "^29.7.0",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-node-dev": "^2.0.0",
"typescript": "^5.0.0"
},
"jest": {
"preset": "ts-jest"
},
"repository": {
"type": "git",
"url": "git@git.ketrenos.com:jketreno/peddlers-of-ketran"

View File

@ -2,8 +2,8 @@
const express = require("express"),
router = express.Router(),
crypto = require("crypto"),
{ readFile, writeFile } = require("fs").promises,
crypto = require("crypto")
const { readFile, writeFile, mkdir } = require("fs").promises,
fs = require("fs"),
accessSync = fs.accessSync,
randomWords = require("random-words"),
@ -619,7 +619,7 @@ const loadGame = async (id) => {
return games[id];
}
let game = await readFile(`games/${id}`)
let game = await readFile(`/db/games/${id}`)
.catch(() => {
return;
});
@ -627,20 +627,20 @@ const loadGame = async (id) => {
if (game) {
try {
game = JSON.parse(game);
console.log(`${info}: Creating backup of games/${id}`);
await writeFile(`games/${id}.bk`, JSON.stringify(game));
console.log(`${info}: Creating backup of /db/games/${id}`);
await writeFile(`/db/games/${id}.bk`, JSON.stringify(game));
} catch (error) {
console.log(`Load or parse error from games/${id}:`, error);
console.log(`Attempting to load backup from games/${id}.bk`);
game = await readFile(`games/${id}.bk`)
console.log(`Load or parse error from /db/games/${id}:`, error);
console.log(`Attempting to load backup from /db/games/${id}.bk`);
game = await readFile(`/db/games/${id}.bk`)
.catch(() => {
console.error(error, game);
});
if (game) {
try {
game = JSON.parse(game);
console.log(`Saving backup to games/${id}`);
await writeFile(`games/${id}`, JSON.stringify(game, null, 2));
console.log(`Saving backup to /db/games/${id}`);
await writeFile(`/db/games/${id}`, JSON.stringify(game, null, 2));
} catch (error) {
console.error(error);
game = null;
@ -3448,15 +3448,16 @@ const saveGame = async (game) => {
/* Save per turn while debugging... */
game.step = game.step ? game.step : 0;
/*
await writeFile(`games/${game.id}.${game.step++}`, JSON.stringify(reducedGame, null, 2))
await writeFile(`/db/games/${game.id}.${game.step++}`, JSON.stringify(reducedGame, null, 2))
.catch((error) => {
console.error(`${session.id} Unable to write to games/${game.id}`);
console.error(`${session.id} Unable to write to /db/games/${game.id}`);
console.error(error);
});
*/
await writeFile(`games/${game.id}`, JSON.stringify(reducedGame, null, 2))
await mkdir('/db/games', { recursive: true });
await writeFile(`/db/games/${game.id}`, JSON.stringify(reducedGame, null, 2))
.catch((error) => {
console.error(`${session.id} Unable to write to games/${game.id}`);
console.error(`Unable to write to /db/games/${game.id}`);
console.error(error);
});
}
@ -3886,7 +3887,7 @@ router.ws("/ws/:id", async (ws, req) => {
/* Check for a game in the Winner state with no more connections
* and remove it */
if (game.state === 'winner' || game.state === 'lobby') {
if (game.state === 'winner') {
let dead = true;
for (let id in game.sessions) {
if (game.sessions[id].live && game.sessions[id].name) {
@ -3910,9 +3911,9 @@ router.ws("/ws/:id", async (ws, req) => {
delete audio[id];
delete games[id];
try {
fs.unlinkSync(`games/${id}`);
fs.unlinkSync(`/db/games/${id}`);
} catch (error) {
console.error(`${session.id}: Unable to remove games/${id}`);
console.error(`${session.id}: Unable to remove /db/games/${id}`);
}
}
}
@ -4655,7 +4656,7 @@ const createGame = (id) => {
id = randomWords(4).join('-');
try {
/* If file can be read, it already exists so look for a new name */
accessSync(`games/${id}`, fs.F_OK);
accessSync(`/db/games/${id}`, fs.F_OK);
id = '';
} catch (error) {
break;

116
server/src/app.ts Normal file
View File

@ -0,0 +1,116 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import type { Request, Response, NextFunction } from 'express';
process.env.TZ = "Etc/GMT";
console.log("Loading ketr.ketran");
const express = require("express");
const bodyParser = require("body-parser");
const config = require("config");
const session = require('express-session');
const basePath = require("../basepath");
const cookieParser = require("cookie-parser");
const fs = require('fs');
const app = express();
const server = require("http").createServer(app);
app.use(cookieParser());
const ws = require('express-ws')(app, server);
require("../console-line.js"); /* Monkey-patch console.log with line numbers */
const frontendPath = config.get("frontendPath").replace(/\/$/, "") + "/",
serverConfig = config.get("server");
console.log("Hosting server from: " + basePath);
let userDB: any, gameDB: any;
app.use(bodyParser.json());
/* App is behind an nginx proxy which we trust, so use the remote address
* set in the headers */
app.set("trust proxy", true);
app.set("basePath", basePath);
app.use(basePath, require("../routes/basepath.js"));
/* Handle static files first so excessive logging doesn't occur */
app.use(basePath, express.static(frontendPath, { index: false }));
const index = require("../routes/index");
if (config.has("admin")) {
const admin = config.get("admin");
app.set("admin", admin);
}
/* Allow loading of the app w/out being logged in */
app.use(basePath, index);
/* /games loads the default index */
app.use(basePath + "games", index);
app.use(function(err: any, req: Request, res: Response, next: NextFunction) {
console.error(err.message);
res.status(err.status || 500).json({
message: err.message,
error: {}
});
});
app.use(`${basePath}api/v1/games`, require("../routes/games"));
/* Declare the "catch all" index route last; the final route is a 404 dynamic router */
app.use(basePath, index);
/**
* Create HTTP server and listen for new connections
*/
app.set("port", serverConfig.port);
process.on('SIGINT', () => {
console.log("Gracefully shutting down from SIGINT (Ctrl-C) in 2 seconds");
setTimeout(() => process.exit(-1), 2000);
server.close(() => process.exit(1));
});
require("../db/games").then(function(db: any) {
gameDB = db;
}).then(function() {
return require("../db/users").then(function(db: any) {
userDB = db;
});
}).then(function() {
console.log("DB connected. Opening server.");
server.listen(serverConfig.port, () => {
console.log(`http/ws server listening on ${serverConfig.port}`);
});
}).catch(function(error: any) {
console.error(error);
process.exit(-1);
});
server.on("error", function(error: any) {
if (error.syscall !== "listen") {
throw error;
}
// handle specific listen errors with friendly messages
switch (error.code) {
case "EACCES":
console.error(serverConfig.port + " requires elevated privileges");
process.exit(1);
break;
case "EADDRINUSE":
console.error(serverConfig.port + " is already in use");
process.exit(1);
break;
default:
throw error;
}
});
module.exports = { app, server };

11
server/tests/app.test.ts Normal file
View File

@ -0,0 +1,11 @@
import request from 'supertest';
const { app } = require('../src/app');
describe('Server Routes', () => {
it('should respond to GET /', async () => {
const response = await request(app).get('/');
expect(response.status).toBe(200);
});
// Add more tests as needed
});

19
server/tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"strict": false,
"noImplicitAny": false,
"moduleResolution": "Node",
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}