Skip to content

Commit

Permalink
gateway stuff
Browse files Browse the repository at this point in the history
  • Loading branch information
lukadev-0 committed May 8, 2023
1 parent 3f9fa68 commit 370efbf
Show file tree
Hide file tree
Showing 27 changed files with 848 additions and 2 deletions.
1 change: 1 addition & 0 deletions config/tsconfig/base.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"display": "Default",
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"composite": false,
"declaration": true,
"declarationMap": true,
Expand Down
12 changes: 11 additions & 1 deletion packages/discokit-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,21 @@
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"@discokit/types": "workspace:^",
"eventemitter3": "^5.0.0"
},
"devDependencies": {
"@types/events": "^3.0.0",
"@types/ws": "^8.5.4",
"eslint": "^7.32.0",
"tsconfig": "workspace:0.0.0",
"tsup": "^6.7.0",
"typescript": "^5.0.4",
"vitest": "^0.29.8"
"vitest": "^0.29.8",
"ws": "^8.13.0"
},
"peerDependencies": {
"ws": "^8.13.0"
}
}
13 changes: 13 additions & 0 deletions packages/discokit-gateway/playground.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createConnection, startConnection } from "./dist/index.js";

const connection = createConnection({
token:
"MTEwNDc4NTUzNTM0MTQ0NTE1MA.G7yogL.iea13IKSgTZVDPIXC7AUsZ_1TVOz0bgr2vhleM",
intents: 0,
});

connection.events.on("*", console.log);

startConnection(connection).then((...args) =>
console.log("Connection started:", ...args)
);
295 changes: 295 additions & 0 deletions packages/discokit-gateway/src/connection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
import { GatewayEncoder } from "./encoder";
import { JSONEncoder } from "./encoder/json-encoder";
import { EventEmitter, waitForEvent } from "./event-emitter";
import { GatewayIdentify, GatewayIdentifyConnectionProperties } from "./events";
import { GatewayHelloData, HelloEvent } from "./events/receive/hello";
import { ReadyEvent } from "./events/receive/ready";
import { GatewayHeartbeat } from "./events/send/heartbeat";
import { GatewayIntents } from "./intents";
import { GatewayOpcode } from "./opcode";
import { GatewayEventDispatchPayload, GatewayEventPayload } from "./payload";
import {
GatewayTransport,
GatewayTransportContext,
GatewayTransportInstance,
WebSocketTransport,
} from "./transport";
import { getGatewayURL } from "./url";

/**
* The events on the gateway.
* @see https://discord.com/developers/docs/topics/gateway-events
*/
export type GatewayEvents = {
raw: (payload: GatewayEventPayload<unknown>) => void;

hello: (event: HelloEvent) => void;
ready: (event: ReadyEvent) => void;
heartbeatAck: () => void;
};

/**
* Options for connecting to the gateway.
*/
export type GatewayConnectionOptions = {
/**
* The token to use for authentication
*/
token: string;

/**
* The URL used to connect to the gateway, this will
* fetch the url from Discord if not given.
*/
url?: string | URL;

/**
* Connection properties
* @see https://discord.com/developers/docs/topics/gateway-events#identify-identify-connection-properties
*
* @default
* ```ts
* {
* "os": "...",
* "browser": "discokit",
* "device": "discokit",
* }
* ```
*/
properties?: Partial<GatewayIdentifyConnectionProperties>;

/**
* Sharding information
*/
shard?: [shard_id: number, num_shards: number];

// TODO: add presence

/**
* Gateway intents you wish to receive
*/
intents: GatewayIntents;

/**
* The transport to use
*/
transport?: GatewayTransport;

/**
* The encoder to use
*/
encoder?: GatewayEncoder;
};

/**
* The current state of the connection
*/
export type GatewayConnectionState =
| "closed"
| "connecting"
| "connected"
| "reconnecting";

/**
* A connection to the Discord gateway
* @see https://discord.com/developers/docs/topics/gateway#connections
*/
export type GatewayConnection = {
/**
* The options for the gateway connection
*/
options: GatewayConnectionOptions;

/**
* An event emitter emitting {@link GatewayEvents}
*/
events: EventEmitter<GatewayEvents>;

/**
* The last given sequence number.
*/
lastSequence: number | null;

/**
* The state of the connection
*/
connectionState: GatewayConnectionState;

/**
* The next heartbeat
* @see https://discord.com/developers/docs/topics/gateway#sending-heartbeats
*/
heartbeatTimeout?: NodeJS.Timeout;

/**
* The milliseconds in between heartbeats
*/
heartbeatInterval?: number;

/**
* Whether a heartbeat ack has been received since
* the last heartbeat sent
*/
heartbeatAcknowledged: boolean;

/**
* The transport instance
*/
transport?: GatewayTransportInstance;
};

/**
* Creates a connection object, allowing you to
* interact with the Discord gateway.
*/
export function createConnection(
options: GatewayConnectionOptions
): GatewayConnection {
const connection: GatewayConnection = {
options: {
...options,
transport: options.transport ?? WebSocketTransport(),
encoder: options.encoder ?? JSONEncoder,
},
events: new EventEmitter() as EventEmitter<GatewayEvents>,
lastSequence: null,
heartbeatAcknowledged: false,
connectionState: "closed",
};

return connection;
}

/**
* Starts the given connection.
* @see https://discord.com/developers/docs/topics/gateway#connections
*/
export async function startConnection(
connection: GatewayConnection
): Promise<ReadyEvent> {
const connectionContext: GatewayTransportContext = {
connection: connection,

getGatewayURL: (options = {}) =>
getGatewayURL({
url: connection.options.url,
encoding: connection.options.encoder!.encoding,
compress: connection.options.encoder!.compression,
...options,
}),

handleMessage: (data) =>
handleMessage(connection, data).catch((err) => {
console.error(`[Discokit] Failed to handle message: ${err}`);
}),
handleClose: () => console.error("CLOSED!"),
};

connection.transport = await connection.options.transport!(connectionContext);

const [[event]] = await Promise.all([
waitForEvent(connection.events, "ready"),
sendIdentify(connection),
]);

return event;
}

async function handleMessage(
connection: GatewayConnection,
data: string | Blob
) {
const payload = await connection.options.encoder!.decode(data);

connection.events.emit("raw", payload);

switch (payload.op) {
case GatewayOpcode.Hello: {
const data = payload.d as GatewayHelloData;

connection.events.emit("hello", {
heartbeatInterval: data.heartbeat_interval,
});
connection.heartbeatInterval = data.heartbeat_interval;

scheduleHeartbeat(connection, true);

break;
}

case GatewayOpcode.HeartbeatAck: {
connection.heartbeatAcknowledged = true;
connection.events.emit("heartbeatAck");
break;
}

case GatewayOpcode.Dispatch: {
const dispatch = payload as GatewayEventDispatchPayload<unknown>;
connection.lastSequence = dispatch.s;
console.log("[DEBUG] Dispatch", dispatch);

break;
}

default:
console.error(`Received unknown opcode: ${payload.op}`);
break;
}
}

function scheduleHeartbeat(connection: GatewayConnection, isInitial = false) {
if (!connection.heartbeatInterval)
throw new Error("Attempt to schedule heartbeat before HELLO event");

connection.heartbeatAcknowledged = false;
connection.heartbeatTimeout = setTimeout(() => {
if (!isInitial && !connection.heartbeatAcknowledged) {
console.error("Heartbeat not acknowledged");
connection.transport!.close(1002);
// TODO: ATTEMPT RECONNECT!!!
return;
}
sendHeartbeat(connection).catch((err) =>
console.error(`[Discokit] Failed to send heartbeat: ${err}`)
);
scheduleHeartbeat(connection);
}, connection.heartbeatInterval * (isInitial ? Math.random() : 1));
}

async function sendMessage<T>(
connection: GatewayConnection,
payload: GatewayEventPayload<T>
) {
if (!connection.transport)
throw new Error("Attempt to send message before connection is established");

connection.transport.send(await connection.options.encoder!.encode(payload));
}

async function sendHeartbeat(connection: GatewayConnection) {
const payload: GatewayHeartbeat = {
op: 1,
d: connection.lastSequence,
};

await sendMessage(connection, payload);
}

async function sendIdentify(connection: GatewayConnection) {
const payload: GatewayIdentify = {
op: 2,
d: {
token: connection.options.token,
intents: connection.options.intents,
properties: {
os:
connection.options.properties?.os ??
(await import("node:os").then((os) => os.platform())),
browser: connection.options.properties?.browser ?? "discokit",
device: connection.options.properties?.browser ?? "discokit",
},
},
};

await sendMessage(connection, payload);
}
35 changes: 35 additions & 0 deletions packages/discokit-gateway/src/encoder/encoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
GatewayCompression,
GatewayEncoding,
GatewayEventPayload,
} from "../payload";

/**
* Handles encoding/decoding of messages from the
* gateway
*/
export type GatewayEncoder = {
/**
* The encoding to use
*/
encoding: GatewayEncoding;

/**
* The compression to use
*/
compression?: GatewayCompression;

/**
* Encodes the given payload
*/
encode: <T>(
payload: GatewayEventPayload<T>
) => string | Blob | Promise<string | Blob>;

/**
* Decodes the given message
*/
decode: <T>(
data: string | Blob
) => GatewayEventPayload<T> | Promise<GatewayEventPayload<T>>;
};
1 change: 1 addition & 0 deletions packages/discokit-gateway/src/encoder/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./encoder";
9 changes: 9 additions & 0 deletions packages/discokit-gateway/src/encoder/json-encoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { GatewayEncoder } from "./encoder";

export const JSONEncoder: GatewayEncoder = {
encoding: "json",

encode: (payload) => JSON.stringify(payload),
decode: async (data) =>
JSON.parse(data instanceof Blob ? await data.text() : data),
};
Loading

0 comments on commit 370efbf

Please sign in to comment.