@haelp/teto
Version:
A typescript-based controllable TETR.IO client.
897 lines (762 loc) • 22.4 kB
text/typescript
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";