UNPKG

@haelp/teto

Version:

A typescript-based controllable TETR.IO client.

897 lines (762 loc) 22.4 kB
import type { Events, Game } from "../../types"; import { API, type APITypes, docLink, EventEmitter } from "../../utils"; import { CandorCodec } from "./codecs/codec-candor"; import { tetoPack } from "./codecs/teto-pack"; import { Bits } from "./codecs/utils/bits"; import type { RibbonEvents } from "./types"; import chalk from "chalk"; export type Codec = "json" | "teto" | "candor"; export interface Spool { host: string; endpoint: string; token: string; signature: Promise<APITypes.Server.Signature>; } interface CodecHandler { method: Codec; encode: (msg: string, data?: Record<string, any>) => Buffer | string; decode: (data: Buffer | string) => RibbonEvents.Raw<Events.in.all>; } export type LoggingLevel = "all" | "error" | "none"; export class Ribbon { static CACHE_MAXSIZE = 4096; static BATCH_TIMEOUT = 25; static readonly 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" } as const; static FLAGS = { ALIVE: 1, SUCCESSFUL: 2, CONNECTING: 4, FAST_PING: 8, TIMING_OUT: 16, DEAD: 32 }; static CODEC_FLAGS = { F_ID: 128 } as const; static SLOW_CODEC_THRESHOLD = 100; #socket: WebSocket | null = null; #token: string; #handling: Game.Handling; #userAgent: string; #codec: CodecHandler; #spool: Spool; #api: API; #self: Promise<APITypes.Users.Me>; #pinger = { heartbeat: 0, interval: setInterval(() => this.#ping(), 2500), last: 0, time: 0 }; #session = { tokenID: null as string | null, ribbonID: null as string | null }; #sentid = 0; #receivedId = 0; #flags = 0; #lastDisconnectReason: | (typeof Ribbon.CLOSE_CODES)[keyof typeof Ribbon.CLOSE_CODES] | "ping timeout" | "failed to connect" | "server closed ribbon" = "ribbon lost"; #sentQueue: { id: number; packet: Buffer | string }[] = []; #receivedQueue: { command: string; data?: any; id?: any }[] = []; #lastReconnect = 0; #reconnectCount = 0; #reconnectPenalty = 0; #reconnectTimeout: null | NodeJS.Timeout = null; #options: { logging: LoggingLevel; spooling: boolean; }; emitter = new EventEmitter<Events.in.all>(); static #getCodec(codec: Codec, userAgent: string): CodecHandler { 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) as RibbonEvents.Raw<Events.in.all>; } return JSON.parse( data.toString() ) as RibbonEvents.Raw<Events.in.all>; } }; case "teto": let pack: Awaited<ReturnType<typeof tetoPack>>; 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 */ private constructor({ logging, token, handling, userAgent, codec, spool, api, self, spooling = true }: { logging: LoggingLevel; token: string; handling: Game.Handling; userAgent: string; codec: Codec; spool: Spool; api: API; self: Promise<APITypes.Users.Me>; spooling?: boolean; }) { 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 }: { /** @deprecated - use `logging` instead */ verbose?: boolean; logging?: LoggingLevel; token: string; handling: Game.Handling; userAgent: string; codec?: Codec; spooling?: boolean; }): Promise<Ribbon> { const api = new API({ token, userAgent }); const envPromise = api.server.environment(); const signature = new Promise<APITypes.Server.Signature>( 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: string, data?: Record<string, any>): Buffer | string { 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: Buffer): any { 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: any) { 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 as any).errors) ) { this.log("Aggregated Connect Errors:", { force: true, level: "error" }); (error as any).errors.forEach((err: any, idx: number) => { 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: string) { 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: string | Buffer) { this.#flags |= Ribbon.FLAGS.ALIVE; this.#flags &= ~Ribbon.FLAGS.TIMING_OUT; const packet = this.#processPacket(data); this.#processMessage(packet); this.#processQueue(); } #onError(error: Error) { if (!this.#hasConnectedOnce) { this.log( "Connection error: " + (error.stack ?? error.message ?? error.toString()), { force: true, level: "error" } ); } } #onClose(code: number) { this.#socket = null; this.#lastDisconnectReason = Ribbon.CLOSE_CODES[code as keyof typeof Ribbon.CLOSE_CODES] || "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: string, data?: Record<string, any>) { 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: { command: string; data?: any; id?: number }) { 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: { command: string; data?: any; id?: number }) { 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 as RibbonEvents.Raw<Events.in.all>; 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 as any; 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 as any).data); } #close(reason: string = this.#lastDisconnectReason) { this.#lastDisconnectReason = reason as any; 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<T extends keyof Events.out.all>( event: T, ...data: Events.out.all[T] extends void ? [] : [Events.out.all[T]] ) { if (event.startsWith("client.")) { this.emitter.emit(event as keyof Events.out.Client, data[0] as any); return; } this.#pipe(event, data[0] as any); } log( msg: string, { force = false, level = "info" }: { force: boolean; level: "info" | "warning" | "error" } = { 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: boolean) { 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; } } export type { RibbonOptions } from "./types";