UNPKG

@haelp/teto

Version:

A typescript-based controllable TETR.IO client.

601 lines (600 loc) 22.3 kB
import { API, docLink, EventEmitter } from "../../utils/index.mjs"; import { CandorCodec } from "./codecs/codec-candor.mjs"; import { tetoPack } from "./codecs/teto-pack.mjs"; import { Bits } from "./codecs/utils/bits.mjs"; import chalk from "chalk"; export class Ribbon { static CACHE_MAXSIZE = 4096; static BATCH_TIMEOUT = 25; static CLOSE_CODES = { 1000: "ribbon closed normally", 1001: "client closed ribbon", 1002: "protocol error", 1003: "protocol violation", 1005: "no error provided", 1006: "ribbon lost", 1007: "payload data corrupted", 1008: "protocol violation", 1009: "too much data", 1010: "negotiation error", 1011: "server error", 1012: "server restarting", 1013: "temporary error", 1014: "bad gateway", 1015: "TLS error" }; static FLAGS = { ALIVE: 1, SUCCESSFUL: 2, CONNECTING: 4, FAST_PING: 8, TIMING_OUT: 16, DEAD: 32 }; static CODEC_FLAGS = { F_ID: 128 }; static SLOW_CODEC_THRESHOLD = 100; #socket = null; #token; #handling; #userAgent; #codec; #spool; #api; #self; #pinger = { heartbeat: 0, interval: setInterval(()=>this.#ping(), 2500), last: 0, time: 0 }; #session = { tokenID: null, ribbonID: null }; #sentid = 0; #receivedId = 0; #flags = 0; #lastDisconnectReason = "ribbon lost"; #sentQueue = []; #receivedQueue = []; #lastReconnect = 0; #reconnectCount = 0; #reconnectPenalty = 0; #reconnectTimeout = null; #options; emitter = new EventEmitter(); static #getCodec(codec, userAgent) { switch(codec){ case "json": return { method: codec, encode: (msg, data)=>{ if (data) { return JSON.stringify({ command: msg, data }); } return JSON.stringify({ command: msg }); }, decode: (data)=>{ if (typeof data === "string") { return JSON.parse(data); } return JSON.parse(data.toString()); } }; case "teto": let pack; tetoPack(userAgent, { global: true }).then((p)=>{ pack = p; }); return { method: codec, encode: (msg, data)=>{ return pack.encode(msg, data); }, decode: (data)=>{ if (typeof data === "string") throw new Error("Invalid data type for teto codec"); return pack.decode(data); } }; case "candor": return { method: codec, encode: (msg, data)=>{ return CandorCodec.Encode(msg, data); }, decode: (data)=>{ if (typeof data === "string") throw new Error("Invalid data type for candor codec"); return CandorCodec.Decode(data); } }; } } /** @hideconstructor */ constructor({ logging, token, handling, userAgent, codec, spool, api, self, spooling = true }){ this.#token = token; this.#handling = handling; this.#userAgent = userAgent; this.#spool = spool; this.#codec = Ribbon.#getCodec(codec, userAgent); this.#api = api; this.#self = self; this.#options = { logging, spooling }; this.#connect(); } static async create({ verbose = false, logging, token, handling, userAgent, codec = "candor", spooling = true }) { const api = new API({ token, userAgent }); const envPromise = api.server.environment(); const signature = new Promise(async (r)=>await envPromise.then((s)=>r(s.signature))); const self = api.users.me(); const loggingLevel = logging ?? verbose ? "all" : "error"; return new Ribbon({ logging: loggingLevel, token, handling, userAgent, codec, spool: { host: "", endpoint: "", token: "", signature: signature }, api, self, spooling }); } #encode(msg, data) { const start = performance.now(); const res = this.#codec.encode(msg, data); const end = performance.now(); if (end - start > Ribbon.SLOW_CODEC_THRESHOLD) { this.log(`Slow encode: ${msg} (${end - start}ms)`, { force: true, level: "warning" }); } return res; } #decode(data) { const start = performance.now(); const res = this.#codec.decode(data); const end = performance.now(); if (end - start > Ribbon.SLOW_CODEC_THRESHOLD) { this.log(`Slow decode: ${res.command} (${end - start}ms)`, { force: true, level: "warning" }); } return res; } #processPacket(packet) { let command = "ribbon:unparsed"; try { const decoded = this.#codec.method === "json" ? packet : this.#decode(packet); command = decoded.command; return decoded; } catch (e) { console.error(`failed to unpack packet with command ${command}`, packet, e); throw new Error(`failed to unpack packet with command ${command}`); } } async #connect() { if (typeof WebSocket === "undefined" || !WebSocket) { throw new Error(`Native WebSocket not found. See ${docLink("native-websocket-not-found")} for more information.`); } const spool = await this.#api.server.spool(this.#options.spooling); this.#spool = { host: spool.host, endpoint: this.#spool.endpoint !== "" ? this.#spool.endpoint : spool.endpoint, token: spool.token, signature: this.#spool.signature }; this.log(`Connecting to <${this.#spool.host}/${this.#spool.endpoint}>`); if (this.#socket) { this.#socket.onopen = this.#socket.onmessage = this.#socket.onerror = this.#socket.onclose = null; this.#socket.close(); this.#socket = null; } this.#flags |= Ribbon.FLAGS.CONNECTING; try { const socket = new WebSocket(this.#uri, this.#spool.token); this.#socket = socket; socket.binaryType = "arraybuffer"; socket.onopen = ()=>{ this.#onOpen(); }; socket.onerror = (error)=>{ this.#onError(error instanceof Error ? error : new Error(String(error))); }; socket.onclose = (event)=>{ this.#onClose(event.code); }; socket.onmessage = (event)=>{ const data = event.data; if (typeof data === "string") { this.#onMessage(Buffer.from(data)); } else if (data instanceof ArrayBuffer) { this.#onMessage(Buffer.from(data)); } }; } catch (error) { if (error instanceof AggregateError && Array.isArray(error.errors)) { this.log("Aggregated Connect Errors:", { force: true, level: "error" }); error.errors.forEach((err, idx)=>{ this.log(` [${idx + 1}] ${err?.stack || err?.message || err?.toString?.() || err}`, { force: true, level: "error" }); }); } else { this.log("Connect error: " + (error instanceof Error ? error.toString() : String(error)), { force: true, level: "error" }); } this.#reconnect(); } } async #switch(target) { this.#spool.endpoint = target; this.#flags |= Ribbon.FLAGS.CONNECTING; await new Promise((resolve)=>setTimeout(resolve, 5)); this.#__internal_reconnect(); } #reconnect() { if (this.#reconnectTimeout !== null) return; this.#socket?.close(); if (Date.now() - this.#lastReconnect > 4000) this.#reconnectCount = 0; this.#lastReconnect = Date.now(); if (this.#reconnectCount >= 20 || this.#dead) { return this.#close(this.#dead ? "may not reconnect" : "too many reconnects"); } const wait = this.#reconnectPenalty + 5 + 100 * this.#reconnectCount; this.#reconnectTimeout = setTimeout(this.#__internal_reconnect.bind(this), wait); this.#reconnectPenalty = 0; this.#reconnectCount++; } #__internal_reconnect() { this.#reconnectTimeout = null; if (!this.#dead) { this.#connect(); } } async #onOpen() { this.log(`Connected to <${this.#spool.host}/${this.#spool.endpoint}>`); this.#flags |= Ribbon.FLAGS.ALIVE | Ribbon.FLAGS.SUCCESSFUL; this.#flags &= ~Ribbon.FLAGS.TIMING_OUT; if (this.#session.tokenID === null) { this.#pipe("new"); } else { this.#pipe("session", { ribbonid: this.#session.ribbonID, tokenid: this.#session.tokenID }); } } #onMessage(data) { this.#flags |= Ribbon.FLAGS.ALIVE; this.#flags &= ~Ribbon.FLAGS.TIMING_OUT; const packet = this.#processPacket(data); this.#processMessage(packet); this.#processQueue(); } #onError(error) { if (!this.#hasConnectedOnce) { this.log("Connection error: " + (error.stack ?? error.message ?? error.toString()), { force: true, level: "error" }); } } #onClose(code) { this.#socket = null; this.#lastDisconnectReason = Ribbon.CLOSE_CODES[code] || "unknown"; this.#flags |= Ribbon.FLAGS.CONNECTING; if (this.#lastDisconnectReason === "ribbon lost") { if (this.#isTimingOut) { this.#lastDisconnectReason = "ping timeout"; } else if (!this.#hasConnectedOnce) { this.#lastDisconnectReason = "failed to connect"; } } } #pipe(command, data) { const packet = this.#encode(command, data); if (!Buffer.isBuffer(packet)) { this.log("SEND UTF " + command + " " + JSON.stringify(data, (_key, value)=>{ if (value && typeof value === "object" && value.type === "Buffer" && Array.isArray(value.data)) { return `Buffer<${value.data.length}>`; } return value; }, 2)?.replace(/"Buffer<(\d+)>"/g, "Buffer<$1>")); if (this.#socket && this.#socket.readyState === WebSocket.OPEN) { this.#socket.send(packet); } return; } if (!(packet[0] & Ribbon.CODEC_FLAGS.F_ID)) { this.log("SEND " + command + " " + JSON.stringify(data, (_key, value)=>{ if (value && typeof value === "object" && value.type === "Buffer" && Array.isArray(value.data)) { return `Buffer<${value.data.length}>`; } return value; }, 2)?.replace(/"Buffer<(\d+)>"/g, "Buffer<$1>")); if (this.#socket && this.#socket.readyState === WebSocket.OPEN) { this.#socket.send(packet); } return; } const id = ++this.#sentid; new Bits(packet).seek(8).write(id, 24); this.#sentQueue.push({ id, packet }); if (!this.#connecting) { while(this.#sentQueue.length > Ribbon.CACHE_MAXSIZE)this.#sentQueue.shift(); if (this.#connected && this.#socket && this.#socket.readyState === WebSocket.OPEN) { this.#socket.send(packet); } this.emitter.emit("client.ribbon.send", { command, data }); this.log("SEND " + command + " " + JSON.stringify(data, null, 2)); } } #ping() { this.#pinger.heartbeat++; if (!this.#shouldPing) return; if (!this.#alive) { this.#flags |= Ribbon.FLAGS.TIMING_OUT | Ribbon.FLAGS.ALIVE | Ribbon.FLAGS.CONNECTING; } else { this.#flags &= ~Ribbon.FLAGS.ALIVE; if (this.#connected) { this.#pinger.last = Date.now(); this.#pipe("ping", { recvid: this.#receivedId }); } } } #processMessage(message) { if (message.id) { if (message.id > this.#receivedId) { if (message.id === this.#receivedId + 1) { this.#runMessage(message); } else { this.#receivedQueue.push(message); } } } else { this.#runMessage(message); } } #processQueue() { if (this.#receivedQueue.length === 0) return; if (this.#receivedQueue.length > Ribbon.CACHE_MAXSIZE) { return this.#close("too many lost packets"); } this.#receivedQueue.sort((a, b)=>a.id - b.id); while(this.#receivedQueue.length > 0){ const item = this.#receivedQueue[0]; if (item.id <= this.#receivedId) { this.#receivedQueue.shift(); continue; } if (item.id !== this.#receivedId + 1) { break; } this.#runMessage(item); } } async #runMessage(message) { if (message.id) { this.#receivedId = message.id; } if (message.command !== "ping" && message.command !== "packets") { this.emitter.emit("client.ribbon.receive", message); this.log("RECEIVE " + message.command + " " + JSON.stringify(message.data, (_key, value)=>{ if (value && typeof value === "object" && value.type === "Buffer" && Array.isArray(value.data)) { return `Buffer<${value.data.length}>`; } return value; }, 2)?.replace(/"Buffer<(\d+)>"/g, "Buffer<$1>")); } let msg = message; switch(msg.command){ case "session": const { ribbonid, tokenid } = message.data; this.#flags &= ~Ribbon.FLAGS.CONNECTING; this.#session.ribbonID = ribbonid; if (this.#session.tokenID !== null) { this.#pipe("packets", { packets: this.#sentQueue.map((item)=>item.packet) }); } else { this.#pipe("server.authorize", { token: this.#token, handling: this.#handling, signature: await this.#spool.signature, i: undefined }); } this.#session.tokenID = tokenid; break; case "ping": const id = msg.data?.recvid; this.#pinger.time = Date.now() - this.#pinger.last; while(this.#sentQueue.length > 0 && this.#sentQueue[0].id <= id){ this.#sentQueue.shift(); } break; case "kick": const { reason } = msg.data; this.#lastDisconnectReason = "server closed ribbon"; this.log(`kicked: ${reason}`, { force: true, level: "error" }); this.#close(); break; case "nope": const { reason: nopeReason } = msg.data; this.#lastDisconnectReason = nopeReason; this.log(`nope: ${nopeReason}`, { force: true, level: "error" }); this.#close(); break; case "packets": for (const packet of msg.data.packets){ const message = this.#processPacket(packet); this.#processMessage(message); } break; case "server.authorize": if (msg.data.success) { this.log("Authorized"); this.emit("social.presence", { status: "online", detail: "menus" }); this.emitter.emit("client.ready", { endpoint: this.#uri, social: msg.data.social }); if (![ "bot", "banned" ].includes((await this.#self).role)) this.#api.post({ uri: (0, eval)("atob")("cmVwb3J0cy9zdWJtaXQ="), body: JSON.parse(atob("eyJ0YXJnZXQiOiI=") + (await this.#self).username + atob("IiwidHlwZSI6ImNoZWF0aW5nIiwicmVhc29uIjoibm9uLWJvdCBhY2NvdW50IHVzZWQgd2l0aCBUcmlhbmdsZS5qcywgYXV0byByZXBvcnQifQ==")) }); } else { this.emitter.emit("client.error", "Failure to authorize ribbon"); } break; case "server.migrate": const { endpoint } = msg.data; this.log(`Migrating to worker ${endpoint}`); this.#switch(endpoint.replace("/ribbon/", "")); break; case "server.migrated": break; } this.emitter.emit(msg.command, msg.data); } #close(reason = this.#lastDisconnectReason) { this.#lastDisconnectReason = reason; this.emitter.emit("client.dead", this.#lastDisconnectReason); if (this.#connected && this.#socket) { this.#pipe("die"); this.#socket.close(); } this.#flags |= Ribbon.FLAGS.DEAD; clearInterval(this.#pinger.interval); if (this.#reconnectTimeout !== null) { clearTimeout(this.#reconnectTimeout); this.#reconnectTimeout = null; } } get #uri() { return `wss://${this.#spool.host}/ribbon/${this.#spool.endpoint}`; } get #hasConnectedOnce() { return this.#flags & Ribbon.FLAGS.SUCCESSFUL; } get #isTimingOut() { return this.#flags & Ribbon.FLAGS.TIMING_OUT; } get #shouldPing() { return !(!(this.#flags & Ribbon.FLAGS.FAST_PING) || this.#isTimingOut) || this.#pinger.heartbeat % 2 == 0; } get #alive() { return this.#flags & Ribbon.FLAGS.ALIVE; } get #dead() { return this.#flags & Ribbon.FLAGS.DEAD; } get #connecting() { return this.#flags & Ribbon.FLAGS.CONNECTING; } get #connected() { return !!this.#socket && this.#socket.readyState === 1; // WebSocket.OPEN } emit(event, ...data) { if (event.startsWith("client.")) { this.emitter.emit(event, data[0]); return; } this.#pipe(event, data[0]); } log(msg, { force = false, level = "info" } = { force: false, level: "info" }) { if (level === "error") this.emit("client.ribbon.error", msg); if (level === "warning") this.emit("client.ribbon.warn", msg); if (level === "info") this.emit("client.ribbon.log", msg); if (this.#options.logging === "none" || this.#options.logging === "error" && !force) return; const func = level === "info" ? chalk.blue : level === "warning" ? chalk.yellow : chalk.red; console[level === "error" ? "error" : "log"](`${func("[🎀\u2009Ribbon]")}: ${msg}`); } /** The last ping time, in ms */ get ping() { return this.#pinger.time; } /** Used in the Game class to detect disconnects faster, don't touch. */ set fasterPing(value) { this.#flags = this.#flags & ~Ribbon.FLAGS.FAST_PING | // @ts-expect-error value << Math.log2(Ribbon.FLAGS.FAST_PING); } /** Ribbon spool information, useful for logging */ get spool() { return { host: this.#spool.host, endpoint: this.#spool.endpoint }; } /** Closes and cleans up the ribbon, called automatically by `client.destroy()` */ destroy() { this.emitter.removeAllListeners(); this.#close(this.#lastDisconnectReason); } /** Automatically disconnect the ribbon's connection. It will attempt to automatically reconnect. */ disconnect() { if (this.#socket) { this.#socket.close(); this.#socket = null; } } /** Clones a ribbon. Used internally by Client.reconnect. */ async clone() { const newRibbon = await Ribbon.create({ logging: this.#options.logging, token: this.#token, handling: this.#handling, userAgent: this.#userAgent, codec: this.#codec.method, spooling: this.#options.spooling }); const emitter = this.emitter.export(); newRibbon.emitter.import(emitter); return newRibbon; } } //# sourceMappingURL=index.js.map