Améliorations de stabilité de l'application

This commit is contained in:
Feror 2026-03-24 10:06:59 +01:00
parent 62beda2bf7
commit 813ffe2171
15 changed files with 913 additions and 205 deletions

View file

@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Linking } from "react-native";
import { Linking, StyleSheet, View } from "react-native";
import {
CommonActions,
NavigationContainer,
type LinkingOptions,
useNavigationContainerRef,
@ -13,6 +14,7 @@ import type { RootStackParamList } from "./navigation/types";
import { SessionProvider, useSession } from "./state/session-context";
import { getNavigationTheme, useTheme } from "./theme";
import { parseNotificationTarget, type NotificationTarget } from "./notifications";
import ConnectionBanner from "./components/ConnectionBanner";
function extractGameId(url: string): string | null {
try {
@ -93,24 +95,28 @@ function RootNavigationGate() {
if (pending.type === "chat") {
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
const targetTab = manager.isBanker ? "BankerChat" : "PlayerChat";
navigationRef.navigate(
targetStack as never,
{
navigationRef.dispatch(
CommonActions.navigate({
name: targetStack,
params: {
screen: targetTab,
params: {
screen: "ChatThread",
params: { chatId: pending.chatId },
},
} as never,
},
}),
);
return;
}
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
const targetTab = manager.isBanker ? "BankerDashboard" : "PlayerHome";
navigationRef.navigate(
targetStack as never,
{ screen: targetTab } as never,
navigationRef.dispatch(
CommonActions.navigate({
name: targetStack,
params: { screen: targetTab },
}),
);
}, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]);
@ -145,7 +151,7 @@ function RootNavigationGate() {
if (!manager.sessionId) {
target = "Entry";
} else if (!manager.session) {
target = manager.connectionState === "error" ? "Entry" : "Lobby";
target = "Lobby";
} else if (manager.session.status === "lobby") {
target = "Lobby";
} else if (manager.isBanker) {
@ -195,6 +201,7 @@ function RootNavigationGate() {
}, [handleNotificationResponse]);
return (
<View style={styles.container}>
<NavigationContainer
ref={navigationRef}
onReady={() => setNavReady(true)}
@ -202,7 +209,18 @@ function RootNavigationGate() {
theme={navigationTheme}
>
<AppNavigator />
<ConnectionBanner
visible={
Boolean(manager.sessionId) &&
manager.connectionState !== "idle" &&
manager.connectionState !== "open"
}
connectionState={manager.connectionState}
reconnectAttempt={manager.reconnectAttempt}
onRetry={manager.retryConnection}
/>
</NavigationContainer>
</View>
);
}
@ -220,3 +238,9 @@ export default function App() {
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});

View file

@ -0,0 +1,102 @@
import React, { useMemo } from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useI18n } from "../i18n";
import { useTheme, type AppTheme } from "../theme";
import type { SessionConnectionState } from "../state/connection";
type ConnectionBannerProps = {
connectionState: SessionConnectionState;
reconnectAttempt: number;
visible: boolean;
onRetry: () => void;
};
export default function ConnectionBanner({
connectionState,
reconnectAttempt,
visible,
onRetry,
}: ConnectionBannerProps) {
const { t } = useI18n();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const insets = useSafeAreaInsets();
if (!visible) {
return null;
}
const title =
connectionState === "connecting"
? t("connection.connecting")
: t("connection.reconnecting");
const detail = t("connection.reconnectingDetail", {
count: reconnectAttempt || 1,
});
return (
<View
pointerEvents="box-none"
style={[styles.wrapper, { top: insets.top + 8 }]}
>
<View style={styles.banner}>
<View style={styles.copy}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.detail}>{detail}</Text>
</View>
<TouchableOpacity style={styles.button} onPress={onRetry}>
<Text style={styles.buttonText}>{t("common.retryNow")}</Text>
</TouchableOpacity>
</View>
</View>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
wrapper: {
position: "absolute",
left: 12,
right: 12,
zIndex: 20,
},
banner: {
borderRadius: 16,
paddingHorizontal: 14,
paddingVertical: 12,
backgroundColor: theme.colors.headerBackground,
borderWidth: 1,
borderColor: theme.colors.border,
flexDirection: "row",
alignItems: "center",
gap: 12,
shadowColor: "#000",
shadowOpacity: 0.12,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
},
copy: {
flex: 1,
gap: 2,
},
title: {
color: theme.colors.text,
fontWeight: "700",
},
detail: {
color: theme.colors.textMuted,
fontSize: 12,
},
button: {
borderRadius: 999,
backgroundColor: theme.colors.primary,
paddingHorizontal: 12,
paddingVertical: 10,
},
buttonText: {
color: theme.colors.primaryText,
fontWeight: "600",
},
});

View file

@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react";
import type { TransactionKind } from "./shared/types";
type Locale = "en" | "fr";
@ -22,6 +23,8 @@ const translations = {
"common.you": "You",
"common.join": "Join",
"common.continue": "Continue",
"common.retryNow": "Retry now",
"common.transactions": "Transactions",
"common.send": "Send",
"common.reset": "Reset",
"common.cancel": "Cancel",
@ -153,11 +156,15 @@ const translations = {
"transaction.transfer": "Transfer",
"transaction.banker_adjust": "Banker adjustment",
"transaction.banker_force_transfer": "Forced transfer",
"connection.connecting": "Connecting to your game",
"connection.reconnecting": "Reconnecting to your game",
"connection.reconnectingDetail": "Attempt {count}. Live updates will resume automatically.",
"error.parseResponse": "Unable to parse server response",
"error.createSession": "Unable to create session",
"error.joinSession": "Unable to join session",
"error.loadSessionInfo": "Unable to load session info",
"error.connectionNotReady": "Connection not ready",
"error.reconnecting": "Reconnecting to the game. Please try again in a moment.",
},
fr: {
"app.name": "Negopoly Companion",
@ -178,6 +185,8 @@ const translations = {
"common.you": "Vous",
"common.join": "Rejoindre",
"common.continue": "Continuer",
"common.retryNow": "Réessayer",
"common.transactions": "Transactions",
"common.send": "Envoyer",
"common.reset": "Réinitialiser",
"common.cancel": "Annuler",
@ -309,11 +318,15 @@ const translations = {
"transaction.transfer": "Transfert",
"transaction.banker_adjust": "Ajustement banquier",
"transaction.banker_force_transfer": "Transfert forcé",
"connection.connecting": "Connexion à la partie",
"connection.reconnecting": "Reconnexion à la partie",
"connection.reconnectingDetail": "Tentative {count}. Les mises à jour vont reprendre automatiquement.",
"error.parseResponse": "Impossible de lire la réponse du serveur",
"error.createSession": "Impossible de créer la session",
"error.joinSession": "Impossible de rejoindre la session",
"error.loadSessionInfo": "Impossible de charger les infos de session",
"error.connectionNotReady": "Connexion non prête",
"error.reconnecting": "Reconnexion à la partie en cours. Réessayez dans un instant.",
},
} as const;
@ -326,7 +339,7 @@ export function getLocale(): Locale {
function translate(locale: Locale, key: I18nKey, vars?: Record<string, string | number>) {
const table = translations[locale] ?? translations.en;
let template = table[key] ?? translations.en[key] ?? key;
let template: string = table[key] ?? translations.en[key] ?? key;
if (vars) {
Object.entries(vars).forEach(([name, value]) => {
template = template.replace(new RegExp(`\\{${name}\\}`, "g"), String(value));
@ -349,7 +362,7 @@ export function tStatic(key: I18nKey, vars?: Record<string, string | number>) {
}
export function formatTransactionKind(
kind: "transfer" | "banker_adjust" | "banker_force_transfer",
kind: TransactionKind,
t: (key: I18nKey) => string,
) {
return t(`transaction.${kind}` as I18nKey);

View file

@ -8,6 +8,8 @@ export type NotificationTarget =
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),

View file

@ -27,10 +27,10 @@ function formatTransactionTimestamp(value: number) {
}
function getTransactionLabel(
kind: string,
kind: Transaction["kind"],
note: string | null | undefined,
t: ReturnType<typeof useI18n>["t"],
) {
): string {
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
const trimmed = note?.trim();
return trimmed || t("common.noReason");
@ -43,15 +43,15 @@ function getTransactionDisplay(
viewerId: string | null | undefined,
players: Player[],
t: ReturnType<typeof useI18n>["t"],
) {
): { label: string; subtitle: string; amount: string; outgoing: boolean } {
const absAmount = Math.abs(transaction.amount);
const label = getTransactionLabel(transaction.kind, transaction.note, t);
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
const from = findPlayer(transaction.fromId);
const to = findPlayer(transaction.toId);
let outgoing = false;
let counterparty = t("common.bank");
const timeLabel = formatTransactionTimestamp(transaction.createdAt);
let counterparty: string = t("common.bank");
const timeLabel: string = formatTransactionTimestamp(transaction.createdAt);
if (transaction.kind === "banker_adjust") {
outgoing = transaction.amount < 0;

View file

@ -47,10 +47,10 @@ function formatTransactionTimestamp(value: number) {
}
function getTransactionLabel(
kind: string,
kind: Transaction["kind"],
note: string | null | undefined,
t: ReturnType<typeof useI18n>["t"],
) {
): string {
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
const trimmed = note?.trim();
return trimmed || t("common.noReason");
@ -63,15 +63,15 @@ function getTransactionDisplay(
viewerId: string | null | undefined,
players: Player[],
t: ReturnType<typeof useI18n>["t"],
) {
): { label: string; subtitle: string; amount: string; outgoing: boolean } {
const absAmount = Math.abs(transaction.amount);
const label = getTransactionLabel(transaction.kind, transaction.note, t);
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
const from = findPlayer(transaction.fromId);
const to = findPlayer(transaction.toId);
let outgoing = false;
let counterparty = t("common.bank");
const timeLabel = formatTransactionTimestamp(transaction.createdAt);
let counterparty: string = t("common.bank");
const timeLabel: string = formatTransactionTimestamp(transaction.createdAt);
if (transaction.kind === "banker_adjust") {
outgoing = transaction.amount < 0;
@ -327,6 +327,9 @@ export default function BankerToolsScreen() {
);
}
const session = manager.session;
const me = manager.me;
const normalizedAdjustAmount = adjustAmount.replace(",", ".");
const adjustValue = Number(normalizedAdjustAmount);
const canAdjust =
@ -436,7 +439,7 @@ export default function BankerToolsScreen() {
const display = getTransactionDisplay(
transaction,
selectedPlayerId,
manager.session?.players ?? [],
session.players,
t,
);
return (
@ -695,14 +698,14 @@ export default function BankerToolsScreen() {
manager.sendMessage({
type: "banker_blackout",
sessionId: manager.sessionId,
bankerId: manager.me?.id,
active: !manager.session.blackoutActive,
reason: !manager.session.blackoutActive ? blackoutReason : null,
bankerId: me.id,
active: !session.blackoutActive,
reason: !session.blackoutActive ? blackoutReason : null,
})
}
>
<Text style={styles.buttonDangerText}>
{manager.session.blackoutActive
{session.blackoutActive
? t("banker.tools.blackoutDisable")
: t("banker.tools.blackoutEnable")}
</Text>
@ -713,7 +716,7 @@ export default function BankerToolsScreen() {
manager.sendMessage({
type: "banker_end",
sessionId: manager.sessionId,
bankerId: manager.me?.id,
bankerId: me.id,
})
}
>

View file

@ -65,25 +65,28 @@ export default function LobbyScreen() {
);
}
const canStart = manager.isBanker && manager.session.status === "lobby";
const pendingTakeover = manager.session.takeoverRequests.find(
const session = manager.session;
const me = manager.me;
const canStart = manager.isBanker && session.status === "lobby";
const pendingTakeover = session.takeoverRequests.find(
(request) =>
request.requesterId === manager.playerId && request.status === "pending",
);
const pendingRequests = manager.isBanker
? manager.session.takeoverRequests.filter((request) => request.status === "pending")
? session.takeoverRequests.filter((request) => request.status === "pending")
: [];
return (
<View style={containerStyle}>
<Text style={styles.title}>{t("lobby.title")}</Text>
<Text style={styles.subtitle}>{t("lobby.code", { code: manager.session.code })}</Text>
<Text style={styles.subtitle}>{t("lobby.code", { code: session.code })}</Text>
{pendingTakeover ? (
<Text style={styles.helper}>{t("entry.takeoverPending")}</Text>
) : null}
<FlatList
data={manager.session.players}
data={session.players}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
@ -108,10 +111,9 @@ export default function LobbyScreen() {
<View style={styles.takeoverList}>
{pendingRequests.map((request) => {
const requester =
manager.session.players.find((player) => player.id === request.requesterId) ??
null;
session.players.find((player) => player.id === request.requesterId) ?? null;
const dummy =
manager.session.players.find((player) => player.id === request.dummyId) ?? null;
session.players.find((player) => player.id === request.dummyId) ?? null;
const requesterName =
requester?.name ?? request.requesterName ?? t("common.player");
return (
@ -128,7 +130,7 @@ export default function LobbyScreen() {
manager.sendMessage({
type: "banker_takeover_approve",
sessionId: manager.sessionId,
bankerId: manager.me?.id,
bankerId: me.id,
dummyId: request.dummyId,
requesterId: request.requesterId,
})
@ -143,7 +145,7 @@ export default function LobbyScreen() {
</View>
) : null}
{manager.isBanker && manager.session.status === "lobby" && (
{manager.isBanker && session.status === "lobby" && (
<View style={styles.card}>
<Text style={styles.cardTitle}>{t("lobby.addDummyTitle")}</Text>
<Text style={styles.helper}>{t("lobby.addDummySubtitle")}</Text>
@ -168,7 +170,7 @@ export default function LobbyScreen() {
manager.sendMessage({
type: "banker_create_dummy",
sessionId: manager.sessionId,
bankerId: manager.me?.id,
bankerId: me.id,
name: dummyName,
balance: Number(dummyBalance) || undefined,
});
@ -188,7 +190,7 @@ export default function LobbyScreen() {
manager.sendMessage({
type: "banker_start",
sessionId: manager.sessionId,
bankerId: manager.me?.id,
bankerId: me.id,
})
}
>

View file

@ -28,10 +28,10 @@ function formatTransactionTimestamp(value: number) {
}
function getTransactionLabel(
kind: string,
kind: Transaction["kind"],
note: string | null | undefined,
t: ReturnType<typeof useI18n>["t"],
) {
): string {
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
const trimmed = note?.trim();
return trimmed || t("common.noReason");
@ -44,15 +44,15 @@ function getTransactionDisplay(
viewerId: string | null | undefined,
players: Player[],
t: ReturnType<typeof useI18n>["t"],
) {
): { label: string; subtitle: string; amount: string; outgoing: boolean } {
const absAmount = Math.abs(transaction.amount);
const label = getTransactionLabel(transaction.kind, transaction.note, t);
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
const from = findPlayer(transaction.fromId);
const to = findPlayer(transaction.toId);
let outgoing = false;
let counterparty = t("common.bank");
const timeLabel = formatTransactionTimestamp(transaction.createdAt);
let counterparty: string = t("common.bank");
const timeLabel: string = formatTransactionTimestamp(transaction.createdAt);
if (transaction.kind === "banker_adjust") {
outgoing = transaction.amount < 0;

View file

@ -64,6 +64,7 @@ export default function ChatThreadScreen() {
</View>
);
}
const activeThread = thread;
const showEmp = manager.session.blackoutActive && !manager.isBanker;
function handleSend() {
@ -74,7 +75,7 @@ export default function ChatThreadScreen() {
sessionId: manager.sessionId,
playerId: manager.me?.id,
body: message.trim(),
groupId: thread.id === "global" ? null : thread.id,
groupId: activeThread.id === "global" ? null : activeThread.id,
});
setMessage("");
}
@ -87,7 +88,7 @@ export default function ChatThreadScreen() {
keyboardVerticalOffset={keyboardOffset}
>
<View style={styles.header}>
<Text style={styles.headerTitle}>{thread.name}</Text>
<Text style={styles.headerTitle}>{activeThread.name}</Text>
<Text style={styles.headerSubtitle}>{threadKindLabel}</Text>
</View>

View file

@ -0,0 +1,29 @@
import { expect, test } from "bun:test";
import {
CONNECTION_IDLE_TIMEOUT_MS,
RECONNECT_MAX_DELAY_MS,
getReconnectDelayMs,
isConnectionStale,
isTerminalSocketClose,
} from "./connection";
test("getReconnectDelayMs grows with backoff and caps at the max delay", () => {
expect(getReconnectDelayMs(0, () => 0)).toBe(800);
expect(getReconnectDelayMs(1, () => 0)).toBe(1600);
expect(getReconnectDelayMs(4, () => 0)).toBe(8000);
expect(getReconnectDelayMs(8, () => 1)).toBe(Math.round(RECONNECT_MAX_DELAY_MS * 1.2));
});
test("isConnectionStale respects the idle timeout", () => {
const now = 100_000;
expect(isConnectionStale(now - CONNECTION_IDLE_TIMEOUT_MS + 1, now)).toBe(false);
expect(isConnectionStale(now - CONNECTION_IDLE_TIMEOUT_MS - 1, now)).toBe(true);
expect(isConnectionStale(null, now)).toBe(false);
});
test("isTerminalSocketClose only resets on invalid session/player closes", () => {
expect(isTerminalSocketClose(1008, "Session not found")).toBe(true);
expect(isTerminalSocketClose(1008, "Player not found")).toBe(true);
expect(isTerminalSocketClose(4000, "Connection stale")).toBe(false);
expect(isTerminalSocketClose(1008, "Policy violation")).toBe(false);
});

View file

@ -0,0 +1,40 @@
export type SessionConnectionState =
| "idle"
| "connecting"
| "open"
| "reconnecting"
| "error";
export const CONNECTION_PING_INTERVAL_MS = 15_000;
export const CONNECTION_IDLE_TIMEOUT_MS = 45_000;
export const CONNECTION_WATCHDOG_INTERVAL_MS = 5_000;
export const RECONNECT_BASE_DELAY_MS = 1_000;
export const RECONNECT_MAX_DELAY_MS = 10_000;
export function getReconnectDelayMs(
attempt: number,
random: () => number = Math.random,
): number {
const normalizedAttempt = Math.max(0, attempt);
const baseDelay = Math.min(
RECONNECT_MAX_DELAY_MS,
RECONNECT_BASE_DELAY_MS * 2 ** normalizedAttempt,
);
const jitterMultiplier = 0.8 + random() * 0.4;
return Math.round(baseDelay * jitterMultiplier);
}
export function isTerminalSocketClose(code?: number, reason?: string): boolean {
return code === 1008 && /session not found|player not found/i.test(reason ?? "");
}
export function isConnectionStale(
lastActivityAt: number | null | undefined,
nowMs = Date.now(),
timeoutMs = CONNECTION_IDLE_TIMEOUT_MS,
): boolean {
if (!lastActivityAt) {
return false;
}
return nowMs - lastActivityAt > timeoutMs;
}

View file

@ -1,9 +1,18 @@
import { useEffect, useRef, useState } from "react";
import { AppState, type AppStateStatus } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types";
import { getApiBaseUrl, getWsUrl } from "../config/api";
import { tStatic } from "../i18n";
import { registerForPushNotificationsAsync } from "../notifications";
import {
CONNECTION_PING_INTERVAL_MS,
CONNECTION_WATCHDOG_INTERVAL_MS,
type SessionConnectionState,
getReconnectDelayMs,
isConnectionStale,
isTerminalSocketClose,
} from "./connection";
const STORAGE_KEY = "negopoly:session";
@ -13,6 +22,12 @@ type StoredSession = {
playerId: string;
};
type IncomingMessage =
| { type: "state"; session: SessionSnapshot }
| { type: "error"; message: string }
| { type: "takeover_approved"; assignedPlayerId: string }
| { type: "pong" };
async function readStoredSession(): Promise<StoredSession | null> {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
@ -37,18 +52,265 @@ export function useSessionManager() {
const [playerId, setPlayerId] = useState("");
const [session, setSession] = useState<SessionSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [connectionState, setConnectionState] = useState<
"idle" | "connecting" | "open" | "error"
>("idle");
const [tick, setTick] = useState(0);
const [connectionState, setConnectionState] =
useState<SessionConnectionState>("idle");
const [pushToken, setPushToken] = useState<{
token: string;
platform: "ios" | "android";
} | null>(null);
const [reconnectAttempt, setReconnectAttempt] = useState(0);
const [lastActivityAt, setLastActivityAt] = useState<number | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const sessionIdRef = useRef(sessionId);
const sessionCodeRef = useRef(sessionCode);
const playerIdRef = useRef(playerId);
const sessionRef = useRef<SessionSnapshot | null>(session);
const connectionGenerationRef = useRef(0);
const reconnectAttemptRef = useRef(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const watchdogTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const suppressReconnectRef = useRef(false);
const lastActivityAtRef = useRef<number | null>(null);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
const lastPushRegistrationRef = useRef<string | null>(null);
function markActivity(at = Date.now()) {
lastActivityAtRef.current = at;
setLastActivityAt(at);
}
function clearReconnectTimer() {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
}
function clearSocketTimers() {
if (pingTimerRef.current) {
clearInterval(pingTimerRef.current);
pingTimerRef.current = null;
}
if (watchdogTimerRef.current) {
clearInterval(watchdogTimerRef.current);
watchdogTimerRef.current = null;
}
}
function closeSocket(ws: WebSocket | null, code?: number, reason?: string) {
if (!ws || ws.readyState === WebSocket.CLOSED) {
return;
}
try {
if (typeof code === "number") {
ws.close(code, reason);
return;
}
ws.close();
} catch {
// Ignore close failures.
}
}
function teardownConnection() {
clearReconnectTimer();
clearSocketTimers();
connectionGenerationRef.current += 1;
const ws = wsRef.current;
wsRef.current = null;
closeSocket(ws);
}
function scheduleReconnect(generation: number) {
if (
suppressReconnectRef.current ||
generation !== connectionGenerationRef.current ||
!sessionIdRef.current ||
!playerIdRef.current
) {
return;
}
clearReconnectTimer();
const nextAttempt = reconnectAttemptRef.current + 1;
reconnectAttemptRef.current = nextAttempt;
setReconnectAttempt(nextAttempt);
setConnectionState("reconnecting");
const delay = getReconnectDelayMs(nextAttempt - 1);
reconnectTimerRef.current = setTimeout(() => {
reconnectTimerRef.current = null;
openSocket("retry");
}, delay);
}
function startSocketTimers(ws: WebSocket, generation: number) {
clearSocketTimers();
pingTimerRef.current = setInterval(() => {
if (
generation !== connectionGenerationRef.current ||
ws.readyState !== WebSocket.OPEN
) {
return;
}
try {
ws.send(
JSON.stringify({
type: "ping",
sessionId: sessionIdRef.current,
playerId: playerIdRef.current,
}),
);
} catch {
closeSocket(ws, 4001, "Ping failed");
}
}, CONNECTION_PING_INTERVAL_MS);
watchdogTimerRef.current = setInterval(() => {
if (
generation !== connectionGenerationRef.current ||
appStateRef.current !== "active"
) {
return;
}
if (!isConnectionStale(lastActivityAtRef.current)) {
return;
}
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
closeSocket(ws, 4000, "Connection stale");
return;
}
scheduleReconnect(generation);
}, CONNECTION_WATCHDOG_INTERVAL_MS);
}
function openSocket(reason: "initial" | "retry" | "resume" | "manual") {
const targetSessionId = sessionIdRef.current;
const targetPlayerId = playerIdRef.current;
if (!targetSessionId || !targetPlayerId) {
return;
}
clearReconnectTimer();
clearSocketTimers();
const previousSocket = wsRef.current;
const generation = connectionGenerationRef.current + 1;
connectionGenerationRef.current = generation;
wsRef.current = null;
closeSocket(previousSocket);
const recovering = Boolean(sessionRef.current) || reason !== "initial";
setConnectionState(recovering ? "reconnecting" : "connecting");
const ws = new WebSocket(getWsUrl(targetSessionId, targetPlayerId));
wsRef.current = ws;
ws.onopen = () => {
if (connectionGenerationRef.current !== generation) {
return;
}
reconnectAttemptRef.current = 0;
setReconnectAttempt(0);
setConnectionState("open");
setError(null);
markActivity();
startSocketTimers(ws, generation);
};
ws.onmessage = (event) => {
if (connectionGenerationRef.current !== generation) {
return;
}
markActivity();
try {
const message = JSON.parse(event.data) as IncomingMessage;
if (message.type === "state") {
setSession(message.session);
return;
}
if (message.type === "error") {
setError(message.message);
return;
}
if (message.type === "takeover_approved") {
const assignedId = message.assignedPlayerId;
setPlayerId(assignedId);
if (sessionIdRef.current && sessionCodeRef.current) {
void writeStoredSession({
sessionId: sessionIdRef.current,
sessionCode: sessionCodeRef.current,
playerId: assignedId,
});
}
}
} catch {
setError(tStatic("error.parseResponse"));
}
};
ws.onerror = () => {
if (connectionGenerationRef.current !== generation) {
return;
}
setConnectionState("reconnecting");
};
ws.onclose = (event) => {
if (connectionGenerationRef.current !== generation) {
return;
}
if (wsRef.current === ws) {
wsRef.current = null;
}
clearSocketTimers();
const reasonText = typeof event?.reason === "string" ? event.reason : "";
if (isTerminalSocketClose(event?.code, reasonText)) {
setConnectionState("error");
void resetSession();
return;
}
if (
suppressReconnectRef.current ||
!sessionIdRef.current ||
!playerIdRef.current
) {
setConnectionState("idle");
return;
}
scheduleReconnect(generation);
};
}
function retryConnection() {
if (!sessionIdRef.current || !playerIdRef.current) {
return;
}
suppressReconnectRef.current = false;
reconnectAttemptRef.current = 0;
setReconnectAttempt(0);
openSocket("manual");
}
useEffect(() => {
sessionIdRef.current = sessionId;
sessionCodeRef.current = sessionCode;
playerIdRef.current = playerId;
}, [playerId, sessionCode, sessionId]);
useEffect(() => {
sessionRef.current = session;
}, [session]);
useEffect(() => {
let mounted = true;
readStoredSession().then((stored) => {
@ -74,8 +336,39 @@ export function useSessionManager() {
}, []);
useEffect(() => {
const timer = setInterval(() => setTick((value) => value + 1), 1000);
return () => clearInterval(timer);
const subscription = AppState.addEventListener("change", (nextState) => {
const previousState = appStateRef.current;
appStateRef.current = nextState;
if (
previousState !== "active" &&
nextState === "active" &&
sessionIdRef.current &&
playerIdRef.current
) {
const socket = wsRef.current;
const socketOpen = socket?.readyState === WebSocket.OPEN;
if (!socketOpen || isConnectionStale(lastActivityAtRef.current)) {
retryConnection();
return;
}
try {
socket.send(
JSON.stringify({
type: "ping",
sessionId: sessionIdRef.current,
playerId: playerIdRef.current,
}),
);
} catch {
retryConnection();
}
}
});
return () => {
subscription.remove();
};
}, []);
useEffect(() => {
@ -83,6 +376,29 @@ export function useSessionManager() {
void registerPushTokenFor(sessionId, playerId);
}, [pushToken, sessionId, playerId]);
useEffect(() => {
if (!sessionId || !playerId) {
suppressReconnectRef.current = true;
teardownConnection();
reconnectAttemptRef.current = 0;
setReconnectAttempt(0);
lastActivityAtRef.current = null;
setLastActivityAt(null);
setConnectionState("idle");
setSession(null);
return;
}
suppressReconnectRef.current = false;
reconnectAttemptRef.current = 0;
setReconnectAttempt(0);
openSocket("initial");
return () => {
teardownConnection();
};
}, [playerId, sessionId]);
async function registerPushTokenFor(targetSessionId: string, targetPlayerId: string) {
if (!pushToken) return;
const signature = `${targetSessionId}:${targetPlayerId}:${pushToken.platform}:${pushToken.token}`;
@ -104,64 +420,6 @@ export function useSessionManager() {
}
}
useEffect(() => {
if (!sessionId || !playerId) {
setConnectionState("idle");
setSession(null);
return;
}
setConnectionState("connecting");
const ws = new WebSocket(getWsUrl(sessionId, playerId));
wsRef.current = ws;
ws.onopen = () => setConnectionState("open");
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === "state") {
setSession(message.session as SessionSnapshot);
}
if (message.type === "error") {
setError(message.message);
}
if (message.type === "takeover_approved") {
const assignedId = message.assignedPlayerId as string;
setPlayerId(assignedId);
if (sessionId && sessionCode) {
writeStoredSession({
sessionId,
sessionCode,
playerId: assignedId,
});
}
}
} catch {
setError(tStatic("error.parseResponse"));
}
};
ws.onerror = () => setConnectionState("error");
ws.onclose = (event) => {
setConnectionState("error");
const reason = typeof event?.reason === "string" ? event.reason : "";
if (event?.code === 1008 && /session not found|player not found/i.test(reason)) {
resetSession();
}
};
const pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping", sessionId, playerId }));
}
}, 15000);
return () => {
clearInterval(pingTimer);
ws.close();
};
}, [sessionId, playerId]);
async function requestTakeover(
dummyId: string,
overrideSessionId?: string,
@ -215,6 +473,7 @@ export function useSessionManager() {
async function createSession(bankerName: string) {
setError(null);
setSession(null);
try {
const response = await fetch(`${getApiBaseUrl()}/api/session`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -234,6 +493,10 @@ export function useSessionManager() {
playerId: data.playerId,
});
return data;
} catch {
setError(tStatic("error.createSession"));
return null;
}
}
async function joinSession(code: string, name: string) {
@ -249,6 +512,7 @@ export function useSessionManager() {
? storedNow.playerId
: undefined;
try {
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -268,6 +532,10 @@ export function useSessionManager() {
playerId: data.playerId,
});
return data;
} catch {
setError(tStatic("error.joinSession"));
return null;
}
}
async function requestTakeoverToken(
@ -339,23 +607,39 @@ export function useSessionManager() {
async function fetchSessionPreview(code: string): Promise<SessionPreview | null> {
if (!code) return null;
try {
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`);
if (!response.ok) {
setError(tStatic("error.loadSessionInfo"));
return null;
}
return (await response.json()) as SessionPreview;
} catch {
setError(tStatic("error.loadSessionInfo"));
return null;
}
}
function sendMessage(payload: Record<string, unknown>) {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
setError(tStatic("error.connectionNotReady"));
retryConnection();
setError(
connectionState === "reconnecting" || connectionState === "connecting"
? tStatic("error.reconnecting")
: tStatic("error.connectionNotReady"),
);
return;
}
wsRef.current.send(JSON.stringify(payload));
}
async function resetSession() {
suppressReconnectRef.current = true;
teardownConnection();
reconnectAttemptRef.current = 0;
setReconnectAttempt(0);
lastActivityAtRef.current = null;
setLastActivityAt(null);
try {
await clearStoredSession();
} catch {
@ -370,15 +654,8 @@ export function useSessionManager() {
}
async function leaveSession() {
const ws = wsRef.current;
wsRef.current = null;
if (ws && ws.readyState !== WebSocket.CLOSED) {
try {
ws.close();
} catch {
// Ignore failures while closing the socket.
}
}
suppressReconnectRef.current = true;
teardownConnection();
await resetSession();
}
@ -392,9 +669,10 @@ export function useSessionManager() {
session,
me,
isBanker,
tick,
error,
connectionState,
reconnectAttempt,
lastActivityAt,
setError,
createSession,
joinSession,
@ -404,6 +682,7 @@ export function useSessionManager() {
sendMessage,
resetSession,
leaveSession,
retryConnection,
setSessionId,
setPlayerId,
setSession,

View file

@ -88,6 +88,9 @@ export type ServerMessage =
type: "state";
session: SessionSnapshot;
}
| {
type: "pong";
}
| {
type: "error";
message: string;

102
server/websocket.test.ts Normal file
View file

@ -0,0 +1,102 @@
import { afterEach, expect, test } from "bun:test";
import { createSession, removeSession } from "./store";
import { joinSession } from "./domain";
import {
STALE_SOCKET_TIMEOUT_MS,
handleSocketMessage,
reapStaleSockets,
registerSocket,
resetWebsocketStateForTests,
unregisterSocket,
} from "./websocket";
const OPEN = 1;
const CLOSED = 3;
type FakeSocket = {
readyState: number;
sent: string[];
closes: Array<{ code?: number; reason?: string }>;
send: (payload: string) => void;
close: (code?: number, reason?: string) => void;
};
function createFakeSocket(): FakeSocket {
return {
readyState: OPEN,
sent: [],
closes: [],
send(payload: string) {
this.sent.push(payload);
},
close(code?: number, reason?: string) {
this.closes.push({ code, reason });
this.readyState = CLOSED;
},
};
}
afterEach(() => {
resetWebsocketStateForTests();
});
test("overlapping sockets do not disconnect a player until the last socket closes", () => {
const { session } = createSession("Banker");
const player = joinSession(session, "Jules");
const firstSocket = createFakeSocket();
const secondSocket = createFakeSocket();
registerSocket(firstSocket as unknown as WebSocket, session.id, player.id);
registerSocket(secondSocket as unknown as WebSocket, session.id, player.id);
unregisterSocket(firstSocket as unknown as WebSocket);
expect(session.players.get(player.id)?.connected).toBe(true);
expect(session.players.get(player.id)?.isDummy).toBe(false);
unregisterSocket(secondSocket as unknown as WebSocket);
expect(session.players.get(player.id)?.connected).toBe(false);
expect(session.players.get(player.id)?.isDummy).toBe(true);
removeSession(session.id);
});
test("stale sockets are reaped and disconnect the player", () => {
const { session } = createSession("Banker");
const player = joinSession(session, "Rosa");
const socket = createFakeSocket();
registerSocket(socket as unknown as WebSocket, session.id, player.id);
reapStaleSockets(Date.now() + STALE_SOCKET_TIMEOUT_MS + 1);
expect(socket.closes[0]).toEqual({ code: 4000, reason: "Connection stale" });
expect(session.players.get(player.id)?.connected).toBe(false);
expect(session.players.get(player.id)?.isDummy).toBe(true);
removeSession(session.id);
});
test("invalid session registration closes the socket with a terminal code", () => {
const socket = createFakeSocket();
registerSocket(socket as unknown as WebSocket, "missing-session", "missing-player");
expect(socket.closes[0]).toEqual({ code: 1008, reason: "Session not found" });
});
test("ping messages update liveness and emit a pong", () => {
const { session } = createSession("Banker");
const player = joinSession(session, "Nina");
const socket = createFakeSocket();
registerSocket(socket as unknown as WebSocket, session.id, player.id);
socket.sent = [];
handleSocketMessage(
socket as unknown as WebSocket,
JSON.stringify({ type: "ping", sessionId: session.id, playerId: player.id }),
);
expect(socket.sent.some((entry) => entry.includes('"type":"pong"'))).toBe(true);
removeSession(session.id);
});

View file

@ -20,10 +20,24 @@ import { getSession, removeSession } from "./store";
import { now } from "./util";
import { notifyChat, notifyTransaction } from "./notifications";
export const STALE_SOCKET_TIMEOUT_MS = 45_000;
const STALE_SOCKET_REAP_INTERVAL_MS = 10_000;
type SocketMeta = {
sessionId: string;
playerId: string;
lastSeenAt: number;
};
const socketsBySession = new Map<string, Set<WebSocket>>();
const metaBySocket = new WeakMap<WebSocket, { sessionId: string; playerId: string }>();
const socketsByPlayer = new Map<string, Set<WebSocket>>();
let metaBySocket = new WeakMap<WebSocket, SocketMeta>();
const testTimers = new Map<string, ReturnType<typeof setTimeout>>();
function playerSocketKey(sessionId: string, playerId: string): string {
return `${sessionId}:${playerId}`;
}
function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
@ -55,7 +69,7 @@ function runTestTransfer(sessionId: string): void {
let attempts = 0;
while (attempts < 5) {
const from = players[randomInt(0, players.length - 1)];
let to = players[randomInt(0, players.length - 1)];
const to = players[randomInt(0, players.length - 1)];
if (to.id === from.id) {
attempts += 1;
continue;
@ -99,6 +113,54 @@ function getSessionSockets(sessionId: string): Set<WebSocket> {
return set;
}
function getPlayerSockets(sessionId: string, playerId: string): Set<WebSocket> {
const key = playerSocketKey(sessionId, playerId);
let set = socketsByPlayer.get(key);
if (!set) {
set = new Set();
socketsByPlayer.set(key, set);
}
return set;
}
function deleteSocketFromTracking(ws: WebSocket, meta: SocketMeta): {
sessionSocketCount: number;
playerSocketCount: number;
} {
const sessionSockets = socketsBySession.get(meta.sessionId);
if (sessionSockets) {
sessionSockets.delete(ws);
if (sessionSockets.size === 0) {
socketsBySession.delete(meta.sessionId);
}
}
const playerKey = playerSocketKey(meta.sessionId, meta.playerId);
const playerSockets = socketsByPlayer.get(playerKey);
if (playerSockets) {
playerSockets.delete(ws);
if (playerSockets.size === 0) {
socketsByPlayer.delete(playerKey);
}
}
metaBySocket.delete(ws);
return {
sessionSocketCount: sessionSockets?.size ?? 0,
playerSocketCount: playerSockets?.size ?? 0,
};
}
function touchSocket(ws: WebSocket): SocketMeta | null {
const meta = metaBySocket.get(ws);
if (!meta) {
return null;
}
meta.lastSeenAt = now();
return meta;
}
export function registerSocket(ws: WebSocket, sessionId: string, playerId: string): void {
const session = getSession(sessionId);
if (!session) {
@ -110,12 +172,15 @@ export function registerSocket(ws: WebSocket, sessionId: string, playerId: strin
ws.close(1008, "Player not found");
return;
}
const meta: SocketMeta = { sessionId, playerId, lastSeenAt: now() };
metaBySocket.set(ws, meta);
getSessionSockets(sessionId).add(ws);
getPlayerSockets(sessionId, playerId).add(ws);
player.connected = true;
player.isDummy = false;
player.lastActiveAt = now();
metaBySocket.set(ws, { sessionId, playerId });
getSessionSockets(sessionId).add(ws);
player.lastActiveAt = meta.lastSeenAt;
sendStateToSession(session);
}
@ -125,32 +190,64 @@ export function unregisterSocket(ws: WebSocket): void {
if (!meta) {
return;
}
const { sessionId, playerId } = meta;
const { sessionSocketCount, playerSocketCount } = deleteSocketFromTracking(ws, meta);
const session = getSession(sessionId);
if (session) {
if (session && playerSocketCount === 0) {
disconnectPlayer(session, playerId);
sendStateToSession(session);
}
const set = socketsBySession.get(sessionId);
if (set) {
set.delete(ws);
if (set.size === 0) {
socketsBySession.delete(sessionId);
if (session?.isTest) {
if (sessionSocketCount === 0 && session?.isTest) {
stopTestSimulation(sessionId);
removeSession(sessionId);
}
}
export function reapStaleSockets(referenceNow = now()): void {
const staleSockets: WebSocket[] = [];
socketsBySession.forEach((sessionSockets) => {
sessionSockets.forEach((socket) => {
const meta = metaBySocket.get(socket);
if (!meta) {
return;
}
if (referenceNow - meta.lastSeenAt <= STALE_SOCKET_TIMEOUT_MS) {
return;
}
metaBySocket.delete(ws);
staleSockets.push(socket);
});
});
staleSockets.forEach((socket) => {
try {
socket.close(4000, "Connection stale");
} catch {
// Ignore close failures.
}
unregisterSocket(socket);
});
}
export function resetWebsocketStateForTests(): void {
socketsBySession.clear();
socketsByPlayer.clear();
testTimers.forEach((timer) => clearTimeout(timer));
testTimers.clear();
metaBySocket = new WeakMap<WebSocket, SocketMeta>();
}
export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): void {
touchSocket(ws);
const messageText = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
let parsed: ClientMessage;
try {
parsed = JSON.parse(messageText) as ClientMessage;
} catch (error) {
} catch {
send(ws, { type: "error", message: "Invalid message" });
return;
}
@ -162,7 +259,7 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v
}
try {
handleMessage(session, parsed);
handleMessage(ws, session, parsed);
} catch (error) {
const message =
error instanceof DomainError
@ -175,15 +272,14 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v
sendStateToSession(session);
}
function handleMessage(session: Session, message: ClientMessage): void {
function handleMessage(ws: WebSocket, session: Session, message: ClientMessage): void {
switch (message.type) {
case "chat_send": {
const chat = addChatMessage(session, message.playerId, message.body, message.groupId);
notifyChat(session, chat);
return;
}
case "transfer":
{
case "transfer": {
const transaction = transfer(
session,
message.playerId,
@ -247,6 +343,7 @@ function handleMessage(session: Session, message: ClientMessage): void {
}
case "ping":
touchPlayer(session, message.playerId);
send(ws, { type: "pong" });
return;
default:
return;
@ -295,6 +392,13 @@ function notifyTakeoverApproval(
if (meta.playerId === requesterId) {
send(socket, { type: "takeover_approved", assignedPlayerId: assignedId });
meta.playerId = assignedId;
deleteSocketFromTracking(socket, {
...meta,
playerId: requesterId,
});
getSessionSockets(sessionId).add(socket);
getPlayerSockets(sessionId, assignedId).add(socket);
metaBySocket.set(socket, meta);
}
});
}
@ -305,3 +409,7 @@ function send(ws: WebSocket, message: ServerMessage): void {
}
ws.send(JSON.stringify(message));
}
setInterval(() => {
reapStaleSockets();
}, STALE_SOCKET_REAP_INTERVAL_MS);