@serverkgg/bridge 0.0.13 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/validate.test.ts +194 -2
- package/src/cli/validate.ts +83 -1
- package/src/context/bridgeContext.ts +9 -2
- package/src/guides/variables.ts +1 -0
- package/src/manifest/manifest.ts +1 -1
- package/src/modules/bridgeModule.ts +2 -1
- package/src/panel/bridgePanel.ts +11 -0
- package/src/panel/bridgePlace.ts +4 -0
- package/src/panel/index.ts +1 -0
- package/src/rcon/index.ts +3 -2
- package/src/rcon/rconAccess.test.ts +304 -0
- package/src/rcon/rconAccess.ts +260 -0
- package/src/rcon/rconClient.test.ts +1544 -0
- package/src/rcon/rconClient.ts +275 -0
- package/src/rcon/rconError.ts +21 -0
- package/src/rcon/rconFrame.test.ts +269 -0
- package/src/rcon/{sourceRconFrame.ts → rconFrame.ts} +23 -14
- package/src/rcon/rconIds.test.ts +59 -0
- package/src/rcon/rconIds.ts +33 -0
- package/src/rcon/rconLink.ts +427 -0
- package/src/rcon/rconWriter.test.ts +95 -0
- package/src/rcon/rconWriter.ts +63 -0
- package/src/runtime/bridgeConsoleHandler.test.ts +196 -0
- package/src/runtime/bridgeConsoleHandler.ts +26 -0
- package/src/runtime/bridgeDriver.test.ts +145 -0
- package/src/runtime/bridgeDriver.ts +44 -2
- package/src/runtime/bridgeRcon.ts +6 -2
- package/src/runtime/bridgeRegistry.test.ts +96 -0
- package/src/runtime/bridgeRegistry.ts +33 -1
- package/src/runtime/serve.ts +10 -1
- package/src/steam/avatars.test.ts +306 -0
- package/src/steam/avatars.ts +107 -0
- package/src/steam/index.ts +1 -0
- package/src/wire/panel.test.ts +79 -0
- package/src/wire/panel.ts +11 -0
- package/src/rcon/sourceRcon.test.ts +0 -308
- package/src/rcon/sourceRcon.ts +0 -276
- package/src/rcon/sourceRconFrame.test.ts +0 -152
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const FIRST_ID = 1;
|
|
2
|
+
|
|
3
|
+
const LAST_ID = 2_147_483_646;
|
|
4
|
+
|
|
5
|
+
export interface RconIdPair {
|
|
6
|
+
id: number;
|
|
7
|
+
probeId: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RconIds {
|
|
11
|
+
next(): RconIdPair;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const createRconIds = (seed = FIRST_ID): RconIds => {
|
|
15
|
+
let cursor = seed >= FIRST_ID && seed < LAST_ID ? seed : FIRST_ID;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
next() {
|
|
19
|
+
if (cursor + 1 > LAST_ID) {
|
|
20
|
+
cursor = FIRST_ID;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const pair = {
|
|
24
|
+
id: cursor,
|
|
25
|
+
probeId: cursor + 1,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
cursor += 2;
|
|
29
|
+
|
|
30
|
+
return pair;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
};
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import type { Socket } from "bun";
|
|
2
|
+
import { BridgeRconCode, BridgeRconError } from "../core";
|
|
3
|
+
import { rconError, rconTarget } from "./rconError";
|
|
4
|
+
import {
|
|
5
|
+
concatBytes,
|
|
6
|
+
decodeRconFrames,
|
|
7
|
+
encodeRconFrame,
|
|
8
|
+
RCON_AUTH_FAILED_ID,
|
|
9
|
+
RCON_REPLY_LIMIT,
|
|
10
|
+
RCON_SPLIT_THRESHOLD,
|
|
11
|
+
type RconDecodedFrame,
|
|
12
|
+
RconRequest,
|
|
13
|
+
RconResponse,
|
|
14
|
+
} from "./rconFrame";
|
|
15
|
+
import type { RconIds } from "./rconIds";
|
|
16
|
+
import { createRconWriter, type RconWriter } from "./rconWriter";
|
|
17
|
+
|
|
18
|
+
const CLOSE_GRACE_MS = 1000;
|
|
19
|
+
|
|
20
|
+
const EMPTY = new Uint8Array(0);
|
|
21
|
+
|
|
22
|
+
export interface RconLinkOptions {
|
|
23
|
+
host: string;
|
|
24
|
+
port: number;
|
|
25
|
+
password: string;
|
|
26
|
+
timeoutMs: number;
|
|
27
|
+
ids: RconIds;
|
|
28
|
+
onLost(error: BridgeRconError): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RconExchange {
|
|
32
|
+
id: number;
|
|
33
|
+
probeId: number | null;
|
|
34
|
+
request: Uint8Array;
|
|
35
|
+
probe: Uint8Array | null;
|
|
36
|
+
mirroredIds: boolean;
|
|
37
|
+
deadlineAt: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RconExchangeResult {
|
|
41
|
+
written: number;
|
|
42
|
+
reply: Promise<string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RconLink {
|
|
46
|
+
readonly alive: boolean;
|
|
47
|
+
send(exchange: RconExchange): RconExchangeResult;
|
|
48
|
+
close(error: BridgeRconError): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface RconWaiting {
|
|
52
|
+
id: number;
|
|
53
|
+
probeId: number | null;
|
|
54
|
+
probe: Uint8Array | null;
|
|
55
|
+
probeSent: boolean;
|
|
56
|
+
mirroredIds: boolean;
|
|
57
|
+
auth: boolean;
|
|
58
|
+
parts: string[];
|
|
59
|
+
length: number;
|
|
60
|
+
settle(outcome: string | BridgeRconError): void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const openRconLink = async (options: RconLinkOptions): Promise<RconLink> => {
|
|
64
|
+
const target = rconTarget(options.host, options.port);
|
|
65
|
+
const deadlineAt = performance.now() + options.timeoutMs;
|
|
66
|
+
|
|
67
|
+
let alive = true;
|
|
68
|
+
let handed = false;
|
|
69
|
+
let expired = false;
|
|
70
|
+
let ended = false;
|
|
71
|
+
let buffer = EMPTY;
|
|
72
|
+
let socket: Socket<undefined> | null = null;
|
|
73
|
+
let writer: RconWriter | null = null;
|
|
74
|
+
let waiting: RconWaiting | null = null;
|
|
75
|
+
let exchangeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
76
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
77
|
+
let releaseClosed = () => {};
|
|
78
|
+
|
|
79
|
+
const closed = new Promise<void>((resolve) => {
|
|
80
|
+
releaseClosed = resolve;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const settleWaiting = (outcome: string | BridgeRconError) => {
|
|
84
|
+
const current = waiting;
|
|
85
|
+
|
|
86
|
+
waiting = null;
|
|
87
|
+
|
|
88
|
+
clearTimeout(exchangeTimer);
|
|
89
|
+
current?.settle(outcome);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const endSocket = () => {
|
|
93
|
+
if (!socket || ended) {
|
|
94
|
+
releaseClosed();
|
|
95
|
+
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const current = socket;
|
|
100
|
+
|
|
101
|
+
ended = true;
|
|
102
|
+
current.end();
|
|
103
|
+
|
|
104
|
+
graceTimer = setTimeout(() => {
|
|
105
|
+
current.terminate();
|
|
106
|
+
releaseClosed();
|
|
107
|
+
}, CLOSE_GRACE_MS);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const drop = (error: BridgeRconError, notify: boolean) => {
|
|
111
|
+
if (!alive) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
alive = false;
|
|
116
|
+
|
|
117
|
+
settleWaiting(error);
|
|
118
|
+
endSocket();
|
|
119
|
+
|
|
120
|
+
if (notify && handed) {
|
|
121
|
+
options.onLost(error);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const accept = (frame: RconDecodedFrame) => {
|
|
126
|
+
const current = waiting;
|
|
127
|
+
|
|
128
|
+
if (!current) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (current.auth) {
|
|
133
|
+
if (frame.type !== RconResponse.Auth) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
settleWaiting(
|
|
138
|
+
frame.id === RCON_AUTH_FAILED_ID
|
|
139
|
+
? new BridgeRconError(BridgeRconCode.AuthFailed, `${target} rejected the rcon password`)
|
|
140
|
+
: "",
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (frame.type !== RconResponse.Value) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (current.probeSent && frame.id === current.probeId) {
|
|
151
|
+
settleWaiting(current.parts.join(""));
|
|
152
|
+
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (current.mirroredIds && frame.id !== current.id) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
current.parts.push(frame.body);
|
|
161
|
+
current.length += frame.body.length;
|
|
162
|
+
|
|
163
|
+
if (current.length > RCON_REPLY_LIMIT) {
|
|
164
|
+
drop(
|
|
165
|
+
new BridgeRconError(
|
|
166
|
+
BridgeRconCode.Oversized,
|
|
167
|
+
`${target} sent an rcon reply larger than ${RCON_REPLY_LIMIT} bytes`,
|
|
168
|
+
),
|
|
169
|
+
true,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const probe = current.probe;
|
|
176
|
+
|
|
177
|
+
if (!probe) {
|
|
178
|
+
settleWaiting(current.parts.join(""));
|
|
179
|
+
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (current.probeSent) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (frame.bodyBytes < RCON_SPLIT_THRESHOLD) {
|
|
188
|
+
settleWaiting(current.parts.join(""));
|
|
189
|
+
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
current.probeSent = true;
|
|
194
|
+
|
|
195
|
+
const accepted = writer ? writer.send(probe) : 0;
|
|
196
|
+
|
|
197
|
+
if (accepted === 0 && (!writer || writer.dead)) {
|
|
198
|
+
drop(new BridgeRconError(BridgeRconCode.Closed, `the rcon probe to ${target} could not be sent`), true);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const receive = (chunk: Uint8Array) => {
|
|
203
|
+
let decoded: ReturnType<typeof decodeRconFrames>;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
decoded = decodeRconFrames(concatBytes(buffer, chunk));
|
|
207
|
+
} catch (error) {
|
|
208
|
+
drop(rconError(BridgeRconCode.Malformed, `${target} sent an unreadable rcon packet`, error), true);
|
|
209
|
+
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
buffer = decoded.rest.length > 0 ? decoded.rest.slice() : EMPTY;
|
|
214
|
+
|
|
215
|
+
for (const frame of decoded.frames) {
|
|
216
|
+
if (!alive) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
accept(frame);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const opened = await new Promise<Socket<undefined>>((resolve, reject) => {
|
|
225
|
+
const timer = setTimeout(
|
|
226
|
+
() => {
|
|
227
|
+
expired = true;
|
|
228
|
+
alive = false;
|
|
229
|
+
|
|
230
|
+
reject(new BridgeRconError(BridgeRconCode.TimedOut, `the rcon connection to ${target} timed out`));
|
|
231
|
+
},
|
|
232
|
+
Math.max(0, deadlineAt - performance.now()),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
Bun.connect({
|
|
236
|
+
hostname: options.host,
|
|
237
|
+
port: options.port,
|
|
238
|
+
socket: {
|
|
239
|
+
binaryType: "uint8array",
|
|
240
|
+
data(_socket, chunk) {
|
|
241
|
+
if (alive) {
|
|
242
|
+
receive(chunk);
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
close() {
|
|
246
|
+
ended = true;
|
|
247
|
+
|
|
248
|
+
clearTimeout(graceTimer);
|
|
249
|
+
releaseClosed();
|
|
250
|
+
drop(
|
|
251
|
+
new BridgeRconError(
|
|
252
|
+
BridgeRconCode.Closed,
|
|
253
|
+
handed
|
|
254
|
+
? `${target} closed the rcon connection`
|
|
255
|
+
: `${target} closed the rcon connection during authentication`,
|
|
256
|
+
),
|
|
257
|
+
true,
|
|
258
|
+
);
|
|
259
|
+
},
|
|
260
|
+
error(_socket, error) {
|
|
261
|
+
drop(rconError(BridgeRconCode.Unreachable, `the rcon connection to ${target} failed`, error), true);
|
|
262
|
+
},
|
|
263
|
+
drain() {
|
|
264
|
+
writer?.drain();
|
|
265
|
+
},
|
|
266
|
+
connectError() {},
|
|
267
|
+
},
|
|
268
|
+
}).then(
|
|
269
|
+
(created) => {
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
|
|
272
|
+
if (expired) {
|
|
273
|
+
created.terminate();
|
|
274
|
+
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
resolve(created);
|
|
279
|
+
},
|
|
280
|
+
(error: unknown) => {
|
|
281
|
+
clearTimeout(timer);
|
|
282
|
+
|
|
283
|
+
if (expired) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
reject(rconError(BridgeRconCode.Unreachable, `the rcon connection to ${target} could not be opened`, error));
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
socket = opened;
|
|
293
|
+
|
|
294
|
+
const sender = createRconWriter((bytes) => opened.write(bytes));
|
|
295
|
+
|
|
296
|
+
writer = sender;
|
|
297
|
+
|
|
298
|
+
const authId = options.ids.next().id;
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
await new Promise<void>((resolve, reject) => {
|
|
302
|
+
if (!alive) {
|
|
303
|
+
reject(
|
|
304
|
+
new BridgeRconError(BridgeRconCode.Closed, `${target} closed the rcon connection during authentication`),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
waiting = {
|
|
311
|
+
id: authId,
|
|
312
|
+
probeId: null,
|
|
313
|
+
probe: null,
|
|
314
|
+
probeSent: false,
|
|
315
|
+
mirroredIds: true,
|
|
316
|
+
auth: true,
|
|
317
|
+
parts: [],
|
|
318
|
+
length: 0,
|
|
319
|
+
settle(outcome) {
|
|
320
|
+
if (outcome instanceof BridgeRconError) {
|
|
321
|
+
reject(outcome);
|
|
322
|
+
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
resolve();
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
exchangeTimer = setTimeout(
|
|
331
|
+
() => {
|
|
332
|
+
drop(
|
|
333
|
+
new BridgeRconError(BridgeRconCode.TimedOut, `${target} did not answer the rcon authentication in time`),
|
|
334
|
+
false,
|
|
335
|
+
);
|
|
336
|
+
},
|
|
337
|
+
Math.max(0, deadlineAt - performance.now()),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const accepted = sender.send(
|
|
341
|
+
encodeRconFrame({
|
|
342
|
+
id: authId,
|
|
343
|
+
type: RconRequest.Auth,
|
|
344
|
+
body: options.password,
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
if (accepted === 0 && sender.dead) {
|
|
349
|
+
drop(
|
|
350
|
+
new BridgeRconError(BridgeRconCode.Closed, `the rcon authentication with ${target} could not be sent`),
|
|
351
|
+
false,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
} catch (error) {
|
|
356
|
+
drop(rconError(BridgeRconCode.Closed, `the rcon authentication with ${target} failed`, error), false);
|
|
357
|
+
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
handed = true;
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
get alive() {
|
|
365
|
+
return alive;
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
send(exchange) {
|
|
369
|
+
let written = 0;
|
|
370
|
+
|
|
371
|
+
const reply = new Promise<string>((resolve, reject) => {
|
|
372
|
+
if (!alive) {
|
|
373
|
+
reject(new BridgeRconError(BridgeRconCode.Closed, `${target} closed the rcon connection`));
|
|
374
|
+
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
waiting = {
|
|
379
|
+
id: exchange.id,
|
|
380
|
+
probeId: exchange.probeId,
|
|
381
|
+
probe: exchange.probe,
|
|
382
|
+
probeSent: false,
|
|
383
|
+
mirroredIds: exchange.mirroredIds,
|
|
384
|
+
auth: false,
|
|
385
|
+
parts: [],
|
|
386
|
+
length: 0,
|
|
387
|
+
settle(outcome) {
|
|
388
|
+
if (outcome instanceof BridgeRconError) {
|
|
389
|
+
reject(outcome);
|
|
390
|
+
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
resolve(outcome);
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
exchangeTimer = setTimeout(
|
|
399
|
+
() => {
|
|
400
|
+
drop(
|
|
401
|
+
new BridgeRconError(BridgeRconCode.TimedOut, `${target} did not answer the rcon request in time`),
|
|
402
|
+
true,
|
|
403
|
+
);
|
|
404
|
+
},
|
|
405
|
+
Math.max(0, exchange.deadlineAt - performance.now()),
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
written = sender.send(exchange.request);
|
|
409
|
+
|
|
410
|
+
if (written === 0 && sender.dead) {
|
|
411
|
+
drop(new BridgeRconError(BridgeRconCode.Closed, `the rcon request to ${target} could not be sent`), true);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
written,
|
|
417
|
+
reply,
|
|
418
|
+
};
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
async close(error) {
|
|
422
|
+
drop(error, false);
|
|
423
|
+
|
|
424
|
+
await closed;
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createRconWriter } from "./rconWriter";
|
|
3
|
+
|
|
4
|
+
const bytes = (...values: number[]) => new Uint8Array(values);
|
|
5
|
+
|
|
6
|
+
const recorder = (accept: (writes: number) => number | null) => {
|
|
7
|
+
const writes: number[][] = [];
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
writes,
|
|
11
|
+
write(chunk: Uint8Array) {
|
|
12
|
+
writes.push(Array.from(chunk));
|
|
13
|
+
|
|
14
|
+
return accept(writes.length) ?? chunk.length;
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("createRconWriter", () => {
|
|
20
|
+
test("reports a full write and keeps nothing pending", () => {
|
|
21
|
+
const socket = recorder(() => null);
|
|
22
|
+
const writer = createRconWriter(socket.write);
|
|
23
|
+
|
|
24
|
+
expect(writer.send(bytes(1, 2, 3))).toBe(3);
|
|
25
|
+
|
|
26
|
+
writer.drain();
|
|
27
|
+
|
|
28
|
+
expect(socket.writes).toEqual([
|
|
29
|
+
[
|
|
30
|
+
1,
|
|
31
|
+
2,
|
|
32
|
+
3,
|
|
33
|
+
],
|
|
34
|
+
]);
|
|
35
|
+
expect(writer.dead).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("buffers the remainder of a short write and finishes it on drain", () => {
|
|
39
|
+
const socket = recorder((writes) => (writes === 1 ? 2 : null));
|
|
40
|
+
const writer = createRconWriter(socket.write);
|
|
41
|
+
|
|
42
|
+
expect(writer.send(bytes(1, 2, 3, 4))).toBe(2);
|
|
43
|
+
|
|
44
|
+
writer.drain();
|
|
45
|
+
|
|
46
|
+
expect(socket.writes).toEqual([
|
|
47
|
+
[
|
|
48
|
+
1,
|
|
49
|
+
2,
|
|
50
|
+
3,
|
|
51
|
+
4,
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
3,
|
|
55
|
+
4,
|
|
56
|
+
],
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("appends a send made while a remainder is pending", () => {
|
|
61
|
+
const socket = recorder((writes) => (writes === 1 ? 1 : null));
|
|
62
|
+
const writer = createRconWriter(socket.write);
|
|
63
|
+
|
|
64
|
+
writer.send(bytes(1, 2));
|
|
65
|
+
|
|
66
|
+
expect(writer.send(bytes(3, 4))).toBe(2);
|
|
67
|
+
|
|
68
|
+
writer.drain();
|
|
69
|
+
|
|
70
|
+
expect(socket.writes.at(1)).toEqual([
|
|
71
|
+
2,
|
|
72
|
+
3,
|
|
73
|
+
4,
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("reports zero accepted and dies when the socket answers minus one", () => {
|
|
78
|
+
const socket = recorder(() => -1);
|
|
79
|
+
const writer = createRconWriter(socket.write);
|
|
80
|
+
|
|
81
|
+
expect(writer.send(bytes(1, 2))).toBe(0);
|
|
82
|
+
expect(writer.dead).toBe(true);
|
|
83
|
+
expect(writer.send(bytes(3))).toBe(0);
|
|
84
|
+
expect(socket.writes).toHaveLength(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("does not write on a drain with an empty buffer", () => {
|
|
88
|
+
const socket = recorder(() => null);
|
|
89
|
+
const writer = createRconWriter(socket.write);
|
|
90
|
+
|
|
91
|
+
writer.drain();
|
|
92
|
+
|
|
93
|
+
expect(socket.writes).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { concatBytes } from "./rconFrame";
|
|
2
|
+
|
|
3
|
+
const EMPTY = new Uint8Array(0);
|
|
4
|
+
|
|
5
|
+
export interface RconWriter {
|
|
6
|
+
readonly dead: boolean;
|
|
7
|
+
send(bytes: Uint8Array): number;
|
|
8
|
+
drain(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const createRconWriter = (write: (bytes: Uint8Array) => number): RconWriter => {
|
|
12
|
+
let pending: Uint8Array<ArrayBufferLike> = EMPTY;
|
|
13
|
+
let dead = false;
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
get dead() {
|
|
17
|
+
return dead;
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
send(bytes) {
|
|
21
|
+
if (dead || bytes.length === 0) {
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (pending.length > 0) {
|
|
26
|
+
pending = concatBytes(pending, bytes);
|
|
27
|
+
|
|
28
|
+
return bytes.length;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const written = write(bytes);
|
|
32
|
+
|
|
33
|
+
if (written < 0) {
|
|
34
|
+
dead = true;
|
|
35
|
+
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (written < bytes.length) {
|
|
40
|
+
pending = bytes.slice(written);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return written;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
drain() {
|
|
47
|
+
if (dead || pending.length === 0) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const written = write(pending);
|
|
52
|
+
|
|
53
|
+
if (written < 0) {
|
|
54
|
+
dead = true;
|
|
55
|
+
pending = EMPTY;
|
|
56
|
+
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
pending = written < pending.length ? pending.slice(written) : EMPTY;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
};
|