UNPKG

bedrocklive

Version:

Turn your Minecraft streams into interactive TikTok experiences. Connect live stream events directly to your game world.

276 lines (275 loc) 10.7 kB
import chalk from "chalk"; import express from "express"; import { WebSocketServer } from "ws"; import { createSpinner } from "nanospinner"; import { createServer } from "node:http"; import MinecraftConnection from "../connections/minecraft/minecraft-connection.js"; import { TikTokConnection } from "../connections/tiktok/tiktok-connection.js"; import { WebcastEvent } from "tiktok-live-connector"; import { pathToFileURL } from "node:url"; import { inquirePlugins } from "./load-plugins.js"; export class BedrockLive { static _instance; _app; _server; _config; _minecraftConnection; _tiktokConnection; _arePluginsLoaded = false; _selectedPlugins = []; minecraftWss; /** * Initializes the bridge with the provided configuration. * @param config - TikTok Live server configuration */ constructor(config) { this._config = config; this._app = express(); this._server = createServer(this._app); } /** * Ensures only one instance of BedrockLive is created. * @param config - TikTok Live server configuration * @returns The instance of BedrockLive */ static instance(config) { if (!BedrockLive._instance) { BedrockLive._instance = new BedrockLive(config); } return BedrockLive._instance; } async initialize() { try { this.minecraftWss = new WebSocketServer({ server: this._server }); this._minecraftConnection = MinecraftConnection.instance(this.minecraftWss); this._tiktokConnection = TikTokConnection.instance(this._config); this._minecraftConnection.on('connected', async () => { if (!this._arePluginsLoaded) { console.warn(chalk.yellow("Plugins should already be loaded. This is unexpected.")); } this._minecraftConnection.flushQueue(); }); } catch (error) { console.error(chalk.red("Error during initialization: "), error.message); process.exit(1); } } /** * Sets up the server, connects to TikTok, and loads plugins. */ async setup() { try { // Load local plugins this._selectedPlugins = await inquirePlugins(); await this.startServer(); await this.connectToTikTok(); } catch (error) { console.error(chalk.red("Error during setup: "), error.message); process.exit(1); } this.minecraftWss.on("error", (error) => { console.error(chalk.red("WebSocket Server Error:"), error.message); }); } /** * Starts the Express server and listens on the configured port. * @returns A promise that resolves when the server is started. */ startServer() { return new Promise((resolve, reject) => { this._server.listen(this._config.port, '0.0.0.0', () => { this._app.get("/", (req, res) => { res.send(` <h1 style="text-align: center;">BedrockLive</h1> <p style="text-align: center;">Connected to ${this._config.tiktokUsername}</p> <p style="text-align: center;">Loaded Plugins: ${this._selectedPlugins.length}</p> `); }); resolve(); }) .on("error", (error) => { if (error.code === "EADDRINUSE") { console.error(chalk.yellowBright(`Port ${this._config.port} is already in use.`)); process.exit(1); } else { reject(error); } }); }); } /** * Connects to the TikTok Live stream using the provided configuration. * @returns A promise that resolves when the connection is established. */ async connectToTikTok() { const username = chalk.cyanBright.bold(this._config.tiktokUsername); const spinner = createSpinner(`Connecting to ${username} TikTok Live Stream...`, { frames: ["[-]", "[\\]", "[|]", "[/]"], interval: 50, color: "yellow", }).start(); // Handle stream end event this._tiktokConnection.events.onEvent(WebcastEvent.STREAM_END, () => { spinner.update({ text: chalk.redBright(`The TikTok Live Stream has ended.`), }); this.shutdown(); }); try { // wait the user to go live if (this._config.waitUntilLive) { spinner.update({ text: `Waiting for ${username} to go live...` }); await this._tiktokConnection.waitUntilLive(); spinner.update({ text: `${username} is now live! Connecting...` }); } // connect to TikTok Live const res = await this._tiktokConnection.connect(); if (!res) { spinner.error({ text: `Failed to connect ${username} TikTok Live Stream.` }); process.exit(1); } // load local plugins if (!this._arePluginsLoaded) { spinner.update({ text: `Loading ${this._selectedPlugins.length} local plugin(s)...` }); await this.loadPlugins(); this._arePluginsLoaded = true; spinner.success({ text: `Loaded ${this._selectedPlugins.length} local plugin(s).` }); } // log number of available gifts if enabled if (this._config.enableGiftInfo) { try { const gifts = await this._tiktokConnection.fetchAvailableGifts(); if (gifts && gifts.length > 0) { spinner.info({ mark: '🎁', text: `Available gifts: ${gifts.length}` }); } } catch (error) { spinner.warn({ text: `Could not fetch gift info: ${error.message}` }); } } spinner.success({ text: `Connected to ${username} TikTok Live Stream.` }); spinner.success({ text: chalk.greenBright.bold(`Open Minecraft and run the command '${"/connect localhost:" + this._config.port}' to connect.`) }); spinner.info({ mark: '🛑', text: chalk.yellowBright.bold("Press Ctrl + C to stop the server.") }); spinner.stop(); this.startPing(); } catch (error) { spinner.error({ text: `Failed to connect ${username} TikTok Live Stream.`, }); process.exit(1); } } /** * Loads and initializes the selected local plugins. */ async loadPlugins() { if (this._selectedPlugins.length === 0) { return; } for (const pluginModuleInfo of this._selectedPlugins) { try { const pluginPath = pathToFileURL(pluginModuleInfo.mainFile).href; const { plugin: pluginFunction } = await import(pluginPath); if (typeof pluginFunction === 'function') { pluginFunction(this); } else { console.error(chalk.red(`Plugin ${pluginModuleInfo.manifest.name} from ${pluginModuleInfo.mainFile} does not export a 'plugin' function.`)); } } catch (error) { console.error(chalk.red(`Failed to load or initialize plugin: ${pluginModuleInfo.manifest.name} (from ${pluginModuleInfo.mainFile})`), error.message, error.stack); } } } /** * Getter for TikTok connection instance. * @returns TikTokConnection instance */ get tiktok() { return this._tiktokConnection; } /** * Getter for Minecraft connection instance. * @returns MinecraftConnection instance */ get minecraft() { return this._minecraftConnection; } /** * Starts the ping interval to keep WebSocket connections alive. */ pingInterval = null; startPing() { try { if (this.pingInterval) { clearInterval(this.pingInterval); this.pingInterval = null; } this.pingInterval = setInterval(() => { // Ping Minecraft WebSocket clients if (this.minecraftWss.clients.size === 0) { console.log("No Minecraft players connected."); } else { this.minecraftWss.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) client.ping(); }); } }, 60 * 1000); // 1 minute } catch (error) { console.error(chalk.red("Error starting ping interval:"), error.message); } } /** * Shuts down the server and closes all connections. * @param exit - Whether to exit the process after shutdown. * @returns A promise that resolves when the server is shut down. */ async shutdown(exit = true) { console.log(chalk.yellow('🛑 Shutting down BedrockLive...')); // Disconnect TikTok this._tiktokConnection.connection().disconnect(); // Clear ping interval if (this.pingInterval) { clearInterval(this.pingInterval); this.pingInterval = null; } // Close Minecraft WebSocket clients this.minecraftWss.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) client.close(); }); return new Promise((resolve, reject) => { this._server.close((err) => { if (err) { console.error("Error closing server:", err.message); if (exit) process.exit(1); reject(err); } else { console.log(chalk.grey("✅ BedrockLive shutdown complete")); if (exit) process.exit(0); resolve(); } }); }); } } /** * Connects to the TikTok Live server using the provided configuration. * @param config - TikTok Live server configuration * @returns The BedrockLive instance */ export async function connect(config) { const bedrockLive = BedrockLive.instance(config); await bedrockLive.initialize(); await bedrockLive.setup(); return bedrockLive; } // Legacy alias for backward compatibility export const TikTokLiveMcbe = BedrockLive;