diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5c3a795 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = tab +indent_size = 2 +quote_type = single + +[Makefile] +indent_style = tab diff --git a/biome.json b/biome.json index c52ade5..3517fbd 100644 --- a/biome.json +++ b/biome.json @@ -4,17 +4,21 @@ "enabled": true, "rules": { "all": true, + "security": { + "noGlobalEval": "off" + }, "suspicious": { "noExplicitAny": "off", "noAssignInExpressions": "off", "noUnsafeDeclarationMerging": "off", - "noRedeclare": "off", "noEmptyInterface": "off", "noConfusingVoidType": "off", "noImplicitAnyLet": "off", "noEmptyBlockStatements": "off", "useAwait": "off", - "noConsoleLog": "off" + "noConsoleLog": "off", + "noAsyncPromiseExecutor": "off", + "noThenProperty": "off" }, "style": { "noNonNullAssertion": "off", @@ -25,11 +29,12 @@ "useNamingConvention": "off", "noParameterProperties": "off", "useFilenamingConvention": "off", - "noDefaultExport": "off", "noNamespaceImport": "off", "useSingleCaseStatement": "off", "useBlockStatements": "off", - "useEnumInitializers": "off" + "useEnumInitializers": "off", + "noArguments": "off", + "useForOf": "off" }, "correctness": { "noUnusedVariables": "off", @@ -43,9 +48,7 @@ "noBannedTypes": "off", "noForEach": "off", "noUselessConstructor": "off", - "noThisInStatic": "off", "noExcessiveCognitiveComplexity": "off", - "noVoid": "off", "noStaticOnlyClass": "off" }, "a11y": { @@ -95,4 +98,4 @@ "organizeImports": { "enabled": false } -} +} \ No newline at end of file diff --git a/packages/generic-adapter/src/adapter.ts b/packages/generic-adapter/src/adapter.ts index d66bd65..274e9b1 100644 --- a/packages/generic-adapter/src/adapter.ts +++ b/packages/generic-adapter/src/adapter.ts @@ -1,92 +1,89 @@ -import { HttpClient, Logger, } from 'seyfert'; -import { InternalRuntimeConfigHTTP } from 'seyfert/lib/client/base'; -import { APIInteraction, InteractionResponseType, InteractionType } from 'seyfert/lib/types'; -import type { HttpServerAdapter } from 'seyfert/lib/client/types'; - -import nacl from 'tweetnacl'; -import { isCloudfareWorker } from 'seyfert/lib/common'; - -export class GenericAdapter implements HttpServerAdapter { - publicKeyHex!: Buffer; - applicationId!: string; - debugger?: Logger; - logger!: Logger - - constructor(public client: HttpClient) { - this.logger = client.logger - } - - async start() { - if (this.client.debugger) this.debugger = this.client.debugger - - const { - publicKey, - applicationId, - } = await this.client.getRC() - - if (!publicKey) { - throw new Error('Expected a publicKey, check your config file'); - } - if (applicationId) { - this.applicationId = applicationId; - } - this.publicKeyHex = Buffer.from(publicKey, 'hex'); - this.logger.info(`Running on `); - } - - protected async verifySignature(req: Request) { - const timestamp = req.headers.get('x-signature-timestamp'); - const ed25519 = req.headers.get('x-signature-ed25519') ?? ''; - const body = (await req.json()) as APIInteraction; - if ( - nacl!.sign.detached.verify( - Buffer.from(timestamp + JSON.stringify(body)), - Buffer.from(ed25519, 'hex'), - this.publicKeyHex, - ) - ) { - return body; - } - return; - } - - async fetch(req: Request) { - const rawBody = await this.verifySignature(req); - if (!rawBody) { - this.debugger?.debug('Invalid request/No info, returning 418 status.'); - // I'm a teapot - return new Response('', { status: 418 }); - } - switch (rawBody.type) { - case InteractionType.Ping: - this.debugger?.debug('Ping interaction received, responding.'); - return Response.json( - { type: InteractionResponseType.Pong }, - { - headers: { - 'Content-Type': 'application/json', - }, - }, - ); - default: - if (isCloudfareWorker()) { - // you can not do more net requests after responding. - // so we use discord api instead - return this.client.handleCommand - .interaction(rawBody, -1) - .then(() => new Response()) - .catch(() => new Response()); - } - return new Promise(async r => { - const { headers, response } = await this.client.onPacket(rawBody) - r( - response instanceof FormData - ? new Response(response, { headers }) - : Response.json(response, { - headers, - }), - ); - }); - } - } -} \ No newline at end of file +import type { HttpClient, Logger } from 'seyfert'; +import type { InternalRuntimeConfigHTTP } from 'seyfert/lib/client/base'; +import { type APIInteraction, InteractionResponseType, InteractionType } from 'seyfert/lib/types'; +import type { HttpServerAdapter } from 'seyfert/lib/client/types'; + +import nacl from 'tweetnacl'; +import { isCloudfareWorker } from 'seyfert/lib/common'; + +export class GenericAdapter implements HttpServerAdapter { + publicKeyHex!: Buffer; + applicationId!: string; + debugger?: Logger; + logger: Logger; + + constructor(public client: HttpClient) { + this.logger = client.logger; + } + + async start() { + if (this.client.debugger) this.debugger = this.client.debugger; + + const { publicKey, applicationId } = await this.client.getRC(); + + if (!publicKey) { + throw new Error('Expected a publicKey, check your config file'); + } + if (applicationId) { + this.applicationId = applicationId; + } + this.publicKeyHex = Buffer.from(publicKey, 'hex'); + this.logger.info('Running on '); + } + + protected async verifySignature(req: Request) { + const timestamp = req.headers.get('x-signature-timestamp'); + const ed25519 = req.headers.get('x-signature-ed25519') ?? ''; + const body = (await req.json()) as APIInteraction; + if ( + nacl!.sign.detached.verify( + Buffer.from(timestamp + JSON.stringify(body)), + Buffer.from(ed25519, 'hex'), + this.publicKeyHex, + ) + ) { + return body; + } + return; + } + + async fetch(req: Request) { + const rawBody = await this.verifySignature(req); + if (!rawBody) { + this.debugger?.debug('Invalid request/No info, returning 418 status.'); + // I'm a teapot + return new Response('', { status: 418 }); + } + switch (rawBody.type) { + case InteractionType.Ping: + this.debugger?.debug('Ping interaction received, responding.'); + return Response.json( + { type: InteractionResponseType.Pong }, + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + default: + if (isCloudfareWorker()) { + // you can not do more net requests after responding. + // so we use discord api instead + return this.client.handleCommand + .interaction(rawBody, -1) + .then(() => new Response()) + .catch(() => new Response()); + } + return new Promise(async r => { + const { headers, response } = await this.client.onPacket(rawBody); + r( + response instanceof FormData + ? new Response(response, { headers }) + : Response.json(response, { + headers, + }), + ); + }); + } + } +} diff --git a/packages/generic-adapter/src/index.ts b/packages/generic-adapter/src/index.ts index 48ffbbc..4a3abe5 100644 --- a/packages/generic-adapter/src/index.ts +++ b/packages/generic-adapter/src/index.ts @@ -1 +1 @@ -export * from './adapter' \ No newline at end of file +export * from './adapter'; diff --git a/packages/redis-adapter/src/adapter.ts b/packages/redis-adapter/src/adapter.ts index 52abcf2..f7b374f 100644 --- a/packages/redis-adapter/src/adapter.ts +++ b/packages/redis-adapter/src/adapter.ts @@ -1,256 +1,256 @@ -import type { RedisOptions } from 'ioredis'; -import type { Adapter } from 'seyfert/lib/cache'; -import { Redis } from 'ioredis'; - -interface RedisAdapterOptions { - namespace?: string; -} - -export class RedisAdapter implements Adapter { - isAsync = true; - - client: Redis; - namespace: string; - - constructor(data: ({ client: Redis } | { redisOptions: RedisOptions }) & RedisAdapterOptions) { - this.client = 'client' in data ? data.client : new Redis(data.redisOptions); - this.namespace = data.namespace ?? 'seyfert'; - } - - private __scanSets(query: string, returnKeys?: false): Promise; - private __scanSets(query: string, returnKeys: true): Promise; - private __scanSets(query: string, returnKeys = false) { - const match = this.buildKey(query); - return new Promise((r, j) => { - const stream = this.client.scanStream({ - match, - type: 'set', - }); - const keys: string[] = []; - stream - .on('data', resultKeys => keys.push(...resultKeys)) - .on('end', () => (returnKeys ? r(keys.map(x => this.buildKey(x))) : r(this.bulkGet(keys)))) - .on('error', err => j(err)); - }); - } - - scan(query: string, returnKeys?: false): Promise; - scan(query: string, returnKeys: true): Promise; - scan(query: string, returnKeys = false) { - const match = this.buildKey(query); - return new Promise((r, j) => { - const stream = this.client.scanStream({ - match, - // omit relationships - type: 'hash', - }); - const keys: string[] = []; - stream - .on('data', resultKeys => keys.push(...resultKeys)) - .on('end', () => (returnKeys ? r(keys.map(x => this.buildKey(x))) : r(this.bulkGet(keys)))) - .on('error', err => j(err)); - }); - } - - async bulkGet(keys: string[]) { - const pipeline = this.client.pipeline(); - - for (const key of keys) { - pipeline.hgetall(this.buildKey(key)); - } - - return (await pipeline.exec())?.filter(x => !!x[1]).map(x => toNormal(x[1] as Record)) ?? []; - } - - async get(keys: string): Promise { - const value = await this.client.hgetall(this.buildKey(keys)); - if (value) { - return toNormal(value); - } - } - - async bulkSet(data: [string, any][]) { - const pipeline = this.client.pipeline(); - - for (const [k, v] of data) { - pipeline.hset(this.buildKey(k), toDb(v)); - } - - await pipeline.exec(); - } - - async set(id: string, data: any) { - await this.client.hset(this.buildKey(id), toDb(data)); - } - - async bulkPatch(updateOnly: boolean, data: [string, any][]) { - const pipeline = this.client.pipeline(); - - for (const [k, v] of data) { - if (updateOnly) { - pipeline.eval( - `if redis.call('exists',KEYS[1]) == 1 then redis.call('hset', KEYS[1], ${Array.from( - { length: Object.keys(v).length * 2 }, - (_, i) => `ARGV[${i + 1}]`, - )}) end`, - 1, - this.buildKey(k), - ...Object.entries(toDb(v)).flat(), - ); - } else { - pipeline.hset(this.buildKey(k), toDb(v)); - } - } - - await pipeline.exec(); - } - - async patch(updateOnly: boolean, id: string, data: any): Promise { - if (updateOnly) { - await this.client.eval( - `if redis.call('exists',KEYS[1]) == 1 then redis.call('hset', KEYS[1], ${Array.from( - { length: Object.keys(data).length * 2 }, - (_, i) => `ARGV[${i + 1}]`, - )}) end`, - 1, - this.buildKey(id), - ...Object.entries(toDb(data)).flat(), - ); - } else { - await this.client.hset(this.buildKey(id), toDb(data)); - } - } - - async values(to: string): Promise { - const array: unknown[] = []; - const data = await this.keys(to); - if (data.length) { - const items = await this.bulkGet(data); - for (const item of items) { - if (item) { - array.push(item); - } - } - } - - return array; - } - - async keys(to: string): Promise { - const data = await this.getToRelationship(to); - return data.map(id => this.buildKey(`${to}.${id}`)); - } - - async count(to: string): Promise { - return this.client.scard(`${this.buildKey(to)}:set`); - } - - async bulkRemove(keys: string[]) { - await this.client.del(...keys.map(x => this.buildKey(x))); - } - - async remove(keys: string): Promise { - await this.client.del(this.buildKey(keys)); - } - - async flush(): Promise { - const keys = await Promise.all([ - this.scan(this.buildKey('*'), true), - this.__scanSets(this.buildKey('*'), true), - ]).then(x => x.flat()); - if (!keys.length) return; - await this.bulkRemove(keys); - } - - async contains(to: string, keys: string): Promise { - return (await this.client.sismember(`${this.buildKey(to)}:set`, keys)) === 1; - } - - async getToRelationship(to: string): Promise { - return this.client.smembers(`${this.buildKey(to)}:set`); - } - - async bulkAddToRelationShip(data: Record): Promise { - const pipeline = this.client.pipeline(); - - for (const [key, value] of Object.entries(data)) { - pipeline.sadd(`${this.buildKey(key)}:set`, ...value); - } - - await pipeline.exec(); - } - - async addToRelationship(to: string, keys: string | string[]): Promise { - await this.client.sadd(`${this.buildKey(to)}:set`, ...(Array.isArray(keys) ? keys : [keys])); - } - - async removeToRelationship(to: string, keys: string | string[]): Promise { - await this.client.srem(`${this.buildKey(to)}:set`, ...(Array.isArray(keys) ? keys : [keys])); - } - - async removeRelationship(to: string | string[]): Promise { - await this.client.del( - ...(Array.isArray(to) ? to.map(x => `${this.buildKey(x)}:set`) : [`${this.buildKey(to)}:set`]), - ); - } - - protected buildKey(key: string) { - return key.startsWith(this.namespace) ? key : `${this.namespace}:${key}`; - } -} - -const isObject = (o: unknown) => { - return !!o && typeof o === 'object' && !Array.isArray(o); -}; - -function toNormal(target: Record): undefined | Record | Record[] { - if (typeof target.ARRAY_OF === 'string') return JSON.parse(target.ARRAY_OF as string).map(toNormal); - if (!Object.keys(target).length) return undefined; - const result: Record = {}; - for (const [key, value] of Object.entries(target)) { - if (key.startsWith('O_')) { - result[key.slice(2)] = JSON.parse(value); - } else if (key.startsWith('N_')) { - result[key.slice(2)] = Number(value); - } else if (key.startsWith('B_')) { - result[key.slice(2)] = value === 'true'; - } else { - result[key] = value; - } - } - return result; -} - -function toDb(target: Record): Record | Record[] { - if (Array.isArray(target)) return { ARRAY_OF: JSON.stringify(target.map(toDb)) }; - const result: Record = {}; - for (const [key, value] of Object.entries(target)) { - switch (typeof value) { - case 'boolean': - result[`B_${key}`] = value; - break; - case 'number': - result[`N_${key}`] = `${value}`; - break; - case 'object': - if (Array.isArray(value)) { - result[`O_${key}`] = JSON.stringify(value); - break; - } - if (isObject(value)) { - result[`O_${key}`] = JSON.stringify(value); - break; - } - if (!Number.isNaN(value)) { - result[`O_${key}`] = 'null'; - break; - } - result[`O_${key}`] = JSON.stringify(value); - break; - default: - result[key] = value; - break; - } - } - return result; -} +import type { RedisOptions } from 'ioredis'; +import type { Adapter } from 'seyfert/lib/cache'; +import { Redis } from 'ioredis'; + +interface RedisAdapterOptions { + namespace?: string; +} + +export class RedisAdapter implements Adapter { + isAsync = true; + + client: Redis; + namespace: string; + + constructor(data: ({ client: Redis } | { redisOptions: RedisOptions }) & RedisAdapterOptions) { + this.client = 'client' in data ? data.client : new Redis(data.redisOptions); + this.namespace = data.namespace ?? 'seyfert'; + } + + private __scanSets(query: string, returnKeys?: false): Promise; + private __scanSets(query: string, returnKeys: true): Promise; + private __scanSets(query: string, returnKeys = false) { + const match = this.buildKey(query); + return new Promise((r, j) => { + const stream = this.client.scanStream({ + match, + type: 'set', + }); + const keys: string[] = []; + stream + .on('data', resultKeys => keys.push(...resultKeys)) + .on('end', () => (returnKeys ? r(keys.map(x => this.buildKey(x))) : r(this.bulkGet(keys)))) + .on('error', err => j(err)); + }); + } + + scan(query: string, returnKeys?: false): Promise; + scan(query: string, returnKeys: true): Promise; + scan(query: string, returnKeys = false) { + const match = this.buildKey(query); + return new Promise((r, j) => { + const stream = this.client.scanStream({ + match, + // omit relationships + type: 'hash', + }); + const keys: string[] = []; + stream + .on('data', resultKeys => keys.push(...resultKeys)) + .on('end', () => (returnKeys ? r(keys.map(x => this.buildKey(x))) : r(this.bulkGet(keys)))) + .on('error', err => j(err)); + }); + } + + async bulkGet(keys: string[]) { + const pipeline = this.client.pipeline(); + + for (const key of keys) { + pipeline.hgetall(this.buildKey(key)); + } + + return (await pipeline.exec())?.filter(x => !!x[1]).map(x => toNormal(x[1] as Record)) ?? []; + } + + async get(keys: string): Promise { + const value = await this.client.hgetall(this.buildKey(keys)); + if (value) { + return toNormal(value); + } + } + + async bulkSet(data: [string, any][]) { + const pipeline = this.client.pipeline(); + + for (const [k, v] of data) { + pipeline.hset(this.buildKey(k), toDb(v)); + } + + await pipeline.exec(); + } + + async set(id: string, data: any) { + await this.client.hset(this.buildKey(id), toDb(data)); + } + + async bulkPatch(updateOnly: boolean, data: [string, any][]) { + const pipeline = this.client.pipeline(); + + for (const [k, v] of data) { + if (updateOnly) { + pipeline.eval( + `if redis.call('exists',KEYS[1]) == 1 then redis.call('hset', KEYS[1], ${Array.from( + { length: Object.keys(v).length * 2 }, + (_, i) => `ARGV[${i + 1}]`, + )}) end`, + 1, + this.buildKey(k), + ...Object.entries(toDb(v)).flat(), + ); + } else { + pipeline.hset(this.buildKey(k), toDb(v)); + } + } + + await pipeline.exec(); + } + + async patch(updateOnly: boolean, id: string, data: any): Promise { + if (updateOnly) { + await this.client.eval( + `if redis.call('exists',KEYS[1]) == 1 then redis.call('hset', KEYS[1], ${Array.from( + { length: Object.keys(data).length * 2 }, + (_, i) => `ARGV[${i + 1}]`, + )}) end`, + 1, + this.buildKey(id), + ...Object.entries(toDb(data)).flat(), + ); + } else { + await this.client.hset(this.buildKey(id), toDb(data)); + } + } + + async values(to: string): Promise { + const array: unknown[] = []; + const data = await this.keys(to); + if (data.length) { + const items = await this.bulkGet(data); + for (const item of items) { + if (item) { + array.push(item); + } + } + } + + return array; + } + + async keys(to: string): Promise { + const data = await this.getToRelationship(to); + return data.map(id => this.buildKey(`${to}.${id}`)); + } + + async count(to: string): Promise { + return this.client.scard(`${this.buildKey(to)}:set`); + } + + async bulkRemove(keys: string[]) { + await this.client.del(...keys.map(x => this.buildKey(x))); + } + + async remove(keys: string): Promise { + await this.client.del(this.buildKey(keys)); + } + + async flush(): Promise { + const keys = await Promise.all([ + this.scan(this.buildKey('*'), true), + this.__scanSets(this.buildKey('*'), true), + ]).then(x => x.flat()); + if (!keys.length) return; + await this.bulkRemove(keys); + } + + async contains(to: string, keys: string): Promise { + return (await this.client.sismember(`${this.buildKey(to)}:set`, keys)) === 1; + } + + async getToRelationship(to: string): Promise { + return this.client.smembers(`${this.buildKey(to)}:set`); + } + + async bulkAddToRelationShip(data: Record): Promise { + const pipeline = this.client.pipeline(); + + for (const [key, value] of Object.entries(data)) { + pipeline.sadd(`${this.buildKey(key)}:set`, ...value); + } + + await pipeline.exec(); + } + + async addToRelationship(to: string, keys: string | string[]): Promise { + await this.client.sadd(`${this.buildKey(to)}:set`, ...(Array.isArray(keys) ? keys : [keys])); + } + + async removeToRelationship(to: string, keys: string | string[]): Promise { + await this.client.srem(`${this.buildKey(to)}:set`, ...(Array.isArray(keys) ? keys : [keys])); + } + + async removeRelationship(to: string | string[]): Promise { + await this.client.del( + ...(Array.isArray(to) ? to.map(x => `${this.buildKey(x)}:set`) : [`${this.buildKey(to)}:set`]), + ); + } + + protected buildKey(key: string) { + return key.startsWith(this.namespace) ? key : `${this.namespace}:${key}`; + } +} + +const isObject = (o: unknown) => { + return !!o && typeof o === 'object' && !Array.isArray(o); +}; + +function toNormal(target: Record): undefined | Record | Record[] { + if (typeof target.ARRAY_OF === 'string') return JSON.parse(target.ARRAY_OF as string).map(toNormal); + if (!Object.keys(target).length) return undefined; + const result: Record = {}; + for (const [key, value] of Object.entries(target)) { + if (key.startsWith('O_')) { + result[key.slice(2)] = JSON.parse(value); + } else if (key.startsWith('N_')) { + result[key.slice(2)] = Number(value); + } else if (key.startsWith('B_')) { + result[key.slice(2)] = value === 'true'; + } else { + result[key] = value; + } + } + return result; +} + +function toDb(target: Record): Record | Record[] { + if (Array.isArray(target)) return { ARRAY_OF: JSON.stringify(target.map(toDb)) }; + const result: Record = {}; + for (const [key, value] of Object.entries(target)) { + switch (typeof value) { + case 'boolean': + result[`B_${key}`] = value; + break; + case 'number': + result[`N_${key}`] = `${value}`; + break; + case 'object': + if (Array.isArray(value)) { + result[`O_${key}`] = JSON.stringify(value); + break; + } + if (isObject(value)) { + result[`O_${key}`] = JSON.stringify(value); + break; + } + if (!Number.isNaN(value)) { + result[`O_${key}`] = 'null'; + break; + } + result[`O_${key}`] = JSON.stringify(value); + break; + default: + result[key] = value; + break; + } + } + return result; +} diff --git a/packages/redis-adapter/src/index.ts b/packages/redis-adapter/src/index.ts index 48ffbbc..4a3abe5 100644 --- a/packages/redis-adapter/src/index.ts +++ b/packages/redis-adapter/src/index.ts @@ -1 +1 @@ -export * from './adapter' \ No newline at end of file +export * from './adapter'; diff --git a/packages/uws-adapter/src/adapter.ts b/packages/uws-adapter/src/adapter.ts index 4cfece8..430b043 100644 --- a/packages/uws-adapter/src/adapter.ts +++ b/packages/uws-adapter/src/adapter.ts @@ -1,116 +1,110 @@ -import { HttpClient, Logger, } from 'seyfert'; -import { InternalRuntimeConfigHTTP } from 'seyfert/lib/client/base'; -import { APIInteraction, InteractionResponseType, InteractionType } from 'seyfert/lib/types'; -import type { HttpServerAdapter } from 'seyfert/lib/client/types'; - -import nacl from 'tweetnacl'; -import { App, HttpRequest, HttpResponse, type TemplatedApp } from 'uWebSockets.js' - -export class UwsAdapter implements HttpServerAdapter { - public app!: TemplatedApp - publicKeyHex!: Buffer; - applicationId!: string; - debugger?: Logger; - logger!: Logger - - constructor(public client: HttpClient) { - this.logger = client.logger - } - - async start(path: `/${string}` = "/interactions", uwsApp?: TemplatedApp) { - if (this.client.debugger) this.debugger = this.client.debugger - - const { - publicKey, - port, - applicationId, - } = await this.client.getRC() - - if (!publicKey) { - throw new Error('Expected a publicKey, check your config file'); - } - if (!port && !uwsApp) { - throw new Error('Expected a port, check your config file'); - } - if (applicationId) { - this.applicationId = applicationId; - } - - this.publicKeyHex = Buffer.from(publicKey, 'hex'); - this.app = uwsApp ?? App() - this.app.post(path, this.onPacket.bind(this)); - - if (!uwsApp) { - this.app.listen(port, () => { - this.logger.info(`Listening to :${port}${path}`); - }); - } else { - this.logger.info(`Running on ${path}`); - } - } - - protected async verifySignature(res: HttpResponse, req: HttpRequest) { - const timestamp = req.getHeader('x-signature-timestamp'); - const ed25519 = req.getHeader('x-signature-ed25519'); - const body = await UwsAdapter.readJson(res); - if ( - nacl.sign.detached.verify( - Buffer.from(timestamp + JSON.stringify(body)), - Buffer.from(ed25519, 'hex'), - this.publicKeyHex, - ) - ) { - return body; - } - return; - } - - protected async onPacket(res: HttpResponse, req: HttpRequest) { - const rawBody = await this.verifySignature(res, req); - if (!rawBody) { - this.debugger?.debug('Invalid request/No info, returning 418 status.'); - // I'm a teapot - res.writeStatus('418').end(); - return - } - switch (rawBody.type) { - case InteractionType.Ping: - this.debugger?.debug('Ping interaction received, responding.'); - res - .writeHeader('Content-Type', 'application/json') - .end(JSON.stringify({ type: InteractionResponseType.Pong })); - break; - default: - res.cork(async () => { - const { headers, response } = await this.client.onPacket(rawBody) - for (const i in headers) { - res.writeHeader(i, headers[i as keyof typeof headers]!); - } - res.end(JSON.stringify(response)) - }); - return; - } - } - - protected static readJson>(res: HttpResponse) { - return new Promise((ok, rej) => { - const buffers: Buffer[] = []; - res.onData((ab, isLast) => { - const chunk = Buffer.from(ab); - if (isLast) { - try { - buffers.push(chunk) - ok(JSON.parse(Buffer.concat(buffers).toString())); - } catch (e) { - res.close(); - return; - } - } else { - buffers.push(chunk) - } - }); - - res.onAborted(rej); - }); - } -} \ No newline at end of file +import type { HttpClient, Logger } from 'seyfert'; +import type { InternalRuntimeConfigHTTP } from 'seyfert/lib/client/base'; +import { type APIInteraction, InteractionResponseType, InteractionType } from 'seyfert/lib/types'; +import type { HttpServerAdapter } from 'seyfert/lib/client/types'; + +import nacl from 'tweetnacl'; +import { App, type HttpRequest, type HttpResponse, type TemplatedApp } from 'uWebSockets.js'; + +export class UwsAdapter implements HttpServerAdapter { + public app!: TemplatedApp; + publicKeyHex!: Buffer; + applicationId!: string; + debugger?: Logger; + logger: Logger; + + constructor(public client: HttpClient) { + this.logger = client.logger; + } + + async start(path: `/${string}` = '/interactions', uwsApp?: TemplatedApp) { + if (this.client.debugger) this.debugger = this.client.debugger; + + const { publicKey, port, applicationId } = await this.client.getRC(); + + if (!publicKey) { + throw new Error('Expected a publicKey, check your config file'); + } + if (!(port || uwsApp)) { + throw new Error('Expected a port, check your config file'); + } + if (applicationId) { + this.applicationId = applicationId; + } + + this.publicKeyHex = Buffer.from(publicKey, 'hex'); + this.app = uwsApp ?? App(); + this.app.post(path, this.onPacket.bind(this)); + + if (uwsApp) { + this.logger.info(`Running on ${path}`); + } else { + this.app.listen(port, () => { + this.logger.info(`Listening to :${port}${path}`); + }); + } + } + + protected async verifySignature(res: HttpResponse, req: HttpRequest) { + const timestamp = req.getHeader('x-signature-timestamp'); + const ed25519 = req.getHeader('x-signature-ed25519'); + const body = await UwsAdapter.readJson(res); + if ( + nacl.sign.detached.verify( + Buffer.from(timestamp + JSON.stringify(body)), + Buffer.from(ed25519, 'hex'), + this.publicKeyHex, + ) + ) { + return body; + } + return; + } + + protected async onPacket(res: HttpResponse, req: HttpRequest) { + const rawBody = await this.verifySignature(res, req); + if (!rawBody) { + this.debugger?.debug('Invalid request/No info, returning 418 status.'); + // I'm a teapot + res.writeStatus('418').end(); + return; + } + switch (rawBody.type) { + case InteractionType.Ping: + this.debugger?.debug('Ping interaction received, responding.'); + res.writeHeader('Content-Type', 'application/json').end(JSON.stringify({ type: InteractionResponseType.Pong })); + break; + default: + res.cork(async () => { + const { headers, response } = await this.client.onPacket(rawBody); + for (const i in headers) { + res.writeHeader(i, headers[i as keyof typeof headers]!); + } + res.end(JSON.stringify(response)); + }); + return; + } + } + + protected static readJson>(res: HttpResponse) { + return new Promise((ok, rej) => { + const buffers: Buffer[] = []; + res.onData((ab, isLast) => { + const chunk = Buffer.from(ab); + if (isLast) { + try { + buffers.push(chunk); + ok(JSON.parse(Buffer.concat(buffers).toString())); + } catch (e) { + res.close(); + return; + } + } else { + buffers.push(chunk); + } + }); + + res.onAborted(rej); + }); + } +} diff --git a/packages/uws-adapter/src/index.ts b/packages/uws-adapter/src/index.ts index 48ffbbc..4a3abe5 100644 --- a/packages/uws-adapter/src/index.ts +++ b/packages/uws-adapter/src/index.ts @@ -1 +1 @@ -export * from './adapter' \ No newline at end of file +export * from './adapter'; diff --git a/packages/watcher/package.json b/packages/watcher/package.json new file mode 100644 index 0000000..264547e --- /dev/null +++ b/packages/watcher/package.json @@ -0,0 +1,30 @@ +{ + "name": "@slipher/watcher", + "version": "0.0.1", + "private": false, + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "files": [ + "lib/**" + ], + "main": "./lib/index.js", + "module": "./lib/index.js", + "types": "./lib/index.d.ts", + "scripts": { + "dev": "tsc --watch", + "build": "tsc", + "lint": "biome lint --write ./src", + "format": "biome format --write ./src" + }, + "dependencies": { + "chokidar": "^3.6.0", + "seyfert": "github:marcrock22/seyfert", + "tweetnacl": "^1.0.3" + }, + "devDependencies": { + "@types/node": "^22.3.0", + "typescript": "^5.5.4" + } +} \ No newline at end of file diff --git a/packages/watcher/src/index.ts b/packages/watcher/src/index.ts new file mode 100644 index 0000000..868dc67 --- /dev/null +++ b/packages/watcher/src/index.ts @@ -0,0 +1 @@ +export * from './watcher'; diff --git a/packages/watcher/src/watcher.ts b/packages/watcher/src/watcher.ts new file mode 100644 index 0000000..56a6ef0 --- /dev/null +++ b/packages/watcher/src/watcher.ts @@ -0,0 +1,149 @@ +import { execSync } from 'node:child_process'; +import { ShardManager, Logger, ApiHandler, Router } from 'seyfert'; +import { BaseClient, type InternalRuntimeConfig } from 'seyfert/lib/client/base'; +import type { MakeRequired, MakePartial } from 'seyfert/lib/common'; +import type { GatewayDispatchPayload, GatewaySendPayload } from 'seyfert/lib/types'; +import type { ShardManagerDefaults, ShardManagerOptions } from 'seyfert/lib/websocket'; +import { Worker } from 'node:worker_threads'; +import { watch } from 'chokidar'; + +/** + * Represents a watcher class that extends the ShardManager. + */ +export class Watcher extends ShardManager { + worker?: import('node:worker_threads').Worker; + logger = new Logger({ + name: '[Watcher]', + }); + rest?: ApiHandler; + + declare options: MakeRequired; + + /** + * Initializes a new instance of the Watcher class. + * @param options The options for the watcher. + */ + constructor(options: WatcherOptions) { + super({ + handlePayload() {}, + token: '', + intents: 0, + info: { + url: 'wss://gateway.discord.gg', + session_start_limit: { + max_concurrency: -1, + remaining: -1, + reset_after: -1, + total: -1, + }, + shards: -1, + }, + ...options, + }); + } + + /** + * Resets the worker instance. + */ + resetWorker() { + if (this.worker) { + this.worker.terminate(); + } + this.build(); + this.worker = new Worker(this.options.filePath, { + argv: this.options.argv, + workerData: { + __USING_WATCHER__: true, + }, + }); + this.worker!.on('message', (data: WatcherSendToShard) => { + switch (data.type) { + case 'SEND_TO_SHARD': + this.send(data.shardId, data.payload); + break; + } + }); + } + + /** + * Spawns shards for the watcher. + */ + async spawnShards() { + const RC = await BaseClient.prototype.getRC(); + this.options.token = RC.token; + this.rest ??= new ApiHandler({ + baseUrl: 'api/v10', + domain: 'https://discord.com', + token: this.options.token, + }); + this.options.intents = RC.intents; + this.options.info = await new Router(this.rest!).createProxy().gateway.bot.get(); + this.options.totalShards = this.options.info.shards; + + this.resetWorker(); + + const oldFn = this.options.handlePayload; + this.options.handlePayload = (shardId, payload) => { + this.worker?.postMessage({ + type: 'PAYLOAD', + shardId, + payload, + } satisfies WatcherPayload); + return oldFn?.(shardId, payload); + }; + this.connectQueue.concurrency = this.options.info.session_start_limit.max_concurrency; + + await super.spawnShards(); + + const watcher = watch(this.options.srcPath).on('ready', () => { + this.logger.debug(`Watching ${this.options.srcPath}`); + watcher.on('all', event => { + this.logger.debug(`${event} event detected, building`); + this.resetWorker(); + }); + }); + } + + /** + * Builds the watcher. + */ + protected build() { + if (this.options.transpileCommand) execSync(`cd ${process.cwd()} && ${this.options.transpileCommand}`); + this.logger.info('Builded'); + } +} + +export interface WatcherOptions + extends MakePartial< + Omit, + | 'compress' + | 'presence' + | 'properties' + | 'shardEnd' + | 'shardStart' + | 'spawnShardDelay' + | 'totalShards' + | 'url' + | 'version' + > { + filePath: string; + transpileCommand?: string; + srcPath: string; + argv?: string[]; + handlePayload?: ShardManagerOptions['handlePayload']; + info?: ShardManagerOptions['info']; + token?: ShardManagerOptions['token']; + intents?: ShardManagerOptions['intents']; +} + +export interface WatcherPayload { + type: 'PAYLOAD'; + shardId: number; + payload: GatewayDispatchPayload; +} + +export interface WatcherSendToShard { + type: 'SEND_TO_SHARD'; + shardId: number; + payload: GatewaySendPayload; +} diff --git a/packages/watcher/tsconfig.json b/packages/watcher/tsconfig.json new file mode 100644 index 0000000..2d6f410 --- /dev/null +++ b/packages/watcher/tsconfig.json @@ -0,0 +1,38 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "target": "ESNext", + "lib": [ + "ESNext", + "WebWorker" + ], + "moduleResolution": "node", + "declaration": true, + "sourceMap": false, + "strict": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "preserveConstEnums": true, + /* Type Checking */ + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "noErrorTruncation": true, + "outDir": "./lib", + "stripInternal": true, + }, + "exclude": [ + "**/lib", + "**/__test__" + ], + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e06233..38b91c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,7 +26,7 @@ importers: devDependencies: '@types/node': specifier: ^22.3.0 - version: 22.3.0 + version: 22.4.0 typescript: specifier: ^5.5.4 version: 5.5.4 @@ -42,7 +42,7 @@ importers: devDependencies: '@types/node': specifier: ^22.3.0 - version: 22.3.0 + version: 22.4.0 typescript: specifier: ^5.5.4 version: 5.5.4 @@ -61,7 +61,26 @@ importers: devDependencies: '@types/node': specifier: ^22.3.0 - version: 22.3.0 + version: 22.4.0 + typescript: + specifier: ^5.5.4 + version: 5.5.4 + + packages/watcher: + dependencies: + chokidar: + specifier: ^3.6.0 + version: 3.6.0 + seyfert: + specifier: github:marcrock22/seyfert + version: https://codeload.github.com/marcrock22/seyfert/tar.gz/c5935a1ad3e948551d3c5e0e541ce1ce404053e3 + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 + devDependencies: + '@types/node': + specifier: ^22.3.0 + version: 22.4.0 typescript: specifier: ^5.5.4 version: 5.5.4 @@ -124,8 +143,8 @@ packages: '@ioredis/commands@1.2.0': resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} - '@types/node@22.3.0': - resolution: {integrity: sha512-nrWpWVaDZuaVc5X84xJ0vNrLvomM205oQyLsRt7OHNZbSHslcWsvgFR7O7hire2ZonjLrWBbedmotmIlJDVd6g==} + '@types/node@22.4.0': + resolution: {integrity: sha512-49AbMDwYUz7EXxKU/r7mXOsxwFr4BYbvB7tWYxVuLdb2ibd30ijjXINSMAHiEEZk5PCRBmW1gUeisn2VMKt3cQ==} anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} @@ -279,8 +298,8 @@ packages: resolution: {tarball: https://codeload.github.com/uNetworking/uWebSockets.js/tar.gz/f40213ec0a97d0d8721d9d32d92d6eb6ddcd22e7} version: 20.42.0 - undici-types@6.18.2: - resolution: {integrity: sha512-5ruQbENj95yDYJNS3TvcaxPMshV7aizdv/hWYjGIKoANWKjhWNBsr2YEuYZKodQulB1b8l7ILOuDQep3afowQQ==} + undici-types@6.19.6: + resolution: {integrity: sha512-e/vggGopEfTKSvj4ihnOLTsqhrKRN3LeO6qSN/GxohhuRv8qH9bNQ4B8W7e/vFL+0XTnmHPB4/kegunZGA4Org==} snapshots: @@ -321,23 +340,20 @@ snapshots: '@ioredis/commands@1.2.0': {} - '@types/node@22.3.0': + '@types/node@22.4.0': dependencies: - undici-types: 6.18.2 + undici-types: 6.19.6 anymatch@3.1.3: dependencies: normalize-path: 3.0.0 picomatch: 2.3.1 - optional: true - binary-extensions@2.3.0: - optional: true + binary-extensions@2.3.0: {} braces@3.0.3: dependencies: fill-range: 7.1.1 - optional: true chokidar@3.6.0: dependencies: @@ -350,7 +366,6 @@ snapshots: readdirp: 3.6.0 optionalDependencies: fsevents: 2.3.3 - optional: true cluster-key-slot@1.1.2: {} @@ -363,7 +378,6 @@ snapshots: fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - optional: true fsevents@2.3.3: optional: true @@ -371,7 +385,6 @@ snapshots: glob-parent@5.1.2: dependencies: is-glob: 4.0.3 - optional: true ioredis@5.4.1: dependencies: @@ -390,18 +403,14 @@ snapshots: is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 - optional: true - is-extglob@2.1.1: - optional: true + is-extglob@2.1.1: {} is-glob@4.0.3: dependencies: is-extglob: 2.1.1 - optional: true - is-number@7.0.0: - optional: true + is-number@7.0.0: {} lodash.defaults@4.2.0: {} @@ -409,16 +418,13 @@ snapshots: ms@2.1.2: {} - normalize-path@3.0.0: - optional: true + normalize-path@3.0.0: {} - picomatch@2.3.1: - optional: true + picomatch@2.3.1: {} readdirp@3.6.0: dependencies: picomatch: 2.3.1 - optional: true redis-errors@1.2.0: {} @@ -435,7 +441,6 @@ snapshots: to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - optional: true turbo-darwin-64@2.0.14: optional: true @@ -470,4 +475,4 @@ snapshots: uWebSockets.js@https://codeload.github.com/uNetworking/uWebSockets.js/tar.gz/f40213ec0a97d0d8721d9d32d92d6eb6ddcd22e7: {} - undici-types@6.18.2: {} + undici-types@6.19.6: {}