A real-time sports match tracking API built with Express 5, WebSockets, Drizzle ORM, and Neon Postgres.
Sportz provides a REST API for managing sports matches and live commentary, with a WebSocket server that pushes real-time updates to connected clients as matches are created, scores change, and commentary is added.
- Runtime: Node.js 22+ (ES Modules)
- Framework: Express 5
- Database: PostgreSQL (Neon)
- ORM: Drizzle ORM with Drizzle Kit migrations
- Validation: Zod v4
- WebSockets: ws (with ping/pong heartbeat)
- Security: Arcjet (shield, bot detection, sliding-window rate limiting for HTTP and WebSocket)
- Testing: Node.js built-in test runner
sportz/
├── frontend/ # React + Vite SPA
│ ├── src/
│ │ ├── api.js # API client
│ │ ├── App.jsx, main.jsx # App entry & layout
│ │ ├── hooks/useWebSocket.jsx # WebSocket + toasts
│ │ ├── pages/ # MatchList, CreateMatch, MatchDetail
│ │ └── App.css
│ ├── index.html
│ ├── vite.config.js
│ └── README.md # How to run frontend
├── drizzle/ # Generated migration files
├── src/
│ ├── db/
│ │ ├── db.js # Database client (node-postgres pool)
│ │ └── schema.js # Drizzle table definitions
│ ├── middleware/
│ │ ├── arcjet.js # Arcjet (HTTP + WebSocket protection)
│ │ └── error-handler.js # Centralized error handling
│ ├── routes/
│ │ ├── matches.js # Match CRUD endpoints
│ │ └── commentary.js # Commentary CRUD endpoints
│ ├── utils/
│ │ ├── env.js # Environment validation
│ │ ├── match-status.js # Status derivation logic
│ │ └── status-sync.js # Periodic status sync job
│ ├── validation/
│ │ ├── matches.js # Match request schemas
│ │ └── commentary.js # Commentary request schemas
│ ├── ws/
│ │ └── server.js # WebSocket server with heartbeat
│ └── index.js # App entrypoint
├── tests/
│ ├── match-status.test.js # Status logic tests
│ └── validation.test.js # Schema validation tests
├── drizzle.config.js
├── package.json
└── .env # Connection string (not committed)
- Node.js >= 22
- A Neon Postgres database (or any PostgreSQL instance)
git clone https://github.com/<your-username>/sportz.git
cd sportz
npm installCreate a .env file in the project root:
DATABASE_URL="postgresql://<user>:<password>@<host>/<database>?sslmode=require"
PORT=8000
HOST=0.0.0.0
NODE_ENV=development
# Optional: Arcjet security. Get a key at https://app.arcjet.com
ARCJET_KEY="ajkey_..."The server validates required environment variables at startup and fails fast with a clear message if any are missing. If ARCJET_KEY is set:
- HTTP: Shield, bot detection (search-engine and preview bots allowed), and sliding-window rate limit (50 requests per 10s per client). Denials return 429 (rate limit) or 403 (forbidden); Arcjet errors return 503.
- WebSocket: Same shield and bot rules, plus a stricter sliding window (5 connections per 2s per client). Denied connections are closed with code 1013 (rate limit) or 1008 (access denied); errors close with 1011 (Service Unavailable).
Generate and apply migrations:
npm run db:generate
npm run db:migrate# Production
npm start
# Development (auto-restart on file changes)
npm run devThe server starts on http://localhost:8000 with a WebSocket endpoint at ws://localhost:8000/ws.
- Start the API (from project root):
npm run dev— keep it running on port 8000. - In a second terminal, from project root:
cd frontend, thennpm install(first time only), thennpm run dev. - Open http://localhost:5173.
See
GET /
Returns { "message": "Welcome to Sportz API" }.
GET /matches?limit=25&offset=0
| Query Param | Type | Default | Description |
|---|---|---|---|
limit |
integer | 50 | Max rows to return (1-100) |
offset |
integer | 0 | Rows to skip (pagination) |
Response: 200 OK
{
"data": [ ... ],
"pagination": { "limit": 25, "offset": 0 }
}GET /matches/:id
Response: 200 OK
{ "data": { "id": 1, "sport": "football", ... } }POST /matches
Content-Type: application/json
| Field | Type | Required | Description |
|---|---|---|---|
sport |
string | Yes | Sport name |
homeTeam |
string | Yes | Home team name |
awayTeam |
string | Yes | Away team name |
startTime |
string | Yes | ISO 8601 datetime |
endTime |
string | Yes | ISO 8601 datetime (must be > start) |
homeScore |
integer | No | Defaults to 0 |
awayScore |
integer | No | Defaults to 0 |
Response: 201 Created
PUT /matches/:id
Content-Type: application/json
Accepts any subset of: sport, homeTeam, awayTeam, startTime, endTime, status. At least one field is required.
Response: 200 OK
PATCH /matches/:id/score
Content-Type: application/json
| Field | Type | Required | Description |
|---|---|---|---|
homeScore |
integer | Yes | Home score |
awayScore |
integer | Yes | Away score |
Response: 200 OK
DELETE /matches/:id
Response: 200 OK with the deleted match data.
GET /matches/:matchId/commentary?limit=50&offset=0&eventType=goal
| Query Param | Type | Default | Description |
|---|---|---|---|
limit |
integer | 50 | Max rows to return (1-100) |
offset |
integer | 0 | Rows to skip (pagination) |
eventType |
string | — | Filter by event type |
Response: 200 OK
POST /matches/:matchId/commentary
Content-Type: application/json
| Field | Type | Required | Description |
|---|---|---|---|
message |
string | Yes | Commentary text |
minute |
integer | No | Match minute |
sequence |
integer | No | Sequence within the minute |
period |
string | No | Match period (e.g. "1H", "2H") |
eventType |
string | No | Event type (e.g. "goal") |
actor |
string | No | Player or person involved |
team |
string | No | Team involved |
metadata |
object | No | Arbitrary JSON data |
tags |
string[] | No | Categorization tags |
Response: 201 Created
DELETE /matches/:matchId/commentary/:id
Response: 200 OK with the deleted entry.
Connect to ws://localhost:8000/ws to receive real-time events.
| Event Type | Trigger | Payload |
|---|---|---|
welcome |
On connection | { type: "welcome" } |
match_created |
Match created via POST | { type: "match_created", data: <match> } |
score_update |
Score updated via PATCH | { type: "score_update", data: <match> } |
commentary_added |
Commentary added via POST | { type: "commentary_added", data: <entry> } |
status_change |
Status sync job transitions | { type: "status_change", data: <match> } |
The server runs a ping/pong heartbeat every 30 seconds to detect and clean up dead connections. When ARCJET_KEY is set, new connections are checked before the welcome message; denied clients are closed with the appropriate code and reason (e.g. "Rate limit exceeded", "Access denied").
A background job runs every 60 seconds to automatically transition match statuses:
scheduled→livewhen the current time passesstartTimelive→finishedwhen the current time passesendTime
Status changes are broadcast to all WebSocket clients as status_change events.
All errors flow through a centralized error handler:
- Client errors (4xx): Return the error message and validation details.
- Server errors (5xx): Return a generic
"Internal Server Error"message in production. In development (NODE_ENV=development), the full error details and stack trace are included.
Run the test suite with:
npm testTests use Node.js built-in test runner (no extra dependencies) and cover:
- Match status derivation logic (scheduled/live/finished/invalid)
- All Zod validation schemas (createMatch, updateScore, updateMatch, matchIdParam, listMatchesQuery)
| Command | Description |
|---|---|
npm start |
Start the production server |
npm run dev |
Start with --watch for development |
npm run db:generate |
Generate migration from schema |
npm run db:migrate |
Apply migrations to the database |
npm test |
Run the test suite |
ISC