Preact: Support auto-reconnecting through web worker (#2409)

---------

Co-authored-by: Guangcong Luo <guangcongluo@gmail.com>
This commit is contained in:
Aurastic 2025-05-26 00:23:44 +05:30 committed by GitHub
parent 1d5e1baa32
commit 268d0f72a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 207 additions and 15 deletions

1
.gitignore vendored
View File

@ -29,6 +29,7 @@ npm-debug.log
/play.pokemonshowdown.com/js/client-main.js
/play.pokemonshowdown.com/js/client-core.js
/play.pokemonshowdown.com/js/client-connection.js
/play.pokemonshowdown.com/js/client-connection-worker.js
/play.pokemonshowdown.com/js/miniedit.js
/play.pokemonshowdown.com/ads.txt

View File

@ -0,0 +1,61 @@
declare const SockJS: any;
import type { ServerInfo } from "./client-main";
let socket: WebSocket | null = null;
let serverInfo: ServerInfo;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
let queue: string[] = [];
self.onmessage = (event: MessageEvent) => {
const { type, server, data } = event.data;
if (type === 'connect') {
serverInfo = server;
connectToServer();
} else if (type === 'send') {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(data);
} else {
queue.push(data);
}
} else if (type === 'disconnect') {
if (socket) socket.close();
if (reconnectTimeout) clearTimeout(reconnectTimeout);
socket = null;
}
};
function connectToServer() {
if (!serverInfo) return;
const port = serverInfo.protocol === 'https' ? '' : `:${serverInfo.port}`;
const url = `${serverInfo.protocol}://${serverInfo.host}${port}${serverInfo.prefix}`;
try {
socket = new SockJS(url, [], { timeout: 5 * 60 * 1000 });
} catch {
socket = new WebSocket(url.replace('http', 'ws') + '/websocket');
}
if (socket) {
socket.onopen = () => {
postMessage({ type: 'connected' });
for (const msg of queue) socket?.send(msg);
queue = [];
};
socket.onmessage = (e: MessageEvent) => {
postMessage({ type: 'message', data: e.data });
};
socket.onclose = () => {
postMessage({ type: 'disconnected' });
// scheduleReconnect();
};
socket.onerror = () => {
postMessage({ type: 'error' });
socket?.close();
};
return;
}
return postMessage({ type: 'error' });
}

View File

@ -13,39 +13,122 @@ declare const POKEMON_SHOWDOWN_TESTCLIENT_KEY: string | undefined;
export class PSConnection {
socket: any = null;
connected = false;
queue = [] as string[];
queue: string[] = [];
private reconnectDelay = 1000;
private reconnectCap = 15000;
private shouldReconnect = true;
private worker: Worker | null = null;
constructor() {
const loading = PSStorage.init();
if (loading) {
loading.then(() => {
this.connect();
this.initConnection();
});
} else {
this.connect();
this.initConnection();
}
}
connect() {
initConnection() {
if (!this.tryConnectInWorker()) this.directConnect();
}
canReconnect() {
const uptime = Date.now() - PS.startTime;
if (uptime > 24 * 60 * 60 * 1000) {
PS.confirm(`It's been over a day since you first connected. Please refresh.`, {
okButton: 'Refresh',
}).then(confirmed => {
if (confirmed) PS.room?.send(`/refresh`);
});
return false;
}
return this.shouldReconnect;
}
tryConnectInWorker(): boolean {
if (this.socket) return false; // must be one or the other
try {
const worker = new Worker('/js/reconnect-worker.js');
this.worker = worker;
worker.postMessage({ type: 'connect', server: PS.server });
worker.onmessage = event => {
const { type, data } = event.data;
switch (type) {
case 'connected':
console.log('\u2705 (CONNECTED via worker)');
this.connected = true;
PS.connected = true;
this.queue.forEach(msg => worker.postMessage({ type: 'send', data: msg }));
this.queue = [];
PS.update();
break;
case 'message':
PS.receive(data);
break;
case 'disconnected':
this.handleDisconnect();
if (this.canReconnect()) this.retryConnection();
break;
case 'error':
console.warn('Worker connection error');
this.worker = null;
this.directConnect(); // fallback
break;
}
};
worker.onerror = (e: ErrorEvent) => {
console.warn('Worker connection error:', e);
this.worker = null;
this.directConnect(); // fallback
};
return true;
} catch {
console.warn('Worker connection failed, falling back to regular connection.');
this.worker = null;
return false;
}
}
directConnect() {
if (this.worker) return; // must be one or the other
const server = PS.server;
const port = server.protocol === 'https' ? `:${server.port}` : `:${server.httpport || server.port}`;
const port = server.protocol === 'https' ? `:${server.port}` : `:${server.httpport!}`;
const url = `${server.protocol}://${server.host}${port}${server.prefix}`;
try {
this.socket = new SockJS(url, [], { timeout: 5 * 60 * 1000 });
} catch {
this.socket = new WebSocket(url.replace('http', 'ws') + '/websocket');
}
const socket = this.socket;
socket.onopen = () => {
console.log('\u2705 (CONNECTED)');
this.connected = true;
PS.connected = true;
for (const msg of this.queue) socket.send(msg);
this.reconnectDelay = 1000;
this.queue.forEach(msg => socket.send(msg));
this.queue = [];
PS.update();
};
socket.onmessage = (e: MessageEvent) => {
PS.receive('' + e.data);
};
socket.onclose = () => {
console.log('\u274C (DISCONNECTED)');
this.handleDisconnect();
if (this.canReconnect()) this.retryConnection();
console.log('\u2705 (DISCONNECTED)');
this.connected = false;
PS.connected = false;
@ -57,32 +140,75 @@ export class PSConnection {
this.socket = null;
PS.update();
};
socket.onerror = () => {
PS.connected = false;
PS.isOffline = true;
PS.alert("Connection error.");
if (this.canReconnect()) this.retryConnection();
};
}
private handleDisconnect() {
this.connected = false;
PS.connected = false;
PS.isOffline = true;
this.socket = null;
for (const roomid in PS.rooms) {
const room = PS.rooms[roomid]!;
if (room.connected === true) room.connected = 'autoreconnect';
}
PS.update();
}
private retryConnection() {
console.log(`Reconnecting in ${this.reconnectDelay / 1000}s...`);
PS.room.add(`||You are disconnected. Attempting to reconnect in ${this.reconnectDelay / 1000}s`);
setTimeout(() => {
if (!this.connected && this.canReconnect()) {
PSConnection.connect(); // or PS.send('/reconnect') ?
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.reconnectCap);
}
}, this.reconnectDelay);
}
disconnect() {
this.socket.close();
this.shouldReconnect = false;
this.socket?.close();
this.worker?.terminate();
this.worker = null;
PS.connection = null;
PS.connected = false;
PS.isOffline = true;
}
reconnectTest() {
this.socket?.close();
this.worker?.postMessage({ type: 'disconnect' });
this.worker = null;
PS.connected = false;
PS.isOffline = true;
}
send(msg: string) {
if (!this.connected) {
this.queue.push(msg);
return;
}
this.socket.send(msg);
if (this.worker) {
this.worker.postMessage({ type: 'send', data: msg });
} else if (this.socket) {
this.socket.send(msg);
}
}
static connect() {
if (PS.connection?.socket) return;
PS.isOffline = false;
if (!PS.connection) {
PS.connection = new PSConnection();
} else {
PS.connection.connect();
PS.connection.directConnect();
}
PS.prefs.doAutojoin();
}
@ -92,7 +218,7 @@ export class PSStorage {
static frame: WindowProxy | null = null;
static requests: Record<string, (data: any) => void> | null = null;
static requestCount = 0;
static readonly origin = 'https://' + Config.routes.client;
static readonly origin = `https://${Config.routes.client}`;
static loader?: () => void;
static loaded: Promise<void> | boolean = false;
static init(): void | Promise<void> {
@ -102,7 +228,7 @@ export class PSStorage {
}
if (Config.testclient) {
return;
} else if (location.protocol + '//' + location.hostname === PSStorage.origin) {
} else if (`${location.protocol}//${location.hostname}` === PSStorage.origin) {
// Same origin, everything can be kept as default
Config.server ||= Config.defaultserver;
return;
@ -128,7 +254,7 @@ export class PSStorage {
} else {
Config.server ||= Config.defaultserver;
$(
'<iframe src="https://' + Config.routes.client + '/crossprotocol.html?v1.2" style="display: none;"></iframe>'
`<iframe src="https://${Config.routes.client}/crossprotocol.html?v1.2" style="display: none;"></iframe>`
).appendTo('body');
setTimeout(() => {
// HTTPS may be blocked
@ -154,7 +280,7 @@ export class PSStorage {
if (Config.server.registered && Config.server.id !== 'showdown' && Config.server.id !== 'smogtours') {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '//' + Config.routes.client + '/customcss.php?server=' + encodeURIComponent(Config.server.id);
link.href = `//${Config.routes.client}/customcss.php?server=${encodeURIComponent(Config.server.id)}`;
document.head.appendChild(link);
}
Object.assign(PS.server, Config.server);

View File

@ -1169,7 +1169,9 @@ class ChatPanel extends PSRoomPanel<ChatRoom> {
return <PSPanelWrapper room={room} focusClick>
<ChatLog class="chat-log" room={this.props.room} left={tinyLayout ? 0 : 146} top={room.tour?.info.isActive ? 30 : 0}>
{challengeTo}{challengeFrom}{PS.isOffline && <p class="buttonbar">
<button class="button" data-cmd="/reconnect"><i class="fa fa-plug" aria-hidden></i> <strong>Reconnect</strong></button>
<button class="button" data-cmd="/reconnect">
<i class="fa fa-plug" aria-hidden></i> <strong>Reconnect Now</strong>
</button>
</p>}
</ChatLog>
{room.tour && <TournamentBox tour={room.tour} left={tinyLayout ? 0 : 146} />}

View File

@ -596,7 +596,9 @@ class MainMenuPanel extends PSRoomPanel<MainMenuRoom> {
</span>, " Disconnected"] : "Connecting..."}</em>
</button>
{PS.isOffline && <p class="buttonbar">
<button class="button" data-cmd="/reconnect"><i class="fa fa-plug" aria-hidden></i> <strong>Reconnect</strong></button>
<button class="button" data-cmd="/reconnect">
<i class="fa fa-plug" aria-hidden></i> <strong>Reconnect Now</strong>
</button>
</p>}
</TeamForm>;
}