From 60c07f65dd5d0d0e323e80acb2db252e0bfc7ddc Mon Sep 17 00:00:00 2001
From: Kashargul <144968721+Kashargul@users.noreply.github.com>
Date: Fri, 24 Jul 2026 11:06:19 +0200
Subject: [PATCH] port of chatbox websocket client [No GBP] (#19496)
* port of chatbox websocket client
* .
---
code/modules/tgui_panel/tgui_panel.dm | 33 +++
tgui/packages/tgui-panel/index.tsx | 6 +-
.../SettingTabs/SettingsWebsocket.tsx | 99 +++++++++
.../tgui-panel/settings/SettingsPanel.tsx | 2 +
tgui/packages/tgui-panel/settings/atoms.ts | 2 +
.../packages/tgui-panel/settings/constants.ts | 4 +
.../packages/tgui-panel/settings/migration.ts | 6 +
tgui/packages/tgui-panel/settings/types.ts | 2 +
tgui/packages/tgui-panel/websocket/helpers.ts | 195 ++++++++++++++++++
9 files changed, 348 insertions(+), 1 deletion(-)
create mode 100644 tgui/packages/tgui-panel/settings/SettingTabs/SettingsWebsocket.tsx
create mode 100644 tgui/packages/tgui-panel/websocket/helpers.ts
diff --git a/code/modules/tgui_panel/tgui_panel.dm b/code/modules/tgui_panel/tgui_panel.dm
index 119e335f744..aafabecfae7 100644
--- a/code/modules/tgui_panel/tgui_panel.dm
+++ b/code/modules/tgui_panel/tgui_panel.dm
@@ -115,6 +115,10 @@
analyze_telemetry(payload)
return TRUE
+ if(type == "requestMetadata")
+ send_metadata()
+ return TRUE
+
/**
* public
*
@@ -122,3 +126,32 @@
*/
/datum/tgui_panel/proc/send_roundrestart()
window.send_message("roundrestart")
+
+/**
+ * private
+ *
+ * Sent when a client requests metadata - used for websocket stuff.
+ */
+/datum/tgui_panel/proc/send_metadata()
+ var/static/list/webroot_asset_urls
+
+ var/list/metadata = list(
+ "game_version" = GLOB.game_version,
+ "server_name" = CONFIG_GET(string/servername),
+ "round_id" = GLOB.round_id,
+ "map_name" = SSmapping.current_map?.name,
+ "round_duration" = round(ROUND_TIME() / 10, 1),
+ "gamestate" = SSticker.current_state,
+ )
+ // if we're using webroot - also pass along the webroot url and such, so we can embed chat logs with the proper styles/images if desired
+ if(istype(SSassets.transport, /datum/asset_transport/webroot))
+ if(isnull(webroot_asset_urls))
+ webroot_asset_urls = list()
+ for(var/asset_type in list(/datum/asset/simple/tgui_panel, /datum/asset/simple/namespaced/fontawesome, /datum/asset/simple/namespaced/tgfont, /datum/asset/spritesheet_batched/chat))
+ var/datum/asset/asset = get_asset_datum(asset_type)
+ webroot_asset_urls += asset.get_url_mappings()
+ metadata["webroot"] = list(
+ "base_url" = CONFIG_GET(string/asset_cdn_url),
+ "assets" = webroot_asset_urls,
+ )
+ window.send_message("metadata", metadata)
diff --git a/tgui/packages/tgui-panel/index.tsx b/tgui/packages/tgui-panel/index.tsx
index 0041681e341..d0ba8e5fba2 100644
--- a/tgui/packages/tgui-panel/index.tsx
+++ b/tgui/packages/tgui-panel/index.tsx
@@ -16,6 +16,7 @@ import { setupHotReloading } from 'tgui-dev-server/link/client';
import { App } from './app';
import { bus } from './events/listeners';
import { setupPanelFocusHacks } from './panelFocus';
+import { wsSend } from './websocket/helpers';
const root = createRoot(document.getElementById('react-root')!);
@@ -43,7 +44,10 @@ function setupApp() {
render();
// Dispatch incoming messages as store actions
- Byond.subscribe((type, payload) => bus.dispatch({ type, payload }));
+ Byond.subscribe((type, payload) => {
+ bus.dispatch({ type, payload });
+ wsSend({ type, payload });
+ });
// Unhide the panel
Byond.winset('outputwindow.legacy_output_selector', {
diff --git a/tgui/packages/tgui-panel/settings/SettingTabs/SettingsWebsocket.tsx b/tgui/packages/tgui-panel/settings/SettingTabs/SettingsWebsocket.tsx
new file mode 100644
index 00000000000..e3db5258093
--- /dev/null
+++ b/tgui/packages/tgui-panel/settings/SettingTabs/SettingsWebsocket.tsx
@@ -0,0 +1,99 @@
+import { useAtomValue } from 'jotai';
+import {
+ Button,
+ Input,
+ LabeledList,
+ Section,
+ Stack,
+} from 'tgui-core/components';
+import { chatRenderer } from 'tgui-panel/chat/renderer';
+import { gameAtom } from 'tgui-panel/game/atoms';
+import {
+ wsDisconnect,
+ wsReconnect,
+ wsUpdate,
+} from 'tgui-panel/websocket/helpers';
+import { useSettings } from '../use-settings';
+
+export function SettingsWebsocket(props) {
+ const game = useAtomValue(gameAtom);
+ const { settings, updateSettings } = useSettings();
+
+ return (
+
+
+
+
+
+ {
+ const websocketEnabled = !settings.websocketEnabled;
+ updateSettings({ websocketEnabled });
+ wsUpdate(websocketEnabled);
+ }}
+ >
+ Enabled
+
+
+
+
+
+
+ updateSettings({
+ websocketServer: value,
+ })
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/tgui/packages/tgui-panel/settings/SettingsPanel.tsx b/tgui/packages/tgui-panel/settings/SettingsPanel.tsx
index 83995b0071f..25937895d73 100644
--- a/tgui/packages/tgui-panel/settings/SettingsPanel.tsx
+++ b/tgui/packages/tgui-panel/settings/SettingsPanel.tsx
@@ -12,6 +12,7 @@ import { ExportTab } from './SettingTabs/ExportTab';
import { MessageLimits } from './SettingTabs/MessageLimits';
import { SettingsGeneral } from './SettingTabs/SettingsGeneral';
import { SettingsStatPanel } from './SettingTabs/SettingsStatPanel';
+import { SettingsWebsocket } from './SettingTabs/SettingsWebsocket';
import { TextHighlightSettings } from './SettingTabs/TextHighlightSettings';
import { TTSSettings } from './SettingTabs/TTSSettings';
import { useSettings } from './use-settings';
@@ -56,6 +57,7 @@ export const SettingsPanel = (props) => {
{activeTab === 'chatPage' && }
{activeTab === 'statPanel' && }
{activeTab === 'ttsSettings' && }
+ {activeTab === 'websocket' && }
);
diff --git a/tgui/packages/tgui-panel/settings/atoms.ts b/tgui/packages/tgui-panel/settings/atoms.ts
index 9e0f3e86b4e..022e199c5e5 100644
--- a/tgui/packages/tgui-panel/settings/atoms.ts
+++ b/tgui/packages/tgui-panel/settings/atoms.ts
@@ -33,6 +33,8 @@ export const defaultSettings: SettingsState = {
activeTab: SETTINGS_TABS[0].id,
visible: false,
},
+ websocketEnabled: false,
+ websocketServer: '',
visibleMessageLimit: 2500,
visibleMessages: 0,
};
diff --git a/tgui/packages/tgui-panel/settings/constants.ts b/tgui/packages/tgui-panel/settings/constants.ts
index b25be7f871b..a69c67da09f 100644
--- a/tgui/packages/tgui-panel/settings/constants.ts
+++ b/tgui/packages/tgui-panel/settings/constants.ts
@@ -39,6 +39,10 @@ export const SETTINGS_TABS = [
id: 'ttsSettings',
name: 'TTS/Accessibility',
},
+ {
+ id: 'websocket',
+ name: 'Websocket',
+ },
] as const;
export const FONTS_DISABLED = 'Default';
diff --git a/tgui/packages/tgui-panel/settings/migration.ts b/tgui/packages/tgui-panel/settings/migration.ts
index abc18c54772..dc685e1e944 100644
--- a/tgui/packages/tgui-panel/settings/migration.ts
+++ b/tgui/packages/tgui-panel/settings/migration.ts
@@ -1,6 +1,7 @@
import { storage } from 'common/storage';
import { smoothMerge } from 'common/type-safety';
import { omit, pick } from 'es-toolkit';
+import { wsUpdate } from 'tgui-panel/websocket/helpers';
import { setMusicVolume } from '../audio/handlers';
import { MESSAGE_TYPES } from '../chat/constants';
import { chatRenderer } from '../chat/renderer';
@@ -127,6 +128,11 @@ export function startSettingsMigration(next: MergedSettings): void {
store.set(settingsAtom, draftSettings);
console.log('Migrated panel settings:', draftSettings);
+ if (draftSettings.websocketEnabled !== defaultSettings.websocketEnabled) {
+ // Ensure websocket state is correct after migration
+ wsUpdate(draftSettings.websocketEnabled);
+ }
+
const migratedHighlights = migrateHighlights(highlightPart);
storage.set('panel-settings', { ...draftSettings, ...migratedHighlights });
diff --git a/tgui/packages/tgui-panel/settings/types.ts b/tgui/packages/tgui-panel/settings/types.ts
index da1dbf2b3f9..ad85f0c4cab 100644
--- a/tgui/packages/tgui-panel/settings/types.ts
+++ b/tgui/packages/tgui-panel/settings/types.ts
@@ -34,6 +34,8 @@ export const settingsSchema = z.object({
ttsVoice: z.string(),
version: z.number(),
view: viewSchema,
+ websocketEnabled: z.boolean(),
+ websocketServer: z.string(),
visibleMessages: z.number(),
visibleMessageLimit: z.number(),
});
diff --git a/tgui/packages/tgui-panel/websocket/helpers.ts b/tgui/packages/tgui-panel/websocket/helpers.ts
new file mode 100644
index 00000000000..fa02fada690
--- /dev/null
+++ b/tgui/packages/tgui-panel/websocket/helpers.ts
@@ -0,0 +1,195 @@
+import { createLogger } from 'tgui/logging';
+import { chatRenderer } from 'tgui-panel/chat/renderer';
+import { store } from 'tgui-panel/events/store';
+import { gameAtom } from 'tgui-panel/game/atoms';
+import { settingsAtom } from 'tgui-panel/settings/atoms';
+
+const MAX_RETRIES = 10;
+const RETRY_INTERVAL = 500; // ms
+
+// Websocket close codes
+const WEBSOCKET_DISABLED = 4555;
+const WEBSOCKET_REATTEMPT = 4556;
+const SAFE_CLOSE_CODE = 1000;
+
+const logger = createLogger('websocket');
+
+let websocket: WebSocket | null = null;
+let reconnectTimer: number | null = null;
+let retryCount = 0;
+let manuallyClosed = false;
+
+function sendWSNotice(message, small = false) {
+ const { roundId } = store.get(gameAtom);
+ chatRenderer.processBatch([
+ {
+ type: '',
+ createdAt: Date.now(),
+ roundId: roundId,
+ html: small
+ ? `${message}`
+ : `
${message}`,
+ },
+ ]);
+}
+
+function clearReconnectTimer() {
+ if (reconnectTimer !== null) {
+ clearInterval(reconnectTimer);
+ reconnectTimer = null;
+ }
+}
+
+function safeClose(code = SAFE_CLOSE_CODE, reason?: string) {
+ if (!websocket) return;
+ if (
+ websocket.readyState === WebSocket.CLOSED ||
+ websocket.readyState === WebSocket.CLOSING
+ ) {
+ return;
+ }
+
+ websocket.close(code, reason);
+}
+
+function startReconnectLoop() {
+ if (reconnectTimer !== null) return;
+
+ reconnectTimer = window.setInterval(() => {
+ const { websocketEnabled } = store.get(settingsAtom);
+
+ if (!websocketEnabled) {
+ clearReconnectTimer();
+ return;
+ }
+
+ if (retryCount >= MAX_RETRIES) {
+ clearReconnectTimer();
+ sendWSNotice(
+ `Websocket failed to reconnect after ${MAX_RETRIES} attempts.`,
+ true,
+ );
+ return;
+ }
+
+ if (
+ !websocket ||
+ websocket.readyState === WebSocket.CLOSED ||
+ websocket.readyState === WebSocket.CLOSING
+ ) {
+ retryCount++;
+ setupWebsocket();
+ }
+ }, RETRY_INTERVAL);
+}
+
+function setupWebsocket(force = false) {
+ const { websocketEnabled, websocketServer } = store.get(settingsAtom);
+
+ if (!websocketEnabled) {
+ clearReconnectTimer();
+ safeClose(WEBSOCKET_DISABLED);
+ websocket = null;
+ return;
+ }
+
+ if (!force && websocket && websocket.readyState === WebSocket.OPEN) {
+ return;
+ }
+
+ if (force) {
+ clearReconnectTimer();
+
+ if (websocket) {
+ manuallyClosed = true;
+ try {
+ websocket.close(WEBSOCKET_REATTEMPT, 'forced reconnect');
+ } catch {
+ /* ignore */
+ }
+ websocket = null;
+ }
+ }
+
+ try {
+ manuallyClosed = false;
+ websocket = new WebSocket(`ws://${websocketServer}`);
+ } catch (e: any) {
+ if (e.name === 'SyntaxError') {
+ sendWSNotice(
+ `Error creating websocket: Invalid address! Make sure you're following the placeholder. Example: localhost:1234`,
+ true,
+ );
+ return;
+ }
+ sendWSNotice(`Error creating websocket: ${e.name} - ${e.message}`);
+ startReconnectLoop();
+ return;
+ }
+
+ websocket.addEventListener('open', () => {
+ clearReconnectTimer();
+ sendWSNotice('Websocket connected!', true);
+ Byond.sendMessage('requestMetadata'); // let's be nice and request metadata
+ retryCount = 0;
+ });
+
+ websocket.addEventListener('message', (event) => {
+ if (event.data === 'requestMetadata') {
+ Byond.sendMessage('requestMetadata');
+ }
+ });
+
+ websocket.addEventListener('close', (ev) => {
+ websocket = null;
+
+ if (manuallyClosed || ev.code === WEBSOCKET_DISABLED) return;
+
+ sendWSNotice(
+ `Websocket disconnected! Code: ${ev.code} Reason: ${ev.reason || 'None provided'}`,
+ true,
+ );
+
+ startReconnectLoop();
+ });
+
+ websocket.addEventListener('error', (ev) => {
+ logger.error('got websocket error', ev);
+ safeClose(WEBSOCKET_REATTEMPT, 'got error from server');
+ });
+}
+
+// Initial connect
+setupWebsocket();
+
+export function wsUpdate(enabled: boolean): void {
+ if (enabled) {
+ setupWebsocket();
+ return;
+ }
+
+ manuallyClosed = true;
+ clearReconnectTimer();
+ safeClose(WEBSOCKET_DISABLED);
+ websocket = null;
+}
+
+export function wsReconnect(): void {
+ setupWebsocket(true);
+ sendWSNotice('Attempting to connect to websocket...', true);
+}
+
+export function wsDisconnect(): void {
+ manuallyClosed = true;
+ clearReconnectTimer();
+ safeClose(WEBSOCKET_DISABLED);
+ websocket = null;
+ retryCount = 0;
+ sendWSNotice('Websocket forcefully disconnected. (Retry count reset)', true);
+}
+
+export function wsSend(msg: Record): void {
+ if (websocket?.readyState === WebSocket.OPEN) {
+ websocket.send(JSON.stringify(msg));
+ }
+}