Compare commits
21 Commits
email-feat
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a746662db4 | ||
|
|
19483178a4 | ||
|
|
ddd5f6ae72 | ||
|
|
f161ccb0f0 | ||
|
|
e28a2d6e9c | ||
|
|
362aa07f5a | ||
|
|
99d34c58c6 | ||
|
|
5aa347eb13 | ||
|
|
33c3518ee4 | ||
|
|
198f03dfb7 | ||
|
|
1dc6f3cc9e | ||
|
|
bbd82a8012 | ||
|
|
25a834adcc | ||
|
|
f7c0915661 | ||
|
|
570d83c3c0 | ||
|
|
e04560c7f9 | ||
|
|
7fa41ef7ac | ||
|
|
42e68db00b | ||
|
|
d1240adbb7 | ||
|
|
fa4fec3a11 | ||
|
|
a810906bcb |
@@ -15,9 +15,5 @@ POSTGRES_USERS_USER=users
|
|||||||
POSTGRES_USERS_PASSWORD=CHANGE_ME
|
POSTGRES_USERS_PASSWORD=CHANGE_ME
|
||||||
POSTGRES_USERS_DB=star_wars_users
|
POSTGRES_USERS_DB=star_wars_users
|
||||||
|
|
||||||
# ── Admin ────────────────────────────────────────────────────────────────────
|
|
||||||
# Password to unlock the team-switching debug widget in the UI
|
|
||||||
ADMIN_PASSWORD=CHANGE_ME
|
|
||||||
|
|
||||||
# ── CORS ─────────────────────────────────────────────────────────────────────
|
# ── CORS ─────────────────────────────────────────────────────────────────────
|
||||||
CORS_ORIGIN=*
|
CORS_ORIGIN=*
|
||||||
27
Dockerfile
@@ -1,29 +1,28 @@
|
|||||||
FROM node:20-alpine
|
# ── Stage 1: install production dependencies ──────────────────────────────────
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
# Create non-root user/group before switching context
|
|
||||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies first for better layer caching
|
COPY package.json package-lock.json* ./
|
||||||
COPY package.json package-lock.json ./
|
RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi
|
||||||
RUN npm ci --omit=dev
|
|
||||||
|
# ── Stage 2: hardened, minimal runtime ────────────────────────────────────────
|
||||||
|
FROM gcr.io/distroless/nodejs20-debian12:nonroot
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
# Copy application source
|
|
||||||
COPY server ./server
|
COPY server ./server
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
COPY config ./config
|
COPY config ./config
|
||||||
|
|
||||||
# Drop to non-root user
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=8080
|
ENV PORT=8080
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# Health-check: lightweight wget is available in node:alpine
|
|
||||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
|
||||||
CMD wget -qO- http://localhost:8080/api/config > /dev/null || exit 1
|
CMD ["/nodejs/bin/node", "server/healthcheck.js"]
|
||||||
|
|
||||||
CMD ["node", "server/index.js"]
|
CMD ["server/index.js"]
|
||||||
361
README.md
@@ -4,119 +4,312 @@ Exploitable ring only (outer Ø100, inner Ø60). Planet positions are determinis
|
|||||||
|
|
||||||
> **Desktop only** — the galaxy map is not playable on phones or narrow screens (< 768 px wide).
|
> **Desktop only** — the galaxy map is not playable on phones or narrow screens (< 768 px wide).
|
||||||
|
|
||||||
## Layout
|
---
|
||||||
|
|
||||||
The UI is split into two panes:
|
## Table of contents
|
||||||
|
|
||||||
|
1. [Architecture](#architecture)
|
||||||
|
2. [Prerequisites](#prerequisites)
|
||||||
|
3. [Deploy on a new server](#deploy-on-a-new-server)
|
||||||
|
4. [Environment variables](#environment-variables)
|
||||||
|
5. [Runtime config (`game.settings.json`)](#runtime-config-gamesettingsjson)
|
||||||
|
6. [Local development (without Docker)](#local-development-without-docker)
|
||||||
|
7. [UI layout](#ui-layout)
|
||||||
|
8. [Game mechanics](#game-mechanics)
|
||||||
|
9. [API reference](#api-reference)
|
||||||
|
10. [Docker image notes](#docker-image-notes)
|
||||||
|
11. [Database persistence](#database-persistence)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| Component | Technology |
|
||||||
|
|-----------|-----------|
|
||||||
|
| Web server / API | Node.js 20 + Express |
|
||||||
|
| Game database | PostgreSQL 16 (`star_wars_grid`) |
|
||||||
|
| Users database | PostgreSQL 16 (`star_wars_users`) |
|
||||||
|
| Auth | JWT (7-day tokens, signed with `JWT_SECRET`) |
|
||||||
|
| Config hot-reload | File mtime polling (`game.settings.json`) |
|
||||||
|
| Economy engine | Server-side tick loop (every 5 s) |
|
||||||
|
| Container runtime | Docker / Docker Compose |
|
||||||
|
|
||||||
|
The app runs as a single Node process that serves both the static frontend (`public/`) and the REST API (`/api/*`). Two separate PostgreSQL instances hold game state and user accounts respectively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
| Requirement | Minimum version |
|
||||||
|
|-------------|----------------|
|
||||||
|
| Docker Engine | 24+ |
|
||||||
|
| Docker Compose plugin | v2 (`docker compose`) |
|
||||||
|
| Git | any recent |
|
||||||
|
|
||||||
|
No Node.js or PostgreSQL installation is needed on the host — everything runs inside Docker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deploy on a new server
|
||||||
|
|
||||||
|
### 1 — Clone the repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> star-wars-wild-space
|
||||||
|
cd star-wars-wild-space
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2 — Create the environment file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `.env` and set every value — see [Environment variables](#environment-variables) for details. At a minimum you **must** change:
|
||||||
|
|
||||||
|
- `JWT_SECRET` — generate a strong random string:
|
||||||
|
```bash
|
||||||
|
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
You may use alternatives for Linux-based distros, like this OpenSSL one:
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 48
|
||||||
|
```
|
||||||
|
|
||||||
|
Or even lower-level tools from Linux/UNIX entropy pool:
|
||||||
|
```bash
|
||||||
|
head -c 48 /dev/urandom | xxd -p -c 48
|
||||||
|
# or
|
||||||
|
head -c 48 /dev/urandom | hexdump -e '48/1 "%02x"'
|
||||||
|
```
|
||||||
|
- `POSTGRES_PASSWORD`
|
||||||
|
- `POSTGRES_USERS_PASSWORD`
|
||||||
|
|
||||||
|
### 3 — Review the game config
|
||||||
|
|
||||||
|
`config/game.settings.json` is mounted into the container and hot-reloaded at runtime. Adjust values before first boot if needed — see [Runtime config](#runtime-config-gamesettingsjson).
|
||||||
|
|
||||||
|
### 4 — Create the data directories
|
||||||
|
|
||||||
|
Docker bind-mounts the PostgreSQL data directories from the host. Create them before the first run so that Docker does not create them as root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p data/postgres data/postgres_users
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5 — Build and start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The `app` service waits for both databases to pass their health checks before starting (pg_isready polling every 3 s, up to 15 retries). On first boot the server automatically creates all required tables.
|
||||||
|
|
||||||
|
### 6 — Verify the deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check all three containers are running
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# Tail logs
|
||||||
|
docker compose logs -f app
|
||||||
|
|
||||||
|
# Health check (should return JSON)
|
||||||
|
curl http://localhost:8080/api/config
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://<server-ip>:8080` in a browser.
|
||||||
|
|
||||||
|
### 7 — (Optional) Reverse proxy
|
||||||
|
|
||||||
|
To expose the app on port 80/443, put Nginx or Caddy in front and proxy to `http://localhost:8080`. Set `CORS_ORIGIN` in `.env` to your public domain.
|
||||||
|
|
||||||
|
### Stopping / restarting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down # stop (data preserved)
|
||||||
|
docker compose down -v # stop and remove named volumes (data lost)
|
||||||
|
docker compose restart app # restart only the app container
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
**Never commit secrets.** All credentials live in **`.env`** (git-ignored). Copy from `.env.example` before the first run.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `PORT` | `8080` | HTTP port exposed by the app |
|
||||||
|
| `JWT_SECRET` | *(required)* | Secret used to sign JWT tokens — use a long random string in production |
|
||||||
|
| `POSTGRES_USER` | `game` | Game-DB username |
|
||||||
|
| `POSTGRES_PASSWORD` | *(required)* | Game-DB password |
|
||||||
|
| `POSTGRES_DB` | `star_wars_grid` | Game-DB name |
|
||||||
|
| `POSTGRES_USERS_USER` | `users` | Users-DB username |
|
||||||
|
| `POSTGRES_USERS_PASSWORD` | *(required)* | Users-DB password |
|
||||||
|
| `POSTGRES_USERS_DB` | `star_wars_users` | Users-DB name |
|
||||||
|
| `CORS_ORIGIN` | `*` | Value of the `Access-Control-Allow-Origin` header — set to your public domain in production |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Runtime config (`game.settings.json`)
|
||||||
|
|
||||||
|
Edit **`config/game.settings.json`** on the host (bind-mounted into the container at `/app/config/game.settings.json`). The server checks the file's **mtime** on a schedule; when it changes the new values are applied without a restart, within at most `configReloadIntervalSeconds` seconds (minimum 5 s).
|
||||||
|
|
||||||
|
### Core fields
|
||||||
|
|
||||||
|
| Field | Default | Meaning |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `clickCooldownSeconds` | `10` | Per-user cooldown in seconds between new tile reveals. `0` disables it. |
|
||||||
|
| `databaseWipeoutIntervalSeconds` | `21600` | World period length in seconds (6 h). The world seed is `swg-<slot>` where `slot = floor(UTC unix seconds / this value)`. When the slot advances, `grid_cells` is truncated and a new era begins. Minimum `60`. |
|
||||||
|
| `configReloadIntervalSeconds` | `30` | How often the server polls the config file; also used by the client to poll `/api/config`. Minimum `5`. |
|
||||||
|
|
||||||
|
### Economy fields
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `elementWorth` | Per-element multipliers (`common`, `food`, `petrol`, `goods`, `industry`, `medic`, `money`, `science`). |
|
||||||
|
| `resourceWorth.common` / `resourceWorth.rare` | Base income per resource type for common and rare planet tiers. Drives the server-side economy tick (every 5 s). |
|
||||||
|
|
||||||
|
`GET /api/config` returns the core fields plus **`worldSeed`**, **`seedPeriodStartsAtUtc`**, **`seedPeriodEndsAtUtc`**, and economy values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local development (without Docker)
|
||||||
|
|
||||||
|
Requires two running PostgreSQL 16 instances, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # fill in all values
|
||||||
|
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Set connection strings (PowerShell example)
|
||||||
|
$env:DATABASE_URL = "postgres://game:game@localhost:5432/star_wars_grid"
|
||||||
|
$env:USERS_DATABASE_URL = "postgres://users:users@localhost:5433/star_wars_users"
|
||||||
|
$env:JWT_SECRET = "dev_secret_change_me"
|
||||||
|
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The server creates tables automatically on first run. Ensure `config/game.settings.json` exists (the file is included in the repository).
|
||||||
|
|
||||||
|
### Ports (local)
|
||||||
|
|
||||||
|
| Service | Port |
|
||||||
|
|---------|------|
|
||||||
|
| App | `8080` (or `PORT` env var) |
|
||||||
|
| Game Postgres | `5432` |
|
||||||
|
| Users Postgres | `5433` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI layout
|
||||||
|
|
||||||
| Pane | Content |
|
| Pane | Content |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| **Left column** | Title, team scores, player info, cooldown timer, world seed & period countdown, Refresh button, selection details |
|
| **Left column** | Title, team scores, player info, cooldown timer, world seed & period countdown, Refresh button, selection details |
|
||||||
| **Right side** | Galaxy map — a perfect square anchored to the right edge of the viewport |
|
| **Right side** | Galaxy map — a perfect square anchored to the right edge of the viewport |
|
||||||
|
|
||||||
## Secrets & environment variables
|
---
|
||||||
|
|
||||||
**Never commit secrets.** All credentials live in **`.env`** (git-ignored).
|
## Game mechanics
|
||||||
Copy the template and fill in your values before the first run:
|
|
||||||
|
|
||||||
```powershell
|
### Teams (Résistance / Premier Ordre)
|
||||||
cp .env.example .env
|
|
||||||
# then edit .env
|
- Users register with a team (`Résistance` or `Premier Ordre`) that is locked to their account and embedded in their JWT.
|
||||||
|
- **Your** discovered tiles use your team colour. Unclaimed ring tiles use the neutral idle tint.
|
||||||
|
- Tiles discovered by the **other** team appear grey, show no planet, and are not clickable. The first reveal owns the tile (`discovered_by`).
|
||||||
|
|
||||||
|
### Cooldown
|
||||||
|
|
||||||
|
After revealing a **new** tile, a per-user cooldown starts. During cooldown you cannot reveal additional unseen tiles, but you can still click tiles your team has already discovered to view their stats. The effective cooldown is reduced by the team's element bonus:
|
||||||
|
|
||||||
|
```
|
||||||
|
effectiveCooldown = clickCooldownSeconds / (1 + elementBonus / 100)
|
||||||
```
|
```
|
||||||
|
|
||||||
| Variable | Description |
|
### World seed & wipe
|
||||||
|----------|-------------|
|
|
||||||
| `JWT_SECRET` | Secret used to sign JWT tokens — use a long random string in production |
|
|
||||||
| `POSTGRES_USER` | Game-DB username (default `game`) |
|
|
||||||
| `POSTGRES_PASSWORD` | Game-DB password — **change in production** |
|
|
||||||
| `POSTGRES_DB` | Game-DB name (default `star_wars_grid`) |
|
|
||||||
| `POSTGRES_USERS_USER` | Users-DB username (default `users`) |
|
|
||||||
| `POSTGRES_USERS_PASSWORD` | Users-DB password — **change in production** |
|
|
||||||
| `POSTGRES_USERS_DB` | Users-DB name (default `star_wars_users`) |
|
|
||||||
| `PORT` | HTTP port exposed by the app (default `8080`) |
|
|
||||||
|
|
||||||
## Runtime config (file + volume)
|
The world seed changes automatically when the UTC period slot advances. On a seed change the server truncates `grid_cells` and starts a fresh era. Data in `users`, cooldown tables, econ scores, and victory points is kept across wipes.
|
||||||
|
|
||||||
Edit **`config/game.settings.json`** on the host (mounted into the container at `/app/config/game.settings.json`). The server reloads it when the file's **mtime** changes, on a schedule controlled by **`configReloadIntervalSeconds`** (minimum 5 s), so frequent polling is avoided when nothing changed.
|
### Economy
|
||||||
|
|
||||||
| Field | Meaning |
|
A server-side tick runs every 5 seconds. It reads the current grid from the database, computes income and element bonus for each team from their discovered planets, and persists the deltas. No browser needs to be connected for the economy to progress.
|
||||||
|-------|---------|
|
|
||||||
| `clickCooldownSeconds` | Cooldown between **new** reveals (same as before). |
|
|
||||||
| `databaseWipeoutIntervalSeconds` | World period length in **seconds** (default `21600` = 6 h). The **world seed** is `swg-<slot>` with `slot = floor(UTC unix seconds / this value)`. When the slot changes, **`grid_cells` is truncated** (full wipe). |
|
|
||||||
| `debugModeForTeams` | If `true`, the **Blue / Red** segmented control is shown; if `false`, it is hidden. |
|
|
||||||
| `configReloadIntervalSeconds` | How often the server **checks** the config file (mtime); also used by the client to poll `/api/config`. |
|
|
||||||
|
|
||||||
`GET /api/config` returns these values plus **`worldSeed`**, **`seedPeriodEndsAtUtc`**, **`seedPeriodStartsAtUtc`**.
|
---
|
||||||
|
|
||||||
## Teams (blue / red)
|
## API reference
|
||||||
|
|
||||||
- With **`debugModeForTeams`**: use the **Team** control (top-left) to switch perspective.
|
All endpoints are relative to the app root.
|
||||||
- **Your** discovered tiles use your team colour. Unclaimed ring tiles use the classic idle tint for both teams.
|
|
||||||
- Tiles discovered by the **other** team appear **grey**, show **no planet**, and are **not clickable**. First reveal **owns** the tile (`discovered_by`).
|
|
||||||
|
|
||||||
## Cooldown
|
### Public
|
||||||
|
|
||||||
After revealing a **new** tile, a **cooldown** runs (left column). During cooldown you **cannot reveal additional unseen tiles**, but you can still **click tiles your team already discovered** to view their stats.
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `GET` | `/api/config` | Game config plus current world seed and period timestamps. Accepts optional `?team=blue|red` and Bearer token to compute per-user cooldown remaining. |
|
||||||
|
| `GET` | `/api/grid/:seed` | All revealed cells for `seed`. Returns `410` if `seed` is not the current world seed. |
|
||||||
|
| `GET` | `/api/scores` | `{ blue: N, red: N }` — tile counts for the current period. |
|
||||||
|
| `GET` | `/api/econ-scores` | Economic score totals for the current period. |
|
||||||
|
| `GET` | `/api/element-bonus` | Current element bonus percentages per team. |
|
||||||
|
| `GET` | `/api/victory-points` | All awarded victory points across eras. |
|
||||||
|
| `GET` | `/api/db-info` | Database creation timestamp. |
|
||||||
|
|
||||||
## Run with Docker Compose (Node + PostgreSQL)
|
### Auth (no token required)
|
||||||
|
|
||||||
### Prerequisites
|
| Method | Path | Body | Response |
|
||||||
|
|--------|------|------|----------|
|
||||||
|
| `POST` | `/api/auth/register` | `{ username, email, password, team }` | `{ token, user }` |
|
||||||
|
| `POST` | `/api/auth/login` | `{ username, password }` | `{ token, user }` |
|
||||||
|
|
||||||
1. Copy the environment template and set your secrets:
|
### Auth (Bearer token required)
|
||||||
```powershell
|
|
||||||
cp star_wars_grid_game/.env.example star_wars_grid_game/.env
|
|
||||||
# Edit .env — at minimum change JWT_SECRET, POSTGRES_PASSWORD, POSTGRES_USERS_PASSWORD
|
|
||||||
```
|
|
||||||
|
|
||||||
2. From `star_wars_grid_game/`:
|
| Method | Path | Description |
|
||||||
```powershell
|
|--------|------|-------------|
|
||||||
docker compose up --build
|
| `GET` | `/api/auth/me` | Returns refreshed `{ token, user }`. |
|
||||||
```
|
| `POST` | `/api/cell/reveal` | Body `{ seed, x, y }`. Team is taken from the JWT. First reveal wins; **`409`** if the other team already owns the tile; **`410`** if seed is stale; **`429`** if on cooldown. |
|
||||||
|
|
||||||
3. Open `http://localhost:8080`.
|
### Auth field rules
|
||||||
|
|
||||||
### Ports
|
| Field | Rules |
|
||||||
|
|-------|-------|
|
||||||
|
| `username` | 2–32 characters |
|
||||||
|
| `password` | Minimum 6 characters |
|
||||||
|
| `team` | `"Résistance"` or `"Premier Ordre"` |
|
||||||
|
| `email` | Must be unique |
|
||||||
|
|
||||||
| Service | Port | Notes |
|
Tokens are valid for **7 days**. Call `GET /api/auth/me` to silently renew.
|
||||||
|---------|------|-------|
|
|
||||||
| App | `8080` | configurable via `PORT` in `.env` |
|
|
||||||
| Game Postgres | `5432` | user/pass from `.env` |
|
|
||||||
| Users Postgres | `5433` | user/pass from `.env` |
|
|
||||||
|
|
||||||
### Database persistence
|
---
|
||||||
|
|
||||||
PostgreSQL data is under **`./data/postgres`** and **`./data/postgres_users`** (bind mounts). The **world wipe** only clears `grid_cells` when the UTC period slot changes; it does not delete the Postgres data directory.
|
|
||||||
|
|
||||||
## Local dev (without Docker)
|
|
||||||
|
|
||||||
Requires PostgreSQL, then:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd star_wars_grid_game
|
|
||||||
cp .env.example .env # fill in DATABASE_URL etc.
|
|
||||||
npm install
|
|
||||||
# export vars or use a tool like dotenv-cli
|
|
||||||
$env:DATABASE_URL="postgres://game:game@localhost:5432/star_wars_grid"
|
|
||||||
$env:USERS_DATABASE_URL="postgres://users:users@localhost:5433/star_wars_users"
|
|
||||||
$env:JWT_SECRET="dev_secret"
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
Ensure `config/game.settings.json` exists (or copy from the repo).
|
|
||||||
|
|
||||||
## Docker image notes
|
## Docker image notes
|
||||||
|
|
||||||
- Base image: `node:20-alpine`
|
- Build uses a **two-stage** Dockerfile:
|
||||||
- Runs as a **non-root user** (`appuser`) for security
|
- Stage 1 (`node:20-alpine`): installs production dependencies via `npm ci`.
|
||||||
- `NODE_ENV=production` is set inside the image
|
- Stage 2 (`gcr.io/distroless/nodejs20-debian12:nonroot`): minimal runtime with no shell or package manager; runs as UID 65532.
|
||||||
- Built-in **health check**: polls `GET /api/config` every 15 s (`wget`)
|
- `NODE_ENV=production` is set inside the image.
|
||||||
- Secrets (`JWT_SECRET`, DB passwords) are **never baked into the image** — pass them via `.env` / Compose environment
|
- Built-in health check: calls `server/healthcheck.js` via the Node binary every 15 s (no `wget`/`curl` — distroless has none).
|
||||||
|
- Secrets are **never baked into the image** — always passed via `.env` / Compose environment.
|
||||||
|
|
||||||
## API
|
---
|
||||||
|
|
||||||
- `GET /api/config` — cooldown, wipe interval, debug flag, poll interval, `worldSeed`, period timestamps.
|
## Database persistence
|
||||||
- `GET /api/grid/:seed` — cells for that seed; **`410`** if `seed` is not the current world seed.
|
|
||||||
- `POST /api/cell/reveal` — body `{ seed, x, y, team: "blue" | "red" }` — first reveal wins; **`409`** if the other team owns the tile; **`410`** if seed is stale.
|
PostgreSQL data lives in bind-mount directories on the host:
|
||||||
- `POST /api/auth/register` — `{ username, email, password, team }` → `{ token, user }`.
|
|
||||||
- `POST /api/auth/login` — `{ username, password }` → `{ token, user }`.
|
| Directory | Database |
|
||||||
- `GET /api/auth/me` — Bearer token required → refreshed `{ token, user }`.
|
|-----------|----------|
|
||||||
- `GET /api/scores` — `{ blue: N, red: N }` tile counts for the current period.
|
| `./data/postgres` | Game DB (`star_wars_grid`) |
|
||||||
|
| `./data/postgres_users` | Users DB (`star_wars_users`) |
|
||||||
|
|
||||||
|
A **world wipe** only truncates the `grid_cells` table when the UTC period slot changes; it does not touch the host data directories. Users, cooldowns, econ scores, and victory points survive wipes.
|
||||||
|
|
||||||
|
To fully reset the game state (including all user accounts), stop the stack and delete both data directories, then start again:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
rm -rf data/postgres data/postgres_users
|
||||||
|
mkdir -p data/postgres data/postgres_users
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
{
|
{
|
||||||
"clickCooldownSeconds": 10,
|
"dailyActionQuota": 100,
|
||||||
"databaseWipeoutIntervalSeconds": 21600,
|
"teamActionQuota": 100,
|
||||||
"debugModeForTeams": true,
|
"databaseWipeoutIntervalSeconds": 604800,
|
||||||
"configReloadIntervalSeconds": 30,
|
"configReloadIntervalSeconds": 30,
|
||||||
"elementWorth": {
|
"elementWorth": {
|
||||||
"common": 1,
|
"common": 0.1,
|
||||||
"food": 2,
|
"food": 0.2,
|
||||||
"petrol": 3,
|
"petrol": 0.3,
|
||||||
"goods": 4,
|
"goods": 0.4,
|
||||||
"industry": 5,
|
"industry": 0.5,
|
||||||
"medic": 6,
|
"medic": 0.6,
|
||||||
"money": 7,
|
"money": 0.7,
|
||||||
"science": 8
|
"science": 0.8
|
||||||
|
},
|
||||||
|
"militaryPower": {
|
||||||
|
"humans": 10,
|
||||||
|
"near": 5,
|
||||||
|
"aliens": 1
|
||||||
},
|
},
|
||||||
"resourceWorth": {
|
"resourceWorth": {
|
||||||
"common": {
|
"common": {
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ services:
|
|||||||
DATABASE_URL: "postgres://${POSTGRES_USER:-game}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-star_wars_grid}"
|
DATABASE_URL: "postgres://${POSTGRES_USER:-game}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-star_wars_grid}"
|
||||||
USERS_DATABASE_URL: "postgres://${POSTGRES_USERS_USER:-users}:${POSTGRES_USERS_PASSWORD}@users_db:5432/${POSTGRES_USERS_DB:-star_wars_users}"
|
USERS_DATABASE_URL: "postgres://${POSTGRES_USERS_USER:-users}:${POSTGRES_USERS_PASSWORD}@users_db:5432/${POSTGRES_USERS_DB:-star_wars_users}"
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
|
||||||
PORT: "${PORT:-8080}"
|
PORT: "${PORT:-8080}"
|
||||||
CONFIG_FILE_PATH: /app/config/game.settings.json
|
CONFIG_FILE_PATH: /app/config/game.settings.json
|
||||||
|
CORS_ORIGIN: ${CORS_ORIGIN:-*}
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
47
node_modules/.package-lock.json
generated
vendored
@@ -132,6 +132,23 @@
|
|||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cors": {
|
||||||
|
"version": "2.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||||
|
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4",
|
||||||
|
"vary": "^1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -641,6 +658,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -1088,6 +1114,27 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
22
node_modules/cors/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
(The MIT License)
|
||||||
|
|
||||||
|
Copyright (c) 2013 Troy Goode <troygoode@gmail.com>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of this software and associated documentation files (the
|
||||||
|
'Software'), to deal in the Software without restriction, including
|
||||||
|
without limitation the rights to use, copy, modify, merge, publish,
|
||||||
|
distribute, sublicense, and/or sell copies of the Software, and to
|
||||||
|
permit persons to whom the Software is furnished to do so, subject to
|
||||||
|
the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be
|
||||||
|
included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||||
|
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||||
|
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||||
|
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
277
node_modules/cors/README.md
generated
vendored
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
# cors
|
||||||
|
|
||||||
|
[![NPM Version][npm-image]][npm-url]
|
||||||
|
[![NPM Downloads][downloads-image]][downloads-url]
|
||||||
|
[![Build Status][github-actions-ci-image]][github-actions-ci-url]
|
||||||
|
[![Test Coverage][coveralls-image]][coveralls-url]
|
||||||
|
|
||||||
|
CORS is a [Node.js](https://nodejs.org/en/) middleware for [Express](https://expressjs.com/)/[Connect](https://github.com/senchalabs/connect) that sets [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) response headers. These headers tell browsers which origins can read responses from your server.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **How CORS Works:** This package sets response headers—it doesn't block requests. CORS is enforced by browsers: they check the headers and decide if JavaScript can read the response. Non-browser clients (curl, Postman, other servers) ignore CORS entirely. See the [MDN CORS guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) for details.
|
||||||
|
|
||||||
|
* [Installation](#installation)
|
||||||
|
* [Usage](#usage)
|
||||||
|
* [Simple Usage](#simple-usage-enable-all-cors-requests)
|
||||||
|
* [Enable CORS for a Single Route](#enable-cors-for-a-single-route)
|
||||||
|
* [Configuring CORS](#configuring-cors)
|
||||||
|
* [Configuring CORS w/ Dynamic Origin](#configuring-cors-w-dynamic-origin)
|
||||||
|
* [Enabling CORS Pre-Flight](#enabling-cors-pre-flight)
|
||||||
|
* [Customizing CORS Settings Dynamically per Request](#customizing-cors-settings-dynamically-per-request)
|
||||||
|
* [Configuration Options](#configuration-options)
|
||||||
|
* [Common Misconceptions](#common-misconceptions)
|
||||||
|
* [License](#license)
|
||||||
|
* [Original Author](#original-author)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
This is a [Node.js](https://nodejs.org/en/) module available through the
|
||||||
|
[npm registry](https://www.npmjs.com/). Installation is done using the
|
||||||
|
[`npm install` command](https://docs.npmjs.com/downloading-and-installing-packages-locally):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ npm install cors
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Simple Usage (Enable *All* CORS Requests)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var express = require('express')
|
||||||
|
var cors = require('cors')
|
||||||
|
var app = express()
|
||||||
|
|
||||||
|
// Adds headers: Access-Control-Allow-Origin: *
|
||||||
|
app.use(cors())
|
||||||
|
|
||||||
|
app.get('/products/:id', function (req, res, next) {
|
||||||
|
res.json({msg: 'Hello'})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(80, function () {
|
||||||
|
console.log('web server listening on port 80')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enable CORS for a Single Route
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var express = require('express')
|
||||||
|
var cors = require('cors')
|
||||||
|
var app = express()
|
||||||
|
|
||||||
|
// Adds headers: Access-Control-Allow-Origin: *
|
||||||
|
app.get('/products/:id', cors(), function (req, res, next) {
|
||||||
|
res.json({msg: 'Hello'})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(80, function () {
|
||||||
|
console.log('web server listening on port 80')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuring CORS
|
||||||
|
|
||||||
|
See the [configuration options](#configuration-options) for details.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var express = require('express')
|
||||||
|
var cors = require('cors')
|
||||||
|
var app = express()
|
||||||
|
|
||||||
|
var corsOptions = {
|
||||||
|
origin: 'http://example.com',
|
||||||
|
optionsSuccessStatus: 200 // some legacy browsers (IE11, various SmartTVs) choke on 204
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds headers: Access-Control-Allow-Origin: http://example.com, Vary: Origin
|
||||||
|
app.get('/products/:id', cors(corsOptions), function (req, res, next) {
|
||||||
|
res.json({msg: 'Hello'})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(80, function () {
|
||||||
|
console.log('web server listening on port 80')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuring CORS w/ Dynamic Origin
|
||||||
|
|
||||||
|
This module supports validating the origin dynamically using a function provided
|
||||||
|
to the `origin` option. This function will be passed a string that is the origin
|
||||||
|
(or `undefined` if the request has no origin), and a `callback` with the signature
|
||||||
|
`callback(error, origin)`.
|
||||||
|
|
||||||
|
The `origin` argument to the callback can be any value allowed for the `origin`
|
||||||
|
option of the middleware, except a function. See the
|
||||||
|
[configuration options](#configuration-options) section for more information on all
|
||||||
|
the possible value types.
|
||||||
|
|
||||||
|
This function is designed to allow the dynamic loading of allowed origin(s) from
|
||||||
|
a backing datasource, like a database.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var express = require('express')
|
||||||
|
var cors = require('cors')
|
||||||
|
var app = express()
|
||||||
|
|
||||||
|
var corsOptions = {
|
||||||
|
origin: function (origin, callback) {
|
||||||
|
// db.loadOrigins is an example call to load
|
||||||
|
// a list of origins from a backing database
|
||||||
|
db.loadOrigins(function (error, origins) {
|
||||||
|
callback(error, origins)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds headers: Access-Control-Allow-Origin: <matched origin>, Vary: Origin
|
||||||
|
app.get('/products/:id', cors(corsOptions), function (req, res, next) {
|
||||||
|
res.json({msg: 'Hello'})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(80, function () {
|
||||||
|
console.log('web server listening on port 80')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enabling CORS Pre-Flight
|
||||||
|
|
||||||
|
Certain CORS requests are considered 'complex' and require an initial
|
||||||
|
`OPTIONS` request (called the "pre-flight request"). An example of a
|
||||||
|
'complex' CORS request is one that uses an HTTP verb other than
|
||||||
|
GET/HEAD/POST (such as DELETE) or that uses custom headers. To enable
|
||||||
|
pre-flighting, you must add a new OPTIONS handler for the route you want
|
||||||
|
to support:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var express = require('express')
|
||||||
|
var cors = require('cors')
|
||||||
|
var app = express()
|
||||||
|
|
||||||
|
app.options('/products/:id', cors()) // preflight for DELETE
|
||||||
|
app.del('/products/:id', cors(), function (req, res, next) {
|
||||||
|
res.json({msg: 'Hello'})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.listen(80, function () {
|
||||||
|
console.log('web server listening on port 80')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also enable pre-flight across-the-board like so:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
app.options('*', cors()) // include before other routes
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE: When using this middleware as an application level middleware (for
|
||||||
|
example, `app.use(cors())`), pre-flight requests are already handled for all
|
||||||
|
routes.
|
||||||
|
|
||||||
|
### Customizing CORS Settings Dynamically per Request
|
||||||
|
|
||||||
|
For APIs that require different CORS configurations for specific routes or requests, you can dynamically generate CORS options based on the incoming request. The `cors` middleware allows you to achieve this by passing a function instead of static options. This function is called for each incoming request and must use the callback pattern to return the appropriate CORS options.
|
||||||
|
|
||||||
|
The function accepts:
|
||||||
|
1. **`req`**:
|
||||||
|
- The incoming request object.
|
||||||
|
|
||||||
|
2. **`callback(error, corsOptions)`**:
|
||||||
|
- A function used to return the computed CORS options.
|
||||||
|
- **Arguments**:
|
||||||
|
- **`error`**: Pass `null` if there’s no error, or an error object to indicate a failure.
|
||||||
|
- **`corsOptions`**: An object specifying the CORS policy for the current request.
|
||||||
|
|
||||||
|
Here’s an example that handles both public routes and restricted, credential-sensitive routes:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var dynamicCorsOptions = function(req, callback) {
|
||||||
|
var corsOptions;
|
||||||
|
if (req.path.startsWith('/auth/connect/')) {
|
||||||
|
// Access-Control-Allow-Origin: http://mydomain.com, Access-Control-Allow-Credentials: true, Vary: Origin
|
||||||
|
corsOptions = {
|
||||||
|
origin: 'http://mydomain.com',
|
||||||
|
credentials: true
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Access-Control-Allow-Origin: *
|
||||||
|
corsOptions = { origin: '*' };
|
||||||
|
}
|
||||||
|
callback(null, corsOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
app.use(cors(dynamicCorsOptions));
|
||||||
|
|
||||||
|
app.get('/auth/connect/twitter', function (req, res) {
|
||||||
|
res.send('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/public', function (req, res) {
|
||||||
|
res.send('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(80, function () {
|
||||||
|
console.log('web server listening on port 80')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
* `origin`: Configures the **Access-Control-Allow-Origin** CORS header. Possible values:
|
||||||
|
- `Boolean` - set `origin` to `true` to reflect the [request origin](https://datatracker.ietf.org/doc/html/draft-abarth-origin-09), as defined by `req.header('Origin')`, or set it to `false` to disable CORS.
|
||||||
|
- `String` - set `origin` to a specific origin. For example, if you set it to
|
||||||
|
- `"http://example.com"` only requests from "http://example.com" will be allowed.
|
||||||
|
- `"*"` for all domains to be allowed.
|
||||||
|
- `RegExp` - set `origin` to a regular expression pattern which will be used to test the request origin. If it's a match, the request origin will be reflected. For example the pattern `/example\.com$/` will reflect any request that is coming from an origin ending with "example.com".
|
||||||
|
- `Array` - set `origin` to an array of valid origins. Each origin can be a `String` or a `RegExp`. For example `["http://example1.com", /\.example2\.com$/]` will accept any request from "http://example1.com" or from a subdomain of "example2.com".
|
||||||
|
- `Function` - set `origin` to a function implementing some custom logic. The function takes the request origin as the first parameter and a callback (called as `callback(err, origin)`, where `origin` is a non-function value of the `origin` option) as the second.
|
||||||
|
* `methods`: Configures the **Access-Control-Allow-Methods** CORS header. Expects a comma-delimited string (ex: 'GET,PUT,POST') or an array (ex: `['GET', 'PUT', 'POST']`).
|
||||||
|
* `allowedHeaders`: Configures the **Access-Control-Allow-Headers** CORS header. Expects a comma-delimited string (ex: 'Content-Type,Authorization') or an array (ex: `['Content-Type', 'Authorization']`). If not specified, defaults to reflecting the headers specified in the request's **Access-Control-Request-Headers** header.
|
||||||
|
* `exposedHeaders`: Configures the **Access-Control-Expose-Headers** CORS header. Expects a comma-delimited string (ex: 'Content-Range,X-Content-Range') or an array (ex: `['Content-Range', 'X-Content-Range']`). If not specified, no custom headers are exposed.
|
||||||
|
* `credentials`: Configures the **Access-Control-Allow-Credentials** CORS header. Set to `true` to pass the header, otherwise it is omitted.
|
||||||
|
* `maxAge`: Configures the **Access-Control-Max-Age** CORS header. Set to an integer to pass the header, otherwise it is omitted.
|
||||||
|
* `preflightContinue`: Pass the CORS preflight response to the next handler.
|
||||||
|
* `optionsSuccessStatus`: Provides a status code to use for successful `OPTIONS` requests, since some legacy browsers (IE11, various SmartTVs) choke on `204`.
|
||||||
|
|
||||||
|
The default configuration is the equivalent of:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"origin": "*",
|
||||||
|
"methods": "GET,HEAD,PUT,PATCH,POST,DELETE",
|
||||||
|
"preflightContinue": false,
|
||||||
|
"optionsSuccessStatus": 204
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Misconceptions
|
||||||
|
|
||||||
|
### "CORS blocks requests from disallowed origins"
|
||||||
|
|
||||||
|
**No.** Your server receives and processes every request. CORS headers tell the browser whether JavaScript can read the response—not whether the request is allowed.
|
||||||
|
|
||||||
|
### "CORS protects my API from unauthorized access"
|
||||||
|
|
||||||
|
**No.** CORS is not access control. Any HTTP client (curl, Postman, another server) can call your API regardless of CORS settings. Use authentication and authorization to protect your API.
|
||||||
|
|
||||||
|
### "Setting `origin: 'http://example.com'` means only that domain can access my server"
|
||||||
|
|
||||||
|
**No.** It means browsers will only let JavaScript from that origin read responses. The server still responds to all requests.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT License](http://www.opensource.org/licenses/mit-license.php)
|
||||||
|
|
||||||
|
## Original Author
|
||||||
|
|
||||||
|
[Troy Goode](https://github.com/TroyGoode) ([troygoode@gmail.com](mailto:troygoode@gmail.com))
|
||||||
|
|
||||||
|
[coveralls-image]: https://img.shields.io/coveralls/expressjs/cors/master.svg
|
||||||
|
[coveralls-url]: https://coveralls.io/r/expressjs/cors?branch=master
|
||||||
|
[downloads-image]: https://img.shields.io/npm/dm/cors.svg
|
||||||
|
[downloads-url]: https://npmjs.com/package/cors
|
||||||
|
[github-actions-ci-image]: https://img.shields.io/github/actions/workflow/status/expressjs/cors/ci.yml?branch=master&label=ci
|
||||||
|
[github-actions-ci-url]: https://github.com/expressjs/cors?query=workflow%3Aci
|
||||||
|
[npm-image]: https://img.shields.io/npm/v/cors.svg
|
||||||
|
[npm-url]: https://npmjs.com/package/cors
|
||||||
238
node_modules/cors/lib/index.js
generated
vendored
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
(function () {
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var assign = require('object-assign');
|
||||||
|
var vary = require('vary');
|
||||||
|
|
||||||
|
var defaults = {
|
||||||
|
origin: '*',
|
||||||
|
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||||
|
preflightContinue: false,
|
||||||
|
optionsSuccessStatus: 204
|
||||||
|
};
|
||||||
|
|
||||||
|
function isString(s) {
|
||||||
|
return typeof s === 'string' || s instanceof String;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOriginAllowed(origin, allowedOrigin) {
|
||||||
|
if (Array.isArray(allowedOrigin)) {
|
||||||
|
for (var i = 0; i < allowedOrigin.length; ++i) {
|
||||||
|
if (isOriginAllowed(origin, allowedOrigin[i])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} else if (isString(allowedOrigin)) {
|
||||||
|
return origin === allowedOrigin;
|
||||||
|
} else if (allowedOrigin instanceof RegExp) {
|
||||||
|
return allowedOrigin.test(origin);
|
||||||
|
} else {
|
||||||
|
return !!allowedOrigin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureOrigin(options, req) {
|
||||||
|
var requestOrigin = req.headers.origin,
|
||||||
|
headers = [],
|
||||||
|
isAllowed;
|
||||||
|
|
||||||
|
if (!options.origin || options.origin === '*') {
|
||||||
|
// allow any origin
|
||||||
|
headers.push([{
|
||||||
|
key: 'Access-Control-Allow-Origin',
|
||||||
|
value: '*'
|
||||||
|
}]);
|
||||||
|
} else if (isString(options.origin)) {
|
||||||
|
// fixed origin
|
||||||
|
headers.push([{
|
||||||
|
key: 'Access-Control-Allow-Origin',
|
||||||
|
value: options.origin
|
||||||
|
}]);
|
||||||
|
headers.push([{
|
||||||
|
key: 'Vary',
|
||||||
|
value: 'Origin'
|
||||||
|
}]);
|
||||||
|
} else {
|
||||||
|
isAllowed = isOriginAllowed(requestOrigin, options.origin);
|
||||||
|
// reflect origin
|
||||||
|
headers.push([{
|
||||||
|
key: 'Access-Control-Allow-Origin',
|
||||||
|
value: isAllowed ? requestOrigin : false
|
||||||
|
}]);
|
||||||
|
headers.push([{
|
||||||
|
key: 'Vary',
|
||||||
|
value: 'Origin'
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureMethods(options) {
|
||||||
|
var methods = options.methods;
|
||||||
|
if (methods.join) {
|
||||||
|
methods = options.methods.join(','); // .methods is an array, so turn it into a string
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: 'Access-Control-Allow-Methods',
|
||||||
|
value: methods
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureCredentials(options) {
|
||||||
|
if (options.credentials === true) {
|
||||||
|
return {
|
||||||
|
key: 'Access-Control-Allow-Credentials',
|
||||||
|
value: 'true'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureAllowedHeaders(options, req) {
|
||||||
|
var allowedHeaders = options.allowedHeaders || options.headers;
|
||||||
|
var headers = [];
|
||||||
|
|
||||||
|
if (!allowedHeaders) {
|
||||||
|
allowedHeaders = req.headers['access-control-request-headers']; // .headers wasn't specified, so reflect the request headers
|
||||||
|
headers.push([{
|
||||||
|
key: 'Vary',
|
||||||
|
value: 'Access-Control-Request-Headers'
|
||||||
|
}]);
|
||||||
|
} else if (allowedHeaders.join) {
|
||||||
|
allowedHeaders = allowedHeaders.join(','); // .headers is an array, so turn it into a string
|
||||||
|
}
|
||||||
|
if (allowedHeaders && allowedHeaders.length) {
|
||||||
|
headers.push([{
|
||||||
|
key: 'Access-Control-Allow-Headers',
|
||||||
|
value: allowedHeaders
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureExposedHeaders(options) {
|
||||||
|
var headers = options.exposedHeaders;
|
||||||
|
if (!headers) {
|
||||||
|
return null;
|
||||||
|
} else if (headers.join) {
|
||||||
|
headers = headers.join(','); // .headers is an array, so turn it into a string
|
||||||
|
}
|
||||||
|
if (headers && headers.length) {
|
||||||
|
return {
|
||||||
|
key: 'Access-Control-Expose-Headers',
|
||||||
|
value: headers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureMaxAge(options) {
|
||||||
|
var maxAge = (typeof options.maxAge === 'number' || options.maxAge) && options.maxAge.toString()
|
||||||
|
if (maxAge && maxAge.length) {
|
||||||
|
return {
|
||||||
|
key: 'Access-Control-Max-Age',
|
||||||
|
value: maxAge
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHeaders(headers, res) {
|
||||||
|
for (var i = 0, n = headers.length; i < n; i++) {
|
||||||
|
var header = headers[i];
|
||||||
|
if (header) {
|
||||||
|
if (Array.isArray(header)) {
|
||||||
|
applyHeaders(header, res);
|
||||||
|
} else if (header.key === 'Vary' && header.value) {
|
||||||
|
vary(res, header.value);
|
||||||
|
} else if (header.value) {
|
||||||
|
res.setHeader(header.key, header.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cors(options, req, res, next) {
|
||||||
|
var headers = [],
|
||||||
|
method = req.method && req.method.toUpperCase && req.method.toUpperCase();
|
||||||
|
|
||||||
|
if (method === 'OPTIONS') {
|
||||||
|
// preflight
|
||||||
|
headers.push(configureOrigin(options, req));
|
||||||
|
headers.push(configureCredentials(options))
|
||||||
|
headers.push(configureMethods(options))
|
||||||
|
headers.push(configureAllowedHeaders(options, req));
|
||||||
|
headers.push(configureMaxAge(options))
|
||||||
|
headers.push(configureExposedHeaders(options))
|
||||||
|
applyHeaders(headers, res);
|
||||||
|
|
||||||
|
if (options.preflightContinue) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
// Safari (and potentially other browsers) need content-length 0,
|
||||||
|
// for 204 or they just hang waiting for a body
|
||||||
|
res.statusCode = options.optionsSuccessStatus;
|
||||||
|
res.setHeader('Content-Length', '0');
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// actual response
|
||||||
|
headers.push(configureOrigin(options, req));
|
||||||
|
headers.push(configureCredentials(options))
|
||||||
|
headers.push(configureExposedHeaders(options))
|
||||||
|
applyHeaders(headers, res);
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function middlewareWrapper(o) {
|
||||||
|
// if options are static (either via defaults or custom options passed in), wrap in a function
|
||||||
|
var optionsCallback = null;
|
||||||
|
if (typeof o === 'function') {
|
||||||
|
optionsCallback = o;
|
||||||
|
} else {
|
||||||
|
optionsCallback = function (req, cb) {
|
||||||
|
cb(null, o);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return function corsMiddleware(req, res, next) {
|
||||||
|
optionsCallback(req, function (err, options) {
|
||||||
|
if (err) {
|
||||||
|
next(err);
|
||||||
|
} else {
|
||||||
|
var corsOptions = assign({}, defaults, options);
|
||||||
|
var originCallback = null;
|
||||||
|
if (corsOptions.origin && typeof corsOptions.origin === 'function') {
|
||||||
|
originCallback = corsOptions.origin;
|
||||||
|
} else if (corsOptions.origin) {
|
||||||
|
originCallback = function (origin, cb) {
|
||||||
|
cb(null, corsOptions.origin);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originCallback) {
|
||||||
|
originCallback(req.headers.origin, function (err2, origin) {
|
||||||
|
if (err2 || !origin) {
|
||||||
|
next(err2);
|
||||||
|
} else {
|
||||||
|
corsOptions.origin = origin;
|
||||||
|
cors(corsOptions, req, res, next);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// can pass either an options hash, an options delegate, or nothing
|
||||||
|
module.exports = middlewareWrapper;
|
||||||
|
|
||||||
|
}());
|
||||||
42
node_modules/cors/package.json
generated
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "cors",
|
||||||
|
"description": "Node.js CORS middleware",
|
||||||
|
"version": "2.8.6",
|
||||||
|
"author": "Troy Goode <troygoode@gmail.com> (https://github.com/troygoode/)",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"cors",
|
||||||
|
"express",
|
||||||
|
"connect",
|
||||||
|
"middleware"
|
||||||
|
],
|
||||||
|
"repository": "expressjs/cors",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
},
|
||||||
|
"main": "./lib/index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4",
|
||||||
|
"vary": "^1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"after": "0.8.2",
|
||||||
|
"eslint": "7.30.0",
|
||||||
|
"express": "4.21.2",
|
||||||
|
"mocha": "9.2.2",
|
||||||
|
"nyc": "15.1.0",
|
||||||
|
"supertest": "6.1.3"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"lib/index.js"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "npm run lint && npm run test-ci",
|
||||||
|
"test-ci": "nyc --reporter=lcov --reporter=text mocha --require test/support/env",
|
||||||
|
"lint": "eslint lib test"
|
||||||
|
}
|
||||||
|
}
|
||||||
90
node_modules/object-assign/index.js
generated
vendored
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
object-assign
|
||||||
|
(c) Sindre Sorhus
|
||||||
|
@license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
/* eslint-disable no-unused-vars */
|
||||||
|
var getOwnPropertySymbols = Object.getOwnPropertySymbols;
|
||||||
|
var hasOwnProperty = Object.prototype.hasOwnProperty;
|
||||||
|
var propIsEnumerable = Object.prototype.propertyIsEnumerable;
|
||||||
|
|
||||||
|
function toObject(val) {
|
||||||
|
if (val === null || val === undefined) {
|
||||||
|
throw new TypeError('Object.assign cannot be called with null or undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseNative() {
|
||||||
|
try {
|
||||||
|
if (!Object.assign) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect buggy property enumeration order in older V8 versions.
|
||||||
|
|
||||||
|
// https://bugs.chromium.org/p/v8/issues/detail?id=4118
|
||||||
|
var test1 = new String('abc'); // eslint-disable-line no-new-wrappers
|
||||||
|
test1[5] = 'de';
|
||||||
|
if (Object.getOwnPropertyNames(test1)[0] === '5') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://bugs.chromium.org/p/v8/issues/detail?id=3056
|
||||||
|
var test2 = {};
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
test2['_' + String.fromCharCode(i)] = i;
|
||||||
|
}
|
||||||
|
var order2 = Object.getOwnPropertyNames(test2).map(function (n) {
|
||||||
|
return test2[n];
|
||||||
|
});
|
||||||
|
if (order2.join('') !== '0123456789') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://bugs.chromium.org/p/v8/issues/detail?id=3056
|
||||||
|
var test3 = {};
|
||||||
|
'abcdefghijklmnopqrst'.split('').forEach(function (letter) {
|
||||||
|
test3[letter] = letter;
|
||||||
|
});
|
||||||
|
if (Object.keys(Object.assign({}, test3)).join('') !==
|
||||||
|
'abcdefghijklmnopqrst') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
// We don't expect any of the above to throw, but better to be safe.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = shouldUseNative() ? Object.assign : function (target, source) {
|
||||||
|
var from;
|
||||||
|
var to = toObject(target);
|
||||||
|
var symbols;
|
||||||
|
|
||||||
|
for (var s = 1; s < arguments.length; s++) {
|
||||||
|
from = Object(arguments[s]);
|
||||||
|
|
||||||
|
for (var key in from) {
|
||||||
|
if (hasOwnProperty.call(from, key)) {
|
||||||
|
to[key] = from[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getOwnPropertySymbols) {
|
||||||
|
symbols = getOwnPropertySymbols(from);
|
||||||
|
for (var i = 0; i < symbols.length; i++) {
|
||||||
|
if (propIsEnumerable.call(from, symbols[i])) {
|
||||||
|
to[symbols[i]] = from[symbols[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return to;
|
||||||
|
};
|
||||||
21
node_modules/object-assign/license
generated
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
42
node_modules/object-assign/package.json
generated
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "object-assign",
|
||||||
|
"version": "4.1.1",
|
||||||
|
"description": "ES2015 `Object.assign()` ponyfill",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": "sindresorhus/object-assign",
|
||||||
|
"author": {
|
||||||
|
"name": "Sindre Sorhus",
|
||||||
|
"email": "sindresorhus@gmail.com",
|
||||||
|
"url": "sindresorhus.com"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "xo && ava",
|
||||||
|
"bench": "matcha bench.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"index.js"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"object",
|
||||||
|
"assign",
|
||||||
|
"extend",
|
||||||
|
"properties",
|
||||||
|
"es2015",
|
||||||
|
"ecmascript",
|
||||||
|
"harmony",
|
||||||
|
"ponyfill",
|
||||||
|
"prollyfill",
|
||||||
|
"polyfill",
|
||||||
|
"shim",
|
||||||
|
"browser"
|
||||||
|
],
|
||||||
|
"devDependencies": {
|
||||||
|
"ava": "^0.16.0",
|
||||||
|
"lodash": "^4.16.4",
|
||||||
|
"matcha": "^0.7.0",
|
||||||
|
"xo": "^0.16.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
61
node_modules/object-assign/readme.md
generated
vendored
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# object-assign [](https://travis-ci.org/sindresorhus/object-assign)
|
||||||
|
|
||||||
|
> ES2015 [`Object.assign()`](http://www.2ality.com/2014/01/object-assign.html) [ponyfill](https://ponyfill.com)
|
||||||
|
|
||||||
|
|
||||||
|
## Use the built-in
|
||||||
|
|
||||||
|
Node.js 4 and up, as well as every evergreen browser (Chrome, Edge, Firefox, Opera, Safari),
|
||||||
|
support `Object.assign()` :tada:. If you target only those environments, then by all
|
||||||
|
means, use `Object.assign()` instead of this package.
|
||||||
|
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
$ npm install --save object-assign
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```js
|
||||||
|
const objectAssign = require('object-assign');
|
||||||
|
|
||||||
|
objectAssign({foo: 0}, {bar: 1});
|
||||||
|
//=> {foo: 0, bar: 1}
|
||||||
|
|
||||||
|
// multiple sources
|
||||||
|
objectAssign({foo: 0}, {bar: 1}, {baz: 2});
|
||||||
|
//=> {foo: 0, bar: 1, baz: 2}
|
||||||
|
|
||||||
|
// overwrites equal keys
|
||||||
|
objectAssign({foo: 0}, {foo: 1}, {foo: 2});
|
||||||
|
//=> {foo: 2}
|
||||||
|
|
||||||
|
// ignores null and undefined sources
|
||||||
|
objectAssign({foo: 0}, null, {bar: 1}, undefined);
|
||||||
|
//=> {foo: 0, bar: 1}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### objectAssign(target, [source, ...])
|
||||||
|
|
||||||
|
Assigns enumerable own properties of `source` objects to the `target` object and returns the `target` object. Additional `source` objects will overwrite previous ones.
|
||||||
|
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [ES2015 spec - Object.assign](https://people.mozilla.org/~jorendorff/es6-draft.html#sec-object.assign)
|
||||||
|
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [deep-assign](https://github.com/sindresorhus/deep-assign) - Recursive `Object.assign()`
|
||||||
|
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT © [Sindre Sorhus](https://sindresorhus.com)
|
||||||
20
node_modules/ws/LICENSE
generated
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
|
||||||
|
Copyright (c) 2013 Arnout Kazemier and contributors
|
||||||
|
Copyright (c) 2016 Luigi Pinca and contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
the Software without restriction, including without limitation the rights to
|
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||||
|
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
548
node_modules/ws/README.md
generated
vendored
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
# ws: a Node.js WebSocket library
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/ws)
|
||||||
|
[](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster)
|
||||||
|
[](https://coveralls.io/github/websockets/ws)
|
||||||
|
|
||||||
|
ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and
|
||||||
|
server implementation.
|
||||||
|
|
||||||
|
Passes the quite extensive Autobahn test suite: [server][server-report],
|
||||||
|
[client][client-report].
|
||||||
|
|
||||||
|
**Note**: This module does not work in the browser. The client in the docs is a
|
||||||
|
reference to a backend with the role of a client in the WebSocket communication.
|
||||||
|
Browser clients must use the native
|
||||||
|
[`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
|
||||||
|
object. To make the same code work seamlessly on Node.js and the browser, you
|
||||||
|
can use one of the many wrappers available on npm, like
|
||||||
|
[isomorphic-ws](https://github.com/heineiuo/isomorphic-ws).
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Protocol support](#protocol-support)
|
||||||
|
- [Installing](#installing)
|
||||||
|
- [Opt-in for performance](#opt-in-for-performance)
|
||||||
|
- [Legacy opt-in for performance](#legacy-opt-in-for-performance)
|
||||||
|
- [API docs](#api-docs)
|
||||||
|
- [WebSocket compression](#websocket-compression)
|
||||||
|
- [Usage examples](#usage-examples)
|
||||||
|
- [Sending and receiving text data](#sending-and-receiving-text-data)
|
||||||
|
- [Sending binary data](#sending-binary-data)
|
||||||
|
- [Simple server](#simple-server)
|
||||||
|
- [External HTTP/S server](#external-https-server)
|
||||||
|
- [Multiple servers sharing a single HTTP/S server](#multiple-servers-sharing-a-single-https-server)
|
||||||
|
- [Client authentication](#client-authentication)
|
||||||
|
- [Server broadcast](#server-broadcast)
|
||||||
|
- [Round-trip time](#round-trip-time)
|
||||||
|
- [Use the Node.js streams API](#use-the-nodejs-streams-api)
|
||||||
|
- [Other examples](#other-examples)
|
||||||
|
- [FAQ](#faq)
|
||||||
|
- [How to get the IP address of the client?](#how-to-get-the-ip-address-of-the-client)
|
||||||
|
- [How to detect and close broken connections?](#how-to-detect-and-close-broken-connections)
|
||||||
|
- [How to connect via a proxy?](#how-to-connect-via-a-proxy)
|
||||||
|
- [Changelog](#changelog)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
|
## Protocol support
|
||||||
|
|
||||||
|
- **HyBi drafts 07-12** (Use the option `protocolVersion: 8`)
|
||||||
|
- **HyBi drafts 13-17** (Current default, alternatively option
|
||||||
|
`protocolVersion: 13`)
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install ws
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opt-in for performance
|
||||||
|
|
||||||
|
[bufferutil][] is an optional module that can be installed alongside the ws
|
||||||
|
module:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install --save-optional bufferutil
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a binary addon that improves the performance of certain operations such
|
||||||
|
as masking and unmasking the data payload of the WebSocket frames. Prebuilt
|
||||||
|
binaries are available for the most popular platforms, so you don't necessarily
|
||||||
|
need to have a C++ compiler installed on your machine.
|
||||||
|
|
||||||
|
To force ws to not use bufferutil, use the
|
||||||
|
[`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) environment variable. This
|
||||||
|
can be useful to enhance security in systems where a user can put a package in
|
||||||
|
the package search path of an application of another user, due to how the
|
||||||
|
Node.js resolver algorithm works.
|
||||||
|
|
||||||
|
#### Legacy opt-in for performance
|
||||||
|
|
||||||
|
If you are running on an old version of Node.js (prior to v18.14.0), ws also
|
||||||
|
supports the [utf-8-validate][] module:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install --save-optional utf-8-validate
|
||||||
|
```
|
||||||
|
|
||||||
|
This contains a binary polyfill for [`buffer.isUtf8()`][].
|
||||||
|
|
||||||
|
To force ws not to use utf-8-validate, use the
|
||||||
|
[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment variable.
|
||||||
|
|
||||||
|
## API docs
|
||||||
|
|
||||||
|
See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and
|
||||||
|
utility functions.
|
||||||
|
|
||||||
|
## WebSocket compression
|
||||||
|
|
||||||
|
ws supports the [permessage-deflate extension][permessage-deflate] which enables
|
||||||
|
the client and server to negotiate a compression algorithm and its parameters,
|
||||||
|
and then selectively apply it to the data payloads of each WebSocket message.
|
||||||
|
|
||||||
|
The extension is disabled by default on the server and enabled by default on the
|
||||||
|
client. It adds a significant overhead in terms of performance and memory
|
||||||
|
consumption so we suggest to enable it only if it is really needed.
|
||||||
|
|
||||||
|
Note that Node.js has a variety of issues with high-performance compression,
|
||||||
|
where increased concurrency, especially on Linux, can lead to [catastrophic
|
||||||
|
memory fragmentation][node-zlib-bug] and slow performance. If you intend to use
|
||||||
|
permessage-deflate in production, it is worthwhile to set up a test
|
||||||
|
representative of your workload and ensure Node.js/zlib will handle it with
|
||||||
|
acceptable performance and memory usage.
|
||||||
|
|
||||||
|
Tuning of permessage-deflate can be done via the options defined below. You can
|
||||||
|
also use `zlibDeflateOptions` and `zlibInflateOptions`, which is passed directly
|
||||||
|
into the creation of [raw deflate/inflate streams][node-zlib-deflaterawdocs].
|
||||||
|
|
||||||
|
See [the docs][ws-server-options] for more options.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket, { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({
|
||||||
|
port: 8080,
|
||||||
|
perMessageDeflate: {
|
||||||
|
zlibDeflateOptions: {
|
||||||
|
// See zlib defaults.
|
||||||
|
chunkSize: 1024,
|
||||||
|
memLevel: 7,
|
||||||
|
level: 3
|
||||||
|
},
|
||||||
|
zlibInflateOptions: {
|
||||||
|
chunkSize: 10 * 1024
|
||||||
|
},
|
||||||
|
// Other options settable:
|
||||||
|
clientNoContextTakeover: true, // Defaults to negotiated value.
|
||||||
|
serverNoContextTakeover: true, // Defaults to negotiated value.
|
||||||
|
serverMaxWindowBits: 10, // Defaults to negotiated value.
|
||||||
|
// Below options specified as default values.
|
||||||
|
concurrencyLimit: 10, // Limits zlib concurrency for perf.
|
||||||
|
threshold: 1024 // Size (in bytes) below which messages
|
||||||
|
// should not be compressed if context takeover is disabled.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The client will only use the extension if it is supported and enabled on the
|
||||||
|
server. To always disable the extension on the client, set the
|
||||||
|
`perMessageDeflate` option to `false`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
const ws = new WebSocket('ws://www.host.com/path', {
|
||||||
|
perMessageDeflate: false
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage examples
|
||||||
|
|
||||||
|
### Sending and receiving text data
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
const ws = new WebSocket('ws://www.host.com/path');
|
||||||
|
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('open', function open() {
|
||||||
|
ws.send('something');
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', function message(data) {
|
||||||
|
console.log('received: %s', data);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sending binary data
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
const ws = new WebSocket('ws://www.host.com/path');
|
||||||
|
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('open', function open() {
|
||||||
|
const array = new Float32Array(5);
|
||||||
|
|
||||||
|
for (var i = 0; i < array.length; ++i) {
|
||||||
|
array[i] = i / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(array);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Simple server
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ port: 8080 });
|
||||||
|
|
||||||
|
wss.on('connection', function connection(ws) {
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('message', function message(data) {
|
||||||
|
console.log('received: %s', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.send('something');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### External HTTP/S server
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { createServer } from 'https';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
const server = createServer({
|
||||||
|
cert: readFileSync('/path/to/cert.pem'),
|
||||||
|
key: readFileSync('/path/to/key.pem')
|
||||||
|
});
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
wss.on('connection', function connection(ws) {
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('message', function message(data) {
|
||||||
|
console.log('received: %s', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.send('something');
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(8080);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple servers sharing a single HTTP/S server
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
const server = createServer();
|
||||||
|
const wss1 = new WebSocketServer({ noServer: true });
|
||||||
|
const wss2 = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
wss1.on('connection', function connection(ws) {
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
wss2.on('connection', function connection(ws) {
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('upgrade', function upgrade(request, socket, head) {
|
||||||
|
const { pathname } = new URL(request.url, 'wss://base.url');
|
||||||
|
|
||||||
|
if (pathname === '/foo') {
|
||||||
|
wss1.handleUpgrade(request, socket, head, function done(ws) {
|
||||||
|
wss1.emit('connection', ws, request);
|
||||||
|
});
|
||||||
|
} else if (pathname === '/bar') {
|
||||||
|
wss2.handleUpgrade(request, socket, head, function done(ws) {
|
||||||
|
wss2.emit('connection', ws, request);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(8080);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client authentication
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
function onSocketError(err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer();
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
wss.on('connection', function connection(ws, request, client) {
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('message', function message(data) {
|
||||||
|
console.log(`Received message ${data} from user ${client}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('upgrade', function upgrade(request, socket, head) {
|
||||||
|
socket.on('error', onSocketError);
|
||||||
|
|
||||||
|
// This function is not defined on purpose. Implement it with your own logic.
|
||||||
|
authenticate(request, function next(err, client) {
|
||||||
|
if (err || !client) {
|
||||||
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.removeListener('error', onSocketError);
|
||||||
|
|
||||||
|
wss.handleUpgrade(request, socket, head, function done(ws) {
|
||||||
|
wss.emit('connection', ws, request, client);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(8080);
|
||||||
|
```
|
||||||
|
|
||||||
|
Also see the provided [example][session-parse-example] using `express-session`.
|
||||||
|
|
||||||
|
### Server broadcast
|
||||||
|
|
||||||
|
A client WebSocket broadcasting to all connected WebSocket clients, including
|
||||||
|
itself.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket, { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ port: 8080 });
|
||||||
|
|
||||||
|
wss.on('connection', function connection(ws) {
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('message', function message(data, isBinary) {
|
||||||
|
wss.clients.forEach(function each(client) {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(data, { binary: isBinary });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
A client WebSocket broadcasting to every other connected WebSocket clients,
|
||||||
|
excluding itself.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket, { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ port: 8080 });
|
||||||
|
|
||||||
|
wss.on('connection', function connection(ws) {
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('message', function message(data, isBinary) {
|
||||||
|
wss.clients.forEach(function each(client) {
|
||||||
|
if (client !== ws && client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(data, { binary: isBinary });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Round-trip time
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
const ws = new WebSocket('wss://websocket-echo.com/');
|
||||||
|
|
||||||
|
ws.on('error', console.error);
|
||||||
|
|
||||||
|
ws.on('open', function open() {
|
||||||
|
console.log('connected');
|
||||||
|
ws.send(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', function close() {
|
||||||
|
console.log('disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', function message(data) {
|
||||||
|
console.log(`Round-trip time: ${Date.now() - data} ms`);
|
||||||
|
|
||||||
|
setTimeout(function timeout() {
|
||||||
|
ws.send(Date.now());
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use the Node.js streams API
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket, { createWebSocketStream } from 'ws';
|
||||||
|
|
||||||
|
const ws = new WebSocket('wss://websocket-echo.com/');
|
||||||
|
|
||||||
|
const duplex = createWebSocketStream(ws, { encoding: 'utf8' });
|
||||||
|
|
||||||
|
duplex.on('error', console.error);
|
||||||
|
|
||||||
|
duplex.pipe(process.stdout);
|
||||||
|
process.stdin.pipe(duplex);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other examples
|
||||||
|
|
||||||
|
For a full example with a browser client communicating with a ws server, see the
|
||||||
|
examples folder.
|
||||||
|
|
||||||
|
Otherwise, see the test cases.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
### How to get the IP address of the client?
|
||||||
|
|
||||||
|
The remote IP address can be obtained from the raw socket.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ port: 8080 });
|
||||||
|
|
||||||
|
wss.on('connection', function connection(ws, req) {
|
||||||
|
const ip = req.socket.remoteAddress;
|
||||||
|
|
||||||
|
ws.on('error', console.error);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
When the server runs behind a proxy like NGINX, the de-facto standard is to use
|
||||||
|
the `X-Forwarded-For` header.
|
||||||
|
|
||||||
|
```js
|
||||||
|
wss.on('connection', function connection(ws, req) {
|
||||||
|
const ip = req.headers['x-forwarded-for'].split(',')[0].trim();
|
||||||
|
|
||||||
|
ws.on('error', console.error);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to detect and close broken connections?
|
||||||
|
|
||||||
|
Sometimes, the link between the server and the client can be interrupted in a
|
||||||
|
way that keeps both the server and the client unaware of the broken state of the
|
||||||
|
connection (e.g. when pulling the cord).
|
||||||
|
|
||||||
|
In these cases, ping messages can be used as a means to verify that the remote
|
||||||
|
endpoint is still responsive.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
function heartbeat() {
|
||||||
|
this.isAlive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ port: 8080 });
|
||||||
|
|
||||||
|
wss.on('connection', function connection(ws) {
|
||||||
|
ws.isAlive = true;
|
||||||
|
ws.on('error', console.error);
|
||||||
|
ws.on('pong', heartbeat);
|
||||||
|
});
|
||||||
|
|
||||||
|
const interval = setInterval(function ping() {
|
||||||
|
wss.clients.forEach(function each(ws) {
|
||||||
|
if (ws.isAlive === false) return ws.terminate();
|
||||||
|
|
||||||
|
ws.isAlive = false;
|
||||||
|
ws.ping();
|
||||||
|
});
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
wss.on('close', function close() {
|
||||||
|
clearInterval(interval);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Pong messages are automatically sent in response to ping messages as required by
|
||||||
|
the spec.
|
||||||
|
|
||||||
|
Just like the server example above, your clients might as well lose connection
|
||||||
|
without knowing it. You might want to add a ping listener on your clients to
|
||||||
|
prevent that. A simple implementation would be:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
|
function heartbeat() {
|
||||||
|
clearTimeout(this.pingTimeout);
|
||||||
|
|
||||||
|
// Use `WebSocket#terminate()`, which immediately destroys the connection,
|
||||||
|
// instead of `WebSocket#close()`, which waits for the close timer.
|
||||||
|
// Delay should be equal to the interval at which your server
|
||||||
|
// sends out pings plus a conservative assumption of the latency.
|
||||||
|
this.pingTimeout = setTimeout(() => {
|
||||||
|
this.terminate();
|
||||||
|
}, 30000 + 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new WebSocket('wss://websocket-echo.com/');
|
||||||
|
|
||||||
|
client.on('error', console.error);
|
||||||
|
client.on('open', heartbeat);
|
||||||
|
client.on('ping', heartbeat);
|
||||||
|
client.on('close', function clear() {
|
||||||
|
clearTimeout(this.pingTimeout);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to connect via a proxy?
|
||||||
|
|
||||||
|
Use a custom `http.Agent` implementation like [https-proxy-agent][] or
|
||||||
|
[socks-proxy-agent][].
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
We're using the GitHub [releases][changelog] for changelog entries.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
|
|
||||||
|
[`buffer.isutf8()`]: https://nodejs.org/api/buffer.html#bufferisutf8input
|
||||||
|
[bufferutil]: https://github.com/websockets/bufferutil
|
||||||
|
[changelog]: https://github.com/websockets/ws/releases
|
||||||
|
[client-report]: http://websockets.github.io/ws/autobahn/clients/
|
||||||
|
[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent
|
||||||
|
[node-zlib-bug]: https://github.com/nodejs/node/issues/8871
|
||||||
|
[node-zlib-deflaterawdocs]:
|
||||||
|
https://nodejs.org/api/zlib.html#zlib_zlib_createdeflateraw_options
|
||||||
|
[permessage-deflate]: https://tools.ietf.org/html/rfc7692
|
||||||
|
[server-report]: http://websockets.github.io/ws/autobahn/servers/
|
||||||
|
[session-parse-example]: ./examples/express-session-parse
|
||||||
|
[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent
|
||||||
|
[utf-8-validate]: https://github.com/websockets/utf-8-validate
|
||||||
|
[ws-server-options]: ./doc/ws.md#new-websocketserveroptions-callback
|
||||||
8
node_modules/ws/browser.js
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = function () {
|
||||||
|
throw new Error(
|
||||||
|
'ws does not work in the browser. Browser clients must use the native ' +
|
||||||
|
'WebSocket object'
|
||||||
|
);
|
||||||
|
};
|
||||||
22
node_modules/ws/index.js
generated
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const createWebSocketStream = require('./lib/stream');
|
||||||
|
const extension = require('./lib/extension');
|
||||||
|
const PerMessageDeflate = require('./lib/permessage-deflate');
|
||||||
|
const Receiver = require('./lib/receiver');
|
||||||
|
const Sender = require('./lib/sender');
|
||||||
|
const subprotocol = require('./lib/subprotocol');
|
||||||
|
const WebSocket = require('./lib/websocket');
|
||||||
|
const WebSocketServer = require('./lib/websocket-server');
|
||||||
|
|
||||||
|
WebSocket.createWebSocketStream = createWebSocketStream;
|
||||||
|
WebSocket.extension = extension;
|
||||||
|
WebSocket.PerMessageDeflate = PerMessageDeflate;
|
||||||
|
WebSocket.Receiver = Receiver;
|
||||||
|
WebSocket.Sender = Sender;
|
||||||
|
WebSocket.Server = WebSocketServer;
|
||||||
|
WebSocket.subprotocol = subprotocol;
|
||||||
|
WebSocket.WebSocket = WebSocket;
|
||||||
|
WebSocket.WebSocketServer = WebSocketServer;
|
||||||
|
|
||||||
|
module.exports = WebSocket;
|
||||||
131
node_modules/ws/lib/buffer-util.js
generated
vendored
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { EMPTY_BUFFER } = require('./constants');
|
||||||
|
|
||||||
|
const FastBuffer = Buffer[Symbol.species];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges an array of buffers into a new buffer.
|
||||||
|
*
|
||||||
|
* @param {Buffer[]} list The array of buffers to concat
|
||||||
|
* @param {Number} totalLength The total length of buffers in the list
|
||||||
|
* @return {Buffer} The resulting buffer
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function concat(list, totalLength) {
|
||||||
|
if (list.length === 0) return EMPTY_BUFFER;
|
||||||
|
if (list.length === 1) return list[0];
|
||||||
|
|
||||||
|
const target = Buffer.allocUnsafe(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
const buf = list[i];
|
||||||
|
target.set(buf, offset);
|
||||||
|
offset += buf.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset < totalLength) {
|
||||||
|
return new FastBuffer(target.buffer, target.byteOffset, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Masks a buffer using the given mask.
|
||||||
|
*
|
||||||
|
* @param {Buffer} source The buffer to mask
|
||||||
|
* @param {Buffer} mask The mask to use
|
||||||
|
* @param {Buffer} output The buffer where to store the result
|
||||||
|
* @param {Number} offset The offset at which to start writing
|
||||||
|
* @param {Number} length The number of bytes to mask.
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function _mask(source, mask, output, offset, length) {
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
output[offset + i] = source[i] ^ mask[i & 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmasks a buffer using the given mask.
|
||||||
|
*
|
||||||
|
* @param {Buffer} buffer The buffer to unmask
|
||||||
|
* @param {Buffer} mask The mask to use
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function _unmask(buffer, mask) {
|
||||||
|
for (let i = 0; i < buffer.length; i++) {
|
||||||
|
buffer[i] ^= mask[i & 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a buffer to an `ArrayBuffer`.
|
||||||
|
*
|
||||||
|
* @param {Buffer} buf The buffer to convert
|
||||||
|
* @return {ArrayBuffer} Converted buffer
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function toArrayBuffer(buf) {
|
||||||
|
if (buf.length === buf.buffer.byteLength) {
|
||||||
|
return buf.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts `data` to a `Buffer`.
|
||||||
|
*
|
||||||
|
* @param {*} data The data to convert
|
||||||
|
* @return {Buffer} The buffer
|
||||||
|
* @throws {TypeError}
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function toBuffer(data) {
|
||||||
|
toBuffer.readOnly = true;
|
||||||
|
|
||||||
|
if (Buffer.isBuffer(data)) return data;
|
||||||
|
|
||||||
|
let buf;
|
||||||
|
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
buf = new FastBuffer(data);
|
||||||
|
} else if (ArrayBuffer.isView(data)) {
|
||||||
|
buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength);
|
||||||
|
} else {
|
||||||
|
buf = Buffer.from(data);
|
||||||
|
toBuffer.readOnly = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
concat,
|
||||||
|
mask: _mask,
|
||||||
|
toArrayBuffer,
|
||||||
|
toBuffer,
|
||||||
|
unmask: _unmask
|
||||||
|
};
|
||||||
|
|
||||||
|
/* istanbul ignore else */
|
||||||
|
if (!process.env.WS_NO_BUFFER_UTIL) {
|
||||||
|
try {
|
||||||
|
const bufferUtil = require('bufferutil');
|
||||||
|
|
||||||
|
module.exports.mask = function (source, mask, output, offset, length) {
|
||||||
|
if (length < 48) _mask(source, mask, output, offset, length);
|
||||||
|
else bufferUtil.mask(source, mask, output, offset, length);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.unmask = function (buffer, mask) {
|
||||||
|
if (buffer.length < 32) _unmask(buffer, mask);
|
||||||
|
else bufferUtil.unmask(buffer, mask);
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// Continue regardless of the error.
|
||||||
|
}
|
||||||
|
}
|
||||||
19
node_modules/ws/lib/constants.js
generated
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BINARY_TYPES = ['nodebuffer', 'arraybuffer', 'fragments'];
|
||||||
|
const hasBlob = typeof Blob !== 'undefined';
|
||||||
|
|
||||||
|
if (hasBlob) BINARY_TYPES.push('blob');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
BINARY_TYPES,
|
||||||
|
CLOSE_TIMEOUT: 30000,
|
||||||
|
EMPTY_BUFFER: Buffer.alloc(0),
|
||||||
|
GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
|
||||||
|
hasBlob,
|
||||||
|
kForOnEventAttribute: Symbol('kIsForOnEventAttribute'),
|
||||||
|
kListener: Symbol('kListener'),
|
||||||
|
kStatusCode: Symbol('status-code'),
|
||||||
|
kWebSocket: Symbol('websocket'),
|
||||||
|
NOOP: () => {}
|
||||||
|
};
|
||||||
292
node_modules/ws/lib/event-target.js
generated
vendored
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { kForOnEventAttribute, kListener } = require('./constants');
|
||||||
|
|
||||||
|
const kCode = Symbol('kCode');
|
||||||
|
const kData = Symbol('kData');
|
||||||
|
const kError = Symbol('kError');
|
||||||
|
const kMessage = Symbol('kMessage');
|
||||||
|
const kReason = Symbol('kReason');
|
||||||
|
const kTarget = Symbol('kTarget');
|
||||||
|
const kType = Symbol('kType');
|
||||||
|
const kWasClean = Symbol('kWasClean');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class representing an event.
|
||||||
|
*/
|
||||||
|
class Event {
|
||||||
|
/**
|
||||||
|
* Create a new `Event`.
|
||||||
|
*
|
||||||
|
* @param {String} type The name of the event
|
||||||
|
* @throws {TypeError} If the `type` argument is not specified
|
||||||
|
*/
|
||||||
|
constructor(type) {
|
||||||
|
this[kTarget] = null;
|
||||||
|
this[kType] = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {*}
|
||||||
|
*/
|
||||||
|
get target() {
|
||||||
|
return this[kTarget];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {String}
|
||||||
|
*/
|
||||||
|
get type() {
|
||||||
|
return this[kType];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(Event.prototype, 'target', { enumerable: true });
|
||||||
|
Object.defineProperty(Event.prototype, 'type', { enumerable: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class representing a close event.
|
||||||
|
*
|
||||||
|
* @extends Event
|
||||||
|
*/
|
||||||
|
class CloseEvent extends Event {
|
||||||
|
/**
|
||||||
|
* Create a new `CloseEvent`.
|
||||||
|
*
|
||||||
|
* @param {String} type The name of the event
|
||||||
|
* @param {Object} [options] A dictionary object that allows for setting
|
||||||
|
* attributes via object members of the same name
|
||||||
|
* @param {Number} [options.code=0] The status code explaining why the
|
||||||
|
* connection was closed
|
||||||
|
* @param {String} [options.reason=''] A human-readable string explaining why
|
||||||
|
* the connection was closed
|
||||||
|
* @param {Boolean} [options.wasClean=false] Indicates whether or not the
|
||||||
|
* connection was cleanly closed
|
||||||
|
*/
|
||||||
|
constructor(type, options = {}) {
|
||||||
|
super(type);
|
||||||
|
|
||||||
|
this[kCode] = options.code === undefined ? 0 : options.code;
|
||||||
|
this[kReason] = options.reason === undefined ? '' : options.reason;
|
||||||
|
this[kWasClean] = options.wasClean === undefined ? false : options.wasClean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Number}
|
||||||
|
*/
|
||||||
|
get code() {
|
||||||
|
return this[kCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {String}
|
||||||
|
*/
|
||||||
|
get reason() {
|
||||||
|
return this[kReason];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Boolean}
|
||||||
|
*/
|
||||||
|
get wasClean() {
|
||||||
|
return this[kWasClean];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(CloseEvent.prototype, 'code', { enumerable: true });
|
||||||
|
Object.defineProperty(CloseEvent.prototype, 'reason', { enumerable: true });
|
||||||
|
Object.defineProperty(CloseEvent.prototype, 'wasClean', { enumerable: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class representing an error event.
|
||||||
|
*
|
||||||
|
* @extends Event
|
||||||
|
*/
|
||||||
|
class ErrorEvent extends Event {
|
||||||
|
/**
|
||||||
|
* Create a new `ErrorEvent`.
|
||||||
|
*
|
||||||
|
* @param {String} type The name of the event
|
||||||
|
* @param {Object} [options] A dictionary object that allows for setting
|
||||||
|
* attributes via object members of the same name
|
||||||
|
* @param {*} [options.error=null] The error that generated this event
|
||||||
|
* @param {String} [options.message=''] The error message
|
||||||
|
*/
|
||||||
|
constructor(type, options = {}) {
|
||||||
|
super(type);
|
||||||
|
|
||||||
|
this[kError] = options.error === undefined ? null : options.error;
|
||||||
|
this[kMessage] = options.message === undefined ? '' : options.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {*}
|
||||||
|
*/
|
||||||
|
get error() {
|
||||||
|
return this[kError];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {String}
|
||||||
|
*/
|
||||||
|
get message() {
|
||||||
|
return this[kMessage];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(ErrorEvent.prototype, 'error', { enumerable: true });
|
||||||
|
Object.defineProperty(ErrorEvent.prototype, 'message', { enumerable: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class representing a message event.
|
||||||
|
*
|
||||||
|
* @extends Event
|
||||||
|
*/
|
||||||
|
class MessageEvent extends Event {
|
||||||
|
/**
|
||||||
|
* Create a new `MessageEvent`.
|
||||||
|
*
|
||||||
|
* @param {String} type The name of the event
|
||||||
|
* @param {Object} [options] A dictionary object that allows for setting
|
||||||
|
* attributes via object members of the same name
|
||||||
|
* @param {*} [options.data=null] The message content
|
||||||
|
*/
|
||||||
|
constructor(type, options = {}) {
|
||||||
|
super(type);
|
||||||
|
|
||||||
|
this[kData] = options.data === undefined ? null : options.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {*}
|
||||||
|
*/
|
||||||
|
get data() {
|
||||||
|
return this[kData];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(MessageEvent.prototype, 'data', { enumerable: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This provides methods for emulating the `EventTarget` interface. It's not
|
||||||
|
* meant to be used directly.
|
||||||
|
*
|
||||||
|
* @mixin
|
||||||
|
*/
|
||||||
|
const EventTarget = {
|
||||||
|
/**
|
||||||
|
* Register an event listener.
|
||||||
|
*
|
||||||
|
* @param {String} type A string representing the event type to listen for
|
||||||
|
* @param {(Function|Object)} handler The listener to add
|
||||||
|
* @param {Object} [options] An options object specifies characteristics about
|
||||||
|
* the event listener
|
||||||
|
* @param {Boolean} [options.once=false] A `Boolean` indicating that the
|
||||||
|
* listener should be invoked at most once after being added. If `true`,
|
||||||
|
* the listener would be automatically removed when invoked.
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
addEventListener(type, handler, options = {}) {
|
||||||
|
for (const listener of this.listeners(type)) {
|
||||||
|
if (
|
||||||
|
!options[kForOnEventAttribute] &&
|
||||||
|
listener[kListener] === handler &&
|
||||||
|
!listener[kForOnEventAttribute]
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
if (type === 'message') {
|
||||||
|
wrapper = function onMessage(data, isBinary) {
|
||||||
|
const event = new MessageEvent('message', {
|
||||||
|
data: isBinary ? data : data.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
event[kTarget] = this;
|
||||||
|
callListener(handler, this, event);
|
||||||
|
};
|
||||||
|
} else if (type === 'close') {
|
||||||
|
wrapper = function onClose(code, message) {
|
||||||
|
const event = new CloseEvent('close', {
|
||||||
|
code,
|
||||||
|
reason: message.toString(),
|
||||||
|
wasClean: this._closeFrameReceived && this._closeFrameSent
|
||||||
|
});
|
||||||
|
|
||||||
|
event[kTarget] = this;
|
||||||
|
callListener(handler, this, event);
|
||||||
|
};
|
||||||
|
} else if (type === 'error') {
|
||||||
|
wrapper = function onError(error) {
|
||||||
|
const event = new ErrorEvent('error', {
|
||||||
|
error,
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
|
||||||
|
event[kTarget] = this;
|
||||||
|
callListener(handler, this, event);
|
||||||
|
};
|
||||||
|
} else if (type === 'open') {
|
||||||
|
wrapper = function onOpen() {
|
||||||
|
const event = new Event('open');
|
||||||
|
|
||||||
|
event[kTarget] = this;
|
||||||
|
callListener(handler, this, event);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute];
|
||||||
|
wrapper[kListener] = handler;
|
||||||
|
|
||||||
|
if (options.once) {
|
||||||
|
this.once(type, wrapper);
|
||||||
|
} else {
|
||||||
|
this.on(type, wrapper);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an event listener.
|
||||||
|
*
|
||||||
|
* @param {String} type A string representing the event type to remove
|
||||||
|
* @param {(Function|Object)} handler The listener to remove
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
removeEventListener(type, handler) {
|
||||||
|
for (const listener of this.listeners(type)) {
|
||||||
|
if (listener[kListener] === handler && !listener[kForOnEventAttribute]) {
|
||||||
|
this.removeListener(type, listener);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CloseEvent,
|
||||||
|
ErrorEvent,
|
||||||
|
Event,
|
||||||
|
EventTarget,
|
||||||
|
MessageEvent
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call an event listener
|
||||||
|
*
|
||||||
|
* @param {(Function|Object)} listener The listener to call
|
||||||
|
* @param {*} thisArg The value to use as `this`` when calling the listener
|
||||||
|
* @param {Event} event The event to pass to the listener
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function callListener(listener, thisArg, event) {
|
||||||
|
if (typeof listener === 'object' && listener.handleEvent) {
|
||||||
|
listener.handleEvent.call(listener, event);
|
||||||
|
} else {
|
||||||
|
listener.call(thisArg, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
203
node_modules/ws/lib/extension.js
generated
vendored
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { tokenChars } = require('./validation');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an offer to the map of extension offers or a parameter to the map of
|
||||||
|
* parameters.
|
||||||
|
*
|
||||||
|
* @param {Object} dest The map of extension offers or parameters
|
||||||
|
* @param {String} name The extension or parameter name
|
||||||
|
* @param {(Object|Boolean|String)} elem The extension parameters or the
|
||||||
|
* parameter value
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function push(dest, name, elem) {
|
||||||
|
if (dest[name] === undefined) dest[name] = [elem];
|
||||||
|
else dest[name].push(elem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the `Sec-WebSocket-Extensions` header into an object.
|
||||||
|
*
|
||||||
|
* @param {String} header The field value of the header
|
||||||
|
* @return {Object} The parsed object
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function parse(header) {
|
||||||
|
const offers = Object.create(null);
|
||||||
|
let params = Object.create(null);
|
||||||
|
let mustUnescape = false;
|
||||||
|
let isEscaping = false;
|
||||||
|
let inQuotes = false;
|
||||||
|
let extensionName;
|
||||||
|
let paramName;
|
||||||
|
let start = -1;
|
||||||
|
let code = -1;
|
||||||
|
let end = -1;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
for (; i < header.length; i++) {
|
||||||
|
code = header.charCodeAt(i);
|
||||||
|
|
||||||
|
if (extensionName === undefined) {
|
||||||
|
if (end === -1 && tokenChars[code] === 1) {
|
||||||
|
if (start === -1) start = i;
|
||||||
|
} else if (
|
||||||
|
i !== 0 &&
|
||||||
|
(code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
|
||||||
|
) {
|
||||||
|
if (end === -1 && start !== -1) end = i;
|
||||||
|
} else if (code === 0x3b /* ';' */ || code === 0x2c /* ',' */) {
|
||||||
|
if (start === -1) {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end === -1) end = i;
|
||||||
|
const name = header.slice(start, end);
|
||||||
|
if (code === 0x2c) {
|
||||||
|
push(offers, name, params);
|
||||||
|
params = Object.create(null);
|
||||||
|
} else {
|
||||||
|
extensionName = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
start = end = -1;
|
||||||
|
} else {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
} else if (paramName === undefined) {
|
||||||
|
if (end === -1 && tokenChars[code] === 1) {
|
||||||
|
if (start === -1) start = i;
|
||||||
|
} else if (code === 0x20 || code === 0x09) {
|
||||||
|
if (end === -1 && start !== -1) end = i;
|
||||||
|
} else if (code === 0x3b || code === 0x2c) {
|
||||||
|
if (start === -1) {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end === -1) end = i;
|
||||||
|
push(params, header.slice(start, end), true);
|
||||||
|
if (code === 0x2c) {
|
||||||
|
push(offers, extensionName, params);
|
||||||
|
params = Object.create(null);
|
||||||
|
extensionName = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
start = end = -1;
|
||||||
|
} else if (code === 0x3d /* '=' */ && start !== -1 && end === -1) {
|
||||||
|
paramName = header.slice(start, i);
|
||||||
|
start = end = -1;
|
||||||
|
} else {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
//
|
||||||
|
// The value of a quoted-string after unescaping must conform to the
|
||||||
|
// token ABNF, so only token characters are valid.
|
||||||
|
// Ref: https://tools.ietf.org/html/rfc6455#section-9.1
|
||||||
|
//
|
||||||
|
if (isEscaping) {
|
||||||
|
if (tokenChars[code] !== 1) {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
if (start === -1) start = i;
|
||||||
|
else if (!mustUnescape) mustUnescape = true;
|
||||||
|
isEscaping = false;
|
||||||
|
} else if (inQuotes) {
|
||||||
|
if (tokenChars[code] === 1) {
|
||||||
|
if (start === -1) start = i;
|
||||||
|
} else if (code === 0x22 /* '"' */ && start !== -1) {
|
||||||
|
inQuotes = false;
|
||||||
|
end = i;
|
||||||
|
} else if (code === 0x5c /* '\' */) {
|
||||||
|
isEscaping = true;
|
||||||
|
} else {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
} else if (code === 0x22 && header.charCodeAt(i - 1) === 0x3d) {
|
||||||
|
inQuotes = true;
|
||||||
|
} else if (end === -1 && tokenChars[code] === 1) {
|
||||||
|
if (start === -1) start = i;
|
||||||
|
} else if (start !== -1 && (code === 0x20 || code === 0x09)) {
|
||||||
|
if (end === -1) end = i;
|
||||||
|
} else if (code === 0x3b || code === 0x2c) {
|
||||||
|
if (start === -1) {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end === -1) end = i;
|
||||||
|
let value = header.slice(start, end);
|
||||||
|
if (mustUnescape) {
|
||||||
|
value = value.replace(/\\/g, '');
|
||||||
|
mustUnescape = false;
|
||||||
|
}
|
||||||
|
push(params, paramName, value);
|
||||||
|
if (code === 0x2c) {
|
||||||
|
push(offers, extensionName, params);
|
||||||
|
params = Object.create(null);
|
||||||
|
extensionName = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
paramName = undefined;
|
||||||
|
start = end = -1;
|
||||||
|
} else {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start === -1 || inQuotes || code === 0x20 || code === 0x09) {
|
||||||
|
throw new SyntaxError('Unexpected end of input');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end === -1) end = i;
|
||||||
|
const token = header.slice(start, end);
|
||||||
|
if (extensionName === undefined) {
|
||||||
|
push(offers, token, params);
|
||||||
|
} else {
|
||||||
|
if (paramName === undefined) {
|
||||||
|
push(params, token, true);
|
||||||
|
} else if (mustUnescape) {
|
||||||
|
push(params, paramName, token.replace(/\\/g, ''));
|
||||||
|
} else {
|
||||||
|
push(params, paramName, token);
|
||||||
|
}
|
||||||
|
push(offers, extensionName, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
return offers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the `Sec-WebSocket-Extensions` header field value.
|
||||||
|
*
|
||||||
|
* @param {Object} extensions The map of extensions and parameters to format
|
||||||
|
* @return {String} A string representing the given object
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function format(extensions) {
|
||||||
|
return Object.keys(extensions)
|
||||||
|
.map((extension) => {
|
||||||
|
let configurations = extensions[extension];
|
||||||
|
if (!Array.isArray(configurations)) configurations = [configurations];
|
||||||
|
return configurations
|
||||||
|
.map((params) => {
|
||||||
|
return [extension]
|
||||||
|
.concat(
|
||||||
|
Object.keys(params).map((k) => {
|
||||||
|
let values = params[k];
|
||||||
|
if (!Array.isArray(values)) values = [values];
|
||||||
|
return values
|
||||||
|
.map((v) => (v === true ? k : `${k}=${v}`))
|
||||||
|
.join('; ');
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.join('; ');
|
||||||
|
})
|
||||||
|
.join(', ');
|
||||||
|
})
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { format, parse };
|
||||||
55
node_modules/ws/lib/limiter.js
generated
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const kDone = Symbol('kDone');
|
||||||
|
const kRun = Symbol('kRun');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A very simple job queue with adjustable concurrency. Adapted from
|
||||||
|
* https://github.com/STRML/async-limiter
|
||||||
|
*/
|
||||||
|
class Limiter {
|
||||||
|
/**
|
||||||
|
* Creates a new `Limiter`.
|
||||||
|
*
|
||||||
|
* @param {Number} [concurrency=Infinity] The maximum number of jobs allowed
|
||||||
|
* to run concurrently
|
||||||
|
*/
|
||||||
|
constructor(concurrency) {
|
||||||
|
this[kDone] = () => {
|
||||||
|
this.pending--;
|
||||||
|
this[kRun]();
|
||||||
|
};
|
||||||
|
this.concurrency = concurrency || Infinity;
|
||||||
|
this.jobs = [];
|
||||||
|
this.pending = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a job to the queue.
|
||||||
|
*
|
||||||
|
* @param {Function} job The job to run
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
add(job) {
|
||||||
|
this.jobs.push(job);
|
||||||
|
this[kRun]();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a job from the queue and runs it if possible.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
[kRun]() {
|
||||||
|
if (this.pending === this.concurrency) return;
|
||||||
|
|
||||||
|
if (this.jobs.length) {
|
||||||
|
const job = this.jobs.shift();
|
||||||
|
|
||||||
|
this.pending++;
|
||||||
|
job(this[kDone]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Limiter;
|
||||||
528
node_modules/ws/lib/permessage-deflate.js
generated
vendored
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const zlib = require('zlib');
|
||||||
|
|
||||||
|
const bufferUtil = require('./buffer-util');
|
||||||
|
const Limiter = require('./limiter');
|
||||||
|
const { kStatusCode } = require('./constants');
|
||||||
|
|
||||||
|
const FastBuffer = Buffer[Symbol.species];
|
||||||
|
const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]);
|
||||||
|
const kPerMessageDeflate = Symbol('permessage-deflate');
|
||||||
|
const kTotalLength = Symbol('total-length');
|
||||||
|
const kCallback = Symbol('callback');
|
||||||
|
const kBuffers = Symbol('buffers');
|
||||||
|
const kError = Symbol('error');
|
||||||
|
|
||||||
|
//
|
||||||
|
// We limit zlib concurrency, which prevents severe memory fragmentation
|
||||||
|
// as documented in https://github.com/nodejs/node/issues/8871#issuecomment-250915913
|
||||||
|
// and https://github.com/websockets/ws/issues/1202
|
||||||
|
//
|
||||||
|
// Intentionally global; it's the global thread pool that's an issue.
|
||||||
|
//
|
||||||
|
let zlibLimiter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* permessage-deflate implementation.
|
||||||
|
*/
|
||||||
|
class PerMessageDeflate {
|
||||||
|
/**
|
||||||
|
* Creates a PerMessageDeflate instance.
|
||||||
|
*
|
||||||
|
* @param {Object} [options] Configuration options
|
||||||
|
* @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support
|
||||||
|
* for, or request, a custom client window size
|
||||||
|
* @param {Boolean} [options.clientNoContextTakeover=false] Advertise/
|
||||||
|
* acknowledge disabling of client context takeover
|
||||||
|
* @param {Number} [options.concurrencyLimit=10] The number of concurrent
|
||||||
|
* calls to zlib
|
||||||
|
* @param {Boolean} [options.isServer=false] Create the instance in either
|
||||||
|
* server or client mode
|
||||||
|
* @param {Number} [options.maxPayload=0] The maximum allowed message length
|
||||||
|
* @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the
|
||||||
|
* use of a custom server window size
|
||||||
|
* @param {Boolean} [options.serverNoContextTakeover=false] Request/accept
|
||||||
|
* disabling of server context takeover
|
||||||
|
* @param {Number} [options.threshold=1024] Size (in bytes) below which
|
||||||
|
* messages should not be compressed if context takeover is disabled
|
||||||
|
* @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on
|
||||||
|
* deflate
|
||||||
|
* @param {Object} [options.zlibInflateOptions] Options to pass to zlib on
|
||||||
|
* inflate
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
this._options = options || {};
|
||||||
|
this._threshold =
|
||||||
|
this._options.threshold !== undefined ? this._options.threshold : 1024;
|
||||||
|
this._maxPayload = this._options.maxPayload | 0;
|
||||||
|
this._isServer = !!this._options.isServer;
|
||||||
|
this._deflate = null;
|
||||||
|
this._inflate = null;
|
||||||
|
|
||||||
|
this.params = null;
|
||||||
|
|
||||||
|
if (!zlibLimiter) {
|
||||||
|
const concurrency =
|
||||||
|
this._options.concurrencyLimit !== undefined
|
||||||
|
? this._options.concurrencyLimit
|
||||||
|
: 10;
|
||||||
|
zlibLimiter = new Limiter(concurrency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {String}
|
||||||
|
*/
|
||||||
|
static get extensionName() {
|
||||||
|
return 'permessage-deflate';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an extension negotiation offer.
|
||||||
|
*
|
||||||
|
* @return {Object} Extension parameters
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
offer() {
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
if (this._options.serverNoContextTakeover) {
|
||||||
|
params.server_no_context_takeover = true;
|
||||||
|
}
|
||||||
|
if (this._options.clientNoContextTakeover) {
|
||||||
|
params.client_no_context_takeover = true;
|
||||||
|
}
|
||||||
|
if (this._options.serverMaxWindowBits) {
|
||||||
|
params.server_max_window_bits = this._options.serverMaxWindowBits;
|
||||||
|
}
|
||||||
|
if (this._options.clientMaxWindowBits) {
|
||||||
|
params.client_max_window_bits = this._options.clientMaxWindowBits;
|
||||||
|
} else if (this._options.clientMaxWindowBits == null) {
|
||||||
|
params.client_max_window_bits = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept an extension negotiation offer/response.
|
||||||
|
*
|
||||||
|
* @param {Array} configurations The extension negotiation offers/reponse
|
||||||
|
* @return {Object} Accepted configuration
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
accept(configurations) {
|
||||||
|
configurations = this.normalizeParams(configurations);
|
||||||
|
|
||||||
|
this.params = this._isServer
|
||||||
|
? this.acceptAsServer(configurations)
|
||||||
|
: this.acceptAsClient(configurations);
|
||||||
|
|
||||||
|
return this.params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases all resources used by the extension.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
cleanup() {
|
||||||
|
if (this._inflate) {
|
||||||
|
this._inflate.close();
|
||||||
|
this._inflate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._deflate) {
|
||||||
|
const callback = this._deflate[kCallback];
|
||||||
|
|
||||||
|
this._deflate.close();
|
||||||
|
this._deflate = null;
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
callback(
|
||||||
|
new Error(
|
||||||
|
'The deflate stream was closed while data was being processed'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept an extension negotiation offer.
|
||||||
|
*
|
||||||
|
* @param {Array} offers The extension negotiation offers
|
||||||
|
* @return {Object} Accepted configuration
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
acceptAsServer(offers) {
|
||||||
|
const opts = this._options;
|
||||||
|
const accepted = offers.find((params) => {
|
||||||
|
if (
|
||||||
|
(opts.serverNoContextTakeover === false &&
|
||||||
|
params.server_no_context_takeover) ||
|
||||||
|
(params.server_max_window_bits &&
|
||||||
|
(opts.serverMaxWindowBits === false ||
|
||||||
|
(typeof opts.serverMaxWindowBits === 'number' &&
|
||||||
|
opts.serverMaxWindowBits > params.server_max_window_bits))) ||
|
||||||
|
(typeof opts.clientMaxWindowBits === 'number' &&
|
||||||
|
!params.client_max_window_bits)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accepted) {
|
||||||
|
throw new Error('None of the extension offers can be accepted');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.serverNoContextTakeover) {
|
||||||
|
accepted.server_no_context_takeover = true;
|
||||||
|
}
|
||||||
|
if (opts.clientNoContextTakeover) {
|
||||||
|
accepted.client_no_context_takeover = true;
|
||||||
|
}
|
||||||
|
if (typeof opts.serverMaxWindowBits === 'number') {
|
||||||
|
accepted.server_max_window_bits = opts.serverMaxWindowBits;
|
||||||
|
}
|
||||||
|
if (typeof opts.clientMaxWindowBits === 'number') {
|
||||||
|
accepted.client_max_window_bits = opts.clientMaxWindowBits;
|
||||||
|
} else if (
|
||||||
|
accepted.client_max_window_bits === true ||
|
||||||
|
opts.clientMaxWindowBits === false
|
||||||
|
) {
|
||||||
|
delete accepted.client_max_window_bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept the extension negotiation response.
|
||||||
|
*
|
||||||
|
* @param {Array} response The extension negotiation response
|
||||||
|
* @return {Object} Accepted configuration
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
acceptAsClient(response) {
|
||||||
|
const params = response[0];
|
||||||
|
|
||||||
|
if (
|
||||||
|
this._options.clientNoContextTakeover === false &&
|
||||||
|
params.client_no_context_takeover
|
||||||
|
) {
|
||||||
|
throw new Error('Unexpected parameter "client_no_context_takeover"');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params.client_max_window_bits) {
|
||||||
|
if (typeof this._options.clientMaxWindowBits === 'number') {
|
||||||
|
params.client_max_window_bits = this._options.clientMaxWindowBits;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
this._options.clientMaxWindowBits === false ||
|
||||||
|
(typeof this._options.clientMaxWindowBits === 'number' &&
|
||||||
|
params.client_max_window_bits > this._options.clientMaxWindowBits)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Unexpected or invalid parameter "client_max_window_bits"'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize parameters.
|
||||||
|
*
|
||||||
|
* @param {Array} configurations The extension negotiation offers/reponse
|
||||||
|
* @return {Array} The offers/response with normalized parameters
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
normalizeParams(configurations) {
|
||||||
|
configurations.forEach((params) => {
|
||||||
|
Object.keys(params).forEach((key) => {
|
||||||
|
let value = params[key];
|
||||||
|
|
||||||
|
if (value.length > 1) {
|
||||||
|
throw new Error(`Parameter "${key}" must have only a single value`);
|
||||||
|
}
|
||||||
|
|
||||||
|
value = value[0];
|
||||||
|
|
||||||
|
if (key === 'client_max_window_bits') {
|
||||||
|
if (value !== true) {
|
||||||
|
const num = +value;
|
||||||
|
if (!Number.isInteger(num) || num < 8 || num > 15) {
|
||||||
|
throw new TypeError(
|
||||||
|
`Invalid value for parameter "${key}": ${value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
value = num;
|
||||||
|
} else if (!this._isServer) {
|
||||||
|
throw new TypeError(
|
||||||
|
`Invalid value for parameter "${key}": ${value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (key === 'server_max_window_bits') {
|
||||||
|
const num = +value;
|
||||||
|
if (!Number.isInteger(num) || num < 8 || num > 15) {
|
||||||
|
throw new TypeError(
|
||||||
|
`Invalid value for parameter "${key}": ${value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
value = num;
|
||||||
|
} else if (
|
||||||
|
key === 'client_no_context_takeover' ||
|
||||||
|
key === 'server_no_context_takeover'
|
||||||
|
) {
|
||||||
|
if (value !== true) {
|
||||||
|
throw new TypeError(
|
||||||
|
`Invalid value for parameter "${key}": ${value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown parameter "${key}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
params[key] = value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return configurations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decompress data. Concurrency limited.
|
||||||
|
*
|
||||||
|
* @param {Buffer} data Compressed data
|
||||||
|
* @param {Boolean} fin Specifies whether or not this is the last fragment
|
||||||
|
* @param {Function} callback Callback
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
decompress(data, fin, callback) {
|
||||||
|
zlibLimiter.add((done) => {
|
||||||
|
this._decompress(data, fin, (err, result) => {
|
||||||
|
done();
|
||||||
|
callback(err, result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compress data. Concurrency limited.
|
||||||
|
*
|
||||||
|
* @param {(Buffer|String)} data Data to compress
|
||||||
|
* @param {Boolean} fin Specifies whether or not this is the last fragment
|
||||||
|
* @param {Function} callback Callback
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
compress(data, fin, callback) {
|
||||||
|
zlibLimiter.add((done) => {
|
||||||
|
this._compress(data, fin, (err, result) => {
|
||||||
|
done();
|
||||||
|
callback(err, result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decompress data.
|
||||||
|
*
|
||||||
|
* @param {Buffer} data Compressed data
|
||||||
|
* @param {Boolean} fin Specifies whether or not this is the last fragment
|
||||||
|
* @param {Function} callback Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_decompress(data, fin, callback) {
|
||||||
|
const endpoint = this._isServer ? 'client' : 'server';
|
||||||
|
|
||||||
|
if (!this._inflate) {
|
||||||
|
const key = `${endpoint}_max_window_bits`;
|
||||||
|
const windowBits =
|
||||||
|
typeof this.params[key] !== 'number'
|
||||||
|
? zlib.Z_DEFAULT_WINDOWBITS
|
||||||
|
: this.params[key];
|
||||||
|
|
||||||
|
this._inflate = zlib.createInflateRaw({
|
||||||
|
...this._options.zlibInflateOptions,
|
||||||
|
windowBits
|
||||||
|
});
|
||||||
|
this._inflate[kPerMessageDeflate] = this;
|
||||||
|
this._inflate[kTotalLength] = 0;
|
||||||
|
this._inflate[kBuffers] = [];
|
||||||
|
this._inflate.on('error', inflateOnError);
|
||||||
|
this._inflate.on('data', inflateOnData);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._inflate[kCallback] = callback;
|
||||||
|
|
||||||
|
this._inflate.write(data);
|
||||||
|
if (fin) this._inflate.write(TRAILER);
|
||||||
|
|
||||||
|
this._inflate.flush(() => {
|
||||||
|
const err = this._inflate[kError];
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
this._inflate.close();
|
||||||
|
this._inflate = null;
|
||||||
|
callback(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = bufferUtil.concat(
|
||||||
|
this._inflate[kBuffers],
|
||||||
|
this._inflate[kTotalLength]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this._inflate._readableState.endEmitted) {
|
||||||
|
this._inflate.close();
|
||||||
|
this._inflate = null;
|
||||||
|
} else {
|
||||||
|
this._inflate[kTotalLength] = 0;
|
||||||
|
this._inflate[kBuffers] = [];
|
||||||
|
|
||||||
|
if (fin && this.params[`${endpoint}_no_context_takeover`]) {
|
||||||
|
this._inflate.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(null, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compress data.
|
||||||
|
*
|
||||||
|
* @param {(Buffer|String)} data Data to compress
|
||||||
|
* @param {Boolean} fin Specifies whether or not this is the last fragment
|
||||||
|
* @param {Function} callback Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_compress(data, fin, callback) {
|
||||||
|
const endpoint = this._isServer ? 'server' : 'client';
|
||||||
|
|
||||||
|
if (!this._deflate) {
|
||||||
|
const key = `${endpoint}_max_window_bits`;
|
||||||
|
const windowBits =
|
||||||
|
typeof this.params[key] !== 'number'
|
||||||
|
? zlib.Z_DEFAULT_WINDOWBITS
|
||||||
|
: this.params[key];
|
||||||
|
|
||||||
|
this._deflate = zlib.createDeflateRaw({
|
||||||
|
...this._options.zlibDeflateOptions,
|
||||||
|
windowBits
|
||||||
|
});
|
||||||
|
|
||||||
|
this._deflate[kTotalLength] = 0;
|
||||||
|
this._deflate[kBuffers] = [];
|
||||||
|
|
||||||
|
this._deflate.on('data', deflateOnData);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._deflate[kCallback] = callback;
|
||||||
|
|
||||||
|
this._deflate.write(data);
|
||||||
|
this._deflate.flush(zlib.Z_SYNC_FLUSH, () => {
|
||||||
|
if (!this._deflate) {
|
||||||
|
//
|
||||||
|
// The deflate stream was closed while data was being processed.
|
||||||
|
//
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = bufferUtil.concat(
|
||||||
|
this._deflate[kBuffers],
|
||||||
|
this._deflate[kTotalLength]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fin) {
|
||||||
|
data = new FastBuffer(data.buffer, data.byteOffset, data.length - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Ensure that the callback will not be called again in
|
||||||
|
// `PerMessageDeflate#cleanup()`.
|
||||||
|
//
|
||||||
|
this._deflate[kCallback] = null;
|
||||||
|
|
||||||
|
this._deflate[kTotalLength] = 0;
|
||||||
|
this._deflate[kBuffers] = [];
|
||||||
|
|
||||||
|
if (fin && this.params[`${endpoint}_no_context_takeover`]) {
|
||||||
|
this._deflate.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(null, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PerMessageDeflate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The listener of the `zlib.DeflateRaw` stream `'data'` event.
|
||||||
|
*
|
||||||
|
* @param {Buffer} chunk A chunk of data
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function deflateOnData(chunk) {
|
||||||
|
this[kBuffers].push(chunk);
|
||||||
|
this[kTotalLength] += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The listener of the `zlib.InflateRaw` stream `'data'` event.
|
||||||
|
*
|
||||||
|
* @param {Buffer} chunk A chunk of data
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function inflateOnData(chunk) {
|
||||||
|
this[kTotalLength] += chunk.length;
|
||||||
|
|
||||||
|
if (
|
||||||
|
this[kPerMessageDeflate]._maxPayload < 1 ||
|
||||||
|
this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload
|
||||||
|
) {
|
||||||
|
this[kBuffers].push(chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this[kError] = new RangeError('Max payload size exceeded');
|
||||||
|
this[kError].code = 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH';
|
||||||
|
this[kError][kStatusCode] = 1009;
|
||||||
|
this.removeListener('data', inflateOnData);
|
||||||
|
|
||||||
|
//
|
||||||
|
// The choice to employ `zlib.reset()` over `zlib.close()` is dictated by the
|
||||||
|
// fact that in Node.js versions prior to 13.10.0, the callback for
|
||||||
|
// `zlib.flush()` is not called if `zlib.close()` is used. Utilizing
|
||||||
|
// `zlib.reset()` ensures that either the callback is invoked or an error is
|
||||||
|
// emitted.
|
||||||
|
//
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The listener of the `zlib.InflateRaw` stream `'error'` event.
|
||||||
|
*
|
||||||
|
* @param {Error} err The emitted error
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function inflateOnError(err) {
|
||||||
|
//
|
||||||
|
// There is no need to call `Zlib#close()` as the handle is automatically
|
||||||
|
// closed when an error is emitted.
|
||||||
|
//
|
||||||
|
this[kPerMessageDeflate]._inflate = null;
|
||||||
|
|
||||||
|
if (this[kError]) {
|
||||||
|
this[kCallback](this[kError]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
err[kStatusCode] = 1007;
|
||||||
|
this[kCallback](err);
|
||||||
|
}
|
||||||
706
node_modules/ws/lib/receiver.js
generated
vendored
Normal file
@@ -0,0 +1,706 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { Writable } = require('stream');
|
||||||
|
|
||||||
|
const PerMessageDeflate = require('./permessage-deflate');
|
||||||
|
const {
|
||||||
|
BINARY_TYPES,
|
||||||
|
EMPTY_BUFFER,
|
||||||
|
kStatusCode,
|
||||||
|
kWebSocket
|
||||||
|
} = require('./constants');
|
||||||
|
const { concat, toArrayBuffer, unmask } = require('./buffer-util');
|
||||||
|
const { isValidStatusCode, isValidUTF8 } = require('./validation');
|
||||||
|
|
||||||
|
const FastBuffer = Buffer[Symbol.species];
|
||||||
|
|
||||||
|
const GET_INFO = 0;
|
||||||
|
const GET_PAYLOAD_LENGTH_16 = 1;
|
||||||
|
const GET_PAYLOAD_LENGTH_64 = 2;
|
||||||
|
const GET_MASK = 3;
|
||||||
|
const GET_DATA = 4;
|
||||||
|
const INFLATING = 5;
|
||||||
|
const DEFER_EVENT = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HyBi Receiver implementation.
|
||||||
|
*
|
||||||
|
* @extends Writable
|
||||||
|
*/
|
||||||
|
class Receiver extends Writable {
|
||||||
|
/**
|
||||||
|
* Creates a Receiver instance.
|
||||||
|
*
|
||||||
|
* @param {Object} [options] Options object
|
||||||
|
* @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether
|
||||||
|
* any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
|
||||||
|
* multiple times in the same tick
|
||||||
|
* @param {String} [options.binaryType=nodebuffer] The type for binary data
|
||||||
|
* @param {Object} [options.extensions] An object containing the negotiated
|
||||||
|
* extensions
|
||||||
|
* @param {Boolean} [options.isServer=false] Specifies whether to operate in
|
||||||
|
* client or server mode
|
||||||
|
* @param {Number} [options.maxPayload=0] The maximum allowed message length
|
||||||
|
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
|
||||||
|
* not to skip UTF-8 validation for text and close messages
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this._allowSynchronousEvents =
|
||||||
|
options.allowSynchronousEvents !== undefined
|
||||||
|
? options.allowSynchronousEvents
|
||||||
|
: true;
|
||||||
|
this._binaryType = options.binaryType || BINARY_TYPES[0];
|
||||||
|
this._extensions = options.extensions || {};
|
||||||
|
this._isServer = !!options.isServer;
|
||||||
|
this._maxPayload = options.maxPayload | 0;
|
||||||
|
this._skipUTF8Validation = !!options.skipUTF8Validation;
|
||||||
|
this[kWebSocket] = undefined;
|
||||||
|
|
||||||
|
this._bufferedBytes = 0;
|
||||||
|
this._buffers = [];
|
||||||
|
|
||||||
|
this._compressed = false;
|
||||||
|
this._payloadLength = 0;
|
||||||
|
this._mask = undefined;
|
||||||
|
this._fragmented = 0;
|
||||||
|
this._masked = false;
|
||||||
|
this._fin = false;
|
||||||
|
this._opcode = 0;
|
||||||
|
|
||||||
|
this._totalPayloadLength = 0;
|
||||||
|
this._messageLength = 0;
|
||||||
|
this._fragments = [];
|
||||||
|
|
||||||
|
this._errored = false;
|
||||||
|
this._loop = false;
|
||||||
|
this._state = GET_INFO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements `Writable.prototype._write()`.
|
||||||
|
*
|
||||||
|
* @param {Buffer} chunk The chunk of data to write
|
||||||
|
* @param {String} encoding The character encoding of `chunk`
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_write(chunk, encoding, cb) {
|
||||||
|
if (this._opcode === 0x08 && this._state == GET_INFO) return cb();
|
||||||
|
|
||||||
|
this._bufferedBytes += chunk.length;
|
||||||
|
this._buffers.push(chunk);
|
||||||
|
this.startLoop(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumes `n` bytes from the buffered data.
|
||||||
|
*
|
||||||
|
* @param {Number} n The number of bytes to consume
|
||||||
|
* @return {Buffer} The consumed bytes
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
consume(n) {
|
||||||
|
this._bufferedBytes -= n;
|
||||||
|
|
||||||
|
if (n === this._buffers[0].length) return this._buffers.shift();
|
||||||
|
|
||||||
|
if (n < this._buffers[0].length) {
|
||||||
|
const buf = this._buffers[0];
|
||||||
|
this._buffers[0] = new FastBuffer(
|
||||||
|
buf.buffer,
|
||||||
|
buf.byteOffset + n,
|
||||||
|
buf.length - n
|
||||||
|
);
|
||||||
|
|
||||||
|
return new FastBuffer(buf.buffer, buf.byteOffset, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dst = Buffer.allocUnsafe(n);
|
||||||
|
|
||||||
|
do {
|
||||||
|
const buf = this._buffers[0];
|
||||||
|
const offset = dst.length - n;
|
||||||
|
|
||||||
|
if (n >= buf.length) {
|
||||||
|
dst.set(this._buffers.shift(), offset);
|
||||||
|
} else {
|
||||||
|
dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset);
|
||||||
|
this._buffers[0] = new FastBuffer(
|
||||||
|
buf.buffer,
|
||||||
|
buf.byteOffset + n,
|
||||||
|
buf.length - n
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
n -= buf.length;
|
||||||
|
} while (n > 0);
|
||||||
|
|
||||||
|
return dst;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the parsing loop.
|
||||||
|
*
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
startLoop(cb) {
|
||||||
|
this._loop = true;
|
||||||
|
|
||||||
|
do {
|
||||||
|
switch (this._state) {
|
||||||
|
case GET_INFO:
|
||||||
|
this.getInfo(cb);
|
||||||
|
break;
|
||||||
|
case GET_PAYLOAD_LENGTH_16:
|
||||||
|
this.getPayloadLength16(cb);
|
||||||
|
break;
|
||||||
|
case GET_PAYLOAD_LENGTH_64:
|
||||||
|
this.getPayloadLength64(cb);
|
||||||
|
break;
|
||||||
|
case GET_MASK:
|
||||||
|
this.getMask();
|
||||||
|
break;
|
||||||
|
case GET_DATA:
|
||||||
|
this.getData(cb);
|
||||||
|
break;
|
||||||
|
case INFLATING:
|
||||||
|
case DEFER_EVENT:
|
||||||
|
this._loop = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} while (this._loop);
|
||||||
|
|
||||||
|
if (!this._errored) cb();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the first two bytes of a frame.
|
||||||
|
*
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
getInfo(cb) {
|
||||||
|
if (this._bufferedBytes < 2) {
|
||||||
|
this._loop = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = this.consume(2);
|
||||||
|
|
||||||
|
if ((buf[0] & 0x30) !== 0x00) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'RSV2 and RSV3 must be clear',
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_UNEXPECTED_RSV_2_3'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compressed = (buf[0] & 0x40) === 0x40;
|
||||||
|
|
||||||
|
if (compressed && !this._extensions[PerMessageDeflate.extensionName]) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'RSV1 must be clear',
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_UNEXPECTED_RSV_1'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._fin = (buf[0] & 0x80) === 0x80;
|
||||||
|
this._opcode = buf[0] & 0x0f;
|
||||||
|
this._payloadLength = buf[1] & 0x7f;
|
||||||
|
|
||||||
|
if (this._opcode === 0x00) {
|
||||||
|
if (compressed) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'RSV1 must be clear',
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_UNEXPECTED_RSV_1'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._fragmented) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'invalid opcode 0',
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_INVALID_OPCODE'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._opcode = this._fragmented;
|
||||||
|
} else if (this._opcode === 0x01 || this._opcode === 0x02) {
|
||||||
|
if (this._fragmented) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
`invalid opcode ${this._opcode}`,
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_INVALID_OPCODE'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._compressed = compressed;
|
||||||
|
} else if (this._opcode > 0x07 && this._opcode < 0x0b) {
|
||||||
|
if (!this._fin) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'FIN must be set',
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_EXPECTED_FIN'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compressed) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'RSV1 must be clear',
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_UNEXPECTED_RSV_1'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this._payloadLength > 0x7d ||
|
||||||
|
(this._opcode === 0x08 && this._payloadLength === 1)
|
||||||
|
) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
`invalid payload length ${this._payloadLength}`,
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
`invalid opcode ${this._opcode}`,
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_INVALID_OPCODE'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._fin && !this._fragmented) this._fragmented = this._opcode;
|
||||||
|
this._masked = (buf[1] & 0x80) === 0x80;
|
||||||
|
|
||||||
|
if (this._isServer) {
|
||||||
|
if (!this._masked) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'MASK must be set',
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_EXPECTED_MASK'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (this._masked) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'MASK must be clear',
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_UNEXPECTED_MASK'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16;
|
||||||
|
else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64;
|
||||||
|
else this.haveLength(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets extended payload length (7+16).
|
||||||
|
*
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
getPayloadLength16(cb) {
|
||||||
|
if (this._bufferedBytes < 2) {
|
||||||
|
this._loop = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._payloadLength = this.consume(2).readUInt16BE(0);
|
||||||
|
this.haveLength(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets extended payload length (7+64).
|
||||||
|
*
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
getPayloadLength64(cb) {
|
||||||
|
if (this._bufferedBytes < 8) {
|
||||||
|
this._loop = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = this.consume(8);
|
||||||
|
const num = buf.readUInt32BE(0);
|
||||||
|
|
||||||
|
//
|
||||||
|
// The maximum safe integer in JavaScript is 2^53 - 1. An error is returned
|
||||||
|
// if payload length is greater than this number.
|
||||||
|
//
|
||||||
|
if (num > Math.pow(2, 53 - 32) - 1) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'Unsupported WebSocket frame: payload length > 2^53 - 1',
|
||||||
|
false,
|
||||||
|
1009,
|
||||||
|
'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4);
|
||||||
|
this.haveLength(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload length has been read.
|
||||||
|
*
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
haveLength(cb) {
|
||||||
|
if (this._payloadLength && this._opcode < 0x08) {
|
||||||
|
this._totalPayloadLength += this._payloadLength;
|
||||||
|
if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'Max payload size exceeded',
|
||||||
|
false,
|
||||||
|
1009,
|
||||||
|
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._masked) this._state = GET_MASK;
|
||||||
|
else this._state = GET_DATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads mask bytes.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
getMask() {
|
||||||
|
if (this._bufferedBytes < 4) {
|
||||||
|
this._loop = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._mask = this.consume(4);
|
||||||
|
this._state = GET_DATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads data bytes.
|
||||||
|
*
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
getData(cb) {
|
||||||
|
let data = EMPTY_BUFFER;
|
||||||
|
|
||||||
|
if (this._payloadLength) {
|
||||||
|
if (this._bufferedBytes < this._payloadLength) {
|
||||||
|
this._loop = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data = this.consume(this._payloadLength);
|
||||||
|
|
||||||
|
if (
|
||||||
|
this._masked &&
|
||||||
|
(this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0
|
||||||
|
) {
|
||||||
|
unmask(data, this._mask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._opcode > 0x07) {
|
||||||
|
this.controlMessage(data, cb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._compressed) {
|
||||||
|
this._state = INFLATING;
|
||||||
|
this.decompress(data, cb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length) {
|
||||||
|
//
|
||||||
|
// This message is not compressed so its length is the sum of the payload
|
||||||
|
// length of all fragments.
|
||||||
|
//
|
||||||
|
this._messageLength = this._totalPayloadLength;
|
||||||
|
this._fragments.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataMessage(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decompresses data.
|
||||||
|
*
|
||||||
|
* @param {Buffer} data Compressed data
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
decompress(data, cb) {
|
||||||
|
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
|
||||||
|
|
||||||
|
perMessageDeflate.decompress(data, this._fin, (err, buf) => {
|
||||||
|
if (err) return cb(err);
|
||||||
|
|
||||||
|
if (buf.length) {
|
||||||
|
this._messageLength += buf.length;
|
||||||
|
if (this._messageLength > this._maxPayload && this._maxPayload > 0) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
'Max payload size exceeded',
|
||||||
|
false,
|
||||||
|
1009,
|
||||||
|
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._fragments.push(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataMessage(cb);
|
||||||
|
if (this._state === GET_INFO) this.startLoop(cb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a data message.
|
||||||
|
*
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
dataMessage(cb) {
|
||||||
|
if (!this._fin) {
|
||||||
|
this._state = GET_INFO;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageLength = this._messageLength;
|
||||||
|
const fragments = this._fragments;
|
||||||
|
|
||||||
|
this._totalPayloadLength = 0;
|
||||||
|
this._messageLength = 0;
|
||||||
|
this._fragmented = 0;
|
||||||
|
this._fragments = [];
|
||||||
|
|
||||||
|
if (this._opcode === 2) {
|
||||||
|
let data;
|
||||||
|
|
||||||
|
if (this._binaryType === 'nodebuffer') {
|
||||||
|
data = concat(fragments, messageLength);
|
||||||
|
} else if (this._binaryType === 'arraybuffer') {
|
||||||
|
data = toArrayBuffer(concat(fragments, messageLength));
|
||||||
|
} else if (this._binaryType === 'blob') {
|
||||||
|
data = new Blob(fragments);
|
||||||
|
} else {
|
||||||
|
data = fragments;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._allowSynchronousEvents) {
|
||||||
|
this.emit('message', data, true);
|
||||||
|
this._state = GET_INFO;
|
||||||
|
} else {
|
||||||
|
this._state = DEFER_EVENT;
|
||||||
|
setImmediate(() => {
|
||||||
|
this.emit('message', data, true);
|
||||||
|
this._state = GET_INFO;
|
||||||
|
this.startLoop(cb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const buf = concat(fragments, messageLength);
|
||||||
|
|
||||||
|
if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
|
||||||
|
const error = this.createError(
|
||||||
|
Error,
|
||||||
|
'invalid UTF-8 sequence',
|
||||||
|
true,
|
||||||
|
1007,
|
||||||
|
'WS_ERR_INVALID_UTF8'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._state === INFLATING || this._allowSynchronousEvents) {
|
||||||
|
this.emit('message', buf, false);
|
||||||
|
this._state = GET_INFO;
|
||||||
|
} else {
|
||||||
|
this._state = DEFER_EVENT;
|
||||||
|
setImmediate(() => {
|
||||||
|
this.emit('message', buf, false);
|
||||||
|
this._state = GET_INFO;
|
||||||
|
this.startLoop(cb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a control message.
|
||||||
|
*
|
||||||
|
* @param {Buffer} data Data to handle
|
||||||
|
* @return {(Error|RangeError|undefined)} A possible error
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
controlMessage(data, cb) {
|
||||||
|
if (this._opcode === 0x08) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
this._loop = false;
|
||||||
|
this.emit('conclude', 1005, EMPTY_BUFFER);
|
||||||
|
this.end();
|
||||||
|
} else {
|
||||||
|
const code = data.readUInt16BE(0);
|
||||||
|
|
||||||
|
if (!isValidStatusCode(code)) {
|
||||||
|
const error = this.createError(
|
||||||
|
RangeError,
|
||||||
|
`invalid status code ${code}`,
|
||||||
|
true,
|
||||||
|
1002,
|
||||||
|
'WS_ERR_INVALID_CLOSE_CODE'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = new FastBuffer(
|
||||||
|
data.buffer,
|
||||||
|
data.byteOffset + 2,
|
||||||
|
data.length - 2
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
|
||||||
|
const error = this.createError(
|
||||||
|
Error,
|
||||||
|
'invalid UTF-8 sequence',
|
||||||
|
true,
|
||||||
|
1007,
|
||||||
|
'WS_ERR_INVALID_UTF8'
|
||||||
|
);
|
||||||
|
|
||||||
|
cb(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._loop = false;
|
||||||
|
this.emit('conclude', code, buf);
|
||||||
|
this.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._state = GET_INFO;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._allowSynchronousEvents) {
|
||||||
|
this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
|
||||||
|
this._state = GET_INFO;
|
||||||
|
} else {
|
||||||
|
this._state = DEFER_EVENT;
|
||||||
|
setImmediate(() => {
|
||||||
|
this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
|
||||||
|
this._state = GET_INFO;
|
||||||
|
this.startLoop(cb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an error object.
|
||||||
|
*
|
||||||
|
* @param {function(new:Error|RangeError)} ErrorCtor The error constructor
|
||||||
|
* @param {String} message The error message
|
||||||
|
* @param {Boolean} prefix Specifies whether or not to add a default prefix to
|
||||||
|
* `message`
|
||||||
|
* @param {Number} statusCode The status code
|
||||||
|
* @param {String} errorCode The exposed error code
|
||||||
|
* @return {(Error|RangeError)} The error
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
createError(ErrorCtor, message, prefix, statusCode, errorCode) {
|
||||||
|
this._loop = false;
|
||||||
|
this._errored = true;
|
||||||
|
|
||||||
|
const err = new ErrorCtor(
|
||||||
|
prefix ? `Invalid WebSocket frame: ${message}` : message
|
||||||
|
);
|
||||||
|
|
||||||
|
Error.captureStackTrace(err, this.createError);
|
||||||
|
err.code = errorCode;
|
||||||
|
err[kStatusCode] = statusCode;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Receiver;
|
||||||
602
node_modules/ws/lib/sender.js
generated
vendored
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex" }] */
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { Duplex } = require('stream');
|
||||||
|
const { randomFillSync } = require('crypto');
|
||||||
|
|
||||||
|
const PerMessageDeflate = require('./permessage-deflate');
|
||||||
|
const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants');
|
||||||
|
const { isBlob, isValidStatusCode } = require('./validation');
|
||||||
|
const { mask: applyMask, toBuffer } = require('./buffer-util');
|
||||||
|
|
||||||
|
const kByteLength = Symbol('kByteLength');
|
||||||
|
const maskBuffer = Buffer.alloc(4);
|
||||||
|
const RANDOM_POOL_SIZE = 8 * 1024;
|
||||||
|
let randomPool;
|
||||||
|
let randomPoolPointer = RANDOM_POOL_SIZE;
|
||||||
|
|
||||||
|
const DEFAULT = 0;
|
||||||
|
const DEFLATING = 1;
|
||||||
|
const GET_BLOB_DATA = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HyBi Sender implementation.
|
||||||
|
*/
|
||||||
|
class Sender {
|
||||||
|
/**
|
||||||
|
* Creates a Sender instance.
|
||||||
|
*
|
||||||
|
* @param {Duplex} socket The connection socket
|
||||||
|
* @param {Object} [extensions] An object containing the negotiated extensions
|
||||||
|
* @param {Function} [generateMask] The function used to generate the masking
|
||||||
|
* key
|
||||||
|
*/
|
||||||
|
constructor(socket, extensions, generateMask) {
|
||||||
|
this._extensions = extensions || {};
|
||||||
|
|
||||||
|
if (generateMask) {
|
||||||
|
this._generateMask = generateMask;
|
||||||
|
this._maskBuffer = Buffer.alloc(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._socket = socket;
|
||||||
|
|
||||||
|
this._firstFragment = true;
|
||||||
|
this._compress = false;
|
||||||
|
|
||||||
|
this._bufferedBytes = 0;
|
||||||
|
this._queue = [];
|
||||||
|
this._state = DEFAULT;
|
||||||
|
this.onerror = NOOP;
|
||||||
|
this[kWebSocket] = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frames a piece of data according to the HyBi WebSocket protocol.
|
||||||
|
*
|
||||||
|
* @param {(Buffer|String)} data The data to frame
|
||||||
|
* @param {Object} options Options object
|
||||||
|
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
|
||||||
|
* FIN bit
|
||||||
|
* @param {Function} [options.generateMask] The function used to generate the
|
||||||
|
* masking key
|
||||||
|
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
|
||||||
|
* `data`
|
||||||
|
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
|
||||||
|
* key
|
||||||
|
* @param {Number} options.opcode The opcode
|
||||||
|
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
|
||||||
|
* modified
|
||||||
|
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
|
||||||
|
* RSV1 bit
|
||||||
|
* @return {(Buffer|String)[]} The framed data
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
static frame(data, options) {
|
||||||
|
let mask;
|
||||||
|
let merge = false;
|
||||||
|
let offset = 2;
|
||||||
|
let skipMasking = false;
|
||||||
|
|
||||||
|
if (options.mask) {
|
||||||
|
mask = options.maskBuffer || maskBuffer;
|
||||||
|
|
||||||
|
if (options.generateMask) {
|
||||||
|
options.generateMask(mask);
|
||||||
|
} else {
|
||||||
|
if (randomPoolPointer === RANDOM_POOL_SIZE) {
|
||||||
|
/* istanbul ignore else */
|
||||||
|
if (randomPool === undefined) {
|
||||||
|
//
|
||||||
|
// This is lazily initialized because server-sent frames must not
|
||||||
|
// be masked so it may never be used.
|
||||||
|
//
|
||||||
|
randomPool = Buffer.alloc(RANDOM_POOL_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
randomFillSync(randomPool, 0, RANDOM_POOL_SIZE);
|
||||||
|
randomPoolPointer = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
mask[0] = randomPool[randomPoolPointer++];
|
||||||
|
mask[1] = randomPool[randomPoolPointer++];
|
||||||
|
mask[2] = randomPool[randomPoolPointer++];
|
||||||
|
mask[3] = randomPool[randomPoolPointer++];
|
||||||
|
}
|
||||||
|
|
||||||
|
skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0;
|
||||||
|
offset = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dataLength;
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
if (
|
||||||
|
(!options.mask || skipMasking) &&
|
||||||
|
options[kByteLength] !== undefined
|
||||||
|
) {
|
||||||
|
dataLength = options[kByteLength];
|
||||||
|
} else {
|
||||||
|
data = Buffer.from(data);
|
||||||
|
dataLength = data.length;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dataLength = data.length;
|
||||||
|
merge = options.mask && options.readOnly && !skipMasking;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payloadLength = dataLength;
|
||||||
|
|
||||||
|
if (dataLength >= 65536) {
|
||||||
|
offset += 8;
|
||||||
|
payloadLength = 127;
|
||||||
|
} else if (dataLength > 125) {
|
||||||
|
offset += 2;
|
||||||
|
payloadLength = 126;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = Buffer.allocUnsafe(merge ? dataLength + offset : offset);
|
||||||
|
|
||||||
|
target[0] = options.fin ? options.opcode | 0x80 : options.opcode;
|
||||||
|
if (options.rsv1) target[0] |= 0x40;
|
||||||
|
|
||||||
|
target[1] = payloadLength;
|
||||||
|
|
||||||
|
if (payloadLength === 126) {
|
||||||
|
target.writeUInt16BE(dataLength, 2);
|
||||||
|
} else if (payloadLength === 127) {
|
||||||
|
target[2] = target[3] = 0;
|
||||||
|
target.writeUIntBE(dataLength, 4, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.mask) return [target, data];
|
||||||
|
|
||||||
|
target[1] |= 0x80;
|
||||||
|
target[offset - 4] = mask[0];
|
||||||
|
target[offset - 3] = mask[1];
|
||||||
|
target[offset - 2] = mask[2];
|
||||||
|
target[offset - 1] = mask[3];
|
||||||
|
|
||||||
|
if (skipMasking) return [target, data];
|
||||||
|
|
||||||
|
if (merge) {
|
||||||
|
applyMask(data, mask, target, offset, dataLength);
|
||||||
|
return [target];
|
||||||
|
}
|
||||||
|
|
||||||
|
applyMask(data, mask, data, 0, dataLength);
|
||||||
|
return [target, data];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a close message to the other peer.
|
||||||
|
*
|
||||||
|
* @param {Number} [code] The status code component of the body
|
||||||
|
* @param {(String|Buffer)} [data] The message component of the body
|
||||||
|
* @param {Boolean} [mask=false] Specifies whether or not to mask the message
|
||||||
|
* @param {Function} [cb] Callback
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
close(code, data, mask, cb) {
|
||||||
|
let buf;
|
||||||
|
|
||||||
|
if (code === undefined) {
|
||||||
|
buf = EMPTY_BUFFER;
|
||||||
|
} else if (typeof code !== 'number' || !isValidStatusCode(code)) {
|
||||||
|
throw new TypeError('First argument must be a valid error code number');
|
||||||
|
} else if (data === undefined || !data.length) {
|
||||||
|
buf = Buffer.allocUnsafe(2);
|
||||||
|
buf.writeUInt16BE(code, 0);
|
||||||
|
} else {
|
||||||
|
const length = Buffer.byteLength(data);
|
||||||
|
|
||||||
|
if (length > 123) {
|
||||||
|
throw new RangeError('The message must not be greater than 123 bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
buf = Buffer.allocUnsafe(2 + length);
|
||||||
|
buf.writeUInt16BE(code, 0);
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
buf.write(data, 2);
|
||||||
|
} else {
|
||||||
|
buf.set(data, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
[kByteLength]: buf.length,
|
||||||
|
fin: true,
|
||||||
|
generateMask: this._generateMask,
|
||||||
|
mask,
|
||||||
|
maskBuffer: this._maskBuffer,
|
||||||
|
opcode: 0x08,
|
||||||
|
readOnly: false,
|
||||||
|
rsv1: false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this._state !== DEFAULT) {
|
||||||
|
this.enqueue([this.dispatch, buf, false, options, cb]);
|
||||||
|
} else {
|
||||||
|
this.sendFrame(Sender.frame(buf, options), cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a ping message to the other peer.
|
||||||
|
*
|
||||||
|
* @param {*} data The message to send
|
||||||
|
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
|
||||||
|
* @param {Function} [cb] Callback
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
ping(data, mask, cb) {
|
||||||
|
let byteLength;
|
||||||
|
let readOnly;
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
byteLength = Buffer.byteLength(data);
|
||||||
|
readOnly = false;
|
||||||
|
} else if (isBlob(data)) {
|
||||||
|
byteLength = data.size;
|
||||||
|
readOnly = false;
|
||||||
|
} else {
|
||||||
|
data = toBuffer(data);
|
||||||
|
byteLength = data.length;
|
||||||
|
readOnly = toBuffer.readOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byteLength > 125) {
|
||||||
|
throw new RangeError('The data size must not be greater than 125 bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
[kByteLength]: byteLength,
|
||||||
|
fin: true,
|
||||||
|
generateMask: this._generateMask,
|
||||||
|
mask,
|
||||||
|
maskBuffer: this._maskBuffer,
|
||||||
|
opcode: 0x09,
|
||||||
|
readOnly,
|
||||||
|
rsv1: false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isBlob(data)) {
|
||||||
|
if (this._state !== DEFAULT) {
|
||||||
|
this.enqueue([this.getBlobData, data, false, options, cb]);
|
||||||
|
} else {
|
||||||
|
this.getBlobData(data, false, options, cb);
|
||||||
|
}
|
||||||
|
} else if (this._state !== DEFAULT) {
|
||||||
|
this.enqueue([this.dispatch, data, false, options, cb]);
|
||||||
|
} else {
|
||||||
|
this.sendFrame(Sender.frame(data, options), cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a pong message to the other peer.
|
||||||
|
*
|
||||||
|
* @param {*} data The message to send
|
||||||
|
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
|
||||||
|
* @param {Function} [cb] Callback
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
pong(data, mask, cb) {
|
||||||
|
let byteLength;
|
||||||
|
let readOnly;
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
byteLength = Buffer.byteLength(data);
|
||||||
|
readOnly = false;
|
||||||
|
} else if (isBlob(data)) {
|
||||||
|
byteLength = data.size;
|
||||||
|
readOnly = false;
|
||||||
|
} else {
|
||||||
|
data = toBuffer(data);
|
||||||
|
byteLength = data.length;
|
||||||
|
readOnly = toBuffer.readOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byteLength > 125) {
|
||||||
|
throw new RangeError('The data size must not be greater than 125 bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
[kByteLength]: byteLength,
|
||||||
|
fin: true,
|
||||||
|
generateMask: this._generateMask,
|
||||||
|
mask,
|
||||||
|
maskBuffer: this._maskBuffer,
|
||||||
|
opcode: 0x0a,
|
||||||
|
readOnly,
|
||||||
|
rsv1: false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isBlob(data)) {
|
||||||
|
if (this._state !== DEFAULT) {
|
||||||
|
this.enqueue([this.getBlobData, data, false, options, cb]);
|
||||||
|
} else {
|
||||||
|
this.getBlobData(data, false, options, cb);
|
||||||
|
}
|
||||||
|
} else if (this._state !== DEFAULT) {
|
||||||
|
this.enqueue([this.dispatch, data, false, options, cb]);
|
||||||
|
} else {
|
||||||
|
this.sendFrame(Sender.frame(data, options), cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a data message to the other peer.
|
||||||
|
*
|
||||||
|
* @param {*} data The message to send
|
||||||
|
* @param {Object} options Options object
|
||||||
|
* @param {Boolean} [options.binary=false] Specifies whether `data` is binary
|
||||||
|
* or text
|
||||||
|
* @param {Boolean} [options.compress=false] Specifies whether or not to
|
||||||
|
* compress `data`
|
||||||
|
* @param {Boolean} [options.fin=false] Specifies whether the fragment is the
|
||||||
|
* last one
|
||||||
|
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
|
||||||
|
* `data`
|
||||||
|
* @param {Function} [cb] Callback
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
send(data, options, cb) {
|
||||||
|
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
|
||||||
|
let opcode = options.binary ? 2 : 1;
|
||||||
|
let rsv1 = options.compress;
|
||||||
|
|
||||||
|
let byteLength;
|
||||||
|
let readOnly;
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
byteLength = Buffer.byteLength(data);
|
||||||
|
readOnly = false;
|
||||||
|
} else if (isBlob(data)) {
|
||||||
|
byteLength = data.size;
|
||||||
|
readOnly = false;
|
||||||
|
} else {
|
||||||
|
data = toBuffer(data);
|
||||||
|
byteLength = data.length;
|
||||||
|
readOnly = toBuffer.readOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._firstFragment) {
|
||||||
|
this._firstFragment = false;
|
||||||
|
if (
|
||||||
|
rsv1 &&
|
||||||
|
perMessageDeflate &&
|
||||||
|
perMessageDeflate.params[
|
||||||
|
perMessageDeflate._isServer
|
||||||
|
? 'server_no_context_takeover'
|
||||||
|
: 'client_no_context_takeover'
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
rsv1 = byteLength >= perMessageDeflate._threshold;
|
||||||
|
}
|
||||||
|
this._compress = rsv1;
|
||||||
|
} else {
|
||||||
|
rsv1 = false;
|
||||||
|
opcode = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.fin) this._firstFragment = true;
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
[kByteLength]: byteLength,
|
||||||
|
fin: options.fin,
|
||||||
|
generateMask: this._generateMask,
|
||||||
|
mask: options.mask,
|
||||||
|
maskBuffer: this._maskBuffer,
|
||||||
|
opcode,
|
||||||
|
readOnly,
|
||||||
|
rsv1
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isBlob(data)) {
|
||||||
|
if (this._state !== DEFAULT) {
|
||||||
|
this.enqueue([this.getBlobData, data, this._compress, opts, cb]);
|
||||||
|
} else {
|
||||||
|
this.getBlobData(data, this._compress, opts, cb);
|
||||||
|
}
|
||||||
|
} else if (this._state !== DEFAULT) {
|
||||||
|
this.enqueue([this.dispatch, data, this._compress, opts, cb]);
|
||||||
|
} else {
|
||||||
|
this.dispatch(data, this._compress, opts, cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the contents of a blob as binary data.
|
||||||
|
*
|
||||||
|
* @param {Blob} blob The blob
|
||||||
|
* @param {Boolean} [compress=false] Specifies whether or not to compress
|
||||||
|
* the data
|
||||||
|
* @param {Object} options Options object
|
||||||
|
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
|
||||||
|
* FIN bit
|
||||||
|
* @param {Function} [options.generateMask] The function used to generate the
|
||||||
|
* masking key
|
||||||
|
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
|
||||||
|
* `data`
|
||||||
|
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
|
||||||
|
* key
|
||||||
|
* @param {Number} options.opcode The opcode
|
||||||
|
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
|
||||||
|
* modified
|
||||||
|
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
|
||||||
|
* RSV1 bit
|
||||||
|
* @param {Function} [cb] Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
getBlobData(blob, compress, options, cb) {
|
||||||
|
this._bufferedBytes += options[kByteLength];
|
||||||
|
this._state = GET_BLOB_DATA;
|
||||||
|
|
||||||
|
blob
|
||||||
|
.arrayBuffer()
|
||||||
|
.then((arrayBuffer) => {
|
||||||
|
if (this._socket.destroyed) {
|
||||||
|
const err = new Error(
|
||||||
|
'The socket was closed while the blob was being read'
|
||||||
|
);
|
||||||
|
|
||||||
|
//
|
||||||
|
// `callCallbacks` is called in the next tick to ensure that errors
|
||||||
|
// that might be thrown in the callbacks behave like errors thrown
|
||||||
|
// outside the promise chain.
|
||||||
|
//
|
||||||
|
process.nextTick(callCallbacks, this, err, cb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._bufferedBytes -= options[kByteLength];
|
||||||
|
const data = toBuffer(arrayBuffer);
|
||||||
|
|
||||||
|
if (!compress) {
|
||||||
|
this._state = DEFAULT;
|
||||||
|
this.sendFrame(Sender.frame(data, options), cb);
|
||||||
|
this.dequeue();
|
||||||
|
} else {
|
||||||
|
this.dispatch(data, compress, options, cb);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
//
|
||||||
|
// `onError` is called in the next tick for the same reason that
|
||||||
|
// `callCallbacks` above is.
|
||||||
|
//
|
||||||
|
process.nextTick(onError, this, err, cb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches a message.
|
||||||
|
*
|
||||||
|
* @param {(Buffer|String)} data The message to send
|
||||||
|
* @param {Boolean} [compress=false] Specifies whether or not to compress
|
||||||
|
* `data`
|
||||||
|
* @param {Object} options Options object
|
||||||
|
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
|
||||||
|
* FIN bit
|
||||||
|
* @param {Function} [options.generateMask] The function used to generate the
|
||||||
|
* masking key
|
||||||
|
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
|
||||||
|
* `data`
|
||||||
|
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
|
||||||
|
* key
|
||||||
|
* @param {Number} options.opcode The opcode
|
||||||
|
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
|
||||||
|
* modified
|
||||||
|
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
|
||||||
|
* RSV1 bit
|
||||||
|
* @param {Function} [cb] Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
dispatch(data, compress, options, cb) {
|
||||||
|
if (!compress) {
|
||||||
|
this.sendFrame(Sender.frame(data, options), cb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
|
||||||
|
|
||||||
|
this._bufferedBytes += options[kByteLength];
|
||||||
|
this._state = DEFLATING;
|
||||||
|
perMessageDeflate.compress(data, options.fin, (_, buf) => {
|
||||||
|
if (this._socket.destroyed) {
|
||||||
|
const err = new Error(
|
||||||
|
'The socket was closed while data was being compressed'
|
||||||
|
);
|
||||||
|
|
||||||
|
callCallbacks(this, err, cb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._bufferedBytes -= options[kByteLength];
|
||||||
|
this._state = DEFAULT;
|
||||||
|
options.readOnly = false;
|
||||||
|
this.sendFrame(Sender.frame(buf, options), cb);
|
||||||
|
this.dequeue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes queued send operations.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
dequeue() {
|
||||||
|
while (this._state === DEFAULT && this._queue.length) {
|
||||||
|
const params = this._queue.shift();
|
||||||
|
|
||||||
|
this._bufferedBytes -= params[3][kByteLength];
|
||||||
|
Reflect.apply(params[0], this, params.slice(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueues a send operation.
|
||||||
|
*
|
||||||
|
* @param {Array} params Send operation parameters.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
enqueue(params) {
|
||||||
|
this._bufferedBytes += params[3][kByteLength];
|
||||||
|
this._queue.push(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a frame.
|
||||||
|
*
|
||||||
|
* @param {(Buffer | String)[]} list The frame to send
|
||||||
|
* @param {Function} [cb] Callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
sendFrame(list, cb) {
|
||||||
|
if (list.length === 2) {
|
||||||
|
this._socket.cork();
|
||||||
|
this._socket.write(list[0]);
|
||||||
|
this._socket.write(list[1], cb);
|
||||||
|
this._socket.uncork();
|
||||||
|
} else {
|
||||||
|
this._socket.write(list[0], cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Sender;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls queued callbacks with an error.
|
||||||
|
*
|
||||||
|
* @param {Sender} sender The `Sender` instance
|
||||||
|
* @param {Error} err The error to call the callbacks with
|
||||||
|
* @param {Function} [cb] The first callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function callCallbacks(sender, err, cb) {
|
||||||
|
if (typeof cb === 'function') cb(err);
|
||||||
|
|
||||||
|
for (let i = 0; i < sender._queue.length; i++) {
|
||||||
|
const params = sender._queue[i];
|
||||||
|
const callback = params[params.length - 1];
|
||||||
|
|
||||||
|
if (typeof callback === 'function') callback(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a `Sender` error.
|
||||||
|
*
|
||||||
|
* @param {Sender} sender The `Sender` instance
|
||||||
|
* @param {Error} err The error
|
||||||
|
* @param {Function} [cb] The first pending callback
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function onError(sender, err, cb) {
|
||||||
|
callCallbacks(sender, err, cb);
|
||||||
|
sender.onerror(err);
|
||||||
|
}
|
||||||
161
node_modules/ws/lib/stream.js
generated
vendored
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^WebSocket$" }] */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const WebSocket = require('./websocket');
|
||||||
|
const { Duplex } = require('stream');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits the `'close'` event on a stream.
|
||||||
|
*
|
||||||
|
* @param {Duplex} stream The stream.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function emitClose(stream) {
|
||||||
|
stream.emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The listener of the `'end'` event.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function duplexOnEnd() {
|
||||||
|
if (!this.destroyed && this._writableState.finished) {
|
||||||
|
this.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The listener of the `'error'` event.
|
||||||
|
*
|
||||||
|
* @param {Error} err The error
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function duplexOnError(err) {
|
||||||
|
this.removeListener('error', duplexOnError);
|
||||||
|
this.destroy();
|
||||||
|
if (this.listenerCount('error') === 0) {
|
||||||
|
// Do not suppress the throwing behavior.
|
||||||
|
this.emit('error', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a `WebSocket` in a duplex stream.
|
||||||
|
*
|
||||||
|
* @param {WebSocket} ws The `WebSocket` to wrap
|
||||||
|
* @param {Object} [options] The options for the `Duplex` constructor
|
||||||
|
* @return {Duplex} The duplex stream
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function createWebSocketStream(ws, options) {
|
||||||
|
let terminateOnDestroy = true;
|
||||||
|
|
||||||
|
const duplex = new Duplex({
|
||||||
|
...options,
|
||||||
|
autoDestroy: false,
|
||||||
|
emitClose: false,
|
||||||
|
objectMode: false,
|
||||||
|
writableObjectMode: false
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', function message(msg, isBinary) {
|
||||||
|
const data =
|
||||||
|
!isBinary && duplex._readableState.objectMode ? msg.toString() : msg;
|
||||||
|
|
||||||
|
if (!duplex.push(data)) ws.pause();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.once('error', function error(err) {
|
||||||
|
if (duplex.destroyed) return;
|
||||||
|
|
||||||
|
// Prevent `ws.terminate()` from being called by `duplex._destroy()`.
|
||||||
|
//
|
||||||
|
// - If the `'error'` event is emitted before the `'open'` event, then
|
||||||
|
// `ws.terminate()` is a noop as no socket is assigned.
|
||||||
|
// - Otherwise, the error is re-emitted by the listener of the `'error'`
|
||||||
|
// event of the `Receiver` object. The listener already closes the
|
||||||
|
// connection by calling `ws.close()`. This allows a close frame to be
|
||||||
|
// sent to the other peer. If `ws.terminate()` is called right after this,
|
||||||
|
// then the close frame might not be sent.
|
||||||
|
terminateOnDestroy = false;
|
||||||
|
duplex.destroy(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.once('close', function close() {
|
||||||
|
if (duplex.destroyed) return;
|
||||||
|
|
||||||
|
duplex.push(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
duplex._destroy = function (err, callback) {
|
||||||
|
if (ws.readyState === ws.CLOSED) {
|
||||||
|
callback(err);
|
||||||
|
process.nextTick(emitClose, duplex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let called = false;
|
||||||
|
|
||||||
|
ws.once('error', function error(err) {
|
||||||
|
called = true;
|
||||||
|
callback(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.once('close', function close() {
|
||||||
|
if (!called) callback(err);
|
||||||
|
process.nextTick(emitClose, duplex);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (terminateOnDestroy) ws.terminate();
|
||||||
|
};
|
||||||
|
|
||||||
|
duplex._final = function (callback) {
|
||||||
|
if (ws.readyState === ws.CONNECTING) {
|
||||||
|
ws.once('open', function open() {
|
||||||
|
duplex._final(callback);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the value of the `_socket` property is `null` it means that `ws` is a
|
||||||
|
// client websocket and the handshake failed. In fact, when this happens, a
|
||||||
|
// socket is never assigned to the websocket. Wait for the `'error'` event
|
||||||
|
// that will be emitted by the websocket.
|
||||||
|
if (ws._socket === null) return;
|
||||||
|
|
||||||
|
if (ws._socket._writableState.finished) {
|
||||||
|
callback();
|
||||||
|
if (duplex._readableState.endEmitted) duplex.destroy();
|
||||||
|
} else {
|
||||||
|
ws._socket.once('finish', function finish() {
|
||||||
|
// `duplex` is not destroyed here because the `'end'` event will be
|
||||||
|
// emitted on `duplex` after this `'finish'` event. The EOF signaling
|
||||||
|
// `null` chunk is, in fact, pushed when the websocket emits `'close'`.
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
duplex._read = function () {
|
||||||
|
if (ws.isPaused) ws.resume();
|
||||||
|
};
|
||||||
|
|
||||||
|
duplex._write = function (chunk, encoding, callback) {
|
||||||
|
if (ws.readyState === ws.CONNECTING) {
|
||||||
|
ws.once('open', function open() {
|
||||||
|
duplex._write(chunk, encoding, callback);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(chunk, callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
duplex.on('end', duplexOnEnd);
|
||||||
|
duplex.on('error', duplexOnError);
|
||||||
|
return duplex;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createWebSocketStream;
|
||||||
62
node_modules/ws/lib/subprotocol.js
generated
vendored
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { tokenChars } = require('./validation');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the `Sec-WebSocket-Protocol` header into a set of subprotocol names.
|
||||||
|
*
|
||||||
|
* @param {String} header The field value of the header
|
||||||
|
* @return {Set} The subprotocol names
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function parse(header) {
|
||||||
|
const protocols = new Set();
|
||||||
|
let start = -1;
|
||||||
|
let end = -1;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
for (i; i < header.length; i++) {
|
||||||
|
const code = header.charCodeAt(i);
|
||||||
|
|
||||||
|
if (end === -1 && tokenChars[code] === 1) {
|
||||||
|
if (start === -1) start = i;
|
||||||
|
} else if (
|
||||||
|
i !== 0 &&
|
||||||
|
(code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
|
||||||
|
) {
|
||||||
|
if (end === -1 && start !== -1) end = i;
|
||||||
|
} else if (code === 0x2c /* ',' */) {
|
||||||
|
if (start === -1) {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end === -1) end = i;
|
||||||
|
|
||||||
|
const protocol = header.slice(start, end);
|
||||||
|
|
||||||
|
if (protocols.has(protocol)) {
|
||||||
|
throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protocols.add(protocol);
|
||||||
|
start = end = -1;
|
||||||
|
} else {
|
||||||
|
throw new SyntaxError(`Unexpected character at index ${i}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start === -1 || end !== -1) {
|
||||||
|
throw new SyntaxError('Unexpected end of input');
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = header.slice(start, i);
|
||||||
|
|
||||||
|
if (protocols.has(protocol)) {
|
||||||
|
throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protocols.add(protocol);
|
||||||
|
return protocols;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parse };
|
||||||
152
node_modules/ws/lib/validation.js
generated
vendored
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { isUtf8 } = require('buffer');
|
||||||
|
|
||||||
|
const { hasBlob } = require('./constants');
|
||||||
|
|
||||||
|
//
|
||||||
|
// Allowed token characters:
|
||||||
|
//
|
||||||
|
// '!', '#', '$', '%', '&', ''', '*', '+', '-',
|
||||||
|
// '.', 0-9, A-Z, '^', '_', '`', a-z, '|', '~'
|
||||||
|
//
|
||||||
|
// tokenChars[32] === 0 // ' '
|
||||||
|
// tokenChars[33] === 1 // '!'
|
||||||
|
// tokenChars[34] === 0 // '"'
|
||||||
|
// ...
|
||||||
|
//
|
||||||
|
// prettier-ignore
|
||||||
|
const tokenChars = [
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31
|
||||||
|
0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32 - 47
|
||||||
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63
|
||||||
|
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79
|
||||||
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80 - 95
|
||||||
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111
|
||||||
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0 // 112 - 127
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a status code is allowed in a close frame.
|
||||||
|
*
|
||||||
|
* @param {Number} code The status code
|
||||||
|
* @return {Boolean} `true` if the status code is valid, else `false`
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function isValidStatusCode(code) {
|
||||||
|
return (
|
||||||
|
(code >= 1000 &&
|
||||||
|
code <= 1014 &&
|
||||||
|
code !== 1004 &&
|
||||||
|
code !== 1005 &&
|
||||||
|
code !== 1006) ||
|
||||||
|
(code >= 3000 && code <= 4999)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a given buffer contains only correct UTF-8.
|
||||||
|
* Ported from https://www.cl.cam.ac.uk/%7Emgk25/ucs/utf8_check.c by
|
||||||
|
* Markus Kuhn.
|
||||||
|
*
|
||||||
|
* @param {Buffer} buf The buffer to check
|
||||||
|
* @return {Boolean} `true` if `buf` contains only correct UTF-8, else `false`
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
function _isValidUTF8(buf) {
|
||||||
|
const len = buf.length;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < len) {
|
||||||
|
if ((buf[i] & 0x80) === 0) {
|
||||||
|
// 0xxxxxxx
|
||||||
|
i++;
|
||||||
|
} else if ((buf[i] & 0xe0) === 0xc0) {
|
||||||
|
// 110xxxxx 10xxxxxx
|
||||||
|
if (
|
||||||
|
i + 1 === len ||
|
||||||
|
(buf[i + 1] & 0xc0) !== 0x80 ||
|
||||||
|
(buf[i] & 0xfe) === 0xc0 // Overlong
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 2;
|
||||||
|
} else if ((buf[i] & 0xf0) === 0xe0) {
|
||||||
|
// 1110xxxx 10xxxxxx 10xxxxxx
|
||||||
|
if (
|
||||||
|
i + 2 >= len ||
|
||||||
|
(buf[i + 1] & 0xc0) !== 0x80 ||
|
||||||
|
(buf[i + 2] & 0xc0) !== 0x80 ||
|
||||||
|
(buf[i] === 0xe0 && (buf[i + 1] & 0xe0) === 0x80) || // Overlong
|
||||||
|
(buf[i] === 0xed && (buf[i + 1] & 0xe0) === 0xa0) // Surrogate (U+D800 - U+DFFF)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 3;
|
||||||
|
} else if ((buf[i] & 0xf8) === 0xf0) {
|
||||||
|
// 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
|
||||||
|
if (
|
||||||
|
i + 3 >= len ||
|
||||||
|
(buf[i + 1] & 0xc0) !== 0x80 ||
|
||||||
|
(buf[i + 2] & 0xc0) !== 0x80 ||
|
||||||
|
(buf[i + 3] & 0xc0) !== 0x80 ||
|
||||||
|
(buf[i] === 0xf0 && (buf[i + 1] & 0xf0) === 0x80) || // Overlong
|
||||||
|
(buf[i] === 0xf4 && buf[i + 1] > 0x8f) ||
|
||||||
|
buf[i] > 0xf4 // > U+10FFFF
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 4;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a value is a `Blob`.
|
||||||
|
*
|
||||||
|
* @param {*} value The value to be tested
|
||||||
|
* @return {Boolean} `true` if `value` is a `Blob`, else `false`
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function isBlob(value) {
|
||||||
|
return (
|
||||||
|
hasBlob &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
typeof value.arrayBuffer === 'function' &&
|
||||||
|
typeof value.type === 'string' &&
|
||||||
|
typeof value.stream === 'function' &&
|
||||||
|
(value[Symbol.toStringTag] === 'Blob' ||
|
||||||
|
value[Symbol.toStringTag] === 'File')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isBlob,
|
||||||
|
isValidStatusCode,
|
||||||
|
isValidUTF8: _isValidUTF8,
|
||||||
|
tokenChars
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isUtf8) {
|
||||||
|
module.exports.isValidUTF8 = function (buf) {
|
||||||
|
return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf);
|
||||||
|
};
|
||||||
|
} /* istanbul ignore else */ else if (!process.env.WS_NO_UTF_8_VALIDATE) {
|
||||||
|
try {
|
||||||
|
const isValidUTF8 = require('utf-8-validate');
|
||||||
|
|
||||||
|
module.exports.isValidUTF8 = function (buf) {
|
||||||
|
return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf);
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// Continue regardless of the error.
|
||||||
|
}
|
||||||
|
}
|
||||||
554
node_modules/ws/lib/websocket-server.js
generated
vendored
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$", "caughtErrors": "none" }] */
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const http = require('http');
|
||||||
|
const { Duplex } = require('stream');
|
||||||
|
const { createHash } = require('crypto');
|
||||||
|
|
||||||
|
const extension = require('./extension');
|
||||||
|
const PerMessageDeflate = require('./permessage-deflate');
|
||||||
|
const subprotocol = require('./subprotocol');
|
||||||
|
const WebSocket = require('./websocket');
|
||||||
|
const { CLOSE_TIMEOUT, GUID, kWebSocket } = require('./constants');
|
||||||
|
|
||||||
|
const keyRegex = /^[+/0-9A-Za-z]{22}==$/;
|
||||||
|
|
||||||
|
const RUNNING = 0;
|
||||||
|
const CLOSING = 1;
|
||||||
|
const CLOSED = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class representing a WebSocket server.
|
||||||
|
*
|
||||||
|
* @extends EventEmitter
|
||||||
|
*/
|
||||||
|
class WebSocketServer extends EventEmitter {
|
||||||
|
/**
|
||||||
|
* Create a `WebSocketServer` instance.
|
||||||
|
*
|
||||||
|
* @param {Object} options Configuration options
|
||||||
|
* @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether
|
||||||
|
* any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
|
||||||
|
* multiple times in the same tick
|
||||||
|
* @param {Boolean} [options.autoPong=true] Specifies whether or not to
|
||||||
|
* automatically send a pong in response to a ping
|
||||||
|
* @param {Number} [options.backlog=511] The maximum length of the queue of
|
||||||
|
* pending connections
|
||||||
|
* @param {Boolean} [options.clientTracking=true] Specifies whether or not to
|
||||||
|
* track clients
|
||||||
|
* @param {Number} [options.closeTimeout=30000] Duration in milliseconds to
|
||||||
|
* wait for the closing handshake to finish after `websocket.close()` is
|
||||||
|
* called
|
||||||
|
* @param {Function} [options.handleProtocols] A hook to handle protocols
|
||||||
|
* @param {String} [options.host] The hostname where to bind the server
|
||||||
|
* @param {Number} [options.maxPayload=104857600] The maximum allowed message
|
||||||
|
* size
|
||||||
|
* @param {Boolean} [options.noServer=false] Enable no server mode
|
||||||
|
* @param {String} [options.path] Accept only connections matching this path
|
||||||
|
* @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable
|
||||||
|
* permessage-deflate
|
||||||
|
* @param {Number} [options.port] The port where to bind the server
|
||||||
|
* @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S
|
||||||
|
* server to use
|
||||||
|
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
|
||||||
|
* not to skip UTF-8 validation for text and close messages
|
||||||
|
* @param {Function} [options.verifyClient] A hook to reject connections
|
||||||
|
* @param {Function} [options.WebSocket=WebSocket] Specifies the `WebSocket`
|
||||||
|
* class to use. It must be the `WebSocket` class or class that extends it
|
||||||
|
* @param {Function} [callback] A listener for the `listening` event
|
||||||
|
*/
|
||||||
|
constructor(options, callback) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
options = {
|
||||||
|
allowSynchronousEvents: true,
|
||||||
|
autoPong: true,
|
||||||
|
maxPayload: 100 * 1024 * 1024,
|
||||||
|
skipUTF8Validation: false,
|
||||||
|
perMessageDeflate: false,
|
||||||
|
handleProtocols: null,
|
||||||
|
clientTracking: true,
|
||||||
|
closeTimeout: CLOSE_TIMEOUT,
|
||||||
|
verifyClient: null,
|
||||||
|
noServer: false,
|
||||||
|
backlog: null, // use default (511 as implemented in net.js)
|
||||||
|
server: null,
|
||||||
|
host: null,
|
||||||
|
path: null,
|
||||||
|
port: null,
|
||||||
|
WebSocket,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
(options.port == null && !options.server && !options.noServer) ||
|
||||||
|
(options.port != null && (options.server || options.noServer)) ||
|
||||||
|
(options.server && options.noServer)
|
||||||
|
) {
|
||||||
|
throw new TypeError(
|
||||||
|
'One and only one of the "port", "server", or "noServer" options ' +
|
||||||
|
'must be specified'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.port != null) {
|
||||||
|
this._server = http.createServer((req, res) => {
|
||||||
|
const body = http.STATUS_CODES[426];
|
||||||
|
|
||||||
|
res.writeHead(426, {
|
||||||
|
'Content-Length': body.length,
|
||||||
|
'Content-Type': 'text/plain'
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
});
|
||||||
|
this._server.listen(
|
||||||
|
options.port,
|
||||||
|
options.host,
|
||||||
|
options.backlog,
|
||||||
|
callback
|
||||||
|
);
|
||||||
|
} else if (options.server) {
|
||||||
|
this._server = options.server;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._server) {
|
||||||
|
const emitConnection = this.emit.bind(this, 'connection');
|
||||||
|
|
||||||
|
this._removeListeners = addListeners(this._server, {
|
||||||
|
listening: this.emit.bind(this, 'listening'),
|
||||||
|
error: this.emit.bind(this, 'error'),
|
||||||
|
upgrade: (req, socket, head) => {
|
||||||
|
this.handleUpgrade(req, socket, head, emitConnection);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.perMessageDeflate === true) options.perMessageDeflate = {};
|
||||||
|
if (options.clientTracking) {
|
||||||
|
this.clients = new Set();
|
||||||
|
this._shouldEmitClose = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options = options;
|
||||||
|
this._state = RUNNING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the bound address, the address family name, and port of the server
|
||||||
|
* as reported by the operating system if listening on an IP socket.
|
||||||
|
* If the server is listening on a pipe or UNIX domain socket, the name is
|
||||||
|
* returned as a string.
|
||||||
|
*
|
||||||
|
* @return {(Object|String|null)} The address of the server
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
address() {
|
||||||
|
if (this.options.noServer) {
|
||||||
|
throw new Error('The server is operating in "noServer" mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._server) return null;
|
||||||
|
return this._server.address();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the server from accepting new connections and emit the `'close'` event
|
||||||
|
* when all existing connections are closed.
|
||||||
|
*
|
||||||
|
* @param {Function} [cb] A one-time listener for the `'close'` event
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
close(cb) {
|
||||||
|
if (this._state === CLOSED) {
|
||||||
|
if (cb) {
|
||||||
|
this.once('close', () => {
|
||||||
|
cb(new Error('The server is not running'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
process.nextTick(emitClose, this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cb) this.once('close', cb);
|
||||||
|
|
||||||
|
if (this._state === CLOSING) return;
|
||||||
|
this._state = CLOSING;
|
||||||
|
|
||||||
|
if (this.options.noServer || this.options.server) {
|
||||||
|
if (this._server) {
|
||||||
|
this._removeListeners();
|
||||||
|
this._removeListeners = this._server = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.clients) {
|
||||||
|
if (!this.clients.size) {
|
||||||
|
process.nextTick(emitClose, this);
|
||||||
|
} else {
|
||||||
|
this._shouldEmitClose = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
process.nextTick(emitClose, this);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const server = this._server;
|
||||||
|
|
||||||
|
this._removeListeners();
|
||||||
|
this._removeListeners = this._server = null;
|
||||||
|
|
||||||
|
//
|
||||||
|
// The HTTP/S server was created internally. Close it, and rely on its
|
||||||
|
// `'close'` event.
|
||||||
|
//
|
||||||
|
server.close(() => {
|
||||||
|
emitClose(this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See if a given request should be handled by this server instance.
|
||||||
|
*
|
||||||
|
* @param {http.IncomingMessage} req Request object to inspect
|
||||||
|
* @return {Boolean} `true` if the request is valid, else `false`
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
shouldHandle(req) {
|
||||||
|
if (this.options.path) {
|
||||||
|
const index = req.url.indexOf('?');
|
||||||
|
const pathname = index !== -1 ? req.url.slice(0, index) : req.url;
|
||||||
|
|
||||||
|
if (pathname !== this.options.path) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a HTTP Upgrade request.
|
||||||
|
*
|
||||||
|
* @param {http.IncomingMessage} req The request object
|
||||||
|
* @param {Duplex} socket The network socket between the server and client
|
||||||
|
* @param {Buffer} head The first packet of the upgraded stream
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
handleUpgrade(req, socket, head, cb) {
|
||||||
|
socket.on('error', socketOnError);
|
||||||
|
|
||||||
|
const key = req.headers['sec-websocket-key'];
|
||||||
|
const upgrade = req.headers.upgrade;
|
||||||
|
const version = +req.headers['sec-websocket-version'];
|
||||||
|
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
const message = 'Invalid HTTP method';
|
||||||
|
abortHandshakeOrEmitwsClientError(this, req, socket, 405, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') {
|
||||||
|
const message = 'Invalid Upgrade header';
|
||||||
|
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === undefined || !keyRegex.test(key)) {
|
||||||
|
const message = 'Missing or invalid Sec-WebSocket-Key header';
|
||||||
|
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version !== 13 && version !== 8) {
|
||||||
|
const message = 'Missing or invalid Sec-WebSocket-Version header';
|
||||||
|
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message, {
|
||||||
|
'Sec-WebSocket-Version': '13, 8'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.shouldHandle(req)) {
|
||||||
|
abortHandshake(socket, 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secWebSocketProtocol = req.headers['sec-websocket-protocol'];
|
||||||
|
let protocols = new Set();
|
||||||
|
|
||||||
|
if (secWebSocketProtocol !== undefined) {
|
||||||
|
try {
|
||||||
|
protocols = subprotocol.parse(secWebSocketProtocol);
|
||||||
|
} catch (err) {
|
||||||
|
const message = 'Invalid Sec-WebSocket-Protocol header';
|
||||||
|
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const secWebSocketExtensions = req.headers['sec-websocket-extensions'];
|
||||||
|
const extensions = {};
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.options.perMessageDeflate &&
|
||||||
|
secWebSocketExtensions !== undefined
|
||||||
|
) {
|
||||||
|
const perMessageDeflate = new PerMessageDeflate({
|
||||||
|
...this.options.perMessageDeflate,
|
||||||
|
isServer: true,
|
||||||
|
maxPayload: this.options.maxPayload
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const offers = extension.parse(secWebSocketExtensions);
|
||||||
|
|
||||||
|
if (offers[PerMessageDeflate.extensionName]) {
|
||||||
|
perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]);
|
||||||
|
extensions[PerMessageDeflate.extensionName] = perMessageDeflate;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
'Invalid or unacceptable Sec-WebSocket-Extensions header';
|
||||||
|
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Optionally call external client verification handler.
|
||||||
|
//
|
||||||
|
if (this.options.verifyClient) {
|
||||||
|
const info = {
|
||||||
|
origin:
|
||||||
|
req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`],
|
||||||
|
secure: !!(req.socket.authorized || req.socket.encrypted),
|
||||||
|
req
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.options.verifyClient.length === 2) {
|
||||||
|
this.options.verifyClient(info, (verified, code, message, headers) => {
|
||||||
|
if (!verified) {
|
||||||
|
return abortHandshake(socket, code || 401, message, headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.completeUpgrade(
|
||||||
|
extensions,
|
||||||
|
key,
|
||||||
|
protocols,
|
||||||
|
req,
|
||||||
|
socket,
|
||||||
|
head,
|
||||||
|
cb
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.options.verifyClient(info)) return abortHandshake(socket, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.completeUpgrade(extensions, key, protocols, req, socket, head, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upgrade the connection to WebSocket.
|
||||||
|
*
|
||||||
|
* @param {Object} extensions The accepted extensions
|
||||||
|
* @param {String} key The value of the `Sec-WebSocket-Key` header
|
||||||
|
* @param {Set} protocols The subprotocols
|
||||||
|
* @param {http.IncomingMessage} req The request object
|
||||||
|
* @param {Duplex} socket The network socket between the server and client
|
||||||
|
* @param {Buffer} head The first packet of the upgraded stream
|
||||||
|
* @param {Function} cb Callback
|
||||||
|
* @throws {Error} If called more than once with the same socket
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
completeUpgrade(extensions, key, protocols, req, socket, head, cb) {
|
||||||
|
//
|
||||||
|
// Destroy the socket if the client has already sent a FIN packet.
|
||||||
|
//
|
||||||
|
if (!socket.readable || !socket.writable) return socket.destroy();
|
||||||
|
|
||||||
|
if (socket[kWebSocket]) {
|
||||||
|
throw new Error(
|
||||||
|
'server.handleUpgrade() was called more than once with the same ' +
|
||||||
|
'socket, possibly due to a misconfiguration'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._state > RUNNING) return abortHandshake(socket, 503);
|
||||||
|
|
||||||
|
const digest = createHash('sha1')
|
||||||
|
.update(key + GUID)
|
||||||
|
.digest('base64');
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
'HTTP/1.1 101 Switching Protocols',
|
||||||
|
'Upgrade: websocket',
|
||||||
|
'Connection: Upgrade',
|
||||||
|
`Sec-WebSocket-Accept: ${digest}`
|
||||||
|
];
|
||||||
|
|
||||||
|
const ws = new this.options.WebSocket(null, undefined, this.options);
|
||||||
|
|
||||||
|
if (protocols.size) {
|
||||||
|
//
|
||||||
|
// Optionally call external protocol selection handler.
|
||||||
|
//
|
||||||
|
const protocol = this.options.handleProtocols
|
||||||
|
? this.options.handleProtocols(protocols, req)
|
||||||
|
: protocols.values().next().value;
|
||||||
|
|
||||||
|
if (protocol) {
|
||||||
|
headers.push(`Sec-WebSocket-Protocol: ${protocol}`);
|
||||||
|
ws._protocol = protocol;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extensions[PerMessageDeflate.extensionName]) {
|
||||||
|
const params = extensions[PerMessageDeflate.extensionName].params;
|
||||||
|
const value = extension.format({
|
||||||
|
[PerMessageDeflate.extensionName]: [params]
|
||||||
|
});
|
||||||
|
headers.push(`Sec-WebSocket-Extensions: ${value}`);
|
||||||
|
ws._extensions = extensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Allow external modification/inspection of handshake headers.
|
||||||
|
//
|
||||||
|
this.emit('headers', headers, req);
|
||||||
|
|
||||||
|
socket.write(headers.concat('\r\n').join('\r\n'));
|
||||||
|
socket.removeListener('error', socketOnError);
|
||||||
|
|
||||||
|
ws.setSocket(socket, head, {
|
||||||
|
allowSynchronousEvents: this.options.allowSynchronousEvents,
|
||||||
|
maxPayload: this.options.maxPayload,
|
||||||
|
skipUTF8Validation: this.options.skipUTF8Validation
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.clients) {
|
||||||
|
this.clients.add(ws);
|
||||||
|
ws.on('close', () => {
|
||||||
|
this.clients.delete(ws);
|
||||||
|
|
||||||
|
if (this._shouldEmitClose && !this.clients.size) {
|
||||||
|
process.nextTick(emitClose, this);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(ws, req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = WebSocketServer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add event listeners on an `EventEmitter` using a map of <event, listener>
|
||||||
|
* pairs.
|
||||||
|
*
|
||||||
|
* @param {EventEmitter} server The event emitter
|
||||||
|
* @param {Object.<String, Function>} map The listeners to add
|
||||||
|
* @return {Function} A function that will remove the added listeners when
|
||||||
|
* called
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function addListeners(server, map) {
|
||||||
|
for (const event of Object.keys(map)) server.on(event, map[event]);
|
||||||
|
|
||||||
|
return function removeListeners() {
|
||||||
|
for (const event of Object.keys(map)) {
|
||||||
|
server.removeListener(event, map[event]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a `'close'` event on an `EventEmitter`.
|
||||||
|
*
|
||||||
|
* @param {EventEmitter} server The event emitter
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function emitClose(server) {
|
||||||
|
server._state = CLOSED;
|
||||||
|
server.emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle socket errors.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function socketOnError() {
|
||||||
|
this.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the connection when preconditions are not fulfilled.
|
||||||
|
*
|
||||||
|
* @param {Duplex} socket The socket of the upgrade request
|
||||||
|
* @param {Number} code The HTTP response status code
|
||||||
|
* @param {String} [message] The HTTP response body
|
||||||
|
* @param {Object} [headers] Additional HTTP response headers
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function abortHandshake(socket, code, message, headers) {
|
||||||
|
//
|
||||||
|
// The socket is writable unless the user destroyed or ended it before calling
|
||||||
|
// `server.handleUpgrade()` or in the `verifyClient` function, which is a user
|
||||||
|
// error. Handling this does not make much sense as the worst that can happen
|
||||||
|
// is that some of the data written by the user might be discarded due to the
|
||||||
|
// call to `socket.end()` below, which triggers an `'error'` event that in
|
||||||
|
// turn causes the socket to be destroyed.
|
||||||
|
//
|
||||||
|
message = message || http.STATUS_CODES[code];
|
||||||
|
headers = {
|
||||||
|
Connection: 'close',
|
||||||
|
'Content-Type': 'text/html',
|
||||||
|
'Content-Length': Buffer.byteLength(message),
|
||||||
|
...headers
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.once('finish', socket.destroy);
|
||||||
|
|
||||||
|
socket.end(
|
||||||
|
`HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` +
|
||||||
|
Object.keys(headers)
|
||||||
|
.map((h) => `${h}: ${headers[h]}`)
|
||||||
|
.join('\r\n') +
|
||||||
|
'\r\n\r\n' +
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a `'wsClientError'` event on a `WebSocketServer` if there is at least
|
||||||
|
* one listener for it, otherwise call `abortHandshake()`.
|
||||||
|
*
|
||||||
|
* @param {WebSocketServer} server The WebSocket server
|
||||||
|
* @param {http.IncomingMessage} req The request object
|
||||||
|
* @param {Duplex} socket The socket of the upgrade request
|
||||||
|
* @param {Number} code The HTTP response status code
|
||||||
|
* @param {String} message The HTTP response body
|
||||||
|
* @param {Object} [headers] The HTTP response headers
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function abortHandshakeOrEmitwsClientError(
|
||||||
|
server,
|
||||||
|
req,
|
||||||
|
socket,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
headers
|
||||||
|
) {
|
||||||
|
if (server.listenerCount('wsClientError')) {
|
||||||
|
const err = new Error(message);
|
||||||
|
Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError);
|
||||||
|
|
||||||
|
server.emit('wsClientError', err, socket, req);
|
||||||
|
} else {
|
||||||
|
abortHandshake(socket, code, message, headers);
|
||||||
|
}
|
||||||
|
}
|
||||||
1393
node_modules/ws/lib/websocket.js
generated
vendored
Normal file
70
node_modules/ws/package.json
generated
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"name": "ws",
|
||||||
|
"version": "8.20.0",
|
||||||
|
"description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
|
||||||
|
"keywords": [
|
||||||
|
"HyBi",
|
||||||
|
"Push",
|
||||||
|
"RFC-6455",
|
||||||
|
"WebSocket",
|
||||||
|
"WebSockets",
|
||||||
|
"real-time"
|
||||||
|
],
|
||||||
|
"homepage": "https://github.com/websockets/ws",
|
||||||
|
"bugs": "https://github.com/websockets/ws/issues",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/websockets/ws.git"
|
||||||
|
},
|
||||||
|
"author": "Einar Otto Stangvik <einaros@gmail.com> (http://2x.io)",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "index.js",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"browser": "./browser.js",
|
||||||
|
"import": "./wrapper.mjs",
|
||||||
|
"require": "./index.js"
|
||||||
|
},
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"browser": "browser.js",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"browser.js",
|
||||||
|
"index.js",
|
||||||
|
"lib/*.js",
|
||||||
|
"wrapper.mjs"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js",
|
||||||
|
"integration": "mocha --throw-deprecation test/*.integration.js",
|
||||||
|
"lint": "eslint . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\""
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"benchmark": "^2.1.4",
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"eslint": "^10.0.1",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
|
"globals": "^17.0.0",
|
||||||
|
"mocha": "^8.4.0",
|
||||||
|
"nyc": "^15.0.0",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"utf-8-validate": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
node_modules/ws/wrapper.mjs
generated
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import createWebSocketStream from './lib/stream.js';
|
||||||
|
import extension from './lib/extension.js';
|
||||||
|
import PerMessageDeflate from './lib/permessage-deflate.js';
|
||||||
|
import Receiver from './lib/receiver.js';
|
||||||
|
import Sender from './lib/sender.js';
|
||||||
|
import subprotocol from './lib/subprotocol.js';
|
||||||
|
import WebSocket from './lib/websocket.js';
|
||||||
|
import WebSocketServer from './lib/websocket-server.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createWebSocketStream,
|
||||||
|
extension,
|
||||||
|
PerMessageDeflate,
|
||||||
|
Receiver,
|
||||||
|
Sender,
|
||||||
|
subprotocol,
|
||||||
|
WebSocket,
|
||||||
|
WebSocketServer
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WebSocket;
|
||||||
51
package-lock.json
generated
@@ -7,10 +7,12 @@
|
|||||||
"name": "star-wars-wild-space",
|
"name": "star-wars-wild-space",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"pg": "^8.13.1"
|
"pg": "^8.13.1",
|
||||||
|
"ws": "^8.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
@@ -142,6 +144,23 @@
|
|||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cors": {
|
||||||
|
"version": "2.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||||
|
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4",
|
||||||
|
"vary": "^1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -651,6 +670,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -1098,6 +1126,27 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"pg": "^8.13.1"
|
"pg": "^8.13.1",
|
||||||
|
"ws": "^8.20.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/graphism/Logo_2.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/graphism/Version_01.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
public/graphism/Version_02.png
Normal file
|
After Width: | Height: | Size: 892 KiB |
BIN
public/graphism/Version_03.png
Normal file
|
After Width: | Height: | Size: 582 KiB |
BIN
public/graphism/Version_03_2.png
Normal file
|
After Width: | Height: | Size: 979 KiB |
BIN
public/graphism/favicon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 76 KiB |
71
public/graphism/playground_OLD.svg
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="1000"
|
||||||
|
height="1000"
|
||||||
|
viewBox="0 0 1000 1000"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
sodipodi:docname="playground.svg"
|
||||||
|
inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="1.5867476"
|
||||||
|
inkscape:cx="178.66736"
|
||||||
|
inkscape:cy="601.86006"
|
||||||
|
inkscape:window-width="3440"
|
||||||
|
inkscape:window-height="1351"
|
||||||
|
inkscape:window-x="-9"
|
||||||
|
inkscape:window-y="222"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg1" />
|
||||||
|
<defs
|
||||||
|
id="defs1">
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath4">
|
||||||
|
<path
|
||||||
|
d="M 0,2048 H 2048 V 0 H 0 Z"
|
||||||
|
transform="translate(-1507.918,-1029.2018)"
|
||||||
|
id="path4" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath4-4">
|
||||||
|
<path
|
||||||
|
d="M 0,2048 H 2048 V 0 H 0 Z"
|
||||||
|
transform="translate(-1507.918,-1029.2018)"
|
||||||
|
id="path4-8" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
id="outer_regions"
|
||||||
|
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;stroke-width:0.51783;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers;enable-background:accumulate;stop-color:#000000"
|
||||||
|
d="M 421.56055 25 C 373.38507 24.854113 329.1082 52.904873 305.05664 94.201172 C 293.06758 105.95087 270.36232 134.71112 257.10352 150.71875 C 247.32899 164.49231 234.8216 176.43594 222.05469 226.12109 C 219.03118 242.36653 217.06491 259.07203 214.29883 275.56445 C 212.67557 298.98884 264.13768 311.51357 287.87891 327.09375 C 296.0849 332.47893 307.97987 340.00424 320.7832 348.60742 C 320.1134 344.11854 323.47791 339.10584 327.63672 332.05078 C 333.0299 323.75882 336.92332 323.45873 343.03125 314.89453 C 351.31162 309.1134 354.9907 298.11968 360.89844 289.64062 C 359.97627 280.01528 353.75632 277.99875 351.76367 273.21289 C 337.55097 259.56523 345.01527 239.46967 354.64648 226.36523 C 362.39976 217.92275 365.16934 208.4102 375.75195 201.58008 C 393.07795 200.38815 393.75913 182.17379 405.15039 169.01367 C 411.69598 149.88677 428.93477 147.15396 446.0293 152.33398 C 463.84346 150.58687 513.99705 149.58582 495.74609 178.20312 C 481.87191 191.89422 490.71187 216.23079 511.38086 216.18359 C 535.38757 220.51262 559.44416 197.25521 582.21484 210.84375 C 583.66092 187.31652 616.67414 194.17432 627.66992 211.77344 C 639.63565 222.2925 657.2255 200.75036 670.13086 212.12109 C 692.3961 211.1996 674.81659 227.38108 675.00195 237.70117 C 671.23258 256.47466 686.23931 264.12366 691.91797 280.63867 C 698.02993 281.73392 699.72808 289.02616 705.51367 291.75 C 719.70278 301.66027 728.05547 314.34223 743.5 321.95312 C 738.76526 335.61082 748.70232 356.30939 741.57227 373.19922 C 729.1255 388.20226 742.26702 383.34648 749.36719 389.08789 C 750.53135 401.37329 740.32006 397.65199 735.78711 409.5918 C 730.09592 428.34355 740.45242 447.3727 742.08789 461.16211 C 744.77627 479.10466 749.10645 479.06498 749.95312 497.29883 C 751.39719 514.8114 748.38528 531.58471 746.43359 548.65039 C 746.77291 565.35558 741.35189 582.18322 742.0332 597.92383 C 739.3678 611.44419 736.1463 628.11891 730.26172 639.51953 C 729.26253 661.79034 717.30912 682.89918 706.31055 702.10547 C 695.71562 721.15694 675.6338 730.68077 655.84375 737.67578 C 643.54509 741.03008 629.14988 736.21891 620.83008 747.55859 C 600.79718 739.99855 601.64775 757.25019 608.26758 772.82227 C 622.50221 792.38563 605.21984 809.86991 583.91211 799.55859 C 559.10376 790.70814 518.22734 773.90224 510.28516 814.05664 C 509.17234 841.04703 481.10084 820.19103 470.73047 836.43164 C 479.5771 853.61378 463.50343 850.19036 459.09766 837.0957 C 448.69254 826.70252 423.44592 823.91476 414.74219 835.57812 C 402.43796 835.08453 388.49386 847.09036 373.93359 849.48633 C 364.87672 842.34189 362.53209 828.37655 346.71289 824.5 C 348.92431 799.51325 316.89209 778.69284 303.48438 807.22656 C 276.36808 813.39829 298.46075 777.29162 301.49414 766.49609 C 297.28513 757.65654 318.0412 735.18774 306.83398 732.92969 C 308.06058 715.91282 288.52149 694.67408 268.26172 687.36914 C 232.49774 693.13392 263.89538 649.66269 268.03516 634.19141 C 270.66323 623.27925 272.49877 622.46824 273.95508 625.85742 C 275.96345 610.91265 276.07179 593.50984 267.37891 580.21484 C 252.2274 557.04193 223.70727 537.43372 188.05664 548.12891 C 181.42086 550.11964 169.81936 552.90135 164.4043 561.08984 C 144.58836 594.04992 132.26742 632.12959 126.00391 669.55664 C 119.77245 713.01501 143.65783 750.68967 167.35742 784.14453 C 155.33363 828.28331 180.22411 879.31446 216.54297 906.95312 C 246.91473 930.04449 286.64182 932.68349 314.79883 960.30469 C 351.47425 986.96424 395.09906 969.18658 435.38867 969.2793 C 488.61384 986.20754 538.18949 949.92996 583.54102 928.72656 C 626.36154 926.74102 673.9568 920.77568 702.33203 884.4668 C 720.04276 849.20725 753.55087 830.82081 782.38477 806.63281 C 816.72989 771.58231 838.33704 725.52218 851.42578 678.64062 C 853.74925 660.01959 870.72296 611.5352 869.11719 579.91211 C 876.51005 532.95074 878.33465 484.14554 866.31641 437.89844 C 879.98281 393.37 868.87111 347.08312 866.87109 301.52148 C 858.01092 246.34361 799.39416 215.12646 792.55078 158.82227 C 768.95243 110.68367 714.34642 83.955117 662.24805 84.765625 C 619.53959 69.32581 571.50126 70.255977 535.91602 38.785156 C 499.34325 25.027918 459.71597 28.170707 421.56055 25 z "
|
||||||
|
inkscape:label="outer_regions">
|
||||||
|
<title
|
||||||
|
id="title164">outer_regions</title>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
id="galaxy"
|
||||||
|
d="m 0,0 c -1.6392001,35.116515 -10.023207,35.039508 -15.228,69.595 -3.16633,26.556969 -23.215345,63.20707 -12.197,99.321 8.775939,22.99484 28.5458585,15.82859 26.292,39.489 -13.746171,11.05737 -39.190764,1.70514 -15.093391,30.59945 13.8040271,32.52806 -5.434238,72.38925 3.732391,98.69255 -29.901136,14.65779 -46.072395,39.08484 -73.543,58.171 -11.20111,5.24584 -14.48904,19.28766 -26.322,21.397 -10.99407,31.8062 -40.04663,46.53721 -32.749,82.693 -0.35886,19.87543 33.67428,51.0387 -9.432,49.264 -24.98523,21.89886 -59.03894,-19.58862 -82.205,0.67 -21.28822,33.89408 -85.20435,47.10102 -88.004,1.79 -44.08483,26.17012 -90.65619,-18.62026 -137.134,-10.283 -40.01587,-0.0909 -57.1319,46.77937 -30.271,73.147 35.33449,55.11398 -61.76417,53.18677 -96.253,49.822 -33.09559,9.97619 -66.47051,4.71143 -79.143,-32.125 -22.05388,-25.34503 -23.37227,-60.42446 -56.916,-62.72 -20.4883,-13.15411 -25.8494,-31.47265 -40.86,-47.732 -18.64637,-25.23778 -33.09729,-63.94102 -5.581,-90.225 3.85784,-9.21708 15.89965,-13.09958 17.685,-31.637 -11.43758,-16.32978 -18.56292,-37.50213 -34.594,-48.636 -11.82516,-16.49377 -19.36161,-17.07354 -29.803,-33.043 -14.73921,-24.87286 -24.32464,-36.55882 9.722,-53.02 -4.46635,-30.01183 27.85389,-43.74102 -4.50894,-76.21989 -41.15805,-9.92894 -28.31953,-49.82846 10.02294,-43.45411 27.71821,12.87712 84.09192,-10.4708 31.292,-27.711 -30.52347,-27.419057 -43.28626,-68.708385 11.977,-75.612 -2.55343,-31.955736 -0.49004,-67.4389 20.284,-94.317 -9.21704,-11.892963 -47.71154,-2.006868 -50.588,-24.1 18.68092,-14.240523 35.97904,-34.807 7.814,-52.503 -37.3362,18.82968 -27.89316,-49.1466 -38.85036,-77.57622 15.50352,-20.08451 47.6389,-25.39807 23.04336,-51.35178 19.90369,-23.02097 60.50378,-65.64143 2.124,-48.359 -43.82094,18.53403 -12.57036,-21.67302 -26.33425,-16.04163 -37.15002,-29.11066 -79.0999,-21.12707 -89.96725,29.91388 -5.73779,15.4801 -9.03267,79.63161 -21.4215,28.46075 -8.01474,-29.79609 -68.8023,-113.51537 0.438,-102.413 39.22362,-14.06855 77.05303,-54.97327 74.67829,-87.746 21.69756,-4.34878 -18.48907,-47.62093 -10.34029,-64.645 -5.87275,-20.79107 -48.64208,-90.32811 3.856,-78.442 25.95779,54.95301 87.97139,14.8539 83.69,-33.268 30.62651,-7.46583 35.16758,-34.36055 52.702,-48.12 28.18917,4.61439 55.18559,27.73461 79.007,26.784 16.85072,22.46244 65.72934,17.0922 85.874,-2.924 8.52973,-25.21895 39.647,-31.81127 22.51962,1.27975 20.0774,31.27774 74.42592,-8.88745 76.58038,43.09325 15.37634,77.33322 94.51355,44.96818 142.54337,27.92312 41.25251,-19.85852 74.71211,13.81099 47.15338,51.488 -12.81622,29.9902 -14.46113,63.21674 24.32325,48.65688 16.10743,21.83905 43.9754,12.57457 67.78603,19.03461 38.31421,13.47166 77.19382,31.81122 97.70597,68.50239 21.293609,36.98933 44.436529,77.64473 46.371,120.536 11.39275,21.95642 17.62657,54.06755 22.786875,80.10637 -1.319042,30.31478 9.1770622,62.72516 8.520125,94.89763 C -3.0364735,-66.030256 2.7957668,-33.727398 0,0 Z"
|
||||||
|
style="display:inline;fill:#000015;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.00024;stroke-dasharray:none;paint-order:stroke markers fill"
|
||||||
|
clip-path="url(#clipPath4)"
|
||||||
|
transform="matrix(0.51651973,0,0,-0.51923866,749.95318,497.299)"
|
||||||
|
inkscape:label="galaxy">
|
||||||
|
<title
|
||||||
|
id="title163">galaxy</title>
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.1 KiB |
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Star Wars - Wild Space</title>
|
<title>Star Wars - Wild Space</title>
|
||||||
<link rel="stylesheet" href="./style.css" />
|
<link rel="stylesheet" href="./style.css" />
|
||||||
|
<link rel="icon" type="image/png" href="./graphism/favicon.png" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Auth Modal ──────────────────────────────────────────────────────────── -->
|
<!-- Auth Modal ──────────────────────────────────────────────────────────── -->
|
||||||
@@ -35,10 +36,6 @@
|
|||||||
<label>Nom d'utilisateur</label>
|
<label>Nom d'utilisateur</label>
|
||||||
<input type="text" id="regUsername" autocomplete="username" required />
|
<input type="text" id="regUsername" autocomplete="username" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="authField">
|
|
||||||
<label>Adresse courriel</label>
|
|
||||||
<input type="email" id="regEmail" autocomplete="email" required />
|
|
||||||
</div>
|
|
||||||
<div class="authField">
|
<div class="authField">
|
||||||
<label>Mot de passe <span class="authHint">(6 caractères min.)</span></label>
|
<label>Mot de passe <span class="authHint">(6 caractères min.)</span></label>
|
||||||
<input type="password" id="regPassword" autocomplete="new-password" required />
|
<input type="password" id="regPassword" autocomplete="new-password" required />
|
||||||
@@ -48,14 +45,23 @@
|
|||||||
<div class="authTeamChoice">
|
<div class="authTeamChoice">
|
||||||
<label class="authTeamOption">
|
<label class="authTeamOption">
|
||||||
<input type="radio" name="regTeam" value="blue" required />
|
<input type="radio" name="regTeam" value="blue" required />
|
||||||
<span class="authTeamBadge authTeamBadge--blue">Résistance</span>
|
<span class="authTeamBadge authTeamBadge--blue">
|
||||||
|
<img src="./graphism/logo_resistance.svg" class="authTeamLogo" alt="" />
|
||||||
|
Résistance
|
||||||
|
</span>
|
||||||
|
<span class="authTeamCount authTeamCount--blue" id="regCountBlue">… joueurs</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="authTeamOption">
|
<label class="authTeamOption">
|
||||||
<input type="radio" name="regTeam" value="red" />
|
<input type="radio" name="regTeam" value="red" />
|
||||||
<span class="authTeamBadge authTeamBadge--red">Premier ordre</span>
|
<span class="authTeamBadge authTeamBadge--red">
|
||||||
|
Premier ordre
|
||||||
|
<img src="./graphism/logo_first_order.svg" class="authTeamLogo" alt="" />
|
||||||
|
</span>
|
||||||
|
<span class="authTeamCount authTeamCount--red" id="regCountRed">… joueurs</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="authNotice">⚠ En cas d'oubli du mot de passe, aucune récupération n'est possible.</p>
|
||||||
<div class="authError hidden" id="registerError"></div>
|
<div class="authError hidden" id="registerError"></div>
|
||||||
<button type="submit" class="authSubmit">Créer le compte</button>
|
<button type="submit" class="authSubmit">Créer le compte</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -72,22 +78,21 @@
|
|||||||
<button type="button" id="closeMenuBtn" class="closeMenuBtn" aria-label="Close menu">✕</button>
|
<button type="button" id="closeMenuBtn" class="closeMenuBtn" aria-label="Close menu">✕</button>
|
||||||
|
|
||||||
<div class="infoSection infoSection--title">
|
<div class="infoSection infoSection--title">
|
||||||
<div class="h1">Star Wars - Wild Space</div>
|
<img src="./graphism/Version_03.png" alt="Star Wars - Wild Space" class="titleBanderol" />
|
||||||
<div class="sub">Explorez les Régions Inconnues pour faire triompher votre camp !</div>
|
<div class="sub">Explorez les Régions Inconnues pour faire triompher votre camp !</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Team score display -->
|
<!-- Team score display -->
|
||||||
<div class="scoreBoard" id="scoreBoard">
|
<div class="scoreBoard" id="scoreBoard">
|
||||||
<img src="./graphism/logo_resistance.svg" alt="Resistance" class="team-logo" />
|
<div class="teamLogoWrap">
|
||||||
|
<img src="./graphism/logo_resistance.svg" alt="Resistance" class="team-logo" />
|
||||||
|
<span class="teamPlayerCount teamPlayerCount--blue" id="activeCountBlue">0 joueur</span>
|
||||||
|
</div>
|
||||||
<div class="scoreBoardContent">
|
<div class="scoreBoardContent">
|
||||||
<div class="scoreBoardRow">
|
<div class="scoreBoardRow">
|
||||||
<div class="scoreTeam scoreTeam--blue">
|
<div class="scoreTeam scoreTeam--blue">
|
||||||
<span class="scoreTeamName">Résistance</span>
|
<span class="scoreTeamName">Résistance</span>
|
||||||
<div class="scoreStats">
|
<div class="scoreStats">
|
||||||
<div class="scoreStat">
|
|
||||||
<span class="scoreStatVal scoreValue" id="scoreBlue">0</span>
|
|
||||||
<span class="scoreStatLabel">Tuiles</span>
|
|
||||||
</div>
|
|
||||||
<div class="scoreStat">
|
<div class="scoreStat">
|
||||||
<span class="scoreStatVal scoreVP" id="vpBlue">0</span>
|
<span class="scoreStatVal scoreVP" id="vpBlue">0</span>
|
||||||
<span class="scoreStatLabel">Points</span>
|
<span class="scoreStatLabel">Points</span>
|
||||||
@@ -102,10 +107,6 @@
|
|||||||
<span class="scoreStatVal scoreVP" id="vpRed">0</span>
|
<span class="scoreStatVal scoreVP" id="vpRed">0</span>
|
||||||
<span class="scoreStatLabel">Points</span>
|
<span class="scoreStatLabel">Points</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="scoreStat">
|
|
||||||
<span class="scoreStatVal scoreValue" id="scoreRed">0</span>
|
|
||||||
<span class="scoreStatLabel">Tuiles</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,7 +134,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<img src="./graphism/logo_first_order.svg" alt="First Order" class="team-logo" />
|
<div class="teamLogoWrap">
|
||||||
|
<img src="./graphism/logo_first_order.svg" alt="First Order" class="team-logo" />
|
||||||
|
<span class="teamPlayerCount teamPlayerCount--red" id="activeCountRed">0 joueur</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Info rows -->
|
<!-- Info rows -->
|
||||||
@@ -146,14 +150,19 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="infoRow" id="countdownWrap" aria-live="polite">
|
<div class="infoRow" id="countdownWrap" aria-live="polite">
|
||||||
<span class="infoKey countdownLabel">Prochain clic</span>
|
<span class="infoKey countdownLabel">Actions restantes</span>
|
||||||
<span class="infoVal countdownVal">
|
<span class="infoVal countdownVal">
|
||||||
<span id="countdown" class="countdown">0</span>
|
<span id="countdown" class="countdown">0</span>
|
||||||
<span class="countdownUnit">s</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="infoRow" aria-live="polite">
|
||||||
|
<span class="infoKey countdownLabel">Actions équipe restantes</span>
|
||||||
|
<span class="infoVal countdownVal">
|
||||||
|
<span id="teamActionsRemaining" class="countdown">—</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="infoRow">
|
<div class="infoRow">
|
||||||
<span class="infoKey muted">Délai entre deux clics</span>
|
<span class="infoKey muted">Actions par jour</span>
|
||||||
<code class="infoVal" id="cooldownConfig">—</code>
|
<code class="infoVal" id="cooldownConfig">—</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="infoRow">
|
<div class="infoRow">
|
||||||
@@ -178,6 +187,7 @@
|
|||||||
<details class="panel panelCollapsible" id="planetStatsDetails" open>
|
<details class="panel panelCollapsible" id="planetStatsDetails" open>
|
||||||
<summary class="panelTitle panelTitleSummary">🪐 Statistiques Planétaires</summary>
|
<summary class="panelTitle panelTitleSummary">🪐 Statistiques Planétaires</summary>
|
||||||
<pre id="details" class="details details--hidden">Les stats sont vides sauf à cliquer sur une tuile exploitable.</pre>
|
<pre id="details" class="details details--hidden">Les stats sont vides sauf à cliquer sur une tuile exploitable.</pre>
|
||||||
|
<div id="captorInfo" class="captorInfo hidden"></div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<!-- Resources overview (collapsible) -->
|
<!-- Resources overview (collapsible) -->
|
||||||
@@ -204,7 +214,7 @@
|
|||||||
<span class="elemBonusLabel">Premier Ordre</span>
|
<span class="elemBonusLabel">Premier Ordre</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="elemBonusEffective">
|
<span class="elemBonusEffective">
|
||||||
<span class="elemBonusDetailLabel">Recharge :</span>
|
<span class="elemBonusDetailLabel">Quota effectif/j :</span>
|
||||||
<span class="elemBonusDetailVal" id="effectiveCooldown">—</span>
|
<span class="elemBonusDetailVal" id="effectiveCooldown">—</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,14 +223,108 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<!-- Military power (collapsible) -->
|
||||||
|
<details class="panel panelCollapsible">
|
||||||
|
<summary class="panelTitle panelTitleSummary">⚔️ Puissance Militaire</summary>
|
||||||
|
<div class="milPowerTotals">
|
||||||
|
<span class="milPowerTeam milPowerTeam--blue">
|
||||||
|
<span class="milPowerLabel">Résistance</span>
|
||||||
|
<span class="milPowerVal" id="milPowerBlue">0.0</span>
|
||||||
|
</span>
|
||||||
|
<span class="milPowerSep">—</span>
|
||||||
|
<span class="milPowerTeam milPowerTeam--red">
|
||||||
|
<span class="milPowerVal" id="milPowerRed">0.0</span>
|
||||||
|
<span class="milPowerLabel">Premier Ordre</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="militaryTableBody" class="econTableWrap">
|
||||||
|
<p class="econEmpty">Chargement…</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Game rules -->
|
||||||
|
<details class="panel panelCollapsible">
|
||||||
|
<summary class="panelTitle panelTitleSummary">📜 Règles du jeu</summary>
|
||||||
|
<div class="rulesContent">
|
||||||
|
<p><strong>Points de Victoire</strong><br/>
|
||||||
|
À la fin de chaque période (rotation de la carte), l'équipe ayant le <em>score économique</em> le plus élevé remporte <strong>1 Point de Victoire</strong>. Ces points sont cumulatifs d'une manche à l'autre.</p>
|
||||||
|
|
||||||
|
<p><strong>Tableau des scores</strong><br/>
|
||||||
|
Sous le tableau des scores, plusieurs indicateurs sont affichés :</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Revenu/s</strong> — crédits générés chaque seconde grâce aux ressources naturelles des planètes découvertes.</li>
|
||||||
|
<li><strong>Score économique</strong> — total cumulé des crédits gagnés au fil du temps. Mis à jour toutes les 5 secondes.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><strong>💰 Ressources</strong><br/>
|
||||||
|
En explorant la galaxie, vous révélez des tuiles qui peuvent contenir des planètes. Chaque planète possède des ressources naturelles (minerais, bois, pétrole, etc.) dont la valeur contribue au revenu de votre équipe.</p>
|
||||||
|
|
||||||
|
<p><strong>⚡ Bonus d'exploration</strong><br/>
|
||||||
|
Les planètes produisent des éléments (matières premières, carburant, nourriture, science…) qui offrent un bonus cumulatif. Ce bonus augmente le pourcentage d'actions disponibles pour vous.</p>
|
||||||
|
|
||||||
|
<p><strong>⚔️ Puissance militaire</strong><br/>
|
||||||
|
La population des planètes conquises fournit de la puissance militaire. Cette puissance augmente le quota d'actions d'équipe pour capturer des planètes.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Credits -->
|
||||||
|
<details class="panel panelCollapsible">
|
||||||
|
<summary class="panelTitle panelTitleSummary">🏷️ Crédits</summary>
|
||||||
|
<div class="rulesContent">
|
||||||
|
<p><strong>Dépôt Git</strong><br/>
|
||||||
|
<a href="https://git.gbmm-holocron.eu/gauvain/star-wars-wild-space" target="_blank" rel="noopener">
|
||||||
|
git.gbmm-holocron.eu/gauvain/star-wars-wild-space
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>Le projet "Star Wars: Wild Space" a été pensé et pitché en 2021 par Mya TELLIS et Harlon ASTELLAN (moi), pseudonymes hérités du forum de jeu de rôle littéraire "Star Wars - Online Roleplay" anciennement "Star Wars Old Revolution". Le projet n'ayant pas abouti faute de développeur disponible, il a été abandonné, mais pas en esprit. Faute de temps, de motivation et d'argent pour coder/le faire coder, j'ai décidé de mettre à profit l'agentique IA pour faire naître ce jeu sans prétention.</p>
|
||||||
|
|
||||||
|
<p>Des morceaux ne sont pas issus de l'IA, notamment la section de génération des planètes, réalisée à la main pour m'entraîner d'abord sur Python puis Javascript. Mais les morceaux ont été "révisés" par l'IA pour l'intégrer à une API, aussi on peut considérer que 100% du contenu ici est généré par Claude Sonnet 4.6, à 1% près. Aussi si vous êtes le genre à chouiner "gnagnagna l'IA le voooool des artiiiiistes" etc, sachez que sans l'IA il n'y aurait rien eu, l'outil existe, autant vous y faire, et pour ce projet personne n'a été remplacé, et personne ne vous force à y jouer. Allez révolutionner ailleurs.</p>
|
||||||
|
|
||||||
|
<p>Il n'y a pas (encore ?) de gestion de compte par courriel avec mot de passe oublié etc parce que l'implémentation est plus complexe. Pour l'instant, c'est suffisamment tranquille pour s'en passer. Au besoin refaites un compte.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<!-- Player list popup (shown on click of joueur count) -->
|
||||||
|
<div id="playerListPopup" class="playerListPopup hidden" role="tooltip">
|
||||||
|
<div id="playerListContent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Galaxy (square, 1000×1000, fixed ratio) ────────────────────────── -->
|
<!-- ── Galaxy (square, 1000×1000, fixed ratio) ────────────────────────── -->
|
||||||
<main class="galaxyMain">
|
<main class="galaxyMain">
|
||||||
<!-- Mobile burger button -->
|
<!-- Mobile burger button -->
|
||||||
<button type="button" id="burgerBtn" class="burgerBtn" aria-label="Open menu">☰</button>
|
<button type="button" id="burgerBtn" class="burgerBtn" aria-label="Open menu">☰</button>
|
||||||
<canvas id="canvas" width="1000" height="1000"></canvas>
|
<canvas id="canvas" width="1000" height="1000"></canvas>
|
||||||
|
<div id="mapAnim" class="mapAnim" aria-hidden="true"></div>
|
||||||
<div id="hint" class="hint">Cliquez sur une tuile. Les stats seront vides à moins de cliquer.</div>
|
<div id="hint" class="hint">Cliquez sur une tuile. Les stats seront vides à moins de cliquer.</div>
|
||||||
|
|
||||||
|
<!-- Military attack confirmation modal -->
|
||||||
|
<div class="attackOverlay hidden" id="attackOverlay">
|
||||||
|
<div class="attackModal">
|
||||||
|
<div class="attackModal__icon">⚔️</div>
|
||||||
|
<div class="attackModal__title">Attaque Militaire</div>
|
||||||
|
<div class="attackModal__body" id="attackModalBody"></div>
|
||||||
|
<div class="attackModal__actions">
|
||||||
|
<button type="button" class="attackModal__btn attackModal__btn--cancel" id="attackModalNo">Annuler</button>
|
||||||
|
<button type="button" class="attackModal__btn attackModal__btn--confirm" id="attackModalYes">Attaquer !</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Planet capture confirmation modal -->
|
||||||
|
<div class="attackOverlay hidden" id="captureOverlay">
|
||||||
|
<div class="attackModal">
|
||||||
|
<div class="attackModal__icon">🏴</div>
|
||||||
|
<div class="attackModal__title">Capturer la Planète</div>
|
||||||
|
<div class="attackModal__body" id="captureModalBody"></div>
|
||||||
|
<div class="attackModal__actions">
|
||||||
|
<button type="button" class="attackModal__btn attackModal__btn--cancel" id="captureModalNo">Annuler</button>
|
||||||
|
<button type="button" class="attackModal__btn attackModal__btn--confirm" id="captureModalYes">Capturer !</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,20 +5,22 @@
|
|||||||
export async function apiFetchConfig(team) {
|
export async function apiFetchConfig(team) {
|
||||||
const token = localStorage.getItem("authToken");
|
const token = localStorage.getItem("authToken");
|
||||||
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
const res = await fetch(`/api/config?team=${encodeURIComponent(team)}`, { headers });
|
const res = await fetch(`/api/config?team=${encodeURIComponent(team)}`, {
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
if (!res.ok) throw new Error("config_fetch_failed");
|
if (!res.ok) throw new Error("config_fetch_failed");
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetchScores() {
|
|
||||||
const res = await fetch("/api/scores");
|
|
||||||
if (!res.ok) throw new Error("scores_fetch_failed");
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the raw Response so the caller can inspect status codes (410, etc.). */
|
/** Returns the raw Response so the caller can inspect status codes (410, etc.). */
|
||||||
export async function apiFetchGrid(seed) {
|
export async function apiFetchGrid(seed) {
|
||||||
return fetch(`/api/grid/${encodeURIComponent(seed)}`);
|
const token = localStorage.getItem("authToken");
|
||||||
|
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
return fetch(`/api/grid/${encodeURIComponent(seed)}`, {
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the raw Response so the caller can inspect status codes (409, 410, etc.). */
|
/** Returns the raw Response so the caller can inspect status codes (409, 410, etc.). */
|
||||||
@@ -42,11 +44,11 @@ export async function apiLogin(username, password) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiRegister(username, email, password, team) {
|
export async function apiRegister(username, password, team) {
|
||||||
return fetch("/api/auth/register", {
|
return fetch("/api/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username, email, password, team }),
|
body: JSON.stringify({ username, password, team }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,3 +101,59 @@ export async function apiFetchVictoryPoints() {
|
|||||||
if (!res.ok) throw new Error("vp_fetch_failed");
|
if (!res.ok) throw new Error("vp_fetch_failed");
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiFetchPlayerCounts() {
|
||||||
|
const res = await fetch("/api/auth/player-counts");
|
||||||
|
if (!res.ok) throw new Error("player_counts_fetch_failed");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetchActivePlayers() {
|
||||||
|
const res = await fetch("/api/active-players");
|
||||||
|
if (!res.ok) throw new Error("active_players_fetch_failed");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetchActivePlayerNames() {
|
||||||
|
const res = await fetch("/api/active-players/names");
|
||||||
|
if (!res.ok) throw new Error("active_player_names_fetch_failed");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetchMilitaryDeductions() {
|
||||||
|
const res = await fetch("/api/military-deductions");
|
||||||
|
if (!res.ok) throw new Error("military_deductions_fetch_failed");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiMilitaryAttack(seed, x, y) {
|
||||||
|
const token = localStorage.getItem("authToken");
|
||||||
|
const res = await fetch("/api/military/attack", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ seed, x, y }),
|
||||||
|
});
|
||||||
|
return res; // caller inspects status
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetchCellAttackCount(x, y) {
|
||||||
|
const res = await fetch(`/api/cell/attacks?x=${x}&y=${y}`);
|
||||||
|
if (!res.ok) throw new Error("cell_attacks_fetch_failed");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the raw Response so the caller can inspect status codes. */
|
||||||
|
export async function apiCaptureCell(seed, x, y) {
|
||||||
|
const token = localStorage.getItem("authToken");
|
||||||
|
return fetch("/api/cell/capture", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ seed, x, y }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiLogin, apiRegister, apiGetMe } from "./api.js";
|
import { apiLogin, apiRegister, apiGetMe, apiFetchPlayerCounts } from "./api.js";
|
||||||
import { setCurrentTeam, refreshFromServer } from "./game.js";
|
import { setCurrentTeam, refreshFromServer } from "./game.js";
|
||||||
|
|
||||||
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||||
@@ -12,9 +12,10 @@ const loginUsernameEl = document.getElementById("loginUsername");
|
|||||||
const loginPasswordEl = document.getElementById("loginPassword");
|
const loginPasswordEl = document.getElementById("loginPassword");
|
||||||
const loginErrorEl = document.getElementById("loginError");
|
const loginErrorEl = document.getElementById("loginError");
|
||||||
const regUsernameEl = document.getElementById("regUsername");
|
const regUsernameEl = document.getElementById("regUsername");
|
||||||
const regEmailEl = document.getElementById("regEmail");
|
|
||||||
const regPasswordEl = document.getElementById("regPassword");
|
const regPasswordEl = document.getElementById("regPassword");
|
||||||
const registerErrorEl = document.getElementById("registerError");
|
const registerErrorEl = document.getElementById("registerError");
|
||||||
|
const regCountBlueEl = document.getElementById("regCountBlue");
|
||||||
|
const regCountRedEl = document.getElementById("regCountRed");
|
||||||
const userDisplayEl = document.getElementById("userDisplay");
|
const userDisplayEl = document.getElementById("userDisplay");
|
||||||
const logoutBtn = document.getElementById("logoutBtn");
|
const logoutBtn = document.getElementById("logoutBtn");
|
||||||
|
|
||||||
@@ -38,8 +39,10 @@ export function applyUser(user, token) {
|
|||||||
authToken = token;
|
authToken = token;
|
||||||
localStorage.setItem("authToken", token);
|
localStorage.setItem("authToken", token);
|
||||||
setCurrentTeam(user.team);
|
setCurrentTeam(user.team);
|
||||||
userDisplayEl.textContent = `${user.username} [${user.team}]`;
|
const teamColor = user.team === "blue" ? "rgba(90,200,255,0.9)" : "rgba(220,75,85,0.9)";
|
||||||
|
userDisplayEl.innerHTML = `<span style="color:${teamColor}">${user.username}</span>`;
|
||||||
logoutBtn.classList.remove("hidden");
|
logoutBtn.classList.remove("hidden");
|
||||||
|
window.dispatchEvent(new CustomEvent("auth:changed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
@@ -49,6 +52,7 @@ function logout() {
|
|||||||
userDisplayEl.textContent = "—";
|
userDisplayEl.textContent = "—";
|
||||||
logoutBtn.classList.add("hidden");
|
logoutBtn.classList.add("hidden");
|
||||||
showAuthOverlay();
|
showAuthOverlay();
|
||||||
|
window.dispatchEvent(new CustomEvent("auth:changed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Session restore ───────────────────────────────────────────────────────────
|
// ── Session restore ───────────────────────────────────────────────────────────
|
||||||
@@ -68,6 +72,15 @@ export async function tryRestoreSession() {
|
|||||||
|
|
||||||
// ── Tab switching ─────────────────────────────────────────────────────────────
|
// ── Tab switching ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadRegisterCounts() {
|
||||||
|
try {
|
||||||
|
const counts = await apiFetchPlayerCounts();
|
||||||
|
const fmt = (n) => `${n} joueur${n > 1 ? "s" : ""}`;
|
||||||
|
regCountBlueEl.textContent = fmt(counts.blue ?? 0);
|
||||||
|
regCountRedEl.textContent = fmt(counts.red ?? 0);
|
||||||
|
} catch { /* silently ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
tabLogin.addEventListener("click", () => {
|
tabLogin.addEventListener("click", () => {
|
||||||
tabLogin.classList.add("authTab--active");
|
tabLogin.classList.add("authTab--active");
|
||||||
tabRegister.classList.remove("authTab--active");
|
tabRegister.classList.remove("authTab--active");
|
||||||
@@ -82,6 +95,7 @@ tabRegister.addEventListener("click", () => {
|
|||||||
registerForm.classList.remove("hidden");
|
registerForm.classList.remove("hidden");
|
||||||
loginForm.classList.add("hidden");
|
loginForm.classList.add("hidden");
|
||||||
clearError(registerErrorEl);
|
clearError(registerErrorEl);
|
||||||
|
loadRegisterCounts();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Login form ────────────────────────────────────────────────────────────────
|
// ── Login form ────────────────────────────────────────────────────────────────
|
||||||
@@ -114,12 +128,11 @@ registerForm.addEventListener("submit", async (e) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
clearError(registerErrorEl);
|
clearError(registerErrorEl);
|
||||||
const username = regUsernameEl.value.trim();
|
const username = regUsernameEl.value.trim();
|
||||||
const email = regEmailEl.value.trim();
|
|
||||||
const password = regPasswordEl.value;
|
const password = regPasswordEl.value;
|
||||||
const teamInput = registerForm.querySelector('input[name="regTeam"]:checked');
|
const teamInput = registerForm.querySelector('input[name="regTeam"]:checked');
|
||||||
if (!teamInput) { showError(registerErrorEl, "Please choose a team."); return; }
|
if (!teamInput) { showError(registerErrorEl, "Please choose a team."); return; }
|
||||||
try {
|
try {
|
||||||
const res = await apiRegister(username, email, password, teamInput.value);
|
const res = await apiRegister(username, password, teamInput.value);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msgs = {
|
const msgs = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { resources, elements } from "./planetEconomy.js";
|
import { resources, elements, population } from "./planetEconomy.js";
|
||||||
|
|
||||||
// ── Sort state (resources) ────────────────────────────────────────────────────
|
// ── Sort state (resources) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ export function computeTeamIncome(team, cells, resourceWorth) {
|
|||||||
let total = 0;
|
let total = 0;
|
||||||
|
|
||||||
for (const [, meta] of cells) {
|
for (const [, meta] of cells) {
|
||||||
if (meta.discoveredBy !== team) continue;
|
if (meta.controlledBy !== team) continue;
|
||||||
if (!meta.hasPlanet || !meta.planet) continue;
|
if (!meta.hasPlanet || !meta.planet) continue;
|
||||||
const { naturalResources } = meta.planet;
|
const { naturalResources } = meta.planet;
|
||||||
if (!naturalResources) continue;
|
if (!naturalResources) continue;
|
||||||
@@ -102,7 +102,7 @@ const ELEMENT_LABEL_TO_KEY = Object.fromEntries(
|
|||||||
export function computeTeamElementBonus(team, cells, elementWorth) {
|
export function computeTeamElementBonus(team, cells, elementWorth) {
|
||||||
let bonus = 0;
|
let bonus = 0;
|
||||||
for (const [, meta] of cells) {
|
for (const [, meta] of cells) {
|
||||||
if (meta.discoveredBy !== team) continue;
|
if (meta.controlledBy !== team) continue;
|
||||||
if (!meta.hasPlanet || !meta.planet) continue;
|
if (!meta.hasPlanet || !meta.planet) continue;
|
||||||
const { production } = meta.planet;
|
const { production } = meta.planet;
|
||||||
if (!production) continue;
|
if (!production) continue;
|
||||||
@@ -130,7 +130,7 @@ export function computeTeamElementBonusDetailed(team, cells, elementWorth) {
|
|||||||
const byElement = new Map();
|
const byElement = new Map();
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const [, meta] of cells) {
|
for (const [, meta] of cells) {
|
||||||
if (meta.discoveredBy !== team) continue;
|
if (meta.controlledBy !== team) continue;
|
||||||
if (!meta.hasPlanet || !meta.planet) continue;
|
if (!meta.hasPlanet || !meta.planet) continue;
|
||||||
const { production } = meta.planet;
|
const { production } = meta.planet;
|
||||||
if (!production) continue;
|
if (!production) continue;
|
||||||
@@ -265,3 +265,109 @@ export function renderElementBonusTable(elementWorth, teamByElement) {
|
|||||||
<tbody>${tableRows}</tbody>
|
<tbody>${tableRows}</tbody>
|
||||||
</table>`;
|
</table>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Military power ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Reverse map: French population label → config key (e.g. "Humains" → "humans") */
|
||||||
|
const POP_LABEL_TO_KEY = new Map();
|
||||||
|
for (const [, subgroup] of Object.entries(population)) {
|
||||||
|
for (const [key, label] of Object.entries(subgroup)) {
|
||||||
|
POP_LABEL_TO_KEY.set(label, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sort state for military table: 0=Type, 1=% Mil., 2=Soldats */
|
||||||
|
let _milSortCol = 2;
|
||||||
|
let _milSortDir = "desc";
|
||||||
|
|
||||||
|
export function setMilSort(col, dir) { _milSortCol = col; _milSortDir = dir; }
|
||||||
|
export function getMilSort() { return { col: _milSortCol, dir: _milSortDir }; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute military power breakdown for a team based on planet populations.
|
||||||
|
* military = population.billions * (militaryPower[typeKey] / 100)
|
||||||
|
* Result is in billions; display as millions (× 1000).
|
||||||
|
*
|
||||||
|
* @param {string} team
|
||||||
|
* @param {Map<string, { discoveredBy: string, hasPlanet: boolean, planet: object|null }>} cells
|
||||||
|
* @param {object} militaryPower - { humans: 0.01, near: 0.005, aliens: 0.001 }
|
||||||
|
* @returns {{ total: number, byType: Map<string, number> }}
|
||||||
|
* byType keys are French population label strings, values are military power in billions
|
||||||
|
*/
|
||||||
|
export function computeTeamMilitaryDetailed(team, cells, militaryPower) {
|
||||||
|
const byType = new Map();
|
||||||
|
let total = 0;
|
||||||
|
for (const [, meta] of cells) {
|
||||||
|
if (meta.controlledBy !== team) continue;
|
||||||
|
if (!meta.hasPlanet || !meta.planet) continue;
|
||||||
|
const pop = meta.planet.population;
|
||||||
|
if (!pop) continue;
|
||||||
|
const key = POP_LABEL_TO_KEY.get(pop.majority);
|
||||||
|
if (!key) continue;
|
||||||
|
const pct = militaryPower?.[key] ?? 0;
|
||||||
|
if (pct === 0) continue;
|
||||||
|
const mil = pop.billions * pct / 100;
|
||||||
|
byType.set(pop.majority, (byType.get(pop.majority) ?? 0) + mil);
|
||||||
|
total += mil;
|
||||||
|
}
|
||||||
|
return { total, byType };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the military power breakdown table.
|
||||||
|
*
|
||||||
|
* @param {object} militaryPower - { humans: 0.01, near: 0.005, aliens: 0.001 }
|
||||||
|
* @param {Map<string, number>} teamByType - military (billions) per population label
|
||||||
|
* @returns {string} HTML string
|
||||||
|
*/
|
||||||
|
export function renderMilitaryTable(militaryPower, teamByType) {
|
||||||
|
const rows = [];
|
||||||
|
|
||||||
|
for (const [groupKey, subgroup] of Object.entries(population)) {
|
||||||
|
if (groupKey === "creatures") continue;
|
||||||
|
for (const [key, label] of Object.entries(subgroup)) {
|
||||||
|
const pct = militaryPower?.[key] ?? 0;
|
||||||
|
const mil = teamByType?.get(label) ?? 0;
|
||||||
|
const milStr = mil > 0 ? `${(mil * 1000).toFixed(1)}` : "—";
|
||||||
|
rows.push({ label, pct, mil, milStr });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mult = _milSortDir === "asc" ? 1 : -1;
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
if (_milSortCol === 0) return mult * a.label.localeCompare(b.label, "fr");
|
||||||
|
if (_milSortCol === 1) return mult * (a.pct - b.pct);
|
||||||
|
if (_milSortCol === 2) return mult * (a.mil - b.mil);
|
||||||
|
return b.mil - a.mil || b.pct - a.pct;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rows.every(r => r.mil === 0)) {
|
||||||
|
return `<p class="econEmpty">Aucune tuile conquise avec population militarisable.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableRows = rows
|
||||||
|
.map(({ label, pct, mil, milStr }) => {
|
||||||
|
const milClass = mil > 0 ? " econ-income--positive" : "";
|
||||||
|
return `<tr>
|
||||||
|
<td class="econ-label">${label}</td>
|
||||||
|
<td class="econ-worth">${pct}%</td>
|
||||||
|
<td class="econ-income${milClass}">${milStr}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const thLabels = ["Type", "% Mil.", "Soldats"];
|
||||||
|
const headers = thLabels
|
||||||
|
.map((lbl, i) => {
|
||||||
|
const isActive = i === _milSortCol;
|
||||||
|
const indicator = isActive ? (_milSortDir === "asc" ? " ▲" : " ▼") : " ⇅";
|
||||||
|
const activeClass = isActive ? " econTh--active" : "";
|
||||||
|
return `<th class="econTh${activeClass}" data-mil-sort-col="${i}">${lbl}<span class="econSortIcon">${indicator}</span></th>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `<table class="econTable">
|
||||||
|
<thead><tr>${headers}</tr></thead>
|
||||||
|
<tbody>${tableRows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
1012
public/src/game.js
@@ -4,12 +4,17 @@ import {
|
|||||||
updateResetCountdown,
|
updateResetCountdown,
|
||||||
fetchConfig,
|
fetchConfig,
|
||||||
fetchGridForSeed,
|
fetchGridForSeed,
|
||||||
fetchAndApplyScores,
|
fetchAndApplyActivePlayers,
|
||||||
updateEconomyDisplay,
|
updateEconomyDisplay,
|
||||||
loadEconScores,
|
loadEconScores,
|
||||||
loadVictoryPoints,
|
loadVictoryPoints,
|
||||||
loadDbInfo,
|
loadDbInfo,
|
||||||
loadElementBonus,
|
loadElementBonus,
|
||||||
|
loadMilitaryDeductions,
|
||||||
|
applyRealtimeSnapshot,
|
||||||
|
applyCellUpdate,
|
||||||
|
applyTeamQuotaUpdate,
|
||||||
|
applyMilitaryDeductionsUpdate,
|
||||||
refreshFromServer,
|
refreshFromServer,
|
||||||
refreshGridDisplay,
|
refreshGridDisplay,
|
||||||
loadPlayfieldMask,
|
loadPlayfieldMask,
|
||||||
@@ -22,6 +27,11 @@ import {
|
|||||||
hideAuthOverlay,
|
hideAuthOverlay,
|
||||||
} from "./auth.js";
|
} from "./auth.js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
startRealtime,
|
||||||
|
restartRealtime,
|
||||||
|
} from "./realtime.js";
|
||||||
|
|
||||||
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const hint = document.getElementById("hint");
|
const hint = document.getElementById("hint");
|
||||||
@@ -35,8 +45,26 @@ const infoColumn = document.getElementById("infoColumn");
|
|||||||
let configPollTimer = 0;
|
let configPollTimer = 0;
|
||||||
let scorePollTimer = 0;
|
let scorePollTimer = 0;
|
||||||
let resetTimer = null;
|
let resetTimer = null;
|
||||||
|
let victoryPollTimer = 0;
|
||||||
|
let gridPollTimer = 0;
|
||||||
|
let fallbackPollingEnabled = false;
|
||||||
|
let syncInFlight = false;
|
||||||
|
|
||||||
|
function clearFallbackTimers() {
|
||||||
|
clearTimeout(configPollTimer);
|
||||||
|
clearTimeout(scorePollTimer);
|
||||||
|
if (victoryPollTimer) {
|
||||||
|
clearInterval(victoryPollTimer);
|
||||||
|
victoryPollTimer = 0;
|
||||||
|
}
|
||||||
|
if (gridPollTimer) {
|
||||||
|
clearInterval(gridPollTimer);
|
||||||
|
gridPollTimer = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleConfigPoll() {
|
function scheduleConfigPoll() {
|
||||||
|
if (!fallbackPollingEnabled) return;
|
||||||
clearTimeout(configPollTimer);
|
clearTimeout(configPollTimer);
|
||||||
const ms = Math.max(5_000, GAME_CONFIG.configReloadIntervalSeconds * 1_000);
|
const ms = Math.max(5_000, GAME_CONFIG.configReloadIntervalSeconds * 1_000);
|
||||||
configPollTimer = window.setTimeout(async () => {
|
configPollTimer = window.setTimeout(async () => {
|
||||||
@@ -51,15 +79,85 @@ function scheduleConfigPoll() {
|
|||||||
const ECON_TICK_SECONDS = 5;
|
const ECON_TICK_SECONDS = 5;
|
||||||
|
|
||||||
function scheduleScorePoll() {
|
function scheduleScorePoll() {
|
||||||
|
if (!fallbackPollingEnabled) return;
|
||||||
clearTimeout(scorePollTimer);
|
clearTimeout(scorePollTimer);
|
||||||
scorePollTimer = window.setTimeout(async () => {
|
scorePollTimer = window.setTimeout(async () => {
|
||||||
await fetchAndApplyScores();
|
await fetchAndApplyActivePlayers();
|
||||||
await loadEconScores();
|
await loadEconScores();
|
||||||
await loadElementBonus();
|
await loadElementBonus();
|
||||||
|
await loadMilitaryDeductions();
|
||||||
scheduleScorePoll();
|
scheduleScorePoll();
|
||||||
}, ECON_TICK_SECONDS * 1_000);
|
}, ECON_TICK_SECONDS * 1_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startFallbackPolling() {
|
||||||
|
if (fallbackPollingEnabled) return;
|
||||||
|
fallbackPollingEnabled = true;
|
||||||
|
scheduleConfigPoll();
|
||||||
|
scheduleScorePoll();
|
||||||
|
victoryPollTimer = setInterval(loadVictoryPoints, 30_000);
|
||||||
|
gridPollTimer = setInterval(refreshGridDisplay, 1_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopFallbackPolling() {
|
||||||
|
fallbackPollingEnabled = false;
|
||||||
|
clearFallbackTimers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncFromServer() {
|
||||||
|
if (syncInFlight) return;
|
||||||
|
syncInFlight = true;
|
||||||
|
try {
|
||||||
|
await refreshFromServer();
|
||||||
|
await loadVictoryPoints();
|
||||||
|
} finally {
|
||||||
|
syncInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRealtimeMessage(message) {
|
||||||
|
if (!message || typeof message !== "object") return;
|
||||||
|
const worldSeed = typeof message.worldSeed === "string" ? message.worldSeed : null;
|
||||||
|
|
||||||
|
if (worldSeed && seedStr && worldSeed !== seedStr) {
|
||||||
|
syncFromServer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case "snapshot":
|
||||||
|
applyRealtimeSnapshot(message);
|
||||||
|
break;
|
||||||
|
case "cell-updated":
|
||||||
|
if (message.cell) applyCellUpdate(message.cell);
|
||||||
|
break;
|
||||||
|
case "team-quota-updated":
|
||||||
|
applyTeamQuotaUpdate(message.team, message.actionsRemaining);
|
||||||
|
break;
|
||||||
|
case "military-deductions-updated":
|
||||||
|
applyMilitaryDeductionsUpdate(message.deductions);
|
||||||
|
break;
|
||||||
|
case "config-updated":
|
||||||
|
case "seed-changed":
|
||||||
|
syncFromServer();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRealtimeFlow() {
|
||||||
|
startRealtime({
|
||||||
|
onOpen: () => {
|
||||||
|
stopFallbackPolling();
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
startFallbackPolling();
|
||||||
|
},
|
||||||
|
onMessage: handleRealtimeMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Burger / mobile menu ──────────────────────────────────────────────────────
|
// ── Burger / mobile menu ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function openMenu() {
|
function openMenu() {
|
||||||
@@ -99,12 +197,19 @@ async function boot() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await fetchConfig();
|
await fetchConfig();
|
||||||
await fetchGridForSeed(seedStr);
|
try {
|
||||||
await fetchAndApplyScores();
|
await fetchGridForSeed(seedStr);
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.message !== "grid_stale") throw e;
|
||||||
|
await fetchConfig();
|
||||||
|
await fetchGridForSeed(seedStr);
|
||||||
|
}
|
||||||
|
await fetchAndApplyActivePlayers();
|
||||||
await loadEconScores();
|
await loadEconScores();
|
||||||
await loadVictoryPoints();
|
await loadVictoryPoints();
|
||||||
await loadDbInfo();
|
await loadDbInfo();
|
||||||
await loadElementBonus();
|
await loadElementBonus();
|
||||||
|
await loadMilitaryDeductions();
|
||||||
updateEconomyDisplay();
|
updateEconomyDisplay();
|
||||||
} catch {
|
} catch {
|
||||||
hint.textContent = "API unavailable — start the Node server (docker-compose up --build).";
|
hint.textContent = "API unavailable — start the Node server (docker-compose up --build).";
|
||||||
@@ -117,14 +222,11 @@ async function boot() {
|
|||||||
resetTimer = setInterval(updateResetCountdown, 1_000);
|
resetTimer = setInterval(updateResetCountdown, 1_000);
|
||||||
updateResetCountdown();
|
updateResetCountdown();
|
||||||
|
|
||||||
scheduleConfigPoll();
|
startRealtimeFlow();
|
||||||
scheduleScorePoll();
|
|
||||||
|
|
||||||
// Refresh VP every 30 s so new awards are reflected promptly
|
window.addEventListener("auth:changed", () => {
|
||||||
setInterval(loadVictoryPoints, 30_000);
|
restartRealtime();
|
||||||
|
});
|
||||||
// Refresh grid every second so all clients see new tiles promptly
|
|
||||||
setInterval(refreshGridDisplay, 1_000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
106
public/src/realtime.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
let socket = null;
|
||||||
|
let reconnectTimer = 0;
|
||||||
|
let reconnectAttempt = 0;
|
||||||
|
let stopped = false;
|
||||||
|
let handlers = null;
|
||||||
|
|
||||||
|
function clearReconnectTimer() {
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSocketUrl() {
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
return `${protocol}//${window.location.host}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendAuthHandshake() {
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
||||||
|
const token = localStorage.getItem("authToken");
|
||||||
|
if (!token) return;
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: "auth",
|
||||||
|
token,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (stopped) return;
|
||||||
|
clearReconnectTimer();
|
||||||
|
const delay = Math.min(10_000, 1_000 * 2 ** reconnectAttempt);
|
||||||
|
reconnectAttempt += 1;
|
||||||
|
reconnectTimer = window.setTimeout(() => {
|
||||||
|
connect();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (stopped) return;
|
||||||
|
clearReconnectTimer();
|
||||||
|
|
||||||
|
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket = new WebSocket(buildSocketUrl());
|
||||||
|
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
reconnectAttempt = 0;
|
||||||
|
sendAuthHandshake();
|
||||||
|
handlers?.onOpen?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
handlers?.onMessage?.(data);
|
||||||
|
} catch {
|
||||||
|
// ignore invalid payloads
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
handlers?.onClose?.();
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("error", () => {
|
||||||
|
// Let close handler trigger reconnect.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startRealtime(nextHandlers = {}) {
|
||||||
|
handlers = nextHandlers;
|
||||||
|
stopped = false;
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restartRealtime() {
|
||||||
|
if (stopped) return;
|
||||||
|
if (socket) {
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopRealtime() {
|
||||||
|
stopped = true;
|
||||||
|
clearReconnectTimer();
|
||||||
|
if (!socket) return;
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRealtimeConnected() {
|
||||||
|
return Boolean(socket && socket.readyState === WebSocket.OPEN);
|
||||||
|
}
|
||||||
306
public/style.css
@@ -134,16 +134,23 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.authTeamBadge {
|
.authTeamBadge {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||||
text-align: center;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
transition: border-color 0.15s, background 0.15s;
|
transition: border-color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.authTeamLogo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.authTeamBadge--blue {
|
.authTeamBadge--blue {
|
||||||
color: rgba(90, 200, 255, 0.9);
|
color: rgba(90, 200, 255, 0.9);
|
||||||
}
|
}
|
||||||
@@ -175,6 +182,14 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.authNotice {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 193, 113, 0.85);
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
.authSubmit {
|
.authSubmit {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -192,6 +207,18 @@ body {
|
|||||||
background: rgba(113, 199, 255, 0.28);
|
background: rgba(113, 199, 255, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.authTeamCount {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 4px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authTeamCount--blue { color: rgba(90, 200, 255, 0.9); }
|
||||||
|
.authTeamCount--red { color: rgba(220, 75, 85, 0.9); }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* ── Score board ──────────────────────────────────────────────────────────── */
|
/* ── Score board ──────────────────────────────────────────────────────────── */
|
||||||
@@ -239,6 +266,26 @@ body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teamLogoWrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teamPlayerCount {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
opacity: 0.75;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teamPlayerCount--blue { color: rgba(90, 200, 255, 0.9); }
|
||||||
|
.teamPlayerCount--red { color: rgba(220, 75, 85, 0.9); }
|
||||||
|
|
||||||
.scoreTeam {
|
.scoreTeam {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -305,8 +352,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scoreTeam--blue .scoreVP {
|
.scoreTeam--blue .scoreVP {
|
||||||
color: rgba(130, 230, 130, 1);
|
color: rgba(90, 200, 255, 1);
|
||||||
text-shadow: 0 0 16px rgba(90, 200, 130, 0.35);
|
text-shadow: 0 0 16px rgba(90, 200, 255, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreTeam--red .scoreValue {
|
.scoreTeam--red .scoreValue {
|
||||||
@@ -315,8 +362,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scoreTeam--red .scoreVP {
|
.scoreTeam--red .scoreVP {
|
||||||
color: rgba(230, 150, 80, 1);
|
color: rgba(220, 75, 85, 1);
|
||||||
text-shadow: 0 0 16px rgba(220, 130, 60, 0.35);
|
text-shadow: 0 0 16px rgba(220, 75, 85, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scoreSep {
|
.scoreSep {
|
||||||
@@ -333,7 +380,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +434,14 @@ body {
|
|||||||
letter-spacing: 0.2px;
|
letter-spacing: 0.2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.titleBanderol {
|
||||||
|
display: block;
|
||||||
|
max-height: 100px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.infoSection--title .sub {
|
.infoSection--title .sub {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
@@ -894,6 +949,167 @@ button:hover {
|
|||||||
color: rgba(255, 220, 100, 0.9);
|
color: rgba(255, 220, 100, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Military power section ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.milPowerTotals {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
padding: 8px 12px 4px;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milPowerTeam {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milPowerTeam--blue { justify-content: flex-end; }
|
||||||
|
.milPowerTeam--red { justify-content: flex-start; }
|
||||||
|
|
||||||
|
.milPowerTeam--blue .milPowerLabel { color: rgba(90, 200, 255, 0.75); font-size: 10px; }
|
||||||
|
.milPowerTeam--blue .milPowerVal { color: rgba(90, 200, 255, 1); font-weight: 800; font-size: 13px; }
|
||||||
|
.milPowerTeam--red .milPowerLabel { color: rgba(220, 75, 85, 0.75); font-size: 10px; }
|
||||||
|
.milPowerTeam--red .milPowerVal { color: rgba(220, 75, 85, 1); font-weight: 800; font-size: 13px; }
|
||||||
|
|
||||||
|
.milPowerUnit {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milPowerSep {
|
||||||
|
opacity: 0.3;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Rules / Credits content ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.rulesContent {
|
||||||
|
padding: 8px 12px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulesContent p {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulesContent ul {
|
||||||
|
margin: 4px 0 10px 16px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulesContent li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulesContent a {
|
||||||
|
color: rgba(90, 200, 255, 0.9);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulesContent a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Military attack modal ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.attackOverlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: rgba(5, 8, 18, 0.72);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attackOverlay.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attackModal {
|
||||||
|
width: 340px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
background: rgba(15, 22, 42, 0.97);
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7);
|
||||||
|
padding: 28px 28px 24px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attackModal__icon {
|
||||||
|
font-size: 36px;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attackModal__title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #e9eef6;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attackModal__body {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(233, 238, 246, 0.8);
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-line;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 12px 14px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attackModal__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attackModal__btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 9px 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attackModal__btn--cancel {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(233, 238, 246, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attackModal__btn--cancel:hover {
|
||||||
|
filter: brightness(1.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attackModal__btn--confirm {
|
||||||
|
background: rgba(220, 75, 85, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attackModal__btn--confirm:hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Galaxy: square, fills viewport height, 1:1 ratio ────────────────────── */
|
/* ── Galaxy: square, fills viewport height, 1:1 ratio ────────────────────── */
|
||||||
|
|
||||||
.galaxyMain {
|
.galaxyMain {
|
||||||
@@ -914,6 +1130,7 @@ canvas {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
|
touch-action: none; /* prevent browser pinch-zoom on canvas */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Burger button (hidden on desktop) ────────────────────────────────────── */
|
/* ── Burger button (hidden on desktop) ────────────────────────────────────── */
|
||||||
@@ -1015,3 +1232,78 @@ canvas {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Captor info banner ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.captorInfo {
|
||||||
|
padding: 6px 14px 10px;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captorInfo.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captorInfo--blue {
|
||||||
|
color: rgba(90, 200, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.captorInfo--red {
|
||||||
|
color: rgba(220, 75, 85, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Player list popup ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.playerListPopup {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 30;
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 200px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||||
|
background: rgba(12, 18, 38, 0.97);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerListPopup.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerListName--blue {
|
||||||
|
color: rgba(90, 200, 255, 0.9);
|
||||||
|
padding: 2px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerListName--red {
|
||||||
|
color: rgba(220, 75, 85, 0.9);
|
||||||
|
padding: 2px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playerListEmpty {
|
||||||
|
color: rgba(233, 238, 246, 0.4);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Map action animation ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mapAnimFloat {
|
||||||
|
position: absolute;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 20;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import authRouter from "./routes/auth.js";
|
import authRouter from "./routes/auth.js";
|
||||||
@@ -8,8 +9,20 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const publicDir = path.join(__dirname, "..", "public");
|
const publicDir = path.join(__dirname, "..", "public");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.use(cors({ origin: process.env.CORS_ORIGIN ?? "*" }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static(publicDir));
|
app.use(express.static(publicDir, {
|
||||||
|
etag: false,
|
||||||
|
lastModified: false,
|
||||||
|
setHeaders: (res) => {
|
||||||
|
res.set("Cache-Control", "no-store");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use("/api", (_req, res, next) => {
|
||||||
|
res.set("Cache-Control", "no-store");
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
app.use("/api/auth", authRouter);
|
app.use("/api/auth", authRouter);
|
||||||
app.use("/api", gameRouter);
|
app.use("/api", gameRouter);
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const CONFIG_FILE_PATH =
|
const CONFIG_FILE_PATH =
|
||||||
process.env.CONFIG_FILE_PATH ?? path.join(__dirname, "..", "config", "game.settings.json");
|
process.env.CONFIG_FILE_PATH ?? path.join(__dirname, "..", "config", "game.settings.json");
|
||||||
|
|
||||||
/** @type {{ clickCooldownSeconds: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, resourceWorth: object }} */
|
/** @type {{ dailyActionQuota: number, teamActionQuota: number, databaseWipeoutIntervalSeconds: number, configReloadIntervalSeconds: number, resourceWorth: object, militaryPower: object }} */
|
||||||
let cached = {
|
let cached = {
|
||||||
clickCooldownSeconds: 5,
|
dailyActionQuota: 100,
|
||||||
|
teamActionQuota: 100,
|
||||||
databaseWipeoutIntervalSeconds: 21600,
|
databaseWipeoutIntervalSeconds: 21600,
|
||||||
debugModeForTeams: true,
|
|
||||||
configReloadIntervalSeconds: 30,
|
configReloadIntervalSeconds: 30,
|
||||||
elementWorth: {},
|
elementWorth: {},
|
||||||
resourceWorth: { common: {}, rare: {} },
|
resourceWorth: { common: {}, rare: {} },
|
||||||
|
militaryPower: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
let lastMtimeMs = 0;
|
let lastMtimeMs = 0;
|
||||||
@@ -37,14 +38,15 @@ export function loadConfigFile() {
|
|||||||
}
|
}
|
||||||
const raw = fs.readFileSync(CONFIG_FILE_PATH, "utf8");
|
const raw = fs.readFileSync(CONFIG_FILE_PATH, "utf8");
|
||||||
const j = JSON.parse(raw);
|
const j = JSON.parse(raw);
|
||||||
if (typeof j.clickCooldownSeconds === "number" && j.clickCooldownSeconds >= 0) {
|
if (typeof j.dailyActionQuota === "number" && j.dailyActionQuota >= 1) {
|
||||||
cached.clickCooldownSeconds = j.clickCooldownSeconds;
|
cached.dailyActionQuota = Math.floor(j.dailyActionQuota);
|
||||||
|
}
|
||||||
|
if (typeof j.teamActionQuota === "number" && j.teamActionQuota >= 1) {
|
||||||
|
cached.teamActionQuota = Math.floor(j.teamActionQuota);
|
||||||
}
|
}
|
||||||
if (typeof j.databaseWipeoutIntervalSeconds === "number" && j.databaseWipeoutIntervalSeconds >= 60) {
|
if (typeof j.databaseWipeoutIntervalSeconds === "number" && j.databaseWipeoutIntervalSeconds >= 60) {
|
||||||
cached.databaseWipeoutIntervalSeconds = j.databaseWipeoutIntervalSeconds;
|
cached.databaseWipeoutIntervalSeconds = j.databaseWipeoutIntervalSeconds;
|
||||||
}
|
}
|
||||||
const dbg = parseBool(j.debugModeForTeams);
|
|
||||||
if (dbg !== null) cached.debugModeForTeams = dbg;
|
|
||||||
if (typeof j.configReloadIntervalSeconds === "number" && j.configReloadIntervalSeconds >= 5) {
|
if (typeof j.configReloadIntervalSeconds === "number" && j.configReloadIntervalSeconds >= 5) {
|
||||||
cached.configReloadIntervalSeconds = j.configReloadIntervalSeconds;
|
cached.configReloadIntervalSeconds = j.configReloadIntervalSeconds;
|
||||||
}
|
}
|
||||||
@@ -53,8 +55,9 @@ export function loadConfigFile() {
|
|||||||
}
|
}
|
||||||
if (j.resourceWorth && typeof j.resourceWorth === "object") {
|
if (j.resourceWorth && typeof j.resourceWorth === "object") {
|
||||||
cached.resourceWorth = j.resourceWorth;
|
cached.resourceWorth = j.resourceWorth;
|
||||||
}
|
} if (j.militaryPower && typeof j.militaryPower === 'object') {
|
||||||
lastMtimeMs = st.mtimeMs;
|
cached.militaryPower = j.militaryPower;
|
||||||
|
} lastMtimeMs = st.mtimeMs;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code === "ENOENT") {
|
if (e.code === "ENOENT") {
|
||||||
lastMtimeMs = 0;
|
lastMtimeMs = 0;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { pool } from "./pools.js";
|
import { pool } from "./pools.js";
|
||||||
import { loadConfigFile, getConfig } from "../configLoader.js";
|
import { loadConfigFile, getConfig } from "../configLoader.js";
|
||||||
import { computeWorldSeedState } from "../worldSeed.js";
|
import { computeWorldSeedState } from "../worldSeed.js";
|
||||||
|
import { nextNoonUtc, resetAllUserActions } from "./usersDb.js";
|
||||||
|
|
||||||
let lastSeedSlot = null;
|
let lastSeedSlot = null;
|
||||||
|
|
||||||
@@ -54,6 +55,26 @@ export async function initGameSchema() {
|
|||||||
PRIMARY KEY (world_seed, team)
|
PRIMARY KEY (world_seed, team)
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS team_military_deductions (
|
||||||
|
world_seed TEXT NOT NULL,
|
||||||
|
team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
|
||||||
|
deducted DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (world_seed, team)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS cell_attack_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
world_seed TEXT NOT NULL,
|
||||||
|
x SMALLINT NOT NULL,
|
||||||
|
y SMALLINT NOT NULL,
|
||||||
|
attacking_team TEXT NOT NULL CHECK (attacking_team IN ('blue', 'red')),
|
||||||
|
attacked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cell_attack_log_seed_xy
|
||||||
|
ON cell_attack_log (world_seed, x, y);
|
||||||
|
`);
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS db_metadata (
|
CREATE TABLE IF NOT EXISTS db_metadata (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -86,6 +107,43 @@ export async function initGameSchema() {
|
|||||||
END $$;
|
END $$;
|
||||||
ALTER TABLE grid_cells ALTER COLUMN discovered_by SET NOT NULL;
|
ALTER TABLE grid_cells ALTER COLUMN discovered_by SET NOT NULL;
|
||||||
`);
|
`);
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS team_action_quota (
|
||||||
|
team TEXT PRIMARY KEY CHECK (team IN ('blue', 'red')),
|
||||||
|
actions_remaining INTEGER NOT NULL DEFAULT 0,
|
||||||
|
quota_reset_at TIMESTAMPTZ NOT NULL DEFAULT '1970-01-01 00:00:00+00'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
// ── Per-team cell visibility (who has revealed what) ──────────────────────
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS team_cell_visibility (
|
||||||
|
world_seed TEXT NOT NULL,
|
||||||
|
team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
|
||||||
|
x SMALLINT NOT NULL,
|
||||||
|
y SMALLINT NOT NULL,
|
||||||
|
PRIMARY KEY (world_seed, team, x, y)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tcv_seed_team ON team_cell_visibility (world_seed, team);
|
||||||
|
`);
|
||||||
|
// ── Make discovered_by nullable (NULL = neutral, uncaptured) ─────────────
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE grid_cells ALTER COLUMN discovered_by DROP NOT NULL;
|
||||||
|
ALTER TABLE grid_cells ALTER COLUMN discovered_by DROP DEFAULT;
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'grid_cells_discovered_by_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE grid_cells DROP CONSTRAINT grid_cells_discovered_by_check;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
ALTER TABLE grid_cells ADD CONSTRAINT grid_cells_discovered_by_check
|
||||||
|
CHECK (discovered_by IS NULL OR discovered_by IN ('blue', 'red'));
|
||||||
|
`);
|
||||||
|
// ── Store the username of whoever last captured a tile ────────────────────
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE grid_cells ADD COLUMN IF NOT EXISTS captured_by TEXT;
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── World-seed epoch ──────────────────────────────────────────────────────────
|
// ── World-seed epoch ──────────────────────────────────────────────────────────
|
||||||
@@ -124,6 +182,21 @@ export async function ensureSeedEpoch() {
|
|||||||
await pool.query("DELETE FROM user_cooldowns WHERE world_seed != $1", [worldSeed]);
|
await pool.query("DELETE FROM user_cooldowns WHERE world_seed != $1", [worldSeed]);
|
||||||
await pool.query("DELETE FROM team_econ_scores WHERE world_seed != $1", [worldSeed]);
|
await pool.query("DELETE FROM team_econ_scores WHERE world_seed != $1", [worldSeed]);
|
||||||
await pool.query("DELETE FROM team_element_bonus WHERE world_seed != $1", [worldSeed]);
|
await pool.query("DELETE FROM team_element_bonus WHERE world_seed != $1", [worldSeed]);
|
||||||
|
await pool.query("DELETE FROM team_military_deductions WHERE world_seed != $1", [worldSeed]);
|
||||||
|
await pool.query("DELETE FROM cell_attack_log WHERE world_seed != $1", [worldSeed]);
|
||||||
|
await pool.query("DELETE FROM team_cell_visibility WHERE world_seed != $1", [worldSeed]);
|
||||||
|
// Reset both team and user quotas to server defaults (no bonuses) for the new period
|
||||||
|
const cfg = getConfig();
|
||||||
|
const nextNoon = nextNoonUtc().toISOString();
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO team_action_quota (team, actions_remaining, quota_reset_at)
|
||||||
|
VALUES ('blue', $1, $2), ('red', $1, $2)
|
||||||
|
ON CONFLICT (team) DO UPDATE
|
||||||
|
SET actions_remaining = $1,
|
||||||
|
quota_reset_at = $2`,
|
||||||
|
[cfg.teamActionQuota, nextNoon]
|
||||||
|
);
|
||||||
|
await resetAllUserActions(cfg.dailyActionQuota, nextNoon);
|
||||||
console.log(`[world] Slot ${lastSeedSlot} → ${seedSlot}; grid wiped, old cooldowns cleared.`);
|
console.log(`[world] Slot ${lastSeedSlot} → ${seedSlot}; grid wiped, old cooldowns cleared.`);
|
||||||
lastSeedSlot = seedSlot;
|
lastSeedSlot = seedSlot;
|
||||||
}
|
}
|
||||||
@@ -134,27 +207,62 @@ export async function ensureSeedEpoch() {
|
|||||||
|
|
||||||
export async function getGridCells(worldSeed) {
|
export async function getGridCells(worldSeed) {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT x, y, exploitable, has_planet, planet_json, discovered_by
|
`SELECT x, y, exploitable, has_planet, planet_json, discovered_by, captured_by
|
||||||
FROM grid_cells WHERE world_seed = $1`,
|
FROM grid_cells WHERE world_seed = $1`,
|
||||||
[worldSeed]
|
[worldSeed]
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function insertCell(seed, x, y, exploitable, hasPlanet, planetJson, team) {
|
export async function insertCell(seed, x, y, exploitable, hasPlanet, planetJson) {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO grid_cells (world_seed, x, y, exploitable, has_planet, planet_json, discovered_by)
|
`INSERT INTO grid_cells (world_seed, x, y, exploitable, has_planet, planet_json, discovered_by)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, NULL)
|
||||||
ON CONFLICT (world_seed, x, y) DO NOTHING
|
ON CONFLICT (world_seed, x, y) DO NOTHING
|
||||||
RETURNING x, y, exploitable, has_planet, planet_json, discovered_by`,
|
RETURNING x, y, exploitable, has_planet, planet_json, discovered_by`,
|
||||||
[seed, x, y, exploitable, hasPlanet, planetJson, team]
|
[seed, x, y, exploitable, hasPlanet, planetJson]
|
||||||
);
|
);
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Team cell visibility ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns true if team has already revealed this cell. */
|
||||||
|
export async function checkTeamVisibility(worldSeed, team, x, y) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT 1 FROM team_cell_visibility WHERE world_seed = $1 AND team = $2 AND x = $3 AND y = $4`,
|
||||||
|
[worldSeed, team, x, y]
|
||||||
|
);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inserts a visibility record. Returns true if newly inserted (first reveal). */
|
||||||
|
export async function insertTeamVisibility(worldSeed, team, x, y) {
|
||||||
|
const { rowCount } = await pool.query(
|
||||||
|
`INSERT INTO team_cell_visibility (world_seed, team, x, y)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (world_seed, team, x, y) DO NOTHING`,
|
||||||
|
[worldSeed, team, x, y]
|
||||||
|
);
|
||||||
|
return rowCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns all grid cells that are visible to a given team. */
|
||||||
|
export async function getTeamVisibleCells(worldSeed, team) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT gc.x, gc.y, gc.exploitable, gc.has_planet, gc.planet_json, gc.discovered_by, gc.captured_by
|
||||||
|
FROM grid_cells gc
|
||||||
|
JOIN team_cell_visibility tcv
|
||||||
|
ON tcv.world_seed = gc.world_seed AND tcv.x = gc.x AND tcv.y = gc.y
|
||||||
|
WHERE gc.world_seed = $1 AND tcv.team = $2`,
|
||||||
|
[worldSeed, team]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getExistingCell(seed, x, y) {
|
export async function getExistingCell(seed, x, y) {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT x, y, exploitable, has_planet, planet_json, discovered_by
|
`SELECT x, y, exploitable, has_planet, planet_json, discovered_by, captured_by
|
||||||
FROM grid_cells WHERE world_seed = $1 AND x = $2 AND y = $3`,
|
FROM grid_cells WHERE world_seed = $1 AND x = $2 AND y = $3`,
|
||||||
[seed, x, y]
|
[seed, x, y]
|
||||||
);
|
);
|
||||||
@@ -267,9 +375,133 @@ export async function getVictoryPoints() {
|
|||||||
export async function getScores(worldSeed) {
|
export async function getScores(worldSeed) {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT discovered_by, COUNT(*) AS cnt
|
`SELECT discovered_by, COUNT(*) AS cnt
|
||||||
FROM grid_cells WHERE world_seed = $1
|
FROM grid_cells WHERE world_seed = $1 AND discovered_by IS NOT NULL
|
||||||
GROUP BY discovered_by`,
|
GROUP BY discovered_by`,
|
||||||
[worldSeed]
|
[worldSeed]
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Active player counts (players who have played in the current epoch) ───────
|
||||||
|
|
||||||
|
export async function getActivePlayerCounts(worldSeed) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT team, COUNT(DISTINCT user_id)::int AS count
|
||||||
|
FROM user_cooldowns WHERE world_seed = $1
|
||||||
|
GROUP BY team`,
|
||||||
|
[worldSeed]
|
||||||
|
);
|
||||||
|
const result = { blue: 0, red: 0 };
|
||||||
|
for (const row of rows) result[row.team] = row.count;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Military deductions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getMilitaryDeductions(worldSeed) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT team, deducted FROM team_military_deductions WHERE world_seed = $1`,
|
||||||
|
[worldSeed]
|
||||||
|
);
|
||||||
|
const result = { blue: 0, red: 0 };
|
||||||
|
for (const row of rows) result[row.team] = Number(row.deducted);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMilitaryDeduction(worldSeed, team, amount) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO team_military_deductions (world_seed, team, deducted)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (world_seed, team) DO UPDATE
|
||||||
|
SET deducted = team_military_deductions.deducted + EXCLUDED.deducted`,
|
||||||
|
[worldSeed, team, amount]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cell attack log ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function recordCellAttack(worldSeed, x, y, attackingTeam) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO cell_attack_log (world_seed, x, y, attacking_team) VALUES ($1, $2, $3, $4)`,
|
||||||
|
[worldSeed, x, y, attackingTeam]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCellAttackCount(worldSeed, x, y) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS cnt FROM cell_attack_log WHERE world_seed = $1 AND x = $2 AND y = $3`,
|
||||||
|
[worldSeed, x, y]
|
||||||
|
);
|
||||||
|
return rows[0]?.cnt ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTileOwner(worldSeed, x, y, team, capturedBy = null) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE grid_cells SET discovered_by = $1, captured_by = $2 WHERE world_seed = $3 AND x = $4 AND y = $5`,
|
||||||
|
[team, capturedBy, worldSeed, x, y]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns per-team lists of user IDs who have been active this seed epoch. */
|
||||||
|
export async function getActivePlayerIds(worldSeed) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT team, user_id FROM user_cooldowns WHERE world_seed = $1`,
|
||||||
|
[worldSeed]
|
||||||
|
);
|
||||||
|
const result = { blue: [], red: [] };
|
||||||
|
for (const row of rows) {
|
||||||
|
if (result[row.team]) result[row.team].push(row.user_id);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Team action quota (daily, independent of world seed) ─────────────────────
|
||||||
|
|
||||||
|
export async function getTeamActionsRow(team) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT actions_remaining, quota_reset_at FROM team_action_quota WHERE team = $1`,
|
||||||
|
[team]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetTeamActions(team, actionsRemaining, quotaResetAt) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO team_action_quota (team, actions_remaining, quota_reset_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (team) DO UPDATE
|
||||||
|
SET actions_remaining = $2,
|
||||||
|
quota_reset_at = $3`,
|
||||||
|
[team, actionsRemaining, quotaResetAt]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically decrements team actions_remaining by 1 if > 0.
|
||||||
|
* Returns the updated row, or null if already 0.
|
||||||
|
*/
|
||||||
|
export async function decrementTeamActions(team) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE team_action_quota
|
||||||
|
SET actions_remaining = actions_remaining - 1
|
||||||
|
WHERE team = $1 AND actions_remaining > 0
|
||||||
|
RETURNING actions_remaining`,
|
||||||
|
[team]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically decrements team actions_remaining by `amount` if sufficient.
|
||||||
|
* Returns the updated row, or null if not enough actions.
|
||||||
|
*/
|
||||||
|
export async function decrementTeamActionsBy(team, amount) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE team_action_quota
|
||||||
|
SET actions_remaining = actions_remaining - $2
|
||||||
|
WHERE team = $1 AND actions_remaining >= $2
|
||||||
|
RETURNING actions_remaining`,
|
||||||
|
[team, amount]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import pg from "pg";
|
import pg from "pg";
|
||||||
|
|
||||||
const DATABASE_URL =
|
if (!process.env.DATABASE_URL) {
|
||||||
process.env.DATABASE_URL ?? "postgres://game:game@localhost:5432/star_wars_grid";
|
throw new Error("[startup] DATABASE_URL environment variable is required but not set.");
|
||||||
|
}
|
||||||
|
if (!process.env.USERS_DATABASE_URL) {
|
||||||
|
throw new Error("[startup] USERS_DATABASE_URL environment variable is required but not set.");
|
||||||
|
}
|
||||||
|
|
||||||
const USERS_DATABASE_URL =
|
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
process.env.USERS_DATABASE_URL ?? "postgres://users:users@localhost:5433/star_wars_users";
|
export const usersPool = new pg.Pool({ connectionString: process.env.USERS_DATABASE_URL });
|
||||||
|
|
||||||
export const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
|
||||||
export const usersPool = new pg.Pool({ connectionString: USERS_DATABASE_URL });
|
|
||||||
@@ -7,30 +7,51 @@ export async function initUsersSchema() {
|
|||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
|
team TEXT NOT NULL CHECK (team IN ('blue', 'red')),
|
||||||
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
await initUserActionQuotaSchema();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initUserActionQuotaSchema() {
|
||||||
|
await usersPool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_action_quota (
|
||||||
|
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
actions_remaining INTEGER NOT NULL DEFAULT 0,
|
||||||
|
quota_reset_at TIMESTAMPTZ NOT NULL DEFAULT '1970-01-01 00:00:00+00'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns the next noon (12:00:00) UTC after the current moment. */
|
||||||
|
export function nextNoonUtc() {
|
||||||
|
const now = new Date();
|
||||||
|
const noon = new Date(Date.UTC(
|
||||||
|
now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 12, 0, 0, 0
|
||||||
|
));
|
||||||
|
if (noon <= now) noon.setUTCDate(noon.getUTCDate() + 1);
|
||||||
|
return noon;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Queries ───────────────────────────────────────────────────────────────────
|
// ── Queries ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function createUser(username, email, passwordHash, team) {
|
export async function createUser(username, passwordHash, team) {
|
||||||
const { rows } = await usersPool.query(
|
const { rows } = await usersPool.query(
|
||||||
`INSERT INTO users (username, email, password_hash, team)
|
`INSERT INTO users (username, password_hash, team)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING id, username, email, team, role`,
|
RETURNING id, username, team`,
|
||||||
[username, email, passwordHash, team]
|
[username, passwordHash, team]
|
||||||
);
|
);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserByUsername(username) {
|
export async function getUserByUsername(username) {
|
||||||
const { rows } = await usersPool.query(
|
const { rows } = await usersPool.query(
|
||||||
`SELECT id, username, email, team, role, password_hash FROM users WHERE username = $1`,
|
`SELECT id, username, team, password_hash FROM users WHERE username = $1`,
|
||||||
[username]
|
[username]
|
||||||
);
|
);
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
@@ -38,8 +59,80 @@ export async function getUserByUsername(username) {
|
|||||||
|
|
||||||
export async function getUserById(id) {
|
export async function getUserById(id) {
|
||||||
const { rows } = await usersPool.query(
|
const { rows } = await usersPool.query(
|
||||||
`SELECT id, username, email, team, role FROM users WHERE id = $1`,
|
`SELECT id, username, team FROM users WHERE id = $1`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getTeamPlayerCounts() {
|
||||||
|
const { rows } = await usersPool.query(
|
||||||
|
`SELECT team, COUNT(*)::int AS count FROM users GROUP BY team`
|
||||||
|
);
|
||||||
|
const result = { blue: 0, red: 0 };
|
||||||
|
for (const row of rows) result[row.team] = row.count;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns username and team for each of the given user IDs. */
|
||||||
|
export async function getUsersByIds(ids) {
|
||||||
|
if (!ids.length) return [];
|
||||||
|
const { rows } = await usersPool.query(
|
||||||
|
`SELECT id, username, team FROM users WHERE id = ANY($1::int[])`,
|
||||||
|
[ids]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User action quota ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns the current quota row for a user, or null if it doesn't exist. */
|
||||||
|
export async function getUserActionsRow(userId) {
|
||||||
|
const { rows } = await usersPool.query(
|
||||||
|
`SELECT actions_remaining, quota_reset_at FROM user_action_quota WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert or overwrite the quota row for a user. */
|
||||||
|
export async function resetUserActions(userId, actionsRemaining, quotaResetAt) {
|
||||||
|
await usersPool.query(
|
||||||
|
`INSERT INTO user_action_quota (user_id, actions_remaining, quota_reset_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
|
SET actions_remaining = $2,
|
||||||
|
quota_reset_at = $3`,
|
||||||
|
[userId, actionsRemaining, quotaResetAt]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically decrements actions_remaining by 1 if > 0.
|
||||||
|
* Returns the updated row (with new actions_remaining), or null if already 0.
|
||||||
|
*/
|
||||||
|
export async function decrementUserActions(userId) {
|
||||||
|
const { rows } = await usersPool.query(
|
||||||
|
`UPDATE user_action_quota
|
||||||
|
SET actions_remaining = actions_remaining - 1
|
||||||
|
WHERE user_id = $1 AND actions_remaining > 0
|
||||||
|
RETURNING actions_remaining`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets ALL users' quota to a given value (used on world-seed wipeout).
|
||||||
|
* Users who have no row yet get one inserted.
|
||||||
|
*/
|
||||||
|
export async function resetAllUserActions(actionsRemaining, quotaResetAt) {
|
||||||
|
await usersPool.query(
|
||||||
|
`INSERT INTO user_action_quota (user_id, actions_remaining, quota_reset_at)
|
||||||
|
SELECT id, $1, $2 FROM users
|
||||||
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
|
SET actions_remaining = $1,
|
||||||
|
quota_reset_at = $2`,
|
||||||
|
[actionsRemaining, quotaResetAt]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,8 +6,11 @@ import {
|
|||||||
setElementBonus,
|
setElementBonus,
|
||||||
} from "./db/gameDb.js";
|
} from "./db/gameDb.js";
|
||||||
import { computeTeamIncome, computeTeamElementBonus } from "./helpers/economy.js";
|
import { computeTeamIncome, computeTeamElementBonus } from "./helpers/economy.js";
|
||||||
|
import { buildRealtimeSnapshot } from "./realtimeSnapshot.js";
|
||||||
|
import { broadcast } from "./ws/hub.js";
|
||||||
|
|
||||||
const TICK_SECONDS = 5;
|
const TICK_SECONDS = 5;
|
||||||
|
let lastTickSeed = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the server-side economy tick loop.
|
* Starts the server-side economy tick loop.
|
||||||
@@ -19,6 +22,11 @@ export function startEconTick() {
|
|||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const worldSeed = await ensureSeedEpoch();
|
const worldSeed = await ensureSeedEpoch();
|
||||||
|
if (lastTickSeed && lastTickSeed !== worldSeed) {
|
||||||
|
broadcast("seed-changed", { worldSeed });
|
||||||
|
}
|
||||||
|
lastTickSeed = worldSeed;
|
||||||
|
|
||||||
const rows = await getGridCells(worldSeed);
|
const rows = await getGridCells(worldSeed);
|
||||||
const cfg = getConfig();
|
const cfg = getConfig();
|
||||||
|
|
||||||
@@ -41,6 +49,9 @@ export function startEconTick() {
|
|||||||
|
|
||||||
await setElementBonus(worldSeed, "blue", blueBonus);
|
await setElementBonus(worldSeed, "blue", blueBonus);
|
||||||
await setElementBonus(worldSeed, "red", redBonus);
|
await setElementBonus(worldSeed, "red", redBonus);
|
||||||
|
|
||||||
|
const snapshot = await buildRealtimeSnapshot(worldSeed);
|
||||||
|
broadcast("snapshot", snapshot);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[econ tick]", e.message);
|
console.error("[econ tick]", e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
8
server/healthcheck.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import http from "http";
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT ?? 8080);
|
||||||
|
http
|
||||||
|
.get(`http://localhost:${port}/api/config`, (res) => {
|
||||||
|
process.exit(res.statusCode === 200 ? 0 : 1);
|
||||||
|
})
|
||||||
|
.on("error", () => process.exit(1));
|
||||||
@@ -51,5 +51,6 @@ export function rowToCellPayload(row) {
|
|||||||
hasPlanet: row.has_planet,
|
hasPlanet: row.has_planet,
|
||||||
planet: row.planet_json ?? null,
|
planet: row.planet_json ?? null,
|
||||||
discoveredBy: row.discovered_by,
|
discoveredBy: row.discovered_by,
|
||||||
|
capturedBy: row.captured_by ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -122,3 +122,35 @@ export function computeTeamElementBonus(team, rows, elementWorth) {
|
|||||||
}
|
}
|
||||||
return bonus;
|
return bonus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Military power computation ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const POP_LABEL_TO_KEY = new Map([
|
||||||
|
["Humains", "humans"],
|
||||||
|
["Presque'humains", "near"],
|
||||||
|
["Aliens", "aliens"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute total military power (in billions) for a team from DB grid rows.
|
||||||
|
*
|
||||||
|
* @param {string} team - "blue" or "red"
|
||||||
|
* @param {Array<{ has_planet: boolean, planet_json: object|null, discovered_by: string }>} rows
|
||||||
|
* @param {object} militaryPower - { humans: 10, near: 5, aliens: 1 }
|
||||||
|
* @returns {number} military power in billions
|
||||||
|
*/
|
||||||
|
export function computeTeamMilitaryPower(team, rows, militaryPower) {
|
||||||
|
let total = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.discovered_by !== team) continue;
|
||||||
|
if (!row.has_planet || !row.planet_json) continue;
|
||||||
|
const pop = row.planet_json.population;
|
||||||
|
if (!pop) continue;
|
||||||
|
const key = POP_LABEL_TO_KEY.get(pop.majority);
|
||||||
|
if (!key) continue;
|
||||||
|
const pct = militaryPower?.[key] ?? 0;
|
||||||
|
if (pct === 0) continue;
|
||||||
|
total += pop.billions * pct / 100;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
import { createServer } from "http";
|
||||||
import { loadConfigFile, getConfig } from "./configLoader.js";
|
import { loadConfigFile, getConfig } from "./configLoader.js";
|
||||||
import { initGameSchema, ensureSeedEpoch } from "./db/gameDb.js";
|
import { initGameSchema, ensureSeedEpoch } from "./db/gameDb.js";
|
||||||
import { initUsersSchema } from "./db/usersDb.js";
|
import { initUsersSchema } from "./db/usersDb.js";
|
||||||
import app from "./app.js";
|
import app from "./app.js";
|
||||||
import { startEconTick } from "./econTick.js";
|
import { startEconTick } from "./econTick.js";
|
||||||
|
import { initWebSocketHub, broadcast } from "./ws/hub.js";
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT ?? 8080);
|
const PORT = Number(process.env.PORT ?? 8080);
|
||||||
|
let lastConfigSignature = "";
|
||||||
|
|
||||||
|
function makeConfigSignature(cfg) {
|
||||||
|
return JSON.stringify({
|
||||||
|
dailyActionQuota: cfg.dailyActionQuota,
|
||||||
|
teamActionQuota: cfg.teamActionQuota,
|
||||||
|
databaseWipeoutIntervalSeconds: cfg.databaseWipeoutIntervalSeconds,
|
||||||
|
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
|
||||||
|
elementWorth: cfg.elementWorth ?? {},
|
||||||
|
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
|
||||||
|
militaryPower: cfg.militaryPower ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Config-file poll ──────────────────────────────────────────────────────────
|
// ── Config-file poll ──────────────────────────────────────────────────────────
|
||||||
// Periodically re-reads game.settings.json and checks for a seed-epoch change.
|
// Periodically re-reads game.settings.json and checks for a seed-epoch change.
|
||||||
@@ -15,8 +30,26 @@ function scheduleConfigPoll() {
|
|||||||
const ms = Math.max(5_000, getConfig().configReloadIntervalSeconds * 1_000);
|
const ms = Math.max(5_000, getConfig().configReloadIntervalSeconds * 1_000);
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
|
const beforeSig = lastConfigSignature;
|
||||||
loadConfigFile();
|
loadConfigFile();
|
||||||
await ensureSeedEpoch();
|
const worldSeed = await ensureSeedEpoch();
|
||||||
|
const cfg = getConfig();
|
||||||
|
const nextSig = makeConfigSignature(cfg);
|
||||||
|
if (beforeSig && nextSig !== beforeSig) {
|
||||||
|
broadcast("config-updated", {
|
||||||
|
worldSeed,
|
||||||
|
config: {
|
||||||
|
dailyActionQuota: cfg.dailyActionQuota,
|
||||||
|
teamActionQuota: cfg.teamActionQuota,
|
||||||
|
databaseWipeoutIntervalSeconds: cfg.databaseWipeoutIntervalSeconds,
|
||||||
|
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
|
||||||
|
elementWorth: cfg.elementWorth ?? {},
|
||||||
|
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
|
||||||
|
militaryPower: cfg.militaryPower ?? {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lastConfigSignature = nextSig;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[config poll]", e.message);
|
console.error("[config poll]", e.message);
|
||||||
}
|
}
|
||||||
@@ -31,11 +64,15 @@ async function main() {
|
|||||||
await initGameSchema();
|
await initGameSchema();
|
||||||
await initUsersSchema();
|
await initUsersSchema();
|
||||||
await ensureSeedEpoch();
|
await ensureSeedEpoch();
|
||||||
|
lastConfigSignature = makeConfigSignature(getConfig());
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
const httpServer = createServer(app);
|
||||||
|
initWebSocketHub(httpServer);
|
||||||
|
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
const cfg = getConfig();
|
const cfg = getConfig();
|
||||||
console.log(
|
console.log(
|
||||||
`[server] Listening on :${PORT} cooldown=${cfg.clickCooldownSeconds}s wipe=${cfg.databaseWipeoutIntervalSeconds}s`
|
`[server] Listening on :${PORT} dailyQuota=${cfg.dailyActionQuota} wipe=${cfg.databaseWipeoutIntervalSeconds}s`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
export const JWT_SECRET = process.env.JWT_SECRET ?? "dev_secret_change_me";
|
if (!process.env.JWT_SECRET) {
|
||||||
|
throw new Error("[startup] JWT_SECRET environment variable is required but not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
|
|
||||||
export function authMiddleware(req, res, next) {
|
export function authMiddleware(req, res, next) {
|
||||||
const authHeader = req.headers["authorization"];
|
const authHeader = req.headers["authorization"];
|
||||||
|
|||||||
26
server/realtimeSnapshot.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
getEconScores,
|
||||||
|
getElementBonus,
|
||||||
|
getMilitaryDeductions,
|
||||||
|
getActivePlayerCounts,
|
||||||
|
getVictoryPoints,
|
||||||
|
} from "./db/gameDb.js";
|
||||||
|
|
||||||
|
export async function buildRealtimeSnapshot(worldSeed) {
|
||||||
|
const [scores, elementBonus, militaryDeductions, activePlayers, victoryPoints] = await Promise.all([
|
||||||
|
getEconScores(worldSeed),
|
||||||
|
getElementBonus(worldSeed),
|
||||||
|
getMilitaryDeductions(worldSeed),
|
||||||
|
getActivePlayerCounts(worldSeed),
|
||||||
|
getVictoryPoints(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
worldSeed,
|
||||||
|
scores,
|
||||||
|
elementBonus,
|
||||||
|
militaryDeductions,
|
||||||
|
activePlayers,
|
||||||
|
victoryPoints,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import express from "express";
|
|||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import { JWT_SECRET, authMiddleware } from "../middleware/auth.js";
|
import { JWT_SECRET, authMiddleware } from "../middleware/auth.js";
|
||||||
import { createUser, getUserByUsername, getUserById } from "../db/usersDb.js";
|
import { createUser, getUserByUsername, getUserById, getTeamPlayerCounts } from "../db/usersDb.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -16,8 +16,8 @@ function issueToken(user) {
|
|||||||
|
|
||||||
// POST /api/auth/register
|
// POST /api/auth/register
|
||||||
router.post("/register", async (req, res) => {
|
router.post("/register", async (req, res) => {
|
||||||
const { username, email, password, team } = req.body ?? {};
|
const { username, password, team } = req.body ?? {};
|
||||||
if (!username || !email || !password || !team) {
|
if (!username || !password || !team) {
|
||||||
return res.status(400).json({ error: "missing_fields" });
|
return res.status(400).json({ error: "missing_fields" });
|
||||||
}
|
}
|
||||||
if (team !== "blue" && team !== "red") {
|
if (team !== "blue" && team !== "red") {
|
||||||
@@ -31,7 +31,7 @@ router.post("/register", async (req, res) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const passwordHash = await bcrypt.hash(password, 12);
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
const user = await createUser(username.trim(), email.trim().toLowerCase(), passwordHash, team);
|
const user = await createUser(username.trim(), passwordHash, team);
|
||||||
const token = issueToken(user);
|
const token = issueToken(user);
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
token,
|
token,
|
||||||
@@ -39,7 +39,6 @@ router.post("/register", async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code === "23505") {
|
if (e.code === "23505") {
|
||||||
if (e.constraint?.includes("email")) return res.status(409).json({ error: "email_taken" });
|
|
||||||
return res.status(409).json({ error: "username_taken" });
|
return res.status(409).json({ error: "username_taken" });
|
||||||
}
|
}
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -67,6 +66,17 @@ router.post("/login", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/player-counts
|
||||||
|
router.get("/player-counts", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const counts = await getTeamPlayerCounts();
|
||||||
|
return res.json(counts);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return res.status(500).json({ error: "database_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/auth/me
|
// GET /api/auth/me
|
||||||
router.get("/me", authMiddleware, async (req, res) => {
|
router.get("/me", authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -18,8 +18,31 @@ import {
|
|||||||
setElementBonus,
|
setElementBonus,
|
||||||
getDbCreatedAt,
|
getDbCreatedAt,
|
||||||
getVictoryPoints,
|
getVictoryPoints,
|
||||||
|
getActivePlayerCounts,
|
||||||
|
getActivePlayerIds,
|
||||||
|
getMilitaryDeductions,
|
||||||
|
addMilitaryDeduction,
|
||||||
|
recordCellAttack,
|
||||||
|
getCellAttackCount,
|
||||||
|
setTileOwner,
|
||||||
|
getTeamActionsRow,
|
||||||
|
resetTeamActions,
|
||||||
|
decrementTeamActions,
|
||||||
|
decrementTeamActionsBy,
|
||||||
|
checkTeamVisibility,
|
||||||
|
insertTeamVisibility,
|
||||||
|
getTeamVisibleCells,
|
||||||
} from "../db/gameDb.js";
|
} from "../db/gameDb.js";
|
||||||
|
import {
|
||||||
|
nextNoonUtc,
|
||||||
|
getUserActionsRow,
|
||||||
|
resetUserActions,
|
||||||
|
decrementUserActions,
|
||||||
|
getUsersByIds,
|
||||||
|
} from "../db/usersDb.js";
|
||||||
import { computeCell, rowToCellPayload } from "../helpers/cell.js";
|
import { computeCell, rowToCellPayload } from "../helpers/cell.js";
|
||||||
|
import { computeTeamMilitaryPower } from "../helpers/economy.js";
|
||||||
|
import { broadcast, broadcastToTeam } from "../ws/hub.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -31,47 +54,55 @@ router.get("/config", async (req, res) => {
|
|||||||
const rot = cfg.databaseWipeoutIntervalSeconds;
|
const rot = cfg.databaseWipeoutIntervalSeconds;
|
||||||
const ws = computeWorldSeedState(rot);
|
const ws = computeWorldSeedState(rot);
|
||||||
|
|
||||||
let teamCooldownRemaining = 0;
|
let actionsRemaining = null;
|
||||||
|
let teamActionsRemaining = null;
|
||||||
const team = typeof req.query.team === "string" ? req.query.team : undefined;
|
const team = typeof req.query.team === "string" ? req.query.team : undefined;
|
||||||
if (team === "blue" || team === "red") {
|
if (team === "blue" || team === "red") {
|
||||||
const bonus = await getElementBonus(worldSeed);
|
|
||||||
const teamBonus = bonus[team] ?? 0;
|
|
||||||
const effectiveCooldown = cfg.clickCooldownSeconds / (1 + teamBonus / 100);
|
|
||||||
// Use per-user cooldown if auth token provided, else fall back to team-wide
|
|
||||||
const authHeader = req.headers["authorization"];
|
const authHeader = req.headers["authorization"];
|
||||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(authHeader.slice(7), JWT_SECRET);
|
const payload = jwt.verify(authHeader.slice(7), JWT_SECRET);
|
||||||
const row = await getUserCooldown(worldSeed, payload.userId);
|
const bonus = await getElementBonus(worldSeed);
|
||||||
if (row) {
|
const teamBonus = bonus[team] ?? 0;
|
||||||
const secondsSince = (Date.now() - new Date(row.last_reveal).getTime()) / 1000;
|
const effectiveQuota = Math.floor(cfg.dailyActionQuota * (1 + teamBonus / 100));
|
||||||
if (secondsSince < effectiveCooldown) {
|
const now = new Date();
|
||||||
teamCooldownRemaining = Math.ceil(effectiveCooldown - secondsSince);
|
const quotaRow = await getUserActionsRow(payload.userId);
|
||||||
}
|
if (!quotaRow || new Date(quotaRow.quota_reset_at) <= now) {
|
||||||
|
actionsRemaining = effectiveQuota;
|
||||||
|
} else {
|
||||||
|
actionsRemaining = quotaRow.actions_remaining;
|
||||||
}
|
}
|
||||||
} catch { /* invalid token — return 0 */ }
|
} catch { /* invalid token — return null */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Team-wide quota: compute current remaining without consuming
|
||||||
|
const now = new Date();
|
||||||
|
const teamRow = await getTeamActionsRow(team);
|
||||||
|
if (!teamRow || new Date(teamRow.quota_reset_at) <= now) {
|
||||||
|
// Expired or unset: compute what it would be when refreshed
|
||||||
|
const rows = await getGridCells(worldSeed);
|
||||||
|
const milPower = computeTeamMilitaryPower(team, rows, cfg.militaryPower ?? {});
|
||||||
|
const milDeductions = await getMilitaryDeductions(worldSeed);
|
||||||
|
const milNet = milPower - (milDeductions[team] ?? 0);
|
||||||
|
const milBonus = Math.floor(Math.max(0, milNet) / 10000);
|
||||||
|
teamActionsRemaining = cfg.teamActionQuota + milBonus;
|
||||||
} else {
|
} else {
|
||||||
const row = await getTeamCooldown(worldSeed, team);
|
teamActionsRemaining = teamRow.actions_remaining;
|
||||||
if (row) {
|
|
||||||
const secondsSince = (Date.now() - new Date(row.last_reveal).getTime()) / 1000;
|
|
||||||
if (secondsSince < effectiveCooldown) {
|
|
||||||
teamCooldownRemaining = Math.ceil(effectiveCooldown - secondsSince);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
clickCooldownSeconds: cfg.clickCooldownSeconds,
|
dailyActionQuota: cfg.dailyActionQuota,
|
||||||
databaseWipeoutIntervalSeconds: rot,
|
databaseWipeoutIntervalSeconds: rot,
|
||||||
debugModeForTeams: cfg.debugModeForTeams,
|
|
||||||
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
|
configReloadIntervalSeconds: cfg.configReloadIntervalSeconds,
|
||||||
worldSeed: ws.worldSeed,
|
worldSeed,
|
||||||
seedPeriodEndsAtUtc: ws.seedPeriodEndsAtUtc,
|
seedPeriodEndsAtUtc: ws.seedPeriodEndsAtUtc,
|
||||||
seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc,
|
seedPeriodStartsAtUtc: ws.seedPeriodStartsAtUtc,
|
||||||
teamCooldownRemaining,
|
actionsRemaining,
|
||||||
|
teamActionsRemaining,
|
||||||
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
|
resourceWorth: cfg.resourceWorth ?? { common: {}, rare: {} },
|
||||||
elementWorth: cfg.elementWorth ?? {},
|
elementWorth: cfg.elementWorth ?? {},
|
||||||
|
militaryPower: cfg.militaryPower ?? {},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -79,7 +110,7 @@ router.get("/config", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/grid/:seed
|
// GET /api/grid/:seed (auth-aware: returns only team-visible cells)
|
||||||
router.get("/grid/:seed", async (req, res) => {
|
router.get("/grid/:seed", async (req, res) => {
|
||||||
const seed = decodeURIComponent(req.params.seed || "");
|
const seed = decodeURIComponent(req.params.seed || "");
|
||||||
try {
|
try {
|
||||||
@@ -93,7 +124,14 @@ router.get("/grid/:seed", async (req, res) => {
|
|||||||
).seedPeriodEndsAtUtc,
|
).seedPeriodEndsAtUtc,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const rows = await getGridCells(seed);
|
let rows = [];
|
||||||
|
const authHeader = req.headers["authorization"];
|
||||||
|
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(authHeader.slice(7), JWT_SECRET);
|
||||||
|
rows = await getTeamVisibleCells(worldSeed, payload.team);
|
||||||
|
} catch { /* invalid token — return empty grid */ }
|
||||||
|
}
|
||||||
res.json({ seed, cells: rows });
|
res.json({ seed, cells: rows });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -101,10 +139,10 @@ router.get("/grid/:seed", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/cell/reveal
|
// POST /api/cell/reveal — reveals a cell for THIS team only (private visibility)
|
||||||
router.post("/cell/reveal", authMiddleware, async (req, res) => {
|
router.post("/cell/reveal", authMiddleware, async (req, res) => {
|
||||||
const seed = String(req.body?.seed ?? "");
|
const seed = String(req.body?.seed ?? "");
|
||||||
const team = req.user.team; // taken from verified JWT, not request body
|
const team = req.user.team;
|
||||||
const userId = req.user.userId;
|
const userId = req.user.userId;
|
||||||
const x = Number(req.body?.x);
|
const x = Number(req.body?.x);
|
||||||
const y = Number(req.body?.y);
|
const y = Number(req.body?.y);
|
||||||
@@ -119,45 +157,169 @@ router.post("/cell/reveal", authMiddleware, async (req, res) => {
|
|||||||
return res.status(410).json({ error: "seed_expired", worldSeed });
|
return res.status(410).json({ error: "seed_expired", worldSeed });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = getConfig();
|
// Check if already revealed by this team (free re-view, no action cost)
|
||||||
if (cfg.clickCooldownSeconds > 0) {
|
const alreadyVisible = await checkTeamVisibility(worldSeed, team, x, y);
|
||||||
const bonus = await getElementBonus(worldSeed);
|
|
||||||
const teamBonus = bonus[team] ?? 0;
|
if (!alreadyVisible) {
|
||||||
const effectiveCooldown = cfg.clickCooldownSeconds / (1 + teamBonus / 100);
|
// First reveal: deduct one user action
|
||||||
const cooldownRow = await getUserCooldown(worldSeed, userId);
|
const cfg = getConfig();
|
||||||
if (cooldownRow) {
|
if (cfg.dailyActionQuota > 0) {
|
||||||
const secondsSince = (Date.now() - new Date(cooldownRow.last_reveal).getTime()) / 1000;
|
const bonus = await getElementBonus(worldSeed);
|
||||||
if (secondsSince < effectiveCooldown) {
|
const teamBonus = bonus[team] ?? 0;
|
||||||
return res.status(429).json({
|
const effectiveQuota = Math.floor(cfg.dailyActionQuota * (1 + teamBonus / 100));
|
||||||
error: "cooldown_active",
|
const now = new Date();
|
||||||
team,
|
const quotaRow = await getUserActionsRow(userId);
|
||||||
remainingSeconds: Math.ceil(effectiveCooldown - secondsSince),
|
if (!quotaRow || new Date(quotaRow.quota_reset_at) <= now) {
|
||||||
cooldownSeconds: effectiveCooldown,
|
await resetUserActions(userId, effectiveQuota - 1, nextNoonUtc().toISOString());
|
||||||
});
|
} else {
|
||||||
|
const updated = await decrementUserActions(userId);
|
||||||
|
if (!updated) {
|
||||||
|
return res.status(429).json({ error: "quota_exhausted", actionsRemaining: 0 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Mark as visible for this team
|
||||||
|
await insertTeamVisibility(worldSeed, team, x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure cell data is in grid_cells (discovered_by stays NULL = neutral)
|
||||||
const cell = computeCell(seed, x, y);
|
const cell = computeCell(seed, x, y);
|
||||||
const planetJson = cell.planet ? JSON.stringify(cell.planet) : null;
|
const planetJson = cell.planet ? JSON.stringify(cell.planet) : null;
|
||||||
const inserted = await insertCell(seed, x, y, cell.exploitable, cell.hasPlanet, planetJson, team);
|
await insertCell(seed, x, y, cell.exploitable, cell.hasPlanet, planetJson);
|
||||||
|
|
||||||
if (inserted) {
|
// Track activity for active-player count
|
||||||
await upsertUserCooldown(worldSeed, userId, team);
|
await upsertUserCooldown(worldSeed, userId, team);
|
||||||
return res.json(rowToCellPayload(inserted));
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await getExistingCell(seed, x, y);
|
const existing = await getExistingCell(seed, x, y);
|
||||||
if (!existing) return res.status(500).json({ error: "insert_race" });
|
if (!existing) return res.status(500).json({ error: "insert_race" });
|
||||||
|
|
||||||
if (existing.discovered_by !== team) {
|
broadcastToTeam(team, "cell-updated", {
|
||||||
return res.status(409).json({
|
worldSeed,
|
||||||
error: "taken_by_other_team",
|
cell: rowToCellPayload(existing),
|
||||||
discoveredBy: existing.discovered_by,
|
});
|
||||||
cell: rowToCellPayload(existing),
|
|
||||||
|
return res.json(rowToCellPayload(existing));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: "database_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/cell/capture — spend team actions to capture a planet
|
||||||
|
router.post("/cell/capture", authMiddleware, async (req, res) => {
|
||||||
|
const seed = String(req.body?.seed ?? "");
|
||||||
|
const team = req.user.team;
|
||||||
|
const x = Number(req.body?.x);
|
||||||
|
const y = Number(req.body?.y);
|
||||||
|
|
||||||
|
if (!seed || !Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= 100 || y < 0 || y >= 100) {
|
||||||
|
return res.status(400).json({ error: "invalid_body" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const worldSeed = await ensureSeedEpoch();
|
||||||
|
if (seed !== worldSeed) return res.status(410).json({ error: "seed_expired", worldSeed });
|
||||||
|
|
||||||
|
const existing = await getExistingCell(worldSeed, x, y);
|
||||||
|
if (!existing) return res.status(404).json({ error: "cell_not_found" });
|
||||||
|
if (!existing.has_planet) return res.status(409).json({ error: "no_planet" });
|
||||||
|
if (existing.discovered_by === team) return res.status(409).json({ error: "already_owned" });
|
||||||
|
|
||||||
|
// Compute capture cost (1 action per 10 billion population, doubled if opponent-controlled)
|
||||||
|
const planet = existing.planet_json;
|
||||||
|
const billions = planet?.population?.billions ?? 0;
|
||||||
|
const baseCost = Math.max(1, Math.ceil(billions / 10));
|
||||||
|
const isOpponentControlled = existing.discovered_by !== null && existing.discovered_by !== team;
|
||||||
|
const cost = isOpponentControlled ? baseCost * 2 : baseCost;
|
||||||
|
|
||||||
|
// Consume team actions
|
||||||
|
const cfg = getConfig();
|
||||||
|
const now = new Date();
|
||||||
|
const teamRow = await getTeamActionsRow(team);
|
||||||
|
if (!teamRow || new Date(teamRow.quota_reset_at) <= now) {
|
||||||
|
// Compute fresh quota
|
||||||
|
const rows = await getGridCells(worldSeed);
|
||||||
|
const milPower = computeTeamMilitaryPower(team, rows, cfg.militaryPower ?? {});
|
||||||
|
const milDeductions = await getMilitaryDeductions(worldSeed);
|
||||||
|
const milNet = milPower - (milDeductions[team] ?? 0);
|
||||||
|
const milBonus = Math.floor(Math.max(0, milNet) / 10000);
|
||||||
|
const totalActions = cfg.teamActionQuota + milBonus;
|
||||||
|
if (totalActions < cost) {
|
||||||
|
return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: totalActions });
|
||||||
|
}
|
||||||
|
await resetTeamActions(team, totalActions - cost, nextNoonUtc().toISOString());
|
||||||
|
} else {
|
||||||
|
if (teamRow.actions_remaining < cost) {
|
||||||
|
return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: teamRow.actions_remaining });
|
||||||
|
}
|
||||||
|
const updated = await decrementTeamActionsBy(team, cost);
|
||||||
|
if (!updated) {
|
||||||
|
return res.status(429).json({ error: "team_quota_exhausted", cost, teamActionsRemaining: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer ownership to capturing team
|
||||||
|
await setTileOwner(worldSeed, x, y, team, req.user.username);
|
||||||
|
|
||||||
|
const updatedCell = await getExistingCell(worldSeed, x, y);
|
||||||
|
const updatedTeamRow = await getTeamActionsRow(team);
|
||||||
|
const updatedCellPayload = rowToCellPayload(updatedCell);
|
||||||
|
|
||||||
|
// Team that made the capture always gets the update.
|
||||||
|
broadcastToTeam(team, "cell-updated", {
|
||||||
|
worldSeed,
|
||||||
|
cell: updatedCellPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Opponent receives the update only if that team had visibility on this cell.
|
||||||
|
const opposingTeam = team === "blue" ? "red" : "blue";
|
||||||
|
const opposingVisible = await checkTeamVisibility(worldSeed, opposingTeam, x, y);
|
||||||
|
if (opposingVisible) {
|
||||||
|
broadcastToTeam(opposingTeam, "cell-updated", {
|
||||||
|
worldSeed,
|
||||||
|
cell: updatedCellPayload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return res.json(rowToCellPayload(existing));
|
|
||||||
|
broadcastToTeam(team, "team-quota-updated", {
|
||||||
|
team,
|
||||||
|
actionsRemaining: updatedTeamRow?.actions_remaining ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
cell: updatedCellPayload,
|
||||||
|
teamActionsRemaining: updatedTeamRow?.actions_remaining ?? null,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: "database_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/team-quota?team=blue|red
|
||||||
|
router.get("/team-quota", async (req, res) => {
|
||||||
|
const team = typeof req.query.team === "string" ? req.query.team : "";
|
||||||
|
if (team !== "blue" && team !== "red") {
|
||||||
|
return res.status(400).json({ error: "invalid_team" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const worldSeed = await ensureSeedEpoch();
|
||||||
|
const cfg = getConfig();
|
||||||
|
const now = new Date();
|
||||||
|
const teamRow = await getTeamActionsRow(team);
|
||||||
|
let actionsRemaining;
|
||||||
|
if (!teamRow || new Date(teamRow.quota_reset_at) <= now) {
|
||||||
|
const rows = await getGridCells(worldSeed);
|
||||||
|
const milPower = computeTeamMilitaryPower(team, rows, cfg.militaryPower ?? {});
|
||||||
|
const milDeductions = await getMilitaryDeductions(worldSeed);
|
||||||
|
const milNet = milPower - (milDeductions[team] ?? 0);
|
||||||
|
const milBonus = Math.floor(Math.max(0, milNet) / 10000);
|
||||||
|
actionsRemaining = cfg.teamActionQuota + milBonus;
|
||||||
|
} else {
|
||||||
|
actionsRemaining = teamRow.actions_remaining;
|
||||||
|
}
|
||||||
|
res.json({ team, actionsRemaining });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
res.status(500).json({ error: "database_error" });
|
res.status(500).json({ error: "database_error" });
|
||||||
@@ -246,6 +408,36 @@ router.get("/victory-points", async (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/active-players
|
||||||
|
router.get("/active-players", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const worldSeed = await ensureSeedEpoch();
|
||||||
|
const counts = await getActivePlayerCounts(worldSeed);
|
||||||
|
res.json(counts);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: "database_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/active-players/names
|
||||||
|
router.get("/active-players/names", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const worldSeed = await ensureSeedEpoch();
|
||||||
|
const playerIds = await getActivePlayerIds(worldSeed);
|
||||||
|
const allIds = [...playerIds.blue, ...playerIds.red];
|
||||||
|
const users = await getUsersByIds(allIds);
|
||||||
|
const result = { blue: [], red: [] };
|
||||||
|
for (const user of users) {
|
||||||
|
if (result[user.team]) result[user.team].push(user.username);
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: "database_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/scores
|
// GET /api/scores
|
||||||
router.get("/scores", async (_req, res) => {
|
router.get("/scores", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -263,4 +455,101 @@ router.get("/scores", async (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/military-deductions
|
||||||
|
router.get("/military-deductions", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const worldSeed = await ensureSeedEpoch();
|
||||||
|
const deductions = await getMilitaryDeductions(worldSeed);
|
||||||
|
res.json(deductions);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: "database_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/military/attack body: { seed, x, y }
|
||||||
|
// Requires auth. The attacker's team must have enough computed military power.
|
||||||
|
// Deducts exactly 1.0 billion (= 1000 M) and logs the attack on the cell.
|
||||||
|
router.post("/military/attack", authMiddleware, async (req, res) => {
|
||||||
|
const seed = String(req.body?.seed ?? "");
|
||||||
|
const x = Number(req.body?.x);
|
||||||
|
const y = Number(req.body?.y);
|
||||||
|
const attackingTeam = req.user.team;
|
||||||
|
|
||||||
|
if (!seed || !Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= 100 || y < 0 || y >= 100) {
|
||||||
|
return res.status(400).json({ error: "invalid_body" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const worldSeed = await ensureSeedEpoch();
|
||||||
|
if (seed !== worldSeed) return res.status(410).json({ error: "seed_expired", worldSeed });
|
||||||
|
|
||||||
|
// Target cell must exist, have a planet, and belong to the opposing team
|
||||||
|
const existing = await getExistingCell(worldSeed, x, y);
|
||||||
|
if (!existing) return res.status(404).json({ error: "cell_not_found" });
|
||||||
|
if (!existing.discovered_by || existing.discovered_by === attackingTeam) {
|
||||||
|
return res.status(409).json({ error: "cannot_attack_own_or_neutral_tile" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduct 1 billion (1.0 in "billions" unit) from the attacking team
|
||||||
|
const COST_BILLIONS = 1.0;
|
||||||
|
await addMilitaryDeduction(worldSeed, attackingTeam, COST_BILLIONS);
|
||||||
|
|
||||||
|
// Transfer tile ownership to the attacking team
|
||||||
|
await setTileOwner(worldSeed, x, y, attackingTeam, req.user.username);
|
||||||
|
|
||||||
|
// Record the attack event
|
||||||
|
await recordCellAttack(worldSeed, x, y, attackingTeam);
|
||||||
|
|
||||||
|
const deductions = await getMilitaryDeductions(worldSeed);
|
||||||
|
const updatedCell = await getExistingCell(worldSeed, x, y);
|
||||||
|
const updatedCellPayload = rowToCellPayload(updatedCell);
|
||||||
|
|
||||||
|
broadcast("military-deductions-updated", {
|
||||||
|
worldSeed,
|
||||||
|
deductions,
|
||||||
|
});
|
||||||
|
|
||||||
|
broadcastToTeam(attackingTeam, "cell-updated", {
|
||||||
|
worldSeed,
|
||||||
|
cell: updatedCellPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
const opposingTeam = attackingTeam === "blue" ? "red" : "blue";
|
||||||
|
const opposingVisible = await checkTeamVisibility(worldSeed, opposingTeam, x, y);
|
||||||
|
if (opposingVisible) {
|
||||||
|
broadcastToTeam(opposingTeam, "cell-updated", {
|
||||||
|
worldSeed,
|
||||||
|
cell: updatedCellPayload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
cell: updatedCellPayload,
|
||||||
|
deductions,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: "database_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/cell/attacks?x=&y=
|
||||||
|
router.get("/cell/attacks", async (req, res) => {
|
||||||
|
const x = Number(req.query.x);
|
||||||
|
const y = Number(req.query.y);
|
||||||
|
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || x >= 100 || y < 0 || y >= 100) {
|
||||||
|
return res.status(400).json({ error: "invalid_params" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const worldSeed = await ensureSeedEpoch();
|
||||||
|
const count = await getCellAttackCount(worldSeed, x, y);
|
||||||
|
res.json({ x, y, attackCount: count });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: "database_error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
147
server/ws/hub.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { JWT_SECRET } from "../middleware/auth.js";
|
||||||
|
|
||||||
|
let wss = null;
|
||||||
|
let heartbeatTimer = null;
|
||||||
|
|
||||||
|
const clients = new Set();
|
||||||
|
const HEARTBEAT_MS = 30_000;
|
||||||
|
|
||||||
|
function parseAuthFromToken(token) {
|
||||||
|
try {
|
||||||
|
if (!token) return null;
|
||||||
|
const payload = jwt.verify(token, JWT_SECRET);
|
||||||
|
return {
|
||||||
|
userId: payload.userId,
|
||||||
|
username: payload.username,
|
||||||
|
team: payload.team,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendRaw(client, text) {
|
||||||
|
if (client.ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
client.ws.send(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWireMessage(type, payload = {}) {
|
||||||
|
return JSON.stringify({
|
||||||
|
type,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupClient(client) {
|
||||||
|
clients.delete(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseClientMessage(data) {
|
||||||
|
try {
|
||||||
|
const text = typeof data === "string" ? data : data.toString("utf8");
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHeartbeat() {
|
||||||
|
if (heartbeatTimer) return;
|
||||||
|
heartbeatTimer = setInterval(() => {
|
||||||
|
for (const client of clients) {
|
||||||
|
if (!client.isAlive) {
|
||||||
|
try {
|
||||||
|
client.ws.terminate();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
cleanupClient(client);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
client.isAlive = false;
|
||||||
|
try {
|
||||||
|
client.ws.ping();
|
||||||
|
} catch {
|
||||||
|
cleanupClient(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initWebSocketHub(httpServer) {
|
||||||
|
if (wss) return wss;
|
||||||
|
|
||||||
|
wss = new WebSocketServer({
|
||||||
|
server: httpServer,
|
||||||
|
path: "/ws",
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on("connection", (ws) => {
|
||||||
|
const client = {
|
||||||
|
ws,
|
||||||
|
auth: null,
|
||||||
|
isAlive: true,
|
||||||
|
};
|
||||||
|
clients.add(client);
|
||||||
|
|
||||||
|
ws.on("pong", () => {
|
||||||
|
client.isAlive = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
cleanupClient(client);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", () => {
|
||||||
|
cleanupClient(client);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("message", (data, isBinary) => {
|
||||||
|
if (isBinary) return;
|
||||||
|
const message = parseClientMessage(data);
|
||||||
|
if (!message || message.type !== "auth") return;
|
||||||
|
|
||||||
|
client.auth = parseAuthFromToken(message.token);
|
||||||
|
sendRaw(
|
||||||
|
client,
|
||||||
|
toWireMessage("auth-state", {
|
||||||
|
authenticated: Boolean(client.auth),
|
||||||
|
team: client.auth?.team ?? null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
sendRaw(
|
||||||
|
client,
|
||||||
|
toWireMessage("welcome", {
|
||||||
|
authenticated: false,
|
||||||
|
team: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
startHeartbeat();
|
||||||
|
return wss;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcast(type, payload = {}) {
|
||||||
|
const text = toWireMessage(type, payload);
|
||||||
|
for (const client of clients) {
|
||||||
|
sendRaw(client, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastToTeam(team, type, payload = {}) {
|
||||||
|
const text = toWireMessage(type, payload);
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.auth?.team !== team) continue;
|
||||||
|
sendRaw(client, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConnectedClientCount() {
|
||||||
|
return clients.size;
|
||||||
|
}
|
||||||