Skip to content

Commit

Permalink
feat: initial v2 rewrite (#96)
Browse files Browse the repository at this point in the history
  • Loading branch information
KagChi authored Dec 22, 2022
1 parent b39f97a commit e4065c5
Show file tree
Hide file tree
Showing 9 changed files with 222 additions and 202 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ LAVALINKS = '[
"name": "node-1"
}
]'
DISABLED_ROUTES = ["/plugins"]
AUTHORIZATION = youshallnotpass
TIMEOUT_SECONDS = 5
WEBHOOK_URL = https://example.com/path
Expand Down
7 changes: 2 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,11 @@
# Features
- Written in TypeScript
- Multiple Lavalink nodes support
- Configure-able response timeout
- Configure-able response timeout, for retries (only for `/loadtracks`, `/decodetracks`, `/decodetrack` routes)
- Vercel Serverless support
- Use preferred custom node using `x-node-name` headers
- Usage analytics, such as tracks info, requester (if user append x-requester-id). everything were sent to client webhook (if set).

# Not implemented
- Lavalink RoutePlanner Endpoint
- Lavalink Plugins Endpoint
- No hardcoded route, note that basic `/loadtracks`, `/decodetracks`, `/decodetrack` routes are hardcoded for caching stuff

# Usage
- You may uses this for scaling LavaLink track loading not for proxying everything via this module
210 changes: 203 additions & 7 deletions api/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,206 @@
import { NestFactory } from "@nestjs/core";
import { AppModule } from "../src/app.module";
/* eslint-disable @typescript-eslint/explicit-function-return-type */
/* eslint-disable no-nested-ternary */
import "reflect-metadata";
import "dotenv/config";

const NezlyApp = (async () => {
const app = await NestFactory.create(AppModule);
import Fastify from "fastify";
import PinoPretty from "pino-pretty";
import Pino from "pino";
import { NodeOptions } from "./types";
import { cast } from "@sapphire/utilities";
import { REST } from "@kirishima/rest";
import { fetch } from "undici";
import { BodyInit } from "undici/types/fetch";
import { Result } from "@sapphire/result";
import { Time } from "@sapphire/time-utilities";

await app.listen(parseInt(process.env.PORT ?? String(3000)));
})();
const fastify = Fastify({
logger: Pino({
name: "nezu",
timestamp: true,
level: process.env.NODE_ENV === "production" ? "info" : "trace",
formatters: {
bindings: () => ({
pid: "Nezly"
})
}
}, PinoPretty())
});

export default NezlyApp;
function getLavalinkNode(nodeName?: string, excludeNode?: string): REST {
const nodes: NodeOptions[] = JSON.parse(cast(process.env.LAVALINKS ?? []));
const firstNode = nodes
.filter(node => (excludeNode ? node.name !== excludeNode : nodeName ? node.name === nodeName : true))[
nodeName ? 0 : Math.floor(Math.random() * (excludeNode ? nodes.length - 1 : nodes.length))
];
return new REST(`${firstNode.secure ? "https" : "http"}://${firstNode.host}`)
.setAuthorization(firstNode.auth);
}

fastify.get("/", () => ({ message: "LavaLink REST Proxy API" }));

for (const route of cast<string[]>(JSON.parse(process.env.DISABLED_ROUTES ?? "[]"))) {
fastify.route({
method: ["GET", "POST", "PATCH"],
url: route,
handler: async (request, reply) => reply.status(404).send({ timestamp: new Date().toISOString(), status: 404, error: "Not Found", message: "Not Found", path: request.url })
});
}

fastify.get("/loadtracks", {
schema: {
querystring: {
identifier: { type: "string" }
}
},
preHandler: async (request, reply, done) => {
if (process.env.AUTHORIZATION && request.headers.authorization !== process.env.AUTHORIZATION) return done(new Error("Unauthorized"));
}
}, async (request, reply) => {
const node = getLavalinkNode(
Array.isArray(request.headers["x-node-name"]) ? request.headers["x-node-name"][0] : request.headers["x-node-name"]
);
const { identifier } = request.query as { identifier: string };

const source = identifier.split(":")[0];
const query = identifier.split(":")[1];

const fetchTracks = async () => {
if (reply.sent) return;
const result = await node.loadTracks(source ? { source, query } : identifier);
if (process.env.WEBHOOK_URL) {
void Result.fromAsync(async () => {
await fetch(process.env.WEBHOOK_URL!, {
headers: {
Authorization: process.env.WEBHOOK_AUTHORIZATION!
},
method: "POST",
body: JSON.stringify({
...result,
type: "LOAD_TRACKS",
user: request.headers["x-requester-id"] ?? null
})
});
});
}
return reply.send(result);
};

const timeout = setTimeout(() => Result.fromAsync(() => fetchTracks()), Time.Second * Number(process.env.TIMEOUT_SECONDS ?? 3));
return fetchTracks().then(() => clearTimeout(timeout));
});

fastify.get("/decodetrack", {
schema: {
querystring: {
track: { type: "string" }
}
},
preHandler: async (request, reply, done) => {
if (process.env.AUTHORIZATION && request.headers.authorization !== process.env.AUTHORIZATION) return done(new Error("Unauthorized"));
}
}, async (request, reply) => {
const node = getLavalinkNode(
Array.isArray(request.headers["x-node-name"]) ? request.headers["x-node-name"][0] : request.headers["x-node-name"]
);
const { track } = request.query as { track: string };

const fetchTracks = async () => {
if (reply.sent) return;
const result = await node.decodeTracks(track);
if (process.env.WEBHOOK_URL) {
void Result.fromAsync(async () => {
await fetch(process.env.WEBHOOK_URL!, {
method: "POST",
headers: {
Authorization: process.env.WEBHOOK_AUTHORIZATION!
},
body: JSON.stringify({
type: "DECODE_TRACKS",
user: request.headers["x-requester-id"] ?? null,
tracks: result
})
});
});
}
return reply.send(result[0].info);
};

const timeout = setTimeout(() => Result.fromAsync(() => fetchTracks()), Time.Second * Number(process.env.TIMEOUT_SECONDS ?? 3));
return fetchTracks().then(() => clearTimeout(timeout));
});

fastify.post("/decodetracks", {
preHandler: async (request, reply, done) => {
if (process.env.AUTHORIZATION && request.headers.authorization !== process.env.AUTHORIZATION) return done(new Error("Unauthorized"));
}
}, async (request, reply) => {
const node = getLavalinkNode(
Array.isArray(request.headers["x-node-name"]) ? request.headers["x-node-name"][0] : request.headers["x-node-name"]
);
const { tracks } = request.body as { tracks: string };
const fetchTracks = async () => {
if (reply.sent) return;
const result = await node.decodeTracks(tracks);
if (process.env.WEBHOOK_URL) {
void Result.fromAsync(async () => {
await fetch(process.env.WEBHOOK_URL!, {
method: "POST",
headers: {
Authorization: process.env.WEBHOOK_AUTHORIZATION!
},
body: JSON.stringify({
type: "DECODE_TRACKS",
user: request.headers["x-requester-id"] ?? null,
tracks: result
})
});
});
}
return reply.send(result.map(x => x.info));
};

const timeout = setTimeout(() => Result.fromAsync(() => fetchTracks()), Time.Second * Number(process.env.TIMEOUT_SECONDS ?? 3));
return fetchTracks().then(() => clearTimeout(timeout));
});

fastify.get("*", {
preHandler: async (request, reply, done) => {
if (process.env.AUTHORIZATION && request.headers.authorization !== process.env.AUTHORIZATION) return done(new Error("Unauthorized"));
}
}, async (request, reply) => {
const node = getLavalinkNode(
Array.isArray(request.headers["x-node-name"]) ? request.headers["x-node-name"][0] : request.headers["x-node-name"]
);
const fetchResult = await fetch(`${node.url}${request.url}`, { method: "GET", headers: { ...node.headers } });
if (fetchResult.headers.get("content-type")?.startsWith("application/json")) return reply.status(fetchResult.status).send(await fetchResult.json());
return reply.status(fetchResult.status).send(await fetchResult.text());
});

fastify.post("*", {
preHandler: async (request, reply, done) => {
if (process.env.AUTHORIZATION && request.headers.authorization !== process.env.AUTHORIZATION) return done(new Error("Unauthorized"));
}
}, async (request, reply) => {
const node = getLavalinkNode(
Array.isArray(request.headers["x-node-name"]) ? request.headers["x-node-name"][0] : request.headers["x-node-name"]
);
const fetchResult = await fetch(`${node.url}${request.url}`, { method: "POST", body: request.body as BodyInit, headers: { ...node.headers } });
if (fetchResult.headers.get("content-type")?.startsWith("application/json")) return reply.status(fetchResult.status).send(await fetchResult.json());
return reply.status(fetchResult.status).send(await fetchResult.text());
});

fastify.patch("*", {
preHandler: async (request, reply, done) => {
if (process.env.AUTHORIZATION && request.headers.authorization !== process.env.AUTHORIZATION) return done(new Error("Unauthorized"));
}
}, async (request, reply) => {
const node = getLavalinkNode(
Array.isArray(request.headers["x-node-name"]) ? request.headers["x-node-name"][0] : request.headers["x-node-name"]
);
const fetchResult = await fetch(`${node.url}${request.url}`, { method: "patch", body: request.body as BodyInit, headers: { ...node.headers } });
if (fetchResult.headers.get("content-type")?.startsWith("application/json")) return reply.status(fetchResult.status).send(await fetchResult.json());
return reply.status(fetchResult.status).send(await fetchResult.text());
});

void fastify.listen({ host: "0.0.0.0", port: Number(process.env.PORT ?? 3000) });
6 changes: 6 additions & 0 deletions api/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface NodeOptions {
name: string;
host: string;
auth: string;
secure?: boolean;
}
17 changes: 8 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
{
"name": "nezly",
"version": "1.0.0",
"version": "2.0.0",
"description": "A REST Proxy container for the Lavalink REST API.",
"main": "dist/api/index.js",
"main": "dist/index.js",
"scripts": {
"compile": "rimraf dist && tsc --outDir dist",
"lint": "eslint **/*.ts",
"lint:fix": "eslint **/*.ts --fix"
"lint": "eslint api/**/*.ts",
"lint:fix": "eslint api/**/*.ts --fix",
"start:dev": "npm run compile && node dist"
},
"author": "KagChi",
"license": "GPL-3.0",
Expand Down Expand Up @@ -44,17 +45,15 @@
},
"dependencies": {
"@kirishima/rest": "^0.2.5",
"@nestjs/common": "^9.2.1",
"@nestjs/core": "^9.2.1",
"@nestjs/platform-express": "^9.2.1",
"@sapphire/result": "^2.6.0",
"@sapphire/time-utilities": "^1.7.8",
"@sapphire/utilities": "^3.11.0",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"fastify": "^4.10.2",
"lavalink-api-types": "^1.1.5",
"pino": "^8.8.0",
"pino-pretty": "^9.1.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.6.0",
"undici": "^5.14.0"
}
}
Loading

0 comments on commit e4065c5

Please sign in to comment.