diff --git a/config/tsconfig/base.json b/config/tsconfig/base.json index 6ca069e..9a0b422 100644 --- a/config/tsconfig/base.json +++ b/config/tsconfig/base.json @@ -3,6 +3,7 @@ "display": "Default", "compilerOptions": { "target": "ES2020", + "module": "ES2020", "composite": false, "declaration": true, "declarationMap": true, diff --git a/packages/discokit-gateway/package.json b/packages/discokit-gateway/package.json index 6df5995..ce45886 100644 --- a/packages/discokit-gateway/package.json +++ b/packages/discokit-gateway/package.json @@ -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" } } diff --git a/packages/discokit-gateway/playground.js b/packages/discokit-gateway/playground.js new file mode 100644 index 0000000..ffa7971 --- /dev/null +++ b/packages/discokit-gateway/playground.js @@ -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) +); diff --git a/packages/discokit-gateway/src/connection.ts b/packages/discokit-gateway/src/connection.ts new file mode 100644 index 0000000..da88e3e --- /dev/null +++ b/packages/discokit-gateway/src/connection.ts @@ -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) => 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; + + /** + * 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; + + /** + * 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, + 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 { + 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; + 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( + connection: GatewayConnection, + payload: GatewayEventPayload +) { + 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); +} diff --git a/packages/discokit-gateway/src/encoder/encoder.ts b/packages/discokit-gateway/src/encoder/encoder.ts new file mode 100644 index 0000000..8cc2236 --- /dev/null +++ b/packages/discokit-gateway/src/encoder/encoder.ts @@ -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: ( + payload: GatewayEventPayload + ) => string | Blob | Promise; + + /** + * Decodes the given message + */ + decode: ( + data: string | Blob + ) => GatewayEventPayload | Promise>; +}; diff --git a/packages/discokit-gateway/src/encoder/index.ts b/packages/discokit-gateway/src/encoder/index.ts new file mode 100644 index 0000000..ebd6dff --- /dev/null +++ b/packages/discokit-gateway/src/encoder/index.ts @@ -0,0 +1 @@ +export * from "./encoder"; diff --git a/packages/discokit-gateway/src/encoder/json-encoder.ts b/packages/discokit-gateway/src/encoder/json-encoder.ts new file mode 100644 index 0000000..d7a0c19 --- /dev/null +++ b/packages/discokit-gateway/src/encoder/json-encoder.ts @@ -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), +}; diff --git a/packages/discokit-gateway/src/event-emitter.ts b/packages/discokit-gateway/src/event-emitter.ts new file mode 100644 index 0000000..64c1e5a --- /dev/null +++ b/packages/discokit-gateway/src/event-emitter.ts @@ -0,0 +1,81 @@ +import EventEmitter3 from "eventemitter3"; + +class EventEmitterClass extends EventEmitter3 { + emit(event: string | symbol, ...args: unknown[]): boolean { + super.emit("*", event, ...args); + return super.emit(event, ...args); + } +} + +// https://github.com/andywer/typed-emitter/blob/master/index.d.ts +/*! + The MIT License (MIT) + + Copyright (c) 2018 Andy Wermke + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ + +export type EventMap = { + [key: string]: (...args: never[]) => void; +}; + +interface TypedEventEmitter { + addListener(event: E, listener: Events[E]): this; + on(event: E, listener: Events[E]): this; + once(event: E, listener: Events[E]): this; + + off(event: E, listener: Events[E]): this; + removeAllListeners(event?: E): this; + removeListener(event: E, listener: Events[E]): this; + + emit( + event: E, + ...args: Parameters + ): boolean; + // The sloppy `eventNames()` return type is to mitigate type incompatibilities - see #5 + eventNames(): (keyof Events | string | symbol)[]; + listeners(event: E): Events[E][]; + listenerCount(event: E): number; +} + +export const EventEmitter = EventEmitterClass as new < + T extends EventMap +>() => EventEmitter; + +export type EventEmitter = TypedEventEmitter< + T & { + "*": ( + event: TEvent, + ...args: Parameters + ) => void; + } +>; + +export function waitForEvent< + TEvents extends EventMap, + TEvent extends keyof TEvents +>( + emitter: TypedEventEmitter, + event: TEvent +): Promise> { + return new Promise((resolve) => { + emitter.once(event, resolve as unknown as TEvents[TEvent]); + }); +} diff --git a/packages/discokit-gateway/src/events/dispatch/.gitkeep b/packages/discokit-gateway/src/events/dispatch/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/discokit-gateway/src/events/index.ts b/packages/discokit-gateway/src/events/index.ts new file mode 100644 index 0000000..592eec5 --- /dev/null +++ b/packages/discokit-gateway/src/events/index.ts @@ -0,0 +1 @@ +export * from "./send"; diff --git a/packages/discokit-gateway/src/events/receive/heartbeat-ack.ts b/packages/discokit-gateway/src/events/receive/heartbeat-ack.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/discokit-gateway/src/events/receive/hello.ts b/packages/discokit-gateway/src/events/receive/hello.ts new file mode 100644 index 0000000..dc33f34 --- /dev/null +++ b/packages/discokit-gateway/src/events/receive/hello.ts @@ -0,0 +1,21 @@ +import { GatewayEventBasePayload } from "../.."; + +/** + * @see https://discord.com/developers/docs/topics/gateway-events#hello-hello-structure + */ +export type GatewayHelloData = { + heartbeat_interval: number; +}; + +/** + * The first event sent by Discord upon connection + * @see https://discord.com/developers/docs/topics/gateway-events#hello-hello-structure + */ +export type HelloEvent = { + /** + * Amount of milliseconds in between heartbeats + */ + heartbeatInterval: number; +}; + +export type GatewayHello = GatewayEventBasePayload; diff --git a/packages/discokit-gateway/src/events/receive/index.ts b/packages/discokit-gateway/src/events/receive/index.ts new file mode 100644 index 0000000..375cb24 --- /dev/null +++ b/packages/discokit-gateway/src/events/receive/index.ts @@ -0,0 +1,2 @@ +export * from "./hello"; +export * from "./ready"; diff --git a/packages/discokit-gateway/src/events/receive/ready.ts b/packages/discokit-gateway/src/events/receive/ready.ts new file mode 100644 index 0000000..1387cb5 --- /dev/null +++ b/packages/discokit-gateway/src/events/receive/ready.ts @@ -0,0 +1,53 @@ +import { Snowflake } from "@discokit/types"; +import { GatewayEventBasePayload } from "../.."; + +/** + * @see https://discord.com/developers/docs/topics/gateway-events#ready-ready-event-fields + */ +export type GatewayReadyData = { + v: number; + user: unknown; // TODO + guilds: unknown[]; // TODO + session_id: string; + resume_gateway_url: string; + shard?: [shard_id: number, num_shards: number]; + application: GatewayReadyApplication; +}; + +export type GatewayReadyApplication = { + id: Snowflake; + flags: number; +}; + +/** + * The ready event is dispatched when a client has completed the initial handshake with the gateway (for new sessions). + * @see https://discord.com/developers/docs/topics/gateway-events#ready + */ +export type ReadyEvent = { + /** The API version */ + apiVersion: number; + + /** Information about the user (including email) */ + user: unknown; // TODO + + /** Guilds the user is in */ + guilds: unknown; // TODO + + /** Used for resuming connections */ + sessionId: string; + + /** Used for resuming connections */ + resumeGatewayUrl: string; + + /** Shard information associated with this session, if sent when identifying */ + shard?: [shard_id: number, num_shards: number]; + + /** A partial application object */ + application: GatewayReadyApplication; +}; + +/** + * The ready event is dispatched when a client has completed the initial handshake with the gateway (for new sessions). + * @see https://discord.com/developers/docs/topics/gateway-events#ready + */ +export type GatewayReady = GatewayEventBasePayload; diff --git a/packages/discokit-gateway/src/events/send/heartbeat.ts b/packages/discokit-gateway/src/events/send/heartbeat.ts new file mode 100644 index 0000000..e8b3682 --- /dev/null +++ b/packages/discokit-gateway/src/events/send/heartbeat.ts @@ -0,0 +1,12 @@ +import { GatewayEventBasePayload } from "../.."; + +/** + * @see https://discord.com/developers/docs/topics/gateway-events#heartbeat + */ +export type GatewayHeartbeatData = number | null; + +/** + * Used to maintain an active gateway connection. + * @see https://discord.com/developers/docs/topics/gateway-events#heartbeat + */ +export type GatewayHeartbeat = GatewayEventBasePayload; diff --git a/packages/discokit-gateway/src/events/send/identify.ts b/packages/discokit-gateway/src/events/send/identify.ts new file mode 100644 index 0000000..2b640c8 --- /dev/null +++ b/packages/discokit-gateway/src/events/send/identify.ts @@ -0,0 +1,31 @@ +import { GatewayIntents } from "../../intents"; +import { GatewayEventBasePayload } from "../../payload"; +import { GatewayUpdatePresenceData } from "./update-presence"; + +/** + * @see https://discord.com/developers/docs/topics/gateway-events#identify + */ +export type GatewayIdentifyData = { + token: string; + properties: GatewayIdentifyConnectionProperties; + compress?: boolean; + large_threshold?: number; + shard?: [shard_id: number, num_shards: number]; + presence?: GatewayUpdatePresenceData; + intents: GatewayIntents; +}; + +/** + * @see https://discord.com/developers/docs/topics/gateway-events#identify + */ +export type GatewayIdentifyConnectionProperties = { + os: string; + browser: string; + device: string; +}; + +/** + * Used to trigger the initial handshake with the gateway. + * @see https://discord.com/developers/docs/topics/gateway-events#identify + */ +export type GatewayIdentify = GatewayEventBasePayload; diff --git a/packages/discokit-gateway/src/events/send/index.ts b/packages/discokit-gateway/src/events/send/index.ts new file mode 100644 index 0000000..2f06f9b --- /dev/null +++ b/packages/discokit-gateway/src/events/send/index.ts @@ -0,0 +1,2 @@ +export * from "./identify"; +export * from "./update-presence"; diff --git a/packages/discokit-gateway/src/events/send/update-presence.ts b/packages/discokit-gateway/src/events/send/update-presence.ts new file mode 100644 index 0000000..4a1f5c5 --- /dev/null +++ b/packages/discokit-gateway/src/events/send/update-presence.ts @@ -0,0 +1,29 @@ +import { GatewayEventBasePayload } from "../../payload"; + +/** + * @see https://discord.com/developers/docs/topics/gateway-events#update-presence-gateway-presence-update-structure + */ +export type GatewayUpdatePresenceData = { + since: number | null; + activities: unknown[]; // TODO + status: StatusType; + afk: boolean; +}; + +/** + * @see https://discord.com/developers/docs/topics/gateway-events#update-presence-status-types + */ +export enum StatusType { + Online = "online", + DoNotDisturb = "dnd", + Idle = "idle", + Invisible = "invisible", + Offline = "offline", +} + +/** + * Sent by the client to indicate a presence or status update. + * @see https://discord.com/developers/docs/topics/gateway-events#update-presence + */ +export type GatewayUpdatePresence = + GatewayEventBasePayload; diff --git a/packages/discokit-gateway/src/index.ts b/packages/discokit-gateway/src/index.ts index 4e50892..2f42264 100644 --- a/packages/discokit-gateway/src/index.ts +++ b/packages/discokit-gateway/src/index.ts @@ -1 +1,7 @@ -export * from "./gateway-intents"; +export * from "./connection"; +export * from "./encoder"; +export * from "./intents"; +export * from "./opcode"; +export * from "./payload"; +export * from "./transport"; +export * from "./url"; diff --git a/packages/discokit-gateway/src/gateway-intents.ts b/packages/discokit-gateway/src/intents.ts similarity index 100% rename from packages/discokit-gateway/src/gateway-intents.ts rename to packages/discokit-gateway/src/intents.ts diff --git a/packages/discokit-gateway/src/opcode.ts b/packages/discokit-gateway/src/opcode.ts new file mode 100644 index 0000000..8459520 --- /dev/null +++ b/packages/discokit-gateway/src/opcode.ts @@ -0,0 +1,17 @@ +/** + * Indicates the payload type. + * @see https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-opcodes + */ +export enum GatewayOpcode { + Dispatch = 0, + Heartbeat = 1, + Identify = 2, + PresenceUpdate = 3, + VoiceStateUpdate = 4, + Resume = 6, + Reconnect = 7, + RequestGuildMembers = 8, + InvalidSession = 9, + Hello = 10, + HeartbeatAck = 11, +} diff --git a/packages/discokit-gateway/src/payload.ts b/packages/discokit-gateway/src/payload.ts new file mode 100644 index 0000000..30e3d59 --- /dev/null +++ b/packages/discokit-gateway/src/payload.ts @@ -0,0 +1,19 @@ +import { GatewayOpcode } from "./opcode"; + +export type GatewayEncoding = "json" | "etf"; +export type GatewayCompression = "zlib-stream"; + +export type GatewayEventBasePayload = { + op: GatewayOpcode; + d: T; +}; + +export type GatewayEventDispatchPayload = GatewayEventBasePayload & { + op: GatewayOpcode.Dispatch; + s: number; + t: string; +}; + +export type GatewayEventPayload = + | GatewayEventBasePayload + | GatewayEventDispatchPayload; diff --git a/packages/discokit-gateway/src/transport/index.ts b/packages/discokit-gateway/src/transport/index.ts new file mode 100644 index 0000000..fdefd7b --- /dev/null +++ b/packages/discokit-gateway/src/transport/index.ts @@ -0,0 +1,2 @@ +export * from "./transport"; +export * from "./websocket-transport"; diff --git a/packages/discokit-gateway/src/transport/transport.ts b/packages/discokit-gateway/src/transport/transport.ts new file mode 100644 index 0000000..6297944 --- /dev/null +++ b/packages/discokit-gateway/src/transport/transport.ts @@ -0,0 +1,44 @@ +import { GatewayConnection } from "../connection"; +import { GatewayURLOptions } from "../url"; + +/** + * Responsible for connecting to the gateway + */ +export type GatewayTransport = ( + context: GatewayTransportContext +) => GatewayTransportInstance | Promise; + +/** + * Object returned from {@link GatewayTransport} + */ +export type GatewayTransportInstance = { + send: (data: string | Blob) => void; + close: (code?: number) => void; +}; + +/** + * Context passed to {@link GatewayTransport.connect} + */ +export type GatewayTransportContext = { + /** + * The gateway connection + */ + connection: GatewayConnection; + + /** + * Gets the websocket URL for connecting to the gateway. + * + * You can overwrite options using the `options` argument. + */ + getGatewayURL: (options?: Partial) => Promise; + + /** + * Handles a message from the gateway + */ + handleMessage: (data: string | Blob) => void; + + /** + * Handles the connection closing + */ + handleClose: (code: number) => void; +}; diff --git a/packages/discokit-gateway/src/transport/websocket-transport.ts b/packages/discokit-gateway/src/transport/websocket-transport.ts new file mode 100644 index 0000000..133c34e --- /dev/null +++ b/packages/discokit-gateway/src/transport/websocket-transport.ts @@ -0,0 +1,89 @@ +import { GatewayTransport } from "./transport"; + +/** + * WebSocket constructor which should be mostly compatible between the + * global WebSocket and the "ws" package. + */ +interface GatewayWebSocket { + readonly CONNECTING: 0; + readonly OPEN: 1; + readonly CLOSING: 2; + readonly CLOSED: 3; + + new (url: string | URL, protocols?: string | string[]): Omit< + WebSocket, + "dispatchEvent" | "binaryType" + > & { + binaryType: "blob"; + }; + + prototype: Omit< + (typeof globalThis)["WebSocket"]["prototype"], + "dispatchEvent" | "binaryType" + > & { + binaryType: "blob"; + }; +} + +/** + * Gets the websocket class, either the user-provided websocket, + * the global websocket or the "ws" package. + */ +async function getWebSocketClass( + websocket?: GatewayWebSocket +): Promise { + if (websocket) return websocket; + if (globalThis.WebSocket) return globalThis.WebSocket as GatewayWebSocket; + + try { + return import("ws").then( + (mod) => mod.WebSocket as unknown as GatewayWebSocket + ); + } catch (e) { + throw new Error( + `Failed to import 'ws', did you forget to install it?\n${ + e instanceof Error ? e.message : String(e) + }` + ); + } +} + +export type WebSocketTransportOptions = { + websocket?: GatewayWebSocket; +}; + +export function WebSocketTransport( + options?: WebSocketTransportOptions +): GatewayTransport { + return async (context) => { + const { getGatewayURL, handleMessage, handleClose } = context; + + const webSocketClass = await getWebSocketClass(options?.websocket); + const url = await getGatewayURL(); + + const socket = new webSocketClass(url); + + socket.addEventListener("message", (e) => { + handleMessage(e.data); + }); + + socket.addEventListener("close", (e) => { + handleClose(e.code); + }); + + await new Promise((resolve, reject) => { + socket.addEventListener("error", reject, { once: true }); + socket.addEventListener("open", resolve, { once: true }); + }); + + return { + send: (data) => { + socket.send(data); + }, + + close: (code) => { + socket.close(code); + }, + }; + }; +} diff --git a/packages/discokit-gateway/src/url.ts b/packages/discokit-gateway/src/url.ts new file mode 100644 index 0000000..a0a632e --- /dev/null +++ b/packages/discokit-gateway/src/url.ts @@ -0,0 +1,30 @@ +import { GatewayCompression, GatewayEncoding } from "./payload"; + +const API_VERSION = 10; + +export type GatewayURLOptions = { + url?: URL | string; + apiVersion?: number; + encoding: GatewayEncoding; + compress?: GatewayCompression; +}; + +/** + * Gets the websocket URL for the gateway. + */ +export async function getGatewayURL( + options: GatewayURLOptions +): Promise { + const url = options.url + ? options.url instanceof URL + ? options.url + : new URL(options.url) + : // TODO: fetch URL from Discord + new URL("wss://gateway.discord.gg/"); + + url.searchParams.set("v", String(options.apiVersion ?? API_VERSION)); + url.searchParams.set("encoding", options.encoding); + if (options.compress) url.searchParams.set("compress", options.compress); + + return url.href; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0866b23..d2f2456 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,7 +139,20 @@ importers: version: 0.29.8 packages/discokit-gateway: + dependencies: + '@discokit/types': + specifier: workspace:^ + version: link:../discokit-types + eventemitter3: + specifier: ^5.0.0 + version: 5.0.0 devDependencies: + '@types/events': + specifier: ^3.0.0 + version: 3.0.0 + '@types/ws': + specifier: ^8.5.4 + version: 8.5.4 eslint: specifier: ^7.32.0 version: 7.32.0 @@ -155,6 +168,9 @@ importers: vitest: specifier: ^0.29.8 version: 0.29.8 + ws: + specifier: ^8.13.0 + version: 8.13.0 packages/discokit-rest: dependencies: @@ -1070,6 +1086,10 @@ packages: resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==} dev: false + /@types/events@3.0.0: + resolution: {integrity: sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==} + dev: true + /@types/hast@2.3.4: resolution: {integrity: sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==} dependencies: @@ -1178,6 +1198,12 @@ packages: resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==} dev: false + /@types/ws@8.5.4: + resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==} + dependencies: + '@types/node': 18.15.11 + dev: true + /@typescript-eslint/eslint-plugin@5.58.0(@typescript-eslint/parser@5.58.0)(eslint@7.32.0)(typescript@5.0.4): resolution: {integrity: sha512-vxHvLhH0qgBd3/tW6/VccptSfc8FxPQIkmNTVLWcCOVqSBvqpnKkBTYrhcGlXfSnd78azwe+PsjYFj0X34/njA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2694,6 +2720,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + /eventemitter3@5.0.0: + resolution: {integrity: sha512-riuVbElZZNXLeLEoprfNYoDSwTBRR44X3mnhdI1YcnENpWTCsTTVZ2zFuqQcpoyqPQIUXdiPEU0ECAq0KQRaHg==} + dev: false + /events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -6718,6 +6748,19 @@ packages: /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + /ws@8.13.0: + resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + /y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} dev: true