mirror of
https://github.com/PR0M3TH3AN/bitvid-seeder.git
synced 2026-10-27 16:01:11 +00:00
656 lines
20 KiB
JavaScript
656 lines
20 KiB
JavaScript
// server.js
|
|
import { EventEmitter } from "events";
|
|
EventEmitter.defaultMaxListeners = 50;
|
|
|
|
import express from "express";
|
|
import WebTorrent from "webtorrent";
|
|
import fs from "fs";
|
|
import { promises as fsPromises } from "fs";
|
|
import path from "path";
|
|
import { fileURLToPath } from "url";
|
|
import rateLimit from "express-rate-limit";
|
|
import winston from "winston";
|
|
import JSONStream from "JSONStream";
|
|
import { lock, unlock } from "proper-lockfile";
|
|
import os from "os";
|
|
import multer from "multer"; // For file uploads
|
|
|
|
// Logger Setup
|
|
const logger = winston.createLogger({
|
|
level: "info", // Changed from "debug" to "info" to reduce logging
|
|
format: winston.format.combine(
|
|
winston.format.timestamp(),
|
|
winston.format.printf(
|
|
({ timestamp, level, message }) => `${timestamp} ${level}: ${message}`
|
|
)
|
|
),
|
|
transports: [new winston.transports.Console()],
|
|
});
|
|
|
|
// Global Variables
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const CONFIG_FILE = path.join(__dirname, "config.json");
|
|
let config = { storageDir: path.join(__dirname, "downloads") };
|
|
const TORRENTS_FILE = path.join(__dirname, "torrents.json");
|
|
let savedTorrents = [];
|
|
|
|
// Helper Functions
|
|
async function pathExists(p) {
|
|
try {
|
|
await fsPromises.access(p);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes === 0) return "0 B";
|
|
const k = 1024;
|
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i];
|
|
}
|
|
|
|
// Load Configuration
|
|
async function loadConfig() {
|
|
try {
|
|
if (!fs.existsSync(CONFIG_FILE)) {
|
|
logger.info(
|
|
"No config.json file found. Creating new default config file."
|
|
);
|
|
const defaultStorageDir = path.join(
|
|
os.homedir(),
|
|
"Documents",
|
|
"bitvid-seeder"
|
|
);
|
|
const defaultConfig = {
|
|
storageDir: defaultStorageDir,
|
|
maxConns: 200,
|
|
utp: false,
|
|
};
|
|
fs.writeFileSync(
|
|
CONFIG_FILE,
|
|
JSON.stringify(defaultConfig, null, 2),
|
|
"utf8"
|
|
);
|
|
config = defaultConfig;
|
|
} else {
|
|
const data = await fsPromises.readFile(CONFIG_FILE, "utf-8");
|
|
config = JSON.parse(data);
|
|
if (!config.maxConns) config.maxConns = 200;
|
|
if (config.utp === undefined) config.utp = false;
|
|
if (!config.storageDir || config.storageDir.trim() === "") {
|
|
config.storageDir = path.join(
|
|
os.homedir(),
|
|
"Documents",
|
|
"bitvid-seeder"
|
|
);
|
|
} else {
|
|
config.storageDir = path.resolve(config.storageDir);
|
|
}
|
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
|
|
}
|
|
} catch (err) {
|
|
logger.error("Error reading config file, using default config: " + err);
|
|
config = {
|
|
storageDir: path.join(os.homedir(), "Documents", "bitvid-seeder"),
|
|
maxConns: 200,
|
|
utp: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Load Torrents with Streaming and Migrate Old Entries
|
|
async function loadSavedTorrentsFromFile() {
|
|
if (!fs.existsSync(TORRENTS_FILE)) {
|
|
logger.info("No torrents.json file found. Creating new empty file.");
|
|
fs.writeFileSync(TORRENTS_FILE, "[]", "utf8");
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const stream = fs.createReadStream(TORRENTS_FILE, { encoding: "utf8" });
|
|
const parser = JSONStream.parse("*");
|
|
savedTorrents = [];
|
|
parser.on("data", (torrent) => {
|
|
// Migrate old 'paused' field to 'state'
|
|
if (torrent.paused !== undefined) {
|
|
torrent.state = torrent.paused ? "paused" : "active";
|
|
delete torrent.paused;
|
|
}
|
|
savedTorrents.push(torrent);
|
|
});
|
|
parser.on("end", () => resolve());
|
|
parser.on("error", (err) => reject(err));
|
|
stream.pipe(parser);
|
|
});
|
|
}
|
|
|
|
// Asynchronous Save Torrents with File Locking
|
|
async function saveTorrents() {
|
|
await lock(TORRENTS_FILE);
|
|
try {
|
|
const uniqueTorrents = Array.from(
|
|
new Set(savedTorrents.map((t) => t.infoHash))
|
|
).map((infoHash) => savedTorrents.find((t) => t.infoHash === infoHash));
|
|
savedTorrents = uniqueTorrents;
|
|
const stream = JSONStream.stringify("[\n", ",\n", "\n]");
|
|
const fileStream = fs.createWriteStream(TORRENTS_FILE);
|
|
stream.pipe(fileStream);
|
|
savedTorrents.forEach((torrent) => stream.write(torrent));
|
|
stream.end();
|
|
await new Promise((resolve, reject) => {
|
|
fileStream.on("finish", resolve);
|
|
fileStream.on("error", reject);
|
|
});
|
|
logger.info("Torrents saved to file");
|
|
} catch (err) {
|
|
logger.error("Error saving torrents: " + err);
|
|
} finally {
|
|
await unlock(TORRENTS_FILE);
|
|
}
|
|
}
|
|
|
|
// Torrent Event Debugging (optional, can be removed if not needed)
|
|
function attachTorrentDebugEvents(torrent) {
|
|
torrent.on("wire", (wire, addr) => {
|
|
logger.debug(`Torrent ${torrent.infoHash} connected to peer: ${addr}`);
|
|
});
|
|
torrent.on("upload", (bytes) => {
|
|
logger.debug(`Torrent ${torrent.infoHash} uploaded ${bytes} bytes`);
|
|
});
|
|
torrent.on("download", (bytes) => {
|
|
logger.debug(`Torrent ${torrent.infoHash} downloaded ${bytes} bytes`);
|
|
});
|
|
}
|
|
|
|
// Resume Torrents on Startup
|
|
async function resumeActiveTorrents(client, STORAGE_DIR) {
|
|
logger.info("Starting to resume torrents...");
|
|
if (savedTorrents.length === 0) {
|
|
logger.info("No saved torrents found in torrents.json.");
|
|
return;
|
|
}
|
|
for (const torrentData of savedTorrents) {
|
|
const { magnet, infoHash, name, state } = torrentData;
|
|
if (magnet) {
|
|
const torrent = await new Promise((resolve, reject) => {
|
|
const t = client.add(magnet, { path: STORAGE_DIR }, resolve);
|
|
t.on("error", reject);
|
|
});
|
|
if (state === "paused") {
|
|
torrent.pause();
|
|
}
|
|
attachTorrentDebugEvents(torrent);
|
|
logger.info(`Torrent ${infoHash} added with state: ${state}`);
|
|
} else {
|
|
// Handle seeding existing files
|
|
const torrentPath = path.join(STORAGE_DIR, name);
|
|
if (await pathExists(torrentPath)) {
|
|
const torrent = await new Promise((resolve, reject) => {
|
|
const t = client.seed(torrentPath, { name }, resolve);
|
|
t.on("error", reject);
|
|
});
|
|
if (state === "paused") {
|
|
torrent.pause();
|
|
}
|
|
attachTorrentDebugEvents(torrent);
|
|
logger.info(`Torrent ${infoHash} seeded with state: ${state}`);
|
|
} else {
|
|
logger.warn(`File missing for torrent ${name}, cannot seed.`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Main Initialization Function
|
|
async function initialize() {
|
|
// Load configuration first
|
|
await loadConfig();
|
|
|
|
// Ensure storage directory exists
|
|
let STORAGE_DIR = config.storageDir;
|
|
if (!fs.existsSync(STORAGE_DIR)) {
|
|
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
|
}
|
|
|
|
// Initialize Express app and WebTorrent client
|
|
const app = express();
|
|
const port = process.env.PORT || 3000;
|
|
const client = new WebTorrent({
|
|
maxConns: config.maxConns,
|
|
utp: config.utp,
|
|
utpOpts: { port: 6881 }, // Specify a different port for UTP
|
|
});
|
|
client.setMaxListeners(50);
|
|
|
|
// Rate Limiting Middleware
|
|
const limiter = rateLimit({
|
|
windowMs: 60 * 1000,
|
|
max: 100,
|
|
});
|
|
app.use(limiter);
|
|
app.use(express.json());
|
|
|
|
// Multer configuration for file uploads
|
|
const upload = multer({ dest: "uploads/" });
|
|
|
|
// Load saved torrents
|
|
await loadSavedTorrentsFromFile();
|
|
|
|
// API Routes
|
|
|
|
// Get configuration
|
|
app.get("/config", async (req, res) => {
|
|
try {
|
|
res.json({
|
|
storageDir: config.storageDir,
|
|
maxConns: config.maxConns,
|
|
utp: config.utp,
|
|
});
|
|
} catch (err) {
|
|
logger.error("Error getting config: " + err);
|
|
res.status(500).json({ error: "Error reading config" });
|
|
}
|
|
});
|
|
|
|
// List torrents with pagination
|
|
app.get("/torrents", (req, res) => {
|
|
try {
|
|
logger.info(`Listing torrents: ${JSON.stringify(savedTorrents)}`);
|
|
const page = parseInt(req.query.page) || 1;
|
|
const limit = parseInt(req.query.limit) || 10;
|
|
const start = (page - 1) * limit;
|
|
const end = start + limit;
|
|
|
|
const allTorrents = savedTorrents.map((saved) => {
|
|
const torrent = client.get(saved.infoHash);
|
|
if (torrent) {
|
|
logger.info(
|
|
`Active torrent ${saved.infoHash}: ${JSON.stringify({
|
|
progress: torrent.progress,
|
|
numPeers: torrent.numPeers,
|
|
downloaded: torrent.downloaded,
|
|
uploaded: torrent.uploaded,
|
|
status: saved.state,
|
|
})}`
|
|
);
|
|
return {
|
|
infoHash: saved.infoHash,
|
|
name: saved.name,
|
|
progress: torrent.progress,
|
|
numPeers: torrent.numPeers,
|
|
downloaded: torrent.downloaded,
|
|
uploaded: torrent.uploaded,
|
|
status: saved.state,
|
|
magnet: saved.magnet,
|
|
};
|
|
} else {
|
|
logger.info(
|
|
`Paused torrent ${saved.infoHash}: ${JSON.stringify({
|
|
progress: 1,
|
|
numPeers: 0,
|
|
downloaded: 0,
|
|
uploaded: 0,
|
|
status: saved.state,
|
|
})}`
|
|
);
|
|
return {
|
|
infoHash: saved.infoHash,
|
|
name: saved.name,
|
|
progress: 1,
|
|
numPeers: 0,
|
|
downloaded: 0,
|
|
uploaded: 0,
|
|
status: saved.state,
|
|
magnet: saved.magnet,
|
|
};
|
|
}
|
|
});
|
|
const paginatedTorrents = allTorrents.slice(start, end);
|
|
res.json({
|
|
torrents: paginatedTorrents,
|
|
total: allTorrents.length,
|
|
page,
|
|
limit,
|
|
});
|
|
} catch (err) {
|
|
logger.error("Error listing torrents: " + err.message);
|
|
res.status(500).json({
|
|
torrents: [],
|
|
total: 0,
|
|
page: parseInt(req.query.page) || 1,
|
|
limit: parseInt(req.query.limit) || 10,
|
|
error: "Error listing torrents",
|
|
message: err.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Download route
|
|
app.get("/download/:infoHash", async (req, res) => {
|
|
const infoHash = req.params.infoHash;
|
|
const savedData = savedTorrents.find((t) => t.infoHash === infoHash);
|
|
if (!savedData) {
|
|
return res.status(404).json({ error: "Torrent not found" });
|
|
}
|
|
const filePath = path.join(STORAGE_DIR, savedData.name);
|
|
if (!(await pathExists(filePath))) {
|
|
return res.status(404).json({ error: "File not found" });
|
|
}
|
|
res.download(filePath, savedData.name, (err) => {
|
|
if (err) {
|
|
logger.error("Error downloading file: " + err);
|
|
res.status(500).json({ error: "Error downloading file" });
|
|
}
|
|
});
|
|
});
|
|
|
|
// Pause a torrent
|
|
app.post("/pause/:infoHash", async (req, res) => {
|
|
const infoHash = req.params.infoHash;
|
|
let torrent = client.get(infoHash);
|
|
// If the torrent isn't currently in the client, try re-adding it from savedTorrents.
|
|
if (!torrent) {
|
|
const savedEntry = savedTorrents.find((s) => s.infoHash === infoHash);
|
|
if (savedEntry && savedEntry.magnet) {
|
|
try {
|
|
torrent = await new Promise((resolve, reject) => {
|
|
const t = client.add(
|
|
savedEntry.magnet,
|
|
{ path: STORAGE_DIR },
|
|
resolve
|
|
);
|
|
t.on("error", reject);
|
|
});
|
|
} catch (err) {
|
|
logger.error("Error re-adding torrent for pause: " + err.message);
|
|
return res.status(500).json({ error: "Error pausing torrent" });
|
|
}
|
|
}
|
|
}
|
|
if (torrent) {
|
|
torrent.pause();
|
|
const entry = savedTorrents.find((s) => s.infoHash === infoHash);
|
|
if (entry) entry.state = "paused";
|
|
await saveTorrents();
|
|
res.json({ message: "Torrent paused" });
|
|
} else {
|
|
res.status(400).json({ error: "Torrent not found" });
|
|
}
|
|
});
|
|
|
|
// Resume a torrent
|
|
app.post("/resume/:infoHash", async (req, res) => {
|
|
const infoHash = req.params.infoHash;
|
|
let torrent = client.get(infoHash);
|
|
// If the torrent isn't in the client, re-add it from the saved torrents
|
|
if (!torrent) {
|
|
const savedEntry = savedTorrents.find((s) => s.infoHash === infoHash);
|
|
if (savedEntry && savedEntry.magnet) {
|
|
try {
|
|
torrent = await new Promise((resolve, reject) => {
|
|
const t = client.add(
|
|
savedEntry.magnet,
|
|
{ path: STORAGE_DIR },
|
|
resolve
|
|
);
|
|
t.on("error", reject);
|
|
});
|
|
} catch (err) {
|
|
logger.error("Error re-adding torrent for resume: " + err.message);
|
|
return res.status(500).json({ error: "Error resuming torrent" });
|
|
}
|
|
}
|
|
}
|
|
if (torrent) {
|
|
torrent.resume();
|
|
const entry = savedTorrents.find((s) => s.infoHash === infoHash);
|
|
if (entry) entry.state = "active";
|
|
await saveTorrents();
|
|
res.json({ message: "Torrent resumed" });
|
|
} else {
|
|
res.status(400).json({ error: "Torrent not found" });
|
|
}
|
|
});
|
|
|
|
// Update configuration with path validation
|
|
app.post("/config", async (req, res) => {
|
|
const { storageDir, maxConns, utp } = req.body;
|
|
try {
|
|
if (storageDir) {
|
|
const resolvedPath = path.resolve(storageDir);
|
|
if (!path.isAbsolute(resolvedPath)) {
|
|
throw new Error("Path must be absolute");
|
|
}
|
|
if (!fs.existsSync(resolvedPath)) {
|
|
fs.mkdirSync(resolvedPath, { recursive: true });
|
|
}
|
|
config.storageDir = resolvedPath;
|
|
STORAGE_DIR = resolvedPath;
|
|
}
|
|
if (maxConns !== undefined) {
|
|
if (!Number.isInteger(maxConns) || maxConns <= 0) {
|
|
throw new Error("maxConns must be a positive integer");
|
|
}
|
|
config.maxConns = maxConns;
|
|
}
|
|
if (utp !== undefined) {
|
|
if (typeof utp !== "boolean") {
|
|
throw new Error("utp must be a boolean");
|
|
}
|
|
config.utp = utp;
|
|
}
|
|
await fsPromises.writeFile(
|
|
CONFIG_FILE,
|
|
JSON.stringify(config, null, 2),
|
|
"utf8"
|
|
);
|
|
res.json({ message: "Config updated", config });
|
|
} catch (err) {
|
|
logger.error("Error updating config: " + err.message);
|
|
res
|
|
.status(500)
|
|
.json({ error: "Error updating config", message: err.message });
|
|
}
|
|
});
|
|
|
|
// Add a new torrent
|
|
app.post("/add", async (req, res) => {
|
|
const { magnet } = req.body;
|
|
if (!magnet || typeof magnet !== "string") {
|
|
return res.status(400).json({ error: "No valid magnet link provided" });
|
|
}
|
|
if (!magnet.startsWith("magnet:?")) {
|
|
return res.status(400).json({ error: "Invalid magnet link format" });
|
|
}
|
|
if (savedTorrents.some((t) => t.magnet === magnet)) {
|
|
return res.status(409).json({ error: "Torrent already added." });
|
|
}
|
|
try {
|
|
const torrent = await new Promise((resolve, reject) => {
|
|
const t = client.add(magnet, { path: STORAGE_DIR }, resolve);
|
|
t.on("error", reject);
|
|
});
|
|
attachTorrentDebugEvents(torrent);
|
|
const torrentData = {
|
|
magnet,
|
|
infoHash: torrent.infoHash,
|
|
name: torrent.name,
|
|
state: "active",
|
|
};
|
|
savedTorrents.push(torrentData);
|
|
await saveTorrents();
|
|
res.json(torrentData);
|
|
} catch (err) {
|
|
logger.error(`Error adding torrent: ${err.message}`);
|
|
res
|
|
.status(500)
|
|
.json({ error: "Error adding torrent", details: err.message });
|
|
}
|
|
});
|
|
|
|
// Upload and seed a video file
|
|
app.post("/upload", upload.single("video"), async (req, res) => {
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: "No video file provided" });
|
|
}
|
|
try {
|
|
const tempFilePath = req.file.path;
|
|
const fileName = req.file.originalname;
|
|
const destPath = path.join(STORAGE_DIR, fileName);
|
|
if (!fs.existsSync(STORAGE_DIR)) {
|
|
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
|
}
|
|
await fsPromises.copyFile(tempFilePath, destPath);
|
|
await fsPromises.unlink(tempFilePath);
|
|
let attempts = 0;
|
|
const maxAttempts = 5;
|
|
while (attempts < maxAttempts) {
|
|
try {
|
|
await fsPromises.access(destPath, fs.constants.R_OK);
|
|
break;
|
|
} catch (err) {
|
|
if (err.code === "EBUSY") {
|
|
attempts++;
|
|
logger.info(
|
|
`File ${destPath} is busy, retrying (${attempts}/${maxAttempts})...`
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
if (attempts === maxAttempts) {
|
|
throw new Error(
|
|
`File ${destPath} remained locked after ${maxAttempts} attempts`
|
|
);
|
|
}
|
|
client.seed(destPath, { name: fileName }, (torrent) => {
|
|
attachTorrentDebugEvents(torrent);
|
|
const torrentData = {
|
|
magnet: torrent.magnetURI,
|
|
infoHash: torrent.infoHash,
|
|
name: fileName,
|
|
state: "active",
|
|
dateAdded: new Date().toISOString(),
|
|
size: torrent.length,
|
|
};
|
|
savedTorrents.push(torrentData);
|
|
saveTorrents();
|
|
res.json(torrentData);
|
|
});
|
|
} catch (err) {
|
|
logger.error("Error uploading file: " + err.message);
|
|
res
|
|
.status(500)
|
|
.json({ error: "Error uploading file", message: err.message });
|
|
}
|
|
});
|
|
|
|
// Remove a torrent
|
|
app.delete("/remove/:infoHash", (req, res) => {
|
|
const infoHash = req.params.infoHash;
|
|
if (!infoHash || typeof infoHash !== "string") {
|
|
return res.status(400).json({ error: "Invalid infoHash" });
|
|
}
|
|
const torrent = client.get(infoHash);
|
|
const savedData = savedTorrents.find((t) => t.infoHash === infoHash);
|
|
const torrentName =
|
|
(torrent && torrent.name) || (savedData && savedData.name);
|
|
if (!torrentName) {
|
|
return res.status(400).json({ error: "Torrent name not found" });
|
|
}
|
|
try {
|
|
client.remove(infoHash, { destroyStore: true }, async (err) => {
|
|
if (err) {
|
|
logger.error("Error removing torrent: " + err);
|
|
return res.status(500).json({ error: "Error removing torrent" });
|
|
}
|
|
savedTorrents = savedTorrents.filter((t) => t.infoHash !== infoHash);
|
|
await saveTorrents();
|
|
const torrentPath = path.join(STORAGE_DIR, torrentName);
|
|
if (await pathExists(torrentPath)) {
|
|
try {
|
|
await fsPromises.rm(torrentPath, { recursive: true, force: true });
|
|
} catch (rmErr) {
|
|
logger.error("Error removing torrent files: " + rmErr);
|
|
}
|
|
}
|
|
res.json({ message: "Torrent removed" });
|
|
});
|
|
} catch (err) {
|
|
logger.error("Error processing removal: " + err);
|
|
res.status(500).json({ error: "Error processing removal" });
|
|
}
|
|
});
|
|
|
|
// Serve static files from the "public" folder
|
|
app.use(express.static("public"));
|
|
|
|
// Resource Usage Monitoring
|
|
setInterval(() => {
|
|
const cpuUsage = os.loadavg()[0];
|
|
const freeMem = os.freemem();
|
|
const totalMem = os.totalmem();
|
|
const memUsage = ((totalMem - freeMem) / totalMem) * 100;
|
|
logger.info(
|
|
`CPU Load: ${cpuUsage.toFixed(2)}%, Memory Usage: ${memUsage.toFixed(2)}%`
|
|
);
|
|
const mem = process.memoryUsage();
|
|
logger.info(
|
|
`Memory: RSS=${formatBytes(mem.rss)}, Heap=${formatBytes(
|
|
mem.heapUsed
|
|
)}/${formatBytes(mem.heapTotal)}`
|
|
);
|
|
logger.info(
|
|
`Active torrents: ${client.torrents.length}, Paused torrents: ${
|
|
savedTorrents.filter((t) => t.state === "paused").length
|
|
}`
|
|
);
|
|
}, 60000);
|
|
|
|
// Function to launch the HTTP server
|
|
function launchServer() {
|
|
const server = app.listen(port, async () => {
|
|
logger.info(`Server running on http://localhost:${port}`);
|
|
await resumeActiveTorrents(client, STORAGE_DIR);
|
|
});
|
|
|
|
// Graceful shutdown
|
|
function shutdown() {
|
|
logger.info("Shutting down server...");
|
|
server.close(() => {
|
|
logger.info("HTTP server closed.");
|
|
client.destroy((err) => {
|
|
if (err) {
|
|
logger.error("Error destroying WebTorrent client: " + err);
|
|
} else {
|
|
logger.info("WebTorrent client destroyed.");
|
|
}
|
|
process.exit(0);
|
|
});
|
|
});
|
|
}
|
|
process.on("SIGINT", shutdown);
|
|
process.on("SIGTERM", shutdown);
|
|
|
|
return port;
|
|
}
|
|
|
|
return launchServer();
|
|
}
|
|
|
|
// Exported startServer function for external usage
|
|
async function startServer() {
|
|
return initialize();
|
|
}
|
|
|
|
// Conditionally start the server if this module is the entry point
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
startServer();
|
|
}
|
|
|
|
export { startServer };
|