@ekmanss/5eplay 20260716.0.0

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/dist/mqtt.js ADDED
@@ -0,0 +1,285 @@
1
+ import { FiveEPlayError } from './errors.js';
2
+ import { record, text } from './value.js';
3
+ const BROKER_URL = 'wss://post-cn-7mz2e5hc90i.mqtt.aliyuncs.com/:443/mqtt';
4
+ const CREDENTIAL_URL = 'https://www.5eplay.com/api/restrict/matchscore';
5
+ function concatBytes(parts) {
6
+ const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
7
+ const output = new Uint8Array(total);
8
+ let offset = 0;
9
+ for (const part of parts) {
10
+ output.set(part, offset);
11
+ offset += part.byteLength;
12
+ }
13
+ return output;
14
+ }
15
+ function mqttString(value) {
16
+ const encoded = new TextEncoder().encode(value);
17
+ if (encoded.byteLength > 65_535)
18
+ throw new Error('MQTT string is too long');
19
+ return concatBytes([
20
+ Uint8Array.of(encoded.byteLength >> 8, encoded.byteLength & 0xff),
21
+ encoded,
22
+ ]);
23
+ }
24
+ function remainingLength(value) {
25
+ const bytes = [];
26
+ let remaining = value;
27
+ do {
28
+ let digit = remaining % 128;
29
+ remaining = Math.floor(remaining / 128);
30
+ if (remaining > 0)
31
+ digit |= 0x80;
32
+ bytes.push(digit);
33
+ } while (remaining > 0);
34
+ return Uint8Array.from(bytes);
35
+ }
36
+ function packet(header, body) {
37
+ return concatBytes([Uint8Array.of(header), remainingLength(body.byteLength), body]);
38
+ }
39
+ export function encodeConnectPacket(credentials, keepAliveSeconds = 30) {
40
+ const variableHeader = concatBytes([
41
+ mqttString('MQTT'),
42
+ Uint8Array.of(4, 0xc2, keepAliveSeconds >> 8, keepAliveSeconds & 0xff),
43
+ ]);
44
+ const payload = concatBytes([
45
+ mqttString(credentials.clientId),
46
+ mqttString(credentials.username),
47
+ mqttString(credentials.password),
48
+ ]);
49
+ return packet(0x10, concatBytes([variableHeader, payload]));
50
+ }
51
+ export function encodeSubscribePacket(topic, packetId = 1) {
52
+ return packet(0x82, concatBytes([
53
+ Uint8Array.of(packetId >> 8, packetId & 0xff),
54
+ mqttString(topic),
55
+ Uint8Array.of(0),
56
+ ]));
57
+ }
58
+ export function decodeMqttPackets(bytes) {
59
+ const packets = [];
60
+ let offset = 0;
61
+ while (offset < bytes.byteLength) {
62
+ const first = bytes[offset++];
63
+ if (first === undefined)
64
+ break;
65
+ let multiplier = 1;
66
+ let length = 0;
67
+ let digit;
68
+ do {
69
+ digit = bytes[offset++] ?? 0;
70
+ length += (digit & 0x7f) * multiplier;
71
+ multiplier *= 128;
72
+ if (multiplier > 128 * 128 * 128 * 128)
73
+ throw new Error('invalid MQTT remaining length');
74
+ } while ((digit & 0x80) !== 0);
75
+ const end = offset + length;
76
+ if (end > bytes.byteLength)
77
+ throw new Error('incomplete MQTT packet');
78
+ const type = first >> 4;
79
+ const flags = first & 0x0f;
80
+ let payload = bytes.subarray(offset, end);
81
+ let topic;
82
+ if (type === 3) {
83
+ const topicLength = (payload[0] ?? 0) * 256 + (payload[1] ?? 0);
84
+ const topicEnd = 2 + topicLength;
85
+ if (topicEnd > payload.byteLength)
86
+ throw new Error('invalid MQTT topic length');
87
+ topic = new TextDecoder().decode(payload.subarray(2, topicEnd));
88
+ const qos = (flags >> 1) & 0x03;
89
+ payload = payload.subarray(topicEnd + (qos > 0 ? 2 : 0));
90
+ }
91
+ packets.push({ type, flags, payload, ...(topic === undefined ? {} : { topic }) });
92
+ offset = end;
93
+ }
94
+ return packets;
95
+ }
96
+ async function eventBytes(data) {
97
+ if (data instanceof ArrayBuffer)
98
+ return new Uint8Array(data);
99
+ if (ArrayBuffer.isView(data)) {
100
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
101
+ }
102
+ if (data instanceof Blob)
103
+ return new Uint8Array(await data.arrayBuffer());
104
+ if (typeof data === 'string')
105
+ return new TextEncoder().encode(data);
106
+ throw new Error('unsupported WebSocket message type');
107
+ }
108
+ function defaultWebSocketFactory(url, protocols) {
109
+ if (typeof globalThis.WebSocket !== 'function') {
110
+ throw new FiveEPlayError('global WebSocket is unavailable; Node.js 22 or newer is required', {
111
+ code: 'REALTIME_CONNECTION_FAILED', operation: 'match-realtime',
112
+ stage: 'connecting-realtime', retryable: false,
113
+ });
114
+ }
115
+ return new globalThis.WebSocket(url, protocols);
116
+ }
117
+ async function credentialsFor(fetchImpl, signal, topic) {
118
+ let response;
119
+ try {
120
+ response = await fetchImpl(CREDENTIAL_URL, {
121
+ method: 'POST',
122
+ signal,
123
+ headers: { 'content-type': 'application/json' },
124
+ body: JSON.stringify({ topic }),
125
+ });
126
+ }
127
+ catch (error) {
128
+ if (signal.aborted)
129
+ throw signal.reason;
130
+ throw new FiveEPlayError('could not obtain 5EPlay realtime credentials', {
131
+ code: 'REALTIME_CONNECTION_FAILED', operation: 'match-realtime',
132
+ stage: 'connecting-realtime', retryable: true, cause: error,
133
+ });
134
+ }
135
+ if (!response.ok) {
136
+ throw new FiveEPlayError(`5EPlay realtime credential request returned HTTP ${response.status}`, {
137
+ code: 'REALTIME_CONNECTION_FAILED', operation: 'match-realtime',
138
+ stage: 'connecting-realtime', retryable: response.status === 429 || response.status >= 500,
139
+ details: { status: response.status },
140
+ });
141
+ }
142
+ let value;
143
+ try {
144
+ value = JSON.parse(await response.text());
145
+ }
146
+ catch (error) {
147
+ throw new FiveEPlayError('5EPlay realtime credential response was invalid', {
148
+ code: 'REALTIME_CONNECTION_FAILED', operation: 'match-realtime',
149
+ stage: 'connecting-realtime', retryable: true, cause: error,
150
+ });
151
+ }
152
+ const envelope = record(value);
153
+ const data = record(envelope.data);
154
+ const clientId = text(data.client_id);
155
+ const username = text(data.username);
156
+ const password = text(data.password);
157
+ if (envelope.success !== true || !clientId || !username || !password) {
158
+ throw new FiveEPlayError('5EPlay realtime credentials were incomplete', {
159
+ code: 'REALTIME_CONNECTION_FAILED', operation: 'match-realtime',
160
+ stage: 'connecting-realtime', retryable: true,
161
+ });
162
+ }
163
+ return { clientId, username, password };
164
+ }
165
+ export class MqttTopicConnection {
166
+ #topic;
167
+ #fetch;
168
+ #signal;
169
+ #factory;
170
+ #onPayload;
171
+ #socket = null;
172
+ #keepAlive;
173
+ #reconnect;
174
+ #attempt = 0;
175
+ #closed = false;
176
+ constructor(options) {
177
+ this.#topic = options.topic;
178
+ this.#fetch = options.fetch;
179
+ this.#signal = options.signal;
180
+ this.#factory = options.webSocketFactory ?? defaultWebSocketFactory;
181
+ this.#onPayload = options.onPayload;
182
+ }
183
+ async start() {
184
+ if (this.#closed)
185
+ throw new FiveEPlayError('realtime connection is closed', {
186
+ code: 'SESSION_CLOSED', operation: 'match-realtime',
187
+ stage: 'connecting-realtime', retryable: false,
188
+ });
189
+ await this.#connectOnce();
190
+ }
191
+ close() {
192
+ this.#closed = true;
193
+ if (this.#keepAlive)
194
+ clearInterval(this.#keepAlive);
195
+ if (this.#reconnect)
196
+ clearTimeout(this.#reconnect);
197
+ this.#socket?.close(1000, 'session closed');
198
+ this.#socket = null;
199
+ }
200
+ async #connectOnce() {
201
+ const credentials = await credentialsFor(this.#fetch, this.#signal, this.#topic);
202
+ if (this.#closed || this.#signal.aborted)
203
+ throw this.#signal.reason;
204
+ await new Promise((resolve, reject) => {
205
+ let settled = false;
206
+ const socket = this.#factory(BROKER_URL, ['mqtt']);
207
+ this.#socket = socket;
208
+ socket.binaryType = 'arraybuffer';
209
+ const fail = (error) => {
210
+ if (settled)
211
+ return;
212
+ settled = true;
213
+ reject(new FiveEPlayError('5EPlay MQTT connection failed', {
214
+ code: 'REALTIME_CONNECTION_FAILED', operation: 'match-realtime',
215
+ stage: 'connecting-realtime', retryable: true, cause: error,
216
+ }));
217
+ };
218
+ const onOpen = () => {
219
+ try {
220
+ socket.send(encodeConnectPacket(credentials));
221
+ }
222
+ catch (error) {
223
+ fail(error);
224
+ }
225
+ };
226
+ const onMessage = (event) => {
227
+ void (async () => {
228
+ const bytes = await eventBytes(event.data);
229
+ for (const mqttPacket of decodeMqttPackets(bytes)) {
230
+ if (mqttPacket.type === 2) {
231
+ if ((mqttPacket.payload[1] ?? 1) !== 0) {
232
+ fail(new Error(`MQTT CONNACK ${mqttPacket.payload[1] ?? -1}`));
233
+ return;
234
+ }
235
+ socket.send(encodeSubscribePacket(this.#topic));
236
+ }
237
+ else if (mqttPacket.type === 9) {
238
+ if (!settled) {
239
+ settled = true;
240
+ this.#attempt = 0;
241
+ this.#keepAlive = setInterval(() => {
242
+ if (socket.readyState === 1)
243
+ socket.send(Uint8Array.of(0xc0, 0));
244
+ }, 20_000);
245
+ resolve();
246
+ }
247
+ }
248
+ else if (mqttPacket.type === 3 && mqttPacket.topic === this.#topic) {
249
+ try {
250
+ this.#onPayload(JSON.parse(new TextDecoder().decode(mqttPacket.payload)));
251
+ }
252
+ catch {
253
+ // Ignore malformed provider messages; the connection remains usable.
254
+ }
255
+ }
256
+ }
257
+ })().catch(fail);
258
+ };
259
+ const onError = () => fail(new Error('WebSocket error'));
260
+ const onClose = () => {
261
+ if (this.#keepAlive)
262
+ clearInterval(this.#keepAlive);
263
+ this.#keepAlive = undefined;
264
+ if (!settled)
265
+ fail(new Error('WebSocket closed before MQTT subscription'));
266
+ else if (!this.#closed && !this.#signal.aborted)
267
+ this.#scheduleReconnect();
268
+ };
269
+ socket.addEventListener('open', onOpen);
270
+ socket.addEventListener('message', onMessage);
271
+ socket.addEventListener('error', onError);
272
+ socket.addEventListener('close', onClose);
273
+ });
274
+ }
275
+ #scheduleReconnect() {
276
+ if (this.#closed || this.#signal.aborted || this.#reconnect)
277
+ return;
278
+ const delay = Math.min(5_000, 250 * 2 ** this.#attempt);
279
+ this.#attempt += 1;
280
+ this.#reconnect = setTimeout(() => {
281
+ this.#reconnect = undefined;
282
+ void this.#connectOnce().catch(() => this.#scheduleReconnect());
283
+ }, delay);
284
+ }
285
+ }
@@ -0,0 +1,2 @@
1
+ import type { CreateFiveEPlayMatchSessionOptions, FiveEPlayMatchSession } from './types.js';
2
+ export declare function createFiveEPlayMatchSession(input: string, options?: CreateFiveEPlayMatchSessionOptions): Promise<FiveEPlayMatchSession>;
@@ -0,0 +1,169 @@
1
+ import { AsyncQueue } from './async_queue.js';
2
+ import { captureFiveEPlayMatch } from './capture.js';
3
+ import { FiveEPlayError } from './errors.js';
4
+ import { transformLogRecord } from './log.js';
5
+ import { MqttTopicConnection } from './mqtt.js';
6
+ import { buildFiveEPlayMatch, mergeDetailData } from './transform.js';
7
+ import { record, text } from './value.js';
8
+ class FiveEPlayMatchSessionImpl {
9
+ id;
10
+ initial;
11
+ #capture;
12
+ #controller;
13
+ #queue = new AsyncQueue();
14
+ #connections = [];
15
+ #externalSignal;
16
+ #onExternalAbort;
17
+ #current;
18
+ #closed = false;
19
+ constructor(capture, controller, externalSignal) {
20
+ this.id = capture.identity.id;
21
+ this.initial = capture.result;
22
+ this.#capture = capture;
23
+ this.#controller = controller;
24
+ this.#externalSignal = externalSignal;
25
+ this.#current = capture.result.data;
26
+ this.#onExternalAbort = () => { void this.close(); };
27
+ externalSignal?.addEventListener('abort', this.#onExternalAbort, { once: true });
28
+ this.#queue.push({
29
+ type: 'snapshot',
30
+ capturedAt: this.#current.capturedAt,
31
+ snapshot: structuredClone(this.#current),
32
+ });
33
+ }
34
+ addConnection(connection) {
35
+ this.#connections.push(connection);
36
+ }
37
+ snapshot() {
38
+ if (this.#closed) {
39
+ throw new FiveEPlayError('5EPlay realtime session is closed', {
40
+ code: 'SESSION_CLOSED', operation: 'match-realtime',
41
+ stage: 'streaming-realtime', retryable: false, matchId: this.id,
42
+ });
43
+ }
44
+ return structuredClone(this.#current);
45
+ }
46
+ handleState(payload) {
47
+ if (this.#closed)
48
+ return;
49
+ const root = record(payload);
50
+ if (text(root.event_name) !== 'csgo-detail')
51
+ return;
52
+ const data = record(root.data);
53
+ this.#capture.detailData = mergeDetailData(this.#capture.detailData, data);
54
+ const capturedAt = new Date().toISOString();
55
+ this.#current = buildFiveEPlayMatch({
56
+ identity: this.#capture.identity,
57
+ capturedAt,
58
+ detailData: this.#capture.detailData,
59
+ analysisData: this.#capture.analysisData,
60
+ logs: this.#capture.logs,
61
+ community: this.#capture.community,
62
+ });
63
+ this.#queue.push({
64
+ type: 'state',
65
+ capturedAt,
66
+ stateVersion: this.#current.stateVersion,
67
+ snapshot: structuredClone(this.#current),
68
+ });
69
+ }
70
+ handleLog(payload) {
71
+ if (this.#closed)
72
+ return;
73
+ const root = record(payload);
74
+ if (text(root.event_name) !== 'csgo-event-log')
75
+ return;
76
+ const data = record(root.data);
77
+ const info = record(data.info);
78
+ const event = transformLogRecord(info);
79
+ if (!event.mapNumber)
80
+ return;
81
+ const existing = this.#capture.logs.get(event.mapNumber) ?? {
82
+ complete: false,
83
+ fromVersion: text(data.from_ver),
84
+ toVersion: null,
85
+ rows: [],
86
+ };
87
+ existing.rows.push(info);
88
+ existing.toVersion = text(data.to_ver) ?? existing.toVersion;
89
+ this.#capture.logs.set(event.mapNumber, existing);
90
+ const capturedAt = new Date().toISOString();
91
+ this.#current = buildFiveEPlayMatch({
92
+ identity: this.#capture.identity,
93
+ capturedAt,
94
+ detailData: this.#capture.detailData,
95
+ analysisData: this.#capture.analysisData,
96
+ logs: this.#capture.logs,
97
+ community: this.#capture.community,
98
+ });
99
+ this.#queue.push({
100
+ type: 'log',
101
+ capturedAt,
102
+ event,
103
+ snapshot: structuredClone(this.#current),
104
+ });
105
+ }
106
+ async close() {
107
+ if (this.#closed)
108
+ return;
109
+ this.#closed = true;
110
+ this.#externalSignal?.removeEventListener('abort', this.#onExternalAbort);
111
+ this.#controller.abort(new FiveEPlayError('5EPlay realtime session was closed', {
112
+ code: 'SESSION_CLOSED', operation: 'match-realtime',
113
+ stage: 'streaming-realtime', retryable: false, matchId: this.id,
114
+ }));
115
+ for (const connection of this.#connections)
116
+ connection.close();
117
+ this.#queue.close();
118
+ }
119
+ async [Symbol.asyncDispose]() {
120
+ await this.close();
121
+ }
122
+ [Symbol.asyncIterator]() {
123
+ return this.#queue[Symbol.asyncIterator]();
124
+ }
125
+ }
126
+ function emit(options, stage, message) {
127
+ options.onProgress?.({
128
+ operation: 'match-realtime',
129
+ stage,
130
+ message,
131
+ timestamp: new Date().toISOString(),
132
+ });
133
+ }
134
+ export async function createFiveEPlayMatchSession(input, options = {}) {
135
+ const capture = await captureFiveEPlayMatch(input, options, options);
136
+ const controller = new AbortController();
137
+ if (options.signal?.aborted)
138
+ controller.abort(options.signal.reason);
139
+ const session = new FiveEPlayMatchSessionImpl(capture, controller, options.signal);
140
+ const fetchImpl = options.fetch ?? globalThis.fetch;
141
+ const stateTopic = `csgo/product/detail/${capture.identity.id}`;
142
+ const logTopic = `csgo/product/event/log/${capture.identity.id}`;
143
+ const state = new MqttTopicConnection({
144
+ topic: stateTopic,
145
+ fetch: fetchImpl,
146
+ signal: controller.signal,
147
+ webSocketFactory: options.webSocketFactory,
148
+ onPayload: (payload) => session.handleState(payload),
149
+ });
150
+ const log = new MqttTopicConnection({
151
+ topic: logTopic,
152
+ fetch: fetchImpl,
153
+ signal: controller.signal,
154
+ webSocketFactory: options.webSocketFactory,
155
+ onPayload: (payload) => session.handleLog(payload),
156
+ });
157
+ session.addConnection(state);
158
+ session.addConnection(log);
159
+ emit(options, 'connecting-realtime', 'Connecting 5EPlay score and event topics');
160
+ try {
161
+ await Promise.all([state.start(), log.start()]);
162
+ }
163
+ catch (error) {
164
+ await session.close();
165
+ throw error;
166
+ }
167
+ emit(options, 'streaming-realtime', '5EPlay realtime session connected');
168
+ return session;
169
+ }
@@ -0,0 +1,21 @@
1
+ import type { FiveEPlayMatch, FiveEPlayMatchIdentity } from './types.js';
2
+ export interface LogCapture {
3
+ complete: boolean;
4
+ fromVersion: string | null;
5
+ toVersion: string | null;
6
+ rows: unknown[];
7
+ }
8
+ export interface CommunityCapture {
9
+ tabs: unknown[];
10
+ cardsByTab: Map<string, unknown[]>;
11
+ }
12
+ export interface BuildMatchInput {
13
+ identity: FiveEPlayMatchIdentity;
14
+ capturedAt: string;
15
+ detailData: unknown;
16
+ analysisData: unknown | null;
17
+ logs: Map<number, LogCapture>;
18
+ community: CommunityCapture | null;
19
+ }
20
+ export declare function buildFiveEPlayMatch(input: BuildMatchInput): FiveEPlayMatch;
21
+ export declare function mergeDetailData(base: unknown, update: unknown): unknown;