Skip to content

Fix inputs on BYOND 516 getting cut off (tgui payload chunking) #6088

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Mar 31, 2025
Merged
7 changes: 7 additions & 0 deletions code/controllers/configuration/entries/general.dm
Original file line number Diff line number Diff line change
Expand Up @@ -705,3 +705,10 @@

/datum/config_entry/flag/vpn_kick
default = FALSE

/**
* 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
42 changes: 42 additions & 0 deletions code/modules/tgui/tgui_window.dm
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
"Ctrl+UP" = "byond/ctrlup",
)

var/list/oversized_payloads = list()

/**
* public
*
Expand Down Expand Up @@ -386,6 +388,17 @@
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) && ..()
Expand Down Expand Up @@ -418,3 +431,32 @@
for(var/mouseMacro in byondToTguiEventMap)
winset(client, null, "[mouseMacro]Window[id]Macro.parent=null")
mouse_event_macro_set = FALSE

/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(chunks.len >= 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
3 changes: 3 additions & 0 deletions config/config.txt
Original file line number Diff line number Diff line change
Expand Up @@ -572,3 +572,6 @@ CONFIG_ERRORS_RUNTIME
## The age in days if minimum account age is on
#MINIMUM_AGE

## 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
156 changes: 155 additions & 1 deletion tgui/packages/tgui/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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',
Expand All @@ -47,6 +57,7 @@ const initialState = {
config: {},
data: {},
shared: {},
outgoingPayloadQueues: {} as Record<string, string[]>,
// Start as suspended
suspended: Date.now(),
suspending: false,
Expand Down Expand Up @@ -123,6 +134,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;
};

Expand All @@ -131,7 +180,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') {
Expand Down Expand Up @@ -234,10 +285,87 @@ 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) => {
// TODO: get rid of the "as any" whenever we upgrade typescript
const charSeq = (string[Symbol.iterator]() as any).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.
Expand All @@ -252,6 +380,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);
};

Expand Down Expand Up @@ -279,6 +432,7 @@ type BackendState<TData> = {
};
data: TData;
shared: Record<string, any>;
outgoingPayloadQueues: Record<string, any[]>;
suspending: boolean;
suspended: boolean;
debug?: {
Expand Down
12 changes: 7 additions & 5 deletions tgui/public/tgui.html
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,13 @@
location.href = 'byond://' + url;
return;
}
// Send an HTTP request to DreamSeeker's HTTP server.
// Allows sending much bigger payloads.
var xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.send();
if (Byond.TRIDENT !== null) {
// Send an HTTP request to DreamSeeker's HTTP server.
// Allows sending much bigger payloads.
var xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.send();
}
};

Byond.callAsync = function (path, params) {
Expand Down
1 change: 1 addition & 0 deletions tools/build/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const DmTarget = new Juke.Target({
'html/**',
'icons/**',
'interface/**',
'tgui/public/tgui.html',
'monkestation/code/**', // monke edit: ensure it also checks for updates in modular code
'monkestation/icons/**',
`${DME_NAME}.dme`,
Expand Down
Loading