@ankorcs/protocol 0.0.0-stage → 0.6.1
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/CHANGELOG.md +9 -0
- package/LICENSE +202 -0
- package/README.md +72 -2
- package/dist/agent-config.d.ts +101 -0
- package/dist/agent-config.js +144 -0
- package/dist/agent-image.d.ts +12 -0
- package/dist/agent-image.js +12 -0
- package/dist/agent-socket.d.ts +3927 -0
- package/dist/agent-socket.js +1764 -0
- package/dist/agent-yml.d.ts +21 -0
- package/dist/agent-yml.js +71 -0
- package/dist/agent-yml.schema.json +160 -0
- package/dist/asyncapi.yaml +10569 -0
- package/dist/checks.d.ts +19 -0
- package/dist/checks.js +40 -0
- package/dist/container.d.ts +190 -0
- package/dist/container.js +144 -0
- package/dist/event-batch.d.ts +857 -0
- package/dist/event-batch.js +90 -0
- package/dist/game-events.d.ts +2137 -0
- package/dist/game-events.js +1082 -0
- package/dist/helpers.d.ts +176 -0
- package/dist/helpers.js +362 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +26 -0
- package/dist/launch.d.ts +316 -0
- package/dist/launch.js +287 -0
- package/dist/limits.d.ts +381 -0
- package/dist/limits.js +277 -0
- package/dist/paths.d.ts +73 -0
- package/dist/paths.js +73 -0
- package/dist/plugin-socket.d.ts +1946 -0
- package/dist/plugin-socket.js +160 -0
- package/dist/session.d.ts +127 -0
- package/dist/session.js +114 -0
- package/dist/values.d.ts +186 -0
- package/dist/values.js +230 -0
- package/node-setup.sh +1061 -0
- package/package.json +57 -4
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functions that the agent and a controller in TypeScript share to implement either end of the
|
|
3
|
+
* wire. The wire itself is the schemas and constants in `@ankorcs/protocol`.
|
|
4
|
+
*/
|
|
5
|
+
import type { z } from 'zod';
|
|
6
|
+
import { type AgentError, type AgentLogBatch, type AgentResourceBatch, type DemoFile, type LogLine, type LogSource, type ResourceTick, type SlotStatus } from './agent-socket.js';
|
|
7
|
+
import { type Budget, type BudgetName, type Rate } from './limits.js';
|
|
8
|
+
import { type SlotPorts } from './values.js';
|
|
9
|
+
export { isIpv4, isControllerHost } from './checks.js';
|
|
10
|
+
/**
|
|
11
|
+
* A slot whose id is absent where the caller has not assigned ids yet and position decides them.
|
|
12
|
+
*/
|
|
13
|
+
type SlotLayout = SlotPorts & {
|
|
14
|
+
id?: string | undefined;
|
|
15
|
+
};
|
|
16
|
+
/** One problem in a slot layout, with the slot and the field it belongs to. */
|
|
17
|
+
export interface SlotLayoutIssue {
|
|
18
|
+
index: number;
|
|
19
|
+
field: 'id' | 'port' | 'tv_port';
|
|
20
|
+
kind: 'id' | 'range' | 'reserved' | 'duplicate';
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
interface SlotLayoutOptions {
|
|
24
|
+
/** The lowest port the caller allows. */
|
|
25
|
+
minPort: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The layout checks that `agent.yml`, edited slot input and {@link ControllerSetConfig | set_config}
|
|
29
|
+
* share.
|
|
30
|
+
*/
|
|
31
|
+
export declare function validateSlotLayout(slots: readonly SlotLayout[], { minPort }: SlotLayoutOptions): SlotLayoutIssue[];
|
|
32
|
+
/** {@link validateSlotLayout} as zod issues, under `prefix`. */
|
|
33
|
+
export declare function addSlotLayoutIssues(ctx: z.RefinementCtx, slots: readonly SlotLayout[], minPort: number, prefix?: readonly (string | number)[]): void;
|
|
34
|
+
/**
|
|
35
|
+
* Game servers a node with `cpuCount` CPUs can run at once: one per CPU, keeping one for the host
|
|
36
|
+
* and the agent, and at least one.
|
|
37
|
+
*/
|
|
38
|
+
export declare function gameServerBudget(cpuCount: number): number;
|
|
39
|
+
/** The part of a token that leaves the agent, as {@link GsltHint} describes. */
|
|
40
|
+
export declare function gsltHint(token: string): string;
|
|
41
|
+
/** Indexes of tokens that an earlier slot in the list already has. Missing tokens are skipped. */
|
|
42
|
+
export declare function duplicateGslts(tokens: readonly (string | undefined)[]): number[];
|
|
43
|
+
/** Adds an issue on each slot whose token another slot of the node already has. */
|
|
44
|
+
export declare function addGsltIssues(ctx: z.RefinementCtx, slots: readonly {
|
|
45
|
+
gslt?: string | undefined;
|
|
46
|
+
}[], prefix?: readonly (string | number)[]): void;
|
|
47
|
+
/**
|
|
48
|
+
* Whether a node that reaches the controller at this address should encrypt by default. It should
|
|
49
|
+
* for every address except the literals of a local network, because no certificate authority issues
|
|
50
|
+
* certificates for them. The operator decides, and this function picks only the side the toggle
|
|
51
|
+
* starts on.
|
|
52
|
+
*/
|
|
53
|
+
export declare function controllerTlsByDefault(host: string): boolean;
|
|
54
|
+
/** The controller's origin for `host` and `tls`, as `http(s)://` or `ws(s)://`. */
|
|
55
|
+
export declare function controllerOrigin(host: string, tls: boolean, kind: 'http' | 'ws'): string;
|
|
56
|
+
/** The agent socket's URL: the `ws(s)://` origin and the socket path. */
|
|
57
|
+
export declare function agentWsUrl(host: string, tls: boolean, path: string): string;
|
|
58
|
+
/** A refusal as a log line reads it: the code, and what the node said beyond it. */
|
|
59
|
+
export declare function agentErrorText(error: AgentError): string;
|
|
60
|
+
/** Orders build ids numerically. Null, for an unknown build, sorts lowest. */
|
|
61
|
+
export declare function compareBuildIds(a: string | null, b: string | null): number;
|
|
62
|
+
/**
|
|
63
|
+
* Whether a node is behind the newest public CS2 build. Such a node refuses launches with
|
|
64
|
+
* `cs2_outdated`, because players on the new client cannot join it. A node whose `auto_update`
|
|
65
|
+
* is `false` does not refuse. The answer is `false` while either build is unknown.
|
|
66
|
+
*/
|
|
67
|
+
export declare function cs2Behind(installed: string | null, latest: string | null): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Whether a launch may use a slot. The slot must be idle and unreserved, and Steam must not have
|
|
70
|
+
* refused its token. The node decides, and the controller's ranking follows it.
|
|
71
|
+
*/
|
|
72
|
+
export declare function slotTakesLaunch(status: Pick<SlotStatus, 'state' | 'gslt_state' | 'reserved'>): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* The key that log sources are matched on: `agent`, `cs2_update:<updateId>` or
|
|
75
|
+
* `server:<allocationId>`.
|
|
76
|
+
*/
|
|
77
|
+
export declare function logSourceKey(source: LogSource): string;
|
|
78
|
+
/** Lines the agent keeps of a log source, and the controller after it. */
|
|
79
|
+
export declare function logSourceLines(source: LogSource): number;
|
|
80
|
+
/**
|
|
81
|
+
* A source's lines with one {@link AgentLogBatch | log_batch} of it applied, kept to what the agent
|
|
82
|
+
* keeps.
|
|
83
|
+
*/
|
|
84
|
+
export declare function withLogBatch(lines: readonly LogLine[], batch: Pick<AgentLogBatch, 'source' | 'reset' | 'lines'>): LogLine[];
|
|
85
|
+
/** The key that {@link AgentDemo | demo} frames are matched on. */
|
|
86
|
+
export declare function demoKey(demo: Pick<DemoFile, 'slot_id' | 'file_name'>): string;
|
|
87
|
+
/**
|
|
88
|
+
* A demo list with one {@link AgentDemo | demo} frame applied: the demo replaced in place, added at
|
|
89
|
+
* the end, or dropped.
|
|
90
|
+
*/
|
|
91
|
+
export declare function withDemo(demos: readonly DemoFile[], demo: DemoFile, removed: boolean): DemoFile[];
|
|
92
|
+
/**
|
|
93
|
+
* A ring with one {@link AgentResourceBatch | resource_batch} applied, kept to the last
|
|
94
|
+
* {@link RESOURCE_TICKS_HELD}.
|
|
95
|
+
*/
|
|
96
|
+
export declare function withTicks(ring: readonly ResourceTick[], frame: Pick<AgentResourceBatch, 'ticks' | 'reset'>): ResourceTick[];
|
|
97
|
+
/**
|
|
98
|
+
* The budget in {@link AGENT_BUDGETS} that a socket message spends from, by its `type`. Log
|
|
99
|
+
* messages spend `logBytes` as well, and a {@link AgentReply | reply} spends `strayReplies` only
|
|
100
|
+
* when it answers nothing open.
|
|
101
|
+
*/
|
|
102
|
+
export declare function frameBudget(type: unknown): Exclude<BudgetName, 'bytes' | 'logBytes' | 'pings'>;
|
|
103
|
+
/** A budget's rate for a node with `slots` slots. A count below one counts as one. */
|
|
104
|
+
export declare function budgetRate(budget: Budget, slots: number): Rate;
|
|
105
|
+
/** One budget of a connection: a burst that refills at a steady rate. */
|
|
106
|
+
export declare class TokenBucket {
|
|
107
|
+
private rate;
|
|
108
|
+
private at;
|
|
109
|
+
private tokens;
|
|
110
|
+
constructor(rate: Rate, at: number);
|
|
111
|
+
/** Spends `amount` and returns `true`, or spends nothing and returns `false`. */
|
|
112
|
+
take(amount: number, now: number): boolean;
|
|
113
|
+
/** What {@link TokenBucket.take} could spend now. */
|
|
114
|
+
available(now: number): number;
|
|
115
|
+
/**
|
|
116
|
+
* Changes the rate. A node that grows gets the extra burst immediately, so its first report of
|
|
117
|
+
* the new slots fits.
|
|
118
|
+
*/
|
|
119
|
+
resize(rate: Rate, now: number): void;
|
|
120
|
+
private refill;
|
|
121
|
+
}
|
|
122
|
+
/** {@link EVENT_BATCH_BUDGET}'s bytes per window for a node with `slots` slots. */
|
|
123
|
+
export declare function eventBatchAllowance(slots: number): number;
|
|
124
|
+
declare const REFUSAL_REASONS: {
|
|
125
|
+
readonly 401: "Unauthorized";
|
|
126
|
+
readonly 404: "Not Found";
|
|
127
|
+
readonly 426: "Upgrade Required";
|
|
128
|
+
readonly 429: "Too Many Requests";
|
|
129
|
+
readonly 503: "Service Unavailable";
|
|
130
|
+
};
|
|
131
|
+
/** An HTTP status a controller refuses an agent with, as {@link REFUSAL_REASONS} names them. */
|
|
132
|
+
export type RefusalStatus = keyof typeof REFUSAL_REASONS;
|
|
133
|
+
/**
|
|
134
|
+
* The whole answer that refuses a WebSocket upgrade: the status line, then the connection closes.
|
|
135
|
+
*/
|
|
136
|
+
export declare function upgradeRefusal(status: RefusalStatus): string;
|
|
137
|
+
/**
|
|
138
|
+
* A zod union of messages told apart by `type`, such as {@link AgentToControllerMessage}. zod keeps
|
|
139
|
+
* every `type` it accepts, nested unions included, in `propValues`.
|
|
140
|
+
*/
|
|
141
|
+
interface MessageUnion {
|
|
142
|
+
_zod: {
|
|
143
|
+
propValues: Partial<Record<string, ReadonlySet<unknown>>>;
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The `type` of a frame that names no message in `union`. A receiver skips such a frame, because a
|
|
148
|
+
* newer peer may send a message that this version does not have yet. Null when the frame has a
|
|
149
|
+
* known `type` or no string `type` at all, which is then for the schema to judge.
|
|
150
|
+
*/
|
|
151
|
+
export declare function unknownMessageType(union: MessageUnion, frame: unknown): string | null;
|
|
152
|
+
/** A protocol version as its major and minor. */
|
|
153
|
+
export interface ProtocolVersion {
|
|
154
|
+
major: number;
|
|
155
|
+
minor: number;
|
|
156
|
+
}
|
|
157
|
+
/** A {@link PROTOCOL_HEADER} value, such as `1.3`, as its major and minor. Null for any other value. */
|
|
158
|
+
export declare function protocolVersionOf(header: string | string[] | undefined): ProtocolVersion | null;
|
|
159
|
+
/**
|
|
160
|
+
* Whether a request's {@link PROTOCOL_HEADER} names a version this build can talk to: the same
|
|
161
|
+
* major as {@link PROTOCOL_VERSION}, at any minor.
|
|
162
|
+
*/
|
|
163
|
+
export declare function isCompatibleProtocol(header: string | string[] | undefined): boolean;
|
|
164
|
+
/** The node token of an `Authorization` header. Null when the header holds none. */
|
|
165
|
+
export declare function bearerNodeToken(header: string | undefined): string | null;
|
|
166
|
+
/** A JSON text parsed and held to `schema`. It throws on either failure. */
|
|
167
|
+
export declare function parseJson<Schema extends z.ZodTypeAny>(schema: Schema, text: string): z.output<Schema>;
|
|
168
|
+
/** `value` held to `schema`, then written as JSON. It throws when the value does not match. */
|
|
169
|
+
export declare function serializeJson<Schema extends z.ZodTypeAny>(schema: Schema, value: z.input<Schema>): string;
|
|
170
|
+
/** A failed parse as one indented line per issue, each under its dotted path. */
|
|
171
|
+
export declare function formatConfigError(error: z.ZodError): string;
|
|
172
|
+
/**
|
|
173
|
+
* The agent's redial and retry delay: exponential backoff, capped, then jittered by
|
|
174
|
+
* {@link AGENT_RECONNECT_JITTER}.
|
|
175
|
+
*/
|
|
176
|
+
export declare function backoffDelay(attempt: number, minMs: number, maxMs: number, random: () => number): number;
|
package/dist/helpers.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { AGENT_RECONNECT_JITTER, AGENT_LOG_LINES, RESOURCE_TICKS_HELD, SERVER_LOG_LINES, } from './agent-socket.js';
|
|
2
|
+
import { EVENT_BATCH_BUDGET } from './limits.js';
|
|
3
|
+
import { PROTOCOL_VERSION } from './paths.js';
|
|
4
|
+
import { hostnameOf, SLOT_ID_MESSAGE } from './checks.js';
|
|
5
|
+
import { EVENT_RELAY_PORT, MAX_PORT, NODE_TOKEN_PATTERN, SLOT_ID_PATTERN, } from './values.js';
|
|
6
|
+
export { isIpv4, isControllerHost } from './checks.js';
|
|
7
|
+
/**
|
|
8
|
+
* The layout checks that `agent.yml`, edited slot input and {@link ControllerSetConfig | set_config}
|
|
9
|
+
* share.
|
|
10
|
+
*/
|
|
11
|
+
export function validateSlotLayout(slots, { minPort }) {
|
|
12
|
+
const issues = [];
|
|
13
|
+
const ids = new Set();
|
|
14
|
+
const ports = new Set();
|
|
15
|
+
slots.forEach((slot, index) => {
|
|
16
|
+
if (slot.id !== undefined) {
|
|
17
|
+
if (!SLOT_ID_PATTERN.test(slot.id)) {
|
|
18
|
+
issues.push({ index, field: 'id', kind: 'id', message: SLOT_ID_MESSAGE });
|
|
19
|
+
}
|
|
20
|
+
else if (ids.has(slot.id)) {
|
|
21
|
+
issues.push({ index, field: 'id', kind: 'duplicate', message: `duplicate slot id "${slot.id}"` });
|
|
22
|
+
}
|
|
23
|
+
ids.add(slot.id);
|
|
24
|
+
}
|
|
25
|
+
for (const field of ['port', 'tv_port']) {
|
|
26
|
+
const value = slot[field];
|
|
27
|
+
if (!Number.isInteger(value) || value < minPort || value > MAX_PORT) {
|
|
28
|
+
issues.push({
|
|
29
|
+
index,
|
|
30
|
+
field,
|
|
31
|
+
kind: 'range',
|
|
32
|
+
message: `port ${value} must be an integer between ${minPort} and ${MAX_PORT}`,
|
|
33
|
+
});
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (value === EVENT_RELAY_PORT) {
|
|
37
|
+
issues.push({ index, field, kind: 'reserved', message: `port ${value} is reserved for the event relay` });
|
|
38
|
+
}
|
|
39
|
+
if (ports.has(value)) {
|
|
40
|
+
issues.push({ index, field, kind: 'duplicate', message: `port ${value} is used by more than one slot` });
|
|
41
|
+
}
|
|
42
|
+
ports.add(value);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
return issues;
|
|
46
|
+
}
|
|
47
|
+
/** {@link validateSlotLayout} as zod issues, under `prefix`. */
|
|
48
|
+
export function addSlotLayoutIssues(ctx, slots, minPort, prefix = []) {
|
|
49
|
+
for (const issue of validateSlotLayout(slots, { minPort })) {
|
|
50
|
+
const path = [...prefix, issue.index, issue.field];
|
|
51
|
+
ctx.addIssue({ code: 'custom', path, message: issue.message });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const GSLT_HINT_LENGTH = 10;
|
|
55
|
+
/**
|
|
56
|
+
* Game servers a node with `cpuCount` CPUs can run at once: one per CPU, keeping one for the host
|
|
57
|
+
* and the agent, and at least one.
|
|
58
|
+
*/
|
|
59
|
+
export function gameServerBudget(cpuCount) {
|
|
60
|
+
return Math.max(1, cpuCount - 1);
|
|
61
|
+
}
|
|
62
|
+
/** The part of a token that leaves the agent, as {@link GsltHint} describes. */
|
|
63
|
+
export function gsltHint(token) {
|
|
64
|
+
return token.slice(-GSLT_HINT_LENGTH);
|
|
65
|
+
}
|
|
66
|
+
/** Indexes of tokens that an earlier slot in the list already has. Missing tokens are skipped. */
|
|
67
|
+
export function duplicateGslts(tokens) {
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
const duplicates = [];
|
|
70
|
+
tokens.forEach((token, index) => {
|
|
71
|
+
if (token === undefined) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (seen.has(token)) {
|
|
75
|
+
duplicates.push(index);
|
|
76
|
+
}
|
|
77
|
+
seen.add(token);
|
|
78
|
+
});
|
|
79
|
+
return duplicates;
|
|
80
|
+
}
|
|
81
|
+
const DUPLICATE_GSLT_MESSAGE = 'each slot needs its own token';
|
|
82
|
+
/** Adds an issue on each slot whose token another slot of the node already has. */
|
|
83
|
+
export function addGsltIssues(ctx, slots, prefix = []) {
|
|
84
|
+
for (const index of duplicateGslts(slots.map((slot) => slot.gslt))) {
|
|
85
|
+
ctx.addIssue({ code: 'custom', path: [...prefix, index, 'gslt'], message: DUPLICATE_GSLT_MESSAGE });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Loopback, private, link-local and carrier-grade NAT, as `[network, prefix length]`. */
|
|
89
|
+
const LOCAL_IPV4 = [[0x7f000000, 8], [0x0a000000, 8], [0xac100000, 12], [0xc0a80000, 16], [0xa9fe0000, 16], [0x64400000, 10]];
|
|
90
|
+
function localIpv4(address) {
|
|
91
|
+
return LOCAL_IPV4.some(([network, bits]) => address >>> (32 - bits) === network >>> (32 - bits));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Whether a hostname, as URL parsing normalizes it, is a local IPv4 or IPv6 literal. IPv6 arrives
|
|
95
|
+
* compressed and lowercase, so loopback, `fc00::/7`, `fe80::/10` and IPv4-mapped forms are
|
|
96
|
+
* prefixes.
|
|
97
|
+
*/
|
|
98
|
+
function isLocalAddress(hostname) {
|
|
99
|
+
const v4 = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(hostname);
|
|
100
|
+
const mapped = /^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/.exec(hostname);
|
|
101
|
+
if (v4 !== null) {
|
|
102
|
+
return localIpv4(v4.slice(1).reduce((address, octet) => address * 256 + Number(octet), 0) >>> 0);
|
|
103
|
+
}
|
|
104
|
+
if (mapped !== null) {
|
|
105
|
+
return localIpv4(Number.parseInt(mapped[1], 16) * 0x10000 + Number.parseInt(mapped[2], 16));
|
|
106
|
+
}
|
|
107
|
+
return hostname === '::1' || /^f[cd][\da-f]{2}:/.test(hostname) || /^fe[89ab][\da-f]:/.test(hostname);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Whether a node that reaches the controller at this address should encrypt by default. It should
|
|
111
|
+
* for every address except the literals of a local network, because no certificate authority issues
|
|
112
|
+
* certificates for them. The operator decides, and this function picks only the side the toggle
|
|
113
|
+
* starts on.
|
|
114
|
+
*/
|
|
115
|
+
export function controllerTlsByDefault(host) {
|
|
116
|
+
const hostname = hostnameOf(host);
|
|
117
|
+
if (hostname === null) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
if (hostname.toLowerCase() === 'localhost') {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return !isLocalAddress(hostname);
|
|
124
|
+
}
|
|
125
|
+
/** The controller's origin for `host` and `tls`, as `http(s)://` or `ws(s)://`. */
|
|
126
|
+
export function controllerOrigin(host, tls, kind) {
|
|
127
|
+
if (kind === 'http') {
|
|
128
|
+
return `${tls ? 'https' : 'http'}://${host}`;
|
|
129
|
+
}
|
|
130
|
+
return `${tls ? 'wss' : 'ws'}://${host}`;
|
|
131
|
+
}
|
|
132
|
+
/** The agent socket's URL: the `ws(s)://` origin and the socket path. */
|
|
133
|
+
export function agentWsUrl(host, tls, path) {
|
|
134
|
+
return `${controllerOrigin(host, tls, 'ws')}${path}`;
|
|
135
|
+
}
|
|
136
|
+
/** A refusal as a log line reads it: the code, and what the node said beyond it. */
|
|
137
|
+
export function agentErrorText(error) {
|
|
138
|
+
return error.detail === '' || error.detail === error.code ? error.code : `${error.code}: ${error.detail}`;
|
|
139
|
+
}
|
|
140
|
+
/** Orders build ids numerically. Null, for an unknown build, sorts lowest. */
|
|
141
|
+
export function compareBuildIds(a, b) {
|
|
142
|
+
if (a === b) {
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
if (a === null) {
|
|
146
|
+
return -1;
|
|
147
|
+
}
|
|
148
|
+
if (b === null) {
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
// BigInt: a 20-digit id is past Number's exact range.
|
|
152
|
+
const diff = BigInt(a) - BigInt(b);
|
|
153
|
+
return diff === 0n ? 0 : diff < 0n ? -1 : 1;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Whether a node is behind the newest public CS2 build. Such a node refuses launches with
|
|
157
|
+
* `cs2_outdated`, because players on the new client cannot join it. A node whose `auto_update`
|
|
158
|
+
* is `false` does not refuse. The answer is `false` while either build is unknown.
|
|
159
|
+
*/
|
|
160
|
+
export function cs2Behind(installed, latest) {
|
|
161
|
+
return installed !== null && latest !== null && compareBuildIds(installed, latest) < 0;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Whether a launch may use a slot. The slot must be idle and unreserved, and Steam must not have
|
|
165
|
+
* refused its token. The node decides, and the controller's ranking follows it.
|
|
166
|
+
*/
|
|
167
|
+
export function slotTakesLaunch(status) {
|
|
168
|
+
return status.state === 'idle' && !status.reserved && status.gslt_state !== 'invalid';
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The key that log sources are matched on: `agent`, `cs2_update:<updateId>` or
|
|
172
|
+
* `server:<allocationId>`.
|
|
173
|
+
*/
|
|
174
|
+
export function logSourceKey(source) {
|
|
175
|
+
switch (source.kind) {
|
|
176
|
+
case 'agent':
|
|
177
|
+
return 'agent';
|
|
178
|
+
case 'cs2_update':
|
|
179
|
+
return `cs2_update:${source.update_id}`;
|
|
180
|
+
case 'server':
|
|
181
|
+
return `server:${source.allocation_id}`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Lines the agent keeps of a log source, and the controller after it. */
|
|
185
|
+
export function logSourceLines(source) {
|
|
186
|
+
return source.kind === 'agent' ? AGENT_LOG_LINES : SERVER_LOG_LINES;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* A source's lines with one {@link AgentLogBatch | log_batch} of it applied, kept to what the agent
|
|
190
|
+
* keeps.
|
|
191
|
+
*/
|
|
192
|
+
export function withLogBatch(lines, batch) {
|
|
193
|
+
return [...batch.reset ? [] : lines, ...batch.lines].slice(-logSourceLines(batch.source));
|
|
194
|
+
}
|
|
195
|
+
/** The key that {@link AgentDemo | demo} frames are matched on. */
|
|
196
|
+
export function demoKey(demo) {
|
|
197
|
+
return `${demo.slot_id}/${demo.file_name}`;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* A demo list with one {@link AgentDemo | demo} frame applied: the demo replaced in place, added at
|
|
201
|
+
* the end, or dropped.
|
|
202
|
+
*/
|
|
203
|
+
export function withDemo(demos, demo, removed) {
|
|
204
|
+
const key = demoKey(demo);
|
|
205
|
+
if (removed) {
|
|
206
|
+
return demos.filter((candidate) => demoKey(candidate) !== key);
|
|
207
|
+
}
|
|
208
|
+
return demos.some((candidate) => demoKey(candidate) === key)
|
|
209
|
+
? demos.map((candidate) => (demoKey(candidate) === key ? demo : candidate))
|
|
210
|
+
: [...demos, demo];
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* A ring with one {@link AgentResourceBatch | resource_batch} applied, kept to the last
|
|
214
|
+
* {@link RESOURCE_TICKS_HELD}.
|
|
215
|
+
*/
|
|
216
|
+
export function withTicks(ring, frame) {
|
|
217
|
+
return frame.reset ? frame.ticks : [...ring, ...frame.ticks].slice(-RESOURCE_TICKS_HELD);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The budget in {@link AGENT_BUDGETS} that a socket message spends from, by its `type`. Log
|
|
221
|
+
* messages spend `logBytes` as well, and a {@link AgentReply | reply} spends `strayReplies` only
|
|
222
|
+
* when it answers nothing open.
|
|
223
|
+
*/
|
|
224
|
+
export function frameBudget(type) {
|
|
225
|
+
switch (type) {
|
|
226
|
+
case 'log_batch':
|
|
227
|
+
case 'log_index':
|
|
228
|
+
return 'logFrames';
|
|
229
|
+
case 'resource_batch':
|
|
230
|
+
return 'resources';
|
|
231
|
+
case 'cs2_update_progress':
|
|
232
|
+
return 'cs2UpdateProgress';
|
|
233
|
+
case 'demo_progress':
|
|
234
|
+
return 'demoProgress';
|
|
235
|
+
case 'phase_change':
|
|
236
|
+
case 'score':
|
|
237
|
+
case 'backup':
|
|
238
|
+
return 'matchReports';
|
|
239
|
+
case 'reply':
|
|
240
|
+
return 'strayReplies';
|
|
241
|
+
default:
|
|
242
|
+
return 'state';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** A budget's rate for a node with `slots` slots. A count below one counts as one. */
|
|
246
|
+
export function budgetRate(budget, slots) {
|
|
247
|
+
const scale = Math.max(1, slots);
|
|
248
|
+
return {
|
|
249
|
+
burst: budget.base.burst + budget.perSlot.burst * scale,
|
|
250
|
+
perSecond: budget.base.perSecond + budget.perSlot.perSecond * scale,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** One budget of a connection: a burst that refills at a steady rate. */
|
|
254
|
+
export class TokenBucket {
|
|
255
|
+
rate;
|
|
256
|
+
at;
|
|
257
|
+
tokens;
|
|
258
|
+
constructor(rate, at) {
|
|
259
|
+
this.rate = rate;
|
|
260
|
+
this.at = at;
|
|
261
|
+
this.tokens = rate.burst;
|
|
262
|
+
}
|
|
263
|
+
/** Spends `amount` and returns `true`, or spends nothing and returns `false`. */
|
|
264
|
+
take(amount, now) {
|
|
265
|
+
this.refill(now);
|
|
266
|
+
if (this.tokens < amount) {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
this.tokens -= amount;
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
/** What {@link TokenBucket.take} could spend now. */
|
|
273
|
+
available(now) {
|
|
274
|
+
this.refill(now);
|
|
275
|
+
return this.tokens;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Changes the rate. A node that grows gets the extra burst immediately, so its first report of
|
|
279
|
+
* the new slots fits.
|
|
280
|
+
*/
|
|
281
|
+
resize(rate, now) {
|
|
282
|
+
this.refill(now);
|
|
283
|
+
this.tokens = Math.max(0, Math.min(rate.burst, this.tokens + rate.burst - this.rate.burst));
|
|
284
|
+
this.rate = rate;
|
|
285
|
+
}
|
|
286
|
+
refill(now) {
|
|
287
|
+
this.tokens = Math.min(this.rate.burst, this.tokens + (Math.max(0, now - this.at) / 1000) * this.rate.perSecond);
|
|
288
|
+
this.at = now;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** {@link EVENT_BATCH_BUDGET}'s bytes per window for a node with `slots` slots. */
|
|
292
|
+
export function eventBatchAllowance(slots) {
|
|
293
|
+
return EVENT_BATCH_BUDGET.base + EVENT_BATCH_BUDGET.perSlot * Math.max(1, slots);
|
|
294
|
+
}
|
|
295
|
+
const REFUSAL_REASONS = {
|
|
296
|
+
401: 'Unauthorized',
|
|
297
|
+
404: 'Not Found',
|
|
298
|
+
426: 'Upgrade Required',
|
|
299
|
+
429: 'Too Many Requests',
|
|
300
|
+
503: 'Service Unavailable',
|
|
301
|
+
};
|
|
302
|
+
/**
|
|
303
|
+
* The whole answer that refuses a WebSocket upgrade: the status line, then the connection closes.
|
|
304
|
+
*/
|
|
305
|
+
export function upgradeRefusal(status) {
|
|
306
|
+
return `HTTP/1.1 ${status} ${REFUSAL_REASONS[status]}\r\nConnection: close\r\n\r\n`;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* The `type` of a frame that names no message in `union`. A receiver skips such a frame, because a
|
|
310
|
+
* newer peer may send a message that this version does not have yet. Null when the frame has a
|
|
311
|
+
* known `type` or no string `type` at all, which is then for the schema to judge.
|
|
312
|
+
*/
|
|
313
|
+
export function unknownMessageType(union, frame) {
|
|
314
|
+
const type = typeof frame === 'object' && frame !== null ? frame.type : undefined;
|
|
315
|
+
if (typeof type !== 'string') {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
return union._zod.propValues['type']?.has(type) === false ? type : null;
|
|
319
|
+
}
|
|
320
|
+
/** A {@link PROTOCOL_HEADER} value, such as `1.3`, as its major and minor. Null for any other value. */
|
|
321
|
+
export function protocolVersionOf(header) {
|
|
322
|
+
const match = typeof header === 'string' ? /^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/.exec(header) : null;
|
|
323
|
+
return match === null ? null : { major: Number(match[1]), minor: Number(match[2]) };
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Whether a request's {@link PROTOCOL_HEADER} names a version this build can talk to: the same
|
|
327
|
+
* major as {@link PROTOCOL_VERSION}, at any minor.
|
|
328
|
+
*/
|
|
329
|
+
export function isCompatibleProtocol(header) {
|
|
330
|
+
const peer = protocolVersionOf(header);
|
|
331
|
+
const own = protocolVersionOf(PROTOCOL_VERSION);
|
|
332
|
+
return peer !== null && own !== null && peer.major === own.major;
|
|
333
|
+
}
|
|
334
|
+
/** The node token of an `Authorization` header. Null when the header holds none. */
|
|
335
|
+
export function bearerNodeToken(header) {
|
|
336
|
+
const token = /^Bearer (\S+)$/.exec(header ?? '')?.[1];
|
|
337
|
+
return token !== undefined && NODE_TOKEN_PATTERN.test(token) ? token : null;
|
|
338
|
+
}
|
|
339
|
+
/** A JSON text parsed and held to `schema`. It throws on either failure. */
|
|
340
|
+
export function parseJson(schema, text) {
|
|
341
|
+
const value = JSON.parse(text);
|
|
342
|
+
return schema.parse(value);
|
|
343
|
+
}
|
|
344
|
+
/** `value` held to `schema`, then written as JSON. It throws when the value does not match. */
|
|
345
|
+
export function serializeJson(schema, value) {
|
|
346
|
+
return JSON.stringify(schema.parse(value));
|
|
347
|
+
}
|
|
348
|
+
/** A failed parse as one indented line per issue, each under its dotted path. */
|
|
349
|
+
export function formatConfigError(error) {
|
|
350
|
+
return error.issues
|
|
351
|
+
.map((issue) => ` ${issue.path.length > 0 ? issue.path.join('.') : '(root)'}: ${issue.message}`)
|
|
352
|
+
.join('\n');
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* The agent's redial and retry delay: exponential backoff, capped, then jittered by
|
|
356
|
+
* {@link AGENT_RECONNECT_JITTER}.
|
|
357
|
+
*/
|
|
358
|
+
export function backoffDelay(attempt, minMs, maxMs, random) {
|
|
359
|
+
const base = Math.min(maxMs, minMs * 2 ** Math.max(0, attempt - 1));
|
|
360
|
+
const jitter = base * AGENT_RECONNECT_JITTER * (random() * 2 - 1);
|
|
361
|
+
return Math.max(minMs, Math.round(base + jitter));
|
|
362
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire between a node and the controller it dials: the agent socket's messages, the
|
|
3
|
+
* event-batch upload and the gameplay events it carries, the `agent.yml` shape, and the paths,
|
|
4
|
+
* limits and timings. This entry holds schemas and constants only, and it imports nothing from the
|
|
5
|
+
* agent runtime.
|
|
6
|
+
*
|
|
7
|
+
* Further entries stay outside the wire:
|
|
8
|
+
*
|
|
9
|
+
* - `@ankorcs/protocol/helpers`: functions both ends can share.
|
|
10
|
+
* - `@ankorcs/protocol/agent-yml`: the `agent.yml` that a controller renders for a node.
|
|
11
|
+
* - `@ankorcs/protocol/session`: one connected agent, from the controller's side of the socket.
|
|
12
|
+
* - `@ankorcs/protocol/container`: the interface between the agent and its game containers.
|
|
13
|
+
* - `@ankorcs/protocol/node-setup.sh`: the script that installs a game node.
|
|
14
|
+
*
|
|
15
|
+
* A controller that hosts agents serves the agent socket, the event-batch upload and, optionally,
|
|
16
|
+
* the node-setup answer, each at a path that a constant in `paths.ts` gives with its rules.
|
|
17
|
+
* ankor.sh serves the setup script for every controller.
|
|
18
|
+
*/
|
|
19
|
+
export * from './values.js';
|
|
20
|
+
export * from './game-events.js';
|
|
21
|
+
export * from './launch.js';
|
|
22
|
+
export * from './event-batch.js';
|
|
23
|
+
export * from './agent-socket.js';
|
|
24
|
+
export * from './agent-config.js';
|
|
25
|
+
export * from './paths.js';
|
|
26
|
+
export * from './limits.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire between a node and the controller it dials: the agent socket's messages, the
|
|
3
|
+
* event-batch upload and the gameplay events it carries, the `agent.yml` shape, and the paths,
|
|
4
|
+
* limits and timings. This entry holds schemas and constants only, and it imports nothing from the
|
|
5
|
+
* agent runtime.
|
|
6
|
+
*
|
|
7
|
+
* Further entries stay outside the wire:
|
|
8
|
+
*
|
|
9
|
+
* - `@ankorcs/protocol/helpers`: functions both ends can share.
|
|
10
|
+
* - `@ankorcs/protocol/agent-yml`: the `agent.yml` that a controller renders for a node.
|
|
11
|
+
* - `@ankorcs/protocol/session`: one connected agent, from the controller's side of the socket.
|
|
12
|
+
* - `@ankorcs/protocol/container`: the interface between the agent and its game containers.
|
|
13
|
+
* - `@ankorcs/protocol/node-setup.sh`: the script that installs a game node.
|
|
14
|
+
*
|
|
15
|
+
* A controller that hosts agents serves the agent socket, the event-batch upload and, optionally,
|
|
16
|
+
* the node-setup answer, each at a path that a constant in `paths.ts` gives with its rules.
|
|
17
|
+
* ankor.sh serves the setup script for every controller.
|
|
18
|
+
*/
|
|
19
|
+
export * from './values.js';
|
|
20
|
+
export * from './game-events.js';
|
|
21
|
+
export * from './launch.js';
|
|
22
|
+
export * from './event-batch.js';
|
|
23
|
+
export * from './agent-socket.js';
|
|
24
|
+
export * from './agent-config.js';
|
|
25
|
+
export * from './paths.js';
|
|
26
|
+
export * from './limits.js';
|