moves clientside storage to an iframe instead of byondstorage (#93044)

## About The Pull Request

this converts the dependency on byondstorage, which is laggy (due to
being a large json file written to disk every 10 seconds), to using
indexeddb, like we did prior to 516. this is achieved by using an iframe
to give us a persistent origin, as the web is evil and has invented
same-origin policy
https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy.

this also hosts the iframe page on the github pages site for a secondary
repository (see tgstation/byond-client-storage) so it works OOTB without
requiring server operators to setup the webroot cdn (which i don't
believe is configured on the tg servers at the moment)

however, if a server is using the webroot cdn, it will use that instead
of github pages

you could also host the iframe.html page on a separate host from your
cdn or github pages if you wanted to

if we can't access the configured cdn at all, it failovers to use
byondstorage anyway, if the internet stops working and you still want
your chat history to save, i guess

## Why It's Good For The Game

saving this enormous json file is laggy, and this solution would fix
https://github.com/tgstation/tgstation/issues/89988 and fix
https://github.com/tgstation/tgstation/issues/92035

i am open to other solutions, but this does seem to require the least
amount of external dependencies of posed solutions

## Changelog

🆑
fix: you should experience less stutters every 10 seconds
server: server operators can now configure an alternative storage domain
for clientside data storage, read the example configuration for more
/🆑

---------

Co-authored-by: harryob <55142896+harryob@users.noreply.github.com>
This commit is contained in:
harry
2025-11-06 10:07:33 +00:00
committed by GitHub
parent 790694ff3c
commit f8e6d91d3c
11 changed files with 296 additions and 6 deletions

View File

@@ -0,0 +1,33 @@
name: "Generate Client Storage"
on:
push:
branches:
- master
paths:
- tgui/public/*
jobs:
dispatch_repo:
if: ( !contains(github.event.head_commit.message, '[ci skip]') )
name: Repository Dispatch
runs-on: ubuntu-latest
steps:
- name: Generate App Token
id: app-token-generation
uses: actions/create-github-app-token@v2
if: env.APP_PRIVATE_KEY != '' && env.APP_ID != ''
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: tgstation
env:
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
APP_ID: ${{ secrets.APP_ID }}
- name: Send Repository Dispatch
if: success()
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ steps.app-token-generation.outputs.token }}
repository: tgstation/byond-client-storage
event-type: on_master_push

View File

@@ -28,3 +28,7 @@
if (str_var && str_var[length(str_var)] != "/")
str_var += "/"
return ..(str_var)
/datum/config_entry/string/storage_cdn_iframe
protection = CONFIG_ENTRY_LOCKED
default = "https://tgstation.github.io/byond-client-storage/iframe.html"

View File

@@ -39,6 +39,25 @@ SUBSYSTEM_DEF(tgui)
basehtml = replacetextEx(basehtml, "<!-- tgui:nt-copyright -->", "Nanotrasen (c) 2525-[CURRENT_STATION_YEAR]")
/datum/controller/subsystem/tgui/OnConfigLoad()
var/storage_iframe = CONFIG_GET(string/storage_cdn_iframe)
if(storage_iframe && storage_iframe != /datum/config_entry/string/storage_cdn_iframe::default)
basehtml = replacetext(basehtml, "\[tgui:storagecdn\]", storage_iframe)
return
if(CONFIG_GET(string/asset_transport) == "webroot")
var/datum/asset_transport/webroot/webroot = SSassets.transport
var/datum/asset_cache_item/item = webroot.register_asset("iframe.html", file("tgui/public/iframe.html"))
basehtml = replacetext(basehtml, "\[tgui:storagecdn\]", webroot.get_asset_url("iframe.html", item))
return
if(!storage_iframe)
return
basehtml = replacetext(basehtml, "\[tgui:storagecdn\]", storage_iframe)
/datum/controller/subsystem/tgui/Shutdown()
close_all_uis()

View File

@@ -268,8 +268,7 @@ GLOBAL_LIST_INIT(unrecommended_builds, list(
persistent_client = new(ckey)
persistent_client.set_client(src)
if(byond_version >= 516)
winset(src, null, list("browser-options" = "find,refresh,byondstorage"))
winset(src, null, list("browser-options" = "find,refresh"))
// Instantiate stat panel
stat_panel = new(src, "statbrowser")

View File

@@ -44,3 +44,12 @@ EXTERNAL_RSC_URLS http://tgstation13.download/byond/tgstationv2.zip
# <add name="Vary" value="Origin" />
# <add name="Access-Control-Allow-Methods" value="GET, POST, OPTIONS" />
# <add name="Access-Control-Max-Age" value="0" />
# If configured, this allows server operators to define the persistent origin used for clientside
# storage. This must host the same file as available in tgui/public/iframe.html. This is also hosted
# on the GitHub Pages site for the /tg/station repository, so does not need to be configured.
# If multiple servers use the same domain name, clientside features such as message saving
# and chat tabs will be persistent across both.
# If this setting is not configured, but the webroot CDN is, that will be used instead of GitHub Pages.
# If this setting is mpty, and the webroot CDN is disabled, byondstorage will be used.
# STORAGE_CDN_IFRAME https://tgstation.github.io/byond-client-storage/iframe.html

5
tgui/global.d.ts vendored
View File

@@ -63,6 +63,11 @@ type ByondType = {
*/
strictMode: boolean;
/**
* The external URL for the IndexedDB IFrame to use as the origin
*/
storageCdn: string;
/**
* Makes a BYOND call.
*

View File

@@ -8,8 +8,12 @@
export const IMPL_MEMORY = 0;
export const IMPL_HUB_STORAGE = 1;
export const IMPL_IFRAME_INDEXED_DB = 2;
type StorageImplementation = typeof IMPL_MEMORY | typeof IMPL_HUB_STORAGE;
type StorageImplementation =
| typeof IMPL_MEMORY
| typeof IMPL_HUB_STORAGE
| typeof IMPL_IFRAME_INDEXED_DB;
type StorageBackend = {
impl: StorageImplementation;
@@ -85,6 +89,80 @@ class HubStorageBackend implements StorageBackend {
}
}
class IFrameIndexedDbBackend implements StorageBackend {
public impl: StorageImplementation;
private documentElement: HTMLIFrameElement;
private iframeWindow: Window;
constructor() {
this.impl = IMPL_IFRAME_INDEXED_DB;
}
async ready(): Promise<boolean | null> {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = Byond.storageCdn;
const completePromise: Promise<boolean> = new Promise((resolve) => {
iframe.onload = () => resolve(true);
});
this.documentElement = document.body.appendChild(iframe);
if (!this.documentElement.contentWindow) {
return new Promise((res) => res(false));
}
this.iframeWindow = this.documentElement.contentWindow;
return completePromise;
}
async get(key: string): Promise<any> {
const promise = new Promise((resolve) => {
window.addEventListener('message', (message) => {
if (message.data.key && message.data.key === key) {
resolve(message.data.value);
}
});
});
this.iframeWindow.postMessage({ type: 'get', key: key }, '*');
return promise;
}
async set(key: string, value: any): Promise<void> {
this.iframeWindow.postMessage({ type: 'set', key: key, value: value }, '*');
}
async remove(key: string): Promise<void> {
this.iframeWindow.postMessage({ type: 'remove', key: key }, '*');
}
async clear(): Promise<void> {
this.iframeWindow.postMessage({ type: 'clear' }, '*');
}
async ping(): Promise<boolean> {
const promise: Promise<boolean> = new Promise((resolve) => {
window.addEventListener('message', (message) => {
if (message.data === true) {
resolve(true);
}
});
setTimeout(() => resolve(false), 100);
});
this.iframeWindow.postMessage({ type: 'ping' }, '*');
return promise;
}
async destroy(): Promise<void> {
document.body.removeChild(this.documentElement);
}
}
/**
* Web Storage Proxy object, which selects the best backend available
* depending on the environment.
@@ -95,10 +173,53 @@ class StorageProxy implements StorageBackend {
constructor() {
this.backendPromise = (async () => {
if (testHubStorage()) {
if (Byond.storageCdn && !window.hubStorage) {
const iframe = new IFrameIndexedDbBackend();
await iframe.ready();
if ((await iframe.ping()) === true) {
if (await iframe.get('byondstorage-migrated')) return iframe;
Byond.winset(null, 'browser-options', '+byondstorage');
await new Promise<void>((resolve) => {
document.addEventListener('byondstorageupdated', async () => {
setTimeout(() => {
const hub = new HubStorageBackend();
for (const setting of ['panel-settings', 'chat-state', 'chat-messages']) {
hub
.get(setting)
.then((settings) => iframe.set(setting, settings));
}
iframe.set('byondstorage-migrated', true);
Byond.winset(null, 'browser-options', '-byondstorage');
resolve();
}, 1);
});
});
return iframe;
}
iframe.destroy();
if (!testHubStorage()) {
Byond.winset(null, 'browser-options', '+byondstorage');
return new Promise((resolve) => {
const listener = () => {
document.removeEventListener('byondstorageupdated', listener);
resolve(new HubStorageBackend());
};
document.addEventListener('byondstorageupdated', listener);
});
}
return new HubStorageBackend();
}
console.warn(
'No supported storage backend found. Using in-memory storage.',
);

View File

@@ -29,6 +29,7 @@
// Expose inlined metadata
Byond.windowId = parseMetaTag('tgui:windowId');
Byond.storageCdn = parseMetaTag('tgui:storagecdn');
// Backwards compatibility
window.__windowId__ = Byond.windowId;

File diff suppressed because one or more lines are too long

98
tgui/public/iframe.html Normal file
View File

@@ -0,0 +1,98 @@
<!doctype html>
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta charset="utf-8" />
<script type="text/javascript">
const INDEXED_DB_VERSION = 1;
const INDEXED_DB_NAME = 'tgui';
const INDEXED_DB_STORE_NAME = 'storage';
const READ_ONLY = 'readonly';
const READ_WRITE = 'readwrite';
const MAX_MESSAGES = 1000;
const dbPromise = new Promise((resolve, reject) => {
const indexedDB = window.indexedDB;
const req = indexedDB.open(INDEXED_DB_NAME, INDEXED_DB_VERSION);
req.onupgradeneeded = (event) => {
try {
if (event.oldVersion < 1) {
req.result.createObjectStore(INDEXED_DB_STORE_NAME);
}
} catch (err) {
reject(new Error('Failed to upgrade IDB: ' + req.error));
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => {
reject(new Error('Failed to open IDB: ' + req.error));
};
});
window.addEventListener('message', (messageEvent) => {
switch (messageEvent.data.type) {
case 'ping':
messageEvent.source.postMessage(true, '*');
break;
case 'get':
get(messageEvent.data.key).then((value) => {
messageEvent.source.postMessage(
{ key: messageEvent.data.key, value: value },
'*',
);
});
break;
case 'set':
set(messageEvent.data.key, messageEvent.data.value);
break;
case 'remove':
remove(messageEvent.data.key);
break;
case 'clear':
clear();
break;
default:
break;
}
});
const getStore = async (mode) => {
return dbPromise.then((db) =>
db
.transaction(INDEXED_DB_STORE_NAME, mode)
.objectStore(INDEXED_DB_STORE_NAME),
);
};
const get = async (key) => {
const store = await getStore(READ_ONLY);
return new Promise((resolve, reject) => {
const req = store.get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
};
const set = async (key, value) => {
const store = await getStore(READ_WRITE);
store.put(value, key);
};
const remove = async (key) => {
const store = await getStore(READ_WRITE);
store.delete(key);
};
const clear = async () => {
const store = await getStore(READ_WRITE);
store.clear();
};
</script>
</head>
</html>

View File

@@ -6,6 +6,7 @@
<!-- Inlined metadata -->
<meta id="tgui:windowId" content="[tgui:windowId]" />
<meta id="tgui:strictMode" content="[tgui:strictMode]" />
<meta id="tgui:storagecdn" content="[tgui:storagecdn]" />
<!-- Early setup -->
<!-- tgui:helpers -->