Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Public values only. Copy this file to .env.local for local development.
EXPO_PUBLIC_APP_ENV=development
EXPO_PUBLIC_API_BASE_URL=http://10.0.2.2:8000
# Public values only. Copy this file to .env.local for local development.
EXPO_PUBLIC_APP_ENV=development
EXPO_PUBLIC_API_BASE_URL=http://10.0.2.2:8000
EXPO_PUBLIC_STOCKFISH_SOCKET_URL=http://10.0.2.2:8080
191 changes: 191 additions & 0 deletions docs/stockfish-socket-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
# Stockfish Socket Contract

The Socket.IO protocol between the mobile app and the Y STEM Stockfish server,
and how `src/features/play/services/StockfishClient.ts` uses it.

**Sources** (repository `YSTEM-LMS/react`, commit `1a5928e`):

- `stockfishServer/src/index.js`: server setup, CORS
- `stockfishServer/src/managers/socket.js`: event handlers
- `stockfishServer/src/managers/StockfishManager.js`: engine sessions and replies
- `react-ystemandchess/src/features/engine/PlayComputer.tsx`: the website's
player-vs-computer client

Server behaviour that depends on `chess.js` was checked against the server's
locked version, `chess.js` 0.11.0.

## Connection

| Item | Value |
| ---------- | ----------------------------------------------------------------------------------------------------------- |
| URL | `EXPO_PUBLIC_STOCKFISH_SOCKET_URL` (local default `http://10.0.2.2:8080`; server `PORT` defaults to `8080`) |
| Versions | Server `socket.io` ^4.8.1 · mobile `socket.io-client` ^4.8.4 (same major version) |
| Transports | Server allows the defaults (polling and WebSocket). The website and mobile both use `['websocket']` |
| Namespace | Default (`/`) |
| CORS | Applies to browsers only. Requests without an `Origin` header (native apps) are accepted |

Mobile sets `autoConnect: false` (lazy connection) and `reconnection: false`.
The server deletes the session when a socket disconnects, so an automatic
reconnect would leave the client without a session. Callers run
`startSession()` again instead.

## Session lifecycle

1. Client connects.
2. Client emits `start-session`. The server spawns one Stockfish process for
this socket.
3. Client emits `evaluate-fen` as many times as needed, **one at a time**.
4. Client emits `end-session` or disconnects. The server kills the process.

- One session per socket. A second `start-session` on the same socket returns
`session-error` with `"Session already exists!"`.
- Disconnecting deletes the session. Reconnecting requires a new
`start-session`.

## Client → server events

### `start-session`

```ts
{ sessionType: 'player-vs-computer', fen: 'start' }
```

- `sessionType` is stored but does not change engine behaviour.
- `fen` sets the session's initial position.
- **`'start'` is not a valid FEN.** chess.js 0.11 ignores it without an error
and leaves an empty board. This is harmless, because every `evaluate-fen`
reloads the position from its own `fen`. The website sends a full FEN
instead.

Replies:

- success: `session-started` with `{ success: true, id: string }` (`id` is the
socket ID)
- failure: `session-error` with `{ error: string }`

If the Stockfish binary fails to launch, the server emits `session-error`
(`"Chess engine failed to start"`) **after** `session-started` has already
been sent.

### `evaluate-fen`

```ts
{ fen: string, move: string, level: 1 | 2 | 3 | 4 | 5 }
```

| Field | Meaning |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fen` | Position to evaluate |
| `move` | Optional move to apply first, in **UCI** format (`e2e4`, promotions like `e7e8q`). Space-separated lists are passed through to the engine. Send `''` for none |
| `level` | Engine **search depth** (`go depth <level>`). Mobile sends difficulty 1–5 directly (confirmed by the team). The server accepts up to 30 and has no lower bound. The website uses 1, 5, 10, 15, 20 |

Replies: `evaluation-complete` or `evaluation-error` (below). Without a
session: `evaluation-error` with `"Session not found!"`.

### Other events (not used by the mobile client)

| Event | Payload | Reply |
| ------------- | ----------------- | --------------------------------------------------------------------------------- |
| `update-fen` | `{ fen: string }` | None on success. `update-error` with `{ err: string }` (note: `err`, not `error`) |
| `end-session` | none | `session-ended` with `{ success: true }` |

## Server → client events

### `evaluation-complete`

Sessions started through `start-session` are always in **move mode**:

```ts
{
mode: 'move',
move: string, // engine's best move, UCI, e.g. "e7e5"
moveDetails: object | null,
newFEN: string,
}
```

`moveDetails` is a chess.js 0.11 move object, or `null` if chess.js could not
apply the move:

```ts
{ color: 'w' | 'b', from: string, to: string, flags: string, piece: string,
san: string, captured?: string, promotion?: string }
```

Example. Request `{ fen: <position after 1. e4>, move: '', level: 3 }`, reply:

```json
{
"mode": "move",
"move": "e7e5",
"moveDetails": {
"color": "b",
"from": "e7",
"to": "e5",
"flags": "b",
"piece": "p",
"san": "e5"
},
"newFEN": "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2"
}
```

An info-mode shape, `{ mode: 'info', output: string[] }`, exists for
server-internal analysis sessions. `start-session` cannot enable it.

#### Caveats

- **`newFEN` and `moveDetails` are wrong when `move` is not empty.** The engine
searches from `fen` + `move`, but the server applies the engine's reply to
`fen` alone. The reply is then illegal, chess.js 0.11 returns `null` without
throwing, `moveDetails` is `null`, and `newFEN` equals the request's `fen`
(neither move applied). The `move` field itself is still correct. The website
avoids this by sending the position **after** the player's move with
`move: ''`, and it applies the engine's `move` locally, ignoring `newFEN`.
**Decision (team):** the mobile client returns only `move`. Callers apply it
to their own board, and `newFEN` and `moveDetails` are ignored.
- **No legal moves.** In checkmate or stalemate, Stockfish answers
`bestmove (none)`. This arrives as `evaluation-complete` with
`move: '(none)'` and `moveDetails: null`, not as `evaluation-error`.

### `evaluation-error`

Payload: `{ error: string }`, e.g. `"Session not found!"` or
`"Invalid move from engine"`.

### `session-started`, `session-error`, `session-ended`

See [Session lifecycle](#session-lifecycle) and the event tables above.

## Request matching

Replies carry no request ID. The server keeps one pending evaluation per
session, and a new `evaluate-fen` resets the previous one. The mobile client
therefore allows only one request at a time and rejects a second call while
the first is pending.

## How the mobile client uses this contract

| Client method | Emits | Resolves on | Rejects on |
| ---------------- | ------------------------------------------ | --------------------------------- | --------------------------------------------------------------------------------------------- |
| `startSession()` | `start-session` (after a lazy connect) | `session-started` (validated) | `connect_error`, `session-error`, `disconnect`, timeout |
| `requestMove()` | `evaluate-fen` with `{ fen, move, level }` | `evaluation-complete` (validated) | invalid level, no session, request already pending, `evaluation-error`, `disconnect`, timeout |
| `disconnect()` | none (closes the socket) | n/a | Rejects anything still pending and removes all listeners |

- Each reply has a 10 second timeout (`timeoutMs`).
- `requestMove()` resolves with `{ move }` only (UCI). The client validates
`mode: 'move'` and `move` with zod, and ignores `newFEN` and `moveDetails`
(see Caveats). Callers apply the move to their own board.
- Screens never touch the socket. They use the `StockfishClient` interface.

## Unresolved

- **Authentication: none.** The server has no handshake auth or tokens. Anyone
who can reach the URL can open sessions. The mobile client sends no
credentials (the issue says not to invent any).
- **Rate limits: none on the socket.** `express-rate-limit` is listed in the
server's `package.json` but is not applied. Each session spawns a Stockfish
process, so connection or session limits are an open server-side question.
- **`(none)` moves** at game end are not yet handled specially by the client.
- **Staging and production URLs** for the Stockfish server are not yet
provided.
69 changes: 47 additions & 22 deletions src/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { z } from 'zod';

const serviceUrl = z.string().trim().min(1).url();

const publicEnvironmentSchema = z.object({
appEnv: z.enum(['development', 'preview', 'production']),
apiBaseUrl: z.string().trim().min(1).url(),
apiBaseUrl: serviceUrl,
stockfishSocketUrl: serviceUrl,
});

export type AppEnvironment = z.infer<typeof publicEnvironmentSchema>['appEnv'];

export interface PublicEnvironment {
appEnv: AppEnvironment;
apiBaseUrl: string;
stockfishSocketUrl: string;
}

export class EnvironmentConfigError extends Error {
Expand All @@ -22,6 +26,7 @@ export class EnvironmentConfigError extends Error {
interface PublicEnvironmentInput {
appEnv?: string;
apiBaseUrl?: string;
stockfishSocketUrl?: string;
}

function invalidConfiguration(details: string): EnvironmentConfigError {
Expand All @@ -30,6 +35,34 @@ function invalidConfiguration(details: string): EnvironmentConfigError {
);
}

function validateServiceUrl(
field: string,
value: string,
appEnv: AppEnvironment,
): string {
const url = new URL(value);

if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw invalidConfiguration(`${field} must use HTTP or HTTPS`);
}

if (appEnv !== 'development' && url.protocol !== 'https:') {
throw invalidConfiguration(`${field} must use HTTPS outside development`);
}

if (url.username || url.password) {
throw invalidConfiguration(`${field} must not contain credentials`);
}

if (url.search || url.hash) {
throw invalidConfiguration(
`${field} must not contain a query string or fragment`,
);
}

return url.toString().replace(/\/+$/, '');
}

export function parsePublicEnvironment(
input: PublicEnvironmentInput,
): PublicEnvironment {
Expand All @@ -46,33 +79,25 @@ export function parsePublicEnvironment(
throw invalidConfiguration(details);
}

const apiUrl = new URL(result.data.apiBaseUrl);

if (apiUrl.protocol !== 'http:' && apiUrl.protocol !== 'https:') {
throw invalidConfiguration('apiBaseUrl must use HTTP or HTTPS');
}

if (result.data.appEnv !== 'development' && apiUrl.protocol !== 'https:') {
throw invalidConfiguration('apiBaseUrl must use HTTPS outside development');
}

if (apiUrl.username || apiUrl.password) {
throw invalidConfiguration('apiBaseUrl must not contain credentials');
}

if (apiUrl.search || apiUrl.hash) {
throw invalidConfiguration(
'apiBaseUrl must not contain a query string or fragment',
);
}
const { appEnv } = result.data;

return Object.freeze({
appEnv: result.data.appEnv,
apiBaseUrl: apiUrl.toString().replace(/\/+$/, ''),
appEnv,
apiBaseUrl: validateServiceUrl(
'apiBaseUrl',
result.data.apiBaseUrl,
appEnv,
),
stockfishSocketUrl: validateServiceUrl(
'stockfishSocketUrl',
result.data.stockfishSocketUrl,
appEnv,
),
});
}

export const env = parsePublicEnvironment({
appEnv: process.env.EXPO_PUBLIC_APP_ENV,
apiBaseUrl: process.env.EXPO_PUBLIC_API_BASE_URL,
stockfishSocketUrl: process.env.EXPO_PUBLIC_STOCKFISH_SOCKET_URL,
});
Loading
Loading