Skip to content

Commit 9896da6

Browse files
committed
Fix inputs on BYOND 516 getting cut off (tgui payload chunking)
Ports tgstation/tgstation#90295
1 parent 62d1b68 commit 9896da6

File tree

7 files changed

+190
-6
lines changed

7 files changed

+190
-6
lines changed

.prettierignore

+3
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@
33

44
# We want it to run into the TGUI folder, however.
55
!/tgui
6+
7+
# Except on tgui.html, because it would mangle the ascii art
8+
/tgui/public/tgui.html

code/controllers/configuration/entries/general.dm

+7
Original file line numberDiff line numberDiff line change
@@ -705,3 +705,10 @@
705705

706706
/datum/config_entry/flag/vpn_kick
707707
default = FALSE
708+
709+
/**
710+
* Tgui ui_act payloads larger than 2kb are split into chunks a maximum of 1kb in size.
711+
* This flag represents the maximum chunk count the server is willing to receive.
712+
*/
713+
/datum/config_entry/number/tgui_max_chunk_count
714+
default = 10

code/modules/tgui/tgui_window.dm

+42
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
"Ctrl+UP" = "byond/ctrlup",
3939
)
4040

41+
var/list/oversized_payloads = list()
42+
4143
/**
4244
* public
4345
*
@@ -386,6 +388,17 @@
386388
reinitialize()
387389
if("chat/resend")
388390
SSchat.handle_resend(client, payload)
391+
if("oversizedPayloadRequest")
392+
var/payload_id = payload["id"]
393+
var/chunk_count = payload["chunkCount"]
394+
var/permit_payload = chunk_count <= CONFIG_GET(number/tgui_max_chunk_count)
395+
if(permit_payload)
396+
create_oversized_payload(payload_id, payload["type"], chunk_count)
397+
send_message("oversizePayloadResponse", list("allow" = permit_payload, "id" = payload_id))
398+
if("payloadChunk")
399+
var/payload_id = payload["id"]
400+
append_payload_chunk(payload_id, payload["chunk"])
401+
send_message("acknowlegePayloadChunk", list("id" = payload_id))
389402

390403
/datum/tgui_window/vv_edit_var(var_name, var_value)
391404
return var_name != NAMEOF(src, id) && ..()
@@ -418,3 +431,32 @@
418431
for(var/mouseMacro in byondToTguiEventMap)
419432
winset(client, null, "[mouseMacro]Window[id]Macro.parent=null")
420433
mouse_event_macro_set = FALSE
434+
435+
/datum/tgui_window/proc/create_oversized_payload(payload_id, message_type, chunk_count)
436+
if(oversized_payloads[payload_id])
437+
stack_trace("Attempted to create oversized tgui payload with duplicate ID.")
438+
return
439+
oversized_payloads[payload_id] = list(
440+
"type" = message_type,
441+
"count" = chunk_count,
442+
"chunks" = list(),
443+
"timeout" = addtimer(CALLBACK(src, PROC_REF(remove_oversized_payload), payload_id), 1 SECONDS, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_STOPPABLE)
444+
)
445+
446+
/datum/tgui_window/proc/append_payload_chunk(payload_id, chunk)
447+
var/list/payload = oversized_payloads[payload_id]
448+
if(!payload)
449+
return
450+
var/list/chunks = payload["chunks"]
451+
chunks += chunk
452+
if(chunks.len >= payload["count"])
453+
deltimer(payload["timeout"])
454+
var/message_type = payload["type"]
455+
var/final_payload = chunks.Join()
456+
remove_oversized_payload(payload_id)
457+
on_message(message_type, json_decode(final_payload), list("type" = message_type, "payload" = final_payload, "tgui" = TRUE, "window_id" = id))
458+
else
459+
payload["timeout"] = addtimer(CALLBACK(src, PROC_REF(remove_oversized_payload), payload_id), 1 SECONDS, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_STOPPABLE)
460+
461+
/datum/tgui_window/proc/remove_oversized_payload(payload_id)
462+
oversized_payloads -= payload_id

config/config.txt

+3
Original file line numberDiff line numberDiff line change
@@ -572,3 +572,6 @@ CONFIG_ERRORS_RUNTIME
572572
## The age in days if minimum account age is on
573573
#MINIMUM_AGE
574574

575+
## Tgui payloads larger than the 2kb limit for BYOND topic requests are split into roughly 1kb chunks and sent in sequence.
576+
## This config option limits the maximum chunk count for which the server will accept a payload, default is 10
577+
TGUI_MAX_CHUNK_COUNT 10

tgui/packages/tgui/backend.ts

+127-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ export const setGlobalStore = (store) => {
3535
export const backendUpdate = createAction('backend/update');
3636
export const backendSetSharedState = createAction('backend/setSharedState');
3737
export const backendSuspendStart = createAction('backend/suspendStart');
38+
export const backendCreatePayloadQueue = createAction(
39+
'backend/createPayloadQueue',
40+
);
41+
export const backendDequeuePayloadQueue = createAction(
42+
'backend/dequeuePayloadQueue',
43+
);
44+
export const backendRemovePayloadQueue = createAction(
45+
'backend/removePayloadQueue',
46+
);
47+
export const nextPayloadChunk = createAction('nextPayloadChunk');
3848

3949
export const backendSuspendSuccess = () => ({
4050
type: 'backend/suspendSuccess',
@@ -47,6 +57,7 @@ const initialState = {
4757
config: {},
4858
data: {},
4959
shared: {},
60+
outgoingPayloadQueues: {} as Record<string, string[]>,
5061
// Start as suspended
5162
suspended: Date.now(),
5263
suspending: false,
@@ -123,6 +134,44 @@ export const backendReducer = (state = initialState, action) => {
123134
};
124135
}
125136

137+
if (type === 'backend/createPayloadQueue') {
138+
const { id, chunks } = payload;
139+
const { outgoingPayloadQueues } = state;
140+
return {
141+
...state,
142+
outgoingPayloadQueues: {
143+
...outgoingPayloadQueues,
144+
[id]: chunks,
145+
},
146+
};
147+
}
148+
149+
if (type === 'backend/dequeuePayloadQueue') {
150+
const { id } = payload;
151+
const { outgoingPayloadQueues } = state;
152+
const { [id]: targetQueue, ...otherQueues } = outgoingPayloadQueues;
153+
const [_, ...rest] = targetQueue;
154+
return {
155+
...state,
156+
outgoingPayloadQueues: rest.length
157+
? {
158+
...otherQueues,
159+
[id]: rest,
160+
}
161+
: otherQueues,
162+
};
163+
}
164+
165+
if (type === 'backend/removePayloadQueue') {
166+
const { id } = payload;
167+
const { outgoingPayloadQueues } = state;
168+
const { [id]: _, ...otherQueues } = outgoingPayloadQueues;
169+
return {
170+
...state,
171+
outgoingPayloadQueues: otherQueues,
172+
};
173+
}
174+
126175
return state;
127176
};
128177

@@ -131,7 +180,9 @@ export const backendMiddleware = (store) => {
131180
let suspendInterval;
132181

133182
return (next) => (action) => {
134-
const { suspended } = selectBackend(store.getState());
183+
const { suspended, outgoingPayloadQueues } = selectBackend(
184+
store.getState(),
185+
);
135186
const { type, payload } = action;
136187

137188
if (type === 'update') {
@@ -234,10 +285,59 @@ export const backendMiddleware = (store) => {
234285
});
235286
}
236287

288+
if (type === 'oversizePayloadResponse') {
289+
const { allow } = payload;
290+
if (allow) {
291+
store.dispatch(nextPayloadChunk(payload));
292+
} else {
293+
store.dispatch(backendRemovePayloadQueue(payload));
294+
}
295+
}
296+
297+
if (type === 'acknowlegePayloadChunk') {
298+
store.dispatch(backendDequeuePayloadQueue(payload));
299+
store.dispatch(nextPayloadChunk(payload));
300+
}
301+
302+
if (type === 'nextPayloadChunk') {
303+
const { id } = payload;
304+
const chunk = outgoingPayloadQueues[id][0];
305+
Byond.sendMessage('payloadChunk', {
306+
id,
307+
chunk,
308+
});
309+
}
310+
237311
return next(action);
238312
};
239313
};
240314

315+
const chunkSplitter = {
316+
[Symbol.split]: (string: string) => {
317+
let chunks: string[] = [];
318+
const uriEncoded = encodeURIComponent(string);
319+
let startIndex = 0;
320+
let endIndex = 1024;
321+
while (startIndex < uriEncoded.length) {
322+
const lastFoundPercent = uriEncoded.lastIndexOf('%', endIndex - 1);
323+
if (lastFoundPercent > endIndex - 3) {
324+
endIndex = lastFoundPercent + 3;
325+
}
326+
chunks.push(
327+
decodeURIComponent(
328+
uriEncoded.substring(
329+
startIndex,
330+
endIndex < uriEncoded.length ? endIndex : undefined,
331+
),
332+
),
333+
);
334+
startIndex = endIndex;
335+
endIndex += 1024;
336+
}
337+
return chunks;
338+
},
339+
};
340+
241341
/**
242342
* Sends an action to `ui_act` on `src_object` that this tgui window
243343
* is associated with.
@@ -252,6 +352,31 @@ export const sendAct = (action: string, payload: object = {}) => {
252352
logger.error(`Payload for act() must be an object, got this:`, payload);
253353
return;
254354
}
355+
if (!Byond.TRIDENT) {
356+
const stringifiedPayload = JSON.stringify(payload);
357+
const urlSize = Object.entries({
358+
type: 'act/' + action,
359+
payload: stringifiedPayload,
360+
tgui: 1,
361+
windowId: Byond.windowId,
362+
}).reduce(
363+
(url, [key, value], i) =>
364+
url +
365+
`${i > 0 ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
366+
'',
367+
).length;
368+
if (urlSize > 2048) {
369+
let chunks: string[] = stringifiedPayload.split(chunkSplitter);
370+
const id = `${Date.now()}`;
371+
globalStore?.dispatch(backendCreatePayloadQueue({ id, chunks }));
372+
Byond.sendMessage('oversizedPayloadRequest', {
373+
type: 'act/' + action,
374+
id,
375+
chunkCount: chunks.length,
376+
});
377+
return;
378+
}
379+
}
255380
Byond.sendMessage('act/' + action, payload);
256381
};
257382

@@ -279,6 +404,7 @@ type BackendState<TData> = {
279404
};
280405
data: TData;
281406
shared: Record<string, any>;
407+
outgoingPayloadQueues: Record<string, any[]>;
282408
suspending: boolean;
283409
suspended: boolean;
284410
debug?: {

tgui/public/tgui.html

+7-5
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,13 @@
122122
location.href = 'byond://' + url;
123123
return;
124124
}
125-
// Send an HTTP request to DreamSeeker's HTTP server.
126-
// Allows sending much bigger payloads.
127-
var xhr = new XMLHttpRequest();
128-
xhr.open('GET', url);
129-
xhr.send();
125+
if (Byond.TRIDENT !== null) {
126+
// Send an HTTP request to DreamSeeker's HTTP server.
127+
// Allows sending much bigger payloads.
128+
var xhr = new XMLHttpRequest();
129+
xhr.open('GET', url);
130+
xhr.send();
131+
}
130132
};
131133

132134
Byond.callAsync = function (path, params) {

tools/build/build.js

+1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export const DmTarget = new Juke.Target({
7575
'html/**',
7676
'icons/**',
7777
'interface/**',
78+
'tgui/public/tgui.html',
7879
'monkestation/code/**', // monke edit: ensure it also checks for updates in modular code
7980
'monkestation/icons/**',
8081
`${DME_NAME}.dme`,

0 commit comments

Comments
 (0)