Merge branch 'codex/websocket' into codex/zongmeng-v1

This commit is contained in:
taojiacheng committed 2026-07-16 11:24:59 +08:00
commit 60d7d9c2c8
3 files changed
+419 -139

No files matched your search

@@ -425,6 +425,7 @@ export function AccountSettingsTab() {
<SettingsSection
title="注销账号"
description="此操作无法撤销,请确认已了解注销后的影响。"
className="border border-crimson/35 bg-crimson/8 px-4 pb-4"
>
{deletionExpanded ? (
<div className="grid gap-4">
+216 -16
View File
@@ -1,16 +1,32 @@
import { isAllowedRealtimeOrigin } from '@server/lib/http/realtimeOrigin';
import { Hono } from 'hono';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { isAllowedRealtimeOrigin } from '@server/lib/http/realtimeOrigin';
const {
recordRealtimeConnectionCloseMock,
recordRealtimeConnectionHeartbeatMock,
recordRealtimeConnectionOpenMock,
subscribePlayerStateEventsMock,
subscribeWorldChatMessagesMock,
upgradeRequestMock,
upgradeWebSocketMock,
} = vi.hoisted(() => ({
recordRealtimeConnectionCloseMock: vi.fn(),
recordRealtimeConnectionOpenMock: vi.fn(),
upgradeWebSocketMock: vi.fn(),
}));
} = vi.hoisted(() => {
const upgradeRequestMock = vi.fn();
return {
recordRealtimeConnectionCloseMock: vi.fn(),
recordRealtimeConnectionHeartbeatMock: vi.fn(),
recordRealtimeConnectionOpenMock: vi.fn(),
subscribePlayerStateEventsMock: vi.fn(),
subscribeWorldChatMessagesMock: vi.fn(),
upgradeRequestMock,
upgradeWebSocketMock: vi.fn((createEvents: (context: any) => any) => {
return async (context: any) => {
const handlers = await createEvents(context);
return upgradeRequestMock(context, handlers);
};
}),
};
});
vi.mock('@server/lib/hono/middleware', () => ({
requireActiveCultivatorRef:
@@ -32,15 +48,16 @@ vi.mock('hono/bun', () => ({
vi.mock('@server/lib/services/onlinePresenceService', () => ({
recordRealtimeConnectionClose: recordRealtimeConnectionCloseMock,
recordRealtimeConnectionHeartbeat: recordRealtimeConnectionHeartbeatMock,
recordRealtimeConnectionOpen: recordRealtimeConnectionOpenMock,
}));
vi.mock('@server/lib/services/playerStateBroadcaster', () => ({
subscribePlayerStateEvents: () => vi.fn(),
subscribePlayerStateEvents: subscribePlayerStateEventsMock,
}));
vi.mock('@server/lib/services/worldChatBroadcaster', () => ({
subscribeWorldChatMessages: () => vi.fn(),
subscribeWorldChatMessages: subscribeWorldChatMessagesMock,
}));
import realtimeRouter from './realtime.router';
@@ -48,12 +65,15 @@ import realtimeRouter from './realtime.router';
const originalOrigins = process.env.PUBLIC_WEB_ORIGINS;
afterEach(() => {
vi.useRealTimers();
process.env.PUBLIC_WEB_ORIGINS = originalOrigins;
});
beforeEach(() => {
vi.clearAllMocks();
upgradeWebSocketMock.mockImplementation((_context, handlers) => {
subscribePlayerStateEventsMock.mockImplementation(() => vi.fn());
subscribeWorldChatMessagesMock.mockImplementation(() => vi.fn());
upgradeRequestMock.mockImplementation((_context, handlers) => {
const ws = {
readyState: 1,
send: vi.fn(),
@@ -88,14 +108,11 @@ describe('realtime origin guard', () => {
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
const app = new Hono().route('/api/realtime', realtimeRouter);
const response = await app.request(
'/api/realtime?channels=player-state',
{
headers: {
Origin: 'https://app.example.com',
},
const response = await app.request('/api/realtime?channels=player-state', {
headers: {
Origin: 'https://app.example.com',
},
);
});
expect(response.status).toBe(200);
expect(recordRealtimeConnectionOpenMock).toHaveBeenCalledWith(
@@ -105,4 +122,187 @@ describe('realtime origin guard', () => {
'cultivator-1',
);
});
it('keeps the connection alive when client activity uses a new Hono WSContext', async () => {
vi.useFakeTimers();
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
let handlers: any;
upgradeRequestMock.mockImplementation((_context, nextHandlers) => {
handlers = nextHandlers;
return new Response(null, { status: 200 });
});
const app = new Hono().route('/api/realtime', realtimeRouter);
const response = await app.request('/api/realtime?channels=player-state', {
headers: {
Origin: 'https://app.example.com',
},
});
expect(response.status).toBe(200);
const send = vi.fn();
const close = vi.fn();
const openContext = { readyState: 1, send, close };
const messageContext = { readyState: 1, send, close };
handlers.onOpen?.({}, openContext);
await vi.advanceTimersByTimeAsync(25_000);
expect(send).toHaveBeenCalledWith(expect.stringContaining('"type":"ping"'));
handlers.onMessage?.(
{ data: JSON.stringify({ type: 'client.activity' }) },
messageContext,
);
await vi.advanceTimersByTimeAsync(50_000);
expect(close).not.toHaveBeenCalled();
handlers.onClose?.();
});
it('uses elapsed time when a heartbeat interval callback is delayed', async () => {
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
let handlers: any;
upgradeRequestMock.mockImplementation((_context, nextHandlers) => {
handlers = nextHandlers;
return new Response(null, { status: 200 });
});
let heartbeatTick: (() => void) | undefined;
vi.spyOn(globalThis, 'setInterval').mockImplementation((callback) => {
heartbeatTick = callback as () => void;
return {} as ReturnType<typeof setInterval>;
});
vi.spyOn(globalThis, 'clearInterval').mockImplementation(() => {});
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000);
const app = new Hono().route('/api/realtime', realtimeRouter);
const response = await app.request('/api/realtime?channels=player-state', {
headers: {
Origin: 'https://app.example.com',
},
});
expect(response.status).toBe(200);
const send = vi.fn();
const close = vi.fn();
handlers.onOpen?.({}, { readyState: 1, send, close });
now.mockReturnValue(51_001);
heartbeatTick?.();
expect(close).toHaveBeenCalledWith(4_000, 'heartbeat timeout');
});
it('isolates heartbeat activity between websocket upgrade closures', async () => {
vi.useFakeTimers();
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
const handlers: any[] = [];
upgradeRequestMock.mockImplementation((_context, nextHandlers) => {
handlers.push(nextHandlers);
return new Response(null, { status: 200 });
});
const app = new Hono().route('/api/realtime', realtimeRouter);
const request = () =>
app.request('/api/realtime?channels=player-state', {
headers: { Origin: 'https://app.example.com' },
});
await request();
await request();
expect(handlers).toHaveLength(2);
const firstSocket = { readyState: 1, send: vi.fn(), close: vi.fn() };
const secondSocket = { readyState: 1, send: vi.fn(), close: vi.fn() };
handlers[0].onOpen?.({}, firstSocket);
handlers[1].onOpen?.({}, secondSocket);
await vi.advanceTimersByTimeAsync(25_000);
handlers[0].onMessage?.(
{ data: JSON.stringify({ type: 'pong' }) },
firstSocket,
);
await vi.advanceTimersByTimeAsync(50_000);
expect(firstSocket.close).not.toHaveBeenCalled();
expect(secondSocket.close).toHaveBeenCalledWith(4_000, 'heartbeat timeout');
handlers[0].onClose?.();
});
it('reserves connection capacity before websocket open', async () => {
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
const handlers: any[] = [];
upgradeRequestMock.mockImplementation((_context, nextHandlers) => {
handlers.push(nextHandlers);
return new Response(null, { status: 200 });
});
const app = new Hono().route('/api/realtime', realtimeRouter);
const request = () =>
app.request('/api/realtime?channels=player-state', {
headers: { Origin: 'https://app.example.com' },
});
const responses = await Promise.all([
request(),
request(),
request(),
request(),
]);
expect(responses.map((response) => response.status)).toEqual([
200, 200, 200, 429,
]);
expect(handlers).toHaveLength(3);
for (const websocketHandlers of handlers) {
const socket = { readyState: 1, send: vi.fn(), close: vi.fn() };
websocketHandlers.onOpen?.({}, socket);
websocketHandlers.onClose?.();
}
});
it('finishes cleanup when an unsubscribe callback throws', async () => {
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
const throwingUnsubscribe = vi.fn(() => {
throw new Error('unsubscribe failed');
});
const safeUnsubscribe = vi.fn();
subscribePlayerStateEventsMock.mockReturnValue(throwingUnsubscribe);
subscribeWorldChatMessagesMock.mockReturnValue(safeUnsubscribe);
vi.spyOn(console, 'warn').mockImplementation(() => {});
let handlers: any;
upgradeRequestMock.mockImplementation((_context, nextHandlers) => {
handlers = nextHandlers;
return new Response(null, { status: 200 });
});
const app = new Hono().route('/api/realtime', realtimeRouter);
await app.request('/api/realtime?channels=player-state%2Cworld-chat', {
headers: { Origin: 'https://app.example.com' },
});
handlers.onOpen?.({}, { readyState: 1, send: vi.fn(), close: vi.fn() });
expect(() => handlers.onClose?.()).not.toThrow();
expect(throwingUnsubscribe).toHaveBeenCalledOnce();
expect(safeUnsubscribe).toHaveBeenCalledOnce();
expect(recordRealtimeConnectionCloseMock).toHaveBeenCalledOnce();
});
it('cleans up when sending to the websocket throws', async () => {
process.env.PUBLIC_WEB_ORIGINS = 'https://app.example.com';
let handlers: any;
upgradeRequestMock.mockImplementation((_context, nextHandlers) => {
handlers = nextHandlers;
return new Response(null, { status: 200 });
});
const app = new Hono().route('/api/realtime', realtimeRouter);
await app.request('/api/realtime?channels=player-state', {
headers: { Origin: 'https://app.example.com' },
});
const socket = {
readyState: 1,
send: vi.fn(() => {
throw new Error('socket ended');
}),
close: vi.fn(),
};
expect(() => handlers.onOpen?.({}, socket)).not.toThrow();
expect(recordRealtimeConnectionCloseMock).toHaveBeenCalledOnce();
});
});
+202 -123
View File
@@ -13,13 +13,16 @@ import {
type RealtimeChannel,
type RealtimeServerEvent,
} from '@shared/contracts/realtime';
import { Hono, type Context, type MiddlewareHandler } from 'hono';
import { upgradeWebSocket } from 'hono/bun';
import type { WSContext } from 'hono/ws';
import { Hono, type Context } from 'hono';
const router = new Hono<AppEnv>();
const HEARTBEAT_INTERVAL_MS = 25_000;
const MAX_MISSED_PONGS = 2;
const MAX_MISSED_HEARTBEATS = 2;
const HEARTBEAT_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * MAX_MISSED_HEARTBEATS;
const HEARTBEAT_TIMEOUT_CLOSE_CODE = 4_000;
const HEARTBEAT_TIMEOUT_CLOSE_REASON = 'heartbeat timeout';
const MAX_MESSAGE_BYTES = 512;
const MAX_CONNECTIONS_PER_USER = 3;
const MAX_CONNECTIONS_PER_CULTIVATOR = 3;
@@ -29,6 +32,21 @@ const userConnectionCounts = new Map<string, number>();
const cultivatorConnectionCounts = new Map<string, number>();
const ipConnectionCounts = new Map<string, number>();
type ConnectionIdentity = {
userId: string;
cultivatorId: string;
ip: string;
};
type ConnectionReservation = ConnectionIdentity & {
release: () => void;
};
const pendingConnectionReservations = new WeakMap<
Context<AppEnv>,
ConnectionReservation
>();
function incrementCount(map: Map<string, number>, key: string) {
map.set(key, (map.get(key) ?? 0) + 1);
}
@@ -64,38 +82,51 @@ function parseChannels(raw: string | undefined): RealtimeChannel[] {
allowed.has(item as RealtimeChannel),
);
return channels.length > 0 ? Array.from(new Set(channels)) : [...REALTIME_CHANNELS];
return channels.length > 0
? Array.from(new Set(channels))
: [...REALTIME_CHANNELS];
}
function canAcceptConnection(args: {
userId: string;
cultivatorId: string;
ip: string;
}) {
return (
(userConnectionCounts.get(args.userId) ?? 0) < MAX_CONNECTIONS_PER_USER &&
(cultivatorConnectionCounts.get(args.cultivatorId) ?? 0) <
MAX_CONNECTIONS_PER_CULTIVATOR &&
(ipConnectionCounts.get(args.ip) ?? 0) < MAX_CONNECTIONS_PER_IP
);
}
function sendEnvelope(ws: WSContext, event: RealtimeServerEvent) {
if (ws.readyState !== 1) {
return;
function reserveConnection(
identity: ConnectionIdentity,
): ConnectionReservation | null {
if (
(userConnectionCounts.get(identity.userId) ?? 0) >=
MAX_CONNECTIONS_PER_USER ||
(cultivatorConnectionCounts.get(identity.cultivatorId) ?? 0) >=
MAX_CONNECTIONS_PER_CULTIVATOR ||
(ipConnectionCounts.get(identity.ip) ?? 0) >= MAX_CONNECTIONS_PER_IP
) {
return null;
}
ws.send(JSON.stringify(event));
incrementCount(userConnectionCounts, identity.userId);
incrementCount(cultivatorConnectionCounts, identity.cultivatorId);
incrementCount(ipConnectionCounts, identity.ip);
let released = false;
return {
...identity,
release() {
if (released) {
return;
}
released = true;
decrementCount(userConnectionCounts, identity.userId);
decrementCount(cultivatorConnectionCounts, identity.cultivatorId);
decrementCount(ipConnectionCounts, identity.ip);
},
};
}
router.get('/', async (c, next) => {
const requireRealtimeOrigin = (async (c, next) => {
if (!isAllowedRealtimeOrigin(c.req.header('origin'))) {
return c.json({ success: false, error: 'Forbidden origin' }, 403);
}
await next();
});
}) satisfies MiddlewareHandler<AppEnv>;
router.get('/', requireActiveCultivatorRef(), async (c) => {
const reserveRealtimeConnection = (async (c, next) => {
const user = c.get('user');
if (!user) {
return c.json({ success: false, error: '未授权访问' }, 401);
@@ -106,115 +137,163 @@ router.get('/', requireActiveCultivatorRef(), async (c) => {
return c.json({ success: false, error: '当前没有活跃角色' }, 404);
}
const channels = parseChannels(c.req.query('channels'));
const ip = getClientIp(c);
if (
!canAcceptConnection({
userId: user.id,
cultivatorId: ref.cultivatorId,
ip,
})
) {
const reservation = reserveConnection({
userId: user.id,
cultivatorId: ref.cultivatorId,
ip: getClientIp(c),
});
if (!reservation) {
return c.json({ success: false, error: '实时连接过多' }, 429);
}
let cleanup: () => void = () => {};
pendingConnectionReservations.set(c, reservation);
try {
await next();
} catch (error) {
pendingConnectionReservations.delete(c);
reservation.release();
throw error;
}
return upgradeWebSocket(c, {
onOpen(_event, ws) {
incrementCount(userConnectionCounts, user.id);
incrementCount(cultivatorConnectionCounts, ref.cultivatorId);
incrementCount(ipConnectionCounts, ip);
recordRealtimeConnectionOpen(ref.cultivatorId);
if (pendingConnectionReservations.get(c) === reservation) {
pendingConnectionReservations.delete(c);
reservation.release();
}
}) satisfies MiddlewareHandler<AppEnv>;
const unsubscribers: Array<() => void> = [];
if (channels.includes('player-state')) {
unsubscribers.push(
subscribePlayerStateEvents(ref.cultivatorId, (events) => {
sendEnvelope(ws, {
type: 'player-state.events',
payload: { events },
});
}),
);
}
if (channels.includes('world-chat')) {
unsubscribers.push(
subscribeWorldChatMessages((message) => {
sendEnvelope(ws, {
type: 'world-chat.message',
payload: message,
});
}),
);
}
router.get(
'/',
requireRealtimeOrigin,
requireActiveCultivatorRef(),
reserveRealtimeConnection,
upgradeWebSocket((rawContext) => {
const c = rawContext as Context<AppEnv>;
const reservation = pendingConnectionReservations.get(c);
if (!reservation) {
throw new Error('Realtime connection reservation is missing');
}
pendingConnectionReservations.delete(c);
let missedPongs = 0;
let closed = false;
let heartbeat: ReturnType<typeof setInterval> | null = setInterval(() => {
missedPongs += 1;
if (missedPongs > MAX_MISSED_PONGS) {
ws.close();
cleanup();
return;
}
recordRealtimeConnectionHeartbeat(ref.cultivatorId);
sendEnvelope(ws, {
type: 'ping',
payload: { serverTime: new Date().toISOString() },
});
}, HEARTBEAT_INTERVAL_MS);
cleanup = () => {
if (closed) {
return;
}
closed = true;
for (const unsubscribe of unsubscribers) {
unsubscribe();
}
decrementCount(userConnectionCounts, user.id);
decrementCount(cultivatorConnectionCounts, ref.cultivatorId);
decrementCount(ipConnectionCounts, ip);
recordRealtimeConnectionClose(ref.cultivatorId);
if (heartbeat) {
clearInterval(heartbeat);
heartbeat = null;
}
};
sendEnvelope(ws, {
type: 'ready',
payload: { cultivatorId: ref.cultivatorId, channels },
});
Object.assign(ws, {
__daoyouPong: () => {
missedPongs = 0;
},
});
},
onMessage(event, ws) {
const raw = event.data;
if (typeof raw !== 'string' || raw.length > MAX_MESSAGE_BYTES) {
ws.close();
const channels = parseChannels(c.req.query('channels'));
const { cultivatorId } = reservation;
let lastClientActivityAt = Date.now();
let heartbeat: ReturnType<typeof setInterval> | undefined;
let closed = false;
const unsubscribers: Array<() => void> = [];
const cleanup = () => {
if (closed) {
return;
}
try {
const parsed = JSON.parse(raw) as { type?: unknown };
if (parsed.type === 'pong') {
(ws as unknown as { __daoyouPong?: () => void }).__daoyouPong?.();
}
} catch {
// Ignore invalid client frames; realtime v1 is server-push only.
closed = true;
if (heartbeat) {
clearInterval(heartbeat);
}
for (const unsubscribe of unsubscribers) {
try {
unsubscribe();
} catch (error) {
console.warn('[realtime] unsubscribe failed', {
cultivatorId,
error,
});
}
}
reservation.release();
recordRealtimeConnectionClose(cultivatorId);
};
const closeConnection = (ws: WSContext, code: number, reason: string) => {
if (closed) {
return;
}
try {
ws.close(code, reason);
} catch (error) {
console.warn('[realtime] websocket close failed', {
cultivatorId,
error,
});
}
},
onClose() {
cleanup();
},
onError() {
cleanup();
},
});
});
};
const sendEnvelope = (ws: WSContext, event: RealtimeServerEvent) => {
if (closed) {
return false;
}
try {
ws.send(JSON.stringify(event));
return true;
} catch (error) {
console.warn('[realtime] websocket send failed', {
cultivatorId,
error,
});
closeConnection(ws, 1_011, 'websocket send failed');
return false;
}
};
return {
onOpen(_event, ws) {
lastClientActivityAt = Date.now();
recordRealtimeConnectionOpen(cultivatorId);
if (channels.includes('player-state')) {
unsubscribers.push(
subscribePlayerStateEvents(cultivatorId, (events) => {
sendEnvelope(ws, {
type: 'player-state.events',
payload: { events },
});
}),
);
}
if (channels.includes('world-chat')) {
unsubscribers.push(
subscribeWorldChatMessages((message) => {
sendEnvelope(ws, {
type: 'world-chat.message',
payload: message,
});
}),
);
}
heartbeat = setInterval(() => {
const timeSinceLastClientActivity = Date.now() - lastClientActivityAt;
if (timeSinceLastClientActivity > HEARTBEAT_TIMEOUT_MS) {
closeConnection(
ws,
HEARTBEAT_TIMEOUT_CLOSE_CODE,
HEARTBEAT_TIMEOUT_CLOSE_REASON,
);
return;
}
recordRealtimeConnectionHeartbeat(cultivatorId);
sendEnvelope(ws, {
type: 'ping',
payload: { serverTime: new Date().toISOString() },
});
}, HEARTBEAT_INTERVAL_MS);
sendEnvelope(ws, {
type: 'ready',
payload: { cultivatorId, channels },
});
},
onMessage(event, ws) {
const raw = event.data;
if (typeof raw !== 'string' || raw.length > MAX_MESSAGE_BYTES) {
closeConnection(ws, 1_003, 'invalid client message');
return;
}
// Any bounded client frame proves that this connection is still alive.
lastClientActivityAt = Date.now();
},
onClose() {
cleanup();
},
};
}),
);
export default router;