chore: monorepo setup (#7175)

This commit is contained in:
Noel
2022-01-07 17:18:25 +01:00
committed by GitHub
parent 780b7ed39f
commit 16390efe6e
504 changed files with 25459 additions and 22830 deletions

View File

@@ -0,0 +1,56 @@
interface Methods {
open(buffer: Buffer, nonce: Buffer, secretKey: Uint8Array): Buffer | null;
close(opusPacket: Buffer, nonce: Buffer, secretKey: Uint8Array): Buffer;
random(bytes: number, nonce: Buffer): Buffer;
}
const libs = {
sodium: (sodium: any): Methods => ({
open: sodium.api.crypto_secretbox_open_easy,
close: sodium.api.crypto_secretbox_easy,
random: (n: any, buffer?: Buffer) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if (!buffer) buffer = Buffer.allocUnsafe(n);
sodium.api.randombytes_buf(buffer);
return buffer;
},
}),
'libsodium-wrappers': (sodium: any): Methods => ({
open: sodium.crypto_secretbox_open_easy,
close: sodium.crypto_secretbox_easy,
random: (n: any) => sodium.randombytes_buf(n),
}),
tweetnacl: (tweetnacl: any): Methods => ({
open: tweetnacl.secretbox.open,
close: tweetnacl.secretbox,
random: (n: any) => tweetnacl.randomBytes(n),
}),
} as const;
const fallbackError = () => {
throw new Error(
`Cannot play audio as no valid encryption package is installed.
- Install sodium, libsodium-wrappers, or tweetnacl.
- Use the generateDependencyReport() function for more information.\n`,
);
};
const methods: Methods = {
open: fallbackError,
close: fallbackError,
random: fallbackError,
};
void (async () => {
for (const libName of Object.keys(libs) as (keyof typeof libs)[]) {
try {
// eslint-disable-next-line
const lib = require(libName);
if (libName === 'libsodium-wrappers' && lib.ready) await lib.ready;
Object.assign(methods, libs[libName](lib));
break;
} catch {}
}
})();
export { methods };

View File

@@ -0,0 +1,16 @@
import { methods } from '../Secretbox';
jest.mock(
'tweetnacl',
() => ({
secretbox: {
// eslint-disable-next-line @typescript-eslint/no-empty-function
open() {},
},
}),
{ virtual: true },
);
test('Does not throw error with a package installed', () => {
// @ts-expect-error
expect(() => methods.open()).not.toThrowError();
});

View File

@@ -0,0 +1,24 @@
import { abortAfter } from '../abortAfter';
jest.useFakeTimers();
const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
describe('abortAfter', () => {
test('Aborts after the given delay', () => {
const [ac, signal] = abortAfter(100);
expect(ac.signal).toBe(signal);
expect(signal.aborted).toBe(false);
jest.runAllTimers();
expect(signal.aborted).toBe(true);
});
test('Cleans up when manually aborted', () => {
const [ac, signal] = abortAfter(100);
expect(ac.signal).toBe(signal);
expect(signal.aborted).toBe(false);
clearTimeoutSpy.mockClear();
ac.abort();
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,122 @@
import { demuxProbe } from '../demuxProbe';
import { opus as _opus } from 'prism-media';
import { Readable } from 'node:stream';
import { StreamType } from '../../audio';
import EventEmitter, { once } from 'node:events';
jest.mock('prism-media');
const WebmDemuxer = _opus.WebmDemuxer as unknown as jest.Mock<_opus.WebmDemuxer>;
const OggDemuxer = _opus.OggDemuxer as unknown as jest.Mock<_opus.OggDemuxer>;
async function* gen(n: number) {
for (let i = 0; i < n; i++) {
yield Buffer.from([i]);
await nextTick();
}
}
function range(n: number) {
return Buffer.from(Array.from(Array(n).keys()));
}
const validHead = Buffer.from([
0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, 0x01, 0x02, 0x38, 0x01, 0x80, 0xbb, 0, 0, 0, 0, 0,
]);
const invalidHead = Buffer.from([
0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, 0x01, 0x01, 0x38, 0x01, 0x80, 0xbb, 0, 0, 0, 0, 0,
]);
async function collectStream(stream: Readable): Promise<Buffer> {
let output = Buffer.alloc(0);
await once(stream, 'readable');
for await (const data of stream) {
output = Buffer.concat([output, data]);
}
return output;
}
function nextTick() {
return new Promise((resolve) => process.nextTick(resolve));
}
describe('demuxProbe', () => {
const webmWrite: jest.Mock<(buffer: Buffer) => void> = jest.fn();
const oggWrite: jest.Mock<(buffer: Buffer) => void> = jest.fn();
beforeAll(() => {
WebmDemuxer.prototype = {
...WebmDemuxer,
...EventEmitter.prototype,
write: webmWrite,
};
OggDemuxer.prototype = {
...OggDemuxer,
...EventEmitter.prototype,
write: oggWrite,
};
});
beforeEach(() => {
webmWrite.mockReset();
oggWrite.mockReset();
});
test('Defaults to arbitrary', async () => {
const stream = Readable.from(gen(10), { objectMode: false });
const probe = await demuxProbe(stream);
expect(probe.type).toBe(StreamType.Arbitrary);
await expect(collectStream(probe.stream)).resolves.toEqual(range(10));
});
test('Detects WebM', async () => {
const stream = Readable.from(gen(10), { objectMode: false });
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
webmWrite.mockImplementation(function mock(data: Buffer) {
if (data[0] === 5) this.emit('head', validHead);
} as any);
const probe = await demuxProbe(stream);
expect(probe.type).toBe(StreamType.WebmOpus);
await expect(collectStream(probe.stream)).resolves.toEqual(range(10));
});
test('Detects Ogg', async () => {
const stream = Readable.from(gen(10), { objectMode: false });
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
oggWrite.mockImplementation(function mock(data: Buffer) {
if (data[0] === 5) this.emit('head', validHead);
} as any);
const probe = await demuxProbe(stream);
expect(probe.type).toBe(StreamType.OggOpus);
await expect(collectStream(probe.stream)).resolves.toEqual(range(10));
});
test('Rejects invalid OpusHead', async () => {
const stream = Readable.from(gen(10), { objectMode: false });
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
oggWrite.mockImplementation(function mock(data: Buffer) {
if (data[0] === 5) this.emit('head', invalidHead);
} as any);
const probe = await demuxProbe(stream);
expect(probe.type).toBe(StreamType.Arbitrary);
await expect(collectStream(probe.stream)).resolves.toEqual(range(10));
});
test('Gives up on larger streams', async () => {
const stream = Readable.from(gen(8192), { objectMode: false });
const probe = await demuxProbe(stream);
expect(probe.type).toBe(StreamType.Arbitrary);
await expect(collectStream(probe.stream)).resolves.toEqual(range(8192));
});
test('Propagates errors', async () => {
const testError = new Error('test error');
const stream = new Readable({
read() {
this.destroy(testError);
},
});
await expect(demuxProbe(stream)).rejects.toBe(testError);
});
});

View File

@@ -0,0 +1,54 @@
import EventEmitter from 'node:events';
import { VoiceConnection, VoiceConnectionStatus } from '../../VoiceConnection';
import { entersState } from '../entersState';
function createFakeVoiceConnection(status = VoiceConnectionStatus.Signalling) {
const vc = new EventEmitter() as any;
vc.state = { status };
return vc as VoiceConnection;
}
beforeEach(() => {
jest.useFakeTimers();
});
describe('entersState', () => {
test('Returns the target once the state has been entered before timeout', async () => {
jest.useRealTimers();
const vc = createFakeVoiceConnection();
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
process.nextTick(() => vc.emit(VoiceConnectionStatus.Ready, null as any, null as any));
const result = await entersState(vc, VoiceConnectionStatus.Ready, 1000);
expect(result).toBe(vc);
});
test('Rejects once the timeout is exceeded', async () => {
const vc = createFakeVoiceConnection();
const promise = entersState(vc, VoiceConnectionStatus.Ready, 1000);
jest.runAllTimers();
await expect(promise).rejects.toThrowError();
});
test('Returns the target once the state has been entered before signal is aborted', async () => {
jest.useRealTimers();
const vc = createFakeVoiceConnection();
const ac = new AbortController();
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
process.nextTick(() => vc.emit(VoiceConnectionStatus.Ready, null as any, null as any));
const result = await entersState(vc, VoiceConnectionStatus.Ready, ac.signal);
expect(result).toBe(vc);
});
test('Rejects once the signal is aborted', async () => {
const vc = createFakeVoiceConnection();
const ac = new AbortController();
const promise = entersState(vc, VoiceConnectionStatus.Ready, ac.signal);
ac.abort();
await expect(promise).rejects.toThrowError();
});
test('Resolves immediately when target already in desired state', async () => {
const vc = createFakeVoiceConnection();
await expect(entersState(vc, VoiceConnectionStatus.Signalling, 1000)).resolves.toBe(vc);
});
});

View File

@@ -0,0 +1,12 @@
/**
* Creates an abort controller that aborts after the given time.
*
* @param delay - The time in milliseconds to wait before aborting
*/
export function abortAfter(delay: number): [AbortController, AbortSignal] {
const ac = new AbortController();
const timeout = setTimeout(() => ac.abort(), delay);
// @ts-ignore
ac.signal.addEventListener('abort', () => clearTimeout(timeout));
return [ac, ac.signal];
}

View File

@@ -0,0 +1,53 @@
import type { GatewayVoiceServerUpdateDispatchData, GatewayVoiceStateUpdateDispatchData } from 'discord-api-types/v9';
/**
* Methods that are provided by the @discordjs/voice library to implementations of
* Discord gateway DiscordGatewayAdapters.
*/
export interface DiscordGatewayAdapterLibraryMethods {
/**
* Call this when you receive a VOICE_SERVER_UPDATE payload that is relevant to the adapter.
*
* @param data - The inner data of the VOICE_SERVER_UPDATE payload
*/
onVoiceServerUpdate(data: GatewayVoiceServerUpdateDispatchData): void;
/**
* Call this when you receive a VOICE_STATE_UPDATE payload that is relevant to the adapter.
*
* @param data - The inner data of the VOICE_STATE_UPDATE payload
*/
onVoiceStateUpdate(data: GatewayVoiceStateUpdateDispatchData): void;
/**
* Call this when the adapter can no longer be used (e.g. due to a disconnect from the main gateway)
*/
destroy(): void;
}
/**
* Methods that are provided by the implementer of a Discord gateway DiscordGatewayAdapter.
*/
export interface DiscordGatewayAdapterImplementerMethods {
/**
* Implement this method such that the given payload is sent to the main Discord gateway connection.
*
* @param payload - The payload to send to the main Discord gateway connection
*
* @returns `false` if the payload definitely failed to send - in this case, the voice connection disconnects
*/
sendPayload(payload: any): boolean;
/**
* This will be called by @discordjs/voice when the adapter can safely be destroyed as it will no
* longer be used.
*/
destroy(): void;
}
/**
* A function used to build adapters. It accepts a methods parameter that contains functions that
* can be called by the implementer when new data is received on its gateway connection. In return,
* the implementer will return some methods that the library can call - e.g. to send messages on
* the gateway, or to signal that the adapter can be removed.
*/
export type DiscordGatewayAdapterCreator = (
methods: DiscordGatewayAdapterLibraryMethods,
) => DiscordGatewayAdapterImplementerMethods;

View File

@@ -0,0 +1,118 @@
import { Readable } from 'node:stream';
import prism from 'prism-media';
import { noop } from './util';
import { StreamType } from '..';
/**
* Takes an Opus Head, and verifies whether the associated Opus audio is suitable to play in a Discord voice channel.
*
* @param opusHead The Opus Head to validate
*
* @returns `true` if suitable to play in a Discord voice channel, otherwise `false`
*/
export function validateDiscordOpusHead(opusHead: Buffer): boolean {
const channels = opusHead.readUInt8(9);
const sampleRate = opusHead.readUInt32LE(12);
return channels === 2 && sampleRate === 48000;
}
/**
* The resulting information after probing an audio stream
*/
export interface ProbeInfo {
/**
* The readable audio stream to use. You should use this rather than the input stream, as the probing
* function can sometimes read the input stream to its end and cause the stream to close.
*/
stream: Readable;
/**
* The recommended stream type for this audio stream.
*/
type: StreamType;
}
/**
* Attempt to probe a readable stream to figure out whether it can be demuxed using an Ogg or WebM Opus demuxer.
*
* @param stream The readable stream to probe
* @param probeSize The number of bytes to attempt to read before giving up on the probe
* @param validator The Opus Head validator function
*
* @experimental
*/
export function demuxProbe(
stream: Readable,
probeSize = 1024,
validator = validateDiscordOpusHead,
): Promise<ProbeInfo> {
return new Promise((resolve, reject) => {
// Preconditions
if (stream.readableObjectMode) reject(new Error('Cannot probe a readable stream in object mode'));
if (stream.readableEnded) reject(new Error('Cannot probe a stream that has ended'));
let readBuffer = Buffer.alloc(0);
let resolved: StreamType | undefined = undefined;
const finish = (type: StreamType) => {
stream.off('data', onData);
stream.off('close', onClose);
stream.off('end', onClose);
stream.pause();
resolved = type;
if (stream.readableEnded) {
resolve({
stream: Readable.from(readBuffer),
type,
});
} else {
if (readBuffer.length > 0) {
stream.push(readBuffer);
}
resolve({
stream,
type,
});
}
};
const foundHead = (type: StreamType) => (head: Buffer) => {
if (validator(head)) {
finish(type);
}
};
const webm = new prism.opus.WebmDemuxer();
webm.once('error', noop);
webm.on('head', foundHead(StreamType.WebmOpus));
const ogg = new prism.opus.OggDemuxer();
ogg.once('error', noop);
ogg.on('head', foundHead(StreamType.OggOpus));
const onClose = () => {
if (!resolved) {
finish(StreamType.Arbitrary);
}
};
const onData = (buffer: Buffer) => {
readBuffer = Buffer.concat([readBuffer, buffer]);
webm.write(buffer);
ogg.write(buffer);
if (readBuffer.length >= probeSize) {
stream.off('data', onData);
stream.pause();
process.nextTick(onClose);
}
};
stream.once('error', reject);
stream.on('data', onData);
stream.once('close', onClose);
stream.once('end', onClose);
});
}

View File

@@ -0,0 +1,54 @@
import type { VoiceConnection, VoiceConnectionStatus } from '../VoiceConnection';
import type { AudioPlayer, AudioPlayerStatus } from '../audio/AudioPlayer';
import { abortAfter } from './abortAfter';
import EventEmitter, { once } from 'node:events';
/**
* Allows a voice connection a specified amount of time to enter a given state, otherwise rejects with an error.
*
* @param target - The voice connection that we want to observe the state change for
* @param status - The status that the voice connection should be in
* @param timeoutOrSignal - The maximum time we are allowing for this to occur, or a signal that will abort the operation
*/
export function entersState(
target: VoiceConnection,
status: VoiceConnectionStatus,
timeoutOrSignal: number | AbortSignal,
): Promise<VoiceConnection>;
/**
* Allows an audio player a specified amount of time to enter a given state, otherwise rejects with an error.
*
* @param target - The audio player that we want to observe the state change for
* @param status - The status that the audio player should be in
* @param timeoutOrSignal - The maximum time we are allowing for this to occur, or a signal that will abort the operation
*/
export function entersState(
target: AudioPlayer,
status: AudioPlayerStatus,
timeoutOrSignal: number | AbortSignal,
): Promise<AudioPlayer>;
/**
* Allows a target a specified amount of time to enter a given state, otherwise rejects with an error.
*
* @param target - The object that we want to observe the state change for
* @param status - The status that the target should be in
* @param timeoutOrSignal - The maximum time we are allowing for this to occur, or a signal that will abort the operation
*/
export async function entersState<T extends VoiceConnection | AudioPlayer>(
target: T,
status: VoiceConnectionStatus | AudioPlayerStatus,
timeoutOrSignal: number | AbortSignal,
) {
if (target.state.status !== status) {
const [ac, signal] =
typeof timeoutOrSignal === 'number' ? abortAfter(timeoutOrSignal) : [undefined, timeoutOrSignal];
try {
await once(target as EventEmitter, status, { signal });
} finally {
ac?.abort();
}
}
return target;
}

View File

@@ -0,0 +1,83 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable @typescript-eslint/no-require-imports */
import { resolve, dirname } from 'node:path';
import prism from 'prism-media';
/**
* Generates a report of the dependencies used by the \@discordjs/voice module.
* Useful for debugging.
*/
export function generateDependencyReport() {
const report = [];
const addVersion = (name: string) => report.push(`- ${name}: ${version(name)}`);
// general
report.push('Core Dependencies');
addVersion('@discordjs/voice');
addVersion('prism-media');
report.push('');
// opus
report.push('Opus Libraries');
addVersion('@discordjs/opus');
addVersion('opusscript');
report.push('');
// encryption
report.push('Encryption Libraries');
addVersion('sodium');
addVersion('libsodium-wrappers');
addVersion('tweetnacl');
report.push('');
// ffmpeg
report.push('FFmpeg');
try {
const info = prism.FFmpeg.getInfo();
report.push(`- version: ${info.version}`);
report.push(`- libopus: ${info.output.includes('--enable-libopus') ? 'yes' : 'no'}`);
} catch (err) {
report.push('- not found');
}
return ['-'.repeat(50), ...report, '-'.repeat(50)].join('\n');
}
/**
* Tries to find the package.json file for a given module.
*
* @param dir - The directory to look in
* @param packageName - The name of the package to look for
* @param depth - The maximum recursion depth
*/
function findPackageJSON(
dir: string,
packageName: string,
depth: number,
): { name: string; version: string } | undefined {
if (depth === 0) return undefined;
const attemptedPath = resolve(dir, './package.json');
try {
const pkg = require(attemptedPath);
if (pkg.name !== packageName) throw new Error('package.json does not match');
return pkg;
} catch (err) {
return findPackageJSON(resolve(dir, '..'), packageName, depth - 1);
}
}
/**
* Tries to find the version of a dependency.
*
* @param name - The package to find the version of
*/
function version(name: string): string {
try {
const pkg =
name === '@discordjs/voice'
? require('../../package.json')
: findPackageJSON(dirname(require.resolve(name)), name, 3);
return pkg?.version ?? 'not found';
} catch (err) {
return 'not found';
}
}

View File

@@ -0,0 +1,4 @@
export * from './generateDependencyReport';
export * from './entersState';
export * from './adapter';
export * from './demuxProbe';

View File

@@ -0,0 +1,4 @@
// eslint-disable-next-line @typescript-eslint/no-empty-function
export const noop = () => {};
export type Awaited<T> = T | Promise<T>;