diff --git a/code/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm index 1c90e9ccc0c..a5f9e33341e 100644 --- a/code/controllers/configuration/entries/general.dm +++ b/code/controllers/configuration/entries/general.dm @@ -775,3 +775,10 @@ /// If admins with +DEBUG can queue byond-tracy to run the next round. /datum/config_entry/flag/allow_tracy_queue protection = CONFIG_ENTRY_LOCKED + +/** + * Tgui ui_act payloads larger than 2kb are split into chunks a maximum of 1kb in size. + * This flag represents the maximum chunk count the server is willing to receive. + */ +/datum/config_entry/number/tgui_max_chunk_count + default = 32 diff --git a/code/modules/tgui/tgui_window.dm b/code/modules/tgui/tgui_window.dm index 7fa6faecc68..38c49548b64 100644 --- a/code/modules/tgui/tgui_window.dm +++ b/code/modules/tgui/tgui_window.dm @@ -26,6 +26,8 @@ var/initial_inline_js var/initial_inline_css + var/list/oversized_payloads = list() + /** * public * @@ -380,6 +382,46 @@ reinitialize() if("chat/resend") SSchat.handle_resend(client, payload) + if("oversizedPayloadRequest") + var/payload_id = payload["id"] + var/chunk_count = payload["chunkCount"] + var/permit_payload = chunk_count <= CONFIG_GET(number/tgui_max_chunk_count) + if(permit_payload) + create_oversized_payload(payload_id, payload["type"], chunk_count) + send_message("oversizePayloadResponse", list("allow" = permit_payload, "id" = payload_id)) + if("payloadChunk") + var/payload_id = payload["id"] + append_payload_chunk(payload_id, payload["chunk"]) + send_message("acknowlegePayloadChunk", list("id" = payload_id)) /datum/tgui_window/vv_edit_var(var_name, var_value) return var_name != NAMEOF(src, id) && ..() + +/datum/tgui_window/proc/create_oversized_payload(payload_id, message_type, chunk_count) + if(oversized_payloads[payload_id]) + stack_trace("Attempted to create oversized tgui payload with duplicate ID.") + return + oversized_payloads[payload_id] = list( + "type" = message_type, + "count" = chunk_count, + "chunks" = list(), + "timeout" = addtimer(CALLBACK(src, PROC_REF(remove_oversized_payload), payload_id), 1 SECONDS, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_STOPPABLE) + ) + +/datum/tgui_window/proc/append_payload_chunk(payload_id, chunk) + var/list/payload = oversized_payloads[payload_id] + if(!payload) + return + var/list/chunks = payload["chunks"] + chunks += chunk + if(length(chunks) >= payload["count"]) + deltimer(payload["timeout"]) + var/message_type = payload["type"] + var/final_payload = chunks.Join() + remove_oversized_payload(payload_id) + on_message(message_type, json_decode(final_payload), list("type" = message_type, "payload" = final_payload, "tgui" = TRUE, "window_id" = id)) + else + payload["timeout"] = addtimer(CALLBACK(src, PROC_REF(remove_oversized_payload), payload_id), 1 SECONDS, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_STOPPABLE) + +/datum/tgui_window/proc/remove_oversized_payload(payload_id) + oversized_payloads -= payload_id diff --git a/config/config.txt b/config/config.txt index f0aa37f8047..daf5a0c1b28 100644 --- a/config/config.txt +++ b/config/config.txt @@ -638,3 +638,7 @@ UPLOAD_LIMIT_ADMIN 5242880 ## Uncomment to allow admins with +DEBUG to queue the next round to run the byond-tracy profiler. #ALLOW_TRACY_QUEUE + +## Tgui payloads larger than the 2kb limit for BYOND topic requests are split into roughly 1kb chunks and sent in sequence. +## This config option limits the maximum chunk count for which the server will accept a payload, default is 32 +TGUI_MAX_CHUNK_COUNT 32 diff --git a/tgui/packages/tgui/backend.ts b/tgui/packages/tgui/backend.ts index 1a0529374b4..d786d7635cc 100644 --- a/tgui/packages/tgui/backend.ts +++ b/tgui/packages/tgui/backend.ts @@ -31,6 +31,16 @@ export const setGlobalStore = (store) => { export const backendUpdate = createAction('backend/update'); export const backendSetSharedState = createAction('backend/setSharedState'); export const backendSuspendStart = createAction('backend/suspendStart'); +export const backendCreatePayloadQueue = createAction( + 'backend/createPayloadQueue', +); +export const backendDequeuePayloadQueue = createAction( + 'backend/dequeuePayloadQueue', +); +export const backendRemovePayloadQueue = createAction( + 'backend/removePayloadQueue', +); +export const nextPayloadChunk = createAction('nextPayloadChunk'); export const backendSuspendSuccess = () => ({ type: 'backend/suspendSuccess', @@ -43,6 +53,7 @@ const initialState = { config: {}, data: {}, shared: {}, + outgoingPayloadQueues: {} as Record, // Start as suspended suspended: Date.now(), suspending: false, @@ -119,6 +130,44 @@ export const backendReducer = (state = initialState, action) => { }; } + if (type === 'backend/createPayloadQueue') { + const { id, chunks } = payload; + const { outgoingPayloadQueues } = state; + return { + ...state, + outgoingPayloadQueues: { + ...outgoingPayloadQueues, + [id]: chunks, + }, + }; + } + + if (type === 'backend/dequeuePayloadQueue') { + const { id } = payload; + const { outgoingPayloadQueues } = state; + const { [id]: targetQueue, ...otherQueues } = outgoingPayloadQueues; + const [_, ...rest] = targetQueue; + return { + ...state, + outgoingPayloadQueues: rest.length + ? { + ...otherQueues, + [id]: rest, + } + : otherQueues, + }; + } + + if (type === 'backend/removePayloadQueue') { + const { id } = payload; + const { outgoingPayloadQueues } = state; + const { [id]: _, ...otherQueues } = outgoingPayloadQueues; + return { + ...state, + outgoingPayloadQueues: otherQueues, + }; + } + return state; }; @@ -127,7 +176,9 @@ export const backendMiddleware = (store) => { let suspendInterval; return (next) => (action) => { - const { suspended } = selectBackend(store.getState()); + const { suspended, outgoingPayloadQueues } = selectBackend( + store.getState(), + ); const { type, payload } = action; if (type === 'update') { @@ -212,10 +263,86 @@ export const backendMiddleware = (store) => { }); } + if (type === 'oversizePayloadResponse') { + const { allow } = payload; + if (allow) { + store.dispatch(nextPayloadChunk(payload)); + } else { + store.dispatch(backendRemovePayloadQueue(payload)); + } + } + + if (type === 'acknowlegePayloadChunk') { + store.dispatch(backendDequeuePayloadQueue(payload)); + store.dispatch(nextPayloadChunk(payload)); + } + + if (type === 'nextPayloadChunk') { + const { id } = payload; + const chunk = outgoingPayloadQueues[id][0]; + Byond.sendMessage('payloadChunk', { + id, + chunk, + }); + } + return next(action); }; }; +const encodedLengthBinarySearch = (haystack: string[], length: number) => { + const haystackLength = haystack.length; + let high = haystackLength - 1; + let low = 0; + let mid = 0; + while (low < high) { + mid = Math.round((low + high) / 2); + const substringLength = encodeURIComponent( + haystack.slice(0, mid).join(''), + ).length; + if (substringLength === length) { + break; + } + if (substringLength < length) { + low = mid + 1; + } else { + high = mid - 1; + } + } + return mid; +}; + +const chunkSplitter = { + [Symbol.split]: (string: string) => { + const charSeq = string[Symbol.iterator]().toArray(); + const length = charSeq.length; + let chunks: string[] = []; + let startIndex = 0; + let endIndex = 1024; + while (startIndex < length) { + const cut = charSeq.slice( + startIndex, + endIndex < length ? endIndex : undefined, + ); + const cutString = cut.join(''); + if (encodeURIComponent(cutString).length > 1024) { + const splitIndex = startIndex + encodedLengthBinarySearch(cut, 1024); + chunks.push( + charSeq + .slice(startIndex, splitIndex < length ? splitIndex : undefined) + .join(''), + ); + startIndex = splitIndex; + } else { + chunks.push(cutString); + startIndex = endIndex; + } + endIndex = startIndex + 1024; + } + return chunks; + }, +}; + /** * Sends an action to `ui_act` on `src_object` that this tgui window * is associated with. @@ -230,6 +357,31 @@ export const sendAct = (action: string, payload: object = {}) => { logger.error(`Payload for act() must be an object, got this:`, payload); return; } + if (!Byond.TRIDENT) { + const stringifiedPayload = JSON.stringify(payload); + const urlSize = Object.entries({ + type: 'act/' + action, + payload: stringifiedPayload, + tgui: 1, + windowId: Byond.windowId, + }).reduce( + (url, [key, value], i) => + url + + `${i > 0 ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}`, + '', + ).length; + if (urlSize > 2048) { + let chunks: string[] = stringifiedPayload.split(chunkSplitter); + const id = `${Date.now()}`; + globalStore?.dispatch(backendCreatePayloadQueue({ id, chunks })); + Byond.sendMessage('oversizedPayloadRequest', { + type: 'act/' + action, + id, + chunkCount: chunks.length, + }); + return; + } + } Byond.sendMessage('act/' + action, payload); }; @@ -260,6 +412,7 @@ type BackendState = { }; data: TData; shared: Record; + outgoingPayloadQueues: Record; suspending: boolean; suspended: boolean; }; diff --git a/tools/build/build.js b/tools/build/build.js index 915c24b3344..6c3fbee4b4b 100644 --- a/tools/build/build.js +++ b/tools/build/build.js @@ -222,6 +222,7 @@ export const DmTarget = new Juke.Target({ 'icons/**', 'interface/**', 'sound/**', + 'tgui/public/tgui.html', `${DME_NAME}.dme`, NamedVersionFile, ],